// Package admin implements the HTTP adapter for the admin API. package admin import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/bnema/zerowrap" "github.com/bnema/gordon/internal/adapters/dto" "github.com/bnema/gordon/internal/boundaries/in" "github.com/bnema/gordon/internal/boundaries/out" "github.com/bnema/gordon/internal/domain" "github.com/bnema/gordon/internal/usecase/config" "github.com/bnema/gordon/internal/usecase/registry" "github.com/bnema/gordon/pkg/validation" ) // maxAdminRequestSize is the maximum allowed size for admin API request bodies. const maxAdminRequestSize = 1 << 20 // 1MB // maxLogLines is the maximum allowed number of log lines that can be requested. const maxLogLines = 10000 type registryDeployService interface { in.RegistryService in.DeployCoordinator } // Handler implements the HTTP handler for the admin API. type Handler struct { configSvc in.ConfigService authSvc in.AuthService containerSvc in.ContainerService backupSvc in.BackupService imageSvc in.ImageService healthSvc in.HealthService secretSvc in.SecretService logSvc in.LogService volumeSvc in.VolumeService registrySvc registryDeployService previewSvc previewService eventBus out.EventPublisher log zerowrap.Logger } // Type aliases for API responses using shared DTO types. type routeInfoResponse = dto.RouteInfo type attachmentResponse = dto.Attachment type routeResponse = dto.Route // toAttachmentResponse converts a domain.Attachment to a dto.Attachment. func toAttachmentResponse(a domain.Attachment) dto.Attachment { return dto.Attachment{ Name: a.Name, Image: a.Image, ContainerID: a.ContainerID, Status: a.Status, Network: a.Network, } } // toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo. func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo { attachments := make([]dto.Attachment, 0, len(r.Attachments)) for _, a := range r.Attachments { attachments = append(attachments, toAttachmentResponse(a)) } return dto.RouteInfo{ Domain: r.Domain, Image: r.Image, ContainerID: r.ContainerID, ContainerStatus: r.ContainerStatus, Network: r.Network, Attachments: attachments, } } // toRouteResponse converts a domain.Route to a dto.Route. func toRouteResponse(r domain.Route) dto.Route { return dto.Route{ Domain: r.Domain, Image: r.Image, HTTPS: r.HTTPS, } } func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { var startedAt *time.Time if !job.StartedAt.IsZero() { t := job.StartedAt startedAt = &t } var completedAt *time.Time if !job.CompletedAt.IsZero() { t := job.CompletedAt completedAt = &t } return dto.BackupJob{ ID: job.ID, Domain: job.Domain, DBName: job.DBName, Schedule: string(job.Schedule), Type: string(job.Type), Status: string(job.Status), StartedAt: startedAt, CompletedAt: completedAt, SizeBytes: job.SizeBytes, Error: job.Error, } } func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo { return dto.DatabaseInfo{ Type: string(db.Type), Name: db.Name, Version: db.Version, Host: db.Host, Port: db.Port, ContainerID: db.ContainerID, ImageName: db.ImageName, } } // HandlerDeps contains all dependencies for the admin HTTP handler. type HandlerDeps struct { ConfigSvc in.ConfigService AuthSvc in.AuthService ContainerSvc in.ContainerService HealthSvc in.HealthService SecretSvc in.SecretService LogSvc in.LogService RegistrySvc registryDeployService EventBus out.EventPublisher Log zerowrap.Logger BackupSvc in.BackupService PreviewSvc previewService ImageSvc in.ImageService VolumeSvc in.VolumeService } // NewHandler creates a new admin HTTP handler. func NewHandler(deps HandlerDeps) *Handler { return &Handler{ configSvc: deps.ConfigSvc, authSvc: deps.AuthSvc, containerSvc: deps.ContainerSvc, backupSvc: deps.BackupSvc, imageSvc: deps.ImageSvc, healthSvc: deps.HealthSvc, secretSvc: deps.SecretSvc, logSvc: deps.LogSvc, volumeSvc: deps.VolumeSvc, registrySvc: deps.RegistrySvc, previewSvc: deps.PreviewSvc, eventBus: deps.EventBus, log: deps.Log, } } // RegisterRoutes registers the admin routes on the given mux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/", h.handleAdminRoutes) } // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleAdminRoutes(w, r) } func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) { ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "http", zerowrap.FieldHandler: "admin", zerowrap.FieldMethod: r.Method, zerowrap.FieldPath: r.URL.Path, }) r = r.WithContext(ctx) path := strings.TrimPrefix(r.URL.Path, "/admin") // Route to appropriate handler if handler, ok := h.matchRoute(path); ok { handler(w, r, path) return } h.sendError(w, http.StatusNotFound, "route not found") } // routeHandler is the signature for path-based route handlers. type routeHandler func(w http.ResponseWriter, r *http.Request, path string) // matchRoute returns the handler for a given path, or false if not found. func (h *Handler) matchRoute(path string) (routeHandler, bool) { // Exact match routes exactRoutes := map[string]routeHandler{ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, } if handler, ok := exactRoutes[path]; ok { return handler, true } // Prefix match routes prefixRoutes := []struct { prefix string handler routeHandler }{ {"/backups", h.handleBackups}, {"/attachments/by-image", h.handleAttachmentsByImage}, {"/attachments", h.handleAttachmentsConfig}, {"/routes/by-image", h.handleRoutesByImage}, {"/routes", h.handleRoutes}, {"/secrets", h.handleSecrets}, {"/deploy-intent", h.handleDeployIntent}, {"/deploy", h.handleDeploy}, {"/restart", h.handleRestart}, {"/tags", h.handleTags}, {"/images", h.handleImages}, {"/logs", h.handleLogs}, {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains}, {"/previews", h.handlePreviewList}, {"/preview", h.handlePreviewAction}, } for _, route := range prefixRoutes { if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") { return route.handler, true } } return nil, false } // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. // Returns all attachment targets associated with the given image name. func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") return } imageName := strings.TrimPrefix(path, "/attachments/by-image/") if imageName == "" || imageName == "/attachments/by-image" { h.sendError(w, http.StatusBadRequest, "image name required in path") return } imageName, err := url.PathUnescape(imageName) if err != nil { h.sendError(w, http.StatusBadRequest, "invalid image name encoding") return } targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName) h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{ Image: imageName, Targets: targets, }) } // handleDeployIntent handles /admin/deploy-intent/:image endpoint. // It registers a deploy intent, suppressing event-based deploys for the image. func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodPost { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } if h.registrySvc == nil { h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable") return } rawName := strings.TrimPrefix(path, "/deploy-intent/") if rawName == "" || rawName == "/deploy-intent" { h.sendError(w, http.StatusBadRequest, "image name required") return } imageName, err := url.PathUnescape(rawName) if err != nil { h.sendError(w, http.StatusBadRequest, "invalid image name encoding") return } log := zerowrap.FromCtx(ctx) log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events") h.registrySvc.SuppressDeployEvent(imageName) h.sendJSON(w, http.StatusOK, map[string]string{ "status": "ok", "image": imageName, }) } func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") return } r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var req dto.BootstrapRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Warn().Err(err).Msg("invalid bootstrap JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if req.Domain == "" { h.sendError(w, http.StatusBadRequest, "domain is required") return } if req.Image == "" { h.sendError(w, http.StatusBadRequest, "image is required") return } normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain()) if err != nil { h.sendError(w, http.StatusBadRequest, err.Error()) return } if err := h.validateBootstrapPermissions(ctx, req); err != nil { h.sendError(w, http.StatusForbidden, err.Error()) return } resp := dto.BootstrapResponse{ Domain: req.Domain, Image: normalizedImage, Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage), } addStep := func(name, status string) { resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) } err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage}) switch { case err == nil: addStep("route", "configured") case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): addStep("route", "failed") h.sendError(w, http.StatusBadRequest, err.Error()) return default: addStep("route", "failed") log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route") h.sendJSON(w, http.StatusInternalServerError, resp) return } if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil { log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments") h.sendJSON(w, http.StatusInternalServerError, resp) return } if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil { log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env") h.sendJSON(w, http.StatusInternalServerError, resp) return } if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil { log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env") h.sendJSON(w, http.StatusInternalServerError, resp) return } h.sendJSON(w, http.StatusOK, resp) } func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error { if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { return errors.New("insufficient permissions for secrets:write") } if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { return errors.New("insufficient permissions for config:write") } return nil } func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error { for _, attachment := range attachments { err := configSvc.AddAttachment(ctx, domainName, attachment) switch { case err == nil: *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"}) case errors.Is(err, domain.ErrAttachmentExists): *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"}) default: *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"}) return err } } return nil } func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error { if len(env) == 0 { return nil } if err := secretSvc.Set(ctx, domainName, env); err != nil { *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"}) return err } *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"}) return nil } func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error { for service, env := range attachmentEnv { if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil { *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"}) return err } *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"}) } return nil } // sendJSON sends a JSON response. func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if data != nil { _ = json.NewEncoder(w).Encode(data) } } // sendError sends an error response. func (h *Handler) sendError(w http.ResponseWriter, status int, message string) { h.sendJSON(w, status, dto.ErrorResponse{Error: message}) } // handleRoutes handles /admin/routes endpoints. func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) { // Parse domain from path if present routeDomain := strings.TrimPrefix(path, "/routes/") if routeDomain == "/routes" { routeDomain = "" } switch r.Method { case http.MethodGet: h.handleRoutesGet(w, r, routeDomain) case http.MethodPost: h.handleRoutesPost(w, r) case http.MethodPut: h.handleRoutesPut(w, r, routeDomain) case http.MethodDelete: h.handleRoutesDelete(w, r, routeDomain) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) { ctx := r.Context() // Check read permission if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read") return } if routeDomain == "" { if r.URL.Query().Get("detailed") == "true" { routes := h.containerSvc.ListRoutesWithDetails(ctx) response := make([]routeInfoResponse, 0, len(routes)) for _, route := range routes { response = append(response, toRouteInfoResponse(route)) } h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response}) return } routes := h.configSvc.GetRoutes(ctx) response := make([]routeResponse, 0, len(routes)) for _, route := range routes { response = append(response, toRouteResponse(route)) } h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response}) return } if strings.HasSuffix(routeDomain, "/attachments") { parentDomain := strings.TrimSuffix(routeDomain, "/attachments") if parentDomain == "" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } attachments := h.containerSvc.ListAttachments(ctx, parentDomain) response := make([]attachmentResponse, 0, len(attachments)) for _, attachment := range attachments { response = append(response, toAttachmentResponse(attachment)) } h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response}) return } route, err := h.configSvc.GetRoute(ctx, routeDomain) if err != nil { h.sendError(w, http.StatusNotFound, "route not found") return } h.sendJSON(w, http.StatusOK, toRouteResponse(*route)) } func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") return } // Limit request body size r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var route domain.Route if err := json.NewDecoder(r.Body).Decode(&route); err != nil { log.Warn().Err(err).Msg("invalid route JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.configSvc.AddRoute(ctx, route); err != nil { log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") switch { case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) default: h.sendError(w, http.StatusInternalServerError, "failed to add route") } return } log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added") h.sendJSON(w, http.StatusCreated, toRouteResponse(route)) } func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") return } if routeDomain == "" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } // Limit request body size r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var route domain.Route if err := json.NewDecoder(r.Body).Decode(&route); err != nil { log.Warn().Err(err).Msg("invalid route JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } route.Domain = routeDomain if err := h.configSvc.UpdateRoute(ctx, route); err != nil { log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") switch { case errors.Is(err, domain.ErrRouteNotFound): h.sendError(w, http.StatusNotFound, "route not found") case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) default: h.sendError(w, http.StatusInternalServerError, "failed to update route") } return } log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated") h.sendJSON(w, http.StatusOK, toRouteResponse(route)) } func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") return } if routeDomain == "" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil { log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") if errors.Is(err, domain.ErrRouteNotFound) { h.sendError(w, http.StatusNotFound, "route not found") } else { h.sendError(w, http.StatusInternalServerError, "failed to remove route") } return } log.Info().Str("domain", routeDomain).Msg("route removed") h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) } // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. // Returns all routes associated with the given image name. func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read") return } imageName := strings.TrimPrefix(path, "/routes/by-image/") if imageName == "" || imageName == "/routes/by-image" { h.sendError(w, http.StatusBadRequest, "image name required in path") return } routes := h.configSvc.FindRoutesByImage(ctx, imageName) response := make([]routeResponse, 0, len(routes)) for _, route := range routes { response = append(response, toRouteResponse(route)) } h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{ Image: imageName, Routes: response, }) } func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } networks, err := h.containerSvc.ListNetworks(ctx) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to list networks") return } response := make([]dto.Network, 0, len(networks)) for _, network := range networks { if network == nil { continue } response = append(response, dto.Network{ Name: network.Name, Driver: network.Driver, Containers: append([]string{}, network.Containers...), }) } h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response}) } // handleSecrets handles /admin/secrets endpoints. func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { // Parse path: /secrets/{domain} or /secrets/{domain}/{key} parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") if len(parts) == 0 || parts[0] == "" { h.sendError(w, http.StatusBadRequest, "domain required") return } secretDomain := parts[0] // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}] if len(parts) >= 2 && parts[1] == "attachments" { switch r.Method { case http.MethodPost: // Expected path: /secrets/{domain}/attachments/{service} if len(parts) != 3 { h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}") return } service := parts[2] h.handleAttachmentSecrets(w, r, secretDomain, service, "") return case http.MethodDelete: // Expected path: /secrets/{domain}/attachments/{service}/{key} if len(parts) != 4 { h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}") return } service := parts[2] attachmentKey := parts[3] h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey) return default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } } secretKey := "" if len(parts) > 1 { secretKey = parts[1] } switch r.Method { case http.MethodGet: h.handleSecretsGet(w, r, secretDomain) case http.MethodPost: h.handleSecretsPost(w, r, secretDomain) case http.MethodDelete: h.handleSecretsDelete(w, r, secretDomain, secretKey) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } // handleSecretsPost handles POST /admin/secrets/{domain} - set secrets. func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") return } r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var data map[string]string if err := json.NewDecoder(r.Body).Decode(&data); err != nil { log.Warn().Err(err).Msg("invalid secrets JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") h.sendError(w, http.StatusBadRequest, "invalid domain") return } log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) } // handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret. func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") return } if secretKey == "" { h.sendError(w, http.StatusBadRequest, "key required in path") return } if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") h.sendError(w, http.StatusBadRequest, "invalid domain") return } log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) } // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check read permission if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read") return } // List secrets for domain (names only, not values) including attachments keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain) if err != nil { log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") h.sendError(w, http.StatusBadRequest, "invalid domain") return } // Convert attachments to DTO format var attachmentDTOs []dto.AttachmentSecretsResponse for _, att := range attachments { attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{ Service: att.Service, Keys: att.Keys, }) } h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{ Domain: secretDomain, Keys: keys, Attachments: attachmentDTOs, }) } // handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints. func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) switch r.Method { case http.MethodPost: if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") return } r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var data map[string]string if err := json.NewDecoder(r.Body).Decode(&data); err != nil { log.Warn().Err(err).Msg("invalid attachment secrets JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil { log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets") h.sendError(w, http.StatusBadRequest, "invalid domain or service") return } log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set") h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) case http.MethodDelete: if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") return } if key == "" { h.sendError(w, http.StatusBadRequest, "key required in path") return } if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil { log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret") h.sendError(w, http.StatusBadRequest, "invalid domain or service") return } log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted") h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } // handleHealth handles /admin/health endpoint. // Returns detailed health status for all routes including HTTP probe results. func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() // Check read permission if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } health := h.healthSvc.CheckAllRoutes(ctx) response := make(map[string]dto.HealthStatus, len(health)) for domain, healthStatus := range health { if healthStatus == nil { continue } response[domain] = dto.HealthStatus{ ContainerStatus: healthStatus.ContainerStatus, HTTPStatus: healthStatus.HTTPStatus, ResponseTimeMs: healthStatus.ResponseTimeMs, Healthy: healthStatus.Healthy, Error: healthStatus.Error, } } h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response}) } // handleStatus handles /admin/status endpoint. func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() // Check read permission if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } routes := h.configSvc.GetRoutes(ctx) // Get container statuses statuses := make(map[string]string) for _, route := range routes { status := "unknown" container, ok := h.containerSvc.Get(ctx, route.Domain) if ok && container != nil { status = container.Status } statuses[route.Domain] = status } status := dto.StatusResponse{ Routes: len(routes), RegistryDomain: h.configSvc.GetRegistryDomain(), RegistryPort: h.configSvc.GetRegistryPort(), ServerPort: h.configSvc.GetServerPort(), AutoRoute: h.configSvc.IsAutoRouteEnabled(), NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(), ContainerStatuses: statuses, } h.sendJSON(w, http.StatusOK, status) } // handleBackups handles /admin/backups endpoints. func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) { if h.backupSvc == nil { h.sendError(w, http.StatusServiceUnavailable, "backup service not available") return } path = strings.TrimSuffix(path, "/") if path == "/backups" || path == "/backups/status" { h.handleBackupsStatus(w, r) return } suffix := strings.TrimPrefix(path, "/backups/") parts := strings.Split(suffix, "/") if len(parts) == 0 || parts[0] == "" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } backupDomain := parts[0] if len(parts) == 1 { h.handleBackupsDomain(w, r, backupDomain) return } if len(parts) == 2 && parts[1] == "detect" { h.handleBackupsDetect(w, r, backupDomain) return } h.sendError(w, http.StatusNotFound, "route not found") } func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } jobs, err := h.backupSvc.Status(ctx) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to get backup status") return } h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) } func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) { switch r.Method { case http.MethodGet: h.handleBackupsDomainList(w, r, backupDomain) case http.MethodPost: h.handleBackupsDomainRun(w, r, backupDomain) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) { ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } jobs, err := h.backupSvc.ListBackups(ctx, backupDomain) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to list backups") return } h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) } func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) { ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } var req dto.BackupRunRequest r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { h.sendError(w, http.StatusBadRequest, "invalid JSON") return } result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB) if err != nil { log := zerowrap.FromCtx(ctx) log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed") h.sendError(w, http.StatusInternalServerError, "failed to run backup") return } log := zerowrap.FromCtx(ctx) log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API") job := toBackupJobResponse(result.Job) h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{ Status: "completed", Backup: &job, }) } func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) { ctx := r.Context() if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to detect databases") return } response := make([]dto.DatabaseInfo, 0, len(dbs)) for _, db := range dbs { response = append(response, toDatabaseInfoResponse(db)) } h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response}) } func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob { response := make([]dto.BackupJob, 0, len(jobs)) for _, job := range jobs { response = append(response, toBackupJobResponse(job)) } return response } // handleReload handles /admin/reload endpoint. // This reloads configuration from file into memory and triggers container sync. func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission (reload modifies state) if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } // Reload configuration from file into memory // Using Reload() instead of Load() to ensure we re-read the file from disk if err := h.configSvc.Reload(ctx); err != nil { log.Error().Err(err).Msg("failed to reload config") h.sendError(w, http.StatusInternalServerError, "failed to reload config") return } // Publish manual reload event to sync containers // ManualReloadHandler starts missing containers without restarting running ones if h.eventBus != nil { if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil { log.Warn().Err(err).Msg("failed to publish manual reload event") } } log.Info().Msg("config reloaded via admin API") h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"}) } // handleConfig handles /admin/config endpoint. func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() // Check read permission if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") return } routes := h.configSvc.GetRoutes(ctx) routeResponses := make([]dto.Route, 0, len(routes)) for _, route := range routes { routeResponses = append(routeResponses, toRouteResponse(route)) } externalRoutes := h.configSvc.GetExternalRoutes() externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) for domain, target := range externalRoutes { externalResponses = append(externalResponses, dto.ExternalRoute{ Domain: domain, Target: target, }) } config := dto.ConfigResponse{ Server: dto.ServerConfig{ Port: h.configSvc.GetServerPort(), RegistryPort: h.configSvc.GetRegistryPort(), RegistryDomain: h.configSvc.GetRegistryDomain(), DataDir: h.configSvc.GetDataDir(), }, AutoRoute: dto.AutoRouteConfig{ Enabled: h.configSvc.IsAutoRouteEnabled(), }, NetworkIsolation: dto.NetworkIsolationConfig{ Enabled: h.configSvc.IsNetworkIsolationEnabled(), Prefix: h.configSvc.GetNetworkPrefix(), }, Routes: routeResponses, ExternalRoutes: externalResponses, } h.sendJSON(w, http.StatusOK, config) } // handleDeploy handles /admin/deploy/:domain endpoint. // POST triggers a deployment for the specified domain. func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodPost { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } // Parse domain from path deployDomain := strings.TrimPrefix(path, "/deploy/") if deployDomain == "" || deployDomain == "/deploy" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } if err := validation.ValidateDomainParam(deployDomain); err != nil { h.sendError(w, http.StatusBadRequest, "invalid domain") return } // Get the route for this domain route, err := h.configSvc.GetRoute(ctx, deployDomain) if err != nil { h.sendError(w, http.StatusNotFound, "route not found") return } // Deploy is an internal server-side action: pull from local registry path. container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) if err != nil { log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") var deployErr *domain.DeployFailureError if errors.As(err, &deployErr) { h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ Error: deployErr.Error(), Cause: deployErr.Cause, Hint: deployErr.Hint, Logs: deployErr.Logs, }) return } h.sendError(w, http.StatusInternalServerError, "failed to deploy container") return } // Clear deploy event suppression now that the explicit deploy has completed. // This re-enables event-based deploys for future direct docker pushes. if route.Image != "" && h.registrySvc != nil { // Use the registry package's image name normaliser so digest-form refs // and multi-segment paths are handled correctly. imageName := registry.ExtractImageName(route.Image) h.registrySvc.ClearDeployEventSuppression(imageName) } log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API") h.sendJSON(w, http.StatusOK, dto.DeployResponse{ Status: "deployed", ContainerID: container.ID, Domain: deployDomain, }) } // handleRestart handles /admin/restart/:domain endpoint. func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodPost { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } restartDomain := strings.TrimPrefix(path, "/restart/") if restartDomain == "" || restartDomain == "/restart" { h.sendError(w, http.StatusBadRequest, "domain required in path") return } if err := validation.ValidateDomainParam(restartDomain); err != nil { h.sendError(w, http.StatusBadRequest, "invalid domain") return } withAttachments := r.URL.Query().Get("attachments") == "true" // Use a detached context so the restart completes even if the HTTP client // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start). restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) defer cancel() if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil { log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container") if errors.Is(err, domain.ErrContainerNotFound) { h.sendError(w, http.StatusNotFound, "container not found") return } h.sendError(w, http.StatusInternalServerError, "failed to restart container") return } log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API") h.sendJSON(w, http.StatusOK, dto.RestartResponse{ Status: "restarted", Domain: restartDomain, }) } // handleTags handles /admin/tags/:repository endpoint. func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } repository := strings.TrimPrefix(path, "/tags/") if repository == "" || repository == "/tags" { h.sendError(w, http.StatusBadRequest, "repository name required in path") return } if err := validation.ValidateRepositoryName(repository); err != nil { h.sendError(w, http.StatusBadRequest, "invalid repository name") return } if h.registrySvc == nil { h.sendError(w, http.StatusServiceUnavailable, "registry service not available") return } tags, err := h.registrySvc.ListTags(ctx, repository) if err != nil { if errors.Is(err, registry.ErrRepositoryNotFound) { h.sendError(w, http.StatusNotFound, "repository not found") return } h.sendError(w, http.StatusInternalServerError, "failed to list tags") return } h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{ Repository: repository, Tags: tags, }) } // handleLogs handles /admin/logs endpoints. // GET /admin/logs - Gordon process logs // GET /admin/logs/:domain - Container logs for a specific domain func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodGet { h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check read permission if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") return } // Check if LogService is available if h.logSvc == nil { h.sendError(w, http.StatusServiceUnavailable, "log service not available") return } // Parse query parameters lines := 50 // default if linesStr := r.URL.Query().Get("lines"); linesStr != "" { if n, err := strconv.Atoi(linesStr); err == nil && n > 0 { lines = n } } if lines > maxLogLines { lines = maxLogLines } follow := r.URL.Query().Get("follow") == "true" // Parse domain from path logDomain := strings.TrimPrefix(path, "/logs/") if logDomain == "/logs" { logDomain = "" } if logDomain != "" { if err := validation.ValidateDomainParam(logDomain); err != nil { h.sendError(w, http.StatusBadRequest, "invalid domain") return } } if logDomain == "" { // Gordon process logs h.handleProcessLogs(w, r, lines, follow) } else { // Container logs h.handleContainerLogs(w, r, logDomain, lines, follow) } // Prevent unused variable warning when follow is implemented _ = log } // handleProcessLogs handles Gordon process logs. func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if follow { // SSE streaming h.streamProcessLogs(w, r, lines) return } // Return last N lines as JSON logLines, err := h.logSvc.GetProcessLogs(ctx, lines) if err != nil { log.Warn().Err(err).Msg("failed to get process logs") h.sendError(w, http.StatusInternalServerError, "failed to get logs") return } h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines}) } // handleContainerLogs handles container logs for a specific domain. func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if follow { // SSE streaming h.streamContainerLogs(w, r, logDomain, lines) return } // Return last N lines as JSON logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines) if err != nil { log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs") h.sendError(w, http.StatusInternalServerError, "failed to get container logs") return } h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ Domain: logDomain, Lines: logLines, }) } // streamProcessLogs streams Gordon process logs via SSE. func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check for flusher support before setting up SSE flusher, ok := w.(http.Flusher) if !ok { h.sendError(w, http.StatusInternalServerError, "streaming not supported") return } // Set up SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) ch, err := h.logSvc.FollowProcessLogs(ctx, lines) if err != nil { log.Warn().Err(err).Msg("failed to follow process logs") _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n") flusher.Flush() return } for { select { case line, ok := <-ch: if !ok { return } // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. // Per the SSE spec, multi-line data must use separate "data:" prefixes. normalized := strings.ReplaceAll(line, "\r\n", "\n") normalized = strings.ReplaceAll(normalized, "\r", "\n") escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ") _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped) flusher.Flush() case <-ctx.Done(): return } } } // streamContainerLogs streams container logs via SSE. func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check for flusher support before setting up SSE flusher, ok := w.(http.Flusher) if !ok { h.sendError(w, http.StatusInternalServerError, "streaming not supported") return } // Set up SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines) if err != nil { log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs") _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n") flusher.Flush() return } for { select { case line, ok := <-ch: if !ok { return } // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. // Per the SSE spec, multi-line data must use separate "data:" prefixes. normalized := strings.ReplaceAll(line, "\r\n", "\n") normalized = strings.ReplaceAll(normalized, "\r", "\n") escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ") _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped) flusher.Flush() case <-ctx.Done(): return } } } // handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments. func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) { // Parse target (domain or group) from path target := strings.TrimPrefix(path, "/attachments/") if target == "/attachments" { target = "" } switch r.Method { case http.MethodGet: h.handleAttachmentsConfigGet(w, r, target) case http.MethodPost: h.handleAttachmentsConfigPost(w, r, target) case http.MethodDelete: h.handleAttachmentsConfigDelete(w, r, target) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) { ctx := r.Context() // Check read permission if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") return } if target == "" { // List all attachments attachments := h.configSvc.GetAllAttachments(ctx) h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments}) return } // List attachments for specific target images, err := h.configSvc.GetAttachmentsFor(ctx, target) if err != nil { if errors.Is(err, domain.ErrAttachmentNotFound) { h.sendError(w, http.StatusNotFound, "no attachments found for target") return } h.sendError(w, http.StatusInternalServerError, "failed to get attachments") return } h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images}) } func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } if target == "" { h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path") return } // Limit request body size r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var req dto.AttachmentAddRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Warn().Err(err).Msg("invalid attachment JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil { log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment") switch { case errors.Is(err, domain.ErrAttachmentExists): h.sendError(w, http.StatusConflict, "attachment already exists") case errors.Is(err, domain.ErrAttachmentImageEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, domain.ErrAttachmentTargetEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) default: h.sendError(w, http.StatusInternalServerError, "failed to add attachment") } return } log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added") h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"}) } func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) // Check write permission if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } if target == "" { h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path") return } // Parse image from path: /attachments/{target}/{image} // target at this point contains "{domain}/{image}" or just "{domain}" parts := strings.SplitN(target, "/", 2) if len(parts) != 2 { h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}") return } domainOrGroup := parts[0] image := parts[1] if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil { log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment") switch { case errors.Is(err, domain.ErrAttachmentNotFound): h.sendError(w, http.StatusNotFound, "attachment not found") case errors.Is(err, domain.ErrAttachmentImageEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, domain.ErrAttachmentTargetEmpty): h.sendError(w, http.StatusBadRequest, err.Error()) default: h.sendError(w, http.StatusInternalServerError, "failed to remove attachment") } return } log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed") h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"}) } func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) { switch r.Method { case http.MethodGet: h.handleAutoRouteAllowedDomainsGet(w, r) case http.MethodPost: h.handleAutoRouteAllowedDomainsPost(w, r) case http.MethodDelete: h.handleAutoRouteAllowedDomainsDelete(w, r, path) default: h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") return } domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains") return } h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains}) } func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) var req dto.AutoRouteAllowedDomainRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Warn().Err(err).Msg("invalid auto-route allowlist JSON") h.sendError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil { if errors.Is(err, domain.ErrInvalidDomainPattern) { h.sendError(w, http.StatusBadRequest, err.Error()) } else { log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain") h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain") } return } h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"}) } func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) { ctx := r.Context() log := zerowrap.FromCtx(ctx) if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") return } raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/") if raw == path || raw == "" || raw == "/" { h.sendError(w, http.StatusBadRequest, "missing domain pattern") return } pattern, err := url.PathUnescape(raw) if err != nil || strings.TrimSpace(pattern) == "" { h.sendError(w, http.StatusBadRequest, "invalid domain pattern") return } if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil { log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain") h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain") return } h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"}) } // handleAuthVerify handles /admin/auth/verify endpoint. // Validates authentication session and returns token status. func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Only allow GET method if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") return } // Call use case to get auth status status, err := h.authSvc.GetAuthStatus(ctx) if err != nil { h.sendError(w, http.StatusInternalServerError, "failed to get auth status") return } // Convert domain.AuthStatus to DTO response := dto.AuthVerifyResponse{ Valid: status.Valid, Subject: status.Subject, Scopes: status.Scopes, ExpiresAt: status.ExpiresAt, IssuedAt: status.IssuedAt, } h.sendJSON(w, http.StatusOK, response) }