337 lines
16 KiB
PHP
337 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Support\NextcloudOcc;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
/**
|
|
* Nothing runs inside a container without saying who it is running as.
|
|
*
|
|
* `docker compose exec` is root unless told otherwise, and the deployment
|
|
* scripts relied on that default. When `artisan optimize` failed during a
|
|
* deployment it wrote its error into storage/logs/laravel.log AS ROOT — and
|
|
* from that moment the application could not append to its own log. Monolog
|
|
* threw on every attempt, and a throw while logging is a 500 on every page that
|
|
* logs, with nothing written down to say why.
|
|
*
|
|
* It surfaced days later as a 500 on the VPN config download, that being one of
|
|
* the few pages writing a log line on its way through, and was chased through
|
|
* the VPN code, the encryption key and the session before anyone looked at the
|
|
* owner of a file.
|
|
*
|
|
* docker/entrypoint.sh had it right all along: it drops to www-data for exactly
|
|
* these commands. The rule is only that the user is named — `-u root` is a fine
|
|
* answer where root is what is wanted (the ownership repair in update.sh), as
|
|
* long as somebody chose it.
|
|
*/
|
|
it('never runs a command in the app container as whoever docker felt like', function () {
|
|
$offenders = [];
|
|
|
|
foreach (File::glob(base_path('deploy/*.sh')) as $path) {
|
|
foreach (preg_split('/\R/', File::get($path)) ?: [] as $i => $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');
|
|
});
|