package server import ( "encoding/json" "net/http" "github.com/go-chi/chi/v5" internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" "git.wntrmute.dev/kyle/metacrypt/internal/engine" ) // registerACMERoutes adds ACME protocol and management routes to r. func (s *Server) registerACMERoutes(r chi.Router) { // ACME protocol endpoints — HTTP only (RFC-defined protocol, no gRPC equivalent). r.Route("/acme/{mount}", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return s.requireUnseal(next.ServeHTTP) }) r.Get("/directory", s.acmeDispatch) r.Head("/new-nonce", s.acmeDispatch) r.Get("/new-nonce", s.acmeDispatch) r.Post("/new-account", s.acmeDispatch) r.Post("/new-order", s.acmeDispatch) r.Post("/authz/{id}", s.acmeDispatch) r.Post("/challenge/{type}/{id}", s.acmeDispatch) r.Post("/finalize/{id}", s.acmeDispatch) r.Post("/cert/{id}", s.acmeDispatch) r.Post("/revoke-cert", s.acmeDispatch) }) // Management endpoints — require MCIAS auth; REST counterparts to ACMEService gRPC. r.Post("/v1/acme/{mount}/eab", s.requireAuth(s.handleACMECreateEAB)) r.Put("/v1/acme/{mount}/config", s.requireAdmin(s.handleACMESetConfig)) r.Get("/v1/acme/{mount}/accounts", s.requireAdmin(s.handleACMEListAccounts)) r.Get("/v1/acme/{mount}/orders", s.requireAdmin(s.handleACMEListOrders)) } // acmeDispatch routes an ACME protocol request to the correct mount handler. func (s *Server) acmeDispatch(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") h, err := s.getOrCreateACMEHandler(mountName) if err != nil { http.Error(w, `{"type":"urn:ietf:params:acme:error:malformed","detail":"unknown mount"}`, http.StatusNotFound) return } // Re-dispatch through the handler's own chi router. // Since we are already matched on the method+path, call the right method directly. // The handler's RegisterRoutes uses a sub-router; instead we create a fresh router // per request and serve it. sub := chi.NewRouter() h.RegisterRoutes(sub) // Strip the "/acme/{mount}" prefix before delegating. http.StripPrefix("/acme/"+mountName, sub).ServeHTTP(w, r) } // getOrCreateACMEHandler lazily creates an ACME handler for the named CA mount. func (s *Server) getOrCreateACMEHandler(mountName string) (*internacme.Handler, error) { s.acmeMu.Lock() defer s.acmeMu.Unlock() if s.acmeHandlers == nil { s.acmeHandlers = make(map[string]*internacme.Handler) } if h, ok := s.acmeHandlers[mountName]; ok { return h, nil } // Verify mount is a CA engine. mount, err := s.engines.GetMount(mountName) if err != nil { return nil, err } if mount.Type != engine.EngineTypeCA { return nil, engine.ErrMountNotFound } // Build base URL from config. baseURL := s.cfg.Server.ExternalURL if baseURL == "" { baseURL = "https://" + s.cfg.Server.ListenAddr } h := internacme.NewHandler(mountName, s.seal.Barrier(), s.engines, baseURL, s.logger) s.acmeHandlers[mountName] = h return h, nil } // handleACMECreateEAB — POST /v1/acme/{mount}/eab // Creates EAB credentials for the authenticated MCIAS user. func (s *Server) handleACMECreateEAB(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") info := TokenInfoFromContext(r.Context()) h, err := s.getOrCreateACMEHandler(mountName) if err != nil { http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) return } cred, err := h.CreateEAB(r.Context(), info.Username) if err != nil { s.logger.Error("acme: create EAB", "error", err) http.Error(w, `{"error":"failed to create EAB credentials"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "kid": cred.KID, "hmac_key": cred.HMACKey, // raw bytes; client should base64url-encode for ACME clients }) } // handleACMESetConfig — PUT /v1/acme/{mount}/config // Sets the default issuer for ACME certificate issuance on this mount. func (s *Server) handleACMESetConfig(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") var req struct { DefaultIssuer string `json:"default_issuer"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.DefaultIssuer == "" { http.Error(w, `{"error":"default_issuer is required"}`, http.StatusBadRequest) return } h, err := s.getOrCreateACMEHandler(mountName) if err != nil { http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) return } cfg := &internacme.ACMEConfig{DefaultIssuer: req.DefaultIssuer} data, _ := json.Marshal(cfg) barrierPath := "acme/" + mountName + "/config.json" if err := s.seal.Barrier().Put(r.Context(), barrierPath, data); err != nil { s.logger.Error("acme: save config", "error", err) http.Error(w, `{"error":"failed to save config"}`, http.StatusInternalServerError) return } _ = h // handler exists; config is read from barrier on each request writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } // handleACMEListAccounts — GET /v1/acme/{mount}/accounts func (s *Server) handleACMEListAccounts(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") h, err := s.getOrCreateACMEHandler(mountName) if err != nil { http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) return } accounts, err := h.ListAccounts(r.Context()) if err != nil { s.logger.Error("acme: list accounts", "error", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, accounts) } // handleACMEListOrders — GET /v1/acme/{mount}/orders func (s *Server) handleACMEListOrders(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") h, err := s.getOrCreateACMEHandler(mountName) if err != nil { http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) return } orders, err := h.ListOrders(r.Context()) if err != nil { s.logger.Error("acme: list orders", "error", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, orders) }