v3.4.0 593c1c3

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/registry"
22	"github.com/bnema/gordon/pkg/validation"
23)
24
25// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
26const maxAdminRequestSize = 1 << 20 // 1MB
27
28// maxLogLines is the maximum allowed number of log lines that can be requested.
29const maxLogLines = 10000
30
31type reloadTrigger interface {
32	Trigger(ctx context.Context) error
33}
34
35// Handler implements the HTTP handler for the admin API.
36type Handler struct {
37	configSvc       in.ConfigService
38	authSvc         in.AuthService
39	containerSvc    in.ContainerService
40	backupSvc       in.BackupService
41	volumeBackupSvc in.VolumeBackupService
42	imageSvc        in.ImageService
43	healthSvc       in.HealthService
44	logSvc          in.LogService
45	volumeSvc       in.VolumeService
46	registrySvc     in.RegistryService
47	reloadTrigger   reloadTrigger
48	publicTLSSvc    in.PublicTLSService
49	trafficSvc      in.TrafficStatusService
50	caSvc           in.CAInfoService
51	appSvc          in.AppService
52	log             zerowrap.Logger
53}
54
55func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
56	var startedAt *time.Time
57	if !job.StartedAt.IsZero() {
58		t := job.StartedAt
59		startedAt = &t
60	}
61	var completedAt *time.Time
62	if !job.CompletedAt.IsZero() {
63		t := job.CompletedAt
64		completedAt = &t
65	}
66
67	return dto.BackupJob{
68		ID:          job.ID,
69		App:         job.App,
70		Service:     job.Service,
71		Database:    job.DBName,
72		Schedule:    string(job.Schedule),
73		Type:        string(job.Type),
74		Status:      string(job.Status),
75		StartedAt:   startedAt,
76		CompletedAt: completedAt,
77		SizeBytes:   job.SizeBytes,
78		Error:       job.Error,
79	}
80}
81
82func toVolumeBackupJobResponse(job domain.VolumeBackupJob) dto.VolumeBackupJob {
83	var startedAt *time.Time
84	if !job.StartedAt.IsZero() {
85		t := job.StartedAt
86		startedAt = &t
87	}
88	var completedAt *time.Time
89	if !job.CompletedAt.IsZero() {
90		t := job.CompletedAt
91		completedAt = &t
92	}
93
94	return dto.VolumeBackupJob{
95		ID:                job.ID,
96		App:               job.App,
97		Service:           job.Service,
98		VolumeName:        job.VolumeName,
99		RuntimeVolumeName: job.RuntimeVolumeName,
100		MountPath:         job.MountPath,
101		Compression:       job.Metadata["compression"],
102		Type:              string(job.Type),
103		Status:            string(job.Status),
104		StartedAt:         startedAt,
105		CompletedAt:       completedAt,
106		SizeBytes:         job.SizeBytes,
107		ArtifactRef:       job.ArtifactRef,
108		Error:             job.Error,
109	}
110}
111
112// HandlerDeps contains all dependencies for the admin HTTP handler.
113type HandlerDeps struct {
114	ConfigSvc       in.ConfigService
115	AuthSvc         in.AuthService
116	ContainerSvc    in.ContainerService
117	HealthSvc       in.HealthService
118	LogSvc          in.LogService
119	RegistrySvc     in.RegistryService
120	Log             zerowrap.Logger
121	BackupSvc       in.BackupService
122	VolumeBackupSvc in.VolumeBackupService
123	ImageSvc        in.ImageService
124	VolumeSvc       in.VolumeService
125	ReloadTrigger   reloadTrigger
126	PublicTLSSvc    in.PublicTLSService
127	TrafficSvc      in.TrafficStatusService
128	CASvc           in.CAInfoService
129	AppSvc          in.AppService
130}
131
132// NewHandler creates a new admin HTTP handler.
133func NewHandler(deps HandlerDeps) *Handler {
134	return &Handler{
135		configSvc:       deps.ConfigSvc,
136		authSvc:         deps.AuthSvc,
137		containerSvc:    deps.ContainerSvc,
138		backupSvc:       deps.BackupSvc,
139		volumeBackupSvc: deps.VolumeBackupSvc,
140		imageSvc:        deps.ImageSvc,
141		healthSvc:       deps.HealthSvc,
142		logSvc:          deps.LogSvc,
143		volumeSvc:       deps.VolumeSvc,
144		registrySvc:     deps.RegistrySvc,
145		reloadTrigger:   deps.ReloadTrigger,
146		publicTLSSvc:    deps.PublicTLSSvc,
147		trafficSvc:      deps.TrafficSvc,
148		caSvc:           deps.CASvc,
149		appSvc:          deps.AppSvc,
150		log:             deps.Log,
151	}
152}
153
154// RegisterRoutes registers the admin routes on the given mux.
155func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
156	mux.HandleFunc("/admin/", h.handleAdminRoutes)
157}
158
159// ServeHTTP implements http.Handler.
160func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
161	h.handleAdminRoutes(w, r)
162}
163
164func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
165	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
166		zerowrap.FieldLayer:   "adapter",
167		zerowrap.FieldAdapter: "http",
168		zerowrap.FieldHandler: "admin",
169		zerowrap.FieldMethod:  r.Method,
170		zerowrap.FieldPath:    r.URL.Path,
171	})
172	r = r.WithContext(ctx)
173
174	path := strings.TrimPrefix(r.URL.Path, "/admin")
175
176	// Route to appropriate handler
177	if handler, ok := h.matchRoute(path); ok {
178		handler(w, r, path)
179		return
180	}
181	h.sendError(w, http.StatusNotFound, "route not found")
182}
183
184// routeHandler is the signature for path-based route handlers.
185type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
186
187// matchRoute returns the handler for a given path, or false if not found.
188func (h *Handler) matchRoute(path string) (routeHandler, bool) {
189	// Retired legacy mutations answer 410 Gone before any other match.
190	if isRetiredMutation(path) {
191		return h.handleRetiredMutation, true
192	}
193	// Exact match routes
194	exactRoutes := map[string]routeHandler{
195		"/networks":       func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
196		"/status":         func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
197		"/health":         func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
198		"/reload":         func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
199		"/config":         func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
200		"/auth/verify":    func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
201		"/volumes":        func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
202		"/volumes/prune":  func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
203		"/tls/status":     func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
204		"/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) },
205		"/ca":             func(w http.ResponseWriter, r *http.Request, _ string) { h.handleCA(w, r) },
206	}
207	if handler, ok := exactRoutes[path]; ok {
208		return handler, true
209	}
210
211	// Prefix match routes
212	prefixRoutes := []struct {
213		prefix  string
214		handler routeHandler
215	}{
216		{"/apps", h.handleApps},
217		{"/backups", h.handleBackups},
218		{"/tags", h.handleTags},
219		{"/images", h.handleImages},
220		{"/logs", h.handleLogs},
221	}
222	for _, route := range prefixRoutes {
223		if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
224			return route.handler, true
225		}
226	}
227
228	return nil, false
229}
230
231func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
232	w.Header().Set("Content-Type", "application/json")
233	w.WriteHeader(status)
234	if data != nil {
235		_ = json.NewEncoder(w).Encode(data)
236	}
237}
238
239// sendError sends an error response.
240func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
241	h.sendJSON(w, status, dto.ErrorResponse{Error: message})
242}
243
244// handleRoutes handles /admin/routes endpoints.
245func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
246	if r.Method != http.MethodGet {
247		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
248		return
249	}
250
251	ctx := r.Context()
252
253	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
254		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
255		return
256	}
257
258	networks, err := h.containerSvc.ListNetworks(ctx)
259	if err != nil {
260		h.sendError(w, http.StatusInternalServerError, "failed to list networks")
261		return
262	}
263
264	response := make([]dto.Network, 0, len(networks))
265	for _, network := range networks {
266		if network == nil {
267			continue
268		}
269		response = append(response, dto.Network{
270			Name:       network.Name,
271			Driver:     network.Driver,
272			Containers: append([]string{}, network.Containers...),
273		})
274	}
275
276	h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
277}
278
279// handleHealth handles /admin/health endpoint.
280// Returns detailed health status for all routes including HTTP probe results.
281func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
282	if r.Method != http.MethodGet {
283		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
284		return
285	}
286
287	ctx := r.Context()
288
289	// Check read permission
290	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
291		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
292		return
293	}
294
295	health := h.healthSvc.CheckAllRoutes(ctx)
296
297	response := make(map[string]dto.HealthStatus, len(health))
298	for domain, healthStatus := range health {
299		if healthStatus == nil {
300			continue
301		}
302		response[domain] = dto.HealthStatus{
303			ContainerStatus: healthStatus.ContainerStatus,
304			HTTPStatus:      healthStatus.HTTPStatus,
305			ResponseTimeMs:  healthStatus.ResponseTimeMs,
306			Healthy:         healthStatus.Healthy,
307			Error:           healthStatus.Error,
308		}
309	}
310
311	h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
312}
313
314// handleStatus handles /admin/status endpoint.
315// Reports installation identity plus the app fleet summary from
316// desired/active state (no container inspection). Per-service detail
317// lives under `apps show APP`.
318func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
319	if r.Method != http.MethodGet {
320		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
321		return
322	}
323
324	ctx := r.Context()
325
326	// Check read permission
327	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
328		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
329		return
330	}
331
332	// App fleet summary from desired/active state.
333	statuses := make(map[string]string)
334	if h.appSvc != nil {
335		if apps, err := h.appSvc.List(ctx); err == nil {
336			for _, app := range apps {
337				statuses[app.App] = appStatusLabel(app)
338			}
339		}
340	}
341
342	status := dto.StatusResponse{
343		Apps:              len(statuses),
344		RegistryDomain:    h.configSvc.GetRegistryDomain(),
345		RegistryPort:      h.configSvc.GetRegistryPort(),
346		ServerPort:        h.configSvc.GetServerPort(),
347		NetworkIsolation:  h.configSvc.IsNetworkIsolationEnabled(),
348		ContainerStatuses: statuses,
349	}
350
351	h.sendJSON(w, http.StatusOK, status)
352}
353
354// appStatusLabel renders one app's fleet status from its summary.
355func appStatusLabel(app in.AppSummary) string {
356	if app.Stopped {
357		return "stopped"
358	}
359	if app.Active == "" {
360		return "pending"
361	}
362	if app.Converged {
363		return "active"
364	}
365	return "deploying"
366}
367
368// handleBackups handles /admin/backups endpoints.
369func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
370	path = strings.TrimSuffix(path, "/")
371	if path == "/backups/volumes" || strings.HasPrefix(path, "/backups/volumes/") {
372		h.handleVolumeBackups(w, r, path)
373		return
374	}
375
376	if h.backupSvc == nil {
377		h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
378		return
379	}
380
381	if path == "/backups" || path == "/backups/status" {
382		h.handleBackupsStatus(w, r)
383		return
384	}
385
386	suffix := strings.TrimPrefix(path, "/backups/")
387	parts := strings.Split(suffix, "/")
388	if len(parts) == 0 || parts[0] == "" {
389		h.sendError(w, http.StatusBadRequest, "app required in path")
390		return
391	}
392
393	backupDomain := parts[0]
394	if len(parts) == 1 {
395		h.handleBackupsApp(w, r, backupDomain)
396		return
397	}
398
399	h.sendError(w, http.StatusNotFound, "route not found")
400}
401
402func (h *Handler) handleVolumeBackups(w http.ResponseWriter, r *http.Request, path string) {
403	if h.volumeBackupSvc == nil {
404		h.sendError(w, http.StatusServiceUnavailable, "volume backup service not available")
405		return
406	}
407	if path == "/backups/volumes/status" {
408		h.handleVolumeBackupsStatus(w, r)
409		return
410	}
411	if path == "/backups/volumes" {
412		h.handleVolumeBackupsDomain(w, r, "")
413		return
414	}
415	suffix := strings.TrimPrefix(path, "/backups/volumes/")
416	parts := strings.Split(suffix, "/")
417	if len(parts) != 1 || parts[0] == "" {
418		h.sendError(w, http.StatusNotFound, "route not found")
419		return
420	}
421	h.handleVolumeBackupsDomain(w, r, parts[0])
422}
423
424func (h *Handler) handleVolumeBackupsStatus(w http.ResponseWriter, r *http.Request) {
425	ctx := r.Context()
426	if r.Method != http.MethodGet {
427		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
428		return
429	}
430	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
431		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
432		return
433	}
434	jobs, err := h.volumeBackupSvc.VolumeBackupStatus(ctx)
435	if err != nil {
436		h.sendError(w, http.StatusInternalServerError, "failed to get volume backup status")
437		return
438	}
439	h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
440}
441
442func (h *Handler) handleVolumeBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
443	ctx := r.Context()
444	switch r.Method {
445	case http.MethodGet:
446		if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
447			h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
448			return
449		}
450		jobs, err := h.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
451		if err != nil {
452			h.sendError(w, http.StatusInternalServerError, "failed to list volume backups")
453			return
454		}
455		h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
456	case http.MethodPost:
457		if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
458			h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
459			return
460		}
461		var req dto.VolumeBackupRunRequest
462		r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
463		if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
464			h.sendError(w, http.StatusBadRequest, "invalid JSON")
465			return
466		}
467		jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Service, req.Volume)
468		backups := mapVolumeBackupJobsResponse(jobs)
469		if err != nil {
470			if len(backups) > 0 {
471				h.sendJSON(w, http.StatusPartialContent, dto.VolumeBackupRunResponse{Status: "partial", Backups: backups, Error: err.Error()})
472				return
473			}
474			h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
475			return
476		}
477		h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: backups})
478	default:
479		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
480	}
481}
482
483func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
484	ctx := r.Context()
485	if r.Method != http.MethodGet {
486		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
487		return
488	}
489	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
490		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
491		return
492	}
493
494	jobs, err := h.backupSvc.Status(ctx)
495	if err != nil {
496		h.sendError(w, http.StatusInternalServerError, "failed to get backup status")
497		return
498	}
499	h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
500}
501
502func (h *Handler) handleBackupsApp(w http.ResponseWriter, r *http.Request, app string) {
503	switch r.Method {
504	case http.MethodGet:
505		h.handleBackupsAppList(w, r, app)
506	case http.MethodPost:
507		h.handleBackupsAppRun(w, r, app)
508	default:
509		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
510	}
511}
512
513func (h *Handler) handleBackupsAppList(w http.ResponseWriter, r *http.Request, app string) {
514	ctx := r.Context()
515	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
516		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
517		return
518	}
519
520	jobs, err := h.backupSvc.ListBackups(ctx, app)
521	if err != nil {
522		h.sendError(w, http.StatusInternalServerError, "failed to list backups")
523		return
524	}
525
526	h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
527}
528
529func (h *Handler) handleBackupsAppRun(w http.ResponseWriter, r *http.Request, app string) {
530	ctx := r.Context()
531	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
532		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
533		return
534	}
535
536	var req dto.BackupRunRequest
537	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
538	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
539		h.sendError(w, http.StatusBadRequest, "invalid JSON")
540		return
541	}
542
543	result, err := h.backupSvc.RunBackup(ctx, app, req.Service, req.Database)
544	if err != nil {
545		log := zerowrap.FromCtx(ctx)
546		log.Error().Err(err).Str("app", app).Msg("backup run failed")
547		h.sendError(w, http.StatusInternalServerError, "failed to run backup")
548		return
549	}
550	log := zerowrap.FromCtx(ctx)
551	log.Info().Str("app", app).Str("service", req.Service).Str("database", req.Database).
552		Str("job_id", result.Job.ID).Msg("backup completed via admin API")
553
554	job := toBackupJobResponse(result.Job)
555	h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
556		Status: "completed",
557		Backup: &job,
558	})
559}
560
561func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
562	response := make([]dto.BackupJob, 0, len(jobs))
563	for _, job := range jobs {
564		response = append(response, toBackupJobResponse(job))
565	}
566	return response
567}
568
569func mapVolumeBackupJobsResponse(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
570	response := make([]dto.VolumeBackupJob, 0, len(jobs))
571	for _, job := range jobs {
572		response = append(response, toVolumeBackupJobResponse(job))
573	}
574	return response
575}
576
577// handleReload handles /admin/reload endpoint.
578// This reloads configuration from file into memory and triggers container sync.
579func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
580	if r.Method != http.MethodPost {
581		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
582		return
583	}
584
585	ctx := r.Context()
586	log := zerowrap.FromCtx(ctx)
587
588	// Check write permission (reload modifies state)
589	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
590		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
591		return
592	}
593
594	if h.reloadTrigger == nil {
595		log.Error().Msg("reload trigger unavailable")
596		h.sendError(w, http.StatusInternalServerError, "reload trigger unavailable")
597		return
598	}
599
600	reloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
601	defer cancel()
602
603	if err := h.reloadTrigger.Trigger(reloadCtx); err != nil {
604		log.Error().Err(err).Msg("failed to reload config")
605		h.sendError(w, http.StatusInternalServerError, "failed to reload config")
606		return
607	}
608
609	log.Info().Msg("config reloaded via admin API")
610	h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
611}
612
613// handleConfig handles /admin/config endpoint.
614func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
615	if r.Method != http.MethodGet {
616		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
617		return
618	}
619
620	ctx := r.Context()
621
622	// Check read permission
623	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
624		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
625		return
626	}
627
628	externalRoutes := h.configSvc.GetExternalRoutes()
629	externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
630	for domain := range externalRoutes {
631		externalResponses = append(externalResponses, dto.ExternalRoute{
632			Domain: domain,
633		})
634	}
635
636	config := dto.ConfigResponse{
637		Server: dto.ServerConfig{
638			Port:           h.configSvc.GetServerPort(),
639			RegistryPort:   h.configSvc.GetRegistryPort(),
640			RegistryDomain: h.configSvc.GetRegistryDomain(),
641		},
642		NetworkIsolation: dto.NetworkIsolationConfig{
643			Enabled: h.configSvc.IsNetworkIsolationEnabled(),
644			Prefix:  h.configSvc.GetNetworkPrefix(),
645		},
646		ExternalRoutes: externalResponses,
647	}
648	if volumeCfg, ok := any(h.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
649		config.Volumes.AutoCreate, config.Volumes.Prefix, config.Volumes.Preserve = volumeCfg.GetVolumeConfig()
650	}
651
652	h.sendJSON(w, http.StatusOK, config)
653}
654
655// handleDeploy handles /admin/deploy/:domain endpoint.
656// POST triggers a deployment for the specified domain.
657func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
658	if r.Method != http.MethodGet {
659		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
660		return
661	}
662
663	ctx := r.Context()
664
665	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
666		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
667		return
668	}
669
670	rawRepository := strings.TrimPrefix(path, "/tags/")
671	if rawRepository == "" || rawRepository == "/tags" {
672		h.sendError(w, http.StatusBadRequest, "repository name required in path")
673		return
674	}
675	repository, err := url.PathUnescape(rawRepository)
676	if err != nil {
677		h.sendError(w, http.StatusBadRequest, "invalid repository name encoding")
678		return
679	}
680	if err := validation.ValidateRepositoryName(repository); err != nil {
681		h.sendError(w, http.StatusBadRequest, "invalid repository name")
682		return
683	}
684
685	if h.registrySvc == nil {
686		h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
687		return
688	}
689
690	tags, err := h.registrySvc.ListTags(ctx, repository)
691	if err != nil {
692		if errors.Is(err, registry.ErrRepositoryNotFound) {
693			h.sendError(w, http.StatusNotFound, "repository not found")
694			return
695		}
696		h.sendError(w, http.StatusInternalServerError, "failed to list tags")
697		return
698	}
699
700	h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
701		Repository: repository,
702		Tags:       tags,
703	})
704}
705
706// handleLogs handles /admin/logs endpoints.
707// GET /admin/logs - Gordon process logs
708// GET /admin/logs/:domain - Container logs for a specific domain
709func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
710	if r.Method != http.MethodGet {
711		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
712		return
713	}
714
715	ctx := r.Context()
716	log := zerowrap.FromCtx(ctx)
717
718	// Check read permission
719	if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
720		h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
721		return
722	}
723
724	// Check if LogService is available
725	if h.logSvc == nil {
726		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
727		return
728	}
729
730	// Parse query parameters
731	lines := 50 // default
732	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
733		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
734			lines = n
735		}
736	}
737	if lines > maxLogLines {
738		lines = maxLogLines
739	}
740	follow := r.URL.Query().Get("follow") == "true"
741
742	// Parse domain from path
743	logDomain := strings.TrimPrefix(path, "/logs/")
744	if logDomain == "/logs" {
745		logDomain = ""
746	}
747
748	if logDomain != "" {
749		parts := strings.Split(logDomain, "/")
750		if len(parts) != 2 || validation.ValidateDomainParam(parts[0]) != nil || validation.ValidateDomainParam(parts[1]) != nil {
751			h.sendError(w, http.StatusBadRequest, "invalid app/service reference")
752			return
753		}
754	}
755
756	if logDomain == "" {
757		// Gordon process logs
758		h.handleProcessLogs(w, r, lines, follow)
759	} else {
760		// Container logs
761		h.handleContainerLogs(w, r, logDomain, lines, follow)
762	}
763
764	// Prevent unused variable warning when follow is implemented
765	_ = log
766}
767
768// handleProcessLogs handles Gordon process logs.
769func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
770	ctx := r.Context()
771	log := zerowrap.FromCtx(ctx)
772
773	if follow {
774		// SSE streaming
775		h.streamProcessLogs(w, r, lines)
776		return
777	}
778
779	// Return last N lines as JSON
780	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
781	if err != nil {
782		log.Warn().Err(err).Msg("failed to get process logs")
783		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
784		return
785	}
786
787	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
788}
789
790// handleContainerLogs handles container logs for a specific domain.
791func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
792	ctx := r.Context()
793	log := zerowrap.FromCtx(ctx)
794
795	if follow {
796		// SSE streaming
797		h.streamContainerLogs(w, r, logDomain, lines)
798		return
799	}
800
801	// Return last N lines as JSON
802	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
803	if err != nil {
804		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
805		h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
806		return
807	}
808
809	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
810		Domain: logDomain,
811		Lines:  domain.RedactSecretLines(logLines),
812	})
813}
814
815// streamProcessLogs streams Gordon process logs via SSE.
816func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
817	ctx := r.Context()
818	log := zerowrap.FromCtx(ctx)
819
820	// Check for flusher support before setting up SSE
821	flusher, ok := w.(http.Flusher)
822	if !ok {
823		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
824		return
825	}
826
827	// Set up SSE
828	w.Header().Set("Content-Type", "text/event-stream")
829	w.Header().Set("Cache-Control", "no-cache")
830	w.Header().Set("Connection", "keep-alive")
831	w.WriteHeader(http.StatusOK)
832
833	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
834	if err != nil {
835		log.Warn().Err(err).Msg("failed to follow process logs")
836		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
837		flusher.Flush()
838		return
839	}
840
841	for {
842		select {
843		case line, ok := <-ch:
844			if !ok {
845				return
846			}
847			line = domain.RedactSecrets(line)
848			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
849			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
850			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
851			normalized := strings.ReplaceAll(line, "\r\n", "\n")
852			normalized = strings.ReplaceAll(normalized, "\r", "\n")
853			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
854			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
855			flusher.Flush()
856		case <-ctx.Done():
857			return
858		}
859	}
860}
861
862// streamContainerLogs streams container logs via SSE.
863func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
864	ctx := r.Context()
865	log := zerowrap.FromCtx(ctx)
866
867	// Check for flusher support before setting up SSE
868	flusher, ok := w.(http.Flusher)
869	if !ok {
870		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
871		return
872	}
873
874	// Set up SSE
875	w.Header().Set("Content-Type", "text/event-stream")
876	w.Header().Set("Cache-Control", "no-cache")
877	w.Header().Set("Connection", "keep-alive")
878	w.WriteHeader(http.StatusOK)
879
880	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
881	if err != nil {
882		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
883		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
884		flusher.Flush()
885		return
886	}
887
888	for {
889		select {
890		case line, ok := <-ch:
891			if !ok {
892				return
893			}
894			line = domain.RedactSecrets(line)
895			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
896			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
897			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
898			normalized := strings.ReplaceAll(line, "\r\n", "\n")
899			normalized = strings.ReplaceAll(normalized, "\r", "\n")
900			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
901			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
902			flusher.Flush()
903		case <-ctx.Done():
904			return
905		}
906	}
907}
908
909// handleAuthVerify handles /admin/auth/verify.
910func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
911	ctx := r.Context()
912
913	// Only allow GET method
914	if r.Method != http.MethodGet {
915		w.Header().Set("Allow", http.MethodGet)
916		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
917		return
918	}
919
920	// Call use case to get auth status
921	status, err := h.authSvc.GetAuthStatus(ctx)
922	if err != nil {
923		h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
924		return
925	}
926
927	// Convert domain.AuthStatus to DTO
928	response := dto.AuthVerifyResponse{
929		Valid:     status.Valid,
930		Subject:   status.Subject,
931		Scopes:    status.Scopes,
932		ExpiresAt: status.ExpiresAt,
933		IssuedAt:  status.IssuedAt,
934	}
935	if status.Valid && h.configSvc != nil {
936		// Push clients need the registry host but not admin:status:read,
937		// so it is published to every admin-scoped token here.
938		response.RegistryDomain = h.configSvc.GetRegistryDomain()
939	}
940
941	h.sendJSON(w, http.StatusOK, response)
942}
943
944// handleTLSStatus handles GET /admin/tls/status.
945func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
946	if r.Method != http.MethodGet {
947		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
948		return
949	}
950
951	ctx := r.Context()
952
953	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
954		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
955		return
956	}
957
958	if h.publicTLSSvc == nil {
959		h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
960			ACMEEnabled:     false,
961			SelectionReason: "public TLS service not configured",
962		})
963		return
964	}
965
966	status := h.publicTLSSvc.Status(ctx)
967	h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
968}

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