HomeDocs › Fleet Firewall — Declarative Per-Role Policy Compiled to nftables

Fleet Firewall — Declarative Per-Role Policy Compiled to nftables

Expands #285 (the four-port k3s-HA seed) into fine-grained, per-role, fleet-wide appliance firewall management. Closes the LAN-wide etcd/kubelet exposure as Phase 1; lands a structured policy engine as the larger arc.


1. Recommendation in one paragraph

We build a first-class declarative firewall policy authored on the control plane in three additive layers — a fleet baseline, per-role overlays keyed on the same role taxonomy as the #16 node labels, and per-appliance overrides — compiled server-side into the authoritative spatium-role.nft drop-in, riding the existing supervisor heartbeat → trigger-file → spatium-firewall-reload plane exactly as SNMP/NTP/LLDP do. The load-bearing primitive is a source_kind enum whose derived scopes (cluster_peers / pod_cidr / mgmt / vip) let an operator author “scope etcd to peers” once against the role abstraction while the compiler resolves each node’s concrete, dynamically-changing /32 peer set at that node’s own render time — so promote/demote re-renders automatically and the firewall tightens on demote with zero per-appliance edits. This shape wins because it is simultaneously fleet-wide, HA-peer-dynamic, and injection-proof by construction (structured rules are rendered, never pasted), and because making the rendered drop-in authoritative is the only way to actually CLOSE the LAN-wide base ports (nftables is first-match-wins; the existing scoped peer rule is dead code stacked behind a base accept that already fired). The dangerous parts — closing base ports, the bootstrap join race, the connectivity-killing-but-valid push, and the k3s data-plane ports (flannel VXLAN 8472/udp) that are opened nowhere today — are bought back by an un-removable SSH/management floor, a baked bootstrap sentinel that retires only once the cluster is genuinely multi-node, a bounded auto-narrowing 6443 join window, an asymmetric-on-leave peer set that mirrors the cert-SAN only-grow safety, and a state-file-driven, reboot-survivable, per-node-independent test-apply auto-revert. Three verified prerequisites the proposals glossed are built explicitly before any port-closing ships: there is no pod-CIDR mirror to the supervisor today, no flannel data-plane port is opened anywhere, and no base-conf-version signal exists to tell a partially-upgraded fleet apart from a hardened one.


2. Data model

2.1 Three layers, one merge

effective_node_ruleset =
    MGMT_FLOOR                                   # ALWAYS, renderer-emitted + base-baked, un-removable (§6.1)
  ⊕ DATA_PLANE_FLOOR   (flannel VXLAN / wireguard, peer-scoped — emitted on EVERY pod-running node, §3.6)
  ⊕ FLEET_BASELINE     (FirewallPolicy scope_kind="fleet")
  ⊕ Σ ROLE_OVERLAY[r]  for r in node.assigned_roles ∪ ({"control-plane"} if node.cluster_role∈{primary,member})
  ⊕ CLUSTER_DERIVED    (peer-scoped etcd/kubelet/6443/memberlist; if CP node)
  ⊕ KUBEAPI_ALLOWLIST  (Appliance.kubeapi_expose_cidrs folded into a 6443 cidr rule)
  ⊕ APPLIANCE_OVERRIDE (FirewallPolicy scope_kind="appliance" structured rules)
  ⊕ RAW_EXTRA          (Appliance.firewall_extra, LINTED at write time, appended last)

