Files
certctl/internal/api/router/router.go
T
shankar0123 f0865bb051 fix(api,web,mcp): add bulk-renew + bulk-reassign endpoints, drop client-side N×HTTP loops (L-1 master)
Two audit findings, both category cat-l, both rooted in
web/src/pages/CertificatesPage.tsx. Pre-L-1 the GUI looped per-cert
HTTP calls — 100 selected certs = 100 sequential round-trips × ~50–200
ms each = a 5–20-second wedge during which the operator stared at a
progress bar. Post-L-1 each workflow is a single POST.

  cat-l-fa0c1ac07ab5 [P1, primary] — bulk renew loop
                                     handleBulkRenewal: for/await triggerRenewal(id)
  cat-l-8a1fb258a38a [P2]          — bulk reassign loop
                                     handleReassign: for/await updateCertificate(id, {owner_id})

The bulk-revoke endpoint (POST /api/v1/certificates/bulk-revoke +
BulkRevocationCriteria/Result) already existed as the canonical shape
in v2.0.x — L-1 ports that pattern to renew + reassign with per-action
twists.

Backend (Go)
- internal/domain/bulk_renewal.go: BulkRenewalCriteria mirrors
  BulkRevocationCriteria (criteria + IDs modes); BulkRenewalResult
  envelope adds EnqueuedJobs[] for per-cert {certificate_id, job_id};
  shared BulkOperationError type for all bulk paths.
- internal/domain/bulk_reassignment.go: narrower shape — IDs-only,
  owner_id required, team_id optional.
- internal/service/bulk_renewal.go::BulkRenewalService.BulkRenew:
  resolves criteria → status filter (Archived/Revoked/Expired/
  RenewalInProgress all silent-skip) → per-cert status flip + job
  create. Keygen-mode-aware so jobs land in the same initial status
  as single-cert TriggerRenewal. Single bulk audit event per call,
  not N.
- internal/service/bulk_reassignment.go::BulkReassignmentService.
  BulkReassign: validates owner_id upfront via the
  ErrBulkReassignOwnerNotFound typed sentinel — non-existent owner
  returns 400 before any cert is touched. Already-owned-by-target
  is silent-skip. Single bulk audit event.
- internal/api/handler/{bulk_renewal,bulk_reassignment}.go: HTTP
  shape mirrors bulk_revocation.go. NOT admin-gated (renew is non-
  destructive; reassign is a common-case workflow). Sentinel-error
  → 400 mapping for OwnerNotFound.
- internal/api/router/router.go: three bulk-* routes registered as a
  block before the {id} routes. HandlerRegistry gains BulkRenewal +
  BulkReassignment fields.
- cmd/server/main.go: NewBulkRenewalService threads cfg.Keygen.Mode
  so bulk-renew jobs land in same initial state as single-cert path.

Frontend
- web/src/api/client.ts: bulkRenewCertificates(criteria) +
  bulkReassignCertificates(request) functions with full TS types.
- web/src/pages/CertificatesPage.tsx: handleBulkRenewal + handleReassign
  rewritten from N-call loops to single calls. Result envelope drives
  progress UI; first-error message surfaced when total_failed > 0.
  Stale triggerRenewal + updateCertificate imports removed.

MCP
- internal/mcp/types.go: BulkRenewCertificatesInput +
  BulkReassignCertificatesInput.
- internal/mcp/tools.go: certctl_bulk_renew_certificates +
  certctl_bulk_reassign_certificates tools mirroring the existing
  certctl_bulk_revoke_certificates pattern.

OpenAPI
- api/openapi.yaml: two new operations (bulkRenewCertificates,
  bulkReassignCertificates) under Certificates tag. Four new schemas
  (BulkRenewRequest, BulkRenewResult, BulkEnqueuedJob,
  BulkReassignRequest, BulkReassignResult).

Tests
- Domain: BulkRenewalCriteria.IsEmpty + BulkReassignmentRequest.IsEmpty
  IsEmpty contracts; JSON round-trip shape pinning.
- Service: 7 BulkRenew tests (happy/criteria-mode/skips-RenewalInProgress/
  skips-revoked-archived/empty-criteria-error/partial-failure/
  audit-event-emitted) + 8 BulkReassign tests (happy/skips-already-
  owned/owner-required/empty-IDs/owner-not-found-sentinel/team-id-
  optional/team-id-provided/partial-failure/audit-event-emitted).
