Files
steward/internal/server/h_config.go
T
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

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

630 lines
17 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
}
if in.AddressOrder != nil {
set.AddressOrder = orderInput(*in.AddressOrder)
}
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) && !res.Disabled {
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
}
// orderInput reads an address or pool order: "default" is "" (inherited,
// or the default); validation checks the rest.
func orderInput(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "default" {
return ""
}
return s
}
// 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.PoolOrder != nil {
sn.PoolOrder = orderInput(*in.PoolOrder)
}
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
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.LeaseTime != nil {
res.LeaseTime = *in.LeaseTime
}
if in.Disabled != nil {
res.Disabled = *in.Disabled
}
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 {
if s.excludedInterface(name) {
continue
}
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 != "" && !s.excludedInterface(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
}