Files
steward/internal/engine/engine.go
T
abrown38andClaude Opus 5.5 8ee430276c Address order: sequential, reverse or random, per subnet and pool
- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

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

466 lines
12 KiB
Go

// 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 = ""
}
}
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
}