A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
21 KiB
Go
366 lines
21 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},
|
|
{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)
|
|
}
|
|
}
|