From 6ebbaa82aabe954467c79a0e17cb8f217a109bd8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 17:00:08 +0200 Subject: [PATCH] Ein Host, der die Sperrmengen nicht kennt, laesst sich jetzt nachziehen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-Welle nach dem Gesamt-Review, zweite Haelfte von Punkt 3. Die beiden nftables-Mengen clupilot_blocked und clupilot_blocked6 kamen mit dem Fruehwarnsystem ins Regelwerk. Jeder Host, der VORHER uebernommen wurde, traegt noch die alte Datei — dort scheitert `nft add element` bei jedem Versuch, und die Sperre steht in Datenbank, Portal, Konsole und in der Mail an den Kunden als aktiv, in der Firewall aber nie. Gemeldet wird dieser Fall seit dem Commit davor; das hier ist der Griff, mit dem man ihn abstellt. Der Uebernahme-Lauf hilft nicht, und genau das stand im Runbook falsch: SecureHostFirewall kuerzt sich ueber den `host_firewall`-Brotkrumen ab, und der haengt am LAUF, nicht am Host. Auf einem bereits uebernommenen Host taete der Schritt gar nichts und meldete trotzdem "erledigt". Das Repo hat fuer genau das ein Muster, und es passt: clupilot:apply-quotas faehrt ueber eine EIN-SCHRITT-Pipeline (`quota`) einen einzelnen Schritt gegen ein bestehendes Subjekt — gedrosselt, wiederholt, protokolliert und in der Konsole sichtbar wie jede andere Fernarbeit, statt dass ein Konsolenbefehl selbst auf die Maschine greift. Ein zweiter Weg, dieselbe Datei zu schreiben, wuerde driften. Also dasselbe hier: - Pipeline `host-firewall` mit Host\SecureHostFirewall als einzigem Schritt. Ein frischer Lauf hat den Brotkrumen nicht, fuehrt den Schritt also wirklich aus — und weil der Schritt die Datei ohnehin vollstaendig neu schreibt und vorher den Tunnel von der HOSTSEITE aus nachprueft, ist das dieselbe Arbeit wie beim ersten Mal, nicht eine zweite Umsetzung davon. - EIGENE Pipeline und nicht `host`, und das ist kein Ordnungssinn: RunRunner::failRun() loest den Subjekt-Haken nur aus, wenn der gescheiterte Lauf DER Lauf des Subjekts ist. Unter `host` wuerde ein gescheitertes Nachziehen einen laufenden, bezahlten Host auf 'error' stellen. Dafuer gibt es einen eigenen Test. - php artisan clupilot:refresh-host-firewall, mit --dry-run und --host=, und mit derselben "der Grund ist der Bericht"-Ausgabe wie beim Vorbild: "12 uebersprungen" und sonst nichts ist keine Auskunft, mit der jemand etwas anfangen kann. Kein Zeitplan, aus den drei Gruenden, die schon ueber clupilot:apply-quotas stehen: das Loch ist endlich und schliesst sich endgueltig, ein naechtlicher Lauf waere eine zweite Instanz, die dieselbe Datei auf laufende Maschinen schreibt und am Tag eines still kaputten Pipeline-Schritts fuer ihn einspraenge, und eine Reparatur, die der Betreiber anstoesst, ist eine, deren Ausgabe er liest. docs/runbooks/tunnel-recovery.md ist richtiggestellt. Dort stand, man solle nach dem Notfallskript "den Schritt SecureHostFirewall erneut laufen lassen" — jetzt steht dort der Befehl, mit der Warnung darunter, warum der alte Rat nicht trug. Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann. Co-Authored-By: Claude Opus 5 --- app/Console/Commands/RefreshHostFirewall.php | 149 ++++++++++++++++++ config/provisioning.php | 28 ++++ docs/runbooks/tunnel-recovery.md | 27 +++- .../Console/RefreshHostFirewallTest.php | 140 ++++++++++++++++ 4 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/RefreshHostFirewall.php create mode 100644 tests/Feature/Console/RefreshHostFirewallTest.php diff --git a/app/Console/Commands/RefreshHostFirewall.php b/app/Console/Commands/RefreshHostFirewall.php new file mode 100644 index 0000000..63e68f0 --- /dev/null +++ b/app/Console/Commands/RefreshHostFirewall.php @@ -0,0 +1,149 @@ +option('dry-run'); + $started = 0; + + /** @var array Grund => Anzahl */ + $skipped = []; + + $query = Host::query(); + + if ($name = $this->option('host')) { + $query->where(fn ($q) => $q->where('name', $name)->orWhere('public_ip', $name)); + } + + foreach ($query->cursor() as $host) { + $reason = $this->reasonToSkip($host); + + if ($reason !== null) { + $skipped[$reason] = ($skipped[$reason] ?? 0) + 1; + + continue; + } + + $started++; + $this->line(($dryRun ? '[Probelauf] ' : '')."{$host->name} ({$host->public_ip}) über {$host->wg_ip}"); + + if (! $dryRun) { + $this->startRun($host); + } + } + + foreach ($skipped as $reason => $count) { + $this->line("übersprungen ({$reason}): {$count}"); + } + + $this->info($dryRun + ? "Probelauf: {$started} Host(s) bekämen ihr Regelwerk neu, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.' + : "{$started} Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.'); + + return self::SUCCESS; + } + + /** + * Warum dieser Host in Ruhe gelassen wird, oder null. + * + * Der Grund IST der Bericht: „12 übersprungen" und sonst nichts ist keine + * Auskunft, mit der jemand etwas anfangen kann — „12 sind noch in der + * Übernahme" und „12 haben keinen Tunnel" sehen von hier aus gleich aus. + */ + private function reasonToSkip(Host $host): ?string + { + // Der Schritt verbindet sich über die WireGuard-Adresse und prüft den + // Tunnel von der Hostseite. Ohne beides gibt es nichts nachzuziehen. + if (blank($host->wg_ip) || blank($host->ssh_host_key)) { + return 'kein Tunnel, kein gepinnter Schlüssel'; + } + + // Ein Host mitten in der Übernahme bekommt sein Regelwerk ohnehin + // gleich — und ein zweiter Lauf daneben schriebe dieselbe Datei. + if (! in_array($host->status, ['active', 'disabled'], true)) { + return 'nicht übernommen (Status '.$host->status.')'; + } + + if ($this->hasRunInFlight($host)) { + return 'anderer Lauf aktiv'; + } + + return null; + } + + private function startRun(Host $host): void + { + $run = ProvisioningRun::create([ + 'subject_type' => Host::class, + 'subject_id' => $host->id, + 'pipeline' => 'host-firewall', + 'status' => ProvisioningRun::STATUS_PENDING, + 'current_step' => 0, + 'context' => [], + ]); + + AdvanceRunJob::dispatch($run->uuid); + } + + /** + * JEDER Lauf gegen diesen Host, absichtlich weit gefasst: das hier ist ein + * Reparatur-Durchgang. Er läuft wieder, und ein heute übergangener Host ist + * beim nächsten Aufruf dran. Nichts geht durch Warten verloren. + */ + private function hasRunInFlight(Host $host): bool + { + return ProvisioningRun::query() + ->where('subject_type', Host::class) + ->where('subject_id', $host->id) + ->inFlight() + ->exists(); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index b19b9e0..5185797 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -213,6 +213,34 @@ return [ Customer\ApplyStorageQuota::class, ], + /* + | Das Regelwerk eines LAUFENDEN Hosts, und sonst nichts. + | + | Ein Host, der vor den Sperrmengen übernommen wurde, trägt ein + | /etc/nftables.conf ohne `clupilot_blocked`. Dort scheitert jedes + | `nft add element` — die Sperre steht in Datenbank, Portal, Konsole und + | in der Mail an den Kunden, in der Firewall aber nie. Der + | Onboarding-Lauf hilft nicht: `SecureHostFirewall` kürzt sich über den + | `host_firewall`-Brotkrumen ab, und der hängt am LAUF, nicht am Host. + | + | Ein frischer Lauf hat den Brotkrumen nicht, führt den Schritt also + | wirklich aus — und weil der Schritt die Datei ohnehin vollständig neu + | schreibt und vorher den Tunnel von der Hostseite aus nachprüft, ist + | das dieselbe Arbeit wie beim ersten Mal, nicht eine zweite Umsetzung + | davon. + | + | Eigene Pipeline und nicht 'host', damit RunRunner::failRun() den + | Subjekt-Haken NICHT auslöst: ein gescheiterter Nachzieh-Lauf darf + | einen laufenden Host nicht auf 'error' stellen. Gestartet von + | `php artisan clupilot:refresh-host-firewall`, nach dem Muster von + | `clupilot:apply-quotas` — eine Reparatur, die der Betreiber anstößt + | und deren Ausgabe er lesen kann, nicht ein Zeitplan, der still für + | einen kaputten Schritt einspringt. + */ + 'host-firewall' => [ + Host\SecureHostFirewall::class, + ], + /* | A storage pack bought, or given back. | diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index ae0b748..b972b83 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -51,9 +51,30 @@ Tunnel nicht, ist SSH aus dem Internet **zu** — das ist Absicht und genau der Grund, warum es dieses Skript gibt. Aufgerufen wird es über die Konsole des Anbieters (Weg 2), nicht per SSH; per SSH käme man ja gerade nicht hin. -Danach ist der Host wieder aus dem Internet erreichbar. **Nach der Reparatur den -Schritt `SecureHostFirewall` erneut laufen lassen**, sonst bleibt die Maschine -offen. +Danach ist der Host wieder aus dem Internet erreichbar — und bleibt es, bis +jemand die Regeln zurückschreibt. **Nach der Reparatur, vom CluPilot-Server +aus:** + +```bash +cd /opt/clupilot +sudo -u clupilot docker compose exec -T app php artisan clupilot:refresh-host-firewall --host= +``` + +Ohne `--host` nimmt der Befehl jeden übernommenen Host; `--dry-run` sagt zuerst, +welche das wären. Er startet je Host einen `host-firewall`-Lauf, der denselben +Schritt `SecureHostFirewall` fährt wie die Übernahme — der Lauf ist also in der +Konsole zu sehen, wird wiederholt und prüft vorher von der Hostseite aus nach, +dass der Tunnel steht. + +> **Nicht** den ursprünglichen Übernahme-Lauf erneut anstoßen. `SecureHostFirewall` +> kürzt sich über den `host_firewall`-Brotkrumen ab, und der hängt am LAUF: auf +> einem bereits übernommenen Host würde der Schritt gar nichts tun und trotzdem +> „erledigt" melden. Genau darum gibt es den Befehl oben. + +Derselbe Befehl ist auch der Weg, einem Host, der vor dem Frühwarnsystem +übernommen wurde, die beiden Sperrmengen (`clupilot_blocked`, +`clupilot_blocked6`) nachzureichen. Ohne sie scheitert jede Sperre still auf der +Maschine, während sie im Portal als aktiv steht. ### 4. Eine WireGuard-Konfiguration, die offline liegt diff --git a/tests/Feature/Console/RefreshHostFirewallTest.php b/tests/Feature/Console/RefreshHostFirewallTest.php new file mode 100644 index 0000000..4f025cd --- /dev/null +++ b/tests/Feature/Console/RefreshHostFirewallTest.php @@ -0,0 +1,140 @@ + Queue::fake()); + +/** + * Der Nachzieh-Griff für Hosts, die vor dem Frühwarnsystem übernommen wurden. + * + * Ihr /etc/nftables.conf kennt `clupilot_blocked` nicht, dort scheitert jedes + * `nft add element` — und die Sperre steht in Datenbank, Portal, Konsole und in + * der Mail an den Kunden als aktiv, in der Firewall aber nie. Der + * Übernahme-Lauf hilft nicht: `SecureHostFirewall` kürzt sich über den + * `host_firewall`-Brotkrumen ab, und der hängt am LAUF, nicht am Host — genau + * das behauptete das Runbook bis zu dieser Fix-Welle fälschlich. + */ +function uebernommenerHost(array $attributes = []): Host +{ + return Host::factory()->active()->create(array_merge([ + 'node' => 'pve', + 'ssh_host_key' => 'SHA256:abc', + ], $attributes)); +} + +it('startet je uebernommenem Host einen host-firewall-Lauf', function () { + $host = uebernommenerHost(); + + $this->artisan('clupilot:refresh-host-firewall')->assertSuccessful(); + + $run = ProvisioningRun::query()->where('subject_id', $host->id)->firstOrFail(); + + expect($run->pipeline)->toBe('host-firewall') + ->and($run->subject_type)->toBe(Host::class) + ->and($run->current_step)->toBe(0); +}); + +it('schreibt das Regelwerk MIT den Sperrmengen, obwohl der Host laengst uebernommen ist', function () { + // Der eigentliche Punkt: der Brotkrumen des ALTEN Laufs darf den neuen + // nicht abkuerzen. Ohne den eigenen Lauf taete der Schritt nichts und + // meldete trotzdem „erledigt". + $shell = new FakeRemoteShell; + app()->instance(RemoteShell::class, $shell); + + $host = uebernommenerHost(); + + // Der Brotkrumen der Uebernahme, an ihrem eigenen Lauf. + $alt = ProvisioningRun::factory()->forHost($host)->create([ + 'status' => ProvisioningRun::STATUS_COMPLETED, + ]); + $alt->resources()->create([ + 'host_id' => $host->id, + 'kind' => 'host_firewall', + 'external_id' => 'nftables', + ]); + + $this->artisan('clupilot:refresh-host-firewall')->assertSuccessful(); + + $neu = ProvisioningRun::query()->where('pipeline', 'host-firewall')->firstOrFail(); + app(RunRunner::class)->advance($neu); + + $regelwerk = $shell->files()['/etc/nftables.conf'] ?? ''; + + expect($regelwerk)->toContain('clupilot_blocked') + ->and($neu->fresh()->status)->toBe(ProvisioningRun::STATUS_COMPLETED); +}); + +it('laesst einen Host in Ruhe, der keinen Tunnel hat', function () { + // Der Schritt verbindet ueber die WireGuard-Adresse und prueft den Tunnel + // von der Hostseite. Ohne beides gibt es nichts nachzuziehen. + Host::factory()->create(['status' => 'pending']); + + $this->artisan('clupilot:refresh-host-firewall')->assertSuccessful(); + + expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->count())->toBe(0); +}); + +it('faengt einen Host nicht zweimal an, solange ein Lauf offen ist', function () { + $host = uebernommenerHost(); + ProvisioningRun::factory()->forHost($host)->create([ + 'status' => ProvisioningRun::STATUS_RUNNING, + ]); + + $this->artisan('clupilot:refresh-host-firewall')->assertSuccessful(); + + expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->count())->toBe(0); +}); + +it('aendert unter --dry-run gar nichts', function () { + uebernommenerHost(); + + $this->artisan('clupilot:refresh-host-firewall', ['--dry-run' => true])->assertSuccessful(); + + expect(ProvisioningRun::count())->toBe(0); +}); + +it('nimmt mit --host genau einen', function () { + $gemeint = uebernommenerHost(['name' => 'pve-fsn-07']); + uebernommenerHost(['name' => 'pve-fsn-08']); + + $this->artisan('clupilot:refresh-host-firewall', ['--host' => 'pve-fsn-07'])->assertSuccessful(); + + expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->pluck('subject_id')->all()) + ->toBe([$gemeint->id]); +}); + +it('stellt einen laufenden Host NICHT auf error, wenn der Nachzieh-Lauf scheitert', function () { + // Deshalb eine eigene Pipeline und nicht 'host': RunRunner::failRun() + // loest den Subjekt-Haken nur aus, wenn der gescheiterte Lauf DER Lauf des + // Subjekts ist. Ein Nachziehen darf einen bezahlten, laufenden Host nicht + // als kaputt markieren. + $shell = new FakeRemoteShell; + $shell->failConnect = true; + app()->instance(RemoteShell::class, $shell); + + $host = uebernommenerHost(); + + $this->artisan('clupilot:refresh-host-firewall')->assertSuccessful(); + + $run = ProvisioningRun::query()->where('pipeline', 'host-firewall')->firstOrFail(); + + // Bis zum Ende der Versuche fahren, statt eine Zahl zu raten: was hier + // zaehlt, ist der Zustand des HOSTS, nachdem der Lauf endgueltig + // gescheitert ist. + for ($i = 0; $i < 20 && ! in_array($run->fresh()->status, [ProvisioningRun::STATUS_FAILED, ProvisioningRun::STATUS_COMPLETED], true); $i++) { + $run->forceFill(['next_attempt_at' => null])->save(); + app(RunRunner::class)->advance($run); + } + + expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_FAILED) + ->and($host->fresh()->status)->toBe('active'); +});