1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/bnema/zerowrap"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/domain"
21 "github.com/bnema/gordon/internal/usecase/config"
22 "github.com/bnema/gordon/internal/usecase/registry"
23 "github.com/bnema/gordon/pkg/validation"
24)
25
26// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
27const maxAdminRequestSize = 1 << 20 // 1MB
28
29// maxLogLines is the maximum allowed number of log lines that can be requested.
30const maxLogLines = 10000
31
32type registryDeployService interface {
33 in.RegistryService
34 in.DeployCoordinator
35}
36
37type reloadTrigger interface {
38 Trigger(ctx context.Context) error
39}
40
41// Handler implements the HTTP handler for the admin API.
42type Handler struct {
43 configSvc in.ConfigService
44 authSvc in.AuthService
45 containerSvc in.ContainerService
46 backupSvc in.BackupService
47 imageSvc in.ImageService
48 healthSvc in.HealthService
49 secretSvc in.SecretService
50 logSvc in.LogService
51 volumeSvc in.VolumeService
52 registrySvc registryDeployService
53 previewSvc previewService
54 reloadTrigger reloadTrigger
55 publicTLSSvc in.PublicTLSService
56 log zerowrap.Logger
57}
58
59// Type aliases for API responses using shared DTO types.
60type routeInfoResponse = dto.RouteInfo
61type attachmentResponse = dto.Attachment
62type routeResponse = dto.Route
63
64// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
65func toAttachmentResponse(a domain.Attachment) dto.Attachment {
66 return dto.Attachment{
67 Name: a.Name,
68 Image: a.Image,
69 ContainerID: a.ContainerID,
70 Status: a.Status,
71 Network: a.Network,
72 }
73}
74
75// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
76func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
77 attachments := make([]dto.Attachment, 0, len(r.Attachments))
78 for _, a := range r.Attachments {
79 attachments = append(attachments, toAttachmentResponse(a))
80 }
81 return dto.RouteInfo{
82 Domain: r.Domain,
83 Image: r.Image,
84 ContainerID: r.ContainerID,
85 ContainerStatus: r.ContainerStatus,
86 Network: r.Network,
87 Attachments: attachments,
88 }
89}
90
91// toRouteResponse converts a domain.Route to a dto.Route.
92func toRouteResponse(r domain.Route) dto.Route {
93 return dto.Route{
94 Domain: r.Domain,
95 Image: r.Image,
96 HTTPS: r.HTTPS,
97 }
98}
99
100func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
101 var startedAt *time.Time
102 if !job.StartedAt.IsZero() {
103 t := job.StartedAt
104 startedAt = &t
105 }
106 var completedAt *time.Time
107 if !job.CompletedAt.IsZero() {
108 t := job.CompletedAt
109 completedAt = &t
110 }
111
112 return dto.BackupJob{
113 ID: job.ID,
114 Domain: job.Domain,
115 DBName: job.DBName,
116 Schedule: string(job.Schedule),
117 Type: string(job.Type),
118 Status: string(job.Status),
119 StartedAt: startedAt,
120 CompletedAt: completedAt,
121 SizeBytes: job.SizeBytes,
122 Error: job.Error,
123 }
124}
125
126func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
127 return dto.DatabaseInfo{
128 Type: string(db.Type),
129 Name: db.Name,
130 Version: db.Version,
131 Host: db.Host,
132 Port: db.Port,
133 ContainerID: db.ContainerID,
134 ImageName: db.ImageName,
135 }
136}
137
138// HandlerDeps contains all dependencies for the admin HTTP handler.
139type HandlerDeps struct {
140 ConfigSvc in.ConfigService
141 AuthSvc in.AuthService
142 ContainerSvc in.ContainerService
143 HealthSvc in.HealthService
144 SecretSvc in.SecretService
145 LogSvc in.LogService
146 RegistrySvc registryDeployService
147 Log zerowrap.Logger
148 BackupSvc in.BackupService
149 PreviewSvc previewService
150 ImageSvc in.ImageService
151 VolumeSvc in.VolumeService
152 ReloadTrigger reloadTrigger
153 PublicTLSSvc in.PublicTLSService
154}
155
156// NewHandler creates a new admin HTTP handler.
157func NewHandler(deps HandlerDeps) *Handler {
158 return &Handler{
159 configSvc: deps.ConfigSvc,
160 authSvc: deps.AuthSvc,
161 containerSvc: deps.ContainerSvc,
162 backupSvc: deps.BackupSvc,
163 imageSvc: deps.ImageSvc,
164 healthSvc: deps.HealthSvc,
165 secretSvc: deps.SecretSvc,
166 logSvc: deps.LogSvc,
167 volumeSvc: deps.VolumeSvc,
168 registrySvc: deps.RegistrySvc,
169 previewSvc: deps.PreviewSvc,
170 reloadTrigger: deps.ReloadTrigger,
171 publicTLSSvc: deps.PublicTLSSvc,
172 log: deps.Log,
173 }
174}
175
176// RegisterRoutes registers the admin routes on the given mux.
177func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
178 mux.HandleFunc("/admin/", h.handleAdminRoutes)
179}
180
181// ServeHTTP implements http.Handler.
182func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
183 h.handleAdminRoutes(w, r)
184}
185
186func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
187 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
188 zerowrap.FieldLayer: "adapter",
189 zerowrap.FieldAdapter: "http",
190 zerowrap.FieldHandler: "admin",
191 zerowrap.FieldMethod: r.Method,
192 zerowrap.FieldPath: r.URL.Path,
193 })
194 r = r.WithContext(ctx)
195
196 path := strings.TrimPrefix(r.URL.Path, "/admin")
197
198 // Route to appropriate handler
199 if handler, ok := h.matchRoute(path); ok {
200 handler(w, r, path)
201 return
202 }
203 h.sendError(w, http.StatusNotFound, "route not found")
204}
205
206// routeHandler is the signature for path-based route handlers.
207type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
208
209// matchRoute returns the handler for a given path, or false if not found.
210func (h *Handler) matchRoute(path string) (routeHandler, bool) {
211 // Exact match routes
212 exactRoutes := map[string]routeHandler{
213 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
214 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
215 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
216 "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
217 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
218 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
219 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
220 "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
221 "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
222 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
223 }
224 if handler, ok := exactRoutes[path]; ok {
225 return handler, true
226 }
227
228 // Prefix match routes
229 prefixRoutes := []struct {
230 prefix string
231 handler routeHandler
232 }{
233 {"/backups", h.handleBackups},
234 {"/attachments/by-image", h.handleAttachmentsByImage},
235 {"/attachments", h.handleAttachmentsConfig},
236 {"/routes/by-image", h.handleRoutesByImage},
237 {"/routes", h.handleRoutes},
238 {"/secrets", h.handleSecrets},
239 {"/deploy-intent", h.handleDeployIntent},
240 {"/deploy", h.handleDeploy},
241 {"/restart", h.handleRestart},
242 {"/tags", h.handleTags},
243 {"/images", h.handleImages},
244 {"/logs", h.handleLogs},
245 {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
246 {"/previews", h.handlePreviewList},
247 {"/preview", h.handlePreviewAction},
248 }
249 for _, route := range prefixRoutes {
250 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
251 return route.handler, true
252 }
253 }
254
255 return nil, false
256}
257
258// handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint.
259// Returns all attachment targets associated with the given image name.
260func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) {
261 if r.Method != http.MethodGet {
262 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
263 return
264 }
265
266 ctx := r.Context()
267
268 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
269 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
270 return
271 }
272
273 imageName := strings.TrimPrefix(path, "/attachments/by-image/")
274 if imageName == "" || imageName == "/attachments/by-image" {
275 h.sendError(w, http.StatusBadRequest, "image name required in path")
276 return
277 }
278
279 imageName, err := url.PathUnescape(imageName)
280 if err != nil {
281 h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
282 return
283 }
284
285 targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName)
286
287 h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{
288 Image: imageName,
289 Targets: targets,
290 })
291}
292
293// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
294// It registers a deploy intent, suppressing event-based deploys for the image.
295func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
296 if r.Method != http.MethodPost {
297 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
298 return
299 }
300
301 ctx := r.Context()
302 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
303 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
304 return
305 }
306
307 if h.registrySvc == nil {
308 h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable")
309 return
310 }
311
312 rawName := strings.TrimPrefix(path, "/deploy-intent/")
313 if rawName == "" || rawName == "/deploy-intent" {
314 h.sendError(w, http.StatusBadRequest, "image name required")
315 return
316 }
317
318 imageName, err := url.PathUnescape(rawName)
319 if err != nil {
320 h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
321 return
322 }
323
324 log := zerowrap.FromCtx(ctx)
325 log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
326
327 h.registrySvc.SuppressDeployEvent(imageName)
328
329 h.sendJSON(w, http.StatusOK, map[string]string{
330 "status": "ok",
331 "image": imageName,
332 })
333}
334
335func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
336 if r.Method != http.MethodPost {
337 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
338 return
339 }
340
341 ctx := r.Context()
342 log := zerowrap.FromCtx(ctx)
343
344 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
345 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
346 return
347 }
348
349 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
350
351 var req dto.BootstrapRequest
352 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
353 log.Warn().Err(err).Msg("invalid bootstrap JSON")
354 h.sendError(w, http.StatusBadRequest, "invalid JSON")
355 return
356 }
357
358 if req.Domain == "" {
359 h.sendError(w, http.StatusBadRequest, "domain is required")
360 return
361 }
362
363 if req.Image == "" {
364 h.sendError(w, http.StatusBadRequest, "image is required")
365 return
366 }
367
368 normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain())
369 if err != nil {
370 h.sendError(w, http.StatusBadRequest, err.Error())
371 return
372 }
373
374 if err := h.validateBootstrapPermissions(ctx, req); err != nil {
375 h.sendError(w, http.StatusForbidden, err.Error())
376 return
377 }
378
379 resp := dto.BootstrapResponse{
380 Domain: req.Domain,
381 Image: normalizedImage,
382 Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
383 }
384 addStep := func(name, status string) {
385 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
386 }
387
388 err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage, HTTPS: true})
389 switch {
390 case err == nil:
391 addStep("route", "configured")
392 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
393 addStep("route", "failed")
394 h.sendError(w, http.StatusBadRequest, err.Error())
395 return
396 case errors.Is(err, domain.ErrRouteConflict):
397 addStep("route", "failed")
398 h.sendError(w, http.StatusConflict, err.Error())
399 return
400 default:
401 addStep("route", "failed")
402 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
403 h.sendJSON(w, http.StatusInternalServerError, resp)
404 return
405 }
406
407 if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil {
408 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments")
409 h.sendJSON(w, http.StatusInternalServerError, resp)
410 return
411 }
412
413 if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil {
414 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
415 h.sendJSON(w, http.StatusInternalServerError, resp)
416 return
417 }
418
419 if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil {
420 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env")
421 h.sendJSON(w, http.StatusInternalServerError, resp)
422 return
423 }
424
425 h.sendJSON(w, http.StatusOK, resp)
426}
427
428func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error {
429 if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
430 return errors.New("insufficient permissions for secrets:write")
431 }
432 if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
433 return errors.New("insufficient permissions for config:write")
434 }
435
436 return nil
437}
438
439func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error {
440 for _, attachment := range attachments {
441 err := configSvc.AddAttachment(ctx, domainName, attachment)
442 switch {
443 case err == nil:
444 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"})
445 case errors.Is(err, domain.ErrAttachmentExists):
446 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"})
447 default:
448 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"})
449 return err
450 }
451 }
452
453 return nil
454}
455
456func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error {
457 if len(env) == 0 {
458 return nil
459 }
460
461 if err := secretSvc.Set(ctx, domainName, env); err != nil {
462 *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"})
463 return err
464 }
465
466 *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"})
467 return nil
468}
469
470func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error {
471 for service, env := range attachmentEnv {
472 if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil {
473 *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"})
474 return err
475 }
476
477 *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"})
478 }
479
480 return nil
481}
482
483// sendJSON sends a JSON response.
484func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
485 w.Header().Set("Content-Type", "application/json")
486 w.WriteHeader(status)
487 if data != nil {
488 _ = json.NewEncoder(w).Encode(data)
489 }
490}
491
492// sendError sends an error response.
493func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
494 h.sendJSON(w, status, dto.ErrorResponse{Error: message})
495}
496
497// handleRoutes handles /admin/routes endpoints.
498func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
499 // Parse domain from path if present
500 routeDomain := strings.TrimPrefix(path, "/routes/")
501 if routeDomain == "/routes" {
502 routeDomain = ""
503 }
504
505 switch r.Method {
506 case http.MethodGet:
507 h.handleRoutesGet(w, r, routeDomain)
508 case http.MethodPost:
509 h.handleRoutesPost(w, r)
510 case http.MethodPut:
511 h.handleRoutesPut(w, r, routeDomain)
512 case http.MethodDelete:
513 h.handleRoutesDelete(w, r, routeDomain)
514 default:
515 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
516 }
517}
518
519func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
520 ctx := r.Context()
521
522 // Check read permission
523 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
524 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
525 return
526 }
527
528 if routeDomain == "" {
529 if r.URL.Query().Get("detailed") == "true" {
530 routes := h.containerSvc.ListRoutesWithDetails(ctx)
531 response := make([]routeInfoResponse, 0, len(routes))
532 for _, route := range routes {
533 response = append(response, toRouteInfoResponse(route))
534 }
535 h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
536 return
537 }
538
539 routes := h.configSvc.GetRoutes(ctx)
540 response := make([]routeResponse, 0, len(routes))
541 for _, route := range routes {
542 response = append(response, toRouteResponse(route))
543 }
544 h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
545 return
546 }
547
548 if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
549 if parentDomain == "" {
550 h.sendError(w, http.StatusBadRequest, "domain required in path")
551 return
552 }
553 attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
554 response := make([]attachmentResponse, 0, len(attachments))
555 for _, attachment := range attachments {
556 response = append(response, toAttachmentResponse(attachment))
557 }
558 h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
559 return
560 }
561
562 route, err := h.configSvc.GetRoute(ctx, routeDomain)
563 if err != nil {
564 h.sendError(w, http.StatusNotFound, "route not found")
565 return
566 }
567 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
568}
569
570func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
571 ctx := r.Context()
572 log := zerowrap.FromCtx(ctx)
573
574 // Check write permission
575 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
576 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
577 return
578 }
579
580 // Limit request body size
581 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
582
583 var req struct {
584 Domain string `json:"domain"`
585 Image string `json:"image"`
586 HTTPS *bool `json:"https"`
587 }
588 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
589 log.Warn().Err(err).Msg("invalid route JSON")
590 h.sendError(w, http.StatusBadRequest, "invalid JSON")
591 return
592 }
593
594 route := domain.Route{Domain: req.Domain, Image: req.Image, HTTPS: true}
595 if req.HTTPS != nil {
596 route.HTTPS = *req.HTTPS
597 }
598
599 if err := h.configSvc.AddRoute(ctx, route); err != nil {
600 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
601 switch {
602 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
603 h.sendError(w, http.StatusBadRequest, err.Error())
604 case errors.Is(err, domain.ErrRouteConflict):
605 h.sendError(w, http.StatusConflict, err.Error())
606 default:
607 h.sendError(w, http.StatusInternalServerError, "failed to add route")
608 }
609 return
610 }
611
612 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
613 h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
614}
615
616func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
617 ctx := r.Context()
618 log := zerowrap.FromCtx(ctx)
619
620 // Check write permission
621 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
622 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
623 return
624 }
625
626 if routeDomain == "" {
627 h.sendError(w, http.StatusBadRequest, "domain required in path")
628 return
629 }
630
631 // Limit request body size
632 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
633
634 var req struct {
635 Image string `json:"image"`
636 HTTPS *bool `json:"https"`
637 }
638 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
639 log.Warn().Err(err).Msg("invalid route JSON")
640 h.sendError(w, http.StatusBadRequest, "invalid JSON")
641 return
642 }
643
644 storedRoute, err := h.configSvc.GetRoute(ctx, routeDomain)
645 if err != nil {
646 if errors.Is(err, domain.ErrRouteNotFound) {
647 h.sendError(w, http.StatusNotFound, "route not found")
648 return
649 }
650 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to load route")
651 h.sendError(w, http.StatusInternalServerError, "failed to update route")
652 return
653 }
654 if storedRoute == nil {
655 h.sendError(w, http.StatusNotFound, "route not found")
656 return
657 }
658
659 route := *storedRoute
660 route.Domain = routeDomain
661 route.Image = req.Image
662 if req.HTTPS != nil {
663 route.HTTPS = *req.HTTPS
664 }
665
666 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
667 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
668 switch {
669 case errors.Is(err, domain.ErrRouteNotFound):
670 h.sendError(w, http.StatusNotFound, "route not found")
671 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
672 h.sendError(w, http.StatusBadRequest, err.Error())
673 default:
674 h.sendError(w, http.StatusInternalServerError, "failed to update route")
675 }
676 return
677 }
678
679 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
680 h.sendJSON(w, http.StatusOK, toRouteResponse(route))
681}
682
683func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
684 ctx := r.Context()
685 log := zerowrap.FromCtx(ctx)
686
687 // Check write permission
688 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
689 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
690 return
691 }
692
693 if routeDomain == "" {
694 h.sendError(w, http.StatusBadRequest, "domain required in path")
695 return
696 }
697
698 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
699 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
700 switch {
701 case errors.Is(err, domain.ErrRouteNotFound):
702 h.sendError(w, http.StatusNotFound, "route not found")
703 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid):
704 h.sendError(w, http.StatusBadRequest, "invalid route domain")
705 default:
706 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
707 }
708 return
709 }
710
711 log.Info().Str("domain", routeDomain).Msg("route removed")
712 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
713}
714
715// handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint.
716// Returns all routes associated with the given image name.
717func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) {
718 if r.Method != http.MethodGet {
719 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
720 return
721 }
722
723 ctx := r.Context()
724
725 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
726 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
727 return
728 }
729
730 imageName := strings.TrimPrefix(path, "/routes/by-image/")
731 if imageName == "" || imageName == "/routes/by-image" {
732 h.sendError(w, http.StatusBadRequest, "image name required in path")
733 return
734 }
735
736 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
737
738 response := make([]routeResponse, 0, len(routes))
739 for _, route := range routes {
740 response = append(response, toRouteResponse(route))
741 }
742
743 h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{
744 Image: imageName,
745 Routes: response,
746 })
747}
748
749func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
750 if r.Method != http.MethodGet {
751 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
752 return
753 }
754
755 ctx := r.Context()
756
757 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
758 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
759 return
760 }
761
762 networks, err := h.containerSvc.ListNetworks(ctx)
763 if err != nil {
764 h.sendError(w, http.StatusInternalServerError, "failed to list networks")
765 return
766 }
767
768 response := make([]dto.Network, 0, len(networks))
769 for _, network := range networks {
770 if network == nil {
771 continue
772 }
773 response = append(response, dto.Network{
774 Name: network.Name,
775 Driver: network.Driver,
776 Containers: append([]string{}, network.Containers...),
777 })
778 }
779
780 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
781}
782
783// handleSecrets handles /admin/secrets endpoints.
784func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
785 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
786 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
787 if len(parts) == 0 || parts[0] == "" {
788 h.sendError(w, http.StatusBadRequest, "domain required")
789 return
790 }
791
792 secretDomain := parts[0]
793
794 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
795 if len(parts) >= 2 && parts[1] == "attachments" {
796 switch r.Method {
797 case http.MethodPost:
798 // Expected path: /secrets/{domain}/attachments/{service}
799 if len(parts) != 3 {
800 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
801 return
802 }
803 service := parts[2]
804 h.handleAttachmentSecrets(w, r, secretDomain, service, "")
805 return
806 case http.MethodDelete:
807 // Expected path: /secrets/{domain}/attachments/{service}/{key}
808 if len(parts) != 4 {
809 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
810 return
811 }
812 service := parts[2]
813 attachmentKey := parts[3]
814 h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
815 return
816 default:
817 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
818 return
819 }
820 }
821
822 secretKey := ""
823 if len(parts) > 1 {
824 secretKey = parts[1]
825 }
826
827 switch r.Method {
828 case http.MethodGet:
829 h.handleSecretsGet(w, r, secretDomain)
830 case http.MethodPost:
831 h.handleSecretsPost(w, r, secretDomain)
832 case http.MethodDelete:
833 h.handleSecretsDelete(w, r, secretDomain, secretKey)
834 default:
835 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
836 }
837}
838
839// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
840func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
841 ctx := r.Context()
842 log := zerowrap.FromCtx(ctx)
843
844 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
845 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
846 return
847 }
848
849 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
850
851 var data map[string]string
852 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
853 log.Warn().Err(err).Msg("invalid secrets JSON")
854 h.sendError(w, http.StatusBadRequest, "invalid JSON")
855 return
856 }
857
858 if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
859 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
860 h.sendError(w, http.StatusBadRequest, "invalid domain")
861 return
862 }
863
864 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
865 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
866}
867
868// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
869func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
870 ctx := r.Context()
871 log := zerowrap.FromCtx(ctx)
872
873 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
874 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
875 return
876 }
877 if secretKey == "" {
878 h.sendError(w, http.StatusBadRequest, "key required in path")
879 return
880 }
881
882 if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
883 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
884 h.sendError(w, http.StatusBadRequest, "invalid domain")
885 return
886 }
887
888 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
889 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
890}
891
892// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
893func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
894 ctx := r.Context()
895 log := zerowrap.FromCtx(ctx)
896
897 // Check read permission
898 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
899 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
900 return
901 }
902
903 // List secrets for domain (names only, not values) including attachments
904 keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
905 if err != nil {
906 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
907 h.sendError(w, http.StatusBadRequest, "invalid domain")
908 return
909 }
910
911 // Convert attachments to DTO format
912 var attachmentDTOs []dto.AttachmentSecretsResponse
913 for _, att := range attachments {
914 attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
915 Service: att.Service,
916 Keys: att.Keys,
917 })
918 }
919
920 h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
921 Domain: secretDomain,
922 Keys: keys,
923 Attachments: attachmentDTOs,
924 })
925}
926
927// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
928func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
929 ctx := r.Context()
930 log := zerowrap.FromCtx(ctx)
931
932 switch r.Method {
933 case http.MethodPost:
934 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
935 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
936 return
937 }
938
939 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
940
941 var data map[string]string
942 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
943 log.Warn().Err(err).Msg("invalid attachment secrets JSON")
944 h.sendError(w, http.StatusBadRequest, "invalid JSON")
945 return
946 }
947
948 if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
949 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
950 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
951 return
952 }
953
954 log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
955 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
956
957 case http.MethodDelete:
958 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
959 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
960 return
961 }
962
963 if key == "" {
964 h.sendError(w, http.StatusBadRequest, "key required in path")
965 return
966 }
967
968 if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
969 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
970 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
971 return
972 }
973
974 log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
975 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
976
977 default:
978 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
979 }
980}
981
982// handleHealth handles /admin/health endpoint.
983// Returns detailed health status for all routes including HTTP probe results.
984func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
985 if r.Method != http.MethodGet {
986 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
987 return
988 }
989
990 ctx := r.Context()
991
992 // Check read permission
993 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
994 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
995 return
996 }
997
998 health := h.healthSvc.CheckAllRoutes(ctx)
999
1000 response := make(map[string]dto.HealthStatus, len(health))
1001 for domain, healthStatus := range health {
1002 if healthStatus == nil {
1003 continue
1004 }
1005 response[domain] = dto.HealthStatus{
1006 ContainerStatus: healthStatus.ContainerStatus,
1007 HTTPStatus: healthStatus.HTTPStatus,
1008 ResponseTimeMs: healthStatus.ResponseTimeMs,
1009 Healthy: healthStatus.Healthy,
1010 Error: healthStatus.Error,
1011 }
1012 }
1013
1014 h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
1015}
1016
1017// handleStatus handles /admin/status endpoint.
1018func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
1019 if r.Method != http.MethodGet {
1020 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1021 return
1022 }
1023
1024 ctx := r.Context()
1025
1026 // Check read permission
1027 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1028 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1029 return
1030 }
1031
1032 routes := h.configSvc.GetRoutes(ctx)
1033
1034 // Get container statuses
1035 statuses := make(map[string]string)
1036 for _, route := range routes {
1037 status := "unknown"
1038 container, ok := h.containerSvc.Get(ctx, route.Domain)
1039 if ok && container != nil {
1040 status = container.Status
1041 }
1042 statuses[route.Domain] = status
1043 }
1044
1045 status := dto.StatusResponse{
1046 Routes: len(routes),
1047 RegistryDomain: h.configSvc.GetRegistryDomain(),
1048 RegistryPort: h.configSvc.GetRegistryPort(),
1049 ServerPort: h.configSvc.GetServerPort(),
1050 AutoRoute: h.configSvc.IsAutoRouteEnabled(),
1051 NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(),
1052 ContainerStatuses: statuses,
1053 }
1054
1055 h.sendJSON(w, http.StatusOK, status)
1056}
1057
1058// handleBackups handles /admin/backups endpoints.
1059func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
1060 if h.backupSvc == nil {
1061 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
1062 return
1063 }
1064
1065 path = strings.TrimSuffix(path, "/")
1066
1067 if path == "/backups" || path == "/backups/status" {
1068 h.handleBackupsStatus(w, r)
1069 return
1070 }
1071
1072 suffix := strings.TrimPrefix(path, "/backups/")
1073 parts := strings.Split(suffix, "/")
1074 if len(parts) == 0 || parts[0] == "" {
1075 h.sendError(w, http.StatusBadRequest, "domain required in path")
1076 return
1077 }
1078
1079 backupDomain := parts[0]
1080 if len(parts) == 1 {
1081 h.handleBackupsDomain(w, r, backupDomain)
1082 return
1083 }
1084
1085 if len(parts) == 2 && parts[1] == "detect" {
1086 h.handleBackupsDetect(w, r, backupDomain)
1087 return
1088 }
1089
1090 h.sendError(w, http.StatusNotFound, "route not found")
1091}
1092
1093func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
1094 ctx := r.Context()
1095 if r.Method != http.MethodGet {
1096 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1097 return
1098 }
1099 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1100 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1101 return
1102 }
1103
1104 jobs, err := h.backupSvc.Status(ctx)
1105 if err != nil {
1106 h.sendError(w, http.StatusInternalServerError, "failed to get backup status")
1107 return
1108 }
1109 h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1110}
1111
1112func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
1113 switch r.Method {
1114 case http.MethodGet:
1115 h.handleBackupsDomainList(w, r, backupDomain)
1116 case http.MethodPost:
1117 h.handleBackupsDomainRun(w, r, backupDomain)
1118 default:
1119 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1120 }
1121}
1122
1123func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) {
1124 ctx := r.Context()
1125 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1126 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1127 return
1128 }
1129
1130 jobs, err := h.backupSvc.ListBackups(ctx, backupDomain)
1131 if err != nil {
1132 h.sendError(w, http.StatusInternalServerError, "failed to list backups")
1133 return
1134 }
1135
1136 h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1137}
1138
1139func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) {
1140 ctx := r.Context()
1141 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1142 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1143 return
1144 }
1145
1146 var req dto.BackupRunRequest
1147 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1148 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
1149 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1150 return
1151 }
1152
1153 result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
1154 if err != nil {
1155 log := zerowrap.FromCtx(ctx)
1156 log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
1157 h.sendError(w, http.StatusInternalServerError, "failed to run backup")
1158 return
1159 }
1160 log := zerowrap.FromCtx(ctx)
1161 log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
1162
1163 job := toBackupJobResponse(result.Job)
1164 h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
1165 Status: "completed",
1166 Backup: &job,
1167 })
1168}
1169
1170func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) {
1171 ctx := r.Context()
1172 if r.Method != http.MethodGet {
1173 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1174 return
1175 }
1176 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1177 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1178 return
1179 }
1180
1181 dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain)
1182 if err != nil {
1183 h.sendError(w, http.StatusInternalServerError, "failed to detect databases")
1184 return
1185 }
1186
1187 response := make([]dto.DatabaseInfo, 0, len(dbs))
1188 for _, db := range dbs {
1189 response = append(response, toDatabaseInfoResponse(db))
1190 }
1191
1192 h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response})
1193}
1194
1195func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
1196 response := make([]dto.BackupJob, 0, len(jobs))
1197 for _, job := range jobs {
1198 response = append(response, toBackupJobResponse(job))
1199 }
1200 return response
1201}
1202
1203// handleReload handles /admin/reload endpoint.
1204// This reloads configuration from file into memory and triggers container sync.
1205func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
1206 if r.Method != http.MethodPost {
1207 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1208 return
1209 }
1210
1211 ctx := r.Context()
1212 log := zerowrap.FromCtx(ctx)
1213
1214 // Check write permission (reload modifies state)
1215 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1216 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1217 return
1218 }
1219
1220 if h.reloadTrigger == nil {
1221 log.Error().Msg("reload trigger unavailable")
1222 h.sendError(w, http.StatusInternalServerError, "reload trigger unavailable")
1223 return
1224 }
1225
1226 reloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1227 defer cancel()
1228
1229 if err := h.reloadTrigger.Trigger(reloadCtx); err != nil {
1230 log.Error().Err(err).Msg("failed to reload config")
1231 h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1232 return
1233 }
1234
1235 log.Info().Msg("config reloaded via admin API")
1236 h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
1237}
1238
1239// handleConfig handles /admin/config endpoint.
1240func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1241 if r.Method != http.MethodGet {
1242 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1243 return
1244 }
1245
1246 ctx := r.Context()
1247
1248 // Check read permission
1249 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1250 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1251 return
1252 }
1253
1254 routes := h.configSvc.GetRoutes(ctx)
1255 routeResponses := make([]dto.Route, 0, len(routes))
1256 for _, route := range routes {
1257 routeResponses = append(routeResponses, toRouteResponse(route))
1258 }
1259
1260 externalRoutes := h.configSvc.GetExternalRoutes()
1261 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1262 for domain := range externalRoutes {
1263 externalResponses = append(externalResponses, dto.ExternalRoute{
1264 Domain: domain,
1265 })
1266 }
1267
1268 config := dto.ConfigResponse{
1269 Server: dto.ServerConfig{
1270 Port: h.configSvc.GetServerPort(),
1271 RegistryPort: h.configSvc.GetRegistryPort(),
1272 RegistryDomain: h.configSvc.GetRegistryDomain(),
1273 },
1274 AutoRoute: dto.AutoRouteConfig{
1275 Enabled: h.configSvc.IsAutoRouteEnabled(),
1276 },
1277 NetworkIsolation: dto.NetworkIsolationConfig{
1278 Enabled: h.configSvc.IsNetworkIsolationEnabled(),
1279 Prefix: h.configSvc.GetNetworkPrefix(),
1280 },
1281 Routes: routeResponses,
1282 ExternalRoutes: externalResponses,
1283 }
1284
1285 h.sendJSON(w, http.StatusOK, config)
1286}
1287
1288// handleDeploy handles /admin/deploy/:domain endpoint.
1289// POST triggers a deployment for the specified domain.
1290func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
1291 if r.Method != http.MethodPost {
1292 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1293 return
1294 }
1295
1296 ctx := r.Context()
1297 log := zerowrap.FromCtx(ctx)
1298
1299 // Check write permission
1300 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1301 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1302 return
1303 }
1304
1305 // Parse domain from path
1306 deployDomain := strings.TrimPrefix(path, "/deploy/")
1307 if deployDomain == "" || deployDomain == "/deploy" {
1308 h.sendError(w, http.StatusBadRequest, "domain required in path")
1309 return
1310 }
1311 if err := validation.ValidateDomainParam(deployDomain); err != nil {
1312 h.sendError(w, http.StatusBadRequest, "invalid domain")
1313 return
1314 }
1315
1316 // Get the route for this domain
1317 route, err := h.configSvc.GetRoute(ctx, deployDomain)
1318 if err != nil {
1319 h.sendError(w, http.StatusNotFound, "route not found")
1320 return
1321 }
1322
1323 // Deploy is an internal server-side action: pull from local registry path.
1324 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1325 if err != nil {
1326 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1327 if deployErr, ok := errors.AsType[*domain.DeployFailureError](err); ok {
1328 response := dto.DeployErrorResponse{
1329 Error: deployErr.Error(),
1330 Cause: deployErr.Cause,
1331 Hint: deployErr.Hint,
1332 }
1333 if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1334 response.Logs = domain.RedactSecretLines(deployErr.Logs)
1335 }
1336 h.sendJSON(w, http.StatusInternalServerError, response)
1337 return
1338 }
1339 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
1340 return
1341 }
1342
1343 // Clear deploy event suppression now that the explicit deploy has completed.
1344 // This re-enables event-based deploys for future direct docker pushes.
1345 if route.Image != "" && h.registrySvc != nil {
1346 // Use the registry package's image name normaliser so digest-form refs
1347 // and multi-segment paths are handled correctly.
1348 imageName := registry.ExtractImageName(route.Image)
1349 h.registrySvc.ClearDeployEventSuppression(imageName)
1350 }
1351
1352 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
1353 h.sendJSON(w, http.StatusOK, dto.DeployResponse{
1354 Status: "deployed",
1355 ContainerID: container.ID,
1356 Domain: deployDomain,
1357 })
1358}
1359
1360// handleRestart handles /admin/restart/:domain endpoint.
1361func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
1362 if r.Method != http.MethodPost {
1363 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1364 return
1365 }
1366
1367 ctx := r.Context()
1368 log := zerowrap.FromCtx(ctx)
1369
1370 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1371 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1372 return
1373 }
1374
1375 restartDomain := strings.TrimPrefix(path, "/restart/")
1376 if restartDomain == "" || restartDomain == "/restart" {
1377 h.sendError(w, http.StatusBadRequest, "domain required in path")
1378 return
1379 }
1380 if err := validation.ValidateDomainParam(restartDomain); err != nil {
1381 h.sendError(w, http.StatusBadRequest, "invalid domain")
1382 return
1383 }
1384
1385 withAttachments := r.URL.Query().Get("attachments") == "true"
1386
1387 // Use a detached context so the restart completes even if the HTTP client
1388 // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1389 restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1390 defer cancel()
1391
1392 if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
1393 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
1394 if errors.Is(err, domain.ErrContainerNotFound) {
1395 h.sendError(w, http.StatusNotFound, "container not found")
1396 return
1397 }
1398 h.sendError(w, http.StatusInternalServerError, "failed to restart container")
1399 return
1400 }
1401
1402 log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
1403 h.sendJSON(w, http.StatusOK, dto.RestartResponse{
1404 Status: "restarted",
1405 Domain: restartDomain,
1406 })
1407}
1408
1409// handleTags handles /admin/tags/:repository endpoint.
1410func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
1411 if r.Method != http.MethodGet {
1412 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1413 return
1414 }
1415
1416 ctx := r.Context()
1417
1418 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1419 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1420 return
1421 }
1422
1423 repository := strings.TrimPrefix(path, "/tags/")
1424 if repository == "" || repository == "/tags" {
1425 h.sendError(w, http.StatusBadRequest, "repository name required in path")
1426 return
1427 }
1428 if err := validation.ValidateRepositoryName(repository); err != nil {
1429 h.sendError(w, http.StatusBadRequest, "invalid repository name")
1430 return
1431 }
1432
1433 if h.registrySvc == nil {
1434 h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
1435 return
1436 }
1437
1438 tags, err := h.registrySvc.ListTags(ctx, repository)
1439 if err != nil {
1440 if errors.Is(err, registry.ErrRepositoryNotFound) {
1441 h.sendError(w, http.StatusNotFound, "repository not found")
1442 return
1443 }
1444 h.sendError(w, http.StatusInternalServerError, "failed to list tags")
1445 return
1446 }
1447
1448 h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
1449 Repository: repository,
1450 Tags: tags,
1451 })
1452}
1453
1454// handleLogs handles /admin/logs endpoints.
1455// GET /admin/logs - Gordon process logs
1456// GET /admin/logs/:domain - Container logs for a specific domain
1457func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
1458 if r.Method != http.MethodGet {
1459 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1460 return
1461 }
1462
1463 ctx := r.Context()
1464 log := zerowrap.FromCtx(ctx)
1465
1466 // Check read permission
1467 if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1468 h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
1469 return
1470 }
1471
1472 // Check if LogService is available
1473 if h.logSvc == nil {
1474 h.sendError(w, http.StatusServiceUnavailable, "log service not available")
1475 return
1476 }
1477
1478 // Parse query parameters
1479 lines := 50 // default
1480 if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
1481 if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
1482 lines = n
1483 }
1484 }
1485 if lines > maxLogLines {
1486 lines = maxLogLines
1487 }
1488 follow := r.URL.Query().Get("follow") == "true"
1489
1490 // Parse domain from path
1491 logDomain := strings.TrimPrefix(path, "/logs/")
1492 if logDomain == "/logs" {
1493 logDomain = ""
1494 }
1495
1496 if logDomain != "" {
1497 if err := validation.ValidateDomainParam(logDomain); err != nil {
1498 h.sendError(w, http.StatusBadRequest, "invalid domain")
1499 return
1500 }
1501 }
1502
1503 if logDomain == "" {
1504 // Gordon process logs
1505 h.handleProcessLogs(w, r, lines, follow)
1506 } else {
1507 // Container logs
1508 h.handleContainerLogs(w, r, logDomain, lines, follow)
1509 }
1510
1511 // Prevent unused variable warning when follow is implemented
1512 _ = log
1513}
1514
1515// handleProcessLogs handles Gordon process logs.
1516func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
1517 ctx := r.Context()
1518 log := zerowrap.FromCtx(ctx)
1519
1520 if follow {
1521 // SSE streaming
1522 h.streamProcessLogs(w, r, lines)
1523 return
1524 }
1525
1526 // Return last N lines as JSON
1527 logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
1528 if err != nil {
1529 log.Warn().Err(err).Msg("failed to get process logs")
1530 h.sendError(w, http.StatusInternalServerError, "failed to get logs")
1531 return
1532 }
1533
1534 h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
1535}
1536
1537// handleContainerLogs handles container logs for a specific domain.
1538func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
1539 ctx := r.Context()
1540 log := zerowrap.FromCtx(ctx)
1541
1542 if follow {
1543 // SSE streaming
1544 h.streamContainerLogs(w, r, logDomain, lines)
1545 return
1546 }
1547
1548 // Return last N lines as JSON
1549 logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
1550 if err != nil {
1551 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
1552 h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
1553 return
1554 }
1555
1556 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1557 Domain: logDomain,
1558 Lines: domain.RedactSecretLines(logLines),
1559 })
1560}
1561
1562// streamProcessLogs streams Gordon process logs via SSE.
1563func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
1564 ctx := r.Context()
1565 log := zerowrap.FromCtx(ctx)
1566
1567 // Check for flusher support before setting up SSE
1568 flusher, ok := w.(http.Flusher)
1569 if !ok {
1570 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1571 return
1572 }
1573
1574 // Set up SSE
1575 w.Header().Set("Content-Type", "text/event-stream")
1576 w.Header().Set("Cache-Control", "no-cache")
1577 w.Header().Set("Connection", "keep-alive")
1578 w.WriteHeader(http.StatusOK)
1579
1580 ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
1581 if err != nil {
1582 log.Warn().Err(err).Msg("failed to follow process logs")
1583 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
1584 flusher.Flush()
1585 return
1586 }
1587
1588 for {
1589 select {
1590 case line, ok := <-ch:
1591 if !ok {
1592 return
1593 }
1594 line = domain.RedactSecrets(line)
1595 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1596 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1597 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1598 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1599 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1600 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1601 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1602 flusher.Flush()
1603 case <-ctx.Done():
1604 return
1605 }
1606 }
1607}
1608
1609// streamContainerLogs streams container logs via SSE.
1610func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1611 ctx := r.Context()
1612 log := zerowrap.FromCtx(ctx)
1613
1614 // Check for flusher support before setting up SSE
1615 flusher, ok := w.(http.Flusher)
1616 if !ok {
1617 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1618 return
1619 }
1620
1621 // Set up SSE
1622 w.Header().Set("Content-Type", "text/event-stream")
1623 w.Header().Set("Cache-Control", "no-cache")
1624 w.Header().Set("Connection", "keep-alive")
1625 w.WriteHeader(http.StatusOK)
1626
1627 ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1628 if err != nil {
1629 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1630 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1631 flusher.Flush()
1632 return
1633 }
1634
1635 for {
1636 select {
1637 case line, ok := <-ch:
1638 if !ok {
1639 return
1640 }
1641 line = domain.RedactSecrets(line)
1642 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1643 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1644 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1645 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1646 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1647 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1648 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1649 flusher.Flush()
1650 case <-ctx.Done():
1651 return
1652 }
1653 }
1654}
1655
1656// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1657func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1658 // Parse target (domain or group) from path
1659 target := strings.TrimPrefix(path, "/attachments/")
1660 if target == "/attachments" {
1661 target = ""
1662 }
1663
1664 switch r.Method {
1665 case http.MethodGet:
1666 h.handleAttachmentsConfigGet(w, r, target)
1667 case http.MethodPost:
1668 h.handleAttachmentsConfigPost(w, r, target)
1669 case http.MethodDelete:
1670 h.handleAttachmentsConfigDelete(w, r, target)
1671 default:
1672 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1673 }
1674}
1675
1676func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1677 ctx := r.Context()
1678
1679 // Check read permission
1680 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1681 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1682 return
1683 }
1684
1685 if target == "" {
1686 // List all attachments
1687 attachments := h.configSvc.GetAllAttachments(ctx)
1688 h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1689 return
1690 }
1691
1692 // List attachments for specific target
1693 images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1694 if err != nil {
1695 if errors.Is(err, domain.ErrAttachmentNotFound) {
1696 h.sendError(w, http.StatusNotFound, "no attachments found for target")
1697 return
1698 }
1699 h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1700 return
1701 }
1702
1703 h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1704}
1705
1706func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1707 ctx := r.Context()
1708 log := zerowrap.FromCtx(ctx)
1709
1710 // Check write permission
1711 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1712 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1713 return
1714 }
1715
1716 if target == "" {
1717 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1718 return
1719 }
1720
1721 // Limit request body size
1722 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1723
1724 var req dto.AttachmentAddRequest
1725 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1726 log.Warn().Err(err).Msg("invalid attachment JSON")
1727 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1728 return
1729 }
1730
1731 if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1732 log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1733 switch {
1734 case errors.Is(err, domain.ErrAttachmentExists):
1735 h.sendError(w, http.StatusConflict, "attachment already exists")
1736 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1737 h.sendError(w, http.StatusBadRequest, err.Error())
1738 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1739 h.sendError(w, http.StatusBadRequest, err.Error())
1740 default:
1741 h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1742 }
1743 return
1744 }
1745
1746 log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1747 h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1748}
1749
1750func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1751 ctx := r.Context()
1752 log := zerowrap.FromCtx(ctx)
1753
1754 // Check write permission
1755 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1756 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1757 return
1758 }
1759
1760 if target == "" {
1761 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1762 return
1763 }
1764
1765 // Parse image from path: /attachments/{target}/{image}
1766 // target at this point contains "{domain}/{image}" or just "{domain}"
1767 parts := strings.SplitN(target, "/", 2)
1768 if len(parts) != 2 {
1769 h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1770 return
1771 }
1772
1773 domainOrGroup := parts[0]
1774 image := parts[1]
1775
1776 if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1777 log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1778 switch {
1779 case errors.Is(err, domain.ErrAttachmentNotFound):
1780 h.sendError(w, http.StatusNotFound, "attachment not found")
1781 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1782 h.sendError(w, http.StatusBadRequest, err.Error())
1783 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1784 h.sendError(w, http.StatusBadRequest, err.Error())
1785 default:
1786 h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1787 }
1788 return
1789 }
1790
1791 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1792 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1793}
1794
1795func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1796 switch r.Method {
1797 case http.MethodGet:
1798 h.handleAutoRouteAllowedDomainsGet(w, r)
1799 case http.MethodPost:
1800 h.handleAutoRouteAllowedDomainsPost(w, r)
1801 case http.MethodDelete:
1802 h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1803 default:
1804 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1805 }
1806}
1807
1808func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1809 ctx := r.Context()
1810 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1811 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1812 return
1813 }
1814
1815 domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1816 if err != nil {
1817 h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1818 return
1819 }
1820
1821 h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1822}
1823
1824func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1825 ctx := r.Context()
1826 log := zerowrap.FromCtx(ctx)
1827 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1828 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1829 return
1830 }
1831
1832 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1833 var req dto.AutoRouteAllowedDomainRequest
1834 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1835 log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1836 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1837 return
1838 }
1839
1840 if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1841 if errors.Is(err, domain.ErrInvalidDomainPattern) {
1842 h.sendError(w, http.StatusBadRequest, err.Error())
1843 } else {
1844 log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1845 h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1846 }
1847 return
1848 }
1849
1850 h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1851}
1852
1853func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1854 ctx := r.Context()
1855 log := zerowrap.FromCtx(ctx)
1856 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1857 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1858 return
1859 }
1860
1861 raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1862 if raw == path || raw == "" || raw == "/" {
1863 h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1864 return
1865 }
1866 pattern, err := url.PathUnescape(raw)
1867 if err != nil || strings.TrimSpace(pattern) == "" {
1868 h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1869 return
1870 }
1871
1872 if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1873 log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1874 h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1875 return
1876 }
1877
1878 h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1879}
1880
1881// handleAuthVerify handles /admin/auth/verify endpoint.
1882// Validates authentication session and returns token status.
1883func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1884 ctx := r.Context()
1885
1886 // Only allow GET method
1887 if r.Method != http.MethodGet {
1888 w.Header().Set("Allow", http.MethodGet)
1889 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1890 return
1891 }
1892
1893 // Call use case to get auth status
1894 status, err := h.authSvc.GetAuthStatus(ctx)
1895 if err != nil {
1896 h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1897 return
1898 }
1899
1900 // Convert domain.AuthStatus to DTO
1901 response := dto.AuthVerifyResponse{
1902 Valid: status.Valid,
1903 Subject: status.Subject,
1904 Scopes: status.Scopes,
1905 ExpiresAt: status.ExpiresAt,
1906 IssuedAt: status.IssuedAt,
1907 }
1908
1909 h.sendJSON(w, http.StatusOK, response)
1910}
1911
1912// handleTLSStatus handles GET /admin/tls/status.
1913func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
1914 if r.Method != http.MethodGet {
1915 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1916 return
1917 }
1918
1919 ctx := r.Context()
1920
1921 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1922 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1923 return
1924 }
1925
1926 if h.publicTLSSvc == nil {
1927 h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
1928 ACMEEnabled: false,
1929 SelectionReason: "public TLS service not configured",
1930 })
1931 return
1932 }
1933
1934 status := h.publicTLSSvc.Status(ctx)
1935 h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
1936}