package importer import ( "fmt" "os" "os/exec" "strings" "testing" "housecarl/internal/model" "housecarl/internal/render" "housecarl/internal/validate" ) // The maintainer's router ruleset (testdata/router.nft), as nft 1.1.3 // lists it in JSON (router.json) and text (router.txt). func importRouter(t *testing.T) *Result { t.Helper() js, _ := os.ReadFile("testdata/router.json") txt, _ := os.ReadFile("testdata/router.txt") defs, _ := os.ReadFile("testdata/router.nft") res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)}) if err != nil { t.Fatal(err) } return res } func TestImportRouter(t *testing.T) { res := importRouter(t) st := res.State for _, n := range res.Notes { t.Log("note:", n) } for _, r := range res.Raw { t.Log("raw:", r) } // Zones, named by the defines; wan is external. wan := st.Zone("wan") if wan == nil || !wan.External || wan.Interfaces[0] != "wan" { t.Errorf("zone wan: %+v", wan) } if z := st.Zone("dhcp"); z == nil || z.Interfaces[0] != "enp2s0" { t.Errorf("zone dhcp: %+v (zones %+v)", z, st.Zones) } // Sets named by the defines. if s := st.Set("trusted_ips"); s == nil || len(s.Entries) != 7 { t.Errorf("set trusted_ips: %+v", s) } if s := st.Set("masq_to_internet"); s == nil { t.Errorf("no set masq_to_internet (sets %v)", setNames(st)) } // Built-ins: the ones at the head of their chains, up to the invalid // drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6 // accepts after it stay rules too, in their order, so nothing the // drop stopped gets through ahead of it. b := st.Settings.Builtins if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof { t.Errorf("built-ins %+v", b) } var input []string for _, r := range st.Rules { if r.Chain == model.ChainInput { input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw) } } if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") || !strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") { t.Errorf("input rules start %q", input) } // The forward chain's asymmetric-routing rule sits between the // established and invalid rules: invalid stays rules, in order. if b.DropInvalid { t.Error("drop_invalid became a built-in, which would put it before the asymmetric-routing rule") } var fwd []string for _, r := range st.Rules { if r.Chain == model.ChainForward { fwd = append(fwd, r.Comment+"|"+strings.Join(r.CtState, ",")) } } if len(fwd) < 2 || fwd[0] != "Needed due to asymmetric routing (internal only)|invalid" || fwd[1] != "|invalid" { t.Errorf("forward rules start %q", fwd) } if st.Settings.OwnPrefixes[0] != "2a02:8013:7f3d::/48" || len(st.Settings.AntispoofV4) != 6 { t.Errorf("antispoof %+v %+v", st.Settings.OwnPrefixes, st.Settings.AntispoofV4) } // Port forwards with their hairpin; the forward-chain accepts are // folded in. if len(st.Forwards) != 5 { t.Fatalf("forwards %+v", st.Forwards) } jf := st.Forwards[0] if jf.Name != "jellyfin" || jf.Ports != "8096" || jf.To != "10.0.10.57" || jf.ToPort != "443" || jf.PublicAddresses[0] != "217.155.8.194" || !jf.Hairpin || jf.HairpinSources[0] != "internal_net" { t.Errorf("jellyfin %+v", jf) } for _, r := range st.Rules { if r.Chain == model.ChainForward && strings.Contains(r.Comment, "Forward") && len(r.CtStatus) > 0 { t.Errorf("forward accept not folded in: %+v", r) } } // Source NAT: the masquerades. if len(st.SNATs) != 2 || st.SNATs[0].OutZones[0] != "wan" || st.SNATs[0].Sources[0] != "masq_to_internet" || !st.SNATs[1].NotOut || st.SNATs[1].OutZones[0] != "wg0" { t.Errorf("snats %+v %+v", st.SNATs[0], st.SNATs[1]) } // The jump to wg0_input is inlined: its rules carry the source. var wgDNS bool for _, r := range st.Rules { if r.Chain == model.ChainInput && len(r.Sources) == 1 && r.Sources[0] == "wg0_net" && strings.Join(r.Services, ",") == "udp/53" { wgDNS = true } } if !wgDNS { t.Errorf("wg0_input not inlined") } // The jump's and the rule's sources both: the rule's lie within the // jump's, so they say it all, without a note. var wgSSH bool for _, r := range st.Rules { if r.Chain == model.ChainInput && strings.Join(r.Services, ",") == "tcp/22" && strings.Join(r.Sources, ",") == "10.50.50.3,10.50.50.6" { wgSSH = true } } if !wgSSH || strings.Contains(strings.Join(res.Notes, "\n"), "jump") { t.Errorf("the WireGuard SSH rule: found %v, notes %q", wgSSH, res.Notes) } // The imported configuration is valid, and renders. if ps := validate.Check(st, nil); validate.HasErrors(ps) { t.Errorf("problems: %+v", validate.Errors(ps)) } text := render.Render(st, render.Options{}).Text for _, want := range []string{ `ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443`, `ip saddr @internal_net_v4 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`, `oifname "wan" ip saddr @masq_to_internet_v4 counter masquerade`, `oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`, `ip saddr @trusted_ips_v4 tcp dport 8053 counter accept comment "hc:r`, } { if !strings.Contains(text, want) { t.Errorf("render: missing %q", want) } } if t.Failed() { t.Log(text) } } func setNames(st *model.State) []string { var out []string for _, s := range st.Sets { out = append(out, s.Name) } return out } func TestImportNeedsATable(t *testing.T) { js := []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"ip","name":"nat"}}]}`) if _, err := Import(Input{JSON: js}); err == nil || !strings.Contains(err.Error(), "several tables") { t.Errorf("several tables: %v", err) } if _, err := Import(Input{JSON: js, Table: "ip nat"}); err != nil { t.Errorf("--table: %v", err) } } func TestForwardNames(t *testing.T) { im := &importer{st: &model.State{}} for _, c := range []string{"Port forward: Jellyfin", "8096 to media", "web", "web", "-"} { im.st.Forwards = append(im.st.Forwards, &model.PortForward{Name: forwardName(c)}) } im.nameForwards() var got []string for _, f := range im.st.Forwards { got = append(got, f.Name) } if want := "jellyfin fwd-8096-to-media web web-2 "; strings.Join(got, " ") != want { t.Errorf("names %q, want %q", strings.Join(got, " "), want) } } // TestImportedRulesetPassesNft: the ruleset rendered from the import is // accepted by the real nft (HOUSECARL_NFT, as in internal/render). func TestImportedRulesetPassesNft(t *testing.T) { cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")) if len(cmd) == 0 { t.Skip("HOUSECARL_NFT not set") } rs := render.Render(importRouter(t).State, render.Options{}) c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c.Stdin = strings.NewReader(rs.Text) if out, err := c.CombinedOutput(); err != nil { t.Errorf("nft -c: %v\n%s", err, out) } } // TestImportMaps: dnat maps become port forwards and verdict maps rules, // one per element (testdata/maps.nft); a map that can't be expanded is // left out with a note, never kept as a raw rule naming it. func TestImportMaps(t *testing.T) { js, _ := os.ReadFile("testdata/maps.json") txt, _ := os.ReadFile("testdata/maps.txt") res, err := Import(Input{JSON: js, Text: string(txt)}) if err != nil { t.Fatal(err) } var fwds []string for _, f := range res.State.Forwards { fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort)) } var rules []string for _, r := range res.State.Rules { rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","), strings.Join(r.Services, ","), r.Action, r.Raw)) } want := []string{ "portforwards-80 tcp/80->192.168.1.50:8080", "portforwards-443 tcp/443->192.168.1.50:", "hosts-2222 tcp/2222->192.168.1.60:", " udp/5000->192.168.1.70:5001", " udp/6000->192.168.1.71:", } if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") { t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) } // In order; the jump in the verdict map is inlined. want = []string{ "input in= from= accept", "input in=lan from= tcp/22 accept", "input in=lan from= tcp/23 drop", "input in=lan from=192.168.1.0/24 tcp/80 accept", "input in=wan from= udp/53 accept", "input in=wan from= udp/123 drop", "input in= from=10.0.0.0/8 accept", "input in= from=192.0.2.1 drop", } if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") { t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) } notes := strings.Join(res.Notes, "\n") for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward", "uses the map @pairs, which couldn't be expanded"} { if !strings.Contains(notes, n) { t.Errorf("note %q missing:\n%s", n, notes) } } if len(res.Raw) > 0 { t.Errorf("raw rules: %v", res.Raw) } if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 { t.Errorf("invalid: %+v", ps) } if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 { c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text) if out, err := c.CombinedOutput(); err != nil { t.Errorf("nft -c: %v\n%s", err, out) } } } // TestImportMeters: meters (per-address limits, connection counts and // quotas), byte rates and quotas become rules' limits and quotas // (testdata/meters.nft); the meters' sets aren't imported as address sets. func TestImportMeters(t *testing.T) { js, _ := os.ReadFile("testdata/meters.json") txt, _ := os.ReadFile("testdata/meters.txt") res, err := Import(Input{JSON: js, Text: string(txt)}) if err != nil { t.Fatal(err) } var got []string for _, r := range res.State.Rules { s := r.Chain if l := r.Limit; l != nil { s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination) } if r.ConnLimit > 0 { s += fmt.Sprintf(" conn %d", r.ConnLimit) } if q := r.Quota; q != nil { s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource) } if r.GroupV4 != 0 || r.GroupV6 != 0 { s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6) } s += " " + r.Action + r.Raw got = append(got, s) } want := []string{ "input accept", // established: only in input here, so a rule "input limit 3 /minute burst 5 over=true src=true dst=false drop", "input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop", "input accept", "forward conn 20 drop", "forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop", "forward quota 1048576000 over=true src=true group 24/0 drop", "forward quota 104857600000 over=true src=false drop", "forward accept", } if strings.Join(got, "\n") != strings.Join(want, "\n") { t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes) } for _, s := range res.State.Sets { if s.Name != "trusted" { t.Errorf("a meter became a set: %s", s.Name) } } if len(res.Raw) > 0 { t.Errorf("raw rules: %v", res.Raw) } if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 { t.Errorf("invalid: %+v", ps) } if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 { c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text) if out, err := c.CombinedOutput(); err != nil { t.Errorf("nft -c: %v\n%s", err, out) } } }