From 5f3d9836581b61cee5e5efe1b6962b8fe64f80d0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 09:10:54 +0200 Subject: [PATCH] =?UTF-8?q?Der=20Tunnel=20bekommt=20eine=20feste=20Adresse?= =?UTF-8?q?=20=E2=80=94=20damit=20ist=20die=20conntrack-Falle=20weg=20stat?= =?UTF-8?q?t=20behandelt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Wurzel des ganzen Uebels, endlich an der Wurzel: Docker traegt den veroeffentlichten UDP-Port als Weiterleitung auf die Container-Adresse ein, und der Kernel merkt sich jeden laufenden Strom samt dieser Adresse. Bekam der Container beim Neubau eine andere — und er bekam die naechste freie —, zeigten alle gemerkten Stroeme ins Leere. Und sie verfallen nicht, weil WireGuards Lebenszeichen sie alle 25 Sekunden auffrischt. Deshalb kam ein Telefon nach Aus/Ein sofort zurueck und ein Host mit festem Quellport ueberhaupt nicht. Jetzt hat vpn-hub eine feste Adresse (172.18.0.240, ueber CLUPILOT_VPN_HUB_IP aenderbar) in einem Netz mit erklaertem Subnetz. Nach einem Neubau entsteht exakt dieselbe Weiterleitung, die gemerkten Stroeme bleiben gueltig, und es gibt nichts mehr aufzuraeumen. Nachgemessen: 172.18.0.240 vor und nach `up -d --force-recreate vpn-hub`. UND DIE UMSTELLUNG SELBST, die mich beim Bauen fast den Stapel gekostet haette: Docker kann ein bestehendes Netz nicht umdefinieren. Es muss neu angelegt werden, und das scheitert, solange auch nur EIN Container daranhaengt — `up -d` bricht dann mit "network … has active endpoints" ab und laesst den Stapel halb unten stehen. Genau so hier passiert, mit einem Container, der gar nicht zum Stapel gehoerte. Das Deployment vergleicht deshalb vorher das erklaerte Subnetz mit dem tatsaechlichen und faehrt bei Abweichung EINMAL geordnet herunter, statt darueber zu stolpern. Danach stimmen die Werte ueberein und der Block tut nie wieder etwas. Das conntrack-Aufraeumen bleibt trotzdem drin: als Rueckfahrkarte fuer Server auf aelterem Stand und fuer den Fall, dass jemand das Subnetz aendert. 2510 Tests gruen. Co-Authored-By: Claude Opus 5 --- deploy/update.sh | 29 +++++++++++++ docker-compose.yml | 28 +++++++++++++ docs/runbooks/tunnel-recovery.md | 14 ++++++- .../DeploymentRunsAsTheAppUserTest.php | 42 ++++++++++++++++++- 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/deploy/update.sh b/deploy/update.sh index 7350a71..98217a7 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -529,6 +529,29 @@ 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. +# EINMALIGE Umstellung: das Compose-Netz bekommt ein erklärtes Subnetz, damit +# vpn-hub eine feste Adresse haben kann (siehe docker-compose.yml, ganz unten). +# +# Docker kann ein bestehendes Netz nicht umdefinieren — es muss neu angelegt +# werden, und das geht nur, wenn KEIN Container mehr daranhängt. `docker compose +# up -d` allein scheitert daran mit "network … has active endpoints" und lässt +# den Stapel halb unten stehen. Beim Bauen genau so passiert; deshalb macht das +# Deployment es geordnet, statt darüber zu stolpern. +# +# Nach dieser einen Umstellung stimmen die Werte überein und der Block tut nie +# wieder etwas. +net_migrated=false +want_subnet="$(sed -n 's/^CLUPILOT_NET_SUBNET=//p' .env 2>/dev/null | tail -1)" +want_subnet="${want_subnet:-172.18.0.0/16}" +net_name="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}_default" +have_subnet="$(docker network inspect "$net_name" --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null || true)" + +if [[ -n "$have_subnet" && "$have_subnet" != "$want_subnet" ]]; then + log "Das Netz bekommt ein festes Subnetz ($want_subnet) — dafür muss der Stapel einmal ganz herunter." + docker compose down --remove-orphans || true + net_migrated=true +fi + hub_before="$(docker compose ps -q vpn-hub 2>/dev/null || true)" docker compose up -d hub_after="$(docker compose ps -q vpn-hub 2>/dev/null || true)" @@ -546,6 +569,12 @@ hub_rebuilt=false if [[ -n "$hub_before" && "$hub_before" != "$hub_after" ]]; then hub_rebuilt=true fi +# Nach der Netz-Umstellung war der Hub vorher schon weg, der Vergleich oben +# greift also nicht — die gemerkten Ströme zeigen aber trotzdem auf die alte, +# automatisch vergebene Adresse und müssen weg. +if [[ "$net_migrated" == true ]]; 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. # diff --git a/docker-compose.yml b/docker-compose.yml index 7da93f5..a5430da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,6 +101,24 @@ services: - net.ipv4.ip_forward=1 ports: - "${WG_HUB_PORT:-51820}:51820/udp" + # FESTE Adresse, nicht die naechste freie. + # + # Der veroeffentlichte UDP-Port wird von Docker als Weiterleitung auf die + # Container-Adresse eingetragen, und der Kernel merkt sich jeden laufenden + # UDP-Strom samt dieser Adresse. Bekam der Container beim Neubau eine andere, + # zeigten alle gemerkten Stroeme ins Leere — und sie verfallen nicht, weil + # WireGuards Lebenszeichen sie alle 25 Sekunden auffrischt. Das war die + # Ursache dafuer, dass ein Host nach einem Neubau NIE von allein zurueckkam. + # + # Mit fester Adresse entsteht nach einem Neubau exakt dieselbe Weiterleitung. + # Die gemerkten Stroeme bleiben gueltig, und es gibt nichts mehr aufzuraeumen. + # + # .240 ist bewusst hoch: Docker vergibt automatisch von unten, hier kommt es + # also nie zu einer Kollision. Beides ueber .env aenderbar, falls das Subnetz + # auf einem Server schon anderweitig belegt ist. + networks: + default: + ipv4_address: ${CLUPILOT_VPN_HUB_IP:-172.18.0.240} volumes: - wireguard:/etc/wireguard @@ -330,3 +348,13 @@ volumes: redis-data: wireguard: dns-hosts: + +# Ein erklaertes Subnetz, damit vpn-hub oben eine feste Adresse bekommen kann — +# ohne das lehnt Docker `ipv4_address` ab. Der Wert ist der, den Docker hier +# ohnehin schon selbst gewaehlt hatte; er steht jetzt nur schriftlich da, statt +# sich bei jedem Neuaufbau des Netzes neu zu ergeben. +networks: + default: + ipam: + config: + - subnet: ${CLUPILOT_NET_SUBNET:-172.18.0.0/16} diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index bdc0e94..ae0b748 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -139,7 +139,13 @@ Fehlt das Werkzeug: `sudo apt-get install -y conntrack`. Danach dreißig Sekunde warten — jeder Zugang meldet sich von selbst wieder, alle 25 Sekunden läuft sein Keepalive. -> **Seit v1.4.6 macht das Update das selbst.** Es merkt sich die Container-ID +> **Seit v1.4.9 kann das gar nicht mehr passieren.** Der Hub hat eine feste +> Adresse im Compose-Netz (`172.18.0.240`, über `CLUPILOT_VPN_HUB_IP` +> änderbar). Nach einem Neubau entsteht damit exakt dieselbe Weiterleitung, und +> die gemerkten Ströme bleiben gültig. Was unten steht, gilt für Server, die +> älter sind — und als Rückfahrkarte, falls jemand das Subnetz ändert. +> +> **Seit v1.4.6 macht das Update das Aufräumen ohnehin selbst.** Es merkt sich die Container-ID > vor und nach `docker compose up -d`; hat sie sich geändert, räumt es die > Ströme weg. Kann es das nicht (es läuft als Dienstbenutzer, der Griff braucht > Root), steht der Befehl als Warnung im Protokoll. Siehe „Einmal einrichten" @@ -219,7 +225,7 @@ Vier Dinge, die man vorher macht und nicht, wenn es brennt. ## Was inzwischen NICHT mehr passieren sollte -Vier Ursachen sind abgestellt. Wer sie trotzdem sieht, hat einen Server auf +Fünf 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 @@ -244,6 +250,10 @@ altem Stand. 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. +* **Bis v1.4.8** bekam der Hub bei jedem Neubau die nächste freie Adresse im + Compose-Netz. Genau darauf zeigen die gemerkten UDP-Ströme — eine neue Adresse + machte sie alle ungültig. Seit **v1.4.9** hat er eine feste. Nachgemessen: + `172.18.0.240` vor und nach `up -d --force-recreate vpn-hub`. Erzwungen durch `tests/Feature/DeploymentRunsAsTheAppUserTest.php` — der Container darf nicht in der Neustart-Liste stehen, und der conntrack-Griff muss im Skript diff --git a/tests/Feature/DeploymentRunsAsTheAppUserTest.php b/tests/Feature/DeploymentRunsAsTheAppUserTest.php index 441706f..649e719 100644 --- a/tests/Feature/DeploymentRunsAsTheAppUserTest.php +++ b/tests/Feature/DeploymentRunsAsTheAppUserTest.php @@ -213,9 +213,13 @@ 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:'); + // Ein `networks:`-Block am Hub selbst ist dagegen erlaubt und noetig — er + // traegt die FESTE Adresse. Der Unterschied: ein Alias war reine Kosmetik + // und hat einen Neubau ausgeloest, die feste Adresse macht einen Neubau + // folgenlos. Was hier nicht stehen darf, steht eine Zeile hoeher. $hub = preg_split('/^ vpn-hub:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; - expect($hub)->not->toContain('networks:'); + expect($hub)->toContain('ipv4_address:'); // Angesprochen wird der Container, dem der Namensraum GEHOERT. Ein // Mitbewohner hat keinen eigenen DNS-Eintrag. @@ -294,3 +298,39 @@ it('startet den Tunnel-Container beim Ausrollen nicht neu', function () { ->and($update)->toContain('hub_before') ->and($update)->toContain('hub_after'); }); + +it('gibt dem Tunnel eine feste Adresse, damit gemerkte Stroeme nie ins Leere zeigen', function () { + // Die Wurzel des ganzen Uebels: Docker traegt den veroeffentlichten UDP-Port + // als Weiterleitung auf die Container-Adresse ein, und der Kernel merkt sich + // jeden laufenden Strom samt dieser Adresse. Bekam der Container beim Neubau + // eine andere, zeigten alle gemerkten Stroeme ins Leere — und sie verfallen + // nicht, weil WireGuards Lebenszeichen sie alle 25 Sekunden auffrischt. + // + // Mit fester Adresse entsteht nach einem Neubau exakt dieselbe Weiterleitung. + // Nachgemessen: 172.18.0.240 vor und nach `up -d --force-recreate vpn-hub`. + $compose = File::get(base_path('docker-compose.yml')); + + $hub = preg_split('/^ vpn-hub:$/m', $compose)[1] ?? ''; + $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; + + expect($hub)->toContain('ipv4_address:') + // Ueber .env aenderbar, falls das Subnetz auf einem Server belegt ist. + ->and($hub)->toContain('CLUPILOT_VPN_HUB_IP'); + + // Eine feste Adresse geht nur in einem Netz mit erklaertem Subnetz. + expect($compose)->toContain('CLUPILOT_NET_SUBNET'); + + // Und die Umstellung darauf muss GEORDNET passieren: Docker kann ein + // bestehendes Netz nicht umdefinieren, es muss neu angelegt werden — und das + // scheitert, solange auch nur ein Container daranhaengt. `up -d` allein + // bricht dann mit "network … has active endpoints" ab und laesst den Stapel + // halb unten stehen. Beim Bauen genau so passiert. + $update = File::get(base_path('deploy/update.sh')); + + expect($update)->toContain('net_migrated') + ->and($update)->toContain('docker compose down --remove-orphans') + // Nach der Umstellung zeigen die gemerkten Stroeme auf die alte, + // automatisch vergebene Adresse — der Vergleich der Container-IDs greift + // dabei nicht, also muss das Aufraeumen ausdruecklich ausgeloest werden. + ->and($update)->toContain('"$net_migrated" == true'); +});