Files
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

76 lines
2.4 KiB
Go

package render
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/model"
)
func TestFallback(t *testing.T) {
st := router()
text := Fallback(st)
for _, want := range []string{
"delete table inet housecarl",
"type filter hook input priority filter; policy accept;",
`iifname "wan" counter drop comment "hc:fallback-input"`,
`iifname "wan" counter drop comment "hc:fallback-forward"`,
`iifname "wan" ip6 saddr fe80::/10 udp dport 546 counter accept`,
`ip6 hoplimit 255 counter accept`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
`meta nfproto ipv4 oifname "wan" counter masquerade comment "hc:fallback-masquerade"`,
} {
if !strings.Contains(text, want) {
t.Errorf("no %q in\n%s", want, text)
}
}
// Nothing that could fail to load before the network is up.
for _, not := range []string{"flowtable", "@", "\tset ", "enp2s0", "wg0"} {
if strings.Contains(text, not) {
t.Errorf("%q in the fallback:\n%s", not, text)
}
}
// Established connections are accepted before the drops.
if strings.Index(text, "ct state established,related accept") > strings.Index(text, "hc:fallback-input") {
t.Errorf("established after the drop:\n%s", text)
}
// No source NAT: no masquerade.
st.SNATs = nil
if text := Fallback(st); strings.Contains(text, "masquerade") {
t.Errorf("masquerade without source NAT:\n%s", text)
}
// No external zone: everything but loopback is dropped.
for _, z := range st.Zones {
z.External = false
}
text = Fallback(st)
if strings.Count(text, "policy drop;") != 2 || strings.Contains(text, "policy accept") || strings.Contains(text, "established") {
t.Errorf("no external zone:\n%s", text)
}
}
func TestFallbackPassesNft(t *testing.T) {
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set")
}
closed := router()
for _, z := range closed.Zones {
z.External = false
}
wild := router()
wild.Zones = append(wild.Zones, &model.Zone{ID: 99, Name: "lte", Interfaces: []string{"wwan*"}, External: true})
for name, st := range map[string]*model.State{"router": router(), "closed": closed, "wildcard": wild} {
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(Fallback(st))
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("%s: nft -c: %v\n%s\n%s", name, err, out, Fallback(st))
}
}
}