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