Files
housecarl/internal/render/render_test.go
T
abrown38andClaude Opus 5.5 500bfc7bdd Ban lists: where Guard and fail2ban ban addresses
A program bans an address (every port, or a protocol's ports) for a
time; the ban list says what it is kept from: the firewall, some hosts
behind it, or everything. Bans drop ahead of every rule, close the
address's connections, group IPv6 by /64, are kept in the database
across restarts and audited. Never banned: the inside, the firewall's
own addresses, the caller, the list's never. bans grant (bans:write:LIST),
hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/,
and failed logins and refused tokens logged for Guard and fail2ban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00

354 lines
20 KiB
Go

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},
}
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"}}})
// 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
}
// 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})
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 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}}},
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)
}
}