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