package keaapi import ( "context" "crypto/subtle" "encoding/json" "errors" "net/http" "strings" "time" "github.com/go-logr/logr" ) // Server is the REST API exposing CRUD over KeaSubnet / KeaClientClass CRs. type Server struct { Store Store Token string Log logr.Logger } // Handler builds the routed http.Handler. Reads and writes are both token // guarded (the whole surface mutates cluster state indirectly). Go 1.22+ // pattern routing gives chi-style method+path matching with no dependency. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) mux.Handle("GET /api/v1/subnets", s.auth(http.HandlerFunc(s.listSubnets))) mux.Handle("GET /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.getSubnet))) mux.Handle("PUT /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.putSubnet))) mux.Handle("DELETE /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.deleteSubnet))) mux.Handle("GET /api/v1/clientclasses", s.auth(http.HandlerFunc(s.listClasses))) mux.Handle("GET /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.getClass))) mux.Handle("PUT /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.putClass))) mux.Handle("DELETE /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.deleteClass))) return mux } // ListenAndServe runs the server until ctx is cancelled. func (s *Server) ListenAndServe(ctx context.Context, addr string) error { srv := &http.Server{Addr: addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } func (s *Server) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.Token == "" { writeError(w, http.StatusServiceUnavailable, "auth disabled: KEA_API_TOKEN not set") return } presented := bearer(r) if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.Token)) != 1 { writeError(w, http.StatusUnauthorized, "invalid or missing token") return } next.ServeHTTP(w, r) }) } func bearer(r *http.Request) string { if h := r.Header.Get("Authorization"); h != "" { if after, ok := strings.CutPrefix(h, "Bearer "); ok { return after } } return r.Header.Get("token") } // ---- subnet handlers ---- func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request) { items, err := s.Store.ListSubnets(r.Context()) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request) { item, err := s.Store.GetSubnet(r.Context(), r.PathValue("name")) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, item) } func (s *Server) putSubnet(w http.ResponseWriter, r *http.Request) { var in SubnetAPI if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } in.Name = r.PathValue("name") if in.Subnet == "" { writeError(w, http.StatusBadRequest, "subnet is required") return } out, err := s.Store.UpsertSubnet(r.Context(), in) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request) { if err := s.Store.DeleteSubnet(r.Context(), r.PathValue("name")); err != nil { s.fail(w, err) return } w.WriteHeader(http.StatusNoContent) } // ---- client class handlers ---- func (s *Server) listClasses(w http.ResponseWriter, r *http.Request) { items, err := s.Store.ListClasses(r.Context()) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) getClass(w http.ResponseWriter, r *http.Request) { item, err := s.Store.GetClass(r.Context(), r.PathValue("name")) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, item) } func (s *Server) putClass(w http.ResponseWriter, r *http.Request) { var in ClientClassAPI if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } in.Name = r.PathValue("name") if in.Test == "" && len(in.ArchHex) == 0 { writeError(w, http.StatusBadRequest, "either test or arch_hex is required") return } out, err := s.Store.UpsertClass(r.Context(), in) if err != nil { s.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (s *Server) deleteClass(w http.ResponseWriter, r *http.Request) { if err := s.Store.DeleteClass(r.Context(), r.PathValue("name")); err != nil { s.fail(w, err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) fail(w http.ResponseWriter, err error) { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "not found") return } s.Log.Error(err, "request failed") writeError(w, http.StatusInternalServerError, err.Error()) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }