detached 64d925b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
174174
175175 ## Runtime guard
176176
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:
178178
179179 - network mode `host`, `container:<id>`, or `ns:<path>`;
180180 - 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
183183 Protected paths:
184184
185185 - 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;
186187 - 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`;
188191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
189192
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.
191196
192197 ## Process memory protection
193198
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:
195202
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.
197206
198207 ## Hardened systemd user unit
199208
@@ -233,7 +242,7 @@ Gordon v3 protects against:
233242
234243 - file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
235244 - 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.
237246
238247 Gordon v3 does not protect against:
239248
@@ -174,7 +174,7 @@ Use `strict` for images designed to write only to mounted volumes and run withou
174 174
175 ## Runtime guard175 ## 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 protection197 ## 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 unit207 ## 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 {
539539 // Split from isMappedPreflightError to keep its complexity within budget.
540540 func isPreflightErrorRest(err error) bool {
541541 return errors.Is(err, domain.ErrRuntimeUnsupported) ||
542+ errors.Is(err, domain.ErrRuntimeGuard) ||
542543 errors.Is(err, domain.ErrAppNotFound) ||
543544 errors.Is(err, domain.ErrAppReservationConflict) ||
544545 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) {
682682 assert.Equal(t, "device-policy-violation", envelope.Error)
683683 }
684684
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) {
686689 appSvc := inmocks.NewMockAppService(t)
687690 handler := appsTestHandler(t, appSvc)
688691
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+ }
689696 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"),
692699 ).Once()
693700
694701 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 (
1313
1414 "github.com/bnema/zerowrap"
1515
16+ "github.com/bnema/gordon/internal/adapters/out/childenv"
1617 "github.com/bnema/gordon/internal/domain"
1718 )
1819
@@ -41,6 +42,7 @@ func (s *Store) SetSecret(ctx context.Context, path, value string) error {
4142 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
4243 defer cancel()
4344 cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below
45+ cmd.Env = childenv.Env()
4446 cmd.Stdin = strings.NewReader(value)
4547 output, err := cmd.CombinedOutput()
4648 if err != nil {
@@ -58,6 +60,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error {
5860 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
5961 defer cancel()
6062 cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below
63+ cmd.Env = childenv.Env()
6164 output, err := cmd.CombinedOutput()
6265 if err != nil {
6366 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 below44 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 below62 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 (
1111
1212 "github.com/bnema/zerowrap"
1313
14+ "github.com/bnema/gordon/internal/adapters/out/childenv"
1415 "github.com/bnema/gordon/internal/domain"
1516 )
1617
@@ -81,6 +82,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
8182 defer cancel()
8283
8384 cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths
85+ cmd.Env = childenv.Env()
8486 output, err := cmd.Output()
8587 if err != nil {
8688 if exitError, ok := err.(*exec.ExitError); ok {
@@ -110,5 +112,6 @@ func (p *PassProvider) IsAvailable() bool {
110112 ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
111113 defer cancel()
112114 cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input
115+ cmd.Env = childenv.Env()
113116 return cmd.Run() == nil
114117 }
@@ -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 paths84 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 input114 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() == nil116 return cmd.Run() == nil
114 }117 }
M internal/adapters/out/secrets/sops.go +3 -0
@@ -10,6 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/zerowrap"
13+
14+ "github.com/bnema/gordon/internal/adapters/out/childenv"
1315 )
1416
1517 // 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
109111 // Use sops -d --extract to get specific key
110112 // #nosec G204 - filePath and keyPath are validated above
111113 cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
114+ cmd.Env = childenv.Env()
112115 output, err := cmd.Output()
113116 if err != nil {
114117 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 key111 // Use sops -d --extract to get specific key
110 // #nosec G204 - filePath and keyPath are validated above112 // #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 (
1414
1515 "github.com/bnema/zerowrap"
1616
17+ "github.com/bnema/gordon/internal/adapters/out/childenv"
1718 "github.com/bnema/gordon/internal/domain"
1819 )
1920
@@ -338,6 +339,7 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
338339
339340 // List all entries under the token path
340341 cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant
342+ cmd.Env = childenv.Env()
341343 output, err := cmd.Output()
342344 if err != nil {
343345 // If the path doesn't exist, return empty list
@@ -484,6 +486,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
484486 // Remove JWT
485487 // #nosec G204 -- command and args are fixed; subject is treated as data only.
486488 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
489+ cmd.Env = childenv.Env()
487490 if err := cmd.Run(); err != nil {
488491 return fmt.Errorf("failed to delete token: %w", err)
489492 }
@@ -491,6 +494,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
491494 // Remove metadata
492495 // #nosec G204 -- command and args are fixed; subject is treated as data only.
493496 cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
497+ cmd.Env = childenv.Env()
494498 if err := cmd.Run(); err != nil {
495499 s.log.Warn().Err(err).Msg("failed to delete token metadata")
496500 }
@@ -512,6 +516,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
512516 // passInsert inserts a value into pass.
513517 func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
514518 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()
515520 cmd.Stdin = strings.NewReader(value)
516521 output, err := cmd.CombinedOutput()
517522 if err != nil {
@@ -523,6 +528,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
523528 // passShow retrieves a value from pass.
524529 func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
525530 cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects
531+ cmd.Env = childenv.Env()
526532 output, err := cmd.Output()
527533 if err != nil {
528534 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 path340 // List all entries under the token path
340 cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant341 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 list345 // 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 JWT486 // 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 metadata494 // 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 subjects518 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 subjects530 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 (
1212 )
1313
1414 // 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 {
1920 runtimeDir := getenv("XDG_RUNTIME_DIR")
2021 configHome := getenv("XDG_CONFIG_HOME")
2122 if configHome == "" {
@@ -25,9 +26,8 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun
2526 if strings.Contains(dockerHost, "://") {
2627 dockerHost = ""
2728 }
28- raw := []string{
29+ raw := append([]string{
2930 dataDir,
30- configFile,
3131 "/var/run/docker.sock",
3232 "/run/docker.sock",
3333 "/run/podman/podman.sock",
@@ -36,14 +36,19 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun
3636 subPath(runtimeDir, "gordon"),
3737 homePath(home, ".gordon", "run"),
3838 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"),
4045 getenv("GNUPGHOME"),
4146 homePath(home, ".gnupg"),
4247 getenv("PASSWORD_STORE_DIR"),
4348 homePath(home, ".password-store"),
4449 getenv("SOPS_AGE_KEY_FILE"),
4550 subPath(configHome, "sops", "age"),
46- }
51+ }, gordonPaths...)
4752 var paths []string
4853 for _, p := range raw {
4954 if p == "" {
@@ -56,7 +61,7 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun
5661 continue
5762 }
5863 paths = append(paths, clean)
59- if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
64+ if resolved := resolveExisting(clean); resolved != clean {
6065 paths = append(paths, resolved)
6166 }
6267 }
@@ -64,6 +69,24 @@ func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv fun
6469 return slices.Compact(paths)
6570 }
6671
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+
6790 // subPath joins elems under base, or returns "" when base is empty.
6891 func subPath(base string, elems ...string) string {
6992 if base == "" {
@@ -78,19 +101,34 @@ func homePath(home string, elems ...string) string {
78101 return subPath(home, elems...)
79102 }
80103
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+
81123 // 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.
83126 func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
84127 store, err := secretfiles.NewStore(dataDir)
85128 if err != nil {
86129 return nil, domain.RuntimeGuard{}, err
87130 }
88131 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)
95133 return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil
96134 }
@@ -12,10 +12,11 @@ import (
12 )12 )
13 13
14 // protectedRuntimePaths lists the host paths no app bind may equal, lie14 // protectedRuntimePaths lists the host paths no app bind may equal, lie
15-// under, or contain: Gordon's data and config, runtime sockets, Gordon's15+// under, or contain: Gordon's data, config, and configured files, runtime
16-// runtime credentials, and key stores. Empty entries are skipped; each16+// 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 []string52 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 continue61 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 the123 // 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{}, err129 return nil, domain.RuntimeGuard{}, err
87 }130 }
88 home, _ := os.UserHomeDir()131 home, _ := os.UserHomeDir()
89- var configFile, runtimeSocket string132+ 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()}, nil133 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) {
2424 "PASSWORD_STORE_DIR": "/keys/pass",
2525 "SOPS_AGE_KEY_FILE": "/keys/age.txt",
2626 "XDG_CONFIG_HOME": "/cfg",
27+
28+ "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token",
2729 })
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)
2931
3032 for _, want := range []string{
3133 "/data/gordon",
@@ -45,6 +47,12 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) {
4547 filepath.Join(home, ".password-store"),
4648 "/keys/age.txt",
4749 "/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",
4856 } {
4957 assert.Contains(t, got, want)
5058 }
@@ -52,7 +60,7 @@ func TestProtectedRuntimePaths_AllSources(t *testing.T) {
5260 }
5361
5462 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"}), "")
5664 assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got)
5765 }
5866
@@ -65,9 +73,30 @@ func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
6573 realResolved, err := filepath.EvalSymlinks(real)
6674 require.NoError(t, err)
6775
68- got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "")
76+ got := protectedRuntimePaths(link, nil, fakeEnv(nil), "")
6977 assert.Contains(t, got, link)
7078 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")
71100 }
72101
73102 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,
301301 }
302302 // Fail closed before any runtime mutation if a bind's policy was
303303 // 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 {
305305 return fail(err.Error())
306306 }
307307 // 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
608608 if eff.Container != "" {
609609 // Fail closed before any runtime mutation if a bind's policy was
610610 // 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 {
612612 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
613613 return
614614 }
@@ -668,7 +668,7 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef
668668 if err != nil {
669669 return failedServiceResult(eff.Container, err), err
670670 }
671- if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
671+ if _, err := s.resolveGuardedBinds(app, eff.Spec); err != nil {
672672 return failedServiceResult(eff.Container, err), err
673673 }
674674 // 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 was302 // 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 fail307 // 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 was609 // 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 return613 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), err669 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), err672 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,
635635 func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) {
636636 // Fail closed before mutating the runtime if a bind's policy was
637637 // 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 {
639639 return false, err
640640 }
641641 // Revoked device grants fail before any runtime mutation.
@@ -691,7 +691,7 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef
691691 }
692692 // Fail closed before mutating the runtime if a bind's policy was
693693 // 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 {
695695 return false, false, err
696696 }
697697 // 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 was636 // 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, err639 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 was692 // 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, err695 return false, false, err
696 }696 }
697 // Revoked device grants fail before any runtime mutation.697 // Revoked device grants fail before any runtime mutation.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close