Files
housecarl/internal/render/fallback.go
T
abrown38andClaude Opus 5.5 9d766b9823 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>
2026-10-19 23:16:46 +01:00

90 lines
3.2 KiB
Go

package render
import (
"fmt"
"strings"
"housecarl/internal/model"
)
// FallbackMark starts the comment of every fallback rule ("hc:fallback-…"):
// how housecarld tells the fallback is in force.
const FallbackMark = "fallback"
// Fallback builds the ruleset housecarl-ruleset.service loads at boot when
// the saved one fails to: nothing new gets in from the external zones'
// interfaces, everything else is let through, and IPv4 leaving an
// external zone is masqueraded (when any source NAT is configured), so the
// inside keeps working until housecarld puts the real ruleset back. With
// no external zone it can't tell which interface faces the internet, and
// lets nothing but loopback through.
//
// It is deliberately small: no sets, flowtable, meters or quotas, and
// interfaces by name only, so it loads before they exist.
func Fallback(st *model.State) string {
var b strings.Builder
line := func(format string, args ...any) {
fmt.Fprintf(&b, format, args...)
b.WriteByte('\n')
}
// rule writes a counted rule: what it matches, then its verdict.
rule := func(name, match, verdict string) {
line("\t\t%s counter %s comment \"hc:%s-%s\"", match, verdict, FallbackMark, name)
}
ext := st.ExternalInterfaces()
line("# Housecarl's fallback: loaded at boot only if ruleset.nft fails to load,")
line("# until housecarld starts and puts the real ruleset back.")
line("table inet %s {}", Table)
line("delete table inet %s", Table)
line("table inet %s {", Table)
if len(ext) == 0 {
// Nothing says which interface faces the internet: close it all.
for _, c := range []string{"input", "forward"} {
line("\tchain %s {", c)
line("\t\ttype filter hook %s priority filter; policy drop;", c)
line("\t\tiifname \"lo\" accept")
line("\t\tcounter comment \"hc:%s-%s\"", FallbackMark, c)
line("\t}")
}
line("}")
return b.String()
}
e := ifaces(ext)
line("\tchain input {")
line("\t\ttype filter hook input priority filter; policy accept;")
line("\t\tiifname \"lo\" accept")
line("\t\tct state established,related accept")
rule("icmpv6", fmt.Sprintf("iifname %s icmpv6 type { %s, echo-request, echo-reply }", e, icmpv6Errors), "accept")
rule("icmpv6-nd", fmt.Sprintf("iifname %s icmpv6 type { %s } ip6 hoplimit 255", e, icmpv6ND), "accept")
rule("icmpv6-mld", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 icmpv6 type { %s }", e, icmpv6MLD), "accept")
rule("dhcpv6-client", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 udp dport 546", e), "accept")
rule("input", "iifname "+e, "drop")
line("\t}")
line("\tchain forward {")
line("\t\ttype filter hook forward priority filter; policy accept;")
if st.Settings.Builtins.MSSClamp {
line("\t\toifname %s tcp flags syn tcp option maxseg size set rt mtu", e)
}
line("\t\tct state established,related accept")
rule("forward", "iifname "+e, "drop")
line("\t}")
if masquerades(st) {
line("\tchain srcnat {")
line("\t\ttype nat hook postrouting priority srcnat; policy accept;")
rule("masquerade", "meta nfproto ipv4 oifname "+e, "masquerade")
line("\t}")
}
line("}")
return b.String()
}
// masquerades reports whether any source NAT is configured.
func masquerades(st *model.State) bool {
for _, x := range st.SNATs {
if !x.Disabled {
return true
}
}
return false
}