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