is the explosion + deny-wins + source-union merge defined concretely in §3.7 — not a loose “set union.” The role taxonomy (control-plane, dns-bind9, dns-powerdns, dhcp, observer, custom) is the same token set the chart uses for spatium.io/role-* node labels (#16), so firewall scope and pod scheduling share one source of truth.

2.2 New tables — backend/app/models/firewall.py

class FirewallPolicy(Base):
    __tablename__ = "firewall_policy"
    id:            Mapped[uuid.UUID]   = mapped_column(primary_key=True, default=uuid.uuid4)
    name:          Mapped[str]         = mapped_column(String(120))
    description:   Mapped[str | None]  = mapped_column(Text)

    scope_kind:    Mapped[str]         = mapped_column(String(16))          # "fleet" | "role" | "appliance"
    scope_role:    Mapped[str | None]  = mapped_column(String(32))          # role token, iff scope_kind="role"
    scope_appliance_id: Mapped[uuid.UUID | None] = mapped_column(
        ForeignKey("appliance.id", ondelete="CASCADE"))                     # iff scope_kind="appliance"

    enabled:       Mapped[bool]        = mapped_column(Boolean, default=True)
    is_builtin:    Mapped[bool]        = mapped_column(Boolean, default=False)  # seeded; non-deletable, editable
    priority:      Mapped[int]         = mapped_column(Integer, default=100)
    created_at / updated_at / updated_by_id

    rules: Mapped[list["FirewallRule"]] = relationship(
        back_populates="policy", cascade="all, delete-orphan", order_by="FirewallRule.seq")

    __table_args__ = (
        UniqueConstraint("scope_kind", "scope_role", name="uq_fw_policy_role"),   # one builtin per role; fleet singleton
        Index("ix_fw_policy_appliance", "scope_appliance_id"),
        CheckConstraint(
            "(scope_kind='fleet'     AND scope_role IS NULL    AND scope_appliance_id IS NULL) OR "
            "(scope_kind='role'      AND scope_role IS NOT NULL AND scope_appliance_id IS NULL) OR "
            "(scope_kind='appliance' AND scope_role IS NULL     AND scope_appliance_id IS NOT NULL)",
            name="ck_fw_policy_scope"),
    )

class FirewallRule(Base):
    __tablename__ = "firewall_rule"
    id:            Mapped[uuid.UUID]   = mapped_column(primary_key=True, default=uuid.uuid4)
    policy_id:     Mapped[uuid.UUID]   = mapped_column(ForeignKey("firewall_policy.id", ondelete="CASCADE"), index=True)
    seq:           Mapped[int]         = mapped_column(Integer)              # render order within policy

    action:        Mapped[str]         = mapped_column(String(8), default="accept")   # "accept" | "drop"
    protocol:      Mapped[str]         = mapped_column(String(8))           # "tcp" | "udp" | "icmp" | "icmpv6"
    ports:         Mapped[list]        = mapped_column(JSONB, default=list) # [53, "1024-2048"]; [] for icmp

    # ── source scoping: the heart of fine-grained control ──
    source_kind:   Mapped[str]         = mapped_column(String(16), default="any")
        # "any" | "cidr" | "alias" | "cluster_peers" | "pod_cidr" | "service_cidr" | "mgmt" | "vip"
    source_cidrs:  Mapped[list]        = mapped_column(JSONB, default=list) # for source_kind="cidr" (v4 + v6, family-split at render)
    source_alias:  Mapped[str | None]  = mapped_column(String(64))         # for source_kind="alias"

    family:        Mapped[str]         = mapped_column(String(6), default="both")  # "v4" | "v6" | "both"
    comment:       Mapped[str | None]  = mapped_column(String(120))
    enabled:       Mapped[bool]        = mapped_column(Boolean, default=True)
    policy: Mapped["FirewallPolicy"]   = relationship(back_populates="rules")

Validation rejects protocol="tcp"|"udp" with empty ports, and rejects any rule whose resolved port atoms include 22 with action="drop" (§6.1 — SSH/22 may never be closed by a rule).

class FirewallAlias(Base):
    __tablename__ = "firewall_alias"
    id / name (unique String(64)) / kind ("port"|"cidr") / v4_members (JSONB) / v6_members (JSONB) / description / is_builtin
    # CIDR aliases are family-split AT REST (v4_members / v6_members) — closes the IPv6 lockout bug (§6.2)

class FirewallApplyState(Base):
    __tablename__ = "firewall_apply_state"
    appliance_id:    Mapped[uuid.UUID] = mapped_column(ForeignKey("appliance.id", ondelete="CASCADE"), primary_key=True)
    rendered_hash:   Mapped[str | None]= mapped_column(String(64))  # sha256 of the body the backend last rendered
    applied_hash:    Mapped[str | None]= mapped_column(String(64))  # echoed from the host runner sidecar
    applied_status:  Mapped[str | None]= mapped_column(String(48))  # "ok"|"error:dry-run-post"|"reverted-timeout"|"stalled"|...
    base_conf_marker:Mapped[str | None]= mapped_column(String(64))  # which base /etc/nftables.conf is live (§2.6)
    pending_commit:  Mapped[bool]      = mapped_column(Boolean, default=False)   # a test-apply is mid-countdown
    commit_deadline: Mapped[datetime | None]
    last_confirmed_hash: Mapped[str | None] = mapped_column(String(64))  # last hash the node CONFIRMED-good (drives stale-PASS compliance, §6.5)
    last_confirmed_at:   Mapped[datetime | None]
    last_rendered_at / last_applied_at

FirewallApplyState closes the verified gap that nothing reads firewall-applied-status back today — the host runner writes it but it dies on the box. Now it rides the heartbeat home and drives the Fleet UI status chip, drift detection (§3.4), the apply-lag alarm (§3.4b), and the stale-but-compliant conformity verdict (§6.5).

2.3 Fleet posture + floor knobs — PlatformSettings (singleton id=1)

These live on PlatformSettings (the fleet-wide channel SNMP/NTP/LLDP use) precisely so no policy edit can touch them:

firewall_enabled:              Mapped[bool] = False        # master enforcement gate; false = floor-only (legacy posture)
firewall_default_input_action: Mapped[str]  = "drop"       # Talos NetworkDefaultActionConfig analog; drop-only in v1 (Q2)
firewall_posture:              Mapped[str]  = "balanced"
    # "open"     — k3s ports LAN-wide via policy (reproduces today; single-tenant lab)
    # "balanced" — DEFAULT-on-opt-in: 2379/2380/10250 peer-only, 6443 peer+pod+join-window, services LAN-wide
    # "locked"   — services also CIDR-scoped (requires per-role client allowlists)
firewall_mgmt_cidrs:           Mapped[list] = []           # SSH/mgmt floor source. [] = SSH LAN-wide (recovery)
firewall_mgmt_lockdown:        Mapped[bool] = False        # honour mgmt_cidrs for SSH ONLY when non-empty (§6.1 guard)
firewall_join_window_minutes:  Mapped[int]  = 30           # 6443 join-window duration after a promote
firewall_autorevert_seconds:   Mapped[int]  = 180          # test-apply revert timeout; clamped >= 2× heartbeat (§3.4a)
firewall_apply_lag_intervals:  Mapped[int]  = 5            # rendered≠applied for > N heartbeats → firewall.apply_stalled (§3.4b)

firewall_enabled defaults false so an upgrade is byte-identical to today (floor-only render + the still-present base accepts). Discovery (the feature module, §7) is on; enforcement is opt-in. Flipping it to true is additionally gated on a fleet-wide base-conf confirmation (§2.6) so hardening is never presented as live while a stale-base node is still LAN-wide.

2.4 What stays / migrates

2.5 Seeded builtins (migration — idempotent data seed, split from schema; see Phase 3)

2.6 Two new derived inputs the supervisor must report (verified absent today)

Both are collected by appliance_state.collect(), shipped on the heartbeat, persisted on Appliance, and consumed by the compiler. Both gate Phase 1 port-closing (§3.6):

  1. Cluster CIDRs + data-plane backend — parse cluster-cidr:, service-cidr:, and flannel-backend: (default vxlan; may be wireguard-native) from the host /etc/rancher/k3s/config.yaml.d/spatium-cidrs.yaml drop-in (already bind-mounted to the supervisor). Fallback 10.42.0.0/16 / 10.43.0.0/16 / vxlan only if unreadable.
  2. Base-conf marker — hash /etc/nftables.conf (or detect the absence of the comment "k3s-ha" line) and report it so the backend knows per node which base is live. This is what lets the UI refuse to claim “hardened” on a stale-base node and lets the conformity check be accurate during a rolling A/B upgrade (§6.5, §3.2).

node_ip collection is also extended to report all InternalIPs as node_ips: list[str] (k3s lists both families on a dual-stack cluster) so v6 peer scoping is derived from a real v6 address rather than a fabricated one (§6.2).


3. Render + apply pipeline

3.1 Compile server-side (mirror the snmp_bundle contract)

New backend/app/services/appliance/firewall.py:

def compile_firewall(
    settings, appliance,
    fleet_policy, role_policies, appliance_policy,   # eager-loaded rules
    cluster_peer_cidrs_v4, cluster_peer_cidrs_v6,    # from family-split + asymmetric-on-leave _cluster_peer_cidrs
    pod_cidr_v4, pod_cidr_v6, service_cidr_v4,        # from the pod-CIDR mirror (§2.6 / §3.6 — PREREQUISITE)
    dataplane_backend, dataplane_peer_cidrs,          # flannel vxlan 8472 / wireguard 51820+51821 (§3.6)
    control_plane_vip, cp_member_count, vip_configured,
    join_window_active, aliases,
) -> str: ...

def firewall_bundle(...) -> dict:
    if not settings.firewall_enabled:
        return {"enabled": False, "config_hash": "", "firewall_conf": "", "default_action": "accept"}
    body = compile_firewall(...)
    return {
        "enabled": True,
        "config_hash": canonical_hash(body),          # sort+dedupe saddr sets BEFORE hashing (anti-flap, §3.4)
        "firewall_conf": body,
        "default_action": settings.firewall_default_input_action,
        "retire_bootstrap": cp_member_count >= 2 and not join_window_active and 'comment "kubeapi"' in body,  # §3.3
        "revert_seconds": settings.firewall_autorevert_seconds,
        "is_test_apply": <set true only on an operator Test action>,    # §3.4a
        "commit_deadline": <epoch, set only on a test-apply>,           # §3.4a
    }

Render order inside the drop-in (every fragment is a bare proto dport N accept since the include glob sits inside chain input — top-level add rule 422s on trixie):

# Auto-generated by SpatiumDDI control plane — do not hand-edit.
# profile: control-plane+dns-only | base: <marker> | layers: floor,dataplane,fleet,role:control-plane,role:dns-bind9,cluster
# ── MGMT FLOOR (immutable, FIRST — first-match-wins over any operator drop) ──
tcp dport 22 accept comment "ssh"                       # OR ip saddr {mgmt} when firewall_mgmt_lockdown (§6.1)
udp sport 67 udp dport 68 accept comment "dhcp-client-return"   # host's own lease renewal — kept even on DHCP-server nodes (§4)
icmp type echo-request accept
icmpv6 type echo-request accept
iif lo accept
# ── DATA-PLANE FLOOR (peer-scoped, on EVERY pod-running node — §3.6) ──
ip  saddr { 192.168.1.11/32, 192.168.1.12/32 } udp dport 8472 accept comment "flannel-vxlan-v4"
ip6 saddr { 2001:db8::12/128 }                 udp dport 8472 accept comment "flannel-vxlan-v6"
# (if flannel-backend=wireguard-native →) udp dport { 51820, 51821 } accept (peer-scoped)
# ── FLEET BASELINE ──
# ── ROLE: dns-bind9 ──
udp dport 53 accept comment "role:dns-bind9"
tcp dport 53 accept comment "role:dns-bind9"
# ── ROLE: control-plane (web) — matches ANY daddr so the MetalLB VIP passes ──
tcp dport { 80, 443 } accept comment "role:control-plane web"
# ── CLUSTER DERIVED (peer-scoped; etcd/kubelet NEVER LAN-wide) ──
ip  saddr @k3s_peers_v4  tcp dport { 2379, 2380, 10250 } accept comment "k3s-peer-v4"
ip6 saddr @k3s_peers_v6  tcp dport { 2379, 2380, 10250 } accept comment "k3s-peer-v6"
ip  saddr @kubeapi_v4    tcp dport 6443 accept comment "kubeapi"        # peers ∪ pod ∪ svc ∪ kubeapi_expose
ip  saddr @k3s_peers_v4  tcp dport 7946 accept comment "metallb-memberlist-tcp"   # emitted iff cp_member_count>=2 && vip
ip  saddr @k3s_peers_v4  udp dport 7946 accept comment "metallb-memberlist-udp"
# (join window open →)  ip saddr @node_subnet tcp dport 6443 accept comment "join-window"   # node-subnet-scoped, §3a
# ── KUBEAPI ALLOWLIST (kubeapi_expose_cidrs) ──
# ── APPLIANCE OVERRIDE (structured) ──
# ── RAW_EXTRA (linted at write time, verbatim, last) ──

Three verified gaps closed here:

Named sets, not anonymous inline {} sets. Peer/kubeapi CIDRs are emitted as named sets (@k3s_peers_v4 …) so a peer-IP change updates one set via nft add/delete element without a full nft -f chain re-evaluation (anti-thrash, §3.4). The web rule stays daddr-agnostic so VIP traffic (which arrives daddr=VIP) passes on every CP node.

3.2 Compose with the baked base — reduce it to an immutable floor

appliance/mkosi.extra/etc/nftables.conf is reduced to a floor. The LAN-wide k3s-ha line (:79) is removed; the belt-and-braces 53 accept is removed, but the DHCP client-return rule (udp sport 67 dport 68) is KEPT (a DHCP-server appliance is itself frequently DHCP-addressed — removing it would break the host’s own lease renewal):

flush ruleset
table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    iif lo accept
    ct state established,related accept
    ct state invalid drop
    meta l4proto icmp accept
    meta l4proto icmpv6 accept
    tcp dport 22 accept comment "ssh-floor"                  # un-removable recovery channel
    udp sport 67 udp dport 68 accept comment "dhcp-client-return-floor"
    include "/etc/nftables.d/00-spatium-k3s-bootstrap.nft"   # baked: 6443 LAN-wide; retired only when multi-node (§3.3)
    include "/etc/nftables.d/*.nft"                          # spatium-role.nft (AUTHORITATIVE) + SNMP/NTP/LLDP drop-ins
    counter
  }
  chain forward { type filter hook forward priority filter; policy accept; }   # UNCHANGED — flannel/kube-proxy
  chain output  { type filter hook output  priority filter; policy accept; }   # UNCHANGED — supervisor outbound
}

