diff --git a/.env.example b/.env.example index 0a2fd5d..41d7516 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,22 @@ # aufgelöst; Anführungszeichen sind optional. Keine Kommentare hinter dem Wert. DEPLOY_TARGET=mih00@mih00.hostsharing.net:~/doms/werkbaum.javagil.de/htdocs-ssl + +# --- Backend (D77) --------------------------------------------------------- +# scripts/deploy-backend.sh bringt das Fat-Jar samt systemd-User-Unit ins Home +# des Servers; scripts/install-jdk.sh legt dort vorher ein JDK 21 ab. +# +# BACKEND_PORT ist die eine Zahl, über die sich Apache und Dienst einig sein +# müssen: deploy-prod.sh setzt sie in die Proxy-Regel der .htaccess ein, +# deploy-backend.sh in die Unit. Nur an 127.0.0.1 gebunden — von außen kommt +# man ausschließlich über den Proxy, und damit über HTTPS. + +BACKEND_SSH=mih00@mih00.hostsharing.net +# Pfade relativ zum Home des Servers (oder absolut, mit fuehrendem /): +#BACKEND_DIR=opt/werkbaum +#BACKEND_JDK_DIR=opt/jdk21 +#BACKEND_PORT=9080 +#BACKEND_XMX=192m + +# Das Master-Passwort steht NICHT hier. Es gehört in /env auf dem +# Server (Modus 600); deploy-backend.sh legt die Datei beim ersten Mal leer an. diff --git a/README.md b/README.md index 475dd59..f12f16c 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,40 @@ keeps the "what's new" highlighting meaningful. Skip it with `--no-promote`; see The commit is **not** pushed automatically — run `git push` afterwards, or the footer version link points at a commit GitHub does not know yet. +### Backend on the stable instance + +The backend is a separate deploy, because it is a service rather than files: + +```bash +scripts/install-jdk.sh # once: a JDK 21 into the server's home +scripts/deploy-backend.sh # build, upload, systemd user unit, restart +scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule +``` + +Configuration lives in the git-ignored `.env` (template `.env.example`): +`BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`, +`BACKEND_XMX`. + +- **A JDK of our own**, because the measured target has only Java 17 while the + build asks for 21. `install-jdk.sh` fetches it from Adoptium and **verifies + the checksum from their API before unpacking**. +- **A systemd user unit** (`scripts/werkbaum-backend.service`, a template with + placeholders). It survives the session because `Linger=yes` is set on that + host; paths are written as `%h/…`, since systemd expands no shell variables + there. +- **The service listens on 127.0.0.1 only.** The way in is a + `RewriteRule … [P]` in `.htaccess` — measured to be permitted on this host, + and measured to hold a request open for 30 s, which is what the change feed + needs. `BACKEND_PORT` is the single number Apache and the service must agree + on; both scripts read it from the same place. +- **The master password never leaves the server.** It goes into + `/env` (mode 600), which the deploy creates empty on the first + run. Until a hash is in it, the document list stays locked — deliberately. +- **Memory is the scarce resource on that host**, not CPU. The JVM flags are + measured, not guessed: `-Xmx192m -Xms48m` plus heap free ratios lands at + ~174 MB RSS, where the defaults take 291 MB. See + [docs/DECISIONS.md](docs/DECISIONS.md) D77 for the table. + ## License MIT — see [LICENSE](LICENSE). © 2026 Michael Hönnig. The bundled IBM Plex fonts diff --git a/backend/README.md b/backend/README.md index 563fd3c..3d906d6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -227,6 +227,29 @@ export WERKBAUM_MASTER_PASSWORD_HASH="{bcrypt}$(htpasswd -bnBC 12 "" geheim | tr - Service-Methoden sind `@Transactional`: Dokument-Änderung und Historieneintrag werden atomar geschrieben. +## Betrieb auf der stabilen Instanz + +`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es +samt systemd-User-Unit ins Home des Servers und startet den Dienst neu; +`scripts/install-jdk.sh` bringt einmalig ein JDK 21 dorthin. Der Dienst lauscht +nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in +`scripts/prod.htaccess`, die der Frontend-Deploy mitspiegelt. + +- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund + 174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig + Luft? `BACKEND_XMX` in `.env`. +- **Master-Passwort:** `/env` auf dem Server, Modus 600. Ohne + Hash bleibt `GET /documents` gesperrt. +- **Datenbank:** H2 im Dateimodus unter `/data/`. Ein Deploy + fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort + laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den + Treiber ergänzen — Schema und Code bleiben. +- **Nachsehen:** `systemctl --user status werkbaum-backend`, + `tail -f /backend.log`. + +Begründungen: docs/DECISIONS.md D77 (und D76-Nachträge 1–3 zur Vermessung der +Zielumgebung). + ## Hinweise - Versionsnummern in `build.gradle.kts` (Spring Boot, OpenAPI Generator, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d163c90..3d1db29 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ reverse. - Plans can live on a Werkbaum backend now: open `?live=` and everyone edits the same text, seeing each other's changes without reloading - When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself - The caret stays where you put it when someone else inserts lines above you +- The backend can be deployed to a server: its own JDK, a systemd service, and Apache passing `/api/` on ## 2026-08-25 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8de1e78..0eac69a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6262,3 +6262,96 @@ alles Übrige zu sehen. **Offen bleibt** `PATCH /title` (und damit das Ereignis `RENAMED`), ein Eingabefeld für den Anzeigenamen und die Präsenz-Anzeige. + +## D77 — Backend-Deploy: JDK im Home, systemd-User-Unit, Proxy in der `.htaccess` +Das Frontend geht seit D16 per rsync auf die stabile Instanz. Das Backend +braucht mehr als Dateien: eine Java-Laufzeit, einen dauerhaft laufenden Dienst +und einen Weg von außen nach innen. Die Zielumgebung ist vermessen +(D76-Nachträge 1–3), hier stehen die Entscheidungen, die daraus folgen. + +**Ein eigenes JDK 21 im Home, nicht die installierte 17.** Der Server hat nur +Java 17, `build.gradle.kts` verlangt 21. Die Toolchain zu senken wäre der +kürzere Weg und der schlechtere: Entwicklung und Produktion liefen dann auf +verschiedenen Versionen, und der Unterschied fiele erst im Betrieb auf. Ein +JDK im Home braucht kein root; `scripts/install-jdk.sh` holt es von Adoptium +und **prüft die Prüfsumme aus deren API, bevor es auspackt** — ohne das wäre es +„lade ein Archiv aus dem Netz und führe es aus". Getauscht wird erst, wenn +alles heil ist: Ein abgebrochener Download darf kein halbes JDK hinterlassen, +das der Dienst beim nächsten Start vorfindet. + +**Ein systemd-User-Unit, kein nohup.** `Linger=yes` ist auf der Zielumgebung +gesetzt (gemessen), der Dienst überlebt also die Sitzung; Neustart nach einem +Absturz, Logrotation und ein definierter Zustand kommen kostenlos dazu. Zwei +Fallen sind eingebaut, weil beide nur am Ziel auffielen: + +- Die Pfade stehen als **`%h/…`**, nicht als `$HOME/…`. In + `WorkingDirectory` und `EnvironmentFile` expandiert systemd keine + Shell-Variablen; ein `$HOME` stünde dort wörtlich und der Dienst startete + nicht — in einer Datei, die man nur auf dem Server zu sehen bekommt. +- Das Skript setzt **`XDG_RUNTIME_DIR`**, bevor es `systemctl --user` ruft. + Über eine nicht-interaktive SSH-Sitzung ist die Variable oft nicht gesetzt, + und `systemctl --user` findet seinen Manager dann nicht. + +**Ohne Sandbox-Optionen.** `PrivateTmp` und Verwandte brauchen in einem +User-Unit unprivilegierte Benutzer-Namensräume; wo die abgeschaltet sind, +startet der Dienst gar nicht. Auf einem fremden Host ist das kein Risiko, das +sich lohnt — `NoNewPrivileges` ist ein schlichtes prctl und bleibt. + +**Der Dienst lauscht nur auf 127.0.0.1.** Von außen kommt man ausschließlich +über den Apache, und damit gilt dessen HTTPS: Das Master-Passwort geht nie im +Klartext über das Netz. Der Weg hinein ist `RewriteRule … [P]` in der +`.htaccess` — `ProxyPass` ist dort nicht zulässig, und `~/doms//etc/` +ist leer. Dass das P-Flag auf diesem Hoster **erlaubt** ist, war die offene +Frage und ist gemessen (D76-Nachtrag 2), samt der 30 s gehaltenen Verbindung, +auf die das Long Polling angewiesen ist. + +**Die Portnummer steht an genau einer Stelle.** `BACKEND_PORT` in der +`.env`; `deploy-prod.sh` setzt sie in die Proxy-Regel ein, `deploy-backend.sh` +in die Unit. Zwei Zahlen, die zueinander passen müssen, sind eine Zahl zu +viel — und der Fehler zeigte sich als 503, ohne zu sagen, warum. + +**Der Speicher: gemessen, nicht geschätzt.** Der erste Entwurf setzte +`-Xmx384m` mit der Begründung, die Voreinstellung (¼ des RAM) sei zu viel. +Nachgemessen — 30 Dokumente angelegt, dann GC — stimmte die Richtung, aber +nicht der Hebel: + +| Flags | RSS | Heap belegt | +|---|---|---| +| ohne Angaben | 291 MB | 359 MB | +| nur `-Xmx384m` | 254 MB | 359 MB | +| **`-Xmx192m -Xms48m` + Freiraum-Verhältnisse** | **174 MB** | 46 MB | +| dito mit G1 statt Serial | 194 MB | 48 MB | + +Nach einem GC leben rund **45 MB**. Der große Hebel sind deshalb nicht die +Obergrenzen, sondern `MinHeapFreeRatio`/`MaxHeapFreeRatio`: Ohne sie behält +der Kollektor den einmal gewachsenen Heap, mit ihnen gibt er ihn zurück. Das +sind 80 MB gegenüber dem Entwurf — auf einem Host mit rund 300 MB frei ist das +der Unterschied zwischen „passt" und „drängt die Datenbank weiter in den Swap". +SerialGC statt G1 bringt weitere 20 MB und kostet bei zehn Beobachtern nichts, +was auffiele. + +**Das Master-Passwort steht in einer Datei am Ziel, nie im Repository und nie +in der Unit.** `/env` mit Modus 600; `systemctl --user show` +gäbe ein `Environment=` sonst preis. Das Deploy legt die Datei beim ersten Mal +leer an und sagt, was zu tun ist — solange kein Hash drinsteht, bleibt die +Dokumentenliste gesperrt, und das ist Absicht (D76-Nachtrag 6). + +**Ein Deploy tauscht das Jar, er räumt nicht auf.** Anders als beim Frontend +(`rsync --delete`, dort ist das Zielverzeichnis exklusiv) liegen im +Backend-Verzeichnis die Datenbank, das Log und die Passwortdatei. Ohne +`--delete` ist ein Deploy wiederholbar, ohne dass jemand vorher nachdenken muss. + +**Die Lebendprobe ist eine Anfrage nach einem Dokument, das es nicht gibt.** +HTTP 404 heißt: Die Anwendung ist oben und beantwortet Anfragen. Ein +Health-Endpunkt wäre die sauberere Antwort, kostet aber eine weitere +Laufzeit-Abhängigkeit (Actuator) — er steht als eigener Knoten im Plan +(`#be.scaffold.ci`), und bis dahin ist die 404 die ehrlichste Probe, die ohne +ihn zu haben ist. + +**Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist +gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar +startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die +Probe mit 404 und ist von außen nicht erreichbar (`server.address=127.0.0.1`, +gegengeprüft über die LAN-Adresse). Der Deploy selbst — SSH, `systemctl`, die +Proxy-Regel im Betrieb — läuft erst, wenn jemand ihn startet. Das ist dieselbe +Grenze wie in D25 und D72: Was die Umgebung stellt, stellt der Emulator nicht. diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index a03b47a..35db9a2 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -97,7 +97,7 @@ - [?] #ed.dates: Dates and milestones (M) | [?] #ed.dates.attr: An attribute in the line (S) | [?] #ed.dates.view: A separate timeline view (L) - - [^] #bld: Build and delivery (L) + - [^] #bld: Build and delivery (XL) - [^] #bld.bundle: One self-contained file, bundled by Vite (S) - [^] #bld.tests: Unit tests (S) - [^] #bld.pages: Published on every push (S) @@ -107,6 +107,10 @@ - [^] #bld.pwa.sw: A service worker that serves the app offline (S) %% network-first, cache as fallback - [^] #bld.pwa.update: The reload notice stays truthful under the worker (S) %% see D45/D73 - [^] #bld.pwa.files: Double-click a .werkbaum file, save without any dialog (S) :#ed.files.inplace %% file_handlers + launchQueue + - [x] #bld.backend: Ship the backend as a service of its own (M) + - [x] #bld.backend.jdk: Its own JDK 21 in the server's home (S) %% only 17 is installed there + - [x] #bld.backend.unit: A systemd user unit that survives the session (S) + - [x] #bld.backend.proxy: Apache passes /api/ on, in .htaccess (XS) %% measured: the P flag is allowed - [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead - [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request - [ ] #be: Backend (XL) @@ -723,6 +727,26 @@ mechanics existed already; this added the manifest entry and the launch-queue receiver. Chromium only. +#bld.backend + The editor is files, the backend is a service: a Java runtime, something that + keeps it running, and a way in from outside. Its own deploy script, because + none of that is rsync. + +#bld.backend.jdk + The measured target has Java 17 while the build asks for 21. Lowering the + toolchain would be shorter and worse — development and production would run + on different versions, and the difference would only show in operation. The + archive's checksum is verified before anything is unpacked. + +#bld.backend.unit + Restart after a crash and a defined state, for free. The service listens on + 127.0.0.1 only; from outside there is exactly one way in. + +#bld.backend.proxy + A rewrite rule with the P flag, because ProxyPass is not allowed in an + .htaccess and the vhost directory is out of reach. Measured on the target: a + request is held open for 30 seconds, which is what the change feed needs. + #bld.ghpages Rejected: the official Actions route needs no extra branch and no deploy key. diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh new file mode 100755 index 0000000..2df20d2 --- /dev/null +++ b/scripts/deploy-backend.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# Werkbaum — Backend auf die stabile Instanz bringen (D77). +# +# Baut das Fat-Jar, legt es neben ein systemd-User-Unit ins Home des Servers +# und startet den Dienst neu. Der Dienst lauscht nur auf 127.0.0.1; von außen +# kommt man über die Proxy-Regel in `scripts/prod.htaccess`, die +# `scripts/deploy-prod.sh` mitspiegelt (D76-Nachtrag 2). +# +# Verwendung: +# scripts/deploy-backend.sh [-y] [--no-build] [--no-restart] [ssh-ziel] +# +# -y ohne Rückfrage spiegeln und neu starten +# --no-build vorhandenes Jar nehmen (z. B. Wiederholung eines Deploys) +# --no-restart nur hochladen, Dienst nicht anfassen +# +# Konfiguration aus der git-ignorierten `.env` (Vorlage: .env.example): +# BACKEND_SSH user@host — Pflicht (oder als Argument) +# BACKEND_DIR Zielverzeichnis, Vorgabe opt/werkbaum — relativ zum Home +# des Servers (oder absolut, wenn es mit / beginnt) +# BACKEND_JDK_DIR JDK, Vorgabe opt/jdk21 (scripts/install-jdk.sh legt es an) +# BACKEND_PORT Vorgabe 9080 — dieselbe Zahl setzt deploy-prod.sh in die +# .htaccess ein; sie ist die eine Vereinbarung zwischen +# Apache und Dienst +# BACKEND_XMX Vorgabe 192m — gemessen leben nach einem GC ~45 MB; +# siehe die Messreihe in der Unit-Vorlage +# +# Das Master-Passwort steht NICHT hier und nicht im Repository. Es gehört in +# `/env` auf dem Server (Modus 600); dieses Skript legt die Datei +# beim ersten Mal mit leerem Hash an und sagt, was zu tun ist. Ohne Hash bleibt +# die Dokumentenliste gesperrt — das ist Absicht (D76-Nachtrag 6). +# +# Siehe docs/DECISIONS.md D77 und backend/README.md. + +set -euo pipefail + +YES=0 +BUILD=1 +RESTART=1 +SSH_TARGET="" +for arg in "$@"; do + case "$arg" in + -y|--yes) YES=1 ;; + --no-build) BUILD=0 ;; + --no-restart) RESTART=0 ;; + -h|--help) + awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0" + exit 0 ;; + -*) echo "Unbekannte Option: $arg" >&2; exit 2 ;; + *) + if [ -n "$SSH_TARGET" ]; then echo "Zu viele Argumente." >&2; exit 2; fi + SSH_TARGET="$arg" ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib-env.sh" + +[ -n "$SSH_TARGET" ] || SSH_TARGET="$(env_value BACKEND_SSH)" +if [ -z "$SSH_TARGET" ]; then + echo "Usage: $0 [-y] (oder BACKEND_SSH in .env)" >&2 + exit 2 +fi + +DIR="$(env_value BACKEND_DIR)"; DIR="${DIR:-opt/werkbaum}" +JDK_DIR="$(env_value BACKEND_JDK_DIR)"; JDK_DIR="${JDK_DIR:-opt/jdk21}" +PORT="$(env_value BACKEND_PORT)"; PORT="${PORT:-9080}" +XMX="$(env_value BACKEND_XMX)"; XMX="${XMX:-192m}" + +# Zweimal derselbe Pfad in zwei Schreibweisen: systemd kennt `%h`, die Shell +# kennt `$HOME` — und systemd expandiert `$HOME` nicht (siehe Unit-Vorlage). +case "$DIR" in + /*) DIR_UNIT="$DIR"; DIR_SH="$DIR" ;; + *) DIR_UNIT="%h/$DIR"; DIR_SH="\$HOME/$DIR" ;; +esac +case "$JDK_DIR" in + /*) JAVA_UNIT="$JDK_DIR/bin/java" ;; + *) JAVA_UNIT="%h/$JDK_DIR/bin/java" ;; +esac + +echo "==> Ziel ${SSH_TARGET}:~/${DIR#/}, Port ${PORT}, -Xmx${XMX}" + +# ---- 1) Bauen ---- +JAR="" +if [ "$BUILD" -eq 1 ]; then + echo "==> ./gradlew bootJar (mit Tests)" + (cd backend && ./gradlew --quiet check bootJar) +else + echo "==> Build übersprungen (--no-build)" +fi +JAR="$(ls -1t backend/build/libs/*.jar 2>/dev/null | grep -v -- '-plain\.jar$' | head -1 || true)" +if [ -z "$JAR" ]; then + echo "Kein Fat-Jar in backend/build/libs — ohne --no-build versuchen." >&2 + exit 1 +fi +echo " ${JAR} ($(du -h "$JAR" | cut -f1))" + +# ---- 2) Unit-Datei aus der Vorlage ---- +# Die Platzhalter werden hier ersetzt, nicht auf dem Server: So steht im +# Repository die Vorlage und am Ziel genau eine fertige Datei — nichts, was +# beide Seiten unterschiedlich interpretieren könnten. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +sed -e "s#__JAVA__#${JAVA_UNIT}#g" \ + -e "s#__DIR__#${DIR_UNIT}#g" \ + -e "s#__XMX__#${XMX}#g" \ + -e "s#__PORT__#${PORT}#g" \ + scripts/werkbaum-backend.service > "$STAGE/werkbaum-backend.service" +cp "$JAR" "$STAGE/werkbaum-backend.jar" + +if [ "$YES" -ne 1 ]; then + echo "==> Es werden übertragen:" + echo " werkbaum-backend.jar -> ${DIR}/" + echo " werkbaum-backend.service -> ~/.config/systemd/user/" + [ "$RESTART" -eq 1 ] && echo " und der Dienst neu gestartet." + printf '==> Weiter? [y/N] ' + read -r ANS + case "$ANS" in y|Y|j|J) : ;; *) echo "Abgebrochen."; exit 1 ;; esac +fi + +# ---- 3) Übertragen und einrichten ---- +# Bewusst OHNE --delete: Im Zielverzeichnis liegen die Datenbank (`data/`), das +# Log und die Datei mit dem Master-Passwort. Ein Deploy tauscht das Jar, er +# räumt nicht auf. +echo "==> Übertragen" +ssh "$SSH_TARGET" "mkdir -p \"$DIR_SH\" \"\$HOME/.config/systemd/user\"" +rsync -az --chmod=F644 "$STAGE/werkbaum-backend.jar" "$SSH_TARGET:$DIR_SH/werkbaum-backend.jar" +rsync -az --chmod=F644 "$STAGE/werkbaum-backend.service" \ + "$SSH_TARGET:\$HOME/.config/systemd/user/werkbaum-backend.service" + +ssh "$SSH_TARGET" DIR="$DIR_SH" PORT="$PORT" RESTART="$RESTART" 'bash -s' <<'REMOTE' +set -euo pipefail +DIR="$(eval echo "$DIR")" + +# Ohne XDG_RUNTIME_DIR findet `systemctl --user` seinen Manager nicht — über +# eine nicht-interaktive SSH-Sitzung ist die Variable oft nicht gesetzt. Das +# ist die klassische Falle bei User-Units aus einem Skript heraus. +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + +# Umgebungsdatei: beim ersten Mal anlegen, danach nie anfassen — dort steht das +# Master-Passwort, und ein Deploy darf es weder überschreiben noch lesen. +if [ ! -f "$DIR/env" ]; then + cat > "$DIR/env" <<'ENV' +# Werkbaum-Backend — Umgebung des Dienstes. Modus 600, nie im Repository. +# +# Hash MIT Verfahrens-Praefix, z. B.: +# WERKBAUM_MASTER_PASSWORD_HASH={bcrypt}$(htpasswd -bnBC 12 "" geheim | tr -d ':\n') +# Solange er leer ist, bleibt GET /api/v1/documents gesperrt (D76-Nachtrag 6). +WERKBAUM_MASTER_PASSWORD_HASH= +ENV + chmod 600 "$DIR/env" + echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen," + echo " sonst bleibt die Dokumentenliste gesperrt." +fi +chmod 600 "$DIR/env" + +if ! loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then + echo " ! Linger ist nicht gesetzt — der Dienst endet mit der Sitzung." + echo " Abhilfe: loginctl enable-linger $(id -un)" +fi + +systemctl --user daemon-reload +if [ "$RESTART" = "1" ]; then + systemctl --user enable --now werkbaum-backend.service >/dev/null 2>&1 || true + systemctl --user restart werkbaum-backend.service + echo " Dienst neu gestartet." +else + echo " Dienst nicht angefasst (--no-restart)." +fi +REMOTE + +# ---- 4) Nachsehen, ob er wirklich antwortet ---- +if [ "$RESTART" -eq 1 ]; then + echo "==> Warten, bis der Dienst antwortet" + # Es gibt (noch) keinen Health-Endpunkt — der steht als eigener Knoten im + # Plan (`#be.scaffold.ci`). Bis dahin ist die ehrlichste Lebendprobe eine + # Anfrage nach einem Dokument, das es nicht gibt: 404 heißt, die Anwendung + # ist oben und beantwortet Anfragen. + PROBE="http://127.0.0.1:${PORT}/api/v1/documents/00000000-0000-0000-0000-000000000000" + if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do + code=\$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 '$PROBE' || true) + if [ \"\$code\" = '404' ]; then echo \" oben nach \${i}s (HTTP 404 wie erwartet)\"; exit 0; fi + sleep 1 + done + echo ' ! antwortet nicht.'; exit 1"; then + : + else + echo "==> Letzte Zeilen des Logs:" >&2 + ssh "$SSH_TARGET" "export XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}; tail -30 \"$DIR_SH/backend.log\" 2>/dev/null || systemctl --user status werkbaum-backend --no-pager -l | tail -30" >&2 + exit 1 + fi +fi + +echo "==> Fertig." +echo " Log: ssh $SSH_TARGET tail -f $DIR_SH/backend.log" +echo " Zustand: ssh $SSH_TARGET systemctl --user status werkbaum-backend" diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 1475cef..7228f5c 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -59,16 +59,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" # ---- Ziel: Argument hat Vorrang, sonst DEPLOY_TARGET aus .env (git-ignoriert) ---- -ENV_FILE="$ROOT/.env" -if [ -z "$TARGET" ] && [ -f "$ENV_FILE" ]; then - # Bewusst NICHT via `source` lesen: bash würde bei `host:~/pfad` das ~ nach dem - # ':' LOKAL expandieren. Stattdessen roh auslesen (letzte Definition gewinnt), - # trailing Whitespace/CR und umgebende Quotes strippen — das ~ bleibt so für - # die Remote-Seite erhalten. - TARGET="$(sed -n -E 's/^[[:space:]]*DEPLOY_TARGET[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" \ - | tail -1 | sed -E 's/[[:space:]]+$//')" - TARGET="${TARGET#\"}"; TARGET="${TARGET%\"}" - TARGET="${TARGET#\'}"; TARGET="${TARGET%\'}" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib-env.sh" +if [ -z "$TARGET" ]; then + TARGET="$(env_value DEPLOY_TARGET)" [ -n "$TARGET" ] && echo "==> Ziel aus .env: ${TARGET}" fi @@ -145,7 +139,13 @@ cp frontend/dist/icon-192.png frontend/dist/icon-512.png frontend/dist/icon-mask cp frontend/dist/sw.js "$STAGE/sw.js" # Ohne diese Zuordnung liefert Apache `.md` ohne Content-Type aus und der # Browser rät windows-1252 (D43-Nachtrag 2). Pages braucht sie nicht. -cp scripts/prod.htaccess "$STAGE/.htaccess" +# +# Dieselbe Datei trägt die Proxy-Regel für das Backend (D77). Die Portnummer +# steht dort als Platzhalter und wird hier eingesetzt — Apache und Dienst +# müssen sich über genau eine Zahl einig sein, und die steht in `.env`. +BACKEND_PORT="$(env_value BACKEND_PORT)"; BACKEND_PORT="${BACKEND_PORT:-9080}" +echo "==> /api/ -> 127.0.0.1:${BACKEND_PORT} (BACKEND_PORT)" +sed -e "s/__BACKEND_PORT__/${BACKEND_PORT}/g" scripts/prod.htaccess > "$STAGE/.htaccess" # ---- 3) Spiegeln (--delete: nichts Altes bleibt am Ziel) ---- # --chmod=D755,F644 erzwingt web-taugliche Rechte am Ziel, unabhängig von den diff --git a/scripts/install-jdk.sh b/scripts/install-jdk.sh new file mode 100755 index 0000000..2359db9 --- /dev/null +++ b/scripts/install-jdk.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Werkbaum — JDK 21 ins Home der Zielumgebung legen (D77). +# +# Auf dem gemessenen Zielserver ist nur Java 17 installiert, das Backend +# verlangt 21 (build.gradle.kts, Toolchain). Statt die Toolchain zu senken +# kommt ein eigenes JDK ins Home: Entwicklung und Produktion laufen dann auf +# derselben Version, und es braucht kein root — `Linger=yes` erlaubt den +# dauerhaften Dienst ohnehin (D76-Nachtrag 1/2). +# +# Verwendung: +# scripts/install-jdk.sh [ssh-ziel] [--force] +# +# ssh-ziel z. B. mih00@mih00.hostsharing.net; ohne Angabe wird +# BACKEND_SSH aus .env genommen (Vorlage: .env.example). +# --force auch dann installieren, wenn dort schon ein JDK 21 liegt. +# +# Das Archiv wird auf dem Server geholt und **gegen die Prüfsumme der +# Adoptium-API geprüft**, bevor irgendetwas ausgepackt wird. Ohne diese Prüfung +# wäre es „lade ein Archiv aus dem Netz und führe es aus"; mit ihr ist es eine +# nachvollziehbare Installation. +# +# Siehe docs/DECISIONS.md D77. + +set -euo pipefail + +FORCE=0 +SSH_TARGET="" +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + -h|--help) + awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0" + exit 0 ;; + -*) echo "Unbekannte Option: $arg" >&2; exit 2 ;; + *) + if [ -n "$SSH_TARGET" ]; then echo "Zu viele Argumente." >&2; exit 2; fi + SSH_TARGET="$arg" ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib-env.sh" + +[ -n "$SSH_TARGET" ] || SSH_TARGET="$(env_value BACKEND_SSH)" +if [ -z "$SSH_TARGET" ]; then + echo "Usage: $0 (oder BACKEND_SSH in .env)" >&2 + exit 2 +fi + +JDK_DIR="$(env_value BACKEND_JDK_DIR)"; JDK_DIR="${JDK_DIR:-opt/jdk21}" +# Relativ zum Home des Servers, sofern nicht absolut angegeben. +case "$JDK_DIR" in /*) : ;; *) JDK_DIR="\$HOME/$JDK_DIR" ;; esac + +echo "==> JDK 21 nach ${SSH_TARGET}:${JDK_DIR}" + +# Alles Weitere läuft auf dem Server. Bewusst als ein einziges Skript über +# stdin: So ist der Ablauf hier vollständig nachlesbar, statt sich über ein +# Dutzend ssh-Aufrufe zu verteilen. +ssh "$SSH_TARGET" FORCE="$FORCE" JDK_DIR="$JDK_DIR" 'bash -s' <<'REMOTE' +set -euo pipefail +JDK_DIR="$(eval echo "$JDK_DIR")" + +if [ -x "$JDK_DIR/bin/java" ] && [ "$FORCE" != "1" ]; then + echo " schon da: $("$JDK_DIR/bin/java" -version 2>&1 | head -1)" + echo " (--force überschreibt)" + exit 0 +fi + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) A=x64 ;; + aarch64) A=aarch64 ;; + *) echo "Unbekannte Architektur: $ARCH" >&2; exit 1 ;; +esac + +API="https://api.adoptium.net/v3/assets/latest/21/hotspot?os=linux&architecture=${A}&image_type=jdk" +echo " frage Adoptium nach dem aktuellen JDK 21 ($A) ..." +META="$(curl -fsSL "$API")" + +# Ohne jq auskommen: Der Server ist ein Managed Webspace, dort ist wenig +# installiert. Python3 ist da (mit ihm laufen auch die Hoster-Werkzeuge). +read -r URL SUM NAME </dev/null || { + echo "PRÜFSUMME PASST NICHT — nichts installiert." >&2; exit 1; } + +echo " packe aus" +rm -rf "$TMP/x" && mkdir "$TMP/x" +tar -xzf "$TMP/jdk.tar.gz" -C "$TMP/x" --strip-components=1 + +mkdir -p "$(dirname "$JDK_DIR")" +rm -rf "$JDK_DIR.neu" +mv "$TMP/x" "$JDK_DIR.neu" +# Erst tauschen, wenn alles heil ist: Ein abgebrochener Download darf kein +# halbes JDK hinterlassen, das der Dienst beim Neustart vorfindet. +rm -rf "$JDK_DIR.alt" +[ -d "$JDK_DIR" ] && mv "$JDK_DIR" "$JDK_DIR.alt" +mv "$JDK_DIR.neu" "$JDK_DIR" +rm -rf "$JDK_DIR.alt" + +echo " fertig: $("$JDK_DIR/bin/java" -version 2>&1 | head -1)" +REMOTE + +echo "==> Fertig." diff --git a/scripts/lib-env.sh b/scripts/lib-env.sh new file mode 100644 index 0000000..ba2cb8c --- /dev/null +++ b/scripts/lib-env.sh @@ -0,0 +1,23 @@ +# Werkbaum — Werte aus der git-ignorierten .env lesen (von den Deploy-Skripten +# eingebunden, nicht selbst ausführbar). +# +# Bewusst NICHT via `source`: bash würde bei `host:~/pfad` das `~` nach dem `:` +# LOKAL expandieren, und das Ziel zeigte danach auf das eigene Home. Stattdessen +# roh auslesen (letzte Definition gewinnt), umgebende Anführungszeichen und +# nachlaufenden Leerraum abstreifen — das `~` bleibt so für die Remote-Seite +# erhalten. +# +# Zwei Skripte lasen das früher je selbst; eine Kopie dieser Feinheit ist eine +# Kopie zu viel. + +ENV_FILE="${ENV_FILE:-$ROOT/.env}" + +env_value(){ + local name="$1" value="" + [ -f "$ENV_FILE" ] || { printf '%s' ""; return 0; } + value="$(sed -n -E "s/^[[:space:]]*${name}[[:space:]]*=[[:space:]]*//p" "$ENV_FILE" \ + | tail -1 | sed -E 's/[[:space:]]+$//')" + value="${value#\"}"; value="${value%\"}" + value="${value#\'}"; value="${value%\'}" + printf '%s' "$value" +} diff --git a/scripts/prod.htaccess b/scripts/prod.htaccess index d1a7436..f30ecd9 100644 --- a/scripts/prod.htaccess +++ b/scripts/prod.htaccess @@ -24,3 +24,25 @@ # ohne Content-Type aus, und Chromium verwirft das Manifest dann still. AddType application/manifest+json .webmanifest + +# --- Backend: /api/ an den eigenen Dienst weiterreichen (D77) --------------- +# Der Editor spricht das Backend unter derselben Herkunft an (`?live=…`, +# D76). Der Weg dorthin ist mod_rewrite mit dem P-Flag: `ProxyPass` ist in +# einer `.htaccess` nicht zulässig, und `~/doms//etc/` ist auf diesem +# Hoster leer. +# +# Dass das P-Flag hier ERLAUBT ist, war die offene Frage — manche Hoster +# sperren es. Gemessen am 26.08.2026 auf mih00.hostsharing.net: sofortige +# Antwort HTTP 200 nach 0,13 s, absichtlich um 30 s verzögerte Antwort +# HTTP 200 nach 30,1 s. Apache hält die Verbindung also durch und puffert +# nichts weg — Long Polling mit `wait=25` trägt dort nachweislich (D76, +# Nachtrag 2). +# +# Die Portnummer setzt scripts/deploy-prod.sh aus BACKEND_PORT ein; hier steht +# ein Platzhalter, damit Port und Dienst nur an EINER Stelle vereinbart sind. +# Läuft kein Backend, antwortet /api/ mit 503 — das ist die Wahrheit, und der +# Rest der Seite bleibt unberührt. + + RewriteEngine On + RewriteRule ^api/(.*)$ http://127.0.0.1:__BACKEND_PORT__/api/$1 [P,L] + diff --git a/scripts/werkbaum-backend.service b/scripts/werkbaum-backend.service new file mode 100644 index 0000000..3ca0e66 --- /dev/null +++ b/scripts/werkbaum-backend.service @@ -0,0 +1,81 @@ +[Unit] +# Werkbaum — Backend als systemd-User-Unit (D77). +# +# Vorlage: scripts/deploy-backend.sh setzt __JAVA__, __DIR__, __XMX__ und +# __PORT__ ein und legt die Datei als ~/.config/systemd/user/werkbaum-backend.service +# ab. Ein User-Unit braucht kein root; dass er auch ohne offene Sitzung +# weiterläuft, liegt an `Linger=yes` — auf der Zielumgebung gemessen gesetzt +# (D76-Nachtrag 1). Fehlt es, hilft `loginctl enable-linger`. +# +# Die Pfade kommen als `%h/…` herein, nicht als `$HOME/…`: systemd expandiert +# in `WorkingDirectory` und `EnvironmentFile` **keine** Shell-Variablen. Ein +# `$HOME` stünde dort wörtlich, und der Dienst startete nicht — der Fehler +# säße in einer Datei, die man nur auf dem Server zu sehen bekommt. +Description=Werkbaum backend +After=network.target + +[Service] +Type=simple +WorkingDirectory=__DIR__ + +# Zugangsdaten kommen aus einer Datei mit Modus 600, nie aus dem Repository +# und nie aus der Unit selbst (`systemctl --user show` gäbe sie sonst preis). +EnvironmentFile=__DIR__/env + +# Speicher ist auf diesem Host die knappe Größe, nicht die Rechenzeit +# (D76-Nachtrag 3): 3,9 GB gesamt, davon rund 300 MB frei, ein weiteres GB +# liegt schon im Swap. Die cgroup-Grenze des Pakets (3147 MB) ist eine +# Erlaubnis, keine Reservierung; bemessen wird am Freien. +# +# Gemessen mit 30 angelegten Dokumenten und anschließendem GC (D77): +# +# ohne Angaben RSS 291 MB (Heap belegt 359 MB) +# nur -Xmx384m RSS 254 MB (Heap belegt 359 MB) +# -Xmx192m -Xms48m + Ratios RSS 174 MB (Heap belegt 46 MB) +# dito mit G1 statt Serial RSS 194 MB +# +# Der große Hebel sind NICHT die Obergrenzen, sondern die +# Freiraum-Verhältnisse: Ohne sie behält der Kollektor den einmal gewachsenen +# Heap, obwohl nach einem GC nur ~45 MB leben. Mit ihnen gibt er ihn zurück. +# SerialGC statt G1 spart weitere 20 MB und kostet bei zehn Beobachtern nichts, +# was auffiele. +# +# ExitOnOutOfMemoryError: lieber ein sauberer Neustart durch systemd als ein +# Dienst, der noch läuft und nichts mehr beantwortet. +ExecStart=__JAVA__ \ + -Xmx__XMX__ \ + -Xms48m \ + -XX:MinHeapFreeRatio=20 \ + -XX:MaxHeapFreeRatio=40 \ + -Xss512k \ + -XX:MaxMetaspaceSize=192m \ + -XX:ReservedCodeCacheSize=64m \ + -XX:MaxDirectMemorySize=64m \ + -XX:+UseSerialGC \ + -XX:+ExitOnOutOfMemoryError \ + -Dserver.port=__PORT__ \ + -Dserver.address=127.0.0.1 \ + -jar __DIR__/werkbaum-backend.jar + +# Der Dienst lauscht nur auf 127.0.0.1 (siehe -Dserver.address): Von außen +# kommt man ausschließlich über den Apache-Proxy, und damit gilt dessen +# HTTPS — das Master-Passwort geht nie im Klartext über das Netz. + +Restart=on-failure +RestartSec=10 +# Beim Stoppen erst freundlich fragen; ein laufender Long-Poll hält bis zu +# 25 s, deshalb darf das Beenden etwas dauern. +TimeoutStopSec=40 +KillSignal=SIGTERM + +# Bewusst ohne die Sandbox-Optionen (`PrivateTmp`, `ProtectHome` und +# Verwandte): In einem User-Unit brauchen sie unprivilegierte +# Benutzer-Namensräume, und wo die abgeschaltet sind, startet der Dienst +# gar nicht erst. `NoNewPrivileges` ist ein schlichtes prctl und geht immer. +NoNewPrivileges=true + +StandardOutput=append:__DIR__/backend.log +StandardError=append:__DIR__/backend.log + +[Install] +WantedBy=default.target