v2.31.0 e02f654

handler.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close