// Package validate checks a whole configuration. Every problem has a // stable code (docs/validation.md); errors stop a change from being saved, // warnings are reported with it. package validate import ( "fmt" "net/netip" "regexp" "strings" "housecarl/internal/model" ) // Severities. const ( Error = "error" Warning = "warning" ) // Problem is one finding. type Problem struct { Severity string `json:"severity"` Code string `json:"code"` Message string `json:"message"` // Object names what it is about: "zone wan", "input #12 (ssh)". Object string `json:"object,omitempty"` } // Env describes the host, for checks against its interfaces. A nil Env // skips them (tests, imports for another machine). type Env struct { // Interfaces maps each interface name to its addresses. Interfaces map[string][]netip.Prefix } // HasErrors reports whether any problem is an error. func HasErrors(ps []Problem) bool { for _, p := range ps { if p.Severity == Error { return true } } return false } // Errors returns only the errors. func Errors(ps []Problem) []Problem { var out []Problem for _, p := range ps { if p.Severity == Error { out = append(out, p) } } return out } type checker struct { st *model.State env *Env out []Problem } func (c *checker) add(sev, code, object, format string, args ...any) { c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)}) } // Check validates a configuration. func Check(st *model.State, env *Env) []Problem { c := &checker{st: st, env: env} c.settings() c.zones() c.sets() c.services() for _, r := range st.Rules { c.rule(r) } c.forwards() for _, x := range st.SNATs { c.snat(x) } for _, x := range st.Redirects { c.redirect(x) } return c.out } // rawIgnores names what a raw rule has besides its text, which the // ruleset wouldn't use (the action too is the text's; it isn't checked). func rawIgnores(r *model.Rule) []string { m := r.Match var out []string for _, x := range []struct { name string set bool }{ {"zones", len(m.InZones) > 0 || len(m.OutZones) > 0}, {"addresses", len(m.Sources) > 0 || len(m.Destinations) > 0}, {"services", len(m.Services) > 0}, {"family", m.Family != ""}, {"connection state", len(r.CtState) > 0 || len(r.CtStatus) > 0}, {"time window", len(r.Days) > 0 || r.HourFrom != "" || r.HourTo != ""}, {"rate limit", r.Limit != nil}, {"connection limit", r.ConnLimit != 0}, {"quota", r.Quota != nil}, {"address grouping", r.GroupV4 != 0 || r.GroupV6 != 0}, {"log", r.Log || r.LogPrefix != ""}, {"reject with", r.RejectWith != ""}, } { if x.set { out = append(out, x.name) } } return out } // maxByteRate is the largest byte rate per period nftables (1.1.3) can // hold: the kernel's token bucket multiplies the rate by the period. var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503} // byteSize writes bytes the way nftables reads them: "1 mbytes". func byteSize(n int64) string { for _, u := range []string{"mbytes", "kbytes"} { if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 { return fmt.Sprintf("%d %s", n/model.ByteUnits[u], u) } } if n >= 10*model.ByteUnits["kbytes"] { return fmt.Sprintf("%d kbytes", n/model.ByteUnits["kbytes"]) } return fmt.Sprintf("%d bytes", n) } var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`) // ifaceRe: Linux interface names (15 bytes), with an optional trailing * // for nftables' wildcard. var ifaceRe = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}\*?$|^\*$`) func (c *checker) name(kind, obj, name string, seen map[string]string) { switch { case !nameRe.MatchString(name): c.add(Error, kind+"_name", obj, "%q isn't a valid name: start with a letter; then letters, digits, - and _ (up to 48)", name) case model.IsLiteral(name): c.add(Error, kind+"_name", obj, "%q looks like an address; give it a name", name) default: k := strings.ToLower(name) if o, ok := seen[k]; ok { c.add(Error, kind+"_name_taken", obj, "the name %s is already used by %s", name, o) } seen[k] = obj } } func (c *checker) settings() { s := c.st.Settings for _, p := range []struct{ name, v string }{{"input", s.InputPolicy}, {"forward", s.ForwardPolicy}, {"output", s.OutputPolicy}} { if p.v != model.PolicyAccept && p.v != model.PolicyDrop { c.add(Error, "policy", "settings", "the %s policy is %q; use accept or drop", p.name, p.v) } } for _, l := range []struct { name string list []string v6 int // 0 v4, 1 v6, 2 either }{{"antispoof_v4", s.AntispoofV4, 0}, {"antispoof_v6", s.AntispoofV6, 1}, {"own_prefixes", s.OwnPrefixes, 2}} { for _, e := range l.list { a, err := model.ParseAddress(e) if err != nil { c.add(Error, "settings_address", "settings", "%s: %v", l.name, err) continue } if l.v6 != 2 && a.IsV6() != (l.v6 == 1) { c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e) } } } for _, d := range s.FlowtableDevices { if !ifaceRe.MatchString(d) || strings.HasSuffix(d, "*") { c.add(Error, "flowtable_device", "settings", "flowtable device %q: give an exact interface name (no wildcards)", d) } else if c.env != nil { if _, ok := c.env.Interfaces[d]; !ok { c.add(Warning, "flowtable_device_missing", "settings", "flowtable device %s doesn't exist here; the flowtable is left out until it does", d) } } } if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) { c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p) } } func (c *checker) zones() { seen := map[string]string{} owner := map[string]string{} external := false for _, z := range c.st.Zones { obj := "zone " + z.Name c.name("zone", obj, z.Name, seen) external = external || z.External if len(z.Interfaces) == 0 { c.add(Error, "zone_empty", obj, "the zone has no interfaces") } for _, i := range z.Interfaces { if !ifaceRe.MatchString(i) { c.add(Error, "zone_interface", obj, "%q isn't an interface name (up to 15 characters; a trailing * matches many)", i) continue } if o, ok := owner[i]; ok { c.add(Error, "zone_interface_taken", obj, "interface %s is already in %s; an interface belongs to one zone", i, o) } owner[i] = obj if c.env != nil && !strings.HasSuffix(i, "*") { if _, ok := c.env.Interfaces[i]; !ok { c.add(Warning, "zone_interface_missing", obj, "interface %s doesn't exist here (yet); its rules apply once it does", i) } } } } if len(c.st.Zones) > 0 && !external { c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses") } } func (c *checker) sets() { seen := map[string]string{} for _, s := range c.st.Sets { obj := "set " + s.Name c.name("set", obj, s.Name, seen) for _, e := range s.Entries { if _, err := model.ParseAddress(e); err != nil { c.add(Error, "set_entry", obj, "%v", err) } } if len(s.Entries) > 0 && s.Dynamic { c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic") } if len(s.Entries) == 0 && !s.Dynamic { c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing") } if s.Timeout < 0 || s.Timeout > 0 && !s.Dynamic { c.add(Error, "set_timeout", obj, "only a dynamic set has a timeout, of 1 second or more") } } } func (c *checker) services() { seen := map[string]string{} for _, s := range c.st.Services { obj := "service " + s.Name c.name("service", obj, s.Name, seen) if len(s.Entries) == 0 { c.add(Error, "service_empty", obj, "the service has no ports or ICMP types") } for _, e := range s.Entries { if _, err := model.ParseServiceEntry(e); err != nil { c.add(Error, "service_entry", obj, "%v", err) } } } } // match checks a Match and returns which families it can match. func (c *checker) match(obj string, m model.Match, allowIn, allowOut bool) (v4, v6 bool) { for _, z := range m.InZones { if !allowIn { c.add(Error, "match_in_zone", obj, "this chain has no incoming interface; leave out the in zones") break } if c.st.Zone(z) == nil { c.add(Error, "unknown_zone", obj, "there is no zone %s", z) } } for _, z := range m.OutZones { if !allowOut { c.add(Error, "match_out_zone", obj, "this chain has no outgoing interface; leave out the out zones") break } if c.st.Zone(z) == nil { c.add(Error, "unknown_zone", obj, "there is no zone %s", z) } } refs := func(what string, list []string) (bool, bool) { if len(list) == 0 { return true, true } for _, r := range list { if !model.IsLiteral(r) && c.st.Set(r) == nil { if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") { c.add(Error, "match_address", obj, "%s: %v", what, err) } else { c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r) } } } f4, f6 := c.st.SplitRefs(list) return !f4.Empty(), !f6.Empty() } s4, s6 := refs("sources", m.Sources) d4, d6 := refs("destinations", m.Destinations) v4, v6 = s4 && d4, s6 && d6 icmp4, icmp6, ports := false, false, false for _, r := range m.Services { if model.IsServiceEntry(r) { if _, err := model.ParseServiceEntry(r); err != nil { c.add(Error, "service_entry", obj, "%v", err) } } else if c.st.Service(r) == nil { c.add(Error, "unknown_service", obj, "there is no service %s (built-in or yours)", r) } } for _, e := range c.st.ServiceEntries(m.Services) { switch e.Proto { case "icmp": icmp4 = true case "icmpv6": icmp6 = true default: ports = true } } if !ports && (icmp4 || icmp6) { v4, v6 = v4 && icmp4, v6 && icmp6 } switch m.Family { case "": case model.FamilyIPv4: v6 = false case model.FamilyIPv6: v4 = false default: c.add(Error, "family", obj, "the family is %q; use ipv4, ipv6 or nothing for both", m.Family) } if !v4 && !v6 { c.add(Error, "match_never", obj, "the addresses, services and family together can never match (an IPv4-only part and an IPv6-only part)") } return v4, v6 } var ( ctStates = map[string]bool{"new": true, "established": true, "related": true, "invalid": true, "untracked": true} ctStatuses = map[string]bool{"dnat": true, "snat": true, "confirmed": true, "assured": true, "expected": true} days = map[string]bool{"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true} hourRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) limitPer = map[string]bool{"second": true, "minute": true, "hour": true, "day": true} ) func (c *checker) rule(r *model.Rule) { obj := r.Label() switch r.Chain { case model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput: default: c.add(Error, "rule_chain", obj, "the chain is %q; use %s", r.Chain, strings.Join(model.Chains, ", ")) return } actionOK := false for _, a := range model.Actions { actionOK = actionOK || r.Action == a } if !actionOK { c.add(Error, "rule_action", obj, "the action is %q; use %s", r.Action, strings.Join(model.Actions, ", ")) } if r.Raw != "" { if strings.ContainsAny(r.Raw, ";\n\r") { c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;") } if ignored := rawIgnores(r); len(ignored) > 0 { c.add(Error, "raw_conflict", obj, "a raw rule is used as written, so its %s would be ignored: write them in the nftables rule, or leave them out", strings.Join(ignored, ", ")) } return } in := r.Chain != model.ChainOutput out := r.Chain == model.ChainForward || r.Chain == model.ChainOutput c.match(obj, r.Match, in, out) if r.Chain == model.ChainEarly { if len(r.CtState) > 0 || len(r.CtStatus) > 0 || r.ConnLimit > 0 { c.add(Error, "early_conntrack", obj, "the early chain runs before connection tracking: leave out connection states and limits") } if r.Action == model.ActionReject { c.add(Error, "early_reject", obj, "the early chain can't reject; drop instead") } } for _, s := range r.CtState { if !ctStates[s] { c.add(Error, "ct_state", obj, "%q isn't a connection state (new, established, related, invalid, untracked)", s) } } for _, s := range r.CtStatus { if !ctStatuses[s] { c.add(Error, "ct_status", obj, "%q isn't a connection status (dnat, snat, confirmed, assured, expected)", s) } } for _, d := range r.Days { if !days[strings.ToLower(d)] { c.add(Error, "rule_day", obj, "%q isn't a day (Monday … Sunday)", d) } } if (r.HourFrom == "") != (r.HourTo == "") { c.add(Error, "rule_hours", obj, "give both hours of the time window, from and to (08:00, 17:30)") } else if r.HourFrom != "" && (!hourRe.MatchString(r.HourFrom) || !hourRe.MatchString(r.HourTo)) { c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)") } if l := r.Limit; l != nil { _, byteUnit := model.ByteUnits[l.Unit] switch { case l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0: c.add(Error, "rule_limit", obj, "a rate is a number of packets (or bytes, kbytes, mbytes) per second, minute, hour or day, with an optional burst") case l.Unit != "" && l.Unit != "packets" && (!byteUnit || l.Unit == "gbytes"): c.add(Error, "rule_limit_unit", obj, "the rate's unit is %q; use packets, bytes, kbytes or mbytes", l.Unit) case byteUnit && int64(l.Rate)*model.ByteUnits[l.Unit] > maxByteRate[l.Per]: perSecond := int64(l.Rate) * model.ByteUnits[l.Unit] / map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}[l.Per] c.add(Error, "rule_limit_bytes", obj, "nftables can't hold a byte rate of %d %s per %s (at most %s per %s); give it per second: %s/second", l.Rate, l.Unit, l.Per, byteSize(maxByteRate[l.Per]), l.Per, byteSize(perSecond)) } if l.PerSource && l.PerDestination { c.add(Error, "rule_limit_key", obj, "a limit is per source or per destination, not both") } } if r.ConnLimit < 0 { c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more") } if q := r.Quota; q != nil { if q.Bytes < 1 { c.add(Error, "rule_quota", obj, "a quota is an amount of bytes, 1 or more") } if q.PerSource && q.PerDestination { c.add(Error, "rule_quota_key", obj, "a quota is per source or per destination, not both") } switch { case q.Reset != "" && q.Reset != "daily" && q.Reset != "weekly" && q.Reset != "monthly": c.add(Error, "rule_quota_reset", obj, "the quota resets %q; use daily, weekly, monthly, or nothing (only by hand)", q.Reset) case q.Day != 0 && q.Reset != "monthly": c.add(Error, "rule_quota_reset", obj, "a day of the month is only for a monthly reset") case q.Day < 0 || q.Day > 31: c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)") } } if r.CountsTraffic() && r.Action == model.ActionAccept { c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own") } if r.CountsTraffic() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled { c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it") } if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 { c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6") } if r.RejectWith != "" { if r.RejectWith != "tcp-reset" { c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith) } else { for _, e := range c.st.ServiceEntries(r.Services) { if e.Proto != "tcp" { c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only (%s isn't)", e) break } } if len(r.Services) == 0 { c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only; add a tcp service") } } if r.Action != model.ActionReject { c.add(Error, "reject_with", obj, "reject with only goes with the reject action") } } if len(r.LogPrefix) > 100 { c.add(Error, "log_prefix", obj, "the log prefix is %d characters; 100 at most", len(r.LogPrefix)) } } func (c *checker) forwards() { type slot struct { addr, proto string lo, hi int } var taken []struct { s slot obj string } hasExternal := len(c.st.ExternalInterfaces()) > 0 seen := map[string]string{} for _, f := range c.st.Forwards { obj := f.Label() if f.Name != "" { c.name("forward", obj, f.Name, seen) } if len(f.Protocols) == 0 { c.add(Error, "forward_protocol", obj, "give the protocols: tcp, udp or both") } for _, p := range f.Protocols { if p != "tcp" && p != "udp" && p != "sctp" { c.add(Error, "forward_protocol", obj, "the protocol is %q; use tcp, udp or sctp", p) } } if _, err := model.NormalizePorts(f.Ports); err != nil { c.add(Error, "forward_ports", obj, "public port: %v", err) continue } lo, hi := model.PortRange(f.Ports) if f.ToPort != "" { if _, err := model.NormalizePorts(f.ToPort); err != nil || strings.Contains(f.ToPort, "-") { c.add(Error, "forward_to_port", obj, "the inside port must be one port") } else if hi != lo { c.add(Error, "forward_to_port", obj, "a range of public ports keeps its ports: leave out the inside port") } } if f.To == "" && f.To6 == "" { c.add(Error, "forward_target", obj, "give the inside host: an IPv4 address (to), an IPv6 one (to6), or both") } if f.To != "" { if a, err := netip.ParseAddr(f.To); err != nil || !a.Is4() { c.add(Error, "forward_target", obj, "to must be one IPv4 address (IPv6 hosts are reached with to6, without translation)") } } if f.To6 != "" { if a, err := netip.ParseAddr(f.To6); err != nil || !a.Is6() || a.Is4In6() { c.add(Error, "forward_target", obj, "to6 must be one IPv6 address") } } if f.To != "" && len(f.PublicAddresses) == 0 && !hasExternal { c.add(Error, "forward_public", obj, "without public addresses, a port forward listens on the external zones, and there is none") } for _, a := range f.PublicAddresses { if pa, err := model.ParseAddress(a); err == nil { if pa.IsV6() { c.add(Error, "forward_public", obj, "public addresses are IPv4 (%s isn't); IPv6 hosts are reached with to6", a) } } else if c.st.Set(a) == nil { c.add(Error, "unknown_set", obj, "public addresses: %s is neither an address nor a set", a) } } c.match(obj, model.Match{Sources: f.Sources}, true, false) if f.Hairpin && len(f.PublicAddresses) == 0 { c.add(Warning, "forward_hairpin", obj, "hairpin needs public addresses to match inside clients' connections; add them") } for _, x := range f.HairpinSources { if !model.IsLiteral(x) && c.st.Set(x) == nil { c.add(Error, "unknown_set", obj, "hairpin sources: %s is neither an address nor a set", x) } } if f.Disabled { continue } addrs := f.PublicAddresses if len(addrs) == 0 { addrs = []string{"(external zones)"} } for _, a := range addrs { for _, p := range f.Protocols { s := slot{a, p, lo, hi} for _, t := range taken { if t.s.addr == s.addr && t.s.proto == s.proto && s.lo <= t.s.hi && t.s.lo <= s.hi { c.add(Error, "forward_overlap", obj, "%s port %s on %s is already forwarded by %s", p, f.Ports, a, t.obj) } } taken = append(taken, struct { s slot obj string }{s, obj}) } } } } func (c *checker) snat(x *model.SourceNAT) { obj := x.Label() v4, v6 := c.match(obj, x.Match, true, true) if x.To != "" { a, err := netip.ParseAddr(x.To) switch { case err != nil: c.add(Error, "snat_to", obj, "to must be one address (or empty to masquerade)") case a.Is4() && v6 && !v4 || a.Is6() && v4 && !v6: c.add(Error, "snat_to", obj, "to is %s, but the rule only matches the other family", x.To) case a.Is4() && v6 || a.Is6() && v4: c.add(Error, "snat_to", obj, "to is one family's address: limit the rule to that family (family %s)", map[bool]string{true: "ipv4", false: "ipv6"}[a.Is4()]) } } if len(x.OutZones) == 0 && len(x.Sources) == 0 { c.add(Warning, "snat_everything", obj, "no out zones and no sources: everything leaving the firewall is translated") } } func (c *checker) redirect(x *model.Redirect) { obj := x.Label() c.match(obj, x.Match, true, false) entries := c.st.ServiceEntries(x.Services) if len(entries) == 0 { c.add(Error, "redirect_service", obj, "give the ports to redirect (a service such as dns, or tcp/80)") } for _, e := range entries { if e.IsICMP() { c.add(Error, "redirect_service", obj, "only TCP, UDP and SCTP ports can be redirected (%s)", e) } } if x.ToPort != "" { if _, err := model.NormalizePorts(x.ToPort); err != nil || strings.Contains(x.ToPort, "-") { c.add(Error, "redirect_to_port", obj, "the local port must be one port") } } }