detached 3a9dcae

Merge pull request 'feat(security): v3 secrets hardening' (#14) from feat/v3-secrets-hardening into main

commit
3a9dcae8f1f05bcdc7138392a93fc8746bf46c82
author
bnema · · 2026-10-10 01:33 UTC
merge of
7ec1d0a + beaf875 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
43 files · +2631 -57
M .forgejo/workflows/ci.yml +1 -1
@@ -20,7 +20,7 @@ jobs:
2020 cache: true
2121 - uses: golangci/golangci-lint-action@v9
2222 with:
23- version: v2.13.1
23+ version: v2.14.0
2424
2525 test:
2626 name: Test
@@ -20,7 +20,7 @@ jobs:
20 cache: true20 cache: true
21 - uses: golangci/golangci-lint-action@v921 - uses: golangci/golangci-lint-action@v9
22 with:22 with:
23- version: v2.13.123+ version: v2.14.0
24 24
25 test:25 test:
26 name: Test26 name: Test
M .github/workflows/ci.yml +1 -1
@@ -63,7 +63,7 @@ jobs:
6363 - name: Run golangci-lint
6464 uses: golangci/golangci-lint-action@v9
6565 with:
66- version: v2.13.1
66+ version: v2.14.0
6767
6868 test:
6969 name: Test
@@ -63,7 +63,7 @@ jobs:
63 - name: Run golangci-lint63 - name: Run golangci-lint
64 uses: golangci/golangci-lint-action@v964 uses: golangci/golangci-lint-action@v9
65 with:65 with:
66- version: v2.13.166+ version: v2.14.0
67 67
68 test:68 test:
69 name: Test69 name: Test
M .mockery.yaml +1 -0
@@ -17,6 +17,7 @@ packages:
1717 EventSubscriber:
1818 EventHandler:
1919 SecretProvider:
20+ SecretFileStore:
2021 ContainerLogWriter:
2122 Metrics:
2223 LogExporter:
@@ -17,6 +17,7 @@ packages:
17 EventSubscriber:17 EventSubscriber:
18 EventHandler:18 EventHandler:
19 SecretProvider:19 SecretProvider:
20+ SecretFileStore:
20 ContainerLogWriter:21 ContainerLogWriter:
21 Metrics:22 Metrics:
22 LogExporter:23 LogExporter:
M docs/config/apps.md +7 -1
@@ -52,7 +52,9 @@ port = 8080 # required; no host or tls
5252 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name
5353
5454 [services.web.secrets] # ENV name -> secret name (values in pass)
55-DATABASE_URL = "database-url"
55+API_KEY = "api-key" # env delivery
56+DATABASE_URL = { name = "database-url", as = "file" } # /run/secrets/DATABASE_URL
57+DB_PASS = { name = "db-pass", as = "env" } # explicit env delivery
5658
5759 [[services.web.volume]] # 0..n named volumes only
5860 name = "web-data"
@@ -113,6 +115,10 @@ Image registry names and digest syntax are validated during manifest apply, reso
113115 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.
114116 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.
115117 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.
118+- A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected.
119+- `as = "file"` delivers the value as a read-only file (mode `0444`) at `/run/secrets/<ENV>` and sets `<ENV>_FILE=/run/secrets/<ENV>` instead of `<ENV>`. The value never appears in the container environment, `docker inspect`, or `/proc/1/environ`. Use it for images that support the `*_FILE` convention.
120+- With file delivery, `<ENV>_FILE` must not be another secret key or a key in `[env]` or `[services.<name>.env]`, and no bind or volume of the service may mount at or under `/run/secrets`.
121+- Switching a secret between `env` and `file` shows as `service/<name>/secret/<secret>` in diffs and applies on the next deploy. See [Secrets](./secrets.md#secret-files).
116122
117123 ## Volumes and Databases
118124
@@ -52,7 +52,9 @@ port = 8080 # required; no host or tls
52 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name52 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name
53 53
54 [services.web.secrets] # ENV name -> secret name (values in pass)54 [services.web.secrets] # ENV name -> secret name (values in pass)
55-DATABASE_URL = "database-url"55+API_KEY = "api-key" # env delivery
56+DATABASE_URL = { name = "database-url", as = "file" } # /run/secrets/DATABASE_URL
57+DB_PASS = { name = "db-pass", as = "env" } # explicit env delivery
56 58
57 [[services.web.volume]] # 0..n named volumes only59 [[services.web.volume]] # 0..n named volumes only
58 name = "web-data"60 name = "web-data"
@@ -113,6 +115,10 @@ Image registry names and digest syntax are validated during manifest apply, reso
113 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.115 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.
114 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.116 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.
115 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.117 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.
118+- A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected.
119+- `as = "file"` delivers the value as a read-only file (mode `0444`) at `/run/secrets/<ENV>` and sets `<ENV>_FILE=/run/secrets/<ENV>` instead of `<ENV>`. The value never appears in the container environment, `docker inspect`, or `/proc/1/environ`. Use it for images that support the `*_FILE` convention.
120+- With file delivery, `<ENV>_FILE` must not be another secret key or a key in `[env]` or `[services.<name>.env]`, and no bind or volume of the service may mount at or under `/run/secrets`.
121+- Switching a secret between `env` and `file` shows as `service/<name>/secret/<secret>` in diffs and applies on the next deploy. See [Secrets](./secrets.md#secret-files).
116 122
117 ## Volumes and Databases123 ## Volumes and Databases
118 124
M docs/config/secrets.md +19 -0
@@ -117,6 +117,22 @@ gordon apps secrets set blog --service web DATABASE_URL=...
117117
118118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).
119119
120+### Secret files
121+
122+A secret declared with `as = "file"` is delivered as a file instead of an env value:
123+
124+```toml
125+[services.web.secrets]
126+DATABASE_URL = { name = "database-url", as = "file" }
127+```
128+
129+- The container sees a read-only file at `/run/secrets/DATABASE_URL` and the env var `DATABASE_URL_FILE=/run/secrets/DATABASE_URL`.
130+- On the host, Gordon writes one file per secret under `<data_dir>/secrets-run/<app>/<container>/`, with directories `0700` and files `0444`. The files persist so containers restarted by the runtime or after a reboot still find them.
131+- Gordon removes a container's files once the container is confirmed gone, and sweeps files of unknown containers at startup.
132+- Exclude `<data_dir>/secrets-run` from host backups: it holds plain secret values.
133+- Env-delivered secrets stay visible through `docker inspect` and `/proc/1/environ` inside the container. Prefer file delivery for apps that read `*_FILE` variables.
134+- An older Gordon v3 binary ignores file delivery and injects those secrets as env vars again. Its leftover `secrets-run` directories are harmless and are swept by the next start of a version that supports file delivery.
135+
120136 ## Examples
121137
122138 ### Production with Pass
@@ -152,9 +168,12 @@ secrets_backend = "unsafe"
152168 3. **Rotate regularly**: Regenerate tokens and passwords periodically
153169 4. **Least privilege**: Use separate secrets per environment
154170 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
171+6. **File delivery**: Use `as = "file"` for app secrets when the image supports `*_FILE` variables (see [Secret files](#secret-files))
172+7. **Hardening**: See [Security Hardening](./security-hardening.md) for the runtime guard, process protection, and credential scoping
155173
156174 ## Related
157175
158176 - [Authentication](./auth.md)
159177 - [App Manifest](./apps.md)
178+- [Security Hardening](./security-hardening.md)
160179 - [Configuration Overview](./index.md)
@@ -117,6 +117,22 @@ gordon apps secrets set blog --service web DATABASE_URL=...
117 117
118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).
119 119
120+### Secret files
121+
122+A secret declared with `as = "file"` is delivered as a file instead of an env value:
123+
124+```toml
125+[services.web.secrets]
126+DATABASE_URL = { name = "database-url", as = "file" }
127+```
128+
129+- The container sees a read-only file at `/run/secrets/DATABASE_URL` and the env var `DATABASE_URL_FILE=/run/secrets/DATABASE_URL`.
130+- On the host, Gordon writes one file per secret under `<data_dir>/secrets-run/<app>/<container>/`, with directories `0700` and files `0444`. The files persist so containers restarted by the runtime or after a reboot still find them.
131+- Gordon removes a container's files once the container is confirmed gone, and sweeps files of unknown containers at startup.
132+- Exclude `<data_dir>/secrets-run` from host backups: it holds plain secret values.
133+- Env-delivered secrets stay visible through `docker inspect` and `/proc/1/environ` inside the container. Prefer file delivery for apps that read `*_FILE` variables.
134+- An older Gordon v3 binary ignores file delivery and injects those secrets as env vars again. Its leftover `secrets-run` directories are harmless and are swept by the next start of a version that supports file delivery.
135+
120 ## Examples136 ## Examples
121 137
122 ### Production with Pass138 ### Production with Pass
@@ -152,9 +168,12 @@ secrets_backend = "unsafe"
152 3. **Rotate regularly**: Regenerate tokens and passwords periodically168 3. **Rotate regularly**: Regenerate tokens and passwords periodically
153 4. **Least privilege**: Use separate secrets per environment169 4. **Least privilege**: Use separate secrets per environment
154 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security170 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
171+6. **File delivery**: Use `as = "file"` for app secrets when the image supports `*_FILE` variables (see [Secret files](#secret-files))
172+7. **Hardening**: See [Security Hardening](./security-hardening.md) for the runtime guard, process protection, and credential scoping
155 173
156 ## Related174 ## Related
157 175
158 - [Authentication](./auth.md)176 - [Authentication](./auth.md)
159 - [App Manifest](./apps.md)177 - [App Manifest](./apps.md)
178+- [Security Hardening](./security-hardening.md)
160 - [Configuration Overview](./index.md)179 - [Configuration Overview](./index.md)
M docs/config/security-hardening.md +81 -0
@@ -172,9 +172,90 @@ security_profile = "compat" # or "strict"
172172
173173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
174174
175+## Runtime guard
176+
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`. The guard refuses:
178+
179+- network mode `host`, `container:<id>`, or `ns:<path>`;
180+- a volume name containing `/` or `\`, which the runtime would read as a host path;
181+- a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.
182+
183+Protected paths:
184+
185+- the data directory and the loaded config file;
186+- files and directories set in the config: `server.tls_key_file` and the backup `storage_dir` values;
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;
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`;
191+- key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192+
193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194+
195+Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under `<data_dir>/logs`; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in `$HOME`, the log file itself stays protected and no read-only exception applies.
196+
197+The only other 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.
198+
199+## Process memory protection
200+
201+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.
202+
203+Limits:
204+
205+- 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.
206+- Helper processes (`pass`, `gpg`, `sops`, `journalctl`) are dumpable again after `exec`; secret tools hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
207+- Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.
208+
209+## Hardened systemd user unit
210+
211+Add these directives to the `[Service]` section of the [user unit](../installation.md#user-service-recommended-for-rootless):
212+
213+```ini
214+[Service]
215+NoNewPrivileges=yes
216+UMask=0077
217+LockPersonality=yes
218+RestrictSUIDSGID=yes
219+RestrictRealtime=yes
220+SystemCallArchitectures=native
221+```
222+
223+Apply and check:
224+
225+```bash
226+systemctl --user daemon-reload
227+systemctl --user restart gordon
228+systemctl --user status gordon
229+systemd-analyze --user security gordon.service
230+```
231+
232+## Credential scoping
233+
234+- **Cloudflare DNS-01 token**: grant only `Zone:Read` and `DNS:Edit`, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See [Server](./server.md).
235+- **Registry push tokens**: issue one token per repository and pipeline instead of a global one:
236+
237+ ```bash
238+ gordon auth token generate --subject ci-blog --scopes push --repo blog
239+ ```
240+
241+## Protection boundary
242+
243+Gordon v3 protects against:
244+
245+- file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
246+- app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
247+- same-user processes reading the Gordon process's memory or environment through `/proc`, within the limits above.
248+
249+Gordon v3 does not protect against:
250+
251+- a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and `pass`;
252+- a malicious image deployed for an app: it receives that app's secrets;
253+- root on the host.
254+
175255 ## Related
176256
177257 - [Auth](./auth.md)
258+- [Secrets](./secrets.md)
178259 - [Images](./images.md)
179260 - [Network Isolation](./network-isolation.md)
180261 - [Volumes](./volumes.md)
@@ -172,9 +172,90 @@ security_profile = "compat" # or "strict"
172 172
173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
174 174
175+## Runtime guard
176+
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`. The guard refuses:
178+
179+- network mode `host`, `container:<id>`, or `ns:<path>`;
180+- a volume name containing `/` or `\`, which the runtime would read as a host path;
181+- a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.
182+
183+Protected paths:
184+
185+- the data directory and the loaded config file;
186+- files and directories set in the config: `server.tls_key_file` and the backup `storage_dir` values;
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;
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`;
191+- key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192+
193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194+
195+Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under `<data_dir>/logs`; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in `$HOME`, the log file itself stays protected and no read-only exception applies.
196+
197+The only other 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.
198+
199+## Process memory protection
200+
201+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.
202+
203+Limits:
204+
205+- 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.
206+- Helper processes (`pass`, `gpg`, `sops`, `journalctl`) are dumpable again after `exec`; secret tools hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
207+- Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.
208+
209+## Hardened systemd user unit
210+
211+Add these directives to the `[Service]` section of the [user unit](../installation.md#user-service-recommended-for-rootless):
212+
213+```ini
214+[Service]
215+NoNewPrivileges=yes
216+UMask=0077
217+LockPersonality=yes
218+RestrictSUIDSGID=yes
219+RestrictRealtime=yes
220+SystemCallArchitectures=native
221+```
222+
223+Apply and check:
224+
225+```bash
226+systemctl --user daemon-reload
227+systemctl --user restart gordon
228+systemctl --user status gordon
229+systemd-analyze --user security gordon.service
230+```
231+
232+## Credential scoping
233+
234+- **Cloudflare DNS-01 token**: grant only `Zone:Read` and `DNS:Edit`, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See [Server](./server.md).
235+- **Registry push tokens**: issue one token per repository and pipeline instead of a global one:
236+
237+ ```bash
238+ gordon auth token generate --subject ci-blog --scopes push --repo blog
239+ ```
240+
241+## Protection boundary
242+
243+Gordon v3 protects against:
244+
245+- file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
246+- app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
247+- same-user processes reading the Gordon process's memory or environment through `/proc`, within the limits above.
248+
249+Gordon v3 does not protect against:
250+
251+- a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and `pass`;
252+- a malicious image deployed for an app: it receives that app's secrets;
253+- root on the host.
254+
175 ## Related255 ## Related
176 256
177 - [Auth](./auth.md)257 - [Auth](./auth.md)
258+- [Secrets](./secrets.md)
178 - [Images](./images.md)259 - [Images](./images.md)
179 - [Network Isolation](./network-isolation.md)260 - [Network Isolation](./network-isolation.md)
180 - [Volumes](./volumes.md)261 - [Volumes](./volumes.md)
M docs/installation.md +2 -0
@@ -266,6 +266,8 @@ sudo loginctl enable-linger $USER
266266 systemctl --user status gordon
267267 ```
268268
269+See [Hardened systemd user unit](./config/security-hardening.md#hardened-systemd-user-unit) for the recommended sandboxing directives.
270+
269271 ### System Service (Root Mode)
270272
271273 ```bash
@@ -266,6 +266,8 @@ sudo loginctl enable-linger $USER
266 systemctl --user status gordon266 systemctl --user status gordon
267 ```267 ```
268 268
269+See [Hardened systemd user unit](./config/security-hardening.md#hardened-systemd-user-unit) for the recommended sandboxing directives.
270+
269 ### System Service (Root Mode)271 ### System Service (Root Mode)
270 272
271 ```bash273 ```bash
M internal/adapters/in/appmanifest/parse.go +71 -5
@@ -52,9 +52,11 @@ type rawService struct {
5252 HTTP []rawHTTP `toml:"http"`
5353 TCP []rawTCP `toml:"tcp"`
5454 UDP []rawUDP `toml:"udp"`
55- Secrets map[string]string `toml:"secrets"`
56- Volumes []rawVolume `toml:"volume"`
57- Binds []rawBind `toml:"bind"`
55+ // Secrets maps env keys to a secret name string or to a table
56+ // { name = "...", as = "env"|"file" }; see toDomainSecrets.
57+ Secrets map[string]any `toml:"secrets"`
58+ Volumes []rawVolume `toml:"volume"`
59+ Binds []rawBind `toml:"bind"`
5860 // Devices lists logical device names from `devices = [...]`.
5961 Devices []string `toml:"devices"`
6062 Databases []rawDatabase `toml:"database"`
@@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
321323 })
322324 }
323325 svc.Env = maps.Clone(raw.Env)
324- for key, value := range raw.Secrets {
325- svc.Secrets[key] = value
326+ secrets, secretFiles, err := toDomainSecrets(serviceName, raw.Secrets)
327+ if err != nil {
328+ return domain.AppService{}, err
326329 }
330+ svc.Secrets = secrets
331+ svc.SecretFiles = secretFiles
327332 for _, v := range raw.Volumes {
328333 svc.Volumes = append(svc.Volumes, domain.AppVolume{
329334 Name: v.Name,
@@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
351356 return svc, nil
352357 }
353358
359+// Secret delivery modes accepted in a secret table's `as` key.
360+const (
361+ secretAsEnv = "env"
362+ secretAsFile = "file"
363+)
364+
365+// toDomainSecrets maps [services.<name>.secrets]. Each value is either a
366+// secret name string (env delivery) or a table with a required `name`
367+// and an optional `as` ("env" or "file", default "env"). It returns the
368+// env key -> secret name map and the sorted file-delivered keys (nil
369+// when none).
370+func toDomainSecrets(service string, raw map[string]any) (map[string]string, []string, error) {
371+ secrets := make(map[string]string, len(raw))
372+ var files []string
373+ for _, key := range slices.Sorted(maps.Keys(raw)) {
374+ name, asFile, err := toDomainSecret(raw[key])
375+ if err != nil {
376+ return nil, nil, fmt.Errorf("%w: service %q secret %q: %s", domain.ErrInvalidAppSpec, service, key, err)
377+ }
378+ secrets[key] = name
379+ if asFile {
380+ files = append(files, key)
381+ }
382+ }
383+ return secrets, files, nil
384+}
385+
386+// toDomainSecret decodes one secret value and reports whether it is
387+// delivered as a file.
388+func toDomainSecret(value any) (string, bool, error) {
389+ switch v := value.(type) {
390+ case string:
391+ return v, false, nil
392+ case map[string]any:
393+ return toDomainSecretTable(v)
394+ default:
395+ return "", false, errors.New("value must be a secret name string or a { name, as } table")
396+ }
397+}
398+
399+func toDomainSecretTable(table map[string]any) (string, bool, error) {
400+ for _, key := range slices.Sorted(maps.Keys(table)) {
401+ if key != "name" && key != "as" {
402+ return "", false, fmt.Errorf("unknown key %q (allowed: name, as)", key)
403+ }
404+ }
405+ name, ok := table["name"].(string)
406+ if !ok || name == "" {
407+ return "", false, errors.New("name must be a non-empty string")
408+ }
409+ rawAs, present := table["as"]
410+ if !present {
411+ return name, false, nil
412+ }
413+ as, ok := rawAs.(string)
414+ if !ok || (as != secretAsEnv && as != secretAsFile) {
415+ return "", false, fmt.Errorf("as must be %q or %q", secretAsEnv, secretAsFile)
416+ }
417+ return name, as == secretAsFile, nil
418+}
419+
354420 // toDomainReadiness maps readiness with defaults.
355421 func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
356422 readiness := domain.AppReadiness{
@@ -52,9 +52,11 @@ type rawService struct {
52 HTTP []rawHTTP `toml:"http"`52 HTTP []rawHTTP `toml:"http"`
53 TCP []rawTCP `toml:"tcp"`53 TCP []rawTCP `toml:"tcp"`
54 UDP []rawUDP `toml:"udp"`54 UDP []rawUDP `toml:"udp"`
55- Secrets map[string]string `toml:"secrets"`55+ // Secrets maps env keys to a secret name string or to a table
56- Volumes []rawVolume `toml:"volume"`56+ // { name = "...", as = "env"|"file" }; see toDomainSecrets.
57- Binds []rawBind `toml:"bind"`57+ Secrets map[string]any `toml:"secrets"`
58+ Volumes []rawVolume `toml:"volume"`
59+ Binds []rawBind `toml:"bind"`
58 // Devices lists logical device names from `devices = [...]`.60 // Devices lists logical device names from `devices = [...]`.
59 Devices []string `toml:"devices"`61 Devices []string `toml:"devices"`
60 Databases []rawDatabase `toml:"database"`62 Databases []rawDatabase `toml:"database"`
@@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
321 })323 })
322 }324 }
323 svc.Env = maps.Clone(raw.Env)325 svc.Env = maps.Clone(raw.Env)
324- for key, value := range raw.Secrets {326+ secrets, secretFiles, err := toDomainSecrets(serviceName, raw.Secrets)
325- svc.Secrets[key] = value327+ if err != nil {
328+ return domain.AppService{}, err
326 }329 }
330+ svc.Secrets = secrets
331+ svc.SecretFiles = secretFiles
327 for _, v := range raw.Volumes {332 for _, v := range raw.Volumes {
328 svc.Volumes = append(svc.Volumes, domain.AppVolume{333 svc.Volumes = append(svc.Volumes, domain.AppVolume{
329 Name: v.Name,334 Name: v.Name,
@@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
351 return svc, nil356 return svc, nil
352 }357 }
353 358
359+// Secret delivery modes accepted in a secret table's `as` key.
360+const (
361+ secretAsEnv = "env"
362+ secretAsFile = "file"
363+)
364+
365+// toDomainSecrets maps [services.<name>.secrets]. Each value is either a
366+// secret name string (env delivery) or a table with a required `name`
367+// and an optional `as` ("env" or "file", default "env"). It returns the
368+// env key -> secret name map and the sorted file-delivered keys (nil
369+// when none).
370+func toDomainSecrets(service string, raw map[string]any) (map[string]string, []string, error) {
371+ secrets := make(map[string]string, len(raw))
372+ var files []string
373+ for _, key := range slices.Sorted(maps.Keys(raw)) {
374+ name, asFile, err := toDomainSecret(raw[key])
375+ if err != nil {
376+ return nil, nil, fmt.Errorf("%w: service %q secret %q: %s", domain.ErrInvalidAppSpec, service, key, err)
377+ }
378+ secrets[key] = name
379+ if asFile {
380+ files = append(files, key)
381+ }
382+ }
383+ return secrets, files, nil
384+}
385+
386+// toDomainSecret decodes one secret value and reports whether it is
387+// delivered as a file.
388+func toDomainSecret(value any) (string, bool, error) {
389+ switch v := value.(type) {
390+ case string:
391+ return v, false, nil
392+ case map[string]any:
393+ return toDomainSecretTable(v)
394+ default:
395+ return "", false, errors.New("value must be a secret name string or a { name, as } table")
396+ }
397+}
398+
399+func toDomainSecretTable(table map[string]any) (string, bool, error) {
400+ for _, key := range slices.Sorted(maps.Keys(table)) {
401+ if key != "name" && key != "as" {
402+ return "", false, fmt.Errorf("unknown key %q (allowed: name, as)", key)
403+ }
404+ }
405+ name, ok := table["name"].(string)
406+ if !ok || name == "" {
407+ return "", false, errors.New("name must be a non-empty string")
408+ }
409+ rawAs, present := table["as"]
410+ if !present {
411+ return name, false, nil
412+ }
413+ as, ok := rawAs.(string)
414+ if !ok || (as != secretAsEnv && as != secretAsFile) {
415+ return "", false, fmt.Errorf("as must be %q or %q", secretAsEnv, secretAsFile)
416+ }
417+ return name, as == secretAsFile, nil
418+}
419+
354 // toDomainReadiness maps readiness with defaults.420 // toDomainReadiness maps readiness with defaults.
355 func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {421 func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
356 readiness := domain.AppReadiness{422 readiness := domain.AppReadiness{
A internal/adapters/in/appmanifest/parse_secrets_test.go +77 -0
@@ -0,0 +1,77 @@
1+package appmanifest_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/appmanifest"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+const secretsHead = "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n"
14+
15+func TestParse_SecretForms(t *testing.T) {
16+ cases := []struct {
17+ name string
18+ doc string
19+ wantMap map[string]string
20+ wantFiles []string
21+ }{
22+ {"short form", "[services.web.secrets]\nDB = \"db\"\n", map[string]string{"DB": "db"}, nil},
23+ {"table name only", "[services.web.secrets]\nDB = { name = \"db\" }\n", map[string]string{"DB": "db"}, nil},
24+ {"table env", "[services.web.secrets]\nDB = { name = \"db\", as = \"env\" }\n", map[string]string{"DB": "db"}, nil},
25+ {"table file", "[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", map[string]string{"DB": "db"}, []string{"DB"}},
26+ {"sub-table", "[services.web.secrets.DB]\nname = \"db\"\nas = \"file\"\n", map[string]string{"DB": "db"}, []string{"DB"}},
27+ {
28+ "mixed",
29+ "[services.web.secrets]\nZ = { name = \"z\", as = \"file\" }\nA = { name = \"a\", as = \"file\" }\nM = \"m\"\nE = { name = \"e\", as = \"env\" }\n",
30+ map[string]string{"Z": "z", "A": "a", "M": "m", "E": "e"},
31+ []string{"A", "Z"},
32+ },
33+ }
34+ for _, tc := range cases {
35+ t.Run(tc.name, func(t *testing.T) {
36+ spec, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml")
37+ require.NoError(t, err)
38+ require.Len(t, spec.Services, 1)
39+ assert.Equal(t, tc.wantMap, spec.Services[0].Secrets)
40+ assert.Equal(t, tc.wantFiles, spec.Services[0].SecretFiles)
41+ })
42+ }
43+}
44+
45+func TestParse_NoSecretsKeepsEmptyMap(t *testing.T) {
46+ spec, _, err := appmanifest.Parse([]byte(secretsHead), "blog.toml")
47+ require.NoError(t, err)
48+ assert.NotNil(t, spec.Services[0].Secrets)
49+ assert.Empty(t, spec.Services[0].Secrets)
50+ assert.Nil(t, spec.Services[0].SecretFiles)
51+}
52+
53+func TestParse_SecretFormsInvalid(t *testing.T) {
54+ cases := []struct {
55+ name string
56+ doc string
57+ wantErr string
58+ }{
59+ {"bad as", "[services.web.secrets]\nDB = { name = \"db\", as = \"volume\" }\n", "as must be"},
60+ {"empty as", "[services.web.secrets]\nDB = { name = \"db\", as = \"\" }\n", "as must be"},
61+ {"as not string", "[services.web.secrets]\nDB = { name = \"db\", as = 1 }\n", "as must be"},
62+ {"missing name", "[services.web.secrets]\nDB = { as = \"file\" }\n", "name must be"},
63+ {"name not string", "[services.web.secrets]\nDB = { name = 1 }\n", "name must be"},
64+ {"extra key", "[services.web.secrets]\nDB = { name = \"db\", mode = \"x\" }\n", "unknown key \"mode\""},
65+ {"integer value", "[services.web.secrets]\nDB = 42\n", "secret name string"},
66+ {"array value", "[services.web.secrets]\nDB = [\"db\"]\n", "secret name string"},
67+ {"file key collision", "[services.web.env]\nDB_FILE = \"x\"\n[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", "collides with a secret file key"},
68+ }
69+ for _, tc := range cases {
70+ t.Run(tc.name, func(t *testing.T) {
71+ _, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml")
72+ require.Error(t, err)
73+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
74+ assert.ErrorContains(t, err, tc.wantErr)
75+ })
76+ }
77+}
@@ -0,0 +1,77 @@
1+package appmanifest_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/appmanifest"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+const secretsHead = "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n"
14+
15+func TestParse_SecretForms(t *testing.T) {
16+ cases := []struct {
17+ name string
18+ doc string
19+ wantMap map[string]string
20+ wantFiles []string
21+ }{
22+ {"short form", "[services.web.secrets]\nDB = \"db\"\n", map[string]string{"DB": "db"}, nil},
23+ {"table name only", "[services.web.secrets]\nDB = { name = \"db\" }\n", map[string]string{"DB": "db"}, nil},
24+ {"table env", "[services.web.secrets]\nDB = { name = \"db\", as = \"env\" }\n", map[string]string{"DB": "db"}, nil},
25+ {"table file", "[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", map[string]string{"DB": "db"}, []string{"DB"}},
26+ {"sub-table", "[services.web.secrets.DB]\nname = \"db\"\nas = \"file\"\n", map[string]string{"DB": "db"}, []string{"DB"}},
27+ {
28+ "mixed",
29+ "[services.web.secrets]\nZ = { name = \"z\", as = \"file\" }\nA = { name = \"a\", as = \"file\" }\nM = \"m\"\nE = { name = \"e\", as = \"env\" }\n",
30+ map[string]string{"Z": "z", "A": "a", "M": "m", "E": "e"},
31+ []string{"A", "Z"},
32+ },
33+ }
34+ for _, tc := range cases {
35+ t.Run(tc.name, func(t *testing.T) {
36+ spec, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml")
37+ require.NoError(t, err)
38+ require.Len(t, spec.Services, 1)
39+ assert.Equal(t, tc.wantMap, spec.Services[0].Secrets)
40+ assert.Equal(t, tc.wantFiles, spec.Services[0].SecretFiles)
41+ })
42+ }
43+}
44+
45+func TestParse_NoSecretsKeepsEmptyMap(t *testing.T) {
46+ spec, _, err := appmanifest.Parse([]byte(secretsHead), "blog.toml")
47+ require.NoError(t, err)
48+ assert.NotNil(t, spec.Services[0].Secrets)
49+ assert.Empty(t, spec.Services[0].Secrets)
50+ assert.Nil(t, spec.Services[0].SecretFiles)
51+}
52+
53+func TestParse_SecretFormsInvalid(t *testing.T) {
54+ cases := []struct {
55+ name string
56+ doc string
57+ wantErr string
58+ }{
59+ {"bad as", "[services.web.secrets]\nDB = { name = \"db\", as = \"volume\" }\n", "as must be"},
60+ {"empty as", "[services.web.secrets]\nDB = { name = \"db\", as = \"\" }\n", "as must be"},
61+ {"as not string", "[services.web.secrets]\nDB = { name = \"db\", as = 1 }\n", "as must be"},
62+ {"missing name", "[services.web.secrets]\nDB = { as = \"file\" }\n", "name must be"},
63+ {"name not string", "[services.web.secrets]\nDB = { name = 1 }\n", "name must be"},
64+ {"extra key", "[services.web.secrets]\nDB = { name = \"db\", mode = \"x\" }\n", "unknown key \"mode\""},
65+ {"integer value", "[services.web.secrets]\nDB = 42\n", "secret name string"},
66+ {"array value", "[services.web.secrets]\nDB = [\"db\"]\n", "secret name string"},
67+ {"file key collision", "[services.web.env]\nDB_FILE = \"x\"\n[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", "collides with a secret file key"},
68+ }
69+ for _, tc := range cases {
70+ t.Run(tc.name, func(t *testing.T) {
71+ _, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml")
72+ require.Error(t, err)
73+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
74+ assert.ErrorContains(t, err, tc.wantErr)
75+ })
76+ }
77+}
M internal/adapters/in/http/admin/handler_apps.go +2 -0
@@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560560 case errors.Is(err, domain.ErrDevicePolicy):
561561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
562+ case errors.Is(err, domain.ErrRuntimeGuard):
563+ h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
562564 case errors.Is(err, domain.ErrRuntimeUnsupported):
563565 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
564566 case errors.Is(err, domain.ErrAppReservationConflict):
@@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560 case errors.Is(err, domain.ErrDevicePolicy):560 case errors.Is(err, domain.ErrDevicePolicy):
561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
562+ case errors.Is(err, domain.ErrRuntimeGuard):
563+ h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
562 case errors.Is(err, domain.ErrRuntimeUnsupported):564 case errors.Is(err, domain.ErrRuntimeUnsupported):
563 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")565 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
564 case errors.Is(err, domain.ErrAppReservationConflict):566 case errors.Is(err, domain.ErrAppReservationConflict):
M internal/adapters/in/http/admin/handler_apps_test.go +15 -0
@@ -682,6 +682,21 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
682682 assert.Equal(t, "device-policy-violation", envelope.Error)
683683 }
684684
685+// TestSendAppOpError_MapsRuntimeGuard proves a runtime guard error returned
686+// synchronously maps to the stable runtime-guard-violation envelope. Async
687+// deploys report the guard in their operation journal instead.
688+func TestSendAppOpError_MapsRuntimeGuard(t *testing.T) {
689+ handler := appsTestHandler(t, inmocks.NewMockAppService(t))
690+ rec := httptest.NewRecorder()
691+
692+ handler.sendAppOpError(rec, fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"))
693+
694+ assert.Equal(t, http.StatusBadRequest, rec.Code)
695+ var envelope dto.AppError
696+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
697+ assert.Equal(t, "runtime-guard-violation", envelope.Error)
698+}
699+
685700 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
686701 appSvc := inmocks.NewMockAppService(t)
687702 handler := appsTestHandler(t, appSvc)
@@ -682,6 +682,21 @@ 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+// TestSendAppOpError_MapsRuntimeGuard proves a runtime guard error returned
686+// synchronously maps to the stable runtime-guard-violation envelope. Async
687+// deploys report the guard in their operation journal instead.
688+func TestSendAppOpError_MapsRuntimeGuard(t *testing.T) {
689+ handler := appsTestHandler(t, inmocks.NewMockAppService(t))
690+ rec := httptest.NewRecorder()
691+
692+ handler.sendAppOpError(rec, fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"))
693+
694+ assert.Equal(t, http.StatusBadRequest, rec.Code)
695+ var envelope dto.AppError
696+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
697+ assert.Equal(t, "runtime-guard-violation", envelope.Error)
698+}
699+
685 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {700 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
686 appSvc := inmocks.NewMockAppService(t)701 appSvc := inmocks.NewMockAppService(t)
687 handler := appsTestHandler(t, appSvc)702 handler := appsTestHandler(t, appSvc)
M internal/adapters/out/appsecrets/store.go +3 -3
@@ -7,13 +7,13 @@ package appsecrets
77 import (
88 "context"
99 "fmt"
10- "os/exec"
1110 "strings"
1211 "time"
1312
1413 "github.com/bnema/zerowrap"
1514
1615 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/pkg/childenv"
1717 )
1818
1919 // Store writes app secret values through the pass CLI.
@@ -40,7 +40,7 @@ func (s *Store) SetSecret(ctx context.Context, path, value string) error {
4040 }
4141 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
4242 defer cancel()
43- cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below
43+ cmd := childenv.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path)
4444 cmd.Stdin = strings.NewReader(value)
4545 output, err := cmd.CombinedOutput()
4646 if err != nil {
@@ -57,7 +57,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error {
5757 }
5858 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
5959 defer cancel()
60- cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below
60+ cmd := childenv.CommandContext(timeoutCtx, "pass", "rm", "-f", path)
6161 output, err := cmd.CombinedOutput()
6262 if err != nil {
6363 return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
@@ -7,13 +7,13 @@ package appsecrets
7 import (7 import (
8 "context"8 "context"
9 "fmt"9 "fmt"
10- "os/exec"
11 "strings"10 "strings"
12 "time"11 "time"
13 12
14 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
15 14
16 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/pkg/childenv"
17 )17 )
18 18
19 // Store writes app secret values through the pass CLI.19 // Store writes app secret values through the pass CLI.
@@ -40,7 +40,7 @@ func (s *Store) SetSecret(ctx context.Context, path, value string) error {
40 }40 }
41 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)41 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
42 defer cancel()42 defer cancel()
43- cmd := exec.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below43+ cmd := childenv.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path)
44 cmd.Stdin = strings.NewReader(value)44 cmd.Stdin = strings.NewReader(value)
45 output, err := cmd.CombinedOutput()45 output, err := cmd.CombinedOutput()
46 if err != nil {46 if err != nil {
@@ -57,7 +57,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error {
57 }57 }
58 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)58 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
59 defer cancel()59 defer cancel()
60- cmd := exec.CommandContext(timeoutCtx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path validated below60+ cmd := childenv.CommandContext(timeoutCtx, "pass", "rm", "-f", path)
61 output, err := cmd.CombinedOutput()61 output, err := cmd.CombinedOutput()
62 if err != nil {62 if err != nil {
63 return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)63 return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
A internal/adapters/out/secretfiles/store.go +198 -0
@@ -0,0 +1,198 @@
1+// Package secretfiles stores file-delivered app secrets on the host.
2+//
3+// Layout: <data_dir>/secrets-run/<app>/<container-name>/<KEY>, directories
4+// 0700 and files 0444. All access goes through an os.Root so symlinks
5+// cannot escape the base directory. Values and paths are never logged.
6+package secretfiles
7+
8+import (
9+ "context"
10+ "errors"
11+ "fmt"
12+ "io/fs"
13+ "maps"
14+ "os"
15+ "path/filepath"
16+ "slices"
17+ "strings"
18+ "syscall"
19+
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const (
25+ dirMode os.FileMode = 0o700
26+ fileMode os.FileMode = 0o444
27+ // maxContainerName bounds a container directory name.
28+ maxContainerName = 255
29+)
30+
31+// Store implements out.SecretFileStore on the local filesystem.
32+type Store struct {
33+ dir string
34+ root *os.Root
35+}
36+
37+var _ out.SecretFileStore = (*Store)(nil)
38+
39+// NewStore opens (creating if needed) <dataDir>/secrets-run.
40+func NewStore(dataDir string) (*Store, error) {
41+ // Bind sources must be absolute: resolve a relative data dir once.
42+ dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run"))
43+ if err != nil {
44+ return nil, fmt.Errorf("secret files: resolve base directory: %w", err)
45+ }
46+ if err := os.MkdirAll(dir, dirMode); err != nil {
47+ return nil, fmt.Errorf("secret files: create base directory: %w", err)
48+ }
49+ info, err := os.Lstat(dir)
50+ if err != nil {
51+ return nil, fmt.Errorf("secret files: stat base directory: %w", err)
52+ }
53+ if !info.IsDir() {
54+ return nil, errors.New("secret files: base directory must be a directory, not a symlink")
55+ }
56+ if err := os.Chmod(dir, dirMode); err != nil {
57+ return nil, fmt.Errorf("secret files: chmod base directory: %w", err)
58+ }
59+ root, err := os.OpenRoot(dir)
60+ if err != nil {
61+ return nil, fmt.Errorf("secret files: open base directory: %w", err)
62+ }
63+ return &Store{dir: dir, root: root}, nil
64+}
65+
66+// Dir returns the absolute base directory.
67+func (s *Store) Dir() string { return s.dir }
68+
69+// Close releases the base directory handle.
70+func (s *Store) Close() error { return s.root.Close() }
71+
72+// Write creates the container's directory with one 0444 file per key. The
73+// directory must not exist yet: container names are unique per create, so
74+// an existing directory belongs to another container and is never touched.
75+// Crash leftovers are removed by the sweeps, not here.
76+func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {
77+ if err := validateSet(app, container); err != nil {
78+ return nil, err
79+ }
80+ for key := range values {
81+ if err := domain.ValidateEnvKey(key); err != nil {
82+ return nil, fmt.Errorf("secret files: %w", err)
83+ }
84+ }
85+ if err := s.root.MkdirAll(app, dirMode); err != nil {
86+ return nil, fmt.Errorf("secret files: create app directory: %w", err)
87+ }
88+ if err := s.root.Chmod(app, dirMode); err != nil {
89+ return nil, fmt.Errorf("secret files: chmod app directory: %w", err)
90+ }
91+ rel := filepath.Join(app, container)
92+ if err := s.root.Mkdir(rel, dirMode); err != nil {
93+ return nil, fmt.Errorf("secret files: create container directory: %w", err)
94+ }
95+ paths, err := s.writeFiles(rel, values)
96+ if err != nil {
97+ _ = s.root.RemoveAll(rel)
98+ return nil, err
99+ }
100+ return paths, nil
101+}
102+
103+func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {
104+ if err := s.root.Chmod(rel, dirMode); err != nil {
105+ return nil, fmt.Errorf("secret files: chmod container directory: %w", err)
106+ }
107+ paths := make(map[string]string, len(values))
108+ for _, key := range slices.Sorted(maps.Keys(values)) {
109+ name := filepath.Join(rel, key)
110+ if err := s.writeFile(name, values[key]); err != nil {
111+ return nil, fmt.Errorf("secret files: write %s: %w", key, err)
112+ }
113+ paths[key] = filepath.Join(s.dir, name)
114+ }
115+ return paths, nil
116+}
117+
118+func (s *Store) writeFile(name, value string) error {
119+ f, err := s.root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode)
120+ if err != nil {
121+ return err
122+ }
123+ if _, err := f.WriteString(value); err != nil {
124+ _ = f.Close()
125+ return err
126+ }
127+ if err := f.Close(); err != nil {
128+ return err
129+ }
130+ return s.root.Chmod(name, fileMode)
131+}
132+
133+// Read returns one file's content.
134+func (s *Store) Read(_ context.Context, app, container, key string) (string, error) {
135+ if err := validateSet(app, container); err != nil {
136+ return "", err
137+ }
138+ if err := domain.ValidateEnvKey(key); err != nil {
139+ return "", fmt.Errorf("secret files: %w", err)
140+ }
141+ data, err := s.root.ReadFile(filepath.Join(app, container, key))
142+ if err != nil {
143+ return "", fmt.Errorf("secret files: read %s: %w", key, err)
144+ }
145+ return string(data), nil
146+}
147+
148+// Remove deletes the container's directory and the app directory when it
149+// becomes empty. A missing directory is not an error.
150+func (s *Store) Remove(_ context.Context, app, container string) error {
151+ if err := validateSet(app, container); err != nil {
152+ return err
153+ }
154+ if err := s.root.RemoveAll(filepath.Join(app, container)); err != nil {
155+ return fmt.Errorf("secret files: remove container directory: %w", err)
156+ }
157+ if err := s.root.Remove(app); err != nil && !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) {
158+ return fmt.Errorf("secret files: remove app directory: %w", err)
159+ }
160+ return nil
161+}
162+
163+// List returns every container directory, sorted by app then container.
164+func (s *Store) List(_ context.Context) ([]out.SecretFileSet, error) {
165+ fsys := s.root.FS()
166+ apps, err := fs.ReadDir(fsys, ".")
167+ if err != nil {
168+ return nil, fmt.Errorf("secret files: list apps: %w", err)
169+ }
170+ var sets []out.SecretFileSet
171+ for _, app := range apps {
172+ if !app.IsDir() {
173+ continue
174+ }
175+ containers, err := fs.ReadDir(fsys, app.Name())
176+ if err != nil {
177+ return nil, fmt.Errorf("secret files: list containers: %w", err)
178+ }
179+ for _, c := range containers {
180+ if c.IsDir() {
181+ sets = append(sets, out.SecretFileSet{App: app.Name(), Container: c.Name()})
182+ }
183+ }
184+ }
185+ // fs.ReadDir returns entries sorted by name, so sets are already ordered.
186+ return sets, nil
187+}
188+
189+func validateSet(app, container string) error {
190+ if err := domain.ValidateAppName(app); err != nil {
191+ return fmt.Errorf("secret files: %w", err)
192+ }
193+ if container == "" || len(container) > maxContainerName || container == "." || container == ".." ||
194+ filepath.Base(container) != container || strings.ContainsAny(container, "/\\\x00") {
195+ return errors.New("secret files: invalid container name")
196+ }
197+ return nil
198+}
@@ -0,0 +1,198 @@
1+// Package secretfiles stores file-delivered app secrets on the host.
2+//
3+// Layout: <data_dir>/secrets-run/<app>/<container-name>/<KEY>, directories
4+// 0700 and files 0444. All access goes through an os.Root so symlinks
5+// cannot escape the base directory. Values and paths are never logged.
6+package secretfiles
7+
8+import (
9+ "context"
10+ "errors"
11+ "fmt"
12+ "io/fs"
13+ "maps"
14+ "os"
15+ "path/filepath"
16+ "slices"
17+ "strings"
18+ "syscall"
19+
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const (
25+ dirMode os.FileMode = 0o700
26+ fileMode os.FileMode = 0o444
27+ // maxContainerName bounds a container directory name.
28+ maxContainerName = 255
29+)
30+
31+// Store implements out.SecretFileStore on the local filesystem.
32+type Store struct {
33+ dir string
34+ root *os.Root
35+}
36+
37+var _ out.SecretFileStore = (*Store)(nil)
38+
39+// NewStore opens (creating if needed) <dataDir>/secrets-run.
40+func NewStore(dataDir string) (*Store, error) {
41+ // Bind sources must be absolute: resolve a relative data dir once.
42+ dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run"))
43+ if err != nil {
44+ return nil, fmt.Errorf("secret files: resolve base directory: %w", err)
45+ }
46+ if err := os.MkdirAll(dir, dirMode); err != nil {
47+ return nil, fmt.Errorf("secret files: create base directory: %w", err)
48+ }
49+ info, err := os.Lstat(dir)
50+ if err != nil {
51+ return nil, fmt.Errorf("secret files: stat base directory: %w", err)
52+ }
53+ if !info.IsDir() {
54+ return nil, errors.New("secret files: base directory must be a directory, not a symlink")
55+ }
56+ if err := os.Chmod(dir, dirMode); err != nil {
57+ return nil, fmt.Errorf("secret files: chmod base directory: %w", err)
58+ }
59+ root, err := os.OpenRoot(dir)
60+ if err != nil {
61+ return nil, fmt.Errorf("secret files: open base directory: %w", err)
62+ }
63+ return &Store{dir: dir, root: root}, nil
64+}
65+
66+// Dir returns the absolute base directory.
67+func (s *Store) Dir() string { return s.dir }
68+
69+// Close releases the base directory handle.
70+func (s *Store) Close() error { return s.root.Close() }
71+
72+// Write creates the container's directory with one 0444 file per key. The
73+// directory must not exist yet: container names are unique per create, so
74+// an existing directory belongs to another container and is never touched.
75+// Crash leftovers are removed by the sweeps, not here.
76+func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {
77+ if err := validateSet(app, container); err != nil {
78+ return nil, err
79+ }
80+ for key := range values {
81+ if err := domain.ValidateEnvKey(key); err != nil {
82+ return nil, fmt.Errorf("secret files: %w", err)
83+ }
84+ }
85+ if err := s.root.MkdirAll(app, dirMode); err != nil {
86+ return nil, fmt.Errorf("secret files: create app directory: %w", err)
87+ }
88+ if err := s.root.Chmod(app, dirMode); err != nil {
89+ return nil, fmt.Errorf("secret files: chmod app directory: %w", err)
90+ }
91+ rel := filepath.Join(app, container)
92+ if err := s.root.Mkdir(rel, dirMode); err != nil {
93+ return nil, fmt.Errorf("secret files: create container directory: %w", err)
94+ }
95+ paths, err := s.writeFiles(rel, values)
96+ if err != nil {
97+ _ = s.root.RemoveAll(rel)
98+ return nil, err
99+ }
100+ return paths, nil
101+}
102+
103+func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {
104+ if err := s.root.Chmod(rel, dirMode); err != nil {
105+ return nil, fmt.Errorf("secret files: chmod container directory: %w", err)
106+ }
107+ paths := make(map[string]string, len(values))
108+ for _, key := range slices.Sorted(maps.Keys(values)) {
109+ name := filepath.Join(rel, key)
110+ if err := s.writeFile(name, values[key]); err != nil {
111+ return nil, fmt.Errorf("secret files: write %s: %w", key, err)
112+ }
113+ paths[key] = filepath.Join(s.dir, name)
114+ }
115+ return paths, nil
116+}
117+
118+func (s *Store) writeFile(name, value string) error {
119+ f, err := s.root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode)
120+ if err != nil {
121+ return err
122+ }
123+ if _, err := f.WriteString(value); err != nil {
124+ _ = f.Close()
125+ return err
126+ }
127+ if err := f.Close(); err != nil {
128+ return err
129+ }
130+ return s.root.Chmod(name, fileMode)
131+}
132+
133+// Read returns one file's content.
134+func (s *Store) Read(_ context.Context, app, container, key string) (string, error) {
135+ if err := validateSet(app, container); err != nil {
136+ return "", err
137+ }
138+ if err := domain.ValidateEnvKey(key); err != nil {
139+ return "", fmt.Errorf("secret files: %w", err)
140+ }
141+ data, err := s.root.ReadFile(filepath.Join(app, container, key))
142+ if err != nil {
143+ return "", fmt.Errorf("secret files: read %s: %w", key, err)
144+ }
145+ return string(data), nil
146+}
147+
148+// Remove deletes the container's directory and the app directory when it
149+// becomes empty. A missing directory is not an error.
150+func (s *Store) Remove(_ context.Context, app, container string) error {
151+ if err := validateSet(app, container); err != nil {
152+ return err
153+ }
154+ if err := s.root.RemoveAll(filepath.Join(app, container)); err != nil {
155+ return fmt.Errorf("secret files: remove container directory: %w", err)
156+ }
157+ if err := s.root.Remove(app); err != nil && !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) {
158+ return fmt.Errorf("secret files: remove app directory: %w", err)
159+ }
160+ return nil
161+}
162+
163+// List returns every container directory, sorted by app then container.
164+func (s *Store) List(_ context.Context) ([]out.SecretFileSet, error) {
165+ fsys := s.root.FS()
166+ apps, err := fs.ReadDir(fsys, ".")
167+ if err != nil {
168+ return nil, fmt.Errorf("secret files: list apps: %w", err)
169+ }
170+ var sets []out.SecretFileSet
171+ for _, app := range apps {
172+ if !app.IsDir() {
173+ continue
174+ }
175+ containers, err := fs.ReadDir(fsys, app.Name())
176+ if err != nil {
177+ return nil, fmt.Errorf("secret files: list containers: %w", err)
178+ }
179+ for _, c := range containers {
180+ if c.IsDir() {
181+ sets = append(sets, out.SecretFileSet{App: app.Name(), Container: c.Name()})
182+ }
183+ }
184+ }
185+ // fs.ReadDir returns entries sorted by name, so sets are already ordered.
186+ return sets, nil
187+}
188+
189+func validateSet(app, container string) error {
190+ if err := domain.ValidateAppName(app); err != nil {
191+ return fmt.Errorf("secret files: %w", err)
192+ }
193+ if container == "" || len(container) > maxContainerName || container == "." || container == ".." ||
194+ filepath.Base(container) != container || strings.ContainsAny(container, "/\\\x00") {
195+ return errors.New("secret files: invalid container name")
196+ }
197+ return nil
198+}
A internal/adapters/out/secretfiles/store_test.go +138 -0
@@ -0,0 +1,138 @@
1+package secretfiles_test
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+)
15+
16+func newStore(t *testing.T) (*secretfiles.Store, string) {
17+ t.Helper()
18+ dataDir := t.TempDir()
19+ store, err := secretfiles.NewStore(dataDir)
20+ require.NoError(t, err)
21+ t.Cleanup(func() { _ = store.Close() })
22+ return store, dataDir
23+}
24+
25+func mode(t *testing.T, p string) os.FileMode {
26+ t.Helper()
27+ info, err := os.Stat(p)
28+ require.NoError(t, err)
29+ return info.Mode().Perm()
30+}
31+
32+func TestStore_WriteModesAndRead(t *testing.T) {
33+ store, dataDir := newStore(t)
34+ ctx := context.Background()
35+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), store.Dir())
36+
37+ paths, err := store.Write(ctx, "blog", "gordon-blog--web--a", map[string]string{"DB": "s3cr3t", "API": "k"})
38+ require.NoError(t, err)
39+ assert.Equal(t, filepath.Join(store.Dir(), "blog", "gordon-blog--web--a", "DB"), paths["DB"])
40+ assert.Len(t, paths, 2)
41+
42+ assert.Equal(t, os.FileMode(0o700), mode(t, store.Dir()))
43+ assert.Equal(t, os.FileMode(0o700), mode(t, filepath.Join(store.Dir(), "blog")))
44+ assert.Equal(t, os.FileMode(0o700), mode(t, filepath.Join(store.Dir(), "blog", "gordon-blog--web--a")))
45+ assert.Equal(t, os.FileMode(0o444), mode(t, paths["DB"]))
46+
47+ got, err := store.Read(ctx, "blog", "gordon-blog--web--a", "DB")
48+ require.NoError(t, err)
49+ assert.Equal(t, "s3cr3t", got)
50+}
51+
52+func TestStore_WriteNeverReplacesExistingContainer(t *testing.T) {
53+ store, _ := newStore(t)
54+ ctx := context.Background()
55+ _, err := store.Write(ctx, "blog", "c1", map[string]string{"K": "live"})
56+ require.NoError(t, err)
57+
58+ _, err = store.Write(ctx, "blog", "c1", map[string]string{"K": "other"})
59+ require.Error(t, err)
60+ assert.NotContains(t, err.Error(), store.Dir())
61+
62+ got, err := store.Read(ctx, "blog", "c1", "K")
63+ require.NoError(t, err)
64+ assert.Equal(t, "live", got, "a live container's files stay intact")
65+}
66+
67+func TestStore_RemoveAndList(t *testing.T) {
68+ store, _ := newStore(t)
69+ ctx := context.Background()
70+ for _, set := range []out.SecretFileSet{{App: "blog", Container: "c2"}, {App: "blog", Container: "c1"}, {App: "alpha", Container: "z"}} {
71+ _, err := store.Write(ctx, set.App, set.Container, map[string]string{"K": "v"})
72+ require.NoError(t, err)
73+ }
74+
75+ sets, err := store.List(ctx)
76+ require.NoError(t, err)
77+ assert.Equal(t, []out.SecretFileSet{{App: "alpha", Container: "z"}, {App: "blog", Container: "c1"}, {App: "blog", Container: "c2"}}, sets)
78+
79+ require.NoError(t, store.Remove(ctx, "blog", "c1"))
80+ assert.NoDirExists(t, filepath.Join(store.Dir(), "blog", "c1"))
81+ assert.DirExists(t, filepath.Join(store.Dir(), "blog", "c2"))
82+
83+ require.NoError(t, store.Remove(ctx, "blog", "c2"))
84+ assert.NoDirExists(t, filepath.Join(store.Dir(), "blog"))
85+
86+ require.NoError(t, store.Remove(ctx, "blog", "missing"))
87+ require.NoError(t, store.Remove(ctx, "ghost", "missing"))
88+
89+ sets, err = store.List(ctx)
90+ require.NoError(t, err)
91+ assert.Equal(t, []out.SecretFileSet{{App: "alpha", Container: "z"}}, sets)
92+}
93+
94+func TestStore_InvalidNamesRejected(t *testing.T) {
95+ store, _ := newStore(t)
96+ ctx := context.Background()
97+ cases := []struct {
98+ name, app, container, key string
99+ }{
100+ {"app traversal", "../x", "c", "K"},
101+ {"app slash", "a/b", "c", "K"},
102+ {"container dot", "blog", ".", "K"},
103+ {"container dotdot", "blog", "..", "K"},
104+ {"container slash", "blog", "a/b", "K"},
105+ {"container empty", "blog", "", "K"},
106+ {"bad key", "blog", "c", "bad-KEY!"},
107+ {"key traversal", "blog", "c", "../K"},
108+ }
109+ for _, tc := range cases {
110+ t.Run(tc.name, func(t *testing.T) {
111+ _, err := store.Write(ctx, tc.app, tc.container, map[string]string{tc.key: "v"})
112+ require.Error(t, err)
113+ assert.NotContains(t, err.Error(), store.Dir())
114+ })
115+ }
116+ sets, err := store.List(ctx)
117+ require.NoError(t, err)
118+ assert.Empty(t, sets)
119+}
120+
121+func TestStore_SymlinkEscapeRefused(t *testing.T) {
122+ store, _ := newStore(t)
123+ outside := t.TempDir()
124+ require.NoError(t, os.Symlink(outside, filepath.Join(store.Dir(), "blog")))
125+
126+ _, err := store.Write(context.Background(), "blog", "c1", map[string]string{"K": "v"})
127+ require.Error(t, err)
128+ entries, err := os.ReadDir(outside)
129+ require.NoError(t, err)
130+ assert.Empty(t, entries)
131+}
132+
133+func TestNewStore_RefusesSymlinkBase(t *testing.T) {
134+ dataDir := t.TempDir()
135+ require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(dataDir, "secrets-run")))
136+ _, err := secretfiles.NewStore(dataDir)
137+ require.Error(t, err)
138+}
@@ -0,0 +1,138 @@
1+package secretfiles_test
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+)
15+
16+func newStore(t *testing.T) (*secretfiles.Store, string) {
17+ t.Helper()
18+ dataDir := t.TempDir()
19+ store, err := secretfiles.NewStore(dataDir)
20+ require.NoError(t, err)
21+ t.Cleanup(func() { _ = store.Close() })
22+ return store, dataDir
23+}
24+
25+func mode(t *testing.T, p string) os.FileMode {
26+ t.Helper()
27+ info, err := os.Stat(p)
28+ require.NoError(t, err)
29+ return info.Mode().Perm()
30+}
31+
32+func TestStore_WriteModesAndRead(t *testing.T) {
33+ store, dataDir := newStore(t)
34+ ctx := context.Background()
35+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), store.Dir())
36+
37+ paths, err := store.Write(ctx, "blog", "gordon-blog--web--a", map[string]string{"DB": "s3cr3t", "API": "k"})
38+ require.NoError(t, err)
39+ assert.Equal(t, filepath.Join(store.Dir(), "blog", "gordon-blog--web--a", "DB"), paths["DB"])
40+ assert.Len(t, paths, 2)
41+
42+ assert.Equal(t, os.FileMode(0o700), mode(t, store.Dir()))
43+ assert.Equal(t, os.FileMode(0o700), mode(t, filepath.Join(store.Dir(), "blog")))
44+ assert.Equal(t, os.FileMode(0o700), mode(t, filepath.Join(store.Dir(), "blog", "gordon-blog--web--a")))
45+ assert.Equal(t, os.FileMode(0o444), mode(t, paths["DB"]))
46+
47+ got, err := store.Read(ctx, "blog", "gordon-blog--web--a", "DB")
48+ require.NoError(t, err)
49+ assert.Equal(t, "s3cr3t", got)
50+}
51+
52+func TestStore_WriteNeverReplacesExistingContainer(t *testing.T) {
53+ store, _ := newStore(t)
54+ ctx := context.Background()
55+ _, err := store.Write(ctx, "blog", "c1", map[string]string{"K": "live"})
56+ require.NoError(t, err)
57+
58+ _, err = store.Write(ctx, "blog", "c1", map[string]string{"K": "other"})
59+ require.Error(t, err)
60+ assert.NotContains(t, err.Error(), store.Dir())
61+
62+ got, err := store.Read(ctx, "blog", "c1", "K")
63+ require.NoError(t, err)
64+ assert.Equal(t, "live", got, "a live container's files stay intact")
65+}
66+
67+func TestStore_RemoveAndList(t *testing.T) {
68+ store, _ := newStore(t)
69+ ctx := context.Background()
70+ for _, set := range []out.SecretFileSet{{App: "blog", Container: "c2"}, {App: "blog", Container: "c1"}, {App: "alpha", Container: "z"}} {
71+ _, err := store.Write(ctx, set.App, set.Container, map[string]string{"K": "v"})
72+ require.NoError(t, err)
73+ }
74+
75+ sets, err := store.List(ctx)
76+ require.NoError(t, err)
77+ assert.Equal(t, []out.SecretFileSet{{App: "alpha", Container: "z"}, {App: "blog", Container: "c1"}, {App: "blog", Container: "c2"}}, sets)
78+
79+ require.NoError(t, store.Remove(ctx, "blog", "c1"))
80+ assert.NoDirExists(t, filepath.Join(store.Dir(), "blog", "c1"))
81+ assert.DirExists(t, filepath.Join(store.Dir(), "blog", "c2"))
82+
83+ require.NoError(t, store.Remove(ctx, "blog", "c2"))
84+ assert.NoDirExists(t, filepath.Join(store.Dir(), "blog"))
85+
86+ require.NoError(t, store.Remove(ctx, "blog", "missing"))
87+ require.NoError(t, store.Remove(ctx, "ghost", "missing"))
88+
89+ sets, err = store.List(ctx)
90+ require.NoError(t, err)
91+ assert.Equal(t, []out.SecretFileSet{{App: "alpha", Container: "z"}}, sets)
92+}
93+
94+func TestStore_InvalidNamesRejected(t *testing.T) {
95+ store, _ := newStore(t)
96+ ctx := context.Background()
97+ cases := []struct {
98+ name, app, container, key string
99+ }{
100+ {"app traversal", "../x", "c", "K"},
101+ {"app slash", "a/b", "c", "K"},
102+ {"container dot", "blog", ".", "K"},
103+ {"container dotdot", "blog", "..", "K"},
104+ {"container slash", "blog", "a/b", "K"},
105+ {"container empty", "blog", "", "K"},
106+ {"bad key", "blog", "c", "bad-KEY!"},
107+ {"key traversal", "blog", "c", "../K"},
108+ }
109+ for _, tc := range cases {
110+ t.Run(tc.name, func(t *testing.T) {
111+ _, err := store.Write(ctx, tc.app, tc.container, map[string]string{tc.key: "v"})
112+ require.Error(t, err)
113+ assert.NotContains(t, err.Error(), store.Dir())
114+ })
115+ }
116+ sets, err := store.List(ctx)
117+ require.NoError(t, err)
118+ assert.Empty(t, sets)
119+}
120+
121+func TestStore_SymlinkEscapeRefused(t *testing.T) {
122+ store, _ := newStore(t)
123+ outside := t.TempDir()
124+ require.NoError(t, os.Symlink(outside, filepath.Join(store.Dir(), "blog")))
125+
126+ _, err := store.Write(context.Background(), "blog", "c1", map[string]string{"K": "v"})
127+ require.Error(t, err)
128+ entries, err := os.ReadDir(outside)
129+ require.NoError(t, err)
130+ assert.Empty(t, entries)
131+}
132+
133+func TestNewStore_RefusesSymlinkBase(t *testing.T) {
134+ dataDir := t.TempDir()
135+ require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(dataDir, "secrets-run")))
136+ _, err := secretfiles.NewStore(dataDir)
137+ require.Error(t, err)
138+}
M internal/adapters/out/secrets/pass.go +3 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/zerowrap"
1313
1414 "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/pkg/childenv"
1516 )
1617
1718 // passPathRegex validates pass paths.
@@ -80,7 +81,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
8081 ctx, cancel := context.WithTimeout(ctx, p.timeout)
8182 defer cancel()
8283
83- cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths
84+ cmd := childenv.CommandContext(ctx, "pass", "show", path)
8485 output, err := cmd.Output()
8586 if err != nil {
8687 if exitError, ok := err.(*exec.ExitError); ok {
@@ -109,6 +110,6 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
109110 func (p *PassProvider) IsAvailable() bool {
110111 ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
111112 defer cancel()
112- cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input
113+ cmd := childenv.CommandContext(ctx, "pass", "version")
113114 return cmd.Run() == nil
114115 }
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/pkg/childenv"
15 )16 )
16 17
17 // passPathRegex validates pass paths.18 // passPathRegex validates pass paths.
@@ -80,7 +81,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
80 ctx, cancel := context.WithTimeout(ctx, p.timeout)81 ctx, cancel := context.WithTimeout(ctx, p.timeout)
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 := childenv.CommandContext(ctx, "pass", "show", path)
84 output, err := cmd.Output()85 output, err := cmd.Output()
85 if err != nil {86 if err != nil {
86 if exitError, ok := err.(*exec.ExitError); ok {87 if exitError, ok := err.(*exec.ExitError); ok {
@@ -109,6 +110,6 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
109 func (p *PassProvider) IsAvailable() bool {110 func (p *PassProvider) IsAvailable() bool {
110 ctx, cancel := context.WithTimeout(context.Background(), p.timeout)111 ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
111 defer cancel()112 defer cancel()
112- cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input113+ cmd := childenv.CommandContext(ctx, "pass", "version")
113 return cmd.Run() == nil114 return cmd.Run() == nil
114 }115 }
M internal/adapters/out/secrets/sops.go +8 -4
@@ -10,6 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/zerowrap"
13+
14+ "github.com/bnema/gordon/pkg/childenv"
1315 )
1416
1517 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
@@ -106,9 +108,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
106108 // Convert dot notation to sops --extract Python dictionary syntax
107109 extractPath := convertToSopsExtractPath(keyPath)
108110
109- // Use sops -d --extract to get specific key
110- // #nosec G204 - filePath and keyPath are validated above
111- cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
111+ // Use sops -d --extract to get specific key; filePath and keyPath are
112+ // validated above.
113+ cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
112114 output, err := cmd.Output()
113115 if err != nil {
114116 if exitError, ok := err.(*exec.ExitError); ok {
@@ -134,7 +136,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
134136
135137 // IsAvailable checks if sops is available in the system.
136138 func (s *SopsProvider) IsAvailable() bool {
137- cmd := exec.Command("sops", "--version")
139+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
140+ defer cancel()
141+ cmd := childenv.CommandContext(ctx, "sops", "--version")
138142 err := cmd.Run()
139143 return err == nil
140144 }
@@ -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/pkg/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).
@@ -106,9 +108,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
106 // Convert dot notation to sops --extract Python dictionary syntax108 // Convert dot notation to sops --extract Python dictionary syntax
107 extractPath := convertToSopsExtractPath(keyPath)109 extractPath := convertToSopsExtractPath(keyPath)
108 110
109- // Use sops -d --extract to get specific key111+ // Use sops -d --extract to get specific key; filePath and keyPath are
110- // #nosec G204 - filePath and keyPath are validated above112+ // validated above.
111- cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)113+ cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
112 output, err := cmd.Output()114 output, err := cmd.Output()
113 if err != nil {115 if err != nil {
114 if exitError, ok := err.(*exec.ExitError); ok {116 if exitError, ok := err.(*exec.ExitError); ok {
@@ -134,7 +136,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
134 136
135 // IsAvailable checks if sops is available in the system.137 // IsAvailable checks if sops is available in the system.
136 func (s *SopsProvider) IsAvailable() bool {138 func (s *SopsProvider) IsAvailable() bool {
137- cmd := exec.Command("sops", "--version")139+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
140+ defer cancel()
141+ cmd := childenv.CommandContext(ctx, "sops", "--version")
138 err := cmd.Run()142 err := cmd.Run()
139 return err == nil143 return err == nil
140 }144 }
M internal/adapters/out/tokenstore/pass.go +9 -9
@@ -5,7 +5,6 @@ import (
55 "context"
66 "encoding/json"
77 "fmt"
8- "os/exec"
98 "regexp"
109 "strings"
1110 "sync"
@@ -15,6 +14,7 @@ import (
1514 "github.com/bnema/zerowrap"
1615
1716 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/pkg/childenv"
1818 )
1919
2020 // ansiRegex matches ANSI escape sequences for stripping from pass output.
@@ -337,7 +337,7 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
337337 defer cancel()
338338
339339 // List all entries under the token path
340- cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant
340+ cmd := childenv.CommandContext(ctx, "pass", "ls", passTokenPath)
341341 output, err := cmd.Output()
342342 if err != nil {
343343 // If the path doesn't exist, return empty list
@@ -482,15 +482,13 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
482482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
483483
484484 // Remove JWT
485- // #nosec G204 -- command and args are fixed; subject is treated as data only.
486- cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
485+ cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
487486 if err := cmd.Run(); err != nil {
488487 return fmt.Errorf("failed to delete token: %w", err)
489488 }
490489
491490 // Remove metadata
492- // #nosec G204 -- command and args are fixed; subject is treated as data only.
493- cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
491+ cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath)
494492 if err := cmd.Run(); err != nil {
495493 s.log.Warn().Err(err).Msg("failed to delete token metadata")
496494 }
@@ -511,7 +509,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
511509
512510 // passInsert inserts a value into pass.
513511 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
512+ cmd := childenv.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
515513 cmd.Stdin = strings.NewReader(value)
516514 output, err := cmd.CombinedOutput()
517515 if err != nil {
@@ -522,7 +520,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
522520
523521 // passShow retrieves a value from pass.
524522 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
523+ cmd := childenv.CommandContext(ctx, "pass", "show", path)
526524 output, err := cmd.Output()
527525 if err != nil {
528526 return "", fmt.Errorf("pass show failed: %w", err)
@@ -548,6 +546,8 @@ func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
548546
549547 // IsAvailable checks if pass is available in the system.
550548 func (s *PassStore) IsAvailable() bool {
551- cmd := exec.Command("pass", "version")
549+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
550+ defer cancel()
551+ cmd := childenv.CommandContext(ctx, "pass", "version")
552552 return cmd.Run() == nil
553553 }
@@ -5,7 +5,6 @@ import (
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7 "fmt"7 "fmt"
8- "os/exec"
9 "regexp"8 "regexp"
10 "strings"9 "strings"
11 "sync"10 "sync"
@@ -15,6 +14,7 @@ import (
15 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
16 15
17 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/pkg/childenv"
18 )18 )
19 19
20 // ansiRegex matches ANSI escape sequences for stripping from pass output.20 // ansiRegex matches ANSI escape sequences for stripping from pass output.
@@ -337,7 +337,7 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
337 defer cancel()337 defer cancel()
338 338
339 // List all entries under the token path339 // List all entries under the token path
340- cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant340+ cmd := childenv.CommandContext(ctx, "pass", "ls", passTokenPath)
341 output, err := cmd.Output()341 output, err := cmd.Output()
342 if err != nil {342 if err != nil {
343 // If the path doesn't exist, return empty list343 // If the path doesn't exist, return empty list
@@ -482,15 +482,13 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
483 483
484 // Remove JWT484 // Remove JWT
485- // #nosec G204 -- command and args are fixed; subject is treated as data only.485+ cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
486- cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
487 if err := cmd.Run(); err != nil {486 if err := cmd.Run(); err != nil {
488 return fmt.Errorf("failed to delete token: %w", err)487 return fmt.Errorf("failed to delete token: %w", err)
489 }488 }
490 489
491 // Remove metadata490 // Remove metadata
492- // #nosec G204 -- command and args are fixed; subject is treated as data only.491+ cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath)
493- cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
494 if err := cmd.Run(); err != nil {492 if err := cmd.Run(); err != nil {
495 s.log.Warn().Err(err).Msg("failed to delete token metadata")493 s.log.Warn().Err(err).Msg("failed to delete token metadata")
496 }494 }
@@ -511,7 +509,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
511 509
512 // passInsert inserts a value into pass.510 // passInsert inserts a value into pass.
513 func (s *PassStore) passInsert(ctx context.Context, path, value string) error {511 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 subjects512+ cmd := childenv.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
515 cmd.Stdin = strings.NewReader(value)513 cmd.Stdin = strings.NewReader(value)
516 output, err := cmd.CombinedOutput()514 output, err := cmd.CombinedOutput()
517 if err != nil {515 if err != nil {
@@ -522,7 +520,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
522 520
523 // passShow retrieves a value from pass.521 // passShow retrieves a value from pass.
524 func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {522 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 subjects523+ cmd := childenv.CommandContext(ctx, "pass", "show", path)
526 output, err := cmd.Output()524 output, err := cmd.Output()
527 if err != nil {525 if err != nil {
528 return "", fmt.Errorf("pass show failed: %w", err)526 return "", fmt.Errorf("pass show failed: %w", err)
@@ -548,6 +546,8 @@ func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
548 546
549 // IsAvailable checks if pass is available in the system.547 // IsAvailable checks if pass is available in the system.
550 func (s *PassStore) IsAvailable() bool {548 func (s *PassStore) IsAvailable() bool {
551- cmd := exec.Command("pass", "version")549+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
550+ defer cancel()
551+ cmd := childenv.CommandContext(ctx, "pass", "version")
552 return cmd.Run() == nil552 return cmd.Run() == nil
553 }553 }
M internal/app/app_wiring.go +11 -0
@@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error {
8888 if err != nil {
8989 return err
9090 }
91+ secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir)
92+ if err != nil {
93+ return si.log.WrapErr(err, "failed to prepare app runtime safety")
94+ }
9195 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{
9296 State: store,
9397 Runtime: si.svc.runtime,
@@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error {
106110 Limits: limits,
107111 ImagePolicy: imagePolicy,
108112 Traffic: si.svc.appTrafficPublisher,
113+ SecretFiles: secretFiles,
114+ Guard: guard,
109115 }, si.log).WithGCBarrier(si.svc.gcBarrier)
116+ // createServices runs before any listener starts, so no deploy can
117+ // race this sweep of secret files left by crashes or downgrades.
118+ if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil {
119+ si.log.Warn().Err(err).Msg("failed to sweep orphan secret files")
120+ }
110121 si.svc.appActivator = apptraffic.NewActivator(si.log)
111122 mountPolicies, err := buildAppMountPolicies(si.cfg)
112123 if err != nil {
@@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error {
88 if err != nil {88 if err != nil {
89 return err89 return err
90 }90 }
91+ secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir)
92+ if err != nil {
93+ return si.log.WrapErr(err, "failed to prepare app runtime safety")
94+ }
91 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{95 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{
92 State: store,96 State: store,
93 Runtime: si.svc.runtime,97 Runtime: si.svc.runtime,
@@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error {
106 Limits: limits,110 Limits: limits,
107 ImagePolicy: imagePolicy,111 ImagePolicy: imagePolicy,
108 Traffic: si.svc.appTrafficPublisher,112 Traffic: si.svc.appTrafficPublisher,
113+ SecretFiles: secretFiles,
114+ Guard: guard,
109 }, si.log).WithGCBarrier(si.svc.gcBarrier)115 }, si.log).WithGCBarrier(si.svc.gcBarrier)
116+ // createServices runs before any listener starts, so no deploy can
117+ // race this sweep of secret files left by crashes or downgrades.
118+ if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil {
119+ si.log.Warn().Err(err).Msg("failed to sweep orphan secret files")
120+ }
110 si.svc.appActivator = apptraffic.NewActivator(si.log)121 si.svc.appActivator = apptraffic.NewActivator(si.log)
111 mountPolicies, err := buildAppMountPolicies(si.cfg)122 mountPolicies, err := buildAppMountPolicies(si.cfg)
112 if err != nil {123 if err != nil {
A internal/app/dumpable_linux.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build linux
2+
3+package app
4+
5+import "golang.org/x/sys/unix"
6+
7+// disableDumpable marks the daemon non-dumpable: no core dumps, and other
8+// processes of the same UID can no longer read /proc/<pid>/environ, mem,
9+// or fds, nor ptrace it. Root keeps that access.
10+func disableDumpable() error {
11+ return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
12+}
@@ -0,0 +1,12 @@
1+//go:build linux
2+
3+package app
4+
5+import "golang.org/x/sys/unix"
6+
7+// disableDumpable marks the daemon non-dumpable: no core dumps, and other
8+// processes of the same UID can no longer read /proc/<pid>/environ, mem,
9+// or fds, nor ptrace it. Root keeps that access.
10+func disableDumpable() error {
11+ return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
12+}
A internal/app/dumpable_linux_test.go +30 -0
@@ -0,0 +1,30 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestDisableDumpable(t *testing.T) {
16+ t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) })
17+
18+ require.NoError(t, disableDumpable())
19+ got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
20+ require.NoError(t, err)
21+ assert.Equal(t, 0, got)
22+
23+ // readFileBeneath reads secrets through /proc/self/fd/N; it must keep
24+ // working once the process is non-dumpable.
25+ root := t.TempDir()
26+ require.NoError(t, os.WriteFile(filepath.Join(root, "secret"), []byte("value"), 0o600))
27+ data, err := readFileBeneath(root, "secret")
28+ require.NoError(t, err)
29+ assert.Equal(t, "value", string(data))
30+}
@@ -0,0 +1,30 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestDisableDumpable(t *testing.T) {
16+ t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) })
17+
18+ require.NoError(t, disableDumpable())
19+ got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
20+ require.NoError(t, err)
21+ assert.Equal(t, 0, got)
22+
23+ // readFileBeneath reads secrets through /proc/self/fd/N; it must keep
24+ // working once the process is non-dumpable.
25+ root := t.TempDir()
26+ require.NoError(t, os.WriteFile(filepath.Join(root, "secret"), []byte("value"), 0o600))
27+ data, err := readFileBeneath(root, "secret")
28+ require.NoError(t, err)
29+ assert.Equal(t, "value", string(data))
30+}
A internal/app/dumpable_other.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build !linux
2+
3+package app
4+
5+// disableDumpable is Linux-only; other platforms keep their defaults.
6+func disableDumpable() error { return nil }
@@ -0,0 +1,6 @@
1+//go:build !linux
2+
3+package app
4+
5+// disableDumpable is Linux-only; other platforms keep their defaults.
6+func disableDumpable() error { return nil }
M internal/app/run.go +6 -0
@@ -32,6 +32,12 @@ import (
3232
3333 // Run initializes and starts the Gordon application.
3434 func Run(ctx context.Context, configPath string) error {
35+ // Before any secret is loaded: keep same-UID processes out of this
36+ // process's /proc entries (environ, mem, fds) and disable core dumps.
37+ if err := disableDumpable(); err != nil {
38+ return fmt.Errorf("failed to disable process dumping: %w", err)
39+ }
40+
3541 // Load configuration
3642 v, cfg, err := initConfig(configPath)
3743 if err != nil {
@@ -32,6 +32,12 @@ import (
32 32
33 // Run initializes and starts the Gordon application.33 // Run initializes and starts the Gordon application.
34 func Run(ctx context.Context, configPath string) error {34 func Run(ctx context.Context, configPath string) error {
35+ // Before any secret is loaded: keep same-UID processes out of this
36+ // process's /proc entries (environ, mem, fds) and disable core dumps.
37+ if err := disableDumpable(); err != nil {
38+ return fmt.Errorf("failed to disable process dumping: %w", err)
39+ }
40+
35 // Load configuration41 // Load configuration
36 v, cfg, err := initConfig(configPath)42 v, cfg, err := initConfig(configPath)
37 if err != nil {43 if err != nil {
A internal/app/runtime_guard.go +222 -0
@@ -0,0 +1,222 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// protectedRuntimePaths lists the host paths no app bind may equal, lie
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 {
20+ runtimeDir := getenv("XDG_RUNTIME_DIR")
21+ configHome := getenv("XDG_CONFIG_HOME")
22+ if configHome == "" {
23+ configHome = subPath(home, ".config")
24+ }
25+ dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://")
26+ if strings.Contains(dockerHost, "://") {
27+ dockerHost = ""
28+ }
29+ raw := append([]string{
30+ dataDir,
31+ "/var/run/docker.sock",
32+ "/run/docker.sock",
33+ "/run/podman/podman.sock",
34+ subPath(runtimeDir, "docker.sock"),
35+ subPath(runtimeDir, "podman", "podman.sock"),
36+ subPath(runtimeDir, "gordon"),
37+ subPath(home, ".gordon", "run"),
38+ dockerHost,
39+ subPath(home, ".local", "share", "containers"),
40+ subPath(home, ".local", "share", "docker"),
41+ subPath(configHome, "containers"),
42+ subPath(home, ".docker"),
43+ subPath(configHome, "systemd", "user"),
44+ getenv("GORDON_CLOUDFLARE_API_TOKEN_FILE"),
45+ getenv("GNUPGHOME"),
46+ subPath(home, ".gnupg"),
47+ getenv("PASSWORD_STORE_DIR"),
48+ subPath(home, ".password-store"),
49+ getenv("SOPS_AGE_KEY_FILE"),
50+ subPath(configHome, "sops", "age"),
51+ }, gordonPaths...)
52+ return absWithResolved(raw)
53+}
54+
55+// absWithResolved makes each non-empty path absolute, adds its
56+// symlink-resolved form when it differs, and returns the sorted set.
57+func absWithResolved(raw []string) []string {
58+ var paths []string
59+ for _, p := range raw {
60+ if p == "" {
61+ continue
62+ }
63+ // A relative data dir or config path is resolved against the
64+ // working directory, as the daemon itself resolves it.
65+ clean, err := filepath.Abs(p)
66+ if err != nil {
67+ continue
68+ }
69+ paths = append(paths, clean)
70+ if resolved := resolveExisting(clean); resolved != clean {
71+ paths = append(paths, resolved)
72+ }
73+ }
74+ slices.Sort(paths)
75+ return slices.Compact(paths)
76+}
77+
78+// resolveExisting resolves symlinks in the longest existing ancestor of p
79+// and re-appends the missing tail, so a path created after startup under
80+// a symlinked parent (for example /home -> /var/home) is still protected
81+// in its resolved form.
82+func resolveExisting(p string) string {
83+ tail := ""
84+ for dir := p; ; dir = filepath.Dir(dir) {
85+ if resolved, err := filepath.EvalSymlinks(dir); err == nil {
86+ return filepath.Join(resolved, tail)
87+ }
88+ parent := filepath.Dir(dir)
89+ if parent == dir {
90+ return p
91+ }
92+ tail = filepath.Join(filepath.Base(dir), tail)
93+ }
94+}
95+
96+// subPath joins elems under base, or returns "" when base is empty.
97+func subPath(base string, elems ...string) string {
98+ if base == "" {
99+ return ""
100+ }
101+ return filepath.Join(append([]string{base}, elems...)...)
102+}
103+
104+// configuredGordonPaths returns the config file, the runtime socket in use,
105+// and every private file or directory the config points Gordon at. Log
106+// paths are left to gordonLogLocations. The public TLS certificate is left out
107+// so its directory (often a shared CA bundle) stays bindable.
108+func configuredGordonPaths(si *serviceInit) []string {
109+ cfg := si.cfg
110+ paths := []string{
111+ cfg.Server.TLSKeyFile,
112+ cfg.Backups.StorageDir,
113+ cfg.Backups.Databases.StorageDir,
114+ }
115+ if si.v != nil {
116+ paths = append(paths, si.v.ConfigFileUsed(), resolveRuntimeConfig(si.v.GetString("server.runtime")))
117+ }
118+ return paths
119+}
120+
121+// logLocation is one enabled Gordon log: the directory a read-only bind
122+// may expose and the exact file or directory Gordon writes.
123+type logLocation struct {
124+ dir string
125+ path string
126+}
127+
128+// readableLogDirs splits the log locations into dirs that may be bound
129+// read-only and exact log paths that must stay protected instead. A dir
130+// under the data dir is readable only at or under <data_dir>/logs; a dir
131+// outside it only when it neither is, contains, nor lies under another
132+// protected path. So a misconfigured log path never opens a read-only
133+// exception on secrets, key stores, or runtime dirs, and a dropped log
134+// stays write-protected.
135+func readableLogDirs(locations []logLocation, protected []string, dataDir string) (readable, keepProtected []string) {
136+ dataDirs := absWithResolved([]string{dataDir})
137+ dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")})
138+ for _, loc := range locations {
139+ if readableLogDir(loc.dir, protected, dataDirs, dataLogs) {
140+ readable = append(readable, loc.dir)
141+ } else {
142+ keepProtected = append(keepProtected, loc.path)
143+ }
144+ }
145+ return readable, keepProtected
146+}
147+
148+func readableLogDir(dir string, protected, dataDirs, dataLogs []string) bool {
149+ underData := slices.ContainsFunc(dataDirs, func(d string) bool { return pathWithin(d, dir) })
150+ if underData && !slices.ContainsFunc(dataLogs, func(l string) bool { return pathWithin(l, dir) }) {
151+ return false
152+ }
153+ for _, p := range protected {
154+ if underData && slices.Contains(dataDirs, p) {
155+ continue
156+ }
157+ if pathWithin(p, dir) || pathWithin(dir, p) {
158+ return false
159+ }
160+ }
161+ return true
162+}
163+
164+// pathWithin reports whether target is root or lies under it.
165+func pathWithin(root, target string) bool {
166+ rel, err := filepath.Rel(root, target)
167+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
168+}
169+
170+// gordonLogLocations returns the enabled file logs: Gordon's process log,
171+// the access log, and the container logs dir, with paths made absolute.
172+func gordonLogLocations(cfg Config, dataDir string) []logLocation {
173+ var locs []logLocation
174+ add := func(dir, path string) {
175+ dir, errDir := filepath.Abs(dir)
176+ path, errPath := filepath.Abs(path)
177+ if errDir == nil && errPath == nil {
178+ locs = append(locs, logLocation{dir: dir, path: path})
179+ }
180+ }
181+ if cfg.Logging.File.Enabled {
182+ p := cfg.Logging.File.Path
183+ if p == "" {
184+ p = filepath.Join(dataDir, "logs", "gordon.log")
185+ }
186+ add(filepath.Dir(p), p)
187+ }
188+ if cfg.Logging.AccessLog.Enabled && cfg.Logging.AccessLog.Output == "file" {
189+ p := cfg.Logging.AccessLog.FilePath
190+ if p == "" {
191+ p = filepath.Join(dataDir, "logs", "access.log")
192+ }
193+ add(filepath.Dir(p), p)
194+ }
195+ if cfg.Logging.ContainerLogs.Enabled {
196+ p := cfg.Logging.ContainerLogs.Dir
197+ if p == "" {
198+ p = filepath.Join(dataDir, "logs", "containers")
199+ }
200+ add(p, p)
201+ }
202+ return locs
203+}
204+
205+// buildAppRuntimeSafety opens the host secret file store and builds the
206+// runtime guard applied to every app container create. The store keeps one
207+// directory handle open for the daemon's lifetime.
208+func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
209+ store, err := secretfiles.NewStore(dataDir)
210+ if err != nil {
211+ return nil, domain.RuntimeGuard{}, err
212+ }
213+ home, _ := os.UserHomeDir()
214+ paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
215+ logDirs, logPaths := readableLogDirs(gordonLogLocations(si.cfg, dataDir), paths, dataDir)
216+ paths = absWithResolved(append(paths, logPaths...))
217+ return store, domain.RuntimeGuard{
218+ ProtectedPaths: paths,
219+ SecretFilesDir: store.Dir(),
220+ LogDirs: absWithResolved(logDirs),
221+ }, nil
222+}
@@ -0,0 +1,222 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// protectedRuntimePaths lists the host paths no app bind may equal, lie
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 {
20+ runtimeDir := getenv("XDG_RUNTIME_DIR")
21+ configHome := getenv("XDG_CONFIG_HOME")
22+ if configHome == "" {
23+ configHome = subPath(home, ".config")
24+ }
25+ dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://")
26+ if strings.Contains(dockerHost, "://") {
27+ dockerHost = ""
28+ }
29+ raw := append([]string{
30+ dataDir,
31+ "/var/run/docker.sock",
32+ "/run/docker.sock",
33+ "/run/podman/podman.sock",
34+ subPath(runtimeDir, "docker.sock"),
35+ subPath(runtimeDir, "podman", "podman.sock"),
36+ subPath(runtimeDir, "gordon"),
37+ subPath(home, ".gordon", "run"),
38+ dockerHost,
39+ subPath(home, ".local", "share", "containers"),
40+ subPath(home, ".local", "share", "docker"),
41+ subPath(configHome, "containers"),
42+ subPath(home, ".docker"),
43+ subPath(configHome, "systemd", "user"),
44+ getenv("GORDON_CLOUDFLARE_API_TOKEN_FILE"),
45+ getenv("GNUPGHOME"),
46+ subPath(home, ".gnupg"),
47+ getenv("PASSWORD_STORE_DIR"),
48+ subPath(home, ".password-store"),
49+ getenv("SOPS_AGE_KEY_FILE"),
50+ subPath(configHome, "sops", "age"),
51+ }, gordonPaths...)
52+ return absWithResolved(raw)
53+}
54+
55+// absWithResolved makes each non-empty path absolute, adds its
56+// symlink-resolved form when it differs, and returns the sorted set.
57+func absWithResolved(raw []string) []string {
58+ var paths []string
59+ for _, p := range raw {
60+ if p == "" {
61+ continue
62+ }
63+ // A relative data dir or config path is resolved against the
64+ // working directory, as the daemon itself resolves it.
65+ clean, err := filepath.Abs(p)
66+ if err != nil {
67+ continue
68+ }
69+ paths = append(paths, clean)
70+ if resolved := resolveExisting(clean); resolved != clean {
71+ paths = append(paths, resolved)
72+ }
73+ }
74+ slices.Sort(paths)
75+ return slices.Compact(paths)
76+}
77+
78+// resolveExisting resolves symlinks in the longest existing ancestor of p
79+// and re-appends the missing tail, so a path created after startup under
80+// a symlinked parent (for example /home -> /var/home) is still protected
81+// in its resolved form.
82+func resolveExisting(p string) string {
83+ tail := ""
84+ for dir := p; ; dir = filepath.Dir(dir) {
85+ if resolved, err := filepath.EvalSymlinks(dir); err == nil {
86+ return filepath.Join(resolved, tail)
87+ }
88+ parent := filepath.Dir(dir)
89+ if parent == dir {
90+ return p
91+ }
92+ tail = filepath.Join(filepath.Base(dir), tail)
93+ }
94+}
95+
96+// subPath joins elems under base, or returns "" when base is empty.
97+func subPath(base string, elems ...string) string {
98+ if base == "" {
99+ return ""
100+ }
101+ return filepath.Join(append([]string{base}, elems...)...)
102+}
103+
104+// configuredGordonPaths returns the config file, the runtime socket in use,
105+// and every private file or directory the config points Gordon at. Log
106+// paths are left to gordonLogLocations. The public TLS certificate is left out
107+// so its directory (often a shared CA bundle) stays bindable.
108+func configuredGordonPaths(si *serviceInit) []string {
109+ cfg := si.cfg
110+ paths := []string{
111+ cfg.Server.TLSKeyFile,
112+ cfg.Backups.StorageDir,
113+ cfg.Backups.Databases.StorageDir,
114+ }
115+ if si.v != nil {
116+ paths = append(paths, si.v.ConfigFileUsed(), resolveRuntimeConfig(si.v.GetString("server.runtime")))
117+ }
118+ return paths
119+}
120+
121+// logLocation is one enabled Gordon log: the directory a read-only bind
122+// may expose and the exact file or directory Gordon writes.
123+type logLocation struct {
124+ dir string
125+ path string
126+}
127+
128+// readableLogDirs splits the log locations into dirs that may be bound
129+// read-only and exact log paths that must stay protected instead. A dir
130+// under the data dir is readable only at or under <data_dir>/logs; a dir
131+// outside it only when it neither is, contains, nor lies under another
132+// protected path. So a misconfigured log path never opens a read-only
133+// exception on secrets, key stores, or runtime dirs, and a dropped log
134+// stays write-protected.
135+func readableLogDirs(locations []logLocation, protected []string, dataDir string) (readable, keepProtected []string) {
136+ dataDirs := absWithResolved([]string{dataDir})
137+ dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")})
138+ for _, loc := range locations {
139+ if readableLogDir(loc.dir, protected, dataDirs, dataLogs) {
140+ readable = append(readable, loc.dir)
141+ } else {
142+ keepProtected = append(keepProtected, loc.path)
143+ }
144+ }
145+ return readable, keepProtected
146+}
147+
148+func readableLogDir(dir string, protected, dataDirs, dataLogs []string) bool {
149+ underData := slices.ContainsFunc(dataDirs, func(d string) bool { return pathWithin(d, dir) })
150+ if underData && !slices.ContainsFunc(dataLogs, func(l string) bool { return pathWithin(l, dir) }) {
151+ return false
152+ }
153+ for _, p := range protected {
154+ if underData && slices.Contains(dataDirs, p) {
155+ continue
156+ }
157+ if pathWithin(p, dir) || pathWithin(dir, p) {
158+ return false
159+ }
160+ }
161+ return true
162+}
163+
164+// pathWithin reports whether target is root or lies under it.
165+func pathWithin(root, target string) bool {
166+ rel, err := filepath.Rel(root, target)
167+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
168+}
169+
170+// gordonLogLocations returns the enabled file logs: Gordon's process log,
171+// the access log, and the container logs dir, with paths made absolute.
172+func gordonLogLocations(cfg Config, dataDir string) []logLocation {
173+ var locs []logLocation
174+ add := func(dir, path string) {
175+ dir, errDir := filepath.Abs(dir)
176+ path, errPath := filepath.Abs(path)
177+ if errDir == nil && errPath == nil {
178+ locs = append(locs, logLocation{dir: dir, path: path})
179+ }
180+ }
181+ if cfg.Logging.File.Enabled {
182+ p := cfg.Logging.File.Path
183+ if p == "" {
184+ p = filepath.Join(dataDir, "logs", "gordon.log")
185+ }
186+ add(filepath.Dir(p), p)
187+ }
188+ if cfg.Logging.AccessLog.Enabled && cfg.Logging.AccessLog.Output == "file" {
189+ p := cfg.Logging.AccessLog.FilePath
190+ if p == "" {
191+ p = filepath.Join(dataDir, "logs", "access.log")
192+ }
193+ add(filepath.Dir(p), p)
194+ }
195+ if cfg.Logging.ContainerLogs.Enabled {
196+ p := cfg.Logging.ContainerLogs.Dir
197+ if p == "" {
198+ p = filepath.Join(dataDir, "logs", "containers")
199+ }
200+ add(p, p)
201+ }
202+ return locs
203+}
204+
205+// buildAppRuntimeSafety opens the host secret file store and builds the
206+// runtime guard applied to every app container create. The store keeps one
207+// directory handle open for the daemon's lifetime.
208+func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
209+ store, err := secretfiles.NewStore(dataDir)
210+ if err != nil {
211+ return nil, domain.RuntimeGuard{}, err
212+ }
213+ home, _ := os.UserHomeDir()
214+ paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
215+ logDirs, logPaths := readableLogDirs(gordonLogLocations(si.cfg, dataDir), paths, dataDir)
216+ paths = absWithResolved(append(paths, logPaths...))
217+ return store, domain.RuntimeGuard{
218+ ProtectedPaths: paths,
219+ SecretFilesDir: store.Dir(),
220+ LogDirs: absWithResolved(logDirs),
221+ }, nil
222+}
A internal/app/runtime_guard_test.go +188 -0
@@ -0,0 +1,188 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func fakeEnv(values map[string]string) func(string) string {
16+ return func(key string) string { return values[key] }
17+}
18+
19+func TestProtectedRuntimePaths_AllSources(t *testing.T) {
20+ home := t.TempDir()
21+ env := fakeEnv(map[string]string{
22+ "XDG_RUNTIME_DIR": "/run/user/1000",
23+ "DOCKER_HOST": "unix:///x/d.sock",
24+ "GNUPGHOME": "/keys/gnupg",
25+ "PASSWORD_STORE_DIR": "/keys/pass",
26+ "SOPS_AGE_KEY_FILE": "/keys/age.txt",
27+ "XDG_CONFIG_HOME": "/cfg",
28+
29+ "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token",
30+ })
31+ got := protectedRuntimePaths("/data/gordon", []string{"/etc/gordon/gordon.toml", "/custom/podman.sock", ""}, env, home)
32+
33+ for _, want := range []string{
34+ "/data/gordon",
35+ "/etc/gordon/gordon.toml",
36+ "/var/run/docker.sock",
37+ "/run/docker.sock",
38+ "/run/podman/podman.sock",
39+ "/run/user/1000/docker.sock",
40+ "/run/user/1000/podman/podman.sock",
41+ "/run/user/1000/gordon",
42+ filepath.Join(home, ".gordon", "run"),
43+ "/x/d.sock",
44+ "/custom/podman.sock",
45+ "/keys/gnupg",
46+ filepath.Join(home, ".gnupg"),
47+ "/keys/pass",
48+ filepath.Join(home, ".password-store"),
49+ "/keys/age.txt",
50+ "/cfg/sops/age",
51+ "/keys/cf-token",
52+ filepath.Join(home, ".local", "share", "containers"),
53+ filepath.Join(home, ".local", "share", "docker"),
54+ "/cfg/containers",
55+ filepath.Join(home, ".docker"),
56+ "/cfg/systemd/user",
57+ } {
58+ assert.Contains(t, got, want)
59+ }
60+ assert.IsIncreasing(t, got)
61+}
62+
63+func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) {
64+ // Subset, not Equal: on hosts where a fixed path crosses a symlink
65+ // (for example /var -> /private/var) its resolved form is added too.
66+ got := protectedRuntimePaths("/data", nil, fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "")
67+ assert.Subset(t, got, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"})
68+ for _, p := range got {
69+ assert.NotContains(t, p, "tcp:")
70+ assert.NotContains(t, p, "h:2375")
71+ }
72+}
73+
74+func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
75+ base := t.TempDir()
76+ real := filepath.Join(base, "real")
77+ require.NoError(t, os.Mkdir(real, 0o700))
78+ link := filepath.Join(base, "link")
79+ require.NoError(t, os.Symlink(real, link))
80+ realResolved, err := filepath.EvalSymlinks(real)
81+ require.NoError(t, err)
82+
83+ got := protectedRuntimePaths(link, nil, fakeEnv(nil), "")
84+ assert.Contains(t, got, link)
85+ assert.Contains(t, got, realResolved)
86+
87+ // A protected path that does not exist yet under a symlinked parent
88+ // is protected in its resolved form too.
89+ missing := filepath.Join(link, "later", "store")
90+ got = protectedRuntimePaths("/data", []string{missing}, fakeEnv(nil), "")
91+ assert.Contains(t, got, missing)
92+ assert.Contains(t, got, filepath.Join(realResolved, "later", "store"))
93+}
94+
95+func TestConfiguredGordonPaths(t *testing.T) {
96+ si := &serviceInit{}
97+ si.cfg.Server.TLSKeyFile = "/tls/key.pem"
98+ si.cfg.Server.DataDir = "/data"
99+ si.cfg.Logging.AccessLog.FilePath = "/logs/access.log"
100+ si.cfg.Backups.Databases.StorageDir = "/backups"
101+
102+ got := configuredGordonPaths(si)
103+ assert.Contains(t, got, "/tls/key.pem")
104+ assert.Contains(t, got, "/backups")
105+ assert.NotContains(t, got, "/logs/access.log")
106+}
107+
108+func TestGordonLogLocations(t *testing.T) {
109+ var cfg Config
110+ cfg.Server.DataDir = "/data"
111+ cfg.Logging.AccessLog.FilePath = "/logs/access.log"
112+ assert.Empty(t, gordonLogLocations(cfg, "/data"), "disabled logs grant nothing")
113+
114+ cfg.Logging.File.Enabled = true
115+ cfg.Logging.AccessLog.Enabled = true
116+ cfg.Logging.AccessLog.Output = "file"
117+ cfg.Logging.ContainerLogs.Enabled = true
118+ assert.ElementsMatch(t, []logLocation{
119+ {dir: "/data/logs", path: "/data/logs/gordon.log"},
120+ {dir: "/logs", path: "/logs/access.log"},
121+ {dir: "/data/logs/containers", path: "/data/logs/containers"},
122+ }, gordonLogLocations(cfg, "/data"))
123+
124+ cfg.Logging.AccessLog.Output = "stdout"
125+ assert.Len(t, gordonLogLocations(cfg, "/data"), 2, "non-file access log grants nothing")
126+}
127+
128+func TestReadableLogDirs(t *testing.T) {
129+ protected := []string{"/data", "/home/u", "/home/u/.gnupg", "/srv/logs/keys"}
130+ loc := func(dir string) logLocation { return logLocation{dir: dir, path: dir + "/x.log"} }
131+ readable, keep := readableLogDirs([]logLocation{
132+ loc("/data/logs"),
133+ loc("/data/logs/containers"),
134+ loc("/data"), // data dir root
135+ loc("/data/secrets-run"), // data child outside logs
136+ loc("/data/secrets"), // data child outside logs
137+ loc("/home/u"), // protected itself
138+ loc("/home/u/.gnupg/log"), // under a key store
139+ loc("/srv/logs"), // contains a protected path
140+ loc("/srv"), // ancestor of a protected path
141+ loc("/var/log/gordon"),
142+ }, protected, "/data")
143+ assert.Equal(t, []string{"/data/logs", "/data/logs/containers", "/var/log/gordon"}, readable)
144+ assert.Equal(t, []string{
145+ "/data/x.log", "/data/secrets-run/x.log", "/data/secrets/x.log", "/home/u/x.log",
146+ "/home/u/.gnupg/log/x.log", "/srv/logs/x.log", "/srv/x.log",
147+ }, keep)
148+}
149+
150+func TestBuildAppRuntimeSafety_DroppedLogStaysProtected(t *testing.T) {
151+ home := t.TempDir()
152+ t.Setenv("HOME", home)
153+ si := &serviceInit{}
154+ si.cfg.Logging.File.Enabled = true
155+ si.cfg.Logging.File.Path = filepath.Join(home, "gordon.log")
156+ store, guard, err := buildAppRuntimeSafety(si, t.TempDir())
157+ require.NoError(t, err)
158+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
159+
160+ // $HOME contains key stores, so it is no readable log dir, and the
161+ // log file itself stays protected even against a writable bind.
162+ assert.NotContains(t, guard.LogDirs, home)
163+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "log", Source: filepath.Join(home, "gordon.log"), Destination: "/log"}}})
164+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
165+}
166+
167+func TestBuildAppRuntimeSafety(t *testing.T) {
168+ dataDir := t.TempDir()
169+ si := &serviceInit{}
170+ si.cfg.Logging.ContainerLogs.Enabled = true
171+ si.cfg.Logging.File.Enabled = true
172+ store, guard, err := buildAppRuntimeSafety(si, dataDir)
173+ require.NoError(t, err)
174+ require.NotNil(t, store)
175+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
176+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir)
177+ assert.Contains(t, guard.ProtectedPaths, dataDir)
178+
179+ // The data dir itself cannot be bound into an app.
180+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
181+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
182+
183+ // The default log dir under the data dir is readable, never writable.
184+ logs := filepath.Join(dataDir, "logs")
185+ require.NoError(t, guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon", ReadOnly: true}}}))
186+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon"}}})
187+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
188+}
@@ -0,0 +1,188 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func fakeEnv(values map[string]string) func(string) string {
16+ return func(key string) string { return values[key] }
17+}
18+
19+func TestProtectedRuntimePaths_AllSources(t *testing.T) {
20+ home := t.TempDir()
21+ env := fakeEnv(map[string]string{
22+ "XDG_RUNTIME_DIR": "/run/user/1000",
23+ "DOCKER_HOST": "unix:///x/d.sock",
24+ "GNUPGHOME": "/keys/gnupg",
25+ "PASSWORD_STORE_DIR": "/keys/pass",
26+ "SOPS_AGE_KEY_FILE": "/keys/age.txt",
27+ "XDG_CONFIG_HOME": "/cfg",
28+
29+ "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token",
30+ })
31+ got := protectedRuntimePaths("/data/gordon", []string{"/etc/gordon/gordon.toml", "/custom/podman.sock", ""}, env, home)
32+
33+ for _, want := range []string{
34+ "/data/gordon",
35+ "/etc/gordon/gordon.toml",
36+ "/var/run/docker.sock",
37+ "/run/docker.sock",
38+ "/run/podman/podman.sock",
39+ "/run/user/1000/docker.sock",
40+ "/run/user/1000/podman/podman.sock",
41+ "/run/user/1000/gordon",
42+ filepath.Join(home, ".gordon", "run"),
43+ "/x/d.sock",
44+ "/custom/podman.sock",
45+ "/keys/gnupg",
46+ filepath.Join(home, ".gnupg"),
47+ "/keys/pass",
48+ filepath.Join(home, ".password-store"),
49+ "/keys/age.txt",
50+ "/cfg/sops/age",
51+ "/keys/cf-token",
52+ filepath.Join(home, ".local", "share", "containers"),
53+ filepath.Join(home, ".local", "share", "docker"),
54+ "/cfg/containers",
55+ filepath.Join(home, ".docker"),
56+ "/cfg/systemd/user",
57+ } {
58+ assert.Contains(t, got, want)
59+ }
60+ assert.IsIncreasing(t, got)
61+}
62+
63+func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) {
64+ // Subset, not Equal: on hosts where a fixed path crosses a symlink
65+ // (for example /var -> /private/var) its resolved form is added too.
66+ got := protectedRuntimePaths("/data", nil, fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "")
67+ assert.Subset(t, got, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"})
68+ for _, p := range got {
69+ assert.NotContains(t, p, "tcp:")
70+ assert.NotContains(t, p, "h:2375")
71+ }
72+}
73+
74+func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
75+ base := t.TempDir()
76+ real := filepath.Join(base, "real")
77+ require.NoError(t, os.Mkdir(real, 0o700))
78+ link := filepath.Join(base, "link")
79+ require.NoError(t, os.Symlink(real, link))
80+ realResolved, err := filepath.EvalSymlinks(real)
81+ require.NoError(t, err)
82+
83+ got := protectedRuntimePaths(link, nil, fakeEnv(nil), "")
84+ assert.Contains(t, got, link)
85+ assert.Contains(t, got, realResolved)
86+
87+ // A protected path that does not exist yet under a symlinked parent
88+ // is protected in its resolved form too.
89+ missing := filepath.Join(link, "later", "store")
90+ got = protectedRuntimePaths("/data", []string{missing}, fakeEnv(nil), "")
91+ assert.Contains(t, got, missing)
92+ assert.Contains(t, got, filepath.Join(realResolved, "later", "store"))
93+}
94+
95+func TestConfiguredGordonPaths(t *testing.T) {
96+ si := &serviceInit{}
97+ si.cfg.Server.TLSKeyFile = "/tls/key.pem"
98+ si.cfg.Server.DataDir = "/data"
99+ si.cfg.Logging.AccessLog.FilePath = "/logs/access.log"
100+ si.cfg.Backups.Databases.StorageDir = "/backups"
101+
102+ got := configuredGordonPaths(si)
103+ assert.Contains(t, got, "/tls/key.pem")
104+ assert.Contains(t, got, "/backups")
105+ assert.NotContains(t, got, "/logs/access.log")
106+}
107+
108+func TestGordonLogLocations(t *testing.T) {
109+ var cfg Config
110+ cfg.Server.DataDir = "/data"
111+ cfg.Logging.AccessLog.FilePath = "/logs/access.log"
112+ assert.Empty(t, gordonLogLocations(cfg, "/data"), "disabled logs grant nothing")
113+
114+ cfg.Logging.File.Enabled = true
115+ cfg.Logging.AccessLog.Enabled = true
116+ cfg.Logging.AccessLog.Output = "file"
117+ cfg.Logging.ContainerLogs.Enabled = true
118+ assert.ElementsMatch(t, []logLocation{
119+ {dir: "/data/logs", path: "/data/logs/gordon.log"},
120+ {dir: "/logs", path: "/logs/access.log"},
121+ {dir: "/data/logs/containers", path: "/data/logs/containers"},
122+ }, gordonLogLocations(cfg, "/data"))
123+
124+ cfg.Logging.AccessLog.Output = "stdout"
125+ assert.Len(t, gordonLogLocations(cfg, "/data"), 2, "non-file access log grants nothing")
126+}
127+
128+func TestReadableLogDirs(t *testing.T) {
129+ protected := []string{"/data", "/home/u", "/home/u/.gnupg", "/srv/logs/keys"}
130+ loc := func(dir string) logLocation { return logLocation{dir: dir, path: dir + "/x.log"} }
131+ readable, keep := readableLogDirs([]logLocation{
132+ loc("/data/logs"),
133+ loc("/data/logs/containers"),
134+ loc("/data"), // data dir root
135+ loc("/data/secrets-run"), // data child outside logs
136+ loc("/data/secrets"), // data child outside logs
137+ loc("/home/u"), // protected itself
138+ loc("/home/u/.gnupg/log"), // under a key store
139+ loc("/srv/logs"), // contains a protected path
140+ loc("/srv"), // ancestor of a protected path
141+ loc("/var/log/gordon"),
142+ }, protected, "/data")
143+ assert.Equal(t, []string{"/data/logs", "/data/logs/containers", "/var/log/gordon"}, readable)
144+ assert.Equal(t, []string{
145+ "/data/x.log", "/data/secrets-run/x.log", "/data/secrets/x.log", "/home/u/x.log",
146+ "/home/u/.gnupg/log/x.log", "/srv/logs/x.log", "/srv/x.log",
147+ }, keep)
148+}
149+
150+func TestBuildAppRuntimeSafety_DroppedLogStaysProtected(t *testing.T) {
151+ home := t.TempDir()
152+ t.Setenv("HOME", home)
153+ si := &serviceInit{}
154+ si.cfg.Logging.File.Enabled = true
155+ si.cfg.Logging.File.Path = filepath.Join(home, "gordon.log")
156+ store, guard, err := buildAppRuntimeSafety(si, t.TempDir())
157+ require.NoError(t, err)
158+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
159+
160+ // $HOME contains key stores, so it is no readable log dir, and the
161+ // log file itself stays protected even against a writable bind.
162+ assert.NotContains(t, guard.LogDirs, home)
163+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "log", Source: filepath.Join(home, "gordon.log"), Destination: "/log"}}})
164+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
165+}
166+
167+func TestBuildAppRuntimeSafety(t *testing.T) {
168+ dataDir := t.TempDir()
169+ si := &serviceInit{}
170+ si.cfg.Logging.ContainerLogs.Enabled = true
171+ si.cfg.Logging.File.Enabled = true
172+ store, guard, err := buildAppRuntimeSafety(si, dataDir)
173+ require.NoError(t, err)
174+ require.NotNil(t, store)
175+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
176+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir)
177+ assert.Contains(t, guard.ProtectedPaths, dataDir)
178+
179+ // The data dir itself cannot be bound into an app.
180+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
181+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
182+
183+ // The default log dir under the data dir is readable, never writable.
184+ logs := filepath.Join(dataDir, "logs")
185+ require.NoError(t, guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon", ReadOnly: true}}}))
186+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon"}}})
187+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
188+}
A internal/boundaries/out/mocks/mock_secret_file_store.go +331 -0
@@ -0,0 +1,331 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockSecretFileStore creates a new instance of MockSecretFileStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockSecretFileStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockSecretFileStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MockSecretFileStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MockSecretFileStore is an autogenerated mock type for the SecretFileStore type
38+type MockSecretFileStore struct {
39+ mock.Mock
40+}
41+
42+type MockSecretFileStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MockSecretFileStore) EXPECT() *MockSecretFileStore_Expecter {
47+ return &MockSecretFileStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// List provides a mock function for the type MockSecretFileStore
51+func (_mock *MockSecretFileStore) List(ctx context.Context) ([]out.SecretFileSet, error) {
52+ ret := _mock.Called(ctx)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for List")
56+ }
57+
58+ var r0 []out.SecretFileSet
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.SecretFileSet, error)); ok {
61+ return returnFunc(ctx)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context) []out.SecretFileSet); ok {
64+ r0 = returnFunc(ctx)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]out.SecretFileSet)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// MockSecretFileStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
79+type MockSecretFileStore_List_Call struct {
80+ *mock.Call
81+}
82+
83+// List is a helper method to define mock.On call
84+// - ctx context.Context
85+func (_e *MockSecretFileStore_Expecter) List(ctx any) *MockSecretFileStore_List_Call {
86+ return &MockSecretFileStore_List_Call{Call: _e.mock.On("List", ctx)}
87+}
88+
89+func (_c *MockSecretFileStore_List_Call) Run(run func(ctx context.Context)) *MockSecretFileStore_List_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ run(
96+ arg0,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockSecretFileStore_List_Call) Return(secretFileSets []out.SecretFileSet, err error) *MockSecretFileStore_List_Call {
103+ _c.Call.Return(secretFileSets, err)
104+ return _c
105+}
106+
107+func (_c *MockSecretFileStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]out.SecretFileSet, error)) *MockSecretFileStore_List_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// Read provides a mock function for the type MockSecretFileStore
113+func (_mock *MockSecretFileStore) Read(ctx context.Context, app string, container string, key string) (string, error) {
114+ ret := _mock.Called(ctx, app, container, key)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for Read")
118+ }
119+
120+ var r0 string
121+ var r1 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (string, error)); ok {
123+ return returnFunc(ctx, app, container, key)
124+ }
125+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) string); ok {
126+ r0 = returnFunc(ctx, app, container, key)
127+ } else {
128+ r0 = ret.Get(0).(string)
129+ }
130+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
131+ r1 = returnFunc(ctx, app, container, key)
132+ } else {
133+ r1 = ret.Error(1)
134+ }
135+ return r0, r1
136+}
137+
138+// MockSecretFileStore_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
139+type MockSecretFileStore_Read_Call struct {
140+ *mock.Call
141+}
142+
143+// Read is a helper method to define mock.On call
144+// - ctx context.Context
145+// - app string
146+// - container string
147+// - key string
148+func (_e *MockSecretFileStore_Expecter) Read(ctx any, app any, container any, key any) *MockSecretFileStore_Read_Call {
149+ return &MockSecretFileStore_Read_Call{Call: _e.mock.On("Read", ctx, app, container, key)}
150+}
151+
152+func (_c *MockSecretFileStore_Read_Call) Run(run func(ctx context.Context, app string, container string, key string)) *MockSecretFileStore_Read_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 string
159+ if args[1] != nil {
160+ arg1 = args[1].(string)
161+ }
162+ var arg2 string
163+ if args[2] != nil {
164+ arg2 = args[2].(string)
165+ }
166+ var arg3 string
167+ if args[3] != nil {
168+ arg3 = args[3].(string)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ arg3,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *MockSecretFileStore_Read_Call) Return(s string, err error) *MockSecretFileStore_Read_Call {
181+ _c.Call.Return(s, err)
182+ return _c
183+}
184+
185+func (_c *MockSecretFileStore_Read_Call) RunAndReturn(run func(ctx context.Context, app string, container string, key string) (string, error)) *MockSecretFileStore_Read_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
189+
190+// Remove provides a mock function for the type MockSecretFileStore
191+func (_mock *MockSecretFileStore) Remove(ctx context.Context, app string, container string) error {
192+ ret := _mock.Called(ctx, app, container)
193+
194+ if len(ret) == 0 {
195+ panic("no return value specified for Remove")
196+ }
197+
198+ var r0 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
200+ r0 = returnFunc(ctx, app, container)
201+ } else {
202+ r0 = ret.Error(0)
203+ }
204+ return r0
205+}
206+
207+// MockSecretFileStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
208+type MockSecretFileStore_Remove_Call struct {
209+ *mock.Call
210+}
211+
212+// Remove is a helper method to define mock.On call
213+// - ctx context.Context
214+// - app string
215+// - container string
216+func (_e *MockSecretFileStore_Expecter) Remove(ctx any, app any, container any) *MockSecretFileStore_Remove_Call {
217+ return &MockSecretFileStore_Remove_Call{Call: _e.mock.On("Remove", ctx, app, container)}
218+}
219+
220+func (_c *MockSecretFileStore_Remove_Call) Run(run func(ctx context.Context, app string, container string)) *MockSecretFileStore_Remove_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 context.Context
223+ if args[0] != nil {
224+ arg0 = args[0].(context.Context)
225+ }
226+ var arg1 string
227+ if args[1] != nil {
228+ arg1 = args[1].(string)
229+ }
230+ var arg2 string
231+ if args[2] != nil {
232+ arg2 = args[2].(string)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ arg2,
238+ )
239+ })
240+ return _c
241+}
242+
243+func (_c *MockSecretFileStore_Remove_Call) Return(err error) *MockSecretFileStore_Remove_Call {
244+ _c.Call.Return(err)
245+ return _c
246+}
247+
248+func (_c *MockSecretFileStore_Remove_Call) RunAndReturn(run func(ctx context.Context, app string, container string) error) *MockSecretFileStore_Remove_Call {
249+ _c.Call.Return(run)
250+ return _c
251+}
252+
253+// Write provides a mock function for the type MockSecretFileStore
254+func (_mock *MockSecretFileStore) Write(ctx context.Context, app string, container string, values map[string]string) (map[string]string, error) {
255+ ret := _mock.Called(ctx, app, container, values)
256+
257+ if len(ret) == 0 {
258+ panic("no return value specified for Write")
259+ }
260+
261+ var r0 map[string]string
262+ var r1 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) (map[string]string, error)); ok {
264+ return returnFunc(ctx, app, container, values)
265+ }
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) map[string]string); ok {
267+ r0 = returnFunc(ctx, app, container, values)
268+ } else {
269+ if ret.Get(0) != nil {
270+ r0 = ret.Get(0).(map[string]string)
271+ }
272+ }
273+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, map[string]string) error); ok {
274+ r1 = returnFunc(ctx, app, container, values)
275+ } else {
276+ r1 = ret.Error(1)
277+ }
278+ return r0, r1
279+}
280+
281+// MockSecretFileStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
282+type MockSecretFileStore_Write_Call struct {
283+ *mock.Call
284+}
285+
286+// Write is a helper method to define mock.On call
287+// - ctx context.Context
288+// - app string
289+// - container string
290+// - values map[string]string
291+func (_e *MockSecretFileStore_Expecter) Write(ctx any, app any, container any, values any) *MockSecretFileStore_Write_Call {
292+ return &MockSecretFileStore_Write_Call{Call: _e.mock.On("Write", ctx, app, container, values)}
293+}
294+
295+func (_c *MockSecretFileStore_Write_Call) Run(run func(ctx context.Context, app string, container string, values map[string]string)) *MockSecretFileStore_Write_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 string
302+ if args[1] != nil {
303+ arg1 = args[1].(string)
304+ }
305+ var arg2 string
306+ if args[2] != nil {
307+ arg2 = args[2].(string)
308+ }
309+ var arg3 map[string]string
310+ if args[3] != nil {
311+ arg3 = args[3].(map[string]string)
312+ }
313+ run(
314+ arg0,
315+ arg1,
316+ arg2,
317+ arg3,
318+ )
319+ })
320+ return _c
321+}
322+
323+func (_c *MockSecretFileStore_Write_Call) Return(stringToString map[string]string, err error) *MockSecretFileStore_Write_Call {
324+ _c.Call.Return(stringToString, err)
325+ return _c
326+}
327+
328+func (_c *MockSecretFileStore_Write_Call) RunAndReturn(run func(ctx context.Context, app string, container string, values map[string]string) (map[string]string, error)) *MockSecretFileStore_Write_Call {
329+ _c.Call.Return(run)
330+ return _c
331+}
@@ -0,0 +1,331 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockSecretFileStore creates a new instance of MockSecretFileStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockSecretFileStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockSecretFileStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MockSecretFileStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MockSecretFileStore is an autogenerated mock type for the SecretFileStore type
38+type MockSecretFileStore struct {
39+ mock.Mock
40+}
41+
42+type MockSecretFileStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MockSecretFileStore) EXPECT() *MockSecretFileStore_Expecter {
47+ return &MockSecretFileStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// List provides a mock function for the type MockSecretFileStore
51+func (_mock *MockSecretFileStore) List(ctx context.Context) ([]out.SecretFileSet, error) {
52+ ret := _mock.Called(ctx)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for List")
56+ }
57+
58+ var r0 []out.SecretFileSet
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.SecretFileSet, error)); ok {
61+ return returnFunc(ctx)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context) []out.SecretFileSet); ok {
64+ r0 = returnFunc(ctx)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]out.SecretFileSet)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// MockSecretFileStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
79+type MockSecretFileStore_List_Call struct {
80+ *mock.Call
81+}
82+
83+// List is a helper method to define mock.On call
84+// - ctx context.Context
85+func (_e *MockSecretFileStore_Expecter) List(ctx any) *MockSecretFileStore_List_Call {
86+ return &MockSecretFileStore_List_Call{Call: _e.mock.On("List", ctx)}
87+}
88+
89+func (_c *MockSecretFileStore_List_Call) Run(run func(ctx context.Context)) *MockSecretFileStore_List_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ run(
96+ arg0,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockSecretFileStore_List_Call) Return(secretFileSets []out.SecretFileSet, err error) *MockSecretFileStore_List_Call {
103+ _c.Call.Return(secretFileSets, err)
104+ return _c
105+}
106+
107+func (_c *MockSecretFileStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]out.SecretFileSet, error)) *MockSecretFileStore_List_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// Read provides a mock function for the type MockSecretFileStore
113+func (_mock *MockSecretFileStore) Read(ctx context.Context, app string, container string, key string) (string, error) {
114+ ret := _mock.Called(ctx, app, container, key)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for Read")
118+ }
119+
120+ var r0 string
121+ var r1 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (string, error)); ok {
123+ return returnFunc(ctx, app, container, key)
124+ }
125+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) string); ok {
126+ r0 = returnFunc(ctx, app, container, key)
127+ } else {
128+ r0 = ret.Get(0).(string)
129+ }
130+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
131+ r1 = returnFunc(ctx, app, container, key)
132+ } else {
133+ r1 = ret.Error(1)
134+ }
135+ return r0, r1
136+}
137+
138+// MockSecretFileStore_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
139+type MockSecretFileStore_Read_Call struct {
140+ *mock.Call
141+}
142+
143+// Read is a helper method to define mock.On call
144+// - ctx context.Context
145+// - app string
146+// - container string
147+// - key string
148+func (_e *MockSecretFileStore_Expecter) Read(ctx any, app any, container any, key any) *MockSecretFileStore_Read_Call {
149+ return &MockSecretFileStore_Read_Call{Call: _e.mock.On("Read", ctx, app, container, key)}
150+}
151+
152+func (_c *MockSecretFileStore_Read_Call) Run(run func(ctx context.Context, app string, container string, key string)) *MockSecretFileStore_Read_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 string
159+ if args[1] != nil {
160+ arg1 = args[1].(string)
161+ }
162+ var arg2 string
163+ if args[2] != nil {
164+ arg2 = args[2].(string)
165+ }
166+ var arg3 string
167+ if args[3] != nil {
168+ arg3 = args[3].(string)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ arg3,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *MockSecretFileStore_Read_Call) Return(s string, err error) *MockSecretFileStore_Read_Call {
181+ _c.Call.Return(s, err)
182+ return _c
183+}
184+
185+func (_c *MockSecretFileStore_Read_Call) RunAndReturn(run func(ctx context.Context, app string, container string, key string) (string, error)) *MockSecretFileStore_Read_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
189+
190+// Remove provides a mock function for the type MockSecretFileStore
191+func (_mock *MockSecretFileStore) Remove(ctx context.Context, app string, container string) error {
192+ ret := _mock.Called(ctx, app, container)
193+
194+ if len(ret) == 0 {
195+ panic("no return value specified for Remove")
196+ }
197+
198+ var r0 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
200+ r0 = returnFunc(ctx, app, container)
201+ } else {
202+ r0 = ret.Error(0)
203+ }
204+ return r0
205+}
206+
207+// MockSecretFileStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
208+type MockSecretFileStore_Remove_Call struct {
209+ *mock.Call
210+}
211+
212+// Remove is a helper method to define mock.On call
213+// - ctx context.Context
214+// - app string
215+// - container string
216+func (_e *MockSecretFileStore_Expecter) Remove(ctx any, app any, container any) *MockSecretFileStore_Remove_Call {
217+ return &MockSecretFileStore_Remove_Call{Call: _e.mock.On("Remove", ctx, app, container)}
218+}
219+
220+func (_c *MockSecretFileStore_Remove_Call) Run(run func(ctx context.Context, app string, container string)) *MockSecretFileStore_Remove_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 context.Context
223+ if args[0] != nil {
224+ arg0 = args[0].(context.Context)
225+ }
226+ var arg1 string
227+ if args[1] != nil {
228+ arg1 = args[1].(string)
229+ }
230+ var arg2 string
231+ if args[2] != nil {
232+ arg2 = args[2].(string)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ arg2,
238+ )
239+ })
240+ return _c
241+}
242+
243+func (_c *MockSecretFileStore_Remove_Call) Return(err error) *MockSecretFileStore_Remove_Call {
244+ _c.Call.Return(err)
245+ return _c
246+}
247+
248+func (_c *MockSecretFileStore_Remove_Call) RunAndReturn(run func(ctx context.Context, app string, container string) error) *MockSecretFileStore_Remove_Call {
249+ _c.Call.Return(run)
250+ return _c
251+}
252+
253+// Write provides a mock function for the type MockSecretFileStore
254+func (_mock *MockSecretFileStore) Write(ctx context.Context, app string, container string, values map[string]string) (map[string]string, error) {
255+ ret := _mock.Called(ctx, app, container, values)
256+
257+ if len(ret) == 0 {
258+ panic("no return value specified for Write")
259+ }
260+
261+ var r0 map[string]string
262+ var r1 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) (map[string]string, error)); ok {
264+ return returnFunc(ctx, app, container, values)
265+ }
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) map[string]string); ok {
267+ r0 = returnFunc(ctx, app, container, values)
268+ } else {
269+ if ret.Get(0) != nil {
270+ r0 = ret.Get(0).(map[string]string)
271+ }
272+ }
273+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, map[string]string) error); ok {
274+ r1 = returnFunc(ctx, app, container, values)
275+ } else {
276+ r1 = ret.Error(1)
277+ }
278+ return r0, r1
279+}
280+
281+// MockSecretFileStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
282+type MockSecretFileStore_Write_Call struct {
283+ *mock.Call
284+}
285+
286+// Write is a helper method to define mock.On call
287+// - ctx context.Context
288+// - app string
289+// - container string
290+// - values map[string]string
291+func (_e *MockSecretFileStore_Expecter) Write(ctx any, app any, container any, values any) *MockSecretFileStore_Write_Call {
292+ return &MockSecretFileStore_Write_Call{Call: _e.mock.On("Write", ctx, app, container, values)}
293+}
294+
295+func (_c *MockSecretFileStore_Write_Call) Run(run func(ctx context.Context, app string, container string, values map[string]string)) *MockSecretFileStore_Write_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 string
302+ if args[1] != nil {
303+ arg1 = args[1].(string)
304+ }
305+ var arg2 string
306+ if args[2] != nil {
307+ arg2 = args[2].(string)
308+ }
309+ var arg3 map[string]string
310+ if args[3] != nil {
311+ arg3 = args[3].(map[string]string)
312+ }
313+ run(
314+ arg0,
315+ arg1,
316+ arg2,
317+ arg3,
318+ )
319+ })
320+ return _c
321+}
322+
323+func (_c *MockSecretFileStore_Write_Call) Return(stringToString map[string]string, err error) *MockSecretFileStore_Write_Call {
324+ _c.Call.Return(stringToString, err)
325+ return _c
326+}
327+
328+func (_c *MockSecretFileStore_Write_Call) RunAndReturn(run func(ctx context.Context, app string, container string, values map[string]string) (map[string]string, error)) *MockSecretFileStore_Write_Call {
329+ _c.Call.Return(run)
330+ return _c
331+}
A internal/boundaries/out/secret_files.go +22 -0
@@ -0,0 +1,22 @@
1+package out
2+
3+import "context"
4+
5+// SecretFileSet identifies one container's secret file directory.
6+type SecretFileSet struct{ App, Container string }
7+
8+// SecretFileStore persists per-container secret files on the host. Values
9+// cross this boundary in memory only; nothing is logged.
10+type SecretFileStore interface {
11+ // Write creates the container's directory with one 0444 file per key
12+ // and returns key -> absolute host path. It fails when the directory
13+ // already exists, so another container's files are never replaced.
14+ Write(ctx context.Context, app, container string, values map[string]string) (map[string]string, error)
15+ // Read returns one file's content.
16+ Read(ctx context.Context, app, container, key string) (string, error)
17+ // Remove deletes the container's directory (missing is not an error) and
18+ // the app directory when it becomes empty.
19+ Remove(ctx context.Context, app, container string) error
20+ // List returns every existing container directory, sorted by app then container.
21+ List(ctx context.Context) ([]SecretFileSet, error)
22+}
@@ -0,0 +1,22 @@
1+package out
2+
3+import "context"
4+
5+// SecretFileSet identifies one container's secret file directory.
6+type SecretFileSet struct{ App, Container string }
7+
8+// SecretFileStore persists per-container secret files on the host. Values
9+// cross this boundary in memory only; nothing is logged.
10+type SecretFileStore interface {
11+ // Write creates the container's directory with one 0444 file per key
12+ // and returns key -> absolute host path. It fails when the directory
13+ // already exists, so another container's files are never replaced.
14+ Write(ctx context.Context, app, container string, values map[string]string) (map[string]string, error)
15+ // Read returns one file's content.
16+ Read(ctx context.Context, app, container, key string) (string, error)
17+ // Remove deletes the container's directory (missing is not an error) and
18+ // the app directory when it becomes empty.
19+ Remove(ctx context.Context, app, container string) error
20+ // List returns every existing container directory, sorted by app then container.
21+ List(ctx context.Context) ([]SecretFileSet, error)
22+}
A internal/domain/app_secret_files.go +64 -0
@@ -0,0 +1,64 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path"
6+ "slices"
7+ "strings"
8+)
9+
10+// AppSecretFileDir is the container directory holding file-delivered secrets.
11+const AppSecretFileDir = "/run/secrets"
12+
13+// SecretFileEnvKey returns the env key that points to a file-delivered secret.
14+func SecretFileEnvKey(key string) string { return key + "_FILE" }
15+
16+// SecretFilePath returns the container path of a file-delivered secret.
17+func SecretFilePath(key string) string { return AppSecretFileDir + "/" + key }
18+
19+// SecretAsFile reports whether the secret env key is delivered as a file.
20+func (s AppService) SecretAsFile(key string) bool {
21+ return slices.Contains(s.SecretFiles, key)
22+}
23+
24+// validateSecretFiles checks the file-delivered secret keys of one service.
25+// <KEY>_FILE collisions with public env are checked by
26+// checkEnvSecretKeyDisjoint, which sees both app and service env.
27+func (s *AppService) validateSecretFiles() error {
28+ if !slices.IsSorted(s.SecretFiles) {
29+ return fmt.Errorf("%w: service %q secret file keys must be sorted", ErrInvalidAppSpec, s.Name)
30+ }
31+ seen := make(map[string]struct{}, len(s.SecretFiles))
32+ for _, key := range s.SecretFiles {
33+ if _, ok := s.Secrets[key]; !ok {
34+ return fmt.Errorf("%w: service %q secret file key %q is not a declared secret", ErrInvalidAppSpec, s.Name, key)
35+ }
36+ if _, dup := seen[key]; dup {
37+ return fmt.Errorf("%w: service %q secret file key %q is duplicated", ErrInvalidAppSpec, s.Name, key)
38+ }
39+ seen[key] = struct{}{}
40+ fileKey := SecretFileEnvKey(key)
41+ if _, ok := s.Secrets[fileKey]; ok {
42+ return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
43+ }
44+ }
45+ if len(s.SecretFiles) == 0 {
46+ return nil
47+ }
48+ for _, b := range s.Binds {
49+ if underSecretFileDir(b.Path) {
50+ return fmt.Errorf("%w: service %q bind %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, b.Name, AppSecretFileDir)
51+ }
52+ }
53+ for _, v := range s.Volumes {
54+ if underSecretFileDir(v.Path) {
55+ return fmt.Errorf("%w: service %q volume %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, v.Name, AppSecretFileDir)
56+ }
57+ }
58+ return nil
59+}
60+
61+func underSecretFileDir(p string) bool {
62+ p = path.Clean(p)
63+ return p == AppSecretFileDir || strings.HasPrefix(p, AppSecretFileDir+"/")
64+}
@@ -0,0 +1,64 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path"
6+ "slices"
7+ "strings"
8+)
9+
10+// AppSecretFileDir is the container directory holding file-delivered secrets.
11+const AppSecretFileDir = "/run/secrets"
12+
13+// SecretFileEnvKey returns the env key that points to a file-delivered secret.
14+func SecretFileEnvKey(key string) string { return key + "_FILE" }
15+
16+// SecretFilePath returns the container path of a file-delivered secret.
17+func SecretFilePath(key string) string { return AppSecretFileDir + "/" + key }
18+
19+// SecretAsFile reports whether the secret env key is delivered as a file.
20+func (s AppService) SecretAsFile(key string) bool {
21+ return slices.Contains(s.SecretFiles, key)
22+}
23+
24+// validateSecretFiles checks the file-delivered secret keys of one service.
25+// <KEY>_FILE collisions with public env are checked by
26+// checkEnvSecretKeyDisjoint, which sees both app and service env.
27+func (s *AppService) validateSecretFiles() error {
28+ if !slices.IsSorted(s.SecretFiles) {
29+ return fmt.Errorf("%w: service %q secret file keys must be sorted", ErrInvalidAppSpec, s.Name)
30+ }
31+ seen := make(map[string]struct{}, len(s.SecretFiles))
32+ for _, key := range s.SecretFiles {
33+ if _, ok := s.Secrets[key]; !ok {
34+ return fmt.Errorf("%w: service %q secret file key %q is not a declared secret", ErrInvalidAppSpec, s.Name, key)
35+ }
36+ if _, dup := seen[key]; dup {
37+ return fmt.Errorf("%w: service %q secret file key %q is duplicated", ErrInvalidAppSpec, s.Name, key)
38+ }
39+ seen[key] = struct{}{}
40+ fileKey := SecretFileEnvKey(key)
41+ if _, ok := s.Secrets[fileKey]; ok {
42+ return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
43+ }
44+ }
45+ if len(s.SecretFiles) == 0 {
46+ return nil
47+ }
48+ for _, b := range s.Binds {
49+ if underSecretFileDir(b.Path) {
50+ return fmt.Errorf("%w: service %q bind %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, b.Name, AppSecretFileDir)
51+ }
52+ }
53+ for _, v := range s.Volumes {
54+ if underSecretFileDir(v.Path) {
55+ return fmt.Errorf("%w: service %q volume %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, v.Name, AppSecretFileDir)
56+ }
57+ }
58+ return nil
59+}
60+
61+func underSecretFileDir(p string) bool {
62+ p = path.Clean(p)
63+ return p == AppSecretFileDir || strings.HasPrefix(p, AppSecretFileDir+"/")
64+}
A internal/domain/app_secret_files_test.go +94 -0
@@ -0,0 +1,94 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func fileSecretSpec() domain.AppSpec {
13+ spec := validSpec()
14+ spec.Services[0].SecretFiles = []string{"DATABASE_URL"}
15+ return spec
16+}
17+
18+func TestAppSpec_ValidateSecretFilesOK(t *testing.T) {
19+ require.NoError(t, fileSecretSpec().Validate())
20+ assert.True(t, fileSecretSpec().Services[0].SecretAsFile("DATABASE_URL"))
21+ assert.False(t, validSpec().Services[0].SecretAsFile("DATABASE_URL"))
22+}
23+
24+func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) {
25+ cases := []struct {
26+ name string
27+ mutate func(*domain.AppSpec)
28+ wantErr string
29+ }{
30+ {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"},
31+ {"unsorted", func(s *domain.AppSpec) {
32+ s.Services[0].Secrets["API"] = "api"
33+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "API"}
34+ }, "must be sorted"},
35+ {"duplicate", func(s *domain.AppSpec) {
36+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"}
37+ }, "duplicated"},
38+ {"file key collides with secret", func(s *domain.AppSpec) {
39+ s.Services[0].Secrets["DATABASE_URL_FILE"] = "other"
40+ }, "collides with a secret file key"},
41+ {"file key collides with service env", func(s *domain.AppSpec) {
42+ s.Services[0].Env = map[string]string{"DATABASE_URL_FILE": "x"}
43+ }, "collides with a secret file key"},
44+ {"file key collides with app env", func(s *domain.AppSpec) {
45+ s.Env["DATABASE_URL_FILE"] = "x"
46+ }, "collides with a secret file key"},
47+ {"bind at secret dir", func(s *domain.AppSpec) {
48+ s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}}
49+ }, "/run/secrets"},
50+ {"bind under secret dir", func(s *domain.AppSpec) {
51+ s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets/x", ReadOnly: true}}
52+ }, "/run/secrets"},
53+ {"volume under secret dir", func(s *domain.AppSpec) {
54+ s.Services[0].Volumes = []domain.AppVolume{{Name: "data", Path: "/run/secrets/data"}}
55+ }, "/run/secrets"},
56+ }
57+ for _, tc := range cases {
58+ t.Run(tc.name, func(t *testing.T) {
59+ spec := fileSecretSpec()
60+ tc.mutate(&spec)
61+ err := spec.Validate()
62+ require.Error(t, err)
63+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
64+ assert.Contains(t, err.Error(), tc.wantErr)
65+ })
66+ }
67+}
68+
69+func TestAppSpec_SecretDirBindWithoutFileSecretsOK(t *testing.T) {
70+ spec := validSpec()
71+ spec.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}}
72+ require.NoError(t, spec.Validate())
73+}
74+
75+func TestDiffAppSpec_SecretFileModeSwitch(t *testing.T) {
76+ envSpec := validSpec()
77+ fileSpec := fileSecretSpec()
78+
79+ diff := domain.DiffAppSpec(fileSpec, envSpec)
80+ assert.Equal(t, []string{"service/web/secret/database-url"}, diff.Changed)
81+ assert.False(t, domain.SameAppService(fileSpec.Services[0], envSpec.Services[0]))
82+
83+ back := domain.DiffAppSpec(envSpec, fileSpec)
84+ assert.Equal(t, []string{"service/web/secret/database-url"}, back.Changed)
85+
86+ same := domain.DiffAppSpec(fileSpec, fileSecretSpec())
87+ assert.Empty(t, same.Changed)
88+ assert.True(t, domain.SameAppService(fileSpec.Services[0], fileSecretSpec().Services[0]))
89+
90+ old := validSpec()
91+ require.Nil(t, old.Services[0].SecretFiles)
92+ assert.Empty(t, domain.DiffAppSpec(old, validSpec()).Changed)
93+ assert.True(t, domain.SameAppService(old.Services[0], validSpec().Services[0]))
94+}
@@ -0,0 +1,94 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func fileSecretSpec() domain.AppSpec {
13+ spec := validSpec()
14+ spec.Services[0].SecretFiles = []string{"DATABASE_URL"}
15+ return spec
16+}
17+
18+func TestAppSpec_ValidateSecretFilesOK(t *testing.T) {
19+ require.NoError(t, fileSecretSpec().Validate())
20+ assert.True(t, fileSecretSpec().Services[0].SecretAsFile("DATABASE_URL"))
21+ assert.False(t, validSpec().Services[0].SecretAsFile("DATABASE_URL"))
22+}
23+
24+func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) {
25+ cases := []struct {
26+ name string
27+ mutate func(*domain.AppSpec)
28+ wantErr string
29+ }{
30+ {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"},
31+ {"unsorted", func(s *domain.AppSpec) {
32+ s.Services[0].Secrets["API"] = "api"
33+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "API"}
34+ }, "must be sorted"},
35+ {"duplicate", func(s *domain.AppSpec) {
36+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"}
37+ }, "duplicated"},
38+ {"file key collides with secret", func(s *domain.AppSpec) {
39+ s.Services[0].Secrets["DATABASE_URL_FILE"] = "other"
40+ }, "collides with a secret file key"},
41+ {"file key collides with service env", func(s *domain.AppSpec) {
42+ s.Services[0].Env = map[string]string{"DATABASE_URL_FILE": "x"}
43+ }, "collides with a secret file key"},
44+ {"file key collides with app env", func(s *domain.AppSpec) {
45+ s.Env["DATABASE_URL_FILE"] = "x"
46+ }, "collides with a secret file key"},
47+ {"bind at secret dir", func(s *domain.AppSpec) {
48+ s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}}
49+ }, "/run/secrets"},
50+ {"bind under secret dir", func(s *domain.AppSpec) {
51+ s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets/x", ReadOnly: true}}
52+ }, "/run/secrets"},
53+ {"volume under secret dir", func(s *domain.AppSpec) {
54+ s.Services[0].Volumes = []domain.AppVolume{{Name: "data", Path: "/run/secrets/data"}}
55+ }, "/run/secrets"},
56+ }
57+ for _, tc := range cases {
58+ t.Run(tc.name, func(t *testing.T) {
59+ spec := fileSecretSpec()
60+ tc.mutate(&spec)
61+ err := spec.Validate()
62+ require.Error(t, err)
63+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
64+ assert.Contains(t, err.Error(), tc.wantErr)
65+ })
66+ }
67+}
68+
69+func TestAppSpec_SecretDirBindWithoutFileSecretsOK(t *testing.T) {
70+ spec := validSpec()
71+ spec.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}}
72+ require.NoError(t, spec.Validate())
73+}
74+
75+func TestDiffAppSpec_SecretFileModeSwitch(t *testing.T) {
76+ envSpec := validSpec()
77+ fileSpec := fileSecretSpec()
78+
79+ diff := domain.DiffAppSpec(fileSpec, envSpec)
80+ assert.Equal(t, []string{"service/web/secret/database-url"}, diff.Changed)
81+ assert.False(t, domain.SameAppService(fileSpec.Services[0], envSpec.Services[0]))
82+
83+ back := domain.DiffAppSpec(envSpec, fileSpec)
84+ assert.Equal(t, []string{"service/web/secret/database-url"}, back.Changed)
85+
86+ same := domain.DiffAppSpec(fileSpec, fileSecretSpec())
87+ assert.Empty(t, same.Changed)
88+ assert.True(t, domain.SameAppService(fileSpec.Services[0], fileSecretSpec().Services[0]))
89+
90+ old := validSpec()
91+ require.Nil(t, old.Services[0].SecretFiles)
92+ assert.Empty(t, domain.DiffAppSpec(old, validSpec()).Changed)
93+ assert.True(t, domain.SameAppService(old.Services[0], validSpec().Services[0]))
94+}
M internal/domain/app_spec.go +28 -11
@@ -2,6 +2,7 @@ package domain
22
33 import (
44 "fmt"
5+ "maps"
56 "net"
67 "path"
78 "reflect"
@@ -106,8 +107,12 @@ type AppService struct {
106107 // [env] for the same key; its keys must be disjoint from Secrets.
107108 Env map[string]string
108109 Secrets map[string]string
109- Volumes []AppVolume
110- Binds []AppBind
110+ // SecretFiles lists the Secrets env keys delivered as read-only files
111+ // under /run/secrets instead of env values. Sorted, nil when empty;
112+ // keys absent from it are delivered as env.
113+ SecretFiles []string
114+ Volumes []AppVolume
115+ Binds []AppBind
111116 // Devices lists logical device names granted by administrative
112117 // [app_devices] policy. Names resolve to CDI device IDs at activation
113118 // time; the logical names (not host resolution) persist in revisions.
@@ -509,6 +514,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error {
509514 return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key)
510515 }
511516 }
517+ for _, key := range svc.SecretFiles {
518+ fileKey := SecretFileEnvKey(key)
519+ if _, ok := s.Env[fileKey]; ok {
520+ return fmt.Errorf("%w: env key %q collides with a secret file key in service %q", ErrInvalidAppSpec, fileKey, svc.Name)
521+ }
522+ if _, ok := svc.Env[fileKey]; ok {
523+ return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, svc.Name, fileKey)
524+ }
525+ }
512526 }
513527 return nil
514528 }
@@ -624,7 +638,7 @@ func (s *AppService) validateSecrets() error {
624638 return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err)
625639 }
626640 }
627- return nil
641+ return s.validateSecretFiles()
628642 }
629643
630644 // validateVolumes checks volume declarations and returns known names.
@@ -1086,11 +1100,7 @@ func diffService(name string, desired, effective AppService) []string {
10861100 if !equalStringMaps(desired.Env, effective.Env) {
10871101 changed = append(changed, "service/"+name+"/env")
10881102 }
1089- if !equalStringMaps(desired.Secrets, effective.Secrets) {
1090- for path := range secretPathChanges(desired, effective) {
1091- changed = append(changed, path)
1092- }
1093- }
1103+ changed = appendSecretChanges(changed, desired, effective)
10941104 if !equalVolumes(desired.Volumes, effective.Volumes) {
10951105 changed = append(changed, "service/"+name+"/volumes")
10961106 }
@@ -1116,16 +1126,23 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap
11161126 return changed
11171127 }
11181128
1119-// secretPathChanges lists secret PATH changes (never values).
1129+// appendSecretChanges appends sorted secret path changes when the secret
1130+// names or their delivery modes differ.
1131+func appendSecretChanges(changed []string, desired, effective AppService) []string {
1132+ return append(changed, slices.Sorted(maps.Keys(secretPathChanges(desired, effective)))...)
1133+}
1134+
1135+// secretPathChanges lists secret PATH changes (never values). A key whose
1136+// delivery mode (env or file) changed counts as changed.
11201137 func secretPathChanges(desired, effective AppService) map[string]struct{} {
11211138 paths := map[string]struct{}{}
11221139 for envKey, secretName := range desired.Secrets {
1123- if effective.Secrets[envKey] != secretName {
1140+ if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
11241141 paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}
11251142 }
11261143 }
11271144 for envKey, secretName := range effective.Secrets {
1128- if desired.Secrets[envKey] != secretName {
1145+ if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
11291146 paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}
11301147 }
11311148 }
@@ -2,6 +2,7 @@ package domain
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "maps"
5 "net"6 "net"
6 "path"7 "path"
7 "reflect"8 "reflect"
@@ -106,8 +107,12 @@ type AppService struct {
106 // [env] for the same key; its keys must be disjoint from Secrets.107 // [env] for the same key; its keys must be disjoint from Secrets.
107 Env map[string]string108 Env map[string]string
108 Secrets map[string]string109 Secrets map[string]string
109- Volumes []AppVolume110+ // SecretFiles lists the Secrets env keys delivered as read-only files
110- Binds []AppBind111+ // under /run/secrets instead of env values. Sorted, nil when empty;
112+ // keys absent from it are delivered as env.
113+ SecretFiles []string
114+ Volumes []AppVolume
115+ Binds []AppBind
111 // Devices lists logical device names granted by administrative116 // Devices lists logical device names granted by administrative
112 // [app_devices] policy. Names resolve to CDI device IDs at activation117 // [app_devices] policy. Names resolve to CDI device IDs at activation
113 // time; the logical names (not host resolution) persist in revisions.118 // time; the logical names (not host resolution) persist in revisions.
@@ -509,6 +514,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error {
509 return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key)514 return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key)
510 }515 }
511 }516 }
517+ for _, key := range svc.SecretFiles {
518+ fileKey := SecretFileEnvKey(key)
519+ if _, ok := s.Env[fileKey]; ok {
520+ return fmt.Errorf("%w: env key %q collides with a secret file key in service %q", ErrInvalidAppSpec, fileKey, svc.Name)
521+ }
522+ if _, ok := svc.Env[fileKey]; ok {
523+ return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, svc.Name, fileKey)
524+ }
525+ }
512 }526 }
513 return nil527 return nil
514 }528 }
@@ -624,7 +638,7 @@ func (s *AppService) validateSecrets() error {
624 return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err)638 return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err)
625 }639 }
626 }640 }
627- return nil641+ return s.validateSecretFiles()
628 }642 }
629 643
630 // validateVolumes checks volume declarations and returns known names.644 // validateVolumes checks volume declarations and returns known names.
@@ -1086,11 +1100,7 @@ func diffService(name string, desired, effective AppService) []string {
1086 if !equalStringMaps(desired.Env, effective.Env) {1100 if !equalStringMaps(desired.Env, effective.Env) {
1087 changed = append(changed, "service/"+name+"/env")1101 changed = append(changed, "service/"+name+"/env")
1088 }1102 }
1089- if !equalStringMaps(desired.Secrets, effective.Secrets) {1103+ changed = appendSecretChanges(changed, desired, effective)
1090- for path := range secretPathChanges(desired, effective) {
1091- changed = append(changed, path)
1092- }
1093- }
1094 if !equalVolumes(desired.Volumes, effective.Volumes) {1104 if !equalVolumes(desired.Volumes, effective.Volumes) {
1095 changed = append(changed, "service/"+name+"/volumes")1105 changed = append(changed, "service/"+name+"/volumes")
1096 }1106 }
@@ -1116,16 +1126,23 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap
1116 return changed1126 return changed
1117 }1127 }
1118 1128
1119-// secretPathChanges lists secret PATH changes (never values).1129+// appendSecretChanges appends sorted secret path changes when the secret
1130+// names or their delivery modes differ.
1131+func appendSecretChanges(changed []string, desired, effective AppService) []string {
1132+ return append(changed, slices.Sorted(maps.Keys(secretPathChanges(desired, effective)))...)
1133+}
1134+
1135+// secretPathChanges lists secret PATH changes (never values). A key whose
1136+// delivery mode (env or file) changed counts as changed.
1120 func secretPathChanges(desired, effective AppService) map[string]struct{} {1137 func secretPathChanges(desired, effective AppService) map[string]struct{} {
1121 paths := map[string]struct{}{}1138 paths := map[string]struct{}{}
1122 for envKey, secretName := range desired.Secrets {1139 for envKey, secretName := range desired.Secrets {
1123- if effective.Secrets[envKey] != secretName {1140+ if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
1124 paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}1141 paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}
1125 }1142 }
1126 }1143 }
1127 for envKey, secretName := range effective.Secrets {1144 for envKey, secretName := range effective.Secrets {
1128- if desired.Secrets[envKey] != secretName {1145+ if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
1129 paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}1146 paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}
1130 }1147 }
1131 }1148 }
M internal/domain/errors.go +4 -0
@@ -83,6 +83,10 @@ var (
8383 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
8484 // installation policy refuses to serve it.
8585 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrRuntimeGuard wraps app container configs refused by the runtime
87+ // guard: host or shared namespaces, host paths passed as volume
88+ // names, or binds overlapping Gordon's own sensitive paths.
89+ ErrRuntimeGuard = errors.New("runtime guard violation")
8690
8791 // App state errors
8892 ErrAppStateIO = errors.New("app state storage failure")
@@ -83,6 +83,10 @@ var (
83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
84 // installation policy refuses to serve it.84 // installation policy refuses to serve it.
85 ErrDevicePolicy = errors.New("device policy violation")85 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrRuntimeGuard wraps app container configs refused by the runtime
87+ // guard: host or shared namespaces, host paths passed as volume
88+ // names, or binds overlapping Gordon's own sensitive paths.
89+ ErrRuntimeGuard = errors.New("runtime guard violation")
86 90
87 // App state errors91 // App state errors
88 ErrAppStateIO = errors.New("app state storage failure")92 ErrAppStateIO = errors.New("app state storage failure")
A internal/domain/runtime_guard.go +154 -0
@@ -0,0 +1,154 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// RuntimeGuard is the last check on every app container config before it
10+// reaches the runtime. It refuses configs that would give a workload
11+// access to the host or to Gordon itself, whatever produced them.
12+//
13+// The zero value still refuses host/shared network namespaces and host
14+// paths passed as volume names; ProtectedPaths adds the bind checks.
15+type RuntimeGuard struct {
16+ // ProtectedPaths are absolute host paths no bind may equal, lie under,
17+ // or contain (data dir, config, runtime sockets, key stores).
18+ ProtectedPaths []string
19+ // SecretFilesDir is the host base directory of file-delivered secrets
20+ // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21+ // it are the only allowed exception.
22+ SecretFilesDir string
23+ // LogDirs are Gordon's log directories. Binds overlapping them are
24+ // refused unless read-only; a read-only bind at or under one is
25+ // allowed even when a protected path (the data dir) contains it, so
26+ // a log shipper can read them. A log dir that is or contains a
27+ // protected path grants no exception.
28+ LogDirs []string
29+}
30+
31+// Check refuses a config that breaks the guard. Errors wrap
32+// ErrRuntimeGuard and name the bind, volume destination, or network mode,
33+// never a host path.
34+func (g RuntimeGuard) Check(config *ContainerConfig) error {
35+ if err := checkGuardNetworkMode(config.NetworkMode); err != nil {
36+ return err
37+ }
38+ if err := checkGuardVolumes(config.Volumes); err != nil {
39+ return err
40+ }
41+ if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
42+ return err
43+ }
44+ for _, bind := range config.Binds {
45+ if g.isOwnSecretBind(config, bind) {
46+ continue
47+ }
48+ if g.overlapsProtected(bind) {
49+ return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
50+ }
51+ }
52+ return nil
53+}
54+
55+func checkGuardNetworkMode(mode string) error {
56+ if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
57+ return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
58+ }
59+ return nil
60+}
61+
62+// guardNetworkLabel keeps the mode kind but drops a namespace path.
63+func guardNetworkLabel(mode string) string {
64+ if kind, _, ok := strings.Cut(mode, ":"); ok {
65+ return kind + ":"
66+ }
67+ return mode
68+}
69+
70+// checkGuardVolumes refuses volume names the runtime would read as host
71+// paths and turn into binds.
72+func checkGuardVolumes(volumes map[string]string) error {
73+ for dest, name := range volumes {
74+ if strings.ContainsAny(name, `/\`) {
75+ return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
76+ }
77+ }
78+ return nil
79+}
80+
81+// isOwnSecretBind reports whether bind is exactly the read-only secret file
82+// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
83+// /run/secrets/<KEY>.
84+func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
85+ if g.SecretFilesDir == "" || !bind.ReadOnly {
86+ return false
87+ }
88+ app := config.Labels[LabelApp]
89+ if app == "" || config.Name == "" {
90+ return false
91+ }
92+ key := filepath.Base(bind.Source)
93+ if err := ValidateEnvKey(key); err != nil {
94+ return false
95+ }
96+ return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
97+ bind.Destination == SecretFilePath(key)
98+}
99+
100+// overlapsProtected reports whether the bind source, raw or
101+// symlink-resolved, is equal to, under, or an ancestor of a protected
102+// path, or of a log dir when the bind is writable. A read-only source at
103+// or under a usable log dir only fails when it contains a protected path.
104+func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
105+ for _, candidate := range guardCandidates(bind.Source) {
106+ readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
107+ for _, protected := range g.ProtectedPaths {
108+ if resolvedUnderRoot(candidate, protected) {
109+ return true
110+ }
111+ if !readableLog && resolvedUnderRoot(protected, candidate) {
112+ return true
113+ }
114+ }
115+ if bind.ReadOnly {
116+ continue
117+ }
118+ for _, logDir := range g.LogDirs {
119+ if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
120+ return true
121+ }
122+ }
123+ }
124+ return false
125+}
126+
127+// underUsableLogDir reports whether path is at or under a log dir that
128+// neither is nor contains a protected path.
129+func (g RuntimeGuard) underUsableLogDir(path string) bool {
130+ for _, logDir := range g.LogDirs {
131+ if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func (g RuntimeGuard) containsProtected(dir string) bool {
139+ for _, protected := range g.ProtectedPaths {
140+ if resolvedUnderRoot(dir, protected) {
141+ return true
142+ }
143+ }
144+ return false
145+}
146+
147+func guardCandidates(source string) []string {
148+ clean := filepath.Clean(source)
149+ candidates := []string{clean}
150+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
151+ candidates = append(candidates, resolved)
152+ }
153+ return candidates
154+}
@@ -0,0 +1,154 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// RuntimeGuard is the last check on every app container config before it
10+// reaches the runtime. It refuses configs that would give a workload
11+// access to the host or to Gordon itself, whatever produced them.
12+//
13+// The zero value still refuses host/shared network namespaces and host
14+// paths passed as volume names; ProtectedPaths adds the bind checks.
15+type RuntimeGuard struct {
16+ // ProtectedPaths are absolute host paths no bind may equal, lie under,
17+ // or contain (data dir, config, runtime sockets, key stores).
18+ ProtectedPaths []string
19+ // SecretFilesDir is the host base directory of file-delivered secrets
20+ // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21+ // it are the only allowed exception.
22+ SecretFilesDir string
23+ // LogDirs are Gordon's log directories. Binds overlapping them are
24+ // refused unless read-only; a read-only bind at or under one is
25+ // allowed even when a protected path (the data dir) contains it, so
26+ // a log shipper can read them. A log dir that is or contains a
27+ // protected path grants no exception.
28+ LogDirs []string
29+}
30+
31+// Check refuses a config that breaks the guard. Errors wrap
32+// ErrRuntimeGuard and name the bind, volume destination, or network mode,
33+// never a host path.
34+func (g RuntimeGuard) Check(config *ContainerConfig) error {
35+ if err := checkGuardNetworkMode(config.NetworkMode); err != nil {
36+ return err
37+ }
38+ if err := checkGuardVolumes(config.Volumes); err != nil {
39+ return err
40+ }
41+ if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
42+ return err
43+ }
44+ for _, bind := range config.Binds {
45+ if g.isOwnSecretBind(config, bind) {
46+ continue
47+ }
48+ if g.overlapsProtected(bind) {
49+ return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
50+ }
51+ }
52+ return nil
53+}
54+
55+func checkGuardNetworkMode(mode string) error {
56+ if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
57+ return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
58+ }
59+ return nil
60+}
61+
62+// guardNetworkLabel keeps the mode kind but drops a namespace path.
63+func guardNetworkLabel(mode string) string {
64+ if kind, _, ok := strings.Cut(mode, ":"); ok {
65+ return kind + ":"
66+ }
67+ return mode
68+}
69+
70+// checkGuardVolumes refuses volume names the runtime would read as host
71+// paths and turn into binds.
72+func checkGuardVolumes(volumes map[string]string) error {
73+ for dest, name := range volumes {
74+ if strings.ContainsAny(name, `/\`) {
75+ return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
76+ }
77+ }
78+ return nil
79+}
80+
81+// isOwnSecretBind reports whether bind is exactly the read-only secret file
82+// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
83+// /run/secrets/<KEY>.
84+func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
85+ if g.SecretFilesDir == "" || !bind.ReadOnly {
86+ return false
87+ }
88+ app := config.Labels[LabelApp]
89+ if app == "" || config.Name == "" {
90+ return false
91+ }
92+ key := filepath.Base(bind.Source)
93+ if err := ValidateEnvKey(key); err != nil {
94+ return false
95+ }
96+ return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
97+ bind.Destination == SecretFilePath(key)
98+}
99+
100+// overlapsProtected reports whether the bind source, raw or
101+// symlink-resolved, is equal to, under, or an ancestor of a protected
102+// path, or of a log dir when the bind is writable. A read-only source at
103+// or under a usable log dir only fails when it contains a protected path.
104+func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
105+ for _, candidate := range guardCandidates(bind.Source) {
106+ readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
107+ for _, protected := range g.ProtectedPaths {
108+ if resolvedUnderRoot(candidate, protected) {
109+ return true
110+ }
111+ if !readableLog && resolvedUnderRoot(protected, candidate) {
112+ return true
113+ }
114+ }
115+ if bind.ReadOnly {
116+ continue
117+ }
118+ for _, logDir := range g.LogDirs {
119+ if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
120+ return true
121+ }
122+ }
123+ }
124+ return false
125+}
126+
127+// underUsableLogDir reports whether path is at or under a log dir that
128+// neither is nor contains a protected path.
129+func (g RuntimeGuard) underUsableLogDir(path string) bool {
130+ for _, logDir := range g.LogDirs {
131+ if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func (g RuntimeGuard) containsProtected(dir string) bool {
139+ for _, protected := range g.ProtectedPaths {
140+ if resolvedUnderRoot(dir, protected) {
141+ return true
142+ }
143+ }
144+ return false
145+}
146+
147+func guardCandidates(source string) []string {
148+ clean := filepath.Clean(source)
149+ candidates := []string{clean}
150+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
151+ candidates = append(candidates, resolved)
152+ }
153+ return candidates
154+}
A internal/domain/runtime_guard_test.go +150 -0
@@ -0,0 +1,150 @@
1+package domain_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const guardContainer = "gordon-blog--web--x"
15+
16+func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig {
17+ return &domain.ContainerConfig{
18+ Name: guardContainer,
19+ NetworkMode: "gordon-app-1",
20+ Labels: map[string]string{domain.LabelApp: "blog"},
21+ Binds: binds,
22+ }
23+}
24+
25+func TestRuntimeGuard_NetworkModes(t *testing.T) {
26+ var guard domain.RuntimeGuard
27+ for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} {
28+ cfg := guardConfig()
29+ cfg.NetworkMode = mode
30+ err := guard.Check(cfg)
31+ require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode)
32+ assert.NotContains(t, err.Error(), "/proc")
33+ }
34+ require.NoError(t, guard.Check(guardConfig()))
35+}
36+
37+func TestRuntimeGuard_VolumeNames(t *testing.T) {
38+ var guard domain.RuntimeGuard
39+ for _, name := range []string{"/etc", "a/b", `a\b`} {
40+ cfg := guardConfig()
41+ cfg.Volumes = map[string]string{"/data": name}
42+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
43+
44+ cfg = guardConfig()
45+ cfg.ReadOnlyVolumes = map[string]string{"/data": name}
46+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
47+ }
48+ cfg := guardConfig()
49+ cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"}
50+ require.NoError(t, guard.Check(cfg))
51+}
52+
53+func TestRuntimeGuard_ProtectedBinds(t *testing.T) {
54+ base := t.TempDir()
55+ dataDir := filepath.Join(base, "data")
56+ protectedChild := filepath.Join(dataDir, "registry")
57+ require.NoError(t, os.MkdirAll(protectedChild, 0o700))
58+ unrelated := filepath.Join(base, "public")
59+ require.NoError(t, os.MkdirAll(unrelated, 0o700))
60+ link := filepath.Join(base, "link")
61+ require.NoError(t, os.Symlink(dataDir, link))
62+
63+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}}
64+ cases := map[string]string{
65+ "equal": dataDir,
66+ "under": protectedChild,
67+ "ancestor": base,
68+ "symlink": link,
69+ }
70+ for name, source := range cases {
71+ t.Run(name, func(t *testing.T) {
72+ err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true}))
73+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
74+ assert.Contains(t, err.Error(), `"conf"`)
75+ assert.NotContains(t, err.Error(), base)
76+ })
77+ }
78+ require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true})))
79+}
80+
81+func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
82+ dataDir := t.TempDir()
83+ secretDir := filepath.Join(dataDir, "secrets-run")
84+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir}
85+ own := domain.ContainerBind{
86+ Name: "secret-file-DB",
87+ Source: filepath.Join(secretDir, "blog", guardContainer, "DB"),
88+ Destination: "/run/secrets/DB",
89+ ReadOnly: true,
90+ }
91+ require.NoError(t, guard.Check(guardConfig(own)))
92+
93+ cases := map[string]func(*domain.ContainerConfig){
94+ "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false },
95+ "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" },
96+ "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" },
97+ "no app label": func(c *domain.ContainerConfig) { c.Labels = nil },
98+ "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" },
99+ "extra segment": func(c *domain.ContainerConfig) {
100+ c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB")
101+ },
102+ "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) },
103+ "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir },
104+ }
105+ for name, mutate := range cases {
106+ t.Run(name, func(t *testing.T) {
107+ cfg := guardConfig(own)
108+ mutate(cfg)
109+ err := guard.Check(cfg)
110+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
111+ assert.NotContains(t, err.Error(), dataDir)
112+ })
113+ }
114+}
115+
116+func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
117+ dataDir := t.TempDir()
118+ logs := filepath.Join(dataDir, "logs")
119+ require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120+ keys := filepath.Join(dataDir, "logs", "keys")
121+ secrets := filepath.Join(dataDir, "secrets-run")
122+ require.NoError(t, os.MkdirAll(secrets, 0o700))
123+ escape := filepath.Join(logs, "escape")
124+ require.NoError(t, os.Symlink(secrets, escape))
125+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
126+ bind := func(source string, readOnly bool) *domain.ContainerConfig {
127+ return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
128+ }
129+
130+ require.NoError(t, guard.Check(bind(logs, true)))
131+ require.NoError(t, guard.Check(bind(filepath.Join(logs, "containers"), true)))
132+
133+ withProtected := domain.RuntimeGuard{ProtectedPaths: []string{dataDir, keys}, LogDirs: []string{logs}}
134+ refused := map[string]struct {
135+ guard domain.RuntimeGuard
136+ cfg *domain.ContainerConfig
137+ }{
138+ "writable log dir": {guard, bind(logs, false)},
139+ "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
140+ "data dir read-only": {guard, bind(dataDir, true)},
141+ "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
142+ "symlink out of logs": {guard, bind(escape, true)},
143+ "log dir with protected": {withProtected, bind(logs, true)},
144+ }
145+ for name, tc := range refused {
146+ t.Run(name, func(t *testing.T) {
147+ require.ErrorIs(t, tc.guard.Check(tc.cfg), domain.ErrRuntimeGuard)
148+ })
149+ }
150+}
@@ -0,0 +1,150 @@
1+package domain_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const guardContainer = "gordon-blog--web--x"
15+
16+func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig {
17+ return &domain.ContainerConfig{
18+ Name: guardContainer,
19+ NetworkMode: "gordon-app-1",
20+ Labels: map[string]string{domain.LabelApp: "blog"},
21+ Binds: binds,
22+ }
23+}
24+
25+func TestRuntimeGuard_NetworkModes(t *testing.T) {
26+ var guard domain.RuntimeGuard
27+ for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} {
28+ cfg := guardConfig()
29+ cfg.NetworkMode = mode
30+ err := guard.Check(cfg)
31+ require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode)
32+ assert.NotContains(t, err.Error(), "/proc")
33+ }
34+ require.NoError(t, guard.Check(guardConfig()))
35+}
36+
37+func TestRuntimeGuard_VolumeNames(t *testing.T) {
38+ var guard domain.RuntimeGuard
39+ for _, name := range []string{"/etc", "a/b", `a\b`} {
40+ cfg := guardConfig()
41+ cfg.Volumes = map[string]string{"/data": name}
42+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
43+
44+ cfg = guardConfig()
45+ cfg.ReadOnlyVolumes = map[string]string{"/data": name}
46+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
47+ }
48+ cfg := guardConfig()
49+ cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"}
50+ require.NoError(t, guard.Check(cfg))
51+}
52+
53+func TestRuntimeGuard_ProtectedBinds(t *testing.T) {
54+ base := t.TempDir()
55+ dataDir := filepath.Join(base, "data")
56+ protectedChild := filepath.Join(dataDir, "registry")
57+ require.NoError(t, os.MkdirAll(protectedChild, 0o700))
58+ unrelated := filepath.Join(base, "public")
59+ require.NoError(t, os.MkdirAll(unrelated, 0o700))
60+ link := filepath.Join(base, "link")
61+ require.NoError(t, os.Symlink(dataDir, link))
62+
63+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}}
64+ cases := map[string]string{
65+ "equal": dataDir,
66+ "under": protectedChild,
67+ "ancestor": base,
68+ "symlink": link,
69+ }
70+ for name, source := range cases {
71+ t.Run(name, func(t *testing.T) {
72+ err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true}))
73+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
74+ assert.Contains(t, err.Error(), `"conf"`)
75+ assert.NotContains(t, err.Error(), base)
76+ })
77+ }
78+ require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true})))
79+}
80+
81+func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
82+ dataDir := t.TempDir()
83+ secretDir := filepath.Join(dataDir, "secrets-run")
84+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir}
85+ own := domain.ContainerBind{
86+ Name: "secret-file-DB",
87+ Source: filepath.Join(secretDir, "blog", guardContainer, "DB"),
88+ Destination: "/run/secrets/DB",
89+ ReadOnly: true,
90+ }
91+ require.NoError(t, guard.Check(guardConfig(own)))
92+
93+ cases := map[string]func(*domain.ContainerConfig){
94+ "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false },
95+ "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" },
96+ "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" },
97+ "no app label": func(c *domain.ContainerConfig) { c.Labels = nil },
98+ "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" },
99+ "extra segment": func(c *domain.ContainerConfig) {
100+ c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB")
101+ },
102+ "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) },
103+ "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir },
104+ }
105+ for name, mutate := range cases {
106+ t.Run(name, func(t *testing.T) {
107+ cfg := guardConfig(own)
108+ mutate(cfg)
109+ err := guard.Check(cfg)
110+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
111+ assert.NotContains(t, err.Error(), dataDir)
112+ })
113+ }
114+}
115+
116+func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
117+ dataDir := t.TempDir()
118+ logs := filepath.Join(dataDir, "logs")
119+ require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120+ keys := filepath.Join(dataDir, "logs", "keys")
121+ secrets := filepath.Join(dataDir, "secrets-run")
122+ require.NoError(t, os.MkdirAll(secrets, 0o700))
123+ escape := filepath.Join(logs, "escape")
124+ require.NoError(t, os.Symlink(secrets, escape))
125+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
126+ bind := func(source string, readOnly bool) *domain.ContainerConfig {
127+ return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
128+ }
129+
130+ require.NoError(t, guard.Check(bind(logs, true)))
131+ require.NoError(t, guard.Check(bind(filepath.Join(logs, "containers"), true)))
132+
133+ withProtected := domain.RuntimeGuard{ProtectedPaths: []string{dataDir, keys}, LogDirs: []string{logs}}
134+ refused := map[string]struct {
135+ guard domain.RuntimeGuard
136+ cfg *domain.ContainerConfig
137+ }{
138+ "writable log dir": {guard, bind(logs, false)},
139+ "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
140+ "data dir read-only": {guard, bind(dataDir, true)},
141+ "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
142+ "symlink out of logs": {guard, bind(escape, true)},
143+ "log dir with protected": {withProtected, bind(logs, true)},
144+ }
145+ for name, tc := range refused {
146+ t.Run(name, func(t *testing.T) {
147+ require.ErrorIs(t, tc.guard.Check(tc.cfg), domain.ErrRuntimeGuard)
148+ })
149+ }
150+}
M internal/usecase/deployment/candidate.go +84 -10
@@ -5,9 +5,12 @@ import (
55 "errors"
66 "fmt"
77 "maps"
8+ "slices"
89 "sort"
910 "strings"
1011
12+ "github.com/bnema/zerowrap"
13+
1114 "github.com/bnema/gordon/internal/domain"
1215 )
1316
@@ -41,7 +44,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
4144 if err != nil {
4245 return startedCandidate{}, err
4346 }
44- env, err := s.serviceEnv(ctx, app, p)
47+ env, files, err := s.serviceEnv(ctx, app, p)
4548 if err != nil {
4649 return startedCandidate{}, err
4750 }
@@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
102105 NanoCPUs: s.deps.Limits.NanoCPUs,
103106 PidsLimit: s.deps.Limits.PidsLimit,
104107 }
105- created, err := s.createContainer(ctx, p.name, config)
108+ created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
106109 if err != nil {
107110 return startedCandidate{}, err
108111 }
@@ -158,7 +161,47 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID
158161 return nil
159162 }
160163
164+// createWithSecretFiles writes file-delivered secrets to the host, mounts
165+// each one read-only at /run/secrets/<KEY>, and creates the container.
166+// The files are removed again when the create fails. Values stay in
167+// memory and on the 0444 host files only; nothing is logged.
168+func (s *Service) createWithSecretFiles(ctx context.Context, app, service string, config *domain.ContainerConfig, files map[string]string) (*domain.Container, error) {
169+ if len(files) == 0 {
170+ return s.createContainer(ctx, service, config)
171+ }
172+ if s.deps.SecretFiles == nil {
173+ return nil, fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", service)
174+ }
175+ paths, err := s.deps.SecretFiles.Write(ctx, app, config.Name, files)
176+ if err != nil {
177+ return nil, fmt.Errorf("deployment: write secret files for service %q: %w", service, err)
178+ }
179+ withFiles := *config
180+ withFiles.Binds = append([]domain.ContainerBind(nil), config.Binds...)
181+ for _, key := range slices.Sorted(maps.Keys(files)) {
182+ withFiles.Binds = append(withFiles.Binds, domain.ContainerBind{
183+ Name: "secret-file-" + key,
184+ Source: paths[key],
185+ Destination: domain.SecretFilePath(key),
186+ ReadOnly: true,
187+ })
188+ }
189+ created, err := s.createContainer(ctx, service, &withFiles)
190+ if err != nil {
191+ if rmErr := s.deps.SecretFiles.Remove(ctx, app, config.Name); rmErr != nil {
192+ log := zerowrap.FromCtx(ctx)
193+ log.Warn().Str("app", app).Str("service", service).
194+ Msg("deployment: secret files cleanup after failed create failed")
195+ }
196+ return nil, err
197+ }
198+ return created, nil
199+}
200+
161201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
202+ if err := s.deps.Guard.Check(config); err != nil {
203+ return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err)
204+ }
162205 created, err := s.deps.Runtime.CreateContainer(ctx, config)
163206 if err == nil {
164207 return created, nil
@@ -273,7 +316,10 @@ func shortOp(opID string) string {
273316 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
274317 // deterministic. Overlapping public/secret keys are invalid with no
275318 // precedence; preflight rejects them before any mutation.
276-func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) {
319+//
320+// File-delivered secrets are returned in files (env key -> value, nil
321+// when none) and the env gets <KEY>_FILE=/run/secrets/<KEY> instead.
322+func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, map[string]string, error) {
277323 spec := p.spec
278324 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))
279325 maps.Copy(envMap, p.appEnv)
@@ -284,24 +330,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) (
284330 }
285331 sort.Strings(keys)
286332 if len(keys) == 0 {
287- return sortedEnv(envMap), nil
333+ return sortedEnv(envMap), nil, nil
288334 }
289335 id, err := s.appSecretID(ctx, app)
290336 if err != nil {
291- return nil, err
337+ return nil, nil, err
292338 }
339+ var files map[string]string
293340 for _, envKey := range keys {
294- if _, overlap := envMap[envKey]; overlap {
295- return nil, fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
341+ if err := checkSecretEnvOverlap(envMap, spec, envKey); err != nil {
342+ return nil, nil, err
296343 }
297344 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])
298345 value, err := s.deps.Secrets.GetSecret(ctx, path)
299346 if err != nil {
300- return nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
347+ return nil, nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
301348 }
302- envMap[envKey] = value
349+ if !spec.SecretAsFile(envKey) {
350+ envMap[envKey] = value
351+ continue
352+ }
353+ if files == nil {
354+ files = make(map[string]string, len(spec.SecretFiles))
355+ }
356+ files[envKey] = value
357+ envMap[domain.SecretFileEnvKey(envKey)] = domain.SecretFilePath(envKey)
358+ }
359+ return sortedEnv(envMap), files, nil
360+}
361+
362+// checkSecretEnvOverlap refuses a secret key (and, for file delivery, its
363+// <KEY>_FILE key) that is already set by public env or another secret.
364+func checkSecretEnvOverlap(envMap map[string]string, spec domain.AppService, envKey string) error {
365+ if _, overlap := envMap[envKey]; overlap {
366+ return fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
303367 }
304- return sortedEnv(envMap), nil
368+ if !spec.SecretAsFile(envKey) {
369+ return nil
370+ }
371+ fileKey := domain.SecretFileEnvKey(envKey)
372+ if _, overlap := envMap[fileKey]; overlap {
373+ return fmt.Errorf("deployment: env key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
374+ }
375+ if _, overlap := spec.Secrets[fileKey]; overlap {
376+ return fmt.Errorf("deployment: secret key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
377+ }
378+ return nil
305379 }
306380
307381 // sortedEnv renders a KEY=value list in sorted key order. Values stay in
@@ -5,9 +5,12 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "maps"7 "maps"
8+ "slices"
8 "sort"9 "sort"
9 "strings"10 "strings"
10 11
12+ "github.com/bnema/zerowrap"
13+
11 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
12 )15 )
13 16
@@ -41,7 +44,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
41 if err != nil {44 if err != nil {
42 return startedCandidate{}, err45 return startedCandidate{}, err
43 }46 }
44- env, err := s.serviceEnv(ctx, app, p)47+ env, files, err := s.serviceEnv(ctx, app, p)
45 if err != nil {48 if err != nil {
46 return startedCandidate{}, err49 return startedCandidate{}, err
47 }50 }
@@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
102 NanoCPUs: s.deps.Limits.NanoCPUs,105 NanoCPUs: s.deps.Limits.NanoCPUs,
103 PidsLimit: s.deps.Limits.PidsLimit,106 PidsLimit: s.deps.Limits.PidsLimit,
104 }107 }
105- created, err := s.createContainer(ctx, p.name, config)108+ created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
106 if err != nil {109 if err != nil {
107 return startedCandidate{}, err110 return startedCandidate{}, err
108 }111 }
@@ -158,7 +161,47 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID
158 return nil161 return nil
159 }162 }
160 163
164+// createWithSecretFiles writes file-delivered secrets to the host, mounts
165+// each one read-only at /run/secrets/<KEY>, and creates the container.
166+// The files are removed again when the create fails. Values stay in
167+// memory and on the 0444 host files only; nothing is logged.
168+func (s *Service) createWithSecretFiles(ctx context.Context, app, service string, config *domain.ContainerConfig, files map[string]string) (*domain.Container, error) {
169+ if len(files) == 0 {
170+ return s.createContainer(ctx, service, config)
171+ }
172+ if s.deps.SecretFiles == nil {
173+ return nil, fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", service)
174+ }
175+ paths, err := s.deps.SecretFiles.Write(ctx, app, config.Name, files)
176+ if err != nil {
177+ return nil, fmt.Errorf("deployment: write secret files for service %q: %w", service, err)
178+ }
179+ withFiles := *config
180+ withFiles.Binds = append([]domain.ContainerBind(nil), config.Binds...)
181+ for _, key := range slices.Sorted(maps.Keys(files)) {
182+ withFiles.Binds = append(withFiles.Binds, domain.ContainerBind{
183+ Name: "secret-file-" + key,
184+ Source: paths[key],
185+ Destination: domain.SecretFilePath(key),
186+ ReadOnly: true,
187+ })
188+ }
189+ created, err := s.createContainer(ctx, service, &withFiles)
190+ if err != nil {
191+ if rmErr := s.deps.SecretFiles.Remove(ctx, app, config.Name); rmErr != nil {
192+ log := zerowrap.FromCtx(ctx)
193+ log.Warn().Str("app", app).Str("service", service).
194+ Msg("deployment: secret files cleanup after failed create failed")
195+ }
196+ return nil, err
197+ }
198+ return created, nil
199+}
200+
161 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
202+ if err := s.deps.Guard.Check(config); err != nil {
203+ return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err)
204+ }
162 created, err := s.deps.Runtime.CreateContainer(ctx, config)205 created, err := s.deps.Runtime.CreateContainer(ctx, config)
163 if err == nil {206 if err == nil {
164 return created, nil207 return created, nil
@@ -273,7 +316,10 @@ func shortOp(opID string) string {
273 // values (memory only, UUID-keyed paths). Keys are emitted sorted and316 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
274 // deterministic. Overlapping public/secret keys are invalid with no317 // deterministic. Overlapping public/secret keys are invalid with no
275 // precedence; preflight rejects them before any mutation.318 // precedence; preflight rejects them before any mutation.
276-func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) {319+//
320+// File-delivered secrets are returned in files (env key -> value, nil
321+// when none) and the env gets <KEY>_FILE=/run/secrets/<KEY> instead.
322+func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, map[string]string, error) {
277 spec := p.spec323 spec := p.spec
278 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))324 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))
279 maps.Copy(envMap, p.appEnv)325 maps.Copy(envMap, p.appEnv)
@@ -284,24 +330,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) (
284 }330 }
285 sort.Strings(keys)331 sort.Strings(keys)
286 if len(keys) == 0 {332 if len(keys) == 0 {
287- return sortedEnv(envMap), nil333+ return sortedEnv(envMap), nil, nil
288 }334 }
289 id, err := s.appSecretID(ctx, app)335 id, err := s.appSecretID(ctx, app)
290 if err != nil {336 if err != nil {
291- return nil, err337+ return nil, nil, err
292 }338 }
339+ var files map[string]string
293 for _, envKey := range keys {340 for _, envKey := range keys {
294- if _, overlap := envMap[envKey]; overlap {341+ if err := checkSecretEnvOverlap(envMap, spec, envKey); err != nil {
295- return nil, fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)342+ return nil, nil, err
296 }343 }
297 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])344 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])
298 value, err := s.deps.Secrets.GetSecret(ctx, path)345 value, err := s.deps.Secrets.GetSecret(ctx, path)
299 if err != nil {346 if err != nil {
300- return nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)347+ return nil, nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
301 }348 }
302- envMap[envKey] = value349+ if !spec.SecretAsFile(envKey) {
350+ envMap[envKey] = value
351+ continue
352+ }
353+ if files == nil {
354+ files = make(map[string]string, len(spec.SecretFiles))
355+ }
356+ files[envKey] = value
357+ envMap[domain.SecretFileEnvKey(envKey)] = domain.SecretFilePath(envKey)
358+ }
359+ return sortedEnv(envMap), files, nil
360+}
361+
362+// checkSecretEnvOverlap refuses a secret key (and, for file delivery, its
363+// <KEY>_FILE key) that is already set by public env or another secret.
364+func checkSecretEnvOverlap(envMap map[string]string, spec domain.AppService, envKey string) error {
365+ if _, overlap := envMap[envKey]; overlap {
366+ return fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
303 }367 }
304- return sortedEnv(envMap), nil368+ if !spec.SecretAsFile(envKey) {
369+ return nil
370+ }
371+ fileKey := domain.SecretFileEnvKey(envKey)
372+ if _, overlap := envMap[fileKey]; overlap {
373+ return fmt.Errorf("deployment: env key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
374+ }
375+ if _, overlap := spec.Secrets[fileKey]; overlap {
376+ return fmt.Errorf("deployment: secret key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
377+ }
378+ return nil
305 }379 }
306 380
307 // sortedEnv renders a KEY=value list in sorted key order. Values stay in381 // sortedEnv renders a KEY=value list in sorted key order. Values stay in
M internal/usecase/deployment/execute.go +24 -5
@@ -445,7 +445,7 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
445445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {
446446 return "container not running"
447447 }
448- if !s.envCurrent(ctx, app, p, container.Env) {
448+ if !s.envCurrent(ctx, app, p, container) {
449449 return "environment or secrets changed"
450450 }
451451 return ""
@@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
455455 // environment p would get now, including current secret values. The
456456 // runtime env also holds image-declared keys, so every desired entry must
457457 // be present; removed keys are caught by the spec and app env comparisons.
458+// File-delivered secrets are compared against the container's host files.
458459 // Values are compared in memory only and never logged.
459-func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, running []string) bool {
460- desired, err := s.serviceEnv(ctx, app, p)
460+func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, container *domain.Container) bool {
461+ desired, files, err := s.serviceEnv(ctx, app, p)
461462 if err != nil {
462463 return false
463464 }
464- have := make(map[string]struct{}, len(running))
465- for _, entry := range running {
465+ have := make(map[string]struct{}, len(container.Env))
466+ for _, entry := range container.Env {
466467 have[entry] = struct{}{}
467468 }
468469 for _, entry := range desired {
@@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r
470471 return false
471472 }
472473 }
474+ return s.secretFilesCurrent(ctx, app, container.Name, files)
475+}
476+
477+// secretFilesCurrent reports whether every host secret file of the
478+// container holds the current value. Values are compared in memory only.
479+func (s *Service) secretFilesCurrent(ctx context.Context, app, container string, files map[string]string) bool {
480+ if len(files) == 0 {
481+ return true
482+ }
483+ if s.deps.SecretFiles == nil {
484+ return false
485+ }
486+ for key, want := range files {
487+ got, err := s.deps.SecretFiles.Read(ctx, app, container, key)
488+ if err != nil || got != want {
489+ return false
490+ }
491+ }
473492 return true
474493 }
475494
@@ -445,7 +445,7 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {
446 return "container not running"446 return "container not running"
447 }447 }
448- if !s.envCurrent(ctx, app, p, container.Env) {448+ if !s.envCurrent(ctx, app, p, container) {
449 return "environment or secrets changed"449 return "environment or secrets changed"
450 }450 }
451 return ""451 return ""
@@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
455 // environment p would get now, including current secret values. The455 // environment p would get now, including current secret values. The
456 // runtime env also holds image-declared keys, so every desired entry must456 // runtime env also holds image-declared keys, so every desired entry must
457 // be present; removed keys are caught by the spec and app env comparisons.457 // be present; removed keys are caught by the spec and app env comparisons.
458+// File-delivered secrets are compared against the container's host files.
458 // Values are compared in memory only and never logged.459 // Values are compared in memory only and never logged.
459-func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, running []string) bool {460+func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, container *domain.Container) bool {
460- desired, err := s.serviceEnv(ctx, app, p)461+ desired, files, err := s.serviceEnv(ctx, app, p)
461 if err != nil {462 if err != nil {
462 return false463 return false
463 }464 }
464- have := make(map[string]struct{}, len(running))465+ have := make(map[string]struct{}, len(container.Env))
465- for _, entry := range running {466+ for _, entry := range container.Env {
466 have[entry] = struct{}{}467 have[entry] = struct{}{}
467 }468 }
468 for _, entry := range desired {469 for _, entry := range desired {
@@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r
470 return false471 return false
471 }472 }
472 }473 }
474+ return s.secretFilesCurrent(ctx, app, container.Name, files)
475+}
476+
477+// secretFilesCurrent reports whether every host secret file of the
478+// container holds the current value. Values are compared in memory only.
479+func (s *Service) secretFilesCurrent(ctx context.Context, app, container string, files map[string]string) bool {
480+ if len(files) == 0 {
481+ return true
482+ }
483+ if s.deps.SecretFiles == nil {
484+ return false
485+ }
486+ for key, want := range files {
487+ got, err := s.deps.SecretFiles.Read(ctx, app, container, key)
488+ if err != nil || got != want {
489+ return false
490+ }
491+ }
473 return true492 return true
474 }493 }
475 494
M internal/usecase/deployment/retirement.go +13 -0
@@ -6,6 +6,8 @@ import (
66 "sort"
77 "time"
88
9+ "github.com/bnema/zerowrap"
10+
911 "github.com/bnema/gordon/internal/domain"
1012 )
1113
@@ -92,6 +94,17 @@ func (s *Service) retireContainer(ctx context.Context, app string, opts retireOp
9294 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),
9395 })
9496 }
97+ // The app lock is held, so no candidate of this app can be between
98+ // secret file write and container create.
99+ if err := s.sweepSecretFiles(ctx, app); err != nil {
100+ // Fixed detail: the error may name host paths, which must stay
101+ // out of journals and API responses.
102+ log := zerowrap.FromCtx(ctx)
103+ log.Warn().Err(err).Str("app", app).Str("service", opts.Service).Msg("deployment: secret files cleanup failed")
104+ result.Warnings = append(result.Warnings, CleanupWarning{
105+ Service: opts.Service, Leftover: containerID, Detail: "secret files cleanup failed",
106+ })
107+ }
95108 if opts.ClearInhibition {
96109 // Confirmed gone: the marker has nothing left to protect, and a
97110 // stale record would only confuse the next recreation.
@@ -6,6 +6,8 @@ import (
6 "sort"6 "sort"
7 "time"7 "time"
8 8
9+ "github.com/bnema/zerowrap"
10+
9 "github.com/bnema/gordon/internal/domain"11 "github.com/bnema/gordon/internal/domain"
10 )12 )
11 13
@@ -92,6 +94,17 @@ func (s *Service) retireContainer(ctx context.Context, app string, opts retireOp
92 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),94 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),
93 })95 })
94 }96 }
97+ // The app lock is held, so no candidate of this app can be between
98+ // secret file write and container create.
99+ if err := s.sweepSecretFiles(ctx, app); err != nil {
100+ // Fixed detail: the error may name host paths, which must stay
101+ // out of journals and API responses.
102+ log := zerowrap.FromCtx(ctx)
103+ log.Warn().Err(err).Str("app", app).Str("service", opts.Service).Msg("deployment: secret files cleanup failed")
104+ result.Warnings = append(result.Warnings, CleanupWarning{
105+ Service: opts.Service, Leftover: containerID, Detail: "secret files cleanup failed",
106+ })
107+ }
95 if opts.ClearInhibition {108 if opts.ClearInhibition {
96 // Confirmed gone: the marker has nothing left to protect, and a109 // Confirmed gone: the marker has nothing left to protect, and a
97 // stale record would only confuse the next recreation.110 // stale record would only confuse the next recreation.
A internal/usecase/deployment/runtime_guard_internal_test.go +93 -0
@@ -0,0 +1,93 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestCreateContainer_GuardRefuses(t *testing.T) {
19+ protected := t.TempDir()
20+ child := filepath.Join(protected, "registry")
21+ require.NoError(t, os.MkdirAll(child, 0o700))
22+ cases := map[string]*domain.ContainerConfig{
23+ "host network": {Name: "c", NetworkMode: "host"},
24+ "protected bind": {
25+ Name: "c",
26+ Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}},
27+ },
28+ }
29+ for name, config := range cases {
30+ t.Run(name, func(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default())
33+
34+ _, err := svc.createContainer(context.Background(), "web", config)
35+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
36+ assert.NotContains(t, err.Error(), protected)
37+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
38+ })
39+ }
40+}
41+
42+func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
43+ dataDir := t.TempDir()
44+ base := filepath.Join(dataDir, "secrets-run")
45+ store := outmocks.NewMockSecretFileStore(t)
46+ runtime := outmocks.NewMockContainerRuntime(t)
47+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base}
48+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default())
49+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")}
50+
51+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
52+ Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once()
53+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once()
54+
55+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"})
56+ require.NoError(t, err)
57+}
58+
59+func TestResolveServiceBinds_GuardRefusesProtectedSource(t *testing.T) {
60+ protected := t.TempDir()
61+ svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
62+ WithBindPolicies(map[string]domain.AppBindPolicy{
63+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
64+ })
65+
66+ _, err := svc.resolveServiceBinds("blog", domain.AppService{
67+ Name: "web",
68+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
69+ })
70+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
71+ assert.NotContains(t, err.Error(), protected)
72+}
73+
74+// TestCreateAndStart_GuardRefusesBeforeRuntimeMutation proves the guard in
75+// resolveServiceBinds stops a protected bind before secrets, volumes, or
76+// container create.
77+func TestCreateAndStart_GuardRefusesBeforeRuntimeMutation(t *testing.T) {
78+ protected := t.TempDir()
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
81+ WithBindPolicies(map[string]domain.AppBindPolicy{
82+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
83+ })
84+ p := pinnedService{name: "web", spec: domain.AppService{
85+ Name: "web",
86+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
87+ }}
88+
89+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
90+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
91+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
92+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything)
93+}
@@ -0,0 +1,93 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestCreateContainer_GuardRefuses(t *testing.T) {
19+ protected := t.TempDir()
20+ child := filepath.Join(protected, "registry")
21+ require.NoError(t, os.MkdirAll(child, 0o700))
22+ cases := map[string]*domain.ContainerConfig{
23+ "host network": {Name: "c", NetworkMode: "host"},
24+ "protected bind": {
25+ Name: "c",
26+ Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}},
27+ },
28+ }
29+ for name, config := range cases {
30+ t.Run(name, func(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default())
33+
34+ _, err := svc.createContainer(context.Background(), "web", config)
35+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
36+ assert.NotContains(t, err.Error(), protected)
37+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
38+ })
39+ }
40+}
41+
42+func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
43+ dataDir := t.TempDir()
44+ base := filepath.Join(dataDir, "secrets-run")
45+ store := outmocks.NewMockSecretFileStore(t)
46+ runtime := outmocks.NewMockContainerRuntime(t)
47+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base}
48+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default())
49+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")}
50+
51+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
52+ Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once()
53+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once()
54+
55+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"})
56+ require.NoError(t, err)
57+}
58+
59+func TestResolveServiceBinds_GuardRefusesProtectedSource(t *testing.T) {
60+ protected := t.TempDir()
61+ svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
62+ WithBindPolicies(map[string]domain.AppBindPolicy{
63+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
64+ })
65+
66+ _, err := svc.resolveServiceBinds("blog", domain.AppService{
67+ Name: "web",
68+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
69+ })
70+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
71+ assert.NotContains(t, err.Error(), protected)
72+}
73+
74+// TestCreateAndStart_GuardRefusesBeforeRuntimeMutation proves the guard in
75+// resolveServiceBinds stops a protected bind before secrets, volumes, or
76+// container create.
77+func TestCreateAndStart_GuardRefusesBeforeRuntimeMutation(t *testing.T) {
78+ protected := t.TempDir()
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
81+ WithBindPolicies(map[string]domain.AppBindPolicy{
82+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
83+ })
84+ p := pinnedService{name: "web", spec: domain.AppService{
85+ Name: "web",
86+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
87+ }}
88+
89+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
90+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
91+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
92+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything)
93+}
A internal/usecase/deployment/secret_files.go +60 -0
@@ -0,0 +1,60 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+)
10+
11+// SweepSecretFiles removes every secret file directory whose container no
12+// longer exists, across all apps. It takes no locks: it runs once at
13+// startup, before any listener (and so any deploy) exists.
14+func (s *Service) SweepSecretFiles(ctx context.Context) error {
15+ return s.sweepSecretFiles(ctx, "")
16+}
17+
18+// sweepSecretFiles removes orphaned directories, limited to one app when
19+// filter is not empty. Directory names are container names because the
20+// files are written before the container ID exists.
21+func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
22+ store := s.deps.SecretFiles
23+ if store == nil {
24+ return nil
25+ }
26+ sets, err := store.List(ctx)
27+ if err != nil {
28+ return fmt.Errorf("deployment: list secret files: %w", err)
29+ }
30+ if filter != "" {
31+ kept := make([]out.SecretFileSet, 0, len(sets))
32+ for _, set := range sets {
33+ if set.App == filter {
34+ kept = append(kept, set)
35+ }
36+ }
37+ sets = kept
38+ }
39+ if len(sets) == 0 {
40+ return nil
41+ }
42+ containers, err := s.deps.Runtime.ListContainers(ctx, true)
43+ if err != nil {
44+ return fmt.Errorf("deployment: list containers for secret file sweep: %w", err)
45+ }
46+ live := make(map[string]struct{}, len(containers))
47+ for _, c := range containers {
48+ live[c.Name] = struct{}{}
49+ }
50+ var failures []error
51+ for _, set := range sets {
52+ if _, ok := live[set.Container]; ok {
53+ continue
54+ }
55+ if err := store.Remove(ctx, set.App, set.Container); err != nil {
56+ failures = append(failures, fmt.Errorf("deployment: remove secret files of app %q: %w", set.App, err))
57+ }
58+ }
59+ return errors.Join(failures...)
60+}
@@ -0,0 +1,60 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+)
10+
11+// SweepSecretFiles removes every secret file directory whose container no
12+// longer exists, across all apps. It takes no locks: it runs once at
13+// startup, before any listener (and so any deploy) exists.
14+func (s *Service) SweepSecretFiles(ctx context.Context) error {
15+ return s.sweepSecretFiles(ctx, "")
16+}
17+
18+// sweepSecretFiles removes orphaned directories, limited to one app when
19+// filter is not empty. Directory names are container names because the
20+// files are written before the container ID exists.
21+func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
22+ store := s.deps.SecretFiles
23+ if store == nil {
24+ return nil
25+ }
26+ sets, err := store.List(ctx)
27+ if err != nil {
28+ return fmt.Errorf("deployment: list secret files: %w", err)
29+ }
30+ if filter != "" {
31+ kept := make([]out.SecretFileSet, 0, len(sets))
32+ for _, set := range sets {
33+ if set.App == filter {
34+ kept = append(kept, set)
35+ }
36+ }
37+ sets = kept
38+ }
39+ if len(sets) == 0 {
40+ return nil
41+ }
42+ containers, err := s.deps.Runtime.ListContainers(ctx, true)
43+ if err != nil {
44+ return fmt.Errorf("deployment: list containers for secret file sweep: %w", err)
45+ }
46+ live := make(map[string]struct{}, len(containers))
47+ for _, c := range containers {
48+ live[c.Name] = struct{}{}
49+ }
50+ var failures []error
51+ for _, set := range sets {
52+ if _, ok := live[set.Container]; ok {
53+ continue
54+ }
55+ if err := store.Remove(ctx, set.App, set.Container); err != nil {
56+ failures = append(failures, fmt.Errorf("deployment: remove secret files of app %q: %w", set.App, err))
57+ }
58+ }
59+ return errors.Join(failures...)
60+}
A internal/usecase/deployment/secret_files_internal_test.go +311 -0
@@ -0,0 +1,311 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+const (
19+ testFileSecretValue = "file-s3cr3t-value"
20+ testSecretFileBase = "/data/secrets-run"
21+)
22+
23+// fileSecretService returns a service with one env and one file secret,
24+// wired to mocks returning "env-only" and testFileSecretValue.
25+func fileSecretService(t *testing.T, deps Deps) (*Service, pinnedService) {
26+ t.Helper()
27+ state := outmocks.NewMockAppState(t)
28+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe()
29+ secrets := outmocks.NewMockSecretProvider(t)
30+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/a").Return("env-only", nil).Maybe()
31+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/b").Return(testFileSecretValue, nil).Maybe()
32+ deps.State = state
33+ deps.Secrets = secrets
34+ p := pinnedService{name: "web", appEnv: map[string]string{"APP_ENV": "production"}, spec: domain.AppService{
35+ Name: "web",
36+ Secrets: map[string]string{"A": "a", "B": "b"},
37+ SecretFiles: []string{"B"},
38+ }}
39+ return NewService(deps, zerowrap.Default()), p
40+}
41+
42+func TestServiceEnv_SplitsFileSecrets(t *testing.T) {
43+ svc, p := fileSecretService(t, Deps{})
44+
45+ env, files, err := svc.serviceEnv(context.Background(), "blog", p)
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"A=env-only", "APP_ENV=production", "B_FILE=/run/secrets/B"}, env)
48+ assert.Equal(t, map[string]string{"B": testFileSecretValue}, files)
49+ for _, entry := range env {
50+ assert.NotContains(t, entry, testFileSecretValue)
51+ }
52+}
53+
54+func TestServiceEnv_FileKeyOverlapFails(t *testing.T) {
55+ svc, p := fileSecretService(t, Deps{})
56+ p.appEnv = map[string]string{"B_FILE": "x"}
57+
58+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
59+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
60+}
61+
62+func TestCreateWithSecretFiles_MountsReadOnlyFiles(t *testing.T) {
63+ store := outmocks.NewMockSecretFileStore(t)
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
66+ admin := domain.ContainerBind{Name: "config", Source: "/srv/config", Destination: "/etc/app", ReadOnly: true}
67+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Binds: []domain.ContainerBind{admin}}
68+
69+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", map[string]string{"B": testFileSecretValue}).
70+ Return(map[string]string{"B": testSecretFileBase + "/blog/gordon-blog--web--abc/B"}, nil).Once()
71+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(c *domain.ContainerConfig) bool {
72+ return assert.ObjectsAreEqual([]domain.ContainerBind{admin, {
73+ Name: "secret-file-B",
74+ Source: testSecretFileBase + "/blog/gordon-blog--web--abc/B",
75+ Destination: "/run/secrets/B",
76+ ReadOnly: true,
77+ }}, c.Binds)
78+ })).Return(&domain.Container{ID: "c-1"}, nil).Once()
79+
80+ created, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
81+ require.NoError(t, err)
82+ assert.Equal(t, "c-1", created.ID)
83+ assert.Equal(t, []domain.ContainerBind{admin}, config.Binds, "caller config is not mutated")
84+}
85+
86+func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
87+ store := outmocks.NewMockSecretFileStore(t)
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
90+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc"}
91+ path := testSecretFileBase + "/blog/gordon-blog--web--abc/B"
92+
93+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
94+ Return(map[string]string{"B": path}, nil).Once()
95+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
96+ Return(nil, errors.New("create failed: mount "+path+" "+testFileSecretValue)).Once()
97+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--abc").Return(nil).Once()
98+
99+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
100+ require.Error(t, err)
101+ assert.NotContains(t, err.Error(), testFileSecretValue)
102+ assert.NotContains(t, err.Error(), path)
103+}
104+
105+func TestCreateWithSecretFiles_WriteFailureSkipsCreate(t *testing.T) {
106+ store := outmocks.NewMockSecretFileStore(t)
107+ runtime := outmocks.NewMockContainerRuntime(t)
108+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
109+
110+ store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).Return(nil, errors.New("disk full")).Once()
111+
112+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": testFileSecretValue})
113+ require.Error(t, err)
114+ assert.NotContains(t, err.Error(), testFileSecretValue)
115+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
116+}
117+
118+func TestCreateWithSecretFiles_CleanupFailureKeepsCreateError(t *testing.T) {
119+ store := outmocks.NewMockSecretFileStore(t)
120+ runtime := outmocks.NewMockContainerRuntime(t)
121+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
122+
123+ store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).
124+ Return(map[string]string{"B": testSecretFileBase + "/blog/c/B"}, nil).Once()
125+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(nil, errors.New("create failed")).Once()
126+ store.EXPECT().Remove(mock.Anything, "blog", "c").Return(errors.New("busy")).Once()
127+
128+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
129+ require.Error(t, err)
130+ // The create error is returned; the cleanup error is only logged.
131+ assert.Contains(t, err.Error(), `"web"`)
132+ assert.Contains(t, err.Error(), "runtime error redacted")
133+ assert.NotContains(t, err.Error(), "busy")
134+}
135+
136+func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
137+ runtime := outmocks.NewMockContainerRuntime(t)
138+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
139+
140+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
141+ require.Error(t, err)
142+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
143+}
144+
145+func TestCheckSecrets_FileSecretsNeedStore(t *testing.T) {
146+ secrets := outmocks.NewMockSecretProvider(t)
147+ svc := NewService(Deps{Secrets: secrets}, zerowrap.Default())
148+
149+ err := svc.checkSecrets(context.Background(), "blog", "app-1", domain.AppService{
150+ Name: "web", Secrets: map[string]string{"B": "b"}, SecretFiles: []string{"B"},
151+ })
152+ require.Error(t, err)
153+ assert.Contains(t, err.Error(), "no secret file store")
154+}
155+
156+func TestEnvCurrent_ComparesSecretFiles(t *testing.T) {
157+ running := &domain.Container{
158+ Name: "gordon-blog--web--abc",
159+ Env: []string{"APP_ENV=production", "A=env-only", "B_FILE=/run/secrets/B", "PATH=/bin"},
160+ }
161+ cases := []struct {
162+ name string
163+ read func(*outmocks.MockSecretFileStore)
164+ want bool
165+ }{
166+ {"same content kept", func(m *outmocks.MockSecretFileStore) {
167+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return(testFileSecretValue, nil).Once()
168+ }, true},
169+ {"changed content replaced", func(m *outmocks.MockSecretFileStore) {
170+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("old", nil).Once()
171+ }, false},
172+ {"read error replaced", func(m *outmocks.MockSecretFileStore) {
173+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("", errors.New("gone")).Once()
174+ }, false},
175+ }
176+ for _, tc := range cases {
177+ t.Run(tc.name, func(t *testing.T) {
178+ store := outmocks.NewMockSecretFileStore(t)
179+ tc.read(store)
180+ svc, p := fileSecretService(t, Deps{SecretFiles: store})
181+ assert.Equal(t, tc.want, svc.envCurrent(context.Background(), "blog", p, running))
182+ })
183+ }
184+
185+ t.Run("nil store replaced", func(t *testing.T) {
186+ svc, p := fileSecretService(t, Deps{})
187+ assert.False(t, svc.envCurrent(context.Background(), "blog", p, running))
188+ })
189+}
190+
191+func TestRedactDiagnostics_RedactsFileSecrets(t *testing.T) {
192+ svc, p := fileSecretService(t, Deps{})
193+
194+ got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"loaded " + testFileSecretValue, "ok"})
195+ assert.Equal(t, []string{"loaded [redacted]", "ok"}, got)
196+}
197+
198+func TestSweepSecretFiles(t *testing.T) {
199+ sets := []out.SecretFileSet{
200+ {App: "blog", Container: "gordon-blog--web--a"},
201+ {App: "blog", Container: "gordon-blog--web--b"},
202+ {App: "other", Container: "gordon-other--x--c"},
203+ }
204+ live := []*domain.Container{{Name: "gordon-blog--web--b"}, {Name: "unrelated"}}
205+
206+ t.Run("app scoped", func(t *testing.T) {
207+ store := outmocks.NewMockSecretFileStore(t)
208+ runtime := outmocks.NewMockContainerRuntime(t)
209+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
210+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
211+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
212+
213+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
214+ require.NoError(t, svc.sweepSecretFiles(context.Background(), "blog"))
215+ })
216+
217+ t.Run("all apps", func(t *testing.T) {
218+ store := outmocks.NewMockSecretFileStore(t)
219+ runtime := outmocks.NewMockContainerRuntime(t)
220+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
221+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
222+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
223+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
224+
225+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
226+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
227+ })
228+
229+ t.Run("no sets skips runtime", func(t *testing.T) {
230+ store := outmocks.NewMockSecretFileStore(t)
231+ runtime := outmocks.NewMockContainerRuntime(t)
232+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
233+
234+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
235+ require.NoError(t, svc.sweepSecretFiles(context.Background(), "ghost"))
236+ runtime.AssertNotCalled(t, "ListContainers", mock.Anything, mock.Anything)
237+ })
238+
239+ t.Run("nil store", func(t *testing.T) {
240+ svc := NewService(Deps{}, zerowrap.Default())
241+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
242+ })
243+
244+ t.Run("runtime list error removes nothing", func(t *testing.T) {
245+ store := outmocks.NewMockSecretFileStore(t)
246+ runtime := outmocks.NewMockContainerRuntime(t)
247+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
248+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, errors.New("daemon down")).Once()
249+
250+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
251+ require.Error(t, svc.SweepSecretFiles(context.Background()))
252+ store.AssertNotCalled(t, "Remove", mock.Anything, mock.Anything, mock.Anything)
253+ })
254+
255+ t.Run("partial remove failure continues", func(t *testing.T) {
256+ store := outmocks.NewMockSecretFileStore(t)
257+ runtime := outmocks.NewMockContainerRuntime(t)
258+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
259+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
260+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(errors.New("busy")).Once()
261+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
262+
263+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
264+ require.Error(t, svc.SweepSecretFiles(context.Background()))
265+ })
266+}
267+
268+func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
269+ t.Run("gone sweeps", func(t *testing.T) {
270+ state := outmocks.NewMockAppState(t)
271+ runtime := outmocks.NewMockContainerRuntime(t)
272+ store := outmocks.NewMockSecretFileStore(t)
273+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
274+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
275+ store.EXPECT().List(mock.Anything).Return([]out.SecretFileSet{{App: "blog", Container: "gordon-blog--web--a"}}, nil).Once()
276+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, nil).Once()
277+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
278+
279+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
280+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
281+ assert.True(t, retired.Gone)
282+ assert.Empty(t, retired.Warnings)
283+ })
284+
285+ t.Run("sweep failure is a fixed warning", func(t *testing.T) {
286+ state := outmocks.NewMockAppState(t)
287+ runtime := outmocks.NewMockContainerRuntime(t)
288+ store := outmocks.NewMockSecretFileStore(t)
289+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
290+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
291+ store.EXPECT().List(mock.Anything).Return(nil, errors.New("open "+testSecretFileBase+": denied")).Once()
292+
293+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
294+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
295+ assert.True(t, retired.Gone)
296+ require.Len(t, retired.Warnings, 1)
297+ assert.Equal(t, "secret files cleanup failed", retired.Warnings[0].Detail)
298+ })
299+
300+ t.Run("not gone does not sweep", func(t *testing.T) {
301+ state := outmocks.NewMockAppState(t)
302+ runtime := outmocks.NewMockContainerRuntime(t)
303+ store := outmocks.NewMockSecretFileStore(t)
304+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(errors.New("busy")).Once()
305+
306+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
307+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
308+ assert.False(t, retired.Gone)
309+ store.AssertNotCalled(t, "List", mock.Anything)
310+ })
311+}
@@ -0,0 +1,311 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+const (
19+ testFileSecretValue = "file-s3cr3t-value"
20+ testSecretFileBase = "/data/secrets-run"
21+)
22+
23+// fileSecretService returns a service with one env and one file secret,
24+// wired to mocks returning "env-only" and testFileSecretValue.
25+func fileSecretService(t *testing.T, deps Deps) (*Service, pinnedService) {
26+ t.Helper()
27+ state := outmocks.NewMockAppState(t)
28+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe()
29+ secrets := outmocks.NewMockSecretProvider(t)
30+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/a").Return("env-only", nil).Maybe()
31+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/b").Return(testFileSecretValue, nil).Maybe()
32+ deps.State = state
33+ deps.Secrets = secrets
34+ p := pinnedService{name: "web", appEnv: map[string]string{"APP_ENV": "production"}, spec: domain.AppService{
35+ Name: "web",
36+ Secrets: map[string]string{"A": "a", "B": "b"},
37+ SecretFiles: []string{"B"},
38+ }}
39+ return NewService(deps, zerowrap.Default()), p
40+}
41+
42+func TestServiceEnv_SplitsFileSecrets(t *testing.T) {
43+ svc, p := fileSecretService(t, Deps{})
44+
45+ env, files, err := svc.serviceEnv(context.Background(), "blog", p)
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"A=env-only", "APP_ENV=production", "B_FILE=/run/secrets/B"}, env)
48+ assert.Equal(t, map[string]string{"B": testFileSecretValue}, files)
49+ for _, entry := range env {
50+ assert.NotContains(t, entry, testFileSecretValue)
51+ }
52+}
53+
54+func TestServiceEnv_FileKeyOverlapFails(t *testing.T) {
55+ svc, p := fileSecretService(t, Deps{})
56+ p.appEnv = map[string]string{"B_FILE": "x"}
57+
58+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
59+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
60+}
61+
62+func TestCreateWithSecretFiles_MountsReadOnlyFiles(t *testing.T) {
63+ store := outmocks.NewMockSecretFileStore(t)
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
66+ admin := domain.ContainerBind{Name: "config", Source: "/srv/config", Destination: "/etc/app", ReadOnly: true}
67+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Binds: []domain.ContainerBind{admin}}
68+
69+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", map[string]string{"B": testFileSecretValue}).
70+ Return(map[string]string{"B": testSecretFileBase + "/blog/gordon-blog--web--abc/B"}, nil).Once()
71+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(c *domain.ContainerConfig) bool {
72+ return assert.ObjectsAreEqual([]domain.ContainerBind{admin, {
73+ Name: "secret-file-B",
74+ Source: testSecretFileBase + "/blog/gordon-blog--web--abc/B",
75+ Destination: "/run/secrets/B",
76+ ReadOnly: true,
77+ }}, c.Binds)
78+ })).Return(&domain.Container{ID: "c-1"}, nil).Once()
79+
80+ created, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
81+ require.NoError(t, err)
82+ assert.Equal(t, "c-1", created.ID)
83+ assert.Equal(t, []domain.ContainerBind{admin}, config.Binds, "caller config is not mutated")
84+}
85+
86+func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
87+ store := outmocks.NewMockSecretFileStore(t)
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
90+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc"}
91+ path := testSecretFileBase + "/blog/gordon-blog--web--abc/B"
92+
93+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
94+ Return(map[string]string{"B": path}, nil).Once()
95+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
96+ Return(nil, errors.New("create failed: mount "+path+" "+testFileSecretValue)).Once()
97+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--abc").Return(nil).Once()
98+
99+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
100+ require.Error(t, err)
101+ assert.NotContains(t, err.Error(), testFileSecretValue)
102+ assert.NotContains(t, err.Error(), path)
103+}
104+
105+func TestCreateWithSecretFiles_WriteFailureSkipsCreate(t *testing.T) {
106+ store := outmocks.NewMockSecretFileStore(t)
107+ runtime := outmocks.NewMockContainerRuntime(t)
108+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
109+
110+ store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).Return(nil, errors.New("disk full")).Once()
111+
112+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": testFileSecretValue})
113+ require.Error(t, err)
114+ assert.NotContains(t, err.Error(), testFileSecretValue)
115+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
116+}
117+
118+func TestCreateWithSecretFiles_CleanupFailureKeepsCreateError(t *testing.T) {
119+ store := outmocks.NewMockSecretFileStore(t)
120+ runtime := outmocks.NewMockContainerRuntime(t)
121+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
122+
123+ store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).
124+ Return(map[string]string{"B": testSecretFileBase + "/blog/c/B"}, nil).Once()
125+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(nil, errors.New("create failed")).Once()
126+ store.EXPECT().Remove(mock.Anything, "blog", "c").Return(errors.New("busy")).Once()
127+
128+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
129+ require.Error(t, err)
130+ // The create error is returned; the cleanup error is only logged.
131+ assert.Contains(t, err.Error(), `"web"`)
132+ assert.Contains(t, err.Error(), "runtime error redacted")
133+ assert.NotContains(t, err.Error(), "busy")
134+}
135+
136+func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
137+ runtime := outmocks.NewMockContainerRuntime(t)
138+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
139+
140+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
141+ require.Error(t, err)
142+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
143+}
144+
145+func TestCheckSecrets_FileSecretsNeedStore(t *testing.T) {
146+ secrets := outmocks.NewMockSecretProvider(t)
147+ svc := NewService(Deps{Secrets: secrets}, zerowrap.Default())
148+
149+ err := svc.checkSecrets(context.Background(), "blog", "app-1", domain.AppService{
150+ Name: "web", Secrets: map[string]string{"B": "b"}, SecretFiles: []string{"B"},
151+ })
152+ require.Error(t, err)
153+ assert.Contains(t, err.Error(), "no secret file store")
154+}
155+
156+func TestEnvCurrent_ComparesSecretFiles(t *testing.T) {
157+ running := &domain.Container{
158+ Name: "gordon-blog--web--abc",
159+ Env: []string{"APP_ENV=production", "A=env-only", "B_FILE=/run/secrets/B", "PATH=/bin"},
160+ }
161+ cases := []struct {
162+ name string
163+ read func(*outmocks.MockSecretFileStore)
164+ want bool
165+ }{
166+ {"same content kept", func(m *outmocks.MockSecretFileStore) {
167+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return(testFileSecretValue, nil).Once()
168+ }, true},
169+ {"changed content replaced", func(m *outmocks.MockSecretFileStore) {
170+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("old", nil).Once()
171+ }, false},
172+ {"read error replaced", func(m *outmocks.MockSecretFileStore) {
173+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("", errors.New("gone")).Once()
174+ }, false},
175+ }
176+ for _, tc := range cases {
177+ t.Run(tc.name, func(t *testing.T) {
178+ store := outmocks.NewMockSecretFileStore(t)
179+ tc.read(store)
180+ svc, p := fileSecretService(t, Deps{SecretFiles: store})
181+ assert.Equal(t, tc.want, svc.envCurrent(context.Background(), "blog", p, running))
182+ })
183+ }
184+
185+ t.Run("nil store replaced", func(t *testing.T) {
186+ svc, p := fileSecretService(t, Deps{})
187+ assert.False(t, svc.envCurrent(context.Background(), "blog", p, running))
188+ })
189+}
190+
191+func TestRedactDiagnostics_RedactsFileSecrets(t *testing.T) {
192+ svc, p := fileSecretService(t, Deps{})
193+
194+ got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"loaded " + testFileSecretValue, "ok"})
195+ assert.Equal(t, []string{"loaded [redacted]", "ok"}, got)
196+}
197+
198+func TestSweepSecretFiles(t *testing.T) {
199+ sets := []out.SecretFileSet{
200+ {App: "blog", Container: "gordon-blog--web--a"},
201+ {App: "blog", Container: "gordon-blog--web--b"},
202+ {App: "other", Container: "gordon-other--x--c"},
203+ }
204+ live := []*domain.Container{{Name: "gordon-blog--web--b"}, {Name: "unrelated"}}
205+
206+ t.Run("app scoped", func(t *testing.T) {
207+ store := outmocks.NewMockSecretFileStore(t)
208+ runtime := outmocks.NewMockContainerRuntime(t)
209+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
210+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
211+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
212+
213+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
214+ require.NoError(t, svc.sweepSecretFiles(context.Background(), "blog"))
215+ })
216+
217+ t.Run("all apps", func(t *testing.T) {
218+ store := outmocks.NewMockSecretFileStore(t)
219+ runtime := outmocks.NewMockContainerRuntime(t)
220+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
221+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
222+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
223+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
224+
225+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
226+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
227+ })
228+
229+ t.Run("no sets skips runtime", func(t *testing.T) {
230+ store := outmocks.NewMockSecretFileStore(t)
231+ runtime := outmocks.NewMockContainerRuntime(t)
232+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
233+
234+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
235+ require.NoError(t, svc.sweepSecretFiles(context.Background(), "ghost"))
236+ runtime.AssertNotCalled(t, "ListContainers", mock.Anything, mock.Anything)
237+ })
238+
239+ t.Run("nil store", func(t *testing.T) {
240+ svc := NewService(Deps{}, zerowrap.Default())
241+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
242+ })
243+
244+ t.Run("runtime list error removes nothing", func(t *testing.T) {
245+ store := outmocks.NewMockSecretFileStore(t)
246+ runtime := outmocks.NewMockContainerRuntime(t)
247+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
248+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, errors.New("daemon down")).Once()
249+
250+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
251+ require.Error(t, svc.SweepSecretFiles(context.Background()))
252+ store.AssertNotCalled(t, "Remove", mock.Anything, mock.Anything, mock.Anything)
253+ })
254+
255+ t.Run("partial remove failure continues", func(t *testing.T) {
256+ store := outmocks.NewMockSecretFileStore(t)
257+ runtime := outmocks.NewMockContainerRuntime(t)
258+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
259+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
260+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(errors.New("busy")).Once()
261+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
262+
263+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
264+ require.Error(t, svc.SweepSecretFiles(context.Background()))
265+ })
266+}
267+
268+func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
269+ t.Run("gone sweeps", func(t *testing.T) {
270+ state := outmocks.NewMockAppState(t)
271+ runtime := outmocks.NewMockContainerRuntime(t)
272+ store := outmocks.NewMockSecretFileStore(t)
273+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
274+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
275+ store.EXPECT().List(mock.Anything).Return([]out.SecretFileSet{{App: "blog", Container: "gordon-blog--web--a"}}, nil).Once()
276+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, nil).Once()
277+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
278+
279+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
280+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
281+ assert.True(t, retired.Gone)
282+ assert.Empty(t, retired.Warnings)
283+ })
284+
285+ t.Run("sweep failure is a fixed warning", func(t *testing.T) {
286+ state := outmocks.NewMockAppState(t)
287+ runtime := outmocks.NewMockContainerRuntime(t)
288+ store := outmocks.NewMockSecretFileStore(t)
289+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
290+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
291+ store.EXPECT().List(mock.Anything).Return(nil, errors.New("open "+testSecretFileBase+": denied")).Once()
292+
293+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
294+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
295+ assert.True(t, retired.Gone)
296+ require.Len(t, retired.Warnings, 1)
297+ assert.Equal(t, "secret files cleanup failed", retired.Warnings[0].Detail)
298+ })
299+
300+ t.Run("not gone does not sweep", func(t *testing.T) {
301+ state := outmocks.NewMockAppState(t)
302+ runtime := outmocks.NewMockContainerRuntime(t)
303+ store := outmocks.NewMockSecretFileStore(t)
304+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(errors.New("busy")).Once()
305+
306+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
307+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
308+ assert.False(t, retired.Gone)
309+ store.AssertNotCalled(t, "List", mock.Anything)
310+ })
311+}
M internal/usecase/deployment/service.go +18 -0
@@ -47,6 +47,14 @@ type Deps struct {
4747 // fail-closed before touching a workload. Nil in tests that assert
4848 // state effects only. Failures are reported to the caller.
4949 Traffic out.AppTrafficRefresher
50+ // SecretFiles stores file-delivered secrets on the host. Optional:
51+ // nil is valid only while no service delivers secrets as files;
52+ // preflight refuses such services otherwise.
53+ SecretFiles out.SecretFileStore
54+ // Guard is checked on every app container config right before the
55+ // runtime create. The zero value still refuses host/shared network
56+ // namespaces and host paths passed as volume names.
57+ Guard domain.RuntimeGuard
5058 }
5159
5260 // RegistryConfig carries the installation registry identity for pulls.
@@ -271,6 +279,13 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma
271279 }
272280 resolved = append(resolved, domain.ContainerBind(r))
273281 }
282+ // Apply the runtime guard here so a bind overlapping a protected
283+ // Gordon path fails at preflight and before the serving generation is
284+ // retired, not only at create time. createContainer keeps the full
285+ // guard as the final check.
286+ if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: resolved}); err != nil {
287+ return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err)
288+ }
274289 return resolved, nil
275290 }
276291
@@ -796,6 +811,9 @@ func devicesEngineChecked(pinned []pinnedService) bool {
796811 // Paths are UUID-keyed so a removed app's secrets are never adopted
797812 // by a new app reusing the name.
798813 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {
814+ if len(svc.SecretFiles) > 0 && s.deps.SecretFiles == nil {
815+ return fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", svc.Name)
816+ }
799817 if s.deps.Secrets == nil {
800818 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)
801819 }
@@ -47,6 +47,14 @@ type Deps struct {
47 // fail-closed before touching a workload. Nil in tests that assert47 // fail-closed before touching a workload. Nil in tests that assert
48 // state effects only. Failures are reported to the caller.48 // state effects only. Failures are reported to the caller.
49 Traffic out.AppTrafficRefresher49 Traffic out.AppTrafficRefresher
50+ // SecretFiles stores file-delivered secrets on the host. Optional:
51+ // nil is valid only while no service delivers secrets as files;
52+ // preflight refuses such services otherwise.
53+ SecretFiles out.SecretFileStore
54+ // Guard is checked on every app container config right before the
55+ // runtime create. The zero value still refuses host/shared network
56+ // namespaces and host paths passed as volume names.
57+ Guard domain.RuntimeGuard
50 }58 }
51 59
52 // RegistryConfig carries the installation registry identity for pulls.60 // RegistryConfig carries the installation registry identity for pulls.
@@ -271,6 +279,13 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma
271 }279 }
272 resolved = append(resolved, domain.ContainerBind(r))280 resolved = append(resolved, domain.ContainerBind(r))
273 }281 }
282+ // Apply the runtime guard here so a bind overlapping a protected
283+ // Gordon path fails at preflight and before the serving generation is
284+ // retired, not only at create time. createContainer keeps the full
285+ // guard as the final check.
286+ if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: resolved}); err != nil {
287+ return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err)
288+ }
274 return resolved, nil289 return resolved, nil
275 }290 }
276 291
@@ -796,6 +811,9 @@ func devicesEngineChecked(pinned []pinnedService) bool {
796 // Paths are UUID-keyed so a removed app's secrets are never adopted811 // Paths are UUID-keyed so a removed app's secrets are never adopted
797 // by a new app reusing the name.812 // by a new app reusing the name.
798 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {813 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {
814+ if len(svc.SecretFiles) > 0 && s.deps.SecretFiles == nil {
815+ return fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", svc.Name)
816+ }
799 if s.deps.Secrets == nil {817 if s.deps.Secrets == nil {
800 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)818 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)
801 }819 }
M internal/usecase/deployment/service_env_internal_test.go +4 -3
@@ -23,12 +23,13 @@ func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) {
2323 name: "web", appEnv: appEnv,
2424 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},
2525 }
26- got, err := svc.serviceEnv(context.Background(), "blog", web)
26+ got, files, err := svc.serviceEnv(context.Background(), "blog", web)
2727 require.NoError(t, err)
28+ assert.Nil(t, files)
2829 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)
2930
3031 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}
31- got, err = svc.serviceEnv(context.Background(), "blog", worker)
32+ got, _, err = svc.serviceEnv(context.Background(), "blog", worker)
3233 require.NoError(t, err)
3334 assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got)
3435 assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated")
@@ -44,6 +45,6 @@ func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) {
4445 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},
4546 }}
4647
47- _, err := svc.serviceEnv(context.Background(), "blog", p)
48+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
4849 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
4950 }
@@ -23,12 +23,13 @@ func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) {
23 name: "web", appEnv: appEnv,23 name: "web", appEnv: appEnv,
24 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},24 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},
25 }25 }
26- got, err := svc.serviceEnv(context.Background(), "blog", web)26+ got, files, err := svc.serviceEnv(context.Background(), "blog", web)
27 require.NoError(t, err)27 require.NoError(t, err)
28+ assert.Nil(t, files)
28 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)29 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)
29 30
30 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}31 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}
31- got, err = svc.serviceEnv(context.Background(), "blog", worker)32+ got, _, err = svc.serviceEnv(context.Background(), "blog", worker)
32 require.NoError(t, err)33 require.NoError(t, err)
33 assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got)34 assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got)
34 assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated")35 assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated")
@@ -44,6 +45,6 @@ func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) {
44 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},45 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},
45 }}46 }}
46 47
47- _, err := svc.serviceEnv(context.Background(), "blog", p)48+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
48 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)49 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
49 }50 }
M internal/usecase/logs/service.go +4 -2
@@ -9,7 +9,6 @@ import (
99 "fmt"
1010 "io"
1111 "os"
12- "os/exec"
1312 "strconv"
1413 "strings"
1514 "time"
@@ -18,6 +17,7 @@ import (
1817
1918 "github.com/bnema/gordon/internal/boundaries/out"
2019 "github.com/bnema/gordon/internal/domain"
20+ "github.com/bnema/gordon/pkg/childenv"
2121 )
2222
2323 // appStateProvider resolves app services from the durable ACTIVE record.
@@ -35,7 +35,9 @@ type Service struct {
3535 appState appStateProvider
3636 }
3737
38-var execCommandContext = exec.CommandContext
38+// execCommandContext starts journalctl without Gordon's GORDON_* variables:
39+// the child is dumpable again after exec, so it must not carry secrets.
40+var execCommandContext = childenv.CommandContext
3941
4042 // NewService creates a new log service. App state is wired after the store opens.
4143 func NewService(
@@ -9,7 +9,6 @@ import (
9 "fmt"9 "fmt"
10 "io"10 "io"
11 "os"11 "os"
12- "os/exec"
13 "strconv"12 "strconv"
14 "strings"13 "strings"
15 "time"14 "time"
@@ -18,6 +17,7 @@ import (
18 17
19 "github.com/bnema/gordon/internal/boundaries/out"18 "github.com/bnema/gordon/internal/boundaries/out"
20 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
20+ "github.com/bnema/gordon/pkg/childenv"
21 )21 )
22 22
23 // appStateProvider resolves app services from the durable ACTIVE record.23 // appStateProvider resolves app services from the durable ACTIVE record.
@@ -35,7 +35,9 @@ type Service struct {
35 appState appStateProvider35 appState appStateProvider
36 }36 }
37 37
38-var execCommandContext = exec.CommandContext38+// execCommandContext starts journalctl without Gordon's GORDON_* variables:
39+// the child is dumpable again after exec, so it must not carry secrets.
40+var execCommandContext = childenv.CommandContext
39 41
40 // NewService creates a new log service. App state is wired after the store opens.42 // NewService creates a new log service. App state is wired after the store opens.
41 func NewService(43 func NewService(
A pkg/childenv/childenv.go +35 -0
@@ -0,0 +1,35 @@
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+ "context"
9+ "os"
10+ "os/exec"
11+ "strings"
12+)
13+
14+// CommandContext is exec.CommandContext with Env set to Env(). Use it for
15+// every child process the daemon starts.
16+func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
17+ cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- callers pass constant binaries with validated args
18+ cmd.Env = Env()
19+ return cmd
20+}
21+
22+// Env returns the current environment without GORDON_* variables.
23+func Env() []string {
24+ return filter(os.Environ())
25+}
26+
27+func filter(environ []string) []string {
28+ kept := make([]string, 0, len(environ))
29+ for _, entry := range environ {
30+ if !strings.HasPrefix(entry, "GORDON_") {
31+ kept = append(kept, entry)
32+ }
33+ }
34+ return kept
35+}
@@ -0,0 +1,35 @@
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+ "context"
9+ "os"
10+ "os/exec"
11+ "strings"
12+)
13+
14+// CommandContext is exec.CommandContext with Env set to Env(). Use it for
15+// every child process the daemon starts.
16+func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
17+ cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- callers pass constant binaries with validated args
18+ cmd.Env = Env()
19+ return cmd
20+}
21+
22+// Env returns the current environment without GORDON_* variables.
23+func Env() []string {
24+ return filter(os.Environ())
25+}
26+
27+func filter(environ []string) []string {
28+ kept := make([]string, 0, len(environ))
29+ for _, entry := range environ {
30+ if !strings.HasPrefix(entry, "GORDON_") {
31+ kept = append(kept, entry)
32+ }
33+ }
34+ return kept
35+}
A pkg/childenv/childenv_test.go +27 -0
@@ -0,0 +1,27 @@
1+package childenv
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestFilterDropsGordonVariables(t *testing.T) {
11+ got := filter([]string{
12+ "PATH=/usr/bin",
13+ "GORDON_AUTH_TOKEN_SECRET=s3cr3t",
14+ "PASSWORD_STORE_DIR=/keys/pass",
15+ "GORDON_CLOUDFLARE_API_TOKEN=t0ken",
16+ "GNUPGHOME=/keys/gnupg",
17+ })
18+ assert.Equal(t, []string{"PATH=/usr/bin", "PASSWORD_STORE_DIR=/keys/pass", "GNUPGHOME=/keys/gnupg"}, got)
19+}
20+
21+func TestCommandContextStripsGordonVariables(t *testing.T) {
22+ t.Setenv("GORDON_TEST_SECRET", "s3cr3t")
23+ t.Setenv("CHILDENV_KEEP", "1")
24+ cmd := CommandContext(context.Background(), "true")
25+ assert.Contains(t, cmd.Env, "CHILDENV_KEEP=1")
26+ assert.NotContains(t, cmd.Env, "GORDON_TEST_SECRET=s3cr3t")
27+}
@@ -0,0 +1,27 @@
1+package childenv
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestFilterDropsGordonVariables(t *testing.T) {
11+ got := filter([]string{
12+ "PATH=/usr/bin",
13+ "GORDON_AUTH_TOKEN_SECRET=s3cr3t",
14+ "PASSWORD_STORE_DIR=/keys/pass",
15+ "GORDON_CLOUDFLARE_API_TOKEN=t0ken",
16+ "GNUPGHOME=/keys/gnupg",
17+ })
18+ assert.Equal(t, []string{"PATH=/usr/bin", "PASSWORD_STORE_DIR=/keys/pass", "GNUPGHOME=/keys/gnupg"}, got)
19+}
20+
21+func TestCommandContextStripsGordonVariables(t *testing.T) {
22+ t.Setenv("GORDON_TEST_SECRET", "s3cr3t")
23+ t.Setenv("CHILDENV_KEEP", "1")
24+ cmd := CommandContext(context.Background(), "true")
25+ assert.Contains(t, cmd.Env, "CHILDENV_KEEP=1")
26+ assert.NotContains(t, cmd.Env, "GORDON_TEST_SECRET=s3cr3t")
27+}

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