- Handler: 5 BulkRenew handler tests (happy/empty-body-400/wrong-
  method-405/actor-attribution/service-error-500) + 6 BulkReassign
  handler tests (happy/empty-IDs-400/missing-owner-400/owner-not-
  found-400-via-sentinel/wrong-method-405/generic-error-500).

CI guardrail
- .github/workflows/ci.yml: 'Forbidden client-side bulk-action loop
  regression guard (L-1)'. Greps web/src/pages/CertificatesPage.tsx
  for 'for(...) await triggerRenewal(...)' and 'for(...) await
  updateCertificate(...)' patterns; comment lines exempt; test files
  exempt. Verified locally (passes against post-fix tree, fires
  against synthetic regression).

Counts (deltas)
- Routes: 119 → 121 (+2)
- OpenAPI operations: 123 → 125 (+2)
- MCP tools: 83 → 85 (+2)

Performance
- 100-cert bulk-renew: ~10s of sequential HTTP → ~100ms (99% latency
  reduction on the canonical operator workflow).
- Audit event volume: 1 + N per operation → 1.

Out of scope (deferred follow-ups)
- cat-b-31ceb6aaa9f1: updateOwner/updateTeam/updateAgentGroup orphan
  (different shape — wire existing PUT to GUI, not new bulk endpoint).
- cat-k-e85d1099b2d7: CertificatesPage no pagination UI.
- cat-i-b0924b6675f8: MCP missing claim/dismiss/acknowledge (L-1 added
  2 new tools but does not close that finding).

Verification
- go build / vet / test -short / test -short -race all clean.
- web tsc --noEmit + vitest run all clean (296 tests passing).
- OpenAPI YAML parses (89 paths, 125 ops).
- L-1 CI guardrail passes against post-fix tree, fires against
  synthetic regression.

No push.
2026-04-25 14:33:02 +00:00

