Files
housecarl/internal/validate/validate_test.go
T
abrown38andClaude Opus 5.5 68ba2d06c5 Sets from Jarl: sets holding the addresses of names in Jarl
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>
2026-10-23 13:51:55 +01:00

246 lines
12 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"}}}},
BanLists: []*model.BanList{{ID: 9, Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"trusted"}}},
}
}
func jarlSource() *model.JarlSource {
return &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com", View: "internal"}}, Within: []string{"lan", "10.9.0.0/16"}}
}
func TestJarlSetValid(t *testing.T) {
st := base()
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names = append(st.Sets[0].Jarl.Names, model.JarlName{Name: "_svc.files.example.com"})
if ps := Check(st, nil); len(ps) > 0 {
t.Fatalf("problems: %+v", ps)
}
}
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 }},
{"set_jarl_entries", func(st *model.State) { st.Sets[0].Jarl = jarlSource() }},
{"set_jarl_dynamic", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Dynamic, st.Sets[0].Jarl = nil, true, jarlSource()
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, &model.JarlSource{Within: []string{"lan"}}
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names[0].Name = "*.example.com"
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names = append(st.Sets[0].Jarl.Names, st.Sets[0].Jarl.Names[0])
}},
{"set_jarl_within", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = nil
}},
{"set_jarl_within", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = []string{"dmz"}
}},
{"set_jarl_anywhere", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = []string{"::/0"}
}},
{"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"}}}}
}},
{"banlist_name", func(st *model.State) { st.BanLists[0].Name = "web list" }},
{"banlist_scope", func(st *model.State) { st.BanLists[0].Scope = "world" }},
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Hosts = nil }},
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Scope = model.BanScopeFirewall }},
{"banlist_address", func(st *model.State) { st.BanLists[0].Never = []string{"10.0.0.300"} }},
{"unknown_set", func(st *model.State) { st.BanLists[0].Hosts = []string{"webservers"} }},
{"banlist_time", func(st *model.State) { st.BanLists[0].MaxTime = -2 }},
{"banlist_time", func(st *model.State) { st.BanLists[0].DefaultTime, st.BanLists[0].MaxTime = 7200, 3600 }},
{"banlist_entries", func(st *model.State) { st.BanLists[0].MaxEntries = -1 }},
{"banlist_group", func(st *model.State) { st.BanLists[0].GroupV6 = 8 }},
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "ban_3" }},
{"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)
}
}
}