$line) { if (! str_contains($line, 'docker compose exec')) { continue; } // Only the app container. The gateway and the queues are other // images with other users, and none of them own this checkout. if (! preg_match('/docker compose exec\b[^|]*?\bapp\b/', $line)) { continue; } if (preg_match('/\s-u\s+\S+/', $line)) { continue; } // Help text counts. Telling an operator to run it as root is how // the file ends up owned by root in the first place. $offenders[] = basename($path).':'.($i + 1).' — '.trim($line); } } expect($offenders)->toBe([]); }); it('never runs occ in a customer guest as whoever docker felt like either', function () { // The same default, the same lesson, a different subsystem — and it was learned // here second. `docker compose exec` is root, the official Nextcloud image's // console.php exits 1 unless the caller owns config/config.php (www-data), and // the prefix was pasted into five separate files, so every occ call in the // product was failing on every instance. The ones that go through // CustomerStep::guest() turned that into a retry and then a failed run for a // machine that was otherwise finished. // // So there is one builder, and this refuses a second: nothing in app/ may spell // the invocation out by hand, which is the only way a sixth call site can be // stopped from getting it wrong again. $offenders = []; foreach (File::allFiles(app_path()) as $file) { if ($file->getExtension() !== 'php' || $file->getRelativePathname() === 'Support/NextcloudOcc.php') { continue; } if (str_contains(File::get($file->getRealPath()), 'docker compose exec')) { $offenders[] = $file->getRelativePathname(); } } expect($offenders)->toBe([]) // And the one builder names the user Nextcloud insists on. ->and(NextcloudOcc::command('status')) ->toContain('docker compose exec -T -u www-data '); }); it('repairs ownership before it needs it, not after', function () { // A server already carrying the damage has to heal on its next deployment. // Nothing else ever will: a root-owned log file stays root-owned, and the // page that trips over it is nowhere near the deployment that caused it. $update = File::get(base_path('deploy/update.sh')); expect($update)->toContain('normalise_ownership') // Every directory the deployment writes into, and node_modules by name: // the first version sampled the top-level owner and skipped the // recursion when it matched, which is how a root-owned // node_modules/.vite-temp under a www-data-owned node_modules failed // the build in maintenance mode. ->and($update)->toContain('! -user www-data -exec chown www-data:www-data') ->and($update)->toContain('node_modules') // Und das Heimatverzeichnis von www-data. Es stand nicht auf der Liste, // weil es außerhalb des Checkouts liegt — und genau daran brach ein // Deployment mit Code 243 ab: npm wollte ~/.npm anlegen und durfte // nicht (EACCES, errno -13; 256 minus 13 ist 243). Der Server blieb im // Wartungsmodus stehen, die Kundenseite antwortete mit 500. ->and($update)->toContain('/var/www/.npm'); // Before the first unprivileged step, or it cannot help: composer and npm // would already have failed on a root-owned vendor directory. $repair = strpos($update, "\nnormalise_ownership\n"); $maintenance = strpos($update, 'php artisan down --retry=60'); expect($repair)->not->toBeFalse() ->and($maintenance)->not->toBeFalse() ->and($repair)->toBeLessThan($maintenance); }); it('gibt www-data sein eigenes Heimatverzeichnis, statt ihm nur eine neue Nummer zu geben', function () { // `usermod -o -u` vergibt die Nummer und schreibt KEINE vorhandene Datei // um. Ohne das chown danach gehört /var/www weiter der Nummer 33 aus dem // Basis-Abbild, und www-data kann in seinem eigenen Zuhause nichts anlegen // — weder ~/.npm noch ~/.composer. $dockerfile = File::get(base_path('docker/php/Dockerfile')); $usermod = strpos($dockerfile, 'usermod'); $chown = strpos($dockerfile, 'chown -R www-data:www-data /var/www'); expect($usermod)->not->toBeFalse() ->and($chown)->not->toBeFalse() // Danach, nicht davor: vor der Umnummerierung würde es auf die alte // Nummer chownen und wäre unmittelbar wieder falsch. ->and($chown)->toBeGreaterThan($usermod); }); /** * Das Update darf sich nicht selbst unter den Füßen wegziehen. * * `update.sh` checkt einen neuen Stand in denselben Baum aus, aus dem es läuft * — es ersetzt also SICH SELBST auf der Platte. Bash liest ein Skript aber * nachlaufend ab einer Byte-Position; ändert sich die Datei mitten im Lauf, * liest es an derselben Position im neuen Text weiter. Liegt die dann vor dem * Punkt, an dem der Lauf schon war, führt das Skript einen Abschnitt ein * zweites Mal aus. * * Genau das war v1.3.97: die Freigabe fügte 23 Zeilen oberhalb des Checkouts * ein, alles danach rutschte, und das Update drehte sich im Kreis. v1.3.96 lief * sauber — sie hatte diese Datei nicht angefasst. Der Fehler trifft also nur * Freigaben, die das Update selbst ändern, und blieb deshalb lange unsichtbar. */ it('runs from a copy of itself, so a checkout cannot rewrite it mid-flight', function () { $update = File::get(base_path('deploy/update.sh')); $reexec = strpos($update, 'exec bash "$CLUPILOT_UPDATE_COPY"'); $checkout = strpos($update, 'git checkout --quiet --detach'); expect($reexec)->not->toBeFalse('update.sh startet sich nicht aus einer Kopie neu') ->and($checkout)->not->toBeFalse() // Vor dem Checkout, sonst hilft es nicht: die Kopie muss stehen, BEVOR // die Originaldatei getauscht werden kann. ->and($reexec)->toBeLessThan($checkout); // Und die Kopie liegt außerhalb des Checkouts — läge sie darin, träfe sie // derselbe Tausch. expect($update)->toContain('mktemp /tmp/clupilot-update-'); }); it('reicht den Terminal-Pfad durch, ohne ihn weiter zu oeffnen als noetig', function () { $nginx = File::get(base_path('docker/nginx/default.conf')); // Genau dieser eine Ort, kein Präfix: ein `location /terminal` machte // jeden Unterpfad zum Weg in den Container. expect($nginx)->toContain('location = /terminal/ws') ->and($nginx)->not->toContain('location /terminal') ->and($nginx)->toContain('terminal:8082') ->and($nginx)->toContain('proxy_pass http://$terminal_upstream') ->and($nginx)->toContain('Upgrade $http_upgrade'); // Und das Ticket reist im Kopf, nicht in der Adresszeile — sonst stünde es // im Zugriffsprotokoll jedes Reverse Proxy auf der Strecke. Der Browser ist // die Seite, die das entscheidet. expect(File::get(base_path('resources/js/terminal.js'))) ->not->toContain('/terminal/ws?'); }); 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 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')); $block = preg_split('/^ terminal:$/m', $compose)[1] ?? ''; $block = preg_split('/^ \S/m', $block)[0] ?? ''; expect($block)->not->toBe('') ->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. ->and($block)->not->toContain('ports:'); // Und nginx muss ihn unter dem Namen des Namensraum-Eigentümers ansprechen, // nicht unter einem eigenen: ein Container ohne eigenes Netz hat keinen // eigenen DNS-Eintrag. // // Hier stand einmal ein Netz-Alias `terminal` auf queue-provisioning, damit // in der nginx-Konfiguration ein sprechender Name steht. Der Preis dafür war // hoch und fiel erst im Betrieb auf: ein Eintrag unter `networks:` ist Teil // der Netzkonfiguration eines Dienstes, und Compose BAUT einen Container NEU, // sobald die sich ändert. Neu gebaut heißt neue Adresse im Compose-Netz, // heißt neu geschriebene Weiterleitungsregeln für den veröffentlichten // UDP-Port 51820 — auf dem jede bestehende WireGuard-Sitzung liegt. Ein // hübscherer Name in einer Konfigurationszeile hat sämtliche Tunnel gekostet. // // 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)->toContain('ipv4_address:'); // 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 vpn-hub:8082;'); }); 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 — Zeitueberlauf nach 2100 // Sekunden, fataler Fehler, Speicherlimit, `queue:restart` —, endete der // 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('/^ vpn-hub:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; 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'); // 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($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 () { // `docker compose restart queue-provisioning` baut dessen Netz-Namensraum // neu auf, und darin lebt wg0. Für ein Update, das nur PHP-Code ändert, // reisst das jede WireGuard-Sitzung ab — die des Betreibers am Telefon wie // die jedes Hosts. Genau so ist es passiert. // // Seit der Arbeiter dort in einer Schleife läuft, genügt ein Signal: der // Arbeiter beendet sich nach dem laufenden Auftrag, die Schleife startet ihn // mit neuem Code neu, der Container bleibt stehen. $update = File::get(base_path('deploy/update.sh')); expect($update)->not->toContain('restart queue queue-provisioning') ->and($update)->toContain('queue-provisioning php artisan queue:restart'); // Und wenn `up -d` ihn doch neu baut (neues Abbild, geänderte // Konfiguration), müssen die gemerkten UDP-Ströme weg — sonst zeigen sie // weiter auf den alten Container, und ein Host mit festem Quellport kommt // nie zurück. expect($update)->toContain('conntrack -D -p udp --dport') ->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'); }); it('haelt einen Waechter bereit, der einen halb liegengebliebenen Stapel selbst aufrichtet', function () { // Der Vorfall: ein Update brach ab, der Stapel lag halb unten, der Tunnel // war weg und die Seite antwortete eine Stunde lang 500 — bis jemand von // Hand nachsah. Genau das soll nie wieder auf einen Menschen warten. $watchdog = File::get(base_path('deploy/watchdog.sh')); // Er fasst nichts an, solange ein Update laeuft: dieselbe Sperre wie der // Update-Agent, und weg, wenn er sie nicht bekommt. expect($watchdog)->toContain('flock -n 9 || exit 0') // Die vier Fehlerbilder, die heute wirklich passiert sind. ->and($watchdog)->toContain('docker compose up -d') ->and($watchdog)->toContain('--force-recreate') ->and($watchdog)->toContain('wg-quick up wg0') ->and($watchdog)->toContain('php artisan up') // Und eine Handbremse: wer die Seite bewusst unten haben will, soll sie // unten halten koennen. ->and($watchdog)->toContain('maintenance-hold'); // Auf dem Wirt, per systemd — nicht als Container mit Docker-Socket, der // genau dann tot waere, wenn man ihn braucht. $installer = File::get(base_path('deploy/install-agent.sh')); expect($installer)->toContain('clupilot-watchdog.service') ->and($installer)->toContain('clupilot-watchdog.timer') ->and($installer)->toContain('systemctl enable --now clupilot-watchdog.timer'); }); it('meldet nicht fertig, wenn der letzte Lauf abgebrochen ist', function () { // Bricht ein Update mittendrin ab, steht der Checkout schon auf dem Ziel. // Ein zweiter Aufruf meldete deshalb "Already up to date" und tat NICHTS — // waehrend der Server halb unten stand. Der Commit ist die falsche Frage; // die richtige ist, ob alles laeuft. $update = File::get(base_path('deploy/update.sh')); expect($update)->toContain('Already up to date') ->and($update)->toContain('unhealthy') ->and($update)->toContain('der Wartungsmodus ist noch an'); // Und die Fortschrittsanzeige darf ein Deployment nicht mehr toeten: gehoert // storage/ nach einem frueheren Fehltritt root, scheiterte das mkdir in // phase() — und mit `set -e` starb der Lauf an seiner ersten Zeile, ohne // eine einzige Ausgabe. expect($update)->toContain('mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true'); });