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