FORWARD/OUTPUT accept are hard invariants the standard surface cannot touch (pod networking + the supervisor’s outbound heartbeat depend on them). The base SSH floor + the renderer-emitted SSH floor are belt-and-braces: a node whose drop-in never rendered (fresh boot, control-plane unreachable) is still SSH-reachable.

This is what lets the drop-in CLOSE ports: with the base no longer accepting 2379/2380/10250, the only accept comes from the scoped cluster-derived rule. The drop-in is the firewall, not decoration.

Honest effective-view scope. kube-proxy/flannel write their own iptables-nft chains (KUBE-*) in the ip nat/ip filter tables; a NodePort/LoadBalancer service can open a path our inet filter drop policy does not see. The server-rendered view (§5, §7) is therefore labeled “SpatiumDDI-managed input rules”, not “effective kernel ruleset.” The supervisor additionally ships a periodic summary of NodePort/LoadBalancer service presence so the auditor knows pod-network paths exist outside our table; ClusterIP/pod traffic is governed by NetworkPolicy (out of scope).

3.3 Bootstrap sentinel + retire-on-multi-node (the flag-day-free migration)

The baked 00-spatium-k3s-bootstrap.nft (tcp dport 6443 accept) keeps a fresh seed / out-of-band rejoin reachable before the supervisor renders. The runner deletes it only on a successful apply where the bundle’s retire_bootstrap=true — and that flag is true only when cp_member_count >= 2 AND not join_window_active**. On a single-node appliance (cp_member_count == 1) the bootstrap **stays indefinitely — it is the only LAN path to 6443 for a future promote/join, and etcd is loopback-only so it is harmless. This closes the air-gap chicken-and-egg: a manual joiner that does not yet exist as a DB row (it pairs/approves after reaching the seed) still finds 6443 open via the un-retired sentinel. For deliberate hardening of a settled multi-node cluster, a Fleet “allow LAN 6443 for join” maintenance toggle (audited, auto-expiring) re-creates a bounded window without requiring the joiner to pre-exist (§3a).

