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 # 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; } }