v2.10.3 f7e7684

handler.go RawHistoryFolder

1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/http"
9	"strconv"
10	"strings"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/adapters/dto"
15	"github.com/bnema/gordon/internal/boundaries/in"
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18	"github.com/bnema/gordon/internal/usecase/registry"
19	"github.com/bnema/gordon/pkg/validation"
20)
21
22// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
23const maxAdminRequestSize = 1 << 20 // 1MB
24
25// maxLogLines is the maximum allowed number of log lines that can be requested.
26const maxLogLines = 10000
27
28// Handler implements the HTTP handler for the admin API.
29type Handler struct {
30	configSvc    in.ConfigService
31	authSvc      in.AuthService
32	containerSvc in.ContainerService
33	healthSvc    in.HealthService
34	secretSvc    in.SecretService
35	logSvc       in.LogService
36	registrySvc  in.RegistryService
37	eventBus     out.EventPublisher
38	log          zerowrap.Logger
39}
40
41// Type aliases for API responses using shared DTO types.
42type routeInfoResponse = dto.RouteInfo
43type attachmentResponse = dto.Attachment
44type routeResponse = dto.Route
45
46// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
47func toAttachmentResponse(a domain.Attachment) dto.Attachment {
48	return dto.Attachment{
49		Name:        a.Name,
50		Image:       a.Image,
51		ContainerID: a.ContainerID,
52		Status:      a.Status,
53		Network:     a.Network,
54	}
55}
56
57// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
58func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
59	attachments := make([]dto.Attachment, 0, len(r.Attachments))
60	for _, a := range r.Attachments {
61		attachments = append(attachments, toAttachmentResponse(a))
62	}
63	return dto.RouteInfo{
64		Domain:          r.Domain,
65		Image:           r.Image,
66		ContainerID:     r.ContainerID,
67		ContainerStatus: r.ContainerStatus,
68		Network:         r.Network,
69		Attachments:     attachments,
70	}
71}
72
73// toRouteResponse converts a domain.Route to a dto.Route.
74func toRouteResponse(r domain.Route) dto.Route {
75	return dto.Route{
76		Domain: r.Domain,
77		Image:  r.Image,
78		HTTPS:  r.HTTPS,
79	}
80}
81
82// NewHandler creates a new admin HTTP handler.
83func NewHandler(
84	configSvc in.ConfigService,
85	authSvc in.AuthService,
86	containerSvc in.ContainerService,
87	healthSvc in.HealthService,
88	secretSvc in.SecretService,
89	logSvc in.LogService,
90	registrySvc in.RegistryService,
91	eventBus out.EventPublisher,
92	log zerowrap.Logger,
93) *Handler {
94	return &Handler{
95		configSvc:    configSvc,
96		authSvc:      authSvc,
97		containerSvc: containerSvc,
98		healthSvc:    healthSvc,
99		secretSvc:    secretSvc,
100		logSvc:       logSvc,
101		registrySvc:  registrySvc,
102		eventBus:     eventBus,
103		log:          log,
104	}
105}
106
107// RegisterRoutes registers the admin routes on the given mux.
108func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
109	mux.HandleFunc("/admin/", h.handleAdminRoutes)
110}
111
112// ServeHTTP implements http.Handler.
113func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114	h.handleAdminRoutes(w, r)
115}
116
117func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
118	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
119		zerowrap.FieldLayer:   "adapter",
120		zerowrap.FieldAdapter: "http",
121		zerowrap.FieldHandler: "admin",
122		zerowrap.FieldMethod:  r.Method,
123		zerowrap.FieldPath:    r.URL.Path,
124	})
125	r = r.WithContext(ctx)
126
127	path := strings.TrimPrefix(r.URL.Path, "/admin")
128
129	// Route to appropriate handler
130	if handler, ok := h.matchRoute(path); ok {
131		handler(w, r, path)
132		return
133	}
134	h.sendError(w, http.StatusNotFound, "route not found")
135}
136
137// routeHandler is the signature for path-based route handlers.
138type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
139
140// matchRoute returns the handler for a given path, or false if not found.
141func (h *Handler) matchRoute(path string) (routeHandler, bool) {
142	// Exact match routes
143	exactRoutes := map[string]routeHandler{
144		"/networks":    func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
145		"/status":      func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
146		"/health":      func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
147		"/reload":      func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
148		"/config":      func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
149		"/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
150	}
151	if handler, ok := exactRoutes[path]; ok {
152		return handler, true
153	}
154
155	// Prefix match routes
156	prefixRoutes := []struct {
157		prefix  string
158		handler routeHandler
159	}{
160		{"/attachments", h.handleAttachmentsConfig},
161		{"/routes", h.handleRoutes},
162		{"/secrets", h.handleSecrets},
163		{"/deploy", h.handleDeploy},
164		{"/restart", h.handleRestart},
165		{"/tags", h.handleTags},
166		{"/logs", h.handleLogs},
167	}
168	for _, route := range prefixRoutes {
169		if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
170			return route.handler, true
171		}
172	}
173
174	return nil, false
175}
176
177// sendJSON sends a JSON response.
178func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
179	w.Header().Set("Content-Type", "application/json")
180	w.WriteHeader(status)
181	if data != nil {
182		_ = json.NewEncoder(w).Encode(data)
183	}
184}
185
186// sendError sends an error response.
187func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
188	h.sendJSON(w, status, dto.ErrorResponse{Error: message})
189}
190
191// handleRoutes handles /admin/routes endpoints.
192func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
193	// Parse domain from path if present
194	routeDomain := strings.TrimPrefix(path, "/routes/")
195	if routeDomain == "/routes" {
196		routeDomain = ""
197	}
198
199	switch r.Method {
200	case http.MethodGet:
201		h.handleRoutesGet(w, r, routeDomain)
202	case http.MethodPost:
203		h.handleRoutesPost(w, r)
204	case http.MethodPut:
205		h.handleRoutesPut(w, r, routeDomain)
206	case http.MethodDelete:
207		h.handleRoutesDelete(w, r, routeDomain)
208	default:
209		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
210	}
211}
212
213func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
214	ctx := r.Context()
215
216	// Check read permission
217	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
218		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
219		return
220	}
221
222	if routeDomain == "" {
223		if r.URL.Query().Get("detailed") == "true" {
224			routes := h.containerSvc.ListRoutesWithDetails(ctx)
225			response := make([]routeInfoResponse, 0, len(routes))
226			for _, route := range routes {
227				response = append(response, toRouteInfoResponse(route))
228			}
229			h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
230			return
231		}
232
233		routes := h.configSvc.GetRoutes(ctx)
234		response := make([]routeResponse, 0, len(routes))
235		for _, route := range routes {
236			response = append(response, toRouteResponse(route))
237		}
238		h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
239		return
240	}
241
242	if strings.HasSuffix(routeDomain, "/attachments") {
243		parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
244		if parentDomain == "" {
245			h.sendError(w, http.StatusBadRequest, "domain required in path")
246			return
247		}
248		attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
249		response := make([]attachmentResponse, 0, len(attachments))
250		for _, attachment := range attachments {
251			response = append(response, toAttachmentResponse(attachment))
252		}
253		h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
254		return
255	}
256
257	route, err := h.configSvc.GetRoute(ctx, routeDomain)
258	if err != nil {
259		h.sendError(w, http.StatusNotFound, "route not found")
260		return
261	}
262	h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
263}
264
265func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
266	ctx := r.Context()
267	log := zerowrap.FromCtx(ctx)
268
269	// Check write permission
270	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
271		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
272		return
273	}
274
275	// Limit request body size
276	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
277
278	var route domain.Route
279	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
280		log.Warn().Err(err).Msg("invalid route JSON")
281		h.sendError(w, http.StatusBadRequest, "invalid JSON")
282		return
283	}
284
285	// Validate image exists in registry before adding route
286	if h.registrySvc != nil && route.Image != "" {
287		imageName, imageRef := validation.ParseImageReference(route.Image)
288		if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
289			if errors.Is(err, domain.ErrManifestNotFound) {
290				log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
291				h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
292			} else {
293				log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
294				h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
295			}
296			return
297		}
298	}
299
300	if err := h.configSvc.AddRoute(ctx, route); err != nil {
301		log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
302		switch {
303		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
304			h.sendError(w, http.StatusBadRequest, err.Error())
305		default:
306			h.sendError(w, http.StatusInternalServerError, "failed to add route")
307		}
308		return
309	}
310
311	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
312	h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
313}
314
315func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
316	ctx := r.Context()
317	log := zerowrap.FromCtx(ctx)
318
319	// Check write permission
320	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
321		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
322		return
323	}
324
325	if routeDomain == "" {
326		h.sendError(w, http.StatusBadRequest, "domain required in path")
327		return
328	}
329
330	// Limit request body size
331	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
332
333	var route domain.Route
334	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
335		log.Warn().Err(err).Msg("invalid route JSON")
336		h.sendError(w, http.StatusBadRequest, "invalid JSON")
337		return
338	}
339
340	route.Domain = routeDomain
341
342	// Validate image exists in registry before updating route
343	if h.registrySvc != nil && route.Image != "" {
344		imageName, imageRef := validation.ParseImageReference(route.Image)
345		if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
346			if errors.Is(err, domain.ErrManifestNotFound) {
347				log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
348				h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
349			} else {
350				log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
351				h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
352			}
353			return
354		}
355	}
356
357	if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
358		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
359		switch {
360		case errors.Is(err, domain.ErrRouteNotFound):
361			h.sendError(w, http.StatusNotFound, "route not found")
362		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
363			h.sendError(w, http.StatusBadRequest, err.Error())
364		default:
365			h.sendError(w, http.StatusInternalServerError, "failed to update route")
366		}
367		return
368	}
369
370	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
371	h.sendJSON(w, http.StatusOK, toRouteResponse(route))
372}
373
374func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
375	ctx := r.Context()
376	log := zerowrap.FromCtx(ctx)
377
378	// Check write permission
379	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
380		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
381		return
382	}
383
384	if routeDomain == "" {
385		h.sendError(w, http.StatusBadRequest, "domain required in path")
386		return
387	}
388
389	if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
390		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
391		if errors.Is(err, domain.ErrRouteNotFound) {
392			h.sendError(w, http.StatusNotFound, "route not found")
393		} else {
394			h.sendError(w, http.StatusInternalServerError, "failed to remove route")
395		}
396		return
397	}
398
399	log.Info().Str("domain", routeDomain).Msg("route removed")
400	h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
401}
402
403func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
404	if r.Method != http.MethodGet {
405		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
406		return
407	}
408
409	ctx := r.Context()
410
411	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
412		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
413		return
414	}
415
416	networks, err := h.containerSvc.ListNetworks(ctx)
417	if err != nil {
418		h.sendError(w, http.StatusInternalServerError, "failed to list networks")
419		return
420	}
421
422	response := make([]dto.Network, 0, len(networks))
423	for _, network := range networks {
424		if network == nil {
425			continue
426		}
427		var labelsCopy map[string]string
428		if network.Labels != nil {
429			labelsCopy = make(map[string]string, len(network.Labels))
430			for key, value := range network.Labels {
431				labelsCopy[key] = value
432			}
433		}
434		response = append(response, dto.Network{
435			ID:         network.ID,
436			Name:       network.Name,
437			Driver:     network.Driver,
438			Containers: append([]string{}, network.Containers...),
439			Labels:     labelsCopy,
440		})
441	}
442
443	h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
444}
445
446// handleSecrets handles /admin/secrets endpoints.
447func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
448	ctx := r.Context()
449	log := zerowrap.FromCtx(ctx)
450
451	// Parse path: /secrets/{domain} or /secrets/{domain}/{key}
452	parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
453	if len(parts) == 0 || parts[0] == "" {
454		h.sendError(w, http.StatusBadRequest, "domain required")
455		return
456	}
457
458	secretDomain := parts[0]
459	secretKey := ""
460	if len(parts) > 1 {
461		secretKey = parts[1]
462	}
463
464	switch r.Method {
465	case http.MethodGet:
466		h.handleSecretsGet(w, r, secretDomain)
467
468	case http.MethodPost:
469		// Check write permission
470		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
471			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
472			return
473		}
474
475		// Limit request body size
476		r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
477
478		// Set secret(s)
479		var data map[string]string
480		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
481			log.Warn().Err(err).Msg("invalid secrets JSON")
482			h.sendError(w, http.StatusBadRequest, "invalid JSON")
483			return
484		}
485
486		if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
487			log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
488			h.sendError(w, http.StatusBadRequest, "invalid domain")
489			return
490		}
491
492		log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
493		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
494
495	case http.MethodDelete:
496		// Check write permission
497		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
498			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
499			return
500		}
501		if secretKey == "" {
502			h.sendError(w, http.StatusBadRequest, "key required in path")
503			return
504		}
505
506		if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
507			log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
508			h.sendError(w, http.StatusBadRequest, "invalid domain")
509			return
510		}
511
512		log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
513		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
514
515	default:
516		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
517	}
518}
519
520// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
521func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
522	ctx := r.Context()
523	log := zerowrap.FromCtx(ctx)
524
525	// Check read permission
526	if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
527		h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
528		return
529	}
530
531	// List secrets for domain (names only, not values) including attachments
532	keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
533	if err != nil {
534		log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
535		h.sendError(w, http.StatusBadRequest, "invalid domain")
536		return
537	}
538
539	// Convert attachments to DTO format
540	var attachmentDTOs []dto.AttachmentSecretsResponse
541	for _, att := range attachments {
542		attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
543			Service: att.Service,
544			Keys:    att.Keys,
545		})
546	}
547
548	h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
549		Domain:      secretDomain,
550		Keys:        keys,
551		Attachments: attachmentDTOs,
552	})
553}
554
555// handleHealth handles /admin/health endpoint.
556// Returns detailed health status for all routes including HTTP probe results.
557func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
558	if r.Method != http.MethodGet {
559		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
560		return
561	}
562
563	ctx := r.Context()
564
565	// Check read permission
566	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
567		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
568		return
569	}
570
571	health := h.healthSvc.CheckAllRoutes(ctx)
572
573	response := make(map[string]dto.HealthStatus, len(health))
574	for domain, healthStatus := range health {
575		if healthStatus == nil {
576			continue
577		}
578		response[domain] = dto.HealthStatus{
579			ContainerStatus: healthStatus.ContainerStatus,
580			HTTPStatus:      healthStatus.HTTPStatus,
581			ResponseTimeMs:  healthStatus.ResponseTimeMs,
582			Healthy:         healthStatus.Healthy,
583			Error:           healthStatus.Error,
584		}
585	}
586
587	h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
588}
589
590// handleStatus handles /admin/status endpoint.
591func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
592	if r.Method != http.MethodGet {
593		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
594		return
595	}
596
597	ctx := r.Context()
598
599	// Check read permission
600	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
601		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
602		return
603	}
604
605	routes := h.configSvc.GetRoutes(ctx)
606
607	// Get container statuses
608	statuses := make(map[string]string)
609	for _, route := range routes {
610		status := "unknown"
611		container, ok := h.containerSvc.Get(ctx, route.Domain)
612		if ok && container != nil {
613			status = container.Status
614		}
615		statuses[route.Domain] = status
616	}
617
618	status := dto.StatusResponse{
619		Routes:            len(routes),
620		RegistryDomain:    h.configSvc.GetRegistryDomain(),
621		RegistryPort:      h.configSvc.GetRegistryPort(),
622		ServerPort:        h.configSvc.GetServerPort(),
623		AutoRoute:         h.configSvc.IsAutoRouteEnabled(),
624		NetworkIsolation:  h.configSvc.IsNetworkIsolationEnabled(),
625		ContainerStatuses: statuses,
626	}
627
628	h.sendJSON(w, http.StatusOK, status)
629}
630
631// handleReload handles /admin/reload endpoint.
632// This reloads configuration from file into memory and triggers container sync.
633func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
634	if r.Method != http.MethodPost {
635		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
636		return
637	}
638
639	ctx := r.Context()
640	log := zerowrap.FromCtx(ctx)
641
642	// Check write permission (reload modifies state)
643	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
644		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
645		return
646	}
647
648	// Reload configuration from file into memory
649	// Using Reload() instead of Load() to ensure we re-read the file from disk
650	if err := h.configSvc.Reload(ctx); err != nil {
651		log.Error().Err(err).Msg("failed to reload config")
652		h.sendError(w, http.StatusInternalServerError, "failed to reload config")
653		return
654	}
655
656	// Publish manual reload event to sync containers
657	// ManualReloadHandler starts missing containers without restarting running ones
658	if h.eventBus != nil {
659		if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
660			log.Warn().Err(err).Msg("failed to publish manual reload event")
661		}
662	}
663
664	log.Info().Msg("config reloaded via admin API")
665	h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
666}
667
668// handleConfig handles /admin/config endpoint.
669func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
670	if r.Method != http.MethodGet {
671		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
672		return
673	}
674
675	ctx := r.Context()
676
677	// Check read permission
678	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
679		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
680		return
681	}
682
683	routes := h.configSvc.GetRoutes(ctx)
684	routeResponses := make([]dto.Route, 0, len(routes))
685	for _, route := range routes {
686		routeResponses = append(routeResponses, toRouteResponse(route))
687	}
688
689	externalRoutes := h.configSvc.GetExternalRoutes()
690	externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
691	for domain, target := range externalRoutes {
692		externalResponses = append(externalResponses, dto.ExternalRoute{
693			Domain: domain,
694			Target: target,
695		})
696	}
697
698	config := dto.ConfigResponse{
699		Server: dto.ServerConfig{
700			Port:           h.configSvc.GetServerPort(),
701			RegistryPort:   h.configSvc.GetRegistryPort(),
702			RegistryDomain: h.configSvc.GetRegistryDomain(),
703			DataDir:        h.configSvc.GetDataDir(),
704		},
705		AutoRoute: dto.AutoRouteConfig{
706			Enabled: h.configSvc.IsAutoRouteEnabled(),
707		},
708		NetworkIsolation: dto.NetworkIsolationConfig{
709			Enabled: h.configSvc.IsNetworkIsolationEnabled(),
710			Prefix:  h.configSvc.GetNetworkPrefix(),
711		},
712		Routes:         routeResponses,
713		ExternalRoutes: externalResponses,
714	}
715
716	h.sendJSON(w, http.StatusOK, config)
717}
718
719// handleDeploy handles /admin/deploy/:domain endpoint.
720// POST triggers a deployment for the specified domain.
721func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
722	if r.Method != http.MethodPost {
723		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
724		return
725	}
726
727	ctx := r.Context()
728	log := zerowrap.FromCtx(ctx)
729
730	// Check write permission
731	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
732		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
733		return
734	}
735
736	// Parse domain from path
737	deployDomain := strings.TrimPrefix(path, "/deploy/")
738	if deployDomain == "" || deployDomain == "/deploy" {
739		h.sendError(w, http.StatusBadRequest, "domain required in path")
740		return
741	}
742
743	// Get the route for this domain
744	route, err := h.configSvc.GetRoute(ctx, deployDomain)
745	if err != nil {
746		h.sendError(w, http.StatusNotFound, "route not found")
747		return
748	}
749
750	// Deploy the container
751	container, err := h.containerSvc.Deploy(ctx, *route)
752	if err != nil {
753		log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
754		h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
755		return
756	}
757
758	log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
759	h.sendJSON(w, http.StatusOK, dto.DeployResponse{
760		Status:      "deployed",
761		ContainerID: container.ID,
762		Domain:      deployDomain,
763	})
764}
765
766// handleRestart handles /admin/restart/:domain endpoint.
767func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
768	if r.Method != http.MethodPost {
769		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
770		return
771	}
772
773	ctx := r.Context()
774	log := zerowrap.FromCtx(ctx)
775
776	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
777		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
778		return
779	}
780
781	restartDomain := strings.TrimPrefix(path, "/restart/")
782	if restartDomain == "" || restartDomain == "/restart" {
783		h.sendError(w, http.StatusBadRequest, "domain required in path")
784		return
785	}
786	if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
787		h.sendError(w, http.StatusBadRequest, "invalid domain")
788		return
789	}
790
791	withAttachments := r.URL.Query().Get("attachments") == "true"
792
793	if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
794		log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
795		if errors.Is(err, domain.ErrContainerNotFound) {
796			h.sendError(w, http.StatusNotFound, "container not found")
797			return
798		}
799		h.sendError(w, http.StatusInternalServerError, "failed to restart container")
800		return
801	}
802
803	log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
804	h.sendJSON(w, http.StatusOK, dto.RestartResponse{
805		Status: "restarted",
806		Domain: restartDomain,
807	})
808}
809
810// handleTags handles /admin/tags/:repository endpoint.
811func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
812	if r.Method != http.MethodGet {
813		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
814		return
815	}
816
817	ctx := r.Context()
818
819	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
820		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
821		return
822	}
823
824	repository := strings.TrimPrefix(path, "/tags/")
825	if repository == "" || repository == "/tags" {
826		h.sendError(w, http.StatusBadRequest, "repository name required in path")
827		return
828	}
829	if err := validation.ValidateRepositoryName(repository); err != nil {
830		h.sendError(w, http.StatusBadRequest, "invalid repository name")
831		return
832	}
833
834	if h.registrySvc == nil {
835		h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
836		return
837	}
838
839	tags, err := h.registrySvc.ListTags(ctx, repository)
840	if err != nil {
841		if errors.Is(err, registry.ErrRepositoryNotFound) {
842			h.sendError(w, http.StatusNotFound, "repository not found")
843			return
844		}
845		h.sendError(w, http.StatusInternalServerError, "failed to list tags")
846		return
847	}
848
849	h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
850		Repository: repository,
851		Tags:       tags,
852	})
853}
854
855// handleLogs handles /admin/logs endpoints.
856// GET /admin/logs - Gordon process logs
857// GET /admin/logs/:domain - Container logs for a specific domain
858func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
859	if r.Method != http.MethodGet {
860		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
861		return
862	}
863
864	ctx := r.Context()
865	log := zerowrap.FromCtx(ctx)
866
867	// Check read permission
868	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
869		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
870		return
871	}
872
873	// Check if LogService is available
874	if h.logSvc == nil {
875		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
876		return
877	}
878
879	// Parse query parameters
880	lines := 50 // default
881	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
882		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
883			lines = n
884		}
885	}
886	if lines > maxLogLines {
887		lines = maxLogLines
888	}
889	follow := r.URL.Query().Get("follow") == "true"
890
891	// Parse domain from path
892	logDomain := strings.TrimPrefix(path, "/logs/")
893	if logDomain == "/logs" {
894		logDomain = ""
895	}
896
897	if logDomain == "" {
898		// Gordon process logs
899		h.handleProcessLogs(w, r, lines, follow)
900	} else {
901		// Container logs
902		h.handleContainerLogs(w, r, logDomain, lines, follow)
903	}
904
905	// Prevent unused variable warning when follow is implemented
906	_ = log
907}
908
909// handleProcessLogs handles Gordon process logs.
910func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
911	ctx := r.Context()
912	log := zerowrap.FromCtx(ctx)
913
914	if follow {
915		// SSE streaming
916		h.streamProcessLogs(w, r, lines)
917		return
918	}
919
920	// Return last N lines as JSON
921	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
922	if err != nil {
923		log.Warn().Err(err).Msg("failed to get process logs")
924		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
925		return
926	}
927
928	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
929}
930
931// handleContainerLogs handles container logs for a specific domain.
932func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
933	ctx := r.Context()
934	log := zerowrap.FromCtx(ctx)
935
936	if follow {
937		// SSE streaming
938		h.streamContainerLogs(w, r, logDomain, lines)
939		return
940	}
941
942	// Return last N lines as JSON
943	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
944	if err != nil {
945		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
946		h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
947		return
948	}
949
950	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
951		Domain: logDomain,
952		Lines:  logLines,
953	})
954}
955
956// streamProcessLogs streams Gordon process logs via SSE.
957func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
958	ctx := r.Context()
959	log := zerowrap.FromCtx(ctx)
960
961	// Check for flusher support before setting up SSE
962	flusher, ok := w.(http.Flusher)
963	if !ok {
964		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
965		return
966	}
967
968	// Set up SSE
969	w.Header().Set("Content-Type", "text/event-stream")
970	w.Header().Set("Cache-Control", "no-cache")
971	w.Header().Set("Connection", "keep-alive")
972	w.WriteHeader(http.StatusOK)
973
974	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
975	if err != nil {
976		log.Warn().Err(err).Msg("failed to follow process logs")
977		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
978		flusher.Flush()
979		return
980	}
981
982	for {
983		select {
984		case line, ok := <-ch:
985			if !ok {
986				return
987			}
988			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
989			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
990			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
991			normalized := strings.ReplaceAll(line, "\r\n", "\n")
992			normalized = strings.ReplaceAll(normalized, "\r", "\n")
993			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
994			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
995			flusher.Flush()
996		case <-ctx.Done():
997			return
998		}
999	}
1000}
1001
1002// streamContainerLogs streams container logs via SSE.
1003func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1004	ctx := r.Context()
1005	log := zerowrap.FromCtx(ctx)
1006
1007	// Check for flusher support before setting up SSE
1008	flusher, ok := w.(http.Flusher)
1009	if !ok {
1010		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1011		return
1012	}
1013
1014	// Set up SSE
1015	w.Header().Set("Content-Type", "text/event-stream")
1016	w.Header().Set("Cache-Control", "no-cache")
1017	w.Header().Set("Connection", "keep-alive")
1018	w.WriteHeader(http.StatusOK)
1019
1020	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1021	if err != nil {
1022		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1023		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1024		flusher.Flush()
1025		return
1026	}
1027
1028	for {
1029		select {
1030		case line, ok := <-ch:
1031			if !ok {
1032				return
1033			}
1034			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1035			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1036			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
1037			normalized := strings.ReplaceAll(line, "\r\n", "\n")
1038			normalized = strings.ReplaceAll(normalized, "\r", "\n")
1039			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1040			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1041			flusher.Flush()
1042		case <-ctx.Done():
1043			return
1044		}
1045	}
1046}
1047
1048// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1049func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1050	// Parse target (domain or group) from path
1051	target := strings.TrimPrefix(path, "/attachments/")
1052	if target == "/attachments" {
1053		target = ""
1054	}
1055
1056	switch r.Method {
1057	case http.MethodGet:
1058		h.handleAttachmentsConfigGet(w, r, target)
1059	case http.MethodPost:
1060		h.handleAttachmentsConfigPost(w, r, target)
1061	case http.MethodDelete:
1062		h.handleAttachmentsConfigDelete(w, r, target)
1063	default:
1064		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1065	}
1066}
1067
1068func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1069	ctx := r.Context()
1070
1071	// Check read permission
1072	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1073		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1074		return
1075	}
1076
1077	if target == "" {
1078		// List all attachments
1079		attachments := h.configSvc.GetAllAttachments(ctx)
1080		h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1081		return
1082	}
1083
1084	// List attachments for specific target
1085	images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1086	if err != nil {
1087		if errors.Is(err, domain.ErrAttachmentNotFound) {
1088			h.sendError(w, http.StatusNotFound, "no attachments found for target")
1089			return
1090		}
1091		h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1092		return
1093	}
1094
1095	h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1096}
1097
1098func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1099	ctx := r.Context()
1100	log := zerowrap.FromCtx(ctx)
1101
1102	// Check write permission
1103	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1104		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1105		return
1106	}
1107
1108	if target == "" {
1109		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1110		return
1111	}
1112
1113	// Limit request body size
1114	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1115
1116	var req dto.AttachmentAddRequest
1117	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1118		log.Warn().Err(err).Msg("invalid attachment JSON")
1119		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1120		return
1121	}
1122
1123	if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1124		log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1125		switch {
1126		case errors.Is(err, domain.ErrAttachmentExists):
1127			h.sendError(w, http.StatusConflict, "attachment already exists")
1128		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1129			h.sendError(w, http.StatusBadRequest, err.Error())
1130		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1131			h.sendError(w, http.StatusBadRequest, err.Error())
1132		default:
1133			h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1134		}
1135		return
1136	}
1137
1138	log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1139	h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1140}
1141
1142func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1143	ctx := r.Context()
1144	log := zerowrap.FromCtx(ctx)
1145
1146	// Check write permission
1147	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1148		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1149		return
1150	}
1151
1152	if target == "" {
1153		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1154		return
1155	}
1156
1157	// Parse image from path: /attachments/{target}/{image}
1158	// target at this point contains "{domain}/{image}" or just "{domain}"
1159	parts := strings.SplitN(target, "/", 2)
1160	if len(parts) != 2 {
1161		h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1162		return
1163	}
1164
1165	domainOrGroup := parts[0]
1166	image := parts[1]
1167
1168	if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1169		log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1170		switch {
1171		case errors.Is(err, domain.ErrAttachmentNotFound):
1172			h.sendError(w, http.StatusNotFound, "attachment not found")
1173		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1174			h.sendError(w, http.StatusBadRequest, err.Error())
1175		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1176			h.sendError(w, http.StatusBadRequest, err.Error())
1177		default:
1178			h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1179		}
1180		return
1181	}
1182
1183	log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1184	h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1185}
1186
1187// handleAuthVerify handles /admin/auth/verify endpoint.
1188// Validates authentication session and returns token status.
1189func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1190	ctx := r.Context()
1191
1192	// Only allow GET method
1193	if r.Method != http.MethodGet {
1194		w.Header().Set("Allow", http.MethodGet)
1195		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1196		return
1197	}
1198
1199	// Call use case to get auth status
1200	status, err := h.authSvc.GetAuthStatus(ctx)
1201	if err != nil {
1202		h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1203		return
1204	}
1205
1206	// Convert domain.AuthStatus to DTO
1207	response := dto.AuthVerifyResponse{
1208		Valid:     status.Valid,
1209		Subject:   status.Subject,
1210		Scopes:    status.Scopes,
1211		ExpiresAt: status.ExpiresAt,
1212		IssuedAt:  status.IssuedAt,
1213	}
1214
1215	h.sendJSON(w, http.StatusOK, response)
1216}

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