// Package acme implements an RFC 8555 ACME server. package acme import ( "context" "encoding/json" "log/slog" "net/http" "github.com/go-chi/chi/v5" "git.wntrmute.dev/mc/metacrypt/internal/barrier" "git.wntrmute.dev/mc/metacrypt/internal/engine" ) // Handler implements the ACME protocol for a single CA mount. type Handler struct { barrier barrier.Barrier engines *engine.Registry nonces *NonceStore logger *slog.Logger mount string baseURL string } // NewHandler creates an ACME handler for the given CA mount. func NewHandler(mount string, b barrier.Barrier, engines *engine.Registry, baseURL string, logger *slog.Logger) *Handler { return &Handler{ mount: mount, barrier: b, engines: engines, nonces: NewNonceStore(), baseURL: baseURL, logger: logger, } } // RegisterRoutes registers all ACME protocol routes on r. // r should be a subrouter already scoped to "/acme/{mount}". func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/directory", h.handleDirectory) r.Head("/new-nonce", h.handleNewNonce) r.Get("/new-nonce", h.handleNewNonce) r.Post("/new-account", h.handleNewAccount) r.Post("/new-order", h.handleNewOrder) r.Post("/authz/{id}", h.handleGetAuthz) r.Post("/challenge/{type}/{id}", h.handleChallenge) r.Post("/finalize/{id}", h.handleFinalize) r.Post("/cert/{id}", h.handleGetCert) r.Post("/revoke-cert", h.handleRevokeCert) } // barrierPrefix returns the barrier key prefix for this mount. func (h *Handler) barrierPrefix() string { return "acme/" + h.mount + "/" } func (h *Handler) accountURL(id string) string { return h.baseURL + "/acme/" + h.mount + "/account/" + id } func (h *Handler) orderURL(id string) string { return h.baseURL + "/acme/" + h.mount + "/order/" + id } func (h *Handler) authzURL(id string) string { return h.baseURL + "/acme/" + h.mount + "/authz/" + id } func (h *Handler) challengeURL(typ, id string) string { return h.baseURL + "/acme/" + h.mount + "/challenge/" + typ + "/" + id } func (h *Handler) finalizeURL(id string) string { return h.baseURL + "/acme/" + h.mount + "/finalize/" + id } func (h *Handler) certURL(id string) string { return h.baseURL + "/acme/" + h.mount + "/cert/" + id } // addNonceHeader issues a fresh nonce and adds it to the Replay-Nonce header. // RFC 8555 ยง6.5: every response must include a fresh nonce. func (h *Handler) addNonceHeader(w http.ResponseWriter) { nonce, err := h.nonces.Issue() if err != nil { h.logger.Error("acme: failed to issue nonce", "error", err) return } w.Header().Set("Replay-Nonce", nonce) } // writeACMEError writes an RFC 7807 problem detail response. func (h *Handler) writeACMEError(w http.ResponseWriter, status int, typ, detail string) { h.addNonceHeader(w) w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{ "type": typ, "detail": detail, }) } // writeJSON writes a JSON response with the given status code and value. func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) { h.addNonceHeader(w) w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // loadConfig loads the ACME config for this mount from the barrier. // Returns a zero-value config if none exists. func (h *Handler) loadConfig(ctx context.Context) (*ACMEConfig, error) { data, err := h.barrier.Get(ctx, h.barrierPrefix()+"config.json") if err != nil || data == nil { return &ACMEConfig{}, nil } var cfg ACMEConfig if err := json.Unmarshal(data, &cfg); err != nil { return &ACMEConfig{}, nil } return &cfg, nil }