package importer import ( "context" "os" "os/exec" "strings" "testing" "housecarl/internal/render" "housecarl/internal/validate" ) // TestImportAudit: rulesets the importer must not change the meaning of // (testdata/audit.nft, as nft 1.1.3 lists it). func TestImportAudit(t *testing.T) { js, _ := os.ReadFile("testdata/audit.json") txt, _ := os.ReadFile("testdata/audit.txt") defs, _ := os.ReadFile("testdata/audit.nft") res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs), Zones: map[string]string{"enp2s0.20": "iot", "eth9": "x"}}) if err != nil { t.Fatal(err) } st := res.State notes := strings.Join(res.Notes, "\n") text := render.Render(st, render.Options{}).Text has := func(what, s string) { t.Helper() if !strings.Contains(text, s) { t.Errorf("%s: no %q in\n%s", what, s, text) } } // Two chains on the input hook: their rules once each, in priority // order; established stays a rule after the first chain's rule. if n := strings.Count(text, "192.0.2.66"); n != 1 { t.Errorf("the early input chain's rule %d times", n) } if strings.Index(text, "192.0.2.66") > strings.Index(text, "ct state established,related counter accept comment \"hc:r") || !strings.Contains(notes, "both hook input") { t.Errorf("chains on one hook:\n%s", text) } // A second match on a field, and != on the hop limit: raw, as written. has("two source matches", "ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop") has("hop limit", "ip6 hoplimit != 255 drop") // A jump: a rule within it is the rule's own; one outside it, or raw, // keeps the jump's match in front. has("within the jump", `ip saddr 10.9.0.5 tcp dport 22 counter accept comment "hc:r`) has("outside the jump", "ip saddr 10.9.0.0/24 ip saddr 10.8.0.1 tcp dport 23 accept") has("raw under the jump", "ip saddr 10.9.0.0/24 meta mark 0x00000001 accept") // Sets: a timeout set is dynamic; an interface set is written out in // the raw rule that names it. if s := st.Set("blocklist"); s == nil || !s.Dynamic || s.Timeout != 3600 { t.Errorf("blocklist: %+v", s) } has("dynamic set", "ip saddr @blocklist_v4 counter drop") has("interface set", `iifname { "enp2s0", "enp3s0" } accept`) // An accept to a forwarded host that isn't only for forwarded // connections stays a rule; a DNAT of two ports, two forwards. has("not only forwarded", `ip daddr 10.0.10.37 tcp dport 443 counter accept comment "hc:r`) if len(st.Forwards) != 2 || st.Forwards[0].Name != "web-80" || st.Forwards[1].Name != "web-443" { t.Errorf("forwards %+v", st.Forwards) } // Zones: as given (a VLAN), from defines, and why. if z := st.Zone("iot"); z == nil || z.Interfaces[0] != "enp2s0.20" { t.Errorf("zone iot: %+v", st.Zones) } if st.Zone("lan") == nil || st.Zone("wan") == nil { t.Errorf("zones %+v", st.Zones) } for _, want := range []string{"iot (enp2s0.20: as given)", "lan (enp2s0: the define lan_if)", "--zone eth9=x: no rule names"} { if !strings.Contains(notes, want) { t.Errorf("no note %q in\n%s", want, notes) } } if ps := validate.Check(st, nil); validate.HasErrors(ps) { t.Errorf("problems: %+v", validate.Errors(ps)) } // And nftables takes it. cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")) if len(cmd) == 0 { return } c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c.Stdin = strings.NewReader(text) if out, err := c.CombinedOutput(); err != nil { t.Errorf("nft -c: %v\n%s\n%s", err, out, text) } } // TestValidIface: interface names a define may hold, VLANs too. func TestValidIface(t *testing.T) { for s, want := range map[string]bool{"enp2s0": true, "wan": true, "enp2s0.20": true, "br0.4094": true, "veth*": true, "10.0.10.5": false, "example.com": false, "a.b.c": false, "enp2s0.20.30": false, "averyveryverylongname": false} { if validIface(s) != want { t.Errorf("validIface(%q) = %v", s, !want) } } }