fix(firewall): skip the host firewall on Proxmox

Proxmox VE and Proxmox Mail Gateway are Debian, so os_detect classified them as
debian and harden-firewall.sh installed the iptables backend on top of
pve-firewall.

The conflict is not the obvious one. pve-firewall does NOT delete third-party
rules: it restores with `iptables-restore -n` (--noflush), only ever flushes
chains matching its own patterns (PVEFW-*, tapNiM-*, vethNiM-*, fwbrN-*,
GROUP-*), appends `-A INPUT -j PVEFW-INPUT` only when that hook is missing, and
never sets a built-in chain's policy. Our rules survive it. We are the ones
doing damage:

- `-P INPUT DROP` is ours alone, and PVEFW-HOST-IN RETURNs on accept rather than
  ACCEPTing (it still has to check tap rules), so traffic Proxmox explicitly
  allowed falls out of its chain onto our DROP -- we silently override the
  platform's own accepts.
- netfilter-persistent restores at boot with a full iptables-restore (no
  --noflush), wiping PVE's hook along with everything else until the daemon
  re-appends it ~10s later.
- a deny-by-default chain has to enumerate the whole platform to stay usable:
  8006, 5405-5412/udp corosync, 60000-60050, 5900-5999, 3128, 22, 111/udp, plus
  Ceph when hyperconverged -- and `-i lo`, or pveproxy loses pvedaemon on :85.
- under the nftables backend (PVE 8.2+) an nft DROP beats an iptables ACCEPT, so
  our rules would not even be authoritative.

So don't manage a firewall there at all:

- oslib: is_proxmox() -- matches hosts shipping pve-firewall (VE/PMG), not PBS.
- harden-firewall.sh: a third backend, "pve", that deliberately does nothing.
  apply explains and exits 0, allow/deny refuse loudly rather than fake success
  for a rule they didn't add, list shows pve-firewall status. It overrides an
  explicit FW_BACKEND; FW_IGNORE_PVE=1 is the one escape hatch.
- harden-ssh.sh / harden-jumphost.sh: skip the firewall and install the
  standalone INPUT -> sshguard boot hook instead. That jump is safe alongside
  pve-firewall -- inserted with -I, it sits ahead of the appended PVEFW-INPUT
  hook and keeps first look at NEW connections.

Detection only helps hosts built from here on, so `disable` cleans up one that
was hardened earlier: it detects leftovers (and says which signal fired), sets
INPUT ACCEPT *before* flushing so it can't drop the SSH session it runs over,
deletes /etc/firewall and the engine, disables boot restore and renames the
saved rulesets aside rather than persisting the open state, re-adds the sshguard
jump, and restarts pve-firewall. `apply` points at it when it spots leftovers.

Documented plainly that skipped is NOT protected: Proxmox's firewall is off by
default (cluster-wide enable defaults to 0, and the daemon tears its chains down
every ~10s while it is), so these hosts have no host firewall until someone
enables it -- and the node panel's "Firewall: Yes" is ignored while the
datacenter one says No.

Also: svc_disable + fw_restore_services/fw_saved_files in oslib
(fw_enable_restore now derives from the former), and usage() prints the whole
header block instead of a hardcoded line range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 18:51:50 -05:00
co-authored by Claude Opus 5
parent 752385cb4c
commit 339c62a1b0
7 changed files with 342 additions and 11 deletions
+48 -6
View File
@@ -65,6 +65,22 @@ os_detect() {
_require_detected() { [[ -n "${OS_FAMILY:-}" ]] || os_detect; }
# True on a Proxmox host that ships pve-firewall -- Proxmox VE and Proxmox Mail
# Gateway. Both are Debian underneath, so os_detect reports debian/systemd and
# every other helper here is correct for them; the ONE thing that is not is the
# firewall. pve-firewall owns the host ruleset, and while it leaves foreign rules
# alone (it restores with --noflush and touches only its own PVEFW-* chains), a
# deny-by-default INPUT chain underneath it overrides the accepts it RETURNs on
# and cuts the web UI, corosync and migration traffic -- and our boot-time
# iptables-restore would wipe its hook outright. harden-firewall.sh therefore
# skips these hosts -- see the Proxmox backend there for the full reasoning.
#
# Proxmox Backup Server ships no firewall of its own and is deliberately NOT
# matched: it is a plain Debian host as far as we are concerned.
is_proxmox() {
[[ -x /usr/sbin/pve-firewall || -x /usr/bin/pveversion || -d /etc/pve/nodes ]]
}
# ============================================================================
# Packages
# ============================================================================
@@ -136,6 +152,14 @@ svc_enable() { # enable at boot
esac
}
svc_disable() { # stop it being started at boot (the mirror of svc_enable)
_require_detected
case "$INIT_SYSTEM" in
openrc) rc-update del "$1" default >/dev/null 2>&1 || true ;;
systemd) systemctl disable "$1" >/dev/null 2>&1 || true ;;
esac
}
svc_start() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && rc-service "$1" start || systemctl start "$1"; }
svc_restart() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && { rc-service "$1" restart || rc-service "$1" start; } || systemctl restart "$1"; }
svc_reload() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && { rc-service "$1" reload || rc-service "$1" restart; } || { systemctl reload "$1" || systemctl restart "$1"; }; }
@@ -543,16 +567,34 @@ fw_save_cmd() {
esac
}
# The service(s) that restore the saved ruleset at boot, and the file(s) they
# restore from -- the inverse of fw_save_cmd. Undoing the firewall on a host that
# should never have had it means disabling the former and clearing the latter
# (harden-firewall.sh's Proxmox `disable`), so name them once, here.
fw_restore_services() {
_require_detected
case "$OS_FAMILY" in
alpine) echo "iptables ip6tables" ;;
debian) echo "netfilter-persistent" ;;
rhel) echo "iptables ip6tables" ;;
esac
}
fw_saved_files() {
_require_detected
case "$OS_FAMILY" in
alpine) echo "/etc/iptables/rules-save /etc/iptables/rules6-save" ;;
debian) echo "/etc/iptables/rules.v4 /etc/iptables/rules.v6" ;;
rhel) echo "/etc/sysconfig/iptables /etc/sysconfig/ip6tables" ;;
esac
}
# Enable the family's native boot-time restore service(s). Rules are already
# live when this runs, so we only need them re-applied on the NEXT boot --
# enable, don't start.
fw_enable_restore() {
_require_detected
case "$OS_FAMILY" in
alpine) svc_enable iptables; svc_enable ip6tables ;;
debian) svc_enable netfilter-persistent ;;
rhel) svc_enable iptables; svc_enable ip6tables ;;
esac
local s
for s in $(fw_restore_services); do svc_enable "$s"; done
}
# Install + enable firewalld (the native RHEL/Alma firewall). harden-firewall.sh