feat(security): add an nftables host firewall scoping cluster ports to peers (#769)

* feat(security): add an nftables host firewall scoping cluster ports to peers

Nodes shipped no host firewall, so every cluster listener — OVN NB/SB, the
formation port, NATS, predastore — was reachable from anything that could route
to the node. On a single-NIC node the planes collapse onto the public address,
which made that the default rather than the exception.

setup.sh installs a policy and an apply helper but does not enable them: the
peer set is unknown until the cluster's planes resolve, and a drop policy with
an empty set would cut the first node that applied it out of its own formation.
install-node.sh writes the peers and enables the unit after formation.

The policy owns only table inet spinifex_filter and replaces it in one
transaction. vpcd writes MASQUERADE and per-EIP FORWARD rules into the ip
nat/filter tables and reinstalls them only on start, so a whole-ruleset flush —
what Debian's nftables.service does on both start and stop — would break
elastic IPs silently. INPUT only, for the same reason: a forward-hook drop here
could not be rescued by an accept in the other table.

Guest traffic keeps working: IMDS and VPC DNS terminate on ime-* ports in the
host netns, and every guest agent reaches its control plane over the AWS
gateway rather than NATS, so no cluster port needs to face the guests.

* feat: reconcile the host firewall peer set from the daemon

The peer set was written by install-node.sh and by cluster-bootstrap.yml. That
missed two of the four formation paths (bm-bootstrap.sh and the ISO firstboot),
duplicated the same logic in each, and went stale as soon as a node was added or
replaced. host.ReconcileFirewall now owns it: every formation path starts the
daemon, and it re-runs on each startup.

A node's own config names its peers by their wan address only, so a set built
from config alone drops every peer's NATS, OVSDB and formation traffic. The
lan plane comes from the generated NATS cluster routes and the OVN remote lists,
and the vpc plane from the Southbound Encap table; an empty encap answer is an
error rather than a narrower set.

ProtectSystem=full applies to the sudo child too, so the root helper could not
write /etc/spinifex/firewall either — it is now in the daemon's ReadWritePaths,
optional so a node installed before the firewall stage still starts.

The apply helper gains set-peers and disable verbs, re-validates every address
as a dotted quad with in-range octets, and rolls the peer file back if the
ruleset will not load.

* fix: retry the host firewall reconcile until every chassis registers

The peer set is only safe to write once OVN reports an encap address for
every node. On a fresh bootstrap the daemon routinely wins the race against
ovn-controller, so a single startup attempt saw a partial chassis list and
either wrote a set that dropped Geneve from the nodes missing from it, or
failed outright and left the node with no policy until a restart.

Gate on the full chassis count and schedule the reconcile instead of running
it once: back off from 15s to 5m while it fails, then re-run every 5m so a
node added or replaced after formation is picked up without a daemon restart.
An unchanged peer set short-circuits before the helper runs.

* test: cover the firewall chassis quorum gate in the multinode e2e suite

A partial chassis list is the normal state early in a bootstrap, and writing a
peer set from one produces a firewall that drops Geneve from every node missing
from it. The gate that refuses that write had no live coverage: a 5-node
bootstrap converges too fast for any daemon to observe a partial list, so the
verification run only ever exercised the OVN-unreachable path.

Reach the branch by inflating the expected count instead of breaking OVN: a
phantom node in one node's config makes the cluster one chassis short, with
nothing in the Southbound DB disturbed. Asserts the gate logs, that no peer
file is written while it holds, that the loaded ruleset survives, and that the
node recovers to a byte-identical peer set once the phantom is removed.
This commit is contained in:
Josh Siviour
2026-08-12 22:37:27 +10:00
committed by GitHub
parent 6f4aa495e6
commit 91d22d9f12
10 changed files with 966 additions and 9 deletions
+13 -2
View File
@@ -101,8 +101,8 @@ while [[ $# -gt 0 ]]; do
done <"$2"
shift 2
;;
--smoke) RUN_SMOKE=true; shift ;;
--wipe) WIPE=true; shift ;;
--smoke) RUN_SMOKE=true; shift ;;
--wipe) WIPE=true; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) sed -n '2,49p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
@@ -596,6 +596,17 @@ for name in "${NODE_NAMES[@]}"; do
Check 'sudo ovs-vsctl get Open_vSwitch . external_ids:system-id' on that host."
done
# --- Host firewall ---------------------------------------------------------
#
# Not applied here. spinifex-daemon reconciles the peer sets from cluster
# membership on every startup, so it also picks up nodes added or replaced after
# formation, which a one-shot write from this script would not.
echo ""
log "host firewall: reconciled by spinifex-daemon from cluster membership."
log " Check with 'sudo nft list table inet spinifex_filter' on a node,"
log " or disable it cluster-wide with network.firewall_enabled = false."
# The pool is the one thing the operator supplied by hand and the one thing
# nothing else validates, so print what actually landed. dns_servers in
# particular is detected rather than passed, and is wrong often enough to be
+187 -4
View File
@@ -14,9 +14,9 @@
# skip systemctl daemon-reload + enable, short-circuit setup_sudo.
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, users, sudoers, files, directories,
# env, systemd, logrotate, udev, fixown,
# migrations
# deps, aws, users, sudoers, firewall, files,
# directories, env, systemd, logrotate, udev,
# fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -28,6 +28,14 @@ INSTALL_BASE_URL="${INSTALL_BASE_URL:-https://install.mulgadc.com}"
ENDPOINT_SYSCTL_HELPER="/usr/local/lib/spinifex/spinifex-set-endpoint-sysctl"
IPSEC_STATE_HELPER="/usr/local/lib/spinifex/spinifex-set-ipsec-state"
# The firewall policy and the peer addresses it scopes cluster ports to. The
# policy ships with the node; the peers are written by install-node.sh, which is
# the only component that knows every node's resolved planes.
FIREWALL_DIR="/etc/spinifex/firewall"
FIREWALL_RULES="${FIREWALL_DIR}/spinifex.nft"
FIREWALL_PEERS="${FIREWALL_DIR}/peers.nft"
FIREWALL_APPLY="/usr/local/lib/spinifex/spinifex-firewall-apply"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -279,6 +287,175 @@ HELPER
$SUDO chmod 0755 "${IPSEC_STATE_HELPER}"
}
# The policy is written here rather than shipped in the tarball so the ISO chroot
# and the ansible path get an identical file without a packaging step. Installed
# but NOT enabled: the peer set is empty until install-node.sh resolves the
# cluster's planes, and a drop policy with no peers would break formation.
install_firewall() {
stage "installing host firewall policy"
info "Installing host firewall policy..."
$SUDO install -d -m 0755 "$FIREWALL_DIR" /usr/local/lib/spinifex
$SUDO tee "$FIREWALL_RULES" > /dev/null << 'RULES'
#!/usr/sbin/nft -f
# Spinifex host firewall. Managed by scripts/setup.sh — edits are overwritten.
#
# Only `table inet spinifex_filter` is created, deleted and replaced. vpcd writes
# MASQUERADE and per-EIP FORWARD ACCEPT rules into the `ip nat` and `ip filter`
# tables and reinstalls them only when the service starts, so anything that
# flushes the whole ruleset breaks elastic IPs silently until the next restart.
#
# INPUT only. nftables runs every table registered on a hook and any drop is
# final, so a forward-hook policy here could not be rescued by vpcd's ACCEPTs in
# the other table. OUTPUT is untouched: the IMDS reply path egresses under
# per-tap policy routing.
include "/etc/spinifex/firewall/peers.nft"
# Create-then-delete so the replace is atomic and the delete cannot fail on a
# first run. nft applies the whole file as one transaction or none of it.
table inet spinifex_filter
delete table inet spinifex_filter
table inet spinifex_filter {
chain input {
type filter hook input priority filter; policy drop;
# First, or every reply to a connection this node opened is dropped.
ct state established,related accept
ct state invalid drop
iif lo accept
# Guest metadata and VPC DNS terminate on per-ENI OVS internal ports in
# the host netns, so guest traffic to 169.254.169.254 and .253 arrives
# here. Without this, cloud-init, instance-role credentials and all
# guest DNS break. The ports exist only while instances run, so this
# matches the prefix rather than an enumerated list.
iifname "ime-*" accept
# PMTUD is not optional under a Geneve overlay.
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
# A broadcast DHCP reply does not reliably match an established
# conntrack entry, so the uplink and external-pool leases need this.
udp sport 67 udp dport 68 accept
# Public plane. 9999 is also how every guest agent (lb, eks, ecs, rds)
# reaches its control plane over br-mgmt: they speak SigV4 to the AWS
# gateway, never NATS, so no cluster port needs to face the guests.
tcp dport { 22, 3000, 8443, 9999 } accept
tcp dport 53 accept
udp dport 53 accept
# Cluster plane, peer-scoped. On a single-NIC node the planes collapse
# onto the public address, which is why these are peer addresses rather
# than an interface or a CIDR.
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644 } accept
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
# kernel UDP-tunnel socket, so encapsulated packets are delivered
# locally and pass this hook before the OVS vport sees them.
ip saddr $spinifex_encap_peers udp dport { 6081, 500, 4500 } accept
ip saddr $spinifex_encap_peers meta l4proto esp accept
# Rate-limited so a scan cannot fill the journal. This is the only way
# to tell a policy gap from an application fault after the fact.
limit rate 5/minute burst 10 packets log prefix "spinifex-fw drop: " level info
}
}
RULES
$SUDO chmod 0644 "$FIREWALL_RULES"
info " $FIREWALL_RULES"
$SUDO tee "$FIREWALL_APPLY" > /dev/null << 'APPLY'
#!/bin/sh
# Applies the spinifex host firewall. Fails without changing the ruleset when
# the peer file is missing, so a node whose daemon has not yet resolved cluster
# membership is left reachable rather than cut off from a cluster it cannot name.
set -eu
RULES="/etc/spinifex/firewall/spinifex.nft"
PEERS="/etc/spinifex/firewall/peers.nft"
[ -r "$RULES" ] || { echo "no firewall policy at $RULES" >&2; exit 1; }
# set-peers reads two comma-separated address lists on stdin (cluster peers,
# then encap peers) and rewrites the peer file before applying. Every address is
# re-validated here as a bare dotted quad: this runs as root under a NOPASSWD
# grant, so a caller must not be able to inject nft syntax through it.
# disable removes spinifex's own table and the peer file, leaving every other
# table alone. Idempotent, so it is safe on a node that never had the policy.
if [ "${1:-}" = "disable" ]; then
nft delete table inet spinifex_filter 2>/dev/null || true
rm -f "$PEERS"
systemctl disable --now spinifex-firewall.service >/dev/null 2>&1 || true
exit 0
fi
if [ "${1:-}" = "set-peers" ]; then
read -r peers_in || peers_in=""
read -r encap_in || encap_in=""
# Octet ranges are checked, not just the shape: nft rejects 10.0.0.999 and
# the whole transaction with it, which would leave a peer file that fails on
# every boot. A loop, not a pipeline — a rejection in a `while read` subshell
# cannot fail the function.
OCTET='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
validate() {
_out=""
for a in $(echo "$1" | tr ',' ' '); do
echo "$a" | grep -Eq "^${OCTET}(\.${OCTET}){3}$" || {
echo "address not permitted: $a" >&2
return 2
}
if [ -z "$_out" ]; then _out="$a"; else _out="$_out, $a"; fi
done
printf '%s' "$_out"
}
peers=$(validate "$peers_in") || exit 2
encap=$(validate "$encap_in") || exit 2
[ -n "$peers" ] && [ -n "$encap" ] || {
echo "set-peers needs a non-empty peer and encap list" >&2
exit 2
}
tmp=$(mktemp)
bak=$(mktemp)
printf '%s\n' \
'# Managed by spinifex-daemon. Regenerated from cluster membership.' \
"define spinifex_peers = { $peers }" \
"define spinifex_encap_peers = { $encap }" > "$tmp"
if [ -r "$PEERS" ]; then cp "$PEERS" "$bak"; fi
install -m 0644 "$tmp" "$PEERS"
# Roll the peer file back if the ruleset will not load, so the boot-time
# unit never inherits a file already known to fail.
if ! nft -f "$RULES"; then
if [ -s "$bak" ]; then install -m 0644 "$bak" "$PEERS"; else rm -f "$PEERS"; fi
rm -f "$tmp" "$bak"
exit 1
fi
rm -f "$tmp" "$bak"
exit 0
fi
if [ ! -r "$PEERS" ]; then
echo "no peer file at $PEERS: the daemon writes it once cluster membership" >&2
echo "resolves. Ruleset left unchanged." >&2
exit 1
fi
exec nft -f "$RULES"
APPLY
$SUDO chown root:root "$FIREWALL_APPLY"
$SUDO chmod 0755 "$FIREWALL_APPLY"
info " $FIREWALL_APPLY"
info "Host firewall installed (applied by spinifex-daemon once peers resolve)"
}
install_sudoers() {
stage "installing scoped sudoers rules"
install_endpoint_sysctl_helper
@@ -329,6 +506,7 @@ SUDOERS
$SUDO tee -a /etc/sudoers.d/spinifex-network > /dev/null << SUDOERS
spinifex-daemon ALL=(root) NOPASSWD: ${ENDPOINT_SYSCTL_HELPER}
spinifex-daemon ALL=(root) NOPASSWD: ${IPSEC_STATE_HELPER}
spinifex-daemon ALL=(root) NOPASSWD: ${FIREWALL_APPLY}
SUDOERS
$SUDO chmod 0440 /etc/sudoers.d/spinifex-network
$SUDO visudo -cf /etc/sudoers.d/spinifex-network || fatal "Invalid sudoers syntax in spinifex-network"
@@ -350,7 +528,7 @@ libvirt-daemon-system libvirt-clients
pciutils
jq curl iproute2 netcat-openbsd wget unzip xz-utils file
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
chrony"
chrony nftables"
install_apt_deps() {
stage "installing apt dependencies"
@@ -810,6 +988,10 @@ EOF
# takes effect once enabled) — without this the JetStream ENOSPC-latch
# watchdog is inert and a full disk requires a manual restart forever.
$SUDO systemctl enable --now spinifex-nats-watchdog.timer
# Enabled, not started: its ConditionPathExists holds it inert until the
# daemon writes a peer file, so this only decides that a node which has one
# gets the policy back at boot, before any service opens a socket.
$SUDO systemctl enable spinifex-firewall.service
info "Systemd units installed and enabled (per-service users)"
}
@@ -977,6 +1159,7 @@ main() {
stage_enabled aws && install_aws_cli
stage_enabled users && create_service_users
stage_enabled sudoers && install_sudoers
stage_enabled firewall && install_firewall
stage_enabled files && install_files
stage_enabled directories && create_directories
stage_enabled env && install_systemd_env