package render import ( "context" "fmt" "net/netip" "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}, {ID: next(), Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com", View: "internal"}}, Within: []string{"lan"}}}, {ID: next(), Name: "printers", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "printer.example.com"}}, Within: []string{"lan"}}}, } 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{InZones: []string{"wg"}, Destinations: []string{"nas", "printers"}}, Comment: "from Jarl"}) rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}}) // Bandwidth: a byte rate, per destination; per /24; quotas, total and // per device. rule(model.ChainForward, model.Rule{Match: model.Match{OutZones: []string{"lan"}}, Action: model.ActionDrop, Limit: &model.Limit{Rate: 5, Unit: "mbytes", Per: "second", Burst: 1, Over: true, PerDestination: true}, Comment: "5 MB/s each"}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, GroupV4: 24, Limit: &model.Limit{Rate: 100, Unit: "kbytes", Per: "minute", Over: true, PerSource: true}, Comment: "per /24"}) rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"guests"}, OutZones: []string{"wan"}}, Action: model.ActionDrop, Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionDrop, Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}, Comment: "device quota"}) rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"10.0.10.66"}}, Action: model.ActionReject, EveryPacket: true, Comment: "blocked now"}) 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.BanLists = []*model.BanList{ {ID: next(), Name: "router", Scope: model.BanScopeFirewall, Never: []string{"trusted"}}, {ID: next(), Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57", "2a02:8013:7f3d:10::57", "dns_servers"}}, {ID: next(), Name: "edge", Scope: model.BanScopeEverything, GroupV6: 48}, {ID: next(), Name: "off", Scope: model.BanScopeEverything, Disabled: true}, } st.Sort() return st } // jarlOptions are the Jarl sets' addresses. var jarlOptions = map[string][]string{"nas": {"10.0.10.20", "10.0.10.21", "2a02:8013:7f3d:10::20"}, "printers": {}} // banOptions are active bans in each ban list, of every port and of one. func banOptions(st *model.State) []Ban { var out []Ban for _, b := range st.BanLists { out = append(out, Ban{List: b.ID, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 3000}, Ban{List: b.ID, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22}, Ban{List: b.ID, Addr: netip.MustParsePrefix("198.51.100.7/32"), Proto: "udp", Port: 53, Timeout: 60}) } return out } // quotaOptions carries quota usage, as a reload does: the guest quota's, // and two devices' of the per-device quota. func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) { q, sets := map[string]int64{}, map[string][]Element{} for _, r := range st.Rules { switch r.Comment { case "guest quota": q[QuotaName(r.ID)] = 123456 case "device quota": sets[QuotaName(r.ID)+"_v4"] = []Element{{Addr: "10.0.10.20", Used: 1 << 20}} sets[QuotaName(r.ID)+"_v6"] = []Element{{Addr: "2a02:8013:7f3d:10::", Used: 2048}} } } return q, sets } func TestRenderRouter(t *testing.T) { st := router() q, qs := quotaOptions(st) rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs, JarlSets: jarlOptions}) 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; }`, // Jarl sets: their addresses now, or empty. `set nas_v4 { type ipv4_addr; elements = { 10.0.10.20, 10.0.10.21 } }`, `set nas_v6 { type ipv6_addr; elements = { 2a02:8013:7f3d:10::20 } }`, "set printers_v4 { type ipv4_addr; }\n", `iifname "wg0" ip daddr @nas_v4 counter accept`, `iifname "wg0" ip6 daddr @printers_v6 counter accept`, `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 update @meter_r`, `ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets }`, `oifname "enp2s0" meta nfproto ipv4 update @meter_r`, `{ ip daddr limit rate over 5 mbytes/second burst 1 mbytes } counter drop comment "hc:r`, `{ ip saddr and 255.255.255.0 limit rate over 100 kbytes/minute } counter drop`, `quota name "quota_r`, `{ over 50000 mbytes used 123456 bytes }`, `flags dynamic; size 65535; elements = { 10.0.10.20 quota over 5120 mbytes used 1048576 bytes } }`, `elements = { 2a02:8013:7f3d:10:: quota over 5120 mbytes used 2048 bytes } }`, `{ ip6 saddr and ffff:ffff:ffff:ffff:: quota over 5120 mbytes } counter drop comment "hc: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) } } // Rules that count traffic see every packet: in forward, after the // MSS clamp and before established connections are accepted. fwd := text[strings.Index(text, "chain forward {"):] quota, est, mss := strings.Index(fwd, `quota name "quota_r`), strings.Index(fwd, "hc:b-established"), strings.Index(fwd, "hc:b-mss-clamp") if quota < 0 || !(mss < quota && quota < est) { t.Errorf("the quota rule isn't between the MSS clamp and established (%d, %d, %d)", mss, quota, est) } if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est { t.Error("the byte rate rule comes after established") } if now := strings.Index(fwd, "blocked now"); now < 0 || now > est { t.Error("the every-packet rule comes after established") } // 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)) } } } func TestRenderDeviceCounts(t *testing.T) { st := router() st.Settings.StatsPerDevice = true text := Render(st, Options{DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}}}).Text for _, want := range []string{ `set stats_up_v4 { type ipv4_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; elements = { 10.0.10.5 timeout 3000s counter packets 7 bytes 7000 } }`, `set stats_down_v6 { type ipv6_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; }`, `iifname != "wan" oifname "wan" update @stats_up_v4 { ip saddr } comment "hc:b-stats"`, `iifname "wan" oifname != "wan" update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: } comment "hc:b-stats"`, } { if !strings.Contains(text, want) { t.Errorf("missing %q", want) } } // Counted before the established built-in, so every packet is. fwd := text[strings.Index(text, "chain forward {"):] if i := strings.Index(fwd, "update @stats_up_v4"); i < 0 || i > strings.Index(fwd, "hc:b-established") { t.Error("the counting comes after the established built-in") } st.Settings.StatsPerDevice = false if strings.Contains(Render(st, Options{}).Text, "stats_") { t.Error("counting without the setting") } } // 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") } stats := router() stats.Settings.StatsPerDevice = true // VLAN sub-interfaces: zones of one VLAN, of all of a parent's, and // the flowtable on one. vlan := router() vlan.Zones = append(vlan.Zones, &model.Zone{ID: 900, Name: "iot", Interfaces: []string{"enp2s0.20"}}, &model.Zone{ID: 901, Name: "vlans", Interfaces: []string{"enp3s0.*"}}) vlan.Rules = append(vlan.Rules, &model.Rule{ID: 902, Chain: model.ChainForward, Position: 99, Action: model.ActionAccept, Match: model.Match{InZones: []string{"iot", "vlans"}, OutZones: []string{"wan"}}}) vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"} for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} { q, qs := quotaOptions(st) // Enforcing, and in shadow mode after and before other tables. for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} { opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, Bans: banOptions(st), DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}, JarlSets: jarlOptions, Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore} nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text) } } nftCheck(t, cmd, "shadow fallback", ShadowFallback()) } func nftCheck(t *testing.T, cmd []string, name, text string) { t.Helper() 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("%s: nft -c: %v\n%s", name, err, out) for i, l := range strings.Split(text, "\n") { t.Logf("%3d %s", i+1, l) } } } func TestRenderBans(t *testing.T) { st := router() id := map[string]int64{} for _, b := range st.BanLists { id[b.Name] = b.ID } text := Render(st, Options{Bans: banOptions(st)}).Text f := func(format string, args ...any) string { return fmt.Sprintf(format, args...) } r, w, e, off := id["router"], id["web"], id["edge"], id["off"] for _, want := range []string{ f("set ban_%d_v4 { type ipv4_addr; flags timeout; }", r), f("set ban_%d_v6_svc { type ipv6_addr . inet_proto . inet_service; flags timeout; }", r), // The firewall: in input, every port and one port, /64 for IPv6. f(`ip saddr @ban_%d_v4 counter drop comment "hc:ban%d"`, r, r), f(`ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop comment "hc:ban%d"`, r, r), f(`ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop comment "hc:ban%d"`, r, r), f(`ip6 saddr and ffff:ffff:ffff:ffff:: . meta l4proto . th dport @ban_%d_v6_svc counter drop comment "hc:ban%d"`, r, r), // Hosts: in forward, first, to the hosts (literals and sets). f(`ip daddr 10.0.10.57 ip saddr @ban_%d_v4 counter drop`, w), f(`ip daddr @dns_servers_v4 ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop`, w), f(`ip6 daddr 2a02:8013:7f3d:10::57 ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop`, w), // Everything: input and forward, /48. f(`ip6 saddr and ffff:ffff:ffff:: @ban_%d_v6 counter drop`, e), // The bans, with their time. f("add element inet housecarl ban_%d_v4 { 203.0.113.9 timeout 3000s }", r), f("add element inet housecarl ban_%d_v6_svc { 2001:db8:1:2:: . tcp . 22 }", w), f("add element inet housecarl ban_%d_v4_svc { 198.51.100.7 . udp . 53 timeout 60s }", e), } { if !strings.Contains(text, want) { t.Errorf("no %q in\n%s", want, text) } } if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, e)); n != 2 { t.Errorf("scope everything: %d rules of every port in IPv4, want 2 (input and forward)", n) } if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, w)); n != 2 { t.Errorf("scope hosts: %d IPv4 rules of every port, want 2 (forward: the literal host and the set)", n) } // Ahead of the accepts: the first rules after loopback, and of forward. in, _, _ := strings.Cut(text[strings.Index(text, "chain input"):], "established") fw, _, _ := strings.Cut(text[strings.Index(text, "chain forward"):], "maxseg") for _, part := range []string{in, fw} { if strings.Contains(part, " accept comment \"hc:r") { t.Errorf("a rule accepts before the bans:\n%s", part) } } if strings.Contains(text, f("ban_%d_", off)) { t.Errorf("a disabled list is rendered") } shadow := Render(st, Options{Shadow: true, Bans: banOptions(st)}).Text if want := f(`ip saddr @ban_%d_v4 counter accept comment "hc:ban%d [would drop]"`, r, r); !strings.Contains(shadow, want) { t.Errorf("shadow: no %q", want) } }