- A subnet is a deny-list subnet (the default: everyone not denied) or
an allow-list subnet (only clients allowed or with a reservation);
the deny list counts in both. Entries are MAC addresses, prefixes
(aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
in the activity log, with an Allow button) or a NAK on renewal.
steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
decoded into words (dhcp4.Describe): the parameter request list by
name, client IDs, lease times. ▸ on an Activity row, steward
activity show N / --details, GET /events/{id}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
12 KiB
Go
475 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 = ""
|
|
}
|
|
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
|
|
}
|