- 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>
125 lines
4.9 KiB
Go
125 lines
4.9 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/version"
|
|
)
|
|
|
|
func (s *Server) routes() {
|
|
m := s.mux
|
|
p := api.Prefix
|
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
|
|
})
|
|
// Authentication, users and tokens
|
|
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
|
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
|
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
|
|
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
|
|
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
|
|
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
|
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
|
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
|
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
|
|
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
|
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
|
|
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
|
|
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
|
|
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
|
|
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
|
|
// Configuration
|
|
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
|
|
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
|
|
m.HandleFunc("GET "+p+"/subnets", s.authed(s.listSubnets))
|
|
m.HandleFunc("POST "+p+"/subnets", s.authed(s.createSubnet))
|
|
m.HandleFunc("GET "+p+"/subnets/{ref}", s.authed(s.getSubnet))
|
|
m.HandleFunc("PATCH "+p+"/subnets/{ref}", s.authed(s.updateSubnet))
|
|
m.HandleFunc("DELETE "+p+"/subnets/{ref}", s.authed(s.deleteSubnet))
|
|
m.HandleFunc("GET "+p+"/reservations", s.authed(s.listReservations))
|
|
m.HandleFunc("POST "+p+"/reservations", s.authed(s.createReservation))
|
|
m.HandleFunc("GET "+p+"/reservations/{ref}", s.authed(s.getReservation))
|
|
m.HandleFunc("PATCH "+p+"/reservations/{ref}", s.authed(s.updateReservation))
|
|
m.HandleFunc("DELETE "+p+"/reservations/{ref}", s.authed(s.deleteReservation))
|
|
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
|
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
|
|
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
|
m.HandleFunc("POST "+p+"/import/kea", s.authed(s.importKea))
|
|
m.HandleFunc("GET "+p+"/catalog", s.authed(s.catalog))
|
|
m.HandleFunc("GET "+p+"/interfaces", s.authed(s.interfaces))
|
|
// Leases and activity
|
|
m.HandleFunc("GET "+p+"/leases", s.authed(s.listLeases))
|
|
m.HandleFunc("GET "+p+"/leases/{ip}", s.authed(s.getLease))
|
|
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
|
|
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
|
|
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
|
|
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
|
|
// Status and audit
|
|
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
|
|
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
|
|
m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate))
|
|
m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck))
|
|
m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew))
|
|
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
|
})
|
|
// Web UI
|
|
if s.web != nil {
|
|
files := http.FileServer(http.FS(s.web))
|
|
etags := contentETags(s.web)
|
|
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
|
b, err := fs.ReadFile(s.web, "index.html")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etags["index.html"])
|
|
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
|
|
return
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
|
|
w.Header().Set("ETag", tag)
|
|
}
|
|
files.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// contentETags gives every embedded file an ETag from its content.
|
|
func contentETags(fsys fs.FS) map[string]string {
|
|
tags := map[string]string{}
|
|
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
b, err := fs.ReadFile(fsys, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
|
|
return nil
|
|
})
|
|
return tags
|
|
}
|