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>
183 lines
5.7 KiB
Go
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
|
|
}
|