From d135e5fd4b3efb87141a1af31e5b1d36d8c93fe9 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 9 Oct 2026 00:27:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(services):=20env=20doctor=20=E2=80=94=20su?= =?UTF-8?q?rface=20empty=20vars,=20bad=20refs,=20unreadable=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /services/:id/env-check (read scope) reports, without exposing values: - unresolved: ${{service.KEY}} refs that don't match a sibling service/env - empty: variables with blank values - unreadable: secrets whose ciphertext fails decryption (key rotation) install.sh now generates SECRETS_KEY explicitly (was falling back to JWT_SECRET — a rotation would orphan ciphertext). .env.example documents it. Surfaces: CLI `services env-check`, MCP containr_services_env_check (90 tools), OpenAPI path, and a warning banner in the service Variables panel listing each issue. Verified live: injected a bogus ${{nosuchservice.PORT}} var plus an unreadable ciphertext row; the endpoint flagged both, ok=false. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 4 + app/backend/internal/api/routes.go | 1 + app/backend/internal/api/runtime.go | 109 ++++++++++++++++++ app/backend/internal/cli/commands/services.go | 35 +++++- app/backend/internal/mcpserver/server.go | 1 + .../service/pages/ServiceDetailPage.tsx | 24 ++++ app/frontend/src/generated/api-types.ts | 46 ++++++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 11 ++ docs/api/openapi.yaml | 35 ++++++ docs/guides/CLI.md | 1 + install.sh | 1 + llms.txt | 2 +- 13 files changed, 269 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 233ce5f..b66014c 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,10 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0 # Application Configuration # In production this must be a strong value with at least 32 characters. JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*() +# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset; +# setting it explicitly survives a JWT_SECRET rotation. +SECRETS_KEY= + # Shared secret for node-agent registration/heartbeat auth. CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret # Optional rotation list (comma-separated). When set, this takes precedence. diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 6e445c1..bd63039 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -279,6 +279,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/domains", handleListServiceDomains) read.GET("/services/:id/domains/check", handleCheckServiceDomains) + read.GET("/services/:id/env-check", handleServiceEnvCheck) read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments/:id", handleGetDeployment) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index 20ac604..f207c72 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -638,3 +638,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error { _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) return nil } + +// handleServiceEnvCheck reports environment problems without exposing +// values: empty vars, ${{service.KEY}} refs that fail to resolve, and +// secrets whose ciphertext can't be decrypted (key rotation). +func handleServiceEnvCheck(c *gin.Context) { + dbVal, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbVal.(*database.DB) + service, ok := loadReadableService(c, db) + if !ok { + return + } + + rows, err := db.Query( + `SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`, + service.ID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + var unresolved, empty, unreadable []string + var raw []struct{ key, val string } + for rows.Next() { + var k, v string + var secret bool + if err := rows.Scan(&k, &v, &secret); err != nil { + continue + } + decrypted := secrets.Decrypt(v) + if secret && secrets.IsEncrypted(v) && decrypted == v { + // Ciphertext failed to decrypt — Decrypt returns the input. + unreadable = append(unreadable, k) + continue + } + if strings.TrimSpace(decrypted) == "" { + empty = append(empty, k) + } + raw = append(raw, struct{ key, val string }{k, decrypted}) + } + + // Project siblings for ${{name.KEY}} resolution. + siblingIDs := map[string]string{} + svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID) + if err == nil { + for svcRows.Next() { + var id, name string + if err := svcRows.Scan(&id, &name); err == nil { + siblingIDs[strings.ToLower(name)] = id + } + } + svcRows.Close() + } + + resolvable := func(serviceName, key string) bool { + id, ok := siblingIDs[strings.ToLower(serviceName)] + if !ok { + return false + } + if strings.EqualFold(key, "HOST") { + return true + } + if strings.EqualFold(key, "PORT") { + var port int + if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil { + return port > 0 + } + return false + } + var n int + if err := db.QueryRow( + `SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`, + id, key, + ).Scan(&n); err == nil { + return n > 0 + } + return false + } + + for _, kv := range raw { + for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) { + if len(match) == 3 && !resolvable(match[1], match[2]) { + unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0])) + } + } + } + + if unresolved == nil { + unresolved = []string{} + } + if empty == nil { + empty = []string{} + } + if unreadable == nil { + unreadable = []string{} + } + okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0 + c.JSON(http.StatusOK, gin.H{ + "ok": okResult, + "unresolved": unresolved, + "empty": empty, + "unreadable": unreadable, + }) +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 0c61134..8bcef75 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -530,5 +530,38 @@ func init() { domainsAddCmd.Flags().Bool("default", false, "set as the default domain") servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) - ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd) + ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd) +} + +var servicesEnvCheckCmd = &cobra.Command{ + Use: "env-check ", + Short: "Diagnose service environment: empty vars, unresolved ${{refs}}", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + m, _ := unwrapObject(data) + if b, ok := m["ok"].(bool); ok && b { + fmt.Println("env check: ok") + return nil + } + for _, group := range []string{"unresolved", "empty", "unreadable"} { + if list, ok := m[group].([]interface{}); ok { + for _, item := range list { + fmt.Printf("%s: %v\n", group, item) + } + } + } + return &APIError{Message: "env check failed", ExitCode: ExitError} + }, } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 633ed4d..f21735d 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -60,6 +60,7 @@ func init() { register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false}) register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false}) register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false}) + register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false}) register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false}) register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 8815f60..ac317d1 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -36,6 +36,7 @@ import { sleepService, wakeService, getServiceRuntime, + getServiceEnvCheck, type CronJobEntity, type ServiceVolume, type ServiceDomain, @@ -189,6 +190,12 @@ export function ServiceDetailPage() { enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables', }); + const envCheckQuery = useQuery({ + queryKey: ['service-env-check', serviceId], + queryFn: () => getServiceEnvCheck(serviceId), + enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables', + }); + const saveVariablesMutation = useMutation({ mutationFn: (rows: VariableDraft[]) => updateServiceVariables( @@ -1315,6 +1322,23 @@ export function ServiceDetailPage() { + {envCheckQuery.data && !envCheckQuery.data.ok && ( +
+

Environment issues detected

+
    + {(envCheckQuery.data.unresolved ?? []).map((u) => ( +
  • Unresolved reference: {u}
  • + ))} + {(envCheckQuery.data.empty ?? []).map((k) => ( +
  • Empty value: {k}
  • + ))} + {(envCheckQuery.data.unreadable ?? []).map((k) => ( +
  • Cannot decrypt secret (key rotated?): {k}
  • + ))} +
+
+ )} + {bulkOpen && (