Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+511
View File
@@ -0,0 +1,511 @@
// 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
}
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(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 ;")
}
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 {
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
}
}
if r.ConnLimit < 0 {
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
}
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")
}
}
}