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