package nft import ( "context" "os" "os/exec" "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) } }