release / release (push) Successful in 5m53s
- A disabled reservation is kept but ignored: the client gets a pool address like any other (steward reservation disable|enable, buttons and a checkbox in the web UI). Schema migration 3. - Subnets get Disable/Enable buttons on their page and in the list. - A rule left without its selector in style.css broke the page heading's layout, so the New ... buttons sat cramped under the description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
6.1 KiB
Go
211 lines
6.1 KiB
Go
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"`
|
|
}
|
|
if err := decodeOptional(r, &in); err != nil {
|
|
return err
|
|
}
|
|
l := s.dhcp.Lease(ip)
|
|
if l == nil || l.MAC == "" {
|
|
return notFound("no lease for %s", ip)
|
|
}
|
|
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 "+l.MAC, func(st *model.State, newID func() int64) error {
|
|
id = newID()
|
|
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, 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
|
|
}
|
|
evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface"))
|
|
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
|
|
}
|