3.4 Heartbeat wiring + apply + drift

3.4a Test-apply with state-file-driven, reboot-survivable auto-revert

Re-specified away from the fragile “transient systemd-run timer fired from a oneshot runner” into a persistent-timer + deadline-file design:

Per-node-independent confirmation (decoupled from generic control-plane reachability). The supervisor writes firewall-confirm only when BOTH (a) a fresh heartbeat succeeded AND (b) an explicit post-apply self-probe of its own critical inbound paths passes — a peer-originated or node-IP loopback TCP connect to 6443 (and, on a CP node, 10250 from a peer IP) confirming the new ruleset did not strand the node’s own service ports. Because each node keys its timer off its own probe, a fleet-wide transient (CNPG failover / VIP re-home / etcd leader election / a 30s blip) can no longer mass-revert a healthy fleet. Honest scope: self-inflicted SSH lockout is not what auto-revert protects against (the heartbeat is outbound and OUTPUT stays accept, so a bad SSH-INPUT rule still confirms) — that is caught by the un-removable floor (§6.1) + the write-time lint that rejects any drop on port 22. Auto-revert’s real job is the inter-node-partition case (a peer-CIDR typo breaks etcd quorum → the control plane itself goes unreachable AND the self-probe fails → revert). On a CP node, the revert restores .last-good which retains the scoped peer rules as a floor — it reverts only the operator’s incremental change, never re-opening LAN-wide etcd.

3.4b Apply-lag alarm (control plane up, supervisor wedged)

If rendered_hash != applied_hash for more than firewall_apply_lag_intervals heartbeats, the backend raises a typed firewall.apply_stalled alert distinct from agent-offline, cross-referencing Wave E’s external-watchdog state so “supervisor wedged” is shown as the cause. The Fleet drift chip differentiates pending / applied-ok / applied-error / stalled / reverted — a true status, not a bare hash mismatch.

