v2.31.0-dev9 062dcae

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

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