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
parent
ca1262cae1
commit
e3c781f067
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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? ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue