<?php

namespace App\Services\Security;

use App\Models\Host;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use RuntimeException;
use Throwable;

/**
 * Trägt eine Adresse in die Sperrmenge eines Hosts ein oder nimmt sie wieder
 * heraus — die beiden nftables-Mengen `clupilot_blocked` (IPv4) und
 * `clupilot_blocked6` (IPv6), die SecureHostFirewall im Regelwerk anlegt.
 * `flags timeout` an beiden Mengen (siehe dort) heißt: die Ablaufzeit geben
 * wir hier mit, und der Kernel selbst nimmt die Adresse wieder heraus — kein
 * eigener Aufräum-Lauf nötig für den Normalfall.
 *
 * Wer eine Sperre AUSLÖST, gehört nicht hierher — das hier ist nur der Griff:
 * eintragen, herausnehmen.
 *
 * Baut die Verbindung direkt über die WireGuard-Adresse des Hosts auf, nicht
 * über HostStep::keyLogin()s Rückfallpfad auf die öffentliche Adresse: diese
 * Klasse läuft ausschließlich gegen bereits aktive Hosts, deren Tunnel längst
 * bewiesen ist, nie während der Inbetriebnahme selbst.
 *
 * block() und release() werfen absichtlich NICHT, wenn der Host nicht
 * erreichbar ist — sie geben `false` zurück. `BlockAddress` legt den
 * Sperrdatensatz trotzdem an und `ScanForIntrusions` trägt ihn beim nächsten
 * Lauf erneut ein (Wiedereintragen). Eine Sperre, die nur in der Datenbank
 * steht, ist sichtbar und wird nachgeholt; eine Ausnahme hier würde
 * stattdessen den ganzen Zeitplan-Auftrag mitreißen, der diese Methode
 * aufruft.
 *
 * `false` ist deshalb kein Grund zum Werfen, aber sehr wohl einer zum
 * MELDEN: auf einem Host, der sein Regelwerk noch ohne die Mengen trägt,
 * scheitert `nft add element` bei JEDEM Versuch, und beide Aufrufer verwerfen
 * den Rückgabewert. Die Sperre stünde dann in Datenbank, Portal, Konsole und
 * in der Mail an den Kunden als aktiv und in der Firewall nie. Darum meldet
 * `apply()` jeden Misserfolg selbst — an der einen Stelle, an der er anfällt,
 * statt an jedem Aufrufer, der ihn vergessen kann. Nachziehen lässt sich so
 * ein Host mit `php artisan clupilot:refresh-host-firewall`.
 *
 * Jede Adresse geht vor dem `sprintf` durch `filter_var(…, FILTER_VALIDATE_IP)`.
 * Das ist kein doppelter Boden für einen ohnehin sauberen Aufrufer, sondern
 * die Bedingung dafür, dass dieser Dienst überhaupt eine Wurzel-Shell füttern
 * darf: `PhpseclibRemoteShell::run()` reicht die zusammengebaute Zeile an
 * `/bin/sh` als root auf dem Proxmox-Host, und ein Semikolon in der Adresse
 * wäre dort ein zweiter Befehl. Der Leser prüft dasselbe (FailedLoginReader);
 * ein Dienst, der eine Wurzel-Shell füttert, darf sich nicht darauf verlassen,
 * dass sein Aufrufer sauber war.
 */
class HostFirewall
{
    public function __construct(private RemoteShell $shell) {}

    public function block(Host $host, string $ip, int $seconds): bool
    {
        return $this->blockMany($host, [$ip => $seconds]);
    }

    /**
     * Mehrere Adressen desselben Hosts in EINER SSH-Sitzung.
     *
     * `ScanForIntrusions::reapplyActiveBlocks()` trug bisher je aktiver Sperre
     * einzeln ein, und jede Eintragung baute ihre eigene Verbindung auf: bei
     * dreissig Sperren dreissig Handshakes pro Minute, seriell, auf dem
     * einzigen Provisionierungs-Arbeiter — also genau WÄHREND eines Angriffs
     * auf demselben Arbeiter, der die bezahlte Kundenbereitstellung fährt.
     *
     * `nft` nimmt mehrere Elemente in einem Aufruf: `{ a timeout 60s, b
     * timeout 90s }`. Je Adressfamilie einer, weil v4 und v6 in getrennten
     * Mengen leben — also höchstens zwei Befehle, eine Verbindung.
     *
     * Jede Adresse behält ihre EIGENE Restlaufzeit. Eine gemeinsame Dauer für
     * das ganze Bündel wäre bequemer und würde genau die Zusicherung
     * verwässern, für die es einen eigenen Test gibt.
     *
     * @param  array<string, int>  $seconds  Adresse => Restlaufzeit in Sekunden
     */
    public function blockMany(Host $host, array $seconds): bool
    {
        /** @var array<string, array<int, string>> */
        $elements = [];

        foreach ($seconds as $ip => $duration) {
            $ip = (string) $ip;

            if (! $this->isWellFormed($host, $ip)) {
                return false;
            }

            $elements[$this->setFor($ip)][] = sprintf('%s timeout %ds', $ip, (int) $duration);
        }

        if ($elements === []) {
            return true;
        }

        $commands = [];

        foreach ($elements as $set => $entries) {
            $commands[] = sprintf(
                'nft add element inet clupilot_filter %s { %s }',
                $set,
                implode(', ', $entries),
            );
        }

        return $this->apply($host, $commands, implode(', ', array_keys($seconds)));
    }

