Files
abrown38andClaude Opus 5.5 34292eeebb 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>
2026-10-19 07:57:14 +01:00

329 lines
7.8 KiB
Go

// Package model holds Housecarl's data: the firewall configuration
// (State), users and tokens, the history of changes and the audit log.
package model
import (
"encoding/json"
"sort"
"strings"
)
// State is the whole firewall configuration. It is small and changed as a
// whole (internal/engine); the ruleset is rendered from it
// (internal/render).
type State struct {
Settings Settings `json:"settings"`
Zones []*Zone `json:"zones"`
Sets []*AddressSet `json:"sets"`
Services []*Service `json:"services"`
Rules []*Rule `json:"rules"`
Forwards []*PortForward `json:"forwards"`
SNATs []*SourceNAT `json:"snats"`
Redirects []*Redirect `json:"redirects"`
}
// Object kinds, for changes and the history.
const (
KindSettings = "settings"
KindZone = "zone"
KindSet = "set"
KindService = "service"
KindRule = "rule"
KindForward = "forward"
KindSNAT = "snat"
KindRedirect = "redirect"
)
// Kinds lists the object kinds (not settings) in display order.
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
// Object is anything in the State with an ID.
type Object interface {
GetID() int64
Label() string
}
// NewState is an empty configuration with the default settings.
func NewState() *State {
st := &State{Settings: DefaultSettings()}
st.Fill()
return st
}
// Fill replaces nil lists with empty ones (for JSON and comparisons).
func (st *State) Fill() {
if st.Zones == nil {
st.Zones = []*Zone{}
}
if st.Sets == nil {
st.Sets = []*AddressSet{}
}
if st.Services == nil {
st.Services = []*Service{}
}
if st.Rules == nil {
st.Rules = []*Rule{}
}
if st.Forwards == nil {
st.Forwards = []*PortForward{}
}
if st.SNATs == nil {
st.SNATs = []*SourceNAT{}
}
if st.Redirects == nil {
st.Redirects = []*Redirect{}
}
}
// Objects returns the objects of a kind.
func (st *State) Objects(kind string) []Object {
var out []Object
switch kind {
case KindZone:
for _, o := range st.Zones {
out = append(out, o)
}
case KindSet:
for _, o := range st.Sets {
out = append(out, o)
}
case KindService:
for _, o := range st.Services {
out = append(out, o)
}
case KindRule:
for _, o := range st.Rules {
out = append(out, o)
}
case KindForward:
for _, o := range st.Forwards {
out = append(out, o)
}
case KindSNAT:
for _, o := range st.SNATs {
out = append(out, o)
}
case KindRedirect:
for _, o := range st.Redirects {
out = append(out, o)
}
}
return out
}
// Put replaces (or adds, or with data nil deletes) the object of a kind
// with an ID, from its JSON.
// Empty reports whether nothing has been configured: no objects of any
// kind. housecarld applies no ruleset for an empty configuration, so
// installing and starting it doesn't close the machine.
func (st *State) Empty() bool {
for _, k := range Kinds {
if len(st.Objects(k)) > 0 {
return false
}
}
return true
}
func (st *State) Put(kind string, id int64, data json.RawMessage) error {
switch kind {
case KindZone:
return put(&st.Zones, id, data)
case KindSet:
return put(&st.Sets, id, data)
case KindService:
return put(&st.Services, id, data)
case KindRule:
return put(&st.Rules, id, data)
case KindForward:
return put(&st.Forwards, id, data)
case KindSNAT:
return put(&st.SNATs, id, data)
case KindRedirect:
return put(&st.Redirects, id, data)
}
return &unknownKind{kind}
}
type unknownKind struct{ kind string }
func (e *unknownKind) Error() string { return "unknown kind " + e.kind }
func put[T Object](list *[]T, id int64, data json.RawMessage) error {
keep := (*list)[:0:0]
for _, o := range *list {
if o.GetID() != id {
keep = append(keep, o)
}
}
if data != nil {
var o T
if err := json.Unmarshal(data, &o); err != nil {
return err
}
keep = append(keep, o)
}
*list = keep
return nil
}
// Clone returns a deep copy.
func (st *State) Clone() *State {
b, err := json.Marshal(st)
if err != nil {
panic(err)
}
var out State
if err := json.Unmarshal(b, &out); err != nil {
panic(err)
}
out.Fill()
return &out
}
// Sort puts the objects in display order: named objects by name, rules by
// chain and position, NAT rules by position.
func (st *State) Sort() {
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
sort.SliceStable(st.Rules, func(i, j int) bool {
a, b := st.Rules[i], st.Rules[j]
if a.Chain != b.Chain {
return ChainOrder(a.Chain) < ChainOrder(b.Chain)
}
return a.Position < b.Position
})
sort.SliceStable(st.Forwards, func(i, j int) bool { return st.Forwards[i].Position < st.Forwards[j].Position })
sort.SliceStable(st.SNATs, func(i, j int) bool { return st.SNATs[i].Position < st.SNATs[j].Position })
sort.SliceStable(st.Redirects, func(i, j int) bool { return st.Redirects[i].Position < st.Redirects[j].Position })
}
// Renumber gives the rules of each chain, and the NAT rules of each kind,
// positions 1, 2, 3, … in their current order (after an insertion at a
// fractional position, or a deletion).
func (st *State) Renumber() {
st.Sort()
n := map[string]float64{}
for _, r := range st.Rules {
n[r.Chain]++
r.Position = n[r.Chain]
}
for i, f := range st.Forwards {
f.Position = float64(i + 1)
}
for i, s := range st.SNATs {
s.Position = float64(i + 1)
}
for i, r := range st.Redirects {
r.Position = float64(i + 1)
}
}
// Zone finds a zone by name.
func (st *State) Zone(name string) *Zone {
for _, z := range st.Zones {
if strings.EqualFold(z.Name, name) {
return z
}
}
return nil
}
// Set finds an address set by name.
func (st *State) Set(name string) *AddressSet {
for _, s := range st.Sets {
if strings.EqualFold(s.Name, name) {
return s
}
}
return nil
}
// Service finds a service by name: the configuration's, else the built-in
// catalog's.
func (st *State) Service(name string) *Service {
for _, s := range st.Services {
if strings.EqualFold(s.Name, name) {
return s
}
}
return BuiltinService(name)
}
// ByID finds an object of a kind by ID (nil if there is none).
func (st *State) ByID(kind string, id int64) Object {
for _, o := range st.Objects(kind) {
if o.GetID() == id {
return o
}
}
return nil
}
// Find finds an object of a kind by ID or name (zones, sets, services,
// port forwards) or by ID alone (rules, source NAT, redirects).
func (st *State) Find(kind, ref string) Object {
for _, o := range st.Objects(kind) {
if itoa(o.GetID()) == ref {
return o
}
}
switch kind {
case KindZone:
if z := st.Zone(ref); z != nil {
return z
}
case KindSet:
if s := st.Set(ref); s != nil {
return s
}
case KindService:
for _, s := range st.Services {
if strings.EqualFold(s.Name, ref) {
return s
}
}
case KindForward:
for _, f := range st.Forwards {
if f.Name != "" && strings.EqualFold(f.Name, ref) {
return f
}
}
}
return nil
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
// ExternalInterfaces lists the interfaces of the external zones.
func (st *State) ExternalInterfaces() []string {
return st.zoneInterfaces(func(z *Zone) bool { return z.External })
}
// InternalInterfaces lists the interfaces of the internal zones.
func (st *State) InternalInterfaces() []string {
return st.zoneInterfaces(func(z *Zone) bool { return !z.External })
}
func (st *State) zoneInterfaces(keep func(*Zone) bool) []string {
seen := map[string]bool{}
var out []string
for _, z := range st.Zones {
if !keep(z) {
continue
}
for _, i := range z.Interfaces {
if !seen[i] {
seen[i] = true
out = append(out, i)
}
}
}
sort.Strings(out)
return out
}