168 lines
6.5 KiB
PHP
168 lines
6.5 KiB
PHP
<?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 bookworm'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.
|
|
*
|
|
* 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 {
|
|
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;
|
|
}
|
|
}
|