<?php

namespace App\Provisioning\Steps\Host;

use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Ssh\RemoteShell;

/**
 * Locks the host down to only what it needs to expose: 80/443 open to the
 * world (Traefik on this host serves customer traffic there), 22 and 8006
 * (Proxmox UI/API) reachable only from the WireGuard management subnet,
 * everything else inbound dropped.
 *
 * Runs LAST in the host pipeline — after RegisterCapacity, before
 * CompleteHostOnboarding — so the tunnel has already carried every prior step
 * through a reboot before SSH-to-the-world is ever closed. Closing 22 earlier
 * would risk stranding a host mid-onboarding if the tunnel turned out not to
 * be solid.
 *
 * Refuses to apply anything unless the tunnel is confirmed up from the HOST'S
 * OWN side first (a ping to the WireGuard hub, run over the already-open SSH
 * session — the same check ConfigureWireguard uses to confirm its handshake).
 * Applying firewall rules on a host that cannot prove its tunnel works is
 * exactly how a host becomes permanently unreachable, so a failed check
 * retries and touches nothing.
 *
 * Persisted via nftables: the step installs the package, writes
 * /etc/nftables.conf, and enables the `nftables` systemd unit, which reloads
 * that file on every boot — including a later maintenance reboot (this step
 * runs after RebootIntoPveKernel, so from here on a reboot is a maintenance
 * one). Chosen over Proxmox's own firewall (pve-firewall needs the /etc/pve
 * cluster filesystem plus a separate datacenter+node enable flag — two more
 * places this could silently stay off) and over iptables-persistent (nftables
 * is Debian's default packet-filtering framework and already ships the
 * boot-time unit the package needs). Only the INPUT chain is touched — FORWARD
 * and OUTPUT are left at the kernel default so customer VM traffic through
 * the bridges is never affected by a host-management rule.
 *
 * This ruleset is the ONLY owner of the host's input policy. ConfigureProxmox
 * enables Proxmox's firewall at datacenter level — guest rules do nothing
 * without it — but explicitly turns the NODE firewall off for exactly this
 * reason: with both active a packet must be accepted by both, and pve-firewall's
 * auto-generated management ipset is seeded from the public subnet, which would
 * silently overrule the WireGuard-only rule below. See that step's docblock.
 *
 * Idempotent: once applied, the resource breadcrumb short-circuits a re-run
 * exactly like the other steps' external-resource guards.
 *
 * Deliberately has NO automatic recovery. If a host becomes unreachable, an
 * operator drops the rules by running
 * /usr/local/sbin/clupilot-emergency-open-firewall.sh from the provider's
 * out-of-band console — no timer, no "reopen after N minutes without a
 * handshake". A firewall that reopens itself under failure is not a firewall.
 */
class SecureHostFirewall extends HostStep
{
    private const EMERGENCY_SCRIPT_PATH = '/usr/local/sbin/clupilot-emergency-open-firewall.sh';

    public function __construct(private RemoteShell $shell) {}

    public function key(): string
    {
        return 'secure_host_firewall';
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $host = $this->host($run);
        $this->keyLogin($this->shell, $host);

        // Idempotent replay: already applied and recorded.
        if ($this->hasResource($run, 'host_firewall')) {
            return StepResult::advance();
        }

        // Verify FIRST, from the host's own side, and touch nothing if it fails.
        if (! $this->tunnelConfirmedUp()) {
            return StepResult::retry(15, 'WireGuard tunnel not confirmed up; refusing to apply the host firewall');
        }

        $wgSubnet = (string) config('provisioning.wireguard.subnet');

        if (! $this->shell->run('export DEBIAN_FRONTEND=noninteractive; apt-get install -y nftables')->ok()) {
            return StepResult::retry(30, 'installing nftables failed');
        }

        $this->shell->putFile('/etc/nftables.conf', $this->renderNftablesConfig($wgSubnet));
        $this->shell->putFile(self::EMERGENCY_SCRIPT_PATH, $this->renderEmergencyScript());
        $this->shell->run('chmod 700 '.escapeshellarg(self::EMERGENCY_SCRIPT_PATH));

        if (! $this->shell->run('nft -c -f /etc/nftables.conf')->ok()) {
            return StepResult::retry(30, 'nftables ruleset failed to validate');
        }

        if (! $this->shell->run('systemctl enable --now nftables')->ok()) {
            return StepResult::retry(30, 'enabling nftables failed');
        }
        $this->shell->run('systemctl reload-or-restart nftables || nft -f /etc/nftables.conf');

        $this->recordResource($run, $host, 'host_firewall', 'nftables');

        return StepResult::advance();
    }

