package server import ( "errors" "net/http" "net/netip" "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 }) out := make([]api.Event, len(evs)) for i, e := range evs { out[i] = api.Event(e) } writeJSON(w, http.StatusOK, out) 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 }