Files
housecarl/internal/engine/engine.go
T
abrown38andClaude Opus 5.5 9863dd1cdd Quotas: carry what was used when a rule counts differently
model.QuotaCarry says what becomes of a quota's use when its rule
changes how it counts; the firewall's apply follows it, comparing with
the rules in force: per address to a total starts at the addresses' sum,
wider groups add up their addresses, and what can't be carried (a total
to per address, per source to per destination, finer groups) starts
again, with the engine warning quota_restarts on that change. Before,
per source to per destination kept the sources' use as destinations',
and regrouping left old entries behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:57:18 +01:00

610 lines
17 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"`
// Before and After are the configurations the change goes from and
// to (After also for a dry run), for diffs and answers.
Before, After *model.State `json:"-"`
}
// 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
// RevertOf is the history entry the change undoes (a changeset with a
// staged undo), so History links the two.
RevertOf int64
}
// 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, opts.RevertOf)
}
// 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, Before: e.state.Clone(), After: next.Clone()}
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)
res.Warnings = append(res.Warnings, quotaRestarts(e.state, next)...)
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
}
// quotaRestarts warns of the rules whose quota starts again because they
// now count it in a way what they used can't be carried to.
func quotaRestarts(before, after *model.State) []validate.Problem {
was := map[int64]*model.Rule{}
for _, r := range before.Rules {
was[r.ID] = r
}
var out []validate.Problem
for _, r := range after.Rules {
if carry, why := model.QuotaCarry(was[r.ID], r); carry == model.QuotaRestarts {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "quota_restarts", Object: r.Label(),
Message: "its quota starts again: " + why})
}
}
return out
}
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 again: at start,
// and when a flowtable device appears. It holds the engine's lock, so a
// change can't be applied in between.
func (e *Engine) ApplyCurrent(ctx context.Context) error {
if e.Firewall == nil {
return nil
}
e.mu.Lock()
defer e.mu.Unlock()
return e.Firewall.Apply(ctx, e.state.Clone())
}
// 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()
next := e.state.Clone()
if err := Undo(next, h); err != nil {
return nil, err
}
return e.commit(ctx, p, UndoDescription(h), opts, next, h.ID)
}
// UndoDescription is an undo's description: "undo 12: create rule …".
func UndoDescription(h *model.HistoryEntry) string {
return fmt.Sprintf("undo %d: %s", h.ID, h.Description)
}
// Undo applies a history entry backwards to st, if every object it
// changed is still as it left it (a change made since would be lost).
func Undo(st *model.State, h *model.HistoryEntry) error {
cur := objects(st)
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 Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, h.ID)
}
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
return err
}
}
return nil
}
// 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 (eth1)", 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)
}