package importer import ( "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. 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) } // 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 are kept, noted. if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") { t.Errorf("no note on the overlapping sources: %q", 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) } }