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:
@@ -101,7 +101,7 @@ deployments/<name>/ # one folder per stack
|
||||
| [`setup-host.sh`](scripts/setup-host.sh) | Set hostname per the naming schema (derives FQDN + Node ID) and render the shared MOTD with auto-computed border spacing. |
|
||||
| [`harden-ssh.sh`](scripts/harden-ssh.sh) | SSH hardening: post-quantum hybrid KEX, fresh Ed25519 host keys, key-only auth, external SFTP subsystem, sshguard. |
|
||||
| [`harden-jumphost.sh`](scripts/harden-jumphost.sh) | Bastion hardening on top of `harden-ssh`: `ssh-admins` (shell) vs `ssh-jumpers` (ProxyJump-only) with a PermitOpen allow-list. |
|
||||
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default host firewall: **iptables** on Alpine/Debian, **firewalld** on Alma/RHEL (set `FW_BACKEND` to override). Loopback, established, ICMP, SSH (configurable port) + registered ports; persisted natively (no boot hook). Same `allow`/`deny`/`list`/`disable` sub-commands on both. |
|
||||
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default host firewall: **iptables** on Alpine/Debian, **firewalld** on Alma/RHEL (set `FW_BACKEND` to override), **skipped on Proxmox** (`pve-firewall` owns the ruleset). Loopback, established, ICMP, SSH (configurable port) + registered ports; persisted natively (no boot hook). Same `allow`/`deny`/`list`/`disable` sub-commands on both. |
|
||||
| [`sshuser.sh`](scripts/sshuser.sh) | Add/edit/remove SSH users on a hardened jump host (Gum TUI or CLI flags). Installed standalone as `sshuser`. |
|
||||
| [`ntfy-ssh-login.sh`](scripts/ntfy-ssh-login.sh) | `pam_exec` hook that posts SSH logins to ntfy (user, source IP, key used, best-effort jump target), gated by group. Config: [`ssh-notify.conf.example`](scripts/ssh-notify.conf.example). |
|
||||
| [`auto-update.sh`](scripts/auto-update.sh) | Daily unattended package updates; reports (doesn't auto-jump) a new Alpine branch; reboot detection; ntfy summary. `install`/`run`/`uninstall`. |
|
||||
@@ -179,6 +179,8 @@ services with Alpine-specific wiring, so it isn't part of the tri-distro set.
|
||||
deny-by-default; we strip the stock `ssh`/`cockpit` services, open SSH +
|
||||
registered ports, and let sshguard block via the `sshguard-firewalld` backend
|
||||
(no `INPUT → sshguard` jump needed).
|
||||
- **Proxmox → nothing. The host is skipped** — and left with no host firewall
|
||||
until you enable Proxmox's own. See below.
|
||||
|
||||
`OUTPUT`/egress stays open and `FORWARD` is left untouched, so Docker container
|
||||
networking is unaffected. The harden scripts and `cloud-init/base.yml` /
|
||||
@@ -212,6 +214,86 @@ networking is unaffected. The harden scripts and `cloud-init/base.yml` /
|
||||
established-connection accept is added before the policy flips to `DROP`, and
|
||||
firewalld reloads preserve established connections.
|
||||
|
||||
### Proxmox hosts are skipped
|
||||
|
||||
Proxmox VE and Proxmox Mail Gateway are Debian underneath, so everything else in
|
||||
this repo treats them as Debian — but they already ship a firewall, and
|
||||
`pve-firewall` owns the host ruleset. The conflict is not the one you'd expect:
|
||||
`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 would survive it fine. The damage runs the
|
||||
other way — **we break Proxmox**:
|
||||
|
||||
- `-P INPUT DROP` is ours alone, and `PVEFW-HOST-IN` **returns** on accept rather
|
||||
than accepting (it still has to check the tap rules), so traffic Proxmox
|
||||
explicitly allowed falls out of its chain and lands on our `DROP`. We silently
|
||||
override the platform's own accepts;
|
||||
- the persistence layer is worse: `netfilter-persistent` restores at boot with a
|
||||
full `iptables-restore` (no `--noflush`), wiping PVE's `-j PVEFW-INPUT` hook
|
||||
along with everything else until the daemon re-appends it on its next ~10s pass;
|
||||
- a deny-by-default chain has to enumerate the whole platform to stay usable:
|
||||
`8006/tcp` web UI, `5405-5412/udp` corosync (5405 + knet link number, up to 8
|
||||
links — the bare `5405` is the pre-6.x multicast-era number), `60000-60050/tcp`
|
||||
migration, `5900-5999/tcp` VNC, `3128/tcp` SPICE, `22/tcp` SSH, `111/udp`
|
||||
rpcbind with NFS storage, plus Ceph's `6789`/`3300`/`6800-7300` when
|
||||
hyperconverged. Miss one and you lose the GUI or the cluster; miss `-i lo` and
|
||||
`pveproxy` can't reach `pvedaemon` on `127.0.0.1:85`, breaking the API locally;
|
||||
- under the opt-in **nftables** backend (PVE 8.2+), `proxmox-firewall` registers
|
||||
its own nft input hook, and an nft `DROP` beats an iptables `ACCEPT` — our
|
||||
rules wouldn't even be authoritative.
|
||||
|
||||
So `harden-firewall.sh` detects Proxmox (`is_proxmox` in `oslib.sh`: it looks
|
||||
for `pve-firewall`/`pveversion`/`/etc/pve/nodes`) and does nothing — `apply`
|
||||
explains and exits cleanly, `allow`/`deny` refuse loudly rather than pretend,
|
||||
and `list` shows `pve-firewall status`. `harden-ssh.sh` / `harden-jumphost.sh`
|
||||
skip the firewall too and install only the `INPUT → sshguard` boot hook, so
|
||||
brute-force protection still works while the Proxmox firewall is off. Manage the
|
||||
host firewall where Proxmox expects it — *Datacenter → Firewall* and
|
||||
*Node → Firewall*, or the `.fw` files directly:
|
||||
|
||||
```
|
||||
[RULES]
|
||||
IN ACCEPT -p tcp -dport 443
|
||||
```
|
||||
|
||||
`FW_IGNORE_PVE=1` forces our firewall on anyway. It will fight `pve-firewall`
|
||||
and can lock you out of the GUI and the cluster; Proxmox Backup Server ships no
|
||||
firewall of its own and is *not* detected, so it hardens as a normal Debian host.
|
||||
|
||||
> **Skipped is not the same as protected.** Proxmox's firewall is **off by
|
||||
> default** — the cluster-wide `enable` in `cluster.fw` defaults to `0`, and while
|
||||
> it is, the `pve-firewall` daemon actively tears its chains down every ~10 seconds,
|
||||
> leaving `INPUT` at policy `ACCEPT` with no rules. Until you enable it at
|
||||
> *Datacenter → Firewall → Options*, a Proxmox host has **no host firewall at all**
|
||||
> and `8006`, `22`, `3128` and `111` are open on every interface. Don't be reassured
|
||||
> by the *node* panel reading `Firewall: Yes` — that setting is ignored while the
|
||||
> datacenter one reads `No`. On these hosts, do your filtering in Proxmox or upstream
|
||||
> of it.
|
||||
|
||||
The `INPUT → sshguard` jump the harden scripts install *is* safe alongside
|
||||
`pve-firewall`: it is inserted with `-I`, so it sits ahead of the appended
|
||||
`PVEFW-INPUT` hook and keeps getting first look at new connections either way.
|
||||
|
||||
**Already hardened a Proxmox host?** Detection only helps hosts set up from now
|
||||
on, so `disable` cleans up one that already has our firewall:
|
||||
|
||||
```sh
|
||||
bash scripts/harden-firewall.sh disable
|
||||
```
|
||||
|
||||
On Proxmox that sub-command checks for leftovers (`/etc/firewall`, the engine, or
|
||||
a `DROP` policy) and, if it finds them, sets `INPUT` back to `ACCEPT` and flushes
|
||||
it — policy first, so it never drops the SSH session you are running it over —
|
||||
deletes `/etc/firewall` and `/usr/local/sbin/firewall-apply`, **disables** the
|
||||
boot-restore service and renames the saved rulesets to `*.bak-harden-firewall`
|
||||
(saving the open state, as the normal `disable` does, would snapshot
|
||||
`pve-firewall`'s own `PVEFW-*` chains and restore that stale copy at the next
|
||||
boot), re-inserts the `INPUT → sshguard` jump, and restarts `pve-firewall`. On a
|
||||
host that was correctly skipped it finds nothing and says so. `apply` points you
|
||||
at it when it spots leftovers.
|
||||
|
||||
## SSH login notifications
|
||||
|
||||
The harden scripts can install a `pam_exec` hook
|
||||
|
||||
Reference in New Issue
Block a user