fix(security): guard restarts, widen protected paths, and strip GORDON_ env from secret tools
- commit
- 64d925b61d4a3aa1702a72fa65e9033690974772
- author
- brice · · 2026-10-08 21:10 UTC
- parent
- 1df31dd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +175 -33
M docs/config/security-hardening.md +15 -6
| @@ -174,7 +174,7 @@ Use `strict` for images designed to write only to mounted volumes and run withou | |||
| 174 | 174 | ||
| 175 | 175 | ## Runtime guard | |
| 176 | 176 | ||
| 177 | - | Every app container config passes a final guard right before the runtime creates the container. A refused config creates no container and fails with error code `runtime-guard-violation` (HTTP 400). The guard refuses: | |
| 177 | + | Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. A replayed deploy whose preflight failed this way returns error code `runtime-guard-violation` (HTTP 400). The guard refuses: | |
| 178 | 178 | ||
| 179 | 179 | - network mode `host`, `container:<id>`, or `ns:<path>`; | |
| 180 | 180 | - a volume name containing `/` or `\`, which the runtime would read as a host path; | |
| @@ -183,17 +183,26 @@ Every app container config passes a final guard right before the runtime creates | |||
| 183 | 183 | Protected paths: | |
| 184 | 184 | ||
| 185 | 185 | - the data directory and the loaded config file; | |
| 186 | + | - files and directories set in the config: `server.tls_cert_file`, `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values; | |
| 186 | 187 | - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket; | |
| 187 | - | - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon` and `~/.gordon/run`; | |
| 188 | + | - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`; | |
| 189 | + | - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`; | |
| 190 | + | - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`; | |
| 188 | 191 | - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`. | |
| 189 | 192 | ||
| 190 | - | The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy is reported at deploy preflight, before the running generation is replaced. Point the policy `source` outside every protected path. | |
| 193 | + | A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. | |
| 194 | + | ||
| 195 | + | The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path. | |
| 191 | 196 | ||
| 192 | 197 | ## Process memory protection | |
| 193 | 198 | ||
| 194 | - | On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) before loading any configuration or secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set. | |
| 199 | + | On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) as its first step, before loading its config file or any backend secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set. | |
| 200 | + | ||
| 201 | + | Limits: | |
| 195 | 202 | ||
| 196 | - | This does not stop root, nor a process of the same user that can replace the Gordon binary or its systemd unit. | |
| 203 | + | - Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend. | |
| 204 | + | - Helper processes (`pass`, `gpg`, `sops`) are dumpable again after `exec` and hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables. | |
| 205 | + | - Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped. | |
| 197 | 206 | ||
| 198 | 207 | ## Hardened systemd user unit | |
| 199 | 208 | ||
| @@ -233,7 +242,7 @@ Gordon v3 protects against: | |||
| 233 | 242 | ||
| 234 | 243 | - file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`; | |
| 235 | 244 | - app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces; | |
| 236 | - | - same-user processes reading Gordon's memory or environment through `/proc`. | |
| 245 | + | - same-user processes reading the Gordon process's memory or environment through `/proc`, within the limits above. | |
| 237 | 246 | ||
| 238 | 247 | Gordon v3 does not protect against: | |
| 239 | 248 | ||
| @@ -174,7 +174,7 @@ Use `strict` for images designed to write only to mounted volumes and run withou | |||||
| 174 | 174 | ||||
| 175 | ## Runtime guard | 175 | ## Runtime guard | ||
| 176 | 176 | ||||
| 177 | - | Every app container config passes a final guard right before the runtime creates the container. A refused config creates no container and fails with error code `runtime-guard-violation` (HTTP 400). The guard refuses: | 177 | + | Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. A replayed deploy whose preflight failed this way returns error code `runtime-guard-violation` (HTTP 400). The guard refuses: |
| 178 | 178 | ||||
| 179 | - network mode `host`, `container:<id>`, or `ns:<path>`; | 179 | - network mode `host`, `container:<id>`, or `ns:<path>`; | ||
| 180 | - a volume name containing `/` or `\`, which the runtime would read as a host path; | 180 | - a volume name containing `/` or `\`, which the runtime would read as a host path; | ||
| @@ -183,17 +183,26 @@ Every app container config passes a final guard right before the runtime creates | |||||
| 183 | Protected paths: | 183 | Protected paths: | ||
| 184 | 184 | ||||
| 185 | - the data directory and the loaded config file; | 185 | - the data directory and the loaded config file; | ||
| 186 | + | - files and directories set in the config: `server.tls_cert_file`, `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values; | |||
| 186 | - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket; | 187 | - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket; | ||
| 187 | - | - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon` and `~/.gordon/run`; | 188 | + | - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`; |
| 189 | + | - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`; | |||
| 190 | + | - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`; | |||
| 188 | - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`. | 191 | - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`. | ||
| 189 | 192 | ||||
| 190 | - | The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy is reported at deploy preflight, before the running generation is replaced. Point the policy `source` outside every protected path. | 193 | + | A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. |
| 194 | + | ||||
| 195 | + | The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path. | |||
| 191 | 196 | ||||
| 192 | ## Process memory protection | 197 | ## Process memory protection | ||
| 193 | 198 | ||||
| 194 | - | On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) before loading any configuration or secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set. | 199 | + | On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) as its first step, before loading its config file or any backend secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set. |
| 200 | + | ||||
| 201 | + | Limits: | |||
| 195 | 202 | ||||
| 196 | - | This does not stop root, nor a process of the same user that can replace the Gordon binary or its systemd unit. | 203 | + | - Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend. |
| 204 | + | - Helper processes (`pass`, `gpg`, `sops`) are dumpable again after `exec` and hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables. | |||
| 205 | + | - Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped. | |||
| 197 | 206 | ||||
| 198 | ## Hardened systemd user unit | 207 | ## Hardened systemd user unit | ||
| 199 | 208 | ||||
| @@ -233,7 +242,7 @@ Gordon v3 protects against: | |||||
| 233 | 242 | ||||
| 234 | - file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`; | 243 | - file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`; | ||
| 235 | - app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces; | 244 | - app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces; | ||
| 236 | - | - same-user processes reading Gordon's memory or environment through `/proc`. | 245 | + | - same-user processes reading the Gordon process's memory or environment through `/proc`, within the limits above. |
| 237 | 246 | ||||
| 238 | Gordon v3 does not protect against: | 247 | Gordon v3 does not protect against: | ||
| 239 | 248 | ||||
M internal/adapters/in/http/admin/handler_apps.go +1 -0
| @@ -539,6 +539,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool { | |||
| 539 | 539 | // Split from isMappedPreflightError to keep its complexity within budget. | |
| 540 | 540 | func isPreflightErrorRest(err error) bool { | |
| 541 | 541 | return errors.Is(err, domain.ErrRuntimeUnsupported) || | |
| 542 | + | errors.Is(err, domain.ErrRuntimeGuard) || | |
| 542 | 543 | errors.Is(err, domain.ErrAppNotFound) || | |
| 543 | 544 | errors.Is(err, domain.ErrAppReservationConflict) || | |
| 544 | 545 | errors.Is(err, domain.ErrAppImageUnresolvable) || | |
| @@ -539,6 +539,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool { | |||||
| 539 | // Split from isMappedPreflightError to keep its complexity within budget. | 539 | // Split from isMappedPreflightError to keep its complexity within budget. | ||
| 540 | func isPreflightErrorRest(err error) bool { | 540 | func isPreflightErrorRest(err error) bool { | ||
| 541 | return errors.Is(err, domain.ErrRuntimeUnsupported) || | 541 | return errors.Is(err, domain.ErrRuntimeUnsupported) || | ||
| 542 | + | errors.Is(err, domain.ErrRuntimeGuard) || | |||
| 542 | errors.Is(err, domain.ErrAppNotFound) || | 543 | errors.Is(err, domain.ErrAppNotFound) || | ||
| 543 | errors.Is(err, domain.ErrAppReservationConflict) || | 544 | errors.Is(err, domain.ErrAppReservationConflict) || | ||
| 544 | errors.Is(err, domain.ErrAppImageUnresolvable) || | 545 | errors.Is(err, domain.ErrAppImageUnresolvable) || | ||
M internal/adapters/in/http/admin/handler_apps_test.go +10 -3
| @@ -682,13 +682,20 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) { | |||
| 682 | 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | |
| 683 | 683 | } | |
| 684 | 684 | ||
| 685 | - | func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) { | |
| 685 | + | // TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay proves a terminal | |
| 686 | + | // replay whose preflight failed on the runtime guard maps to the stable | |
| 687 | + | // runtime-guard-violation envelope. | |
| 688 | + | func TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay(t *testing.T) { | |
| 686 | 689 | appSvc := inmocks.NewMockAppService(t) | |
| 687 | 690 | handler := appsTestHandler(t, appSvc) | |
| 688 | 691 | ||
| 692 | + | op := &domain.AppOperation{ | |
| 693 | + | Op: "op-g", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeFailed, | |
| 694 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepFailed, Error: "runtime guard violation"}}, | |
| 695 | + | } | |
| 689 | 696 | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return( | |
| 690 | - | nil, | |
| 691 | - | fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"), | |
| 697 | + | op, | |
| 698 | + | fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"), | |
| 692 | 699 | ).Once() | |
| 693 | 700 | ||
| 694 | 701 | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | |
| @@ -682,13 +682,20 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) { | |||||
| 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | ||
| 683 | } | 683 | } | ||
| 684 | 684 | ||||
| 685 | - | func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) { | 685 | + | // TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay proves a terminal |
| 686 | + | // replay whose preflight failed on the runtime guard maps to the stable | |||
| 687 | + | // runtime-guard-violation envelope. | |||
| 688 | + | func TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay(t *testing.T) { | |||
| 686 | appSvc := inmocks.NewMockAppService(t) | 689 | appSvc := inmocks.NewMockAppService(t) | ||
| 687 | handler := appsTestHandler(t, appSvc) | 690 | handler := appsTestHandler(t, appSvc) | ||
| 688 | 691 | ||||
| 692 | + | op := &domain.AppOperation{ | |||
| 693 | + | Op: "op-g", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeFailed, | |||
| 694 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepFailed, Error: "runtime guard violation"}}, | |||
| 695 | + | } | |||
| 689 | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return( | 696 | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return( | ||
| 690 | - | nil, | 697 | + | op, |
| 691 | - | fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"), | 698 | + | fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"), |
| 692 | ).Once() | 699 | ).Once() | ||
| 693 | 700 | ||||
| 694 | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | 701 | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | ||
M internal/adapters/out/appsecrets/store.go +3 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/zerowrap" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -41,6 +42,7 @@ func (s *Store) SetSecret(ctx context.Context, path, value string) error { | |||
| 41 | 42 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 42 | 43 | defer cancel() | |
| 43 | 44 | cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | |
| 45 | + | cmd.Env = childenv.Env() | |
| 44 | 46 | cmd.Stdin = strings.NewReader(value) | |
| 45 | 47 | output, err := cmd.CombinedOutput() | |
| 46 | 48 | if err != nil { | |
| @@ -58,6 +60,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error { | |||
| 58 | 60 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 59 | 61 | defer cancel() | |
| 60 | 62 | cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | |
| 63 | + | cmd.Env = childenv.Env() | |
| 61 | 64 | output, err := cmd.CombinedOutput() | |
| 62 | 65 | if err != nil { | |
| 63 | 66 | return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -41,6 +42,7 @@ func (s *Store) SetSecret(ctx context.Context, path, value string) error { | |||||
| 41 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | 42 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | ||
| 42 | defer cancel() | 43 | defer cancel() | ||
| 43 | cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | 44 | cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | ||
| 45 | + | cmd.Env = childenv.Env() | |||
| 44 | cmd.Stdin = strings.NewReader(value) | 46 | cmd.Stdin = strings.NewReader(value) | ||
| 45 | output, err := cmd.CombinedOutput() | 47 | output, err := cmd.CombinedOutput() | ||
| 46 | if err != nil { | 48 | if err != nil { | ||
| @@ -58,6 +60,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error { | |||||
| 58 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | 60 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | ||
| 59 | defer cancel() | 61 | defer cancel() | ||
| 60 | cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | 62 | cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below | ||
| 63 | + | cmd.Env = childenv.Env() | |||
| 61 | output, err := cmd.CombinedOutput() | 64 | output, err := cmd.CombinedOutput() | ||
| 62 | if err != nil { | 65 | if err != nil { | ||
| 63 | return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | 66 | return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | ||
A internal/adapters/out/childenv/childenv.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | // Package childenv builds the environment for secret-tool child processes | |
| 2 | + | // (pass, gpg, sops). execve makes a child dumpable again, so a same-user | |
| 3 | + | // process can read its /proc/<pid>/environ: Gordon's own GORDON_* values | |
| 4 | + | // (token secrets, API tokens) must never be inherited. | |
| 5 | + | package childenv | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Env returns the current environment without GORDON_* variables. | |
| 13 | + | func Env() []string { | |
| 14 | + | return filter(os.Environ()) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func filter(environ []string) []string { | |
| 18 | + | kept := make([]string, 0, len(environ)) | |
| 19 | + | for _, entry := range environ { | |
| 20 | + | if !strings.HasPrefix(entry, "GORDON_") { | |
| 21 | + | kept = append(kept, entry) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | return kept | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | // Package childenv builds the environment for secret-tool child processes | |||
| 2 | + | // (pass, gpg, sops). execve makes a child dumpable again, so a same-user | |||
| 3 | + | // process can read its /proc/<pid>/environ: Gordon's own GORDON_* values | |||
| 4 | + | // (token secrets, API tokens) must never be inherited. | |||
| 5 | + | package childenv | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Env returns the current environment without GORDON_* variables. | |||
| 13 | + | func Env() []string { | |||
| 14 | + | return filter(os.Environ()) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func filter(environ []string) []string { | |||
| 18 | + | kept := make([]string, 0, len(environ)) | |||
| 19 | + | for _, entry := range environ { | |||
| 20 | + | if !strings.HasPrefix(entry, "GORDON_") { | |||
| 21 | + | kept = append(kept, entry) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | return kept | |||
| 25 | + | } | |||
A internal/adapters/out/childenv/childenv_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package childenv | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFilterDropsGordonVariables(t *testing.T) { | |
| 10 | + | got := filter([]string{ | |
| 11 | + | "PATH=/usr/bin", | |
| 12 | + | "GORDON_AUTH_TOKEN_SECRET=s3cr3t", | |
| 13 | + | "PASSWORD_STORE_DIR=/keys/pass", | |
| 14 | + | "GORDON_CLOUDFLARE_API_TOKEN=t0ken", | |
| 15 | + | "GNUPGHOME=/keys/gnupg", | |
| 16 | + | }) | |
| 17 | + | assert.Equal(t, []string{"PATH=/usr/bin", "PASSWORD_STORE_DIR=/keys/pass", "GNUPGHOME=/keys/gnupg"}, got) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package childenv | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFilterDropsGordonVariables(t *testing.T) { | |||
| 10 | + | got := filter([]string{ | |||
| 11 | + | "PATH=/usr/bin", | |||
| 12 | + | "GORDON_AUTH_TOKEN_SECRET=s3cr3t", | |||
| 13 | + | "PASSWORD_STORE_DIR=/keys/pass", | |||
| 14 | + | "GORDON_CLOUDFLARE_API_TOKEN=t0ken", | |||
| 15 | + | "GNUPGHOME=/keys/gnupg", | |||
| 16 | + | }) | |||
| 17 | + | assert.Equal(t, []string{"PATH=/usr/bin", "PASSWORD_STORE_DIR=/keys/pass", "GNUPGHOME=/keys/gnupg"}, got) | |||
| 18 | + | } | |||
M internal/adapters/out/secrets/pass.go +3 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |
| 14 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -81,6 +82,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 81 | 82 | defer cancel() | |
| 82 | 83 | ||
| 83 | 84 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths | |
| 85 | + | cmd.Env = childenv.Env() | |
| 84 | 86 | output, err := cmd.Output() | |
| 85 | 87 | if err != nil { | |
| 86 | 88 | if exitError, ok := err.(*exec.ExitError); ok { | |
| @@ -110,5 +112,6 @@ func (p *PassProvider) IsAvailable() bool { | |||
| 110 | 112 | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | |
| 111 | 113 | defer cancel() | |
| 112 | 114 | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input | |
| 115 | + | cmd.Env = childenv.Env() | |
| 113 | 116 | return cmd.Run() == nil | |
| 114 | 117 | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |||
| 14 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -81,6 +82,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 81 | defer cancel() | 82 | defer cancel() | ||
| 82 | 83 | ||||
| 83 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths | 84 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths | ||
| 85 | + | cmd.Env = childenv.Env() | |||
| 84 | output, err := cmd.Output() | 86 | output, err := cmd.Output() | ||
| 85 | if err != nil { | 87 | if err != nil { | ||
| 86 | if exitError, ok := err.(*exec.ExitError); ok { | 88 | if exitError, ok := err.(*exec.ExitError); ok { | ||
| @@ -110,5 +112,6 @@ func (p *PassProvider) IsAvailable() bool { | |||||
| 110 | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | 112 | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | ||
| 111 | defer cancel() | 113 | defer cancel() | ||
| 112 | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input | 114 | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input | ||
| 115 | + | cmd.Env = childenv.Env() | |||
| 113 | return cmd.Run() == nil | 116 | return cmd.Run() == nil | ||
| 114 | } | 117 | } | ||
M internal/adapters/out/secrets/sops.go +3 -0
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | |
| @@ -109,6 +111,7 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 109 | 111 | // Use sops -d --extract to get specific key | |
| 110 | 112 | // #nosec G204 - filePath and keyPath are validated above | |
| 111 | 113 | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) | |
| 114 | + | cmd.Env = childenv.Env() | |
| 112 | 115 | output, err := cmd.Output() | |
| 113 | 116 | if err != nil { | |
| 114 | 117 | if exitError, ok := err.(*exec.ExitError); ok { | |
| @@ -10,6 +10,8 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | 17 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | ||
| @@ -109,6 +111,7 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 109 | // Use sops -d --extract to get specific key | 111 | // Use sops -d --extract to get specific key | ||
| 110 | // #nosec G204 - filePath and keyPath are validated above | 112 | // #nosec G204 - filePath and keyPath are validated above | ||
| 111 | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) | 113 | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) | ||
| 114 | + | cmd.Env = childenv.Env() | |||
| 112 | output, err := cmd.Output() | 115 | output, err := cmd.Output() | ||
| 113 | if err != nil { | 116 | if err != nil { | ||
| 114 | if exitError, ok := err.(*exec.ExitError); ok { | 117 | if exitError, ok := err.(*exec.ExitError); ok { | ||
M internal/adapters/out/tokenstore/pass.go +6 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/zerowrap" | |
| 16 | 16 | ||
| 17 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |
| 17 | 18 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -338,6 +339,7 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 338 | 339 | ||
| 339 | 340 | // List all entries under the token path | |
| 340 | 341 | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant | |
| 342 | + | cmd.Env = childenv.Env() | |
| 341 | 343 | output, err := cmd.Output() | |
| 342 | 344 | if err != nil { | |
| 343 | 345 | // If the path doesn't exist, return empty list | |
| @@ -484,6 +486,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 484 | 486 | // Remove JWT | |
| 485 | 487 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 486 | 488 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | |
| 489 | + | cmd.Env = childenv.Env() | |
| 487 | 490 | if err := cmd.Run(); err != nil { | |
| 488 | 491 | return fmt.Errorf("failed to delete token: %w", err) | |
| 489 | 492 | } | |
| @@ -491,6 +494,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 491 | 494 | // Remove metadata | |
| 492 | 495 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 493 | 496 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | |
| 497 | + | cmd.Env = childenv.Env() | |
| 494 | 498 | if err := cmd.Run(); err != nil { | |
| 495 | 499 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | |
| 496 | 500 | } | |
| @@ -512,6 +516,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 512 | 516 | // passInsert inserts a value into pass. | |
| 513 | 517 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |
| 514 | 518 | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | |
| 519 | + | cmd.Env = childenv.Env() | |
| 515 | 520 | cmd.Stdin = strings.NewReader(value) | |
| 516 | 521 | output, err := cmd.CombinedOutput() | |
| 517 | 522 | if err != nil { | |
| @@ -523,6 +528,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 523 | 528 | // passShow retrieves a value from pass. | |
| 524 | 529 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | |
| 525 | 530 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | |
| 531 | + | cmd.Env = childenv.Env() | |
| 526 | 532 | output, err := cmd.Output() | |
| 527 | 533 | if err != nil { | |
| 528 | 534 | return "", fmt.Errorf("pass show failed: %w", err) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | 16 | ||||
| 17 | + | "github.com/bnema/gordon/internal/adapters/out/childenv" | |||
| 17 | "github.com/bnema/gordon/internal/domain" | 18 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -338,6 +339,7 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||||
| 338 | 339 | ||||
| 339 | // List all entries under the token path | 340 | // List all entries under the token path | ||
| 340 | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant | 341 | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant | ||
| 342 | + | cmd.Env = childenv.Env() | |||
| 341 | output, err := cmd.Output() | 343 | output, err := cmd.Output() | ||
| 342 | if err != nil { | 344 | if err != nil { | ||
| 343 | // If the path doesn't exist, return empty list | 345 | // If the path doesn't exist, return empty list | ||
| @@ -484,6 +486,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||||
| 484 | // Remove JWT | 486 | // Remove JWT | ||
| 485 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | 487 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | ||
| 486 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | 488 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | ||
| 489 | + | cmd.Env = childenv.Env() | |||
| 487 | if err := cmd.Run(); err != nil { | 490 | if err := cmd.Run(); err != nil { | ||
| 488 | return fmt.Errorf("failed to delete token: %w", err) | 491 | return fmt.Errorf("failed to delete token: %w", err) | ||
| 489 | } | 492 | } | ||
| @@ -491,6 +494,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||||
| 491 | // Remove metadata | 494 | // Remove metadata | ||
| 492 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | 495 | // #nosec G204 -- command and args are fixed; subject is treated as data only. | ||
| 493 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | 496 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | ||
| 497 | + | cmd.Env = childenv.Env() | |||
| 494 | if err := cmd.Run(); err != nil { | 498 | if err := cmd.Run(); err != nil { | ||
| 495 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | 499 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | ||
| 496 | } | 500 | } | ||
| @@ -512,6 +516,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||||
| 512 | // passInsert inserts a value into pass. | 516 | // passInsert inserts a value into pass. | ||
| 513 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | 517 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | ||
| 514 | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | 518 | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | ||
| 519 | + | cmd.Env = childenv.Env() | |||
| 515 | cmd.Stdin = strings.NewReader(value) | 520 | cmd.Stdin = strings.NewReader(value) | ||
| 516 | output, err := cmd.CombinedOutput() | 521 | output, err := cmd.CombinedOutput() | ||
| 517 | if err != nil { | 522 | if err != nil { | ||
| @@ -523,6 +528,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||||
| 523 | // passShow retrieves a value from pass. | 528 | // passShow retrieves a value from pass. | ||
| 524 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | 529 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | ||
| 525 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | 530 | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | ||
| 531 | + | cmd.Env = childenv.Env() | |||
| 526 | output, err := cmd.Output() | 532 | output, err := cmd.Output() | ||
| 527 | if err != nil { | 533 | if err != nil { | ||
| 528 | return "", fmt.Errorf("pass show failed: %w", err) | 534 | return "", fmt.Errorf("pass show failed: %w", err) | ||
M internal/app/runtime_guard.go +54 -16
| @@ -12,10 +12,11 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // protectedRuntimePaths lists the host paths no app bind may equal, lie | |
| 15 | - | // under, or contain: Gordon's data and config, runtime sockets, Gordon's | |
| 16 | - | // runtime credentials, and key stores. Empty entries are skipped; each | |
| 17 | - | // entry is cleaned and its symlink-resolved form is added when it differs. | |
| 18 | - | func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string { | |
| 15 | + | // under, or contain: Gordon's data, config, and configured files, runtime | |
| 16 | + | // sockets and storage, Gordon's runtime credentials, and key stores. | |
| 17 | + | // Empty entries are skipped; each entry is made absolute and its | |
| 18 | + | // symlink-resolved form is added when it differs. | |
| 19 | + | func protectedRuntimePaths(dataDir string, gordonPaths []string, getenv func(string) string, home string) []string { | |
| 19 | 20 | runtimeDir := getenv("XDG_RUNTIME_DIR") | |
| 20 | 21 | configHome := getenv("XDG_CONFIG_HOME") | |
| 21 | 22 | if configHome == "" { | |
| @@ -25,9 +26,8 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||
| 25 | 26 | if strings.Contains(dockerHost, "://") { | |
| 26 | 27 | dockerHost = "" | |
| 27 | 28 | } | |
| 28 | - | raw := []string{ | |
| 29 | + | raw := append([]string{ | |
| 29 | 30 | dataDir, | |
| 30 | - | configFile, | |
| 31 | 31 | "/var/run/docker.sock", | |
| 32 | 32 | "/run/docker.sock", | |
| 33 | 33 | "/run/podman/podman.sock", | |
| @@ -36,14 +36,19 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||
| 36 | 36 | subPath(runtimeDir, "gordon"), | |
| 37 | 37 | homePath(home, ".gordon", "run"), | |
| 38 | 38 | dockerHost, | |
| 39 | - | runtimeSocket, | |
| 39 | + | homePath(home, ".local", "share", "containers"), | |
| 40 | + | homePath(home, ".local", "share", "docker"), | |
| 41 | + | subPath(configHome, "containers"), | |
| 42 | + | homePath(home, ".docker"), | |
| 43 | + | subPath(configHome, "systemd", "user"), | |
| 44 | + | getenv("GORDON_CLOUDFLARE_API_TOKEN_FILE"), | |
| 40 | 45 | getenv("GNUPGHOME"), | |
| 41 | 46 | homePath(home, ".gnupg"), | |
| 42 | 47 | getenv("PASSWORD_STORE_DIR"), | |
| 43 | 48 | homePath(home, ".password-store"), | |
| 44 | 49 | getenv("SOPS_AGE_KEY_FILE"), | |
| 45 | 50 | subPath(configHome, "sops", "age"), | |
| 46 | - | } | |
| 51 | + | }, gordonPaths...) | |
| 47 | 52 | var paths []string | |
| 48 | 53 | for _, p := range raw { | |
| 49 | 54 | if p == "" { | |
| @@ -56,7 +61,7 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||
| 56 | 61 | continue | |
| 57 | 62 | } | |
| 58 | 63 | paths = append(paths, clean) | |
| 59 | - | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | |
| 64 | + | if resolved := resolveExisting(clean); resolved != clean { | |
| 60 | 65 | paths = append(paths, resolved) | |
| 61 | 66 | } | |
| 62 | 67 | } | |
| @@ -64,6 +69,24 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||
| 64 | 69 | return slices.Compact(paths) | |
| 65 | 70 | } | |
| 66 | 71 | ||
| 72 | + | // resolveExisting resolves symlinks in the longest existing ancestor of p | |
| 73 | + | // and re-appends the missing tail, so a path created after startup under | |
| 74 | + | // a symlinked parent (for example /home -> /var/home) is still protected | |
| 75 | + | // in its resolved form. | |
| 76 | + | func resolveExisting(p string) string { | |
| 77 | + | tail := "" | |
| 78 | + | for dir := p; ; dir = filepath.Dir(dir) { | |
| 79 | + | if resolved, err := filepath.EvalSymlinks(dir); err == nil { | |
| 80 | + | return filepath.Join(resolved, tail) | |
| 81 | + | } | |
| 82 | + | parent := filepath.Dir(dir) | |
| 83 | + | if parent == dir { | |
| 84 | + | return p | |
| 85 | + | } | |
| 86 | + | tail = filepath.Join(filepath.Base(dir), tail) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 67 | 90 | // subPath joins elems under base, or returns "" when base is empty. | |
| 68 | 91 | func subPath(base string, elems ...string) string { | |
| 69 | 92 | if base == "" { | |
| @@ -78,19 +101,34 @@ func homePath(home string, elems ...string) string { | |||
| 78 | 101 | return subPath(home, elems...) | |
| 79 | 102 | } | |
| 80 | 103 | ||
| 104 | + | // configuredGordonPaths returns the config file, the runtime socket in use, | |
| 105 | + | // and every file or directory the config points Gordon at. | |
| 106 | + | func configuredGordonPaths(si *serviceInit) []string { | |
| 107 | + | cfg := si.cfg | |
| 108 | + | paths := []string{ | |
| 109 | + | cfg.Server.TLSCertFile, | |
| 110 | + | cfg.Server.TLSKeyFile, | |
| 111 | + | resolveLogFilePath(cfg), | |
| 112 | + | cfg.Logging.ContainerLogs.Dir, | |
| 113 | + | cfg.Logging.AccessLog.FilePath, | |
| 114 | + | cfg.Backups.StorageDir, | |
| 115 | + | cfg.Backups.Databases.StorageDir, | |
| 116 | + | } | |
| 117 | + | if si.v != nil { | |
| 118 | + | paths = append(paths, si.v.ConfigFileUsed(), resolveRuntimeConfig(si.v.GetString("server.runtime"))) | |
| 119 | + | } | |
| 120 | + | return paths | |
| 121 | + | } | |
| 122 | + | ||
| 81 | 123 | // buildAppRuntimeSafety opens the host secret file store and builds the | |
| 82 | - | // runtime guard applied to every app container create. | |
| 124 | + | // runtime guard applied to every app container create. The store keeps one | |
| 125 | + | // directory handle open for the daemon's lifetime. | |
| 83 | 126 | func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) { | |
| 84 | 127 | store, err := secretfiles.NewStore(dataDir) | |
| 85 | 128 | if err != nil { | |
| 86 | 129 | return nil, domain.RuntimeGuard{}, err | |
| 87 | 130 | } | |
| 88 | 131 | home, _ := os.UserHomeDir() | |
| 89 | - | var configFile, runtimeSocket string | |
| 90 | - | if si.v != nil { | |
| 91 | - | configFile = si.v.ConfigFileUsed() | |
| 92 | - | runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime")) | |
| 93 | - | } | |
| 94 | - | paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home) | |
| 132 | + | paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home) | |
| 95 | 133 | return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil | |
| 96 | 134 | } | |
| @@ -12,10 +12,11 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // protectedRuntimePaths lists the host paths no app bind may equal, lie | 14 | // protectedRuntimePaths lists the host paths no app bind may equal, lie | ||
| 15 | - | // under, or contain: Gordon's data and config, runtime sockets, Gordon's | 15 | + | // under, or contain: Gordon's data, config, and configured files, runtime |
| 16 | - | // runtime credentials, and key stores. Empty entries are skipped; each | 16 | + | // sockets and storage, Gordon's runtime credentials, and key stores. |
| 17 | - | // entry is cleaned and its symlink-resolved form is added when it differs. | 17 | + | // Empty entries are skipped; each entry is made absolute and its |
| 18 | - | func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string { | 18 | + | // symlink-resolved form is added when it differs. |
| 19 | + | func protectedRuntimePaths(dataDir string, gordonPaths []string, getenv func(string) string, home string) []string { | |||
| 19 | runtimeDir := getenv("XDG_RUNTIME_DIR") | 20 | runtimeDir := getenv("XDG_RUNTIME_DIR") | ||
| 20 | configHome := getenv("XDG_CONFIG_HOME") | 21 | configHome := getenv("XDG_CONFIG_HOME") | ||
| 21 | if configHome == "" { | 22 | if configHome == "" { | ||
| @@ -25,9 +26,8 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||||
| 25 | if strings.Contains(dockerHost, "://") { | 26 | if strings.Contains(dockerHost, "://") { | ||
| 26 | dockerHost = "" | 27 | dockerHost = "" | ||
| 27 | } | 28 | } | ||
| 28 | - | raw := []string{ | 29 | + | raw := append([]string{ |
| 29 | dataDir, | 30 | dataDir, | ||
| 30 | - | configFile, | |||
| 31 | "/var/run/docker.sock", | 31 | "/var/run/docker.sock", | ||
| 32 | "/run/docker.sock", | 32 | "/run/docker.sock", | ||
| 33 | "/run/podman/podman.sock", | 33 | "/run/podman/podman.sock", | ||
| @@ -36,14 +36,19 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||||
| 36 | subPath(runtimeDir, "gordon"), | 36 | subPath(runtimeDir, "gordon"), | ||
| 37 | homePath(home, ".gordon", "run"), | 37 | homePath(home, ".gordon", "run"), | ||
| 38 | dockerHost, | 38 | dockerHost, | ||
| 39 | - | runtimeSocket, | 39 | + | homePath(home, ".local", "share", "containers"), |
| 40 | + | homePath(home, ".local", "share", "docker"), | |||
| 41 | + | subPath(configHome, "containers"), | |||
| 42 | + | homePath(home, ".docker"), | |||
| 43 | + | subPath(configHome, "systemd", "user"), | |||
| 44 | + | getenv("GORDON_CLOUDFLARE_API_TOKEN_FILE"), | |||
| 40 | getenv("GNUPGHOME"), | 45 | getenv("GNUPGHOME"), | ||
| 41 | homePath(home, ".gnupg"), | 46 | homePath(home, ".gnupg"), | ||
| 42 | getenv("PASSWORD_STORE_DIR"), | 47 | getenv("PASSWORD_STORE_DIR"), | ||
| 43 | homePath(home, ".password-store"), | 48 | homePath(home, ".password-store"), | ||
| 44 | getenv("SOPS_AGE_KEY_FILE"), | 49 | getenv("SOPS_AGE_KEY_FILE"), | ||
| 45 | subPath(configHome, "sops", "age"), | 50 | subPath(configHome, "sops", "age"), | ||
| 46 | - | } | 51 | + | }, gordonPaths...) |
| 47 | var paths []string | 52 | var paths []string | ||
| 48 | for _, p := range raw { | 53 | for _, p := range raw { | ||
| 49 | if p == "" { | 54 | if p == "" { | ||
| @@ -56,7 +61,7 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||||
| 56 | continue | 61 | continue | ||
| 57 | } | 62 | } | ||
| 58 | paths = append(paths, clean) | 63 | paths = append(paths, clean) | ||
| 59 | - | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | 64 | + | if resolved := resolveExisting(clean); resolved != clean { |
| 60 | paths = append(paths, resolved) | 65 | paths = append(paths, resolved) | ||
| 61 | } | 66 | } | ||
| 62 | } | 67 | } | ||
| @@ -64,6 +69,24 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun | |||||
| 64 | return slices.Compact(paths) | 69 | return slices.Compact(paths) | ||
| 65 | } | 70 | } | ||
| 66 | 71 | ||||
| 72 | + | // resolveExisting resolves symlinks in the longest existing ancestor of p | |||
| 73 | + | // and re-appends the missing tail, so a path created after startup under | |||
| 74 | + | // a symlinked parent (for example /home -> /var/home) is still protected | |||
| 75 | + | // in its resolved form. | |||
| 76 | + | func resolveExisting(p string) string { | |||
| 77 | + | tail := "" | |||
| 78 | + | for dir := p; ; dir = filepath.Dir(dir) { | |||
| 79 | + | if resolved, err := filepath.EvalSymlinks(dir); err == nil { | |||
| 80 | + | return filepath.Join(resolved, tail) | |||
| 81 | + | } | |||
| 82 | + | parent := filepath.Dir(dir) | |||
| 83 | + | if parent == dir { | |||
| 84 | + | return p | |||
| 85 | + | } | |||
| 86 | + | tail = filepath.Join(filepath.Base(dir), tail) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 67 | // subPath joins elems under base, or returns "" when base is empty. | 90 | // subPath joins elems under base, or returns "" when base is empty. | ||
| 68 | func subPath(base string, elems ...string) string { | 91 | func subPath(base string, elems ...string) string { | ||
| 69 | if base == "" { | 92 | if base == "" { | ||
| @@ -78,19 +101,34 @@ func homePath(home string, elems ...string) string { | |||||
| 78 | return subPath(home, elems...) | 101 | return subPath(home, elems...) | ||
| 79 | } | 102 | } | ||
| 80 | 103 | ||||
| 104 | + | // configuredGordonPaths returns the config file, the runtime socket in use, | |||
| 105 | + | // and every file or directory the config points Gordon at. | |||
| 106 | + | func configuredGordonPaths(si *serviceInit) []string { | |||
| 107 | + | cfg := si.cfg | |||
| 108 | + | paths := []string{ | |||
| 109 | + | cfg.Server.TLSCertFile, | |||
| 110 | + | cfg.Server.TLSKeyFile, | |||
| 111 | + | resolveLogFilePath(cfg), | |||
| 112 | + | cfg.Logging.ContainerLogs.Dir, | |||
| 113 | + | cfg.Logging.AccessLog.FilePath, | |||
| 114 | + | cfg.Backups.StorageDir, | |||
| 115 | + | cfg.Backups.Databases.StorageDir, | |||
| 116 | + | } | |||
| 117 | + | if si.v != nil { | |||
| 118 | + | paths = append(paths, si.v.ConfigFileUsed(), resolveRuntimeConfig(si.v.GetString("server.runtime"))) | |||
| 119 | + | } | |||
| 120 | + | return paths | |||
| 121 | + | } | |||
| 122 | + | ||||
| 81 | // buildAppRuntimeSafety opens the host secret file store and builds the | 123 | // buildAppRuntimeSafety opens the host secret file store and builds the | ||
| 82 | - | // runtime guard applied to every app container create. | 124 | + | // runtime guard applied to every app container create. The store keeps one |
| 125 | + | // directory handle open for the daemon's lifetime. | |||
| 83 | func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) { | 126 | func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) { | ||
| 84 | store, err := secretfiles.NewStore(dataDir) | 127 | store, err := secretfiles.NewStore(dataDir) | ||
| 85 | if err != nil { | 128 | if err != nil { | ||
| 86 | return nil, domain.RuntimeGuard{}, err | 129 | return nil, domain.RuntimeGuard{}, err | ||
| 87 | } | 130 | } | ||
| 88 | home, _ := os.UserHomeDir() | 131 | home, _ := os.UserHomeDir() | ||
| 89 | - | var configFile, runtimeSocket string | 132 | + | paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home) |
| 90 | - | if si.v != nil { | |||
| 91 | - | configFile = si.v.ConfigFileUsed() | |||
| 92 | - | runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime")) | |||
| 93 | - | } | |||
| 94 | - | paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home) | |||
| 95 | return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil | 133 | return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil | ||
| 96 | } | 134 | } | ||
M internal/app/runtime_guard_test.go +32 -3
| @@ -24,8 +24,10 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||
| 24 | 24 | "PASSWORD_STORE_DIR": "/keys/pass", | |
| 25 | 25 | "SOPS_AGE_KEY_FILE": "/keys/age.txt", | |
| 26 | 26 | "XDG_CONFIG_HOME": "/cfg", | |
| 27 | + | ||
| 28 | + | "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token", | |
| 27 | 29 | }) | |
| 28 | - | got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home) | |
| 30 | + | got := protectedRuntimePaths("/data/gordon", []string{"/etc/gordon/gordon.toml", "/custom/podman.sock", ""}, env, home) | |
| 29 | 31 | ||
| 30 | 32 | for _, want := range []string{ | |
| 31 | 33 | "/data/gordon", | |
| @@ -45,6 +47,12 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||
| 45 | 47 | filepath.Join(home, ".password-store"), | |
| 46 | 48 | "/keys/age.txt", | |
| 47 | 49 | "/cfg/sops/age", | |
| 50 | + | "/keys/cf-token", | |
| 51 | + | filepath.Join(home, ".local", "share", "containers"), | |
| 52 | + | filepath.Join(home, ".local", "share", "docker"), | |
| 53 | + | "/cfg/containers", | |
| 54 | + | filepath.Join(home, ".docker"), | |
| 55 | + | "/cfg/systemd/user", | |
| 48 | 56 | } { | |
| 49 | 57 | assert.Contains(t, got, want) | |
| 50 | 58 | } | |
| @@ -52,7 +60,7 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | 62 | func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) { | |
| 55 | - | got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") | |
| 63 | + | got := protectedRuntimePaths("/data", nil, fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") | |
| 56 | 64 | assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got) | |
| 57 | 65 | } | |
| 58 | 66 | ||
| @@ -65,9 +73,30 @@ func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) { | |||
| 65 | 73 | realResolved, err := filepath.EvalSymlinks(real) | |
| 66 | 74 | require.NoError(t, err) | |
| 67 | 75 | ||
| 68 | - | got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "") | |
| 76 | + | got := protectedRuntimePaths(link, nil, fakeEnv(nil), "") | |
| 69 | 77 | assert.Contains(t, got, link) | |
| 70 | 78 | assert.Contains(t, got, realResolved) | |
| 79 | + | ||
| 80 | + | // A protected path that does not exist yet under a symlinked parent | |
| 81 | + | // is protected in its resolved form too. | |
| 82 | + | missing := filepath.Join(link, "later", "store") | |
| 83 | + | got = protectedRuntimePaths("/data", []string{missing}, fakeEnv(nil), "") | |
| 84 | + | assert.Contains(t, got, missing) | |
| 85 | + | assert.Contains(t, got, filepath.Join(realResolved, "later", "store")) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestConfiguredGordonPaths(t *testing.T) { | |
| 89 | + | si := &serviceInit{} | |
| 90 | + | si.cfg.Server.TLSKeyFile = "/tls/key.pem" | |
| 91 | + | si.cfg.Server.DataDir = "/data" | |
| 92 | + | si.cfg.Logging.AccessLog.FilePath = "/logs/access.log" | |
| 93 | + | si.cfg.Backups.Databases.StorageDir = "/backups" | |
| 94 | + | ||
| 95 | + | got := configuredGordonPaths(si) | |
| 96 | + | assert.Contains(t, got, "/tls/key.pem") | |
| 97 | + | assert.Contains(t, got, "/data/logs/gordon.log") | |
| 98 | + | assert.Contains(t, got, "/logs/access.log") | |
| 99 | + | assert.Contains(t, got, "/backups") | |
| 71 | 100 | } | |
| 72 | 101 | ||
| 73 | 102 | func TestBuildAppRuntimeSafety(t *testing.T) { | |
| @@ -24,8 +24,10 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||||
| 24 | "PASSWORD_STORE_DIR": "/keys/pass", | 24 | "PASSWORD_STORE_DIR": "/keys/pass", | ||
| 25 | "SOPS_AGE_KEY_FILE": "/keys/age.txt", | 25 | "SOPS_AGE_KEY_FILE": "/keys/age.txt", | ||
| 26 | "XDG_CONFIG_HOME": "/cfg", | 26 | "XDG_CONFIG_HOME": "/cfg", | ||
| 27 | + | ||||
| 28 | + | "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token", | |||
| 27 | }) | 29 | }) | ||
| 28 | - | got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home) | 30 | + | got := protectedRuntimePaths("/data/gordon", []string{"/etc/gordon/gordon.toml", "/custom/podman.sock", ""}, env, home) |
| 29 | 31 | ||||
| 30 | for _, want := range []string{ | 32 | for _, want := range []string{ | ||
| 31 | "/data/gordon", | 33 | "/data/gordon", | ||
| @@ -45,6 +47,12 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||||
| 45 | filepath.Join(home, ".password-store"), | 47 | filepath.Join(home, ".password-store"), | ||
| 46 | "/keys/age.txt", | 48 | "/keys/age.txt", | ||
| 47 | "/cfg/sops/age", | 49 | "/cfg/sops/age", | ||
| 50 | + | "/keys/cf-token", | |||
| 51 | + | filepath.Join(home, ".local", "share", "containers"), | |||
| 52 | + | filepath.Join(home, ".local", "share", "docker"), | |||
| 53 | + | "/cfg/containers", | |||
| 54 | + | filepath.Join(home, ".docker"), | |||
| 55 | + | "/cfg/systemd/user", | |||
| 48 | } { | 56 | } { | ||
| 49 | assert.Contains(t, got, want) | 57 | assert.Contains(t, got, want) | ||
| 50 | } | 58 | } | ||
| @@ -52,7 +60,7 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) { | 62 | func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) { | ||
| 55 | - | got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") | 63 | + | got := protectedRuntimePaths("/data", nil, fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") |
| 56 | assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got) | 64 | assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got) | ||
| 57 | } | 65 | } | ||
| 58 | 66 | ||||
| @@ -65,9 +73,30 @@ func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) { | |||||
| 65 | realResolved, err := filepath.EvalSymlinks(real) | 73 | realResolved, err := filepath.EvalSymlinks(real) | ||
| 66 | require.NoError(t, err) | 74 | require.NoError(t, err) | ||
| 67 | 75 | ||||
| 68 | - | got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "") | 76 | + | got := protectedRuntimePaths(link, nil, fakeEnv(nil), "") |
| 69 | assert.Contains(t, got, link) | 77 | assert.Contains(t, got, link) | ||
| 70 | assert.Contains(t, got, realResolved) | 78 | assert.Contains(t, got, realResolved) | ||
| 79 | + | ||||
| 80 | + | // A protected path that does not exist yet under a symlinked parent | |||
| 81 | + | // is protected in its resolved form too. | |||
| 82 | + | missing := filepath.Join(link, "later", "store") | |||
| 83 | + | got = protectedRuntimePaths("/data", []string{missing}, fakeEnv(nil), "") | |||
| 84 | + | assert.Contains(t, got, missing) | |||
| 85 | + | assert.Contains(t, got, filepath.Join(realResolved, "later", "store")) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestConfiguredGordonPaths(t *testing.T) { | |||
| 89 | + | si := &serviceInit{} | |||
| 90 | + | si.cfg.Server.TLSKeyFile = "/tls/key.pem" | |||
| 91 | + | si.cfg.Server.DataDir = "/data" | |||
| 92 | + | si.cfg.Logging.AccessLog.FilePath = "/logs/access.log" | |||
| 93 | + | si.cfg.Backups.Databases.StorageDir = "/backups" | |||
| 94 | + | ||||
| 95 | + | got := configuredGordonPaths(si) | |||
| 96 | + | assert.Contains(t, got, "/tls/key.pem") | |||
| 97 | + | assert.Contains(t, got, "/data/logs/gordon.log") | |||
| 98 | + | assert.Contains(t, got, "/logs/access.log") | |||
| 99 | + | assert.Contains(t, got, "/backups") | |||
| 71 | } | 100 | } | ||
| 72 | 101 | ||||
| 73 | func TestBuildAppRuntimeSafety(t *testing.T) { | 102 | func TestBuildAppRuntimeSafety(t *testing.T) { | ||
M internal/usecase/deployment/lifecycle.go +3 -3
| @@ -301,7 +301,7 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string, | |||
| 301 | 301 | } | |
| 302 | 302 | // Fail closed before any runtime mutation if a bind's policy was | |
| 303 | 303 | // revoked or became invalid since ACTIVE was published. | |
| 304 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |
| 304 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { | |
| 305 | 305 | return fail(err.Error()) | |
| 306 | 306 | } | |
| 307 | 307 | // A device grant revoked since ACTIVE was published must fail | |
| @@ -608,7 +608,7 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri | |||
| 608 | 608 | if eff.Container != "" { | |
| 609 | 609 | // Fail closed before any runtime mutation if a bind's policy was | |
| 610 | 610 | // revoked or became invalid since ACTIVE was published. | |
| 611 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |
| 611 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { | |
| 612 | 612 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | |
| 613 | 613 | return | |
| 614 | 614 | } | |
| @@ -668,7 +668,7 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef | |||
| 668 | 668 | if err != nil { | |
| 669 | 669 | return failedServiceResult(eff.Container, err), err | |
| 670 | 670 | } | |
| 671 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |
| 671 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { | |
| 672 | 672 | return failedServiceResult(eff.Container, err), err | |
| 673 | 673 | } | |
| 674 | 674 | // Revoked device grants fail before any runtime mutation. | |
| @@ -301,7 +301,7 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string, | |||||
| 301 | } | 301 | } | ||
| 302 | // Fail closed before any runtime mutation if a bind's policy was | 302 | // Fail closed before any runtime mutation if a bind's policy was | ||
| 303 | // revoked or became invalid since ACTIVE was published. | 303 | // revoked or became invalid since ACTIVE was published. | ||
| 304 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 304 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { |
| 305 | return fail(err.Error()) | 305 | return fail(err.Error()) | ||
| 306 | } | 306 | } | ||
| 307 | // A device grant revoked since ACTIVE was published must fail | 307 | // A device grant revoked since ACTIVE was published must fail | ||
| @@ -608,7 +608,7 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri | |||||
| 608 | if eff.Container != "" { | 608 | if eff.Container != "" { | ||
| 609 | // Fail closed before any runtime mutation if a bind's policy was | 609 | // Fail closed before any runtime mutation if a bind's policy was | ||
| 610 | // revoked or became invalid since ACTIVE was published. | 610 | // revoked or became invalid since ACTIVE was published. | ||
| 611 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 611 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { |
| 612 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 612 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 613 | return | 613 | return | ||
| 614 | } | 614 | } | ||
| @@ -668,7 +668,7 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef | |||||
| 668 | if err != nil { | 668 | if err != nil { | ||
| 669 | return failedServiceResult(eff.Container, err), err | 669 | return failedServiceResult(eff.Container, err), err | ||
| 670 | } | 670 | } | ||
| 671 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 671 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { |
| 672 | return failedServiceResult(eff.Container, err), err | 672 | return failedServiceResult(eff.Container, err), err | ||
| 673 | } | 673 | } | ||
| 674 | // Revoked device grants fail before any runtime mutation. | 674 | // Revoked device grants fail before any runtime mutation. | ||
M internal/usecase/deployment/monitor.go +2 -2
| @@ -635,7 +635,7 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string, | |||
| 635 | 635 | func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) { | |
| 636 | 636 | // Fail closed before mutating the runtime if a bind's policy was | |
| 637 | 637 | // revoked or became invalid since ACTIVE was published. | |
| 638 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |
| 638 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { | |
| 639 | 639 | return false, err | |
| 640 | 640 | } | |
| 641 | 641 | // Revoked device grants fail before any runtime mutation. | |
| @@ -691,7 +691,7 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef | |||
| 691 | 691 | } | |
| 692 | 692 | // Fail closed before mutating the runtime if a bind's policy was | |
| 693 | 693 | // revoked or became invalid since ACTIVE was published. | |
| 694 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |
| 694 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { | |
| 695 | 695 | return false, false, err | |
| 696 | 696 | } | |
| 697 | 697 | // Revoked device grants fail before any runtime mutation. | |
| @@ -635,7 +635,7 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string, | |||||
| 635 | func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) { | 635 | func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) { | ||
| 636 | // Fail closed before mutating the runtime if a bind's policy was | 636 | // Fail closed before mutating the runtime if a bind's policy was | ||
| 637 | // revoked or became invalid since ACTIVE was published. | 637 | // revoked or became invalid since ACTIVE was published. | ||
| 638 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 638 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { |
| 639 | return false, err | 639 | return false, err | ||
| 640 | } | 640 | } | ||
| 641 | // Revoked device grants fail before any runtime mutation. | 641 | // Revoked device grants fail before any runtime mutation. | ||
| @@ -691,7 +691,7 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef | |||||
| 691 | } | 691 | } | ||
| 692 | // Fail closed before mutating the runtime if a bind's policy was | 692 | // Fail closed before mutating the runtime if a bind's policy was | ||
| 693 | // revoked or became invalid since ACTIVE was published. | 693 | // revoked or became invalid since ACTIVE was published. | ||
| 694 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 694 | + | if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil { |
| 695 | return false, false, err | 695 | return false, false, err | ||
| 696 | } | 696 | } | ||
| 697 | // Revoked device grants fail before any runtime mutation. | 697 | // Revoked device grants fail before any runtime mutation. | ||