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