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