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