v3.0.0 f19f453

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

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