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 }