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
parent
552be46881
commit
6ebbaa82aa
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue