Files
steward/internal/server/h_leases.go
T
abrown38andClaude Opus 5.5 3ad2f981d4 Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

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

280 lines
8.1 KiB
Go

package server
import (
"errors"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcpd"
"steward/internal/model"
"steward/internal/store"
"steward/internal/validate"
"steward/internal/version"
)
func (s *Server) nowFn() time.Time { return time.Now() }
func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
c := *l
c.State = l.StateAt(s.nowFn())
out := &api.Lease{Lease: &c}
if sn := st.SubnetFor(l.IP); sn != nil {
out.SubnetName = sn.Name
if out.SubnetName == "" {
out.SubnetName = sn.Prefix.String()
}
}
for _, r := range st.Reservations {
if r.IP == l.IP && !r.Disabled {
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
}
}
return out
}
// listLeases: GET /leases?subnet=&state=&interface=&q= (q matches the
// address, MAC address or host name).
func (s *Server) listLeases(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
st := s.eng.State()
q := r.URL.Query()
var only *model.Subnet
if ref := q.Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(st, ref); err != nil {
return err
}
}
state, iface, search := q.Get("state"), q.Get("interface"), strings.ToLower(q.Get("q"))
out := []*api.Lease{}
for _, l := range s.dhcp.Leases() {
pl := s.presentLease(l, st)
if only != nil && !only.Prefix.Contains(l.IP) || state != "" && pl.State != state || iface != "" && l.Interface != iface {
continue
}
if search != "" && !strings.Contains(l.IP.String(), search) && !strings.Contains(l.MAC, search) &&
!strings.Contains(strings.ToLower(l.Hostname), search) && !strings.Contains(strings.ToLower(pl.ReservedHostname), search) {
continue
}
out = append(out, pl)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func leaseIP(r *http.Request) (netip.Addr, error) {
a, err := netip.ParseAddr(r.PathValue("ip"))
if err != nil || !a.Is4() {
return netip.Addr{}, badRequest("%q is not an IPv4 address", r.PathValue("ip"))
}
return a, nil
}
func (s *Server) getLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
ip, err := leaseIP(r)
if err != nil {
return err
}
l := s.dhcp.Lease(ip)
if l == nil {
return notFound("no lease for %s", ip)
}
writeJSON(w, http.StatusOK, s.presentLease(l, s.eng.State()))
return nil
}
// deleteLease forgets a lease, freeing the address.
func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, true); err != nil {
return err
}
ip, err := leaseIP(r)
if err != nil {
return err
}
l := s.dhcp.Lease(ip)
if err := s.dhcp.DeleteLease(r.Context(), ip); err != nil {
if errors.Is(err, dhcpd.ErrNoLease) {
return notFound("no lease for %s", ip)
}
return err
}
s.eng.Audit(r.Context(), p, "lease.delete", "lease "+ip.String(), "success", l.MAC+" "+l.Hostname)
w.WriteHeader(http.StatusNoContent)
return nil
}
// reserveLease makes a lease's address the client's reservation.
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
ip, err := leaseIP(r)
if err != nil {
return err
}
var in struct {
Hostname *string `json:"hostname,omitempty"`
Comment string `json:"comment,omitempty"`
// By is how the reservation knows the client: mac, client_id or
// both (the default when the client sent a client ID).
By string `json:"by,omitempty"`
}
if err := decodeOptional(r, &in); err != nil {
return err
}
l := s.dhcp.Lease(ip)
if l == nil || l.MAC == "" && l.ClientID == "" {
return notFound("no lease for %s", ip)
}
mac, cid := l.MAC, l.ClientID
switch strings.ReplaceAll(in.By, "-", "_") {
case "", "both":
case "mac":
cid = ""
case "client_id":
mac = ""
default:
return badRequest("by %q: use mac, client_id or both", in.By)
}
if mac == "" && cid == "" {
what := "client ID (option 61); reserve it by mac"
if in.By == "mac" {
what = "MAC address; reserve it by client_id"
}
return badRequest("the client of %s sent no %s", ip, what)
}
who := mac
if cid != "" {
who = strings.TrimSpace(mac + " client id " + cid)
}
host := l.Hostname
if in.Hostname != nil {
host = *in.Hostname
} else if !validate.ValidHostname(host) {
host = ""
}
var id int64
return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error {
id = newID()
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
return nil
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
q := r.URL.Query()
var only *model.Subnet
if ref := q.Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(s.eng.State(), ref); err != nil {
return err
}
}
mac, iface := q.Get("mac"), q.Get("interface")
evs := s.dhcp.Events(queryInt(r, "limit", 200), func(e dhcpd.Event) bool {
if mac != "" && !strings.EqualFold(e.MAC, mac) || iface != "" && e.Interface != iface {
return false
}
if only != nil {
// A message with an address belongs to the subnet that has
// it; one without (a DISCOVER) to every subnet on its
// interface.
if ip, err := netip.ParseAddr(e.IP); err == nil {
return only.Prefix.Contains(ip)
}
return e.Interface == only.Interface
}
return true
})
details := q.Get("details") == "1"
out := make([]api.Event, len(evs))
for i, e := range evs {
if !details {
e.Fields, e.Options = nil, nil
}
out[i] = api.Event(e)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// getEvent: GET /events/{id}, one message with its fields and options.
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
return badRequest("%q is not an event number", r.PathValue("id"))
}
e, ok := s.dhcp.Event(id)
if !ok {
return notFound("no event %d: only the last 1000 are kept, and none from before stewardd started", id)
}
writeJSON(w, http.StatusOK, api.Event(e))
return nil
}
// ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLeases, false) {
return forbidden("permission denied: this needs config:read or leases:read")
}
st := s.eng.State()
leases := s.dhcp.Leases()
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()}
if p.IsAdmin() {
out.Problems = append(out.Problems, s.certificateProblems()...)
}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
for _, is := range s.dhcp.Status() {
out.Interfaces = append(out.Interfaces, api.InterfaceStatus(is))
}
for _, sn := range st.Subnets {
out.Subnets = append(out.Subnets, s.presentSubnet(sn, st, leases))
}
now := s.nowFn()
for _, l := range leases {
out.Leases[l.StateAt(now)]++
}
if s.cfg.PingProblem != nil && st.Settings.PingCheck {
out.PingProblem = s.cfg.PingProblem()
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResAudit, false); err != nil {
return err
}
q := r.URL.Query()
evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"),
Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, evs)
return nil
}