150 lines
5.5 KiB
PHP
150 lines
5.5 KiB
PHP
<?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();
|
|
}
|
|
}
|