package render import ( "context" "os" "os/exec" "strings" "testing" "housecarl/internal/model" ) // router is a configuration like the maintainer's router: PPPoE WAN, // a LAN, WireGuard, Incus bridges, port forwards with hairpin NAT and an // IPv6 pinhole, plus the common features the tests exercise. func router() *model.State { st := model.NewState() st.Settings.Builtins.MSSClamp = true st.Settings.OwnPrefixes = []string{"2a02:8013:7f3d::/48"} st.Settings.FlowtableDevices = []string{"lo"} st.Settings.LogPolicyDrops = true id := int64(0) next := func() int64 { id++; return id } st.Zones = []*model.Zone{ {ID: next(), Name: "wan", Interfaces: []string{"wan"}, External: true}, {ID: next(), Name: "lan", Interfaces: []string{"enp2s0"}}, {ID: next(), Name: "wg", Interfaces: []string{"wg0"}}, {ID: next(), Name: "incus", Interfaces: []string{"incusbr*"}}, } st.Sets = []*model.AddressSet{ {ID: next(), Name: "trusted", Entries: []string{"10.0.10.0/24", "10.20.30.0/24", "10.50.50.0/24", "2a02:8013:7f3d:10::/64"}}, {ID: next(), Name: "wg_net", Entries: []string{"10.50.50.0/24"}}, {ID: next(), Name: "wg_admins", Entries: []string{"10.50.50.3", "10.50.50.6"}}, {ID: next(), Name: "dns_servers", Entries: []string{"10.0.10.5", "217.155.8.194", "fdab:abab:abab:10::5", "2a02:8013:7f3d:10::5"}}, {ID: next(), Name: "guests", Dynamic: true, Timeout: 3600}, } st.Services = []*model.Service{ {ID: next(), Name: "jarl", Entries: []string{"tcp/8053"}}, {ID: next(), Name: "doh-dot", Entries: []string{"tcp/8443", "tcp/853"}}, } pos := map[string]float64{} rule := func(chain string, r model.Rule) { pos[chain]++ r.ID, r.Chain, r.Position = next(), chain, pos[chain] if r.Action == "" { r.Action = model.ActionAccept } st.Rules = append(st.Rules, &r) } rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"wireguard"}}, CtState: []string{"new"}, Comment: "WireGuard"}) rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Services: []string{"dns"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_admins"}, Services: []string{"ssh"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Destinations: []string{"10.50.50.1"}}, Action: model.ActionDrop}) rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"dns"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{Destinations: []string{"dns_servers"}, Services: []string{"doh-dot"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh", "jarl", "tcp/8067"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Services: []string{"dhcp"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Sources: []string{"fe80::/10"}, Services: []string{"dhcpv6"}}}) rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"wan"}, Services: []string{"ssh"}}, Action: model.ActionDrop, Limit: &model.Limit{Rate: 3, Per: "minute", Burst: 5, Over: true, PerSource: true}, Log: true}) rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"ssh"}}, ConnLimit: 10, Action: model.ActionReject, RejectWith: "tcp-reset"}) rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"http"}}, Days: []string{"monday", "Friday"}, HourFrom: "22:00", HourTo: "06:00"}) rule(model.ChainForward, model.Rule{Match: model.Match{NotIn: true, InZones: []string{"wan"}, Destinations: []string{"trusted"}}, CtState: []string{"invalid"}, Comment: "asymmetric routing"}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}, Sources: []string{"2a02:8013:7f3d:10::/64"}}}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, Comment: "nothing else in"}) rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop}) rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}}) rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}}) rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop}) rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"}) st.Forwards = []*model.PortForward{ {ID: next(), Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", PublicAddresses: []string{"217.155.8.194"}, To: "10.0.10.57", ToPort: "443", To6: "2a02:8013:7f3d:10::57", Hairpin: true, HairpinSources: []string{"10.0.10.0/24"}}, {ID: next(), Position: 2, Name: "terraria", Protocols: []string{"tcp", "udp"}, Ports: "7777-7780", PublicAddresses: []string{"217.155.8.194"}, To: "10.0.10.76", Hairpin: true}, {ID: next(), Position: 3, Name: "smtp", Protocols: []string{"tcp"}, Ports: "25", To: "10.0.10.25", Sources: []string{"trusted"}}, } st.SNATs = []*model.SourceNAT{ {ID: next(), Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}, Comment: "external masquerade"}, {ID: next(), Position: 2, Match: model.Match{NotOut: true, OutZones: []string{"wg"}, Sources: []string{"wg_net"}}}, {ID: next(), Position: 3, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"fdab:abab:abab::/48"}, Family: "ipv6"}, To: "2a02:8013:7f3d::1"}, } st.Redirects = []*model.Redirect{ {ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"}, } st.Sort() return st } func TestRenderRouter(t *testing.T) { rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}}) text := rs.Text for _, want := range []string{ "table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {", `set trusted_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 10.0.10.0/24, 10.20.30.0/24, 10.50.50.0/24 } }`, `set trusted_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`, `set guests_v4 { type ipv4_addr; flags timeout; timeout 3600s; }`, `iifname "wan" ip saddr { 0.0.0.0/8,`, `iifname "wan" ip6 saddr { ::1/128, ::/128, fc00::/7, 2001:db8::/32, ff00::/8, 2a02:8013:7f3d::/48 } counter drop`, `oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "hc:b-mss-clamp"`, `udp dport 51820 ct state new counter accept comment "hc:r`, // DNS from the WireGuard network: v4 only, both protocols. `ip saddr @wg_net_v4 udp dport 53 counter accept`, `ip saddr @wg_net_v4 tcp dport 53 counter accept`, // A set holding both families: one rule per family. `ip daddr @dns_servers_v4 tcp dport { 8443, 853 } counter accept`, `ip6 daddr @dns_servers_v6 tcp dport { 8443, 853 } counter accept`, `ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`, `ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`, `iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`, `iifname "wan" meta nfproto ipv4 tcp dport 22 add @meter_r`, `ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`, `tcp dport 22 add @conn_r`, `counter reject with tcp reset comment`, `meta day { "Monday", "Friday" } meta hour != "06:00"-"21:59:59"`, `iifname != "wan" ip daddr @trusted_v4 ct state invalid counter accept comment "hc:r`, `icmp type echo-request counter accept`, `icmpv6 type echo-request counter accept`, `ip saddr @guests_v4 counter accept`, `ip6 saddr @guests_v6 counter accept`, `meta skuid 0 counter accept`, `ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443 comment "hc:f`, `ip daddr 217.155.8.194 udp dport 7777-7780 counter dnat ip to 10.0.10.76 comment`, `iifname "wan" meta nfproto ipv4 ip saddr @trusted_v4 tcp dport 25 counter dnat ip to 10.0.10.25`, `ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter accept`, `ip6 daddr 2a02:8013:7f3d:10::57 tcp dport 443 counter accept`, `ip saddr 10.0.10.0/24 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`, `iifname != "wan" ip daddr 10.0.10.76 udp dport 7777-7780 ct status dnat counter masquerade`, `oifname "wan" ip saddr @trusted_v4 counter masquerade`, `oifname != "wg0" ip saddr @wg_net_v4 counter masquerade`, `oifname "wan" ip6 saddr fdab:abab:abab::/48 counter snat ip6 to 2a02:8013:7f3d::1`, `iifname "enp2s0" ip daddr != 10.0.10.5 udp dport 53 counter redirect to :53 comment "hc:d`, `limit rate 10/second burst 20 packets log prefix "hc forward policy drop: " group 4171`, `counter comment "hc:p-forward"`, `flowtable ft { hook ingress priority filter; devices = { "lo" }; }`, `add element inet housecarl guests_v4 { 10.0.30.5 timeout 1700s }`, } { if !strings.Contains(text, want) { t.Errorf("missing %q", want) } } // The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN // isn't wanted (its sources are global). if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") { t.Errorf("masquerade rendered for IPv6 too") } if t.Failed() { t.Log(text) } // Lines map back to their objects. for i, l := range strings.Split(text, "\n") { if strings.Contains(l, "8096 counter dnat") && rs.Object(i+1) != "jellyfin" { t.Errorf("line %d belongs to %q", i+1, rs.Object(i+1)) } } } // TestRenderedRulesetPassesNft has the real nft check the router's // ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g. // // HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft" // // (nft 1.1.3, Debian 13's), or run as root with nft installed. func TestRenderedRulesetPassesNft(t *testing.T) { cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")) if len(cmd) == 0 { t.Skip("HOUSECARL_NFT not set") } for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} { rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}}) c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c.Stdin = strings.NewReader(rs.Text) if out, err := c.CombinedOutput(); err != nil { t.Errorf("%s: nft -c: %v\n%s", name, err, out) for i, l := range strings.Split(rs.Text, "\n") { t.Logf("%3d %s", i+1, l) } } } }