Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
10 KiB
Go
181 lines
10 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"}}})
|
|
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
|
|
}
|
|
|
|
func TestRenderRouter(t *testing.T) {
|
|
rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}})
|
|
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 add @meter_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)
|
|
}
|
|
}
|
|
// 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()} {
|
|
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}})
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|