v2.31.0-dev21 b59533b

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	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}

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 7

Tags 106

↑↓ moveenter openesc close