    public function release(Host $host, string $ip): bool
    {
        return $this->releaseMany($host, [$ip]);
    }

    /**
     * Mehrere Adressen desselben Hosts wieder heraus, in EINER SSH-Sitzung —
     * das Gegenstück zu blockMany(), aus demselben Grund.
     *
     * `|| true` hinter jedem Befehl ist hier kein Wegsehen, sondern die
     * richtige Bedeutung: `nft delete element` scheitert, wenn das Element
     * schon weg ist — und weg ist genau das Ziel. Der Kernel nimmt abgelaufene
     * Einträge selbst heraus (`flags timeout`), und eine Sperre, die wegen
     * eines nicht erreichbaren Hosts nie eingetragen wurde, kann man auch
     * nicht löschen. Ohne dieses `|| true` meldete der Abgleich unten bei
     * JEDEM Lauf einen Fehlschlag, und der Mensch am Bildschirm bekäme
     * „Server nicht erreichbar" zu lesen, während in Wahrheit alles stimmt.
     *
     * Was bleibt, ist die Aussage, um die es geht: `false` heißt, der Host war
     * nicht erreichbar — das wirft `connectWithKey()`, und `apply()` fängt es.
     *
     * @param  array<int, string>  $ips
     */
    public function releaseMany(Host $host, array $ips): bool
    {
        /** @var array<string, array<int, string>> */
        $elements = [];

        foreach ($ips as $ip) {
            if (! $this->isWellFormed($host, $ip)) {
                return false;
            }

            $elements[$this->setFor($ip)][] = $ip;
        }

        if ($elements === []) {
            return true;
        }

        $commands = [];

        foreach ($elements as $set => $entries) {
            $commands[] = sprintf(
                'nft delete element inet clupilot_filter %s { %s } 2>/dev/null || true',
                $set,
                implode(', ', $entries),
            );
        }

        return $this->apply($host, $commands, implode(', ', $ips));
    }

    /**
     * Wohlgeformt genug, um in eine Wurzel-Shell zu wandern?
     *
     * Meldet und verweigert statt still weiterzumachen: eine Adresse, die hier
     * ankommt und keine ist, ist entweder ein Versuch oder ein Fehler weiter
     * oben — beides will man sehen.
     */
    private function isWellFormed(Host $host, string $ip): bool
    {
        if (filter_var($ip, FILTER_VALIDATE_IP) !== false) {
            return true;
        }

        report(new RuntimeException(
            "HostFirewall: abgewiesene Adresse [{$ip}] fuer Host [{$host->name}] — keine gueltige IP, nichts ausgefuehrt."
        ));

        return false;
    }

    /** Die Adressfamilie entscheidet über die Menge — v4 und v6 leben getrennt. */
    private function setFor(string $ip): string
    {
        return str_contains($ip, ':') ? 'clupilot_blocked6' : 'clupilot_blocked';
    }

    /**
     * @param  array<int, string>  $commands  alle in EINER Sitzung
     */
    private function apply(Host $host, array $commands, string $subject): bool
    {
        try {
            $this->shell->connectWithKey(
                $host->wg_ip,
                'root',
                (string) app(SecretVault::class)->get('ssh.private_key'),
                $host->ssh_host_key, // gepinnt bei EstablishSshTrust
            );

            $ok = true;

            foreach ($commands as $command) {
                // Nicht abbrechen beim ersten Fehlschlag: die Verbindung steht
                // ohnehin schon, und v6 soll nicht deshalb ausfallen, weil v4
                // gerade nicht ging.
                $ok = $this->shell->run($command)->ok() && $ok;
            }
        } catch (Throwable) {
            $ok = false;
        }

        if (! $ok) {
            report(new RuntimeException(
                "HostFirewall: [{$subject}] auf Host [{$host->name}] nicht in die Firewall geschrieben "
                .'— der Sperrdatensatz steht, die Regel nicht.'
            ));
        }

        return $ok;
    }
}
