- interfaces.exclude in steward.yaml (names or shell patterns) leaves interfaces out of GET /interfaces, the web UI's picker and 'steward interface list'. Subnets on them are still served. - With the ping check on, active leases are pinged every minute; a client that answers gets a last-seen time (leases.last_seen, migration 4). Not answering never takes a client offline. - The activity log filters by subnet (--subnet, the Activity page, GET /events?subnet=). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
608 lines
16 KiB
Go
608 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) && !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
|
|
}
|
|
|
|
// 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.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
|
|
}
|