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