From 4aff995a0eea9dfa6ae1fde108a2af5a3d0305f3 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 18:11:56 +0200 Subject: [PATCH] feat(deploy): Backend auf die stabile Instanz bringen (D77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Teile, die das Frontend-Deploy nicht braucht: ein eigenes JDK 21 im Home des Servers (dort ist nur 17 installiert, und die Toolchain zu senken hiesse, Entwicklung und Produktion auseinanderlaufen zu lassen), ein systemd-User-Unit statt nohup, und die Proxy-Regel in der .htaccess — gemessen ist, dass das P-Flag auf diesem Hoster erlaubt ist und eine Verbindung 30 s durchhaelt. Der Port steht an genau einer Stelle: deploy-prod.sh setzt ihn in die Proxy-Regel, deploy-backend.sh in die Unit. Zwei Zahlen, die zueinander passen muessen, sind eine zu viel. Die JVM-Flags sind gemessen, nicht geschaetzt. Mein erster Entwurf setzte -Xmx384m; nachgemessen kam heraus, dass die Obergrenze der kleine Hebel ist: Ohne Freiraum-Verhaeltnisse behaelt der Kollektor den gewachsenen Heap, obwohl nach einem GC nur ~45 MB leben. Mit ihnen 174 MB RSS statt 291 MB ohne jede Angabe — auf einem Host mit rund 300 MB frei ist das der Unterschied zwischen "passt" und "draengt die Datenbank weiter in den Swap". Zwei Fallen sind eingebaut, weil beide nur am Ziel auffielen: systemd expandiert kein $HOME (deshalb %h), und `systemctl --user` findet ohne XDG_RUNTIME_DIR seinen Manager nicht. Geprueft bis an die SSH-Grenze: Das Jar startet mit genau den Flags der Unit in einer Sekunde, antwortet auf die Lebendprobe mit 404 und ist von aussen nicht erreichbar; die erzeugte Unit besteht systemd-analyze verify; der ganze Ablauf lief mit gestelltem ssh/rsync durch; deploy-prod.sh liefert die Proxy-Regel mit eingesetztem Port aus. Der Deploy selbst laeuft erst, wenn jemand ihn startet. Co-Authored-By: Claude Opus 5 --- .env.example | 19 +++ README.md | 34 ++++++ backend/README.md | 23 ++++ docs/CHANGELOG.md | 1 + docs/DECISIONS.md | 93 +++++++++++++++ docs/examples/werkbaum.werkbaum | 26 +++- scripts/deploy-backend.sh | 198 +++++++++++++++++++++++++++++++ scripts/deploy-prod.sh | 22 ++-- scripts/install-jdk.sh | 120 +++++++++++++++++++ scripts/lib-env.sh | 23 ++++ scripts/prod.htaccess | 22 ++++ scripts/werkbaum-backend.service | 81 +++++++++++++ 12 files changed, 650 insertions(+), 12 deletions(-) create mode 100755 scripts/deploy-backend.sh create mode 100755 scripts/install-jdk.sh create mode 100644 scripts/lib-env.sh create mode 100644 scripts/werkbaum-backend.service 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