v2.10.0 893d8fa

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

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