Files
housecarl/internal/validate/validate_test.go
T
abrown38andClaude Opus 5.5 cf30715c5a Statistics: traffic, drops, devices and connections over time
internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.

Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).

GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:27:48 +01:00

168 lines
8.7 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)
}
}
}