Boot fallback, flowtable devices that come and go, healthz, renames

The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 23:16:46 +01:00
1 parent 835a9ba2cc
commit 9d766b9823
38 files changed
+1335 -84

No files matched your search

+64 -47
View File
@@ -940,8 +940,10 @@ func (im *importer) run() {
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
im.st.Settings.Builtins = model.Builtins{}
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
var deferred []func()
found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
var chains []string
rest := map[string][]*parsed{} // chain → the rules after its head
for _, b := range bases {
rules := im.flatten(b.name, nil, 0)
switch b.kind {
@@ -954,9 +956,9 @@ func (im *importer) run() {
im.srcnat(p)
}
default:
chains = append(chains, b.kind)
prefix := true
for _, p := range rules {
p := p
if bi := im.builtinOf(b.kind, p); bi != "" {
if found[bi] == nil {
found[bi] = map[string]string{}
@@ -967,18 +969,25 @@ func (im *importer) run() {
}
found[bi][b.kind] = where
if prefix {
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
continue
}
}
prefix = false
kind := b.kind
deferred = append(deferred, func() { im.rule(kind, p) })
rest[b.kind] = append(rest[b.kind], p)
}
}
}
im.settleBuiltins(found)
for _, f := range deferred {
f()
im.settleBuiltins(found, heads)
for _, chain := range chains {
for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.rule(chain, h.p)
}
}
for _, p := range rest[chain] {
im.rule(chain, p)
}
}
if len(im.flow) > 0 {
im.st.Settings.FlowtableDevices = im.flow[0].Dev
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
return ""
}
// headRule is a rule at the head of a chain that is a built-in.
type headRule struct {
builtin string
p *parsed
}
// settleBuiltins turns on the built-ins found where they go, in every
// chain they apply to; the others stay off, and the rules that looked
// like them are kept as rules.
func (im *importer) settleBuiltins(found map[string]map[string]string) {
// like them are kept as rules. Built-ins come before every rule, so once
// one at a chain's head stays a rule, the ones after it there stay rules
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
// ahead of dropping invalid packets) would let through what the drop
// stopped.
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
b := &im.st.Settings.Builtins
on := func(name string, chains ...string) bool {
for _, c := range chains {
@@ -1252,14 +1271,36 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
}
return true
}
b.Loopback = on("loopback", model.ChainInput)
b.Established = on("established", model.ChainInput, model.ChainForward)
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
b.PingInternal = on("ping", model.ChainInput)
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
b.MSSClamp = on("mss-clamp", model.ChainForward)
b.Antispoof = on("antispoof", model.ChainEarly)
for {
b.Loopback = on("loopback", model.ChainInput)
b.Established = on("established", model.ChainInput, model.ChainForward)
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
b.PingInternal = on("ping", model.ChainInput)
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
b.MSSClamp = on("mss-clamp", model.ChainForward)
b.Antispoof = on("antispoof", model.ChainEarly)
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}
changed := false
for chain, hs := range heads {
cut := false
for _, h := range hs {
// mss-clamp and flowtable decide nothing: their place
// doesn't matter.
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
cut = true
}
if cut && found[h.builtin][chain] == "prefix" {
found[h.builtin][chain] = "demoted"
changed = true
}
}
}
if !changed {
break
}
}
if !b.Antispoof {
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
} else if im.st.Settings.AntispoofV6 == nil {
@@ -1268,40 +1309,16 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
if b.ICMPv6 {
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
}
// The ones found in one chain's head but not another's: explicit rules
// in the chains where they were found.
for name, chains := range found {
for chain, where := range chains {
if where != "prefix" {
continue
}
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
if !kept {
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
im.builtinRule(name, chain)
// The ones that stay rules, in their place: say which.
for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
}
}
}
}
// builtinRule writes a built-in that stays off as a rule of its chain.
func (im *importer) builtinRule(name, chain string) {
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
switch name {
case "established":
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
case "invalid":
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
case "loopback":
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
default:
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
return
}
im.st.Rules = append(im.st.Rules, r)
}
func (im *importer) nextPos(chain string) float64 {
im.positions[chain]++
return im.positions[chain]
+15 -2
View File
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
if s := st.Set("masq_to_internet"); s == nil {
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
}
// Built-ins: the ones at the head of their chains.
// Built-ins: the ones at the head of their chains, up to the invalid
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
// accepts after it stay rules too, in their order, so nothing the
// drop stopped gets through ahead of it.
b := st.Settings.Builtins
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
t.Errorf("built-ins %+v", b)
}
var input []string
for _, r := range st.Rules {
if r.Chain == model.ChainInput {
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
}
}
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
t.Errorf("input rules start %q", input)
}
// The forward chain's asymmetric-routing rule sits between the
// established and invalid rules: invalid stays rules, in order.
if b.DropInvalid {