Files
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

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

183 lines
5.7 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/netip"
"steward/internal/model"
)
const settingsKey = "settings"
// LoadState reads the whole configuration.
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
st := &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}
if v, err := s.GetMeta(ctx, settingsKey); err != nil {
return nil, err
} else if v != "" {
if err := json.Unmarshal([]byte(v), &st.Settings); err != nil {
return nil, fmt.Errorf("reading settings: %w", err)
}
}
if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{}
}
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
sn := &model.Subnet{}
var prefix, pools, opts string
var disabled int
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
sn.Disabled = disabled != 0
if err := json.Unmarshal([]byte(pools), &sn.Pools); err != nil {
return nil, fmt.Errorf("subnet %d pools: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
}
st.Subnets = append(st.Subnets, sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, comment FROM reservations ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
r := &model.Reservation{}
var ip, opts string
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil {
return nil, err
}
r.IP, _ = netip.ParseAddr(ip)
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
}
st.Reservations = append(st.Reservations, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, sn := range st.Subnets {
if sn.Pools == nil {
sn.Pools = []model.Pool{}
}
if sn.Options == nil {
sn.Options = []model.OptionValue{}
}
}
for _, r := range st.Reservations {
if r.Options == nil {
r.Options = []model.OptionValue{}
}
}
st.Sort()
return st, nil
}
// SaveChanges writes configuration changes and their history entry in one
// transaction. The entry gets its ID and, when it undoes another, that
// entry is marked reverted.
func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, c := range changes {
if err := applyChange(ctx, tx, c); err != nil {
return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err)
}
}
if e != nil {
var uid any
if e.UserID != 0 {
uid = e.UserID
}
var revertOf any
if e.RevertOf != 0 {
revertOf = e.RevertOf
}
res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`,
ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf)
if err != nil {
return err
}
if e.ID, err = res.LastInsertId(); err != nil {
return err
}
if e.RevertOf != 0 {
if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil {
return err
}
}
}
return tx.Commit()
}
func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
switch c.Kind {
case model.KindSettings:
if c.After == nil {
return fmt.Errorf("settings can't be deleted")
}
_, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After))
return err
case model.KindSubnet:
if c.After == nil {
_, err := tx.ExecContext(ctx, `DELETE FROM subnets WHERE id = ?`, c.ID)
return err
}
var sn model.Subnet
if err := json.Unmarshal(c.After, &sn); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
_, err := tx.ExecContext(ctx, `DELETE FROM reservations WHERE id = ?`, c.ID)
return err
}
var r model.Reservation
if err := json.Unmarshal(c.After, &r); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, comment) VALUES (?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
options = excluded.options, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment)
return err
}
return fmt.Errorf("unknown kind %q", c.Kind)
}
func nonNil(p []model.Pool) []model.Pool {
if p == nil {
return []model.Pool{}
}
return p
}
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
if o == nil {
return []model.OptionValue{}
}
return o
}