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
936 h.sendJSON(w, http.StatusOK, response)
937}
938
939// handleTLSStatus handles GET /admin/tls/status.
940func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
941 if r.Method != http.MethodGet {
942 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
943 return
944 }
945
946 ctx := r.Context()
947
948 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
949 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
950 return
951 }
952
953 if h.publicTLSSvc == nil {
954 h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
955 ACMEEnabled: false,
956 SelectionReason: "public TLS service not configured",
957 })
958 return
959 }
960
961 status := h.publicTLSSvc.Status(ctx)
962 h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
963}