    /** Same mechanism ConfigureWireguard::verifyHandshake() uses to confirm the tunnel. */
    private function tunnelConfirmedUp(): bool
    {
        $hubIp = (string) config('provisioning.wireguard.hub_ip');

        return $this->shell->run('ping -c1 -W2 '.escapeshellarg($hubIp))->ok();
    }

    private function renderNftablesConfig(string $wgSubnet): string
    {
        return <<<NFT
#!/usr/sbin/nft -f
# Written by CluPilot's SecureHostFirewall provisioning step.
# Do not edit by hand — the next run of that step overwrites this file.
# To fully re-open the host in an emergency, run
# {$this->emergencyScriptPath()} from the provider's out-of-band console.

flush ruleset

table inet clupilot_filter {
    # Adressen, die gerade gesperrt sind. `flags timeout` ist nicht schmückend:
    # ohne das nimmt nftables beim Eintragen gar keine Zeitangabe an — und die
    # Frist liefe dann nur in unserer Datenbank ab, nicht im Kernel.
    set clupilot_blocked {
        type ipv4_addr
        flags timeout
    }

    set clupilot_blocked6 {
        type ipv6_addr
        flags timeout
    }

    chain input {
        type filter hook input priority 0; policy drop;

        iif "lo" accept
        ct state invalid drop
        ct state established,related accept

        # UNTER der Zeile darüber, und das ist die ganze Zusage dieses Systems:
        # wer schon verbunden ist, bleibt verbunden. Gesperrt wird nur, was neu
        # anklopft. Stünde diese Regel eine Zeile höher, flöge jeder mitten aus
        # seiner Sitzung — auch der, den es gar nicht meint.
        ip  saddr @clupilot_blocked  drop
        ip6 saddr @clupilot_blocked6 drop

        # ICMPv6 is not optional. Neighbour discovery (135/136) and router
        # advertisements arrive in the INPUT chain, so a bare policy drop takes
        # IPv6 down on this host as soon as the neighbour cache expires — minutes,
        # not days. packet-too-big is the other half: it is how the far end of a
        # TLS connection learns the path MTU, and dropping it black-holes large
        # transfers rather than failing them. Debian's own example ruleset accepts
        # all of ipv6-icmp; the types are listed instead so the intent is readable
        # and so the legacy information-leak requests stay out. One rule per line,
        # deliberately: a ruleset that can be read a line at a time can also be
        # asserted on a line at a time.
        meta nfproto ipv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query, mld-listener-report, mld-listener-done } accept

        # IPv4 ICMP, for the same reason: destination-unreachable carries
        # fragmentation-needed (code 4), which is what IPv4 path-MTU discovery
        # runs on. echo is here so the host answers a ping — including the hub's.
        meta nfproto ipv4 icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request, echo-reply } accept

        # Public web — Traefik on this host serves customer traffic here.
        tcp dport { 80, 443 } accept

        # DHCP client replies. Some providers hand out the IPv4 address by DHCP,
        # and a lease REBIND arrives as a fresh inbound packet that conntrack has
        # no established entry for — dropped, the lease expires and the host loses
        # the address it is reached on. Narrow on purpose: only a reply from a
        # server port to the client port. IPv6 needs no equivalent here because
        # addressing on these hosts comes from router advertisements, which the
        # nd-router-advert accept above already covers.
        udp sport 67 udp dport 68 accept

        # Management surfaces: only reachable over the WireGuard tunnel.
        ip saddr {$wgSubnet} tcp dport { 22, 8006 } accept
    }
}
NFT;
    }

    private function renderEmergencyScript(): string
    {
        return <<<'SH'
#!/bin/sh
# CluPilot emergency firewall release.
#
# Run this ONLY from the provider's out-of-band console (KVM/serial) if this
# host has become unreachable over the network. It does exactly one thing:
# disable nftables and flush every rule, so the host reverts to the kernel's
# default-accept state on every port.
#
# Deliberately manual. Nothing here reopens the firewall automatically, on a
# timer, or because a handshake looked stale for a while — a firewall that
# reopens itself under failure is not a firewall. Put the rules back by
# re-running the SecureHostFirewall provisioning step from CluPilot, or by
# hand:
#   nft -f /etc/nftables.conf && systemctl enable --now nftables

set -e

systemctl disable --now nftables 2>/dev/null || true
nft flush ruleset 2>/dev/null || true

echo "Firewall rules dropped: this host now accepts inbound traffic on every port."
SH;
    }

    private function emergencyScriptPath(): string
    {
        return self::EMERGENCY_SCRIPT_PATH;
    }
}
