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 <<emergencyScriptPath()} from the provider's out-of-band console. flush ruleset table inet clupilot_filter { chain input { type filter hook input priority 0; policy drop; iif "lo" accept ct state invalid drop ct state established,related accept # Public web — Traefik on this host serves customer traffic here. tcp dport { 80, 443 } 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; } }