Merge pull request 'feat(security): v3 secrets hardening' (#14) from feat/v3-secrets-hardening into main
43 files · +2631 -57
M .forgejo/workflows/ci.yml +1 -1
| @@ -20,7 +20,7 @@ jobs: | |||
| 20 | 20 | cache: true | |
| 21 | 21 | - uses: golangci/golangci-lint-action@v9 | |
| 22 | 22 | with: | |
| 23 | - | version: v2.13.1 | |
| 23 | + | version: v2.14.0 | |
| 24 | 24 | ||
| 25 | 25 | test: | |
| 26 | 26 | name: Test | |
| @@ -20,7 +20,7 @@ jobs: | |||||
| 20 | cache: true | 20 | cache: true | ||
| 21 | - uses: golangci/golangci-lint-action@v9 | 21 | - uses: golangci/golangci-lint-action@v9 | ||
| 22 | with: | 22 | with: | ||
| 23 | - | version: v2.13.1 | 23 | + | version: v2.14.0 |
| 24 | 24 | ||||
| 25 | test: | 25 | test: | ||
| 26 | name: Test | 26 | name: Test | ||
M .github/workflows/ci.yml +1 -1
| @@ -63,7 +63,7 @@ jobs: | |||
| 63 | 63 | - name: Run golangci-lint | |
| 64 | 64 | uses: golangci/golangci-lint-action@v9 | |
| 65 | 65 | with: | |
| 66 | - | version: v2.13.1 | |
| 66 | + | version: v2.14.0 | |
| 67 | 67 | ||
| 68 | 68 | test: | |
| 69 | 69 | name: Test | |
| @@ -63,7 +63,7 @@ jobs: | |||||
| 63 | - name: Run golangci-lint | 63 | - name: Run golangci-lint | ||
| 64 | uses: golangci/golangci-lint-action@v9 | 64 | uses: golangci/golangci-lint-action@v9 | ||
| 65 | with: | 65 | with: | ||
| 66 | - | version: v2.13.1 | 66 | + | version: v2.14.0 |
| 67 | 67 | ||||
| 68 | test: | 68 | test: | ||
| 69 | name: Test | 69 | name: Test | ||
M .mockery.yaml +1 -0
| @@ -17,6 +17,7 @@ packages: | |||
| 17 | 17 | EventSubscriber: | |
| 18 | 18 | EventHandler: | |
| 19 | 19 | SecretProvider: | |
| 20 | + | SecretFileStore: | |
| 20 | 21 | ContainerLogWriter: | |
| 21 | 22 | Metrics: | |
| 22 | 23 | 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 | |||
| 52 | 52 | LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name | |
| 53 | 53 | ||
| 54 | 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 | 59 | [[services.web.volume]] # 0..n named volumes only | |
| 58 | 60 | name = "web-data" | |
| @@ -113,6 +115,10 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||
| 113 | 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 | 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 | 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 | 123 | ## Volumes and Databases | |
| 118 | 124 | ||
| @@ -52,7 +52,9 @@ port = 8080 # required; no host or tls | |||||
| 52 | LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name | 52 | 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 only | 59 | [[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 Databases | 123 | ## 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=... | |||
| 117 | 117 | ||
| 118 | 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 | 136 | ## Examples | |
| 121 | 137 | ||
| 122 | 138 | ### Production with Pass | |
| @@ -152,9 +168,12 @@ secrets_backend = "unsafe" | |||
| 152 | 168 | 3. **Rotate regularly**: Regenerate tokens and passwords periodically | |
| 153 | 169 | 4. **Least privilege**: Use separate secrets per environment | |
| 154 | 170 | 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 | 174 | ## Related | |
| 157 | 175 | ||
| 158 | 176 | - [Authentication](./auth.md) | |
| 159 | 177 | - [App Manifest](./apps.md) | |
| 178 | + | - [Security Hardening](./security-hardening.md) | |
| 160 | 179 | - [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 | ## Examples | 136 | ## Examples | ||
| 121 | 137 | ||||
| 122 | ### Production with Pass | 138 | ### Production with Pass | ||
| @@ -152,9 +168,12 @@ secrets_backend = "unsafe" | |||||
| 152 | 3. **Rotate regularly**: Regenerate tokens and passwords periodically | 168 | 3. **Rotate regularly**: Regenerate tokens and passwords periodically | ||
| 153 | 4. **Least privilege**: Use separate secrets per environment | 169 | 4. **Least privilege**: Use separate secrets per environment | ||
| 154 | 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security | 170 | 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 | ## Related | 174 | ## 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" | |||
| 172 | 172 | ||
| 173 | 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 | 255 | ## Related | |
| 176 | 256 | ||
| 177 | 257 | - [Auth](./auth.md) | |
| 258 | + | - [Secrets](./secrets.md) | |
| 178 | 259 | - [Images](./images.md) | |
| 179 | 260 | - [Network Isolation](./network-isolation.md) | |
| 180 | 261 | - [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 | ## Related | 255 | ## 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 | |||
| 266 | 266 | 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 | 271 | ### System Service (Root Mode) | |
| 270 | 272 | ||
| 271 | 273 | ```bash | |
| @@ -266,6 +266,8 @@ sudo loginctl enable-linger $USER | |||||
| 266 | systemctl --user status gordon | 266 | 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 | ```bash | 273 | ```bash | ||
M internal/adapters/in/appmanifest/parse.go +71 -5
| @@ -52,9 +52,11 @@ type rawService struct { | |||
| 52 | 52 | HTTP []rawHTTP `toml:"http"` | |
| 53 | 53 | TCP []rawTCP `toml:"tcp"` | |
| 54 | 54 | 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"` | |
| 58 | 60 | // Devices lists logical device names from `devices = [...]`. | |
| 59 | 61 | Devices []string `toml:"devices"` | |
| 60 | 62 | Databases []rawDatabase `toml:"database"` | |
| @@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 321 | 323 | }) | |
| 322 | 324 | } | |
| 323 | 325 | 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 | |
| 326 | 329 | } | |
| 330 | + | svc.Secrets = secrets | |
| 331 | + | svc.SecretFiles = secretFiles | |
| 327 | 332 | for _, v := range raw.Volumes { | |
| 328 | 333 | svc.Volumes = append(svc.Volumes, domain.AppVolume{ | |
| 329 | 334 | Name: v.Name, | |
| @@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 351 | 356 | 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 | 420 | // toDomainReadiness maps readiness with defaults. | |
| 355 | 421 | func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) { | |
| 356 | 422 | 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] = value | 327 | + | 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, nil | 356 | 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) { | |||
| 559 | 559 | h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization") | |
| 560 | 560 | case errors.Is(err, domain.ErrDevicePolicy): | |
| 561 | 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 | 564 | case errors.Is(err, domain.ErrRuntimeUnsupported): | |
| 563 | 565 | h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support") | |
| 564 | 566 | 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) { | |||
| 682 | 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 | 700 | func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) { | |
| 686 | 701 | appSvc := inmocks.NewMockAppService(t) | |
| 687 | 702 | 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 | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | "fmt" | |
| 10 | - | "os/exec" | |
| 11 | 10 | "strings" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| 14 | 13 | "github.com/bnema/zerowrap" | |
| 15 | 14 | ||
| 16 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | "github.com/bnema/gordon/pkg/childenv" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 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 | 41 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 42 | 42 | 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) | |
| 44 | 44 | cmd.Stdin = strings.NewReader(value) | |
| 45 | 45 | output, err := cmd.CombinedOutput() | |
| 46 | 46 | if err != nil { | |
| @@ -57,7 +57,7 @@ func (s *Store) DeleteSecret(ctx context.Context, path string) error { | |||
| 57 | 57 | } | |
| 58 | 58 | timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 59 | 59 | 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) | |
| 61 | 61 | output, err := cmd.CombinedOutput() | |
| 62 | 62 | if err != nil { | |
| 63 | 63 | 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 below | 43 | + | 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 below | 60 | + | 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 ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | "github.com/bnema/gordon/pkg/childenv" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // passPathRegex validates pass paths. | |
| @@ -80,7 +81,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 80 | 81 | ctx, cancel := context.WithTimeout(ctx, p.timeout) | |
| 81 | 82 | defer cancel() | |
| 82 | 83 | ||
| 83 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths | |
| 84 | + | cmd := childenv.CommandContext(ctx, "pass", "show", path) | |
| 84 | 85 | output, err := cmd.Output() | |
| 85 | 86 | if err != nil { | |
| 86 | 87 | if exitError, ok := err.(*exec.ExitError); ok { | |
| @@ -109,6 +110,6 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 109 | 110 | func (p *PassProvider) IsAvailable() bool { | |
| 110 | 111 | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | |
| 111 | 112 | 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") | |
| 113 | 114 | return cmd.Run() == nil | |
| 114 | 115 | } | |
| @@ -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 paths | 84 | + | 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 input | 113 | + | cmd := childenv.CommandContext(ctx, "pass", "version") |
| 113 | return cmd.Run() == nil | 114 | return cmd.Run() == nil | ||
| 114 | } | 115 | } | ||
M internal/adapters/out/secrets/sops.go +8 -4
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/pkg/childenv" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 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 | 108 | // Convert dot notation to sops --extract Python dictionary syntax | |
| 107 | 109 | extractPath := convertToSopsExtractPath(keyPath) | |
| 108 | 110 | ||
| 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) | |
| 112 | 114 | output, err := cmd.Output() | |
| 113 | 115 | if err != nil { | |
| 114 | 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 | 137 | // IsAvailable checks if sops is available in the system. | |
| 136 | 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 | 142 | err := cmd.Run() | |
| 139 | 143 | return err == nil | |
| 140 | 144 | } | |
| @@ -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 syntax | 108 | // 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 key | 111 | + | // Use sops -d --extract to get specific key; filePath and keyPath are |
| 110 | - | // #nosec G204 - filePath and keyPath are validated above | 112 | + | // 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 == nil | 143 | return err == nil | ||
| 140 | } | 144 | } | ||
M internal/adapters/out/tokenstore/pass.go +9 -9
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | - | "os/exec" | |
| 9 | 8 | "regexp" | |
| 10 | 9 | "strings" | |
| 11 | 10 | "sync" | |
| @@ -15,6 +14,7 @@ import ( | |||
| 15 | 14 | "github.com/bnema/zerowrap" | |
| 16 | 15 | ||
| 17 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | "github.com/bnema/gordon/pkg/childenv" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 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 | 337 | defer cancel() | |
| 338 | 338 | ||
| 339 | 339 | // 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) | |
| 341 | 341 | output, err := cmd.Output() | |
| 342 | 342 | if err != nil { | |
| 343 | 343 | // If the path doesn't exist, return empty list | |
| @@ -482,15 +482,13 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 482 | 482 | metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject) | |
| 483 | 483 | ||
| 484 | 484 | // 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) | |
| 487 | 486 | if err := cmd.Run(); err != nil { | |
| 488 | 487 | return fmt.Errorf("failed to delete token: %w", err) | |
| 489 | 488 | } | |
| 490 | 489 | ||
| 491 | 490 | // 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) | |
| 494 | 492 | if err := cmd.Run(); err != nil { | |
| 495 | 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 | 510 | // passInsert inserts a value into pass. | |
| 513 | 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 subjects | |
| 512 | + | cmd := childenv.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 515 | 513 | cmd.Stdin = strings.NewReader(value) | |
| 516 | 514 | output, err := cmd.CombinedOutput() | |
| 517 | 515 | if err != nil { | |
| @@ -522,7 +520,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 522 | 520 | ||
| 523 | 521 | // passShow retrieves a value from pass. | |
| 524 | 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 subjects | |
| 523 | + | cmd := childenv.CommandContext(ctx, "pass", "show", path) | |
| 526 | 524 | output, err := cmd.Output() | |
| 527 | 525 | if err != nil { | |
| 528 | 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 | 547 | // IsAvailable checks if pass is available in the system. | |
| 550 | 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 | 552 | return cmd.Run() == nil | |
| 553 | 553 | } | |
| @@ -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 path | 339 | // 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) |
| 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 list | 343 | // 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 JWT | 484 | // 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 metadata | 490 | // 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 subjects | 512 | + | 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 subjects | 523 | + | 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() == nil | 552 | return cmd.Run() == nil | ||
| 553 | } | 553 | } | ||
M internal/app/app_wiring.go +11 -0
| @@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error { | |||
| 88 | 88 | if err != nil { | |
| 89 | 89 | 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 | 95 | si.svc.appDeploySvc = deployment.NewService(deployment.Deps{ | |
| 92 | 96 | State: store, | |
| 93 | 97 | Runtime: si.svc.runtime, | |
| @@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error { | |||
| 106 | 110 | Limits: limits, | |
| 107 | 111 | ImagePolicy: imagePolicy, | |
| 108 | 112 | Traffic: si.svc.appTrafficPublisher, | |
| 113 | + | SecretFiles: secretFiles, | |
| 114 | + | Guard: guard, | |
| 109 | 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 | 121 | si.svc.appActivator = apptraffic.NewActivator(si.log) | |
| 111 | 122 | mountPolicies, err := buildAppMountPolicies(si.cfg) | |
| 112 | 123 | if err != nil { | |
| @@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error { | |||||
| 88 | if err != nil { | 88 | if err != nil { | ||
| 89 | return err | 89 | 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 ( | |||
| 32 | 32 | ||
| 33 | 33 | // Run initializes and starts the Gordon application. | |
| 34 | 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 | 41 | // Load configuration | |
| 36 | 42 | v, cfg, err := initConfig(configPath) | |
| 37 | 43 | 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 configuration | 41 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "maps" | |
| 5 | 6 | "net" | |
| 6 | 7 | "path" | |
| 7 | 8 | "reflect" | |
| @@ -106,8 +107,12 @@ type AppService struct { | |||
| 106 | 107 | // [env] for the same key; its keys must be disjoint from Secrets. | |
| 107 | 108 | Env map[string]string | |
| 108 | 109 | 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 | |
| 111 | 116 | // Devices lists logical device names granted by administrative | |
| 112 | 117 | // [app_devices] policy. Names resolve to CDI device IDs at activation | |
| 113 | 118 | // time; the logical names (not host resolution) persist in revisions. | |
| @@ -509,6 +514,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error { | |||
| 509 | 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 | 527 | return nil | |
| 514 | 528 | } | |
| @@ -624,7 +638,7 @@ func (s *AppService) validateSecrets() error { | |||
| 624 | 638 | return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err) | |
| 625 | 639 | } | |
| 626 | 640 | } | |
| 627 | - | return nil | |
| 641 | + | return s.validateSecretFiles() | |
| 628 | 642 | } | |
| 629 | 643 | ||
| 630 | 644 | // validateVolumes checks volume declarations and returns known names. | |
| @@ -1086,11 +1100,7 @@ func diffService(name string, desired, effective AppService) []string { | |||
| 1086 | 1100 | if !equalStringMaps(desired.Env, effective.Env) { | |
| 1087 | 1101 | changed = append(changed, "service/"+name+"/env") | |
| 1088 | 1102 | } | |
| 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) | |
| 1094 | 1104 | if !equalVolumes(desired.Volumes, effective.Volumes) { | |
| 1095 | 1105 | changed = append(changed, "service/"+name+"/volumes") | |
| 1096 | 1106 | } | |
| @@ -1116,16 +1126,23 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap | |||
| 1116 | 1126 | 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 | 1137 | func secretPathChanges(desired, effective AppService) map[string]struct{} { | |
| 1121 | 1138 | paths := map[string]struct{}{} | |
| 1122 | 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 | 1141 | paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{} | |
| 1125 | 1142 | } | |
| 1126 | 1143 | } | |
| 1127 | 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 | 1146 | paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{} | |
| 1130 | 1147 | } | |
| 1131 | 1148 | } | |
| @@ -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]string | 108 | Env map[string]string | ||
| 108 | Secrets map[string]string | 109 | Secrets map[string]string | ||
| 109 | - | Volumes []AppVolume | 110 | + | // SecretFiles lists the Secrets env keys delivered as read-only files |
| 110 | - | Binds []AppBind | 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 | |||
| 111 | // Devices lists logical device names granted by administrative | 116 | // Devices lists logical device names granted by administrative | ||
| 112 | // [app_devices] policy. Names resolve to CDI device IDs at activation | 117 | // [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 nil | 527 | 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 nil | 641 | + | 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 changed | 1126 | 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 ( | |||
| 83 | 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | |
| 84 | 84 | // installation policy refuses to serve it. | |
| 85 | 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 | 91 | // App state errors | |
| 88 | 92 | ErrAppStateIO = errors.New("app state storage failure") | |
| @@ -83,6 +83,10 @@ var ( | |||||
| 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | 83 | // 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 errors | 91 | // 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "maps" | |
| 8 | + | "slices" | |
| 8 | 9 | "sort" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 11 | 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 | 44 | if err != nil { | |
| 42 | 45 | return startedCandidate{}, err | |
| 43 | 46 | } | |
| 44 | - | env, err := s.serviceEnv(ctx, app, p) | |
| 47 | + | env, files, err := s.serviceEnv(ctx, app, p) | |
| 45 | 48 | if err != nil { | |
| 46 | 49 | return startedCandidate{}, err | |
| 47 | 50 | } | |
| @@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi | |||
| 102 | 105 | NanoCPUs: s.deps.Limits.NanoCPUs, | |
| 103 | 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 | 109 | if err != nil { | |
| 107 | 110 | return startedCandidate{}, err | |
| 108 | 111 | } | |
| @@ -158,7 +161,47 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID | |||
| 158 | 161 | 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 | 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 | 205 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | |
| 163 | 206 | if err == nil { | |
| 164 | 207 | return created, nil | |
| @@ -273,7 +316,10 @@ func shortOp(opID string) string { | |||
| 273 | 316 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | |
| 274 | 317 | // deterministic. Overlapping public/secret keys are invalid with no | |
| 275 | 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 | 323 | spec := p.spec | |
| 278 | 324 | envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets)) | |
| 279 | 325 | maps.Copy(envMap, p.appEnv) | |
| @@ -284,24 +330,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ( | |||
| 284 | 330 | } | |
| 285 | 331 | sort.Strings(keys) | |
| 286 | 332 | if len(keys) == 0 { | |
| 287 | - | return sortedEnv(envMap), nil | |
| 333 | + | return sortedEnv(envMap), nil, nil | |
| 288 | 334 | } | |
| 289 | 335 | id, err := s.appSecretID(ctx, app) | |
| 290 | 336 | if err != nil { | |
| 291 | - | return nil, err | |
| 337 | + | return nil, nil, err | |
| 292 | 338 | } | |
| 339 | + | var files map[string]string | |
| 293 | 340 | 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 | |
| 296 | 343 | } | |
| 297 | 344 | path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey]) | |
| 298 | 345 | value, err := s.deps.Secrets.GetSecret(ctx, path) | |
| 299 | 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] = 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) | |
| 303 | 367 | } | |
| 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 | |
| 305 | 379 | } | |
| 306 | 380 | ||
| 307 | 381 | // 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{}, err | 45 | 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{}, err | 49 | 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{}, err | 110 | return startedCandidate{}, err | ||
| 108 | } | 111 | } | ||
| @@ -158,7 +161,47 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID | |||||
| 158 | return nil | 161 | 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, nil | 207 | return created, nil | ||
| @@ -273,7 +316,10 @@ func shortOp(opID string) string { | |||||
| 273 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | 316 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | ||
| 274 | // deterministic. Overlapping public/secret keys are invalid with no | 317 | // 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.spec | 323 | 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), nil | 333 | + | 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, err | 337 | + | 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] = 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) | |||
| 303 | } | 367 | } | ||
| 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 | |||
| 305 | } | 379 | } | ||
| 306 | 380 | ||||
| 307 | // sortedEnv renders a KEY=value list in sorted key order. Values stay in | 381 | // 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 | |||
| 445 | 445 | if err != nil || container.Status != string(domain.ContainerStatusRunning) { | |
| 446 | 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 | 449 | return "environment or secrets changed" | |
| 450 | 450 | } | |
| 451 | 451 | return "" | |
| @@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ | |||
| 455 | 455 | // environment p would get now, including current secret values. The | |
| 456 | 456 | // runtime env also holds image-declared keys, so every desired entry must | |
| 457 | 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 | 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 | - | 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) | |
| 461 | 462 | if err != nil { | |
| 462 | 463 | return false | |
| 463 | 464 | } | |
| 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 { | |
| 466 | 467 | have[entry] = struct{}{} | |
| 467 | 468 | } | |
| 468 | 469 | for _, entry := range desired { | |
| @@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r | |||
| 470 | 471 | 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 | 492 | return true | |
| 474 | 493 | } | |
| 475 | 494 | ||
| @@ -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. The | 455 | // environment p would get now, including current secret values. The | ||
| 456 | // runtime env also holds image-declared keys, so every desired entry must | 456 | // 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 false | 463 | 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 false | 471 | 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 true | 492 | return true | ||
| 474 | } | 493 | } | ||
| 475 | 494 | ||||
M internal/usecase/deployment/retirement.go +13 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "sort" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 9 | 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 | 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 | 108 | if opts.ClearInhibition { | |
| 96 | 109 | // Confirmed gone: the marker has nothing left to protect, and a | |
| 97 | 110 | // 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 a | 109 | // 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 { | |||
| 47 | 47 | // fail-closed before touching a workload. Nil in tests that assert | |
| 48 | 48 | // state effects only. Failures are reported to the caller. | |
| 49 | 49 | 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 | 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 | 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 | 289 | return resolved, nil | |
| 275 | 290 | } | |
| 276 | 291 | ||
| @@ -796,6 +811,9 @@ func devicesEngineChecked(pinned []pinnedService) bool { | |||
| 796 | 811 | // Paths are UUID-keyed so a removed app's secrets are never adopted | |
| 797 | 812 | // by a new app reusing the name. | |
| 798 | 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 | 817 | if s.deps.Secrets == nil { | |
| 800 | 818 | return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing) | |
| 801 | 819 | } | |
| @@ -47,6 +47,14 @@ type Deps struct { | |||||
| 47 | // fail-closed before touching a workload. Nil in tests that assert | 47 | // 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.AppTrafficRefresher | 49 | 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, nil | 289 | 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 adopted | 811 | // 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) { | |||
| 23 | 23 | name: "web", appEnv: appEnv, | |
| 24 | 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 | 27 | require.NoError(t, err) | |
| 28 | + | assert.Nil(t, files) | |
| 28 | 29 | assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got) | |
| 29 | 30 | ||
| 30 | 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 | 33 | require.NoError(t, err) | |
| 33 | 34 | assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got) | |
| 34 | 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 | 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 | 49 | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 49 | 50 | } | |
| @@ -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 ( | |||
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | 11 | "os" | |
| 12 | - | "os/exec" | |
| 13 | 12 | "strconv" | |
| 14 | 13 | "strings" | |
| 15 | 14 | "time" | |
| @@ -18,6 +17,7 @@ import ( | |||
| 18 | 17 | ||
| 19 | 18 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 20 | 19 | "github.com/bnema/gordon/internal/domain" | |
| 20 | + | "github.com/bnema/gordon/pkg/childenv" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // appStateProvider resolves app services from the durable ACTIVE record. | |
| @@ -35,7 +35,9 @@ type Service struct { | |||
| 35 | 35 | appState appStateProvider | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 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 | |
| 39 | 41 | ||
| 40 | 42 | // NewService creates a new log service. App state is wired after the store opens. | |
| 41 | 43 | 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 appStateProvider | 35 | appState appStateProvider | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 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 | |||
| 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 | + | } | |||