Files
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00

194 lines
9.5 KiB
Go

package validate
import (
"net/netip"
"strings"
"testing"
"housecarl/internal/model"
)
// base is a valid configuration each case changes.
func base() *model.State {
return &model.State{
Settings: model.DefaultSettings(),
Zones: []*model.Zone{
{ID: 1, Name: "wan", Interfaces: []string{"wan"}, External: true},
{ID: 2, Name: "lan", Interfaces: []string{"eth1"}},
},
Sets: []*model.AddressSet{{ID: 3, Name: "trusted", Entries: []string{"10.0.10.0/24", "2001:db8:10::/64"}}},
Services: []*model.Service{{ID: 4, Name: "jellyfin", Entries: []string{"tcp/8096"}}},
Rules: []*model.Rule{
{ID: 5, Chain: model.ChainInput, Position: 1, Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: model.ActionAccept},
{ID: 6, Chain: model.ChainForward, Position: 1, Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionAccept},
},
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
}
}
func codes(ps []Problem) string {
var out []string
for _, p := range ps {
out = append(out, p.Code)
}
return strings.Join(out, " ")
}
func TestBaseIsValid(t *testing.T) {
env := &Env{Interfaces: map[string][]netip.Prefix{"wan": nil, "eth1": nil}}
if ps := Check(base(), env); len(ps) > 0 {
t.Fatalf("problems: %+v", ps)
}
}
func TestCodes(t *testing.T) {
rule := func(st *model.State) *model.Rule { return st.Rules[0] }
cases := []struct {
code string
edit func(st *model.State)
}{
{"zone_name", func(st *model.State) { st.Zones[0].Name = "1wan" }},
{"zone_name_taken", func(st *model.State) { st.Zones[1].Name = "WAN" }},
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
{"zone_interface", func(st *model.State) { st.Zones[1].Interfaces = []string{"a-very-long-interface"} }},
{"zone_interface_taken", func(st *model.State) { st.Zones[1].Interfaces = []string{"wan"} }},
{"no_external_zone", func(st *model.State) {
st.Zones[0].External = false
st.Forwards[0].PublicAddresses = []string{"203.0.113.7"}
}},
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "stats_up" }},
{"stats_flowtable", func(st *model.State) {
st.Settings.StatsPerDevice = true
st.Settings.FlowtableDevices = []string{"wan", "eth1"}
}},
{"stats_no_external", func(st *model.State) {
st.Settings.StatsPerDevice = true
st.Zones[0].External = false
}},
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
{"match_in_zone", func(st *model.State) {
st.Rules = append(st.Rules, &model.Rule{ID: 9, Chain: model.ChainOutput, Position: 1, Match: model.Match{InZones: []string{"lan"}}, Action: "accept"})
}},
{"match_out_zone", func(st *model.State) { rule(st).OutZones = []string{"wan"} }},
{"unknown_set", func(st *model.State) { rule(st).Sources = []string{"friends"} }},
{"unknown_service", func(st *model.State) { rule(st).Services = []string{"gopher"} }},
{"family", func(st *model.State) { rule(st).Family = "ipv5" }},
{"match_never", func(st *model.State) {
rule(st).Sources = []string{"10.0.0.1"}
rule(st).Services = []string{"icmpv6/echo-request"}
}},
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
{"raw_conflict", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept" }}, // it still has sources and services
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
{"ct_status", func(st *model.State) { rule(st).CtStatus = []string{"translated"} }},
{"rule_day", func(st *model.State) { rule(st).Days = []string{"Caturday"} }},
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
{"rule_limit_unit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 1, Per: "second", Unit: "gbytes"} }},
{"rule_limit_bytes", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 100, Per: "hour", Unit: "mbytes"} }},
{"rule_limit_key", func(st *model.State) {
rule(st).Limit = &model.Limit{Rate: 1, Per: "second", PerSource: true, PerDestination: true}
}},
{"rule_quota", func(st *model.State) { rule(st).Quota = &model.Quota{} }},
{"rule_quota_key", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, PerSource: true, PerDestination: true} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "yearly"} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "daily", Day: 3} }},
{"rule_group", func(st *model.State) { rule(st).GroupV6 = 129 }},
{"rule_traffic_accept", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1 << 20} }},
{"rule_every_packet_accept", func(st *model.State) { rule(st).EveryPacket = true }},
{"flowtable_traffic", func(st *model.State) {
st.Settings.FlowtableDevices = []string{"wan"}
st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true}
}},
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
{"forward_ports", func(st *model.State) { st.Forwards[0].Ports = "http" }},
{"forward_to_port", func(st *model.State) { st.Forwards[0].Ports = "8000-8010"; st.Forwards[0].ToPort = "80" }},
{"forward_target", func(st *model.State) { st.Forwards[0].To = "2001:db8::57" }},
{"forward_public", func(st *model.State) { st.Forwards[0].PublicAddresses = []string{"2001:db8::1"} }},
{"forward_overlap", func(st *model.State) {
f := *st.Forwards[0]
f.ID, f.Name = 10, "other"
st.Forwards = append(st.Forwards, &f)
}},
{"forward_hairpin", func(st *model.State) { st.Forwards[0].PublicAddresses = nil }},
{"snat_to", func(st *model.State) { st.SNATs[0].To = "2001:db8::1" }},
{"snat_everything", func(st *model.State) { st.SNATs[0].OutZones, st.SNATs[0].Sources = nil, nil }},
{"redirect_service", func(st *model.State) {
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
}},
{"redirect_to_port", func(st *model.State) {
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
}},
}
for _, c := range cases {
st := base()
c.edit(st)
if got := codes(Check(st, nil)); !strings.Contains(" "+got+" ", " "+c.code+" ") {
t.Errorf("%s: got [%s]", c.code, got)
}
}
}
func TestHostWarnings(t *testing.T) {
st := base()
st.Settings.FlowtableDevices = []string{"wan"}
env := &Env{Interfaces: map[string][]netip.Prefix{"eth1": nil}}
got := codes(Check(st, env))
for _, want := range []string{"zone_interface_missing", "flowtable_device_missing"} {
if !strings.Contains(got, want) {
t.Errorf("%s missing: [%s]", want, got)
}
}
for _, p := range Check(st, env) {
if p.Severity != Warning {
t.Errorf("a missing interface is only a warning: %+v", p)
}
}
}
func TestNoExternalZone(t *testing.T) {
st := base()
if got := codes(Check(st, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("an external zone, yet: [%s]", got)
}
st.Zones[0].External = false
if got := codes(Check(st, nil)); !strings.Contains(got, "no_external_zone") {
t.Errorf("no external zone, and no warning: [%s]", got)
}
// An empty configuration applies nothing, so has no fallback either.
if got := codes(Check(&model.State{Settings: model.DefaultSettings()}, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("empty configuration: [%s]", got)
}
}
func TestVLANInterfaces(t *testing.T) {
st := base()
st.Zones[1].Interfaces = []string{"enp2s0.10", "enp2s0.4094", "br0.20", "enp3s0.*"}
st.Settings.FlowtableDevices = []string{"enp2s0.10"}
for _, p := range Check(st, nil) {
if p.Severity == Error {
t.Errorf("VLAN interfaces refused: %+v", p)
}
}
}