Files
steward/internal/server/h_config.go
T
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

602 lines
16 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcp4"
"steward/internal/engine"
"steward/internal/model"
"steward/internal/store"
)
// change applies a configuration change and answers with what changed.
// object, if set, returns the object to include from the new state.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.respondChange(w, r, res, object, r.Method == http.MethodPost)
return nil
}
// respondChange answers a change: 201 when it created something.
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if object != nil && res.Applied {
if v := object(s.eng.State()); v != nil {
out.Object, _ = json.Marshal(v)
}
}
status := http.StatusOK
if res.Applied && creates {
status = http.StatusCreated
}
writeJSON(w, status, out)
}
// optionsFrom reads options given by name or number.
func optionsFrom(in []api.OptionInput) ([]model.OptionValue, error) {
out := []model.OptionValue{}
for _, o := range in {
def, ok := dhcp4.Lookup(o.Option)
if !ok {
return nil, badRequest("unknown option %q: use a name from 'steward option list' or a number from 1 to 254", o.Option)
}
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
return nil, badRequest("option %s (%d) can't be set: %s", o.Option, def.Code, why)
}
v := model.OptionValue{Code: int(def.Code), Value: o.Value}
if !dhcp4.Known(def.Code) {
v.Type = o.Type
if v.Type == "" {
v.Type = dhcp4.TypeHex
}
} else if o.Type != "" && o.Type != def.Type {
return nil, badRequest("option %s has type %s; a type can only be given for options Steward doesn't know", def.Name, def.Type)
}
out = append(out, v)
}
return out, nil
}
// ---- settings
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.eng.State().Settings)
return nil
}
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SettingsInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var opts []model.OptionValue
if in.Options != nil {
var err error
if opts, err = optionsFrom(*in.Options); err != nil {
return err
}
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
set := &st.Settings
if in.LeaseTime != nil {
set.LeaseTime = *in.LeaseTime
}
if in.Options != nil {
set.Options = opts
}
if in.PingCheck != nil {
set.PingCheck = *in.PingCheck
}
if in.PingTimeout != nil {
set.PingTimeout = *in.PingTimeout
}
if in.DeclineHold != nil {
set.DeclineHold = *in.DeclineHold
}
if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative
}
return nil
}, func(st *model.State) any { return st.Settings })
}
// ---- subnets
func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*model.Lease) api.Subnet {
out := api.Subnet{Subnet: sn}
now := s.nowFn()
for _, p := range sn.Pools {
out.Usage.PoolSize += p.Size()
}
reserved := map[netip.Addr]bool{}
for _, res := range st.Reservations {
if sn.Prefix.Contains(res.IP) {
out.Usage.Reservations++
reserved[res.IP] = true
for _, p := range sn.Pools {
if p.Contains(res.IP) {
out.Usage.PoolSize-- // reserved addresses aren't handed out
}
}
}
}
for _, l := range leases {
if !sn.Prefix.Contains(l.IP) {
continue
}
switch {
case reserved[l.IP] && l.ActiveAt(now):
out.Usage.ReservationsLeased++
case l.ActiveAt(now):
out.Usage.Leased++
case l.HeldAt(now):
out.Usage.Declined++
}
}
return out
}
func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
leases := s.dhcp.Leases()
out := []api.Subnet{}
for _, sn := range st.Subnets {
out = append(out, s.presentSubnet(sn, st, leases))
}
writeJSON(w, http.StatusOK, out)
return nil
}
// subnetRef finds a subnet by ID, name or prefix (10.0.10.0/24 or
// 10.0.10.0_24 in a path).
func subnetRef(st *model.State, ref string) (*model.Subnet, error) {
if sn := st.FindSubnet(strings.Replace(ref, "_", "/", 1)); sn != nil {
return sn, nil
}
return nil, notFound("no subnet %q (give its ID, name or prefix)", ref)
}
func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.presentSubnet(sn, st, s.dhcp.Leases()))
return nil
}
// applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil {
sn.Name = strings.TrimSpace(*in.Name)
}
if in.Interface != nil {
sn.Interface = strings.TrimSpace(*in.Interface)
}
if in.Prefix != nil {
pfx, err := netip.ParsePrefix(strings.TrimSpace(*in.Prefix))
if err != nil {
return badRequest("prefix %q: write the network as 10.0.10.0/24", *in.Prefix)
}
sn.Prefix = pfx
}
if in.Pools != nil {
sn.Pools = []model.Pool{}
for _, ps := range *in.Pools {
pool, err := model.ParsePool(ps)
if err != nil {
return badRequest("%v", err)
}
sn.Pools = append(sn.Pools, pool)
}
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
if in.Options != nil {
opts, err := optionsFrom(*in.Options)
if err != nil {
return err
}
sn.Options = opts
}
if in.Disabled != nil {
sn.Disabled = *in.Disabled
}
if in.Comment != nil {
sn.Comment = *in.Comment
}
return nil
}
func (s *Server) createSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SubnetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Prefix == nil || in.Interface == nil {
return badRequest("a subnet needs its prefix and its interface")
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
sn := &model.Subnet{Pools: []model.Pool{}, Options: []model.OptionValue{}}
if err := applySubnetInput(sn, &in); err != nil {
return err
}
id = newID()
sn.ID = id
st.Subnets = append(st.Subnets, sn)
return nil
}, func(st *model.State) any { return st.SubnetByID(id) })
}
func (s *Server) updateSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SubnetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
id = sn.ID
return applySubnetInput(sn, &in)
}, func(st *model.State) any { return st.SubnetByID(id) })
}
func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
var keep []*model.Subnet
for _, x := range st.Subnets {
if x.ID != sn.ID {
keep = append(keep, x)
}
}
st.Subnets = keep
// Its reservations go with it (they can't be anywhere else).
var kr []*model.Reservation
for _, res := range st.Reservations {
if !sn.Prefix.Contains(res.IP) || st.SubnetFor(res.IP) != nil {
kr = append(kr, res)
}
}
st.Reservations = kr
return nil
}, nil)
}
// ---- reservations
func (s *Server) presentReservation(res *model.Reservation, st *model.State) api.Reservation {
out := api.Reservation{Reservation: res}
if sn := st.SubnetFor(res.IP); sn != nil {
out.SubnetID, out.SubnetName = sn.ID, sn.Name
if out.SubnetName == "" {
out.SubnetName = sn.Prefix.String()
}
}
if l := s.dhcp.Lease(res.IP); l != nil && l.ActiveAt(s.nowFn()) {
out.Lease = s.presentLease(l, st)
}
return out
}
func (s *Server) listReservations(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
var only *model.Subnet
if ref := r.URL.Query().Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(st, ref); err != nil {
return err
}
}
out := []api.Reservation{}
for _, res := range st.Reservations {
if only != nil && !only.Prefix.Contains(res.IP) {
continue
}
out = append(out, s.presentReservation(res, st))
}
writeJSON(w, http.StatusOK, out)
return nil
}
// reservationRef finds a reservation by ID, address or MAC address.
func reservationRef(st *model.State, ref string) (*model.Reservation, error) {
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
if res := st.ReservationByID(id); res != nil {
return res, nil
}
}
if a, err := netip.ParseAddr(ref); err == nil {
for _, res := range st.Reservations {
if res.IP == a {
return res, nil
}
}
}
if m, err := model.NormalizeMAC(ref); err == nil {
var found []*model.Reservation
for _, res := range st.Reservations {
if res.MAC == m {
found = append(found, res)
}
}
if len(found) == 1 {
return found[0], nil
}
if len(found) > 1 {
return nil, badRequest("%s has %d reservations; give the address or the ID", m, len(found))
}
}
return nil, notFound("no reservation %q (give its ID, address or MAC address)", ref)
}
func (s *Server) getReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.presentReservation(res, st))
return nil
}
func applyReservationInput(res *model.Reservation, in *api.ReservationInput) error {
if in.MAC != nil {
res.MAC = ""
if strings.TrimSpace(*in.MAC) != "" {
m, err := model.NormalizeMAC(*in.MAC)
if err != nil {
return badRequest("%v", err)
}
res.MAC = m
}
}
if in.ClientID != nil {
res.ClientID = ""
if strings.TrimSpace(*in.ClientID) != "" {
id, err := model.NormalizeClientID(*in.ClientID)
if err != nil {
return badRequest("%v", err)
}
res.ClientID = id
}
}
if in.IP != nil {
a, err := netip.ParseAddr(strings.TrimSpace(*in.IP))
if err != nil || !a.Is4() {
return badRequest("%q is not an IPv4 address", *in.IP)
}
res.IP = a
}
if in.Hostname != nil {
res.Hostname = strings.TrimSpace(*in.Hostname)
}
if in.Options != nil {
opts, err := optionsFrom(*in.Options)
if err != nil {
return err
}
res.Options = opts
}
if in.Comment != nil {
res.Comment = *in.Comment
}
return nil
}
func (s *Server) createReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.ReservationInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.IP == nil {
return badRequest("a reservation needs an address")
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
res := &model.Reservation{Options: []model.OptionValue{}}
if err := applyReservationInput(res, &in); err != nil {
return err
}
id = newID()
res.ID = id
st.Reservations = append(st.Reservations, res)
return nil
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) updateReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.ReservationInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
id = res.ID
return applyReservationInput(res, &in)
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) deleteReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
var keep []*model.Reservation
for _, x := range st.Reservations {
if x.ID != res.ID {
keep = append(keep, x)
}
}
st.Reservations = keep
return nil
}, nil)
}
// ---- history
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, hs)
return nil
}
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, h)
return nil
}
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.respondChange(w, r, res, nil, false)
return nil
}
// ---- catalog and interfaces
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
out := api.Catalog{Types: dhcp4.Types, Reserved: map[int]string{}, Roles: map[string][]string{}}
for _, d := range dhcp4.Catalog {
out.Options = append(out.Options, api.OptionDef{Code: int(d.Code), Name: d.Name, Type: d.Type, Help: d.Help, Common: d.Common})
}
for c, why := range dhcp4.Reserved {
out.Reserved[int(c)] = why
}
for n, gs := range auth.Roles {
out.Roles[n] = grantStrings(gs)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
all, err := s.ifaces.All()
if err != nil {
return fmt.Errorf("reading the interfaces: %w", err)
}
st := s.eng.State()
out := []api.HostInterface{}
for name, ps := range all {
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String())
}
for _, sn := range st.Subnets {
if sn.Interface == name {
hi.Subnets = append(hi.Subnets, sn.ID)
}
}
out = append(out, hi)
}
// Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" {
found := false
for i := range out {
if out[i].Name == sn.Interface {
out[i].Subnets = append(out[i].Subnets, sn.ID)
found = true
}
}
if !found {
out = append(out, api.HostInterface{Name: sn.Interface, Addresses: []string{}, Subnets: []int64{sn.ID}})
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
writeJSON(w, http.StatusOK, out)
return nil
}