Der Tunnel bekommt einen eigenen Container

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 07:49:29 +02:00
parent ca1262cae1
commit e3c781f067
18 changed files with 570 additions and 119 deletions

View File

@ -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.

View File

@ -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? ────────────────────────────────────────────────────────────

View File

@ -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

View File

@ -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.

View File

@ -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 <vm-public-ip>:${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

View File

@ -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;

View File

@ -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.

39
docker/vpn-hub/Dockerfile Normal file
View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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.<host>.node.clupilot.com``_acme-challenge.<host>.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://<name>.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):
<https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/features-and-differences/>
- Hetzner, API-Token erzeugen (nur Lesen/Schreiben, keine Ressourcentypen):
<https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/>
- Hetzner Status, Abschaltung der DNS-Konsole im Mai 2026:
<https://status.hetzner.com/incident/c2146c42-6dd2-4454-916a-19f07e0e5a44>
- Proxmox-Forum, `hetznercloud` in `libproxmox-acme-plugins` 1.7.1, bestätigt
im April 2026:
<https://forum.proxmox.com/threads/acme-plugin-hetzner-dns-console-is-moving-to-the-hetzner-console.174363/page-2>
- Proxmox VE Wiki, Alias-Modus für DNS-01:
<https://pve.proxmox.com/wiki/Certificate_Management>

View File

@ -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.',

View File

@ -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',

View File

@ -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.',

View File

@ -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',

View File

@ -449,7 +449,7 @@
fallback right here same shape as the update
card's own admin_settings.update_no_agent. --}}
<x-ui.alert variant="warning" class="mt-2">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue scheduler reverb</pre>
@endif
</div>
</div>

View File

@ -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 operators 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();

View File

@ -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 () {