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