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}