// Package engine changes Steward'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, and only then are // the changed objects and a history entry saved together and the new state // handed to the DHCP server. Revert undoes a history entry the same way. package engine import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "sort" "strings" "sync" "time" "steward/internal/auth" "steward/internal/dhcp4" "steward/internal/model" "steward/internal/store" "steward/internal/validate" ) // Kinds of errors, for the API's status codes. const ( KindBadRequest = "bad_request" KindNotFound = "not_found" KindConflict = "conflict" KindValidation = "validation" ) // 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 // OnChange is called with each new state (the DHCP server's config). 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 opts.DryRun { return res, nil } 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 { 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 } // 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 _, s := range st.Subnets { m[key{model.KindSubnet, s.ID}] = mustJSON(s) } for _, r := range st.Reservations { m[key{model.KindReservation, r.ID}] = mustJSON(r) } return m } func labels(st *model.State) map[key]string { m := map[key]string{{model.KindSettings, 0}: "settings"} for _, s := range st.Subnets { m[key{model.KindSubnet, s.ID}] = s.Label() } for _, r := range st.Reservations { m[key{model.KindReservation, r.ID}] = r.Label() } return m } // Diff lists what changes from a to b: settings, then subnets, then // reservations, 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, model.KindSubnet: 1, model.KindReservation: 2} 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 subnet office (10.0.10.0/24)", 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 { switch kind { case model.KindSettings: if data == nil { return fmt.Errorf("settings can't be deleted") } var s model.Settings if err := json.Unmarshal(data, &s); err != nil { return err } st.Settings = s case model.KindSubnet: var keep []*model.Subnet for _, s := range st.Subnets { if s.ID != id { keep = append(keep, s) } } if data != nil { var s model.Subnet if err := json.Unmarshal(data, &s); err != nil { return err } keep = append(keep, &s) } st.Subnets = keep case model.KindReservation: var keep []*model.Reservation for _, r := range st.Reservations { if r.ID != id { keep = append(keep, r) } } if data != nil { var r model.Reservation if err := json.Unmarshal(data, &r); err != nil { return err } keep = append(keep, &r) } st.Reservations = keep default: return fmt.Errorf("unknown kind %q", kind) } return nil } // Normalize fills in option names and writes option values, pools and // lists in their canonical form, so equal configurations compare equal. func Normalize(st *model.State) { st.Settings.Options = normOptions(st.Settings.Options) if st.Settings.AddressOrder == model.OrderSequential { st.Settings.AddressOrder = "" // the default } if st.Subnets == nil { st.Subnets = []*model.Subnet{} } if st.Reservations == nil { st.Reservations = []*model.Reservation{} } for _, s := range st.Subnets { s.Options = normOptions(s.Options) if s.Pools == nil { s.Pools = []model.Pool{} } if s.Prefix.IsValid() && s.Prefix.Addr().Is4() { s.Prefix = s.Prefix.Masked() } // The pools' order counts (pool_order listed): it is kept. if s.PoolOrder == model.PoolsListed { s.PoolOrder = "" } if s.Access == model.AccessDenyList { s.Access = "" } if len(s.Allow) == 0 { s.Allow = nil } if len(s.Deny) == 0 { s.Deny = nil } } for _, r := range st.Reservations { r.Options = normOptions(r.Options) } } func normOptions(opts []model.OptionValue) []model.OptionValue { out := []model.OptionValue{} for _, o := range opts { if o.Code > 0 && o.Code < 255 { def := dhcp4.ByCode(byte(o.Code)) o.Name = def.Name typ := def.Type if dhcp4.Known(byte(o.Code)) { o.Type = "" } else if o.Type != "" { typ = o.Type } if v, err := dhcp4.Normalize(typ, o.Value); err == nil { o.Value = v } } out = append(out, o) } sort.SliceStable(out, func(i, j int) bool { return out[i].Code < out[j].Code }) return out }