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