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>
602 lines
16 KiB
Go
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
|
|
}
|