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_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }}, {"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" }}, {"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} }}, {"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) } } }