- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
14 KiB
Go
233 lines
14 KiB
Go
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"}}})
|
|
// 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()} {
|
|
q, qs := quotaOptions(st)
|
|
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs})
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|