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>
368 lines
14 KiB
Go
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)
|
|
}
|
|
}
|