Files
housecarl/internal/engine/engine.go
T
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

568 lines
15 KiB
Go

// Package engine changes Housecarl's configuration. Every change, from the
// API, the CLI or an import, goes through Apply: the change is made on a
// copy of the state, the result is validated as a whole, the ruleset built
// from it is checked by nftables and put in force, and only then are the
// changed objects and a history entry saved together (if saving fails, the
// previous ruleset is put back). Revert undoes a history entry the same
// way.
package engine
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/store"
"housecarl/internal/validate"
)
// Kinds of errors, for the API's status codes.
const (
KindBadRequest = "bad_request"
KindNotFound = "not_found"
KindConflict = "conflict"
KindValidation = "validation"
// KindFirewall: nftables refused or failed to apply the ruleset.
KindFirewall = "firewall"
)
// Firewall puts configurations in force (internal/nft, through
// internal/render).
type Firewall interface {
// Check builds the ruleset and has nftables check it, changing
// nothing.
Check(ctx context.Context, st *model.State) error
// Apply puts the configuration's ruleset in force, in one
// transaction.
Apply(ctx context.Context, st *model.State) error
}
// Error is a refused change.
type Error struct {
Kind string `json:"code"`
Message string `json:"message"`
Problems []validate.Problem `json:"problems,omitempty"`
}
func (e *Error) Error() string { return e.Message }
// Errorf makes an Error.
func Errorf(kind, format string, args ...any) *Error {
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
}
// AsError unwraps an Error.
func AsError(err error) (*Error, bool) {
var e *Error
ok := errors.As(err, &e)
return e, ok
}
// Result describes an applied (or previewed) change.
type Result struct {
Applied bool `json:"applied"`
DryRun bool `json:"dry_run,omitempty"`
HistoryID int64 `json:"history_id,omitempty"`
Changes []model.Change `json:"changes"`
Warnings []validate.Problem `json:"warnings,omitempty"`
}
// Engine holds the current configuration.
type Engine struct {
store *store.Store
log *slog.Logger
// Env describes the host's interfaces for validation (nil: skip).
Env func() *validate.Env
// Firewall puts each new state in force (nil: none, in tests that
// only look at the configuration).
Firewall Firewall
// OnChange is called with each new state, after it was saved.
OnChange func(*model.State)
mu sync.Mutex
state *model.State
}
// New loads the configuration.
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
if log == nil {
log = slog.Default()
}
s, err := st.LoadState(ctx)
if err != nil {
return nil, err
}
Normalize(s)
return &Engine{store: st, log: log, state: s}, nil
}
// Store is the engine's database.
func (e *Engine) Store() *store.Store { return e.store }
// State returns a copy of the current configuration.
func (e *Engine) State() *model.State {
e.mu.Lock()
defer e.mu.Unlock()
return e.state.Clone()
}
func (e *Engine) env() *validate.Env {
if e.Env == nil {
return nil
}
return e.Env()
}
// Problems validates the current configuration (for the status page).
func (e *Engine) Problems() []validate.Problem {
return validate.Check(e.State(), e.env())
}
// Edit changes a configuration in place. NewID gives IDs for new objects.
type Edit func(st *model.State, newID func() int64) error
// Options say how to apply a change.
type Options struct {
DryRun bool
Via string // web, cli, api, import
}
// Apply makes a change: edit runs on a copy of the configuration, the
// result is validated, and the changed objects are saved with a history
// entry. A change that touches nothing is not recorded.
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
e.mu.Lock()
defer e.mu.Unlock()
next := e.state.Clone()
var idErr error
newID := func() int64 {
ids, err := e.store.AllocateIDs(ctx, 1)
if err != nil {
idErr = err
return 0
}
return ids[0]
}
if err := edit(next, newID); err != nil {
return nil, err
}
if idErr != nil {
return nil, idErr
}
return e.commit(ctx, p, description, opts, next, 0)
}
// commit validates next and saves it as the new state.
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
Normalize(next)
next.Sort()
changes := Diff(e.state, next)
res := &Result{DryRun: opts.DryRun, Changes: changes}
if len(changes) == 0 {
return res, nil
}
problems := validate.Check(next, e.env())
if validate.HasErrors(problems) {
errs := validate.Errors(problems)
msg := errs[0].Message
if errs[0].Object != "" {
msg = errs[0].Object + ": " + msg
}
if len(errs) > 1 {
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
}
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
}
// Only report warnings the change brings; old ones are on the status
// page.
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
if e.Firewall != nil {
if err := e.Firewall.Check(ctx, next); err != nil {
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
}
}
if opts.DryRun {
return res, nil
}
if e.Firewall != nil {
if err := e.Firewall.Apply(ctx, next); err != nil {
return res, &Error{Kind: KindFirewall, Message: "applying the ruleset failed, nothing was changed: " + err.Error()}
}
}
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
if p != nil && p.User != nil {
entry.UserID = p.User.ID
}
if entry.Description == "" {
entry.Description = Describe(changes)
}
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
// The new ruleset is in force but not saved: put the old one back.
if e.Firewall != nil {
if rerr := e.Firewall.Apply(ctx, e.state); rerr != nil {
e.log.Error("saving a change failed, and putting the previous ruleset back failed too", "err", rerr)
}
}
return res, err
}
e.state = next
res.Applied, res.HistoryID = true, entry.ID
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
if e.OnChange != nil {
e.OnChange(next.Clone())
}
return res, nil
}
func newProblems(before, after []validate.Problem) []validate.Problem {
seen := map[string]bool{}
for _, p := range before {
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
}
var out []validate.Problem
for _, p := range after {
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
out = append(out, p)
}
}
return out
}
// ApplyCurrent puts the current configuration in force (at start).
func (e *Engine) ApplyCurrent(ctx context.Context) error {
if e.Firewall == nil {
return nil
}
return e.Firewall.Apply(ctx, e.State())
}
// Revert undoes a history entry, if the objects it changed are still as
// it left them.
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
h, err := e.store.GetHistory(ctx, id)
if errors.Is(err, store.ErrNotFound) {
return nil, Errorf(KindNotFound, "history entry %d not found", id)
}
if err != nil {
return nil, err
}
if h.RevertedBy != 0 {
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
}
e.mu.Lock()
defer e.mu.Unlock()
cur := objects(e.state)
next := e.state.Clone()
for i := len(h.Changes) - 1; i >= 0; i-- {
c := h.Changes[i]
now := cur[key{c.Kind, c.ID}]
if !sameJSON(now, c.After) {
what := c.Label
if what == "" {
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
}
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
}
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
return nil, err
}
}
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
}
// Replace makes the configuration next as a whole (imports).
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
e.mu.Lock()
defer e.mu.Unlock()
return e.commit(ctx, p, description, opts, next.Clone(), 0)
}
// Audit records an action in the audit log.
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
if p != nil {
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
if p.User != nil {
ev.UserID = p.User.ID
}
if p.Token != nil {
ev.TokenID = p.Token.ID
}
}
if err := e.store.AddAudit(ctx, ev); err != nil {
e.log.Warn("writing the audit log failed", "err", err)
}
}
// ---- diffs
type key struct {
kind string
id int64
}
func objects(st *model.State) map[key]json.RawMessage {
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
for _, k := range model.Kinds {
for _, o := range st.Objects(k) {
m[key{k, o.GetID()}] = mustJSON(o)
}
}
return m
}
func labels(st *model.State) map[key]string {
m := map[key]string{{model.KindSettings, 0}: "settings"}
for _, k := range model.Kinds {
for _, o := range st.Objects(k) {
m[key{k, o.GetID()}] = o.Label()
}
}
return m
}
// Diff lists what changes from a to b: settings, then each kind of
// object (zones, sets, services, rules, NAT), each by ID.
func Diff(a, b *model.State) []model.Change {
oa, ob := objects(a), objects(b)
la, lb := labels(a), labels(b)
var keys []key
for k := range oa {
keys = append(keys, k)
}
for k := range ob {
if _, ok := oa[k]; !ok {
keys = append(keys, k)
}
}
order := map[string]int{model.KindSettings: 0}
for i, k := range model.Kinds {
order[k] = i + 1
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].kind != keys[j].kind {
return order[keys[i].kind] < order[keys[j].kind]
}
return keys[i].id < keys[j].id
})
var out []model.Change
for _, k := range keys {
before, after := oa[k], ob[k]
if sameJSON(before, after) {
continue
}
label := lb[k]
if label == "" {
label = la[k]
}
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
}
return out
}
// Describe sums up changes: "create zone lan (enp2s0)", or
// "3 changes" for many.
func Describe(cs []model.Change) string {
if len(cs) == 0 {
return "no changes"
}
parts := []string{}
for _, c := range cs {
if c.Kind == model.KindSettings {
parts = append(parts, "update settings")
continue
}
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
}
if len(parts) > 3 {
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
}
return strings.Join(parts, ", ")
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func sameJSON(a, b json.RawMessage) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return bytes.Equal(a, b)
}
// put sets (or with nil data deletes) one object in st.
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
if kind == model.KindSettings {
if data == nil {
return fmt.Errorf("settings can't be deleted")
}
var set model.Settings
if err := json.Unmarshal(data, &set); err != nil {
return err
}
st.Settings = set
return nil
}
return st.Put(kind, id, data)
}
// Normalize writes names, addresses, ports and lists in their canonical
// form, so equal configurations compare equal, and spaces rule positions
// out again when insertions have crowded them.
func Normalize(st *model.State) {
st.Fill()
set := &st.Settings
set.AntispoofV4 = normAddrs(set.AntispoofV4)
set.AntispoofV6 = normAddrs(set.AntispoofV6)
set.OwnPrefixes = normAddrs(set.OwnPrefixes)
set.FlowtableDevices = normList(set.FlowtableDevices)
for _, z := range st.Zones {
z.Name = strings.TrimSpace(z.Name)
z.Interfaces = normList(z.Interfaces)
}
for _, x := range st.Sets {
x.Name = strings.TrimSpace(x.Name)
x.Entries = normAddrs(x.Entries)
}
for _, x := range st.Services {
x.Name = strings.TrimSpace(x.Name)
x.Entries = normServices(x.Entries)
}
for _, r := range st.Rules {
normMatch(&r.Match)
r.CtState = normList(lower(r.CtState))
r.CtStatus = normList(lower(r.CtStatus))
r.Raw = strings.TrimSpace(r.Raw)
}
for _, f := range st.Forwards {
f.Protocols = normList(lower(f.Protocols))
f.PublicAddresses = normAddrs(f.PublicAddresses)
f.Sources = normAddrs(f.Sources)
f.HairpinSources = normAddrs(f.HairpinSources)
f.Ports = normPorts(f.Ports)
f.ToPort = normPorts(f.ToPort)
f.To = normAddr(f.To)
f.To6 = normAddr(f.To6)
}
for _, x := range st.SNATs {
normMatch(&x.Match)
x.To = normAddr(x.To)
}
for _, x := range st.Redirects {
normMatch(&x.Match)
x.ToPort = normPorts(x.ToPort)
}
// Positions are fractions after insertions between rules; renumber
// only when two have come too close to put another between.
st.Sort()
crowded := func(pos []float64) bool {
for i := 1; i < len(pos); i++ {
if pos[i]-pos[i-1] < 1e-6 {
return true
}
}
return false
}
byChain := map[string][]float64{}
for _, r := range st.Rules {
byChain[r.Chain] = append(byChain[r.Chain], r.Position)
}
re := false
for _, pos := range byChain {
re = re || crowded(pos)
}
var fp, sp, rp []float64
for _, x := range st.Forwards {
fp = append(fp, x.Position)
}
for _, x := range st.SNATs {
sp = append(sp, x.Position)
}
for _, x := range st.Redirects {
rp = append(rp, x.Position)
}
if re || crowded(fp) || crowded(sp) || crowded(rp) {
st.Renumber()
}
}
func normMatch(m *model.Match) {
m.InZones = normList(m.InZones)
m.OutZones = normList(m.OutZones)
m.Sources = normAddrs(m.Sources)
m.Destinations = normAddrs(m.Destinations)
m.Services = normServices(m.Services)
m.Family = strings.ToLower(strings.TrimSpace(m.Family))
}
// normList trims, drops empty entries and duplicates, keeping the order.
func normList(in []string) []string {
var out []string
seen := map[string]bool{}
for _, s := range in {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func lower(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ToLower(s)
}
return out
}
// normAddrs writes addresses canonically; set names stay as they are.
func normAddrs(in []string) []string {
out := normList(in)
for i, s := range out {
out[i] = normAddr(s)
}
return normList(out)
}
func normAddr(s string) string {
s = strings.TrimSpace(s)
if n, err := model.NormalizeAddress(s); err == nil {
return n
}
return s
}
func normServices(in []string) []string {
out := normList(in)
for i, s := range out {
if e, err := model.ParseServiceEntry(s); err == nil {
out[i] = e.String()
}
}
return normList(out)
}
func normPorts(s string) string {
if n, err := model.NormalizePorts(s); err == nil {
return n
}
return strings.TrimSpace(s)
}