Ein Host, der die Sperrmengen nicht kennt, laesst sich jetzt nachziehen

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 17:00:08 +02:00
parent 552be46881
commit 6ebbaa82aa
4 changed files with 341 additions and 3 deletions

View File

@ -0,0 +1,149 @@
<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Console\Command;
/**
* Schreibt einem bereits übernommenen Host sein nftables-Regelwerk neu.
*
* Der Anlass: die beiden Sperrmengen `clupilot_blocked`/`clupilot_blocked6`
* kamen mit dem Frühwarnsystem ins Regelwerk. Jeder Host, der VORHER übernommen
* wurde, trägt 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. `HostFirewall` meldet
* diesen Fall inzwischen (`report()`); DAS hier ist der Griff, mit dem man ihn
* abstellt.
*
* Warum ein Befehl und kein Zeitplan dieselbe Begründung wie bei
* `clupilot:apply-quotas`, an dem sich dieser hier orientiert:
*
* - das Loch ist endlich und schließt sich endgültig. Jeder ab jetzt
* übernommene Host bekommt die Mengen im Onboarding;
* - ein nächtlicher Lauf wäre eine zweite Instanz, die dieselbe Datei auf
* laufende Maschinen schreibt, und würde am Tag, an dem der Schritt in der
* Pipeline still kaputtginge, für ihn einspringen das Versagen käme nie
* ans Licht;
* - eine Reparatur, die der Betreiber anstößt, ist eine, deren Ausgabe er
* liest. Dieser Befehl sagt, was er anfasst und was nicht, und rührt unter
* `--dry-run` nichts an.
*
* Die Arbeit selbst läuft über die `host-firewall`-Pipeline, also über
* denselben `SecureHostFirewall`-Schritt, den auch das Onboarding fährt
* gedrosselt, wiederholt, protokolliert und in der Konsole sichtbar wie jede
* andere Fernarbeit. Ein zweiter Weg, dieselbe Datei zu schreiben, würde
* driften.
*
* Der Schritt prüft von der Hostseite aus selbst nach, dass der Tunnel steht,
* und fasst nichts an, wenn nicht. Ein Host, der gerade nicht erreichbar ist,
* wird also nicht halb umgestellt.
*/
class RefreshHostFirewall extends Command
{
protected $signature = 'clupilot:refresh-host-firewall
{--dry-run : auflisten, was liefe, und nichts anfassen}
{--host= : nur dieser eine Host, per Name oder oeffentlicher Adresse}';
protected $description = 'Schreibt uebernommenen Hosts ihr nftables-Regelwerk neu — inklusive der Sperrmengen';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$started = 0;
/** @var array<string, int> 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();
}
}

View File

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

View File

@ -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=<name-oder-ip>
```
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

View File

@ -0,0 +1,140 @@
<?php
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\RunRunner;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Facades\Queue;
// QUEUE_CONNECTION ist in dieser Suite `sync`: ohne das hier faehrt jedes
// `AdvanceRunJob::dispatch()` den Lauf sofort im Test, gegen eine echte,
// unerreichbare Adresse — zwei Minuten TCP-Zeitueberlauf je Host. Die Laeufe,
// die dieser Test wirklich fahren will, stoesst er unten selbst an.
beforeEach(fn () => 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');
});