Files
steward/internal/server/h_leases.go
T
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

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 {
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
}