v2.31.0-dev12 6a38a35

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

Keyboard shortcuts

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

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

Branches 8

Tags 106

↑↓ moveenter openesc close