Files
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:11:01 +01:00

280 lines
16 KiB
Go

package render
import (
"context"
"fmt"
"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.Sort()
return st
}
// 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,
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)
}
}
}