Der Tunnel bekommt eine feste Adresse — damit ist die conntrack-Falle weg statt behandelt

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 09:10:54 +02:00
parent c18b471b9d
commit 5f3d983658
4 changed files with 110 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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');
});