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 }