- Undo is staged like any edit: in review mode the web UI's Undo, and hc history undo with -C or HOUSECARL_CHANGESET, add a revert operation that checks, when replayed, that nothing it touches changed since; applied, History links the entries. Imports and takeovers still can't be staged. - The rules page sums up a chain's built-ins in one line; a window lists them with what they do and their hits, and switches them. - Warnings about the machine (dashboard, hc status): nftables.service enabled (it loads /etc/nftables.conf at boot and flushes every table when stopped), and the imported table still in force beside Housecarl's. The docs say to disable nftables.service but not stop it. - A raw rule with other fields set is refused (raw_conflict) instead of silently ignoring them; the rule dialog's nftables section hides what it replaces, and the importer's raw rules carry only their text. - A dynamic set with configured entries is refused (set_dynamic_entries): they were silently ignored. The set dialog shows entries or the entry timeout, as fits. - The rule dialog's chain choices are just the chains' names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
8.2 KiB
Go
158 lines
8.2 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_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} }},
|
|
{"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)
|
|
}
|
|
}
|
|
}
|