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

368 lines
14 KiB
Go

package nft
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"housecarl/internal/model"
"housecarl/internal/render"
)
func TestParseTable(t *testing.T) {
data := []byte(`{"nftables": [{"metainfo": {"version": "1.1.3"}},
{"set": {"family": "inet", "name": "guests_v4", "table": "housecarl", "type": "ipv4_addr", "flags": ["timeout"],
"elem": [{"elem": {"val": "10.0.30.5", "timeout": 1800, "expires": 1799}}, "10.0.30.6"]}},
{"set": {"family": "inet", "name": "meter_r4_v4", "table": "housecarl", "elem": ["10.0.0.1"]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
"expr": [{"counter": {"packets": 5, "bytes": 300}}, {"accept": null}]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
"expr": [{"counter": {"packets": 2, "bytes": 100}}, {"accept": null}]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "not ours",
"expr": [{"counter": {"packets": 9, "bytes": 900}}]}}]}`)
tb, err := ParseTable(data)
if err != nil {
t.Fatal(err)
}
if c := tb.Counters["r12"]; c.Packets != 7 || c.Bytes != 400 || len(tb.Counters) != 1 {
t.Errorf("counters %+v", tb.Counters)
}
if e := tb.Elements["guests"]; len(e) != 2 || e[0] != (render.Element{Addr: "10.0.30.5", Timeout: 1799}) || e[1].Addr != "10.0.30.6" || len(tb.Elements) != 1 {
t.Errorf("elements %+v", tb.Elements)
}
}
func TestExplain(t *testing.T) {
rs := &render.Ruleset{Lines: map[int]string{10: "input #4 (ssh)"}}
err := explain(&Error{Output: "/dev/stdin:11:3-12: Error: syntax error, unexpected junk\n\t\tjunk\n"}, rs)
if err.Error() != "input #4 (ssh): syntax error, unexpected junk" {
t.Errorf("explained: %v", err)
}
}
// TestFirewallInContainer applies rulesets in a container with nft
// (HOUSECARL_NFT_IMAGE, e.g. housecarl-nft: Debian 13 with nftables):
// dynamic sets keep their entries across a reload, counters come back,
// and a broken rule is reported against its object.
func TestFirewallInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.Sets = []*model.AddressSet{{ID: 2, Name: "guests", Dynamic: true, Timeout: 3600}}
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"guests"}}}}
if err := fw.Check(ctx, st); err != nil {
t.Fatalf("check: %v", err)
}
if _, err := fw.Table(ctx); err != ErrNoTable {
t.Errorf("before applying, the table: %v", err)
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if err := fw.Runner.AddElement(ctx, "guests", "127.0.0.2", 600); err != nil {
t.Fatal(err)
}
if err := fw.Runner.AddElement(ctx, "guests", "::2", 0); err != nil {
t.Fatal(err)
}
exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
// Reapplying keeps the entries.
st.Settings.ForwardPolicy = model.PolicyAccept
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("reapply: %v", err)
}
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var got []string
for _, e := range tb.Elements["guests"] {
got = append(got, e.Addr)
if e.Addr == "127.0.0.2" && (e.Timeout < 500 || e.Timeout > 600) {
t.Errorf("timeout after reload: %+v", e)
}
}
if strings.Join(got, ",") != "127.0.0.2,::2" {
t.Errorf("entries after reload: %v", got)
}
if _, ok := tb.Counters["r3"]; !ok {
t.Errorf("counters %+v", tb.Counters)
}
if tb.Counters["b-established"].Packets == 0 && tb.Counters["r3"].Packets == 0 {
t.Logf("no packets counted (ping may be missing): %+v", tb.Counters)
}
// A broken raw rule: refused, named, and nothing changes.
st.Rules = append(st.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "tcp dport bogus accept", Comment: "broken"})
err = fw.Check(ctx, st)
if err == nil || !strings.Contains(err.Error(), "input #4 (broken)") {
t.Errorf("check of a broken rule: %v", err)
}
if err := fw.Apply(ctx, st); err == nil {
t.Error("a broken ruleset was applied")
}
tb, _ = fw.Table(ctx)
if len(tb.Elements["guests"]) != 2 {
t.Errorf("a refused apply changed the table: %+v", tb.Elements)
}
// The table is Housecarl's alone: another one is left alone.
exec.Command("docker", "exec", name, "nft", "add", "table", "inet", "other").Run()
fw.Apply(ctx, st.Clone())
if out, _ := exec.Command("docker", "exec", name, "nft", "list", "tables").CombinedOutput(); !strings.Contains(string(out), "inet other") {
t.Errorf("another table was touched: %s", out)
}
}
// TestQuotasInContainer: quotas keep what they used across a reload,
// reset by hand, and come back after a reboot from what was saved.
func TestQuotasInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-quota-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
// A total quota on pings to 127.0.0.2, and one per destination on
// pings to 127.0.0.3.
st.Rules = []*model.Rule{
{ID: 1, Chain: model.ChainOutput, Position: 1, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.2"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true}},
{ID: 2, Chain: model.ChainOutput, Position: 2, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.3"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true, PerDestination: true}},
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
ping := func(addr string) {
exec.Command("docker", "exec", name, "ping", "-c", "3", "-s", "1000", "-W", "1", addr).Run()
}
ping("127.0.0.2")
ping("127.0.0.3")
used := func() (int64, int64) {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var dev int64
for _, e := range tb.QuotaSets["quota_r2_v4"] {
if e.Addr == "127.0.0.3" {
dev = e.Used
}
}
return tb.Quotas["quota_r1"], dev
}
total, dev := used()
if total < 3000 || dev < 3000 {
t.Fatalf("used: total %d, per destination %d", total, dev)
}
// A reload (a change) keeps what was used.
st.Rules[0].Comment = "changed"
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t2, d2 := used(); t2 < total || d2 < dev {
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
}
// Reset by hand.
if err := fw.ResetQuota(ctx, 1, false); err != nil {
t.Fatal(err)
}
if err := fw.ResetQuota(ctx, 2, true); err != nil {
t.Fatal(err)
}
if t3, d3 := used(); t3 != 0 || d3 != 0 {
t.Errorf("after a reset: total %d, per destination %d", t3, d3)
}
// A reboot: the table is gone (the boot ruleset has no usage); the
// saved usage comes back.
ping("127.0.0.2")
ping("127.0.0.3")
tb, _ := fw.Table(ctx)
saved := tb.QuotaUsage
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
fw.RestoreQuotas(&saved)
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t4, d4 := used(); t4 < saved.Quotas["quota_r1"] || t4 == 0 || d4 == 0 {
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
}
}
// TestDeviceCountsInContainer: the per-device counters are read back and
// survive a reload.
func TestDeviceCountsInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-stats-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.Settings.StatsPerDevice = true
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if out, err := exec.Command("docker", "exec", name, "nft", "add", "element", "inet", "housecarl", "stats_up_v4", "{ 10.0.10.5 counter packets 7 bytes 7000 }").CombinedOutput(); err != nil {
t.Fatalf("adding an element: %v %s", err, out)
}
read := func() render.Element {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
for _, e := range tb.DeviceSets["stats_up_v4"] {
if e.Addr == "10.0.10.5" {
return e
}
}
t.Fatalf("no 10.0.10.5 in %+v", tb.DeviceSets)
return render.Element{}
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 || e.Timeout <= 0 {
t.Fatalf("read back %+v", e)
}
st.Settings.InputPolicy = model.PolicyAccept // a change: a reload
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 {
t.Errorf("after a reload: %+v", e)
}
}
// TestFlowtableStale: the flowtable in force goes stale when one of its
// devices appears, goes, or comes back as a new interface.
func TestFlowtableStale(t *testing.T) {
idx := map[string]int{"eth1": 3}
old := ifIndex
ifIndex = func(name string) int { return idx[name] }
t.Cleanup(func() { ifIndex = old })
exists := func() map[string]bool {
out := map[string]bool{}
for n, i := range idx {
out[n] = i > 0
}
return out
}
// "true" stands in for nft: every apply succeeds, and lists nothing.
fw := &Firewall{Runner: &Runner{Command: []string{"true"}}, Interfaces: exists}
st := model.NewState()
st.Settings.FlowtableDevices = []string{"ppp0", "eth1"}
if fw.FlowtableStale(st) {
t.Error("stale before anything was applied")
}
if err := fw.Apply(context.Background(), st); err != nil {
t.Fatal(err)
}
if fw.FlowtableStale(st) {
t.Error("stale right after applying")
}
idx["ppp0"] = 7 // PPPoE came up
if !fw.FlowtableStale(st) {
t.Error("ppp0 appeared, yet not stale")
}
fw.Apply(context.Background(), st)
idx["ppp0"] = 9 // and reconnected: a new interface by the same name
if !fw.FlowtableStale(st) {
t.Error("ppp0 came back as a new interface, yet not stale")
}
fw.Apply(context.Background(), st)
idx["eth9"] = 4 // not a flowtable device
if fw.FlowtableStale(st) {
t.Error("stale over an interface the flowtable doesn't use")
}
}
// TestBootInContainer: the boot ruleset loads although its flowtable
// devices don't exist yet, the fallback loads and is recognised, and
// housecarld's apply replaces it.
func TestBootInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-boot-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
nft := func(script string) error {
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
c.Stdin = strings.NewReader(script)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
dir := t.TempDir()
// ppp0 exists when housecarld saves, but not at boot.
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, BootFile: filepath.Join(dir, "ruleset.nft"),
Interfaces: func() map[string]bool { return map[string]bool{"ppp0": true, "eth0": true} }}
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"ppp0"}, External: true}, {ID: 2, Name: "lan", Interfaces: []string{"eth0"}}}
st.SNATs = []*model.SourceNAT{{ID: 3, Match: model.Match{OutZones: []string{"wan"}}}}
st.Settings.FlowtableDevices = []string{"ppp0", "eth0"}
if err := fw.SaveBoot(st); err != nil {
t.Fatal(err)
}
boot, _ := os.ReadFile(fw.BootFile)
if strings.Contains(string(boot), "flowtable") {
t.Errorf("the boot ruleset has a flowtable:\n%s", boot)
}
fallback, err := os.ReadFile(filepath.Join(dir, "ruleset-fallback.nft"))
if err != nil {
t.Fatal(err)
}
if err := nft(string(boot)); err != nil {
t.Fatalf("the boot ruleset doesn't load: %v", err)
}
ctx := context.Background()
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("after the boot ruleset: %+v %v", tb, err)
}
if err := nft(string(fallback)); err != nil {
t.Fatalf("the fallback doesn't load: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
t.Errorf("the fallback isn't recognised: %+v %v", tb, err)
}
fw.Interfaces = func() map[string]bool { return map[string]bool{"eth0": true} }
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply over the fallback: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
}
}