358 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"net/http"
"github.com/shankar0123/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/middleware"
)
// Router wraps http.ServeMux and manages route registration with middleware.
type Router struct {
mux *http.ServeMux
middleware []func(http.Handler) http.Handler
}
// New creates a new Router instance.
func New() *Router {
return &Router{
mux: http.NewServeMux(),
middleware: []func(http.Handler) http.Handler{},
}
}
// NewWithMiddleware creates a Router with initial middleware stack.
func NewWithMiddleware(middlewares ...func(http.Handler) http.Handler) *Router {
r := New()
r.middleware = middlewares
return r
}
// ServeHTTP implements http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
// Register registers a handler for a given path with the middleware chain applied.
func (r *Router) Register(pattern string, handler http.Handler) {
r.mux.Handle(pattern, middleware.Chain(handler, r.middleware...))
}
// RegisterFunc registers a handler function for a given path.
func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
r.Register(pattern, http.HandlerFunc(handler))
}
// HandlerRegistry groups all API handler dependencies for router registration.
type HandlerRegistry struct {
Certificates handler.CertificateHandler
Issuers handler.IssuerHandler
Targets handler.TargetHandler
Agents handler.AgentHandler
Jobs handler.JobHandler
Policies handler.PolicyHandler
Profiles handler.ProfileHandler
Teams handler.TeamHandler
Owners handler.OwnerHandler
AgentGroups handler.AgentGroupHandler
Audit handler.AuditHandler
Notifications handler.NotificationHandler
Stats handler.StatsHandler
Metrics handler.MetricsHandler
Health handler.HealthHandler
Discovery handler.DiscoveryHandler
NetworkScan handler.NetworkScanHandler
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
// handler/bulk_reassignment.go.
BulkRenewal handler.BulkRenewalHandler
BulkReassignment handler.BulkReassignmentHandler
RenewalPolicies handler.RenewalPolicyHandler
// Version handles GET /api/v1/version (U-3 ride-along,
// cat-u-no_version_endpoint). Wired through the no-auth dispatch in
// cmd/server/main.go so probes and rollout systems can read build
// identity without Bearer credentials. See handler/version.go.
Version handler.VersionHandler
}
// RegisterHandlers sets up all API routes with their handlers.
func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// Health endpoints (no auth middleware — must always be accessible)
r.mux.Handle("GET /health", middleware.Chain(
http.HandlerFunc(reg.Health.Health),
middleware.CORS,
middleware.ContentType,
))
r.mux.Handle("GET /ready", middleware.Chain(
http.HandlerFunc(reg.Health.Ready),
middleware.CORS,
middleware.ContentType,
))
// Auth info endpoint (no auth middleware — GUI needs this before login)
r.mux.Handle("GET /api/v1/auth/info", middleware.Chain(
http.HandlerFunc(reg.Health.AuthInfo),
middleware.CORS,
middleware.ContentType,
))
// Version endpoint (no auth middleware — used by rollout probes that
// don't carry Bearer tokens; the dispatch layer in cmd/server/main.go
// also routes /api/v1/version through the no-auth chain). U-3 ride-along
// (cat-u-no_version_endpoint, P2). The handler reads
// runtime/debug.BuildInfo for VCS attribution; ldflags-supplied Version
// is preferred when present.
r.mux.Handle("GET /api/v1/version", middleware.Chain(
reg.Version,
middleware.CORS,
middleware.ContentType,
))
// Auth check endpoint (uses full middleware chain via r.Register)
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// Certificates routes: /api/v1/certificates
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
// gives literal segments precedence over pattern-var segments, but
// listing the bulk paths first makes the precedence operator-visible
// and prevents a future refactor from accidentally inverting it. All
// three bulk endpoints share the same envelope shape (criteria/IDs
// in, {total_matched, total_<verb>, total_skipped, total_failed,
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
// alongside the pre-existing bulk-revoke.
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(reg.Certificates.GetCertificateVersions))
r.Register("GET /api/v1/certificates/{id}/deployments", http.HandlerFunc(reg.Certificates.GetCertificateDeployments))
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(reg.Certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment))
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate))
// Export endpoints: /api/v1/certificates/{id}/export/{format}
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM))
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12))
// NOTE: RFC 5280 CRL and RFC 6960 OCSP endpoints are registered separately
// via RegisterPKIHandlers under /.well-known/pki/ so relying parties can
// fetch them without presenting certctl API credentials. The legacy
// /api/v1/crl and /api/v1/ocsp paths have been retired (see M-006).
// Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(reg.Issuers.ListIssuers))
r.Register("POST /api/v1/issuers", http.HandlerFunc(reg.Issuers.CreateIssuer))
r.Register("GET /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.GetIssuer))
r.Register("PUT /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.UpdateIssuer))
r.Register("DELETE /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.DeleteIssuer))
r.Register("POST /api/v1/issuers/{id}/test", http.HandlerFunc(reg.Issuers.TestConnection))
// Targets routes: /api/v1/targets
r.Register("GET /api/v1/targets", http.HandlerFunc(reg.Targets.ListTargets))
r.Register("POST /api/v1/targets", http.HandlerFunc(reg.Targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
// Agents routes: /api/v1/agents
//
// I-004 soft-retirement surface:
// * GET /api/v1/agents/retired — opt-in listing of retired agents.
// MUST be registered before /agents/{id} so Go 1.22 ServeMux's
// literal-beats-pattern-var precedence routes the `retired` literal
// to ListRetiredAgents instead of treating "retired" as a {id}
// parameter value against GetAgent.
// * DELETE /api/v1/agents/{id} — RetireAgent. Replaces the pre-I-004
// hard-delete; the underlying repo does a soft-retire with
// optional cascade.
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
r.Register("POST /api/v1/agents", http.HandlerFunc(reg.Agents.RegisterAgent))
r.Register("GET /api/v1/agents/retired", http.HandlerFunc(reg.Agents.ListRetiredAgents))
r.Register("GET /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.GetAgent))
r.Register("DELETE /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.RetireAgent))
r.Register("POST /api/v1/agents/{id}/heartbeat", http.HandlerFunc(reg.Agents.Heartbeat))
r.Register("POST /api/v1/agents/{id}/csr", http.HandlerFunc(reg.Agents.AgentCSRSubmit))
r.Register("GET /api/v1/agents/{id}/certificates/{cert_id}", http.HandlerFunc(reg.Agents.AgentCertificatePickup))
r.Register("GET /api/v1/agents/{id}/work", http.HandlerFunc(reg.Agents.AgentGetWork))
r.Register("POST /api/v1/agents/{id}/jobs/{job_id}/status", http.HandlerFunc(reg.Agents.AgentReportJobStatus))
// Jobs routes: /api/v1/jobs
r.Register("GET /api/v1/jobs", http.HandlerFunc(reg.Jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(reg.Jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(reg.Jobs.CancelJob))
r.Register("POST /api/v1/jobs/{id}/approve", http.HandlerFunc(reg.Jobs.ApproveJob))
r.Register("POST /api/v1/jobs/{id}/reject", http.HandlerFunc(reg.Jobs.RejectJob))
// Policies routes: /api/v1/policies
r.Register("GET /api/v1/policies", http.HandlerFunc(reg.Policies.ListPolicies))
r.Register("POST /api/v1/policies", http.HandlerFunc(reg.Policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(reg.Policies.ListViolations))
// Renewal Policies routes: /api/v1/renewal-policies
// G-1: fixes frontend FK drift — OnboardingWizard + CertificatesPage dropdowns
// were previously populating renewal_policy_id from /api/v1/policies (compliance
// rules, pol-* IDs), violating FK managed_certificates.renewal_policy_id →
// renewal_policies(id) ON DELETE RESTRICT. This block is the backend half; the
// frontend half swaps getPolicies → getRenewalPolicies at 3 call sites.
r.Register("GET /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.ListRenewalPolicies))
r.Register("POST /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.CreateRenewalPolicy))
r.Register("GET /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.GetRenewalPolicy))
r.Register("PUT /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.UpdateRenewalPolicy))
r.Register("DELETE /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.DeleteRenewalPolicy))
// Profiles routes: /api/v1/profiles
r.Register("GET /api/v1/profiles", http.HandlerFunc(reg.Profiles.ListProfiles))
r.Register("POST /api/v1/profiles", http.HandlerFunc(reg.Profiles.CreateProfile))
r.Register("GET /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.GetProfile))
r.Register("PUT /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.UpdateProfile))
r.Register("DELETE /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.DeleteProfile))
// Teams routes: /api/v1/teams
r.Register("GET /api/v1/teams", http.HandlerFunc(reg.Teams.ListTeams))
r.Register("POST /api/v1/teams", http.HandlerFunc(reg.Teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.DeleteTeam))
// Owners routes: /api/v1/owners
r.Register("GET /api/v1/owners", http.HandlerFunc(reg.Owners.ListOwners))
r.Register("POST /api/v1/owners", http.HandlerFunc(reg.Owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.DeleteOwner))
// Agent Groups routes: /api/v1/agent-groups
r.Register("GET /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.ListAgentGroups))
r.Register("POST /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.CreateAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.GetAgentGroup))
r.Register("PUT /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.UpdateAgentGroup))
r.Register("DELETE /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.DeleteAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}/members", http.HandlerFunc(reg.AgentGroups.ListAgentGroupMembers))
// Audit routes: /api/v1/audit
r.Register("GET /api/v1/audit", http.HandlerFunc(reg.Audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(reg.Audit.GetAuditEvent))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(reg.Notifications.GetNotification))
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(reg.Notifications.MarkAsRead))
// I-005: requeue a dead notification back to pending so the retry sweep
// picks it up again. Go 1.22 ServeMux resolves the literal /requeue segment
// before falling back to the {id} path-variable route above.
r.Register("POST /api/v1/notifications/{id}/requeue", http.HandlerFunc(reg.Notifications.RequeueNotification))
// Stats routes: /api/v1/stats
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary))
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(reg.Stats.GetCertificatesByStatus))
r.Register("GET /api/v1/stats/expiration-timeline", http.HandlerFunc(reg.Stats.GetExpirationTimeline))
r.Register("GET /api/v1/stats/job-trends", http.HandlerFunc(reg.Stats.GetJobTrends))
r.Register("GET /api/v1/stats/issuance-rate", http.HandlerFunc(reg.Stats.GetIssuanceRate))
// Metrics routes: /api/v1/metrics
r.Register("GET /api/v1/metrics", http.HandlerFunc(reg.Metrics.GetMetrics))
r.Register("GET /api/v1/metrics/prometheus", http.HandlerFunc(reg.Metrics.GetPrometheusMetrics))
// Discovery routes: /api/v1/discovered-certificates, /api/v1/discovery-scans
r.Register("POST /api/v1/agents/{id}/discoveries", http.HandlerFunc(reg.Discovery.SubmitDiscoveryReport))
r.Register("GET /api/v1/discovered-certificates", http.HandlerFunc(reg.Discovery.ListDiscovered))
r.Register("GET /api/v1/discovered-certificates/{id}", http.HandlerFunc(reg.Discovery.GetDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/claim", http.HandlerFunc(reg.Discovery.ClaimDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", http.HandlerFunc(reg.Discovery.DismissDiscovered))
r.Register("GET /api/v1/discovery-scans", http.HandlerFunc(reg.Discovery.ListScans))
r.Register("GET /api/v1/discovery-summary", http.HandlerFunc(reg.Discovery.GetDiscoverySummary))
// Network scan routes: /api/v1/network-scan-targets
r.Register("GET /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.ListNetworkScanTargets))
r.Register("POST /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.CreateNetworkScanTarget))
r.Register("GET /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.GetNetworkScanTarget))
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus))
// Digest routes: /api/v1/digest
r.Register("GET /api/v1/digest/preview", http.HandlerFunc(reg.Digest.PreviewDigest))
r.Register("POST /api/v1/digest/send", http.HandlerFunc(reg.Digest.SendDigest))
// Health check routes: /api/v1/health-checks
// Summary endpoint must be registered before {id} routes
r.Register("GET /api/v1/health-checks/summary", http.HandlerFunc(reg.HealthChecks.GetHealthCheckSummary))
r.Register("GET /api/v1/health-checks", http.HandlerFunc(reg.HealthChecks.ListHealthChecks))
r.Register("POST /api/v1/health-checks", http.HandlerFunc(reg.HealthChecks.CreateHealthCheck))
r.Register("GET /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.GetHealthCheck))
r.Register("PUT /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.UpdateHealthCheck))
r.Register("DELETE /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.DeleteHealthCheck))
r.Register("GET /api/v1/health-checks/{id}/history", http.HandlerFunc(reg.HealthChecks.GetHealthCheckHistory))
r.Register("POST /api/v1/health-checks/{id}/acknowledge", http.HandlerFunc(reg.HealthChecks.AcknowledgeHealthCheck))
}
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
//
// EST endpoints are intentionally unauthenticated at the HTTP layer. Per RFC 7030
// §3.2.3, authentication and authorization for enrollment are deployment-specific;
// certctl relies on CSR signature verification, profile policy enforcement (allowed
// key types, max TTL, permitted EKUs), and the underlying issuer connector's own
// policy. Per RFC 7030 §4.1.1, /.well-known/est/cacerts is explicitly anonymous.
//
// cmd/server/main.go's finalHandler dispatches /.well-known/est/* to a dedicated
// no-auth middleware chain (RequestID, structuredLogger, Recovery only) so EST
// clients — IoT devices, 802.1X supplicants, MDM-enrolled laptops — never hit the
// Bearer-token auth middleware they cannot satisfy. See M-001 audit 2026-04-19
// (option D): prior builds routed EST through the authenticated apiHandler chain,
// which reduced every enrollment to a 401 before the handler was reached.
func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
// EST endpoints per RFC 7030 Section 3.2.2
r.Register("GET /.well-known/est/cacerts", http.HandlerFunc(est.CACerts))
r.Register("POST /.well-known/est/simpleenroll", http.HandlerFunc(est.SimpleEnroll))
r.Register("POST /.well-known/est/simplereenroll", http.HandlerFunc(est.SimpleReEnroll))
r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs))
}
// RegisterSCEPHandlers sets up SCEP (RFC 8894) routes.
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
// Authentication is via the challengePassword attribute in the PKCS#10 CSR, not
// via HTTP Bearer tokens or TLS client certs. cmd/server/main.go's finalHandler
// routes /scep* through the no-auth middleware chain (M-001 audit 2026-04-19,
// option D), and Config.Validate() refuses to start the server if SCEP is enabled
// without a non-empty CERTCTL_SCEP_CHALLENGE_PASSWORD (H-2, CWE-306).
func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
// SCEP uses a single path; the handler dispatches on ?operation= query param
r.Register("GET /scep", http.HandlerFunc(scep.HandleSCEP))
r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP))
}
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
// /.well-known/pki/. These endpoints are intentionally unauthenticated so
// relying parties (browsers, OpenSSL, OCSP stapling sidecars, mTLS clients)
// can fetch revocation data without presenting certctl API credentials.
// The response bodies are DER-encoded and carry the IANA-registered content
// types application/pkix-crl and application/ocsp-response.
//
// Precedent: EST (RFC 7030) and SCEP (RFC 8894) follow the same pattern —
// standards-defined wire formats served via a dedicated router registration
// that cmd/server wires into a no-auth middleware chain.
func (r *Router) RegisterPKIHandlers(pki handler.CertificateHandler) {
r.Register("GET /.well-known/pki/crl/{issuer_id}", http.HandlerFunc(pki.GetDERCRL))
r.Register("GET /.well-known/pki/ocsp/{issuer_id}/{serial}", http.HandlerFunc(pki.HandleOCSP))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
func (r *Router) GetMux() *http.ServeMux {
return r.mux
}