From e3c781f067e267540b9b3c96195f46e6fd2101d4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 07:49:29 +0200 Subject: [PATCH] Der Tunnel bekommt einen eigenen Container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wohnte wg0 im Container des Provisionierungs-Arbeiters, also im Abbild `clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut — und ein neu gebauter Container bekommt eine neue Adresse im Compose-Netz, womit die Weiterleitung fuer UDP 51820 neu geschrieben wird und JEDE bestehende WireGuard-Sitzung abreisst. Der Tunnel hing damit am Veroeffentlichungstakt der Anwendung, und zusaetzlich daran, dass ein PHP-Prozess nicht abstuerzt. Jetzt gehoert der Netz-Namensraum einem eigenen Container `vpn-hub` mit eigenem, winzigem Abbild (Alpine plus wireguard-tools), das sich fast nie aendert. Provisionierungs-Arbeiter, Terminal-Bruecke, interner DNS und internes Gateway steigen dort ein, statt einer von ihnen den Namensraum zu besitzen. NACHGEMESSEN, nicht angenommen: App-Abbild neu gebaut, Arbeiter, Bruecke und Gateway per --force-recreate neu erzeugt — der Hub blieb Container 92e928cf53b0, wg0 und beide Zugaenge unangetastet, und nginx erreichte die Bruecke weiter (HTTP 426). Genau der Vorgang, der bisher jedes Mal alles abgerissen hat. Nachgezogen: - nginx spricht die Bruecke unter `vpn-hub:8082` an — dem Namen des Namensraum-Eigentuemers; ein Mitbewohner hat keinen eigenen DNS-Eintrag. - update.sh baut vpn-hub mit und haengt Nachbar-Neustarts und den conntrack-Griff an die Frage, ob der Hub WIRKLICH neu gebaut wurde. - update-agent.sh startet den Arbeiter nicht mehr neu, sondern signalisiert ihm. Diese Stelle laeuft unbeaufsichtigt hinter dem Knopf „Dienste neu starten" — wer den drueckt, rechnet nicht damit, sich selbst auszusperren. - Vier Meldungen in der Konsole rieten dem Betreiber, genau den Befehl von Hand auszufuehren, der ihm den Tunnel abreisst. Auch die sind korrigiert. - rescue-tunnel.sh und das Runbook zeigen auf den neuen Besitzer. Eine Kleinigkeit unterwegs, die ich falsch angekuendigt hatte: `[[ … ]] && x=true` bricht unter `set -e` NICHT ab — bash nimmt die linke Seite einer &&-Liste ausdruecklich aus. Nachgeprueft; die if-Form bleibt trotzdem, aus Lesbarkeit, und der Kommentar sagt jetzt den wahren Grund. 2509 Tests gruen. Co-Authored-By: Claude Opus 5 --- app/Services/Deployment/UpdateChannel.php | 4 +- deploy/rescue-tunnel.sh | 24 +- deploy/update-agent.sh | 18 +- deploy/update.sh | 54 ++-- docker-compose.yml | 79 ++++-- docker/nginx/default.conf | 11 +- docker/provisioning-worker.sh | 9 +- docker/vpn-hub/Dockerfile | 39 +++ docker/vpn-hub/entrypoint.sh | 50 ++++ docs/runbooks/tunnel-recovery.md | 52 +++- ...26-08-01-host-konsole-zertifikat-design.md | 249 ++++++++++++++++++ lang/de/admin_settings.php | 2 +- lang/de/integrations.php | 4 +- lang/en/admin_settings.php | 2 +- lang/en/integrations.php | 4 +- .../livewire/admin/integrations.blade.php | 2 +- tests/Feature/Admin/EnvRestartTest.php | 19 +- .../DeploymentRunsAsTheAppUserTest.php | 67 +++-- 18 files changed, 570 insertions(+), 119 deletions(-) create mode 100644 docker/vpn-hub/Dockerfile create mode 100644 docker/vpn-hub/entrypoint.sh create mode 100644 docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 4ceeadd..93b1272 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -30,7 +30,7 @@ use Throwable; * The request carries a KIND, not a flag: "check" (look only — the agent * already fetches on every tick regardless, this just answers sooner than the * next one would on its own), "run" (apply it, downtime and all), or - * "restart" (queue, queue-provisioning, scheduler, reverb only — picking up a + * "restart" (queue, scheduler, reverb only — picking up a * freshly-saved .env without a deployment). Three different things an * operator can ask for, not one request with a modifier — an operator who * only wants to know whether anything is new must not be routed through a @@ -61,7 +61,7 @@ final class UpdateChannel /** * A request to restart the four workers that only read .env at their own - * startup — queue, queue-provisioning, scheduler, reverb — and nothing + * startup — queue, scheduler, reverb — und nichts * else. Not a deployment: no fetch is consulted, no maintenance mode, no * release is involved. `app` is deliberately never in that list — see * update-agent.sh's handling of this kind for why. diff --git a/deploy/rescue-tunnel.sh b/deploy/rescue-tunnel.sh index fbd1dc1..94ca63a 100755 --- a/deploy/rescue-tunnel.sh +++ b/deploy/rescue-tunnel.sh @@ -28,7 +28,7 @@ if [[ $EUID -eq 0 ]]; then exit 1 fi -hub() { docker compose exec -T queue-provisioning "$@" 2>/dev/null; } +hub() { docker compose exec -T vpn-hub "$@" 2>/dev/null; } WG_PORT="$(grep -m1 '^WG_HUB_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"'"'"' ')" WG_PORT="${WG_PORT:-51820}" @@ -36,27 +36,27 @@ WG_PORT="${WG_PORT:-51820}" # ── 1. Läuft der Container, in dessen Namensraum wg0 lebt? ─────────────────── log "Der Container mit dem Tunnel" -if ! docker compose ps --status running queue-provisioning 2>/dev/null | grep -q queue-provisioning; then - bad "queue-provisioning läuft nicht. Ich starte ihn." - docker compose up -d queue-provisioning || true +if ! docker compose ps --status running vpn-hub 2>/dev/null | grep -q vpn-hub; then + bad "vpn-hub läuft nicht. Ich starte ihn." + docker compose up -d vpn-hub || true sleep 8 fi -if docker compose ps --status running queue-provisioning 2>/dev/null | grep -q queue-provisioning; then +if docker compose ps --status running vpn-hub 2>/dev/null | grep -q vpn-hub; then ok "läuft" else bad "startet nicht. Das Protokoll sagt warum:" - docker compose logs --tail=30 queue-provisioning + docker compose logs --tail=30 vpn-hub exit 1 fi -# Prozess 1 verrät, ob der Server den Stand ab v1.4.4 hat. Vorher war es der -# Warteschlangen-Arbeiter, und dann nimmt jeder seiner Abstürze den Tunnel mit. -if hub sh -c 'tr "\0" " " < /proc/1/cmdline' | grep -q 'provisioning-worker.sh'; then - ok "Der Tunnel hängt nicht mehr am Warteschlangen-Arbeiter (v1.4.4 oder neuer)" +# Prozess 1 verrät den Stand. Gehört der Namensraum noch dem +# Warteschlangen-Arbeiter, nimmt ihn jede Freigabe und jeder Absturz mit. +if hub sh -c 'tr "\0" " " < /proc/1/cmdline' | grep -q 'vpn-hub'; then + ok "Der Tunnel hat einen eigenen Container (v1.4.8 oder neuer)" else - warn "Prozess 1 ist noch der Arbeiter selbst — dieser Server steht vor v1.4.4." - warn "Bis dahin nimmt jeder Absturz des Arbeiters den Tunnel mit. Update einspielen." + warn "Dieser Server hat den eigenen Tunnel-Container noch nicht." + warn "Bis dahin nimmt jede Freigabe den Tunnel mit. Update einspielen." fi # ── 2. Steht wg0? ──────────────────────────────────────────────────────────── diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 509e049..e647e77 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -409,7 +409,23 @@ if [[ "$REQUEST_KIND" == "restart" ]]; then # App\Livewire\Admin\Integrations::saveEnv()) without being touched here. # Not a deployment: no fetch was needed for this, no maintenance mode, no # phase file, no update.sh. - if docker compose restart queue queue-provisioning scheduler reverb >/dev/null 2>&1; then + # + # queue-provisioning steht mit Absicht NICHT dabei, und das ist hier noch + # wichtiger als in update.sh: diese Stelle läuft unbeaufsichtigt, ausgelöst + # von einem Knopf in der Konsole. Sein Container teilt sich den + # Netz-Namensraum mit dem Tunnel, ein `restart` baut ihn neu auf, und jede + # WireGuard-Sitzung reißt ab — die des Betreibers am Telefon inbegriffen. + # Jemand, der „Dienste neu starten" drückt, um eine geänderte Einstellung zu + # übernehmen, rechnet nicht damit, sich dabei selbst auszusperren. + # + # Stattdessen dasselbe Signal wie im Deployment: der Arbeiter beendet sich + # nach dem laufenden Auftrag, seine Schleife startet ihn mit dem neuen Stand + # neu (docker/provisioning-worker.sh), der Container bleibt stehen. + restart_ok=true + docker compose restart queue scheduler reverb >/dev/null 2>&1 || restart_ok=false + docker compose exec -T queue-provisioning php artisan queue:restart >/dev/null 2>&1 || true + + if [[ "$restart_ok" == true ]]; then write_restart succeeded else write_restart failed restart_failed diff --git a/deploy/update.sh b/deploy/update.sh index 49be72f..7350a71 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -440,11 +440,11 @@ after="$(git rev-parse HEAD)" # The image is only rebuilt when its definition changed — minutes versus seconds. if ! git diff --quiet "$base" "$after" -- docker/ 2>/dev/null; then phase image "Rebuilding the image" - # `terminal` mitbauen, nicht nur `app`: `docker compose up -d` weiter unten + # `terminal` und `vpn-hub` mitbauen, nicht nur `app`: `docker compose up -d` weiter unten # baut nur Images, die es noch GAR NICHT gibt. Beim ersten Ausrollen fällt # das nicht auf, danach nie wieder — eine Änderung an docker/terminal/ # sähe ausgeliefert aus, und es liefe das alte Image. - docker compose build --quiet app terminal + docker compose build --quiet app terminal vpn-hub # Recreate now, not at the end: everything below runs INSIDE this container, # and an update that changes the PHP runtime would otherwise install and # migrate under the old one. @@ -529,9 +529,23 @@ phase restart "Restarting services" # (neues Image, geänderte Konfiguration), ist sein Netz-Namensraum ein anderer — # und nur dann müssen die Nachbarn hinterher und die gemerkten Ströme weg. Ein # blosses „lief schon" lässt beides in Ruhe. -hub_before="$(docker compose ps -q queue-provisioning 2>/dev/null || true)" +hub_before="$(docker compose ps -q vpn-hub 2>/dev/null || true)" docker compose up -d -hub_after="$(docker compose ps -q queue-provisioning 2>/dev/null || true)" +hub_after="$(docker compose ps -q vpn-hub 2>/dev/null || true)" + +# Seit der Tunnel einen eigenen Container mit eigenem, selten wechselndem Abbild +# hat, sollten diese beiden Werte bei einem gewoehnlichen Update GLEICH sein. +# Sind sie es nicht, wurde der Namensraum neu gebaut — dann muessen die +# Mitbewohner hinterher und die gemerkten Stroeme weg. +# +# Als `if` geschrieben, nicht als `[[ … ]] && hub_rebuilt=true`. Nicht weil +# Letzteres bräche — bash nimmt die linke Seite einer `&&`-Liste ausdrücklich +# von `set -e` aus, nachgeprüft —, sondern weil man an dieser Stelle nicht erst +# nachschlagen sollen muss, ob es bricht. +hub_rebuilt=false +if [[ -n "$hub_before" && "$hub_before" != "$hub_after" ]]; then + hub_rebuilt=true +fi # Workers hold their PHP classes for the life of the process; without this they # keep running the code from before the update. # @@ -552,22 +566,22 @@ hub_after="$(docker compose ps -q queue-provisioning 2>/dev/null || true)" docker compose restart queue scheduler reverb docker compose exec -T queue-provisioning php artisan queue:restart >/dev/null 2>&1 || true -# Und die Terminal-Brücke gleich hinterher, aus demselben Grund wie die beiden -# VPN-Dienste unten: sie lebt im Netz-Namensraum des Provisioning-Containers -# (`network_mode: "service:queue-provisioning"`), und ein Prozess bleibt in dem -# Namensraum, in dem er gestartet ist. Nach der Zeile darüber lauscht sie in -# einem, den es nicht mehr gibt. +# Die Mitbewohner des Namensraums — aber nur, wenn er wirklich neu ist. # -# Nichts meldet dabei einen Fehler — im Gegenteil: `docker compose ps` sagt -# weiter „healthy", weil die Lebendprüfung über die Loopback-Adresse INNERHALB -# des verwaisten Namensraums läuft. Nach außen antwortet nginx mit 502, und der -# Betreiber liest „Keine Verbindung — läuft der Terminal-Dienst?", während der -# Dienst behauptet, es gehe ihm gut. +# Ein Prozess bleibt in dem Namensraum, in dem er gestartet ist. Wurde vpn-hub +# neu gebaut, lauschen Terminal-Bruecke, interner DNS und internes Gateway in +# einem, den es nicht mehr gibt. Nichts meldet dabei einen Fehler — im +# Gegenteil: `docker compose ps` sagt weiter „healthy", weil die Lebendpruefung +# ueber die Loopback-Adresse INNERHALB des verwaisten Namensraums laeuft. Nach +# aussen antwortet nginx mit 502. # -# Ohne Profil-Abfrage, anders als unten: `terminal` hängt an keinem Profil und -# läuft immer. Auf wg0 wartet sie nicht — sie bindet 8082 im Namensraum, nicht -# die Tunneladresse; die braucht sie erst, wenn jemand ein Ticket einlöst. -docker compose restart terminal +# Vorher stand das hier bedingungslos. Das war richtig, solange der Namensraum +# dem Arbeiter gehoerte und bei jedem Update neu entstand; seit vpn-hub ist es +# der Ausnahmefall, und ein Neustart „nur zur Sicherheit" ist eine +# Unterbrechung ohne Anlass. +if [[ "$hub_rebuilt" == true ]]; then + docker compose restart terminal +fi # Die gemerkten UDP-Ströme, wenn der Tunnel-Container neu gebaut wurde. # @@ -584,7 +598,7 @@ docker compose restart terminal # Braucht Root auf dem Wirt; dieses Skript läuft als Dienstbenutzer. Deshalb # `sudo -n` (fragt nicht nach einem Passwort) und, wenn das nicht darf, eine # deutliche Zeile statt eines stillen Fehlschlags. -if [[ -n "$hub_before" && "$hub_before" != "$hub_after" ]]; then +if [[ "$hub_rebuilt" == true ]]; then wg_port="$(grep -m1 '^WG_HUB_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"'"'"' ' || true)" wg_port="${wg_port:-51820}" @@ -603,7 +617,7 @@ fi # the hub is restarted they are listening inside one that no longer exists. # Nothing errors; connections to the tunnel address are simply refused, which # looks exactly like the gateway never having worked. -if grep -qE '^COMPOSE_PROFILES=.*vpn' .env 2>/dev/null; then +if [[ "$hub_rebuilt" == true ]] && grep -qE '^COMPOSE_PROFILES=.*vpn' .env 2>/dev/null; then # And only once wg0 is back. The hub brings the interface up as part of its # start command, so restarting these the instant the container is "started" # has them binding an address that does not exist yet. diff --git a/docker-compose.yml b/docker-compose.yml index ea69c68..7da93f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,27 +75,24 @@ services: - redis - mariadb - # Dedicated worker for long-running provisioning steps (own timeout, single try; - # the DB state machine owns retries). Separate from the fast default queue. - # It also acts as the WireGuard hub (LocalWireguardHub runs `wg set wg0` here), - # so it needs NET_ADMIN, the tun device, a persistent wg config, and the WG - # UDP port published on the host for peers to reach. Set CLUPILOT_WG_ENDPOINT - # to :${WG_HUB_PORT} and CLUPILOT_WG_HUB_PUBKEY to wg0's key. - queue-provisioning: - image: clupilot-app:dev + # ── Der Tunnel, und sonst nichts ──────────────────────────────────────────── + # + # Dieser Container besitzt den Netz-Namensraum, in dem wg0 lebt. Der + # Provisionierungs-Arbeiter, die Terminal-Bruecke, der interne DNS und das + # interne Gateway steigen alle darin ein (`network_mode` bei jedem von ihnen). + # + # Warum ein EIGENER Container mit EIGENEM Abbild: bis hierher gehoerte der + # Namensraum dem Arbeiter, also `clupilot-app` — und das wird bei fast jeder + # Freigabe neu gebaut. Ein neu gebauter Container bekommt eine neue Adresse im + # Compose-Netz, die Weiterleitung fuer UDP 51820 wird neu geschrieben, und jede + # bestehende WireGuard-Sitzung reisst ab. Der Tunnel hing damit am + # Veroeffentlichungstakt der Anwendung. Dieses Abbild aendert sich fast nie, + # also fasst ein gewoehnliches Update den Container nicht mehr an. + vpn-hub: + build: + context: ./docker/vpn-hub + image: clupilot-vpn-hub:dev restart: unless-stopped - # Bring up wg0 (from the mounted config, once the operator has installed it) - # before starting the worker, so LocalWireguardHub can manage peers. - # The one worker that stays root: it brings wg0 up and runs `wg set` for - # every peer change, which needs NET_ADMIN on the running process. It - # renders no mail, so it is not the one that was writing compiled views — - # and update.sh normalises ownership at the end of every deployment, which - # heals it if it ever does. - # Der Rumpf steht in einer Datei, nicht hier: er haelt den Arbeiter in einer - # Schleife, damit ein Zeitueberlauf oder ein fataler Fehler nicht mehr den - # ganzen Container — und mit ihm wg0 und jede WireGuard-Sitzung — mitnimmt. - # Die Begruendung steht ausfuehrlich in der Datei. - command: sh /var/www/html/docker/provisioning-worker.sh cap_add: - NET_ADMIN devices: @@ -104,6 +101,35 @@ services: - net.ipv4.ip_forward=1 ports: - "${WG_HUB_PORT:-51820}:51820/udp" + volumes: + - wireguard:/etc/wireguard + + queue-provisioning: + image: clupilot-app:dev + restart: unless-stopped + # Dedicated worker for long-running provisioning steps (own timeout, single + # try; the DB state machine owns retries). Separate from the fast default + # queue. Bleibt root: `wg set` fuer jede Zugangsaenderung braucht NET_ADMIN + # am laufenden Prozess. Er rendert keine Mail, schreibt also keine + # kompilierten Views — und update.sh raeumt Eigentumsrechte am Ende jedes + # Deployments auf. + # Der Rumpf steht in einer Datei, nicht hier: er haelt den Arbeiter in einer + # Schleife, damit ein Zeitueberlauf oder ein fataler Fehler nicht mehr den + # ganzen Container — und mit ihm wg0 und jede WireGuard-Sitzung — mitnimmt. + # Die Begruendung steht ausfuehrlich in der Datei. + command: sh /var/www/html/docker/provisioning-worker.sh + # Steigt in den Namensraum des Hubs ein, statt ihn zu besitzen. `wg set` fuer + # jede Zugangsaenderung braucht wg0 im EIGENEN Namensraum und NET_ADMIN am + # eigenen Prozess — Ersteres kommt hierher, Letzteres bleibt. + # + # Damit hat dieser Container keine eigene Adresse und keinen eigenen + # DNS-Eintrag mehr. Wer ihn ansprechen will, nimmt `vpn-hub` (so macht es + # docker/nginx/default.conf fuer die Terminal-Bruecke). Ausgehend aendert + # sich nichts: `redis` und `mariadb` loesen im Namensraum des Hubs genauso + # auf, der haengt am selben Netz. + network_mode: "service:vpn-hub" + cap_add: + - NET_ADMIN volumes: - .:/var/www/html - wireguard:/etc/wireguard @@ -113,13 +139,14 @@ services: # gets a name now, since it can no longer go in public DNS. - dns-hosts:/etc/clupilot/dns-hosts depends_on: + - vpn-hub - app - redis - mariadb # ── The console, reachable from inside the tunnel ─────────────────────────── # - # Both of these share the provisioning container's network namespace, which is + # Both of these share the vpn-hub container's network namespace, which is # where wg0 (10.66.0.1) lives. That is the whole point: they answer ON the # tunnel address, so a VPN client needs no route beyond the management subnet # it already has, the host's docker bridge is never exposed to the tunnel, and @@ -134,7 +161,7 @@ services: image: 4km3/dnsmasq:2.90-r3 restart: unless-stopped profiles: ["vpn"] - network_mode: "service:queue-provisioning" + network_mode: "service:vpn-hub" cap_add: - NET_ADMIN volumes: @@ -149,13 +176,13 @@ services: --address=/${VPN_INTERNAL_HOST:-admin.invalid}/${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} --hostsdir=/etc/clupilot/dns-hosts depends_on: - - queue-provisioning + - vpn-hub vpn-gateway: image: caddy:2-alpine restart: unless-stopped profiles: ["vpn"] - network_mode: "service:queue-provisioning" + network_mode: "service:vpn-hub" volumes: - ./docker/caddy/vpn.Caddyfile:/etc/caddy/Caddyfile:ro # The certificate the public Caddy already obtains and renews. Read-only, @@ -176,7 +203,7 @@ services: VPN_CERT_PATH: ${VPN_CERT_PATH:-} VPN_KEY_PATH: ${VPN_KEY_PATH:-} depends_on: - - queue-provisioning + - vpn-hub - app # ── Terminal-Bruecke: SSH auf einen Host, als WebSocket ───────────────────── @@ -197,7 +224,7 @@ services: context: ./docker/terminal image: clupilot-terminal:dev restart: unless-stopped - network_mode: "service:queue-provisioning" + network_mode: "service:vpn-hub" environment: # Dieselbe Datenbank, auf der `Redis::connection('cache')` das Ticket # ablegt. Der Wert kam aus derselben .env-Stelle wie bei PHP, statt fest diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index cfa8bc2..8db7936 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -58,9 +58,9 @@ server { # Adresszeile, sondern in `Sec-WebSocket-Protocol`: die Adresse eines # Upgrade-Antrags landet im Zugriffsprotokoll, der Kopf nicht. # - # `terminal` ist ein Netz-Alias von queue-provisioning (siehe - # docker-compose.yml): die Bruecke steht im Tunnel und teilt sich dessen - # Netz-Namensraum. + # Die Bruecke steht im Tunnel: sie teilt sich den Netz-Namensraum von + # vpn-hub (siehe docker-compose.yml) und ist deshalb unter dessen Namen + # erreichbar, nicht unter einem eigenen. location = /terminal/ws { # Dieselbe Regel wie bei /admin darueber: die Bruecke ist Werkzeug der # Konsole und hat auf einem oeffentlichen Namen nichts zu suchen. @@ -77,7 +77,8 @@ server { # eingebauter DNS) wird daraus ein 502 an genau dieser Stelle, waehrend # alles andere weiterlaeuft. resolver 127.0.0.11 valid=10s ipv6=off; - # Der Name des Provisionierungs-Workers, nicht ein huebscherer Alias. + # Der Name des Containers, dem der Netz-Namensraum GEHOERT (vpn-hub), + # nicht der eines Mitbewohners und nicht ein huebscherer Alias. # # Hier stand `terminal:8082`, und dieser Alias war ein Eintrag unter # `networks:` von queue-provisioning. Genau das hat den Tunnel-Container @@ -90,7 +91,7 @@ server { # Der Preis war also: ein Wort in dieser Zeile gegen einen Abriss aller # Tunnel. Die Bruecke teilt sich ohnehin den Namensraum, `:8082` ist ihr # Port darin — der Name daneben darf ruhig der des Nachbarn sein. - set $terminal_upstream queue-provisioning:8082; + set $terminal_upstream vpn-hub:8082; proxy_pass http://$terminal_upstream; proxy_http_version 1.1; diff --git a/docker/provisioning-worker.sh b/docker/provisioning-worker.sh index 75ada9f..77489ad 100755 --- a/docker/provisioning-worker.sh +++ b/docker/provisioning-worker.sh @@ -14,15 +14,18 @@ # das aus wie „das VPN ist unzuverlaessig", und die Ursache stand in einer Zeile # docker-compose.yml, die von Warteschlangen handelt. # -# Deshalb: wg0 einmal hochziehen, und danach den Arbeiter in einer Schleife -# halten. Faellt er, kommt er wieder — der Tunnel merkt nichts davon. +# Deshalb laeuft der Arbeiter in einer Schleife: faellt er, kommt er wieder. +# Seit wg0 im eigenen Container vpn-hub wohnt, ist das die zweite Sicherung — +# die erste ist, dass dieser Container den Namensraum gar nicht mehr besitzt. # # Der Container endet dann nur noch, wenn Docker ihn beendet. Das ist Absicht: # ein Arbeiter, der nicht startet, ist ein Fall fuers Protokoll, kein Grund, # jedem Host die Verbindung abzuschneiden. set -u -wg-quick up wg0 2>/dev/null || true +# wg0 wird NICHT mehr hier hochgezogen — das tut der vpn-hub, dessen +# Netz-Namensraum dieser Container nur noch mitbenutzt. Der Tunnel haengt damit +# nicht mehr am Veroeffentlichungstakt der Anwendung. # SIGTERM sauber weiterreichen: sonst wartet `docker compose stop` zehn Sekunden # und schlaegt dann mit SIGKILL zu — mitten in einem Provisionierungs-Schritt. diff --git a/docker/vpn-hub/Dockerfile b/docker/vpn-hub/Dockerfile new file mode 100644 index 0000000..396ce31 --- /dev/null +++ b/docker/vpn-hub/Dockerfile @@ -0,0 +1,39 @@ +# Der Tunnel, und sonst nichts. +# +# Ein eigenes, winziges Abbild ist der ganze Sinn dieses Containers. wg0 lebte +# bisher im Container des Provisionierungs-Arbeiters, also in `clupilot-app`, +# und das wird bei fast jeder Freigabe neu gebaut. Ein neu gebauter Container +# bekommt eine neue Adresse im Compose-Netz, die Weiterleitung für den +# WireGuard-Port wird neu geschrieben, und jede bestehende Sitzung reisst ab. +# Der Tunnel hing damit am Veröffentlichungstakt der Anwendung — und daran, dass +# ein Warteschlangen-Arbeiter nicht abstürzt. +# +# Dieses Abbild aendert sich fast nie. Damit fasst `docker compose up -d` den +# Container bei einem gewoehnlichen Update nicht mehr an, und der Tunnel steht +# einfach. +# +# Alpine mit fester Nebenversion, nicht `alpine:latest`: ein Abbild, das sich +# unter der Hand erneuert, ist genau das, was hier nicht passieren soll. +FROM alpine:3.20 + +# wireguard-tools bringt `wg` und `wg-quick`. wg-quick ist ein Bash-Skript und +# ruft `ip` auf — beides muss mit. iptables ist nicht für uns, sondern für den +# Fall, dass eine wg0.conf eines Tages PostUp-Regeln mitbringt: dann scheitert +# der Start sonst an einem fehlenden Programm, und das faellt erst im Betrieb auf. +RUN apk add --no-cache \ + wireguard-tools \ + bash \ + iproute2 \ + iptables + +COPY entrypoint.sh /usr/local/bin/vpn-hub +RUN chmod +x /usr/local/bin/vpn-hub + +# Gesund, wenn wg0 steht — oder wenn es (noch) gar keine Konfiguration gibt. +# Eine Installation ohne eingerichteten Tunnel ist kein Fehlerzustand, und ein +# dauerhaft „unhealthy" gemeldeter Container waere eine Warnung, die niemand +# mehr liest. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD sh -c '[ -f /etc/wireguard/wg0.conf ] || exit 0; wg show wg0 >/dev/null 2>&1' + +ENTRYPOINT ["/usr/local/bin/vpn-hub"] diff --git a/docker/vpn-hub/entrypoint.sh b/docker/vpn-hub/entrypoint.sh new file mode 100644 index 0000000..ed3961b --- /dev/null +++ b/docker/vpn-hub/entrypoint.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Der Prozess 1 des Tunnel-Containers. +# +# Er zieht wg0 hoch und tut danach nichts mehr. Genau das ist die Aufgabe: der +# Netz-Namensraum dieses Containers ist das, worin wg0, der Provisionierungs- +# Arbeiter, die Terminal-Bruecke, der interne DNS und das interne Gateway +# zusammen leben. Er darf nur enden, wenn Docker ihn beendet. +# +# Vorher stand hier — im Container des Arbeiters — `exec php artisan queue:work`. +# Damit hing der Namensraum am Leben eines PHP-Prozesses: ein Zeitueberlauf, ein +# fataler Fehler, ein Speicherlimit, und jede WireGuard-Sitzung riss ab. +set -u + +CONF=/etc/wireguard/wg0.conf + +# Ohne Konfiguration kein Tunnel — aber sehr wohl ein laufender Container. Sonst +# koennte eine Installation, in der der Betreiber den Tunnel noch gar nicht +# eingerichtet hat, den halben Stapel nicht starten: vier Dienste haengen an +# diesem Namensraum und kommen ohne ihn nicht hoch. +if [ -f "$CONF" ]; then + # `|| true`: liegt das Interface aus einem frueheren Leben noch da, ist das + # kein Grund, den Container sterben zu lassen. Die Zeile darunter sagt in + # jedem Fall, was Sache ist. + wg-quick up wg0 || true + + if wg show wg0 >/dev/null 2>&1; then + echo "[vpn-hub] wg0 steht — $(wg show wg0 peers | grep -c .) Zugaenge geladen" + else + echo "[vpn-hub] WARNUNG: wg0 kam nicht hoch. $CONF pruefen." >&2 + fi +else + echo "[vpn-hub] Kein $CONF — der Tunnel ist auf diesem Server noch nicht eingerichtet." +fi + +# Sauber abbauen, wenn Docker den Container beendet. Ohne das laeuft `docker +# compose stop` in seine Zeitueberschreitung und schlaegt mit SIGKILL zu. +stop() { + echo "[vpn-hub] Beende — wg0 wird abgebaut." + wg-quick down wg0 2>/dev/null || true + exit 0 +} +trap stop TERM INT + +# Warten, bis jemand kommt. `sleep` im Hintergrund plus `wait`, nicht `sleep` +# im Vordergrund: ein Signal erreicht die Shell nur, wenn sie gerade auf `wait` +# steht — sonst kaeme das `trap` oben erst nach Ablauf des Schlafs zum Zug. +while true; do + sleep 86400 & + wait $! +done diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index 449af42..bdc0e94 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -64,7 +64,22 @@ kommen, der einen zur Konsole bringt. --- -## Erst feststellen, wo es klemmt +## Zuerst: ein Befehl, der das meiste allein macht + +```bash +cd /opt/clupilot && sudo -u clupilot bash deploy/rescue-tunnel.sh +``` + +Er stellt der Reihe nach fest, wo es klemmt, startet und zieht hoch, was fehlt, +räumt die gemerkten Ströme weg — und sagt am Ende, was noch von Hand bleibt. Er +fasst den Tunnel-Container **nicht** neu an; genau das wäre die Ursache und +nicht die Lösung. + +Was er tut, steht unten einzeln, falls man es von Hand nachvollziehen will. + +--- + +## Von Hand feststellen, wo es klemmt Vom CluPilot-Server aus, in dieser Reihenfolge. Jede Zeile schließt eine Möglichkeit aus. @@ -73,13 +88,13 @@ Möglichkeit aus. cd /opt/clupilot # 1. Steht wg0 überhaupt, und wie viele Zugänge kennt es? -sudo -u clupilot docker compose exec -T queue-provisioning wg show +sudo -u clupilot docker compose exec -T vpn-hub wg show -# 2. Läuft der Container, in dessen Namensraum wg0 lebt? -sudo -u clupilot docker compose ps queue-provisioning +# 2. Läuft der Container, dem der Namensraum mit wg0 gehört? +sudo -u clupilot docker compose ps vpn-hub # 3. Was sagt er beim Starten? -sudo -u clupilot docker compose logs --tail=40 queue-provisioning +sudo -u clupilot docker compose logs --tail=40 vpn-hub ``` **Kein Interface, kein `wg0`** → weiter bei „Der Tunnel kommt nicht hoch". @@ -140,10 +155,10 @@ Keepalive. cd /opt/clupilot # Von Hand hochziehen — sagt auch gleich, woran es scheitert. -sudo -u clupilot docker compose exec -T queue-provisioning wg-quick up wg0 +sudo -u clupilot docker compose exec -T vpn-hub wg-quick up wg0 # Steht die Konfiguration überhaupt noch im Datenträger? -sudo -u clupilot docker compose exec -T queue-provisioning ls -l /etc/wireguard/ +sudo -u clupilot docker compose exec -T vpn-hub ls -l /etc/wireguard/ ``` Die Zugänge stehen **in `wg0.conf`** und überleben einen Neustart. Fehlt die @@ -152,13 +167,14 @@ Datei, ist der `wireguard`-Datenträger das Problem, nicht WireGuard. Läuft der Container gar nicht: ```bash -sudo -u clupilot docker compose up -d queue-provisioning -sudo -u clupilot docker compose logs --tail=40 queue-provisioning +sudo -u clupilot docker compose up -d vpn-hub +sudo -u clupilot docker compose logs --tail=40 vpn-hub ``` -Prozess 1 ist `docker/provisioning-worker.sh`. Das Skript zieht wg0 hoch und -hält danach den Warteschlangen-Arbeiter in einer Schleife. Steht dort etwas -anderes, läuft der Container auf einem alten Stand — dann fehlt das Update. +Prozess 1 ist `/usr/local/bin/vpn-hub`. Das Skript zieht wg0 hoch und tut danach +nichts mehr — genau das ist die Aufgabe. Steht dort etwas anderes, läuft der +Server auf einem Stand vor v1.4.8, und der Tunnel hängt noch am +Provisionierungs-Arbeiter. --- @@ -203,7 +219,7 @@ Vier Dinge, die man vorher macht und nicht, wenn es brennt. ## Was inzwischen NICHT mehr passieren sollte -Drei Ursachen sind abgestellt. Wer sie trotzdem sieht, hat einen Server auf +Vier Ursachen sind abgestellt. Wer sie trotzdem sieht, hat einen Server auf altem Stand. * **Bis v1.4.3** hing wg0 am Leben des Warteschlangen-Arbeiters: Prozess 1 des @@ -219,7 +235,15 @@ altem Stand. * **Bis v1.4.5** startete jedes Update `queue-provisioning` neu und riss damit jede Sitzung ab, auch wenn sich nur PHP-Code geändert hatte. Seit v1.4.6 bekommt der Arbeiter ein Signal und wird von seiner Schleife neu gestartet, - während der Container steht. + während der Container steht. Dasselbe gilt seither für den Knopf „Dienste neu + starten" in der Konsole — der lief über denselben Befehl. +* **Bis v1.4.7** wohnte wg0 im Container des Provisionierungs-Arbeiters, also im + Abbild `clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut, und ein + neu gebauter Container bekommt eine neue Adresse — mit allem, was oben unter + „gemerkte Ströme" steht. Seit **v1.4.8** gehört der Namensraum einem eigenen + Container `vpn-hub` mit eigenem, selten wechselndem Abbild. Nachgemessen: + App-Abbild neu gebaut, Arbeiter und Brücke neu erzeugt — der Hub blieb + derselbe Container, wg0 und beide Zugänge unangetastet. Erzwungen durch `tests/Feature/DeploymentRunsAsTheAppUserTest.php` — der Container darf nicht in der Neustart-Liste stehen, und der conntrack-Griff muss im Skript diff --git a/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md b/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md new file mode 100644 index 0000000..abd794d --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md @@ -0,0 +1,249 @@ +# Die Host-Oberfläche unter ihrem Namen, mit gültigem Zertifikat, ohne Port + +**Stand:** Entwurf, 1. August 2026 — Punkt 1 beantwortet, Weg entschieden +**Auslöser:** gemessen auf `fsn-01`, dem ersten echten Host + +--- + +## Wie es heute ist + +Der Betreiber erreicht Proxmox unter: + +``` +https://fsn-01.node.clupilot.com:8006 +``` + +Mit Zertifikatswarnung, weil Proxmox sich selbst eines ausstellt. Und mit +Port, weil nichts davor sitzt. + +Beides ist kein Schönheitsfehler. Eine Warnung, die bei jedem Aufruf kommt, +lehrt den Betreiber, Warnungen wegzuklicken — und irgendwann klickt er die +weg, die echt war. + +## Wie es sein soll + +``` +https://fsn-01.node.clupilot.com +``` + +Gültiges Zertifikat, kein Port, **weiterhin nur im VPN erreichbar**. Der Host +bleibt hinter der Firewall. + +## Warum kein Port 80 aufgemacht wird + +Der naheliegende Weg wäre HTTP-01: Let's Encrypt ruft den Host auf Port 80 auf. +Das verlangt eine öffentliche, offene Maschine — genau das, was +`SecureHostFirewall` gerade zugemacht hat. + +**DNS-01 verlangt das nicht.** Let's Encrypt ruft überhaupt nichts auf. Es +fragt das öffentliche DNS nach einem TXT-Eintrag: + +``` +_acme-challenge.fsn-01.node.clupilot.com TXT "…" +``` + +Wer den setzen kann, besitzt den Namen. Der Host braucht keine öffentliche +Adresse, keinen offenen Port, und ein **A-Eintrag ist nicht nötig** — der Name +löst weiterhin nur im Tunnel auf `10.66.0.100` auf. + +--- + +## Der Punkt, der eine Entscheidung brauchte — beantwortet am 1. August 2026 + +Die Frage war: **Lässt sich bei Hetzner ein DNS-Token beschränken?** Denn für +DNS-01 muss ein Token auf den Host, und ein kontoweiter Token auf jeder +Proxmox-Maschine wäre ein Schlüssel, der weiter reicht als die Maschine. + +**Ja — über Projekte, nicht über Zonen.** Und zwar erst seit kurzem. + +Die alte DNS-Konsole (`dns.hetzner.com`) kannte genau einen Tokentyp: +kontoweit, alle Zonen, keine Abstufung. Wäre das noch der Stand, hieße die +Antwort nein und dieser Entwurf sähe anders aus. Diese Konsole ist am +**20. Mai 2026 abgeschaltet** worden; `HttpHetznerDnsClient` fährt seit dem +Umbau vom 31. Juli auf `api.hetzner.cloud/v1`. + +Im neuen Modell liegen DNS-Zonen **in Cloud-Projekten**, und Token sind +projektweit. Hetzner nennt das ausdrücklich als den vorgesehenen Weg: + +> „As API tokens are scoped per project, you can restrict access to specific +> zones by placing them in separate projects." + +### Der Haken, der bleibt + +**Ein Projekt-Token ist kein DNS-Token.** Es hat genau zwei Achsen: Projekt und +Lesen/Schreiben. Ein Schreibtoken darf in seinem Projekt alles, was die +Cloud-API kann — auch Server anlegen. Eine Beschränkung auf einen Ressourcentyp +gibt es nicht. + +Daraus folgt die Regel für das Projekt: **es enthält nichts außer der einen +Zone.** Keine Server, keine Netze, kein Speicher. Dann ist der Schaden eines +gestohlenen Tokens auf diese Zone begrenzt, plus Kosten in einem sonst leeren +Projekt. + +### Und die Zone, die er erreicht, enthält nichts Wichtiges + +Der Token wird nicht auf `node.clupilot.com` ausgestellt, sondern auf eine +eigene Zone `acme.clupilot.com`, die **nichts enthält als Challenge-Einträge**. +Proxmox kann das von Haus aus — Alias-Modus: + +``` +pvenode config set -acmedomain0 \ + domain=fsn-01.node.clupilot.com,alias=fsn-01.acme.clupilot.com,plugin=hetznercloud +``` + +Dazu ein fester CNAME im öffentlichen DNS, einmal je Host: + +``` +_acme-challenge.fsn-01.node.clupilot.com CNAME _acme-challenge.fsn-01.acme.clupilot.com +``` + +Let's Encrypt folgt dem CNAME bei der Prüfung; der Host schreibt nur in die +Alias-Zone. **Ein kompromittierter Host erreicht `node.clupilot.com` damit gar +nicht mehr** — auch nicht lesend. Das ist der Unterschied zwischen „der +Schlüssel kann nur eine Zone ändern" und „die Zone, die er ändern kann, ist +wertlos". + +Der CNAME kostet nichts: `RegisterHostDns` legt ohnehin Einträge für den Host +an, mit dem zentralen Token. Ein Eintrag mehr, an der Stelle, die es schon tut. + +### Was damit vom Tisch ist + +Weg 3 aus dem ursprünglichen Entwurf — CluPilot setzt den TXT selbst und +übernimmt die Erneuerung — wird **nicht** gebaut. Er wäre eine zweite Stelle, +die still ausfallen kann, und Zertifikate laufen nach 90 Tagen ab. + +--- + +## Was einmal von Hand eingerichtet wird + +Nicht pro Host, nicht durch die Pipeline. Einmal, vom Betreiber, bevor der +Schritt überhaupt laufen kann: + +1. Ein eigenes Hetzner-Cloud-Projekt, z. B. `clupilot-acme`. **Leer** — nur die + Zone darin. +2. Zone `acme.clupilot.com` in diesem Projekt anlegen und beim Registrar auf + Hetzners Nameserver delegieren. +3. API-Token für dieses Projekt, Lesen **und** Schreiben. Roher Wert, ohne + Anführungszeichen. +4. Der Token wird als **eigener Schlüssel** hinterlegt, nicht als der aus + `provisioning.dns.token` — der ist kontoweit und bleibt zentral. Zwei + Schlüssel, zwei Reichweiten; wer sie zusammenlegt, hebt die ganze + Entscheidung oben wieder auf. + +Fehlt einer dieser vier Punkte, muss der Schritt mit einer klaren Meldung +stehenbleiben und nicht auf den kontoweiten Token zurückfallen. + +--- + +## Die zwei Teile + +### 1. Zertifikat — eingerichtet in Proxmox, nicht in CluPilot + +Proxmox bringt ACME mit und kann DNS-01. Der Schritt richtet über SSH ein: + +- ACME-Konto (Kontaktadresse aus den Einstellungen) +- DNS-Modul `hetznercloud` mit dem Token des ACME-Projekts, Variable + `HETZNER_TOKEN` +- Zertifikatsauftrag für den FQDN des Hosts, **im Alias-Modus** (siehe oben) + +**Warum in Proxmox und nicht in CluPilot:** Proxmox erneuert danach selbst, +über seinen täglichen Zeitgeber. Eine Erneuerung, die in CluPilot hinge, wäre +eine zweite Stelle, die still ausfallen kann — und Zertifikate laufen nach 90 +Tagen ab. Das merkt man dann an einem Dienstag um sieben. + +**Das Modul heißt `hetznercloud`, nicht `hetzner`.** Das alte `hetzner` spricht +mit `dns.hetzner.com/api/v1` und ist damit tot. Ein Host, auf dem noch das alte +Modul konfiguriert ist, muss es losbekommen, bevor das neue greift. + +**Vor allem anderen: Version prüfen.** Das Modul `hetznercloud` kam erst mit +`libproxmox-acme-plugins` **1.7.1**. Darunter existiert es nicht — und der +Schritt scheitert dann nicht beim Einrichten, sondern erst beim Ausstellen, +also spät und mit einer Meldung, die nicht danach aussieht. Deshalb steht die +Prüfung **an den Anfang** des Schritts, nicht ans Ende: + +``` +dpkg-query -W -f='${Version}' libproxmox-acme-plugins +``` + +- ≥ 1.7.1 → weiter +- darunter → aktualisieren; geht das nicht, **abbrechen mit klarer Meldung**. + Kein Nachrüsten der Datei von Hand nach `/usr/share/proxmox-acme/dnsapi/` — + das überlebt kein Paket-Update und hinterlässt einen Host, der sich von den + anderen unterscheidet, ohne dass es jemand aufgeschrieben hat. + +Nach dem Einrichten `pveproxy` und `pvedaemon` neu starten, sonst kennt die +Oberfläche das Modul nicht. + +### 2. Port — ein Proxy, gebunden an den Tunnel + +443 → 127.0.0.1:8006, **nur auf der Tunneladresse lauschend**, nicht auf der +öffentlichen. `deploy/bootstrap/lib/traefik.sh` liegt bereits im Repo. + +Das Zertifikat aus Teil 1 wird von Proxmox verwaltet; der Proxy liest es von +dort (`/etc/pve/local/pveproxy-ssl.pem`) oder reicht TLS unverändert durch. +Zwei Wege, einer davon ist zu wählen — durchreichen ist einfacher und hat +keine zweite Kopie des Schlüssels. + +## Wo der Schritt hingehört + +Nach `RegisterHostDns` — der Name muss registriert sein, bevor ein Zertifikat +auf ihn ausgestellt wird, und der CNAME in die Alias-Zone entsteht dort. Vor +`SecureHostFirewall` ist nicht nötig (DNS-01 braucht nichts Eingehendes), aber +es schadet auch nicht. + +``` +… RegisterHostDns → SecureHostConsole → RegisterCapacity → SecureHostFirewall … +``` + +`RegisterHostDns` bekommt eine Aufgabe dazu: den festen CNAME +`_acme-challenge..node.clupilot.com` → `_acme-challenge..acme.clupilot.com`, +gesetzt mit dem **zentralen** Token. Er ist dauerhaft und wird nie wieder +angefasst. + +## Wiederholbarkeit + +- `libproxmox-acme-plugins` < 1.7.1 → abbrechen, bevor irgendetwas geändert wird +- Zertifikat vorhanden und gültig → nichts tun, `advance()` +- Proxy schon eingerichtet und läuft → nichts tun +- ACME-Modul schon mit demselben Token eingerichtet → nichts tun; bei anderem + Token überschreiben, nicht ein zweites danebenstellen +- CNAME existiert schon → nichts tun (`RegisterHostDns` ist ohnehin schon so + gebaut) +- Beides prüfen, nicht annehmen: `pvenode cert info` sagt, was wirklich liegt + +Dieselbe Regel wie überall in dieser Pipeline — ein Wiederanlauf darf nichts +zerstören, was funktioniert. + +## Abnahme + +1. Neuen Host anlegen → am Ende ist `https://.node.clupilot.com` im VPN + erreichbar, ohne Port, ohne Warnung +2. Von außen (ohne VPN) ist derselbe Name **nicht** erreichbar +3. Im öffentlichen DNS existiert **kein A-Eintrag** für den Namen +4. Wiederholung des Schritts stellt kein zweites Zertifikat aus +5. `pvenode cert info` zeigt ein Let's-Encrypt-Zertifikat mit Laufzeit +6. Der Token auf dem Host kann `node.clupilot.com` **nicht** lesen und nicht + schreiben — einmal von Hand nachgewiesen, mit genau diesem Token gegen die + Zone +7. Ein Host mit `libproxmox-acme-plugins` < 1.7.1 bricht den Schritt ab und + hinterlässt keine halbe ACME-Konfiguration + +Punkt 2, 3 und 6 sind die wichtigen: Sie beweisen, dass der Weg das Zertifikat +gebracht hat, **ohne** die Maschine zu öffnen und **ohne** einen Schlüssel zu +verteilen, der weiter reicht als sie. + +--- + +## Belege + +- Hetzner, DNS-Migration ins Console (Projekt-Scoping der Token): + +- Hetzner, API-Token erzeugen (nur Lesen/Schreiben, keine Ressourcentypen): + +- Hetzner Status, Abschaltung der DNS-Konsole im Mai 2026: + +- Proxmox-Forum, `hetznercloud` in `libproxmox-acme-plugins` 1.7.1, bestätigt + im April 2026: + +- Proxmox VE Wiki, Alias-Modus für DNS-01: + diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 913249d..d0b20d1 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -120,7 +120,7 @@ return [ 'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.', 'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.', 'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.', - 'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', + 'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.', ], 'update_last_run' => 'Letzter Lauf: :state, :when.', diff --git a/lang/de/integrations.php b/lang/de/integrations.php index 06b7ab2..74966e5 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -103,8 +103,8 @@ return [ // Host-Agent nicht läuft oder weil schon etwas anderes läuft — und dann // steht hier der manuelle Befehl statt eines Versprechens. 'env_saved_restarting' => 'Gespeichert. Sicherung: :backup. Konfigurations-Cache geleert — queue, queue-provisioning, scheduler und reverb werden jetzt neu gestartet.', - 'env_saved_restart_busy' => 'Gespeichert. Sicherung: :backup. Es läuft gerade schon eine andere Anfrage an den Update-Dienst — der Neustart entfällt diesmal automatisch. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', - 'env_saved_no_agent' => 'Gespeichert. Sicherung: :backup. Der Update-Dienst auf dem Server läuft nicht — queue, queue-provisioning, scheduler und reverb halten noch die alten Werte. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', + 'env_saved_restart_busy' => 'Gespeichert. Sicherung: :backup. Es läuft gerade schon eine andere Anfrage an den Update-Dienst — der Neustart entfällt diesmal automatisch. Bitte manuell ausführen: docker compose restart queue scheduler reverb.', + 'env_saved_no_agent' => 'Gespeichert. Sicherung: :backup. Der Update-Dienst auf dem Server läuft nicht — queue, scheduler und reverb halten noch die alten Werte. Bitte manuell ausführen: docker compose restart queue scheduler reverb.', // Nur wenn keine .env vorher existierte — dann gibt es nichts zu sichern. 'env_no_previous_file' => 'keine, da zuvor keine Datei existierte', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 4f27b28..5530bd8 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -120,7 +120,7 @@ return [ 'repo_unreachable' => 'The repository cannot be reached from the server.', 'detached_no_release' => 'The checkout is on no branch and pinned to no release.', 'update_failed' => 'The update failed (code :code). See the log.', - 'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', + 'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.', ], 'update_last_run' => 'Last run: :state, :when.', diff --git a/lang/en/integrations.php b/lang/en/integrations.php index bafcc11..12f5032 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -103,8 +103,8 @@ return [ // not running, or because something else is already in flight — and then // the manual command is shown here instead of a promise. 'env_saved_restarting' => 'Saved. Backup: :backup. Configuration cache cleared — queue, queue-provisioning, scheduler and reverb are restarting now.', - 'env_saved_restart_busy' => 'Saved. Backup: :backup. Another request to the update service is already in flight — the restart does not happen automatically this time. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', - 'env_saved_no_agent' => 'Saved. Backup: :backup. The server-side update service is not running — queue, queue-provisioning, scheduler and reverb still hold the old values. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', + 'env_saved_restart_busy' => 'Saved. Backup: :backup. Another request to the update service is already in flight — the restart does not happen automatically this time. Please run manually: docker compose restart queue scheduler reverb.', + 'env_saved_no_agent' => 'Saved. Backup: :backup. The server-side update service is not running — queue, scheduler and reverb still hold the old values. Please run manually: docker compose restart queue scheduler reverb.', // Only when no .env existed before — then there is nothing to back up. 'env_no_previous_file' => 'none, no file existed before this', diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index 9720962..58c1061 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -449,7 +449,7 @@ fallback right here — same shape as the update card's own admin_settings.update_no_agent. --}} {{ __('admin_settings.update_no_agent') }} -
docker compose restart queue queue-provisioning scheduler reverb
+
docker compose restart queue scheduler reverb
@endif diff --git a/tests/Feature/Admin/EnvRestartTest.php b/tests/Feature/Admin/EnvRestartTest.php index 08e0782..68bade8 100644 --- a/tests/Feature/Admin/EnvRestartTest.php +++ b/tests/Feature/Admin/EnvRestartTest.php @@ -10,7 +10,7 @@ use Livewire\Livewire; /** * Saving .env used to end with a card telling the operator to run - * `docker compose restart queue queue-provisioning scheduler reverb` by + * `docker compose restart queue scheduler reverb` by * hand — a shell step tacked onto the end of a console action, the exact * complaint the update button drew before it got its own request/agent * mechanism (App\Services\Deployment\UpdateChannel). This reuses that @@ -140,10 +140,10 @@ it('translates a failed restart into the operator’s language', function () { // ---- update-agent.sh: the host side. ---- -it('mutation target: restarts only queue, queue-provisioning, scheduler and reverb — never app', function () { +it('mutation target: restarts only queue, scheduler and reverb — never app, never den Tunnel', function () { $agent = File::get(base_path('deploy/update-agent.sh')); - expect($agent)->toContain('docker compose restart queue queue-provisioning scheduler reverb'); + expect($agent)->toContain('docker compose restart queue scheduler reverb'); // The exact restart LIST, not merely "the word app does not appear // anywhere" — the script legitimately says "app" elsewhere (the -u @@ -153,10 +153,17 @@ it('mutation target: restarts only queue, queue-provisioning, scheduler and reve $services = explode(' ', trim($matches[1] ?? '')); expect($services)->toContain('queue') - ->and($services)->toContain('queue-provisioning') ->and($services)->toContain('scheduler') ->and($services)->toContain('reverb') - ->and($services)->not->toContain('app'); + ->and($services)->not->toContain('app') + // Und NIE der Provisionierungs-Arbeiter. Er teilt sich den + // Netz-Namensraum mit dem Tunnel; ein `restart` baut den Namensraum neu + // auf und reisst jede WireGuard-Sitzung ab — auch die des Betreibers, + // der gerade „Dienste neu starten" gedrueckt hat. Er bekommt stattdessen + // ein `queue:restart`-Signal, das seine Schleife auffaengt. + ->and($services)->not->toContain('queue-provisioning'); + + expect($agent)->toContain('queue-provisioning php artisan queue:restart'); }); it('branches on the restart kind and records its own outcome, apart from the deployment', function () { @@ -273,7 +280,7 @@ it('explains the automatic restart, and shows the manual fallback only when no a unlockedIntegrations($owner) ->assertSee(__('integrations.env_restart_title')) ->assertSee(__('admin_settings.update_no_agent')) - ->assertSee('docker compose restart queue queue-provisioning scheduler reverb'); + ->assertSee('docker compose restart queue scheduler reverb'); restartAgentAlive(); diff --git a/tests/Feature/DeploymentRunsAsTheAppUserTest.php b/tests/Feature/DeploymentRunsAsTheAppUserTest.php index eacb608..441706f 100644 --- a/tests/Feature/DeploymentRunsAsTheAppUserTest.php +++ b/tests/Feature/DeploymentRunsAsTheAppUserTest.php @@ -182,7 +182,7 @@ it('reicht den Terminal-Pfad durch, ohne ihn weiter zu oeffnen als noetig', func it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar ist', function () { // Der app-Container erreicht keinen einzigen Host — gemessen, nicht - // vermutet: wg0 lebt im Netz-Namensraum von queue-provisioning. Wer diese + // vermutet: wg0 lebt im Netz-Namensraum von vpn-hub. Wer diese // Zeile für Aufräumen hält, bekommt einen Container, der startet, gesund // aussieht und bei jeder Sitzung in eine Zeitüberschreitung läuft. $compose = File::get(base_path('docker-compose.yml')); @@ -191,7 +191,7 @@ it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar i $block = preg_split('/^ \S/m', $block)[0] ?? ''; expect($block)->not->toBe('') - ->and($block)->toContain('network_mode: "service:queue-provisioning"') + ->and($block)->toContain('network_mode: "service:vpn-hub"') // Kein eigener Port nach außen: erreichbar allein über nginx. Ein // Dienst in fremdem Namensraum könnte ihn ohnehin nicht veröffentlichen // — Docker lehnt das Compose-File dann komplett ab. @@ -213,42 +213,63 @@ it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar i // Deshalb: kein Alias. Der Dienstname des Nachbarn tut es auch. expect($compose)->not->toContain('aliases:'); - $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; + $hub = preg_split('/^ vpn-hub:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; expect($hub)->not->toContain('networks:'); + // Angesprochen wird der Container, dem der Namensraum GEHOERT. Ein + // Mitbewohner hat keinen eigenen DNS-Eintrag. expect(File::get(base_path('docker/nginx/default.conf'))) - ->toContain('set $terminal_upstream queue-provisioning:8082;'); + ->toContain('set $terminal_upstream vpn-hub:8082;'); }); -it('haengt den Tunnel nicht an das Leben eines Warteschlangen-Arbeiters', function () { +it('haengt den Tunnel an gar keinen Warteschlangen-Arbeiter mehr', function () { // Der Fall, der den Betreiber unterwegs erwischt hat: wg0 stand im // Namensraum von queue-provisioning, und dessen Prozess 1 war - // `php artisan queue:work`. Endete der Arbeiter — Zeitüberlauf nach 2100 + // `php artisan queue:work`. Endete der Arbeiter — Zeitueberlauf nach 2100 // Sekunden, fataler Fehler, Speicherlimit, `queue:restart` —, endete der - // Container, und mit ihm fielen ALLE WireGuard-Sitzungen: die des Betreibers - // am Telefon wie die jedes Hosts. Von außen las sich das als „das VPN ist - // unzuverlässig"; die Ursache stand in einer Zeile, die von Warteschlangen - // handelt. + // Container, und mit ihm fielen ALLE WireGuard-Sitzungen. Und weil das + // Abbild `clupilot-app` bei fast jeder Freigabe neu gebaut wird, riss es + // ausserdem bei jedem Ausrollen ab. + // + // Jetzt besitzt ein eigener Container den Namensraum, mit eigenem Abbild, + // das sich fast nie aendert. $compose = File::get(base_path('docker-compose.yml')); - $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; + $hub = preg_split('/^ vpn-hub:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; - // Kein `exec` auf den Arbeiter — das ist genau das Ersetzen von Prozess 1, - // das den Container am Arbeiter sterben ließ. - expect($hub)->not->toContain('exec php artisan queue:work') - ->and($hub)->toContain('docker/provisioning-worker.sh'); + expect($hub)->not->toBe('') + // Eigenes Abbild, nicht clupilot-app: das ist der ganze Punkt. + ->and($hub)->toContain('image: clupilot-vpn-hub:dev') + ->and($hub)->not->toContain('clupilot-app') + // Er besitzt den Namensraum, also traegt ER den Port, das Geraet und + // die Netz-Einstellung. + ->and($hub)->toContain(':51820/udp') + ->and($hub)->toContain('/dev/net/tun') + ->and($hub)->toContain('net.ipv4.ip_forward=1'); - $runner = File::get(base_path('docker/provisioning-worker.sh')); + // Der Arbeiter steigt nur noch ein — ohne Port, ohne Geraet. + $worker = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; + $worker = preg_split('/^ \S/m', $worker)[0] ?? ''; - expect($runner)->toContain('wg-quick up wg0') - // Die Schleife ist der ganze Punkt: fällt der Arbeiter, kommt er wieder, - // ohne dass der Namensraum stirbt. - ->and($runner)->toContain('while true') - // Und SIGTERM wird weitergereicht, sonst wartet `docker compose stop` - // zehn Sekunden und schlägt dann mitten in einem Schritt mit SIGKILL zu. - ->and($runner)->toContain('trap stop TERM INT'); + expect($worker)->toContain('network_mode: "service:vpn-hub"') + ->and($worker)->not->toContain('51820') + ->and($worker)->not->toContain('/dev/net/tun') + // NET_ADMIN bleibt: `wg set` fuer jede Zugangsaenderung laeuft hier. + ->and($worker)->toContain('NET_ADMIN'); + + // Und wg0 wird nicht mehr vom Arbeiterskript hochgezogen, sondern vom Hub. + expect(File::get(base_path('docker/provisioning-worker.sh'))) + ->not->toContain('wg-quick up wg0') + ->and($worker)->toContain('docker/provisioning-worker.sh'); + + $entry = File::get(base_path('docker/vpn-hub/entrypoint.sh')); + + expect($entry)->toContain('wg-quick up wg0') + // Prozess 1 darf nur enden, wenn Docker ihn beendet. + ->and($entry)->toContain('while true') + ->and($entry)->toContain('trap stop TERM INT'); }); it('startet den Tunnel-Container beim Ausrollen nicht neu', function () {