detached 062dcae

Merge pull request #177 from bnema/security-hardening-audit-fixes

Security hardening audit fixes
commit
062dcae22fdb18dff104e1d7ef35907e8084adf4
author
bnema · · 2026-04-26 18:25 UTC
committer
GitHub · · 2026-04-26 18:25 UTC
merge of
ec8c5b9 + aba13a4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev9
73 files · +3026 -411
A .dockerignore +11 -0
@@ -0,0 +1,11 @@
1+.git
2+.worktrees
3+.gitignore
4+.gitleaks.toml
5+Dockerfile
6+*.log
7+/tmp
8+coverage.out
9+dist
10+build
11+node_modules
M .github/actions/setup-gordon/action.yml +58 -9
@@ -26,6 +26,8 @@ runs:
26 - name: Install Gordon26 - name: Install Gordon
27 id: install27 id: install
28 shell: bash28 shell: bash
29+ env:
30+ GORDON_VERSION_INPUT: ${{ inputs.version }}
29 run: |31 run: |
30 set -euo pipefail32 set -euo pipefail
31 33
@@ -43,7 +45,11 @@ runs:
43 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;45 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
44 esac46 esac
45 47
46- version="${{ inputs.version }}"48+ version="${GORDON_VERSION_INPUT}"
49+ if ! [[ "${version}" =~ ^(latest|v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?)$ ]]; then
50+ echo "Invalid Gordon version: ${version}" >&2
51+ exit 1
52+ fi
47 53
48 # Resolve 'latest' to an actual tag via redirect URL54 # Resolve 'latest' to an actual tag via redirect URL
49 if [ "${version}" = "latest" ]; then55 if [ "${version}" = "latest" ]; then
@@ -53,24 +59,67 @@ runs:
53 echo "Failed to resolve latest Gordon version" >&259 echo "Failed to resolve latest Gordon version" >&2
54 exit 160 exit 1
55 fi61 fi
62+ if ! [[ "${version}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
63+ echo "Resolved invalid Gordon version: ${version}" >&2
64+ exit 1
65+ fi
56 fi66 fi
57 67
58 archive="gordon_${os}_${arch}.tar.gz"68 archive="gordon_${os}_${arch}.tar.gz"
59- url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"69+ base_url="https://github.com/bnema/gordon/releases/download/${version}"
60- dest="/tmp/${archive}"70+ url="${base_url}/${archive}"
71+ checksums_url="${base_url}/checksums.txt"
72+ work_dir="$(mktemp -d)"
73+ dest="${work_dir}/${archive}"
74+ checksums="${work_dir}/checksums.txt"
75+ extract_dir="${work_dir}/extract"
61 install_path="/usr/local/bin/gordon"76 install_path="/usr/local/bin/gordon"
77+ trap 'rm -rf "${work_dir}"' EXIT
62 78
63 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"79 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
64 80
65- # Download the release archive81+ # Download the release archive and checksums
82+ curl -fsSL -o "${checksums}" "${checksums_url}"
66 curl -fsSL -o "${dest}" "${url}"83 curl -fsSL -o "${dest}" "${url}"
67 84
68- # Extract just the gordon binary and clean up85+ # Verify checksum before extraction
69- tar -xzf "${dest}" -C /usr/local/bin/ gordon86+ archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')"
70- rm -f "${dest}"87+ checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive_escaped}$" "${checksums}" || true)"
88+ if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then
89+ echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2
90+ exit 1
91+ fi
92+ expected="$(printf '%s\n' "${checksum_lines}" | awk '{print $1}')"
93+ if command -v sha256sum >/dev/null 2>&1; then
94+ actual="$(sha256sum "${dest}" | awk '{print $1}')"
95+ elif command -v shasum >/dev/null 2>&1; then
96+ actual="$(shasum -a 256 "${dest}" | awk '{print $1}')"
97+ else
98+ echo "Neither sha256sum nor shasum is available for checksum verification" >&2
99+ exit 1
100+ fi
101+ if [ "${expected}" != "${actual}" ]; then
102+ echo "Checksum verification failed for ${archive}" >&2
103+ echo "Expected: ${expected}" >&2
104+ echo "Actual: ${actual}" >&2
105+ exit 1
106+ fi
107+
108+ # Extract just the gordon binary and reject symlinks/directories
109+ mkdir -p "${extract_dir}"
110+ tar -xzf "${dest}" -C "${extract_dir}" gordon
111+ binary="${extract_dir}/gordon"
112+ if [ ! -f "${binary}" ] || [ -L "${binary}" ]; then
113+ echo "Archive did not contain a regular Gordon binary" >&2
114+ exit 1
115+ fi
71 116
72- # Make executable and verify117+ # Install with explicit executable permissions and verify
73- chmod +x "${install_path}"118+ if [ -w "$(dirname "${install_path}")" ]; then
119+ install -m 0755 "${binary}" "${install_path}"
120+ else
121+ sudo install -m 0755 "${binary}" "${install_path}"
122+ fi
74 gordon version123 gordon version
75 124
76 # Set outputs125 # Set outputs
A .gitleaks.toml +32 -0
@@ -0,0 +1,32 @@
1+title = "Gordon gitleaks configuration"
2+
3+[extend]
4+useDefault = true
5+
6+[[allowlists]]
7+description = "Allow documented placeholder credentials in Markdown examples"
8+condition = "AND"
9+paths = [
10+ '''docs/config/env\.md$''',
11+ '''docs/cli/auth\.md$''',
12+ '''docs/config/telemetry\.md$''',
13+ '''wiki/guides/remote-cli\.md$''',
14+]
15+regexes = [
16+ '''sk-1234567890''',
17+ '''8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c\.\.\.''',
18+ '''YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0''',
19+ '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.\.\.''',
20+]
21+
22+[[allowlists]]
23+description = "Allow deterministic non-secret test fixtures"
24+condition = "AND"
25+paths = [
26+ '''internal/adapters/in/cli/remote/client_test\.go$''',
27+ '''internal/adapters/out/envloader/file_test\.go$''',
28+]
29+regexes = [
30+ '''short-lived-123''',
31+ '''YXBpLnN1Yi5leGFtcGxlLmNvbQ\.env''',
32+]
M Dockerfile +5 -6
@@ -2,7 +2,7 @@
2 # Multi-stage build for optimized container image2 # Multi-stage build for optimized container image
3 3
4 # Build stage4 # Build stage
5-FROM golang:1.25-alpine AS builder5+FROM golang:1.26.1-alpine3.22 AS builder
6 6
7 # Install build dependencies7 # Install build dependencies
8 RUN apk add --no-cache git ca-certificates tzdata8 RUN apk add --no-cache git ca-certificates tzdata
@@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
26 -o gordon .26 -o gordon .
27 27
28 # Runtime stage28 # Runtime stage
29-FROM alpine:latest29+FROM alpine:3.22
30 30
31 # Install runtime dependencies31 # Install runtime dependencies
32 RUN apk add --no-cache \32 RUN apk add --no-cache \
@@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
56 USER gordon56 USER gordon
57 57
58 # Expose ports58 # Expose ports
59-EXPOSE 8080 500059+EXPOSE 8088 5000
60 60
61-# Health check61+# Admin health is served on the registry/admin listener and is gated by auth in
62-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \62+# normal deployments, so this image does not declare a Docker healthcheck.
63- CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
64 63
65 # Default command64 # Default command
66 CMD ["./gordon", "serve"]65 CMD ["./gordon", "serve"]
M docs/cli/auth.md +2 -1
@@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
139 139
140 Registry scopes: `push`, `pull`, `push,pull`140 Registry scopes: `push`, `pull`, `push,pull`
141 141
142-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write`142+Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`
143 143
144 Combine scopes with commas:144 Combine scopes with commas:
145 145
@@ -280,6 +280,7 @@ Usage:
280 | `admin:config:read` | Read-only config access |280 | `admin:config:read` | Read-only config access |
281 | `admin:config:write` | Config write access |281 | `admin:config:write` | Config write access |
282 | `admin:status:read` | Read-only status/health |282 | `admin:status:read` | Read-only status/health |
283+| `admin:logs:read` | Read-only logs access |
283 | `admin:secrets:read` | List secret keys |284 | `admin:secrets:read` | List secret keys |
284 | `admin:secrets:write` | Set/delete secrets |285 | `admin:secrets:write` | Set/delete secrets |
285 286
M docs/cli/config.md +8 -7
@@ -17,8 +17,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
17 17
18 ## gordon config show18 ## gordon config show
19 19
20-Display the full Gordon server configuration including server settings,20+Display the Gordon server configuration including server settings,
21-auto-route, network isolation, routes, and external routes.21+auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default.
22 22
23 ```bash23 ```bash
24 gordon config show24 gordon config show
@@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
39 "server": {39 "server": {
40 "port": 1111,40 "port": 1111,
41 "registry_port": 5000,41 "registry_port": 5000,
42- "registry_domain": "reg.example.com",42+ "registry_domain": "reg.example.com"
43- "data_dir": "/var/lib/gordon"
44 },43 },
45 "auto_route": {44 "auto_route": {
46 "enabled": true,45 "enabled": true,
@@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
53 "routes": [52 "routes": [
54 {"domain": "app.example.com", "image": "myapp:latest"}53 {"domain": "app.example.com", "image": "myapp:latest"}
55 ],54 ],
56- "external_routes": {55+ "external_routes": [
57- "reg.example.com": "localhost:5000"56+ {"domain": "reg.example.com"}
58- }57+ ]
59 }58 }
60 ```59 ```
61 60
61+External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
62+
62 ### Auto-Route Allowed Domains63 ### Auto-Route Allowed Domains
63 64
64 The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md).65 The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md).
M docs/cli/serve.md +2 -0
@@ -235,6 +235,8 @@ gordon logs [domain] [options]
235 Remote targeting uses client config or an active remote by default.235 Remote targeting uses client config or an active remote by default.
236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237 237
238+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
239+
238 ### Examples240 ### Examples
239 241
240 ```bash242 ```bash
M docs/config/auth.md +3 -0
@@ -92,6 +92,9 @@ Admin scopes (for remote CLI):
92 | `admin:config:read` | Read-only config access |92 | `admin:config:read` | Read-only config access |
93 | `admin:config:write` | Config write access |93 | `admin:config:write` | Config write access |
94 | `admin:status:read` | Read-only status/health |94 | `admin:status:read` | Read-only status/health |
95+| `admin:logs:read` | Read-only logs access |
96+| `admin:volumes:read` | List Docker volumes |
97+| `admin:volumes:write` | Prune eligible Gordon-managed volumes |
95 | `admin:secrets:read` | List secret keys |98 | `admin:secrets:read` | List secret keys |
96 | `admin:secrets:write` | Set/delete secrets |99 | `admin:secrets:write` | Set/delete secrets |
97 100
M docs/config/deploy.md +12 -0
@@ -98,6 +98,18 @@ container shutdown.
98 98
99 Default: `"30s"`99 Default: `"30s"`
100 100
101+## Container security profile
102+
103+Runtime hardening is configured under `[containers]`:
104+
105+```toml
106+[containers]
107+security_profile = "compat" # "compat" or "strict"
108+```
109+
110+- `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults.
111+- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile.
112+
101 ## Notes113 ## Notes
102 114
103 - Changes to `deploy.pull_policy` require a restart to take effect.115 - Changes to `deploy.pull_policy` require a restart to take effect.
M docs/config/images.md +6 -0
@@ -16,6 +16,10 @@ The CLI `gordon images prune` uses the same defaults as the scheduled job (keep
16 ## Configuration16 ## Configuration
17 17
18 ```toml18 ```toml
19+[images]
20+allowed_registries = []
21+require_digest = false
22+
19 [images.prune]23 [images.prune]
20 enabled = false24 enabled = false
21 schedule = "daily"25 schedule = "daily"
@@ -26,6 +30,8 @@ keep_last = 3
26 30
27 | Key | Type | Default | Description |31 | Key | Type | Default | Description |
28 |-----|------|---------|-------------|32 |-----|------|---------|-------------|
33+| `images.allowed_registries` | array | `[]` | Allowlist for external image registries. Empty means no external registries are accepted. Unqualified image names such as `nginx:latest` are treated as Docker Hub (`docker.io`) for policy checks. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |
34+| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |
29 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |35 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
30 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |36 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
31 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |37 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
M docs/config/index.md +18 -1
@@ -63,6 +63,10 @@ drain_mode = "auto" # auto, inflight, delay
63 drain_timeout = "30s" # Max wait for in-flight drain63 drain_timeout = "30s" # Max wait for in-flight drain
64 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container64 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
65 65
66+# Container runtime profile
67+[containers]
68+security_profile = "compat" # compat or strict
69+
66 # Logging70 # Logging
67 [logging]71 [logging]
68 level = "info" # trace, debug, info, warn, error72 level = "info" # trace, debug, info, warn, error
@@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal
106 [network_isolation]110 [network_isolation]
107 enabled = true # Per-app isolated networks111 enabled = true # Per-app isolated networks
108 network_prefix = "gordon" # Network name prefix112 network_prefix = "gordon" # Network name prefix
113+internal = false # Set true to block direct egress from isolated networks
109 114
110 # Auto-route115 # Auto-route
111 [auto_route]116 [auto_route]
@@ -138,6 +143,10 @@ weekly = 4
138 monthly = 12143 monthly = 12
139 144
140 # Images145 # Images
146+[images]
147+allowed_registries = [] # Explicit external registries allowed for deploy/attachments
148+require_digest = false # Require digests for allowlisted external registries
149+
141 [images.prune]150 [images.prune]
142 enabled = false151 enabled = false
143 schedule = "daily"152 schedule = "daily"
@@ -164,6 +173,7 @@ keep_last = 3
164 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |173 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
165 | `[backups]` | Database backups | [Backups](./backups.md) |174 | `[backups]` | Database backups | [Backups](./backups.md) |
166 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |175 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
176+| Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
167 177
168 ## Default Values178 ## Default Values
169 179
@@ -172,6 +182,8 @@ keep_last = 3
172 | `server.port` | `8088` |182 | `server.port` | `8088` |
173 | `server.registry_port` | `5000` |183 | `server.registry_port` | `5000` |
174 | `server.data_dir` | `~/.gordon` |184 | `server.data_dir` | `~/.gordon` |
185+| `server.max_blob_chunk_size` | `"95MB"` |
186+| `server.max_blob_size` | `"1GB"` |
175 | `auth.enabled` | `true` |187 | `auth.enabled` | `true` |
176 | `auth.secrets_backend` | `"unsafe"` |188 | `auth.secrets_backend` | `"unsafe"` |
177 | `auth.token_expiry` | `"30d"` |189 | `auth.token_expiry` | `"30d"` |
@@ -187,6 +199,7 @@ keep_last = 3
187 | `deploy.drain_mode` | `"auto"` |199 | `deploy.drain_mode` | `"auto"` |
188 | `deploy.drain_timeout` | `"30s"` |200 | `deploy.drain_timeout` | `"30s"` |
189 | `deploy.drain_delay` | `"2s"` |201 | `deploy.drain_delay` | `"2s"` |
202+| `containers.security_profile` | `"compat"` |
190 | `logging.level` | `"info"` |203 | `logging.level` | `"info"` |
191 | `logging.format` | `"console"` |204 | `logging.format` | `"console"` |
192 | `logging.file.enabled` | `false` |205 | `logging.file.enabled` | `false` |
@@ -198,9 +211,12 @@ keep_last = 3
198 | `volumes.prefix` | `"gordon"` |211 | `volumes.prefix` | `"gordon"` |
199 | `volumes.preserve` | `true` |212 | `volumes.preserve` | `true` |
200 | `network_isolation.enabled` | `true` |213 | `network_isolation.enabled` | `true` |
214+| `network_isolation.internal` | `false` |
201 | `auto_route.enabled` | `false` |215 | `auto_route.enabled` | `false` |
202 | `backups.enabled` | `false` |216 | `backups.enabled` | `false` |
203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |217 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
218+| `images.allowed_registries` | `[]` |
219+| `images.require_digest` | `false` |
204 | `images.prune.enabled` | `false` |220 | `images.prune.enabled` | `false` |
205 | `images.prune.schedule` | `"daily"` |221 | `images.prune.schedule` | `"daily"` |
206 | `images.prune.keep_last` | `3` |222 | `images.prune.keep_last` | `3` |
@@ -229,7 +245,6 @@ gordon reload
229 | `server.gordon_domain` (registry domain) |245 | `server.gordon_domain` (registry domain) |
230 | `server.registry_port` |246 | `server.registry_port` |
231 | `server.max_proxy_body_size` |247 | `server.max_proxy_body_size` |
232-| `server.max_blob_chunk_size` |
233 | `server.max_proxy_response_size` |248 | `server.max_proxy_response_size` |
234 | `server.max_concurrent_conns` |249 | `server.max_concurrent_conns` |
235 250
@@ -241,6 +256,8 @@ gordon reload
241 |---------|256 |---------|
242 | `server.port` |257 | `server.port` |
243 | `server.data_dir` |258 | `server.data_dir` |
259+| `server.max_blob_chunk_size` |
260+| `server.max_blob_size` |
244 | `auth.*` |261 | `auth.*` |
245 | `deploy.readiness_mode` |262 | `deploy.readiness_mode` |
246 | `deploy.readiness_delay` |263 | `deploy.readiness_delay` |
M docs/config/network-isolation.md +2 -0
@@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security.
8 [network_isolation]8 [network_isolation]
9 enabled = true9 enabled = true
10 network_prefix = "gordon"10 network_prefix = "gordon"
11+internal = false
11 ```12 ```
12 13
13 ## Migration Note14 ## Migration Note
@@ -26,6 +27,7 @@ enabled = false
26 |--------|------|---------|-------------|27 |--------|------|---------|-------------|
27 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |28 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
28 | `network_prefix` | string | `"gordon"` | Prefix for created networks |29 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
30+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. |
29 31
30 ## How It Works32 ## How It Works
31 33
M docs/config/reference.md +19 -2
@@ -17,7 +17,8 @@ tls_key_file = "" # PEM key path (optional, must be s
17 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)17 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)
18 gordon_domain = "" # Required: Gordon domain (registry + API)18 gordon_domain = "" # Required: Gordon domain (registry + API)
19 data_dir = "~/.gordon" # Data directory (varies by install type)19 data_dir = "~/.gordon" # Data directory (varies by install type)
20-max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk20+max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk
21+max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload
21 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)22 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
22 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)23 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
23 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)24 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
@@ -93,6 +94,12 @@ drain_mode = "auto" # "auto", "inflight", "delay"
93 drain_timeout = "30s" # Max wait for in-flight request drain94 drain_timeout = "30s" # Max wait for in-flight request drain
94 drain_delay = "2s" # Wait after cache invalidation before old stop95 drain_delay = "2s" # Wait after cache invalidation before old stop
95 96
97+# =============================================================================
98+# CONTAINERS
99+# =============================================================================
100+[containers]
101+security_profile = "compat" # "compat" or "strict"
102+
96 # =============================================================================103 # =============================================================================
97 # AUTO-ROUTE104 # AUTO-ROUTE
98 # =============================================================================105 # =============================================================================
@@ -105,6 +112,7 @@ enabled = false # Create routes from image labels a
105 [network_isolation]112 [network_isolation]
106 enabled = true # Enable per-app Docker networks113 enabled = true # Enable per-app Docker networks
107 network_prefix = "gordon" # Prefix for created networks114 network_prefix = "gordon" # Prefix for created networks
115+internal = false # Create Docker internal networks (blocks direct egress)
108 116
109 # =============================================================================117 # =============================================================================
110 # VOLUMES118 # VOLUMES
@@ -157,6 +165,10 @@ monthly = 0 # Keep N monthly backups per DB
157 # =============================================================================165 # =============================================================================
158 # IMAGES166 # IMAGES
159 # =============================================================================167 # =============================================================================
168+[images]
169+allowed_registries = []
170+require_digest = false
171+
160 [images.prune]172 [images.prune]
161 enabled = false # Enable scheduled image cleanup173 enabled = false # Enable scheduled image cleanup
162 schedule = "daily" # "hourly", "daily", "weekly", "monthly"174 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
@@ -178,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository
178 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |190 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |
179 | `server.gordon_domain` | `""` | **Required** - Gordon domain |191 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
180 | `server.data_dir` | `~/.gordon` | Data directory |192 | `server.data_dir` | `~/.gordon` | Data directory |
181-| `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |193+| `server.max_blob_chunk_size` | `"95MB"` | Max size per registry blob upload chunk |
194+| `server.max_blob_size` | `"1GB"` | Max cumulative size per registry blob/layer upload |
182 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |195 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
183 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |196 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
184 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |197 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
@@ -215,9 +228,11 @@ keep_last = 3 # Keep N newest tags per repository
215 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |228 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
216 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |229 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
217 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |230 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
231+| `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
218 | `auto_route.enabled` | `false` | Auto-route disabled |232 | `auto_route.enabled` | `false` | Auto-route disabled |
219 | `network_isolation.enabled` | `true` | Network isolation enabled |233 | `network_isolation.enabled` | `true` | Network isolation enabled |
220 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |234 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
235+| `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
221 | `volumes.auto_create` | `true` | Auto-create volumes |236 | `volumes.auto_create` | `true` | Auto-create volumes |
222 | `volumes.prefix` | `"gordon"` | Volume prefix |237 | `volumes.prefix` | `"gordon"` | Volume prefix |
223 | `volumes.preserve` | `true` | Keep volumes |238 | `volumes.preserve` | `true` | Keep volumes |
@@ -228,6 +243,8 @@ keep_last = 3 # Keep N newest tags per repository
228 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |243 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
229 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |244 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
230 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |245 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
246+| `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected |
247+| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |
231 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |248 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
232 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |249 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
233 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |250 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
A docs/config/security-hardening.md +90 -0
@@ -0,0 +1,90 @@
1+# Security Hardening Controls
2+
3+This page summarizes Gordon's security-related configuration knobs and the defaults operators should know before exposing an instance.
4+
5+## Registry upload quotas
6+
7+Registry blob uploads are bounded by two limits under `[server]`:
8+
9+```toml
10+[server]
11+max_blob_chunk_size = "95MB"
12+max_blob_size = "1GB"
13+```
14+
15+- `max_blob_chunk_size` limits a single upload chunk.
16+- `max_blob_size` limits the cumulative blob/layer upload size.
17+- Exceeding either limit returns an OCI-compatible size error and the failed upload is cleaned up.
18+
19+## Admin logs permission
20+
21+Container and deploy logs can include environment-derived output. Gordon gates log access behind a dedicated scope:
22+
23+```bash
24+gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
25+```
26+
27+- `/admin/logs` and deploy failure logs require `admin:logs:read`.
28+- `admin:status:read` does not grant log access.
29+- Common secret patterns are redacted before logs are returned.
30+
31+## Volume pruning scope
32+
33+Volume pruning only removes unused Docker volumes explicitly managed by Gordon (`gordon.managed=true`). It ignores unrelated Docker volumes even if they are unused.
34+
35+Use dedicated admin scopes:
36+
37+- `admin:volumes:read` for listing volumes.
38+- `admin:volumes:write` for prune operations.
39+
40+## Pass migration plaintext handling
41+
42+When Gordon migrates legacy plaintext `.env` files into `pass`, it removes the plaintext source after a successful migration and does not leave `.env.migrated` copies by default. If pass entries already exist, migration fails closed and leaves the plaintext file in place for manual operator review rather than deleting potentially unique values.
43+
44+## External image registries
45+
46+Gordon's configured registry is always allowed. Explicit external registries must be allowlisted:
47+
48+```toml
49+[images]
50+allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"]
51+require_digest = true
52+```
53+
54+- Empty `allowed_registries` rejects explicit external registries.
55+- `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected.
56+- `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.
57+- Include ports in allowlist entries when the registry uses a non-default port.
58+
59+## Docker network isolation
60+
61+Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:
62+
63+```toml
64+[network_isolation]
65+enabled = true
66+internal = true
67+```
68+
69+`internal = false` remains the compatibility default because some applications and attachments need direct egress during startup.
70+
71+## Container runtime profile
72+
73+```toml
74+[containers]
75+security_profile = "compat" # or "strict"
76+```
77+
78+- `compat` preserves broad image compatibility while retaining `no-new-privileges` and default capability restrictions.
79+- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`.
80+
81+Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
82+
83+## Related
84+
85+- [Auth](./auth.md)
86+- [Images](./images.md)
87+- [Network Isolation](./network-isolation.md)
88+- [Deploy](./deploy.md)
89+- [Volumes](./volumes.md)
90+- [Reference](./reference.md)
M docs/config/server.md +19 -0
@@ -14,6 +14,8 @@ tls_port = 8443 # HTTPS listener port (0 = disabled)
14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
16 # data_dir = "~/.gordon" # Data storage directory (default)16 # data_dir = "~/.gordon" # Data storage directory (default)
17+max_blob_chunk_size = "95MB" # Max registry blob upload chunk
18+max_blob_size = "1GB" # Max cumulative registry blob/layer upload
17 ```19 ```
18 20
19 ## Options21 ## Options
@@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
31 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |33 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
32 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |34 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
33 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |35 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
36+| `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
34 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |37 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
35 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |38 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
36 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |39 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
@@ -274,6 +277,22 @@ max_blob_chunk_size = "1GB" # Allow larger layers
274 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine277 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
275 - Increase it only if you expect larger direct registry uploads from other clients278 - Increase it only if you expect larger direct registry uploads from other clients
276 279
280+## Maximum Registry Blob Size
281+
282+The `max_blob_size` setting limits the cumulative size of one blob/layer upload across all chunks:
283+
284+```toml
285+[server]
286+max_blob_size = "1GB" # Default
287+```
288+
289+**Purpose:** Prevents disk exhaustion from multi-chunk uploads where each individual chunk is below `max_blob_chunk_size` but the total blob grows without bound.
290+
291+**Behavior:**
292+- Gordon tracks the current upload size and rejects appends that would exceed the limit.
293+- Rejected uploads return an OCI-compatible `413` size error.
294+- Failed uploads are cleaned up so temporary blob data does not accumulate indefinitely.
295+
277 ## Registry IP Allowlist296 ## Registry IP Allowlist
278 297
279 The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default).298 The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default).
A docs/security-hardening-regression-matrix.md +222 -0
@@ -0,0 +1,222 @@
1+# Security Hardening Regression Matrix
2+
3+This matrix lists the behaviors that should be tested while implementing the security-hardening fixes from the audit. It is intentionally focused on regressions: normal Gordon workflows must continue to work while exploit paths are blocked.
4+
5+## Registry uploads
6+
7+### Normal behavior to preserve
8+- Docker push of a small image succeeds.
9+- Docker push with multiple layers succeeds when every layer is below the configured cumulative blob limit.
10+- Chunked blob upload succeeds when the cumulative size remains below the new cumulative limit setting, tentatively named `max_blob_size` unless implementation chooses a clearer config name.
11+- A final `PUT` with a valid digest finalizes the blob and returns OCI-compatible headers.
12+- Existing `max_blob_chunk_size` behavior still rejects a single oversized chunk; this remains distinct from the cumulative per-blob/layer limit.
13+
14+### Security behavior to verify
15+- Repeated `PATCH` chunks cannot exceed the cumulative upload limit.
16+- `Content-Length` larger than the remaining allowed size is rejected before writing when possible.
17+- A rejected upload does not leave unbounded stale data on disk.
18+- Cancel/cleanup paths remove upload temp files and upload locks.
19+- Error response remains Docker Registry compatible (`413` / `SIZE_INVALID`).
20+
21+### Suggested tests
22+- Unit tests for filesystem blob append under and over cumulative size.
23+- Handler tests for `PATCH` and final `PUT` over limit.
24+- Integration/manual: push image with a large layer and confirm failure is clean.
25+
26+## Deploy errors and logs
27+
28+### Normal behavior to preserve
29+- Successful deploy response still includes status, domain, and container ID.
30+- Failed deploy still returns useful high-level error/cause/hint.
31+- Operators with the new logs permission can still retrieve logs through `/admin/logs`.
32+- CLI deploy displays helpful failure information without leaking raw secrets.
33+
34+### Security behavior to verify
35+- A token with only `admin:config:write` cannot receive raw container logs in `/admin/deploy` error responses.
36+- Logs returned through log endpoints are redacted for common secret patterns.
37+- `admin:status:read` no longer grants log read access.
38+- `admin:*:*` remains compatible and can access logs.
39+
40+### Suggested tests
41+- Handler tests for deploy error response with and without `logs:read`.
42+- Scope tests for `logs:read`, wildcard, and old `status:read` behavior.
43+- Redactor table tests for `PASSWORD=`, `TOKEN=`, `SECRET=`, `API_KEY=`, `DATABASE_URL=`, and simple JSON fields.
44+
45+## Admin scopes and compatibility
46+
47+### Normal behavior to preserve
48+- Existing `admin:*:*` tokens continue to work everywhere.
49+- Existing `routes`, `config`, `secrets`, and `status` scopes keep their intended behavior.
50+- Token exchange through `/auth/token` still works for admin scopes.
51+
52+### Security behavior to verify
53+- New sensitive resources such as `logs` and possibly `volumes` are parsed and enforced.
54+- Unknown or malformed scopes remain ignored/denied, not accidentally allowed.
55+
56+### Suggested tests
57+- Domain auth scope parsing tests.
58+- Admin middleware/handler tests for each sensitive endpoint.
59+
60+## Auto-route env-file extraction
61+
62+### Normal behavior to preserve
63+- Valid `gordon.env-file` imports default env values for a newly created auto-route.
64+- Existing env values still override image-provided defaults.
65+- Secret reference rejection remains intact.
66+- Auto-route without env-file remains unchanged.
67+
68+### Security behavior to verify
69+- Oversized env files are rejected before full memory read.
70+- Disallowed paths (`/proc`, `/sys`, `/dev`, `/run`, `/var/run`, `/`, traversal) are rejected.
71+- Failed env-file import does not prevent route/deploy behavior beyond the intended warning path unless explicitly designed.
72+
73+### Suggested tests
74+- Runtime extraction tests using a controlled reader larger than the limit.
75+- Auto-route usecase tests for allowed/disallowed paths.
76+- Tests confirming `${pass:...}` / `${sops:...}` refs remain rejected.
77+
78+## Domain canonicalization and proxy routing
79+
80+### Normal behavior to preserve
81+- Existing lowercase route domains continue to resolve.
82+- Registry domain still routes to registry.
83+- External routes still resolve and retain SSRF protections.
84+- Hostnames with expected public domains work through proxy.
85+
86+### Security behavior to verify
87+- `App.Example.com` and `app.example.com` map to the same canonical route.
88+- Duplicate case-variant routes cannot be created.
89+- Host with invalid port/trailing dot/case is handled consistently.
90+- HTTP-to-HTTPS redirect does not reflect arbitrary unconfigured Host values.
91+
92+### Suggested tests
93+- Config service tests for add/update/list canonical route domains.
94+- Auto-route tests for case variants.
95+- Proxy handler tests for Host normalization.
96+- Middleware tests for redirect target host validation.
97+
98+## Volumes
99+
100+### Normal behavior to preserve
101+- Listing volumes still shows Gordon-managed volumes.
102+- Dry-run prune still reports what would be removed.
103+- Actual prune still removes unused Gordon-managed volumes.
104+- In-use Gordon volumes are not removed.
105+
106+### Security behavior to verify
107+- Unused non-Gordon Docker volumes are not removed.
108+- Volumes without explicit Gordon ownership metadata, preferably `gordon.managed=true`, are ignored; prefix-only matching is unsafe except as a constrained legacy fallback with dedicated tests.
109+- Any new `volumes:read/write` scope is enforced correctly.
110+
111+### Suggested tests
112+- Volume service tests with mixed Gordon/non-Gordon, used/unused volumes.
113+- Admin handler tests for permissions.
114+- Manual: create an unrelated Docker volume, run Gordon prune dry-run/real, confirm it remains.
115+
116+## Backups
117+
118+### Normal behavior to preserve
119+- Manual backup completes and appears in list.
120+- Scheduled backup path format remains readable/listable.
121+- Backup detect still finds supported DB attachments.
122+- Existing backups can still be listed after filename format changes.
123+
124+### Security behavior to verify
125+- Two backups for same domain/db/schedule in the same second do not share tmp/final filenames.
126+- Concurrent backups do not truncate each other.
127+- Store uses unique tmp files and atomic rename.
128+- Path traversal protections remain intact.
129+
130+### Suggested tests
131+- Filesystem storage tests with identical timestamps.
132+- Concurrent store test with two readers and same domain/db/schedule.
133+- List tests for old and new filename formats if compatibility is needed.
134+
135+## Secrets and pass migration
136+
137+### Normal behavior to preserve
138+- `pass` backend still migrates existing env keys correctly.
139+- Attachment env migration still works.
140+- Unsafe backend still loads valid files under `dataDir/secrets` for development.
141+- Existing env file parsing remains compatible.
142+
143+### Security behavior to verify
144+- Unsafe backend rejects `../` and absolute `auth.token_secret` paths.
145+- Migration does not leave plaintext `.env.migrated` by default.
146+- If an explicit keep/quarantine option exists, files are stored with restrictive permissions and loud warnings.
147+
148+### Suggested tests
149+- `loadSecret` path traversal tests.
150+- Migration tests verifying plaintext source file removal/quarantine.
151+- Permission tests where practical (`0600` files, `0700` dirs).
152+
153+## Config snapshot
154+
155+### Normal behavior to preserve
156+- `/admin/config` still returns enough information for CLI/admin use.
157+- Routes and network isolation status remain visible to authorized callers.
158+
159+### Security behavior to verify
160+- `data_dir` is hidden by default or only available under a deliberately stronger detailed mode.
161+- External route targets are hidden/redacted by default or gated.
162+- `config:read` behavior remains documented.
163+
164+### Suggested tests
165+- Admin config response tests for default and detailed modes.
166+- CLI remote config tests if output shape changes.
167+
168+## External image registries
169+
170+### Normal behavior to preserve
171+- Images in the Gordon registry deploy normally.
172+- Explicitly allowlisted external registries deploy if configured.
173+- Attachments still deploy from allowed sources.
174+
175+### Security behavior to verify
176+- `localhost`, loopback, private IP, link-local, metadata IP, and DNS resolving to private ranges are rejected unless a deliberate unsafe override exists.
177+- Non-allowlisted external registries are rejected before Docker pull.
178+- Error messages do not leak sensitive network details.
179+
180+### Suggested tests
181+- Image reference validation table tests.
182+- Config tests for `allowed_registries`.
183+- Container service tests ensuring rejected images do not call `PullImage`.
184+
185+## Docker networks and runtime hardening
186+
187+### Normal behavior to preserve
188+- Default/compat profile keeps common apps and attachments working.
189+- Attachments remain reachable by service alias where intended.
190+- Network groups still work when configured.
191+
192+### Security behavior to verify
193+- `internal` network option is applied when configured.
194+- Strict profile applies readonly rootfs/user/caps as designed.
195+- `no-new-privileges:true` and cap dropping are preserved.
196+- Compat profile behavior is documented and tested.
197+
198+### Suggested tests
199+- Docker adapter config construction tests.
200+- Container service tests for network option propagation.
201+- Manual smoke: deploy app + postgres attachment in compat profile.
202+- Manual smoke: deploy simple read-only app in strict profile.
203+
204+## Install, Dockerfile, and supply-chain
205+
206+### Normal behavior to preserve
207+- `install.sh` installs the binary on Linux/macOS.
208+- GitHub composite action installs Gordon in CI.
209+- Dockerfile builds successfully.
210+
211+### Security behavior to verify
212+- Tar extraction only extracts `gordon`.
213+- Installed binary must be a regular file, not symlink/directory.
214+- Permissions are explicit (`0755`).
215+- Checksums/signatures are verified consistently.
216+- Gitleaks output is clean or only contains allowlisted examples.
217+
218+### Suggested tests
219+- Shellcheck/manual review for install script.
220+- Action script dry-run if practical.
221+- Docker build smoke after version alignment.
222+- Gitleaks on `git archive HEAD`, not local `.worktrees`.
M go.mod +1 -1
@@ -110,7 +110,7 @@ require (
110 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0110 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
111 go.opentelemetry.io/otel/metric v1.43.0111 go.opentelemetry.io/otel/metric v1.43.0
112 go.opentelemetry.io/otel/trace v1.43.0112 go.opentelemetry.io/otel/trace v1.43.0
113- golang.org/x/sys v0.43.0 // indirect113+ golang.org/x/sys v0.43.0
114 golang.org/x/text v0.35.0 // indirect114 golang.org/x/text v0.35.0 // indirect
115 gopkg.in/yaml.v3 v3.0.1 // indirect115 gopkg.in/yaml.v3 v3.0.1 // indirect
116 gotest.tools/v3 v3.5.2 // indirect116 gotest.tools/v3 v3.5.2 // indirect
A gordon.toml.example +94 -0
@@ -0,0 +1,94 @@
1+# Gordon example configuration
2+# Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain.
3+
4+[server]
5+port = 8088
6+registry_port = 5000
7+gordon_domain = "gordon.example.com"
8+# data_dir = "~/.gordon"
9+
10+[auth]
11+enabled = true
12+# Use "pass" or "sops" in production. The unsafe backend reads a local file
13+# under server.data_dir/secrets and is intended for development only.
14+secrets_backend = "pass"
15+token_secret = "gordon/auth/token_secret"
16+token_expiry = "30d"
17+access_token_ttl = "15m"
18+
19+[api.rate_limit]
20+enabled = true
21+global_rps = 500
22+per_ip_rps = 50
23+burst = 100
24+trusted_proxies = []
25+
26+[deploy]
27+pull_policy = "if-tag-changed"
28+readiness_mode = "auto"
29+health_timeout = "90s"
30+readiness_delay = "5s"
31+drain_mode = "auto"
32+drain_timeout = "30s"
33+drain_delay = "2s"
34+
35+[containers]
36+# "compat" preserves broad image compatibility. "strict" enables a read-only
37+# root filesystem and narrower Linux capabilities.
38+security_profile = "compat"
39+
40+[network_isolation]
41+enabled = true
42+network_prefix = "gordon"
43+# Set true to create Docker internal networks without direct external egress.
44+internal = false
45+
46+[volumes]
47+auto_create = true
48+prefix = "gordon"
49+preserve = true
50+
51+[images]
52+# Explicit external registries must be listed here. Gordon's own registry is
53+# always allowed; localhost/private/link-local registries are always rejected.
54+allowed_registries = []
55+# Require @sha256:<digest> for allowlisted external registries.
56+require_digest = false
57+
58+[images.prune]
59+enabled = false
60+schedule = "daily"
61+keep_last = 3
62+
63+[auto_route]
64+enabled = false
65+
66+[logging]
67+level = "info"
68+format = "console"
69+
70+[logging.container_logs]
71+enabled = true
72+
73+[backups]
74+enabled = false
75+schedule = "daily"
76+storage_dir = ""
77+
78+[backups.retention]
79+hourly = 0
80+daily = 0
81+weekly = 0
82+monthly = 0
83+
84+[routes]
85+# "app.example.com" = { image = "myapp:latest" }
86+
87+[external_routes]
88+# "status.example.com" = "127.0.0.1:9000"
89+
90+[network_groups]
91+# "backend" = ["app.example.com", "api.example.com"]
92+
93+[attachments]
94+# "app.example.com" = ["postgres:18"]
M install.sh +18 -7
@@ -101,11 +101,14 @@ fi
101 101
102 # Verify checksum102 # Verify checksum
103 echo "Verifying checksum..."103 echo "Verifying checksum..."
104-EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}')104+TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105-if [ -z "$EXPECTED_CHECKSUM" ]; then105+CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
106- echo "Error: Could not find checksum for ${TARBALL} in checksums.txt"106+CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
107+if [ "$CHECKSUM_COUNT" != "1" ]; then
108+ echo "Error: Expected exactly one checksum for ${TARBALL} in checksums.txt"
107 exit 1109 exit 1
108 fi110 fi
111+EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}')
109 112
110 # Calculate actual checksum (works on both Linux and macOS)113 # Calculate actual checksum (works on both Linux and macOS)
111 if command -v sha256sum >/dev/null 2>&1; then114 if command -v sha256sum >/dev/null 2>&1; then
@@ -132,15 +135,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
132 fi135 fi
133 echo "Checksum verified successfully."136 echo "Checksum verified successfully."
134 137
135-echo "Extracting..."138+echo "Extracting Gordon binary..."
136-tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR"139+EXTRACT_DIR="$TMP_DIR/extract"
140+mkdir -p "$EXTRACT_DIR"
141+tar -xzf "$TMP_DIR/$TARBALL" -C "$EXTRACT_DIR" gordon
142+
143+BINARY="$EXTRACT_DIR/gordon"
144+if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
145+ echo "Error: Archive did not contain a regular Gordon binary"
146+ exit 1
147+fi
137 148
138 echo "Installing to ${INSTALL_DIR}..."149 echo "Installing to ${INSTALL_DIR}..."
139 if [ -w "$INSTALL_DIR" ]; then150 if [ -w "$INSTALL_DIR" ]; then
140- mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon"151+ install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
141 else152 else
142 echo "sudo required to install to ${INSTALL_DIR}"153 echo "sudo required to install to ${INSTALL_DIR}"
143- sudo mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon"154+ sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
144 fi155 fi
145 156
146 # Verify installation157 # Verify installation
M internal/adapters/dto/admin_config.go +2 -2
@@ -14,7 +14,7 @@ type ServerConfig struct {
14 Port int `json:"port"`14 Port int `json:"port"`
15 RegistryPort int `json:"registry_port"`15 RegistryPort int `json:"registry_port"`
16 RegistryDomain string `json:"registry_domain"`16 RegistryDomain string `json:"registry_domain"`
17- DataDir string `json:"data_dir"`17+ DataDir string `json:"data_dir,omitempty"`
18 }18 }
19 19
20 // AutoRouteConfig represents auto-route config details.20 // AutoRouteConfig represents auto-route config details.
@@ -31,5 +31,5 @@ type NetworkIsolationConfig struct {
31 // ExternalRoute represents an external route config entry.31 // ExternalRoute represents an external route config entry.
32 type ExternalRoute struct {32 type ExternalRoute struct {
33 Domain string `json:"domain"`33 Domain string `json:"domain"`
34- Target string `json:"target"`34+ Target string `json:"target,omitempty"`
35 }35 }
M internal/adapters/in/cli/auth.go +4 -1
@@ -84,13 +84,16 @@ Registry scopes (for Docker push/pull):
84 Admin scopes (for remote CLI access):84 Admin scopes (for remote CLI access):
85 Format: admin:<resource>:<actions>85 Format: admin:<resource>:<actions>
86 86
87- Resources: routes, secrets, config, status, * (all)87+ Resources: routes, secrets, config, status, logs, volumes, * (all)
88 Actions: read, write, * (all)88 Actions: read, write, * (all)
89 89
90 Examples:90 Examples:
91 admin:*:* Full admin access (recommended for CLI)91 admin:*:* Full admin access (recommended for CLI)
92 admin:routes:read Read-only routes access92 admin:routes:read Read-only routes access
93 admin:status:read Read-only status access93 admin:status:read Read-only status access
94+ admin:logs:read Read-only log access
95+ admin:volumes:read Read-only volume access
96+ admin:volumes:write Volume management access
94 97
95 Repository scoping:98 Repository scoping:
96 --repo myapp Scope to specific repository99 --repo myapp Scope to specific repository
M internal/adapters/in/cli/config.go +13 -10
@@ -94,8 +94,10 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
94 if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {94 if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {
95 return err95 return err
96 }96 }
97- if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {97+ if config.Server.DataDir != "" {
98- return err98+ if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {
99+ return err
100+ }
99 }101 }
100 if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {102 if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
101 return err103 return err
@@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
155 if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {157 if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
156 return err158 return err
157 }159 }
158- domains := make([]string, 0, len(config.ExternalRoutes))160+ routes := append([]remote.ExternalRoute(nil), config.ExternalRoutes...)
159- for d := range config.ExternalRoutes {161+ sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
160- domains = append(domains, d)
161- }
162- sort.Strings(domains)
163 162
164- rows := make([][]string, 0, len(domains))163+ rows := make([][]string, 0, len(routes))
165- for _, d := range domains {164+ for _, route := range routes {
166- rows = append(rows, []string{d, config.ExternalRoutes[d]})165+ target := route.Target
166+ if target == "" {
167+ target = "[redacted]"
168+ }
169+ rows = append(rows, []string{route.Domain, target})
167 }170 }
168 table := components.NewTable(171 table := components.NewTable(
169 components.WithColumns([]components.TableColumn{172 components.WithColumns([]components.TableColumn{
M internal/adapters/in/cli/controlplane_local.go +10 -2
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "sort"
7 "time"8 "time"
8 9
9 "github.com/bnema/gordon/internal/adapters/dto"10 "github.com/bnema/gordon/internal/adapters/dto"
@@ -372,14 +373,21 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro
372 if l.configSvc == nil {373 if l.configSvc == nil {
373 return nil, fmt.Errorf("config service unavailable")374 return nil, fmt.Errorf("config service unavailable")
374 }375 }
376+ externalRoutes := l.configSvc.GetExternalRoutes()
377+ externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes))
378+ for domainName := range externalRoutes {
379+ externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName})
380+ }
381+ sort.Slice(externalResponses, func(i, j int) bool {
382+ return externalResponses[i].Domain < externalResponses[j].Domain
383+ })
375 cfg := &remote.Config{384 cfg := &remote.Config{
376 Routes: l.configSvc.GetRoutes(ctx),385 Routes: l.configSvc.GetRoutes(ctx),
377- ExternalRoutes: l.configSvc.GetExternalRoutes(),386+ ExternalRoutes: externalResponses,
378 }387 }
379 cfg.Server.Port = l.configSvc.GetServerPort()388 cfg.Server.Port = l.configSvc.GetServerPort()
380 cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()389 cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
381 cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()390 cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
382- cfg.Server.DataDir = l.configSvc.GetDataDir()
383 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()391 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
384 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()392 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
385 cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()393 cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
M internal/adapters/in/cli/remote/client.go +9 -3
@@ -826,7 +826,7 @@ type Config struct {
826 Port int `json:"port"`826 Port int `json:"port"`
827 RegistryPort int `json:"registry_port"`827 RegistryPort int `json:"registry_port"`
828 RegistryDomain string `json:"registry_domain"`828 RegistryDomain string `json:"registry_domain"`
829- DataDir string `json:"data_dir"`829+ DataDir string `json:"data_dir,omitempty"`
830 } `json:"server"`830 } `json:"server"`
831 AutoRoute struct {831 AutoRoute struct {
832 Enabled bool `json:"enabled"`832 Enabled bool `json:"enabled"`
@@ -835,8 +835,14 @@ type Config struct {
835 Enabled bool `json:"enabled"`835 Enabled bool `json:"enabled"`
836 Prefix string `json:"prefix"`836 Prefix string `json:"prefix"`
837 } `json:"network_isolation"`837 } `json:"network_isolation"`
838- Routes []domain.Route `json:"routes"`838+ Routes []domain.Route `json:"routes"`
839- ExternalRoutes map[string]string `json:"external_routes"`839+ ExternalRoutes []ExternalRoute `json:"external_routes"`
840+}
841+
842+// ExternalRoute represents a redacted external route config entry.
843+type ExternalRoute struct {
844+ Domain string `json:"domain"`
845+ Target string `json:"target,omitempty"`
840 }846 }
841 847
842 // GetConfig returns the Gordon configuration.848 // GetConfig returns the Gordon configuration.
M internal/adapters/in/http/admin/handler.go +19 -10
@@ -389,6 +389,10 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
389 addStep("route", "failed")389 addStep("route", "failed")
390 h.sendError(w, http.StatusBadRequest, err.Error())390 h.sendError(w, http.StatusBadRequest, err.Error())
391 return391 return
392+ case errors.Is(err, domain.ErrRouteConflict):
393+ addStep("route", "failed")
394+ h.sendError(w, http.StatusConflict, err.Error())
395+ return
392 default:396 default:
393 addStep("route", "failed")397 addStep("route", "failed")
394 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")398 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
@@ -594,6 +598,8 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
594 switch {598 switch {
595 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):599 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
596 h.sendError(w, http.StatusBadRequest, err.Error())600 h.sendError(w, http.StatusBadRequest, err.Error())
601+ case errors.Is(err, domain.ErrRouteConflict):
602+ h.sendError(w, http.StatusConflict, err.Error())
597 default:603 default:
598 h.sendError(w, http.StatusInternalServerError, "failed to add route")604 h.sendError(w, http.StatusInternalServerError, "failed to add route")
599 }605 }
@@ -1250,10 +1256,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1250 1256
1251 externalRoutes := h.configSvc.GetExternalRoutes()1257 externalRoutes := h.configSvc.GetExternalRoutes()
1252 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))1258 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1253- for domain, target := range externalRoutes {1259+ for domain := range externalRoutes {
1254 externalResponses = append(externalResponses, dto.ExternalRoute{1260 externalResponses = append(externalResponses, dto.ExternalRoute{
1255 Domain: domain,1261 Domain: domain,
1256- Target: target,
1257 })1262 })
1258 }1263 }
1259 1264
@@ -1262,7 +1267,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1262 Port: h.configSvc.GetServerPort(),1267 Port: h.configSvc.GetServerPort(),
1263 RegistryPort: h.configSvc.GetRegistryPort(),1268 RegistryPort: h.configSvc.GetRegistryPort(),
1264 RegistryDomain: h.configSvc.GetRegistryDomain(),1269 RegistryDomain: h.configSvc.GetRegistryDomain(),
1265- DataDir: h.configSvc.GetDataDir(),
1266 },1270 },
1267 AutoRoute: dto.AutoRouteConfig{1271 AutoRoute: dto.AutoRouteConfig{
1268 Enabled: h.configSvc.IsAutoRouteEnabled(),1272 Enabled: h.configSvc.IsAutoRouteEnabled(),
@@ -1319,12 +1323,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1319 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")1323 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1320 var deployErr *domain.DeployFailureError1324 var deployErr *domain.DeployFailureError
1321 if errors.As(err, &deployErr) {1325 if errors.As(err, &deployErr) {
1322- h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{1326+ response := dto.DeployErrorResponse{
1323 Error: deployErr.Error(),1327 Error: deployErr.Error(),
1324 Cause: deployErr.Cause,1328 Cause: deployErr.Cause,
1325 Hint: deployErr.Hint,1329 Hint: deployErr.Hint,
1326- Logs: deployErr.Logs,1330+ }
1327- })1331+ if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1332+ response.Logs = domain.RedactSecretLines(deployErr.Logs)
1333+ }
1334+ h.sendJSON(w, http.StatusInternalServerError, response)
1328 return1335 return
1329 }1336 }
1330 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")1337 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
@@ -1455,8 +1462,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
1455 log := zerowrap.FromCtx(ctx)1462 log := zerowrap.FromCtx(ctx)
1456 1463
1457 // Check read permission1464 // Check read permission
1458- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {1465+ if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1459- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")1466+ h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
1460 return1467 return
1461 }1468 }
1462 1469
@@ -1522,7 +1529,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
1522 return1529 return
1523 }1530 }
1524 1531
1525- h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})1532+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
1526 }1533 }
1527 1534
1528 // handleContainerLogs handles container logs for a specific domain.1535 // handleContainerLogs handles container logs for a specific domain.
@@ -1546,7 +1553,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
1546 1553
1547 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{1554 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1548 Domain: logDomain,1555 Domain: logDomain,
1549- Lines: logLines,1556+ Lines: domain.RedactSecretLines(logLines),
1550 })1557 })
1551 }1558 }
1552 1559
@@ -1582,6 +1589,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
1582 if !ok {1589 if !ok {
1583 return1590 return
1584 }1591 }
1592+ line = domain.RedactSecrets(line)
1585 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.1593 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1586 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.1594 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1587 // Per the SSE spec, multi-line data must use separate "data:" prefixes.1595 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
@@ -1628,6 +1636,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
1628 if !ok {1636 if !ok {
1629 return1637 return
1630 }1638 }
1639+ line = domain.RedactSecrets(line)
1631 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.1640 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1632 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.1641 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1633 // Per the SSE spec, multi-line data must use separate "data:" prefixes.1642 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
M internal/adapters/in/http/admin/handler_test.go +166 -4
@@ -98,6 +98,64 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
98 return NewHandler(deps)98 return NewHandler(deps)
99 }99 }
100 100
101+func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) {
102+ volumeSvc := inmocks.NewMockVolumeService(t)
103+ handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
104+
105+ tests := []struct {
106+ name string
107+ scopes []string
108+ wantStatus int
109+ }{
110+ {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK},
111+ {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
112+ {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden},
113+ {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden},
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ if tt.wantStatus == http.StatusOK {
119+ volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once()
120+ }
121+ server := newScopedTestServer(t, handler, tt.scopes...)
122+ resp, err := http.Get(server.URL + "/admin/volumes")
123+ require.NoError(t, err)
124+ defer resp.Body.Close()
125+ assert.Equal(t, tt.wantStatus, resp.StatusCode)
126+ })
127+ }
128+}
129+
130+func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
131+ volumeSvc := inmocks.NewMockVolumeService(t)
132+ handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
133+
134+ tests := []struct {
135+ name string
136+ scopes []string
137+ wantStatus int
138+ }{
139+ {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK},
140+ {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
141+ {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden},
142+ {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden},
143+ }
144+
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ if tt.wantStatus == http.StatusOK {
148+ volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once()
149+ }
150+ server := newScopedTestServer(t, handler, tt.scopes...)
151+ resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`))
152+ require.NoError(t, err)
153+ defer resp.Body.Close()
154+ assert.Equal(t, tt.wantStatus, resp.StatusCode)
155+ })
156+ }
157+}
158+
101 // Routes endpoint tests159 // Routes endpoint tests
102 160
103 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {161 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
@@ -1064,6 +1122,36 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
1064 1122
1065 // Config endpoint tests1123 // Config endpoint tests
1066 1124
1125+func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {
1126+ configSvc := inmocks.NewMockConfigService(t)
1127+ handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc })
1128+
1129+ configSvc.EXPECT().GetServerPort().Return(8080).Once()
1130+ configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
1131+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
1132+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once()
1133+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once()
1134+ configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once()
1135+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
1136+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once()
1137+
1138+ server := newScopedTestServer(t, handler, "admin:config:read")
1139+ resp, err := http.Get(server.URL + "/admin/config")
1140+ require.NoError(t, err)
1141+ defer resp.Body.Close()
1142+ bodyBytes, err := io.ReadAll(resp.Body)
1143+ require.NoError(t, err)
1144+
1145+ require.Equal(t, http.StatusOK, resp.StatusCode)
1146+ body := string(bodyBytes)
1147+ var raw map[string]any
1148+ require.NoError(t, json.Unmarshal([]byte(body), &raw))
1149+ serverConfig := raw["server"].(map[string]any)
1150+ assert.NotContains(t, serverConfig, "data_dir")
1151+ assert.NotContains(t, body, "/var/lib/gordon")
1152+ assert.NotContains(t, body, "10.0.0.5")
1153+}
1154+
1067 func TestHandler_Config_RequiresReadScope(t *testing.T) {1155 func TestHandler_Config_RequiresReadScope(t *testing.T) {
1068 configSvc := inmocks.NewMockConfigService(t)1156 configSvc := inmocks.NewMockConfigService(t)
1069 authSvc := inmocks.NewMockAuthService(t)1157 authSvc := inmocks.NewMockAuthService(t)
@@ -1542,6 +1630,41 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
1542 }1630 }
1543 }1631 }
1544 1632
1633+func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) {
1634+ configSvc := inmocks.NewMockConfigService(t)
1635+ containerSvc := inmocks.NewMockContainerService(t)
1636+ secretSvc := inmocks.NewMockSecretService(t)
1637+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1638+ deployErr := &domain.DeployFailureError{
1639+ Summary: "failed to deploy",
1640+ Cause: "image pull failed",
1641+ Hint: "push the image before retrying",
1642+ Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"},
1643+ }
1644+
1645+ handler := newTestHandler(t, func(d *HandlerDeps) {
1646+ d.ConfigSvc = configSvc
1647+ d.ContainerSvc = containerSvc
1648+ d.SecretSvc = secretSvc
1649+ })
1650+
1651+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1652+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1653+
1654+ server := newScopedTestServer(t, handler, "admin:config:write")
1655+ resp, err := http.Post(server.URL+"/admin/deploy/app.example.com", "application/json", nil)
1656+ require.NoError(t, err)
1657+ defer resp.Body.Close()
1658+ bodyBytes, err := io.ReadAll(resp.Body)
1659+ require.NoError(t, err)
1660+ body := string(bodyBytes)
1661+
1662+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1663+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, body)
1664+ assert.NotContains(t, body, "hunter2")
1665+ assert.NotContains(t, body, "abc123")
1666+}
1667+
1545 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {1668 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1546 configSvc := inmocks.NewMockConfigService(t)1669 configSvc := inmocks.NewMockConfigService(t)
1547 authSvc := inmocks.NewMockAuthService(t)1670 authSvc := inmocks.NewMockAuthService(t)
@@ -1553,8 +1676,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1553 Cause: "image pull failed",1676 Cause: "image pull failed",
1554 Hint: "push the image before retrying",1677 Hint: "push the image before retrying",
1555 Logs: []string{1678 Logs: []string{
1556- "pulling manifest",1679+ "pulling manifest PASSWORD=hunter2",
1557- "manifest unknown",1680+ `{"TOKEN":"abc123"}`,
1558 },1681 },
1559 Err: errors.New("manifest unknown: internal registry detail"),1682 Err: errors.New("manifest unknown: internal registry detail"),
1560 }1683 }
@@ -1566,7 +1689,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1566 d.SecretSvc = secretSvc1689 d.SecretSvc = secretSvc
1567 })1690 })
1568 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1691 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1569- handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))1692+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read")))
1570 }))1693 }))
1571 defer s.Close()1694 defer s.Close()
1572 1695
@@ -1582,8 +1705,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1582 assert.NoError(t, err)1705 assert.NoError(t, err)
1583 1706
1584 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)1707 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1585- assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))1708+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body))
1586 assert.NotContains(t, string(body), "internal registry detail")1709 assert.NotContains(t, string(body), "internal registry detail")
1710+ assert.NotContains(t, string(body), "hunter2")
1711+ assert.NotContains(t, string(body), "abc123")
1587 }1712 }
1588 1713
1589 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {1714 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
@@ -2045,6 +2170,43 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2045 assert.Contains(t, rec.Body.String(), "completed")2170 assert.Contains(t, rec.Body.String(), "completed")
2046 }2171 }
2047 2172
2173+func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
2174+ logSvc := inmocks.NewMockLogService(t)
2175+ handler := newTestHandler(t, func(d *HandlerDeps) {
2176+ d.LogSvc = logSvc
2177+ })
2178+
2179+ t.Run("status read denied", func(t *testing.T) {
2180+ server := newScopedTestServer(t, handler, "admin:status:read")
2181+ resp, err := http.Get(server.URL + "/admin/logs")
2182+ require.NoError(t, err)
2183+ defer resp.Body.Close()
2184+ bodyBytes, err := io.ReadAll(resp.Body)
2185+ require.NoError(t, err)
2186+
2187+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2188+ assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read")
2189+ })
2190+
2191+ t.Run("logs read allowed and redacted", func(t *testing.T) {
2192+ logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
2193+
2194+ server := newScopedTestServer(t, handler, "admin:logs:read")
2195+ resp, err := http.Get(server.URL + "/admin/logs")
2196+ require.NoError(t, err)
2197+ defer resp.Body.Close()
2198+ bodyBytes, err := io.ReadAll(resp.Body)
2199+ require.NoError(t, err)
2200+ body := string(bodyBytes)
2201+
2202+ assert.Equal(t, http.StatusOK, resp.StatusCode)
2203+ assert.Contains(t, body, "PASSWORD=[REDACTED]")
2204+ assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`)
2205+ assert.NotContains(t, body, "hunter2")
2206+ assert.NotContains(t, body, "abc123")
2207+ })
2208+}
2209+
2048 func TestHandler_BackupsDetectDomain(t *testing.T) {2210 func TestHandler_BackupsDetectDomain(t *testing.T) {
2049 configSvc := inmocks.NewMockConfigService(t)2211 configSvc := inmocks.NewMockConfigService(t)
2050 authSvc := inmocks.NewMockAuthService(t)2212 authSvc := inmocks.NewMockAuthService(t)
M internal/adapters/in/http/admin/handler_volumes.go +4 -4
@@ -19,8 +19,8 @@ func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) {
19 19
20 ctx := r.Context()20 ctx := r.Context()
21 21
22- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {22+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) {
23- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")23+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read")
24 return24 return
25 }25 }
26 26
@@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
63 63
64 ctx := r.Context()64 ctx := r.Context()
65 65
66- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {66+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionWrite) {
67- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")67+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:write")
68 return68 return
69 }69 }
70 70
M internal/adapters/in/http/middleware/cidr.go +37 -5
@@ -5,12 +5,14 @@ import (
5 "fmt"5 "fmt"
6 "net"6 "net"
7 "net/http"7 "net/http"
8+ "strconv"
8 "strings"9 "strings"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
11 12
12 "github.com/bnema/gordon/internal/adapters/dto"13 "github.com/bnema/gordon/internal/adapters/dto"
13 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"14 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.18 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
@@ -67,7 +69,7 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
67 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect69 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
68 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from70 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
69 // the public URL; hosts with an unknown explicit port preserve it as-is.71 // the public URL; hosts with an unknown explicit port preserve it as-is.
70-func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler {72+func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler {
71 return func(next http.Handler) http.Handler {73 return func(next http.Handler) http.Handler {
72 if tlsPort == 0 {74 if tlsPort == 0 {
73 return next75 return next
@@ -88,7 +90,11 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
88 }90 }
89 }91 }
90 92
91- target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort)93+ target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
94+ if !ok {
95+ http.Error(w, "Bad Request", http.StatusBadRequest)
96+ return
97+ }
92 98
93 log.Debug().99 log.Debug().
94 Str("target", target).100 Str("target", target).
@@ -99,14 +105,40 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
99 }105 }
100 }106 }
101 107
102-// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.108+// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
103-func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {109+func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
110+ host = strings.ToLower(strings.TrimSpace(host))
111+ if host == "" {
112+ return "", false
113+ }
114+ hostname, portStr, err := net.SplitHostPort(host)
115+ if err != nil {
116+ if strings.Contains(host, ":") {
117+ return "", false
118+ }
119+ hostname = host
120+ } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
121+ return "", false
122+ }
123+ hostname = strings.TrimSuffix(hostname, ".")
124+ canonicalHost, ok := domain.CanonicalRouteDomain(hostname)
125+ if !ok {
126+ return "", false
127+ }
128+ if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
129+ return "", false
130+ }
131+
104 path := requestURI132 path := requestURI
105 if !strings.HasPrefix(path, "/") {133 if !strings.HasPrefix(path, "/") {
106 path = "/"134 path = "/"
107 }135 }
108 136
109- return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)137+ canonicalAuthority := canonicalHost
138+ if portStr != "" {
139+ canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
140+ }
141+ return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
110 }142 }
111 143
112 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.144 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
M internal/adapters/in/http/middleware/cidr_test.go +47 -17
@@ -2,7 +2,6 @@ package middleware
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5- "fmt"
6 "net"5 "net"
7 "net/http"6 "net/http"
8 "net/http/httptest"7 "net/http/httptest"
@@ -118,7 +117,7 @@ func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
118 w.WriteHeader(http.StatusOK)117 w.WriteHeader(http.StatusOK)
119 })118 })
120 119
121- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)120+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
122 srv := httptest.NewServer(handler)121 srv := httptest.NewServer(handler)
123 defer srv.Close()122 defer srv.Close()
124 123
@@ -141,7 +140,7 @@ func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
141 w.WriteHeader(http.StatusOK)140 w.WriteHeader(http.StatusOK)
142 })141 })
143 142
144- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)143+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
145 srv := httptest.NewServer(handler)144 srv := httptest.NewServer(handler)
146 defer srv.Close()145 defer srv.Close()
147 146
@@ -164,7 +163,7 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
164 w.WriteHeader(http.StatusOK)163 w.WriteHeader(http.StatusOK)
165 })164 })
166 165
167- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)166+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
168 srv := httptest.NewServer(handler)167 srv := httptest.NewServer(handler)
169 defer srv.Close()168 defer srv.Close()
170 169
@@ -181,31 +180,62 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
181 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))180 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))
182 }181 }
183 182
184-func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) {183+func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) {
185 log := testLogger()184 log := testLogger()
186 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {185 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187 w.WriteHeader(http.StatusOK)186 w.WriteHeader(http.StatusOK)
188 })187 })
188+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
189 189
190- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
191 srv := httptest.NewServer(handler)190 srv := httptest.NewServer(handler)
192 defer srv.Close()191 defer srv.Close()
192+ client := srv.Client()
193+ client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
193 194
194- // Use a raw TCP connection because Go's HTTP client rejects the195+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/path", nil)
195- // invalid port in the Location header before CheckRedirect fires.
196- conn, err := net.Dial("tcp", srv.Listener.Addr().String())
197 require.NoError(t, err)196 require.NoError(t, err)
198- defer conn.Close()197+ req.Host = "O2.Bnema.Dev.:8088"
199-198+ resp, err := client.Do(req)
200- _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n")
201 require.NoError(t, err)199 require.NoError(t, err)
200+ defer resp.Body.Close()
202 201
203- var buf [4096]byte202+ assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
204- n, _ := conn.Read(buf[:])203+ assert.Equal(t, "https://o2.bnema.dev:8443/path", resp.Header.Get("Location"))
205- raw := string(buf[:n])204+}
206 205
207- assert.Contains(t, raw, "308")206+func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) {
208- assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/")207+ log := testLogger()
208+ ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
209+ w.WriteHeader(http.StatusOK)
210+ })
211+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
212+
213+ tests := []struct {
214+ name string
215+ host string
216+ }{
217+ {name: "invalid port", host: "o2.bnema.dev:abcd"},
218+
219+ {name: "localhost", host: "localhost"},
220+ {name: "ipv6", host: "[::1]:8088"},
221+ {name: "unconfigured public host", host: "evil.example.com"},
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ srv := httptest.NewServer(handler)
227+ defer srv.Close()
228+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
229+ require.NoError(t, err)
230+ req.Host = tt.host
231+ resp, err := srv.Client().Do(req)
232+ require.NoError(t, err)
233+ defer resp.Body.Close()
234+
235+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
236+ assert.Empty(t, resp.Header.Get("Location"))
237+ })
238+ }
209 }239 }
210 240
211 func TestProxyCIDRAllowlist(t *testing.T) {241 func TestProxyCIDRAllowlist(t *testing.T) {
M internal/adapters/in/http/proxy/handler.go +33 -3
@@ -4,6 +4,8 @@ package proxy
4 import (4 import (
5 "net"5 "net"
6 "net/http"6 "net/http"
7+ "strconv"
8+ "strings"
7 "sync/atomic"9 "sync/atomic"
8 10
9 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -70,16 +72,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
70 r = r.WithContext(ctx)72 r = r.WithContext(ctx)
71 log := zerowrap.FromCtx(ctx)73 log := zerowrap.FromCtx(ctx)
72 74
75+ host := normalizeRequestHost(r.Host)
76+ if host == "" {
77+ proxyError(w, "Bad Request", http.StatusBadRequest)
78+ return
79+ }
80+
73 // Check if this is the registry domain81 // Check if this is the registry domain
74- if h.proxySvc.IsRegistryDomain(r.Host) {82+ if h.proxySvc.IsRegistryDomain(host) {
75 log.Debug().Msg("routing request to registry")83 log.Debug().Msg("routing request to registry")
76 h.forwardToRegistry(w, r, cfg.RegistryPort)84 h.forwardToRegistry(w, r, cfg.RegistryPort)
77 return85 return
78 }86 }
79 87
80 // Get target for this domain88 // Get target for this domain
81- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")89+ log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target")
82- target, err := h.proxySvc.GetTarget(ctx, r.Host)90+ target, err := h.proxySvc.GetTarget(ctx, host)
83 if err != nil {91 if err != nil {
84 log.Warn().Err(err).Msg("no route found for domain")92 log.Warn().Err(err).Msg("no route found for domain")
85 proxyError(w, "404 page not found", http.StatusNotFound)93 proxyError(w, "404 page not found", http.StatusNotFound)
@@ -94,3 +102,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
94 102
95 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)103 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
96 }104 }
105+
106+func normalizeRequestHost(host string) string {
107+ host = strings.TrimSpace(host)
108+ if h, port, err := net.SplitHostPort(host); err == nil {
109+ portNum, err := strconv.Atoi(port)
110+ if err != nil || portNum < 1 || portNum > 65535 {
111+ return ""
112+ }
113+ host = h
114+ } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
115+ inner := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
116+ if ip := net.ParseIP(inner); ip == nil || ip.To4() != nil {
117+ return ""
118+ }
119+ host = inner
120+ } else if strings.Contains(host, ":") {
121+ return ""
122+ }
123+ host = strings.ToLower(host)
124+ host = strings.TrimSuffix(host, ".")
125+ return host
126+}
M internal/adapters/in/http/proxy/handler_test.go +53 -0
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
14 15
15 "github.com/bnema/gordon/internal/boundaries/in"16 "github.com/bnema/gordon/internal/boundaries/in"
16 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"17 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
@@ -61,6 +62,58 @@ func TestHandler_RoutesToRegistry(t *testing.T) {
61 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)62 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
62 }63 }
63 64
65+func TestHandler_NormalizesRequestHostForLookup(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ host string
69+ wantHost string
70+ }{
71+ {name: "mixed case", host: "App.Example.com", wantHost: "app.example.com"},
72+ {name: "explicit port", host: "App.Example.com:8080", wantHost: "app.example.com"},
73+ {name: "trailing dot", host: "App.Example.com.", wantHost: "app.example.com"},
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ proxySvc := inmocks.NewMockProxyService(t)
79+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
80+ proxySvc.EXPECT().IsRegistryDomain(tt.wantHost).Return(false)
81+ proxySvc.EXPECT().GetTarget(mock.Anything, tt.wantHost).Return(nil, domain.ErrNoTargetAvailable)
82+
83+ handler := NewHandler(proxySvc, nil, testLogger())
84+ srv := httptest.NewServer(handler)
85+ defer srv.Close()
86+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
87+ require.NoError(t, err)
88+ req.Host = tt.host
89+ resp, err := srv.Client().Do(req)
90+ require.NoError(t, err)
91+ defer resp.Body.Close()
92+
93+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
94+ })
95+ }
96+}
97+
98+func TestHandler_RejectsInvalidRequestHost(t *testing.T) {
99+ proxySvc := inmocks.NewMockProxyService(t)
100+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
101+
102+ handler := NewHandler(proxySvc, nil, testLogger())
103+ srv := httptest.NewServer(handler)
104+ defer srv.Close()
105+ for _, host := range []string{"app.example.com:bad", "app.example.com:0", "app.example.com:99999"} {
106+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
107+ require.NoError(t, err)
108+ req.Host = host
109+ resp, err := srv.Client().Do(req)
110+ require.NoError(t, err)
111+ defer resp.Body.Close()
112+
113+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
114+ }
115+}
116+
64 func TestHandler_Returns404WhenNoTarget(t *testing.T) {117 func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65 proxySvc := inmocks.NewMockProxyService(t)118 proxySvc := inmocks.NewMockProxyService(t)
66 119
M internal/adapters/in/http/registry/handler.go +73 -12
@@ -2,6 +2,7 @@
2 package registry2 package registry
3 3
4 import (4 import (
5+ "context"
5 "encoding/json"6 "encoding/json"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
@@ -9,6 +10,7 @@ import (
9 "net/http"10 "net/http"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
13+ "sync/atomic"
12 14
13 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
14 16
@@ -26,13 +28,21 @@ const (
26 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.28 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.
27 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.29 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.
28 DefaultMaxBlobChunkSize = 95 * 1024 * 102430 DefaultMaxBlobChunkSize = 95 * 1024 * 1024
31+ // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload.
32+ DefaultMaxBlobSize = 1024 * 1024 * 1024
29 )33 )
30 34
35+// blobLimits holds consistent blob size limits.
36+type blobLimits struct {
37+ MaxChunkSize int64
38+ MaxBlobSize int64
39+}
40+
31 // Handler implements the HTTP handler for Docker Registry API v2.41 // Handler implements the HTTP handler for Docker Registry API v2.
32 type Handler struct {42 type Handler struct {
33- registrySvc in.RegistryService43+ registrySvc in.RegistryService
34- log zerowrap.Logger44+ log zerowrap.Logger
35- maxBlobChunkSize int6445+ blobLimits atomic.Value // stores *blobLimits
36 }46 }
37 47
38 // NewHandler creates a new registry HTTP handler.48 // NewHandler creates a new registry HTTP handler.
@@ -40,16 +50,39 @@ func NewHandler(
40 registrySvc in.RegistryService,50 registrySvc in.RegistryService,
41 log zerowrap.Logger,51 log zerowrap.Logger,
42 maxBlobChunkSize int64,52 maxBlobChunkSize int64,
53+ maxBlobSize ...int64,
43 ) *Handler {54 ) *Handler {
44 if maxBlobChunkSize <= 0 {55 if maxBlobChunkSize <= 0 {
45 maxBlobChunkSize = DefaultMaxBlobChunkSize56 maxBlobChunkSize = DefaultMaxBlobChunkSize
46 }57 }
58+ blobSizeLimit := int64(DefaultMaxBlobSize)
59+ if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 {
60+ blobSizeLimit = maxBlobSize[0]
61+ }
47 62
48- return &Handler{63+ h := &Handler{
49- registrySvc: registrySvc,64+ registrySvc: registrySvc,
50- log: log,65+ log: log,
51- maxBlobChunkSize: maxBlobChunkSize,
52 }66 }
67+ h.blobLimits.Store(&blobLimits{
68+ MaxChunkSize: maxBlobChunkSize,
69+ MaxBlobSize: blobSizeLimit,
70+ })
71+ return h
72+}
73+
74+// UpdateBlobLimits updates request-size limits used by live upload handlers.
75+func (h *Handler) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
76+ if maxBlobChunkSize <= 0 {
77+ maxBlobChunkSize = DefaultMaxBlobChunkSize
78+ }
79+ if maxBlobSize <= 0 {
80+ maxBlobSize = DefaultMaxBlobSize
81+ }
82+ h.blobLimits.Store(&blobLimits{
83+ MaxChunkSize: maxBlobChunkSize,
84+ MaxBlobSize: maxBlobSize,
85+ })
53 }86 }
54 87
55 // RegisterRoutes registers the registry routes on the given mux.88 // RegisterRoutes registers the registry routes on the given mux.
@@ -244,7 +277,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
244 }277 }
245 }278 }
246 279
247-func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {280+func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
248 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")281 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
249 w.WriteHeader(http.StatusOK)282 w.WriteHeader(http.StatusOK)
250 }283 }
@@ -407,20 +440,36 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
407 }440 }
408 }441 }
409 442
443+ // Load a consistent snapshot of both blob limits atomically
444+ limits := h.blobLimits.Load().(*blobLimits)
445+ maxBlobChunkSize := limits.MaxChunkSize
446+ maxBlobSize := limits.MaxBlobSize
447+
410 // Limit blob chunk size to prevent excessive uploads.448 // Limit blob chunk size to prevent excessive uploads.
411 // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit.449 // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit.
412- r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize)450+ r.Body = http.MaxBytesReader(w, r.Body, maxBlobChunkSize)
413 451
414 // Stream the body directly to storage — memory usage is bounded to a small452 // Stream the body directly to storage — memory usage is bounded to a small
415 // copy buffer (~32KB) regardless of chunk size.453 // copy buffer (~32KB) regardless of chunk size.
416- length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body)454+ length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, maxBlobSize)
417 if err != nil {455 if err != nil {
418 var maxBytesErr *http.MaxBytesError456 var maxBytesErr *http.MaxBytesError
419 if errors.As(err, &maxBytesErr) {457 if errors.As(err, &maxBytesErr) {
420- log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large")458+ log.Warn().Int64("max_size", maxBlobChunkSize).Msg("blob chunk too large")
459+ if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") {
460+ return
461+ }
421 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")462 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
422 return463 return
423 }464 }
465+ if errors.Is(err, domain.ErrBlobSizeExceeded) {
466+ log.Warn().Int64("max_size", maxBlobSize).Msg("blob upload too large")
467+ if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") {
468+ return
469+ }
470+ h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
471+ return
472+ }
424 log.Error().Err(err).Msg("failed to append blob chunk")473 log.Error().Err(err).Msg("failed to append blob chunk")
425 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")474 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
426 return475 return
@@ -430,7 +479,9 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
430 if r.Method == "PUT" && digest != "" {479 if r.Method == "PUT" && digest != "" {
431 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {480 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
432 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")481 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
433- _ = h.registrySvc.CancelUpload(ctx, uuid)482+ if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel invalid blob upload") {
483+ return
484+ }
434 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")485 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
435 return486 return
436 }487 }
@@ -450,6 +501,16 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
450 w.WriteHeader(http.StatusAccepted)501 w.WriteHeader(http.StatusAccepted)
451 }502 }
452 503
504+func (h *Handler) cancelUploadAfterError(w http.ResponseWriter, ctx context.Context, uuid, message string) bool {
505+ if err := h.registrySvc.CancelUpload(ctx, uuid); err != nil {
506+ log := zerowrap.FromCtx(ctx)
507+ log.Error().Err(err).Str("uuid", uuid).Msg(message)
508+ h.sendRegistryError(w, http.StatusInternalServerError, "UNKNOWN", "failed to cancel upload")
509+ return false
510+ }
511+ return true
512+}
513+
453 func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {514 func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
454 ctx := r.Context()515 ctx := r.Context()
455 log := zerowrap.FromCtx(ctx)516 log := zerowrap.FromCtx(ctx)
M internal/adapters/in/http/registry/handler_test.go +83 -7
@@ -293,7 +293,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
294 294
295 chunkData := []byte("chunk content")295 chunkData := []byte("chunk content")
296- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)296+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
297 297
298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
299 rec := httptest.NewRecorder()299 rec := httptest.NewRecorder()
@@ -310,7 +310,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
311 311
312 chunkData := []byte("final chunk")312 chunkData := []byte("final chunk")
313- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)313+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
315 315
316 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))316 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
@@ -432,7 +432,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
433 433
434 chunkData := []byte("chunk content")434 chunkData := []byte("chunk content")
435- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)435+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
436 436
437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
438 rec := httptest.NewRecorder()438 rec := httptest.NewRecorder()
@@ -453,12 +453,14 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.
453 453
454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.
455 // The mock must read the body to trigger MaxBytesError, which the handler detects.455 // The mock must read the body to trigger MaxBytesError, which the handler detects.
456- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).456+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).
457- Run(func(_ context.Context, _ string, _ string, data io.Reader) {457+ Run(func(_ context.Context, _ string, _ string, data io.Reader, _ int64, _ int64) {
458- // Drain the reader to trigger MaxBytesError458+ // Drain the reader to trigger MaxBytesError after the storage layer may
459+ // have written the limit-sized prefix.
459 _, _ = io.ReadAll(data)460 _, _ = io.ReadAll(data)
460 }).461 }).
461 Return(int64(0), &http.MaxBytesError{Limit: 5})462 Return(int64(0), &http.MaxBytesError{Limit: 5})
463+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
462 464
463 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))465 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
464 rec := httptest.NewRecorder()466 rec := httptest.NewRecorder()
@@ -473,7 +475,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
473 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)475 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
474 476
475 chunkData := []byte("chunk content")477 chunkData := []byte("chunk content")
476- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)478+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
477 479
478 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))480 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
479 rec := httptest.NewRecorder()481 rec := httptest.NewRecorder()
@@ -484,3 +486,77 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
484 // OCI spec: Range header should reflect total received bytes486 // OCI spec: Range header should reflect total received bytes
485 assert.Equal(t, "0-12", rec.Header().Get("Range"))487 assert.Equal(t, "0-12", rec.Header().Get("Range"))
486 }488 }
489+
490+func TestDefaultMaxBlobSize_Is1GB(t *testing.T) {
491+ assert.Equal(t, int64(1024*1024*1024), int64(DefaultMaxBlobSize))
492+}
493+
494+func TestHandler_BlobUpload_PATCH_MaxBlobSizeExceededReturnsSizeInvalidAndCancels(t *testing.T) {
495+ registrySvc := inmocks.NewMockRegistryService(t)
496+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
497+
498+ registrySvc.EXPECT().AppendBlobChunk(
499+ mock.Anything,
500+ "myapp",
501+ "550e8400-e29b-41d4-a716-446655440000",
502+ mock.Anything,
503+ int64(6),
504+ int64(10),
505+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
506+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
507+
508+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("123456")))
509+ rec := httptest.NewRecorder()
510+
511+ handler.ServeHTTP(rec, req)
512+
513+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
514+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
515+}
516+
517+func TestHandler_BlobUpload_PATCH_ContentLengthTooLargeReturnsSizeInvalidAndCancels(t *testing.T) {
518+ registrySvc := inmocks.NewMockRegistryService(t)
519+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
520+
521+ registrySvc.EXPECT().AppendBlobChunk(
522+ mock.Anything,
523+ "myapp",
524+ "550e8400-e29b-41d4-a716-446655440000",
525+ mock.Anything,
526+ int64(11),
527+ int64(10),
528+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
529+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
530+
531+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("12345678901")))
532+ rec := httptest.NewRecorder()
533+
534+ handler.ServeHTTP(rec, req)
535+
536+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
537+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
538+}
539+
540+func TestHandler_BlobUpload_PUT_FinalizeUnderMaxBlobSize(t *testing.T) {
541+ registrySvc := inmocks.NewMockRegistryService(t)
542+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
543+
544+ chunkData := []byte("1234567890")
545+ registrySvc.EXPECT().AppendBlobChunk(
546+ mock.Anything,
547+ "myapp",
548+ "550e8400-e29b-41d4-a716-446655440000",
549+ mock.Anything,
550+ int64(len(chunkData)),
551+ int64(10),
552+ ).Return(int64(len(chunkData)), nil)
553+ registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
554+
555+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
556+ rec := httptest.NewRecorder()
557+
558+ handler.ServeHTTP(rec, req)
559+
560+ assert.Equal(t, http.StatusCreated, rec.Code)
561+ assert.Equal(t, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", rec.Header().Get("Docker-Content-Digest"))
562+}
M internal/adapters/out/docker/runtime.go +64 -51
@@ -161,6 +161,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161 WorkingDir: config.WorkingDir,161 WorkingDir: config.WorkingDir,
162 Cmd: config.Cmd,162 Cmd: config.Cmd,
163 Labels: config.Labels,163 Labels: config.Labels,
164+ User: config.User,
164 }165 }
165 166
166 resources := container.Resources{167 resources := container.Resources{
@@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
173 if config.PidsLimit > 0 {174 if config.PidsLimit > 0 {
174 resources.PidsLimit = &config.PidsLimit175 resources.PidsLimit = &config.PidsLimit
175 }176 }
177+ capDrop := strslice.StrSlice{"ALL"}
178+ capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
179+ if config.CapDrop != nil {
180+ capDrop = strslice.StrSlice(config.CapDrop)
181+ }
182+ if config.CapAdd != nil {
183+ capAdd = strslice.StrSlice(config.CapAdd)
184+ }
176 hostConfig := &container.HostConfig{185 hostConfig := &container.HostConfig{
177- PortBindings: portBindings,186+ PortBindings: portBindings,
178- AutoRemove: config.AutoRemove,187+ AutoRemove: config.AutoRemove,
179- Binds: binds,188+ Binds: binds,
180- NetworkMode: container.NetworkMode(config.NetworkMode),189+ NetworkMode: container.NetworkMode(config.NetworkMode),
181- Resources: resources,190+ Resources: resources,
182- SecurityOpt: []string{"no-new-privileges:true"},191+ SecurityOpt: []string{"no-new-privileges:true"},
183- CapDrop: strslice.StrSlice{"ALL"},192+ CapDrop: capDrop,
184- // Re-add the minimal set of capabilities that standard images need.193+ CapAdd: capAdd,
185- // CHOWN/FOWNER/DAC_OVERRIDE: postgres, mysql etc. chown/chmod data dirs194+ ReadonlyRootfs: config.ReadOnlyRootFS,
186- // owned by their service user during entrypoint init.
187- // SETUID/SETGID: gosu/su-exec to drop privileges after init.
188- // NET_BIND_SERVICE: nginx etc. binding to ports < 1024.
189- CapAdd: strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"},
190 }195 }
191 196
192 // Create network configuration for container197 // Create network configuration for container
@@ -1251,7 +1256,7 @@ func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, erro
1251 }1256 }
1252 1257
1253 // CreateNetwork creates a new Docker network.1258 // CreateNetwork creates a new Docker network.
1254-func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {1259+func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
1255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1256 zerowrap.FieldLayer: "adapter",1261 zerowrap.FieldLayer: "adapter",
1257 zerowrap.FieldAdapter: "docker",1262 zerowrap.FieldAdapter: "docker",
@@ -1260,24 +1265,20 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st
1260 })1265 })
1261 log := zerowrap.FromCtx(ctx)1266 log := zerowrap.FromCtx(ctx)
1262 1267
1263- // Set default driver to bridge if not specified1268+ driver := config.Driver
1264- driver := "bridge"1269+ if driver == "" {
1265- if driverOption, exists := options["driver"]; exists {1270+ driver = "bridge"
1266- driver = driverOption
1267 }1271 }
1268-1272+ labels := make(map[string]string, len(config.Labels)+1)
1269- createOptions := network.CreateOptions{1273+ for key, value := range config.Labels {
1270- Driver: driver,1274+ labels[key] = value
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- },
1274 }1275 }
1276+ labels[domain.LabelManaged] = "true"
1275 1277
1276- // Add any additional options to labels1278+ createOptions := network.CreateOptions{
1277- for key, value := range options {1279+ Driver: driver,
1278- if key != "driver" {1280+ Internal: config.Internal,
1279- createOptions.Labels["gordon."+key] = value1281+ Labels: labels,
1280- }
1281 }1282 }
1282 1283
1283 _, err := r.client.NetworkCreate(ctx, name, createOptions)1284 _, err := r.client.NetworkCreate(ctx, name, createOptions)
@@ -1503,10 +1504,7 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1503 // extractFileFromTar extracts a single file from a tar archive.1504 // extractFileFromTar extracts a single file from a tar archive.
1504 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {1505 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1505 tr := tar.NewReader(reader)1506 tr := tar.NewReader(reader)
1506-1507+ targetName := normalizedTarPath(targetPath)
1507- // The path in the tar may be relative or absolute
1508- // We need to match based on the filename
1509- targetName := filepath.Base(targetPath)
1510 1508
1511 for {1509 for {
1512 header, err := tr.Next()1510 header, err := tr.Next()
@@ -1517,23 +1515,19 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1517 return nil, err1515 return nil, err
1518 }1516 }
1519 1517
1520- // Check if this is our target file1518+ if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1521- if header.Typeflag == tar.TypeReg {1519+ size := header.Size
1522- headerName := filepath.Base(header.Name)1520+ pr, pw := io.Pipe()
1523- if headerName == targetName {1521+ go func() {
1524- size := header.Size1522+ defer reader.Close()
1525- pr, pw := io.Pipe()1523+ _, copyErr := io.CopyN(pw, tr, size)
1526- go func() {1524+ if copyErr != nil {
1527- defer reader.Close()1525+ _ = pw.CloseWithError(copyErr)
1528- _, copyErr := io.CopyN(pw, tr, size)1526+ return
1529- if copyErr != nil {1527+ }
1530- _ = pw.CloseWithError(copyErr)1528+ _ = pw.Close()
1531- return1529+ }()
1532- }1530+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1533- _ = pw.Close()
1534- }()
1535- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1536- }
1537 }1531 }
1538 }1532 }
1539 1533
@@ -1541,6 +1535,25 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1541 return nil, fmt.Errorf("file not found in container: %s", targetPath)1535 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1542 }1536 }
1543 1537
1538+func normalizedTarPath(path string) string {
1539+ path = filepath.ToSlash(filepath.Clean(path))
1540+ return strings.TrimPrefix(path, "/")
1541+}
1542+
1543+const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
1544+
1545+func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
1546+ limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
1547+ data, err := io.ReadAll(limited)
1548+ if err != nil {
1549+ return nil, err
1550+ }
1551+ if len(data) > maxExtractedEnvFileSize {
1552+ return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
1553+ }
1554+ return data, nil
1555+}
1556+
1544 // ExtractEnvFileFromImage extracts an env file from an image.1557 // ExtractEnvFileFromImage extracts an env file from an image.
1545 // This creates a temporary container, copies the file, and removes the container.1558 // This creates a temporary container, copies the file, and removes the container.
1546 func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {1559 func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
@@ -1583,7 +1596,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
1583 }1596 }
1584 defer reader.Close()1597 defer reader.Close()
1585 1598
1586- data, err := io.ReadAll(reader)1599+ data, err := readExtractedEnvFile(reader)
1587 if err != nil {1600 if err != nil {
1588 return nil, log.WrapErr(err, "failed to read extracted env file")1601 return nil, log.WrapErr(err, "failed to read extracted env file")
1589 }1602 }
M internal/adapters/out/docker/runtime_exec_test.go +30 -0
@@ -1,9 +1,12 @@
1 package docker1 package docker
2 2
3 import (3 import (
4+ "archive/tar"
4 "bytes"5 "bytes"
5 "context"6 "context"
6 "encoding/binary"7 "encoding/binary"
8+ "io"
9+ "strings"
7 "testing"10 "testing"
8 11
9 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -47,3 +50,30 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
47 copy(frame[8:], payload)50 copy(frame[8:], payload)
48 return frame51 return frame
49 }52 }
53+
54+func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
55+ var buf bytes.Buffer
56+ tw := tar.NewWriter(&buf)
57+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))
58+ _, err := tw.Write([]byte("wrong"))
59+ require.NoError(t, err)
60+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))
61+ _, err = tw.Write([]byte("right"))
62+ require.NoError(t, err)
63+ require.NoError(t, tw.Close())
64+
65+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
66+ require.NoError(t, err)
67+ defer rc.Close()
68+
69+ data, err := io.ReadAll(rc)
70+ require.NoError(t, err)
71+ assert.Equal(t, "right", string(data))
72+}
73+
74+func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75+ data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76+ require.Error(t, err)
77+ assert.Nil(t, data)
78+ assert.Contains(t, err.Error(), "exceeds")
79+}
M internal/adapters/out/domainsecrets/pass_store.go +111 -0
@@ -8,6 +8,7 @@ import (
8 "regexp"8 "regexp"
9 "sort"9 "sort"
10 "strings"10 "strings"
11+ "sync"
11 "time"12 "time"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
@@ -27,6 +28,7 @@ const (
27 28
28 // PassStore implements the DomainSecretStore interface using the pass password manager.29 // PassStore implements the DomainSecretStore interface using the pass password manager.
29 type PassStore struct {30 type PassStore struct {
31+ mu sync.Mutex
30 timeout time.Duration32 timeout time.Duration
31 log zerowrap.Logger33 log zerowrap.Logger
32 }34 }
@@ -138,6 +140,26 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
138 return secretsMap, nil140 return secretsMap, nil
139 }141 }
140 142
143+// SetIfEmpty sets multiple secrets for a domain only when no keys already exist.
144+// It performs the precondition check inside the store adapter so migration
145+// callers cannot accidentally split the check and write into separate steps.
146+func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) {
147+ s.mu.Lock()
148+ defer s.mu.Unlock()
149+
150+ existingKeys, err := s.ListKeys(domainName)
151+ if err != nil {
152+ return nil, err
153+ }
154+ if len(existingKeys) > 0 {
155+ return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys))
156+ }
157+ if err := s.Set(domainName, secretsMap); err != nil {
158+ return nil, err
159+ }
160+ return sortedMapKeys(secretsMap), nil
161+}
162+
141 // Set sets or updates multiple secrets for a domain.163 // Set sets or updates multiple secrets for a domain.
142 func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {164 func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
143 if _, err := s.domainPath(domainName); err != nil {165 if _, err := s.domainPath(domainName); err != nil {
@@ -253,6 +275,26 @@ func (s *PassStore) Delete(domainName, key string) error {
253 return nil275 return nil
254 }276 }
255 277
278+// SetAttachmentIfEmpty sets attachment secrets only when no keys already exist.
279+// It performs the precondition check inside the store adapter so migration
280+// callers cannot accidentally split the check and write into separate steps.
281+func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) {
282+ s.mu.Lock()
283+ defer s.mu.Unlock()
284+
285+ existingKeys, err := s.listAttachmentKeysRecover(containerName)
286+ if err != nil {
287+ return nil, err
288+ }
289+ if len(existingKeys) > 0 {
290+ return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys))
291+ }
292+ if err := s.SetAttachment(containerName, secretsMap); err != nil {
293+ return nil, err
294+ }
295+ return sortedMapKeys(secretsMap), nil
296+}
297+
256 // SetAttachment sets or updates multiple secrets for an attachment container.298 // SetAttachment sets or updates multiple secrets for an attachment container.
257 func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {299 func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
258 if _, err := s.attachmentPath(containerName); err != nil {300 if _, err := s.attachmentPath(containerName); err != nil {
@@ -613,6 +655,66 @@ func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
613 keys = append(keys, key)655 keys = append(keys, key)
614 }656 }
615 657
658+ sort.Strings(keys)
659+ return keys, nil
660+}
661+
662+func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) {
663+ keys, err := s.listAttachmentKeys(containerName)
664+ if err != nil {
665+ return nil, err
666+ }
667+ discovered, err := s.discoverAttachmentKeys(containerName)
668+ if err != nil {
669+ return nil, err
670+ }
671+ merged, changed := mergeUniqueKeys(keys, discovered)
672+ if changed {
673+ manifestPath, err := s.attachmentManifestPath(containerName)
674+ if err != nil {
675+ return nil, err
676+ }
677+ writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
678+ writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
679+ writeCancel()
680+ if writeErr != nil {
681+ s.log.Warn().
682+ Str(zerowrap.FieldLayer, "adapter").
683+ Str(zerowrap.FieldAdapter, "domainsecrets").
684+ Str("container", containerName).
685+ Err(writeErr).
686+ Msg("failed to self-heal attachment pass manifest, continuing with recovered keys")
687+ }
688+ }
689+ return merged, nil
690+}
691+
692+func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) {
693+ basePath, err := s.attachmentPath(containerName)
694+ if err != nil {
695+ return nil, err
696+ }
697+
698+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
699+ defer cancel()
700+
701+ entries, err := s.listTopLevelEntries(ctx, basePath)
702+ if err != nil {
703+ return nil, err
704+ }
705+
706+ keys := make([]string, 0, len(entries))
707+ for _, entry := range entries {
708+ if entry == ".keys" {
709+ continue
710+ }
711+ if err := domain.ValidateEnvKey(entry); err != nil {
712+ continue
713+ }
714+ keys = append(keys, entry)
715+ }
716+
717+ sort.Strings(keys)
616 return keys, nil718 return keys, nil
617 }719 }
618 720
@@ -778,6 +880,15 @@ func passEntryMissing(output string) bool {
778 strings.Contains(lower, "password store is empty")880 strings.Contains(lower, "password store is empty")
779 }881 }
780 882
883+func sortedMapKeys(values map[string]string) []string {
884+ keys := make([]string, 0, len(values))
885+ for key := range values {
886+ keys = append(keys, key)
887+ }
888+ sort.Strings(keys)
889+ return keys
890+}
891+
781 func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {892 func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
782 mergedSet := make(map[string]struct{}, len(primary)+len(secondary))893 mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
783 for _, key := range primary {894 for _, key := range primary {
M internal/adapters/out/filesystem/backup_storage.go +36 -4
@@ -2,6 +2,8 @@ package filesystem
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
6+ "encoding/hex"
5 "errors"7 "errors"
6 "fmt"8 "fmt"
7 "io"9 "io"
@@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
62 return "", fmt.Errorf("failed to create backup path: %w", err)64 return "", fmt.Errorf("failed to create backup path: %w", err)
63 }65 }
64 66
65- fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak"67+ fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak"
68+ fileName = sanitizeBackupFileName(fileName)
66 finalPath := filepath.Join(backupDir, fileName)69 finalPath := filepath.Join(backupDir, fileName)
67 tmpPath := finalPath + ".tmp"70 tmpPath := finalPath + ".tmp"
68 71
69- f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)72+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
70 if err != nil {73 if err != nil {
71 return "", fmt.Errorf("failed to create temp backup file: %w", err)74 return "", fmt.Errorf("failed to create temp backup file: %w", err)
72 }75 }
@@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
118 if walkErr != nil {121 if walkErr != nil {
119 return walkErr122 return walkErr
120 }123 }
121- if d.IsDir() {124+ if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
122 return nil125 return nil
123 }126 }
124 127
@@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
144 147
145 startedAt := info.ModTime().UTC()148 startedAt := info.ModTime().UTC()
146 base := filepath.Base(path)149 base := filepath.Base(path)
147- if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {150+ if ts, ok := parseBackupStartedAt(base); ok {
148 startedAt = ts.UTC()151 startedAt = ts.UTC()
149 }152 }
150 153
@@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch
267 }270 }
268 }271 }
269 272
273+func parseBackupStartedAt(base string) (time.Time, bool) {
274+ if !strings.HasSuffix(base, ".bak") {
275+ return time.Time{}, false
276+ }
277+ if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
278+ return ts, true
279+ }
280+ trimmed := strings.TrimSuffix(base, ".bak")
281+ if idx := strings.LastIndex(trimmed, "-"); idx >= 0 {
282+ trimmed = trimmed[:idx]
283+ }
284+ if ts, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(trimmed, "_", ":")); err == nil {
285+ return ts, true
286+ }
287+ return time.Time{}, false
288+}
289+
290+func randomBackupSuffix() string {
291+ var b [8]byte
292+ if _, err := rand.Read(b[:]); err != nil {
293+ return fmt.Sprintf("%d", time.Now().UTC().UnixNano())
294+ }
295+ return hex.EncodeToString(b[:])
296+}
297+
298+func sanitizeBackupFileName(input string) string {
299+ return strings.NewReplacer(":", "_").Replace(input)
300+}
301+
270 func sanitizeBackupPathComponent(input string) string {302 func sanitizeBackupPathComponent(input string) string {
271 clean := strings.TrimSpace(input)303 clean := strings.TrimSpace(input)
272 if clean == "" {304 if clean == "" {
M internal/adapters/out/filesystem/backup_storage_test.go +34 -1
@@ -31,6 +31,39 @@ func TestBackupStorage_StoreAndGet(t *testing.T) {
31 assert.Equal(t, payload, data)31 assert.Equal(t, payload, data)
32 }32 }
33 33
34+func TestBackupStorage_StoreSameSecondBackupsUseDistinctFinalAndTempPaths(t *testing.T) {
35+ rootDir := t.TempDir()
36+ storage, err := NewBackupStorage(rootDir, testLogger())
37+ require.NoError(t, err)
38+
39+ now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
40+ firstPayload := []byte("first-backup")
41+ secondPayload := []byte("second-backup")
42+
43+ firstPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(firstPayload))
44+ require.NoError(t, err)
45+ secondPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(secondPayload))
46+ require.NoError(t, err)
47+
48+ assert.NotEqual(t, firstPath, secondPath)
49+ assert.NoFileExists(t, firstPath+".tmp")
50+ assert.NoFileExists(t, secondPath+".tmp")
51+
52+ firstRead, err := storage.Get(context.Background(), firstPath)
53+ require.NoError(t, err)
54+ defer firstRead.Close()
55+ firstData, err := io.ReadAll(firstRead)
56+ require.NoError(t, err)
57+ assert.Equal(t, firstPayload, firstData)
58+
59+ secondRead, err := storage.Get(context.Background(), secondPath)
60+ require.NoError(t, err)
61+ defer secondRead.Close()
62+ secondData, err := io.ReadAll(secondRead)
63+ require.NoError(t, err)
64+ assert.Equal(t, secondPayload, secondData)
65+}
66+
34 func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {67 func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {
35 storage, err := NewBackupStorage(t.TempDir(), testLogger())68 storage, err := NewBackupStorage(t.TempDir(), testLogger())
36 require.NoError(t, err)69 require.NoError(t, err)
@@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) {
71 require.NoError(t, err)104 require.NoError(t, err)
72 105
73 base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)106 base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)
74- for i := 0; i < 4; i++ {107+ for i := range 4 {
75 _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))108 _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))
76 require.NoError(t, err)109 require.NoError(t, err)
77 }110 }
M internal/adapters/out/filesystem/blob.go +24 -2
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
18 "github.com/google/uuid"18 "github.com/google/uuid"
19 19
20+ "github.com/bnema/gordon/internal/domain"
20 "github.com/bnema/gordon/pkg/validation"21 "github.com/bnema/gordon/pkg/validation"
21 )22 )
22 23
@@ -254,7 +255,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
254 }255 }
255 256
256 // AppendBlobChunk appends data to an in-progress upload.257 // AppendBlobChunk appends data to an in-progress upload.
257-func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) {258+func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
258 uploadPath, err := s.getUploadPath(uuid)259 uploadPath, err := s.getUploadPath(uuid)
259 if err != nil {260 if err != nil {
260 return 0, fmt.Errorf("invalid upload path: %w", err)261 return 0, fmt.Errorf("invalid upload path: %w", err)
@@ -274,12 +275,33 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
274 }275 }
275 defer file.Close()276 defer file.Close()
276 277
278+ fi, err := file.Stat()
279+ if err != nil {
280+ return 0, fmt.Errorf("failed to get file info: %w", err)
281+ }
282+ originalSize := fi.Size()
283+
284+ if maxBlobSize > 0 {
285+ remaining := maxBlobSize - originalSize
286+ if remaining < 0 || (contentLength >= 0 && contentLength > remaining) {
287+ return 0, domain.ErrBlobSizeExceeded
288+ }
289+ data = io.LimitReader(data, remaining+1)
290+ }
291+
277 written, err := io.Copy(file, data)292 written, err := io.Copy(file, data)
278 if err != nil {293 if err != nil {
279 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)294 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
280 }295 }
281 296
282- fi, err := file.Stat()297+ if maxBlobSize > 0 && originalSize+written > maxBlobSize {
298+ if err := file.Truncate(originalSize); err != nil {
299+ return 0, fmt.Errorf("failed to truncate oversized upload: %w", err)
300+ }
301+ return 0, domain.ErrBlobSizeExceeded
302+ }
303+
304+ fi, err = file.Stat()
283 if err != nil {305 if err != nil {
284 return 0, fmt.Errorf("failed to get file info: %w", err)306 return 0, fmt.Errorf("failed to get file info: %w", err)
285 }307 }
M internal/adapters/out/filesystem/blob_test.go +95 -12
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "crypto/sha256"5 "crypto/sha256"
6 "encoding/hex"6 "encoding/hex"
7+ "errors"
7 "io"8 "io"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
@@ -13,6 +14,8 @@ import (
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
16 )19 )
17 20
18 const (21 const (
@@ -26,6 +29,15 @@ func testLogger() zerowrap.Logger {
26 return zerowrap.Default()29 return zerowrap.Default()
27 }30 }
28 31
32+type readFailer struct {
33+ read bool
34+}
35+
36+func (r *readFailer) Read(_ []byte) (int, error) {
37+ r.read = true
38+ return 0, io.ErrUnexpectedEOF
39+}
40+
29 func TestNewBlobStorage(t *testing.T) {41 func TestNewBlobStorage(t *testing.T) {
30 tmpDir := t.TempDir()42 tmpDir := t.TempDir()
31 log := testLogger()43 log := testLogger()
@@ -280,13 +292,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) {
280 292
281 // Append first chunk293 // Append first chunk
282 chunk1 := []byte("first chunk")294 chunk1 := []byte("first chunk")
283- size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1))295+ size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1), -1, 0)
284 require.NoError(t, err)296 require.NoError(t, err)
285 assert.Equal(t, int64(len(chunk1)), size1)297 assert.Equal(t, int64(len(chunk1)), size1)
286 298
287 // Append second chunk299 // Append second chunk
288 chunk2 := []byte(" second chunk")300 chunk2 := []byte(" second chunk")
289- size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2))301+ size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2), -1, 0)
290 require.NoError(t, err)302 require.NoError(t, err)
291 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)303 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
292 }304 }
@@ -298,12 +310,83 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
298 storage, err := NewBlobStorage(tmpDir, log)310 storage, err := NewBlobStorage(tmpDir, log)
299 require.NoError(t, err)311 require.NoError(t, err)
300 312
301- _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")))313+ _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")), -1, 100)
302 314
303 assert.Error(t, err)315 assert.Error(t, err)
304 assert.Contains(t, err.Error(), "upload not found")316 assert.Contains(t, err.Error(), "upload not found")
305 }317 }
306 318
319+func TestBlobStorage_AppendBlobChunk_MaxBlobSize(t *testing.T) {
320+ tmpDir := t.TempDir()
321+ log := testLogger()
322+
323+ storage, err := NewBlobStorage(tmpDir, log)
324+ require.NoError(t, err)
325+
326+ uuid, err := storage.StartBlobUpload("myapp")
327+ require.NoError(t, err)
328+
329+ size, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
330+ require.NoError(t, err)
331+ assert.Equal(t, int64(5), size)
332+
333+ size, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("67890")), -1, 10)
334+ require.NoError(t, err)
335+ assert.Equal(t, int64(10), size)
336+
337+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("x")), -1, 10)
338+ require.Error(t, err)
339+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
340+
341+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
342+ info, statErr := os.Stat(uploadPath)
343+ require.NoError(t, statErr)
344+ assert.Equal(t, int64(10), info.Size())
345+}
346+
347+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeContentLengthTooLargeDoesNotRead(t *testing.T) {
348+ tmpDir := t.TempDir()
349+ log := testLogger()
350+
351+ storage, err := NewBlobStorage(tmpDir, log)
352+ require.NoError(t, err)
353+
354+ uuid, err := storage.StartBlobUpload("myapp")
355+ require.NoError(t, err)
356+
357+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), 5, 10)
358+ require.NoError(t, err)
359+
360+ reader := &readFailer{}
361+ _, err = storage.AppendBlobChunk("myapp", uuid, reader, 6, 10)
362+ require.Error(t, err)
363+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
364+ assert.False(t, reader.read)
365+}
366+
367+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeOverflowMidReadTruncates(t *testing.T) {
368+ tmpDir := t.TempDir()
369+ log := testLogger()
370+
371+ storage, err := NewBlobStorage(tmpDir, log)
372+ require.NoError(t, err)
373+
374+ uuid, err := storage.StartBlobUpload("myapp")
375+ require.NoError(t, err)
376+
377+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
378+ require.NoError(t, err)
379+
380+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("6789012345")), -1, 10)
381+ require.Error(t, err)
382+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
383+
384+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
385+ info, statErr := os.Stat(uploadPath)
386+ require.NoError(t, statErr)
387+ assert.Equal(t, int64(5), info.Size())
388+}
389+
307 func TestBlobStorage_GetBlobUpload(t *testing.T) {390 func TestBlobStorage_GetBlobUpload(t *testing.T) {
308 tmpDir := t.TempDir()391 tmpDir := t.TempDir()
309 log := testLogger()392 log := testLogger()
@@ -350,7 +433,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) {
350 433
351 // Append data - content that hashes to testDigest1434 // Append data - content that hashes to testDigest1
352 blobData := []byte("complete blob content")435 blobData := []byte("complete blob content")
353- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))436+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
354 require.NoError(t, err)437 require.NoError(t, err)
355 438
356 // Finish upload - use testDigest1 (validation will pass, but hash won't match)439 // Finish upload - use testDigest1 (validation will pass, but hash won't match)
@@ -477,7 +560,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
477 560
478 var totalSize int64561 var totalSize int64
479 for _, chunk := range chunks {562 for _, chunk := range chunks {
480- size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk))563+ size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk), -1, 0)
481 require.NoError(t, err)564 require.NoError(t, err)
482 totalSize = size565 totalSize = size
483 }566 }
@@ -501,7 +584,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
501 require.NoError(t, err)584 require.NoError(t, err)
502 585
503 initialData := []byte("initial-")586 initialData := []byte("initial-")
504- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData), -1, 0)
505 require.NoError(t, err)588 require.NoError(t, err)
506 589
507 appendChunk := []byte("chunk")590 appendChunk := []byte("chunk")
@@ -516,7 +599,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
516 go func() {599 go func() {
517 defer wg.Done()600 defer wg.Done()
518 <-appendReady601 <-appendReady
519- _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))602+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk), -1, 0)
520 appendErrs <- appendErr603 appendErrs <- appendErr
521 }()604 }()
522 }605 }
@@ -570,7 +653,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
570 assert.Equal(t, fullyAppended, blobData)653 assert.Equal(t, fullyAppended, blobData)
571 }654 }
572 655
573- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))656+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")), -1, 0)
574 assert.Error(t, err)657 assert.Error(t, err)
575 assert.Contains(t, err.Error(), "upload not found")658 assert.Contains(t, err.Error(), "upload not found")
576 }659 }
@@ -586,13 +669,13 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
586 require.NoError(t, err)669 require.NoError(t, err)
587 670
588 blobData := []byte("finalized content")671 blobData := []byte("finalized content")
589- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))672+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
590 require.NoError(t, err)673 require.NoError(t, err)
591 674
592 err = storage.FinishBlobUpload(uuid, digestForData(blobData))675 err = storage.FinishBlobUpload(uuid, digestForData(blobData))
593 require.NoError(t, err)676 require.NoError(t, err)
594 677
595- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))678+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")), -1, 0)
596 assert.Error(t, err)679 assert.Error(t, err)
597 assert.Contains(t, err.Error(), "upload not found")680 assert.Contains(t, err.Error(), "upload not found")
598 }681 }
@@ -608,7 +691,7 @@ func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
608 require.NoError(t, err)691 require.NoError(t, err)
609 692
610 blobData := []byte("retryable finalize")693 blobData := []byte("retryable finalize")
611- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))694+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
612 require.NoError(t, err)695 require.NoError(t, err)
613 696
614 err = storage.FinishBlobUpload(uuid, testDigest1)697 err = storage.FinishBlobUpload(uuid, testDigest1)
@@ -638,7 +721,7 @@ func TestBlobStorage_SequentialFlow(t *testing.T) {
638 var blobData []byte721 var blobData []byte
639 for _, chunk := range chunks {722 for _, chunk := range chunks {
640 blobData = append(blobData, chunk...)723 blobData = append(blobData, chunk...)
641- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))724+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk), -1, 0)
642 require.NoError(t, err)725 require.NoError(t, err)
643 }726 }
644 727
M internal/app/migrate_env.go +76 -71
@@ -1,6 +1,7 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
@@ -79,59 +80,46 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log
79 return nil80 return nil
80 }81 }
81 82
82- existingKeys, err := passStore.ListKeys(domainName)83+ writtenKeys, err := passStore.SetIfEmpty(domainName, secrets)
83 if err != nil {84 if err != nil {
84- return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err)85+ if errors.Is(err, domain.ErrSecretsAlreadyExist) {
85- }86+ return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
86-
87- missing := missingSecrets(existingKeys, secrets)
88- if len(missing) > 0 {
89- if err := passStore.Set(domainName, missing); err != nil {
90- return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
91 }87 }
92- }88+ return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
93-89+ }
94- migratedPath := filePath + ".migrated"90+
95- if err := os.Rename(filePath, migratedPath); err != nil {91+ if err := os.Remove(filePath); err != nil {
96- return fmt.Errorf("failed to rename env file %s: %w", filePath, err)92+ // Check if the stored secrets match what we just wrote
93+ storedSecrets, getErr := passStore.GetAll(domainName)
94+ if getErr == nil && secretsMatch(storedSecrets, secrets) {
95+ // Migration was successful, removal failed but secrets are stored
96+ log.Warn().
97+ Str("file", filePath).
98+ Str("domain", domainName).
99+ Msg("secrets migrated successfully but failed to remove original file; treating as success")
100+ return nil
101+ }
102+ // Attempt rollback: remove only the pass entries this migration wrote.
103+ for _, key := range writtenKeys {
104+ if delErr := passStore.Delete(domainName, key); delErr != nil {
105+ log.Error().
106+ Err(delErr).
107+ Str("domain", domainName).
108+ Str("key", key).
109+ Msg("failed to rollback pass entry after file removal failure")
110+ }
111+ }
112+ return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err)
97 }113 }
98 114
99 log.Info().115 log.Info().
100- Int(zerowrap.FieldCount, len(missing)).116+ Int(zerowrap.FieldCount, len(secrets)).
101 Str("domain", domainName).117 Str("domain", domainName).
102- Msg("migrated secrets for domain from plain text to pass")118+ Msg("migrated secrets for domain from plain text to pass and removed original env file")
103- log.Info().
104- Str("file", migratedPath).
105- Msg("original file renamed to .env.migrated - you can safely remove it")
106 119
107 return nil120 return nil
108 }121 }
109 122
110-func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string {
111- if len(secrets) == 0 {
112- return nil
113- }
114-
115- existingSet := make(map[string]struct{}, len(existingKeys))
116- for _, key := range existingKeys {
117- existingSet[key] = struct{}{}
118- }
119-
120- missing := make(map[string]string)
121- for key, value := range secrets {
122- if _, exists := existingSet[key]; exists {
123- continue
124- }
125- missing[key] = value
126- }
127-
128- if len(missing) == 0 {
129- return nil
130- }
131-
132- return missing
133-}
134-
135 func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {123 func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
136 entries, err := readEnvDir(envDir)124 entries, err := readEnvDir(envDir)
137 if err != nil {125 if err != nil {
@@ -180,39 +168,43 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass
180 return nil168 return nil
181 }169 }
182 170
183- existingSecrets, err := passStore.GetAllAttachment(containerName)171+ writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets)
184 if err != nil {172 if err != nil {
185- return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err)173+ if errors.Is(err, domain.ErrSecretsAlreadyExist) {
186- }174+ return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
187-
188- existingKeys := make([]string, 0, len(existingSecrets))
189- for key := range existingSecrets {
190- existingKeys = append(existingKeys, key)
191- }
192-
193- missing := missingSecrets(existingKeys, secrets)
194- if len(missing) > 0 {
195- if err := passStore.SetAttachment(containerName, missing); err != nil {
196- return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
197 }175 }
198- log.Info().176+ return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
199- Int(zerowrap.FieldCount, len(missing)).177+ }
200- Str("container", containerName).178+
201- Msg("migrated secrets for attachment container from plain text to pass")179+ if err := os.Remove(filePath); err != nil {
202- } else {180+ // Check if the stored secrets match what we just wrote
203- log.Info().181+ storedSecrets, getErr := passStore.GetAllAttachment(containerName)
204- Str("container", containerName).182+ if getErr == nil && secretsMatch(storedSecrets, secrets) {
205- Msg("all attachment secrets already exist in pass; no migration needed")183+ // Migration was successful, removal failed but secrets are stored
206- }184+ log.Warn().
207-185+ Str("file", filePath).
208- migratedPath := filePath + ".migrated"186+ Str("container", containerName).
209- if err := os.Rename(filePath, migratedPath); err != nil {187+ Msg("attachment secrets migrated successfully but failed to remove original file; treating as success")
210- return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err)188+ return nil
189+ }
190+ // Attempt rollback: remove only the pass entries this migration wrote.
191+ for _, key := range writtenKeys {
192+ if delErr := passStore.DeleteAttachment(containerName, key); delErr != nil {
193+ log.Error().
194+ Err(delErr).
195+ Str("container", containerName).
196+ Str("key", key).
197+ Msg("failed to rollback pass entry after file removal failure")
198+ }
199+ }
200+ return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err)
211 }201 }
212 202
213 log.Info().203 log.Info().
214- Str("file", migratedPath).204+ Int(zerowrap.FieldCount, len(secrets)).
215- Msg("original attachment file renamed to .env.migrated - you can safely remove it")205+ Str("container", containerName).
206+ Str("file", filePath).
207+ Msg("migrated attachment secrets to pass and removed original env file")
216 208
217 return nil209 return nil
218 }210 }
@@ -221,3 +213,16 @@ func extractContainerNameFromAttachmentFile(filename string) string {
221 name := strings.TrimSuffix(filename, ".env")213 name := strings.TrimSuffix(filename, ".env")
222 return name214 return name
223 }215 }
216+
217+func secretsMatch(a, b map[string]string) bool {
218+ if len(a) != len(b) {
219+ return false
220+ }
221+ for k, v := range a {
222+ bv, ok := b[k]
223+ if !ok || bv != v {
224+ return false
225+ }
226+ }
227+ return true
228+}
M internal/app/migrate_env_test.go +4 -6
@@ -71,12 +71,11 @@ func TestMigrateEnvFile(t *testing.T) {
71 err = migrateEnvFile(tmpDir, envFileName, store, logger)71 err = migrateEnvFile(tmpDir, envFileName, store, logger)
72 require.NoError(t, err)72 require.NoError(t, err)
73 73
74- migratedPath := envFilePath + ".migrated"
75 _, err = os.Stat(envFilePath)74 _, err = os.Stat(envFilePath)
76 assert.True(t, os.IsNotExist(err), "original .env file should be removed")75 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
77 76
78- _, err = os.Stat(migratedPath)77+ _, err = os.Stat(envFilePath + ".migrated")
79- assert.NoError(t, err, ".env.migrated file should exist")78+ assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
80 79
81 passKeys, err := store.ListKeys(domainName)80 passKeys, err := store.ListKeys(domainName)
82 require.NoError(t, err)81 require.NoError(t, err)
@@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
125 err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)124 err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
126 require.NoError(t, err)125 require.NoError(t, err)
127 126
128- migratedPath := envFilePath + ".migrated"
129 _, err = os.Stat(envFilePath)127 _, err = os.Stat(envFilePath)
130 assert.True(t, os.IsNotExist(err), "original .env file should be removed")128 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
131 129
132- _, err = os.Stat(migratedPath)130+ _, err = os.Stat(envFilePath + ".migrated")
133- assert.NoError(t, err, ".env.migrated file should exist")131+ assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
134 132
135 values, err := store.GetAllAttachment(storedContainerName)133 values, err := store.GetAllAttachment(storedContainerName)
136 require.NoError(t, err)134 require.NoError(t, err)
M internal/app/run.go +143 -27
@@ -23,6 +23,7 @@ import (
23 "github.com/bnema/zerowrap"23 "github.com/bnema/zerowrap"
24 zerowrapotel "github.com/bnema/zerowrap/otel"24 zerowrapotel "github.com/bnema/zerowrap/otel"
25 "github.com/spf13/viper"25 "github.com/spf13/viper"
26+ "golang.org/x/sys/unix"
26 27
27 // Adapters - Output28 // Adapters - Output
28 "github.com/bnema/gordon/internal/adapters/out/accesslog"29 "github.com/bnema/gordon/internal/adapters/out/accesslog"
@@ -96,6 +97,7 @@ type Config struct {
96 DataDir string `mapstructure:"data_dir"`97 DataDir string `mapstructure:"data_dir"`
97 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"98 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
98 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"99 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
100+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
99 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
100 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
101 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`103 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
@@ -170,7 +172,9 @@ type Config struct {
170 } `mapstructure:"backups"`172 } `mapstructure:"backups"`
171 173
172 Images struct {174 Images struct {
173- Prune struct {175+ AllowedRegistries []string `mapstructure:"allowed_registries"`
176+ RequireDigest bool `mapstructure:"require_digest"`
177+ Prune struct {
174 Enabled bool `mapstructure:"enabled"`178 Enabled bool `mapstructure:"enabled"`
175 Schedule string `mapstructure:"schedule"`179 Schedule string `mapstructure:"schedule"`
176 KeepLast int `mapstructure:"keep_last"`180 KeepLast int `mapstructure:"keep_last"`
@@ -178,9 +182,10 @@ type Config struct {
178 } `mapstructure:"images"`182 } `mapstructure:"images"`
179 183
180 Containers struct {184 Containers struct {
181- MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"185+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
182- CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core186+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
183- PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512187+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
188+ SecurityProfile string `mapstructure:"security_profile"` // compat or strict
184 } `mapstructure:"containers"`189 } `mapstructure:"containers"`
185 190
186 Telemetry telemetry.Config `mapstructure:"telemetry"`191 Telemetry telemetry.Config `mapstructure:"telemetry"`
@@ -215,9 +220,13 @@ type services struct {
215 previewService *preview.Service220 previewService *preview.Service
216 envDir string221 envDir string
217 maxBlobChunkSize int64222 maxBlobChunkSize int64
223+ maxBlobSize int64
218 caAdapter *pkiadapter.CA224 caAdapter *pkiadapter.CA
219 pkiSvc *pkiusecase.Service225 pkiSvc *pkiusecase.Service
220 reloadCoordinator *reloadCoordinator226 reloadCoordinator *reloadCoordinator
227+ registryHandler interface {
228+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
229+ }
221 }230 }
222 231
223 // Run initializes and starts the Gordon application.232 // Run initializes and starts the Gordon application.
@@ -460,7 +469,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
460 return nil, err469 return nil, err
461 }470 }
462 471
463- si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, si.svc.eventBus, log)472+ si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log)
464 473
465 si.initHandlers()474 si.initHandlers()
466 475
@@ -526,6 +535,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
526 return err535 return err
527 }536 }
528 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize537 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
538+ si.svc.maxBlobSize = proxyCfg.maxBlobSize
529 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)539 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
530 540
531 // Wire synchronous proxy cache invalidation for zero-downtime deployments.541 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
@@ -1333,22 +1343,87 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
1333 provider := secrets.NewSopsProvider(log)1343 provider := secrets.NewSopsProvider(log)
1334 return provider.GetSecret(ctx, path)1344 return provider.GetSecret(ctx, path)
1335 case domain.SecretsBackendUnsafe:1345 case domain.SecretsBackendUnsafe:
1336- // For unsafe backend, path is relative to dataDir/secrets/1346+ // For unsafe backend, path is relative to dataDir/secrets/.
1337- secretFile := filepath.Join(dataDir, "secrets", path)1347+ return readUnsafeSecret(dataDir, path)
1338- data, err := os.ReadFile(secretFile)
1339- if err != nil {
1340- return "", fmt.Errorf("failed to read secret file: %w", err)
1341- }
1342- return string(data), nil
1343 default:1348 default:
1344 return "", fmt.Errorf("unknown secrets backend: %s", backend)1349 return "", fmt.Errorf("unknown secrets backend: %s", backend)
1345 }1350 }
1346 }1351 }
1347 1352
1353+func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
1354+ rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1355+ if err != nil {
1356+ return nil, fmt.Errorf("failed to open secrets root: %w", err)
1357+ }
1358+ defer unix.Close(rootFD)
1359+
1360+ parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/")
1361+ dirFD := rootFD
1362+ var closeDirFDs []int
1363+ defer func() {
1364+ for i := len(closeDirFDs) - 1; i >= 0; i-- {
1365+ _ = unix.Close(closeDirFDs[i])
1366+ }
1367+ }()
1368+
1369+ for i, part := range parts {
1370+ if part == "" || part == "." || part == ".." {
1371+ return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1372+ }
1373+ last := i == len(parts)-1
1374+ if last {
1375+ fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1376+ if err != nil {
1377+ return nil, fmt.Errorf("failed to read secret file: %w", err)
1378+ }
1379+ defer unix.Close(fd)
1380+ var st unix.Stat_t
1381+ if err := unix.Fstat(fd, &st); err != nil {
1382+ return nil, fmt.Errorf("failed to stat secret file: %w", err)
1383+ }
1384+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
1385+ return nil, fmt.Errorf("invalid secret path: secret must be a regular file")
1386+ }
1387+ data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
1388+ if err != nil {
1389+ return nil, fmt.Errorf("failed to read secret file: %w", err)
1390+ }
1391+ return data, nil
1392+ }
1393+
1394+ nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1395+ if err != nil {
1396+ return nil, fmt.Errorf("failed to open secret path component: %w", err)
1397+ }
1398+ closeDirFDs = append(closeDirFDs, nextFD)
1399+ dirFD = nextFD
1400+ }
1401+
1402+ return nil, fmt.Errorf("invalid secret path: empty path")
1403+}
1404+
1405+func readUnsafeSecret(dataDir, secretPath string) (string, error) {
1406+ if filepath.IsAbs(secretPath) {
1407+ return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
1408+ }
1409+ cleaned := filepath.Clean(secretPath)
1410+ if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
1411+ return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1412+ }
1413+
1414+ root := filepath.Clean(filepath.Join(dataDir, "secrets"))
1415+ data, err := readFileBeneath(root, cleaned)
1416+ if err != nil {
1417+ return "", err
1418+ }
1419+ return string(data), nil
1420+}
1421+
1348 // proxyConfigResult holds parsed proxy and blob chunk size config.1422 // proxyConfigResult holds parsed proxy and blob chunk size config.
1349 type proxyConfigResult struct {1423 type proxyConfigResult struct {
1350 proxyConfig proxy.Config1424 proxyConfig proxy.Config
1351 maxBlobChunkSize int641425 maxBlobChunkSize int64
1426+ maxBlobSize int64
1352 }1427 }
1353 1428
1354 type configWatcher interface {1429 type configWatcher interface {
@@ -1376,24 +1451,38 @@ type reloadCoordinator struct {
1376 lastRun time.Time1451 lastRun time.Time
1377 debounce time.Duration1452 debounce time.Duration
1378 1453
1379- configSvc configReloader1454+ configSvc configReloader
1380- v *viper.Viper1455+ v *viper.Viper
1381- proxySvc proxyConfigUpdater1456+ proxySvc proxyConfigUpdater
1382- eventBus out.EventPublisher1457+ registryLimits interface {
1383- log zerowrap.Logger1458+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1459+ }
1460+ eventBus out.EventPublisher
1461+ log zerowrap.Logger
1384 }1462 }
1385 1463
1386-func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {1464+func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
1465+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1466+}, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {
1387 return &reloadCoordinator{1467 return &reloadCoordinator{
1388- debounce: 500 * time.Millisecond,1468+ debounce: 500 * time.Millisecond,
1389- configSvc: configSvc,1469+ configSvc: configSvc,
1390- v: v,1470+ v: v,
1391- proxySvc: proxySvc,1471+ proxySvc: proxySvc,
1392- eventBus: eventBus,1472+ registryLimits: registryLimits,
1393- log: log,1473+ eventBus: eventBus,
1474+ log: log,
1394 }1475 }
1395 }1476 }
1396 1477
1478+func (c *reloadCoordinator) SetRegistryLimits(limits interface {
1479+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1480+}) {
1481+ c.mu.Lock()
1482+ defer c.mu.Unlock()
1483+ c.registryLimits = limits
1484+}
1485+
1397 func (c *reloadCoordinator) Trigger(ctx context.Context) error {1486 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1398 c.mu.Lock()1487 c.mu.Lock()
1399 defer c.mu.Unlock()1488 defer c.mu.Unlock()
@@ -1443,6 +1532,9 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
1443 }1532 }
1444 1533
1445 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)1534 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1535+ if c.registryLimits != nil {
1536+ c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1537+ }
1446 1538
1447 if c.eventBus != nil {1539 if c.eventBus != nil {
1448 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {1540 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
@@ -1477,6 +1569,15 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1477 maxBlobChunkSize = parsedSize1569 maxBlobChunkSize = parsedSize
1478 }1570 }
1479 1571
1572+ maxBlobSize := int64(registry.DefaultMaxBlobSize)
1573+ if cfg.Server.MaxBlobSize != "" {
1574+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize)
1575+ if err != nil {
1576+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize})
1577+ }
1578+ maxBlobSize = parsedSize
1579+ }
1580+
1480 maxProxyResponseSize := int64(1 << 30) // 1GB default1581 maxProxyResponseSize := int64(1 << 30) // 1GB default
1481 if cfg.Server.MaxProxyResponseSize != "" {1582 if cfg.Server.MaxProxyResponseSize != "" {
1482 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)1583 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
@@ -1501,6 +1602,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1501 MaxConcurrentConns: maxConcurrentConns,1602 MaxConcurrentConns: maxConcurrentConns,
1502 },1603 },
1503 maxBlobChunkSize: maxBlobChunkSize,1604 maxBlobChunkSize: maxBlobChunkSize,
1605+ maxBlobSize: maxBlobSize,
1504 }, nil1606 }, nil
1505 }1607 }
1506 1608
@@ -1544,7 +1646,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1544 NetworkIsolation: v.GetBool("network_isolation.enabled"),1646 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1545 NetworkPrefix: v.GetString("network_isolation.network_prefix"),1647 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1546 NetworkGroups: attachmentConfig.NetworkGroups,1648 NetworkGroups: attachmentConfig.NetworkGroups,
1649+ NetworkInternal: v.GetBool("network_isolation.internal"),
1547 Attachments: attachmentConfig.Attachments,1650 Attachments: attachmentConfig.Attachments,
1651+ AllowedRegistries: cfg.Images.AllowedRegistries,
1652+ RequireImageDigest: cfg.Images.RequireDigest,
1653+ SecurityProfile: cfg.Containers.SecurityProfile,
1548 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),1654 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1549 ReadinessMode: v.GetString("deploy.readiness_mode"),1655 ReadinessMode: v.GetString("deploy.readiness_mode"),
1550 HealthTimeout: v.GetDuration("deploy.health_timeout"),1656 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -1758,8 +1864,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
1758 // This ensures consistent IP extraction across logging, rate limiting, and auth.1864 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1759 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)1865 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1760 1866
1761- // Registry handler (unchanged)1867+ // Registry handler
1762- registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)1868+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
1869+ svc.registryHandler = registryHandler
1870+ if svc.reloadCoordinator != nil {
1871+ svc.reloadCoordinator.SetRegistryLimits(registryHandler)
1872+ }
1763 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(1873 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1764 svc,1874 svc,
1765 cfg,1875 cfg,
@@ -1783,7 +1893,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
1783 middleware.PanicRecovery(log),1893 middleware.PanicRecovery(log),
1784 middleware.RequestLogger(log, trustedNets),1894 middleware.RequestLogger(log, trustedNets),
1785 middleware.SecurityHeaders,1895 middleware.SecurityHeaders,
1786- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log),1896+ middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
1897+ return svc.proxySvc.IsKnownHost(context.Background(), host)
1898+ }),
1787 }1899 }
1788 if proxyCIDRMiddleware != nil {1900 if proxyCIDRMiddleware != nil {
1789 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)1901 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -2889,6 +3001,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2889 v.SetDefault("auto_route.enabled", false)3001 v.SetDefault("auto_route.enabled", false)
2890 v.SetDefault("network_isolation.enabled", true)3002 v.SetDefault("network_isolation.enabled", true)
2891 v.SetDefault("network_isolation.network_prefix", "gordon")3003 v.SetDefault("network_isolation.network_prefix", "gordon")
3004+ v.SetDefault("network_isolation.internal", false)
2892 v.SetDefault("volumes.auto_create", true)3005 v.SetDefault("volumes.auto_create", true)
2893 v.SetDefault("volumes.prefix", "gordon")3006 v.SetDefault("volumes.prefix", "gordon")
2894 v.SetDefault("volumes.preserve", true)3007 v.SetDefault("volumes.preserve", true)
@@ -2900,9 +3013,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
2900 v.SetDefault("backups.retention.daily", 0)3013 v.SetDefault("backups.retention.daily", 0)
2901 v.SetDefault("backups.retention.weekly", 0)3014 v.SetDefault("backups.retention.weekly", 0)
2902 v.SetDefault("backups.retention.monthly", 0)3015 v.SetDefault("backups.retention.monthly", 0)
3016+ v.SetDefault("images.allowed_registries", []string{})
3017+ v.SetDefault("images.require_digest", false)
2903 v.SetDefault("images.prune.enabled", false)3018 v.SetDefault("images.prune.enabled", false)
2904 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))3019 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2905 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)3020 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
3021+ v.SetDefault("containers.security_profile", "compat")
2906 v.SetDefault("telemetry.enabled", false)3022 v.SetDefault("telemetry.enabled", false)
2907 v.SetDefault("telemetry.endpoint", "")3023 v.SetDefault("telemetry.endpoint", "")
2908 v.SetDefault("telemetry.auth_token", "")3024 v.SetDefault("telemetry.auth_token", "")
M internal/app/run_auth_config_test.go +85 -1
@@ -2,13 +2,18 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
6+ "path/filepath"
7+ "runtime"
5 "testing"8 "testing"
6 9
7- "github.com/bnema/gordon/internal/domain"
8 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
9 "github.com/spf13/viper"11 "github.com/spf13/viper"
10 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+ "golang.org/x/sys/unix"
15+
16+ "github.com/bnema/gordon/internal/domain"
12 )17 )
13 18
14 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {19 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -65,3 +70,82 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
65 require.NoError(t, err)70 require.NoError(t, err)
66 assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")71 assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")
67 }72 }
73+
74+func TestLoadSecretUnsafeRejectsTraversalAndAbsolutePaths(t *testing.T) {
75+ dataDir := t.TempDir()
76+ secretsDir := filepath.Join(dataDir, "secrets")
77+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
78+ require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600))
79+ outside := filepath.Join(dataDir, "outside")
80+ require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600))
81+
82+ for _, path := range []string{"../outside", filepath.Join(dataDir, "outside")} {
83+ t.Run(path, func(t *testing.T) {
84+ _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, path, dataDir, zerowrap.Default())
85+ require.Error(t, err)
86+ assert.Contains(t, err.Error(), "invalid secret path")
87+ })
88+ }
89+}
90+
91+func TestLoadSecretUnsafeAllowsRelativePathUnderSecretsDir(t *testing.T) {
92+ dataDir := t.TempDir()
93+ secretsDir := filepath.Join(dataDir, "secrets", "auth")
94+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
95+ require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600))
96+
97+ secret, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "auth/token", dataDir, zerowrap.Default())
98+ require.NoError(t, err)
99+ assert.Equal(t, "safe-secret", secret)
100+}
101+
102+func TestLoadSecretUnsafeRejectsMissingCandidate(t *testing.T) {
103+ dataDir := t.TempDir()
104+ secretsDir := filepath.Join(dataDir, "secrets")
105+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
106+
107+ _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "missing", dataDir, zerowrap.Default())
108+ require.Error(t, err)
109+ assert.Contains(t, err.Error(), "failed to read secret file")
110+}
111+
112+func TestLoadSecretUnsafeRejectsIntermediateSymlinkEscape(t *testing.T) {
113+ dataDir := t.TempDir()
114+ secretsDir := filepath.Join(dataDir, "secrets")
115+ outsideDir := filepath.Join(dataDir, "outside")
116+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
117+ require.NoError(t, os.MkdirAll(outsideDir, 0700))
118+ require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "token"), []byte("outside-secret"), 0600))
119+ require.NoError(t, os.Symlink(outsideDir, filepath.Join(secretsDir, "linkdir")))
120+
121+ _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "linkdir/token", dataDir, zerowrap.Default())
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "failed to open secret path component")
124+}
125+
126+func TestLoadSecretUnsafeRejectsNonRegularFile(t *testing.T) {
127+ if runtime.GOOS == "windows" {
128+ t.Skip("unix mkfifo is not available on windows")
129+ }
130+ dataDir := t.TempDir()
131+ secretsDir := filepath.Join(dataDir, "secrets")
132+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
133+ require.NoError(t, unix.Mkfifo(filepath.Join(secretsDir, "token"), 0600))
134+
135+ _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default())
136+ require.Error(t, err)
137+ assert.Contains(t, err.Error(), "secret must be a regular file")
138+}
139+
140+func TestLoadSecretUnsafeRejectsSymlinkEscape(t *testing.T) {
141+ dataDir := t.TempDir()
142+ secretsDir := filepath.Join(dataDir, "secrets")
143+ require.NoError(t, os.MkdirAll(secretsDir, 0700))
144+ outside := filepath.Join(dataDir, "outside")
145+ require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600))
146+ require.NoError(t, os.Symlink(outside, filepath.Join(secretsDir, "token")))
147+
148+ _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default())
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "failed to read secret file")
151+}
M internal/app/run_reload_test.go +46 -5
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ "github.com/bnema/gordon/internal/adapters/in/http/registry"
15 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/proxy"17 "github.com/bnema/gordon/internal/usecase/proxy"
17 )18 )
@@ -99,6 +100,18 @@ func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
99 p.config = config100 p.config = config
100 }101 }
101 102
103+type registryLimitsRecorder struct {
104+ calls int
105+ maxBlobChunkSize int64
106+ maxBlobSize int64
107+}
108+
109+func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
110+ r.calls++
111+ r.maxBlobChunkSize = maxBlobChunkSize
112+ r.maxBlobSize = maxBlobSize
113+}
114+
102 type eventBusRecorder struct {115 type eventBusRecorder struct {
103 mu sync.Mutex116 mu sync.Mutex
104 calls int117 calls int
@@ -138,6 +151,24 @@ func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
138 require.Equal(t, 0, coordinator.TriggerCalls())151 require.Equal(t, 0, coordinator.TriggerCalls())
139 }152 }
140 153
154+func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) {
155+ cfg := Config{}
156+ cfg.Server.MaxBlobSize = "2GB"
157+
158+ result, err := buildProxyConfig(cfg, zerowrap.Default())
159+
160+ require.NoError(t, err)
161+ assert.Equal(t, int64(2<<30), result.maxBlobSize)
162+ assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize)
163+}
164+
165+func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) {
166+ result, err := buildProxyConfig(Config{}, zerowrap.Default())
167+
168+ require.NoError(t, err)
169+ assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize)
170+}
171+
141 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {172 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
142 ctx := context.Background()173 ctx := context.Background()
143 v := viper.New()174 v := viper.New()
@@ -145,6 +176,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
145 v.Set("server.registry_port", 5000)176 v.Set("server.registry_port", 5000)
146 v.Set("server.max_proxy_body_size", "5MB")177 v.Set("server.max_proxy_body_size", "5MB")
147 v.Set("server.max_blob_chunk_size", "6MB")178 v.Set("server.max_blob_chunk_size", "6MB")
179+ v.Set("server.max_blob_size", "8MB")
148 v.Set("server.max_proxy_response_size", "7MB")180 v.Set("server.max_proxy_response_size", "7MB")
149 v.Set("server.max_concurrent_connections", 99)181 v.Set("server.max_concurrent_connections", 99)
150 182
@@ -152,12 +184,16 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
152 proxySvc := &proxyRecorder{}184 proxySvc := &proxyRecorder{}
153 events := &eventBusRecorder{}185 events := &eventBusRecorder{}
154 186
155- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())187+ registryLimits := &registryLimitsRecorder{}
188+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
156 require.NoError(t, coord.ApplyLoadedConfig(ctx))189 require.NoError(t, coord.ApplyLoadedConfig(ctx))
157 190
158 require.Equal(t, 0, reloadSvc.Calls())191 require.Equal(t, 0, reloadSvc.Calls())
159 require.Equal(t, 1, proxySvc.calls)192 require.Equal(t, 1, proxySvc.calls)
160 require.Equal(t, 1, events.Calls())193 require.Equal(t, 1, events.Calls())
194+ require.Equal(t, 1, registryLimits.calls)
195+ require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
196+ require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
161 require.Equal(t, domain.EventConfigReload, events.eventType)197 require.Equal(t, domain.EventConfigReload, events.eventType)
162 require.Equal(t, proxy.Config{198 require.Equal(t, proxy.Config{
163 RegistryDomain: "reload.example.com",199 RegistryDomain: "reload.example.com",
@@ -175,6 +211,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
175 v.Set("server.registry_port", 5000)211 v.Set("server.registry_port", 5000)
176 v.Set("server.max_proxy_body_size", "5MB")212 v.Set("server.max_proxy_body_size", "5MB")
177 v.Set("server.max_blob_chunk_size", "6MB")213 v.Set("server.max_blob_chunk_size", "6MB")
214+ v.Set("server.max_blob_size", "8MB")
178 v.Set("server.max_proxy_response_size", "7MB")215 v.Set("server.max_proxy_response_size", "7MB")
179 v.Set("server.max_concurrent_connections", 99)216 v.Set("server.max_concurrent_connections", 99)
180 217
@@ -182,13 +219,17 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
182 proxySvc := &proxyRecorder{}219 proxySvc := &proxyRecorder{}
183 events := &eventBusRecorder{}220 events := &eventBusRecorder{}
184 221
185- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())222+ registryLimits := &registryLimitsRecorder{}
223+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
186 require.NoError(t, coord.Trigger(ctx))224 require.NoError(t, coord.Trigger(ctx))
187 require.NoError(t, coord.Trigger(ctx))225 require.NoError(t, coord.Trigger(ctx))
188 226
189 require.Equal(t, 1, reloadSvc.Calls())227 require.Equal(t, 1, reloadSvc.Calls())
190 require.Equal(t, 1, proxySvc.calls)228 require.Equal(t, 1, proxySvc.calls)
191 require.Equal(t, 1, events.Calls())229 require.Equal(t, 1, events.Calls())
230+ require.Equal(t, 1, registryLimits.calls)
231+ require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
232+ require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
192 require.Equal(t, domain.EventConfigReload, events.eventType)233 require.Equal(t, domain.EventConfigReload, events.eventType)
193 require.Equal(t, proxy.Config{234 require.Equal(t, proxy.Config{
194 RegistryDomain: "new.example.com",235 RegistryDomain: "new.example.com",
@@ -212,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
212 reloadErr := errors.New("reload failed")253 reloadErr := errors.New("reload failed")
213 reloadSvc := &reloadRecorder{err: reloadErr}254 reloadSvc := &reloadRecorder{err: reloadErr}
214 proxySvc := &proxyRecorder{}255 proxySvc := &proxyRecorder{}
215- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())256+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
216 257
217 err := coord.Trigger(ctx)258 err := coord.Trigger(ctx)
218 require.ErrorIs(t, err, reloadErr)259 require.ErrorIs(t, err, reloadErr)
@@ -241,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
241 proxySvc := &proxyRecorder{}282 proxySvc := &proxyRecorder{}
242 events := &eventBusRecorder{err: publishErr}283 events := &eventBusRecorder{err: publishErr}
243 284
244- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())285+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default())
245 286
246 err := coord.Trigger(ctx)287 err := coord.Trigger(ctx)
247 require.ErrorIs(t, err, publishErr)288 require.ErrorIs(t, err, publishErr)
@@ -273,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
273 <-release314 <-release
274 }}315 }}
275 proxySvc := &proxyRecorder{}316 proxySvc := &proxyRecorder{}
276- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())317+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
277 318
278 firstDone := make(chan struct{})319 firstDone := make(chan struct{})
279 go func() {320 go func() {
M internal/boundaries/in/mocks/mock_proxy_service.go +57 -0
@@ -107,6 +107,63 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con
107 return _c107 return _c
108 }108 }
109 109
110+// IsKnownHost provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) IsKnownHost(ctx context.Context, host string) bool {
112+ ret := _mock.Called(ctx, host)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for IsKnownHost")
116+ }
117+
118+ var r0 bool
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
120+ r0 = returnFunc(ctx, host)
121+ } else {
122+ r0 = ret.Get(0).(bool)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_IsKnownHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsKnownHost'
128+type MockProxyService_IsKnownHost_Call struct {
129+ *mock.Call
130+}
131+
132+// IsKnownHost is a helper method to define mock.On call
133+// - ctx context.Context
134+// - host string
135+func (_e *MockProxyService_Expecter) IsKnownHost(ctx interface{}, host interface{}) *MockProxyService_IsKnownHost_Call {
136+ return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}
137+}
138+
139+func (_c *MockProxyService_IsKnownHost_Call) Run(run func(ctx context.Context, host string)) *MockProxyService_IsKnownHost_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ var arg1 string
146+ if args[1] != nil {
147+ arg1 = args[1].(string)
148+ }
149+ run(
150+ arg0,
151+ arg1,
152+ )
153+ })
154+ return _c
155+}
156+
157+func (_c *MockProxyService_IsKnownHost_Call) Return(b bool) *MockProxyService_IsKnownHost_Call {
158+ _c.Call.Return(b)
159+ return _c
160+}
161+
162+func (_c *MockProxyService_IsKnownHost_Call) RunAndReturn(run func(ctx context.Context, host string) bool) *MockProxyService_IsKnownHost_Call {
163+ _c.Call.Return(run)
164+ return _c
165+}
166+
110 // IsRegistryDomain provides a mock function for the type MockProxyService167 // IsRegistryDomain provides a mock function for the type MockProxyService
111 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {168 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112 ret := _mock.Called(host)169 ret := _mock.Called(host)
M internal/boundaries/in/mocks/mock_registry_service.go +24 -12
@@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
40 }40 }
41 41
42 // AppendBlobChunk provides a mock function for the type MockRegistryService42 // AppendBlobChunk provides a mock function for the type MockRegistryService
43-func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) {43+func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
44- ret := _mock.Called(ctx, name, uuid, data)44+ ret := _mock.Called(ctx, name, uuid, data, contentLength, maxBlobSize)
45 45
46 if len(ret) == 0 {46 if len(ret) == 0 {
47 panic("no return value specified for AppendBlobChunk")47 panic("no return value specified for AppendBlobChunk")
@@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri
49 49
50 var r0 int6450 var r0 int64
51 var r1 error51 var r1 error
52- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok {52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) (int64, error)); ok {
53- return returnFunc(ctx, name, uuid, data)53+ return returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
54 }54 }
55- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok {55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) int64); ok {
56- r0 = returnFunc(ctx, name, uuid, data)56+ r0 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
57 } else {57 } else {
58 r0 = ret.Get(0).(int64)58 r0 = ret.Get(0).(int64)
59 }59 }
60- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok {60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader, int64, int64) error); ok {
61- r1 = returnFunc(ctx, name, uuid, data)61+ r1 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
62 } else {62 } else {
63 r1 = ret.Error(1)63 r1 = ret.Error(1)
64 }64 }
@@ -75,11 +75,13 @@ type MockRegistryService_AppendBlobChunk_Call struct {
75 // - name string75 // - name string
76 // - uuid string76 // - uuid string
77 // - data io.Reader77 // - data io.Reader
78-func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call {78+// - contentLength int64
79- return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)}79+// - maxBlobSize int64
80+func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockRegistryService_AppendBlobChunk_Call {
81+ return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}
80 }82 }
81 83
82-func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call {84+func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockRegistryService_AppendBlobChunk_Call {
83 _c.Call.Run(func(args mock.Arguments) {85 _c.Call.Run(func(args mock.Arguments) {
84 var arg0 context.Context86 var arg0 context.Context
85 if args[0] != nil {87 if args[0] != nil {
@@ -97,11 +99,21 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con
97 if args[3] != nil {99 if args[3] != nil {
98 arg3 = args[3].(io.Reader)100 arg3 = args[3].(io.Reader)
99 }101 }
102+ var arg4 int64
103+ if args[4] != nil {
104+ arg4 = args[4].(int64)
105+ }
106+ var arg5 int64
107+ if args[5] != nil {
108+ arg5 = args[5].(int64)
109+ }
100 run(110 run(
101 arg0,111 arg0,
102 arg1,112 arg1,
103 arg2,113 arg2,
104 arg3,114 arg3,
115+ arg4,
116+ arg5,
105 )117 )
106 })118 })
107 return _c119 return _c
@@ -112,7 +124,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *
112 return _c124 return _c
113 }125 }
114 126
115-func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {127+func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {
116 _c.Call.Return(run)128 _c.Call.Return(run)
117 return _c129 return _c
118 }130 }
M internal/boundaries/in/proxy.go +3 -0
@@ -24,6 +24,9 @@ type ProxyService interface {
24 // IsRegistryDomain returns true if the host matches the configured registry domain.24 // IsRegistryDomain returns true if the host matches the configured registry domain.
25 IsRegistryDomain(host string) bool25 IsRegistryDomain(host string) bool
26 26
27+ // IsKnownHost returns true if the host is configured as registry, route, or external route.
28+ IsKnownHost(ctx context.Context, host string) bool
29+
27 // TrackInFlight records an in-flight request for a container.30 // TrackInFlight records an in-flight request for a container.
28 // Returns a release function that must be called when the request completes.31 // Returns a release function that must be called when the request completes.
29 TrackInFlight(containerID string) func()32 TrackInFlight(containerID string) func()
M internal/boundaries/in/registry.go +1 -1
@@ -22,7 +22,7 @@ type RegistryService interface {
22 22
23 // Upload operations23 // Upload operations
24 StartUpload(ctx context.Context, name string) (string, error)24 StartUpload(ctx context.Context, name string) (string, error)
25- AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error)25+ AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
26 FinishUpload(ctx context.Context, uuid, digest string) error26 FinishUpload(ctx context.Context, uuid, digest string) error
27 CancelUpload(ctx context.Context, uuid string) error27 CancelUpload(ctx context.Context, uuid string) error
28 28
M internal/boundaries/out/mocks/mock_blob_storage.go +24 -12
@@ -39,8 +39,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
39 }39 }
40 40
41 // AppendBlobChunk provides a mock function for the type MockBlobStorage41 // AppendBlobChunk provides a mock function for the type MockBlobStorage
42-func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) {42+func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
43- ret := _mock.Called(name, uuid, data)43+ ret := _mock.Called(name, uuid, data, contentLength, maxBlobSize)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for AppendBlobChunk")46 panic("no return value specified for AppendBlobChunk")
@@ -48,16 +48,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.
48 48
49 var r0 int6449 var r0 int64
50 var r1 error50 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok {51+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) (int64, error)); ok {
52- return returnFunc(name, uuid, data)52+ return returnFunc(name, uuid, data, contentLength, maxBlobSize)
53 }53 }
54- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok {54+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) int64); ok {
55- r0 = returnFunc(name, uuid, data)55+ r0 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
56 } else {56 } else {
57 r0 = ret.Get(0).(int64)57 r0 = ret.Get(0).(int64)
58 }58 }
59- if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok {59+ if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader, int64, int64) error); ok {
60- r1 = returnFunc(name, uuid, data)60+ r1 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
61 } else {61 } else {
62 r1 = ret.Error(1)62 r1 = ret.Error(1)
63 }63 }
@@ -73,11 +73,13 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
73 // - name string73 // - name string
74 // - uuid string74 // - uuid string
75 // - data io.Reader75 // - data io.Reader
76-func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call {76+// - contentLength int64
77- return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)}77+// - maxBlobSize int64
78+func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockBlobStorage_AppendBlobChunk_Call {
79+ return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}
78 }80 }
79 81
80-func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call {82+func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockBlobStorage_AppendBlobChunk_Call {
81 _c.Call.Run(func(args mock.Arguments) {83 _c.Call.Run(func(args mock.Arguments) {
82 var arg0 string84 var arg0 string
83 if args[0] != nil {85 if args[0] != nil {
@@ -91,10 +93,20 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s
91 if args[2] != nil {93 if args[2] != nil {
92 arg2 = args[2].(io.Reader)94 arg2 = args[2].(io.Reader)
93 }95 }
96+ var arg3 int64
97+ if args[3] != nil {
98+ arg3 = args[3].(int64)
99+ }
100+ var arg4 int64
101+ if args[4] != nil {
102+ arg4 = args[4].(int64)
103+ }
94 run(104 run(
95 arg0,105 arg0,
96 arg1,106 arg1,
97 arg2,107 arg2,
108+ arg3,
109+ arg4,
98 )110 )
99 })111 })
100 return _c112 return _c
@@ -105,7 +117,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock
105 return _c117 return _c
106 }118 }
107 119
108-func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {120+func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {
109 _c.Call.Return(run)121 _c.Call.Return(run)
110 return _c122 return _c
111 }123 }
M internal/boundaries/out/mocks/mock_container_runtime.go +11 -11
@@ -246,16 +246,16 @@ func (_c *MockContainerRuntime_CreateContainer_Call) RunAndReturn(run func(ctx c
246 }246 }
247 247
248 // CreateNetwork provides a mock function for the type MockContainerRuntime248 // CreateNetwork provides a mock function for the type MockContainerRuntime
249-func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {249+func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
250- ret := _mock.Called(ctx, name, options)250+ ret := _mock.Called(ctx, name, config)
251 251
252 if len(ret) == 0 {252 if len(ret) == 0 {
253 panic("no return value specified for CreateNetwork")253 panic("no return value specified for CreateNetwork")
254 }254 }
255 255
256 var r0 error256 var r0 error
257- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {257+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.NetworkConfig) error); ok {
258- r0 = returnFunc(ctx, name, options)258+ r0 = returnFunc(ctx, name, config)
259 } else {259 } else {
260 r0 = ret.Error(0)260 r0 = ret.Error(0)
261 }261 }
@@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct {
270 // CreateNetwork is a helper method to define mock.On call270 // CreateNetwork is a helper method to define mock.On call
271 // - ctx context.Context271 // - ctx context.Context
272 // - name string272 // - name string
273-// - options map[string]string273+// - config domain.NetworkConfig
274-func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, options interface{}) *MockContainerRuntime_CreateNetwork_Call {274+func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, config interface{}) *MockContainerRuntime_CreateNetwork_Call {
275- return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, options)}275+ return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}
276 }276 }
277 277
278-func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, options map[string]string)) *MockContainerRuntime_CreateNetwork_Call {278+func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, config domain.NetworkConfig)) *MockContainerRuntime_CreateNetwork_Call {
279 _c.Call.Run(func(args mock.Arguments) {279 _c.Call.Run(func(args mock.Arguments) {
280 var arg0 context.Context280 var arg0 context.Context
281 if args[0] != nil {281 if args[0] != nil {
@@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont
285 if args[1] != nil {285 if args[1] != nil {
286 arg1 = args[1].(string)286 arg1 = args[1].(string)
287 }287 }
288- var arg2 map[string]string288+ var arg2 domain.NetworkConfig
289 if args[2] != nil {289 if args[2] != nil {
290- arg2 = args[2].(map[string]string)290+ arg2 = args[2].(domain.NetworkConfig)
291 }291 }
292 run(292 run(
293 arg0,293 arg0,
@@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai
303 return _c303 return _c
304 }304 }
305 305
306-func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, options map[string]string) error) *MockContainerRuntime_CreateNetwork_Call {306+func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, config domain.NetworkConfig) error) *MockContainerRuntime_CreateNetwork_Call {
307 _c.Call.Return(run)307 _c.Call.Return(run)
308 return _c308 return _c
309 }309 }
M internal/boundaries/out/runtime.go +1 -1
@@ -70,7 +70,7 @@ type ContainerRuntime interface {
70 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)70 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
71 71
72 // Network management72 // Network management
73- CreateNetwork(ctx context.Context, name string, options map[string]string) error73+ CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error
74 RemoveNetwork(ctx context.Context, name string) error74 RemoveNetwork(ctx context.Context, name string) error
75 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)75 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
76 NetworkExists(ctx context.Context, name string) (bool, error)76 NetworkExists(ctx context.Context, name string) (bool, error)
M internal/boundaries/out/storage.go +1 -1
@@ -30,7 +30,7 @@ type BlobStorage interface {
30 StartBlobUpload(name string) (string, error)30 StartBlobUpload(name string) (string, error)
31 31
32 // AppendBlobChunk appends data to an in-progress upload.32 // AppendBlobChunk appends data to an in-progress upload.
33- AppendBlobChunk(name, uuid string, data io.Reader) (int64, error)33+ AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
34 34
35 // GetBlobUpload returns a writer for the upload.35 // GetBlobUpload returns a writer for the upload.
36 GetBlobUpload(uuid string) (io.WriteCloser, error)36 GetBlobUpload(uuid string) (io.WriteCloser, error)
M internal/domain/auth.go +13 -1
@@ -79,6 +79,8 @@ const (
79 AdminResourceSecrets = "secrets"79 AdminResourceSecrets = "secrets"
80 AdminResourceConfig = "config"80 AdminResourceConfig = "config"
81 AdminResourceStatus = "status"81 AdminResourceStatus = "status"
82+ AdminResourceLogs = "logs"
83+ AdminResourceVolumes = "volumes"
82 AdminResourceAll = "*"84 AdminResourceAll = "*"
83 )85 )
84 86
@@ -166,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool {
166 // AdminScope represents an admin API scope (admin:resource:actions).168 // AdminScope represents an admin API scope (admin:resource:actions).
167 // Format: admin:routes:read,write or admin:*:* for full access.169 // Format: admin:routes:read,write or admin:*:* for full access.
168 type AdminScope struct {170 type AdminScope struct {
169- Resource string // routes, secrets, config, status, or *171+ Resource string // routes, secrets, config, status, logs, volumes, or *
170 Actions []string // read, write, or *172 Actions []string // read, write, or *
171 }173 }
172 174
@@ -302,6 +304,16 @@ func AdminScopeStatus(actions ...string) string {
302 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))304 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
303 }305 }
304 306
307+// AdminScopeLogs creates an admin scope for logs with the given actions.
308+func AdminScopeLogs(actions ...string) string {
309+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ","))
310+}
311+
312+// AdminScopeVolumes creates an admin scope for volumes with the given actions.
313+func AdminScopeVolumes(actions ...string) string {
314+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceVolumes, strings.Join(actions, ","))
315+}
316+
305 // AuthStatus represents status of an authentication session.317 // AuthStatus represents status of an authentication session.
306 type AuthStatus struct {318 type AuthStatus struct {
307 Valid bool319 Valid bool
M internal/domain/auth_test.go +28 -0
@@ -491,6 +491,34 @@ func TestScopesGrantAdminAccess(t *testing.T) {
491 action: "write",491 action: "write",
492 want: true,492 want: true,
493 },493 },
494+ {
495+ name: "logs read grants logs resource",
496+ granted: []string{"admin:logs:read"},
497+ resource: AdminResourceLogs,
498+ action: AdminActionRead,
499+ want: true,
500+ },
501+ {
502+ name: "status read does not grant logs resource",
503+ granted: []string{"admin:status:read"},
504+ resource: AdminResourceLogs,
505+ action: AdminActionRead,
506+ want: false,
507+ },
508+ {
509+ name: "full admin wildcard grants logs resource",
510+ granted: []string{"admin:*:*"},
511+ resource: AdminResourceLogs,
512+ action: AdminActionRead,
513+ want: true,
514+ },
515+ {
516+ name: "volumes read grants volumes resource",
517+ granted: []string{"admin:volumes:read"},
518+ resource: AdminResourceVolumes,
519+ action: AdminActionRead,
520+ want: true,
521+ },
494 {522 {
495 name: "repository scope is ignored for admin access",523 name: "repository scope is ignored for admin access",
496 granted: []string{"repository:myrepo:push"},524 granted: []string{"repository:myrepo:push"},
M internal/domain/container.go +11 -0
@@ -26,6 +26,13 @@ type NetworkInfo struct {
26 Labels map[string]string26 Labels map[string]string
27 }27 }
28 28
29+// NetworkConfig holds options for creating a container network.
30+type NetworkConfig struct {
31+ Driver string
32+ Internal bool
33+ Labels map[string]string
34+}
35+
29 // Attachment represents an attached service container.36 // Attachment represents an attached service container.
30 type Attachment struct {37 type Attachment struct {
31 Name string38 Name string
@@ -64,6 +71,10 @@ type ContainerConfig struct {
64 MemoryLimit int64 // Memory limit in bytes (0 = no limit)71 MemoryLimit int64 // Memory limit in bytes (0 = no limit)
65 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)72 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
66 PidsLimit int64 // Max number of PIDs (0 = no limit)73 PidsLimit int64 // Max number of PIDs (0 = no limit)
74+ ReadOnlyRootFS bool // Mount container root filesystem read-only
75+ User string // User to run as
76+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
77+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
67 }78 }
68 79
69 // ContainerStatus represents the current state of a container.80 // ContainerStatus represents the current state of a container.
M internal/domain/errors.go +3 -0
@@ -34,6 +34,7 @@ var (
34 ErrInvalidDigest = errors.New("invalid digest")34 ErrInvalidDigest = errors.New("invalid digest")
35 ErrDigestMismatch = errors.New("digest mismatch")35 ErrDigestMismatch = errors.New("digest mismatch")
36 ErrUnauthorized = errors.New("unauthorized")36 ErrUnauthorized = errors.New("unauthorized")
37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
37 38
38 // Network errors39 // Network errors
39 ErrNetworkNotFound = errors.New("network not found")40 ErrNetworkNotFound = errors.New("network not found")
@@ -51,10 +52,12 @@ var (
51 ErrInvalidConfig = errors.New("invalid configuration")52 ErrInvalidConfig = errors.New("invalid configuration")
52 ErrConfigLoadFailed = errors.New("failed to load configuration")53 ErrConfigLoadFailed = errors.New("failed to load configuration")
53 ErrInvalidDomainPattern = errors.New("invalid domain pattern")54 ErrInvalidDomainPattern = errors.New("invalid domain pattern")
55+ ErrRouteConflict = errors.New("route conflicts with existing configuration")
54 56
55 // Environment errors57 // Environment errors
56 ErrEnvFileNotFound = errors.New("environment file not found")58 ErrEnvFileNotFound = errors.New("environment file not found")
57 ErrSecretNotFound = errors.New("secret not found")59 ErrSecretNotFound = errors.New("secret not found")
60+ ErrSecretsAlreadyExist = errors.New("secrets already exist")
58 ErrProviderNotFound = errors.New("secret provider not found")61 ErrProviderNotFound = errors.New("secret provider not found")
59 ErrInvalidContainerName = errors.New("invalid container name")62 ErrInvalidContainerName = errors.New("invalid container name")
60 63
A internal/domain/redact.go +46 -0
@@ -0,0 +1,46 @@
1+package domain
2+
3+import "regexp"
4+
5+type secretRedactionPattern struct {
6+ re *regexp.Regexp
7+ repl string
8+}
9+
10+const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential.
11+
12+var secretValuePatterns = []secretRedactionPattern{
13+ {
14+ re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)`),
15+ repl: `${1}[REDACTED]`,
16+ },
17+ {
18+ re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")((?:\\.|[^"])*)(")`),
19+ repl: `${1}[REDACTED]${3}`,
20+ },
21+ {
22+ re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`),
23+ repl: `${1}[REDACTED]`,
24+ },
25+}
26+
27+// RedactSecrets redacts common secret values from a single log or diagnostic line.
28+func RedactSecrets(line string) string {
29+ redacted := line
30+ for _, pattern := range secretValuePatterns {
31+ redacted = pattern.re.ReplaceAllString(redacted, pattern.repl)
32+ }
33+ return redacted
34+}
35+
36+// RedactSecretLines redacts common secret values from log or diagnostic lines.
37+func RedactSecretLines(lines []string) []string {
38+ if lines == nil {
39+ return nil
40+ }
41+ redacted := make([]string, len(lines))
42+ for i, line := range lines {
43+ redacted[i] = RedactSecrets(line)
44+ }
45+ return redacted
46+}
A internal/domain/redact_test.go +45 -0
@@ -0,0 +1,45 @@
1+package domain
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRedactSecrets(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ want string
14+ }{
15+ {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"},
16+ {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"},
17+ {"secret env", "SECRET=shh", "SECRET=[REDACTED]"},
18+ {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"},
19+ {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"},
20+ {"quoted password env with spaces", `PASSWORD="hunter 2"`, "PASSWORD=[REDACTED]"},
21+ {"single quoted token env with spaces", `TOKEN='abc 123'`, "TOKEN=[REDACTED]"},
22+ {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`},
23+ {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`},
24+ {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"},
25+ {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"},
26+ {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"},
27+ {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"},
28+ {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"},
29+ {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"},
30+ {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`},
31+ {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ assert.Equal(t, tt.want, RedactSecrets(tt.in))
37+ })
38+ }
39+}
40+
41+func TestRedactSecretLinesPreservesEmptySlice(t *testing.T) {
42+ assert.Nil(t, RedactSecretLines(nil))
43+ assert.Empty(t, RedactSecretLines([]string{}))
44+ assert.NotNil(t, RedactSecretLines([]string{}))
45+}
M internal/domain/validation.go +8 -0
@@ -17,6 +17,14 @@ var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
17 //17 //
18 // Valid: app.example.com, api.site.org, my-app.co.uk18 // Valid: app.example.com, api.site.org, my-app.co.uk
19 // Invalid: 192.168.1.1, localhost, myapp.local, example.com:808019 // Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080
20+func CanonicalRouteDomain(domainName string) (string, bool) {
21+ canonical := strings.ToLower(strings.TrimSpace(domainName))
22+ if !IsValidRouteDomain(canonical) {
23+ return "", false
24+ }
25+ return canonical, true
26+}
27+
20 func IsValidRouteDomain(domain string) bool {28 func IsValidRouteDomain(domain string) bool {
21 // Reject empty29 // Reject empty
22 if domain == "" {30 if domain == "" {
M internal/usecase/backup/integration_test.go +4 -1
@@ -47,7 +47,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
47 require.NoError(t, runtime.PullImage(pullCtx, image))47 require.NoError(t, runtime.PullImage(pullCtx, image))
48 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)48 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
49 defer cancelNet()49 defer cancelNet()
50- require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"}))50+ require.NoError(t, runtime.CreateNetwork(netCtx, networkName, domain.NetworkConfig{Driver: "bridge"}))
51 t.Cleanup(func() {51 t.Cleanup(func() {
52 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)52 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
53 defer cancelCleanup()53 defer cancelCleanup()
@@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error {
239 return nil239 return nil
240 }240 }
241 241
242+func (s *integrationContainerService) UpdateAttachments(map[string][]string) {
243+}
244+
242 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {245 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243 return nil246 return nil
244 }247 }
M internal/usecase/config/service.go +73 -11
@@ -95,7 +95,16 @@ func (s *Service) Load(ctx context.Context) error {
95 return log.WrapErr(err, "failed to load routes")95 return log.WrapErr(err, "failed to load routes")
96 }96 }
97 newConfig.Routes = routes97 newConfig.Routes = routes
98- newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))98+ externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes"))
99+ if err != nil {
100+ return log.WrapErr(err, "failed to load external routes")
101+ }
102+ for domainName := range externalRoutes {
103+ if _, exists := routes[domainName]; exists {
104+ return fmt.Errorf("external route %q conflicts with configured route", domainName)
105+ }
106+ }
107+ newConfig.ExternalRoutes = externalRoutes
99 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))108 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))109 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
101 110
@@ -208,6 +217,33 @@ func loadStringMap(raw any) map[string]string {
208 return result217 return result
209 }218 }
210 219
220+// loadExternalRoutes loads and canonicalizes external route mappings.
221+func loadExternalRoutes(raw any) (map[string]string, error) {
222+ result := make(map[string]string)
223+ if raw == nil {
224+ return result, nil
225+ }
226+ m, ok := raw.(map[string]any)
227+ if !ok {
228+ return result, nil
229+ }
230+ for k, v := range m {
231+ vs, ok := v.(string)
232+ if !ok {
233+ continue
234+ }
235+ canonicalKey, ok := domain.CanonicalRouteDomain(k)
236+ if !ok {
237+ return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid)
238+ }
239+ if _, exists := result[canonicalKey]; exists {
240+ return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey)
241+ }
242+ result[canonicalKey] = vs
243+ }
244+ return result, nil
245+}
246+
211 // loadStringArrayMap loads a map[string][]string from a viper value.247 // loadStringArrayMap loads a map[string][]string from a viper value.
212 func loadStringArrayMap(raw any) map[string][]string {248 func loadStringArrayMap(raw any) map[string][]string {
213 result := make(map[string][]string)249 result := make(map[string][]string)
@@ -252,7 +288,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
252 if err != nil {288 if err != nil {
253 return nil, err289 return nil, err
254 }290 }
255- result[key] = route291+ canonicalKey, _ := domain.CanonicalRouteDomain(key)
292+ if _, exists := result[canonicalKey]; exists {
293+ return nil, fmt.Errorf("duplicate route key %q canonicalizes to %q", key, canonicalKey)
294+ }
295+ result[canonicalKey] = route
256 }296 }
257 297
258 for key, value := range routes {298 for key, value := range routes {
@@ -261,7 +301,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
261 }301 }
262 302
263 domainName := strings.TrimPrefix(key, "http://")303 domainName := strings.TrimPrefix(key, "http://")
264- if _, exists := result[domainName]; exists {304+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
305+ if !ok {
306+ return nil, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
307+ }
308+ if _, exists := result[canonicalDomain]; exists {
265 continue309 continue
266 }310 }
267 311
@@ -269,16 +313,18 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
269 if err != nil {313 if err != nil {
270 return nil, err314 return nil, err
271 }315 }
272- result[domainName] = route316+ result[canonicalDomain] = route
273 }317 }
274 318
275 return result, nil319 return result, nil
276 }320 }
277 321
278 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {322 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279- if !domain.IsValidRouteDomain(domainName) {323+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
324+ if !ok {
280 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)325 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281 }326 }
327+ domainName = canonicalDomain
282 328
283 switch value := raw.(type) {329 switch value := raw.(type) {
284 case string:330 case string:
@@ -294,9 +340,11 @@ func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
294 }340 }
295 341
296 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {342 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297- if !domain.IsValidRouteDomain(domainName) {343+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
344+ if !ok {
298 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)345 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299 }346 }
347+ domainName = canonicalDomain
300 348
301 value, ok := raw.(string)349 value, ok := raw.(string)
302 if !ok {350 if !ok {
@@ -348,6 +396,10 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
348 s.mu.RLock()396 s.mu.RLock()
349 defer s.mu.RUnlock()397 defer s.mu.RUnlock()
350 398
399+ domainName, ok := domain.CanonicalRouteDomain(domainName)
400+ if !ok {
401+ return nil, domain.ErrRouteNotFound
402+ }
351 routeCfg, ok := s.config.Routes[domainName]403 routeCfg, ok := s.config.Routes[domainName]
352 if !ok {404 if !ok {
353 return nil, domain.ErrRouteNotFound405 return nil, domain.ErrRouteNotFound
@@ -494,9 +546,11 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
494 if route.Domain == "" {546 if route.Domain == "" {
495 return domain.ErrRouteDomainEmpty547 return domain.ErrRouteDomainEmpty
496 }548 }
497- if !domain.IsValidRouteDomain(route.Domain) {549+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
550+ if !ok {
498 return domain.ErrRouteDomainInvalid551 return domain.ErrRouteDomainInvalid
499 }552 }
553+ route.Domain = canonicalDomain
500 if route.Image == "" {554 if route.Image == "" {
501 return domain.ErrRouteImageEmpty555 return domain.ErrRouteImageEmpty
502 }556 }
@@ -507,6 +561,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
507 if currentConfig.Routes == nil {561 if currentConfig.Routes == nil {
508 currentConfig.Routes = make(map[string]routeConfig)562 currentConfig.Routes = make(map[string]routeConfig)
509 }563 }
564+ if _, exists := currentConfig.ExternalRoutes[route.Domain]; exists {
565+ s.mu.Unlock()
566+ return fmt.Errorf("%w: route %q conflicts with external route", domain.ErrRouteConflict, route.Domain)
567+ }
510 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]568 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511 legacyKey := legacyRouteStorageKey(route.Domain)569 legacyKey := legacyRouteStorageKey(route.Domain)
512 _, legacyExisted := currentConfig.Routes[legacyKey]570 _, legacyExisted := currentConfig.Routes[legacyKey]
@@ -550,9 +608,11 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
550 if route.Domain == "" {608 if route.Domain == "" {
551 return domain.ErrRouteDomainEmpty609 return domain.ErrRouteDomainEmpty
552 }610 }
553- if !domain.IsValidRouteDomain(route.Domain) {611+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
612+ if !ok {
554 return domain.ErrRouteDomainInvalid613 return domain.ErrRouteDomainInvalid
555 }614 }
615+ route.Domain = canonicalDomain
556 if route.Image == "" {616 if route.Image == "" {
557 return domain.ErrRouteImageEmpty617 return domain.ErrRouteImageEmpty
558 }618 }
@@ -601,14 +661,16 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
601 if domainName == "" {661 if domainName == "" {
602 return domain.ErrRouteDomainEmpty662 return domain.ErrRouteDomainEmpty
603 }663 }
604- if !domain.IsValidRouteDomain(domainName) {664+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
665+ if !ok {
605 return domain.ErrRouteDomainInvalid666 return domain.ErrRouteDomainInvalid
606 }667 }
668+ domainName = canonicalDomain
607 669
608 // Store previous value for rollback670 // Store previous value for rollback
609 s.mu.Lock()671 s.mu.Lock()
610 currentConfig := s.config672 currentConfig := s.config
611- _, ok := currentConfig.Routes[domainName]673+ _, ok = currentConfig.Routes[domainName]
612 if !ok {674 if !ok {
613 s.mu.Unlock()675 s.mu.Unlock()
614 return domain.ErrRouteNotFound676 return domain.ErrRouteNotFound
@@ -1455,7 +1517,7 @@ func (s *Service) GetExternalRoutes() map[string]string {
1455 s.mu.RLock()1517 s.mu.RLock()
1456 defer s.mu.RUnlock()1518 defer s.mu.RUnlock()
1457 1519
1458- result := make(map[string]string)1520+ result := make(map[string]string, len(s.config.ExternalRoutes))
1459 for k, v := range s.config.ExternalRoutes {1521 for k, v := range s.config.ExternalRoutes {
1460 result[k] = v1522 result[k] = v
1461 }1523 }
M internal/usecase/config/service_test.go +87 -1
@@ -458,6 +458,92 @@ func TestService_GetRoute(t *testing.T) {
458 })458 })
459 }459 }
460 460
461+func TestLoadCanonicalRoutes_RejectsDuplicateCanonicalRouteKeys(t *testing.T) {
462+ _, err := loadCanonicalRoutes(map[string]any{
463+ "App.Example.com": "app:v1",
464+ "app.example.com": "app:v2",
465+ })
466+
467+ require.Error(t, err)
468+ assert.Contains(t, err.Error(), "duplicate route key")
469+}
470+
471+func TestService_Load_RejectsExternalRouteConflictingWithRoute(t *testing.T) {
472+ v := viper.New()
473+ v.Set("routes", map[string]any{
474+ "App.Example.com": "app:latest",
475+ })
476+ v.Set("external_routes", map[string]any{
477+ "app.example.com": "203.0.113.10:5000",
478+ })
479+
480+ svc := NewService(v, mocks.NewMockEventPublisher(t))
481+ err := svc.Load(testContext())
482+
483+ require.Error(t, err)
484+ assert.Contains(t, err.Error(), "conflicts with configured route")
485+}
486+
487+func TestLoadExternalRoutes_RejectsInvalidAndDuplicateKeys(t *testing.T) {
488+ t.Run("invalid", func(t *testing.T) {
489+ _, err := loadExternalRoutes(map[string]any{
490+ "bad.example.com:8080": "203.0.113.10:5000",
491+ })
492+
493+ require.Error(t, err)
494+ assert.Contains(t, err.Error(), "invalid external route key")
495+ })
496+
497+ t.Run("duplicate canonical", func(t *testing.T) {
498+ _, err := loadExternalRoutes(map[string]any{
499+ "Reg.Example.com": "203.0.113.10:5000",
500+ "reg.example.com": "203.0.113.11:5000",
501+ })
502+
503+ require.Error(t, err)
504+ assert.Contains(t, err.Error(), "duplicate external route key")
505+ })
506+}
507+
508+func TestService_AddRoute_RejectsExternalRouteConflict(t *testing.T) {
509+ v := viper.New()
510+ v.Set("external_routes", map[string]any{
511+ "app.example.com": "203.0.113.10:5000",
512+ })
513+
514+ svc := NewService(v, mocks.NewMockEventPublisher(t))
515+ ctx := testContext()
516+ require.NoError(t, svc.Load(ctx))
517+
518+ err := svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest"})
519+
520+ require.Error(t, err)
521+ assert.ErrorIs(t, err, domain.ErrRouteConflict)
522+ assert.Contains(t, err.Error(), "conflicts with external route")
523+}
524+
525+func TestService_AddRoute_CanonicalizesDomainCaseAndRejectsInvalidAuthority(t *testing.T) {
526+ configFile := filepath.Join(t.TempDir(), "gordon.toml")
527+ require.NoError(t, os.WriteFile(configFile, []byte("[routes]\n"), 0600))
528+ v := viper.New()
529+ v.SetConfigFile(configFile)
530+ svc := NewService(v, mocks.NewMockEventPublisher(t))
531+ ctx := testContext()
532+ require.NoError(t, svc.Load(ctx))
533+
534+ require.NoError(t, svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest", HTTPS: true}))
535+ route, err := svc.GetRoute(ctx, "app.example.com")
536+ require.NoError(t, err)
537+ assert.Equal(t, "app.example.com", route.Domain)
538+ _, err = svc.GetRoute(ctx, "APP.EXAMPLE.COM")
539+ require.NoError(t, err)
540+
541+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com:8080", Image: "app:latest"})
542+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
543+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com.", Image: "app:latest"})
544+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
545+}
546+
461 func TestService_AddRoute(t *testing.T) {547 func TestService_AddRoute(t *testing.T) {
462 t.Run("success", func(t *testing.T) {548 t.Run("success", func(t *testing.T) {
463 // Create temp config file for Save() to work549 // Create temp config file for Save() to work
@@ -1556,7 +1642,7 @@ func TestService_AutoRouteAllowedDomains(t *testing.T) {
1556 func TestService_GetExternalRoutes(t *testing.T) {1642 func TestService_GetExternalRoutes(t *testing.T) {
1557 v := viper.New()1643 v := viper.New()
1558 v.Set("external_routes", map[string]interface{}{1644 v.Set("external_routes", map[string]interface{}{
1559- "reg.example.com": "localhost:5000",1645+ "Reg.Example.com": "localhost:5000",
1560 "cache.example.com": "127.0.0.1:6379",1646 "cache.example.com": "127.0.0.1:6379",
1561 })1647 })
1562 1648
M internal/usecase/container/autoroute.go +59 -8
@@ -174,6 +174,13 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
174 continue174 continue
175 }175 }
176 176
177+ canonicalDomain, ok := domain.CanonicalRouteDomain(routeDomain)
178+ if !ok {
179+ log.Warn().Str("domain", routeDomain).Msg("auto-route rejected invalid domain")
180+ continue
181+ }
182+ routeDomain = canonicalDomain
183+
177 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)184 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178 185
179 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {186 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
@@ -259,6 +266,38 @@ func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route
259 }266 }
260 }267 }
261 268
269+func allowedAbsoluteEnvFileRoots() []string {
270+ return []string{"/app", "/workspace", "/usr/src/app"}
271+}
272+
273+func validateAutoRouteEnvFilePath(envFilePath string) (string, error) {
274+ envFilePath = strings.TrimSpace(envFilePath)
275+ if envFilePath == "" {
276+ return "", fmt.Errorf("env file path cannot be empty")
277+ }
278+ if strings.Contains(envFilePath, "\x00") {
279+ return "", fmt.Errorf("env file path contains NUL byte")
280+ }
281+
282+ cleaned := filepath.Clean(envFilePath)
283+ if cleaned == "." || cleaned == string(filepath.Separator) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
284+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
285+ }
286+ if filepath.IsAbs(cleaned) && !pathHasAllowedRoot(cleaned, allowedAbsoluteEnvFileRoots()) {
287+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
288+ }
289+ return cleaned, nil
290+}
291+
292+func pathHasAllowedRoot(path string, roots []string) bool {
293+ for _, root := range roots {
294+ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
295+ return true
296+ }
297+ }
298+ return false
299+}
300+
262 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.301 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {302 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264 log := zerowrap.FromCtx(ctx)303 log := zerowrap.FromCtx(ctx)
@@ -269,8 +308,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
269 Str("env_file", envFilePath).308 Str("env_file", envFilePath).
270 Msg("extracting env file from image")309 Msg("extracting env file from image")
271 310
311+ safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath)
312+ if err != nil {
313+ return err
314+ }
315+
272 // Extract env file from image316 // Extract env file from image
273- envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)317+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath)
274 if err != nil {318 if err != nil {
275 return fmt.Errorf("failed to extract env file: %w", err)319 return fmt.Errorf("failed to extract env file: %w", err)
276 }320 }
@@ -291,13 +335,8 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
291 return nil335 return nil
292 }336 }
293 337
294- // Security check: reject imported env files containing secret references338+ if err := rejectSecretReferences(imageEnv); err != nil {
295- // to prevent attacker-controlled images from persisting ${provider:path}339+ return err
296- // syntax that would later resolve against host secret providers.
297- for key, value := range imageEnv {
298- if domain.ContainsSecretReference(value) {
299- return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300- }
301 }340 }
302 341
303 // Load existing env file for this domain342 // Load existing env file for this domain
@@ -379,6 +418,18 @@ func domainToEnvFileName(domainName string) (string, error) {
379 return storageKey.FileName(), nil418 return storageKey.FileName(), nil
380 }419 }
381 420
421+func rejectSecretReferences(env map[string]string) error {
422+ // Security check: reject imported env files containing secret references
423+ // to prevent attacker-controlled images from persisting ${provider:path}
424+ // syntax that would later resolve against host secret providers.
425+ for key, value := range env {
426+ if domain.ContainsSecretReference(value) {
427+ return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
428+ }
429+ }
430+ return nil
431+}
432+
382 // writeEnvFile writes environment variables to a file.433 // writeEnvFile writes environment variables to a file.
383 func writeEnvFile(path string, env map[string]string) error {434 func writeEnvFile(path string, env map[string]string) error {
384 var buf bytes.Buffer435 var buf bytes.Buffer
M internal/usecase/container/autoroute_test.go +47 -0
@@ -910,6 +910,53 @@ func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef,
910 910
911 // collectDomains tests911 // collectDomains tests
912 912
913+func TestValidateAutoRouteEnvFilePath(t *testing.T) {
914+ tests := []struct {
915+ name string
916+ path string
917+ want string
918+ wantErr bool
919+ }{
920+ {name: "relative path", path: ".env", want: ".env"},
921+ {name: "absolute app path", path: "/app/.env", want: "/app/.env"},
922+ {name: "absolute workspace path", path: "/workspace/.env", want: "/workspace/.env"},
923+ {name: "cleans safe path", path: "/app/config/../.env", want: "/app/.env"},
924+ {name: "absolute traversal escapes app root", path: "/app/../../etc/passwd", wantErr: true},
925+ {name: "absolute non app path", path: "/etc/passwd", wantErr: true},
926+ {name: "empty", path: "", wantErr: true},
927+ {name: "root", path: "/", wantErr: true},
928+ {name: "traversal", path: "../.env", wantErr: true},
929+ {name: "proc", path: "/proc/self/environ", wantErr: true},
930+ {name: "sys", path: "/sys/kernel", wantErr: true},
931+ {name: "dev", path: "/dev/null", wantErr: true},
932+ {name: "run", path: "/run/secrets", wantErr: true},
933+ {name: "var run", path: "/var/run/docker.sock", wantErr: true},
934+ }
935+
936+ for _, tt := range tests {
937+ t.Run(tt.name, func(t *testing.T) {
938+ got, err := validateAutoRouteEnvFilePath(tt.path)
939+ if tt.wantErr {
940+ assert.Error(t, err)
941+ return
942+ }
943+ require.NoError(t, err)
944+ assert.Equal(t, tt.want, got)
945+ })
946+ }
947+}
948+
949+func TestAutoRouteHandler_ExtractAndMergeEnvFile_UsesCleanSafeEnvFilePath(t *testing.T) {
950+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
951+ extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
952+ handler := NewAutoRouteHandler(ctx, inmocks.NewMockConfigService(t), inmocks.NewMockContainerService(t), mocks.NewMockBlobStorage(t), "registry.example.com").
953+ WithEnvExtractor(extractor, t.TempDir())
954+
955+ err := handler.extractAndMergeEnvFile(ctx, "myapp:latest", "app.example.com", "/app/config/../.env")
956+ require.NoError(t, err)
957+ assert.Equal(t, "/app/.env", extractor.lastPath)
958+}
959+
913 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {960 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
914 tests := []struct {961 tests := []struct {
915 name string962 name string
M internal/usecase/container/service.go +211 -21
@@ -5,10 +5,12 @@ import (
5 "bufio"5 "bufio"
6 "context"6 "context"
7 "encoding/binary"7 "encoding/binary"
8+ "encoding/hex"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "io"11 "io"
11 "maps"12 "maps"
13+ "net"
12 "slices"14 "slices"
13 "strconv"15 "strconv"
14 "strings"16 "strings"
@@ -43,7 +45,11 @@ type Config struct {
43 NetworkIsolation bool45 NetworkIsolation bool
44 NetworkPrefix string46 NetworkPrefix string
45 NetworkGroups map[string][]string47 NetworkGroups map[string][]string
48+ NetworkInternal bool
46 Attachments map[string][]string49 Attachments map[string][]string
50+ AllowedRegistries []string
51+ RequireImageDigest bool
52+ SecurityProfile string
47 ReadinessDelay time.Duration // Delay after container starts before considering it ready53 ReadinessDelay time.Duration // Delay after container starts before considering it ready
48 ReadinessMode string // Readiness strategy: auto, docker-health, delay54 ReadinessMode string // Readiness strategy: auto, docker-health, delay
49 HealthTimeout time.Duration // Max wait for health-based readiness55 HealthTimeout time.Duration // Max wait for health-based readiness
@@ -268,7 +274,7 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
268 }274 }
269 }275 }
270 276
271- return &domain.ContainerConfig{277+ containerConfig := &domain.ContainerConfig{
272 Image: in.ImageRef,278 Image: in.ImageRef,
273 Name: containerName,279 Name: containerName,
274 Ports: ports,280 Ports: ports,
@@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
282 NanoCPUs: cfg.DefaultNanoCPUs,288 NanoCPUs: cfg.DefaultNanoCPUs,
283 PidsLimit: cfg.DefaultPidsLimit,289 PidsLimit: cfg.DefaultPidsLimit,
284 }290 }
291+ applySecurityProfile(containerConfig, cfg)
292+ return containerConfig
285 }293 }
286 294
287 // Deploy creates and starts a container for the given route.295 // Deploy creates and starts a container for the given route.
@@ -561,7 +569,10 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
561 log.WrapErr(err, "failed to cleanup orphaned containers")569 log.WrapErr(err, "failed to cleanup orphaned containers")
562 }570 }
563 571
564- imageRef := s.buildImageRef(route.Image)572+ imageRef, err := s.buildValidatedImageRef(ctx, route.Image)
573+ if err != nil {
574+ return nil, err
575+ }
565 actualImageRef, err := s.ensureImage(ctx, imageRef)576 actualImageRef, err := s.ensureImage(ctx, imageRef)
566 if err != nil {577 if err != nil {
567 return nil, err578 return nil, err
@@ -1668,6 +1679,14 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
1668 }1679 }
1669 }1680 }
1670 1681
1682+func (s *Service) buildValidatedImageRef(ctx context.Context, image string) (string, error) {
1683+ imageRef := s.buildImageRef(image)
1684+ if err := s.validateImageRef(ctx, imageRef); err != nil {
1685+ return "", err
1686+ }
1687+ return imageRef, nil
1688+}
1689+
1671 func (s *Service) buildImageRef(image string) string {1690 func (s *Service) buildImageRef(image string) string {
1672 s.mu.RLock()1691 s.mu.RLock()
1673 cfg := s.config1692 cfg := s.config
@@ -1743,6 +1762,135 @@ func hasExplicitRegistry(image string) bool {
1743 return false1762 return false
1744 }1763 }
1745 1764
1765+func (s *Service) validateImageRef(ctx context.Context, imageRef string) error {
1766+ if !hasExplicitRegistry(imageRef) {
1767+ return nil
1768+ }
1769+ return s.validateExternalImageRef(ctx, imageRef, imageRegistry(imageRef))
1770+}
1771+
1772+func (s *Service) validateImagePullRef(ctx context.Context, imageRef string) error {
1773+ return s.validateExternalImageRef(ctx, imageRef, imageRegistryForPolicy(imageRef))
1774+}
1775+
1776+func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, registry string) error {
1777+ s.mu.RLock()
1778+ cfg := s.config
1779+ s.mu.RUnlock()
1780+
1781+ if registry == "" {
1782+ return fmt.Errorf("image %q rejected: invalid image reference", imageRef)
1783+ }
1784+
1785+ if sameRegistry(registry, cfg.RegistryDomain) {
1786+ return nil
1787+ }
1788+ dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))
1789+ if err != nil {
1790+ return fmt.Errorf("image %q rejected: registry %q could not be verified: %w", imageRef, registry, err)
1791+ }
1792+ if dangerous {
1793+ return fmt.Errorf("image %q rejected: registry %q is not allowed", imageRef, registry)
1794+ }
1795+ if len(cfg.AllowedRegistries) == 0 {
1796+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1797+ }
1798+ for _, allowed := range cfg.AllowedRegistries {
1799+ if sameRegistry(registry, allowed) {
1800+ if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) {
1801+ return fmt.Errorf("image %q rejected: digest reference required", imageRef)
1802+ }
1803+ return nil
1804+ }
1805+ }
1806+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1807+}
1808+
1809+func imageRegistry(imageRef string) string {
1810+ slashIdx := strings.Index(imageRef, "/")
1811+ if slashIdx == -1 {
1812+ return ""
1813+ }
1814+ return normalizeRegistry(imageRef[:slashIdx])
1815+}
1816+
1817+func imageRegistryForPolicy(imageRef string) string {
1818+ if hasExplicitRegistry(imageRef) {
1819+ return imageRegistry(imageRef)
1820+ }
1821+ return "docker.io"
1822+}
1823+
1824+// normalizeRegistry normalizes a registry string for comparison purposes only.
1825+// This function is used by sameRegistry to determine if two registry strings
1826+// refer to the same registry. The output intentionally strips IPv6 brackets
1827+// (e.g., "[fd00::1]:5000" -> "fd00::1:5000") which is acceptable for equality
1828+// checks but the result MUST NOT be used to construct or emit valid registry URLs.
1829+func normalizeRegistry(registry string) string {
1830+ registry = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(registry), "."))
1831+ registry = strings.TrimSuffix(registry, "/")
1832+ if strings.HasPrefix(registry, "[") {
1833+ if end := strings.Index(registry, "]"); end != -1 {
1834+ host := strings.Trim(registry[:end+1], "[]")
1835+ port := registry[end+1:]
1836+ return host + port
1837+ }
1838+ }
1839+ return registry
1840+}
1841+
1842+func imageRegistryHost(registry string) string {
1843+ if ip := net.ParseIP(registry); ip != nil {
1844+ return registry
1845+ }
1846+ if h, _, err := net.SplitHostPort(registry); err == nil {
1847+ return strings.Trim(h, "[]")
1848+ }
1849+ if strings.HasPrefix(registry, "[") && strings.Contains(registry, "]") {
1850+ return strings.TrimPrefix(strings.Split(registry, "]")[0], "[")
1851+ }
1852+ if colon := strings.LastIndex(registry, ":"); colon != -1 && isNumeric(registry[colon+1:]) {
1853+ return registry[:colon]
1854+ }
1855+ return registry
1856+}
1857+
1858+func sameRegistry(a, b string) bool {
1859+ return normalizeRegistry(a) == normalizeRegistry(b)
1860+}
1861+
1862+func isDangerousRegistryHost(ctx context.Context, host string) (bool, error) {
1863+ host = strings.ToLower(strings.TrimSuffix(host, "."))
1864+ if host == "localhost" || host == "metadata.google.internal" {
1865+ return true, nil
1866+ }
1867+ if ip := net.ParseIP(host); ip != nil {
1868+ return isDangerousRegistryIP(ip), nil
1869+ }
1870+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
1871+ if err != nil {
1872+ return false, err
1873+ }
1874+ for _, addr := range addrs {
1875+ if isDangerousRegistryIP(addr.IP) {
1876+ return true, nil
1877+ }
1878+ }
1879+ return false, nil
1880+}
1881+
1882+func isDangerousRegistryIP(ip net.IP) bool {
1883+ return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
1884+}
1885+
1886+func applySecurityProfile(config *domain.ContainerConfig, cfg Config) {
1887+ if strings.EqualFold(cfg.SecurityProfile, "strict") {
1888+ config.ReadOnlyRootFS = true
1889+ config.CapDrop = []string{"ALL"}
1890+ config.CapAdd = []string{"NET_BIND_SERVICE"}
1891+ }
1892+}
1893+
1746 // isNumeric checks if a string contains only digits.1894 // isNumeric checks if a string contains only digits.
1747 func isNumeric(s string) bool {1895 func isNumeric(s string) bool {
1748 for _, c := range s {1896 for _, c := range s {
@@ -1767,7 +1915,16 @@ func normalizePullPolicy(policy string) string {
1767 }1915 }
1768 1916
1769 func isDigestRef(imageRef string) bool {1917 func isDigestRef(imageRef string) bool {
1770- return strings.Contains(imageRef, "@sha256:")1918+ return isValidSha256DigestRef(imageRef)
1919+}
1920+
1921+func isValidSha256DigestRef(imageRef string) bool {
1922+ _, digest, ok := strings.Cut(imageRef, "@sha256:")
1923+ if !ok || len(digest) != 64 {
1924+ return false
1925+ }
1926+ _, err := hex.DecodeString(digest)
1927+ return err == nil
1771 }1928 }
1772 1929
1773 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {1930 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
@@ -1794,6 +1951,9 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
1794 1951
1795 // Determine if this is an internal deploy and what reference to use for pulls.1952 // Determine if this is an internal deploy and what reference to use for pulls.
1796 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)1953 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
1954+ if err := s.validateImagePullRef(ctx, imageRef); err != nil {
1955+ return "", err
1956+ }
1797 if pullRef != imageRef {1957 if pullRef != imageRef {
1798 log.Info().1958 log.Info().
1799 Str("original_ref", imageRef).1959 Str("original_ref", imageRef).
@@ -2101,7 +2261,10 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2101 }2261 }
2102 2262
2103 if !exists {2263 if !exists {
2104- if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {2264+ s.mu.RLock()
2265+ internal := s.config.NetworkInternal
2266+ s.mu.RUnlock()
2267+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {
2105 return log.WrapErr(err, "failed to create network")2268 return log.WrapErr(err, "failed to create network")
2106 }2269 }
2107 log.Info().Msg("created network for app isolation")2270 log.Info().Msg("created network for app isolation")
@@ -2415,31 +2578,23 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2415 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")2578 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
2416 2579
2417 // Ensure image (canonical ref is returned; internal pulls may use the local registry).2580 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
2418- imageRef := s.buildImageRef(serviceImage)2581+ imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2419- actualImageRef, err := s.ensureImage(ctx, imageRef)
2420 if err != nil {2582 if err != nil {
2421 return err2583 return err
2422 }2584 }
2423-2585+ actualImageRef, err := s.ensureImage(ctx, imageRef)
2424- // Get image metadata
2425- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
2426 if err != nil {2586 if err != nil {
2427- log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")2587+ return err
2428- exposedPorts = []int{}
2429 }2588 }
2430 2589
2431- // Setup volumes (attachments need persistent data)2590+ exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2432- volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)2591+ volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)
2433 if err != nil {2592 if err != nil {
2434- log.WrapErr(err, "failed to setup volumes for attachment")2593+ return err
2435- volumes = make(map[string]string)
2436 }2594 }
2437-2595+ envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef)
2438- // Load environment (attachment-specific env file)
2439- envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
2440 if err != nil {2596 if err != nil {
2441- log.WrapErr(err, "failed to load environment for attachment")2597+ return err
2442- envVars = []string{}
2443 }2598 }
2444 envHash := hashEnvironment(envVars)2599 envHash := hashEnvironment(envVars)
2445 2600
@@ -2468,6 +2623,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2468 NanoCPUs: cfg.DefaultNanoCPUs,2623 NanoCPUs: cfg.DefaultNanoCPUs,
2469 PidsLimit: cfg.DefaultPidsLimit,2624 PidsLimit: cfg.DefaultPidsLimit,
2470 }2625 }
2626+ applySecurityProfile(config, cfg)
2471 2627
2472 container, err := s.runtime.CreateContainer(ctx, config)2628 container, err := s.runtime.CreateContainer(ctx, config)
2473 if err != nil {2629 if err != nil {
@@ -2502,8 +2658,38 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2502 return nil2658 return nil
2503 }2659 }
2504 2660
2661+func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) []int {
2662+ log := zerowrap.FromCtx(ctx)
2663+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
2664+ if err != nil {
2665+ log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2666+ return []int{}
2667+ }
2668+ return exposedPorts
2669+}
2670+
2671+func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) (map[string]string, error) {
2672+ volumes, err := s.setupVolumes(ctx, containerName, imageRef)
2673+ if err != nil {
2674+ return nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err)
2675+ }
2676+ return volumes, nil
2677+}
2678+
2679+func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) ([]string, error) {
2680+ envVars, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
2681+ if err != nil {
2682+ return nil, fmt.Errorf("failed to load environment for attachment %s with image %s: %w", containerName, imageRef, err)
2683+ }
2684+ return envVars, nil
2685+}
2686+
2505 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {2687 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2506- currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))2688+ imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2689+ if err != nil {
2690+ return false, err
2691+ }
2692+ currentEnv, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
2507 if err != nil {2693 if err != nil {
2508 return false, err2694 return false, err
2509 }2695 }
@@ -2598,6 +2784,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
2598 // Utility functions2784 // Utility functions
2599 2785
2600 func normalizeImageRef(image string) string {2786 func normalizeImageRef(image string) string {
2787+ if isDigestRef(image) {
2788+ return image
2789+ }
2790+
2601 // Extract repository from image reference, handling port numbers correctly.2791 // Extract repository from image reference, handling port numbers correctly.
2602 // Examples:2792 // Examples:
2603 // nginx:latest -> docker.io/library/nginx2793 // nginx:latest -> docker.io/library/nginx
M internal/usecase/container/service_test.go +155 -6
@@ -49,6 +49,7 @@ func dockerLogFrames(stream byte, lines ...string) io.ReadCloser {
49 // suitable for unit tests that don't want to wait for real timeouts.49 // suitable for unit tests that don't want to wait for real timeouts.
50 func testMinDelayConfig() Config {50 func testMinDelayConfig() Config {
51 return Config{51 return Config{
52+ AllowedRegistries: []string{"docker.io"},
52 ReadinessDelay: time.Millisecond,53 ReadinessDelay: time.Millisecond,
53 DrainDelay: time.Millisecond,54 DrainDelay: time.Millisecond,
54 DrainDelayConfigured: true,55 DrainDelayConfigured: true,
@@ -326,6 +327,7 @@ func TestService_Deploy_Success(t *testing.T) {
326 eventBus := mocks.NewMockEventPublisher(t)327 eventBus := mocks.NewMockEventPublisher(t)
327 328
328 config := Config{329 config := Config{
330+ AllowedRegistries: []string{"docker.io"},
329 NetworkIsolation: false,331 NetworkIsolation: false,
330 VolumeAutoCreate: false,332 VolumeAutoCreate: false,
331 ReadinessDelay: time.Millisecond, // Minimal delay for tests333 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -408,6 +410,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
408 eventBus := mocks.NewMockEventPublisher(t)410 eventBus := mocks.NewMockEventPublisher(t)
409 411
410 config := Config{412 config := Config{
413+ AllowedRegistries: []string{"docker.io"},
411 NetworkIsolation: false,414 NetworkIsolation: false,
412 VolumeAutoCreate: false,415 VolumeAutoCreate: false,
413 ReadinessDelay: time.Millisecond,416 ReadinessDelay: time.Millisecond,
@@ -472,7 +475,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
472 envLoader := mocks.NewMockEnvLoader(t)475 envLoader := mocks.NewMockEnvLoader(t)
473 eventBus := mocks.NewMockEventPublisher(t)476 eventBus := mocks.NewMockEventPublisher(t)
474 477
475- config := Config{}478+ config := Config{AllowedRegistries: []string{"docker.io"}}
476 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)479 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
477 ctx := testContext()480 ctx := testContext()
478 481
@@ -514,7 +517,7 @@ func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.
514 envLoader := mocks.NewMockEnvLoader(t)517 envLoader := mocks.NewMockEnvLoader(t)
515 eventBus := mocks.NewMockEventPublisher(t)518 eventBus := mocks.NewMockEventPublisher(t)
516 519
517- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)520+ svc := NewService(runtime, envLoader, eventBus, nil, Config{AllowedRegistries: []string{"docker.io"}}, nil)
518 ctx := testContext()521 ctx := testContext()
519 522
520 route := domain.Route{523 route := domain.Route{
@@ -555,6 +558,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.
555 eventBus := mocks.NewMockEventPublisher(t)558 eventBus := mocks.NewMockEventPublisher(t)
556 559
557 config := Config{560 config := Config{
561+ AllowedRegistries: []string{"docker.io"},
558 ReadinessMode: "docker-health",562 ReadinessMode: "docker-health",
559 HealthTimeout: 10 * time.Millisecond,563 HealthTimeout: 10 * time.Millisecond,
560 ReadinessDelay: time.Millisecond,564 ReadinessDelay: time.Millisecond,
@@ -613,6 +617,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing
613 eventBus := mocks.NewMockEventPublisher(t)617 eventBus := mocks.NewMockEventPublisher(t)
614 618
615 config := Config{619 config := Config{
620+ AllowedRegistries: []string{"docker.io"},
616 ReadinessMode: "docker-health",621 ReadinessMode: "docker-health",
617 ReadinessDelay: time.Millisecond,622 ReadinessDelay: time.Millisecond,
618 DrainDelay: time.Millisecond,623 DrainDelay: time.Millisecond,
@@ -659,6 +664,7 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
659 runtime := mocks.NewMockContainerRuntime(t)664 runtime := mocks.NewMockContainerRuntime(t)
660 665
661 config := Config{666 config := Config{
667+ AllowedRegistries: []string{"docker.io"},
662 RegistryAuthEnabled: true,668 RegistryAuthEnabled: true,
663 ServiceTokenUsername: "gordon-service",669 ServiceTokenUsername: "gordon-service",
664 ServiceToken: "service-token",670 ServiceToken: "service-token",
@@ -678,6 +684,7 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
678 runtime := mocks.NewMockContainerRuntime(t)684 runtime := mocks.NewMockContainerRuntime(t)
679 685
680 config := Config{686 config := Config{
687+ AllowedRegistries: []string{"docker.io"},
681 RegistryAuthEnabled: true,688 RegistryAuthEnabled: true,
682 }689 }
683 690
@@ -694,6 +701,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
694 runtime := mocks.NewMockContainerRuntime(t)701 runtime := mocks.NewMockContainerRuntime(t)
695 702
696 config := Config{703 config := Config{
704+ AllowedRegistries: []string{"docker.io"},
697 RegistryAuthEnabled: true,705 RegistryAuthEnabled: true,
698 InternalRegistryUsername: "internal",706 InternalRegistryUsername: "internal",
699 InternalRegistryPassword: "secret",707 InternalRegistryPassword: "secret",
@@ -721,6 +729,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
721 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)729 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
722 730
723 config := Config{731 config := Config{
732+ AllowedRegistries: []string{"docker.io"},
724 ReadinessDelay: time.Millisecond, // Minimal delay for tests733 ReadinessDelay: time.Millisecond, // Minimal delay for tests
725 DrainDelay: time.Millisecond,734 DrainDelay: time.Millisecond,
726 DrainDelayConfigured: true,735 DrainDelayConfigured: true,
@@ -963,6 +972,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
963 eventBus := mocks.NewMockEventPublisher(t)972 eventBus := mocks.NewMockEventPublisher(t)
964 973
965 config := Config{974 config := Config{
975+ AllowedRegistries: []string{"docker.io"},
966 NetworkIsolation: true,976 NetworkIsolation: true,
967 NetworkPrefix: "gordon",977 NetworkPrefix: "gordon",
968 ReadinessDelay: time.Millisecond, // Minimal delay for tests978 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -989,7 +999,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
989 999
990 // Network isolation - should check and create network1000 // Network isolation - should check and create network
991 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)1001 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
992- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)1002+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil)
993 1003
994 container := &domain.Container{ID: "container-123", Status: "created"}1004 container := &domain.Container{ID: "container-123", Status: "created"}
995 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {1005 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -1020,6 +1030,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
1020 eventBus := mocks.NewMockEventPublisher(t)1030 eventBus := mocks.NewMockEventPublisher(t)
1021 1031
1022 config := Config{1032 config := Config{
1033+ AllowedRegistries: []string{"docker.io"},
1023 VolumeAutoCreate: true,1034 VolumeAutoCreate: true,
1024 VolumePrefix: "gordon",1035 VolumePrefix: "gordon",
1025 ReadinessDelay: time.Millisecond, // Minimal delay for tests1036 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -1135,8 +1146,9 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
1135 eventBus := mocks.NewMockEventPublisher(t)1146 eventBus := mocks.NewMockEventPublisher(t)
1136 1147
1137 config := Config{1148 config := Config{
1138- NetworkIsolation: true,1149+ AllowedRegistries: []string{"docker.io"},
1139- NetworkPrefix: "gordon",1150+ NetworkIsolation: true,
1151+ NetworkPrefix: "gordon",
1140 }1152 }
1141 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)1153 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1142 ctx := testContext()1154 ctx := testContext()
@@ -1663,6 +1675,16 @@ func TestNormalizeImageRef(t *testing.T) {
1663 input: "registry.example.com:5000/image:v1.0",1675 input: "registry.example.com:5000/image:v1.0",
1664 expected: "registry.example.com:5000/image",1676 expected: "registry.example.com:5000/image",
1665 },1677 },
1678+ {
1679+ name: "digest ref preserves digest",
1680+ input: "registry.example.com/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1681+ expected: "registry.example.com/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1682+ },
1683+ {
1684+ name: "docker hub digest ref preserves digest",
1685+ input: "nginx@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1686+ expected: "nginx@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1687+ },
1666 }1688 }
1667 1689
1668 for _, tt := range tests {1690 for _, tt := range tests {
@@ -1932,6 +1954,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1932 eventBus := mocks.NewMockEventPublisher(t)1954 eventBus := mocks.NewMockEventPublisher(t)
1933 1955
1934 config := Config{1956 config := Config{
1957+ AllowedRegistries: []string{"docker.io"},
1935 RegistryAuthEnabled: true,1958 RegistryAuthEnabled: true,
1936 RegistryDomain: "registry.example.com",1959 RegistryDomain: "registry.example.com",
1937 RegistryPort: 5000,1960 RegistryPort: 5000,
@@ -2011,6 +2034,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2011 eventBus := mocks.NewMockEventPublisher(t)2034 eventBus := mocks.NewMockEventPublisher(t)
2012 2035
2013 config := Config{2036 config := Config{
2037+ AllowedRegistries: []string{"docker.io"},
2014 ReadinessDelay: time.Millisecond,2038 ReadinessDelay: time.Millisecond,
2015 DrainDelay: time.Millisecond,2039 DrainDelay: time.Millisecond,
2016 DrainDelayConfigured: true,2040 DrainDelayConfigured: true,
@@ -2059,6 +2083,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
2059 eventBus := mocks.NewMockEventPublisher(t)2083 eventBus := mocks.NewMockEventPublisher(t)
2060 2084
2061 config := Config{2085 config := Config{
2086+ AllowedRegistries: []string{"docker.io"},
2062 ReadinessDelay: time.Millisecond,2087 ReadinessDelay: time.Millisecond,
2063 DrainDelay: time.Millisecond,2088 DrainDelay: time.Millisecond,
2064 DrainDelayConfigured: true,2089 DrainDelayConfigured: true,
@@ -2106,7 +2131,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
2106 envLoader := mocks.NewMockEnvLoader(t)2131 envLoader := mocks.NewMockEnvLoader(t)
2107 eventBus := mocks.NewMockEventPublisher(t)2132 eventBus := mocks.NewMockEventPublisher(t)
2108 2133
2109- config := Config{}2134+ config := Config{AllowedRegistries: []string{"docker.io"}}
2110 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)2135 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2111 ctx := testContext()2136 ctx := testContext()
2112 2137
@@ -2188,6 +2213,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2188 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2213 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2189 2214
2190 config := Config{2215 config := Config{
2216+ AllowedRegistries: []string{"docker.io"},
2191 ReadinessDelay: time.Millisecond, // Minimal delay for tests2217 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2192 DrainDelay: time.Millisecond,2218 DrainDelay: time.Millisecond,
2193 DrainDelayConfigured: true,2219 DrainDelayConfigured: true,
@@ -2282,6 +2308,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2282 eventBus := mocks.NewMockEventPublisher(t)2308 eventBus := mocks.NewMockEventPublisher(t)
2283 2309
2284 config := Config{2310 config := Config{
2311+ AllowedRegistries: []string{"docker.io"},
2285 ReadinessDelay: time.Millisecond, // Minimal delay for tests2312 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2286 DrainDelay: time.Millisecond,2313 DrainDelay: time.Millisecond,
2287 DrainDelayConfigured: true,2314 DrainDelayConfigured: true,
@@ -2732,6 +2759,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
2732 eventBus := mocks.NewMockEventPublisher(t)2759 eventBus := mocks.NewMockEventPublisher(t)
2733 2760
2734 config := Config{2761 config := Config{
2762+ AllowedRegistries: []string{"docker.io"},
2735 ReadinessDelay: time.Millisecond,2763 ReadinessDelay: time.Millisecond,
2736 DrainDelay: time.Millisecond,2764 DrainDelay: time.Millisecond,
2737 DrainDelayConfigured: true,2765 DrainDelayConfigured: true,
@@ -2890,6 +2918,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2890 eventBus := mocks.NewMockEventPublisher(t)2918 eventBus := mocks.NewMockEventPublisher(t)
2891 2919
2892 config := Config{2920 config := Config{
2921+ AllowedRegistries: []string{"docker.io"},
2893 ReadinessDelay: time.Millisecond,2922 ReadinessDelay: time.Millisecond,
2894 DrainDelay: time.Millisecond,2923 DrainDelay: time.Millisecond,
2895 DrainDelayConfigured: true,2924 DrainDelayConfigured: true,
@@ -2949,6 +2978,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
2949 eventBus := mocks.NewMockEventPublisher(t)2978 eventBus := mocks.NewMockEventPublisher(t)
2950 2979
2951 config := Config{2980 config := Config{
2981+ AllowedRegistries: []string{"docker.io"},
2952 ReadinessDelay: time.Millisecond,2982 ReadinessDelay: time.Millisecond,
2953 DrainDelay: time.Millisecond,2983 DrainDelay: time.Millisecond,
2954 DrainDelayConfigured: true,2984 DrainDelayConfigured: true,
@@ -3455,6 +3485,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3455 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3485 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3456 3486
3457 config := Config{3487 config := Config{
3488+ AllowedRegistries: []string{"docker.io"},
3458 ReadinessDelay: time.Millisecond,3489 ReadinessDelay: time.Millisecond,
3459 DrainDelay: time.Millisecond,3490 DrainDelay: time.Millisecond,
3460 DrainDelayConfigured: true,3491 DrainDelayConfigured: true,
@@ -3548,6 +3579,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3548 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3579 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3549 3580
3550 config := Config{3581 config := Config{
3582+ AllowedRegistries: []string{"docker.io"},
3551 ReadinessDelay: time.Millisecond,3583 ReadinessDelay: time.Millisecond,
3552 DrainDelay: time.Millisecond,3584 DrainDelay: time.Millisecond,
3553 DrainDelayConfigured: true,3585 DrainDelayConfigured: true,
@@ -3635,6 +3667,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
3635 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3667 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3636 3668
3637 config := Config{3669 config := Config{
3670+ AllowedRegistries: []string{"docker.io"},
3638 ReadinessDelay: time.Millisecond,3671 ReadinessDelay: time.Millisecond,
3639 DrainDelay: time.Millisecond,3672 DrainDelay: time.Millisecond,
3640 DrainDelayConfigured: true,3673 DrainDelayConfigured: true,
@@ -3729,6 +3762,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3729 eventBus := mocks.NewMockEventPublisher(t)3762 eventBus := mocks.NewMockEventPublisher(t)
3730 3763
3731 config := Config{3764 config := Config{
3765+ AllowedRegistries: []string{"docker.io"},
3732 ReadinessDelay: time.Millisecond,3766 ReadinessDelay: time.Millisecond,
3733 DrainDelay: time.Millisecond,3767 DrainDelay: time.Millisecond,
3734 DrainDelayConfigured: true,3768 DrainDelayConfigured: true,
@@ -3818,6 +3852,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3818 eventBus := mocks.NewMockEventPublisher(t)3852 eventBus := mocks.NewMockEventPublisher(t)
3819 3853
3820 config := Config{3854 config := Config{
3855+ AllowedRegistries: []string{"docker.io"},
3821 ReadinessDelay: time.Millisecond,3856 ReadinessDelay: time.Millisecond,
3822 DrainDelay: time.Millisecond,3857 DrainDelay: time.Millisecond,
3823 DrainDelayConfigured: true,3858 DrainDelayConfigured: true,
@@ -3893,6 +3928,7 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3893 eventBus := mocks.NewMockEventPublisher(t)3928 eventBus := mocks.NewMockEventPublisher(t)
3894 3929
3895 config := Config{3930 config := Config{
3931+ AllowedRegistries: []string{"docker.io"},
3896 NetworkIsolation: false,3932 NetworkIsolation: false,
3897 VolumeAutoCreate: false,3933 VolumeAutoCreate: false,
3898 ReadinessDelay: time.Millisecond,3934 ReadinessDelay: time.Millisecond,
@@ -4168,3 +4204,116 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4168 require.Len(t, attachIDs, 1)4204 require.Len(t, attachIDs, 1)
4169 assert.Equal(t, "pg-container-1", attachIDs[0])4205 assert.Equal(t, "pg-container-1", attachIDs[0])
4170 }4206 }
4207+
4208+func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) {
4209+ svc := NewService(nil, nil, nil, nil, Config{
4210+ RegistryAuthEnabled: true,
4211+ RegistryDomain: "registry.example.com",
4212+ AllowedRegistries: []string{"docker.io"},
4213+ }, nil)
4214+
4215+ ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4216+ require.NoError(t, err)
4217+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4218+
4219+ ref, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4220+ require.NoError(t, err)
4221+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4222+
4223+ ref, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4224+ require.NoError(t, err)
4225+ assert.Equal(t, "docker.io/library/nginx:latest", ref)
4226+
4227+ for _, image := range []string{
4228+ "ghcr.io/acme/app:latest",
4229+ "localhost:5000/app:latest",
4230+ "127.0.0.1:5000/app:latest",
4231+ "10.0.0.2:5000/app:latest",
4232+ "169.254.169.254/app:latest",
4233+ } {
4234+ t.Run(image, func(t *testing.T) {
4235+ _, err := svc.buildValidatedImageRef(context.Background(), image)
4236+ require.Error(t, err)
4237+ })
4238+ }
4239+}
4240+
4241+func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) {
4242+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4243+
4244+ _, err := svc.buildValidatedImageRef(context.Background(), "ghcr.io/acme/app:latest")
4245+ require.Error(t, err)
4246+ assert.Contains(t, err.Error(), "images.allowed_registries")
4247+
4248+ err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4249+ require.Error(t, err)
4250+ assert.Contains(t, err.Error(), "docker.io")
4251+}
4252+
4253+func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) {
4254+ svc := NewService(nil, nil, nil, nil, Config{
4255+ RegistryAuthEnabled: true,
4256+ RegistryDomain: "registry.example.com",
4257+ RequireImageDigest: true,
4258+ AllowedRegistries: []string{"docker.io"},
4259+ }, nil)
4260+
4261+ _, err := svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4262+ require.Error(t, err)
4263+
4264+ err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4265+ require.Error(t, err)
4266+
4267+ _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
4268+ require.NoError(t, err)
4269+
4270+ _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:not-a-digest")
4271+ require.Error(t, err)
4272+
4273+ ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4274+ require.NoError(t, err)
4275+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4276+
4277+ _, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4278+ require.NoError(t, err)
4279+}
4280+
4281+func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) {
4282+ svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"docker.io:5000"}}, nil)
4283+
4284+ _, err := svc.buildValidatedImageRef(context.Background(), "docker.io:5000/app:latest")
4285+ require.NoError(t, err)
4286+
4287+ _, err = svc.buildValidatedImageRef(context.Background(), "docker.io:5001/app:latest")
4288+ require.Error(t, err)
4289+}
4290+
4291+func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) {
4292+ runtime := mocks.NewMockContainerRuntime(t)
4293+ svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4294+
4295+ runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4296+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)
4297+
4298+ require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4299+}
4300+
4301+func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) {
4302+ svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil)
4303+
4304+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4305+
4306+ assert.True(t, cfg.ReadOnlyRootFS)
4307+ assert.Equal(t, []string{"ALL"}, cfg.CapDrop)
4308+ assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd)
4309+}
4310+
4311+func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) {
4312+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4313+
4314+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4315+
4316+ assert.False(t, cfg.ReadOnlyRootFS)
4317+ assert.Nil(t, cfg.CapDrop)
4318+ assert.Nil(t, cfg.CapAdd)
4319+}
M internal/usecase/proxy/service.go +52 -5
@@ -3,6 +3,7 @@ package proxy
3 3
4 import (4 import (
5 "context"5 "context"
6+ "fmt"
6 "net"7 "net"
7 "os"8 "os"
8 "strconv"9 "strconv"
@@ -65,6 +66,12 @@ func NewService(
65 66
66 // GetTarget returns the proxy target for a given domain.67 // GetTarget returns the proxy target for a given domain.
67 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {68 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
69+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
70+ if !ok {
71+ return nil, domain.ErrNoTargetAvailable
72+ }
73+ domainName = canonicalDomain
74+
68 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",75 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
69 trace.WithAttributes(attribute.String("domain", domainName)))76 trace.WithAttributes(attribute.String("domain", domainName)))
70 defer func() {77 defer func() {
@@ -181,6 +188,9 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
181 if err != nil {188 if err != nil {
182 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})189 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
183 }190 }
191+ if port < 1 || port > 65535 {
192+ return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
193+ }
184 194
185 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked195 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
186 // network. We use the resolved IP as the proxy target to prevent DNS rebinding196 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
@@ -225,29 +235,44 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
225 235
226 // RegisterTarget registers a new proxy target for a domain.236 // RegisterTarget registers a new proxy target for a domain.
227 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {237 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
238+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
239+ if !ok {
240+ return domain.ErrRouteDomainInvalid
241+ }
242+
228 s.mu.Lock()243 s.mu.Lock()
229 defer s.mu.Unlock()244 defer s.mu.Unlock()
230 245
231- s.targets[domainName] = target246+ s.targets[canonicalDomain] = target
232 return nil247 return nil
233 }248 }
234 249
235 // UnregisterTarget removes a proxy target for a domain.250 // UnregisterTarget removes a proxy target for a domain.
236 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {251 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
252+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
253+ if !ok {
254+ return domain.ErrRouteDomainInvalid
255+ }
256+
237 s.mu.Lock()257 s.mu.Lock()
238 defer s.mu.Unlock()258 defer s.mu.Unlock()
239 259
240- delete(s.targets, domainName)260+ delete(s.targets, canonicalDomain)
241 return nil261 return nil
242 }262 }
243 263
244 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.264 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
245 // This is used during zero-downtime deployments to switch traffic to a new container.265 // This is used during zero-downtime deployments to switch traffic to a new container.
246 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {266 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
267+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
268+ if !ok {
269+ return
270+ }
271+
247 s.mu.Lock()272 s.mu.Lock()
248 defer s.mu.Unlock()273 defer s.mu.Unlock()
249 274
250- delete(s.targets, domainName)275+ delete(s.targets, canonicalDomain)
251 }276 }
252 277
253 // WaitForNoInFlight waits until no requests are currently proxied to the278 // WaitForNoInFlight waits until no requests are currently proxied to the
@@ -303,9 +328,31 @@ func (s *Service) UpdateConfig(config Config) {
303 328
304 // IsRegistryDomain returns true if the host matches the configured registry domain.329 // IsRegistryDomain returns true if the host matches the configured registry domain.
305 func (s *Service) IsRegistryDomain(host string) bool {330 func (s *Service) IsRegistryDomain(host string) bool {
331+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
332+ if !ok {
333+ return false
334+ }
335+
306 s.mu.RLock()336 s.mu.RLock()
307- defer s.mu.RUnlock()337+ registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
308- return s.config.RegistryDomain != "" && host == s.config.RegistryDomain338+ s.mu.RUnlock()
339+ return ok && canonicalHost == registryDomain
340+}
341+
342+// IsKnownHost returns true if host is configured as registry, route, or external route.
343+func (s *Service) IsKnownHost(ctx context.Context, host string) bool {
344+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
345+ if !ok {
346+ return false
347+ }
348+ if s.IsRegistryDomain(canonicalHost) {
349+ return true
350+ }
351+ if _, err := s.configSvc.GetRoute(ctx, canonicalHost); err == nil {
352+ return true
353+ }
354+ _, ok = s.configSvc.GetExternalRoutes()[canonicalHost]
355+ return ok
309 }356 }
310 357
311 // TrackInFlight records an in-flight request for a container.358 // TrackInFlight records an in-flight request for a container.
M internal/usecase/proxy/service_test.go +51 -17
@@ -47,6 +47,28 @@ func TestService_GetTarget_FromCache(t *testing.T) {
47 assert.Equal(t, cachedTarget, result)47 assert.Equal(t, cachedTarget, result)
48 }48 }
49 49
50+func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
51+ runtime := outmocks.NewMockContainerRuntime(t)
52+ containerSvc := inmocks.NewMockContainerService(t)
53+ configSvc := inmocks.NewMockConfigService(t)
54+ svc := NewService(runtime, containerSvc, configSvc, Config{})
55+ ctx := testContext()
56+
57+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59+
60+ result, err := svc.GetTarget(ctx, "App.Example.com")
61+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
62+ assert.Nil(t, result)
63+}
64+
65+func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
66+ svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{})
67+ result, err := svc.GetTarget(testContext(), "app.example.com:8080")
68+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
69+ assert.Nil(t, result)
70+}
71+
50 func TestService_GetTarget_ContainerNotFound(t *testing.T) {72 func TestService_GetTarget_ContainerNotFound(t *testing.T) {
51 runtime := outmocks.NewMockContainerRuntime(t)73 runtime := outmocks.NewMockContainerRuntime(t)
52 containerSvc := inmocks.NewMockContainerService(t)74 containerSvc := inmocks.NewMockContainerService(t)
@@ -169,22 +191,34 @@ func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
169 }191 }
170 192
171 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {193 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
172- runtime := outmocks.NewMockContainerRuntime(t)194+ tests := []struct {
173- containerSvc := inmocks.NewMockContainerService(t)195+ name string
174- configSvc := inmocks.NewMockConfigService(t)196+ target string
197+ }{
198+ {name: "not a number", target: "localhost:abc"},
199+ {name: "zero", target: "localhost:0"},
200+ {name: "too high", target: "localhost:65536"},
201+ }
175 202
176- svc := NewService(runtime, containerSvc, configSvc, Config{})203+ for _, tt := range tests {
177- ctx := testContext()204+ t.Run(tt.name, func(t *testing.T) {
205+ runtime := outmocks.NewMockContainerRuntime(t)
206+ containerSvc := inmocks.NewMockContainerService(t)
207+ configSvc := inmocks.NewMockConfigService(t)
178 208
179- // Mock external routes with invalid port209+ svc := NewService(runtime, containerSvc, configSvc, Config{})
180- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{210+ ctx := testContext()
181- "invalid.example.com": "localhost:abc",
182- })
183 211
184- result, err := svc.GetTarget(ctx, "invalid.example.com")212+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213+ "invalid.example.com": tt.target,
214+ })
185 215
186- assert.Error(t, err)216+ result, err := svc.GetTarget(ctx, "invalid.example.com")
187- assert.Nil(t, result)217+
218+ assert.Error(t, err)
219+ assert.Nil(t, result)
220+ })
221+ }
188 }222 }
189 223
190 func TestService_RegisterTarget(t *testing.T) {224 func TestService_RegisterTarget(t *testing.T) {
@@ -202,11 +236,11 @@ func TestService_RegisterTarget(t *testing.T) {
202 Scheme: "http",236 Scheme: "http",
203 }237 }
204 238
205- err := svc.RegisterTarget(ctx, "app.example.com", target)239+ err := svc.RegisterTarget(ctx, "App.Example.com", target)
206 240
207 assert.NoError(t, err)241 assert.NoError(t, err)
208 242
209- // Verify target is cached243+ // Verify target is cached under the canonical domain.
210 svc.mu.RLock()244 svc.mu.RLock()
211 cached := svc.targets["app.example.com"]245 cached := svc.targets["app.example.com"]
212 svc.mu.RUnlock()246 svc.mu.RUnlock()
@@ -228,7 +262,7 @@ func TestService_UnregisterTarget(t *testing.T) {
228 Port: 8080,262 Port: 8080,
229 }263 }
230 264
231- err := svc.UnregisterTarget(ctx, "app.example.com")265+ err := svc.UnregisterTarget(ctx, "App.Example.com")
232 266
233 assert.NoError(t, err)267 assert.NoError(t, err)
234 268
@@ -313,8 +347,8 @@ func TestService_InvalidateTarget(t *testing.T) {
313 ContainerID: "old-container",347 ContainerID: "old-container",
314 }348 }
315 349
316- // Invalidate the target350+ // Invalidate the target using mixed-case input.
317- svc.InvalidateTarget(ctx, "app.example.com")351+ svc.InvalidateTarget(ctx, "App.Example.com")
318 352
319 // Verify target is removed from cache353 // Verify target is removed from cache
320 svc.mu.RLock()354 svc.mu.RLock()
M internal/usecase/registry/service.go +2 -2
@@ -304,7 +304,7 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error)
304 }304 }
305 305
306 // AppendBlobChunk appends data to an in-progress blob upload.306 // AppendBlobChunk appends data to an in-progress blob upload.
307-func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) {307+func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
309 zerowrap.FieldLayer: "usecase",309 zerowrap.FieldLayer: "usecase",
310 zerowrap.FieldUseCase: "AppendBlobChunk",310 zerowrap.FieldUseCase: "AppendBlobChunk",
@@ -313,7 +313,7 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
313 })313 })
314 log := zerowrap.FromCtx(ctx)314 log := zerowrap.FromCtx(ctx)
315 315
316- length, err := s.blobStorage.AppendBlobChunk(name, uuid, data)316+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, data, contentLength, maxBlobSize)
317 if err != nil {317 if err != nil {
318 return 0, log.WrapErr(err, "failed to append blob chunk")318 return 0, log.WrapErr(err, "failed to append blob chunk")
319 }319 }
M internal/usecase/registry/service_test.go +32 -0
@@ -306,6 +306,38 @@ func TestService_StartUpload_Error(t *testing.T) {
306 assert.Contains(t, err.Error(), "failed to start blob upload")306 assert.Contains(t, err.Error(), "failed to start blob upload")
307 }307 }
308 308
309+func TestService_AppendBlobChunk_PassesMaxBlobSize(t *testing.T) {
310+ blobStorage := mocks.NewMockBlobStorage(t)
311+ manifestStorage := mocks.NewMockManifestStorage(t)
312+ eventBus := mocks.NewMockEventPublisher(t)
313+
314+ svc := NewService(blobStorage, manifestStorage, eventBus)
315+ ctx := testContext()
316+
317+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(4), int64(10)).Return(int64(4), nil)
318+
319+ length, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("data"), 4, 10)
320+
321+ assert.NoError(t, err)
322+ assert.Equal(t, int64(4), length)
323+}
324+
325+func TestService_AppendBlobChunk_MaxBlobSizeExceeded(t *testing.T) {
326+ blobStorage := mocks.NewMockBlobStorage(t)
327+ manifestStorage := mocks.NewMockManifestStorage(t)
328+ eventBus := mocks.NewMockEventPublisher(t)
329+
330+ svc := NewService(blobStorage, manifestStorage, eventBus)
331+ ctx := testContext()
332+
333+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(11), int64(10)).Return(int64(0), domain.ErrBlobSizeExceeded)
334+
335+ _, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("too large"), 11, 10)
336+
337+ require.Error(t, err)
338+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
339+}
340+
309 func TestService_FinishUpload_Success(t *testing.T) {341 func TestService_FinishUpload_Success(t *testing.T) {
310 blobStorage := mocks.NewMockBlobStorage(t)342 blobStorage := mocks.NewMockBlobStorage(t)
311 manifestStorage := mocks.NewMockManifestStorage(t)343 manifestStorage := mocks.NewMockManifestStorage(t)
M internal/usecase/volumes/service.go +8 -1
@@ -54,7 +54,7 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume
54 var removed []*domain.VolumeInfo54 var removed []*domain.VolumeInfo
55 55
56 for _, vol := range vols {56 for _, vol := range vols {
57- if vol.InUse {57+ if !isGordonManagedVolume(vol) || vol.InUse {
58 continue58 continue
59 }59 }
60 60
@@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume
72 72
73 return report, removed, nil73 return report, removed, nil
74 }74 }
75+
76+func isGordonManagedVolume(vol *domain.VolumeInfo) bool {
77+ if vol == nil || vol.Labels == nil {
78+ return false
79+ }
80+ return vol.Labels[domain.LabelManaged] == "true"
81+}
M internal/usecase/volumes/service_test.go +46 -5
@@ -31,8 +31,8 @@ func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) {
31 runtime := outmocks.NewMockContainerRuntime(t)31 runtime := outmocks.NewMockContainerRuntime(t)
32 32
33 vols := []*domain.VolumeInfo{33 vols := []*domain.VolumeInfo{
34- {Name: "vol-in-use", InUse: true, Containers: []string{"web"}},34+ {Name: "vol-in-use", InUse: true, Containers: []string{"web"}, Labels: map[string]string{domain.LabelManaged: "true"}},
35- {Name: "vol-orphaned", InUse: false, Size: 1024},35+ {Name: "vol-orphaned", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}},
36 }36 }
37 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)37 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
38 runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)38 runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)
@@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
50 runtime := outmocks.NewMockContainerRuntime(t)50 runtime := outmocks.NewMockContainerRuntime(t)
51 51
52 vols := []*domain.VolumeInfo{52 vols := []*domain.VolumeInfo{
53- {Name: "vol-orphaned", InUse: false, Size: 2048},53+ {Name: "vol-orphaned", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}},
54 }54 }
55 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)55 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
56 56
@@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
62 assert.Len(t, removed, 1)62 assert.Len(t, removed, 1)
63 }63 }
64 64
65+func TestService_PruneVolumes_OnlyRemovesUnusedGordonManagedVolumes(t *testing.T) {
66+ runtime := outmocks.NewMockContainerRuntime(t)
67+
68+ vols := []*domain.VolumeInfo{
69+ {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}},
70+ {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}},
71+ {Name: "other-unused", InUse: false, Size: 300},
72+ {Name: "other-labelled-false", InUse: false, Size: 400, Labels: map[string]string{domain.LabelManaged: "false"}},
73+ {Name: "other-used", InUse: true, Size: 500},
74+ }
75+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
76+ runtime.EXPECT().RemoveVolume(context.Background(), "gordon-unused", false).Return(nil)
77+
78+ svc := NewService(runtime)
79+ report, removed, err := svc.PruneVolumes(context.Background(), false)
80+ require.NoError(t, err)
81+ assert.Equal(t, 1, report.VolumesRemoved)
82+ assert.Equal(t, int64(200), report.SpaceReclaimed)
83+ require.Len(t, removed, 1)
84+ assert.Equal(t, "gordon-unused", removed[0].Name)
85+}
86+
87+func TestService_PruneVolumes_DryRunReportsOnlyUnusedGordonManagedVolumes(t *testing.T) {
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+
90+ vols := []*domain.VolumeInfo{
91+ {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}},
92+ {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}},
93+ {Name: "other-unused", InUse: false, Size: 300},
94+ }
95+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
96+
97+ svc := NewService(runtime)
98+ report, removed, err := svc.PruneVolumes(context.Background(), true)
99+ require.NoError(t, err)
100+ assert.Equal(t, 1, report.VolumesRemoved)
101+ assert.Equal(t, int64(200), report.SpaceReclaimed)
102+ require.Len(t, removed, 1)
103+ assert.Equal(t, "gordon-unused", removed[0].Name)
104+}
105+
65 func TestService_PruneVolumes_AllInUse(t *testing.T) {106 func TestService_PruneVolumes_AllInUse(t *testing.T) {
66 runtime := outmocks.NewMockContainerRuntime(t)107 runtime := outmocks.NewMockContainerRuntime(t)
67 108
@@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) {
91 runtime := outmocks.NewMockContainerRuntime(t)132 runtime := outmocks.NewMockContainerRuntime(t)
92 133
93 vols := []*domain.VolumeInfo{134 vols := []*domain.VolumeInfo{
94- {Name: "vol-fail", InUse: false, Size: 1024},135+ {Name: "vol-fail", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}},
95- {Name: "vol-ok", InUse: false, Size: 2048},136+ {Name: "vol-ok", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}},
96 }137 }
97 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)138 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
98 runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use"))139 runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use"))

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close