// 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 } // Rewind applies a history entry backwards to st without Undo's check: // stepping back through the entries newest first brings back the // configuration as it was before each. func Rewind(st *model.State, h *model.HistoryEntry) error { for i := len(h.Changes) - 1; i >= 0; i-- { c := h.Changes[i] if c.Kind == model.KindSettings && c.Before == nil { continue } if err := put(st, c.Kind, c.ID, c.Before); err != nil { return err } } // put appends what it brings back; its position puts it in place. st.Sort() 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) if j := x.Jarl; j != nil { for i, n := range j.Names { j.Names[i] = model.ParseJarlName(n.Name) if n.View != "" { j.Names[i].View = strings.TrimSpace(n.View) } } j.Within = normAddrs(j.Within) // zone names stay as they are } } 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) } for _, b := range st.BanLists { b.Name = strings.TrimSpace(b.Name) b.Scope = strings.ToLower(strings.TrimSpace(b.Scope)) b.Hosts = normAddrs(b.Hosts) b.Never = normAddrs(b.Never) } // 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) }