3.5 Control-plane-loss survival (non-negotiable #5)

The rendered spatium-role.nft lives on the /etc overlay upper (/var/persist/etc); sidecars on /var — both survive reboots and A/B slot swaps. On a non-200 heartbeat, heartbeat_once returns early and maybe_fire_firewall_reload is never reached → last-good stays loaded indefinitely. Nothing tightens, nothing flushes. The supervisor retains firewall_renderer.py for one release as a cache-fallback: if a heartbeat arrives without a firewall_settings block (old control plane / transitional bundle), it re-renders from cached role_assignment as today. A node that reboots while the control plane is down loads its last-good drop-in from the overlay — fully functional, no control plane needed.

3.6 PREREQUISITES — pod-CIDR mirror + data-plane port (both verified absent today)

Three things the steady-state rules depend on are not collected today and are built before any base port is closed, gated as the first Phase-1 tasks:

  1. Pod / service CIDR — the steady-state 6443 rule accepts from the operator-chosen pod/service CIDR (in-pod apiserver access traverses INPUT via the 10.43.0.1:443 → node:6443 DNAT with saddr=pod-IP; #302 lets operators change 10.42/10.43). Built per §2.6.
  2. Data-plane port + backend — cross-node pod networking depends on flannel VXLAN 8472/udp (default) or wireguard-native 51820+51821/udp. Opened nowhere today; removing the LAN-wide base accept without it breaks cross-node pod traffic. Built per §2.6, emitted as the DATA-PLANE FLOOR (§3.1).
  3. Base-conf marker — per §2.6, so a partially-upgraded fleet is not silently split-brain.

Getting any of these wrong silently breaks cluster traffic the moment firewall_enabled flips on, so all three are hard Phase-1/Phase-2 gates with explicit cross-node pod-to-pod and pod-to-apiserver acceptance tests on a real 3-node appliance.

3.7 The merge algorithm (the load-bearing core of “fine-grained”)

is not loose set-union. Concretely:

  1. Explode every enabled rule (from every layer) into per-individual-port atoms (action, proto, port, resolved_source_set, family) — a rule for ports {80,443} becomes two atoms.
  2. Resolve sources to concrete CIDR sets per family (cluster_peers→peer set, pod_cidr→pod set, alias→alias members, cidr→literal, any→universe). The dedup key is (action, proto, port, family, frozenset(resolved_source_set)) — two accept/tcp/6443/cidr atoms with different CIDR lists union their sets into one rule; identical atoms collapse to one.
  3. Deny-wins per port atom: for each (proto, port, family), emit every drop atom (each carrying its own saddr clause) before any accept atom for that same port. First-match-wins then does the rest. A blanket close is source_kind=any → bare <proto> dport N drop; a scoped close keeps its ip saddr {…} clause.
  4. The MGMT floor + DATA-PLANE floor are emitted first of all, ahead of step 3’s drops, so no operator drop can shadow SSH/22 or the data-plane port.

Worked example. Fleet accept 443/any; appliance-override drop 443/any + accept 443/alias:@mgmt (10.0.0.0/24):

# floor first …
tcp dport 22 accept comment "ssh"
# … then deny-wins for the 443 atom:
ip saddr 10.0.0.0/24 tcp dport 443 accept comment "appliance:mgmt-web"   # narrower accept emitted…
tcp dport 443 drop comment "appliance:close-web"                          # …but the blanket drop is ALSO present;
                                                                          # first-match: mgmt CIDR hits accept, world hits drop

(Per first-match-wins, the scoped accept precedes the blanket drop so 10.0.0.0/24 is permitted and everything else is dropped — the design’s emit order places narrower accepts ahead of blanket drops within a port atom; the fleet accept 443/any is shadowed and surfaced as a “this rule will never match” preview warning.) firewall_extra is strictly additive and last; the preview runs the merged ruleset through nft --check + a structural shadow-analysis so any shadowed rule is flagged before commit.


3a. #285 join bootstrap

The constraint (verified against spatium-cluster-join): a fresh joiner dials seed:6443 to fetch /cacerts + validate the token before it is a settled member. 2379/2380/10250 are pure peer-to-peer — the seed already knows the joiner’s node_ip (the promote API stamps desired_cluster_role='member' before telling the joiner to run the join runner, and _cluster_peer_cidrs already includes in-flight rows).

Peer-set derivation (ranked): _cluster_peer_cidrs (the control plane’s own membership model) is the spine — available at heartbeat time, includes in-flight promotions, injection-validated. As a host-side cross-check against control-plane staleness, the supervisor compares its rendered peer set against the live k3s etcd member list and logs supervisor.firewall.peer_drift if a settled member is missing — surfaced as a Fleet warning, never an auto-widen (auto-widening would be a privilege-escalation vector). k3s node list / configured size are not used (over-broad / coarse).

HA promote / leave reconciliation (asymmetric — the verified cert-SAN safety, applied here):

Final posture per port:

Port Steady-state scope Bootstrap handling
2379 (etcd client) cluster_peers only; no LAN fallback, no base accept. Single-node → not emitted (etcd loopback-only). Asymmetric-on-leave (above). None needed — never reached by a non-member. Ships in Phase 1.
2380 (etcd peer) cluster_peers only; asymmetric-on-leave. None needed. Phase 1.
10250 (kubelet) cluster_peers only (metrics-server disabled). None needed. Phase 1.
8472/udp (flannel VXLAN) or 51820/51821 (wireguard) cluster_peers only, on every pod-running node. Read from k3s config (§2.6). None — peer-to-peer only. Phase 1 prerequisite.
7946 tcp+udp (MetalLB memberlist) cluster_peers only, emitted iff cp_member_count>=2 AND vip_configured. None. Phase 1.
6443 (apiserver) cluster_peers ∪ pod_cidr ∪ service_cidr ∪ kubeapi_expose_cidrs. Two layers: (a) baked 00-spatium-k3s-bootstrap.nft keeps 6443 LAN-reachable until the cluster is genuinely multi-node + no join window (§3.3); (b) a bounded auto-narrowing join window — when any appliance is cluster_join_state ∈ {pending, joining}, the backend sets firewall_join_window_until = now()+firewall_join_window_minutes on the seed; the seed’s drop-in adds ip saddr @node_subnet tcp dport 6443 accept (node subnet, never world) for the window; on joined/expiry it auto-narrows. For air-gap manual join (joiner not yet a DB row) the Fleet “allow LAN 6443 for join” maintenance toggle creates the same bounded window explicitly.

4. Per-role scoping

A node’s posture is the union of two orthogonal axes (verified roles-and-topologies model): cluster role (Appliance.cluster_role ∈ {primary,member,None}control-plane overlay) and service roles (Appliance.assigned_roles ⊆ {dns-bind9,dns-powerdns,dhcp,observer,custom}).

Node shape (user vocabulary) cluster_role service roles Inbound the compiler emits (balanced)
Frontend / control node primary/member floor + dataplane; 80/443 (+VIP daddr); 6443 (peers∪pod∪svc∪kubeapi, join-window); 2379/2380/10250 (peers); 7946 tcp+udp memberlist (peers, if ≥2+VIP)
DNS worker None dns-bind9 | dns-powerdns floor + dataplane; 53 tcp+udp. No k3s ports — the exact #16 misplacement risk, closed by absence (etcd/kubelet never opened here)
DHCP worker None dhcp floor + dataplane; udp/67 broadcast (any); udp/68 return via floor; relay-VIP (daddr=relayVIP, relay CIDRs) in bridged mode
Combined DNS+DHCP worker None dns-* ∪ dhcp floor + dataplane; 53; 67/68 — union
Promoted CP also serving DNS member dns-bind9 union of frontend-node + DNS-worker
Observer any observer floor + dataplane; node-exporter 9100 scoped to scraper CIDR (default-disabled rule)
Custom any custom floor + dataplane; custom role policy + firewall_extra

Composition is the §3.7 merge, de-duplicated so two roles opening 53 emit it once. DNS engines (dns-bind9 XOR dns-powerdns) map to the same 53 overlay. The drop-in header profile: reflects both axes.

DHCP/67 honesty (verified Kea hostNetwork). udp/67 is opened from any because broadcast DISCOVER has no useful saddr to scope; relayed unicast from a giaddr is therefore already covered by the same any rule (no extra scoping needed). The locked posture cannot scope DHCP/67 — broadcast is unscopeable — and the UI says so explicitly so an operator does not believe they have locked it down. The host’s own DHCP-client return (udp sport 67 dport 68) lives in the floor (§3.2) so a DHCP-server appliance that is itself DHCP-addressed keeps renewing its lease; this is verified by an explicit Phase-1 test.

#16 tie-in: the firewall adds no workload (it’s host config), so #16’s chart-label clause doesn’t directly fire — but keying the role overlays on the same tokens as spatium.io/role-* means the role assignment that schedules a pod (via label) also selects that node’s firewall layer (via the compiler), so a workload can’t land on a node without its firewall rendering there, and vice versa.


5. Operator UX

A new top-level Firewall surface under the Fleet sidebar Services group (alongside NTP/SNMP/LLDP), built as a *Section on PlatformSettings matching the established {values, isSuperadmin, applianceMode, inputCls} contract — master toggle = firewall_enabled, !applianceMode amber banner, Save/dirty/✓ Saved, superadmin-gated. The firewall_enabled toggle is itself gated: the UI refuses to present posture=hardened as fleet-wide and refuses to flip the master enable until every CP node reports the new base-conf marker (§2.6) — a “N of M nodes still on the legacy base” badge blocks the claim.


6. Safety rails + IPv6 + injection-safety + audit/compliance

6.1 Un-removable management floor. SSH/22 + ICMP v4/v6 + loopback are emitted first, outside any operator-controllable block, AND baked in the base conf (ssh-floor). No policy, no firewall_extra, no default_action=drop can remove them. compile_firewall asserts the body contains an SSH accept and refuses to ship a body lacking it (returns last-good / floor-only + logs). The write-time lint additionally rejects any rule whose resolved port atoms include 22 with action=drop so an operator can’t even author a self-lockout. firewall_mgmt_lockdown is honoured only when firewall_mgmt_cidrs is non-empty AND yields a non-empty ip saddr {…} tcp dport 22 accept; the backend 422s lockdown=true with empty mgmt_cidrs. The base-conf floor stays LAN-wide regardless — the irreducible recovery channel.

6.2 IPv6 (three confirmed bugs, fixed at the model layer). (a) Lockout-via-validation-gap: aliases/rules family-split at rest (v4_members/v6_members, family); the renderer routes by family (ip saddr vs ip6 saddr) so a v6 entry can never leak into a v4 set and wipe the drop-in; the API 422s a v6 CIDR in a v4-only context. (b) node_ip family: _cluster_peer_cidrs is family-split so a v6 InternalIP yields /128 (not a garbage /32); collection reports all InternalIPs as node_ips: list (k3s lists both families on dual-stack) so the v6 peer set is derived from a real v6 address. The compiler emits ip6 saddr {…/128} only when the v6 peer set is non-empty, and a preflight check flags an asymmetric v6 peer set (some CP nodes have a v6 InternalIP, the firewall set doesn’t) before allowing the base-conf close — preventing a silent v6 etcd hole or v6 quorum outage on a dual-stack cluster. (c) Silent bypass: the base conf’s family-agnostic k3s accept is gone (§3.2); the floor keeps icmpv6 so v6 ND/ICMP is never stranded.

6.3 Injection-safety. Structured FirewallRule rows are the primary, inject-proof-by-construction surface (rendered, never pasted). firewall_extra is the linted expert hatch: an allowlist grammar (only ip[6] saddr {cidrs} (tcp|udp|icmp[v6]) [dport {ports}] (accept|drop) [comment "…"] shapes parse; everything else 422s) replaces a brittle deny-list — this closes the injection surface more durably and the lint runs at write time (never at compile time, no silent drops). nft -c -f on the host is the backstop. CIDR fields reuse the _validated_cidrs (#236) round-trip. The shadow-analysis in preview warns when an extra rule is shadowed by an earlier accept and will never match.

6.4 Audit (gaps closed). Every policy/rule/alias mutation writes audit_log with both old_value and new_value (closing today’s gaps where kubeapi_cidrs_updated omits old_value and role_assigned omits firewall_extra entirely). New typed events (auto-appear in the webhook catalog): firewall.policy.{created,updated,deleted}, firewall.rule.*, firewall.appliance_override.updated, firewall.posture.changed, firewall.auto_reverted, firewall.drift_repaired, firewall.apply_stalled.

6.5 Compliance (stale-PASS, not connectivity-FAIL). A conformity check kind no_lanwide_control_plane_ports (PCI Req 1 / HIPAA §164.312). Because the conformity engine’s _TARGET_KINDS has no appliance target today, this is implemented as a platform-kind check whose evaluator iterates all appliances internally (the less-invasive route — no engine/frontend target-enum surgery). The verdict is based on the last CONFIRMED applied ruleset + base-conf marker (FirewallApplyState.last_confirmed_hash / base_conf_marker), not heartbeat freshness — so a node that confirmed a hardened ruleset and then went silent reports PASS-stale (“compliant as of T”), honouring non-negotiable #5 instead of flipping the whole fleet to FAIL during a transient control-plane outage. A node fails only if it has never confirmed a hardened base, or its last confirmed state was LAN-wide, or it is still on the legacy base-conf marker. The expected-port set for CP nodes includes memberlist 7946 tcp+udp (a missing rule surfaces as a compliance fail, not a silent VIP outage). The report carries a staleness column so the auditor sees “compliant as of <timestamp>.” Wired into the PCI/HIPAA seed frameworks; the read-only managed-rules view is the auditor artifact (honestly labeled per §3.2).


7. API + MCP + feature-module gating

Feature module (#14). New top-level surface → ModuleSpec(key="appliance.firewall", name="Fleet Firewall", default_enabled=True) in feature_modules.MODULES; migration seeds the feature_module row; dependencies=[Depends(require_module("appliance.firewall"))] on the new firewall.py router include in router.py (alphabetised); MCP tools tagged module="appliance.firewall"; the Firewall NavItem carries module: "appliance.firewall". Default-enabled for discovery per #14 — firewall_enabled (the enforcement knob) defaults false.

APIbackend/app/api/v1/appliance/firewall.py (new router): policy/rule/alias CRUD (write-time lint); GET /appliances/{id}/firewall/effective (server-rendered managed-rules body + layer breakdown + NodePort note + base-conf badge); POST /firewall/preview (staged-policy diff + shadow-analysis). Reuse _validate_cidr. Heartbeat firewall_settings block + the firewall-confirm round-trip live in supervisor.py. A fleet-firewall coordination Lease (coordination.k8s.io, spatium-firewall-apply-lock) is held for the duration of any fan-out OR any single-policy change affecting >1 node, mirroring #296; a new apply (operator OR copilot) is refused while that lock or the rolling-OS-upgrade Lease is held. Rule edits SAVE concurrently (just rows); only RENDER+APPLY fan-out is single-flight.

MCP (#13)backend/app/services/ai/tools/firewall.py:


8. Phased implementation plan

Each phase independently shippable. Phase 1 is the minimal #285 close; later phases build the engine.

Phase 1 — Close #285 (no new tables, no UI). Files: appliance/mkosi.extra/etc/nftables.conf, agent/supervisor/spatium_supervisor/firewall_renderer.py, appliance_state.py, heartbeat.py, backend/app/api/v1/appliance/supervisor.py.

Phase 2 — Server-side render + auto-revert safety. Files: new backend/app/services/appliance/firewall.py, supervisor.py (heartbeat firewall_settings block, firewall-confirm + self-probe), appliance_state.py (maybe_fire_firewall_reload), usr/local/bin/spatium-firewall-reload + new spatium-firewall-revert.timer/.service + deadline-file wiring.

Phase 3 — Policy data model + merge + preview API + feature module. Files: new backend/app/models/firewall.py, migration, new backend/app/api/v1/appliance/firewall.py, feature_modules.py, app/api/v1/router.py.

Phase 4 — Fleet UX. Files: new frontend/src/components/FirewallSection.tsx, frontend/src/components/FirewallRuleBuilder.tsx, new frontend/src/pages/appliance/FirewallTab.tsx, FleetTab.tsx (effective view + drift table + base badge + linted override), NetworkTab.tsx (fulfil the deferral), lib/api.ts.

Phase 5 — MCP + compliance + drift hardening + docs. Files: new backend/app/services/ai/tools/firewall.py, conformity platform-kind check, host-side etcd-member-list cross-check, docs.


9. Open questions / decisions for the maintainer

  1. Pod-CIDR + base-conf authority. §2.6 has the supervisor parse spatium-cidrs.yaml + hash /etc/nftables.conf and report them (operator-chosen #302 stays authoritative on the owning box, survives control-plane loss). Alternative: the backend reads cluster CIDRs from kubectl get cm. Supervisor-reported is simpler and #5-safe — confirm that’s the call.
  2. firewall_default_input_action. Ship drop-only first (keep base policy drop + floor-first). Defer the Talos-style accept default posture. Confirm.
  3. Web/443 closure. balanced keeps 80/443 LAN-wide (daddr-agnostic for the VIP). True LAN-lockdown of web (locked posture) needs the base 80/443 to also leave the base conf — a second base-conf cut. Ship locked web-scoping in Phase 4 or defer to a later base cut?
  4. Auto-revert default-on for narrowing? Recommendation: yes — fleet-tier narrowing forces a test-apply (no permanent button) until one node confirms via self-probe; widening is optional-test. Confirm.
  5. Static IPs for CP nodes. Recommendation: surface a Fleet warning when a cluster_role!=None appliance is DHCP-addressed (peer-IP churn thrashes drift + can wobble etcd). Make it a hard preflight before base-conf close, or warn-only?
  6. memberlist 7946 derivation. Shipped derived from cp_member_count>=2 AND vip_configured rather than a metallb_enabled flag the supervisor doesn’t mirror. Confirm that’s acceptable, or do we add the flag to the heartbeat?
  7. Phase-1 ship vehicle + rolling-upgrade sequencing. The base-conf cut only lands via A/B slot upgrade. Confirm Phase 1 ships in a release that bumps the slot image, that the rolling OS upgrade sequences so firewall posture “counts” only after every CP node reports the new base marker, and that the conformity check (stale-PASS) is the operator’s per-node signal for which fleet nodes are still on the legacy base.

10. Risk register (residual risks after the fixes above)

# Residual risk Likelihood × impact Mitigation in this design
R1 Self-inflicted SSH lockout via a typo’d mgmt alias or a bad source-scoped accept on 22’s neighbours. Low × High Un-removable SSH floor emitted first + baked (§6.1); write-time lint rejects any drop on 22; base-conf floor stays LAN-wide as the irreducible recovery channel. Auto-revert explicitly does not cover this (honest UX copy, §5).
R2 Mid-rolling-upgrade split-brain (new-slot hardened nodes vs legacy-base LAN-wide nodes) misreported as fully hardened. Medium × Medium Per-node base-conf marker (§2.6) gates the UI “hardened” claim + the conformity verdict; master firewall_enabled flip blocked until all CP nodes report the new base; additive scoped rules are harmless on the legacy base.
R3 flannel/data-plane port still wrong if an operator runs a non-default backend the parser doesn’t recognise. Low × High flannel-backend is read from k3s config, not assumed; unknown backend → fall back to leaving the data-plane LAN-reachable (fail-open for the data plane) + raise a Fleet warning rather than silently dropping pod traffic.
R4 etcd quorum wobble from an nft -f reload dropping conntrack-new etcd connections during a peer-set change on a busy cluster. Low × Medium Named saddr sets + nft add/delete element (not full reload) for peer-set-only changes; canonical-hash debounce; static-IP recommendation for CP nodes (R-adjacent: DHCP churn warning).
R5 Air-gap manual join when the cluster is already multi-node and the bootstrap sentinel has retired. Low × Medium Bootstrap retires only at cp_member_count>=2 AND no join window; Fleet “allow LAN 6443 for join” audited auto-expiring toggle re-opens a bounded window without a pre-existing DB row; documented runbook (Phase 5).
R6 Effective-view misread as the true kernel ruleset when kube-proxy NodePort/LoadBalancer paths exist outside our inet filter table. Medium × Low View labeled “SpatiumDDI-managed input rules”; supervisor ships NodePort/LB-presence summary; docs scope the firewall to host + hostNetwork-pod inbound, NetworkPolicy for pod traffic.
R7 Dual-stack v6 etcd hole/outage if some CP nodes have a v6 InternalIP and the firewall set is asymmetric. Low × High node_ips reports all families; v6 rules emitted only when the v6 peer set is non-empty; preflight check flags asymmetric v6 peer sets before the base-conf close.
R8 Concurrent writers (operator + copilot + rolling OS upgrade) racing renders/reverts. Low × Medium Fleet-firewall coordination Lease for any >1-node apply; refuse new apply while that or the OS-upgrade Lease is held; confirm-before-advance fan-out bounds split-brain to one node.
R9 Supervisor wedged (control plane up) so a critical lockdown never lands on a node. Low × Medium firewall.apply_stalled typed alert after N stale heartbeats, cross-referenced with Wave E external-watchdog state; differentiated apply-state chip.
R10 firewall_extra shadowed rule an expert expects to take effect but never matches. Medium × Low Allowlist grammar (write-time 422 on malformed); nft --check + structural shadow-analysis in preview surfaces “will never match”; documented as strictly additive/last.
R11 Demote/leave strands if cluster_join_state never reaches left (e.g. a force-removed node). Low × Medium Asymmetric-on-leave keeps peer ports open until left; the existing leave runbook + a Fleet warning if a desired_cluster_role='none' row stays non-left past a timeout.

11. Expanded issue #285 text (ready-to-paste GitHub issue body)

Title: Appliance: fine-grained, per-role, fleet-wide firewall (declarative policy → nftables); close LAN-wide k3s-HA ports as Phase 1

Context

The appliance base firewall (appliance/mkosi.extra/etc/nftables.conf) unconditionally accepts the k3s control-plane/etcd/kubelet ports from any source:

tcp dport { 6443, 2379, 2380, 10250 } accept comment "k3s-ha"

This is intentional today — a joining node must reach these before the supervisor can render a peer-scoped drop-in (a bootstrap chicken-and-egg), and it matches k3s’s own posture (token + mTLS on 6443, client-cert on etcd). But it exposes etcd and the kubelet LAN-wide. The supervisor already renders a peer-scoped drop-in for these ports (firewall_renderer.py, #272 Phase 7b) — except nftables is first-match-wins, so that scoped rule is dead code stacked behind the base accept that already fired. The only way to actually close these ports is to make the rendered drop-in authoritative and reduce the base conf to a floor.

The broader operator ask: fine-grained firewall control per node role — a frontend/control node, a DNS-only worker, and a DHCP-only worker should each expose only what that role needs, authored once and rolled fleet-wide.

Goal

A first-class declarative firewall policy authored on the control plane in three additive layers (fleet baseline → per-role overlay keyed on the same taxonomy as the spatium.io/role-* node labels → per-appliance override), compiled server-side into the authoritative spatium-role.nft drop-in, riding the existing supervisor heartbeat → trigger-file → spatium-firewall-reload plane (the SNMP/NTP/LLDP pattern). Source scoping is a source_kind enum whose derived scopes (cluster_peers / pod_cidr / mgmt / vip) resolve per-node at render time, so promote/demote re-renders automatically and the firewall tightens on demote with zero per-appliance edits. Injection-proof by construction (rules are rendered, never pasted); honours #5 (last-good cache survives control-plane loss), #13 (MCP), #14 (feature module), #16 (role taxonomy).

Verified prerequisites (must land before any port is closed)

Phased deliverables

Phase 1 — close #285 (no new tables, no UI)

Phase 2 — server-side render + auto-revert safety

Phase 3 — policy data model + merge + preview API + feature module

Phase 4 — Fleet UX

Phase 5 — MCP + compliance + docs

Key design decisions

Supersedes the original “scope 6443/2379/2380/10250 to peer CIDRs” framing — that is now Phase 1.