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