fe80::/10 in the IPv6 antispoof list (or your prefixes) dropped the ISP's router adverts and DHCPv6 replies in the early chain, before the ICMPv6 and DHCPv6 client built-ins could accept them: the router lost its IPv6 default route. Validation now refuses it (antispoof_link_local), the settings page says so, and the importer leaves it out with a note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
13 KiB
Go
249 lines
13 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"} }},
|
|
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"fc00::/7", "fe80::/10"} }},
|
|
{"antispoof_link_local", func(st *model.State) { st.Settings.OwnPrefixes = []string{"fe80::/64"} }},
|
|
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"::/0"} }},
|
|
{"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)
|
|
}
|
|
}
|
|
}
|