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:
2626 - name: Install Gordon
2727 id: install
2828 shell: bash
29+ env:
30+ GORDON_VERSION_INPUT: ${{ inputs.version }}
2931 run: |
3032 set -euo pipefail
3133
@@ -43,7 +45,11 @@ runs:
4345 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
4446 esac
4547
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
4753
4854 # Resolve 'latest' to an actual tag via redirect URL
4955 if [ "${version}" = "latest" ]; then
@@ -53,24 +59,67 @@ runs:
5359 echo "Failed to resolve latest Gordon version" >&2
5460 exit 1
5561 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
5666 fi
5767
5868 archive="gordon_${os}_${arch}.tar.gz"
59- url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"
60- dest="/tmp/${archive}"
69+ base_url="https://github.com/bnema/gordon/releases/download/${version}"
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"
6176 install_path="/usr/local/bin/gordon"
77+ trap 'rm -rf "${work_dir}"' EXIT
6278
6379 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
6480
65- # Download the release archive
81+ # Download the release archive and checksums
82+ curl -fsSL -o "${checksums}" "${checksums_url}"
6683 curl -fsSL -o "${dest}" "${url}"
6784
68- # Extract just the gordon binary and clean up
69- tar -xzf "${dest}" -C /usr/local/bin/ gordon
70- rm -f "${dest}"
85+ # Verify checksum before extraction
86+ archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')"
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
71116
72- # Make executable and verify
73- chmod +x "${install_path}"
117+ # Install with explicit executable permissions and verify
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
74123 gordon version
75124
76125 # 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 @@
22 # Multi-stage build for optimized container image
33
44 # Build stage
5-FROM golang:1.25-alpine AS builder
5+FROM golang:1.26.1-alpine3.22 AS builder
66
77 # Install build dependencies
88 RUN apk add --no-cache git ca-certificates tzdata
@@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
2626 -o gordon .
2727
2828 # Runtime stage
29-FROM alpine:latest
29+FROM alpine:3.22
3030
3131 # Install runtime dependencies
3232 RUN apk add --no-cache \
@@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
5656 USER gordon
5757
5858 # Expose ports
59-EXPOSE 8080 5000
59+EXPOSE 8088 5000
6060
61-# Health check
62-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
63- CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
61+# Admin health is served on the registry/admin listener and is gated by auth in
62+# normal deployments, so this image does not declare a Docker healthcheck.
6463
6564 # Default command
6665 CMD ["./gordon", "serve"]
M docs/cli/auth.md +2 -1
@@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
139139
140140 Registry scopes: `push`, `pull`, `push,pull`
141141
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`
143143
144144 Combine scopes with commas:
145145
@@ -280,6 +280,7 @@ Usage:
280280 | `admin:config:read` | Read-only config access |
281281 | `admin:config:write` | Config write access |
282282 | `admin:status:read` | Read-only status/health |
283+| `admin:logs:read` | Read-only logs access |
283284 | `admin:secrets:read` | List secret keys |
284285 | `admin:secrets:write` | Set/delete secrets |
285286
M docs/cli/config.md +8 -7
@@ -17,8 +17,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1717
1818 ## gordon config show
1919
20-Display the full Gordon server configuration including server settings,
21-auto-route, network isolation, routes, and external routes.
20+Display the Gordon server configuration including server settings,
21+auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default.
2222
2323 ```bash
2424 gordon config show
@@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
3939 "server": {
4040 "port": 1111,
4141 "registry_port": 5000,
42- "registry_domain": "reg.example.com",
43- "data_dir": "/var/lib/gordon"
42+ "registry_domain": "reg.example.com"
4443 },
4544 "auto_route": {
4645 "enabled": true,
@@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
5352 "routes": [
5453 {"domain": "app.example.com", "image": "myapp:latest"}
5554 ],
56- "external_routes": {
57- "reg.example.com": "localhost:5000"
58- }
55+ "external_routes": [
56+ {"domain": "reg.example.com"}
57+ ]
5958 }
6059 ```
6160
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+
6263 ### Auto-Route Allowed Domains
6364
6465 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]
235235 Remote targeting uses client config or an active remote by default.
236236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237237
238+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
239+
238240 ### Examples
239241
240242 ```bash
M docs/config/auth.md +3 -0
@@ -92,6 +92,9 @@ Admin scopes (for remote CLI):
9292 | `admin:config:read` | Read-only config access |
9393 | `admin:config:write` | Config write access |
9494 | `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 |
9598 | `admin:secrets:read` | List secret keys |
9699 | `admin:secrets:write` | Set/delete secrets |
97100
M docs/config/deploy.md +12 -0
@@ -98,6 +98,18 @@ container shutdown.
9898
9999 Default: `"30s"`
100100
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+
101113 ## Notes
102114
103115 - 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
1616 ## Configuration
1717
1818 ```toml
19+[images]
20+allowed_registries = []
21+require_digest = false
22+
1923 [images.prune]
2024 enabled = false
2125 schedule = "daily"
@@ -26,6 +30,8 @@ keep_last = 3
2630
2731 | Key | Type | Default | Description |
2832 |-----|------|---------|-------------|
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. |
2935 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
3036 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
3137 | `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
6363 drain_timeout = "30s" # Max wait for in-flight drain
6464 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
6565
66+# Container runtime profile
67+[containers]
68+security_profile = "compat" # compat or strict
69+
6670 # Logging
6771 [logging]
6872 level = "info" # trace, debug, info, warn, error
@@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal
106110 [network_isolation]
107111 enabled = true # Per-app isolated networks
108112 network_prefix = "gordon" # Network name prefix
113+internal = false # Set true to block direct egress from isolated networks
109114
110115 # Auto-route
111116 [auto_route]
@@ -138,6 +143,10 @@ weekly = 4
138143 monthly = 12
139144
140145 # Images
146+[images]
147+allowed_registries = [] # Explicit external registries allowed for deploy/attachments
148+require_digest = false # Require digests for allowlisted external registries
149+
141150 [images.prune]
142151 enabled = false
143152 schedule = "daily"
@@ -164,6 +173,7 @@ keep_last = 3
164173 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
165174 | `[backups]` | Database backups | [Backups](./backups.md) |
166175 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
176+| Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
167177
168178 ## Default Values
169179
@@ -172,6 +182,8 @@ keep_last = 3
172182 | `server.port` | `8088` |
173183 | `server.registry_port` | `5000` |
174184 | `server.data_dir` | `~/.gordon` |
185+| `server.max_blob_chunk_size` | `"95MB"` |
186+| `server.max_blob_size` | `"1GB"` |
175187 | `auth.enabled` | `true` |
176188 | `auth.secrets_backend` | `"unsafe"` |
177189 | `auth.token_expiry` | `"30d"` |
@@ -187,6 +199,7 @@ keep_last = 3
187199 | `deploy.drain_mode` | `"auto"` |
188200 | `deploy.drain_timeout` | `"30s"` |
189201 | `deploy.drain_delay` | `"2s"` |
202+| `containers.security_profile` | `"compat"` |
190203 | `logging.level` | `"info"` |
191204 | `logging.format` | `"console"` |
192205 | `logging.file.enabled` | `false` |
@@ -198,9 +211,12 @@ keep_last = 3
198211 | `volumes.prefix` | `"gordon"` |
199212 | `volumes.preserve` | `true` |
200213 | `network_isolation.enabled` | `true` |
214+| `network_isolation.internal` | `false` |
201215 | `auto_route.enabled` | `false` |
202216 | `backups.enabled` | `false` |
203217 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
218+| `images.allowed_registries` | `[]` |
219+| `images.require_digest` | `false` |
204220 | `images.prune.enabled` | `false` |
205221 | `images.prune.schedule` | `"daily"` |
206222 | `images.prune.keep_last` | `3` |
@@ -229,7 +245,6 @@ gordon reload
229245 | `server.gordon_domain` (registry domain) |
230246 | `server.registry_port` |
231247 | `server.max_proxy_body_size` |
232-| `server.max_blob_chunk_size` |
233248 | `server.max_proxy_response_size` |
234249 | `server.max_concurrent_conns` |
235250
@@ -241,6 +256,8 @@ gordon reload
241256 |---------|
242257 | `server.port` |
243258 | `server.data_dir` |
259+| `server.max_blob_chunk_size` |
260+| `server.max_blob_size` |
244261 | `auth.*` |
245262 | `deploy.readiness_mode` |
246263 | `deploy.readiness_delay` |
M docs/config/network-isolation.md +2 -0
@@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security.
88 [network_isolation]
99 enabled = true
1010 network_prefix = "gordon"
11+internal = false
1112 ```
1213
1314 ## Migration Note
@@ -26,6 +27,7 @@ enabled = false
2627 |--------|------|---------|-------------|
2728 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
2829 | `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. |
2931
3032 ## How It Works
3133
M docs/config/reference.md +19 -2
@@ -17,7 +17,8 @@ tls_key_file = "" # PEM key path (optional, must be s
1717 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)
1818 gordon_domain = "" # Required: Gordon domain (registry + API)
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
20-max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
20+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
2122 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
2223 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
2324 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"
9394 drain_timeout = "30s" # Max wait for in-flight request drain
9495 drain_delay = "2s" # Wait after cache invalidation before old stop
9596
97+# =============================================================================
98+# CONTAINERS
99+# =============================================================================
100+[containers]
101+security_profile = "compat" # "compat" or "strict"
102+
96103 # =============================================================================
97104 # AUTO-ROUTE
98105 # =============================================================================
@@ -105,6 +112,7 @@ enabled = false # Create routes from image labels a
105112 [network_isolation]
106113 enabled = true # Enable per-app Docker networks
107114 network_prefix = "gordon" # Prefix for created networks
115+internal = false # Create Docker internal networks (blocks direct egress)
108116
109117 # =============================================================================
110118 # VOLUMES
@@ -157,6 +165,10 @@ monthly = 0 # Keep N monthly backups per DB
157165 # =============================================================================
158166 # IMAGES
159167 # =============================================================================
168+[images]
169+allowed_registries = []
170+require_digest = false
171+
160172 [images.prune]
161173 enabled = false # Enable scheduled image cleanup
162174 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
@@ -178,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository
178190 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |
179191 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
180192 | `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 |
182195 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
183196 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
184197 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
@@ -215,9 +228,11 @@ keep_last = 3 # Keep N newest tags per repository
215228 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
216229 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
217230 | `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 |
218232 | `auto_route.enabled` | `false` | Auto-route disabled |
219233 | `network_isolation.enabled` | `true` | Network isolation enabled |
220234 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
235+| `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
221236 | `volumes.auto_create` | `true` | Auto-create volumes |
222237 | `volumes.prefix` | `"gordon"` | Volume prefix |
223238 | `volumes.preserve` | `true` | Keep volumes |
@@ -228,6 +243,8 @@ keep_last = 3 # Keep N newest tags per repository
228243 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
229244 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
230245 | `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 |
231248 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
232249 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
233250 | `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)
1414 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
1515 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1616 # 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
1719 ```
1820
1921 ## Options
@@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
3133 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
3234 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3335 | `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 |
3437 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
3538 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
3639 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
@@ -274,6 +277,22 @@ max_blob_chunk_size = "1GB" # Allow larger layers
274277 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
275278 - Increase it only if you expect larger direct registry uploads from other clients
276279
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+
277296 ## Registry IP Allowlist
278297
279298 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 (
110110 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
111111 go.opentelemetry.io/otel/metric v1.43.0
112112 go.opentelemetry.io/otel/trace v1.43.0
113- golang.org/x/sys v0.43.0 // indirect
113+ golang.org/x/sys v0.43.0
114114 golang.org/x/text v0.35.0 // indirect
115115 gopkg.in/yaml.v3 v3.0.1 // indirect
116116 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
101101
102102 # Verify checksum
103103 echo "Verifying checksum..."
104-EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}')
105-if [ -z "$EXPECTED_CHECKSUM" ]; then
106- echo "Error: Could not find checksum for ${TARBALL} in checksums.txt"
104+TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105+CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
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"
107109 exit 1
108110 fi
111+EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}')
109112
110113 # Calculate actual checksum (works on both Linux and macOS)
111114 if command -v sha256sum >/dev/null 2>&1; then
@@ -132,15 +135,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
132135 fi
133136 echo "Checksum verified successfully."
134137
135-echo "Extracting..."
136-tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR"
138+echo "Extracting Gordon binary..."
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
137148
138149 echo "Installing to ${INSTALL_DIR}..."
139150 if [ -w "$INSTALL_DIR" ]; then
140- mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon"
151+ install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
141152 else
142153 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"
144155 fi
145156
146157 # Verify installation
M internal/adapters/dto/admin_config.go +2 -2
@@ -14,7 +14,7 @@ type ServerConfig struct {
1414 Port int `json:"port"`
1515 RegistryPort int `json:"registry_port"`
1616 RegistryDomain string `json:"registry_domain"`
17- DataDir string `json:"data_dir"`
17+ DataDir string `json:"data_dir,omitempty"`
1818 }
1919
2020 // AutoRouteConfig represents auto-route config details.
@@ -31,5 +31,5 @@ type NetworkIsolationConfig struct {
3131 // ExternalRoute represents an external route config entry.
3232 type ExternalRoute struct {
3333 Domain string `json:"domain"`
34- Target string `json:"target"`
34+ Target string `json:"target,omitempty"`
3535 }
M internal/adapters/in/cli/auth.go +4 -1
@@ -84,13 +84,16 @@ Registry scopes (for Docker push/pull):
8484 Admin scopes (for remote CLI access):
8585 Format: admin:<resource>:<actions>
8686
87- Resources: routes, secrets, config, status, * (all)
87+ Resources: routes, secrets, config, status, logs, volumes, * (all)
8888 Actions: read, write, * (all)
8989
9090 Examples:
9191 admin:*:* Full admin access (recommended for CLI)
9292 admin:routes:read Read-only routes access
9393 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
9497
9598 Repository scoping:
9699 --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 {
9494 if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {
9595 return err
9696 }
97- if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {
98- return err
97+ if config.Server.DataDir != "" {
98+ if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {
99+ return err
100+ }
99101 }
100102 if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
101103 return err
@@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
155157 if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
156158 return err
157159 }
158- domains := make([]string, 0, len(config.ExternalRoutes))
159- for d := range config.ExternalRoutes {
160- domains = append(domains, d)
161- }
162- sort.Strings(domains)
160+ routes := append([]remote.ExternalRoute(nil), config.ExternalRoutes...)
161+ sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
163162
164- rows := make([][]string, 0, len(domains))
165- for _, d := range domains {
166- rows = append(rows, []string{d, config.ExternalRoutes[d]})
163+ rows := make([][]string, 0, len(routes))
164+ for _, route := range routes {
165+ target := route.Target
166+ if target == "" {
167+ target = "[redacted]"
168+ }
169+ rows = append(rows, []string{route.Domain, target})
167170 }
168171 table := components.NewTable(
169172 components.WithColumns([]components.TableColumn{
M internal/adapters/in/cli/controlplane_local.go +10 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "sort"
78 "time"
89
910 "github.com/bnema/gordon/internal/adapters/dto"
@@ -372,14 +373,21 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro
372373 if l.configSvc == nil {
373374 return nil, fmt.Errorf("config service unavailable")
374375 }
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+ })
375384 cfg := &remote.Config{
376385 Routes: l.configSvc.GetRoutes(ctx),
377- ExternalRoutes: l.configSvc.GetExternalRoutes(),
386+ ExternalRoutes: externalResponses,
378387 }
379388 cfg.Server.Port = l.configSvc.GetServerPort()
380389 cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
381390 cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
382- cfg.Server.DataDir = l.configSvc.GetDataDir()
383391 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
384392 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
385393 cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
M internal/adapters/in/cli/remote/client.go +9 -3
@@ -826,7 +826,7 @@ type Config struct {
826826 Port int `json:"port"`
827827 RegistryPort int `json:"registry_port"`
828828 RegistryDomain string `json:"registry_domain"`
829- DataDir string `json:"data_dir"`
829+ DataDir string `json:"data_dir,omitempty"`
830830 } `json:"server"`
831831 AutoRoute struct {
832832 Enabled bool `json:"enabled"`
@@ -835,8 +835,14 @@ type Config struct {
835835 Enabled bool `json:"enabled"`
836836 Prefix string `json:"prefix"`
837837 } `json:"network_isolation"`
838- Routes []domain.Route `json:"routes"`
839- ExternalRoutes map[string]string `json:"external_routes"`
838+ Routes []domain.Route `json:"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"`
840846 }
841847
842848 // 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) {
389389 addStep("route", "failed")
390390 h.sendError(w, http.StatusBadRequest, err.Error())
391391 return
392+ case errors.Is(err, domain.ErrRouteConflict):
393+ addStep("route", "failed")
394+ h.sendError(w, http.StatusConflict, err.Error())
395+ return
392396 default:
393397 addStep("route", "failed")
394398 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) {
594598 switch {
595599 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
596600 h.sendError(w, http.StatusBadRequest, err.Error())
601+ case errors.Is(err, domain.ErrRouteConflict):
602+ h.sendError(w, http.StatusConflict, err.Error())
597603 default:
598604 h.sendError(w, http.StatusInternalServerError, "failed to add route")
599605 }
@@ -1250,10 +1256,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
12501256
12511257 externalRoutes := h.configSvc.GetExternalRoutes()
12521258 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1253- for domain, target := range externalRoutes {
1259+ for domain := range externalRoutes {
12541260 externalResponses = append(externalResponses, dto.ExternalRoute{
12551261 Domain: domain,
1256- Target: target,
12571262 })
12581263 }
12591264
@@ -1262,7 +1267,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
12621267 Port: h.configSvc.GetServerPort(),
12631268 RegistryPort: h.configSvc.GetRegistryPort(),
12641269 RegistryDomain: h.configSvc.GetRegistryDomain(),
1265- DataDir: h.configSvc.GetDataDir(),
12661270 },
12671271 AutoRoute: dto.AutoRouteConfig{
12681272 Enabled: h.configSvc.IsAutoRouteEnabled(),
@@ -1319,12 +1323,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
13191323 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
13201324 var deployErr *domain.DeployFailureError
13211325 if errors.As(err, &deployErr) {
1322- h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{
1326+ response := dto.DeployErrorResponse{
13231327 Error: deployErr.Error(),
13241328 Cause: deployErr.Cause,
13251329 Hint: deployErr.Hint,
1326- Logs: deployErr.Logs,
1327- })
1330+ }
1331+ if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1332+ response.Logs = domain.RedactSecretLines(deployErr.Logs)
1333+ }
1334+ h.sendJSON(w, http.StatusInternalServerError, response)
13281335 return
13291336 }
13301337 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
14551462 log := zerowrap.FromCtx(ctx)
14561463
14571464 // Check read permission
1458- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1459- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1465+ if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1466+ h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
14601467 return
14611468 }
14621469
@@ -1522,7 +1529,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
15221529 return
15231530 }
15241531
1525- h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1532+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
15261533 }
15271534
15281535 // handleContainerLogs handles container logs for a specific domain.
@@ -1546,7 +1553,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
15461553
15471554 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
15481555 Domain: logDomain,
1549- Lines: logLines,
1556+ Lines: domain.RedactSecretLines(logLines),
15501557 })
15511558 }
15521559
@@ -1582,6 +1589,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
15821589 if !ok {
15831590 return
15841591 }
1592+ line = domain.RedactSecrets(line)
15851593 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
15861594 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
15871595 // 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
16281636 if !ok {
16291637 return
16301638 }
1639+ line = domain.RedactSecrets(line)
16311640 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
16321641 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
16331642 // 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 {
9898 return NewHandler(deps)
9999 }
100100
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+
101159 // Routes endpoint tests
102160
103161 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
@@ -1064,6 +1122,36 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
10641122
10651123 // Config endpoint tests
10661124
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+
10671155 func TestHandler_Config_RequiresReadScope(t *testing.T) {
10681156 configSvc := inmocks.NewMockConfigService(t)
10691157 authSvc := inmocks.NewMockAuthService(t)
@@ -1542,6 +1630,41 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
15421630 }
15431631 }
15441632
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+
15451668 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15461669 configSvc := inmocks.NewMockConfigService(t)
15471670 authSvc := inmocks.NewMockAuthService(t)
@@ -1553,8 +1676,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15531676 Cause: "image pull failed",
15541677 Hint: "push the image before retrying",
15551678 Logs: []string{
1556- "pulling manifest",
1557- "manifest unknown",
1679+ "pulling manifest PASSWORD=hunter2",
1680+ `{"TOKEN":"abc123"}`,
15581681 },
15591682 Err: errors.New("manifest unknown: internal registry detail"),
15601683 }
@@ -1566,7 +1689,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15661689 d.SecretSvc = secretSvc
15671690 })
15681691 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")))
15701693 }))
15711694 defer s.Close()
15721695
@@ -1582,8 +1705,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15821705 assert.NoError(t, err)
15831706
15841707 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))
15861709 assert.NotContains(t, string(body), "internal registry detail")
1710+ assert.NotContains(t, string(body), "hunter2")
1711+ assert.NotContains(t, string(body), "abc123")
15871712 }
15881713
15891714 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
@@ -2045,6 +2170,43 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
20452170 assert.Contains(t, rec.Body.String(), "completed")
20462171 }
20472172
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+
20482210 func TestHandler_BackupsDetectDomain(t *testing.T) {
20492211 configSvc := inmocks.NewMockConfigService(t)
20502212 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) {
1919
2020 ctx := r.Context()
2121
22- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
23- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
22+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) {
23+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read")
2424 return
2525 }
2626
@@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
6363
6464 ctx := r.Context()
6565
66- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
67- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
66+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionWrite) {
67+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:write")
6868 return
6969 }
7070
M internal/adapters/in/http/middleware/cidr.go +37 -5
@@ -5,12 +5,14 @@ import (
55 "fmt"
66 "net"
77 "net/http"
8+ "strconv"
89 "strings"
910
1011 "github.com/bnema/zerowrap"
1112
1213 "github.com/bnema/gordon/internal/adapters/dto"
1314 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
@@ -67,7 +69,7 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
6769 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
6870 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
6971 // 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 {
7173 return func(next http.Handler) http.Handler {
7274 if tlsPort == 0 {
7375 return next
@@ -88,7 +90,11 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
8890 }
8991 }
9092
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+ }
9298
9399 log.Debug().
94100 Str("target", target).
@@ -99,14 +105,40 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
99105 }
100106 }
101107
102-// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
103-func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
108+// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
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+
104132 path := requestURI
105133 if !strings.HasPrefix(path, "/") {
106134 path = "/"
107135 }
108136
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
110142 }
111143
112144 // 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
22
33 import (
44 "encoding/json"
5- "fmt"
65 "net"
76 "net/http"
87 "net/http/httptest"
@@ -118,7 +117,7 @@ func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
118117 w.WriteHeader(http.StatusOK)
119118 })
120119
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)
122121 srv := httptest.NewServer(handler)
123122 defer srv.Close()
124123
@@ -141,7 +140,7 @@ func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
141140 w.WriteHeader(http.StatusOK)
142141 })
143142
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)
145144 srv := httptest.NewServer(handler)
146145 defer srv.Close()
147146
@@ -164,7 +163,7 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
164163 w.WriteHeader(http.StatusOK)
165164 })
166165
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)
168167 srv := httptest.NewServer(handler)
169168 defer srv.Close()
170169
@@ -181,31 +180,62 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
181180 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))
182181 }
183182
184-func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) {
183+func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) {
185184 log := testLogger()
186185 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187186 w.WriteHeader(http.StatusOK)
188187 })
188+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
189189
190- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
191190 srv := httptest.NewServer(handler)
192191 defer srv.Close()
192+ client := srv.Client()
193+ client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
193194
194- // Use a raw TCP connection because Go's HTTP client rejects the
195- // invalid port in the Location header before CheckRedirect fires.
196- conn, err := net.Dial("tcp", srv.Listener.Addr().String())
195+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/path", nil)
197196 require.NoError(t, err)
198- defer conn.Close()
199-
200- _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n")
197+ req.Host = "O2.Bnema.Dev.:8088"
198+ resp, err := client.Do(req)
201199 require.NoError(t, err)
200+ defer resp.Body.Close()
202201
203- var buf [4096]byte
204- n, _ := conn.Read(buf[:])
205- raw := string(buf[:n])
202+ assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
203+ assert.Equal(t, "https://o2.bnema.dev:8443/path", resp.Header.Get("Location"))
204+}
206205
207- assert.Contains(t, raw, "308")
208- assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/")
206+func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) {
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+ }
209239 }
210240
211241 func TestProxyCIDRAllowlist(t *testing.T) {
M internal/adapters/in/http/proxy/handler.go +33 -3
@@ -4,6 +4,8 @@ package proxy
44 import (
55 "net"
66 "net/http"
7+ "strconv"
8+ "strings"
79 "sync/atomic"
810
911 "github.com/bnema/zerowrap"
@@ -70,16 +72,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7072 r = r.WithContext(ctx)
7173 log := zerowrap.FromCtx(ctx)
7274
75+ host := normalizeRequestHost(r.Host)
76+ if host == "" {
77+ proxyError(w, "Bad Request", http.StatusBadRequest)
78+ return
79+ }
80+
7381 // Check if this is the registry domain
74- if h.proxySvc.IsRegistryDomain(r.Host) {
82+ if h.proxySvc.IsRegistryDomain(host) {
7583 log.Debug().Msg("routing request to registry")
7684 h.forwardToRegistry(w, r, cfg.RegistryPort)
7785 return
7886 }
7987
8088 // Get target for this domain
81- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
82- target, err := h.proxySvc.GetTarget(ctx, r.Host)
89+ log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target")
90+ target, err := h.proxySvc.GetTarget(ctx, host)
8391 if err != nil {
8492 log.Warn().Err(err).Msg("no route found for domain")
8593 proxyError(w, "404 page not found", http.StatusNotFound)
@@ -94,3 +102,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
94102
95103 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
96104 }
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 (
1111 "github.com/bnema/zerowrap"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
1415
1516 "github.com/bnema/gordon/internal/boundaries/in"
1617 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
@@ -61,6 +62,58 @@ func TestHandler_RoutesToRegistry(t *testing.T) {
6162 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
6263 }
6364
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+
64117 func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65118 proxySvc := inmocks.NewMockProxyService(t)
66119
M internal/adapters/in/http/registry/handler.go +73 -12
@@ -2,6 +2,7 @@
22 package registry
33
44 import (
5+ "context"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -9,6 +10,7 @@ import (
910 "net/http"
1011 "strconv"
1112 "strings"
13+ "sync/atomic"
1214
1315 "github.com/bnema/zerowrap"
1416
@@ -26,13 +28,21 @@ const (
2628 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.
2729 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.
2830 DefaultMaxBlobChunkSize = 95 * 1024 * 1024
31+ // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload.
32+ DefaultMaxBlobSize = 1024 * 1024 * 1024
2933 )
3034
35+// blobLimits holds consistent blob size limits.
36+type blobLimits struct {
37+ MaxChunkSize int64
38+ MaxBlobSize int64
39+}
40+
3141 // Handler implements the HTTP handler for Docker Registry API v2.
3242 type Handler struct {
33- registrySvc in.RegistryService
34- log zerowrap.Logger
35- maxBlobChunkSize int64
43+ registrySvc in.RegistryService
44+ log zerowrap.Logger
45+ blobLimits atomic.Value // stores *blobLimits
3646 }
3747
3848 // NewHandler creates a new registry HTTP handler.
@@ -40,16 +50,39 @@ func NewHandler(
4050 registrySvc in.RegistryService,
4151 log zerowrap.Logger,
4252 maxBlobChunkSize int64,
53+ maxBlobSize ...int64,
4354 ) *Handler {
4455 if maxBlobChunkSize <= 0 {
4556 maxBlobChunkSize = DefaultMaxBlobChunkSize
4657 }
58+ blobSizeLimit := int64(DefaultMaxBlobSize)
59+ if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 {
60+ blobSizeLimit = maxBlobSize[0]
61+ }
4762
48- return &Handler{
49- registrySvc: registrySvc,
50- log: log,
51- maxBlobChunkSize: maxBlobChunkSize,
63+ h := &Handler{
64+ registrySvc: registrySvc,
65+ log: log,
5266 }
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+ })
5386 }
5487
5588 // RegisterRoutes registers the registry routes on the given mux.
@@ -244,7 +277,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
244277 }
245278 }
246279
247-func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {
280+func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
248281 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
249282 w.WriteHeader(http.StatusOK)
250283 }
@@ -407,20 +440,36 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
407440 }
408441 }
409442
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+
410448 // Limit blob chunk size to prevent excessive uploads.
411449 // 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)
413451
414452 // Stream the body directly to storage — memory usage is bounded to a small
415453 // 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)
417455 if err != nil {
418456 var maxBytesErr *http.MaxBytesError
419457 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+ }
421462 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
422463 return
423464 }
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+ }
424473 log.Error().Err(err).Msg("failed to append blob chunk")
425474 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
426475 return
@@ -430,7 +479,9 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
430479 if r.Method == "PUT" && digest != "" {
431480 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
432481 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+ }
434485 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
435486 return
436487 }
@@ -450,6 +501,16 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
450501 w.WriteHeader(http.StatusAccepted)
451502 }
452503
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+
453514 func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
454515 ctx := r.Context()
455516 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) {
293293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
294294
295295 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)
297297
298298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
299299 rec := httptest.NewRecorder()
@@ -310,7 +310,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
310310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
311311
312312 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)
314314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
315315
316316 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) {
432432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
433433
434434 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)
436436
437437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
438438 rec := httptest.NewRecorder()
@@ -453,12 +453,14 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.
453453
454454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.
455455 // 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).
457- Run(func(_ context.Context, _ string, _ string, data io.Reader) {
458- // Drain the reader to trigger MaxBytesError
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, _ int64, _ int64) {
458+ // Drain the reader to trigger MaxBytesError after the storage layer may
459+ // have written the limit-sized prefix.
459460 _, _ = io.ReadAll(data)
460461 }).
461462 Return(int64(0), &http.MaxBytesError{Limit: 5})
463+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
462464
463465 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
464466 rec := httptest.NewRecorder()
@@ -473,7 +475,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
473475 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
474476
475477 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)
477479
478480 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
479481 rec := httptest.NewRecorder()
@@ -484,3 +486,77 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
484486 // OCI spec: Range header should reflect total received bytes
485487 assert.Equal(t, "0-12", rec.Header().Get("Range"))
486488 }
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
161161 WorkingDir: config.WorkingDir,
162162 Cmd: config.Cmd,
163163 Labels: config.Labels,
164+ User: config.User,
164165 }
165166
166167 resources := container.Resources{
@@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
173174 if config.PidsLimit > 0 {
174175 resources.PidsLimit = &config.PidsLimit
175176 }
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+ }
176185 hostConfig := &container.HostConfig{
177- PortBindings: portBindings,
178- AutoRemove: config.AutoRemove,
179- Binds: binds,
180- NetworkMode: container.NetworkMode(config.NetworkMode),
181- Resources: resources,
182- SecurityOpt: []string{"no-new-privileges:true"},
183- CapDrop: strslice.StrSlice{"ALL"},
184- // Re-add the minimal set of capabilities that standard images need.
185- // CHOWN/FOWNER/DAC_OVERRIDE: postgres, mysql etc. chown/chmod data dirs
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"},
186+ PortBindings: portBindings,
187+ AutoRemove: config.AutoRemove,
188+ Binds: binds,
189+ NetworkMode: container.NetworkMode(config.NetworkMode),
190+ Resources: resources,
191+ SecurityOpt: []string{"no-new-privileges:true"},
192+ CapDrop: capDrop,
193+ CapAdd: capAdd,
194+ ReadonlyRootfs: config.ReadOnlyRootFS,
190195 }
191196
192197 // Create network configuration for container
@@ -1251,7 +1256,7 @@ func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, erro
12511256 }
12521257
12531258 // 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 {
12551260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
12561261 zerowrap.FieldLayer: "adapter",
12571262 zerowrap.FieldAdapter: "docker",
@@ -1260,24 +1265,20 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st
12601265 })
12611266 log := zerowrap.FromCtx(ctx)
12621267
1263- // Set default driver to bridge if not specified
1264- driver := "bridge"
1265- if driverOption, exists := options["driver"]; exists {
1266- driver = driverOption
1268+ driver := config.Driver
1269+ if driver == "" {
1270+ driver = "bridge"
12671271 }
1268-
1269- createOptions := network.CreateOptions{
1270- Driver: driver,
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- },
1272+ labels := make(map[string]string, len(config.Labels)+1)
1273+ for key, value := range config.Labels {
1274+ labels[key] = value
12741275 }
1276+ labels[domain.LabelManaged] = "true"
12751277
1276- // Add any additional options to labels
1277- for key, value := range options {
1278- if key != "driver" {
1279- createOptions.Labels["gordon."+key] = value
1280- }
1278+ createOptions := network.CreateOptions{
1279+ Driver: driver,
1280+ Internal: config.Internal,
1281+ Labels: labels,
12811282 }
12821283
12831284 _, err := r.client.NetworkCreate(ctx, name, createOptions)
@@ -1503,10 +1504,7 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
15031504 // extractFileFromTar extracts a single file from a tar archive.
15041505 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
15051506 tr := tar.NewReader(reader)
1506-
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)
1507+ targetName := normalizedTarPath(targetPath)
15101508
15111509 for {
15121510 header, err := tr.Next()
@@ -1517,23 +1515,19 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15171515 return nil, err
15181516 }
15191517
1520- // Check if this is our target file
1521- if header.Typeflag == tar.TypeReg {
1522- headerName := filepath.Base(header.Name)
1523- if headerName == targetName {
1524- size := header.Size
1525- pr, pw := io.Pipe()
1526- go func() {
1527- defer reader.Close()
1528- _, copyErr := io.CopyN(pw, tr, size)
1529- if copyErr != nil {
1530- _ = pw.CloseWithError(copyErr)
1531- return
1532- }
1533- _ = pw.Close()
1534- }()
1535- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1536- }
1518+ if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1519+ size := header.Size
1520+ pr, pw := io.Pipe()
1521+ go func() {
1522+ defer reader.Close()
1523+ _, copyErr := io.CopyN(pw, tr, size)
1524+ if copyErr != nil {
1525+ _ = pw.CloseWithError(copyErr)
1526+ return
1527+ }
1528+ _ = pw.Close()
1529+ }()
1530+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
15371531 }
15381532 }
15391533
@@ -1541,6 +1535,25 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15411535 return nil, fmt.Errorf("file not found in container: %s", targetPath)
15421536 }
15431537
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+
15441557 // ExtractEnvFileFromImage extracts an env file from an image.
15451558 // This creates a temporary container, copies the file, and removes the container.
15461559 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
15831596 }
15841597 defer reader.Close()
15851598
1586- data, err := io.ReadAll(reader)
1599+ data, err := readExtractedEnvFile(reader)
15871600 if err != nil {
15881601 return nil, log.WrapErr(err, "failed to read extracted env file")
15891602 }
M internal/adapters/out/docker/runtime_exec_test.go +30 -0
@@ -1,9 +1,12 @@
11 package docker
22
33 import (
4+ "archive/tar"
45 "bytes"
56 "context"
67 "encoding/binary"
8+ "io"
9+ "strings"
710 "testing"
811
912 "github.com/stretchr/testify/assert"
@@ -47,3 +50,30 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
4750 copy(frame[8:], payload)
4851 return frame
4952 }
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 (
88 "regexp"
99 "sort"
1010 "strings"
11+ "sync"
1112 "time"
1213
1314 "github.com/bnema/zerowrap"
@@ -27,6 +28,7 @@ const (
2728
2829 // PassStore implements the DomainSecretStore interface using the pass password manager.
2930 type PassStore struct {
31+ mu sync.Mutex
3032 timeout time.Duration
3133 log zerowrap.Logger
3234 }
@@ -138,6 +140,26 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
138140 return secretsMap, nil
139141 }
140142
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+
141163 // Set sets or updates multiple secrets for a domain.
142164 func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
143165 if _, err := s.domainPath(domainName); err != nil {
@@ -253,6 +275,26 @@ func (s *PassStore) Delete(domainName, key string) error {
253275 return nil
254276 }
255277
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+
256298 // SetAttachment sets or updates multiple secrets for an attachment container.
257299 func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
258300 if _, err := s.attachmentPath(containerName); err != nil {
@@ -613,6 +655,66 @@ func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
613655 keys = append(keys, key)
614656 }
615657
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)
616718 return keys, nil
617719 }
618720
@@ -778,6 +880,15 @@ func passEntryMissing(output string) bool {
778880 strings.Contains(lower, "password store is empty")
779881 }
780882
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+
781892 func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
782893 mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
783894 for _, key := range primary {
M internal/adapters/out/filesystem/backup_storage.go +36 -4
@@ -2,6 +2,8 @@ package filesystem
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "errors"
68 "fmt"
79 "io"
@@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
6264 return "", fmt.Errorf("failed to create backup path: %w", err)
6365 }
6466
65- fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak"
67+ fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak"
68+ fileName = sanitizeBackupFileName(fileName)
6669 finalPath := filepath.Join(backupDir, fileName)
6770 tmpPath := finalPath + ".tmp"
6871
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)
7073 if err != nil {
7174 return "", fmt.Errorf("failed to create temp backup file: %w", err)
7275 }
@@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
118121 if walkErr != nil {
119122 return walkErr
120123 }
121- if d.IsDir() {
124+ if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
122125 return nil
123126 }
124127
@@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
144147
145148 startedAt := info.ModTime().UTC()
146149 base := filepath.Base(path)
147- if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
150+ if ts, ok := parseBackupStartedAt(base); ok {
148151 startedAt = ts.UTC()
149152 }
150153
@@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch
267270 }
268271 }
269272
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+
270302 func sanitizeBackupPathComponent(input string) string {
271303 clean := strings.TrimSpace(input)
272304 if clean == "" {
M internal/adapters/out/filesystem/backup_storage_test.go +34 -1
@@ -31,6 +31,39 @@ func TestBackupStorage_StoreAndGet(t *testing.T) {
3131 assert.Equal(t, payload, data)
3232 }
3333
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+
3467 func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {
3568 storage, err := NewBackupStorage(t.TempDir(), testLogger())
3669 require.NoError(t, err)
@@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) {
71104 require.NoError(t, err)
72105
73106 base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)
74- for i := 0; i < 4; i++ {
107+ for i := range 4 {
75108 _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))
76109 require.NoError(t, err)
77110 }
M internal/adapters/out/filesystem/blob.go +24 -2
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/zerowrap"
1818 "github.com/google/uuid"
1919
20+ "github.com/bnema/gordon/internal/domain"
2021 "github.com/bnema/gordon/pkg/validation"
2122 )
2223
@@ -254,7 +255,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
254255 }
255256
256257 // 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) {
258259 uploadPath, err := s.getUploadPath(uuid)
259260 if err != nil {
260261 return 0, fmt.Errorf("invalid upload path: %w", err)
@@ -274,12 +275,33 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
274275 }
275276 defer file.Close()
276277
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+
277292 written, err := io.Copy(file, data)
278293 if err != nil {
279294 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
280295 }
281296
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()
283305 if err != nil {
284306 return 0, fmt.Errorf("failed to get file info: %w", err)
285307 }
M internal/adapters/out/filesystem/blob_test.go +95 -12
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "crypto/sha256"
66 "encoding/hex"
7+ "errors"
78 "io"
89 "os"
910 "path/filepath"
@@ -13,6 +14,8 @@ import (
1314 "github.com/bnema/zerowrap"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
1619 )
1720
1821 const (
@@ -26,6 +29,15 @@ func testLogger() zerowrap.Logger {
2629 return zerowrap.Default()
2730 }
2831
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+
2941 func TestNewBlobStorage(t *testing.T) {
3042 tmpDir := t.TempDir()
3143 log := testLogger()
@@ -280,13 +292,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) {
280292
281293 // Append first chunk
282294 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)
284296 require.NoError(t, err)
285297 assert.Equal(t, int64(len(chunk1)), size1)
286298
287299 // Append second chunk
288300 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)
290302 require.NoError(t, err)
291303 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
292304 }
@@ -298,12 +310,83 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
298310 storage, err := NewBlobStorage(tmpDir, log)
299311 require.NoError(t, err)
300312
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)
302314
303315 assert.Error(t, err)
304316 assert.Contains(t, err.Error(), "upload not found")
305317 }
306318
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+
307390 func TestBlobStorage_GetBlobUpload(t *testing.T) {
308391 tmpDir := t.TempDir()
309392 log := testLogger()
@@ -350,7 +433,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) {
350433
351434 // Append data - content that hashes to testDigest1
352435 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)
354437 require.NoError(t, err)
355438
356439 // Finish upload - use testDigest1 (validation will pass, but hash won't match)
@@ -477,7 +560,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
477560
478561 var totalSize int64
479562 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)
481564 require.NoError(t, err)
482565 totalSize = size
483566 }
@@ -501,7 +584,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
501584 require.NoError(t, err)
502585
503586 initialData := []byte("initial-")
504- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))
587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData), -1, 0)
505588 require.NoError(t, err)
506589
507590 appendChunk := []byte("chunk")
@@ -516,7 +599,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
516599 go func() {
517600 defer wg.Done()
518601 <-appendReady
519- _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))
602+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk), -1, 0)
520603 appendErrs <- appendErr
521604 }()
522605 }
@@ -570,7 +653,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
570653 assert.Equal(t, fullyAppended, blobData)
571654 }
572655
573- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))
656+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")), -1, 0)
574657 assert.Error(t, err)
575658 assert.Contains(t, err.Error(), "upload not found")
576659 }
@@ -586,13 +669,13 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
586669 require.NoError(t, err)
587670
588671 blobData := []byte("finalized content")
589- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
672+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
590673 require.NoError(t, err)
591674
592675 err = storage.FinishBlobUpload(uuid, digestForData(blobData))
593676 require.NoError(t, err)
594677
595- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))
678+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")), -1, 0)
596679 assert.Error(t, err)
597680 assert.Contains(t, err.Error(), "upload not found")
598681 }
@@ -608,7 +691,7 @@ func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
608691 require.NoError(t, err)
609692
610693 blobData := []byte("retryable finalize")
611- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
694+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
612695 require.NoError(t, err)
613696
614697 err = storage.FinishBlobUpload(uuid, testDigest1)
@@ -638,7 +721,7 @@ func TestBlobStorage_SequentialFlow(t *testing.T) {
638721 var blobData []byte
639722 for _, chunk := range chunks {
640723 blobData = append(blobData, chunk...)
641- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))
724+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk), -1, 0)
642725 require.NoError(t, err)
643726 }
644727
M internal/app/migrate_env.go +76 -71
@@ -1,6 +1,7 @@
11 package app
22
33 import (
4+ "errors"
45 "fmt"
56 "os"
67 "path/filepath"
@@ -79,59 +80,46 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log
7980 return nil
8081 }
8182
82- existingKeys, err := passStore.ListKeys(domainName)
83+ writtenKeys, err := passStore.SetIfEmpty(domainName, secrets)
8384 if err != nil {
84- return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err)
85- }
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)
85+ if errors.Is(err, domain.ErrSecretsAlreadyExist) {
86+ return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
9187 }
92- }
93-
94- migratedPath := filePath + ".migrated"
95- if err := os.Rename(filePath, migratedPath); err != nil {
96- return fmt.Errorf("failed to rename env file %s: %w", filePath, err)
88+ return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
89+ }
90+
91+ if err := os.Remove(filePath); err != nil {
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)
97113 }
98114
99115 log.Info().
100- Int(zerowrap.FieldCount, len(missing)).
116+ Int(zerowrap.FieldCount, len(secrets)).
101117 Str("domain", domainName).
102- Msg("migrated secrets for domain from plain text to pass")
103- log.Info().
104- Str("file", migratedPath).
105- Msg("original file renamed to .env.migrated - you can safely remove it")
118+ Msg("migrated secrets for domain from plain text to pass and removed original env file")
106119
107120 return nil
108121 }
109122
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-
135123 func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
136124 entries, err := readEnvDir(envDir)
137125 if err != nil {
@@ -180,39 +168,43 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass
180168 return nil
181169 }
182170
183- existingSecrets, err := passStore.GetAllAttachment(containerName)
171+ writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets)
184172 if err != nil {
185- return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err)
186- }
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)
173+ if errors.Is(err, domain.ErrSecretsAlreadyExist) {
174+ return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
197175 }
198- log.Info().
199- Int(zerowrap.FieldCount, len(missing)).
200- Str("container", containerName).
201- Msg("migrated secrets for attachment container from plain text to pass")
202- } else {
203- log.Info().
204- Str("container", containerName).
205- Msg("all attachment secrets already exist in pass; no migration needed")
206- }
207-
208- migratedPath := filePath + ".migrated"
209- if err := os.Rename(filePath, migratedPath); err != nil {
210- return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err)
176+ return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
177+ }
178+
179+ if err := os.Remove(filePath); err != nil {
180+ // Check if the stored secrets match what we just wrote
181+ storedSecrets, getErr := passStore.GetAllAttachment(containerName)
182+ if getErr == nil && secretsMatch(storedSecrets, secrets) {
183+ // Migration was successful, removal failed but secrets are stored
184+ log.Warn().
185+ Str("file", filePath).
186+ Str("container", containerName).
187+ Msg("attachment secrets migrated successfully but failed to remove original file; treating as success")
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)
211201 }
212202
213203 log.Info().
214- Str("file", migratedPath).
215- Msg("original attachment file renamed to .env.migrated - you can safely remove it")
204+ Int(zerowrap.FieldCount, len(secrets)).
205+ Str("container", containerName).
206+ Str("file", filePath).
207+ Msg("migrated attachment secrets to pass and removed original env file")
216208
217209 return nil
218210 }
@@ -221,3 +213,16 @@ func extractContainerNameFromAttachmentFile(filename string) string {
221213 name := strings.TrimSuffix(filename, ".env")
222214 return name
223215 }
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) {
7171 err = migrateEnvFile(tmpDir, envFileName, store, logger)
7272 require.NoError(t, err)
7373
74- migratedPath := envFilePath + ".migrated"
7574 _, err = os.Stat(envFilePath)
7675 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
7776
78- _, err = os.Stat(migratedPath)
79- assert.NoError(t, err, ".env.migrated file should exist")
77+ _, err = os.Stat(envFilePath + ".migrated")
78+ assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
8079
8180 passKeys, err := store.ListKeys(domainName)
8281 require.NoError(t, err)
@@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
125124 err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
126125 require.NoError(t, err)
127126
128- migratedPath := envFilePath + ".migrated"
129127 _, err = os.Stat(envFilePath)
130128 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
131129
132- _, err = os.Stat(migratedPath)
133- assert.NoError(t, err, ".env.migrated file should exist")
130+ _, err = os.Stat(envFilePath + ".migrated")
131+ assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
134132
135133 values, err := store.GetAllAttachment(storedContainerName)
136134 require.NoError(t, err)
M internal/app/run.go +143 -27
@@ -23,6 +23,7 @@ import (
2323 "github.com/bnema/zerowrap"
2424 zerowrapotel "github.com/bnema/zerowrap/otel"
2525 "github.com/spf13/viper"
26+ "golang.org/x/sys/unix"
2627
2728 // Adapters - Output
2829 "github.com/bnema/gordon/internal/adapters/out/accesslog"
@@ -96,6 +97,7 @@ type Config struct {
9697 DataDir string `mapstructure:"data_dir"`
9798 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
9899 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
100+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
99101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
100102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
101103 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
@@ -170,7 +172,9 @@ type Config struct {
170172 } `mapstructure:"backups"`
171173
172174 Images struct {
173- Prune struct {
175+ AllowedRegistries []string `mapstructure:"allowed_registries"`
176+ RequireDigest bool `mapstructure:"require_digest"`
177+ Prune struct {
174178 Enabled bool `mapstructure:"enabled"`
175179 Schedule string `mapstructure:"schedule"`
176180 KeepLast int `mapstructure:"keep_last"`
@@ -178,9 +182,10 @@ type Config struct {
178182 } `mapstructure:"images"`
179183
180184 Containers struct {
181- MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
182- CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
183- PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
185+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
186+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
187+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
188+ SecurityProfile string `mapstructure:"security_profile"` // compat or strict
184189 } `mapstructure:"containers"`
185190
186191 Telemetry telemetry.Config `mapstructure:"telemetry"`
@@ -215,9 +220,13 @@ type services struct {
215220 previewService *preview.Service
216221 envDir string
217222 maxBlobChunkSize int64
223+ maxBlobSize int64
218224 caAdapter *pkiadapter.CA
219225 pkiSvc *pkiusecase.Service
220226 reloadCoordinator *reloadCoordinator
227+ registryHandler interface {
228+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
229+ }
221230 }
222231
223232 // Run initializes and starts the Gordon application.
@@ -460,7 +469,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
460469 return nil, err
461470 }
462471
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)
464473
465474 si.initHandlers()
466475
@@ -526,6 +535,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
526535 return err
527536 }
528537 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
538+ si.svc.maxBlobSize = proxyCfg.maxBlobSize
529539 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
530540
531541 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
@@ -1333,22 +1343,87 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
13331343 provider := secrets.NewSopsProvider(log)
13341344 return provider.GetSecret(ctx, path)
13351345 case domain.SecretsBackendUnsafe:
1336- // For unsafe backend, path is relative to dataDir/secrets/
1337- secretFile := filepath.Join(dataDir, "secrets", 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
1346+ // For unsafe backend, path is relative to dataDir/secrets/.
1347+ return readUnsafeSecret(dataDir, path)
13431348 default:
13441349 return "", fmt.Errorf("unknown secrets backend: %s", backend)
13451350 }
13461351 }
13471352
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+
13481422 // proxyConfigResult holds parsed proxy and blob chunk size config.
13491423 type proxyConfigResult struct {
13501424 proxyConfig proxy.Config
13511425 maxBlobChunkSize int64
1426+ maxBlobSize int64
13521427 }
13531428
13541429 type configWatcher interface {
@@ -1376,24 +1451,38 @@ type reloadCoordinator struct {
13761451 lastRun time.Time
13771452 debounce time.Duration
13781453
1379- configSvc configReloader
1380- v *viper.Viper
1381- proxySvc proxyConfigUpdater
1382- eventBus out.EventPublisher
1383- log zerowrap.Logger
1454+ configSvc configReloader
1455+ v *viper.Viper
1456+ proxySvc proxyConfigUpdater
1457+ registryLimits interface {
1458+ UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1459+ }
1460+ eventBus out.EventPublisher
1461+ log zerowrap.Logger
13841462 }
13851463
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 {
13871467 return &reloadCoordinator{
1388- debounce: 500 * time.Millisecond,
1389- configSvc: configSvc,
1390- v: v,
1391- proxySvc: proxySvc,
1392- eventBus: eventBus,
1393- log: log,
1468+ debounce: 500 * time.Millisecond,
1469+ configSvc: configSvc,
1470+ v: v,
1471+ proxySvc: proxySvc,
1472+ registryLimits: registryLimits,
1473+ eventBus: eventBus,
1474+ log: log,
13941475 }
13951476 }
13961477
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+
13971486 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
13981487 c.mu.Lock()
13991488 defer c.mu.Unlock()
@@ -1443,6 +1532,9 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
14431532 }
14441533
14451534 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1535+ if c.registryLimits != nil {
1536+ c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1537+ }
14461538
14471539 if c.eventBus != nil {
14481540 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
@@ -1477,6 +1569,15 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
14771569 maxBlobChunkSize = parsedSize
14781570 }
14791571
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+
14801581 maxProxyResponseSize := int64(1 << 30) // 1GB default
14811582 if cfg.Server.MaxProxyResponseSize != "" {
14821583 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
@@ -1501,6 +1602,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
15011602 MaxConcurrentConns: maxConcurrentConns,
15021603 },
15031604 maxBlobChunkSize: maxBlobChunkSize,
1605+ maxBlobSize: maxBlobSize,
15041606 }, nil
15051607 }
15061608
@@ -1544,7 +1646,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
15441646 NetworkIsolation: v.GetBool("network_isolation.enabled"),
15451647 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
15461648 NetworkGroups: attachmentConfig.NetworkGroups,
1649+ NetworkInternal: v.GetBool("network_isolation.internal"),
15471650 Attachments: attachmentConfig.Attachments,
1651+ AllowedRegistries: cfg.Images.AllowedRegistries,
1652+ RequireImageDigest: cfg.Images.RequireDigest,
1653+ SecurityProfile: cfg.Containers.SecurityProfile,
15481654 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
15491655 ReadinessMode: v.GetString("deploy.readiness_mode"),
15501656 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -1758,8 +1864,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
17581864 // This ensures consistent IP extraction across logging, rate limiting, and auth.
17591865 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
17601866
1761- // Registry handler (unchanged)
1762- registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1867+ // Registry handler
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+ }
17631873 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
17641874 svc,
17651875 cfg,
@@ -1783,7 +1893,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
17831893 middleware.PanicRecovery(log),
17841894 middleware.RequestLogger(log, trustedNets),
17851895 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+ }),
17871899 }
17881900 if proxyCIDRMiddleware != nil {
17891901 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -2889,6 +3001,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
28893001 v.SetDefault("auto_route.enabled", false)
28903002 v.SetDefault("network_isolation.enabled", true)
28913003 v.SetDefault("network_isolation.network_prefix", "gordon")
3004+ v.SetDefault("network_isolation.internal", false)
28923005 v.SetDefault("volumes.auto_create", true)
28933006 v.SetDefault("volumes.prefix", "gordon")
28943007 v.SetDefault("volumes.preserve", true)
@@ -2900,9 +3013,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
29003013 v.SetDefault("backups.retention.daily", 0)
29013014 v.SetDefault("backups.retention.weekly", 0)
29023015 v.SetDefault("backups.retention.monthly", 0)
3016+ v.SetDefault("images.allowed_registries", []string{})
3017+ v.SetDefault("images.require_digest", false)
29033018 v.SetDefault("images.prune.enabled", false)
29043019 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
29053020 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
3021+ v.SetDefault("containers.security_profile", "compat")
29063022 v.SetDefault("telemetry.enabled", false)
29073023 v.SetDefault("telemetry.endpoint", "")
29083024 v.SetDefault("telemetry.auth_token", "")
M internal/app/run_auth_config_test.go +85 -1
@@ -2,13 +2,18 @@ package app
22
33 import (
44 "context"
5+ "os"
6+ "path/filepath"
7+ "runtime"
58 "testing"
69
7- "github.com/bnema/gordon/internal/domain"
810 "github.com/bnema/zerowrap"
911 "github.com/spf13/viper"
1012 "github.com/stretchr/testify/assert"
1113 "github.com/stretchr/testify/require"
14+ "golang.org/x/sys/unix"
15+
16+ "github.com/bnema/gordon/internal/domain"
1217 )
1318
1419 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -65,3 +70,82 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
6570 require.NoError(t, err)
6671 assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")
6772 }
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 (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
1414
15+ "github.com/bnema/gordon/internal/adapters/in/http/registry"
1516 "github.com/bnema/gordon/internal/domain"
1617 "github.com/bnema/gordon/internal/usecase/proxy"
1718 )
@@ -99,6 +100,18 @@ func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
99100 p.config = config
100101 }
101102
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+
102115 type eventBusRecorder struct {
103116 mu sync.Mutex
104117 calls int
@@ -138,6 +151,24 @@ func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
138151 require.Equal(t, 0, coordinator.TriggerCalls())
139152 }
140153
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+
141172 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
142173 ctx := context.Background()
143174 v := viper.New()
@@ -145,6 +176,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
145176 v.Set("server.registry_port", 5000)
146177 v.Set("server.max_proxy_body_size", "5MB")
147178 v.Set("server.max_blob_chunk_size", "6MB")
179+ v.Set("server.max_blob_size", "8MB")
148180 v.Set("server.max_proxy_response_size", "7MB")
149181 v.Set("server.max_concurrent_connections", 99)
150182
@@ -152,12 +184,16 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
152184 proxySvc := &proxyRecorder{}
153185 events := &eventBusRecorder{}
154186
155- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
187+ registryLimits := &registryLimitsRecorder{}
188+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
156189 require.NoError(t, coord.ApplyLoadedConfig(ctx))
157190
158191 require.Equal(t, 0, reloadSvc.Calls())
159192 require.Equal(t, 1, proxySvc.calls)
160193 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)
161197 require.Equal(t, domain.EventConfigReload, events.eventType)
162198 require.Equal(t, proxy.Config{
163199 RegistryDomain: "reload.example.com",
@@ -175,6 +211,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
175211 v.Set("server.registry_port", 5000)
176212 v.Set("server.max_proxy_body_size", "5MB")
177213 v.Set("server.max_blob_chunk_size", "6MB")
214+ v.Set("server.max_blob_size", "8MB")
178215 v.Set("server.max_proxy_response_size", "7MB")
179216 v.Set("server.max_concurrent_connections", 99)
180217
@@ -182,13 +219,17 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
182219 proxySvc := &proxyRecorder{}
183220 events := &eventBusRecorder{}
184221
185- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
222+ registryLimits := &registryLimitsRecorder{}
223+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
186224 require.NoError(t, coord.Trigger(ctx))
187225 require.NoError(t, coord.Trigger(ctx))
188226
189227 require.Equal(t, 1, reloadSvc.Calls())
190228 require.Equal(t, 1, proxySvc.calls)
191229 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)
192233 require.Equal(t, domain.EventConfigReload, events.eventType)
193234 require.Equal(t, proxy.Config{
194235 RegistryDomain: "new.example.com",
@@ -212,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
212253 reloadErr := errors.New("reload failed")
213254 reloadSvc := &reloadRecorder{err: reloadErr}
214255 proxySvc := &proxyRecorder{}
215- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())
256+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
216257
217258 err := coord.Trigger(ctx)
218259 require.ErrorIs(t, err, reloadErr)
@@ -241,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
241282 proxySvc := &proxyRecorder{}
242283 events := &eventBusRecorder{err: publishErr}
243284
244- coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
285+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default())
245286
246287 err := coord.Trigger(ctx)
247288 require.ErrorIs(t, err, publishErr)
@@ -273,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
273314 <-release
274315 }}
275316 proxySvc := &proxyRecorder{}
276- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())
317+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
277318
278319 firstDone := make(chan struct{})
279320 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
107107 return _c
108108 }
109109
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+
110167 // IsRegistryDomain provides a mock function for the type MockProxyService
111168 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112169 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 {
4040 }
4141
4242 // 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) {
44- ret := _mock.Called(ctx, name, uuid, data)
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, contentLength, maxBlobSize)
4545
4646 if len(ret) == 0 {
4747 panic("no return value specified for AppendBlobChunk")
@@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri
4949
5050 var r0 int64
5151 var r1 error
52- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok {
53- return returnFunc(ctx, name, uuid, data)
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, contentLength, maxBlobSize)
5454 }
55- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok {
56- r0 = returnFunc(ctx, name, uuid, data)
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, contentLength, maxBlobSize)
5757 } else {
5858 r0 = ret.Get(0).(int64)
5959 }
60- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok {
61- r1 = returnFunc(ctx, name, uuid, data)
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, contentLength, maxBlobSize)
6262 } else {
6363 r1 = ret.Error(1)
6464 }
@@ -75,11 +75,13 @@ type MockRegistryService_AppendBlobChunk_Call struct {
7575 // - name string
7676 // - uuid string
7777 // - data io.Reader
78-func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call {
79- return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)}
78+// - contentLength int64
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)}
8082 }
8183
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 {
8385 _c.Call.Run(func(args mock.Arguments) {
8486 var arg0 context.Context
8587 if args[0] != nil {
@@ -97,11 +99,21 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con
9799 if args[3] != nil {
98100 arg3 = args[3].(io.Reader)
99101 }
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+ }
100110 run(
101111 arg0,
102112 arg1,
103113 arg2,
104114 arg3,
115+ arg4,
116+ arg5,
105117 )
106118 })
107119 return _c
@@ -112,7 +124,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *
112124 return _c
113125 }
114126
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 {
116128 _c.Call.Return(run)
117129 return _c
118130 }
M internal/boundaries/in/proxy.go +3 -0
@@ -24,6 +24,9 @@ type ProxyService interface {
2424 // IsRegistryDomain returns true if the host matches the configured registry domain.
2525 IsRegistryDomain(host string) bool
2626
27+ // IsKnownHost returns true if the host is configured as registry, route, or external route.
28+ IsKnownHost(ctx context.Context, host string) bool
29+
2730 // TrackInFlight records an in-flight request for a container.
2831 // Returns a release function that must be called when the request completes.
2932 TrackInFlight(containerID string) func()
M internal/boundaries/in/registry.go +1 -1
@@ -22,7 +22,7 @@ type RegistryService interface {
2222
2323 // Upload operations
2424 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)
2626 FinishUpload(ctx context.Context, uuid, digest string) error
2727 CancelUpload(ctx context.Context, uuid string) error
2828
M internal/boundaries/out/mocks/mock_blob_storage.go +24 -12
@@ -39,8 +39,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
3939 }
4040
4141 // AppendBlobChunk provides a mock function for the type MockBlobStorage
42-func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) {
43- ret := _mock.Called(name, uuid, data)
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, contentLength, maxBlobSize)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for AppendBlobChunk")
@@ -48,16 +48,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.
4848
4949 var r0 int64
5050 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok {
52- return returnFunc(name, uuid, data)
51+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) (int64, error)); ok {
52+ return returnFunc(name, uuid, data, contentLength, maxBlobSize)
5353 }
54- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok {
55- r0 = returnFunc(name, uuid, data)
54+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) int64); ok {
55+ r0 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
5656 } else {
5757 r0 = ret.Get(0).(int64)
5858 }
59- if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok {
60- r1 = returnFunc(name, uuid, data)
59+ if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader, int64, int64) error); ok {
60+ r1 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
6161 } else {
6262 r1 = ret.Error(1)
6363 }
@@ -73,11 +73,13 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
7373 // - name string
7474 // - uuid string
7575 // - data io.Reader
76-func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call {
77- return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)}
76+// - contentLength int64
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)}
7880 }
7981
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 {
8183 _c.Call.Run(func(args mock.Arguments) {
8284 var arg0 string
8385 if args[0] != nil {
@@ -91,10 +93,20 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s
9193 if args[2] != nil {
9294 arg2 = args[2].(io.Reader)
9395 }
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+ }
94104 run(
95105 arg0,
96106 arg1,
97107 arg2,
108+ arg3,
109+ arg4,
98110 )
99111 })
100112 return _c
@@ -105,7 +117,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock
105117 return _c
106118 }
107119
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 {
109121 _c.Call.Return(run)
110122 return _c
111123 }
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
246246 }
247247
248248 // CreateNetwork provides a mock function for the type MockContainerRuntime
249-func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
250- ret := _mock.Called(ctx, name, options)
249+func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
250+ ret := _mock.Called(ctx, name, config)
251251
252252 if len(ret) == 0 {
253253 panic("no return value specified for CreateNetwork")
254254 }
255255
256256 var r0 error
257- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {
258- r0 = returnFunc(ctx, name, options)
257+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.NetworkConfig) error); ok {
258+ r0 = returnFunc(ctx, name, config)
259259 } else {
260260 r0 = ret.Error(0)
261261 }
@@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct {
270270 // CreateNetwork is a helper method to define mock.On call
271271 // - ctx context.Context
272272 // - name string
273-// - options map[string]string
274-func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, options interface{}) *MockContainerRuntime_CreateNetwork_Call {
275- return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, options)}
273+// - config domain.NetworkConfig
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, config)}
276276 }
277277
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 {
279279 _c.Call.Run(func(args mock.Arguments) {
280280 var arg0 context.Context
281281 if args[0] != nil {
@@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont
285285 if args[1] != nil {
286286 arg1 = args[1].(string)
287287 }
288- var arg2 map[string]string
288+ var arg2 domain.NetworkConfig
289289 if args[2] != nil {
290- arg2 = args[2].(map[string]string)
290+ arg2 = args[2].(domain.NetworkConfig)
291291 }
292292 run(
293293 arg0,
@@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai
303303 return _c
304304 }
305305
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 {
307307 _c.Call.Return(run)
308308 return _c
309309 }
M internal/boundaries/out/runtime.go +1 -1
@@ -70,7 +70,7 @@ type ContainerRuntime interface {
7070 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
7171
7272 // Network management
73- CreateNetwork(ctx context.Context, name string, options map[string]string) error
73+ CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error
7474 RemoveNetwork(ctx context.Context, name string) error
7575 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
7676 NetworkExists(ctx context.Context, name string) (bool, error)
M internal/boundaries/out/storage.go +1 -1
@@ -30,7 +30,7 @@ type BlobStorage interface {
3030 StartBlobUpload(name string) (string, error)
3131
3232 // 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)
3434
3535 // GetBlobUpload returns a writer for the upload.
3636 GetBlobUpload(uuid string) (io.WriteCloser, error)
M internal/domain/auth.go +13 -1
@@ -79,6 +79,8 @@ const (
7979 AdminResourceSecrets = "secrets"
8080 AdminResourceConfig = "config"
8181 AdminResourceStatus = "status"
82+ AdminResourceLogs = "logs"
83+ AdminResourceVolumes = "volumes"
8284 AdminResourceAll = "*"
8385 )
8486
@@ -166,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool {
166168 // AdminScope represents an admin API scope (admin:resource:actions).
167169 // Format: admin:routes:read,write or admin:*:* for full access.
168170 type AdminScope struct {
169- Resource string // routes, secrets, config, status, or *
171+ Resource string // routes, secrets, config, status, logs, volumes, or *
170172 Actions []string // read, write, or *
171173 }
172174
@@ -302,6 +304,16 @@ func AdminScopeStatus(actions ...string) string {
302304 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
303305 }
304306
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+
305317 // AuthStatus represents status of an authentication session.
306318 type AuthStatus struct {
307319 Valid bool
M internal/domain/auth_test.go +28 -0
@@ -491,6 +491,34 @@ func TestScopesGrantAdminAccess(t *testing.T) {
491491 action: "write",
492492 want: true,
493493 },
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+ },
494522 {
495523 name: "repository scope is ignored for admin access",
496524 granted: []string{"repository:myrepo:push"},
M internal/domain/container.go +11 -0
@@ -26,6 +26,13 @@ type NetworkInfo struct {
2626 Labels map[string]string
2727 }
2828
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+
2936 // Attachment represents an attached service container.
3037 type Attachment struct {
3138 Name string
@@ -64,6 +71,10 @@ type ContainerConfig struct {
6471 MemoryLimit int64 // Memory limit in bytes (0 = no limit)
6572 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
6673 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
6778 }
6879
6980 // ContainerStatus represents the current state of a container.
M internal/domain/errors.go +3 -0
@@ -34,6 +34,7 @@ var (
3434 ErrInvalidDigest = errors.New("invalid digest")
3535 ErrDigestMismatch = errors.New("digest mismatch")
3636 ErrUnauthorized = errors.New("unauthorized")
37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
3738
3839 // Network errors
3940 ErrNetworkNotFound = errors.New("network not found")
@@ -51,10 +52,12 @@ var (
5152 ErrInvalidConfig = errors.New("invalid configuration")
5253 ErrConfigLoadFailed = errors.New("failed to load configuration")
5354 ErrInvalidDomainPattern = errors.New("invalid domain pattern")
55+ ErrRouteConflict = errors.New("route conflicts with existing configuration")
5456
5557 // Environment errors
5658 ErrEnvFileNotFound = errors.New("environment file not found")
5759 ErrSecretNotFound = errors.New("secret not found")
60+ ErrSecretsAlreadyExist = errors.New("secrets already exist")
5861 ErrProviderNotFound = errors.New("secret provider not found")
5962 ErrInvalidContainerName = errors.New("invalid container name")
6063
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.-]+$`)
1717 //
1818 // Valid: app.example.com, api.site.org, my-app.co.uk
1919 // 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+
2028 func IsValidRouteDomain(domain string) bool {
2129 // Reject empty
2230 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
4747 require.NoError(t, runtime.PullImage(pullCtx, image))
4848 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
4949 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"}))
5151 t.Cleanup(func() {
5252 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
5353 defer cancelCleanup()
@@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error {
239239 return nil
240240 }
241241
242+func (s *integrationContainerService) UpdateAttachments(map[string][]string) {
243+}
244+
242245 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243246 return nil
244247 }
M internal/usecase/config/service.go +73 -11
@@ -95,7 +95,16 @@ func (s *Service) Load(ctx context.Context) error {
9595 return log.WrapErr(err, "failed to load routes")
9696 }
9797 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
99108 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100109 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
101110
@@ -208,6 +217,33 @@ func loadStringMap(raw any) map[string]string {
208217 return result
209218 }
210219
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+
211247 // loadStringArrayMap loads a map[string][]string from a viper value.
212248 func loadStringArrayMap(raw any) map[string][]string {
213249 result := make(map[string][]string)
@@ -252,7 +288,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
252288 if err != nil {
253289 return nil, err
254290 }
255- result[key] = route
291+ 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
256296 }
257297
258298 for key, value := range routes {
@@ -261,7 +301,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
261301 }
262302
263303 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 {
265309 continue
266310 }
267311
@@ -269,16 +313,18 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
269313 if err != nil {
270314 return nil, err
271315 }
272- result[domainName] = route
316+ result[canonicalDomain] = route
273317 }
274318
275319 return result, nil
276320 }
277321
278322 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279- if !domain.IsValidRouteDomain(domainName) {
323+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
324+ if !ok {
280325 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281326 }
327+ domainName = canonicalDomain
282328
283329 switch value := raw.(type) {
284330 case string:
@@ -294,9 +340,11 @@ func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
294340 }
295341
296342 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297- if !domain.IsValidRouteDomain(domainName) {
343+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
344+ if !ok {
298345 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299346 }
347+ domainName = canonicalDomain
300348
301349 value, ok := raw.(string)
302350 if !ok {
@@ -348,6 +396,10 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
348396 s.mu.RLock()
349397 defer s.mu.RUnlock()
350398
399+ domainName, ok := domain.CanonicalRouteDomain(domainName)
400+ if !ok {
401+ return nil, domain.ErrRouteNotFound
402+ }
351403 routeCfg, ok := s.config.Routes[domainName]
352404 if !ok {
353405 return nil, domain.ErrRouteNotFound
@@ -494,9 +546,11 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
494546 if route.Domain == "" {
495547 return domain.ErrRouteDomainEmpty
496548 }
497- if !domain.IsValidRouteDomain(route.Domain) {
549+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
550+ if !ok {
498551 return domain.ErrRouteDomainInvalid
499552 }
553+ route.Domain = canonicalDomain
500554 if route.Image == "" {
501555 return domain.ErrRouteImageEmpty
502556 }
@@ -507,6 +561,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
507561 if currentConfig.Routes == nil {
508562 currentConfig.Routes = make(map[string]routeConfig)
509563 }
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+ }
510568 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511569 legacyKey := legacyRouteStorageKey(route.Domain)
512570 _, legacyExisted := currentConfig.Routes[legacyKey]
@@ -550,9 +608,11 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
550608 if route.Domain == "" {
551609 return domain.ErrRouteDomainEmpty
552610 }
553- if !domain.IsValidRouteDomain(route.Domain) {
611+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
612+ if !ok {
554613 return domain.ErrRouteDomainInvalid
555614 }
615+ route.Domain = canonicalDomain
556616 if route.Image == "" {
557617 return domain.ErrRouteImageEmpty
558618 }
@@ -601,14 +661,16 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
601661 if domainName == "" {
602662 return domain.ErrRouteDomainEmpty
603663 }
604- if !domain.IsValidRouteDomain(domainName) {
664+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
665+ if !ok {
605666 return domain.ErrRouteDomainInvalid
606667 }
668+ domainName = canonicalDomain
607669
608670 // Store previous value for rollback
609671 s.mu.Lock()
610672 currentConfig := s.config
611- _, ok := currentConfig.Routes[domainName]
673+ _, ok = currentConfig.Routes[domainName]
612674 if !ok {
613675 s.mu.Unlock()
614676 return domain.ErrRouteNotFound
@@ -1455,7 +1517,7 @@ func (s *Service) GetExternalRoutes() map[string]string {
14551517 s.mu.RLock()
14561518 defer s.mu.RUnlock()
14571519
1458- result := make(map[string]string)
1520+ result := make(map[string]string, len(s.config.ExternalRoutes))
14591521 for k, v := range s.config.ExternalRoutes {
14601522 result[k] = v
14611523 }
M internal/usecase/config/service_test.go +87 -1
@@ -458,6 +458,92 @@ func TestService_GetRoute(t *testing.T) {
458458 })
459459 }
460460
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+
461547 func TestService_AddRoute(t *testing.T) {
462548 t.Run("success", func(t *testing.T) {
463549 // Create temp config file for Save() to work
@@ -1556,7 +1642,7 @@ func TestService_AutoRouteAllowedDomains(t *testing.T) {
15561642 func TestService_GetExternalRoutes(t *testing.T) {
15571643 v := viper.New()
15581644 v.Set("external_routes", map[string]interface{}{
1559- "reg.example.com": "localhost:5000",
1645+ "Reg.Example.com": "localhost:5000",
15601646 "cache.example.com": "127.0.0.1:6379",
15611647 })
15621648
M internal/usecase/container/autoroute.go +59 -8
@@ -174,6 +174,13 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
174174 continue
175175 }
176176
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+
177184 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178185
179186 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
@@ -259,6 +266,38 @@ func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route
259266 }
260267 }
261268
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+
262301 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263302 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264303 log := zerowrap.FromCtx(ctx)
@@ -269,8 +308,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
269308 Str("env_file", envFilePath).
270309 Msg("extracting env file from image")
271310
311+ safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath)
312+ if err != nil {
313+ return err
314+ }
315+
272316 // Extract env file from image
273- envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
317+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath)
274318 if err != nil {
275319 return fmt.Errorf("failed to extract env file: %w", err)
276320 }
@@ -291,13 +335,8 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
291335 return nil
292336 }
293337
294- // Security check: reject imported env files containing secret references
295- // to prevent attacker-controlled images from persisting ${provider:path}
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- }
338+ if err := rejectSecretReferences(imageEnv); err != nil {
339+ return err
301340 }
302341
303342 // Load existing env file for this domain
@@ -379,6 +418,18 @@ func domainToEnvFileName(domainName string) (string, error) {
379418 return storageKey.FileName(), nil
380419 }
381420
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+
382433 // writeEnvFile writes environment variables to a file.
383434 func writeEnvFile(path string, env map[string]string) error {
384435 var buf bytes.Buffer
M internal/usecase/container/autoroute_test.go +47 -0
@@ -910,6 +910,53 @@ func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef,
910910
911911 // collectDomains tests
912912
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+
913960 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
914961 tests := []struct {
915962 name string
M internal/usecase/container/service.go +211 -21
@@ -5,10 +5,12 @@ import (
55 "bufio"
66 "context"
77 "encoding/binary"
8+ "encoding/hex"
89 "errors"
910 "fmt"
1011 "io"
1112 "maps"
13+ "net"
1214 "slices"
1315 "strconv"
1416 "strings"
@@ -43,7 +45,11 @@ type Config struct {
4345 NetworkIsolation bool
4446 NetworkPrefix string
4547 NetworkGroups map[string][]string
48+ NetworkInternal bool
4649 Attachments map[string][]string
50+ AllowedRegistries []string
51+ RequireImageDigest bool
52+ SecurityProfile string
4753 ReadinessDelay time.Duration // Delay after container starts before considering it ready
4854 ReadinessMode string // Readiness strategy: auto, docker-health, delay
4955 HealthTimeout time.Duration // Max wait for health-based readiness
@@ -268,7 +274,7 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
268274 }
269275 }
270276
271- return &domain.ContainerConfig{
277+ containerConfig := &domain.ContainerConfig{
272278 Image: in.ImageRef,
273279 Name: containerName,
274280 Ports: ports,
@@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
282288 NanoCPUs: cfg.DefaultNanoCPUs,
283289 PidsLimit: cfg.DefaultPidsLimit,
284290 }
291+ applySecurityProfile(containerConfig, cfg)
292+ return containerConfig
285293 }
286294
287295 // Deploy creates and starts a container for the given route.
@@ -561,7 +569,10 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
561569 log.WrapErr(err, "failed to cleanup orphaned containers")
562570 }
563571
564- imageRef := s.buildImageRef(route.Image)
572+ imageRef, err := s.buildValidatedImageRef(ctx, route.Image)
573+ if err != nil {
574+ return nil, err
575+ }
565576 actualImageRef, err := s.ensureImage(ctx, imageRef)
566577 if err != nil {
567578 return nil, err
@@ -1668,6 +1679,14 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
16681679 }
16691680 }
16701681
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+
16711690 func (s *Service) buildImageRef(image string) string {
16721691 s.mu.RLock()
16731692 cfg := s.config
@@ -1743,6 +1762,135 @@ func hasExplicitRegistry(image string) bool {
17431762 return false
17441763 }
17451764
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+
17461894 // isNumeric checks if a string contains only digits.
17471895 func isNumeric(s string) bool {
17481896 for _, c := range s {
@@ -1767,7 +1915,16 @@ func normalizePullPolicy(policy string) string {
17671915 }
17681916
17691917 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
17711928 }
17721929
17731930 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
17941951
17951952 // Determine if this is an internal deploy and what reference to use for pulls.
17961953 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
1954+ if err := s.validateImagePullRef(ctx, imageRef); err != nil {
1955+ return "", err
1956+ }
17971957 if pullRef != imageRef {
17981958 log.Info().
17991959 Str("original_ref", imageRef).
@@ -2101,7 +2261,10 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
21012261 }
21022262
21032263 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 {
21052268 return log.WrapErr(err, "failed to create network")
21062269 }
21072270 log.Info().Msg("created network for app isolation")
@@ -2415,31 +2578,23 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
24152578 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
24162579
24172580 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
2418- imageRef := s.buildImageRef(serviceImage)
2419- actualImageRef, err := s.ensureImage(ctx, imageRef)
2581+ imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
24202582 if err != nil {
24212583 return err
24222584 }
2423-
2424- // Get image metadata
2425- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
2585+ actualImageRef, err := s.ensureImage(ctx, imageRef)
24262586 if err != nil {
2427- log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2428- exposedPorts = []int{}
2587+ return err
24292588 }
24302589
2431- // Setup volumes (attachments need persistent data)
2432- volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
2590+ exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2591+ volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)
24332592 if err != nil {
2434- log.WrapErr(err, "failed to setup volumes for attachment")
2435- volumes = make(map[string]string)
2593+ return err
24362594 }
2437-
2438- // Load environment (attachment-specific env file)
2439- envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
2595+ envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef)
24402596 if err != nil {
2441- log.WrapErr(err, "failed to load environment for attachment")
2442- envVars = []string{}
2597+ return err
24432598 }
24442599 envHash := hashEnvironment(envVars)
24452600
@@ -2468,6 +2623,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
24682623 NanoCPUs: cfg.DefaultNanoCPUs,
24692624 PidsLimit: cfg.DefaultPidsLimit,
24702625 }
2626+ applySecurityProfile(config, cfg)
24712627
24722628 container, err := s.runtime.CreateContainer(ctx, config)
24732629 if err != nil {
@@ -2502,8 +2658,38 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
25022658 return nil
25032659 }
25042660
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+
25052687 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)
25072693 if err != nil {
25082694 return false, err
25092695 }
@@ -2598,6 +2784,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
25982784 // Utility functions
25992785
26002786 func normalizeImageRef(image string) string {
2787+ if isDigestRef(image) {
2788+ return image
2789+ }
2790+
26012791 // Extract repository from image reference, handling port numbers correctly.
26022792 // Examples:
26032793 // 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 {
4949 // suitable for unit tests that don't want to wait for real timeouts.
5050 func testMinDelayConfig() Config {
5151 return Config{
52+ AllowedRegistries: []string{"docker.io"},
5253 ReadinessDelay: time.Millisecond,
5354 DrainDelay: time.Millisecond,
5455 DrainDelayConfigured: true,
@@ -326,6 +327,7 @@ func TestService_Deploy_Success(t *testing.T) {
326327 eventBus := mocks.NewMockEventPublisher(t)
327328
328329 config := Config{
330+ AllowedRegistries: []string{"docker.io"},
329331 NetworkIsolation: false,
330332 VolumeAutoCreate: false,
331333 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -408,6 +410,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
408410 eventBus := mocks.NewMockEventPublisher(t)
409411
410412 config := Config{
413+ AllowedRegistries: []string{"docker.io"},
411414 NetworkIsolation: false,
412415 VolumeAutoCreate: false,
413416 ReadinessDelay: time.Millisecond,
@@ -472,7 +475,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
472475 envLoader := mocks.NewMockEnvLoader(t)
473476 eventBus := mocks.NewMockEventPublisher(t)
474477
475- config := Config{}
478+ config := Config{AllowedRegistries: []string{"docker.io"}}
476479 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
477480 ctx := testContext()
478481
@@ -514,7 +517,7 @@ func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.
514517 envLoader := mocks.NewMockEnvLoader(t)
515518 eventBus := mocks.NewMockEventPublisher(t)
516519
517- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
520+ svc := NewService(runtime, envLoader, eventBus, nil, Config{AllowedRegistries: []string{"docker.io"}}, nil)
518521 ctx := testContext()
519522
520523 route := domain.Route{
@@ -555,6 +558,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.
555558 eventBus := mocks.NewMockEventPublisher(t)
556559
557560 config := Config{
561+ AllowedRegistries: []string{"docker.io"},
558562 ReadinessMode: "docker-health",
559563 HealthTimeout: 10 * time.Millisecond,
560564 ReadinessDelay: time.Millisecond,
@@ -613,6 +617,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing
613617 eventBus := mocks.NewMockEventPublisher(t)
614618
615619 config := Config{
620+ AllowedRegistries: []string{"docker.io"},
616621 ReadinessMode: "docker-health",
617622 ReadinessDelay: time.Millisecond,
618623 DrainDelay: time.Millisecond,
@@ -659,6 +664,7 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
659664 runtime := mocks.NewMockContainerRuntime(t)
660665
661666 config := Config{
667+ AllowedRegistries: []string{"docker.io"},
662668 RegistryAuthEnabled: true,
663669 ServiceTokenUsername: "gordon-service",
664670 ServiceToken: "service-token",
@@ -678,6 +684,7 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
678684 runtime := mocks.NewMockContainerRuntime(t)
679685
680686 config := Config{
687+ AllowedRegistries: []string{"docker.io"},
681688 RegistryAuthEnabled: true,
682689 }
683690
@@ -694,6 +701,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
694701 runtime := mocks.NewMockContainerRuntime(t)
695702
696703 config := Config{
704+ AllowedRegistries: []string{"docker.io"},
697705 RegistryAuthEnabled: true,
698706 InternalRegistryUsername: "internal",
699707 InternalRegistryPassword: "secret",
@@ -721,6 +729,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
721729 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
722730
723731 config := Config{
732+ AllowedRegistries: []string{"docker.io"},
724733 ReadinessDelay: time.Millisecond, // Minimal delay for tests
725734 DrainDelay: time.Millisecond,
726735 DrainDelayConfigured: true,
@@ -963,6 +972,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
963972 eventBus := mocks.NewMockEventPublisher(t)
964973
965974 config := Config{
975+ AllowedRegistries: []string{"docker.io"},
966976 NetworkIsolation: true,
967977 NetworkPrefix: "gordon",
968978 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -989,7 +999,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
989999
9901000 // Network isolation - should check and create network
9911001 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)
9931003
9941004 container := &domain.Container{ID: "container-123", Status: "created"}
9951005 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -1020,6 +1030,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
10201030 eventBus := mocks.NewMockEventPublisher(t)
10211031
10221032 config := Config{
1033+ AllowedRegistries: []string{"docker.io"},
10231034 VolumeAutoCreate: true,
10241035 VolumePrefix: "gordon",
10251036 ReadinessDelay: time.Millisecond, // Minimal delay for tests
@@ -1135,8 +1146,9 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
11351146 eventBus := mocks.NewMockEventPublisher(t)
11361147
11371148 config := Config{
1138- NetworkIsolation: true,
1139- NetworkPrefix: "gordon",
1149+ AllowedRegistries: []string{"docker.io"},
1150+ NetworkIsolation: true,
1151+ NetworkPrefix: "gordon",
11401152 }
11411153 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
11421154 ctx := testContext()
@@ -1663,6 +1675,16 @@ func TestNormalizeImageRef(t *testing.T) {
16631675 input: "registry.example.com:5000/image:v1.0",
16641676 expected: "registry.example.com:5000/image",
16651677 },
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+ },
16661688 }
16671689
16681690 for _, tt := range tests {
@@ -1932,6 +1954,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
19321954 eventBus := mocks.NewMockEventPublisher(t)
19331955
19341956 config := Config{
1957+ AllowedRegistries: []string{"docker.io"},
19351958 RegistryAuthEnabled: true,
19361959 RegistryDomain: "registry.example.com",
19371960 RegistryPort: 5000,
@@ -2011,6 +2034,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
20112034 eventBus := mocks.NewMockEventPublisher(t)
20122035
20132036 config := Config{
2037+ AllowedRegistries: []string{"docker.io"},
20142038 ReadinessDelay: time.Millisecond,
20152039 DrainDelay: time.Millisecond,
20162040 DrainDelayConfigured: true,
@@ -2059,6 +2083,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
20592083 eventBus := mocks.NewMockEventPublisher(t)
20602084
20612085 config := Config{
2086+ AllowedRegistries: []string{"docker.io"},
20622087 ReadinessDelay: time.Millisecond,
20632088 DrainDelay: time.Millisecond,
20642089 DrainDelayConfigured: true,
@@ -2106,7 +2131,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
21062131 envLoader := mocks.NewMockEnvLoader(t)
21072132 eventBus := mocks.NewMockEventPublisher(t)
21082133
2109- config := Config{}
2134+ config := Config{AllowedRegistries: []string{"docker.io"}}
21102135 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
21112136 ctx := testContext()
21122137
@@ -2188,6 +2213,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
21882213 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
21892214
21902215 config := Config{
2216+ AllowedRegistries: []string{"docker.io"},
21912217 ReadinessDelay: time.Millisecond, // Minimal delay for tests
21922218 DrainDelay: time.Millisecond,
21932219 DrainDelayConfigured: true,
@@ -2282,6 +2308,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
22822308 eventBus := mocks.NewMockEventPublisher(t)
22832309
22842310 config := Config{
2311+ AllowedRegistries: []string{"docker.io"},
22852312 ReadinessDelay: time.Millisecond, // Minimal delay for tests
22862313 DrainDelay: time.Millisecond,
22872314 DrainDelayConfigured: true,
@@ -2732,6 +2759,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
27322759 eventBus := mocks.NewMockEventPublisher(t)
27332760
27342761 config := Config{
2762+ AllowedRegistries: []string{"docker.io"},
27352763 ReadinessDelay: time.Millisecond,
27362764 DrainDelay: time.Millisecond,
27372765 DrainDelayConfigured: true,
@@ -2890,6 +2918,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
28902918 eventBus := mocks.NewMockEventPublisher(t)
28912919
28922920 config := Config{
2921+ AllowedRegistries: []string{"docker.io"},
28932922 ReadinessDelay: time.Millisecond,
28942923 DrainDelay: time.Millisecond,
28952924 DrainDelayConfigured: true,
@@ -2949,6 +2978,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
29492978 eventBus := mocks.NewMockEventPublisher(t)
29502979
29512980 config := Config{
2981+ AllowedRegistries: []string{"docker.io"},
29522982 ReadinessDelay: time.Millisecond,
29532983 DrainDelay: time.Millisecond,
29542984 DrainDelayConfigured: true,
@@ -3455,6 +3485,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
34553485 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
34563486
34573487 config := Config{
3488+ AllowedRegistries: []string{"docker.io"},
34583489 ReadinessDelay: time.Millisecond,
34593490 DrainDelay: time.Millisecond,
34603491 DrainDelayConfigured: true,
@@ -3548,6 +3579,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
35483579 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
35493580
35503581 config := Config{
3582+ AllowedRegistries: []string{"docker.io"},
35513583 ReadinessDelay: time.Millisecond,
35523584 DrainDelay: time.Millisecond,
35533585 DrainDelayConfigured: true,
@@ -3635,6 +3667,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
36353667 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
36363668
36373669 config := Config{
3670+ AllowedRegistries: []string{"docker.io"},
36383671 ReadinessDelay: time.Millisecond,
36393672 DrainDelay: time.Millisecond,
36403673 DrainDelayConfigured: true,
@@ -3729,6 +3762,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
37293762 eventBus := mocks.NewMockEventPublisher(t)
37303763
37313764 config := Config{
3765+ AllowedRegistries: []string{"docker.io"},
37323766 ReadinessDelay: time.Millisecond,
37333767 DrainDelay: time.Millisecond,
37343768 DrainDelayConfigured: true,
@@ -3818,6 +3852,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
38183852 eventBus := mocks.NewMockEventPublisher(t)
38193853
38203854 config := Config{
3855+ AllowedRegistries: []string{"docker.io"},
38213856 ReadinessDelay: time.Millisecond,
38223857 DrainDelay: time.Millisecond,
38233858 DrainDelayConfigured: true,
@@ -3893,6 +3928,7 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
38933928 eventBus := mocks.NewMockEventPublisher(t)
38943929
38953930 config := Config{
3931+ AllowedRegistries: []string{"docker.io"},
38963932 NetworkIsolation: false,
38973933 VolumeAutoCreate: false,
38983934 ReadinessDelay: time.Millisecond,
@@ -4168,3 +4204,116 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
41684204 require.Len(t, attachIDs, 1)
41694205 assert.Equal(t, "pg-container-1", attachIDs[0])
41704206 }
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
33
44 import (
55 "context"
6+ "fmt"
67 "net"
78 "os"
89 "strconv"
@@ -65,6 +66,12 @@ func NewService(
6566
6667 // GetTarget returns the proxy target for a given domain.
6768 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+
6875 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
6976 trace.WithAttributes(attribute.String("domain", domainName)))
7077 defer func() {
@@ -181,6 +188,9 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
181188 if err != nil {
182189 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
183190 }
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+ }
184194
185195 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
186196 // 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
225235
226236 // RegisterTarget registers a new proxy target for a domain.
227237 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+
228243 s.mu.Lock()
229244 defer s.mu.Unlock()
230245
231- s.targets[domainName] = target
246+ s.targets[canonicalDomain] = target
232247 return nil
233248 }
234249
235250 // UnregisterTarget removes a proxy target for a domain.
236251 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
252+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
253+ if !ok {
254+ return domain.ErrRouteDomainInvalid
255+ }
256+
237257 s.mu.Lock()
238258 defer s.mu.Unlock()
239259
240- delete(s.targets, domainName)
260+ delete(s.targets, canonicalDomain)
241261 return nil
242262 }
243263
244264 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
245265 // This is used during zero-downtime deployments to switch traffic to a new container.
246266 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
267+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
268+ if !ok {
269+ return
270+ }
271+
247272 s.mu.Lock()
248273 defer s.mu.Unlock()
249274
250- delete(s.targets, domainName)
275+ delete(s.targets, canonicalDomain)
251276 }
252277
253278 // WaitForNoInFlight waits until no requests are currently proxied to the
@@ -303,9 +328,31 @@ func (s *Service) UpdateConfig(config Config) {
303328
304329 // IsRegistryDomain returns true if the host matches the configured registry domain.
305330 func (s *Service) IsRegistryDomain(host string) bool {
331+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
332+ if !ok {
333+ return false
334+ }
335+
306336 s.mu.RLock()
307- defer s.mu.RUnlock()
308- return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
337+ registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
338+ 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
309356 }
310357
311358 // 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) {
4747 assert.Equal(t, cachedTarget, result)
4848 }
4949
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+
5072 func TestService_GetTarget_ContainerNotFound(t *testing.T) {
5173 runtime := outmocks.NewMockContainerRuntime(t)
5274 containerSvc := inmocks.NewMockContainerService(t)
@@ -169,22 +191,34 @@ func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
169191 }
170192
171193 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
172- runtime := outmocks.NewMockContainerRuntime(t)
173- containerSvc := inmocks.NewMockContainerService(t)
174- configSvc := inmocks.NewMockConfigService(t)
194+ tests := []struct {
195+ name string
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+ }
175202
176- svc := NewService(runtime, containerSvc, configSvc, Config{})
177- ctx := testContext()
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ runtime := outmocks.NewMockContainerRuntime(t)
206+ containerSvc := inmocks.NewMockContainerService(t)
207+ configSvc := inmocks.NewMockConfigService(t)
178208
179- // Mock external routes with invalid port
180- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
181- "invalid.example.com": "localhost:abc",
182- })
209+ svc := NewService(runtime, containerSvc, configSvc, Config{})
210+ ctx := testContext()
183211
184- result, err := svc.GetTarget(ctx, "invalid.example.com")
212+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213+ "invalid.example.com": tt.target,
214+ })
185215
186- assert.Error(t, err)
187- assert.Nil(t, result)
216+ result, err := svc.GetTarget(ctx, "invalid.example.com")
217+
218+ assert.Error(t, err)
219+ assert.Nil(t, result)
220+ })
221+ }
188222 }
189223
190224 func TestService_RegisterTarget(t *testing.T) {
@@ -202,11 +236,11 @@ func TestService_RegisterTarget(t *testing.T) {
202236 Scheme: "http",
203237 }
204238
205- err := svc.RegisterTarget(ctx, "app.example.com", target)
239+ err := svc.RegisterTarget(ctx, "App.Example.com", target)
206240
207241 assert.NoError(t, err)
208242
209- // Verify target is cached
243+ // Verify target is cached under the canonical domain.
210244 svc.mu.RLock()
211245 cached := svc.targets["app.example.com"]
212246 svc.mu.RUnlock()
@@ -228,7 +262,7 @@ func TestService_UnregisterTarget(t *testing.T) {
228262 Port: 8080,
229263 }
230264
231- err := svc.UnregisterTarget(ctx, "app.example.com")
265+ err := svc.UnregisterTarget(ctx, "App.Example.com")
232266
233267 assert.NoError(t, err)
234268
@@ -313,8 +347,8 @@ func TestService_InvalidateTarget(t *testing.T) {
313347 ContainerID: "old-container",
314348 }
315349
316- // Invalidate the target
317- svc.InvalidateTarget(ctx, "app.example.com")
350+ // Invalidate the target using mixed-case input.
351+ svc.InvalidateTarget(ctx, "App.Example.com")
318352
319353 // Verify target is removed from cache
320354 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)
304304 }
305305
306306 // 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) {
308308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
309309 zerowrap.FieldLayer: "usecase",
310310 zerowrap.FieldUseCase: "AppendBlobChunk",
@@ -313,7 +313,7 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
313313 })
314314 log := zerowrap.FromCtx(ctx)
315315
316- length, err := s.blobStorage.AppendBlobChunk(name, uuid, data)
316+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, data, contentLength, maxBlobSize)
317317 if err != nil {
318318 return 0, log.WrapErr(err, "failed to append blob chunk")
319319 }
M internal/usecase/registry/service_test.go +32 -0
@@ -306,6 +306,38 @@ func TestService_StartUpload_Error(t *testing.T) {
306306 assert.Contains(t, err.Error(), "failed to start blob upload")
307307 }
308308
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+
309341 func TestService_FinishUpload_Success(t *testing.T) {
310342 blobStorage := mocks.NewMockBlobStorage(t)
311343 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
5454 var removed []*domain.VolumeInfo
5555
5656 for _, vol := range vols {
57- if vol.InUse {
57+ if !isGordonManagedVolume(vol) || vol.InUse {
5858 continue
5959 }
6060
@@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume
7272
7373 return report, removed, nil
7474 }
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) {
3131 runtime := outmocks.NewMockContainerRuntime(t)
3232
3333 vols := []*domain.VolumeInfo{
34- {Name: "vol-in-use", InUse: true, Containers: []string{"web"}},
35- {Name: "vol-orphaned", InUse: false, Size: 1024},
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, Labels: map[string]string{domain.LabelManaged: "true"}},
3636 }
3737 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
3838 runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)
@@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
5050 runtime := outmocks.NewMockContainerRuntime(t)
5151
5252 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"}},
5454 }
5555 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
5656
@@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
6262 assert.Len(t, removed, 1)
6363 }
6464
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+
65106 func TestService_PruneVolumes_AllInUse(t *testing.T) {
66107 runtime := outmocks.NewMockContainerRuntime(t)
67108
@@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) {
91132 runtime := outmocks.NewMockContainerRuntime(t)
92133
93134 vols := []*domain.VolumeInfo{
94- {Name: "vol-fail", InUse: false, Size: 1024},
95- {Name: "vol-ok", InUse: false, Size: 2048},
135+ {Name: "vol-fail", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}},
136+ {Name: "vol-ok", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}},
96137 }
97138 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
98139 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