Files
Johnathan Corgan 23c6609a6e Ship fips0 nftables security baseline (Linux)
Add a default-deny nftables ruleset for the fips0 mesh interface as
a packaged operator asset, with a companion fips-firewall.service
oneshot unit for systemd hosts. Both are shipped disabled — the
baseline is an operator conffile and the unit is intentionally not
enabled in postinst. Activation is an explicit one-liner:

  sudo systemctl enable --now fips-firewall.service

This is deliberate: silently mutating host firewall state on package
install is hostile across the axes that matter (collisions with
existing operator nftables / Docker / OPNsense rulesets, surprise
behaviour for hosts that already filter elsewhere, conversion of an
explicit security decision into an invisible one). The opt-in
posture preserves operator agency.

The baseline closes a real default-exposure gap: any service on a
mesh host bound to a wildcard address (0.0.0.0 or [::]) is
reachable from every authenticated peer in the mesh by default.
Identity on the mesh is the peer's npub but identity is not
authorization, and the mesh is closer to a shared LAN than to the
public internet. With this filter loaded, the surface is closed
unless a drop-in opens it explicitly.

Baseline shape:
- Early-return for non-fips0 traffic (every other firewall left
  undisturbed)
- conntrack established/related accept (replies to outbound flows)
- ICMPv6 echo-request accept (ping6 reachability)
- include "/etc/fips/fips.d/*.nft" — operator-supplied allowances
- counter drop default

The accompanying docs/fips-security.md lays out the threat model
(npub-authenticated mesh is closer to a shared LAN than to the
public internet — identity is not authorization), the activation
workflow, drop-in extension recipes (allow inbound SSH from a
specific peer fd97:.../128, allow HTTP from one /64, etc), drop
visibility / debugging via the journal log rule and the drop
counter, coexistence with the runtime-managed `inet fips_gateway`
table, what the baseline does NOT cover (outbound, application
auth, mesh handshake ACL = PR #50 / IDEA-0047 territory), and
future cross-OS work (macOS PF baseline, OpenWrt fw4, gateway
abstraction).

Packaging:
- packaging/common/fips.nft       → /etc/fips/fips.nft (conffile)
- packaging/debian/fips-firewall.service → /lib/systemd/system/
- docs/fips-security.md           → /usr/share/doc/fips/
- postinst creates /etc/fips/fips.d/ (mode 0755) on configure
- prerm stops/disables fips-firewall.service on remove/purge

OpenWrt fw4 path and macOS PF baseline are deferred — separate
asymmetries, separate work.
2026-04-30 03:10:56 +00:00

129 lines
5.6 KiB
Plaintext

#!/usr/sbin/nft -f
#
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ fips0 nftables policy — security baseline ║
# ╚═══════════════════════════════════════════════════════════════════╝
#
# Polices ONLY the fips0 mesh interface — every packet not arriving
# on fips0 returns immediately, leaving Docker, Tor, OPNsense, the
# host's main /etc/nftables.conf, and any other firewall undisturbed.
#
# ─── DEFAULT POLICY ─────────────────────────────────────────────────
#
# INBOUND on fips0: DROP everything except —
# • replies to outbound flows we initiated
# (conntrack established/related)
# • ICMPv6 echo-request (ping6 reachability)
# • rules in /etc/fips/fips.d/*.nft drop-ins
#
# OUTBOUND from fips0: unrestricted
#
# Why default-deny: services binding to 0.0.0.0 or [::] accept
# connections on fips0 by default. This baseline ensures that
# accidental exposure of HTTP, SSH, databases, etc. through the
# mesh is impossible without explicit operator action.
#
# ─── LOADING / RELOADING ────────────────────────────────────────────
#
# Apply this file:
#
# sudo nft -f /etc/fips/fips.nft
#
# To make it persist across reboots, load it from your preferred
# mechanism: a systemd unit that runs `nft -f` on start, an include
# from /etc/nftables.conf, etc.
#
# Verify the loaded ruleset:
#
# sudo nft list table inet fips
#
# ─── ADDING INBOUND ALLOWANCES ──────────────────────────────────────
#
# Drop a file into /etc/fips/fips.d/ ending in .nft. Each file is
# included inline into the `inbound` chain at the marked point below.
# Format: chain-context rule lines, one per line. For example:
#
# /etc/fips/fips.d/services.nft
# ────────────────────────────
# tcp dport 22 accept
# tcp dport 80 accept
# udp dport 53 accept
# ip6 saddr fd97:467a::/64 tcp dport 22 accept # SSH from one /64
# ip6 saddr fd97:.../128 tcp dport 8443 accept # one specific peer
#
# After editing, reload:
#
# sudo nft -f /etc/fips/fips.nft
#
# (or trigger your loading mechanism's reload).
#
# Source-based filtering uses the peer's mesh address (npub-derived
# fd97:... format). To find a peer's mesh address, look in their
# fips.pub or query the daemon.
#
# Note: an empty /etc/fips/fips.d/ is fine — the include glob simply
# matches nothing and the baseline policy applies as-is.
#
# ─── DEBUGGING / DROP VISIBILITY ────────────────────────────────────
#
# To see drops in the systemd journal:
# 1. Uncomment the `log` rule near the bottom of the inbound chain.
# 2. Reload: sudo nft -f /etc/fips/fips.nft
# 3. Tail: sudo journalctl -k -f -g "fips drop:"
#
# Drop counter (always on, no log):
# sudo nft list table inet fips
#
# ─── COEXISTENCE WITH OTHER NFTABLES TABLES ─────────────────────────
#
# This table lives in the `inet` family. Other tables you may see:
#
# inet fips_gateway — created by the fips-gateway binary when
# running (separate concern from this file).
# See the fips-gateway design doc for details.
#
# ip / ip6 family — Docker, /etc/nftables.conf, etc.
# Independent of this table.
#
# All hook at standard filter priority (0). Order between same-family
# same-priority tables is determined by load order, but the early-
# return for non-fips0 traffic makes this chain a no-op for anything
# not on fips0, so coexistence is safe.
# ━━━ Idempotent table replacement ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
add table inet fips
flush table inet fips
table inet fips {
chain inbound {
type filter hook input priority 0; policy accept;
# Non-fips0 traffic returns immediately — chain is a no-op
# for anything not arriving on the fips0 mesh interface.
iifname != "fips0" return
# Replies to outbound flows + related ICMPv6 errors
# (Packet Too Big for PMTU, Destination Unreachable, etc.)
ct state established,related accept
# ICMPv6 echo-request — ping6 reachability tests.
meta nfproto ipv6 icmpv6 type echo-request accept
# Operator-supplied allow rules from drop-in files.
# See "ADDING INBOUND ALLOWANCES" in the header.
include "/etc/fips/fips.d/*.nft"
# Optional: log drops to journal (rate-limited).
# Uncomment to enable, then reload via `nft -f`.
#
#log prefix "fips drop: " level info limit rate 10/minute
# Default deny on fips0 inbound. Counter visible via
# `nft list table inet fips`.
counter drop
}
}
# ━━━ END OF FILE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━