Merge pull request #177 from bnema/security-hardening-audit-fixes
Security hardening audit fixes
73 files · +3026 -411
A .dockerignore +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | .git | |||
| 2 | + | .worktrees | |||
| 3 | + | .gitignore | |||
| 4 | + | .gitleaks.toml | |||
| 5 | + | Dockerfile | |||
| 6 | + | *.log | |||
| 7 | + | /tmp | |||
| 8 | + | coverage.out | |||
| 9 | + | dist | |||
| 10 | + | build | |||
| 11 | + | node_modules | |||
M .github/actions/setup-gordon/action.yml +58 -9
| @@ -26,6 +26,8 @@ runs: | |||||
| 26 | - name: Install Gordon | 26 | - name: Install Gordon | ||
| 27 | id: install | 27 | id: install | ||
| 28 | shell: bash | 28 | shell: bash | ||
| 29 | + | env: | |||
| 30 | + | GORDON_VERSION_INPUT: ${{ inputs.version }} | |||
| 29 | run: | | 31 | run: | | ||
| 30 | set -euo pipefail | 32 | set -euo pipefail | ||
| 31 | 33 | ||||
| @@ -43,7 +45,11 @@ runs: | |||||
| 43 | *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;; | 45 | *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;; | ||
| 44 | esac | 46 | esac | ||
| 45 | 47 | ||||
| 46 | - | version="${{ inputs.version }}" | 48 | + | version="${GORDON_VERSION_INPUT}" |
| 49 | + | if ! [[ "${version}" =~ ^(latest|v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?)$ ]]; then | |||
| 50 | + | echo "Invalid Gordon version: ${version}" >&2 | |||
| 51 | + | exit 1 | |||
| 52 | + | fi | |||
| 47 | 53 | ||||
| 48 | # Resolve 'latest' to an actual tag via redirect URL | 54 | # Resolve 'latest' to an actual tag via redirect URL | ||
| 49 | if [ "${version}" = "latest" ]; then | 55 | if [ "${version}" = "latest" ]; then | ||
| @@ -53,24 +59,67 @@ runs: | |||||
| 53 | echo "Failed to resolve latest Gordon version" >&2 | 59 | echo "Failed to resolve latest Gordon version" >&2 | ||
| 54 | exit 1 | 60 | exit 1 | ||
| 55 | fi | 61 | fi | ||
| 62 | + | if ! [[ "${version}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then | |||
| 63 | + | echo "Resolved invalid Gordon version: ${version}" >&2 | |||
| 64 | + | exit 1 | |||
| 65 | + | fi | |||
| 56 | fi | 66 | fi | ||
| 57 | 67 | ||||
| 58 | archive="gordon_${os}_${arch}.tar.gz" | 68 | archive="gordon_${os}_${arch}.tar.gz" | ||
| 59 | - | url="https://github.com/bnema/gordon/releases/download/${version}/${archive}" | 69 | + | base_url="https://github.com/bnema/gordon/releases/download/${version}" |
| 60 | - | dest="/tmp/${archive}" | 70 | + | url="${base_url}/${archive}" |
| 71 | + | checksums_url="${base_url}/checksums.txt" | |||
| 72 | + | work_dir="$(mktemp -d)" | |||
| 73 | + | dest="${work_dir}/${archive}" | |||
| 74 | + | checksums="${work_dir}/checksums.txt" | |||
| 75 | + | extract_dir="${work_dir}/extract" | |||
| 61 | install_path="/usr/local/bin/gordon" | 76 | install_path="/usr/local/bin/gordon" | ||
| 77 | + | trap 'rm -rf "${work_dir}"' EXIT | |||
| 62 | 78 | ||||
| 63 | echo "Installing Gordon ${version} (${os}/${arch}) from ${url}" | 79 | echo "Installing Gordon ${version} (${os}/${arch}) from ${url}" | ||
| 64 | 80 | ||||
| 65 | - | # Download the release archive | 81 | + | # Download the release archive and checksums |
| 82 | + | curl -fsSL -o "${checksums}" "${checksums_url}" | |||
| 66 | curl -fsSL -o "${dest}" "${url}" | 83 | curl -fsSL -o "${dest}" "${url}" | ||
| 67 | 84 | ||||
| 68 | - | # Extract just the gordon binary and clean up | 85 | + | # Verify checksum before extraction |
| 69 | - | tar -xzf "${dest}" -C /usr/local/bin/ gordon | 86 | + | archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')" |
| 70 | - | rm -f "${dest}" | 87 | + | checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive_escaped}$" "${checksums}" || true)" |
| 88 | + | if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then | |||
| 89 | + | echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2 | |||
| 90 | + | exit 1 | |||
| 91 | + | fi | |||
| 92 | + | expected="$(printf '%s\n' "${checksum_lines}" | awk '{print $1}')" | |||
| 93 | + | if command -v sha256sum >/dev/null 2>&1; then | |||
| 94 | + | actual="$(sha256sum "${dest}" | awk '{print $1}')" | |||
| 95 | + | elif command -v shasum >/dev/null 2>&1; then | |||
| 96 | + | actual="$(shasum -a 256 "${dest}" | awk '{print $1}')" | |||
| 97 | + | else | |||
| 98 | + | echo "Neither sha256sum nor shasum is available for checksum verification" >&2 | |||
| 99 | + | exit 1 | |||
| 100 | + | fi | |||
| 101 | + | if [ "${expected}" != "${actual}" ]; then | |||
| 102 | + | echo "Checksum verification failed for ${archive}" >&2 | |||
| 103 | + | echo "Expected: ${expected}" >&2 | |||
| 104 | + | echo "Actual: ${actual}" >&2 | |||
| 105 | + | exit 1 | |||
| 106 | + | fi | |||
| 107 | + | ||||
| 108 | + | # Extract just the gordon binary and reject symlinks/directories | |||
| 109 | + | mkdir -p "${extract_dir}" | |||
| 110 | + | tar -xzf "${dest}" -C "${extract_dir}" gordon | |||
| 111 | + | binary="${extract_dir}/gordon" | |||
| 112 | + | if [ ! -f "${binary}" ] || [ -L "${binary}" ]; then | |||
| 113 | + | echo "Archive did not contain a regular Gordon binary" >&2 | |||
| 114 | + | exit 1 | |||
| 115 | + | fi | |||
| 71 | 116 | ||||
| 72 | - | # Make executable and verify | 117 | + | # Install with explicit executable permissions and verify |
| 73 | - | chmod +x "${install_path}" | 118 | + | if [ -w "$(dirname "${install_path}")" ]; then |
| 119 | + | install -m 0755 "${binary}" "${install_path}" | |||
| 120 | + | else | |||
| 121 | + | sudo install -m 0755 "${binary}" "${install_path}" | |||
| 122 | + | fi | |||
| 74 | gordon version | 123 | gordon version | ||
| 75 | 124 | ||||
| 76 | # Set outputs | 125 | # Set outputs | ||
A .gitleaks.toml +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | title = "Gordon gitleaks configuration" | |||
| 2 | + | ||||
| 3 | + | [extend] | |||
| 4 | + | useDefault = true | |||
| 5 | + | ||||
| 6 | + | [[allowlists]] | |||
| 7 | + | description = "Allow documented placeholder credentials in Markdown examples" | |||
| 8 | + | condition = "AND" | |||
| 9 | + | paths = [ | |||
| 10 | + | '''docs/config/env\.md$''', | |||
| 11 | + | '''docs/cli/auth\.md$''', | |||
| 12 | + | '''docs/config/telemetry\.md$''', | |||
| 13 | + | '''wiki/guides/remote-cli\.md$''', | |||
| 14 | + | ] | |||
| 15 | + | regexes = [ | |||
| 16 | + | '''sk-1234567890''', | |||
| 17 | + | '''8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c\.\.\.''', | |||
| 18 | + | '''YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0''', | |||
| 19 | + | '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.\.\.''', | |||
| 20 | + | ] | |||
| 21 | + | ||||
| 22 | + | [[allowlists]] | |||
| 23 | + | description = "Allow deterministic non-secret test fixtures" | |||
| 24 | + | condition = "AND" | |||
| 25 | + | paths = [ | |||
| 26 | + | '''internal/adapters/in/cli/remote/client_test\.go$''', | |||
| 27 | + | '''internal/adapters/out/envloader/file_test\.go$''', | |||
| 28 | + | ] | |||
| 29 | + | regexes = [ | |||
| 30 | + | '''short-lived-123''', | |||
| 31 | + | '''YXBpLnN1Yi5leGFtcGxlLmNvbQ\.env''', | |||
| 32 | + | ] | |||
M Dockerfile +5 -6
| @@ -2,7 +2,7 @@ | |||||
| 2 | # Multi-stage build for optimized container image | 2 | # Multi-stage build for optimized container image | ||
| 3 | 3 | ||||
| 4 | # Build stage | 4 | # Build stage | ||
| 5 | - | FROM golang:1.25-alpine AS builder | 5 | + | FROM golang:1.26.1-alpine3.22 AS builder |
| 6 | 6 | ||||
| 7 | # Install build dependencies | 7 | # Install build dependencies | ||
| 8 | RUN apk add --no-cache git ca-certificates tzdata | 8 | RUN apk add --no-cache git ca-certificates tzdata | ||
| @@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ | |||||
| 26 | -o gordon . | 26 | -o gordon . | ||
| 27 | 27 | ||||
| 28 | # Runtime stage | 28 | # Runtime stage | ||
| 29 | - | FROM alpine:latest | 29 | + | FROM alpine:3.22 |
| 30 | 30 | ||||
| 31 | # Install runtime dependencies | 31 | # Install runtime dependencies | ||
| 32 | RUN apk add --no-cache \ | 32 | RUN apk add --no-cache \ | ||
| @@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example | |||||
| 56 | USER gordon | 56 | USER gordon | ||
| 57 | 57 | ||||
| 58 | # Expose ports | 58 | # Expose ports | ||
| 59 | - | EXPOSE 8080 5000 | 59 | + | EXPOSE 8088 5000 |
| 60 | 60 | ||||
| 61 | - | # Health check | 61 | + | # Admin health is served on the registry/admin listener and is gated by auth in |
| 62 | - | HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ | 62 | + | # normal deployments, so this image does not declare a Docker healthcheck. |
| 63 | - | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | |||
| 64 | 63 | ||||
| 65 | # Default command | 64 | # Default command | ||
| 66 | CMD ["./gordon", "serve"] | 65 | CMD ["./gordon", "serve"] | ||
M docs/cli/auth.md +2 -1
| @@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||||
| 139 | 139 | ||||
| 140 | Registry scopes: `push`, `pull`, `push,pull` | 140 | Registry scopes: `push`, `pull`, `push,pull` | ||
| 141 | 141 | ||||
| 142 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write` | 142 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` |
| 143 | 143 | ||||
| 144 | Combine scopes with commas: | 144 | Combine scopes with commas: | ||
| 145 | 145 | ||||
| @@ -280,6 +280,7 @@ Usage: | |||||
| 280 | | `admin:config:read` | Read-only config access | | 280 | | `admin:config:read` | Read-only config access | | ||
| 281 | | `admin:config:write` | Config write access | | 281 | | `admin:config:write` | Config write access | | ||
| 282 | | `admin:status:read` | Read-only status/health | | 282 | | `admin:status:read` | Read-only status/health | | ||
| 283 | + | | `admin:logs:read` | Read-only logs access | | |||
| 283 | | `admin:secrets:read` | List secret keys | | 284 | | `admin:secrets:read` | List secret keys | | ||
| 284 | | `admin:secrets:write` | Set/delete secrets | | 285 | | `admin:secrets:write` | Set/delete secrets | | ||
| 285 | 286 | ||||
M docs/cli/config.md +8 -7
| @@ -17,8 +17,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||||
| 17 | 17 | ||||
| 18 | ## gordon config show | 18 | ## gordon config show | ||
| 19 | 19 | ||||
| 20 | - | Display the full Gordon server configuration including server settings, | 20 | + | Display the Gordon server configuration including server settings, |
| 21 | - | auto-route, network isolation, routes, and external routes. | 21 | + | auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default. |
| 22 | 22 | ||||
| 23 | ```bash | 23 | ```bash | ||
| 24 | gordon config show | 24 | gordon config show | ||
| @@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 39 | "server": { | 39 | "server": { | ||
| 40 | "port": 1111, | 40 | "port": 1111, | ||
| 41 | "registry_port": 5000, | 41 | "registry_port": 5000, | ||
| 42 | - | "registry_domain": "reg.example.com", | 42 | + | "registry_domain": "reg.example.com" |
| 43 | - | "data_dir": "/var/lib/gordon" | |||
| 44 | }, | 43 | }, | ||
| 45 | "auto_route": { | 44 | "auto_route": { | ||
| 46 | "enabled": true, | 45 | "enabled": true, | ||
| @@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 53 | "routes": [ | 52 | "routes": [ | ||
| 54 | {"domain": "app.example.com", "image": "myapp:latest"} | 53 | {"domain": "app.example.com", "image": "myapp:latest"} | ||
| 55 | ], | 54 | ], | ||
| 56 | - | "external_routes": { | 55 | + | "external_routes": [ |
| 57 | - | "reg.example.com": "localhost:5000" | 56 | + | {"domain": "reg.example.com"} |
| 58 | - | } | 57 | + | ] |
| 59 | } | 58 | } | ||
| 60 | ``` | 59 | ``` | ||
| 61 | 60 | ||||
| 61 | + | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |||
| 62 | + | ||||
| 62 | ### Auto-Route Allowed Domains | 63 | ### Auto-Route Allowed Domains | ||
| 63 | 64 | ||||
| 64 | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | 65 | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | ||
M docs/cli/serve.md +2 -0
| @@ -235,6 +235,8 @@ gordon logs [domain] [options] | |||||
| 235 | Remote targeting uses client config or an active remote by default. | 235 | Remote targeting uses client config or an active remote by default. | ||
| 236 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | 236 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | ||
| 237 | 237 | ||||
| 238 | + | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |||
| 239 | + | ||||
| 238 | ### Examples | 240 | ### Examples | ||
| 239 | 241 | ||||
| 240 | ```bash | 242 | ```bash | ||
M docs/config/auth.md +3 -0
| @@ -92,6 +92,9 @@ Admin scopes (for remote CLI): | |||||
| 92 | | `admin:config:read` | Read-only config access | | 92 | | `admin:config:read` | Read-only config access | | ||
| 93 | | `admin:config:write` | Config write access | | 93 | | `admin:config:write` | Config write access | | ||
| 94 | | `admin:status:read` | Read-only status/health | | 94 | | `admin:status:read` | Read-only status/health | | ||
| 95 | + | | `admin:logs:read` | Read-only logs access | | |||
| 96 | + | | `admin:volumes:read` | List Docker volumes | | |||
| 97 | + | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | |||
| 95 | | `admin:secrets:read` | List secret keys | | 98 | | `admin:secrets:read` | List secret keys | | ||
| 96 | | `admin:secrets:write` | Set/delete secrets | | 99 | | `admin:secrets:write` | Set/delete secrets | | ||
| 97 | 100 | ||||
M docs/config/deploy.md +12 -0
| @@ -98,6 +98,18 @@ container shutdown. | |||||
| 98 | 98 | ||||
| 99 | Default: `"30s"` | 99 | Default: `"30s"` | ||
| 100 | 100 | ||||
| 101 | + | ## Container security profile | |||
| 102 | + | ||||
| 103 | + | Runtime hardening is configured under `[containers]`: | |||
| 104 | + | ||||
| 105 | + | ```toml | |||
| 106 | + | [containers] | |||
| 107 | + | security_profile = "compat" # "compat" or "strict" | |||
| 108 | + | ``` | |||
| 109 | + | ||||
| 110 | + | - `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults. | |||
| 111 | + | - `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile. | |||
| 112 | + | ||||
| 101 | ## Notes | 113 | ## Notes | ||
| 102 | 114 | ||||
| 103 | - Changes to `deploy.pull_policy` require a restart to take effect. | 115 | - Changes to `deploy.pull_policy` require a restart to take effect. | ||
M docs/config/images.md +6 -0
| @@ -16,6 +16,10 @@ The CLI `gordon images prune` uses the same defaults as the scheduled job (keep | |||||
| 16 | ## Configuration | 16 | ## Configuration | ||
| 17 | 17 | ||||
| 18 | ```toml | 18 | ```toml | ||
| 19 | + | [images] | |||
| 20 | + | allowed_registries = [] | |||
| 21 | + | require_digest = false | |||
| 22 | + | ||||
| 19 | [images.prune] | 23 | [images.prune] | ||
| 20 | enabled = false | 24 | enabled = false | ||
| 21 | schedule = "daily" | 25 | schedule = "daily" | ||
| @@ -26,6 +30,8 @@ keep_last = 3 | |||||
| 26 | 30 | ||||
| 27 | | Key | Type | Default | Description | | 31 | | Key | Type | Default | Description | | ||
| 28 | |-----|------|---------|-------------| | 32 | |-----|------|---------|-------------| | ||
| 33 | + | | `images.allowed_registries` | array | `[]` | Allowlist for external image registries. Empty means no external registries are accepted. Unqualified image names such as `nginx:latest` are treated as Docker Hub (`docker.io`) for policy checks. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. | | |||
| 34 | + | | `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. | | |||
| 29 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | 35 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | ||
| 30 | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | 36 | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | ||
| 31 | | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) | | 37 | | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) | | ||
M docs/config/index.md +18 -1
| @@ -63,6 +63,10 @@ drain_mode = "auto" # auto, inflight, delay | |||||
| 63 | drain_timeout = "30s" # Max wait for in-flight drain | 63 | drain_timeout = "30s" # Max wait for in-flight drain | ||
| 64 | drain_delay = "2s" # Wait after proxy invalidation before stopping the old container | 64 | drain_delay = "2s" # Wait after proxy invalidation before stopping the old container | ||
| 65 | 65 | ||||
| 66 | + | # Container runtime profile | |||
| 67 | + | [containers] | |||
| 68 | + | security_profile = "compat" # compat or strict | |||
| 69 | + | ||||
| 66 | # Logging | 70 | # Logging | ||
| 67 | [logging] | 71 | [logging] | ||
| 68 | level = "info" # trace, debug, info, warn, error | 72 | level = "info" # trace, debug, info, warn, error | ||
| @@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal | |||||
| 106 | [network_isolation] | 110 | [network_isolation] | ||
| 107 | enabled = true # Per-app isolated networks | 111 | enabled = true # Per-app isolated networks | ||
| 108 | network_prefix = "gordon" # Network name prefix | 112 | network_prefix = "gordon" # Network name prefix | ||
| 113 | + | internal = false # Set true to block direct egress from isolated networks | |||
| 109 | 114 | ||||
| 110 | # Auto-route | 115 | # Auto-route | ||
| 111 | [auto_route] | 116 | [auto_route] | ||
| @@ -138,6 +143,10 @@ weekly = 4 | |||||
| 138 | monthly = 12 | 143 | monthly = 12 | ||
| 139 | 144 | ||||
| 140 | # Images | 145 | # Images | ||
| 146 | + | [images] | |||
| 147 | + | allowed_registries = [] # Explicit external registries allowed for deploy/attachments | |||
| 148 | + | require_digest = false # Require digests for allowlisted external registries | |||
| 149 | + | ||||
| 141 | [images.prune] | 150 | [images.prune] | ||
| 142 | enabled = false | 151 | enabled = false | ||
| 143 | schedule = "daily" | 152 | schedule = "daily" | ||
| @@ -164,6 +173,7 @@ keep_last = 3 | |||||
| 164 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 173 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 165 | | `[backups]` | Database backups | [Backups](./backups.md) | | 174 | | `[backups]` | Database backups | [Backups](./backups.md) | | ||
| 166 | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | 175 | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | ||
| 176 | + | | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) | | |||
| 167 | 177 | ||||
| 168 | ## Default Values | 178 | ## Default Values | ||
| 169 | 179 | ||||
| @@ -172,6 +182,8 @@ keep_last = 3 | |||||
| 172 | | `server.port` | `8088` | | 182 | | `server.port` | `8088` | | ||
| 173 | | `server.registry_port` | `5000` | | 183 | | `server.registry_port` | `5000` | | ||
| 174 | | `server.data_dir` | `~/.gordon` | | 184 | | `server.data_dir` | `~/.gordon` | | ||
| 185 | + | | `server.max_blob_chunk_size` | `"95MB"` | | |||
| 186 | + | | `server.max_blob_size` | `"1GB"` | | |||
| 175 | | `auth.enabled` | `true` | | 187 | | `auth.enabled` | `true` | | ||
| 176 | | `auth.secrets_backend` | `"unsafe"` | | 188 | | `auth.secrets_backend` | `"unsafe"` | | ||
| 177 | | `auth.token_expiry` | `"30d"` | | 189 | | `auth.token_expiry` | `"30d"` | | ||
| @@ -187,6 +199,7 @@ keep_last = 3 | |||||
| 187 | | `deploy.drain_mode` | `"auto"` | | 199 | | `deploy.drain_mode` | `"auto"` | | ||
| 188 | | `deploy.drain_timeout` | `"30s"` | | 200 | | `deploy.drain_timeout` | `"30s"` | | ||
| 189 | | `deploy.drain_delay` | `"2s"` | | 201 | | `deploy.drain_delay` | `"2s"` | | ||
| 202 | + | | `containers.security_profile` | `"compat"` | | |||
| 190 | | `logging.level` | `"info"` | | 203 | | `logging.level` | `"info"` | | ||
| 191 | | `logging.format` | `"console"` | | 204 | | `logging.format` | `"console"` | | ||
| 192 | | `logging.file.enabled` | `false` | | 205 | | `logging.file.enabled` | `false` | | ||
| @@ -198,9 +211,12 @@ keep_last = 3 | |||||
| 198 | | `volumes.prefix` | `"gordon"` | | 211 | | `volumes.prefix` | `"gordon"` | | ||
| 199 | | `volumes.preserve` | `true` | | 212 | | `volumes.preserve` | `true` | | ||
| 200 | | `network_isolation.enabled` | `true` | | 213 | | `network_isolation.enabled` | `true` | | ||
| 214 | + | | `network_isolation.internal` | `false` | | |||
| 201 | | `auto_route.enabled` | `false` | | 215 | | `auto_route.enabled` | `false` | | ||
| 202 | | `backups.enabled` | `false` | | 216 | | `backups.enabled` | `false` | | ||
| 203 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | 217 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | ||
| 218 | + | | `images.allowed_registries` | `[]` | | |||
| 219 | + | | `images.require_digest` | `false` | | |||
| 204 | | `images.prune.enabled` | `false` | | 220 | | `images.prune.enabled` | `false` | | ||
| 205 | | `images.prune.schedule` | `"daily"` | | 221 | | `images.prune.schedule` | `"daily"` | | ||
| 206 | | `images.prune.keep_last` | `3` | | 222 | | `images.prune.keep_last` | `3` | | ||
| @@ -229,7 +245,6 @@ gordon reload | |||||
| 229 | | `server.gordon_domain` (registry domain) | | 245 | | `server.gordon_domain` (registry domain) | | ||
| 230 | | `server.registry_port` | | 246 | | `server.registry_port` | | ||
| 231 | | `server.max_proxy_body_size` | | 247 | | `server.max_proxy_body_size` | | ||
| 232 | - | | `server.max_blob_chunk_size` | | |||
| 233 | | `server.max_proxy_response_size` | | 248 | | `server.max_proxy_response_size` | | ||
| 234 | | `server.max_concurrent_conns` | | 249 | | `server.max_concurrent_conns` | | ||
| 235 | 250 | ||||
| @@ -241,6 +256,8 @@ gordon reload | |||||
| 241 | |---------| | 256 | |---------| | ||
| 242 | | `server.port` | | 257 | | `server.port` | | ||
| 243 | | `server.data_dir` | | 258 | | `server.data_dir` | | ||
| 259 | + | | `server.max_blob_chunk_size` | | |||
| 260 | + | | `server.max_blob_size` | | |||
| 244 | | `auth.*` | | 261 | | `auth.*` | | ||
| 245 | | `deploy.readiness_mode` | | 262 | | `deploy.readiness_mode` | | ||
| 246 | | `deploy.readiness_delay` | | 263 | | `deploy.readiness_delay` | | ||
M docs/config/network-isolation.md +2 -0
| @@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security. | |||||
| 8 | [network_isolation] | 8 | [network_isolation] | ||
| 9 | enabled = true | 9 | enabled = true | ||
| 10 | network_prefix = "gordon" | 10 | network_prefix = "gordon" | ||
| 11 | + | internal = false | |||
| 11 | ``` | 12 | ``` | ||
| 12 | 13 | ||||
| 13 | ## Migration Note | 14 | ## Migration Note | ||
| @@ -26,6 +27,7 @@ enabled = false | |||||
| 26 | |--------|------|---------|-------------| | 27 | |--------|------|---------|-------------| | ||
| 27 | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | | 28 | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | | ||
| 28 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | 29 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | ||
| 30 | + | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. | | |||
| 29 | 31 | ||||
| 30 | ## How It Works | 32 | ## How It Works | ||
| 31 | 33 | ||||
M docs/config/reference.md +19 -2
| @@ -17,7 +17,8 @@ tls_key_file = "" # PEM key path (optional, must be s | |||||
| 17 | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) | 17 | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) | ||
| 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | ||
| 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 20 | - | max_blob_chunk_size = "512MB" # Max size per registry blob upload 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 | |||
| 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | 22 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | ||
| 22 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | 23 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | ||
| 23 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | 24 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | ||
| @@ -93,6 +94,12 @@ drain_mode = "auto" # "auto", "inflight", "delay" | |||||
| 93 | drain_timeout = "30s" # Max wait for in-flight request drain | 94 | drain_timeout = "30s" # Max wait for in-flight request drain | ||
| 94 | drain_delay = "2s" # Wait after cache invalidation before old stop | 95 | drain_delay = "2s" # Wait after cache invalidation before old stop | ||
| 95 | 96 | ||||
| 97 | + | # ============================================================================= | |||
| 98 | + | # CONTAINERS | |||
| 99 | + | # ============================================================================= | |||
| 100 | + | [containers] | |||
| 101 | + | security_profile = "compat" # "compat" or "strict" | |||
| 102 | + | ||||
| 96 | # ============================================================================= | 103 | # ============================================================================= | ||
| 97 | # AUTO-ROUTE | 104 | # AUTO-ROUTE | ||
| 98 | # ============================================================================= | 105 | # ============================================================================= | ||
| @@ -105,6 +112,7 @@ enabled = false # Create routes from image labels a | |||||
| 105 | [network_isolation] | 112 | [network_isolation] | ||
| 106 | enabled = true # Enable per-app Docker networks | 113 | enabled = true # Enable per-app Docker networks | ||
| 107 | network_prefix = "gordon" # Prefix for created networks | 114 | network_prefix = "gordon" # Prefix for created networks | ||
| 115 | + | internal = false # Create Docker internal networks (blocks direct egress) | |||
| 108 | 116 | ||||
| 109 | # ============================================================================= | 117 | # ============================================================================= | ||
| 110 | # VOLUMES | 118 | # VOLUMES | ||
| @@ -157,6 +165,10 @@ monthly = 0 # Keep N monthly backups per DB | |||||
| 157 | # ============================================================================= | 165 | # ============================================================================= | ||
| 158 | # IMAGES | 166 | # IMAGES | ||
| 159 | # ============================================================================= | 167 | # ============================================================================= | ||
| 168 | + | [images] | |||
| 169 | + | allowed_registries = [] | |||
| 170 | + | require_digest = false | |||
| 171 | + | ||||
| 160 | [images.prune] | 172 | [images.prune] | ||
| 161 | enabled = false # Enable scheduled image cleanup | 173 | enabled = false # Enable scheduled image cleanup | ||
| 162 | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | 174 | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | ||
| @@ -178,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 178 | | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) | | 190 | | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) | | ||
| 179 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 191 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 180 | | `server.data_dir` | `~/.gordon` | Data directory | | 192 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 181 | - | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | 193 | + | | `server.max_blob_chunk_size` | `"95MB"` | Max size per registry blob upload chunk | |
| 194 | + | | `server.max_blob_size` | `"1GB"` | Max cumulative size per registry blob/layer upload | | |||
| 182 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 195 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 183 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | 196 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | ||
| 184 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | 197 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | ||
| @@ -215,9 +228,11 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 215 | | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) | | 228 | | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) | | ||
| 216 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | 229 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | ||
| 217 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | 230 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | ||
| 231 | + | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | |||
| 218 | | `auto_route.enabled` | `false` | Auto-route disabled | | 232 | | `auto_route.enabled` | `false` | Auto-route disabled | | ||
| 219 | | `network_isolation.enabled` | `true` | Network isolation enabled | | 233 | | `network_isolation.enabled` | `true` | Network isolation enabled | | ||
| 220 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | 234 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | ||
| 235 | + | | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress | | |||
| 221 | | `volumes.auto_create` | `true` | Auto-create volumes | | 236 | | `volumes.auto_create` | `true` | Auto-create volumes | | ||
| 222 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 237 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 223 | | `volumes.preserve` | `true` | Keep volumes | | 238 | | `volumes.preserve` | `true` | Keep volumes | | ||
| @@ -228,6 +243,8 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 228 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | 243 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | ||
| 229 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | 244 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | ||
| 230 | | `backups.retention.monthly` | `0` | Keep no monthly backups by default | | 245 | | `backups.retention.monthly` | `0` | Keep no monthly backups by default | | ||
| 246 | + | | `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected | | |||
| 247 | + | | `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries | | |||
| 231 | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | 248 | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | ||
| 232 | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | 249 | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | ||
| 233 | | `images.prune.keep_last` | `3` | Number of recent tags kept per repository | | 250 | | `images.prune.keep_last` | `3` | Number of recent tags kept per repository | | ||
A docs/config/security-hardening.md +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | # Security Hardening Controls | |||
| 2 | + | ||||
| 3 | + | This page summarizes Gordon's security-related configuration knobs and the defaults operators should know before exposing an instance. | |||
| 4 | + | ||||
| 5 | + | ## Registry upload quotas | |||
| 6 | + | ||||
| 7 | + | Registry blob uploads are bounded by two limits under `[server]`: | |||
| 8 | + | ||||
| 9 | + | ```toml | |||
| 10 | + | [server] | |||
| 11 | + | max_blob_chunk_size = "95MB" | |||
| 12 | + | max_blob_size = "1GB" | |||
| 13 | + | ``` | |||
| 14 | + | ||||
| 15 | + | - `max_blob_chunk_size` limits a single upload chunk. | |||
| 16 | + | - `max_blob_size` limits the cumulative blob/layer upload size. | |||
| 17 | + | - Exceeding either limit returns an OCI-compatible size error and the failed upload is cleaned up. | |||
| 18 | + | ||||
| 19 | + | ## Admin logs permission | |||
| 20 | + | ||||
| 21 | + | Container and deploy logs can include environment-derived output. Gordon gates log access behind a dedicated scope: | |||
| 22 | + | ||||
| 23 | + | ```bash | |||
| 24 | + | gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d | |||
| 25 | + | ``` | |||
| 26 | + | ||||
| 27 | + | - `/admin/logs` and deploy failure logs require `admin:logs:read`. | |||
| 28 | + | - `admin:status:read` does not grant log access. | |||
| 29 | + | - Common secret patterns are redacted before logs are returned. | |||
| 30 | + | ||||
| 31 | + | ## Volume pruning scope | |||
| 32 | + | ||||
| 33 | + | Volume pruning only removes unused Docker volumes explicitly managed by Gordon (`gordon.managed=true`). It ignores unrelated Docker volumes even if they are unused. | |||
| 34 | + | ||||
| 35 | + | Use dedicated admin scopes: | |||
| 36 | + | ||||
| 37 | + | - `admin:volumes:read` for listing volumes. | |||
| 38 | + | - `admin:volumes:write` for prune operations. | |||
| 39 | + | ||||
| 40 | + | ## Pass migration plaintext handling | |||
| 41 | + | ||||
| 42 | + | When Gordon migrates legacy plaintext `.env` files into `pass`, it removes the plaintext source after a successful migration and does not leave `.env.migrated` copies by default. If pass entries already exist, migration fails closed and leaves the plaintext file in place for manual operator review rather than deleting potentially unique values. | |||
| 43 | + | ||||
| 44 | + | ## External image registries | |||
| 45 | + | ||||
| 46 | + | Gordon's configured registry is always allowed. Explicit external registries must be allowlisted: | |||
| 47 | + | ||||
| 48 | + | ```toml | |||
| 49 | + | [images] | |||
| 50 | + | allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"] | |||
| 51 | + | require_digest = true | |||
| 52 | + | ``` | |||
| 53 | + | ||||
| 54 | + | - Empty `allowed_registries` rejects explicit external registries. | |||
| 55 | + | - `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected. | |||
| 56 | + | - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`. | |||
| 57 | + | - Include ports in allowlist entries when the registry uses a non-default port. | |||
| 58 | + | ||||
| 59 | + | ## Docker network isolation | |||
| 60 | + | ||||
| 61 | + | Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks: | |||
| 62 | + | ||||
| 63 | + | ```toml | |||
| 64 | + | [network_isolation] | |||
| 65 | + | enabled = true | |||
| 66 | + | internal = true | |||
| 67 | + | ``` | |||
| 68 | + | ||||
| 69 | + | `internal = false` remains the compatibility default because some applications and attachments need direct egress during startup. | |||
| 70 | + | ||||
| 71 | + | ## Container runtime profile | |||
| 72 | + | ||||
| 73 | + | ```toml | |||
| 74 | + | [containers] | |||
| 75 | + | security_profile = "compat" # or "strict" | |||
| 76 | + | ``` | |||
| 77 | + | ||||
| 78 | + | - `compat` preserves broad image compatibility while retaining `no-new-privileges` and default capability restrictions. | |||
| 79 | + | - `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. | |||
| 80 | + | ||||
| 81 | + | Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities. | |||
| 82 | + | ||||
| 83 | + | ## Related | |||
| 84 | + | ||||
| 85 | + | - [Auth](./auth.md) | |||
| 86 | + | - [Images](./images.md) | |||
| 87 | + | - [Network Isolation](./network-isolation.md) | |||
| 88 | + | - [Deploy](./deploy.md) | |||
| 89 | + | - [Volumes](./volumes.md) | |||
| 90 | + | - [Reference](./reference.md) | |||
M docs/config/server.md +19 -0
| @@ -14,6 +14,8 @@ tls_port = 8443 # HTTPS listener port (0 = disabled) | |||||
| 14 | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | 14 | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | ||
| 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | ||
| 16 | # data_dir = "~/.gordon" # Data storage directory (default) | 16 | # data_dir = "~/.gordon" # Data storage directory (default) | ||
| 17 | + | max_blob_chunk_size = "95MB" # Max registry blob upload chunk | |||
| 18 | + | max_blob_size = "1GB" # Max cumulative registry blob/layer upload | |||
| 17 | ``` | 19 | ``` | ||
| 18 | 20 | ||||
| 19 | ## Options | 21 | ## Options | ||
| @@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 33 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | ||
| 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | 34 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | ||
| 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | 35 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | ||
| 36 | + | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | |||
| 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 37 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 35 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | 38 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | ||
| 36 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | 39 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | ||
| @@ -274,6 +277,22 @@ max_blob_chunk_size = "1GB" # Allow larger layers | |||||
| 274 | - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine | 277 | - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine | ||
| 275 | - Increase it only if you expect larger direct registry uploads from other clients | 278 | - Increase it only if you expect larger direct registry uploads from other clients | ||
| 276 | 279 | ||||
| 280 | + | ## Maximum Registry Blob Size | |||
| 281 | + | ||||
| 282 | + | The `max_blob_size` setting limits the cumulative size of one blob/layer upload across all chunks: | |||
| 283 | + | ||||
| 284 | + | ```toml | |||
| 285 | + | [server] | |||
| 286 | + | max_blob_size = "1GB" # Default | |||
| 287 | + | ``` | |||
| 288 | + | ||||
| 289 | + | **Purpose:** Prevents disk exhaustion from multi-chunk uploads where each individual chunk is below `max_blob_chunk_size` but the total blob grows without bound. | |||
| 290 | + | ||||
| 291 | + | **Behavior:** | |||
| 292 | + | - Gordon tracks the current upload size and rejects appends that would exceed the limit. | |||
| 293 | + | - Rejected uploads return an OCI-compatible `413` size error. | |||
| 294 | + | - Failed uploads are cleaned up so temporary blob data does not accumulate indefinitely. | |||
| 295 | + | ||||
| 277 | ## Registry IP Allowlist | 296 | ## Registry IP Allowlist | ||
| 278 | 297 | ||||
| 279 | The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default). | 298 | The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default). | ||
A docs/security-hardening-regression-matrix.md +222 -0
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | # Security Hardening Regression Matrix | |||
| 2 | + | ||||
| 3 | + | This matrix lists the behaviors that should be tested while implementing the security-hardening fixes from the audit. It is intentionally focused on regressions: normal Gordon workflows must continue to work while exploit paths are blocked. | |||
| 4 | + | ||||
| 5 | + | ## Registry uploads | |||
| 6 | + | ||||
| 7 | + | ### Normal behavior to preserve | |||
| 8 | + | - Docker push of a small image succeeds. | |||
| 9 | + | - Docker push with multiple layers succeeds when every layer is below the configured cumulative blob limit. | |||
| 10 | + | - Chunked blob upload succeeds when the cumulative size remains below the new cumulative limit setting, tentatively named `max_blob_size` unless implementation chooses a clearer config name. | |||
| 11 | + | - A final `PUT` with a valid digest finalizes the blob and returns OCI-compatible headers. | |||
| 12 | + | - Existing `max_blob_chunk_size` behavior still rejects a single oversized chunk; this remains distinct from the cumulative per-blob/layer limit. | |||
| 13 | + | ||||
| 14 | + | ### Security behavior to verify | |||
| 15 | + | - Repeated `PATCH` chunks cannot exceed the cumulative upload limit. | |||
| 16 | + | - `Content-Length` larger than the remaining allowed size is rejected before writing when possible. | |||
| 17 | + | - A rejected upload does not leave unbounded stale data on disk. | |||
| 18 | + | - Cancel/cleanup paths remove upload temp files and upload locks. | |||
| 19 | + | - Error response remains Docker Registry compatible (`413` / `SIZE_INVALID`). | |||
| 20 | + | ||||
| 21 | + | ### Suggested tests | |||
| 22 | + | - Unit tests for filesystem blob append under and over cumulative size. | |||
| 23 | + | - Handler tests for `PATCH` and final `PUT` over limit. | |||
| 24 | + | - Integration/manual: push image with a large layer and confirm failure is clean. | |||
| 25 | + | ||||
| 26 | + | ## Deploy errors and logs | |||
| 27 | + | ||||
| 28 | + | ### Normal behavior to preserve | |||
| 29 | + | - Successful deploy response still includes status, domain, and container ID. | |||
| 30 | + | - Failed deploy still returns useful high-level error/cause/hint. | |||
| 31 | + | - Operators with the new logs permission can still retrieve logs through `/admin/logs`. | |||
| 32 | + | - CLI deploy displays helpful failure information without leaking raw secrets. | |||
| 33 | + | ||||
| 34 | + | ### Security behavior to verify | |||
| 35 | + | - A token with only `admin:config:write` cannot receive raw container logs in `/admin/deploy` error responses. | |||
| 36 | + | - Logs returned through log endpoints are redacted for common secret patterns. | |||
| 37 | + | - `admin:status:read` no longer grants log read access. | |||
| 38 | + | - `admin:*:*` remains compatible and can access logs. | |||
| 39 | + | ||||
| 40 | + | ### Suggested tests | |||
| 41 | + | - Handler tests for deploy error response with and without `logs:read`. | |||
| 42 | + | - Scope tests for `logs:read`, wildcard, and old `status:read` behavior. | |||
| 43 | + | - Redactor table tests for `PASSWORD=`, `TOKEN=`, `SECRET=`, `API_KEY=`, `DATABASE_URL=`, and simple JSON fields. | |||
| 44 | + | ||||
| 45 | + | ## Admin scopes and compatibility | |||
| 46 | + | ||||
| 47 | + | ### Normal behavior to preserve | |||
| 48 | + | - Existing `admin:*:*` tokens continue to work everywhere. | |||
| 49 | + | - Existing `routes`, `config`, `secrets`, and `status` scopes keep their intended behavior. | |||
| 50 | + | - Token exchange through `/auth/token` still works for admin scopes. | |||
| 51 | + | ||||
| 52 | + | ### Security behavior to verify | |||
| 53 | + | - New sensitive resources such as `logs` and possibly `volumes` are parsed and enforced. | |||
| 54 | + | - Unknown or malformed scopes remain ignored/denied, not accidentally allowed. | |||
| 55 | + | ||||
| 56 | + | ### Suggested tests | |||
| 57 | + | - Domain auth scope parsing tests. | |||
| 58 | + | - Admin middleware/handler tests for each sensitive endpoint. | |||
| 59 | + | ||||
| 60 | + | ## Auto-route env-file extraction | |||
| 61 | + | ||||
| 62 | + | ### Normal behavior to preserve | |||
| 63 | + | - Valid `gordon.env-file` imports default env values for a newly created auto-route. | |||
| 64 | + | - Existing env values still override image-provided defaults. | |||
| 65 | + | - Secret reference rejection remains intact. | |||
| 66 | + | - Auto-route without env-file remains unchanged. | |||
| 67 | + | ||||
| 68 | + | ### Security behavior to verify | |||
| 69 | + | - Oversized env files are rejected before full memory read. | |||
| 70 | + | - Disallowed paths (`/proc`, `/sys`, `/dev`, `/run`, `/var/run`, `/`, traversal) are rejected. | |||
| 71 | + | - Failed env-file import does not prevent route/deploy behavior beyond the intended warning path unless explicitly designed. | |||
| 72 | + | ||||
| 73 | + | ### Suggested tests | |||
| 74 | + | - Runtime extraction tests using a controlled reader larger than the limit. | |||
| 75 | + | - Auto-route usecase tests for allowed/disallowed paths. | |||
| 76 | + | - Tests confirming `${pass:...}` / `${sops:...}` refs remain rejected. | |||
| 77 | + | ||||
| 78 | + | ## Domain canonicalization and proxy routing | |||
| 79 | + | ||||
| 80 | + | ### Normal behavior to preserve | |||
| 81 | + | - Existing lowercase route domains continue to resolve. | |||
| 82 | + | - Registry domain still routes to registry. | |||
| 83 | + | - External routes still resolve and retain SSRF protections. | |||
| 84 | + | - Hostnames with expected public domains work through proxy. | |||
| 85 | + | ||||
| 86 | + | ### Security behavior to verify | |||
| 87 | + | - `App.Example.com` and `app.example.com` map to the same canonical route. | |||
| 88 | + | - Duplicate case-variant routes cannot be created. | |||
| 89 | + | - Host with invalid port/trailing dot/case is handled consistently. | |||
| 90 | + | - HTTP-to-HTTPS redirect does not reflect arbitrary unconfigured Host values. | |||
| 91 | + | ||||
| 92 | + | ### Suggested tests | |||
| 93 | + | - Config service tests for add/update/list canonical route domains. | |||
| 94 | + | - Auto-route tests for case variants. | |||
| 95 | + | - Proxy handler tests for Host normalization. | |||
| 96 | + | - Middleware tests for redirect target host validation. | |||
| 97 | + | ||||
| 98 | + | ## Volumes | |||
| 99 | + | ||||
| 100 | + | ### Normal behavior to preserve | |||
| 101 | + | - Listing volumes still shows Gordon-managed volumes. | |||
| 102 | + | - Dry-run prune still reports what would be removed. | |||
| 103 | + | - Actual prune still removes unused Gordon-managed volumes. | |||
| 104 | + | - In-use Gordon volumes are not removed. | |||
| 105 | + | ||||
| 106 | + | ### Security behavior to verify | |||
| 107 | + | - Unused non-Gordon Docker volumes are not removed. | |||
| 108 | + | - Volumes without explicit Gordon ownership metadata, preferably `gordon.managed=true`, are ignored; prefix-only matching is unsafe except as a constrained legacy fallback with dedicated tests. | |||
| 109 | + | - Any new `volumes:read/write` scope is enforced correctly. | |||
| 110 | + | ||||
| 111 | + | ### Suggested tests | |||
| 112 | + | - Volume service tests with mixed Gordon/non-Gordon, used/unused volumes. | |||
| 113 | + | - Admin handler tests for permissions. | |||
| 114 | + | - Manual: create an unrelated Docker volume, run Gordon prune dry-run/real, confirm it remains. | |||
| 115 | + | ||||
| 116 | + | ## Backups | |||
| 117 | + | ||||
| 118 | + | ### Normal behavior to preserve | |||
| 119 | + | - Manual backup completes and appears in list. | |||
| 120 | + | - Scheduled backup path format remains readable/listable. | |||
| 121 | + | - Backup detect still finds supported DB attachments. | |||
| 122 | + | - Existing backups can still be listed after filename format changes. | |||
| 123 | + | ||||
| 124 | + | ### Security behavior to verify | |||
| 125 | + | - Two backups for same domain/db/schedule in the same second do not share tmp/final filenames. | |||
| 126 | + | - Concurrent backups do not truncate each other. | |||
| 127 | + | - Store uses unique tmp files and atomic rename. | |||
| 128 | + | - Path traversal protections remain intact. | |||
| 129 | + | ||||
| 130 | + | ### Suggested tests | |||
| 131 | + | - Filesystem storage tests with identical timestamps. | |||
| 132 | + | - Concurrent store test with two readers and same domain/db/schedule. | |||
| 133 | + | - List tests for old and new filename formats if compatibility is needed. | |||
| 134 | + | ||||
| 135 | + | ## Secrets and pass migration | |||
| 136 | + | ||||
| 137 | + | ### Normal behavior to preserve | |||
| 138 | + | - `pass` backend still migrates existing env keys correctly. | |||
| 139 | + | - Attachment env migration still works. | |||
| 140 | + | - Unsafe backend still loads valid files under `dataDir/secrets` for development. | |||
| 141 | + | - Existing env file parsing remains compatible. | |||
| 142 | + | ||||
| 143 | + | ### Security behavior to verify | |||
| 144 | + | - Unsafe backend rejects `../` and absolute `auth.token_secret` paths. | |||
| 145 | + | - Migration does not leave plaintext `.env.migrated` by default. | |||
| 146 | + | - If an explicit keep/quarantine option exists, files are stored with restrictive permissions and loud warnings. | |||
| 147 | + | ||||
| 148 | + | ### Suggested tests | |||
| 149 | + | - `loadSecret` path traversal tests. | |||
| 150 | + | - Migration tests verifying plaintext source file removal/quarantine. | |||
| 151 | + | - Permission tests where practical (`0600` files, `0700` dirs). | |||
| 152 | + | ||||
| 153 | + | ## Config snapshot | |||
| 154 | + | ||||
| 155 | + | ### Normal behavior to preserve | |||
| 156 | + | - `/admin/config` still returns enough information for CLI/admin use. | |||
| 157 | + | - Routes and network isolation status remain visible to authorized callers. | |||
| 158 | + | ||||
| 159 | + | ### Security behavior to verify | |||
| 160 | + | - `data_dir` is hidden by default or only available under a deliberately stronger detailed mode. | |||
| 161 | + | - External route targets are hidden/redacted by default or gated. | |||
| 162 | + | - `config:read` behavior remains documented. | |||
| 163 | + | ||||
| 164 | + | ### Suggested tests | |||
| 165 | + | - Admin config response tests for default and detailed modes. | |||
| 166 | + | - CLI remote config tests if output shape changes. | |||
| 167 | + | ||||
| 168 | + | ## External image registries | |||
| 169 | + | ||||
| 170 | + | ### Normal behavior to preserve | |||
| 171 | + | - Images in the Gordon registry deploy normally. | |||
| 172 | + | - Explicitly allowlisted external registries deploy if configured. | |||
| 173 | + | - Attachments still deploy from allowed sources. | |||
| 174 | + | ||||
| 175 | + | ### Security behavior to verify | |||
| 176 | + | - `localhost`, loopback, private IP, link-local, metadata IP, and DNS resolving to private ranges are rejected unless a deliberate unsafe override exists. | |||
| 177 | + | - Non-allowlisted external registries are rejected before Docker pull. | |||
| 178 | + | - Error messages do not leak sensitive network details. | |||
| 179 | + | ||||
| 180 | + | ### Suggested tests | |||
| 181 | + | - Image reference validation table tests. | |||
| 182 | + | - Config tests for `allowed_registries`. | |||
| 183 | + | - Container service tests ensuring rejected images do not call `PullImage`. | |||
| 184 | + | ||||
| 185 | + | ## Docker networks and runtime hardening | |||
| 186 | + | ||||
| 187 | + | ### Normal behavior to preserve | |||
| 188 | + | - Default/compat profile keeps common apps and attachments working. | |||
| 189 | + | - Attachments remain reachable by service alias where intended. | |||
| 190 | + | - Network groups still work when configured. | |||
| 191 | + | ||||
| 192 | + | ### Security behavior to verify | |||
| 193 | + | - `internal` network option is applied when configured. | |||
| 194 | + | - Strict profile applies readonly rootfs/user/caps as designed. | |||
| 195 | + | - `no-new-privileges:true` and cap dropping are preserved. | |||
| 196 | + | - Compat profile behavior is documented and tested. | |||
| 197 | + | ||||
| 198 | + | ### Suggested tests | |||
| 199 | + | - Docker adapter config construction tests. | |||
| 200 | + | - Container service tests for network option propagation. | |||
| 201 | + | - Manual smoke: deploy app + postgres attachment in compat profile. | |||
| 202 | + | - Manual smoke: deploy simple read-only app in strict profile. | |||
| 203 | + | ||||
| 204 | + | ## Install, Dockerfile, and supply-chain | |||
| 205 | + | ||||
| 206 | + | ### Normal behavior to preserve | |||
| 207 | + | - `install.sh` installs the binary on Linux/macOS. | |||
| 208 | + | - GitHub composite action installs Gordon in CI. | |||
| 209 | + | - Dockerfile builds successfully. | |||
| 210 | + | ||||
| 211 | + | ### Security behavior to verify | |||
| 212 | + | - Tar extraction only extracts `gordon`. | |||
| 213 | + | - Installed binary must be a regular file, not symlink/directory. | |||
| 214 | + | - Permissions are explicit (`0755`). | |||
| 215 | + | - Checksums/signatures are verified consistently. | |||
| 216 | + | - Gitleaks output is clean or only contains allowlisted examples. | |||
| 217 | + | ||||
| 218 | + | ### Suggested tests | |||
| 219 | + | - Shellcheck/manual review for install script. | |||
| 220 | + | - Action script dry-run if practical. | |||
| 221 | + | - Docker build smoke after version alignment. | |||
| 222 | + | - Gitleaks on `git archive HEAD`, not local `.worktrees`. | |||
M go.mod +1 -1
| @@ -110,7 +110,7 @@ require ( | |||||
| 110 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 | 110 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 | ||
| 111 | go.opentelemetry.io/otel/metric v1.43.0 | 111 | go.opentelemetry.io/otel/metric v1.43.0 | ||
| 112 | go.opentelemetry.io/otel/trace v1.43.0 | 112 | 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 |
| 114 | golang.org/x/text v0.35.0 // indirect | 114 | golang.org/x/text v0.35.0 // indirect | ||
| 115 | gopkg.in/yaml.v3 v3.0.1 // indirect | 115 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| 116 | gotest.tools/v3 v3.5.2 // indirect | 116 | gotest.tools/v3 v3.5.2 // indirect | ||
A gordon.toml.example +94 -0
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | # Gordon example configuration | |||
| 2 | + | # Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain. | |||
| 3 | + | ||||
| 4 | + | [server] | |||
| 5 | + | port = 8088 | |||
| 6 | + | registry_port = 5000 | |||
| 7 | + | gordon_domain = "gordon.example.com" | |||
| 8 | + | # data_dir = "~/.gordon" | |||
| 9 | + | ||||
| 10 | + | [auth] | |||
| 11 | + | enabled = true | |||
| 12 | + | # Use "pass" or "sops" in production. The unsafe backend reads a local file | |||
| 13 | + | # under server.data_dir/secrets and is intended for development only. | |||
| 14 | + | secrets_backend = "pass" | |||
| 15 | + | token_secret = "gordon/auth/token_secret" | |||
| 16 | + | token_expiry = "30d" | |||
| 17 | + | access_token_ttl = "15m" | |||
| 18 | + | ||||
| 19 | + | [api.rate_limit] | |||
| 20 | + | enabled = true | |||
| 21 | + | global_rps = 500 | |||
| 22 | + | per_ip_rps = 50 | |||
| 23 | + | burst = 100 | |||
| 24 | + | trusted_proxies = [] | |||
| 25 | + | ||||
| 26 | + | [deploy] | |||
| 27 | + | pull_policy = "if-tag-changed" | |||
| 28 | + | readiness_mode = "auto" | |||
| 29 | + | health_timeout = "90s" | |||
| 30 | + | readiness_delay = "5s" | |||
| 31 | + | drain_mode = "auto" | |||
| 32 | + | drain_timeout = "30s" | |||
| 33 | + | drain_delay = "2s" | |||
| 34 | + | ||||
| 35 | + | [containers] | |||
| 36 | + | # "compat" preserves broad image compatibility. "strict" enables a read-only | |||
| 37 | + | # root filesystem and narrower Linux capabilities. | |||
| 38 | + | security_profile = "compat" | |||
| 39 | + | ||||
| 40 | + | [network_isolation] | |||
| 41 | + | enabled = true | |||
| 42 | + | network_prefix = "gordon" | |||
| 43 | + | # Set true to create Docker internal networks without direct external egress. | |||
| 44 | + | internal = false | |||
| 45 | + | ||||
| 46 | + | [volumes] | |||
| 47 | + | auto_create = true | |||
| 48 | + | prefix = "gordon" | |||
| 49 | + | preserve = true | |||
| 50 | + | ||||
| 51 | + | [images] | |||
| 52 | + | # Explicit external registries must be listed here. Gordon's own registry is | |||
| 53 | + | # always allowed; localhost/private/link-local registries are always rejected. | |||
| 54 | + | allowed_registries = [] | |||
| 55 | + | # Require @sha256:<digest> for allowlisted external registries. | |||
| 56 | + | require_digest = false | |||
| 57 | + | ||||
| 58 | + | [images.prune] | |||
| 59 | + | enabled = false | |||
| 60 | + | schedule = "daily" | |||
| 61 | + | keep_last = 3 | |||
| 62 | + | ||||
| 63 | + | [auto_route] | |||
| 64 | + | enabled = false | |||
| 65 | + | ||||
| 66 | + | [logging] | |||
| 67 | + | level = "info" | |||
| 68 | + | format = "console" | |||
| 69 | + | ||||
| 70 | + | [logging.container_logs] | |||
| 71 | + | enabled = true | |||
| 72 | + | ||||
| 73 | + | [backups] | |||
| 74 | + | enabled = false | |||
| 75 | + | schedule = "daily" | |||
| 76 | + | storage_dir = "" | |||
| 77 | + | ||||
| 78 | + | [backups.retention] | |||
| 79 | + | hourly = 0 | |||
| 80 | + | daily = 0 | |||
| 81 | + | weekly = 0 | |||
| 82 | + | monthly = 0 | |||
| 83 | + | ||||
| 84 | + | [routes] | |||
| 85 | + | # "app.example.com" = { image = "myapp:latest" } | |||
| 86 | + | ||||
| 87 | + | [external_routes] | |||
| 88 | + | # "status.example.com" = "127.0.0.1:9000" | |||
| 89 | + | ||||
| 90 | + | [network_groups] | |||
| 91 | + | # "backend" = ["app.example.com", "api.example.com"] | |||
| 92 | + | ||||
| 93 | + | [attachments] | |||
| 94 | + | # "app.example.com" = ["postgres:18"] | |||
M install.sh +18 -7
| @@ -101,11 +101,14 @@ fi | |||||
| 101 | 101 | ||||
| 102 | # Verify checksum | 102 | # Verify checksum | ||
| 103 | echo "Verifying checksum..." | 103 | echo "Verifying checksum..." | ||
| 104 | - | EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}') | 104 | + | TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g') |
| 105 | - | if [ -z "$EXPECTED_CHECKSUM" ]; then | 105 | + | CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true) |
| 106 | - | echo "Error: Could not find checksum for ${TARBALL} in checksums.txt" | 106 | + | CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ') |
| 107 | + | if [ "$CHECKSUM_COUNT" != "1" ]; then | |||
| 108 | + | echo "Error: Expected exactly one checksum for ${TARBALL} in checksums.txt" | |||
| 107 | exit 1 | 109 | exit 1 | ||
| 108 | fi | 110 | fi | ||
| 111 | + | EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}') | |||
| 109 | 112 | ||||
| 110 | # Calculate actual checksum (works on both Linux and macOS) | 113 | # Calculate actual checksum (works on both Linux and macOS) | ||
| 111 | if command -v sha256sum >/dev/null 2>&1; then | 114 | if command -v sha256sum >/dev/null 2>&1; then | ||
| @@ -132,15 +135,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then | |||||
| 132 | fi | 135 | fi | ||
| 133 | echo "Checksum verified successfully." | 136 | echo "Checksum verified successfully." | ||
| 134 | 137 | ||||
| 135 | - | echo "Extracting..." | 138 | + | echo "Extracting Gordon binary..." |
| 136 | - | tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" | 139 | + | EXTRACT_DIR="$TMP_DIR/extract" |
| 140 | + | mkdir -p "$EXTRACT_DIR" | |||
| 141 | + | tar -xzf "$TMP_DIR/$TARBALL" -C "$EXTRACT_DIR" gordon | |||
| 142 | + | ||||
| 143 | + | BINARY="$EXTRACT_DIR/gordon" | |||
| 144 | + | if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then | |||
| 145 | + | echo "Error: Archive did not contain a regular Gordon binary" | |||
| 146 | + | exit 1 | |||
| 147 | + | fi | |||
| 137 | 148 | ||||
| 138 | echo "Installing to ${INSTALL_DIR}..." | 149 | echo "Installing to ${INSTALL_DIR}..." | ||
| 139 | if [ -w "$INSTALL_DIR" ]; then | 150 | if [ -w "$INSTALL_DIR" ]; then | ||
| 140 | - | mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | 151 | + | install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" |
| 141 | else | 152 | else | ||
| 142 | echo "sudo required to install to ${INSTALL_DIR}" | 153 | echo "sudo required to install to ${INSTALL_DIR}" | ||
| 143 | - | sudo mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | 154 | + | sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" |
| 144 | fi | 155 | fi | ||
| 145 | 156 | ||||
| 146 | # Verify installation | 157 | # Verify installation | ||
M internal/adapters/dto/admin_config.go +2 -2
| @@ -14,7 +14,7 @@ type ServerConfig struct { | |||||
| 14 | Port int `json:"port"` | 14 | Port int `json:"port"` | ||
| 15 | RegistryPort int `json:"registry_port"` | 15 | RegistryPort int `json:"registry_port"` | ||
| 16 | RegistryDomain string `json:"registry_domain"` | 16 | RegistryDomain string `json:"registry_domain"` | ||
| 17 | - | DataDir string `json:"data_dir"` | 17 | + | DataDir string `json:"data_dir,omitempty"` |
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | // AutoRouteConfig represents auto-route config details. | 20 | // AutoRouteConfig represents auto-route config details. | ||
| @@ -31,5 +31,5 @@ type NetworkIsolationConfig struct { | |||||
| 31 | // ExternalRoute represents an external route config entry. | 31 | // ExternalRoute represents an external route config entry. | ||
| 32 | type ExternalRoute struct { | 32 | type ExternalRoute struct { | ||
| 33 | Domain string `json:"domain"` | 33 | Domain string `json:"domain"` | ||
| 34 | - | Target string `json:"target"` | 34 | + | Target string `json:"target,omitempty"` |
| 35 | } | 35 | } | ||
M internal/adapters/in/cli/auth.go +4 -1
| @@ -84,13 +84,16 @@ Registry scopes (for Docker push/pull): | |||||
| 84 | Admin scopes (for remote CLI access): | 84 | Admin scopes (for remote CLI access): | ||
| 85 | Format: admin:<resource>:<actions> | 85 | Format: admin:<resource>:<actions> | ||
| 86 | 86 | ||||
| 87 | - | Resources: routes, secrets, config, status, * (all) | 87 | + | Resources: routes, secrets, config, status, logs, volumes, * (all) |
| 88 | Actions: read, write, * (all) | 88 | Actions: read, write, * (all) | ||
| 89 | 89 | ||||
| 90 | Examples: | 90 | Examples: | ||
| 91 | admin:*:* Full admin access (recommended for CLI) | 91 | admin:*:* Full admin access (recommended for CLI) | ||
| 92 | admin:routes:read Read-only routes access | 92 | admin:routes:read Read-only routes access | ||
| 93 | admin:status:read Read-only status access | 93 | admin:status:read Read-only status access | ||
| 94 | + | admin:logs:read Read-only log access | |||
| 95 | + | admin:volumes:read Read-only volume access | |||
| 96 | + | admin:volumes:write Volume management access | |||
| 94 | 97 | ||||
| 95 | Repository scoping: | 98 | Repository scoping: | ||
| 96 | --repo myapp Scope to specific repository | 99 | --repo myapp Scope to specific repository | ||
M internal/adapters/in/cli/config.go +13 -10
| @@ -94,8 +94,10 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error { | |||||
| 94 | if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil { | 94 | if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil { | ||
| 95 | return err | 95 | return err | ||
| 96 | } | 96 | } | ||
| 97 | - | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { | 97 | + | if config.Server.DataDir != "" { |
| 98 | - | return err | 98 | + | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { |
| 99 | + | return err | |||
| 100 | + | } | |||
| 99 | } | 101 | } | ||
| 100 | if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil { | 102 | if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil { | ||
| 101 | return err | 103 | return err | ||
| @@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | |||||
| 155 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | 157 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | ||
| 156 | return err | 158 | return err | ||
| 157 | } | 159 | } | ||
| 158 | - | domains := make([]string, 0, len(config.ExternalRoutes)) | 160 | + | routes := append([]remote.ExternalRoute(nil), config.ExternalRoutes...) |
| 159 | - | for d := range config.ExternalRoutes { | 161 | + | sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain }) |
| 160 | - | domains = append(domains, d) | |||
| 161 | - | } | |||
| 162 | - | sort.Strings(domains) | |||
| 163 | 162 | ||||
| 164 | - | rows := make([][]string, 0, len(domains)) | 163 | + | rows := make([][]string, 0, len(routes)) |
| 165 | - | for _, d := range domains { | 164 | + | for _, route := range routes { |
| 166 | - | rows = append(rows, []string{d, config.ExternalRoutes[d]}) | 165 | + | target := route.Target |
| 166 | + | if target == "" { | |||
| 167 | + | target = "[redacted]" | |||
| 168 | + | } | |||
| 169 | + | rows = append(rows, []string{route.Domain, target}) | |||
| 167 | } | 170 | } | ||
| 168 | table := components.NewTable( | 171 | table := components.NewTable( | ||
| 169 | components.WithColumns([]components.TableColumn{ | 172 | components.WithColumns([]components.TableColumn{ | ||
M internal/adapters/in/cli/controlplane_local.go +10 -2
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "sort" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/gordon/internal/adapters/dto" | 10 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| @@ -372,14 +373,21 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro | |||||
| 372 | if l.configSvc == nil { | 373 | if l.configSvc == nil { | ||
| 373 | return nil, fmt.Errorf("config service unavailable") | 374 | return nil, fmt.Errorf("config service unavailable") | ||
| 374 | } | 375 | } | ||
| 376 | + | externalRoutes := l.configSvc.GetExternalRoutes() | |||
| 377 | + | externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes)) | |||
| 378 | + | for domainName := range externalRoutes { | |||
| 379 | + | externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName}) | |||
| 380 | + | } | |||
| 381 | + | sort.Slice(externalResponses, func(i, j int) bool { | |||
| 382 | + | return externalResponses[i].Domain < externalResponses[j].Domain | |||
| 383 | + | }) | |||
| 375 | cfg := &remote.Config{ | 384 | cfg := &remote.Config{ | ||
| 376 | Routes: l.configSvc.GetRoutes(ctx), | 385 | Routes: l.configSvc.GetRoutes(ctx), | ||
| 377 | - | ExternalRoutes: l.configSvc.GetExternalRoutes(), | 386 | + | ExternalRoutes: externalResponses, |
| 378 | } | 387 | } | ||
| 379 | cfg.Server.Port = l.configSvc.GetServerPort() | 388 | cfg.Server.Port = l.configSvc.GetServerPort() | ||
| 380 | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | 389 | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | ||
| 381 | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | 390 | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | ||
| 382 | - | cfg.Server.DataDir = l.configSvc.GetDataDir() | |||
| 383 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | 391 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | ||
| 384 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | 392 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | ||
| 385 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | 393 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | ||
M internal/adapters/in/cli/remote/client.go +9 -3
| @@ -826,7 +826,7 @@ type Config struct { | |||||
| 826 | Port int `json:"port"` | 826 | Port int `json:"port"` | ||
| 827 | RegistryPort int `json:"registry_port"` | 827 | RegistryPort int `json:"registry_port"` | ||
| 828 | RegistryDomain string `json:"registry_domain"` | 828 | RegistryDomain string `json:"registry_domain"` | ||
| 829 | - | DataDir string `json:"data_dir"` | 829 | + | DataDir string `json:"data_dir,omitempty"` |
| 830 | } `json:"server"` | 830 | } `json:"server"` | ||
| 831 | AutoRoute struct { | 831 | AutoRoute struct { | ||
| 832 | Enabled bool `json:"enabled"` | 832 | Enabled bool `json:"enabled"` | ||
| @@ -835,8 +835,14 @@ type Config struct { | |||||
| 835 | Enabled bool `json:"enabled"` | 835 | Enabled bool `json:"enabled"` | ||
| 836 | Prefix string `json:"prefix"` | 836 | Prefix string `json:"prefix"` | ||
| 837 | } `json:"network_isolation"` | 837 | } `json:"network_isolation"` | ||
| 838 | - | Routes []domain.Route `json:"routes"` | 838 | + | Routes []domain.Route `json:"routes"` |
| 839 | - | ExternalRoutes map[string]string `json:"external_routes"` | 839 | + | ExternalRoutes []ExternalRoute `json:"external_routes"` |
| 840 | + | } | |||
| 841 | + | ||||
| 842 | + | // ExternalRoute represents a redacted external route config entry. | |||
| 843 | + | type ExternalRoute struct { | |||
| 844 | + | Domain string `json:"domain"` | |||
| 845 | + | Target string `json:"target,omitempty"` | |||
| 840 | } | 846 | } | ||
| 841 | 847 | ||||
| 842 | // GetConfig returns the Gordon configuration. | 848 | // GetConfig returns the Gordon configuration. | ||
M internal/adapters/in/http/admin/handler.go +19 -10
| @@ -389,6 +389,10 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||||
| 389 | addStep("route", "failed") | 389 | addStep("route", "failed") | ||
| 390 | h.sendError(w, http.StatusBadRequest, err.Error()) | 390 | h.sendError(w, http.StatusBadRequest, err.Error()) | ||
| 391 | return | 391 | return | ||
| 392 | + | case errors.Is(err, domain.ErrRouteConflict): | |||
| 393 | + | addStep("route", "failed") | |||
| 394 | + | h.sendError(w, http.StatusConflict, err.Error()) | |||
| 395 | + | return | |||
| 392 | default: | 396 | default: | ||
| 393 | addStep("route", "failed") | 397 | addStep("route", "failed") | ||
| 394 | log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route") | 398 | log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route") | ||
| @@ -594,6 +598,8 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||||
| 594 | switch { | 598 | switch { | ||
| 595 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty): | 599 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty): | ||
| 596 | h.sendError(w, http.StatusBadRequest, err.Error()) | 600 | h.sendError(w, http.StatusBadRequest, err.Error()) | ||
| 601 | + | case errors.Is(err, domain.ErrRouteConflict): | |||
| 602 | + | h.sendError(w, http.StatusConflict, err.Error()) | |||
| 597 | default: | 603 | default: | ||
| 598 | h.sendError(w, http.StatusInternalServerError, "failed to add route") | 604 | h.sendError(w, http.StatusInternalServerError, "failed to add route") | ||
| 599 | } | 605 | } | ||
| @@ -1250,10 +1256,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| 1250 | 1256 | ||||
| 1251 | externalRoutes := h.configSvc.GetExternalRoutes() | 1257 | externalRoutes := h.configSvc.GetExternalRoutes() | ||
| 1252 | externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) | 1258 | externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) | ||
| 1253 | - | for domain, target := range externalRoutes { | 1259 | + | for domain := range externalRoutes { |
| 1254 | externalResponses = append(externalResponses, dto.ExternalRoute{ | 1260 | externalResponses = append(externalResponses, dto.ExternalRoute{ | ||
| 1255 | Domain: domain, | 1261 | Domain: domain, | ||
| 1256 | - | Target: target, | |||
| 1257 | }) | 1262 | }) | ||
| 1258 | } | 1263 | } | ||
| 1259 | 1264 | ||||
| @@ -1262,7 +1267,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| 1262 | Port: h.configSvc.GetServerPort(), | 1267 | Port: h.configSvc.GetServerPort(), | ||
| 1263 | RegistryPort: h.configSvc.GetRegistryPort(), | 1268 | RegistryPort: h.configSvc.GetRegistryPort(), | ||
| 1264 | RegistryDomain: h.configSvc.GetRegistryDomain(), | 1269 | RegistryDomain: h.configSvc.GetRegistryDomain(), | ||
| 1265 | - | DataDir: h.configSvc.GetDataDir(), | |||
| 1266 | }, | 1270 | }, | ||
| 1267 | AutoRoute: dto.AutoRouteConfig{ | 1271 | AutoRoute: dto.AutoRouteConfig{ | ||
| 1268 | Enabled: h.configSvc.IsAutoRouteEnabled(), | 1272 | Enabled: h.configSvc.IsAutoRouteEnabled(), | ||
| @@ -1319,12 +1323,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 1319 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | 1323 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | ||
| 1320 | var deployErr *domain.DeployFailureError | 1324 | var deployErr *domain.DeployFailureError | ||
| 1321 | if errors.As(err, &deployErr) { | 1325 | if errors.As(err, &deployErr) { | ||
| 1322 | - | h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ | 1326 | + | response := dto.DeployErrorResponse{ |
| 1323 | Error: deployErr.Error(), | 1327 | Error: deployErr.Error(), | ||
| 1324 | Cause: deployErr.Cause, | 1328 | Cause: deployErr.Cause, | ||
| 1325 | Hint: deployErr.Hint, | 1329 | Hint: deployErr.Hint, | ||
| 1326 | - | Logs: deployErr.Logs, | 1330 | + | } |
| 1327 | - | }) | 1331 | + | if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { |
| 1332 | + | response.Logs = domain.RedactSecretLines(deployErr.Logs) | |||
| 1333 | + | } | |||
| 1334 | + | h.sendJSON(w, http.StatusInternalServerError, response) | |||
| 1328 | return | 1335 | return | ||
| 1329 | } | 1336 | } | ||
| 1330 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | 1337 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | ||
| @@ -1455,8 +1462,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||||
| 1455 | log := zerowrap.FromCtx(ctx) | 1462 | log := zerowrap.FromCtx(ctx) | ||
| 1456 | 1463 | ||||
| 1457 | // Check read permission | 1464 | // Check read permission | ||
| 1458 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | 1465 | + | if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) { |
| 1459 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | 1466 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read") |
| 1460 | return | 1467 | return | ||
| 1461 | } | 1468 | } | ||
| 1462 | 1469 | ||||
| @@ -1522,7 +1529,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line | |||||
| 1522 | return | 1529 | return | ||
| 1523 | } | 1530 | } | ||
| 1524 | 1531 | ||||
| 1525 | - | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines}) | 1532 | + | h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)}) |
| 1526 | } | 1533 | } | ||
| 1527 | 1534 | ||||
| 1528 | // handleContainerLogs handles container logs for a specific domain. | 1535 | // handleContainerLogs handles container logs for a specific domain. | ||
| @@ -1546,7 +1553,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 1546 | 1553 | ||||
| 1547 | h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ | 1554 | h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{ | ||
| 1548 | Domain: logDomain, | 1555 | Domain: logDomain, | ||
| 1549 | - | Lines: logLines, | 1556 | + | Lines: domain.RedactSecretLines(logLines), |
| 1550 | }) | 1557 | }) | ||
| 1551 | } | 1558 | } | ||
| 1552 | 1559 | ||||
| @@ -1582,6 +1589,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line | |||||
| 1582 | if !ok { | 1589 | if !ok { | ||
| 1583 | return | 1590 | return | ||
| 1584 | } | 1591 | } | ||
| 1592 | + | line = domain.RedactSecrets(line) | |||
| 1585 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | 1593 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | ||
| 1586 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | 1594 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | ||
| 1587 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | 1595 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | ||
| @@ -1628,6 +1636,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 1628 | if !ok { | 1636 | if !ok { | ||
| 1629 | return | 1637 | return | ||
| 1630 | } | 1638 | } | ||
| 1639 | + | line = domain.RedactSecrets(line) | |||
| 1631 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | 1640 | // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection. | ||
| 1632 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | 1641 | // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines. | ||
| 1633 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | 1642 | // Per the SSE spec, multi-line data must use separate "data:" prefixes. | ||
M internal/adapters/in/http/admin/handler_test.go +166 -4
| @@ -98,6 +98,64 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||||
| 98 | return NewHandler(deps) | 98 | return NewHandler(deps) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) { | |||
| 102 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |||
| 103 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |||
| 104 | + | ||||
| 105 | + | tests := []struct { | |||
| 106 | + | name string | |||
| 107 | + | scopes []string | |||
| 108 | + | wantStatus int | |||
| 109 | + | }{ | |||
| 110 | + | {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK}, | |||
| 111 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |||
| 112 | + | {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden}, | |||
| 113 | + | {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden}, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, tt := range tests { | |||
| 117 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 118 | + | if tt.wantStatus == http.StatusOK { | |||
| 119 | + | volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once() | |||
| 120 | + | } | |||
| 121 | + | server := newScopedTestServer(t, handler, tt.scopes...) | |||
| 122 | + | resp, err := http.Get(server.URL + "/admin/volumes") | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | defer resp.Body.Close() | |||
| 125 | + | assert.Equal(t, tt.wantStatus, resp.StatusCode) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { | |||
| 131 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |||
| 132 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |||
| 133 | + | ||||
| 134 | + | tests := []struct { | |||
| 135 | + | name string | |||
| 136 | + | scopes []string | |||
| 137 | + | wantStatus int | |||
| 138 | + | }{ | |||
| 139 | + | {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK}, | |||
| 140 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |||
| 141 | + | {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden}, | |||
| 142 | + | {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden}, | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | for _, tt := range tests { | |||
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 147 | + | if tt.wantStatus == http.StatusOK { | |||
| 148 | + | volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once() | |||
| 149 | + | } | |||
| 150 | + | server := newScopedTestServer(t, handler, tt.scopes...) | |||
| 151 | + | resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`)) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | defer resp.Body.Close() | |||
| 154 | + | assert.Equal(t, tt.wantStatus, resp.StatusCode) | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 101 | // Routes endpoint tests | 159 | // Routes endpoint tests | ||
| 102 | 160 | ||||
| 103 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | 161 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | ||
| @@ -1064,6 +1122,36 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 1064 | 1122 | ||||
| 1065 | // Config endpoint tests | 1123 | // Config endpoint tests | ||
| 1066 | 1124 | ||||
| 1125 | + | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | |||
| 1126 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1127 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc }) | |||
| 1128 | + | ||||
| 1129 | + | configSvc.EXPECT().GetServerPort().Return(8080).Once() | |||
| 1130 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Once() | |||
| 1131 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() | |||
| 1132 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once() | |||
| 1133 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once() | |||
| 1134 | + | configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once() | |||
| 1135 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once() | |||
| 1136 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once() | |||
| 1137 | + | ||||
| 1138 | + | server := newScopedTestServer(t, handler, "admin:config:read") | |||
| 1139 | + | resp, err := http.Get(server.URL + "/admin/config") | |||
| 1140 | + | require.NoError(t, err) | |||
| 1141 | + | defer resp.Body.Close() | |||
| 1142 | + | bodyBytes, err := io.ReadAll(resp.Body) | |||
| 1143 | + | require.NoError(t, err) | |||
| 1144 | + | ||||
| 1145 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 1146 | + | body := string(bodyBytes) | |||
| 1147 | + | var raw map[string]any | |||
| 1148 | + | require.NoError(t, json.Unmarshal([]byte(body), &raw)) | |||
| 1149 | + | serverConfig := raw["server"].(map[string]any) | |||
| 1150 | + | assert.NotContains(t, serverConfig, "data_dir") | |||
| 1151 | + | assert.NotContains(t, body, "/var/lib/gordon") | |||
| 1152 | + | assert.NotContains(t, body, "10.0.0.5") | |||
| 1153 | + | } | |||
| 1154 | + | ||||
| 1067 | func TestHandler_Config_RequiresReadScope(t *testing.T) { | 1155 | func TestHandler_Config_RequiresReadScope(t *testing.T) { | ||
| 1068 | configSvc := inmocks.NewMockConfigService(t) | 1156 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1069 | authSvc := inmocks.NewMockAuthService(t) | 1157 | authSvc := inmocks.NewMockAuthService(t) | ||
| @@ -1542,6 +1630,41 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||||
| 1542 | } | 1630 | } | ||
| 1543 | } | 1631 | } | ||
| 1544 | 1632 | ||||
| 1633 | + | func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) { | |||
| 1634 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1635 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1636 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1637 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |||
| 1638 | + | deployErr := &domain.DeployFailureError{ | |||
| 1639 | + | Summary: "failed to deploy", | |||
| 1640 | + | Cause: "image pull failed", | |||
| 1641 | + | Hint: "push the image before retrying", | |||
| 1642 | + | Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"}, | |||
| 1643 | + | } | |||
| 1644 | + | ||||
| 1645 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 1646 | + | d.ConfigSvc = configSvc | |||
| 1647 | + | d.ContainerSvc = containerSvc | |||
| 1648 | + | d.SecretSvc = secretSvc | |||
| 1649 | + | }) | |||
| 1650 | + | ||||
| 1651 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |||
| 1652 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |||
| 1653 | + | ||||
| 1654 | + | server := newScopedTestServer(t, handler, "admin:config:write") | |||
| 1655 | + | resp, err := http.Post(server.URL+"/admin/deploy/app.example.com", "application/json", nil) | |||
| 1656 | + | require.NoError(t, err) | |||
| 1657 | + | defer resp.Body.Close() | |||
| 1658 | + | bodyBytes, err := io.ReadAll(resp.Body) | |||
| 1659 | + | require.NoError(t, err) | |||
| 1660 | + | body := string(bodyBytes) | |||
| 1661 | + | ||||
| 1662 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |||
| 1663 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, body) | |||
| 1664 | + | assert.NotContains(t, body, "hunter2") | |||
| 1665 | + | assert.NotContains(t, body, "abc123") | |||
| 1666 | + | } | |||
| 1667 | + | ||||
| 1545 | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | 1668 | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | ||
| 1546 | configSvc := inmocks.NewMockConfigService(t) | 1669 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1547 | authSvc := inmocks.NewMockAuthService(t) | 1670 | authSvc := inmocks.NewMockAuthService(t) | ||
| @@ -1553,8 +1676,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1553 | Cause: "image pull failed", | 1676 | Cause: "image pull failed", | ||
| 1554 | Hint: "push the image before retrying", | 1677 | Hint: "push the image before retrying", | ||
| 1555 | Logs: []string{ | 1678 | Logs: []string{ | ||
| 1556 | - | "pulling manifest", | 1679 | + | "pulling manifest PASSWORD=hunter2", |
| 1557 | - | "manifest unknown", | 1680 | + | `{"TOKEN":"abc123"}`, |
| 1558 | }, | 1681 | }, | ||
| 1559 | Err: errors.New("manifest unknown: internal registry detail"), | 1682 | Err: errors.New("manifest unknown: internal registry detail"), | ||
| 1560 | } | 1683 | } | ||
| @@ -1566,7 +1689,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1566 | d.SecretSvc = secretSvc | 1689 | d.SecretSvc = secretSvc | ||
| 1567 | }) | 1690 | }) | ||
| 1568 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 1691 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 1569 | - | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write"))) | 1692 | + | handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read"))) |
| 1570 | })) | 1693 | })) | ||
| 1571 | defer s.Close() | 1694 | defer s.Close() | ||
| 1572 | 1695 | ||||
| @@ -1582,8 +1705,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||||
| 1582 | assert.NoError(t, err) | 1705 | assert.NoError(t, err) | ||
| 1583 | 1706 | ||||
| 1584 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | 1707 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | ||
| 1585 | - | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body)) | 1708 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body)) |
| 1586 | assert.NotContains(t, string(body), "internal registry detail") | 1709 | assert.NotContains(t, string(body), "internal registry detail") | ||
| 1710 | + | assert.NotContains(t, string(body), "hunter2") | |||
| 1711 | + | assert.NotContains(t, string(body), "abc123") | |||
| 1587 | } | 1712 | } | ||
| 1588 | 1713 | ||||
| 1589 | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | 1714 | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | ||
| @@ -2045,6 +2170,43 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) { | |||||
| 2045 | assert.Contains(t, rec.Body.String(), "completed") | 2170 | assert.Contains(t, rec.Body.String(), "completed") | ||
| 2046 | } | 2171 | } | ||
| 2047 | 2172 | ||||
| 2173 | + | func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) { | |||
| 2174 | + | logSvc := inmocks.NewMockLogService(t) | |||
| 2175 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2176 | + | d.LogSvc = logSvc | |||
| 2177 | + | }) | |||
| 2178 | + | ||||
| 2179 | + | t.Run("status read denied", func(t *testing.T) { | |||
| 2180 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 2181 | + | resp, err := http.Get(server.URL + "/admin/logs") | |||
| 2182 | + | require.NoError(t, err) | |||
| 2183 | + | defer resp.Body.Close() | |||
| 2184 | + | bodyBytes, err := io.ReadAll(resp.Body) | |||
| 2185 | + | require.NoError(t, err) | |||
| 2186 | + | ||||
| 2187 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 2188 | + | assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read") | |||
| 2189 | + | }) | |||
| 2190 | + | ||||
| 2191 | + | t.Run("logs read allowed and redacted", func(t *testing.T) { | |||
| 2192 | + | logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once() | |||
| 2193 | + | ||||
| 2194 | + | server := newScopedTestServer(t, handler, "admin:logs:read") | |||
| 2195 | + | resp, err := http.Get(server.URL + "/admin/logs") | |||
| 2196 | + | require.NoError(t, err) | |||
| 2197 | + | defer resp.Body.Close() | |||
| 2198 | + | bodyBytes, err := io.ReadAll(resp.Body) | |||
| 2199 | + | require.NoError(t, err) | |||
| 2200 | + | body := string(bodyBytes) | |||
| 2201 | + | ||||
| 2202 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 2203 | + | assert.Contains(t, body, "PASSWORD=[REDACTED]") | |||
| 2204 | + | assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`) | |||
| 2205 | + | assert.NotContains(t, body, "hunter2") | |||
| 2206 | + | assert.NotContains(t, body, "abc123") | |||
| 2207 | + | }) | |||
| 2208 | + | } | |||
| 2209 | + | ||||
| 2048 | func TestHandler_BackupsDetectDomain(t *testing.T) { | 2210 | func TestHandler_BackupsDetectDomain(t *testing.T) { | ||
| 2049 | configSvc := inmocks.NewMockConfigService(t) | 2211 | configSvc := inmocks.NewMockConfigService(t) | ||
| 2050 | authSvc := inmocks.NewMockAuthService(t) | 2212 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/adapters/in/http/admin/handler_volumes.go +4 -4
| @@ -19,8 +19,8 @@ func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) { | |||||
| 19 | 19 | ||||
| 20 | ctx := r.Context() | 20 | ctx := r.Context() | ||
| 21 | 21 | ||||
| 22 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | 22 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) { |
| 23 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | 23 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read") |
| 24 | return | 24 | return | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| @@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) { | |||||
| 63 | 63 | ||||
| 64 | ctx := r.Context() | 64 | ctx := r.Context() | ||
| 65 | 65 | ||||
| 66 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | 66 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionWrite) { |
| 67 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | 67 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:write") |
| 68 | return | 68 | return | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
M internal/adapters/in/http/middleware/cidr.go +37 -5
| @@ -5,12 +5,14 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/gordon/internal/adapters/dto" | 13 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 13 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | 14 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | ||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | 18 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | ||
| @@ -67,7 +69,7 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 67 | // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect | 69 | // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect | ||
| 68 | // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from | 70 | // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from | ||
| 69 | // the public URL; hosts with an unknown explicit port preserve it as-is. | 71 | // the public URL; hosts with an unknown explicit port preserve it as-is. | ||
| 70 | - | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | 72 | + | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler { |
| 71 | return func(next http.Handler) http.Handler { | 73 | return func(next http.Handler) http.Handler { | ||
| 72 | if tlsPort == 0 { | 74 | if tlsPort == 0 { | ||
| 73 | return next | 75 | return next | ||
| @@ -88,7 +90,11 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, | |||||
| 88 | } | 90 | } | ||
| 89 | } | 91 | } | ||
| 90 | 92 | ||||
| 91 | - | target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort) | 93 | + | target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed) |
| 94 | + | if !ok { | |||
| 95 | + | http.Error(w, "Bad Request", http.StatusBadRequest) | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 92 | 98 | ||||
| 93 | log.Debug(). | 99 | log.Debug(). | ||
| 94 | Str("target", target). | 100 | Str("target", target). | ||
| @@ -99,14 +105,40 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, | |||||
| 99 | } | 105 | } | ||
| 100 | } | 106 | } | ||
| 101 | 107 | ||||
| 102 | - | // httpsRedirectTarget derives the HTTPS redirect URL from the request Host. | 108 | + | // httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host. |
| 103 | - | func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string { | 109 | + | func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) { |
| 110 | + | host = strings.ToLower(strings.TrimSpace(host)) | |||
| 111 | + | if host == "" { | |||
| 112 | + | return "", false | |||
| 113 | + | } | |||
| 114 | + | hostname, portStr, err := net.SplitHostPort(host) | |||
| 115 | + | if err != nil { | |||
| 116 | + | if strings.Contains(host, ":") { | |||
| 117 | + | return "", false | |||
| 118 | + | } | |||
| 119 | + | hostname = host | |||
| 120 | + | } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 { | |||
| 121 | + | return "", false | |||
| 122 | + | } | |||
| 123 | + | hostname = strings.TrimSuffix(hostname, ".") | |||
| 124 | + | canonicalHost, ok := domain.CanonicalRouteDomain(hostname) | |||
| 125 | + | if !ok { | |||
| 126 | + | return "", false | |||
| 127 | + | } | |||
| 128 | + | if isHostAllowed == nil || !isHostAllowed(canonicalHost) { | |||
| 129 | + | return "", false | |||
| 130 | + | } | |||
| 131 | + | ||||
| 104 | path := requestURI | 132 | path := requestURI | ||
| 105 | if !strings.HasPrefix(path, "/") { | 133 | if !strings.HasPrefix(path, "/") { | ||
| 106 | path = "/" | 134 | path = "/" | ||
| 107 | } | 135 | } | ||
| 108 | 136 | ||||
| 109 | - | return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path) | 137 | + | canonicalAuthority := canonicalHost |
| 138 | + | if portStr != "" { | |||
| 139 | + | canonicalAuthority = net.JoinHostPort(canonicalHost, portStr) | |||
| 140 | + | } | |||
| 141 | + | return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true | |||
| 110 | } | 142 | } | ||
| 111 | 143 | ||||
| 112 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | 144 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | ||
M internal/adapters/in/http/middleware/cidr_test.go +47 -17
| @@ -2,7 +2,6 @@ package middleware | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | - | "fmt" | |||
| 6 | "net" | 5 | "net" | ||
| 7 | "net/http" | 6 | "net/http" | ||
| 8 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| @@ -118,7 +117,7 @@ func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) { | |||||
| 118 | w.WriteHeader(http.StatusOK) | 117 | w.WriteHeader(http.StatusOK) | ||
| 119 | }) | 118 | }) | ||
| 120 | 119 | ||||
| 121 | - | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | 120 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok) |
| 122 | srv := httptest.NewServer(handler) | 121 | srv := httptest.NewServer(handler) | ||
| 123 | defer srv.Close() | 122 | defer srv.Close() | ||
| 124 | 123 | ||||
| @@ -141,7 +140,7 @@ func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) { | |||||
| 141 | w.WriteHeader(http.StatusOK) | 140 | w.WriteHeader(http.StatusOK) | ||
| 142 | }) | 141 | }) | ||
| 143 | 142 | ||||
| 144 | - | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | 143 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok) |
| 145 | srv := httptest.NewServer(handler) | 144 | srv := httptest.NewServer(handler) | ||
| 146 | defer srv.Close() | 145 | defer srv.Close() | ||
| 147 | 146 | ||||
| @@ -164,7 +163,7 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |||||
| 164 | w.WriteHeader(http.StatusOK) | 163 | w.WriteHeader(http.StatusOK) | ||
| 165 | }) | 164 | }) | ||
| 166 | 165 | ||||
| 167 | - | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | 166 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok) |
| 168 | srv := httptest.NewServer(handler) | 167 | srv := httptest.NewServer(handler) | ||
| 169 | defer srv.Close() | 168 | defer srv.Close() | ||
| 170 | 169 | ||||
| @@ -181,31 +180,62 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |||||
| 181 | assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location")) | 180 | assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location")) | ||
| 182 | } | 181 | } | ||
| 183 | 182 | ||||
| 184 | - | func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) { | 183 | + | func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) { |
| 185 | log := testLogger() | 184 | log := testLogger() | ||
| 186 | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 185 | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 187 | w.WriteHeader(http.StatusOK) | 186 | w.WriteHeader(http.StatusOK) | ||
| 188 | }) | 187 | }) | ||
| 188 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok) | |||
| 189 | 189 | ||||
| 190 | - | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 191 | srv := httptest.NewServer(handler) | 190 | srv := httptest.NewServer(handler) | ||
| 192 | defer srv.Close() | 191 | defer srv.Close() | ||
| 192 | + | client := srv.Client() | |||
| 193 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |||
| 193 | 194 | ||||
| 194 | - | // Use a raw TCP connection because Go's HTTP client rejects the | 195 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/path", nil) |
| 195 | - | // invalid port in the Location header before CheckRedirect fires. | |||
| 196 | - | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 197 | require.NoError(t, err) | 196 | require.NoError(t, err) | ||
| 198 | - | defer conn.Close() | 197 | + | req.Host = "O2.Bnema.Dev.:8088" |
| 199 | - | 198 | + | resp, err := client.Do(req) | |
| 200 | - | _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n") | |||
| 201 | require.NoError(t, err) | 199 | require.NoError(t, err) | ||
| 200 | + | defer resp.Body.Close() | |||
| 202 | 201 | ||||
| 203 | - | var buf [4096]byte | 202 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) |
| 204 | - | n, _ := conn.Read(buf[:]) | 203 | + | assert.Equal(t, "https://o2.bnema.dev:8443/path", resp.Header.Get("Location")) |
| 205 | - | raw := string(buf[:n]) | 204 | + | } |
| 206 | 205 | ||||
| 207 | - | assert.Contains(t, raw, "308") | 206 | + | func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) { |
| 208 | - | assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/") | 207 | + | log := testLogger() |
| 208 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 209 | + | w.WriteHeader(http.StatusOK) | |||
| 210 | + | }) | |||
| 211 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok) | |||
| 212 | + | ||||
| 213 | + | tests := []struct { | |||
| 214 | + | name string | |||
| 215 | + | host string | |||
| 216 | + | }{ | |||
| 217 | + | {name: "invalid port", host: "o2.bnema.dev:abcd"}, | |||
| 218 | + | ||||
| 219 | + | {name: "localhost", host: "localhost"}, | |||
| 220 | + | {name: "ipv6", host: "[::1]:8088"}, | |||
| 221 | + | {name: "unconfigured public host", host: "evil.example.com"}, | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | for _, tt := range tests { | |||
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 226 | + | srv := httptest.NewServer(handler) | |||
| 227 | + | defer srv.Close() | |||
| 228 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | req.Host = tt.host | |||
| 231 | + | resp, err := srv.Client().Do(req) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | defer resp.Body.Close() | |||
| 234 | + | ||||
| 235 | + | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) | |||
| 236 | + | assert.Empty(t, resp.Header.Get("Location")) | |||
| 237 | + | }) | |||
| 238 | + | } | |||
| 209 | } | 239 | } | ||
| 210 | 240 | ||||
| 211 | func TestProxyCIDRAllowlist(t *testing.T) { | 241 | func TestProxyCIDRAllowlist(t *testing.T) { | ||
M internal/adapters/in/http/proxy/handler.go +33 -3
| @@ -4,6 +4,8 @@ package proxy | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "net" | 5 | "net" | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 7 | "sync/atomic" | 9 | "sync/atomic" | ||
| 8 | 10 | ||||
| 9 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -70,16 +72,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 70 | r = r.WithContext(ctx) | 72 | r = r.WithContext(ctx) | ||
| 71 | log := zerowrap.FromCtx(ctx) | 73 | log := zerowrap.FromCtx(ctx) | ||
| 72 | 74 | ||||
| 75 | + | host := normalizeRequestHost(r.Host) | |||
| 76 | + | if host == "" { | |||
| 77 | + | proxyError(w, "Bad Request", http.StatusBadRequest) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | ||||
| 73 | // Check if this is the registry domain | 81 | // Check if this is the registry domain | ||
| 74 | - | if h.proxySvc.IsRegistryDomain(r.Host) { | 82 | + | if h.proxySvc.IsRegistryDomain(host) { |
| 75 | log.Debug().Msg("routing request to registry") | 83 | log.Debug().Msg("routing request to registry") | ||
| 76 | h.forwardToRegistry(w, r, cfg.RegistryPort) | 84 | h.forwardToRegistry(w, r, cfg.RegistryPort) | ||
| 77 | return | 85 | return | ||
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| 80 | // Get target for this domain | 88 | // Get target for this domain | ||
| 81 | - | log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target") | 89 | + | log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target") |
| 82 | - | target, err := h.proxySvc.GetTarget(ctx, r.Host) | 90 | + | target, err := h.proxySvc.GetTarget(ctx, host) |
| 83 | if err != nil { | 91 | if err != nil { | ||
| 84 | log.Warn().Err(err).Msg("no route found for domain") | 92 | log.Warn().Err(err).Msg("no route found for domain") | ||
| 85 | proxyError(w, "404 page not found", http.StatusNotFound) | 93 | proxyError(w, "404 page not found", http.StatusNotFound) | ||
| @@ -94,3 +102,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 94 | 102 | ||||
| 95 | h.forwardToTarget(w, r, target, cfg.MaxResponseSize) | 103 | h.forwardToTarget(w, r, target, cfg.MaxResponseSize) | ||
| 96 | } | 104 | } | ||
| 105 | + | ||||
| 106 | + | func normalizeRequestHost(host string) string { | |||
| 107 | + | host = strings.TrimSpace(host) | |||
| 108 | + | if h, port, err := net.SplitHostPort(host); err == nil { | |||
| 109 | + | portNum, err := strconv.Atoi(port) | |||
| 110 | + | if err != nil || portNum < 1 || portNum > 65535 { | |||
| 111 | + | return "" | |||
| 112 | + | } | |||
| 113 | + | host = h | |||
| 114 | + | } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | |||
| 115 | + | inner := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | |||
| 116 | + | if ip := net.ParseIP(inner); ip == nil || ip.To4() != nil { | |||
| 117 | + | return "" | |||
| 118 | + | } | |||
| 119 | + | host = inner | |||
| 120 | + | } else if strings.Contains(host, ":") { | |||
| 121 | + | return "" | |||
| 122 | + | } | |||
| 123 | + | host = strings.ToLower(host) | |||
| 124 | + | host = strings.TrimSuffix(host, ".") | |||
| 125 | + | return host | |||
| 126 | + | } | |||
M internal/adapters/in/http/proxy/handler_test.go +53 -0
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 14 | 15 | ||||
| 15 | "github.com/bnema/gordon/internal/boundaries/in" | 16 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 17 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| @@ -61,6 +62,58 @@ func TestHandler_RoutesToRegistry(t *testing.T) { | |||||
| 61 | assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable) | 62 | assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable) | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 65 | + | func TestHandler_NormalizesRequestHostForLookup(t *testing.T) { | |||
| 66 | + | tests := []struct { | |||
| 67 | + | name string | |||
| 68 | + | host string | |||
| 69 | + | wantHost string | |||
| 70 | + | }{ | |||
| 71 | + | {name: "mixed case", host: "App.Example.com", wantHost: "app.example.com"}, | |||
| 72 | + | {name: "explicit port", host: "App.Example.com:8080", wantHost: "app.example.com"}, | |||
| 73 | + | {name: "trailing dot", host: "App.Example.com.", wantHost: "app.example.com"}, | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | for _, tt := range tests { | |||
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 78 | + | proxySvc := inmocks.NewMockProxyService(t) | |||
| 79 | + | proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{}) | |||
| 80 | + | proxySvc.EXPECT().IsRegistryDomain(tt.wantHost).Return(false) | |||
| 81 | + | proxySvc.EXPECT().GetTarget(mock.Anything, tt.wantHost).Return(nil, domain.ErrNoTargetAvailable) | |||
| 82 | + | ||||
| 83 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |||
| 84 | + | srv := httptest.NewServer(handler) | |||
| 85 | + | defer srv.Close() | |||
| 86 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | req.Host = tt.host | |||
| 89 | + | resp, err := srv.Client().Do(req) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | defer resp.Body.Close() | |||
| 92 | + | ||||
| 93 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHandler_RejectsInvalidRequestHost(t *testing.T) { | |||
| 99 | + | proxySvc := inmocks.NewMockProxyService(t) | |||
| 100 | + | proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{}) | |||
| 101 | + | ||||
| 102 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |||
| 103 | + | srv := httptest.NewServer(handler) | |||
| 104 | + | defer srv.Close() | |||
| 105 | + | for _, host := range []string{"app.example.com:bad", "app.example.com:0", "app.example.com:99999"} { | |||
| 106 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | req.Host = host | |||
| 109 | + | resp, err := srv.Client().Do(req) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | defer resp.Body.Close() | |||
| 112 | + | ||||
| 113 | + | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 64 | func TestHandler_Returns404WhenNoTarget(t *testing.T) { | 117 | func TestHandler_Returns404WhenNoTarget(t *testing.T) { | ||
| 65 | proxySvc := inmocks.NewMockProxyService(t) | 118 | proxySvc := inmocks.NewMockProxyService(t) | ||
| 66 | 119 | ||||
M internal/adapters/in/http/registry/handler.go +73 -12
| @@ -2,6 +2,7 @@ | |||||
| 2 | package registry | 2 | package registry | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| @@ -9,6 +10,7 @@ import ( | |||||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "strconv" | 11 | "strconv" | ||
| 11 | "strings" | 12 | "strings" | ||
| 13 | + | "sync/atomic" | |||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 14 | 16 | ||||
| @@ -26,13 +28,21 @@ const ( | |||||
| 26 | // Kept at 95MB to stay within Cloudflare's 100MB per-request limit. | 28 | // Kept at 95MB to stay within Cloudflare's 100MB per-request limit. | ||
| 27 | // Users who need larger chunks can configure it explicitly via max_blob_chunk_size. | 29 | // Users who need larger chunks can configure it explicitly via max_blob_chunk_size. | ||
| 28 | DefaultMaxBlobChunkSize = 95 * 1024 * 1024 | 30 | DefaultMaxBlobChunkSize = 95 * 1024 * 1024 | ||
| 31 | + | // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload. | |||
| 32 | + | DefaultMaxBlobSize = 1024 * 1024 * 1024 | |||
| 29 | ) | 33 | ) | ||
| 30 | 34 | ||||
| 35 | + | // blobLimits holds consistent blob size limits. | |||
| 36 | + | type blobLimits struct { | |||
| 37 | + | MaxChunkSize int64 | |||
| 38 | + | MaxBlobSize int64 | |||
| 39 | + | } | |||
| 40 | + | ||||
| 31 | // Handler implements the HTTP handler for Docker Registry API v2. | 41 | // Handler implements the HTTP handler for Docker Registry API v2. | ||
| 32 | type Handler struct { | 42 | type Handler struct { | ||
| 33 | - | registrySvc in.RegistryService | 43 | + | registrySvc in.RegistryService |
| 34 | - | log zerowrap.Logger | 44 | + | log zerowrap.Logger |
| 35 | - | maxBlobChunkSize int64 | 45 | + | blobLimits atomic.Value // stores *blobLimits |
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 38 | // NewHandler creates a new registry HTTP handler. | 48 | // NewHandler creates a new registry HTTP handler. | ||
| @@ -40,16 +50,39 @@ func NewHandler( | |||||
| 40 | registrySvc in.RegistryService, | 50 | registrySvc in.RegistryService, | ||
| 41 | log zerowrap.Logger, | 51 | log zerowrap.Logger, | ||
| 42 | maxBlobChunkSize int64, | 52 | maxBlobChunkSize int64, | ||
| 53 | + | maxBlobSize ...int64, | |||
| 43 | ) *Handler { | 54 | ) *Handler { | ||
| 44 | if maxBlobChunkSize <= 0 { | 55 | if maxBlobChunkSize <= 0 { | ||
| 45 | maxBlobChunkSize = DefaultMaxBlobChunkSize | 56 | maxBlobChunkSize = DefaultMaxBlobChunkSize | ||
| 46 | } | 57 | } | ||
| 58 | + | blobSizeLimit := int64(DefaultMaxBlobSize) | |||
| 59 | + | if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 { | |||
| 60 | + | blobSizeLimit = maxBlobSize[0] | |||
| 61 | + | } | |||
| 47 | 62 | ||||
| 48 | - | return &Handler{ | 63 | + | h := &Handler{ |
| 49 | - | registrySvc: registrySvc, | 64 | + | registrySvc: registrySvc, |
| 50 | - | log: log, | 65 | + | log: log, |
| 51 | - | maxBlobChunkSize: maxBlobChunkSize, | |||
| 52 | } | 66 | } | ||
| 67 | + | h.blobLimits.Store(&blobLimits{ | |||
| 68 | + | MaxChunkSize: maxBlobChunkSize, | |||
| 69 | + | MaxBlobSize: blobSizeLimit, | |||
| 70 | + | }) | |||
| 71 | + | return h | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // UpdateBlobLimits updates request-size limits used by live upload handlers. | |||
| 75 | + | func (h *Handler) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) { | |||
| 76 | + | if maxBlobChunkSize <= 0 { | |||
| 77 | + | maxBlobChunkSize = DefaultMaxBlobChunkSize | |||
| 78 | + | } | |||
| 79 | + | if maxBlobSize <= 0 { | |||
| 80 | + | maxBlobSize = DefaultMaxBlobSize | |||
| 81 | + | } | |||
| 82 | + | h.blobLimits.Store(&blobLimits{ | |||
| 83 | + | MaxChunkSize: maxBlobChunkSize, | |||
| 84 | + | MaxBlobSize: maxBlobSize, | |||
| 85 | + | }) | |||
| 53 | } | 86 | } | ||
| 54 | 87 | ||||
| 55 | // RegisterRoutes registers the registry routes on the given mux. | 88 | // RegisterRoutes registers the registry routes on the given mux. | ||
| @@ -244,7 +277,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) { | |||||
| 244 | } | 277 | } | ||
| 245 | } | 278 | } | ||
| 246 | 279 | ||||
| 247 | - | func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) { | 280 | + | func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) { |
| 248 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 281 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
| 249 | w.WriteHeader(http.StatusOK) | 282 | w.WriteHeader(http.StatusOK) | ||
| 250 | } | 283 | } | ||
| @@ -407,20 +440,36 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 407 | } | 440 | } | ||
| 408 | } | 441 | } | ||
| 409 | 442 | ||||
| 443 | + | // Load a consistent snapshot of both blob limits atomically | |||
| 444 | + | limits := h.blobLimits.Load().(*blobLimits) | |||
| 445 | + | maxBlobChunkSize := limits.MaxChunkSize | |||
| 446 | + | maxBlobSize := limits.MaxBlobSize | |||
| 447 | + | ||||
| 410 | // Limit blob chunk size to prevent excessive uploads. | 448 | // Limit blob chunk size to prevent excessive uploads. | ||
| 411 | // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit. | 449 | // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit. | ||
| 412 | - | r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize) | 450 | + | r.Body = http.MaxBytesReader(w, r.Body, maxBlobChunkSize) |
| 413 | 451 | ||||
| 414 | // Stream the body directly to storage — memory usage is bounded to a small | 452 | // Stream the body directly to storage — memory usage is bounded to a small | ||
| 415 | // copy buffer (~32KB) regardless of chunk size. | 453 | // copy buffer (~32KB) regardless of chunk size. | ||
| 416 | - | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body) | 454 | + | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, maxBlobSize) |
| 417 | if err != nil { | 455 | if err != nil { | ||
| 418 | var maxBytesErr *http.MaxBytesError | 456 | var maxBytesErr *http.MaxBytesError | ||
| 419 | if errors.As(err, &maxBytesErr) { | 457 | if errors.As(err, &maxBytesErr) { | ||
| 420 | - | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") | 458 | + | log.Warn().Int64("max_size", maxBlobChunkSize).Msg("blob chunk too large") |
| 459 | + | if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") { | |||
| 460 | + | return | |||
| 461 | + | } | |||
| 421 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | 462 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | ||
| 422 | return | 463 | return | ||
| 423 | } | 464 | } | ||
| 465 | + | if errors.Is(err, domain.ErrBlobSizeExceeded) { | |||
| 466 | + | log.Warn().Int64("max_size", maxBlobSize).Msg("blob upload too large") | |||
| 467 | + | if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") { | |||
| 468 | + | return | |||
| 469 | + | } | |||
| 470 | + | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size") | |||
| 471 | + | return | |||
| 472 | + | } | |||
| 424 | log.Error().Err(err).Msg("failed to append blob chunk") | 473 | log.Error().Err(err).Msg("failed to append blob chunk") | ||
| 425 | h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk") | 474 | h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk") | ||
| 426 | return | 475 | return | ||
| @@ -430,7 +479,9 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 430 | if r.Method == "PUT" && digest != "" { | 479 | if r.Method == "PUT" && digest != "" { | ||
| 431 | if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil { | 480 | if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil { | ||
| 432 | log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload") | 481 | log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload") | ||
| 433 | - | _ = h.registrySvc.CancelUpload(ctx, uuid) | 482 | + | if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel invalid blob upload") { |
| 483 | + | return | |||
| 484 | + | } | |||
| 434 | h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch") | 485 | h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch") | ||
| 435 | return | 486 | return | ||
| 436 | } | 487 | } | ||
| @@ -450,6 +501,16 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 450 | w.WriteHeader(http.StatusAccepted) | 501 | w.WriteHeader(http.StatusAccepted) | ||
| 451 | } | 502 | } | ||
| 452 | 503 | ||||
| 504 | + | func (h *Handler) cancelUploadAfterError(w http.ResponseWriter, ctx context.Context, uuid, message string) bool { | |||
| 505 | + | if err := h.registrySvc.CancelUpload(ctx, uuid); err != nil { | |||
| 506 | + | log := zerowrap.FromCtx(ctx) | |||
| 507 | + | log.Error().Err(err).Str("uuid", uuid).Msg(message) | |||
| 508 | + | h.sendRegistryError(w, http.StatusInternalServerError, "UNKNOWN", "failed to cancel upload") | |||
| 509 | + | return false | |||
| 510 | + | } | |||
| 511 | + | return true | |||
| 512 | + | } | |||
| 513 | + | ||||
| 453 | func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) { | 514 | func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) { | ||
| 454 | ctx := r.Context() | 515 | ctx := r.Context() | ||
| 455 | log := zerowrap.FromCtx(ctx) | 516 | log := zerowrap.FromCtx(ctx) | ||
M internal/adapters/in/http/registry/handler_test.go +83 -7
| @@ -293,7 +293,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) { | |||||
| 293 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 293 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 294 | 294 | ||||
| 295 | chunkData := []byte("chunk content") | 295 | chunkData := []byte("chunk content") | ||
| 296 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | 296 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil) |
| 297 | 297 | ||||
| 298 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 298 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 299 | rec := httptest.NewRecorder() | 299 | rec := httptest.NewRecorder() | ||
| @@ -310,7 +310,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |||||
| 310 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 310 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 311 | 311 | ||||
| 312 | chunkData := []byte("final chunk") | 312 | chunkData := []byte("final chunk") | ||
| 313 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | 313 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil) |
| 314 | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | 314 | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | ||
| 315 | 315 | ||||
| 316 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | 316 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | ||
| @@ -432,7 +432,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | |||||
| 432 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 432 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 433 | 433 | ||||
| 434 | chunkData := []byte("chunk content") | 434 | chunkData := []byte("chunk content") | ||
| 435 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | 435 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil) |
| 436 | 436 | ||||
| 437 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 437 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 438 | rec := httptest.NewRecorder() | 438 | rec := httptest.NewRecorder() | ||
| @@ -453,12 +453,14 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing. | |||||
| 453 | 453 | ||||
| 454 | // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk. | 454 | // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk. | ||
| 455 | // The mock must read the body to trigger MaxBytesError, which the handler detects. | 455 | // The mock must read the body to trigger MaxBytesError, which the handler detects. | ||
| 456 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything). | 456 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything). |
| 457 | - | Run(func(_ context.Context, _ string, _ string, data io.Reader) { | 457 | + | Run(func(_ context.Context, _ string, _ string, data io.Reader, _ int64, _ int64) { |
| 458 | - | // Drain the reader to trigger MaxBytesError | 458 | + | // Drain the reader to trigger MaxBytesError after the storage layer may |
| 459 | + | // have written the limit-sized prefix. | |||
| 459 | _, _ = io.ReadAll(data) | 460 | _, _ = io.ReadAll(data) | ||
| 460 | }). | 461 | }). | ||
| 461 | Return(int64(0), &http.MaxBytesError{Limit: 5}) | 462 | Return(int64(0), &http.MaxBytesError{Limit: 5}) | ||
| 463 | + | registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil) | |||
| 462 | 464 | ||||
| 463 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content"))) | 465 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content"))) | ||
| 464 | rec := httptest.NewRecorder() | 466 | rec := httptest.NewRecorder() | ||
| @@ -473,7 +475,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) { | |||||
| 473 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 475 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 474 | 476 | ||||
| 475 | chunkData := []byte("chunk content") | 477 | chunkData := []byte("chunk content") | ||
| 476 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | 478 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil) |
| 477 | 479 | ||||
| 478 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 480 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 479 | rec := httptest.NewRecorder() | 481 | rec := httptest.NewRecorder() | ||
| @@ -484,3 +486,77 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) { | |||||
| 484 | // OCI spec: Range header should reflect total received bytes | 486 | // OCI spec: Range header should reflect total received bytes | ||
| 485 | assert.Equal(t, "0-12", rec.Header().Get("Range")) | 487 | assert.Equal(t, "0-12", rec.Header().Get("Range")) | ||
| 486 | } | 488 | } | ||
| 489 | + | ||||
| 490 | + | func TestDefaultMaxBlobSize_Is1GB(t *testing.T) { | |||
| 491 | + | assert.Equal(t, int64(1024*1024*1024), int64(DefaultMaxBlobSize)) | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func TestHandler_BlobUpload_PATCH_MaxBlobSizeExceededReturnsSizeInvalidAndCancels(t *testing.T) { | |||
| 495 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 496 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10) | |||
| 497 | + | ||||
| 498 | + | registrySvc.EXPECT().AppendBlobChunk( | |||
| 499 | + | mock.Anything, | |||
| 500 | + | "myapp", | |||
| 501 | + | "550e8400-e29b-41d4-a716-446655440000", | |||
| 502 | + | mock.Anything, | |||
| 503 | + | int64(6), | |||
| 504 | + | int64(10), | |||
| 505 | + | ).Return(int64(0), domain.ErrBlobSizeExceeded) | |||
| 506 | + | registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil) | |||
| 507 | + | ||||
| 508 | + | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("123456"))) | |||
| 509 | + | rec := httptest.NewRecorder() | |||
| 510 | + | ||||
| 511 | + | handler.ServeHTTP(rec, req) | |||
| 512 | + | ||||
| 513 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) | |||
| 514 | + | assert.Contains(t, rec.Body.String(), "SIZE_INVALID") | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func TestHandler_BlobUpload_PATCH_ContentLengthTooLargeReturnsSizeInvalidAndCancels(t *testing.T) { | |||
| 518 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 519 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10) | |||
| 520 | + | ||||
| 521 | + | registrySvc.EXPECT().AppendBlobChunk( | |||
| 522 | + | mock.Anything, | |||
| 523 | + | "myapp", | |||
| 524 | + | "550e8400-e29b-41d4-a716-446655440000", | |||
| 525 | + | mock.Anything, | |||
| 526 | + | int64(11), | |||
| 527 | + | int64(10), | |||
| 528 | + | ).Return(int64(0), domain.ErrBlobSizeExceeded) | |||
| 529 | + | registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil) | |||
| 530 | + | ||||
| 531 | + | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("12345678901"))) | |||
| 532 | + | rec := httptest.NewRecorder() | |||
| 533 | + | ||||
| 534 | + | handler.ServeHTTP(rec, req) | |||
| 535 | + | ||||
| 536 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) | |||
| 537 | + | assert.Contains(t, rec.Body.String(), "SIZE_INVALID") | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestHandler_BlobUpload_PUT_FinalizeUnderMaxBlobSize(t *testing.T) { | |||
| 541 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 542 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10) | |||
| 543 | + | ||||
| 544 | + | chunkData := []byte("1234567890") | |||
| 545 | + | registrySvc.EXPECT().AppendBlobChunk( | |||
| 546 | + | mock.Anything, | |||
| 547 | + | "myapp", | |||
| 548 | + | "550e8400-e29b-41d4-a716-446655440000", | |||
| 549 | + | mock.Anything, | |||
| 550 | + | int64(len(chunkData)), | |||
| 551 | + | int64(10), | |||
| 552 | + | ).Return(int64(len(chunkData)), nil) | |||
| 553 | + | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | |||
| 554 | + | ||||
| 555 | + | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | |||
| 556 | + | rec := httptest.NewRecorder() | |||
| 557 | + | ||||
| 558 | + | handler.ServeHTTP(rec, req) | |||
| 559 | + | ||||
| 560 | + | assert.Equal(t, http.StatusCreated, rec.Code) | |||
| 561 | + | assert.Equal(t, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", rec.Header().Get("Docker-Content-Digest")) | |||
| 562 | + | } | |||
M internal/adapters/out/docker/runtime.go +64 -51
| @@ -161,6 +161,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 161 | WorkingDir: config.WorkingDir, | 161 | WorkingDir: config.WorkingDir, | ||
| 162 | Cmd: config.Cmd, | 162 | Cmd: config.Cmd, | ||
| 163 | Labels: config.Labels, | 163 | Labels: config.Labels, | ||
| 164 | + | User: config.User, | |||
| 164 | } | 165 | } | ||
| 165 | 166 | ||||
| 166 | resources := container.Resources{ | 167 | resources := container.Resources{ | ||
| @@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 173 | if config.PidsLimit > 0 { | 174 | if config.PidsLimit > 0 { | ||
| 174 | resources.PidsLimit = &config.PidsLimit | 175 | resources.PidsLimit = &config.PidsLimit | ||
| 175 | } | 176 | } | ||
| 177 | + | capDrop := strslice.StrSlice{"ALL"} | |||
| 178 | + | capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"} | |||
| 179 | + | if config.CapDrop != nil { | |||
| 180 | + | capDrop = strslice.StrSlice(config.CapDrop) | |||
| 181 | + | } | |||
| 182 | + | if config.CapAdd != nil { | |||
| 183 | + | capAdd = strslice.StrSlice(config.CapAdd) | |||
| 184 | + | } | |||
| 176 | hostConfig := &container.HostConfig{ | 185 | hostConfig := &container.HostConfig{ | ||
| 177 | - | PortBindings: portBindings, | 186 | + | PortBindings: portBindings, |
| 178 | - | AutoRemove: config.AutoRemove, | 187 | + | AutoRemove: config.AutoRemove, |
| 179 | - | Binds: binds, | 188 | + | Binds: binds, |
| 180 | - | NetworkMode: container.NetworkMode(config.NetworkMode), | 189 | + | NetworkMode: container.NetworkMode(config.NetworkMode), |
| 181 | - | Resources: resources, | 190 | + | Resources: resources, |
| 182 | - | SecurityOpt: []string{"no-new-privileges:true"}, | 191 | + | SecurityOpt: []string{"no-new-privileges:true"}, |
| 183 | - | CapDrop: strslice.StrSlice{"ALL"}, | 192 | + | CapDrop: capDrop, |
| 184 | - | // Re-add the minimal set of capabilities that standard images need. | 193 | + | CapAdd: capAdd, |
| 185 | - | // CHOWN/FOWNER/DAC_OVERRIDE: postgres, mysql etc. chown/chmod data dirs | 194 | + | ReadonlyRootfs: config.ReadOnlyRootFS, |
| 186 | - | // owned by their service user during entrypoint init. | |||
| 187 | - | // SETUID/SETGID: gosu/su-exec to drop privileges after init. | |||
| 188 | - | // NET_BIND_SERVICE: nginx etc. binding to ports < 1024. | |||
| 189 | - | CapAdd: strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}, | |||
| 190 | } | 195 | } | ||
| 191 | 196 | ||||
| 192 | // Create network configuration for container | 197 | // Create network configuration for container | ||
| @@ -1251,7 +1256,7 @@ func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, erro | |||||
| 1251 | } | 1256 | } | ||
| 1252 | 1257 | ||||
| 1253 | // CreateNetwork creates a new Docker network. | 1258 | // CreateNetwork creates a new Docker network. | ||
| 1254 | - | func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error { | 1259 | + | func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error { |
| 1255 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 1260 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 1256 | zerowrap.FieldLayer: "adapter", | 1261 | zerowrap.FieldLayer: "adapter", | ||
| 1257 | zerowrap.FieldAdapter: "docker", | 1262 | zerowrap.FieldAdapter: "docker", | ||
| @@ -1260,24 +1265,20 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st | |||||
| 1260 | }) | 1265 | }) | ||
| 1261 | log := zerowrap.FromCtx(ctx) | 1266 | log := zerowrap.FromCtx(ctx) | ||
| 1262 | 1267 | ||||
| 1263 | - | // Set default driver to bridge if not specified | 1268 | + | driver := config.Driver |
| 1264 | - | driver := "bridge" | 1269 | + | if driver == "" { |
| 1265 | - | if driverOption, exists := options["driver"]; exists { | 1270 | + | driver = "bridge" |
| 1266 | - | driver = driverOption | |||
| 1267 | } | 1271 | } | ||
| 1268 | - | 1272 | + | labels := make(map[string]string, len(config.Labels)+1) | |
| 1269 | - | createOptions := network.CreateOptions{ | 1273 | + | for key, value := range config.Labels { |
| 1270 | - | Driver: driver, | 1274 | + | labels[key] = value |
| 1271 | - | Labels: map[string]string{ | |||
| 1272 | - | domain.LabelManaged: "true", | |||
| 1273 | - | }, | |||
| 1274 | } | 1275 | } | ||
| 1276 | + | labels[domain.LabelManaged] = "true" | |||
| 1275 | 1277 | ||||
| 1276 | - | // Add any additional options to labels | 1278 | + | createOptions := network.CreateOptions{ |
| 1277 | - | for key, value := range options { | 1279 | + | Driver: driver, |
| 1278 | - | if key != "driver" { | 1280 | + | Internal: config.Internal, |
| 1279 | - | createOptions.Labels["gordon."+key] = value | 1281 | + | Labels: labels, |
| 1280 | - | } | |||
| 1281 | } | 1282 | } | ||
| 1282 | 1283 | ||||
| 1283 | _, err := r.client.NetworkCreate(ctx, name, createOptions) | 1284 | _, err := r.client.NetworkCreate(ctx, name, createOptions) | ||
| @@ -1503,10 +1504,7 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st | |||||
| 1503 | // extractFileFromTar extracts a single file from a tar archive. | 1504 | // extractFileFromTar extracts a single file from a tar archive. | ||
| 1504 | func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) { | 1505 | func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) { | ||
| 1505 | tr := tar.NewReader(reader) | 1506 | tr := tar.NewReader(reader) | ||
| 1506 | - | 1507 | + | targetName := normalizedTarPath(targetPath) | |
| 1507 | - | // The path in the tar may be relative or absolute | |||
| 1508 | - | // We need to match based on the filename | |||
| 1509 | - | targetName := filepath.Base(targetPath) | |||
| 1510 | 1508 | ||||
| 1511 | for { | 1509 | for { | ||
| 1512 | header, err := tr.Next() | 1510 | header, err := tr.Next() | ||
| @@ -1517,23 +1515,19 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, | |||||
| 1517 | return nil, err | 1515 | return nil, err | ||
| 1518 | } | 1516 | } | ||
| 1519 | 1517 | ||||
| 1520 | - | // Check if this is our target file | 1518 | + | if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName { |
| 1521 | - | if header.Typeflag == tar.TypeReg { | 1519 | + | size := header.Size |
| 1522 | - | headerName := filepath.Base(header.Name) | 1520 | + | pr, pw := io.Pipe() |
| 1523 | - | if headerName == targetName { | 1521 | + | go func() { |
| 1524 | - | size := header.Size | 1522 | + | defer reader.Close() |
| 1525 | - | pr, pw := io.Pipe() | 1523 | + | _, copyErr := io.CopyN(pw, tr, size) |
| 1526 | - | go func() { | 1524 | + | if copyErr != nil { |
| 1527 | - | defer reader.Close() | 1525 | + | _ = pw.CloseWithError(copyErr) |
| 1528 | - | _, copyErr := io.CopyN(pw, tr, size) | 1526 | + | return |
| 1529 | - | if copyErr != nil { | 1527 | + | } |
| 1530 | - | _ = pw.CloseWithError(copyErr) | 1528 | + | _ = pw.Close() |
| 1531 | - | return | 1529 | + | }() |
| 1532 | - | } | 1530 | + | return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil |
| 1533 | - | _ = pw.Close() | |||
| 1534 | - | }() | |||
| 1535 | - | return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil | |||
| 1536 | - | } | |||
| 1537 | } | 1531 | } | ||
| 1538 | } | 1532 | } | ||
| 1539 | 1533 | ||||
| @@ -1541,6 +1535,25 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, | |||||
| 1541 | return nil, fmt.Errorf("file not found in container: %s", targetPath) | 1535 | return nil, fmt.Errorf("file not found in container: %s", targetPath) | ||
| 1542 | } | 1536 | } | ||
| 1543 | 1537 | ||||
| 1538 | + | func normalizedTarPath(path string) string { | |||
| 1539 | + | path = filepath.ToSlash(filepath.Clean(path)) | |||
| 1540 | + | return strings.TrimPrefix(path, "/") | |||
| 1541 | + | } | |||
| 1542 | + | ||||
| 1543 | + | const maxExtractedEnvFileSize = 1 << 20 // 1 MiB | |||
| 1544 | + | ||||
| 1545 | + | func readExtractedEnvFile(reader io.Reader) ([]byte, error) { | |||
| 1546 | + | limited := io.LimitReader(reader, maxExtractedEnvFileSize+1) | |||
| 1547 | + | data, err := io.ReadAll(limited) | |||
| 1548 | + | if err != nil { | |||
| 1549 | + | return nil, err | |||
| 1550 | + | } | |||
| 1551 | + | if len(data) > maxExtractedEnvFileSize { | |||
| 1552 | + | return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize) | |||
| 1553 | + | } | |||
| 1554 | + | return data, nil | |||
| 1555 | + | } | |||
| 1556 | + | ||||
| 1544 | // ExtractEnvFileFromImage extracts an env file from an image. | 1557 | // ExtractEnvFileFromImage extracts an env file from an image. | ||
| 1545 | // This creates a temporary container, copies the file, and removes the container. | 1558 | // This creates a temporary container, copies the file, and removes the container. | ||
| 1546 | func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) { | 1559 | func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) { | ||
| @@ -1583,7 +1596,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile | |||||
| 1583 | } | 1596 | } | ||
| 1584 | defer reader.Close() | 1597 | defer reader.Close() | ||
| 1585 | 1598 | ||||
| 1586 | - | data, err := io.ReadAll(reader) | 1599 | + | data, err := readExtractedEnvFile(reader) |
| 1587 | if err != nil { | 1600 | if err != nil { | ||
| 1588 | return nil, log.WrapErr(err, "failed to read extracted env file") | 1601 | return nil, log.WrapErr(err, "failed to read extracted env file") | ||
| 1589 | } | 1602 | } | ||
M internal/adapters/out/docker/runtime_exec_test.go +30 -0
| @@ -1,9 +1,12 @@ | |||||
| 1 | package docker | 1 | package docker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "archive/tar" | |||
| 4 | "bytes" | 5 | "bytes" | ||
| 5 | "context" | 6 | "context" | ||
| 6 | "encoding/binary" | 7 | "encoding/binary" | ||
| 8 | + | "io" | |||
| 9 | + | "strings" | |||
| 7 | "testing" | 10 | "testing" | ||
| 8 | 11 | ||||
| 9 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| @@ -47,3 +50,30 @@ func frameDockerStream(streamID byte, payload []byte) []byte { | |||||
| 47 | copy(frame[8:], payload) | 50 | copy(frame[8:], payload) | ||
| 48 | return frame | 51 | return frame | ||
| 49 | } | 52 | } | ||
| 53 | + | ||||
| 54 | + | func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) { | |||
| 55 | + | var buf bytes.Buffer | |||
| 56 | + | tw := tar.NewWriter(&buf) | |||
| 57 | + | require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))})) | |||
| 58 | + | _, err := tw.Write([]byte("wrong")) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))})) | |||
| 61 | + | _, err = tw.Write([]byte("right")) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | require.NoError(t, tw.Close()) | |||
| 64 | + | ||||
| 65 | + | rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env") | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | defer rc.Close() | |||
| 68 | + | ||||
| 69 | + | data, err := io.ReadAll(rc) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.Equal(t, "right", string(data)) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) { | |||
| 75 | + | data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1)))) | |||
| 76 | + | require.Error(t, err) | |||
| 77 | + | assert.Nil(t, data) | |||
| 78 | + | assert.Contains(t, err.Error(), "exceeds") | |||
| 79 | + | } | |||
M internal/adapters/out/domainsecrets/pass_store.go +111 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "regexp" | 8 | "regexp" | ||
| 9 | "sort" | 9 | "sort" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "sync" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| @@ -27,6 +28,7 @@ const ( | |||||
| 27 | 28 | ||||
| 28 | // PassStore implements the DomainSecretStore interface using the pass password manager. | 29 | // PassStore implements the DomainSecretStore interface using the pass password manager. | ||
| 29 | type PassStore struct { | 30 | type PassStore struct { | ||
| 31 | + | mu sync.Mutex | |||
| 30 | timeout time.Duration | 32 | timeout time.Duration | ||
| 31 | log zerowrap.Logger | 33 | log zerowrap.Logger | ||
| 32 | } | 34 | } | ||
| @@ -138,6 +140,26 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||||
| 138 | return secretsMap, nil | 140 | return secretsMap, nil | ||
| 139 | } | 141 | } | ||
| 140 | 142 | ||||
| 143 | + | // SetIfEmpty sets multiple secrets for a domain only when no keys already exist. | |||
| 144 | + | // It performs the precondition check inside the store adapter so migration | |||
| 145 | + | // callers cannot accidentally split the check and write into separate steps. | |||
| 146 | + | func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) { | |||
| 147 | + | s.mu.Lock() | |||
| 148 | + | defer s.mu.Unlock() | |||
| 149 | + | ||||
| 150 | + | existingKeys, err := s.ListKeys(domainName) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return nil, err | |||
| 153 | + | } | |||
| 154 | + | if len(existingKeys) > 0 { | |||
| 155 | + | return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys)) | |||
| 156 | + | } | |||
| 157 | + | if err := s.Set(domainName, secretsMap); err != nil { | |||
| 158 | + | return nil, err | |||
| 159 | + | } | |||
| 160 | + | return sortedMapKeys(secretsMap), nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 141 | // Set sets or updates multiple secrets for a domain. | 163 | // Set sets or updates multiple secrets for a domain. | ||
| 142 | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | 164 | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | ||
| 143 | if _, err := s.domainPath(domainName); err != nil { | 165 | if _, err := s.domainPath(domainName); err != nil { | ||
| @@ -253,6 +275,26 @@ func (s *PassStore) Delete(domainName, key string) error { | |||||
| 253 | return nil | 275 | return nil | ||
| 254 | } | 276 | } | ||
| 255 | 277 | ||||
| 278 | + | // SetAttachmentIfEmpty sets attachment secrets only when no keys already exist. | |||
| 279 | + | // It performs the precondition check inside the store adapter so migration | |||
| 280 | + | // callers cannot accidentally split the check and write into separate steps. | |||
| 281 | + | func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) { | |||
| 282 | + | s.mu.Lock() | |||
| 283 | + | defer s.mu.Unlock() | |||
| 284 | + | ||||
| 285 | + | existingKeys, err := s.listAttachmentKeysRecover(containerName) | |||
| 286 | + | if err != nil { | |||
| 287 | + | return nil, err | |||
| 288 | + | } | |||
| 289 | + | if len(existingKeys) > 0 { | |||
| 290 | + | return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys)) | |||
| 291 | + | } | |||
| 292 | + | if err := s.SetAttachment(containerName, secretsMap); err != nil { | |||
| 293 | + | return nil, err | |||
| 294 | + | } | |||
| 295 | + | return sortedMapKeys(secretsMap), nil | |||
| 296 | + | } | |||
| 297 | + | ||||
| 256 | // SetAttachment sets or updates multiple secrets for an attachment container. | 298 | // SetAttachment sets or updates multiple secrets for an attachment container. | ||
| 257 | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | 299 | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | ||
| 258 | if _, err := s.attachmentPath(containerName); err != nil { | 300 | if _, err := s.attachmentPath(containerName); err != nil { | ||
| @@ -613,6 +655,66 @@ func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |||||
| 613 | keys = append(keys, key) | 655 | keys = append(keys, key) | ||
| 614 | } | 656 | } | ||
| 615 | 657 | ||||
| 658 | + | sort.Strings(keys) | |||
| 659 | + | return keys, nil | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) { | |||
| 663 | + | keys, err := s.listAttachmentKeys(containerName) | |||
| 664 | + | if err != nil { | |||
| 665 | + | return nil, err | |||
| 666 | + | } | |||
| 667 | + | discovered, err := s.discoverAttachmentKeys(containerName) | |||
| 668 | + | if err != nil { | |||
| 669 | + | return nil, err | |||
| 670 | + | } | |||
| 671 | + | merged, changed := mergeUniqueKeys(keys, discovered) | |||
| 672 | + | if changed { | |||
| 673 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 674 | + | if err != nil { | |||
| 675 | + | return nil, err | |||
| 676 | + | } | |||
| 677 | + | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 678 | + | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |||
| 679 | + | writeCancel() | |||
| 680 | + | if writeErr != nil { | |||
| 681 | + | s.log.Warn(). | |||
| 682 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 683 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 684 | + | Str("container", containerName). | |||
| 685 | + | Err(writeErr). | |||
| 686 | + | Msg("failed to self-heal attachment pass manifest, continuing with recovered keys") | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | return merged, nil | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) { | |||
| 693 | + | basePath, err := s.attachmentPath(containerName) | |||
| 694 | + | if err != nil { | |||
| 695 | + | return nil, err | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 699 | + | defer cancel() | |||
| 700 | + | ||||
| 701 | + | entries, err := s.listTopLevelEntries(ctx, basePath) | |||
| 702 | + | if err != nil { | |||
| 703 | + | return nil, err | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | keys := make([]string, 0, len(entries)) | |||
| 707 | + | for _, entry := range entries { | |||
| 708 | + | if entry == ".keys" { | |||
| 709 | + | continue | |||
| 710 | + | } | |||
| 711 | + | if err := domain.ValidateEnvKey(entry); err != nil { | |||
| 712 | + | continue | |||
| 713 | + | } | |||
| 714 | + | keys = append(keys, entry) | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | sort.Strings(keys) | |||
| 616 | return keys, nil | 718 | return keys, nil | ||
| 617 | } | 719 | } | ||
| 618 | 720 | ||||
| @@ -778,6 +880,15 @@ func passEntryMissing(output string) bool { | |||||
| 778 | strings.Contains(lower, "password store is empty") | 880 | strings.Contains(lower, "password store is empty") | ||
| 779 | } | 881 | } | ||
| 780 | 882 | ||||
| 883 | + | func sortedMapKeys(values map[string]string) []string { | |||
| 884 | + | keys := make([]string, 0, len(values)) | |||
| 885 | + | for key := range values { | |||
| 886 | + | keys = append(keys, key) | |||
| 887 | + | } | |||
| 888 | + | sort.Strings(keys) | |||
| 889 | + | return keys | |||
| 890 | + | } | |||
| 891 | + | ||||
| 781 | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | 892 | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | ||
| 782 | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | 893 | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | ||
| 783 | for _, key := range primary { | 894 | for _, key := range primary { | ||
M internal/adapters/out/filesystem/backup_storage.go +36 -4
| @@ -2,6 +2,8 @@ package filesystem | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/rand" | |||
| 6 | + | "encoding/hex" | |||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "io" | 9 | "io" | ||
| @@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche | |||||
| 62 | return "", fmt.Errorf("failed to create backup path: %w", err) | 64 | return "", fmt.Errorf("failed to create backup path: %w", err) | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | - | fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak" | 67 | + | fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak" |
| 68 | + | fileName = sanitizeBackupFileName(fileName) | |||
| 66 | finalPath := filepath.Join(backupDir, fileName) | 69 | finalPath := filepath.Join(backupDir, fileName) | ||
| 67 | tmpPath := finalPath + ".tmp" | 70 | tmpPath := finalPath + ".tmp" | ||
| 68 | 71 | ||||
| 69 | - | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | 72 | + | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) |
| 70 | if err != nil { | 73 | if err != nil { | ||
| 71 | return "", fmt.Errorf("failed to create temp backup file: %w", err) | 74 | return "", fmt.Errorf("failed to create temp backup file: %w", err) | ||
| 72 | } | 75 | } | ||
| @@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||||
| 118 | if walkErr != nil { | 121 | if walkErr != nil { | ||
| 119 | return walkErr | 122 | return walkErr | ||
| 120 | } | 123 | } | ||
| 121 | - | if d.IsDir() { | 124 | + | if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") { |
| 122 | return nil | 125 | return nil | ||
| 123 | } | 126 | } | ||
| 124 | 127 | ||||
| @@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||||
| 144 | 147 | ||||
| 145 | startedAt := info.ModTime().UTC() | 148 | startedAt := info.ModTime().UTC() | ||
| 146 | base := filepath.Base(path) | 149 | base := filepath.Base(path) | ||
| 147 | - | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | 150 | + | if ts, ok := parseBackupStartedAt(base); ok { |
| 148 | startedAt = ts.UTC() | 151 | startedAt = ts.UTC() | ||
| 149 | } | 152 | } | ||
| 150 | 153 | ||||
| @@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch | |||||
| 267 | } | 270 | } | ||
| 268 | } | 271 | } | ||
| 269 | 272 | ||||
| 273 | + | func parseBackupStartedAt(base string) (time.Time, bool) { | |||
| 274 | + | if !strings.HasSuffix(base, ".bak") { | |||
| 275 | + | return time.Time{}, false | |||
| 276 | + | } | |||
| 277 | + | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |||
| 278 | + | return ts, true | |||
| 279 | + | } | |||
| 280 | + | trimmed := strings.TrimSuffix(base, ".bak") | |||
| 281 | + | if idx := strings.LastIndex(trimmed, "-"); idx >= 0 { | |||
| 282 | + | trimmed = trimmed[:idx] | |||
| 283 | + | } | |||
| 284 | + | if ts, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(trimmed, "_", ":")); err == nil { | |||
| 285 | + | return ts, true | |||
| 286 | + | } | |||
| 287 | + | return time.Time{}, false | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func randomBackupSuffix() string { | |||
| 291 | + | var b [8]byte | |||
| 292 | + | if _, err := rand.Read(b[:]); err != nil { | |||
| 293 | + | return fmt.Sprintf("%d", time.Now().UTC().UnixNano()) | |||
| 294 | + | } | |||
| 295 | + | return hex.EncodeToString(b[:]) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func sanitizeBackupFileName(input string) string { | |||
| 299 | + | return strings.NewReplacer(":", "_").Replace(input) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 270 | func sanitizeBackupPathComponent(input string) string { | 302 | func sanitizeBackupPathComponent(input string) string { | ||
| 271 | clean := strings.TrimSpace(input) | 303 | clean := strings.TrimSpace(input) | ||
| 272 | if clean == "" { | 304 | if clean == "" { | ||
M internal/adapters/out/filesystem/backup_storage_test.go +34 -1
| @@ -31,6 +31,39 @@ func TestBackupStorage_StoreAndGet(t *testing.T) { | |||||
| 31 | assert.Equal(t, payload, data) | 31 | assert.Equal(t, payload, data) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | func TestBackupStorage_StoreSameSecondBackupsUseDistinctFinalAndTempPaths(t *testing.T) { | |||
| 35 | + | rootDir := t.TempDir() | |||
| 36 | + | storage, err := NewBackupStorage(rootDir, testLogger()) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 40 | + | firstPayload := []byte("first-backup") | |||
| 41 | + | secondPayload := []byte("second-backup") | |||
| 42 | + | ||||
| 43 | + | firstPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(firstPayload)) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | secondPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(secondPayload)) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | ||||
| 48 | + | assert.NotEqual(t, firstPath, secondPath) | |||
| 49 | + | assert.NoFileExists(t, firstPath+".tmp") | |||
| 50 | + | assert.NoFileExists(t, secondPath+".tmp") | |||
| 51 | + | ||||
| 52 | + | firstRead, err := storage.Get(context.Background(), firstPath) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | defer firstRead.Close() | |||
| 55 | + | firstData, err := io.ReadAll(firstRead) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | assert.Equal(t, firstPayload, firstData) | |||
| 58 | + | ||||
| 59 | + | secondRead, err := storage.Get(context.Background(), secondPath) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | defer secondRead.Close() | |||
| 62 | + | secondData, err := io.ReadAll(secondRead) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Equal(t, secondPayload, secondData) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 34 | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | 67 | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | ||
| 35 | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | 68 | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | ||
| 36 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| @@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) { | |||||
| 71 | require.NoError(t, err) | 104 | require.NoError(t, err) | ||
| 72 | 105 | ||||
| 73 | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | 106 | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | ||
| 74 | - | for i := 0; i < 4; i++ { | 107 | + | for i := range 4 { |
| 75 | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | 108 | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | ||
| 76 | require.NoError(t, err) | 109 | require.NoError(t, err) | ||
| 77 | } | 110 | } | ||
M internal/adapters/out/filesystem/blob.go +24 -2
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/zerowrap" | 17 | "github.com/bnema/zerowrap" | ||
| 18 | "github.com/google/uuid" | 18 | "github.com/google/uuid" | ||
| 19 | 19 | ||||
| 20 | + | "github.com/bnema/gordon/internal/domain" | |||
| 20 | "github.com/bnema/gordon/pkg/validation" | 21 | "github.com/bnema/gordon/pkg/validation" | ||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| @@ -254,7 +255,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | |||||
| 254 | } | 255 | } | ||
| 255 | 256 | ||||
| 256 | // AppendBlobChunk appends data to an in-progress upload. | 257 | // AppendBlobChunk appends data to an in-progress upload. | ||
| 257 | - | func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) { | 258 | + | func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) { |
| 258 | uploadPath, err := s.getUploadPath(uuid) | 259 | uploadPath, err := s.getUploadPath(uuid) | ||
| 259 | if err != nil { | 260 | if err != nil { | ||
| 260 | return 0, fmt.Errorf("invalid upload path: %w", err) | 261 | return 0, fmt.Errorf("invalid upload path: %w", err) | ||
| @@ -274,12 +275,33 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, | |||||
| 274 | } | 275 | } | ||
| 275 | defer file.Close() | 276 | defer file.Close() | ||
| 276 | 277 | ||||
| 278 | + | fi, err := file.Stat() | |||
| 279 | + | if err != nil { | |||
| 280 | + | return 0, fmt.Errorf("failed to get file info: %w", err) | |||
| 281 | + | } | |||
| 282 | + | originalSize := fi.Size() | |||
| 283 | + | ||||
| 284 | + | if maxBlobSize > 0 { | |||
| 285 | + | remaining := maxBlobSize - originalSize | |||
| 286 | + | if remaining < 0 || (contentLength >= 0 && contentLength > remaining) { | |||
| 287 | + | return 0, domain.ErrBlobSizeExceeded | |||
| 288 | + | } | |||
| 289 | + | data = io.LimitReader(data, remaining+1) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 277 | written, err := io.Copy(file, data) | 292 | written, err := io.Copy(file, data) | ||
| 278 | if err != nil { | 293 | if err != nil { | ||
| 279 | return 0, fmt.Errorf("failed to write chunk to upload file: %w", err) | 294 | return 0, fmt.Errorf("failed to write chunk to upload file: %w", err) | ||
| 280 | } | 295 | } | ||
| 281 | 296 | ||||
| 282 | - | fi, err := file.Stat() | 297 | + | if maxBlobSize > 0 && originalSize+written > maxBlobSize { |
| 298 | + | if err := file.Truncate(originalSize); err != nil { | |||
| 299 | + | return 0, fmt.Errorf("failed to truncate oversized upload: %w", err) | |||
| 300 | + | } | |||
| 301 | + | return 0, domain.ErrBlobSizeExceeded | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | fi, err = file.Stat() | |||
| 283 | if err != nil { | 305 | if err != nil { | ||
| 284 | return 0, fmt.Errorf("failed to get file info: %w", err) | 306 | return 0, fmt.Errorf("failed to get file info: %w", err) | ||
| 285 | } | 307 | } | ||
M internal/adapters/out/filesystem/blob_test.go +95 -12
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "crypto/sha256" | 5 | "crypto/sha256" | ||
| 6 | "encoding/hex" | 6 | "encoding/hex" | ||
| 7 | + | "errors" | |||
| 7 | "io" | 8 | "io" | ||
| 8 | "os" | 9 | "os" | ||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| @@ -13,6 +14,8 @@ import ( | |||||
| 13 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 17 | + | ||||
| 18 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | ) | 19 | ) | ||
| 17 | 20 | ||||
| 18 | const ( | 21 | const ( | ||
| @@ -26,6 +29,15 @@ func testLogger() zerowrap.Logger { | |||||
| 26 | return zerowrap.Default() | 29 | return zerowrap.Default() | ||
| 27 | } | 30 | } | ||
| 28 | 31 | ||||
| 32 | + | type readFailer struct { | |||
| 33 | + | read bool | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (r *readFailer) Read(_ []byte) (int, error) { | |||
| 37 | + | r.read = true | |||
| 38 | + | return 0, io.ErrUnexpectedEOF | |||
| 39 | + | } | |||
| 40 | + | ||||
| 29 | func TestNewBlobStorage(t *testing.T) { | 41 | func TestNewBlobStorage(t *testing.T) { | ||
| 30 | tmpDir := t.TempDir() | 42 | tmpDir := t.TempDir() | ||
| 31 | log := testLogger() | 43 | log := testLogger() | ||
| @@ -280,13 +292,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) { | |||||
| 280 | 292 | ||||
| 281 | // Append first chunk | 293 | // Append first chunk | ||
| 282 | chunk1 := []byte("first chunk") | 294 | chunk1 := []byte("first chunk") | ||
| 283 | - | size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1)) | 295 | + | size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1), -1, 0) |
| 284 | require.NoError(t, err) | 296 | require.NoError(t, err) | ||
| 285 | assert.Equal(t, int64(len(chunk1)), size1) | 297 | assert.Equal(t, int64(len(chunk1)), size1) | ||
| 286 | 298 | ||||
| 287 | // Append second chunk | 299 | // Append second chunk | ||
| 288 | chunk2 := []byte(" second chunk") | 300 | chunk2 := []byte(" second chunk") | ||
| 289 | - | size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2)) | 301 | + | size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2), -1, 0) |
| 290 | require.NoError(t, err) | 302 | require.NoError(t, err) | ||
| 291 | assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2) | 303 | assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2) | ||
| 292 | } | 304 | } | ||
| @@ -298,12 +310,83 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) { | |||||
| 298 | storage, err := NewBlobStorage(tmpDir, log) | 310 | storage, err := NewBlobStorage(tmpDir, log) | ||
| 299 | require.NoError(t, err) | 311 | require.NoError(t, err) | ||
| 300 | 312 | ||||
| 301 | - | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data"))) | 313 | + | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")), -1, 100) |
| 302 | 314 | ||||
| 303 | assert.Error(t, err) | 315 | assert.Error(t, err) | ||
| 304 | assert.Contains(t, err.Error(), "upload not found") | 316 | assert.Contains(t, err.Error(), "upload not found") | ||
| 305 | } | 317 | } | ||
| 306 | 318 | ||||
| 319 | + | func TestBlobStorage_AppendBlobChunk_MaxBlobSize(t *testing.T) { | |||
| 320 | + | tmpDir := t.TempDir() | |||
| 321 | + | log := testLogger() | |||
| 322 | + | ||||
| 323 | + | storage, err := NewBlobStorage(tmpDir, log) | |||
| 324 | + | require.NoError(t, err) | |||
| 325 | + | ||||
| 326 | + | uuid, err := storage.StartBlobUpload("myapp") | |||
| 327 | + | require.NoError(t, err) | |||
| 328 | + | ||||
| 329 | + | size, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10) | |||
| 330 | + | require.NoError(t, err) | |||
| 331 | + | assert.Equal(t, int64(5), size) | |||
| 332 | + | ||||
| 333 | + | size, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("67890")), -1, 10) | |||
| 334 | + | require.NoError(t, err) | |||
| 335 | + | assert.Equal(t, int64(10), size) | |||
| 336 | + | ||||
| 337 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("x")), -1, 10) | |||
| 338 | + | require.Error(t, err) | |||
| 339 | + | assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded)) | |||
| 340 | + | ||||
| 341 | + | uploadPath := filepath.Join(tmpDir, "uploads", uuid) | |||
| 342 | + | info, statErr := os.Stat(uploadPath) | |||
| 343 | + | require.NoError(t, statErr) | |||
| 344 | + | assert.Equal(t, int64(10), info.Size()) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func TestBlobStorage_AppendBlobChunk_MaxBlobSizeContentLengthTooLargeDoesNotRead(t *testing.T) { | |||
| 348 | + | tmpDir := t.TempDir() | |||
| 349 | + | log := testLogger() | |||
| 350 | + | ||||
| 351 | + | storage, err := NewBlobStorage(tmpDir, log) | |||
| 352 | + | require.NoError(t, err) | |||
| 353 | + | ||||
| 354 | + | uuid, err := storage.StartBlobUpload("myapp") | |||
| 355 | + | require.NoError(t, err) | |||
| 356 | + | ||||
| 357 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), 5, 10) | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | ||||
| 360 | + | reader := &readFailer{} | |||
| 361 | + | _, err = storage.AppendBlobChunk("myapp", uuid, reader, 6, 10) | |||
| 362 | + | require.Error(t, err) | |||
| 363 | + | assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded) | |||
| 364 | + | assert.False(t, reader.read) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestBlobStorage_AppendBlobChunk_MaxBlobSizeOverflowMidReadTruncates(t *testing.T) { | |||
| 368 | + | tmpDir := t.TempDir() | |||
| 369 | + | log := testLogger() | |||
| 370 | + | ||||
| 371 | + | storage, err := NewBlobStorage(tmpDir, log) | |||
| 372 | + | require.NoError(t, err) | |||
| 373 | + | ||||
| 374 | + | uuid, err := storage.StartBlobUpload("myapp") | |||
| 375 | + | require.NoError(t, err) | |||
| 376 | + | ||||
| 377 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10) | |||
| 378 | + | require.NoError(t, err) | |||
| 379 | + | ||||
| 380 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("6789012345")), -1, 10) | |||
| 381 | + | require.Error(t, err) | |||
| 382 | + | assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded)) | |||
| 383 | + | ||||
| 384 | + | uploadPath := filepath.Join(tmpDir, "uploads", uuid) | |||
| 385 | + | info, statErr := os.Stat(uploadPath) | |||
| 386 | + | require.NoError(t, statErr) | |||
| 387 | + | assert.Equal(t, int64(5), info.Size()) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 307 | func TestBlobStorage_GetBlobUpload(t *testing.T) { | 390 | func TestBlobStorage_GetBlobUpload(t *testing.T) { | ||
| 308 | tmpDir := t.TempDir() | 391 | tmpDir := t.TempDir() | ||
| 309 | log := testLogger() | 392 | log := testLogger() | ||
| @@ -350,7 +433,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) { | |||||
| 350 | 433 | ||||
| 351 | // Append data - content that hashes to testDigest1 | 434 | // Append data - content that hashes to testDigest1 | ||
| 352 | blobData := []byte("complete blob content") | 435 | blobData := []byte("complete blob content") | ||
| 353 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData)) | 436 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0) |
| 354 | require.NoError(t, err) | 437 | require.NoError(t, err) | ||
| 355 | 438 | ||||
| 356 | // Finish upload - use testDigest1 (validation will pass, but hash won't match) | 439 | // Finish upload - use testDigest1 (validation will pass, but hash won't match) | ||
| @@ -477,7 +560,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) { | |||||
| 477 | 560 | ||||
| 478 | var totalSize int64 | 561 | var totalSize int64 | ||
| 479 | for _, chunk := range chunks { | 562 | for _, chunk := range chunks { | ||
| 480 | - | size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk)) | 563 | + | size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk), -1, 0) |
| 481 | require.NoError(t, err) | 564 | require.NoError(t, err) | ||
| 482 | totalSize = size | 565 | totalSize = size | ||
| 483 | } | 566 | } | ||
| @@ -501,7 +584,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) { | |||||
| 501 | require.NoError(t, err) | 584 | require.NoError(t, err) | ||
| 502 | 585 | ||||
| 503 | initialData := []byte("initial-") | 586 | initialData := []byte("initial-") | ||
| 504 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData)) | 587 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData), -1, 0) |
| 505 | require.NoError(t, err) | 588 | require.NoError(t, err) | ||
| 506 | 589 | ||||
| 507 | appendChunk := []byte("chunk") | 590 | appendChunk := []byte("chunk") | ||
| @@ -516,7 +599,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) { | |||||
| 516 | go func() { | 599 | go func() { | ||
| 517 | defer wg.Done() | 600 | defer wg.Done() | ||
| 518 | <-appendReady | 601 | <-appendReady | ||
| 519 | - | _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk)) | 602 | + | _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk), -1, 0) |
| 520 | appendErrs <- appendErr | 603 | appendErrs <- appendErr | ||
| 521 | }() | 604 | }() | ||
| 522 | } | 605 | } | ||
| @@ -570,7 +653,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) { | |||||
| 570 | assert.Equal(t, fullyAppended, blobData) | 653 | assert.Equal(t, fullyAppended, blobData) | ||
| 571 | } | 654 | } | ||
| 572 | 655 | ||||
| 573 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after"))) | 656 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")), -1, 0) |
| 574 | assert.Error(t, err) | 657 | assert.Error(t, err) | ||
| 575 | assert.Contains(t, err.Error(), "upload not found") | 658 | assert.Contains(t, err.Error(), "upload not found") | ||
| 576 | } | 659 | } | ||
| @@ -586,13 +669,13 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) { | |||||
| 586 | require.NoError(t, err) | 669 | require.NoError(t, err) | ||
| 587 | 670 | ||||
| 588 | blobData := []byte("finalized content") | 671 | blobData := []byte("finalized content") | ||
| 589 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData)) | 672 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0) |
| 590 | require.NoError(t, err) | 673 | require.NoError(t, err) | ||
| 591 | 674 | ||||
| 592 | err = storage.FinishBlobUpload(uuid, digestForData(blobData)) | 675 | err = storage.FinishBlobUpload(uuid, digestForData(blobData)) | ||
| 593 | require.NoError(t, err) | 676 | require.NoError(t, err) | ||
| 594 | 677 | ||||
| 595 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more"))) | 678 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")), -1, 0) |
| 596 | assert.Error(t, err) | 679 | assert.Error(t, err) | ||
| 597 | assert.Contains(t, err.Error(), "upload not found") | 680 | assert.Contains(t, err.Error(), "upload not found") | ||
| 598 | } | 681 | } | ||
| @@ -608,7 +691,7 @@ func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) { | |||||
| 608 | require.NoError(t, err) | 691 | require.NoError(t, err) | ||
| 609 | 692 | ||||
| 610 | blobData := []byte("retryable finalize") | 693 | blobData := []byte("retryable finalize") | ||
| 611 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData)) | 694 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0) |
| 612 | require.NoError(t, err) | 695 | require.NoError(t, err) | ||
| 613 | 696 | ||||
| 614 | err = storage.FinishBlobUpload(uuid, testDigest1) | 697 | err = storage.FinishBlobUpload(uuid, testDigest1) | ||
| @@ -638,7 +721,7 @@ func TestBlobStorage_SequentialFlow(t *testing.T) { | |||||
| 638 | var blobData []byte | 721 | var blobData []byte | ||
| 639 | for _, chunk := range chunks { | 722 | for _, chunk := range chunks { | ||
| 640 | blobData = append(blobData, chunk...) | 723 | blobData = append(blobData, chunk...) | ||
| 641 | - | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk)) | 724 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk), -1, 0) |
| 642 | require.NoError(t, err) | 725 | require.NoError(t, err) | ||
| 643 | } | 726 | } | ||
| 644 | 727 | ||||
M internal/app/migrate_env.go +76 -71
| @@ -1,6 +1,7 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| @@ -79,59 +80,46 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log | |||||
| 79 | return nil | 80 | return nil | ||
| 80 | } | 81 | } | ||
| 81 | 82 | ||||
| 82 | - | existingKeys, err := passStore.ListKeys(domainName) | 83 | + | writtenKeys, err := passStore.SetIfEmpty(domainName, secrets) |
| 83 | if err != nil { | 84 | if err != nil { | ||
| 84 | - | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | 85 | + | if errors.Is(err, domain.ErrSecretsAlreadyExist) { |
| 85 | - | } | 86 | + | return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err) |
| 86 | - | ||||
| 87 | - | missing := missingSecrets(existingKeys, secrets) | |||
| 88 | - | if len(missing) > 0 { | |||
| 89 | - | if err := passStore.Set(domainName, missing); err != nil { | |||
| 90 | - | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |||
| 91 | } | 87 | } | ||
| 92 | - | } | 88 | + | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) |
| 93 | - | 89 | + | } | |
| 94 | - | migratedPath := filePath + ".migrated" | 90 | + | |
| 95 | - | if err := os.Rename(filePath, migratedPath); err != nil { | 91 | + | if err := os.Remove(filePath); err != nil { |
| 96 | - | return fmt.Errorf("failed to rename env file %s: %w", filePath, err) | 92 | + | // Check if the stored secrets match what we just wrote |
| 93 | + | storedSecrets, getErr := passStore.GetAll(domainName) | |||
| 94 | + | if getErr == nil && secretsMatch(storedSecrets, secrets) { | |||
| 95 | + | // Migration was successful, removal failed but secrets are stored | |||
| 96 | + | log.Warn(). | |||
| 97 | + | Str("file", filePath). | |||
| 98 | + | Str("domain", domainName). | |||
| 99 | + | Msg("secrets migrated successfully but failed to remove original file; treating as success") | |||
| 100 | + | return nil | |||
| 101 | + | } | |||
| 102 | + | // Attempt rollback: remove only the pass entries this migration wrote. | |||
| 103 | + | for _, key := range writtenKeys { | |||
| 104 | + | if delErr := passStore.Delete(domainName, key); delErr != nil { | |||
| 105 | + | log.Error(). | |||
| 106 | + | Err(delErr). | |||
| 107 | + | Str("domain", domainName). | |||
| 108 | + | Str("key", key). | |||
| 109 | + | Msg("failed to rollback pass entry after file removal failure") | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err) | |||
| 97 | } | 113 | } | ||
| 98 | 114 | ||||
| 99 | log.Info(). | 115 | log.Info(). | ||
| 100 | - | Int(zerowrap.FieldCount, len(missing)). | 116 | + | Int(zerowrap.FieldCount, len(secrets)). |
| 101 | Str("domain", domainName). | 117 | Str("domain", domainName). | ||
| 102 | - | Msg("migrated secrets for domain from plain text to pass") | 118 | + | Msg("migrated secrets for domain from plain text to pass and removed original env file") |
| 103 | - | log.Info(). | |||
| 104 | - | Str("file", migratedPath). | |||
| 105 | - | Msg("original file renamed to .env.migrated - you can safely remove it") | |||
| 106 | 119 | ||||
| 107 | return nil | 120 | return nil | ||
| 108 | } | 121 | } | ||
| 109 | 122 | ||||
| 110 | - | func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string { | |||
| 111 | - | if len(secrets) == 0 { | |||
| 112 | - | return nil | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 116 | - | for _, key := range existingKeys { | |||
| 117 | - | existingSet[key] = struct{}{} | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | missing := make(map[string]string) | |||
| 121 | - | for key, value := range secrets { | |||
| 122 | - | if _, exists := existingSet[key]; exists { | |||
| 123 | - | continue | |||
| 124 | - | } | |||
| 125 | - | missing[key] = value | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | if len(missing) == 0 { | |||
| 129 | - | return nil | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | return missing | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | 123 | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | ||
| 136 | entries, err := readEnvDir(envDir) | 124 | entries, err := readEnvDir(envDir) | ||
| 137 | if err != nil { | 125 | if err != nil { | ||
| @@ -180,39 +168,43 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass | |||||
| 180 | return nil | 168 | return nil | ||
| 181 | } | 169 | } | ||
| 182 | 170 | ||||
| 183 | - | existingSecrets, err := passStore.GetAllAttachment(containerName) | 171 | + | writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets) |
| 184 | if err != nil { | 172 | if err != nil { | ||
| 185 | - | return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err) | 173 | + | if errors.Is(err, domain.ErrSecretsAlreadyExist) { |
| 186 | - | } | 174 | + | return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err) |
| 187 | - | ||||
| 188 | - | existingKeys := make([]string, 0, len(existingSecrets)) | |||
| 189 | - | for key := range existingSecrets { | |||
| 190 | - | existingKeys = append(existingKeys, key) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | missing := missingSecrets(existingKeys, secrets) | |||
| 194 | - | if len(missing) > 0 { | |||
| 195 | - | if err := passStore.SetAttachment(containerName, missing); err != nil { | |||
| 196 | - | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |||
| 197 | } | 175 | } | ||
| 198 | - | log.Info(). | 176 | + | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) |
| 199 | - | Int(zerowrap.FieldCount, len(missing)). | 177 | + | } |
| 200 | - | Str("container", containerName). | 178 | + | |
| 201 | - | Msg("migrated secrets for attachment container from plain text to pass") | 179 | + | if err := os.Remove(filePath); err != nil { |
| 202 | - | } else { | 180 | + | // Check if the stored secrets match what we just wrote |
| 203 | - | log.Info(). | 181 | + | storedSecrets, getErr := passStore.GetAllAttachment(containerName) |
| 204 | - | Str("container", containerName). | 182 | + | if getErr == nil && secretsMatch(storedSecrets, secrets) { |
| 205 | - | Msg("all attachment secrets already exist in pass; no migration needed") | 183 | + | // Migration was successful, removal failed but secrets are stored |
| 206 | - | } | 184 | + | log.Warn(). |
| 207 | - | 185 | + | Str("file", filePath). | |
| 208 | - | migratedPath := filePath + ".migrated" | 186 | + | Str("container", containerName). |
| 209 | - | if err := os.Rename(filePath, migratedPath); err != nil { | 187 | + | Msg("attachment secrets migrated successfully but failed to remove original file; treating as success") |
| 210 | - | return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err) | 188 | + | return nil |
| 189 | + | } | |||
| 190 | + | // Attempt rollback: remove only the pass entries this migration wrote. | |||
| 191 | + | for _, key := range writtenKeys { | |||
| 192 | + | if delErr := passStore.DeleteAttachment(containerName, key); delErr != nil { | |||
| 193 | + | log.Error(). | |||
| 194 | + | Err(delErr). | |||
| 195 | + | Str("container", containerName). | |||
| 196 | + | Str("key", key). | |||
| 197 | + | Msg("failed to rollback pass entry after file removal failure") | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err) | |||
| 211 | } | 201 | } | ||
| 212 | 202 | ||||
| 213 | log.Info(). | 203 | log.Info(). | ||
| 214 | - | Str("file", migratedPath). | 204 | + | Int(zerowrap.FieldCount, len(secrets)). |
| 215 | - | Msg("original attachment file renamed to .env.migrated - you can safely remove it") | 205 | + | Str("container", containerName). |
| 206 | + | Str("file", filePath). | |||
| 207 | + | Msg("migrated attachment secrets to pass and removed original env file") | |||
| 216 | 208 | ||||
| 217 | return nil | 209 | return nil | ||
| 218 | } | 210 | } | ||
| @@ -221,3 +213,16 @@ func extractContainerNameFromAttachmentFile(filename string) string { | |||||
| 221 | name := strings.TrimSuffix(filename, ".env") | 213 | name := strings.TrimSuffix(filename, ".env") | ||
| 222 | return name | 214 | return name | ||
| 223 | } | 215 | } | ||
| 216 | + | ||||
| 217 | + | func secretsMatch(a, b map[string]string) bool { | |||
| 218 | + | if len(a) != len(b) { | |||
| 219 | + | return false | |||
| 220 | + | } | |||
| 221 | + | for k, v := range a { | |||
| 222 | + | bv, ok := b[k] | |||
| 223 | + | if !ok || bv != v { | |||
| 224 | + | return false | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | return true | |||
| 228 | + | } | |||
M internal/app/migrate_env_test.go +4 -6
| @@ -71,12 +71,11 @@ func TestMigrateEnvFile(t *testing.T) { | |||||
| 71 | err = migrateEnvFile(tmpDir, envFileName, store, logger) | 71 | err = migrateEnvFile(tmpDir, envFileName, store, logger) | ||
| 72 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 73 | 73 | ||||
| 74 | - | migratedPath := envFilePath + ".migrated" | |||
| 75 | _, err = os.Stat(envFilePath) | 74 | _, err = os.Stat(envFilePath) | ||
| 76 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | 75 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | ||
| 77 | 76 | ||||
| 78 | - | _, err = os.Stat(migratedPath) | 77 | + | _, err = os.Stat(envFilePath + ".migrated") |
| 79 | - | assert.NoError(t, err, ".env.migrated file should exist") | 78 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") |
| 80 | 79 | ||||
| 81 | passKeys, err := store.ListKeys(domainName) | 80 | passKeys, err := store.ListKeys(domainName) | ||
| 82 | require.NoError(t, err) | 81 | require.NoError(t, err) | ||
| @@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) { | |||||
| 125 | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | 124 | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | ||
| 126 | require.NoError(t, err) | 125 | require.NoError(t, err) | ||
| 127 | 126 | ||||
| 128 | - | migratedPath := envFilePath + ".migrated" | |||
| 129 | _, err = os.Stat(envFilePath) | 127 | _, err = os.Stat(envFilePath) | ||
| 130 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | 128 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | ||
| 131 | 129 | ||||
| 132 | - | _, err = os.Stat(migratedPath) | 130 | + | _, err = os.Stat(envFilePath + ".migrated") |
| 133 | - | assert.NoError(t, err, ".env.migrated file should exist") | 131 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") |
| 134 | 132 | ||||
| 135 | values, err := store.GetAllAttachment(storedContainerName) | 133 | values, err := store.GetAllAttachment(storedContainerName) | ||
| 136 | require.NoError(t, err) | 134 | require.NoError(t, err) | ||
M internal/app/run.go +143 -27
| @@ -23,6 +23,7 @@ import ( | |||||
| 23 | "github.com/bnema/zerowrap" | 23 | "github.com/bnema/zerowrap" | ||
| 24 | zerowrapotel "github.com/bnema/zerowrap/otel" | 24 | zerowrapotel "github.com/bnema/zerowrap/otel" | ||
| 25 | "github.com/spf13/viper" | 25 | "github.com/spf13/viper" | ||
| 26 | + | "golang.org/x/sys/unix" | |||
| 26 | 27 | ||||
| 27 | // Adapters - Output | 28 | // Adapters - Output | ||
| 28 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | 29 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | ||
| @@ -96,6 +97,7 @@ type Config struct { | |||||
| 96 | DataDir string `mapstructure:"data_dir"` | 97 | DataDir string `mapstructure:"data_dir"` | ||
| 97 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 98 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | ||
| 98 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | 99 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | ||
| 100 | + | MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB" | |||
| 99 | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | 101 | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | ||
| 100 | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | 102 | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | ||
| 101 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | 103 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | ||
| @@ -170,7 +172,9 @@ type Config struct { | |||||
| 170 | } `mapstructure:"backups"` | 172 | } `mapstructure:"backups"` | ||
| 171 | 173 | ||||
| 172 | Images struct { | 174 | Images struct { | ||
| 173 | - | Prune struct { | 175 | + | AllowedRegistries []string `mapstructure:"allowed_registries"` |
| 176 | + | RequireDigest bool `mapstructure:"require_digest"` | |||
| 177 | + | Prune struct { | |||
| 174 | Enabled bool `mapstructure:"enabled"` | 178 | Enabled bool `mapstructure:"enabled"` | ||
| 175 | Schedule string `mapstructure:"schedule"` | 179 | Schedule string `mapstructure:"schedule"` | ||
| 176 | KeepLast int `mapstructure:"keep_last"` | 180 | KeepLast int `mapstructure:"keep_last"` | ||
| @@ -178,9 +182,10 @@ type Config struct { | |||||
| 178 | } `mapstructure:"images"` | 182 | } `mapstructure:"images"` | ||
| 179 | 183 | ||||
| 180 | Containers struct { | 184 | Containers struct { | ||
| 181 | - | MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB" | 185 | + | MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB" |
| 182 | - | CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core | 186 | + | CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core |
| 183 | - | PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512 | 187 | + | PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512 |
| 188 | + | SecurityProfile string `mapstructure:"security_profile"` // compat or strict | |||
| 184 | } `mapstructure:"containers"` | 189 | } `mapstructure:"containers"` | ||
| 185 | 190 | ||||
| 186 | Telemetry telemetry.Config `mapstructure:"telemetry"` | 191 | Telemetry telemetry.Config `mapstructure:"telemetry"` | ||
| @@ -215,9 +220,13 @@ type services struct { | |||||
| 215 | previewService *preview.Service | 220 | previewService *preview.Service | ||
| 216 | envDir string | 221 | envDir string | ||
| 217 | maxBlobChunkSize int64 | 222 | maxBlobChunkSize int64 | ||
| 223 | + | maxBlobSize int64 | |||
| 218 | caAdapter *pkiadapter.CA | 224 | caAdapter *pkiadapter.CA | ||
| 219 | pkiSvc *pkiusecase.Service | 225 | pkiSvc *pkiusecase.Service | ||
| 220 | reloadCoordinator *reloadCoordinator | 226 | reloadCoordinator *reloadCoordinator | ||
| 227 | + | registryHandler interface { | |||
| 228 | + | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |||
| 229 | + | } | |||
| 221 | } | 230 | } | ||
| 222 | 231 | ||||
| 223 | // Run initializes and starts the Gordon application. | 232 | // Run initializes and starts the Gordon application. | ||
| @@ -460,7 +469,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 460 | return nil, err | 469 | return nil, err | ||
| 461 | } | 470 | } | ||
| 462 | 471 | ||||
| 463 | - | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, si.svc.eventBus, log) | 472 | + | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log) |
| 464 | 473 | ||||
| 465 | si.initHandlers() | 474 | si.initHandlers() | ||
| 466 | 475 | ||||
| @@ -526,6 +535,7 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||||
| 526 | return err | 535 | return err | ||
| 527 | } | 536 | } | ||
| 528 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | 537 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | ||
| 538 | + | si.svc.maxBlobSize = proxyCfg.maxBlobSize | |||
| 529 | si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig) | 539 | si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig) | ||
| 530 | 540 | ||||
| 531 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | 541 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | ||
| @@ -1333,22 +1343,87 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||||
| 1333 | provider := secrets.NewSopsProvider(log) | 1343 | provider := secrets.NewSopsProvider(log) | ||
| 1334 | return provider.GetSecret(ctx, path) | 1344 | return provider.GetSecret(ctx, path) | ||
| 1335 | case domain.SecretsBackendUnsafe: | 1345 | case domain.SecretsBackendUnsafe: | ||
| 1336 | - | // For unsafe backend, path is relative to dataDir/secrets/ | 1346 | + | // For unsafe backend, path is relative to dataDir/secrets/. |
| 1337 | - | secretFile := filepath.Join(dataDir, "secrets", path) | 1347 | + | return readUnsafeSecret(dataDir, path) |
| 1338 | - | data, err := os.ReadFile(secretFile) | |||
| 1339 | - | if err != nil { | |||
| 1340 | - | return "", fmt.Errorf("failed to read secret file: %w", err) | |||
| 1341 | - | } | |||
| 1342 | - | return string(data), nil | |||
| 1343 | default: | 1348 | default: | ||
| 1344 | return "", fmt.Errorf("unknown secrets backend: %s", backend) | 1349 | return "", fmt.Errorf("unknown secrets backend: %s", backend) | ||
| 1345 | } | 1350 | } | ||
| 1346 | } | 1351 | } | ||
| 1347 | 1352 | ||||
| 1353 | + | func readFileBeneath(root, cleanedRelPath string) ([]byte, error) { | |||
| 1354 | + | rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1355 | + | if err != nil { | |||
| 1356 | + | return nil, fmt.Errorf("failed to open secrets root: %w", err) | |||
| 1357 | + | } | |||
| 1358 | + | defer unix.Close(rootFD) | |||
| 1359 | + | ||||
| 1360 | + | parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/") | |||
| 1361 | + | dirFD := rootFD | |||
| 1362 | + | var closeDirFDs []int | |||
| 1363 | + | defer func() { | |||
| 1364 | + | for i := len(closeDirFDs) - 1; i >= 0; i-- { | |||
| 1365 | + | _ = unix.Close(closeDirFDs[i]) | |||
| 1366 | + | } | |||
| 1367 | + | }() | |||
| 1368 | + | ||||
| 1369 | + | for i, part := range parts { | |||
| 1370 | + | if part == "" || part == "." || part == ".." { | |||
| 1371 | + | return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |||
| 1372 | + | } | |||
| 1373 | + | last := i == len(parts)-1 | |||
| 1374 | + | if last { | |||
| 1375 | + | fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1376 | + | if err != nil { | |||
| 1377 | + | return nil, fmt.Errorf("failed to read secret file: %w", err) | |||
| 1378 | + | } | |||
| 1379 | + | defer unix.Close(fd) | |||
| 1380 | + | var st unix.Stat_t | |||
| 1381 | + | if err := unix.Fstat(fd, &st); err != nil { | |||
| 1382 | + | return nil, fmt.Errorf("failed to stat secret file: %w", err) | |||
| 1383 | + | } | |||
| 1384 | + | if st.Mode&unix.S_IFMT != unix.S_IFREG { | |||
| 1385 | + | return nil, fmt.Errorf("invalid secret path: secret must be a regular file") | |||
| 1386 | + | } | |||
| 1387 | + | data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) | |||
| 1388 | + | if err != nil { | |||
| 1389 | + | return nil, fmt.Errorf("failed to read secret file: %w", err) | |||
| 1390 | + | } | |||
| 1391 | + | return data, nil | |||
| 1392 | + | } | |||
| 1393 | + | ||||
| 1394 | + | nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1395 | + | if err != nil { | |||
| 1396 | + | return nil, fmt.Errorf("failed to open secret path component: %w", err) | |||
| 1397 | + | } | |||
| 1398 | + | closeDirFDs = append(closeDirFDs, nextFD) | |||
| 1399 | + | dirFD = nextFD | |||
| 1400 | + | } | |||
| 1401 | + | ||||
| 1402 | + | return nil, fmt.Errorf("invalid secret path: empty path") | |||
| 1403 | + | } | |||
| 1404 | + | ||||
| 1405 | + | func readUnsafeSecret(dataDir, secretPath string) (string, error) { | |||
| 1406 | + | if filepath.IsAbs(secretPath) { | |||
| 1407 | + | return "", fmt.Errorf("invalid secret path: absolute paths are not allowed") | |||
| 1408 | + | } | |||
| 1409 | + | cleaned := filepath.Clean(secretPath) | |||
| 1410 | + | if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { | |||
| 1411 | + | return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |||
| 1412 | + | } | |||
| 1413 | + | ||||
| 1414 | + | root := filepath.Clean(filepath.Join(dataDir, "secrets")) | |||
| 1415 | + | data, err := readFileBeneath(root, cleaned) | |||
| 1416 | + | if err != nil { | |||
| 1417 | + | return "", err | |||
| 1418 | + | } | |||
| 1419 | + | return string(data), nil | |||
| 1420 | + | } | |||
| 1421 | + | ||||
| 1348 | // proxyConfigResult holds parsed proxy and blob chunk size config. | 1422 | // proxyConfigResult holds parsed proxy and blob chunk size config. | ||
| 1349 | type proxyConfigResult struct { | 1423 | type proxyConfigResult struct { | ||
| 1350 | proxyConfig proxy.Config | 1424 | proxyConfig proxy.Config | ||
| 1351 | maxBlobChunkSize int64 | 1425 | maxBlobChunkSize int64 | ||
| 1426 | + | maxBlobSize int64 | |||
| 1352 | } | 1427 | } | ||
| 1353 | 1428 | ||||
| 1354 | type configWatcher interface { | 1429 | type configWatcher interface { | ||
| @@ -1376,24 +1451,38 @@ type reloadCoordinator struct { | |||||
| 1376 | lastRun time.Time | 1451 | lastRun time.Time | ||
| 1377 | debounce time.Duration | 1452 | debounce time.Duration | ||
| 1378 | 1453 | ||||
| 1379 | - | configSvc configReloader | 1454 | + | configSvc configReloader |
| 1380 | - | v *viper.Viper | 1455 | + | v *viper.Viper |
| 1381 | - | proxySvc proxyConfigUpdater | 1456 | + | proxySvc proxyConfigUpdater |
| 1382 | - | eventBus out.EventPublisher | 1457 | + | registryLimits interface { |
| 1383 | - | log zerowrap.Logger | 1458 | + | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) |
| 1459 | + | } | |||
| 1460 | + | eventBus out.EventPublisher | |||
| 1461 | + | log zerowrap.Logger | |||
| 1384 | } | 1462 | } | ||
| 1385 | 1463 | ||||
| 1386 | - | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator { | 1464 | + | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface { |
| 1465 | + | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |||
| 1466 | + | }, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator { | |||
| 1387 | return &reloadCoordinator{ | 1467 | return &reloadCoordinator{ | ||
| 1388 | - | debounce: 500 * time.Millisecond, | 1468 | + | debounce: 500 * time.Millisecond, |
| 1389 | - | configSvc: configSvc, | 1469 | + | configSvc: configSvc, |
| 1390 | - | v: v, | 1470 | + | v: v, |
| 1391 | - | proxySvc: proxySvc, | 1471 | + | proxySvc: proxySvc, |
| 1392 | - | eventBus: eventBus, | 1472 | + | registryLimits: registryLimits, |
| 1393 | - | log: log, | 1473 | + | eventBus: eventBus, |
| 1474 | + | log: log, | |||
| 1394 | } | 1475 | } | ||
| 1395 | } | 1476 | } | ||
| 1396 | 1477 | ||||
| 1478 | + | func (c *reloadCoordinator) SetRegistryLimits(limits interface { | |||
| 1479 | + | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |||
| 1480 | + | }) { | |||
| 1481 | + | c.mu.Lock() | |||
| 1482 | + | defer c.mu.Unlock() | |||
| 1483 | + | c.registryLimits = limits | |||
| 1484 | + | } | |||
| 1485 | + | ||||
| 1397 | func (c *reloadCoordinator) Trigger(ctx context.Context) error { | 1486 | func (c *reloadCoordinator) Trigger(ctx context.Context) error { | ||
| 1398 | c.mu.Lock() | 1487 | c.mu.Lock() | ||
| 1399 | defer c.mu.Unlock() | 1488 | defer c.mu.Unlock() | ||
| @@ -1443,6 +1532,9 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error { | |||||
| 1443 | } | 1532 | } | ||
| 1444 | 1533 | ||||
| 1445 | c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | 1534 | c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | ||
| 1535 | + | if c.registryLimits != nil { | |||
| 1536 | + | c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize) | |||
| 1537 | + | } | |||
| 1446 | 1538 | ||||
| 1447 | if c.eventBus != nil { | 1539 | if c.eventBus != nil { | ||
| 1448 | if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | 1540 | if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | ||
| @@ -1477,6 +1569,15 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||||
| 1477 | maxBlobChunkSize = parsedSize | 1569 | maxBlobChunkSize = parsedSize | ||
| 1478 | } | 1570 | } | ||
| 1479 | 1571 | ||||
| 1572 | + | maxBlobSize := int64(registry.DefaultMaxBlobSize) | |||
| 1573 | + | if cfg.Server.MaxBlobSize != "" { | |||
| 1574 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize) | |||
| 1575 | + | if err != nil { | |||
| 1576 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize}) | |||
| 1577 | + | } | |||
| 1578 | + | maxBlobSize = parsedSize | |||
| 1579 | + | } | |||
| 1580 | + | ||||
| 1480 | maxProxyResponseSize := int64(1 << 30) // 1GB default | 1581 | maxProxyResponseSize := int64(1 << 30) // 1GB default | ||
| 1481 | if cfg.Server.MaxProxyResponseSize != "" { | 1582 | if cfg.Server.MaxProxyResponseSize != "" { | ||
| 1482 | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize) | 1583 | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize) | ||
| @@ -1501,6 +1602,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||||
| 1501 | MaxConcurrentConns: maxConcurrentConns, | 1602 | MaxConcurrentConns: maxConcurrentConns, | ||
| 1502 | }, | 1603 | }, | ||
| 1503 | maxBlobChunkSize: maxBlobChunkSize, | 1604 | maxBlobChunkSize: maxBlobChunkSize, | ||
| 1605 | + | maxBlobSize: maxBlobSize, | |||
| 1504 | }, nil | 1606 | }, nil | ||
| 1505 | } | 1607 | } | ||
| 1506 | 1608 | ||||
| @@ -1544,7 +1646,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1544 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | 1646 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | ||
| 1545 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | 1647 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | ||
| 1546 | NetworkGroups: attachmentConfig.NetworkGroups, | 1648 | NetworkGroups: attachmentConfig.NetworkGroups, | ||
| 1649 | + | NetworkInternal: v.GetBool("network_isolation.internal"), | |||
| 1547 | Attachments: attachmentConfig.Attachments, | 1650 | Attachments: attachmentConfig.Attachments, | ||
| 1651 | + | AllowedRegistries: cfg.Images.AllowedRegistries, | |||
| 1652 | + | RequireImageDigest: cfg.Images.RequireDigest, | |||
| 1653 | + | SecurityProfile: cfg.Containers.SecurityProfile, | |||
| 1548 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | 1654 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | ||
| 1549 | ReadinessMode: v.GetString("deploy.readiness_mode"), | 1655 | ReadinessMode: v.GetString("deploy.readiness_mode"), | ||
| 1550 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | 1656 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | ||
| @@ -1758,8 +1864,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 1758 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 1864 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 1759 | trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1865 | trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| 1760 | 1866 | ||||
| 1761 | - | // Registry handler (unchanged) | 1867 | + | // Registry handler |
| 1762 | - | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | 1868 | + | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize) |
| 1869 | + | svc.registryHandler = registryHandler | |||
| 1870 | + | if svc.reloadCoordinator != nil { | |||
| 1871 | + | svc.reloadCoordinator.SetRegistryLimits(registryHandler) | |||
| 1872 | + | } | |||
| 1763 | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | 1873 | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | ||
| 1764 | svc, | 1874 | svc, | ||
| 1765 | cfg, | 1875 | cfg, | ||
| @@ -1783,7 +1893,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 1783 | middleware.PanicRecovery(log), | 1893 | middleware.PanicRecovery(log), | ||
| 1784 | middleware.RequestLogger(log, trustedNets), | 1894 | middleware.RequestLogger(log, trustedNets), | ||
| 1785 | middleware.SecurityHeaders, | 1895 | middleware.SecurityHeaders, | ||
| 1786 | - | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | 1896 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool { |
| 1897 | + | return svc.proxySvc.IsKnownHost(context.Background(), host) | |||
| 1898 | + | }), | |||
| 1787 | } | 1899 | } | ||
| 1788 | if proxyCIDRMiddleware != nil { | 1900 | if proxyCIDRMiddleware != nil { | ||
| 1789 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | 1901 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | ||
| @@ -2889,6 +3001,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2889 | v.SetDefault("auto_route.enabled", false) | 3001 | v.SetDefault("auto_route.enabled", false) | ||
| 2890 | v.SetDefault("network_isolation.enabled", true) | 3002 | v.SetDefault("network_isolation.enabled", true) | ||
| 2891 | v.SetDefault("network_isolation.network_prefix", "gordon") | 3003 | v.SetDefault("network_isolation.network_prefix", "gordon") | ||
| 3004 | + | v.SetDefault("network_isolation.internal", false) | |||
| 2892 | v.SetDefault("volumes.auto_create", true) | 3005 | v.SetDefault("volumes.auto_create", true) | ||
| 2893 | v.SetDefault("volumes.prefix", "gordon") | 3006 | v.SetDefault("volumes.prefix", "gordon") | ||
| 2894 | v.SetDefault("volumes.preserve", true) | 3007 | v.SetDefault("volumes.preserve", true) | ||
| @@ -2900,9 +3013,12 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2900 | v.SetDefault("backups.retention.daily", 0) | 3013 | v.SetDefault("backups.retention.daily", 0) | ||
| 2901 | v.SetDefault("backups.retention.weekly", 0) | 3014 | v.SetDefault("backups.retention.weekly", 0) | ||
| 2902 | v.SetDefault("backups.retention.monthly", 0) | 3015 | v.SetDefault("backups.retention.monthly", 0) | ||
| 3016 | + | v.SetDefault("images.allowed_registries", []string{}) | |||
| 3017 | + | v.SetDefault("images.require_digest", false) | |||
| 2903 | v.SetDefault("images.prune.enabled", false) | 3018 | v.SetDefault("images.prune.enabled", false) | ||
| 2904 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | 3019 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | ||
| 2905 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | 3020 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | ||
| 3021 | + | v.SetDefault("containers.security_profile", "compat") | |||
| 2906 | v.SetDefault("telemetry.enabled", false) | 3022 | v.SetDefault("telemetry.enabled", false) | ||
| 2907 | v.SetDefault("telemetry.endpoint", "") | 3023 | v.SetDefault("telemetry.endpoint", "") | ||
| 2908 | v.SetDefault("telemetry.auth_token", "") | 3024 | v.SetDefault("telemetry.auth_token", "") | ||
M internal/app/run_auth_config_test.go +85 -1
| @@ -2,13 +2,18 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "runtime" | |||
| 5 | "testing" | 8 | "testing" | ||
| 6 | 9 | ||||
| 7 | - | "github.com/bnema/gordon/internal/domain" | |||
| 8 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 9 | "github.com/spf13/viper" | 11 | "github.com/spf13/viper" | ||
| 10 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | ) | 17 | ) | ||
| 13 | 18 | ||||
| 14 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | 19 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | ||
| @@ -65,3 +70,82 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |||||
| 65 | require.NoError(t, err) | 70 | require.NoError(t, err) | ||
| 66 | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | 71 | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | ||
| 67 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | func TestLoadSecretUnsafeRejectsTraversalAndAbsolutePaths(t *testing.T) { | |||
| 75 | + | dataDir := t.TempDir() | |||
| 76 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 77 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 78 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |||
| 79 | + | outside := filepath.Join(dataDir, "outside") | |||
| 80 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |||
| 81 | + | ||||
| 82 | + | for _, path := range []string{"../outside", filepath.Join(dataDir, "outside")} { | |||
| 83 | + | t.Run(path, func(t *testing.T) { | |||
| 84 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, path, dataDir, zerowrap.Default()) | |||
| 85 | + | require.Error(t, err) | |||
| 86 | + | assert.Contains(t, err.Error(), "invalid secret path") | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestLoadSecretUnsafeAllowsRelativePathUnderSecretsDir(t *testing.T) { | |||
| 92 | + | dataDir := t.TempDir() | |||
| 93 | + | secretsDir := filepath.Join(dataDir, "secrets", "auth") | |||
| 94 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 95 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |||
| 96 | + | ||||
| 97 | + | secret, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "auth/token", dataDir, zerowrap.Default()) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | assert.Equal(t, "safe-secret", secret) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestLoadSecretUnsafeRejectsMissingCandidate(t *testing.T) { | |||
| 103 | + | dataDir := t.TempDir() | |||
| 104 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 105 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 106 | + | ||||
| 107 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "missing", dataDir, zerowrap.Default()) | |||
| 108 | + | require.Error(t, err) | |||
| 109 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestLoadSecretUnsafeRejectsIntermediateSymlinkEscape(t *testing.T) { | |||
| 113 | + | dataDir := t.TempDir() | |||
| 114 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 115 | + | outsideDir := filepath.Join(dataDir, "outside") | |||
| 116 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 117 | + | require.NoError(t, os.MkdirAll(outsideDir, 0700)) | |||
| 118 | + | require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "token"), []byte("outside-secret"), 0600)) | |||
| 119 | + | require.NoError(t, os.Symlink(outsideDir, filepath.Join(secretsDir, "linkdir"))) | |||
| 120 | + | ||||
| 121 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "linkdir/token", dataDir, zerowrap.Default()) | |||
| 122 | + | require.Error(t, err) | |||
| 123 | + | assert.Contains(t, err.Error(), "failed to open secret path component") | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestLoadSecretUnsafeRejectsNonRegularFile(t *testing.T) { | |||
| 127 | + | if runtime.GOOS == "windows" { | |||
| 128 | + | t.Skip("unix mkfifo is not available on windows") | |||
| 129 | + | } | |||
| 130 | + | dataDir := t.TempDir() | |||
| 131 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 132 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 133 | + | require.NoError(t, unix.Mkfifo(filepath.Join(secretsDir, "token"), 0600)) | |||
| 134 | + | ||||
| 135 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default()) | |||
| 136 | + | require.Error(t, err) | |||
| 137 | + | assert.Contains(t, err.Error(), "secret must be a regular file") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestLoadSecretUnsafeRejectsSymlinkEscape(t *testing.T) { | |||
| 141 | + | dataDir := t.TempDir() | |||
| 142 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 143 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 144 | + | outside := filepath.Join(dataDir, "outside") | |||
| 145 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |||
| 146 | + | require.NoError(t, os.Symlink(outside, filepath.Join(secretsDir, "token"))) | |||
| 147 | + | ||||
| 148 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default()) | |||
| 149 | + | require.Error(t, err) | |||
| 150 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |||
| 151 | + | } | |||
M internal/app/run_reload_test.go +46 -5
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |||
| 15 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | "github.com/bnema/gordon/internal/usecase/proxy" | 17 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 17 | ) | 18 | ) | ||
| @@ -99,6 +100,18 @@ func (p *proxyRecorder) UpdateConfig(config proxy.Config) { | |||||
| 99 | p.config = config | 100 | p.config = config | ||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
| 103 | + | type registryLimitsRecorder struct { | |||
| 104 | + | calls int | |||
| 105 | + | maxBlobChunkSize int64 | |||
| 106 | + | maxBlobSize int64 | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) { | |||
| 110 | + | r.calls++ | |||
| 111 | + | r.maxBlobChunkSize = maxBlobChunkSize | |||
| 112 | + | r.maxBlobSize = maxBlobSize | |||
| 113 | + | } | |||
| 114 | + | ||||
| 102 | type eventBusRecorder struct { | 115 | type eventBusRecorder struct { | ||
| 103 | mu sync.Mutex | 116 | mu sync.Mutex | ||
| 104 | calls int | 117 | calls int | ||
| @@ -138,6 +151,24 @@ func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) { | |||||
| 138 | require.Equal(t, 0, coordinator.TriggerCalls()) | 151 | require.Equal(t, 0, coordinator.TriggerCalls()) | ||
| 139 | } | 152 | } | ||
| 140 | 153 | ||||
| 154 | + | func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) { | |||
| 155 | + | cfg := Config{} | |||
| 156 | + | cfg.Server.MaxBlobSize = "2GB" | |||
| 157 | + | ||||
| 158 | + | result, err := buildProxyConfig(cfg, zerowrap.Default()) | |||
| 159 | + | ||||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | assert.Equal(t, int64(2<<30), result.maxBlobSize) | |||
| 162 | + | assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) { | |||
| 166 | + | result, err := buildProxyConfig(Config{}, zerowrap.Default()) | |||
| 167 | + | ||||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 141 | func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) { | 172 | func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) { | ||
| 142 | ctx := context.Background() | 173 | ctx := context.Background() | ||
| 143 | v := viper.New() | 174 | v := viper.New() | ||
| @@ -145,6 +176,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||||
| 145 | v.Set("server.registry_port", 5000) | 176 | v.Set("server.registry_port", 5000) | ||
| 146 | v.Set("server.max_proxy_body_size", "5MB") | 177 | v.Set("server.max_proxy_body_size", "5MB") | ||
| 147 | v.Set("server.max_blob_chunk_size", "6MB") | 178 | v.Set("server.max_blob_chunk_size", "6MB") | ||
| 179 | + | v.Set("server.max_blob_size", "8MB") | |||
| 148 | v.Set("server.max_proxy_response_size", "7MB") | 180 | v.Set("server.max_proxy_response_size", "7MB") | ||
| 149 | v.Set("server.max_concurrent_connections", 99) | 181 | v.Set("server.max_concurrent_connections", 99) | ||
| 150 | 182 | ||||
| @@ -152,12 +184,16 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||||
| 152 | proxySvc := &proxyRecorder{} | 184 | proxySvc := &proxyRecorder{} | ||
| 153 | events := &eventBusRecorder{} | 185 | events := &eventBusRecorder{} | ||
| 154 | 186 | ||||
| 155 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default()) | 187 | + | registryLimits := ®istryLimitsRecorder{} |
| 188 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | |||
| 156 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | ||
| 157 | 190 | ||||
| 158 | require.Equal(t, 0, reloadSvc.Calls()) | 191 | require.Equal(t, 0, reloadSvc.Calls()) | ||
| 159 | require.Equal(t, 1, proxySvc.calls) | 192 | require.Equal(t, 1, proxySvc.calls) | ||
| 160 | require.Equal(t, 1, events.Calls()) | 193 | require.Equal(t, 1, events.Calls()) | ||
| 194 | + | require.Equal(t, 1, registryLimits.calls) | |||
| 195 | + | require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) | |||
| 196 | + | require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) | |||
| 161 | require.Equal(t, domain.EventConfigReload, events.eventType) | 197 | require.Equal(t, domain.EventConfigReload, events.eventType) | ||
| 162 | require.Equal(t, proxy.Config{ | 198 | require.Equal(t, proxy.Config{ | ||
| 163 | RegistryDomain: "reload.example.com", | 199 | RegistryDomain: "reload.example.com", | ||
| @@ -175,6 +211,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |||||
| 175 | v.Set("server.registry_port", 5000) | 211 | v.Set("server.registry_port", 5000) | ||
| 176 | v.Set("server.max_proxy_body_size", "5MB") | 212 | v.Set("server.max_proxy_body_size", "5MB") | ||
| 177 | v.Set("server.max_blob_chunk_size", "6MB") | 213 | v.Set("server.max_blob_chunk_size", "6MB") | ||
| 214 | + | v.Set("server.max_blob_size", "8MB") | |||
| 178 | v.Set("server.max_proxy_response_size", "7MB") | 215 | v.Set("server.max_proxy_response_size", "7MB") | ||
| 179 | v.Set("server.max_concurrent_connections", 99) | 216 | v.Set("server.max_concurrent_connections", 99) | ||
| 180 | 217 | ||||
| @@ -182,13 +219,17 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |||||
| 182 | proxySvc := &proxyRecorder{} | 219 | proxySvc := &proxyRecorder{} | ||
| 183 | events := &eventBusRecorder{} | 220 | events := &eventBusRecorder{} | ||
| 184 | 221 | ||||
| 185 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default()) | 222 | + | registryLimits := ®istryLimitsRecorder{} |
| 223 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | |||
| 186 | require.NoError(t, coord.Trigger(ctx)) | 224 | require.NoError(t, coord.Trigger(ctx)) | ||
| 187 | require.NoError(t, coord.Trigger(ctx)) | 225 | require.NoError(t, coord.Trigger(ctx)) | ||
| 188 | 226 | ||||
| 189 | require.Equal(t, 1, reloadSvc.Calls()) | 227 | require.Equal(t, 1, reloadSvc.Calls()) | ||
| 190 | require.Equal(t, 1, proxySvc.calls) | 228 | require.Equal(t, 1, proxySvc.calls) | ||
| 191 | require.Equal(t, 1, events.Calls()) | 229 | require.Equal(t, 1, events.Calls()) | ||
| 230 | + | require.Equal(t, 1, registryLimits.calls) | |||
| 231 | + | require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) | |||
| 232 | + | require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) | |||
| 192 | require.Equal(t, domain.EventConfigReload, events.eventType) | 233 | require.Equal(t, domain.EventConfigReload, events.eventType) | ||
| 193 | require.Equal(t, proxy.Config{ | 234 | require.Equal(t, proxy.Config{ | ||
| 194 | RegistryDomain: "new.example.com", | 235 | RegistryDomain: "new.example.com", | ||
| @@ -212,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { | |||||
| 212 | reloadErr := errors.New("reload failed") | 253 | reloadErr := errors.New("reload failed") | ||
| 213 | reloadSvc := &reloadRecorder{err: reloadErr} | 254 | reloadSvc := &reloadRecorder{err: reloadErr} | ||
| 214 | proxySvc := &proxyRecorder{} | 255 | proxySvc := &proxyRecorder{} | ||
| 215 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default()) | 256 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) |
| 216 | 257 | ||||
| 217 | err := coord.Trigger(ctx) | 258 | err := coord.Trigger(ctx) | ||
| 218 | require.ErrorIs(t, err, reloadErr) | 259 | require.ErrorIs(t, err, reloadErr) | ||
| @@ -241,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||||
| 241 | proxySvc := &proxyRecorder{} | 282 | proxySvc := &proxyRecorder{} | ||
| 242 | events := &eventBusRecorder{err: publishErr} | 283 | events := &eventBusRecorder{err: publishErr} | ||
| 243 | 284 | ||||
| 244 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default()) | 285 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default()) |
| 245 | 286 | ||||
| 246 | err := coord.Trigger(ctx) | 287 | err := coord.Trigger(ctx) | ||
| 247 | require.ErrorIs(t, err, publishErr) | 288 | require.ErrorIs(t, err, publishErr) | ||
| @@ -273,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { | |||||
| 273 | <-release | 314 | <-release | ||
| 274 | }} | 315 | }} | ||
| 275 | proxySvc := &proxyRecorder{} | 316 | proxySvc := &proxyRecorder{} | ||
| 276 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default()) | 317 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) |
| 277 | 318 | ||||
| 278 | firstDone := make(chan struct{}) | 319 | firstDone := make(chan struct{}) | ||
| 279 | go func() { | 320 | go func() { | ||
M internal/boundaries/in/mocks/mock_proxy_service.go +57 -0
| @@ -107,6 +107,63 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con | |||||
| 107 | return _c | 107 | return _c | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | + | // IsKnownHost provides a mock function for the type MockProxyService | |||
| 111 | + | func (_mock *MockProxyService) IsKnownHost(ctx context.Context, host string) bool { | |||
| 112 | + | ret := _mock.Called(ctx, host) | |||
| 113 | + | ||||
| 114 | + | if len(ret) == 0 { | |||
| 115 | + | panic("no return value specified for IsKnownHost") | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | var r0 bool | |||
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |||
| 120 | + | r0 = returnFunc(ctx, host) | |||
| 121 | + | } else { | |||
| 122 | + | r0 = ret.Get(0).(bool) | |||
| 123 | + | } | |||
| 124 | + | return r0 | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // MockProxyService_IsKnownHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsKnownHost' | |||
| 128 | + | type MockProxyService_IsKnownHost_Call struct { | |||
| 129 | + | *mock.Call | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // IsKnownHost is a helper method to define mock.On call | |||
| 133 | + | // - ctx context.Context | |||
| 134 | + | // - host string | |||
| 135 | + | func (_e *MockProxyService_Expecter) IsKnownHost(ctx interface{}, host interface{}) *MockProxyService_IsKnownHost_Call { | |||
| 136 | + | return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (_c *MockProxyService_IsKnownHost_Call) Run(run func(ctx context.Context, host string)) *MockProxyService_IsKnownHost_Call { | |||
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 141 | + | var arg0 context.Context | |||
| 142 | + | if args[0] != nil { | |||
| 143 | + | arg0 = args[0].(context.Context) | |||
| 144 | + | } | |||
| 145 | + | var arg1 string | |||
| 146 | + | if args[1] != nil { | |||
| 147 | + | arg1 = args[1].(string) | |||
| 148 | + | } | |||
| 149 | + | run( | |||
| 150 | + | arg0, | |||
| 151 | + | arg1, | |||
| 152 | + | ) | |||
| 153 | + | }) | |||
| 154 | + | return _c | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (_c *MockProxyService_IsKnownHost_Call) Return(b bool) *MockProxyService_IsKnownHost_Call { | |||
| 158 | + | _c.Call.Return(b) | |||
| 159 | + | return _c | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (_c *MockProxyService_IsKnownHost_Call) RunAndReturn(run func(ctx context.Context, host string) bool) *MockProxyService_IsKnownHost_Call { | |||
| 163 | + | _c.Call.Return(run) | |||
| 164 | + | return _c | |||
| 165 | + | } | |||
| 166 | + | ||||
| 110 | // IsRegistryDomain provides a mock function for the type MockProxyService | 167 | // IsRegistryDomain provides a mock function for the type MockProxyService | ||
| 111 | func (_mock *MockProxyService) IsRegistryDomain(host string) bool { | 168 | func (_mock *MockProxyService) IsRegistryDomain(host string) bool { | ||
| 112 | ret := _mock.Called(host) | 169 | ret := _mock.Called(host) | ||
M internal/boundaries/in/mocks/mock_registry_service.go +24 -12
| @@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter { | |||||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | // AppendBlobChunk provides a mock function for the type MockRegistryService | 42 | // AppendBlobChunk provides a mock function for the type MockRegistryService | ||
| 43 | - | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) { | 43 | + | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) { |
| 44 | - | ret := _mock.Called(ctx, name, uuid, data) | 44 | + | ret := _mock.Called(ctx, name, uuid, data, contentLength, maxBlobSize) |
| 45 | 45 | ||||
| 46 | if len(ret) == 0 { | 46 | if len(ret) == 0 { | ||
| 47 | panic("no return value specified for AppendBlobChunk") | 47 | panic("no return value specified for AppendBlobChunk") | ||
| @@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri | |||||
| 49 | 49 | ||||
| 50 | var r0 int64 | 50 | var r0 int64 | ||
| 51 | var r1 error | 51 | var r1 error | ||
| 52 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok { | 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) (int64, error)); ok { |
| 53 | - | return returnFunc(ctx, name, uuid, data) | 53 | + | return returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize) |
| 54 | } | 54 | } | ||
| 55 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok { | 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) int64); ok { |
| 56 | - | r0 = returnFunc(ctx, name, uuid, data) | 56 | + | r0 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize) |
| 57 | } else { | 57 | } else { | ||
| 58 | r0 = ret.Get(0).(int64) | 58 | r0 = ret.Get(0).(int64) | ||
| 59 | } | 59 | } | ||
| 60 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok { | 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader, int64, int64) error); ok { |
| 61 | - | r1 = returnFunc(ctx, name, uuid, data) | 61 | + | r1 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize) |
| 62 | } else { | 62 | } else { | ||
| 63 | r1 = ret.Error(1) | 63 | r1 = ret.Error(1) | ||
| 64 | } | 64 | } | ||
| @@ -75,11 +75,13 @@ type MockRegistryService_AppendBlobChunk_Call struct { | |||||
| 75 | // - name string | 75 | // - name string | ||
| 76 | // - uuid string | 76 | // - uuid string | ||
| 77 | // - data io.Reader | 77 | // - data io.Reader | ||
| 78 | - | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call { | 78 | + | // - contentLength int64 |
| 79 | - | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)} | 79 | + | // - maxBlobSize int64 |
| 80 | + | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockRegistryService_AppendBlobChunk_Call { | |||
| 81 | + | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)} | |||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 82 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call { | 84 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockRegistryService_AppendBlobChunk_Call { |
| 83 | _c.Call.Run(func(args mock.Arguments) { | 85 | _c.Call.Run(func(args mock.Arguments) { | ||
| 84 | var arg0 context.Context | 86 | var arg0 context.Context | ||
| 85 | if args[0] != nil { | 87 | if args[0] != nil { | ||
| @@ -97,11 +99,21 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con | |||||
| 97 | if args[3] != nil { | 99 | if args[3] != nil { | ||
| 98 | arg3 = args[3].(io.Reader) | 100 | arg3 = args[3].(io.Reader) | ||
| 99 | } | 101 | } | ||
| 102 | + | var arg4 int64 | |||
| 103 | + | if args[4] != nil { | |||
| 104 | + | arg4 = args[4].(int64) | |||
| 105 | + | } | |||
| 106 | + | var arg5 int64 | |||
| 107 | + | if args[5] != nil { | |||
| 108 | + | arg5 = args[5].(int64) | |||
| 109 | + | } | |||
| 100 | run( | 110 | run( | ||
| 101 | arg0, | 111 | arg0, | ||
| 102 | arg1, | 112 | arg1, | ||
| 103 | arg2, | 113 | arg2, | ||
| 104 | arg3, | 114 | arg3, | ||
| 115 | + | arg4, | |||
| 116 | + | arg5, | |||
| 105 | ) | 117 | ) | ||
| 106 | }) | 118 | }) | ||
| 107 | return _c | 119 | return _c | ||
| @@ -112,7 +124,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) * | |||||
| 112 | return _c | 124 | return _c | ||
| 113 | } | 125 | } | ||
| 114 | 126 | ||||
| 115 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { | 127 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { |
| 116 | _c.Call.Return(run) | 128 | _c.Call.Return(run) | ||
| 117 | return _c | 129 | return _c | ||
| 118 | } | 130 | } | ||
M internal/boundaries/in/proxy.go +3 -0
| @@ -24,6 +24,9 @@ type ProxyService interface { | |||||
| 24 | // IsRegistryDomain returns true if the host matches the configured registry domain. | 24 | // IsRegistryDomain returns true if the host matches the configured registry domain. | ||
| 25 | IsRegistryDomain(host string) bool | 25 | IsRegistryDomain(host string) bool | ||
| 26 | 26 | ||||
| 27 | + | // IsKnownHost returns true if the host is configured as registry, route, or external route. | |||
| 28 | + | IsKnownHost(ctx context.Context, host string) bool | |||
| 29 | + | ||||
| 27 | // TrackInFlight records an in-flight request for a container. | 30 | // TrackInFlight records an in-flight request for a container. | ||
| 28 | // Returns a release function that must be called when the request completes. | 31 | // Returns a release function that must be called when the request completes. | ||
| 29 | TrackInFlight(containerID string) func() | 32 | TrackInFlight(containerID string) func() | ||
M internal/boundaries/in/registry.go +1 -1
| @@ -22,7 +22,7 @@ type RegistryService interface { | |||||
| 22 | 22 | ||||
| 23 | // Upload operations | 23 | // Upload operations | ||
| 24 | StartUpload(ctx context.Context, name string) (string, error) | 24 | StartUpload(ctx context.Context, name string) (string, error) | ||
| 25 | - | AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) | 25 | + | AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) |
| 26 | FinishUpload(ctx context.Context, uuid, digest string) error | 26 | FinishUpload(ctx context.Context, uuid, digest string) error | ||
| 27 | CancelUpload(ctx context.Context, uuid string) error | 27 | CancelUpload(ctx context.Context, uuid string) error | ||
| 28 | 28 | ||||
M internal/boundaries/out/mocks/mock_blob_storage.go +24 -12
| @@ -39,8 +39,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // AppendBlobChunk provides a mock function for the type MockBlobStorage | 41 | // AppendBlobChunk provides a mock function for the type MockBlobStorage | ||
| 42 | - | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) { | 42 | + | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) { |
| 43 | - | ret := _mock.Called(name, uuid, data) | 43 | + | ret := _mock.Called(name, uuid, data, contentLength, maxBlobSize) |
| 44 | 44 | ||||
| 45 | if len(ret) == 0 { | 45 | if len(ret) == 0 { | ||
| 46 | panic("no return value specified for AppendBlobChunk") | 46 | panic("no return value specified for AppendBlobChunk") | ||
| @@ -48,16 +48,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io. | |||||
| 48 | 48 | ||||
| 49 | var r0 int64 | 49 | var r0 int64 | ||
| 50 | var r1 error | 50 | var r1 error | ||
| 51 | - | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok { | 51 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) (int64, error)); ok { |
| 52 | - | return returnFunc(name, uuid, data) | 52 | + | return returnFunc(name, uuid, data, contentLength, maxBlobSize) |
| 53 | } | 53 | } | ||
| 54 | - | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok { | 54 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) int64); ok { |
| 55 | - | r0 = returnFunc(name, uuid, data) | 55 | + | r0 = returnFunc(name, uuid, data, contentLength, maxBlobSize) |
| 56 | } else { | 56 | } else { | ||
| 57 | r0 = ret.Get(0).(int64) | 57 | r0 = ret.Get(0).(int64) | ||
| 58 | } | 58 | } | ||
| 59 | - | if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok { | 59 | + | if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader, int64, int64) error); ok { |
| 60 | - | r1 = returnFunc(name, uuid, data) | 60 | + | r1 = returnFunc(name, uuid, data, contentLength, maxBlobSize) |
| 61 | } else { | 61 | } else { | ||
| 62 | r1 = ret.Error(1) | 62 | r1 = ret.Error(1) | ||
| 63 | } | 63 | } | ||
| @@ -73,11 +73,13 @@ type MockBlobStorage_AppendBlobChunk_Call struct { | |||||
| 73 | // - name string | 73 | // - name string | ||
| 74 | // - uuid string | 74 | // - uuid string | ||
| 75 | // - data io.Reader | 75 | // - data io.Reader | ||
| 76 | - | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call { | 76 | + | // - contentLength int64 |
| 77 | - | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)} | 77 | + | // - maxBlobSize int64 |
| 78 | + | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockBlobStorage_AppendBlobChunk_Call { | |||
| 79 | + | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)} | |||
| 78 | } | 80 | } | ||
| 79 | 81 | ||||
| 80 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call { | 82 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockBlobStorage_AppendBlobChunk_Call { |
| 81 | _c.Call.Run(func(args mock.Arguments) { | 83 | _c.Call.Run(func(args mock.Arguments) { | ||
| 82 | var arg0 string | 84 | var arg0 string | ||
| 83 | if args[0] != nil { | 85 | if args[0] != nil { | ||
| @@ -91,10 +93,20 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s | |||||
| 91 | if args[2] != nil { | 93 | if args[2] != nil { | ||
| 92 | arg2 = args[2].(io.Reader) | 94 | arg2 = args[2].(io.Reader) | ||
| 93 | } | 95 | } | ||
| 96 | + | var arg3 int64 | |||
| 97 | + | if args[3] != nil { | |||
| 98 | + | arg3 = args[3].(int64) | |||
| 99 | + | } | |||
| 100 | + | var arg4 int64 | |||
| 101 | + | if args[4] != nil { | |||
| 102 | + | arg4 = args[4].(int64) | |||
| 103 | + | } | |||
| 94 | run( | 104 | run( | ||
| 95 | arg0, | 105 | arg0, | ||
| 96 | arg1, | 106 | arg1, | ||
| 97 | arg2, | 107 | arg2, | ||
| 108 | + | arg3, | |||
| 109 | + | arg4, | |||
| 98 | ) | 110 | ) | ||
| 99 | }) | 111 | }) | ||
| 100 | return _c | 112 | return _c | ||
| @@ -105,7 +117,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock | |||||
| 105 | return _c | 117 | return _c | ||
| 106 | } | 118 | } | ||
| 107 | 119 | ||||
| 108 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { | 120 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { |
| 109 | _c.Call.Return(run) | 121 | _c.Call.Return(run) | ||
| 110 | return _c | 122 | return _c | ||
| 111 | } | 123 | } | ||
M internal/boundaries/out/mocks/mock_container_runtime.go +11 -11
| @@ -246,16 +246,16 @@ func (_c *MockContainerRuntime_CreateContainer_Call) RunAndReturn(run func(ctx c | |||||
| 246 | } | 246 | } | ||
| 247 | 247 | ||||
| 248 | // CreateNetwork provides a mock function for the type MockContainerRuntime | 248 | // CreateNetwork provides a mock function for the type MockContainerRuntime | ||
| 249 | - | func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error { | 249 | + | func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error { |
| 250 | - | ret := _mock.Called(ctx, name, options) | 250 | + | ret := _mock.Called(ctx, name, config) |
| 251 | 251 | ||||
| 252 | if len(ret) == 0 { | 252 | if len(ret) == 0 { | ||
| 253 | panic("no return value specified for CreateNetwork") | 253 | panic("no return value specified for CreateNetwork") | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | var r0 error | 256 | var r0 error | ||
| 257 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | 257 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.NetworkConfig) error); ok { |
| 258 | - | r0 = returnFunc(ctx, name, options) | 258 | + | r0 = returnFunc(ctx, name, config) |
| 259 | } else { | 259 | } else { | ||
| 260 | r0 = ret.Error(0) | 260 | r0 = ret.Error(0) | ||
| 261 | } | 261 | } | ||
| @@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct { | |||||
| 270 | // CreateNetwork is a helper method to define mock.On call | 270 | // CreateNetwork is a helper method to define mock.On call | ||
| 271 | // - ctx context.Context | 271 | // - ctx context.Context | ||
| 272 | // - name string | 272 | // - name string | ||
| 273 | - | // - options map[string]string | 273 | + | // - config domain.NetworkConfig |
| 274 | - | func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, options interface{}) *MockContainerRuntime_CreateNetwork_Call { | 274 | + | func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, config interface{}) *MockContainerRuntime_CreateNetwork_Call { |
| 275 | - | return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, options)} | 275 | + | return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)} |
| 276 | } | 276 | } | ||
| 277 | 277 | ||||
| 278 | - | func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, options map[string]string)) *MockContainerRuntime_CreateNetwork_Call { | 278 | + | func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, config domain.NetworkConfig)) *MockContainerRuntime_CreateNetwork_Call { |
| 279 | _c.Call.Run(func(args mock.Arguments) { | 279 | _c.Call.Run(func(args mock.Arguments) { | ||
| 280 | var arg0 context.Context | 280 | var arg0 context.Context | ||
| 281 | if args[0] != nil { | 281 | if args[0] != nil { | ||
| @@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont | |||||
| 285 | if args[1] != nil { | 285 | if args[1] != nil { | ||
| 286 | arg1 = args[1].(string) | 286 | arg1 = args[1].(string) | ||
| 287 | } | 287 | } | ||
| 288 | - | var arg2 map[string]string | 288 | + | var arg2 domain.NetworkConfig |
| 289 | if args[2] != nil { | 289 | if args[2] != nil { | ||
| 290 | - | arg2 = args[2].(map[string]string) | 290 | + | arg2 = args[2].(domain.NetworkConfig) |
| 291 | } | 291 | } | ||
| 292 | run( | 292 | run( | ||
| 293 | arg0, | 293 | arg0, | ||
| @@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai | |||||
| 303 | return _c | 303 | return _c | ||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | - | func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, options map[string]string) error) *MockContainerRuntime_CreateNetwork_Call { | 306 | + | func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, config domain.NetworkConfig) error) *MockContainerRuntime_CreateNetwork_Call { |
| 307 | _c.Call.Return(run) | 307 | _c.Call.Return(run) | ||
| 308 | return _c | 308 | return _c | ||
| 309 | } | 309 | } | ||
M internal/boundaries/out/runtime.go +1 -1
| @@ -70,7 +70,7 @@ type ContainerRuntime interface { | |||||
| 70 | CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) | 70 | CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) | ||
| 71 | 71 | ||||
| 72 | // Network management | 72 | // 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 |
| 74 | RemoveNetwork(ctx context.Context, name string) error | 74 | RemoveNetwork(ctx context.Context, name string) error | ||
| 75 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | 75 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | ||
| 76 | NetworkExists(ctx context.Context, name string) (bool, error) | 76 | NetworkExists(ctx context.Context, name string) (bool, error) | ||
M internal/boundaries/out/storage.go +1 -1
| @@ -30,7 +30,7 @@ type BlobStorage interface { | |||||
| 30 | StartBlobUpload(name string) (string, error) | 30 | StartBlobUpload(name string) (string, error) | ||
| 31 | 31 | ||||
| 32 | // AppendBlobChunk appends data to an in-progress upload. | 32 | // AppendBlobChunk appends data to an in-progress upload. | ||
| 33 | - | AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) | 33 | + | AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) |
| 34 | 34 | ||||
| 35 | // GetBlobUpload returns a writer for the upload. | 35 | // GetBlobUpload returns a writer for the upload. | ||
| 36 | GetBlobUpload(uuid string) (io.WriteCloser, error) | 36 | GetBlobUpload(uuid string) (io.WriteCloser, error) | ||
M internal/domain/auth.go +13 -1
| @@ -79,6 +79,8 @@ const ( | |||||
| 79 | AdminResourceSecrets = "secrets" | 79 | AdminResourceSecrets = "secrets" | ||
| 80 | AdminResourceConfig = "config" | 80 | AdminResourceConfig = "config" | ||
| 81 | AdminResourceStatus = "status" | 81 | AdminResourceStatus = "status" | ||
| 82 | + | AdminResourceLogs = "logs" | |||
| 83 | + | AdminResourceVolumes = "volumes" | |||
| 82 | AdminResourceAll = "*" | 84 | AdminResourceAll = "*" | ||
| 83 | ) | 85 | ) | ||
| 84 | 86 | ||||
| @@ -166,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool { | |||||
| 166 | // AdminScope represents an admin API scope (admin:resource:actions). | 168 | // AdminScope represents an admin API scope (admin:resource:actions). | ||
| 167 | // Format: admin:routes:read,write or admin:*:* for full access. | 169 | // Format: admin:routes:read,write or admin:*:* for full access. | ||
| 168 | type AdminScope struct { | 170 | type AdminScope struct { | ||
| 169 | - | Resource string // routes, secrets, config, status, or * | 171 | + | Resource string // routes, secrets, config, status, logs, volumes, or * |
| 170 | Actions []string // read, write, or * | 172 | Actions []string // read, write, or * | ||
| 171 | } | 173 | } | ||
| 172 | 174 | ||||
| @@ -302,6 +304,16 @@ func AdminScopeStatus(actions ...string) string { | |||||
| 302 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | 304 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | ||
| 303 | } | 305 | } | ||
| 304 | 306 | ||||
| 307 | + | // AdminScopeLogs creates an admin scope for logs with the given actions. | |||
| 308 | + | func AdminScopeLogs(actions ...string) string { | |||
| 309 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // AdminScopeVolumes creates an admin scope for volumes with the given actions. | |||
| 313 | + | func AdminScopeVolumes(actions ...string) string { | |||
| 314 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceVolumes, strings.Join(actions, ",")) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 305 | // AuthStatus represents status of an authentication session. | 317 | // AuthStatus represents status of an authentication session. | ||
| 306 | type AuthStatus struct { | 318 | type AuthStatus struct { | ||
| 307 | Valid bool | 319 | Valid bool | ||
M internal/domain/auth_test.go +28 -0
| @@ -491,6 +491,34 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||||
| 491 | action: "write", | 491 | action: "write", | ||
| 492 | want: true, | 492 | want: true, | ||
| 493 | }, | 493 | }, | ||
| 494 | + | { | |||
| 495 | + | name: "logs read grants logs resource", | |||
| 496 | + | granted: []string{"admin:logs:read"}, | |||
| 497 | + | resource: AdminResourceLogs, | |||
| 498 | + | action: AdminActionRead, | |||
| 499 | + | want: true, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "status read does not grant logs resource", | |||
| 503 | + | granted: []string{"admin:status:read"}, | |||
| 504 | + | resource: AdminResourceLogs, | |||
| 505 | + | action: AdminActionRead, | |||
| 506 | + | want: false, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "full admin wildcard grants logs resource", | |||
| 510 | + | granted: []string{"admin:*:*"}, | |||
| 511 | + | resource: AdminResourceLogs, | |||
| 512 | + | action: AdminActionRead, | |||
| 513 | + | want: true, | |||
| 514 | + | }, | |||
| 515 | + | { | |||
| 516 | + | name: "volumes read grants volumes resource", | |||
| 517 | + | granted: []string{"admin:volumes:read"}, | |||
| 518 | + | resource: AdminResourceVolumes, | |||
| 519 | + | action: AdminActionRead, | |||
| 520 | + | want: true, | |||
| 521 | + | }, | |||
| 494 | { | 522 | { | ||
| 495 | name: "repository scope is ignored for admin access", | 523 | name: "repository scope is ignored for admin access", | ||
| 496 | granted: []string{"repository:myrepo:push"}, | 524 | granted: []string{"repository:myrepo:push"}, | ||
M internal/domain/container.go +11 -0
| @@ -26,6 +26,13 @@ type NetworkInfo struct { | |||||
| 26 | Labels map[string]string | 26 | Labels map[string]string | ||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | + | // NetworkConfig holds options for creating a container network. | |||
| 30 | + | type NetworkConfig struct { | |||
| 31 | + | Driver string | |||
| 32 | + | Internal bool | |||
| 33 | + | Labels map[string]string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 29 | // Attachment represents an attached service container. | 36 | // Attachment represents an attached service container. | ||
| 30 | type Attachment struct { | 37 | type Attachment struct { | ||
| 31 | Name string | 38 | Name string | ||
| @@ -64,6 +71,10 @@ type ContainerConfig struct { | |||||
| 64 | MemoryLimit int64 // Memory limit in bytes (0 = no limit) | 71 | MemoryLimit int64 // Memory limit in bytes (0 = no limit) | ||
| 65 | NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit) | 72 | NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit) | ||
| 66 | PidsLimit int64 // Max number of PIDs (0 = no limit) | 73 | PidsLimit int64 // Max number of PIDs (0 = no limit) | ||
| 74 | + | ReadOnlyRootFS bool // Mount container root filesystem read-only | |||
| 75 | + | User string // User to run as | |||
| 76 | + | CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults | |||
| 77 | + | CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults | |||
| 67 | } | 78 | } | ||
| 68 | 79 | ||||
| 69 | // ContainerStatus represents the current state of a container. | 80 | // ContainerStatus represents the current state of a container. | ||
M internal/domain/errors.go +3 -0
| @@ -34,6 +34,7 @@ var ( | |||||
| 34 | ErrInvalidDigest = errors.New("invalid digest") | 34 | ErrInvalidDigest = errors.New("invalid digest") | ||
| 35 | ErrDigestMismatch = errors.New("digest mismatch") | 35 | ErrDigestMismatch = errors.New("digest mismatch") | ||
| 36 | ErrUnauthorized = errors.New("unauthorized") | 36 | ErrUnauthorized = errors.New("unauthorized") | ||
| 37 | + | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | |||
| 37 | 38 | ||||
| 38 | // Network errors | 39 | // Network errors | ||
| 39 | ErrNetworkNotFound = errors.New("network not found") | 40 | ErrNetworkNotFound = errors.New("network not found") | ||
| @@ -51,10 +52,12 @@ var ( | |||||
| 51 | ErrInvalidConfig = errors.New("invalid configuration") | 52 | ErrInvalidConfig = errors.New("invalid configuration") | ||
| 52 | ErrConfigLoadFailed = errors.New("failed to load configuration") | 53 | ErrConfigLoadFailed = errors.New("failed to load configuration") | ||
| 53 | ErrInvalidDomainPattern = errors.New("invalid domain pattern") | 54 | ErrInvalidDomainPattern = errors.New("invalid domain pattern") | ||
| 55 | + | ErrRouteConflict = errors.New("route conflicts with existing configuration") | |||
| 54 | 56 | ||||
| 55 | // Environment errors | 57 | // Environment errors | ||
| 56 | ErrEnvFileNotFound = errors.New("environment file not found") | 58 | ErrEnvFileNotFound = errors.New("environment file not found") | ||
| 57 | ErrSecretNotFound = errors.New("secret not found") | 59 | ErrSecretNotFound = errors.New("secret not found") | ||
| 60 | + | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |||
| 58 | ErrProviderNotFound = errors.New("secret provider not found") | 61 | ErrProviderNotFound = errors.New("secret provider not found") | ||
| 59 | ErrInvalidContainerName = errors.New("invalid container name") | 62 | ErrInvalidContainerName = errors.New("invalid container name") | ||
| 60 | 63 | ||||
A internal/domain/redact.go +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "regexp" | |||
| 4 | + | ||||
| 5 | + | type secretRedactionPattern struct { | |||
| 6 | + | re *regexp.Regexp | |||
| 7 | + | repl string | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential. | |||
| 11 | + | ||||
| 12 | + | var secretValuePatterns = []secretRedactionPattern{ | |||
| 13 | + | { | |||
| 14 | + | re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)`), | |||
| 15 | + | repl: `${1}[REDACTED]`, | |||
| 16 | + | }, | |||
| 17 | + | { | |||
| 18 | + | re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")((?:\\.|[^"])*)(")`), | |||
| 19 | + | repl: `${1}[REDACTED]${3}`, | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`), | |||
| 23 | + | repl: `${1}[REDACTED]`, | |||
| 24 | + | }, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // RedactSecrets redacts common secret values from a single log or diagnostic line. | |||
| 28 | + | func RedactSecrets(line string) string { | |||
| 29 | + | redacted := line | |||
| 30 | + | for _, pattern := range secretValuePatterns { | |||
| 31 | + | redacted = pattern.re.ReplaceAllString(redacted, pattern.repl) | |||
| 32 | + | } | |||
| 33 | + | return redacted | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RedactSecretLines redacts common secret values from log or diagnostic lines. | |||
| 37 | + | func RedactSecretLines(lines []string) []string { | |||
| 38 | + | if lines == nil { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | redacted := make([]string, len(lines)) | |||
| 42 | + | for i, line := range lines { | |||
| 43 | + | redacted[i] = RedactSecrets(line) | |||
| 44 | + | } | |||
| 45 | + | return redacted | |||
| 46 | + | } | |||
A internal/domain/redact_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRedactSecrets(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | in string | |||
| 13 | + | want string | |||
| 14 | + | }{ | |||
| 15 | + | {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"}, | |||
| 16 | + | {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"}, | |||
| 17 | + | {"secret env", "SECRET=shh", "SECRET=[REDACTED]"}, | |||
| 18 | + | {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"}, | |||
| 19 | + | {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"}, | |||
| 20 | + | {"quoted password env with spaces", `PASSWORD="hunter 2"`, "PASSWORD=[REDACTED]"}, | |||
| 21 | + | {"single quoted token env with spaces", `TOKEN='abc 123'`, "TOKEN=[REDACTED]"}, | |||
| 22 | + | {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`}, | |||
| 23 | + | {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`}, | |||
| 24 | + | {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"}, | |||
| 25 | + | {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"}, | |||
| 26 | + | {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"}, | |||
| 27 | + | {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"}, | |||
| 28 | + | {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"}, | |||
| 29 | + | {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"}, | |||
| 30 | + | {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`}, | |||
| 31 | + | {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`}, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | for _, tt := range tests { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | assert.Equal(t, tt.want, RedactSecrets(tt.in)) | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRedactSecretLinesPreservesEmptySlice(t *testing.T) { | |||
| 42 | + | assert.Nil(t, RedactSecretLines(nil)) | |||
| 43 | + | assert.Empty(t, RedactSecretLines([]string{})) | |||
| 44 | + | assert.NotNil(t, RedactSecretLines([]string{})) | |||
| 45 | + | } | |||
M internal/domain/validation.go +8 -0
| @@ -17,6 +17,14 @@ var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`) | |||||
| 17 | // | 17 | // | ||
| 18 | // Valid: app.example.com, api.site.org, my-app.co.uk | 18 | // Valid: app.example.com, api.site.org, my-app.co.uk | ||
| 19 | // Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080 | 19 | // Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080 | ||
| 20 | + | func CanonicalRouteDomain(domainName string) (string, bool) { | |||
| 21 | + | canonical := strings.ToLower(strings.TrimSpace(domainName)) | |||
| 22 | + | if !IsValidRouteDomain(canonical) { | |||
| 23 | + | return "", false | |||
| 24 | + | } | |||
| 25 | + | return canonical, true | |||
| 26 | + | } | |||
| 27 | + | ||||
| 20 | func IsValidRouteDomain(domain string) bool { | 28 | func IsValidRouteDomain(domain string) bool { | ||
| 21 | // Reject empty | 29 | // Reject empty | ||
| 22 | if domain == "" { | 30 | if domain == "" { | ||
M internal/usecase/backup/integration_test.go +4 -1
| @@ -47,7 +47,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru | |||||
| 47 | require.NoError(t, runtime.PullImage(pullCtx, image)) | 47 | require.NoError(t, runtime.PullImage(pullCtx, image)) | ||
| 48 | netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute) | 48 | netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute) | ||
| 49 | defer cancelNet() | 49 | defer cancelNet() | ||
| 50 | - | require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"})) | 50 | + | require.NoError(t, runtime.CreateNetwork(netCtx, networkName, domain.NetworkConfig{Driver: "bridge"})) |
| 51 | t.Cleanup(func() { | 51 | t.Cleanup(func() { | ||
| 52 | cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second) | 52 | cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second) | ||
| 53 | defer cancelCleanup() | 53 | defer cancelCleanup() | ||
| @@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error { | |||||
| 239 | return nil | 239 | return nil | ||
| 240 | } | 240 | } | ||
| 241 | 241 | ||||
| 242 | + | func (s *integrationContainerService) UpdateAttachments(map[string][]string) { | |||
| 243 | + | } | |||
| 244 | + | ||||
| 242 | func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error { | 245 | func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error { | ||
| 243 | return nil | 246 | return nil | ||
| 244 | } | 247 | } | ||
M internal/usecase/config/service.go +73 -11
| @@ -95,7 +95,16 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 95 | return log.WrapErr(err, "failed to load routes") | 95 | return log.WrapErr(err, "failed to load routes") | ||
| 96 | } | 96 | } | ||
| 97 | newConfig.Routes = routes | 97 | newConfig.Routes = routes | ||
| 98 | - | newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes")) | 98 | + | externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes")) |
| 99 | + | if err != nil { | |||
| 100 | + | return log.WrapErr(err, "failed to load external routes") | |||
| 101 | + | } | |||
| 102 | + | for domainName := range externalRoutes { | |||
| 103 | + | if _, exists := routes[domainName]; exists { | |||
| 104 | + | return fmt.Errorf("external route %q conflicts with configured route", domainName) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | newConfig.ExternalRoutes = externalRoutes | |||
| 99 | newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | 108 | newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | ||
| 100 | newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | 109 | newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | ||
| 101 | 110 | ||||
| @@ -208,6 +217,33 @@ func loadStringMap(raw any) map[string]string { | |||||
| 208 | return result | 217 | return result | ||
| 209 | } | 218 | } | ||
| 210 | 219 | ||||
| 220 | + | // loadExternalRoutes loads and canonicalizes external route mappings. | |||
| 221 | + | func loadExternalRoutes(raw any) (map[string]string, error) { | |||
| 222 | + | result := make(map[string]string) | |||
| 223 | + | if raw == nil { | |||
| 224 | + | return result, nil | |||
| 225 | + | } | |||
| 226 | + | m, ok := raw.(map[string]any) | |||
| 227 | + | if !ok { | |||
| 228 | + | return result, nil | |||
| 229 | + | } | |||
| 230 | + | for k, v := range m { | |||
| 231 | + | vs, ok := v.(string) | |||
| 232 | + | if !ok { | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | canonicalKey, ok := domain.CanonicalRouteDomain(k) | |||
| 236 | + | if !ok { | |||
| 237 | + | return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid) | |||
| 238 | + | } | |||
| 239 | + | if _, exists := result[canonicalKey]; exists { | |||
| 240 | + | return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey) | |||
| 241 | + | } | |||
| 242 | + | result[canonicalKey] = vs | |||
| 243 | + | } | |||
| 244 | + | return result, nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 211 | // loadStringArrayMap loads a map[string][]string from a viper value. | 247 | // loadStringArrayMap loads a map[string][]string from a viper value. | ||
| 212 | func loadStringArrayMap(raw any) map[string][]string { | 248 | func loadStringArrayMap(raw any) map[string][]string { | ||
| 213 | result := make(map[string][]string) | 249 | result := make(map[string][]string) | ||
| @@ -252,7 +288,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) { | |||||
| 252 | if err != nil { | 288 | if err != nil { | ||
| 253 | return nil, err | 289 | return nil, err | ||
| 254 | } | 290 | } | ||
| 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 | |||
| 256 | } | 296 | } | ||
| 257 | 297 | ||||
| 258 | for key, value := range routes { | 298 | for key, value := range routes { | ||
| @@ -261,7 +301,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) { | |||||
| 261 | } | 301 | } | ||
| 262 | 302 | ||||
| 263 | domainName := strings.TrimPrefix(key, "http://") | 303 | domainName := strings.TrimPrefix(key, "http://") | ||
| 264 | - | if _, exists := result[domainName]; exists { | 304 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) |
| 305 | + | if !ok { | |||
| 306 | + | return nil, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid) | |||
| 307 | + | } | |||
| 308 | + | if _, exists := result[canonicalDomain]; exists { | |||
| 265 | continue | 309 | continue | ||
| 266 | } | 310 | } | ||
| 267 | 311 | ||||
| @@ -269,16 +313,18 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) { | |||||
| 269 | if err != nil { | 313 | if err != nil { | ||
| 270 | return nil, err | 314 | return nil, err | ||
| 271 | } | 315 | } | ||
| 272 | - | result[domainName] = route | 316 | + | result[canonicalDomain] = route |
| 273 | } | 317 | } | ||
| 274 | 318 | ||||
| 275 | return result, nil | 319 | return result, nil | ||
| 276 | } | 320 | } | ||
| 277 | 321 | ||||
| 278 | func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) { | 322 | func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) { | ||
| 279 | - | if !domain.IsValidRouteDomain(domainName) { | 323 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) |
| 324 | + | if !ok { | |||
| 280 | return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid) | 325 | return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid) | ||
| 281 | } | 326 | } | ||
| 327 | + | domainName = canonicalDomain | |||
| 282 | 328 | ||||
| 283 | switch value := raw.(type) { | 329 | switch value := raw.(type) { | ||
| 284 | case string: | 330 | case string: | ||
| @@ -294,9 +340,11 @@ func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) { | |||||
| 294 | } | 340 | } | ||
| 295 | 341 | ||||
| 296 | func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) { | 342 | func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) { | ||
| 297 | - | if !domain.IsValidRouteDomain(domainName) { | 343 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) |
| 344 | + | if !ok { | |||
| 298 | return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid) | 345 | return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid) | ||
| 299 | } | 346 | } | ||
| 347 | + | domainName = canonicalDomain | |||
| 300 | 348 | ||||
| 301 | value, ok := raw.(string) | 349 | value, ok := raw.(string) | ||
| 302 | if !ok { | 350 | if !ok { | ||
| @@ -348,6 +396,10 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, | |||||
| 348 | s.mu.RLock() | 396 | s.mu.RLock() | ||
| 349 | defer s.mu.RUnlock() | 397 | defer s.mu.RUnlock() | ||
| 350 | 398 | ||||
| 399 | + | domainName, ok := domain.CanonicalRouteDomain(domainName) | |||
| 400 | + | if !ok { | |||
| 401 | + | return nil, domain.ErrRouteNotFound | |||
| 402 | + | } | |||
| 351 | routeCfg, ok := s.config.Routes[domainName] | 403 | routeCfg, ok := s.config.Routes[domainName] | ||
| 352 | if !ok { | 404 | if !ok { | ||
| 353 | return nil, domain.ErrRouteNotFound | 405 | return nil, domain.ErrRouteNotFound | ||
| @@ -494,9 +546,11 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 494 | if route.Domain == "" { | 546 | if route.Domain == "" { | ||
| 495 | return domain.ErrRouteDomainEmpty | 547 | return domain.ErrRouteDomainEmpty | ||
| 496 | } | 548 | } | ||
| 497 | - | if !domain.IsValidRouteDomain(route.Domain) { | 549 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain) |
| 550 | + | if !ok { | |||
| 498 | return domain.ErrRouteDomainInvalid | 551 | return domain.ErrRouteDomainInvalid | ||
| 499 | } | 552 | } | ||
| 553 | + | route.Domain = canonicalDomain | |||
| 500 | if route.Image == "" { | 554 | if route.Image == "" { | ||
| 501 | return domain.ErrRouteImageEmpty | 555 | return domain.ErrRouteImageEmpty | ||
| 502 | } | 556 | } | ||
| @@ -507,6 +561,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 507 | if currentConfig.Routes == nil { | 561 | if currentConfig.Routes == nil { | ||
| 508 | currentConfig.Routes = make(map[string]routeConfig) | 562 | currentConfig.Routes = make(map[string]routeConfig) | ||
| 509 | } | 563 | } | ||
| 564 | + | if _, exists := currentConfig.ExternalRoutes[route.Domain]; exists { | |||
| 565 | + | s.mu.Unlock() | |||
| 566 | + | return fmt.Errorf("%w: route %q conflicts with external route", domain.ErrRouteConflict, route.Domain) | |||
| 567 | + | } | |||
| 510 | previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain] | 568 | previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain] | ||
| 511 | legacyKey := legacyRouteStorageKey(route.Domain) | 569 | legacyKey := legacyRouteStorageKey(route.Domain) | ||
| 512 | _, legacyExisted := currentConfig.Routes[legacyKey] | 570 | _, legacyExisted := currentConfig.Routes[legacyKey] | ||
| @@ -550,9 +608,11 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | |||||
| 550 | if route.Domain == "" { | 608 | if route.Domain == "" { | ||
| 551 | return domain.ErrRouteDomainEmpty | 609 | return domain.ErrRouteDomainEmpty | ||
| 552 | } | 610 | } | ||
| 553 | - | if !domain.IsValidRouteDomain(route.Domain) { | 611 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain) |
| 612 | + | if !ok { | |||
| 554 | return domain.ErrRouteDomainInvalid | 613 | return domain.ErrRouteDomainInvalid | ||
| 555 | } | 614 | } | ||
| 615 | + | route.Domain = canonicalDomain | |||
| 556 | if route.Image == "" { | 616 | if route.Image == "" { | ||
| 557 | return domain.ErrRouteImageEmpty | 617 | return domain.ErrRouteImageEmpty | ||
| 558 | } | 618 | } | ||
| @@ -601,14 +661,16 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |||||
| 601 | if domainName == "" { | 661 | if domainName == "" { | ||
| 602 | return domain.ErrRouteDomainEmpty | 662 | return domain.ErrRouteDomainEmpty | ||
| 603 | } | 663 | } | ||
| 604 | - | if !domain.IsValidRouteDomain(domainName) { | 664 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) |
| 665 | + | if !ok { | |||
| 605 | return domain.ErrRouteDomainInvalid | 666 | return domain.ErrRouteDomainInvalid | ||
| 606 | } | 667 | } | ||
| 668 | + | domainName = canonicalDomain | |||
| 607 | 669 | ||||
| 608 | // Store previous value for rollback | 670 | // Store previous value for rollback | ||
| 609 | s.mu.Lock() | 671 | s.mu.Lock() | ||
| 610 | currentConfig := s.config | 672 | currentConfig := s.config | ||
| 611 | - | _, ok := currentConfig.Routes[domainName] | 673 | + | _, ok = currentConfig.Routes[domainName] |
| 612 | if !ok { | 674 | if !ok { | ||
| 613 | s.mu.Unlock() | 675 | s.mu.Unlock() | ||
| 614 | return domain.ErrRouteNotFound | 676 | return domain.ErrRouteNotFound | ||
| @@ -1455,7 +1517,7 @@ func (s *Service) GetExternalRoutes() map[string]string { | |||||
| 1455 | s.mu.RLock() | 1517 | s.mu.RLock() | ||
| 1456 | defer s.mu.RUnlock() | 1518 | defer s.mu.RUnlock() | ||
| 1457 | 1519 | ||||
| 1458 | - | result := make(map[string]string) | 1520 | + | result := make(map[string]string, len(s.config.ExternalRoutes)) |
| 1459 | for k, v := range s.config.ExternalRoutes { | 1521 | for k, v := range s.config.ExternalRoutes { | ||
| 1460 | result[k] = v | 1522 | result[k] = v | ||
| 1461 | } | 1523 | } | ||
M internal/usecase/config/service_test.go +87 -1
| @@ -458,6 +458,92 @@ func TestService_GetRoute(t *testing.T) { | |||||
| 458 | }) | 458 | }) | ||
| 459 | } | 459 | } | ||
| 460 | 460 | ||||
| 461 | + | func TestLoadCanonicalRoutes_RejectsDuplicateCanonicalRouteKeys(t *testing.T) { | |||
| 462 | + | _, err := loadCanonicalRoutes(map[string]any{ | |||
| 463 | + | "App.Example.com": "app:v1", | |||
| 464 | + | "app.example.com": "app:v2", | |||
| 465 | + | }) | |||
| 466 | + | ||||
| 467 | + | require.Error(t, err) | |||
| 468 | + | assert.Contains(t, err.Error(), "duplicate route key") | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func TestService_Load_RejectsExternalRouteConflictingWithRoute(t *testing.T) { | |||
| 472 | + | v := viper.New() | |||
| 473 | + | v.Set("routes", map[string]any{ | |||
| 474 | + | "App.Example.com": "app:latest", | |||
| 475 | + | }) | |||
| 476 | + | v.Set("external_routes", map[string]any{ | |||
| 477 | + | "app.example.com": "203.0.113.10:5000", | |||
| 478 | + | }) | |||
| 479 | + | ||||
| 480 | + | svc := NewService(v, mocks.NewMockEventPublisher(t)) | |||
| 481 | + | err := svc.Load(testContext()) | |||
| 482 | + | ||||
| 483 | + | require.Error(t, err) | |||
| 484 | + | assert.Contains(t, err.Error(), "conflicts with configured route") | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestLoadExternalRoutes_RejectsInvalidAndDuplicateKeys(t *testing.T) { | |||
| 488 | + | t.Run("invalid", func(t *testing.T) { | |||
| 489 | + | _, err := loadExternalRoutes(map[string]any{ | |||
| 490 | + | "bad.example.com:8080": "203.0.113.10:5000", | |||
| 491 | + | }) | |||
| 492 | + | ||||
| 493 | + | require.Error(t, err) | |||
| 494 | + | assert.Contains(t, err.Error(), "invalid external route key") | |||
| 495 | + | }) | |||
| 496 | + | ||||
| 497 | + | t.Run("duplicate canonical", func(t *testing.T) { | |||
| 498 | + | _, err := loadExternalRoutes(map[string]any{ | |||
| 499 | + | "Reg.Example.com": "203.0.113.10:5000", | |||
| 500 | + | "reg.example.com": "203.0.113.11:5000", | |||
| 501 | + | }) | |||
| 502 | + | ||||
| 503 | + | require.Error(t, err) | |||
| 504 | + | assert.Contains(t, err.Error(), "duplicate external route key") | |||
| 505 | + | }) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestService_AddRoute_RejectsExternalRouteConflict(t *testing.T) { | |||
| 509 | + | v := viper.New() | |||
| 510 | + | v.Set("external_routes", map[string]any{ | |||
| 511 | + | "app.example.com": "203.0.113.10:5000", | |||
| 512 | + | }) | |||
| 513 | + | ||||
| 514 | + | svc := NewService(v, mocks.NewMockEventPublisher(t)) | |||
| 515 | + | ctx := testContext() | |||
| 516 | + | require.NoError(t, svc.Load(ctx)) | |||
| 517 | + | ||||
| 518 | + | err := svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest"}) | |||
| 519 | + | ||||
| 520 | + | require.Error(t, err) | |||
| 521 | + | assert.ErrorIs(t, err, domain.ErrRouteConflict) | |||
| 522 | + | assert.Contains(t, err.Error(), "conflicts with external route") | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func TestService_AddRoute_CanonicalizesDomainCaseAndRejectsInvalidAuthority(t *testing.T) { | |||
| 526 | + | configFile := filepath.Join(t.TempDir(), "gordon.toml") | |||
| 527 | + | require.NoError(t, os.WriteFile(configFile, []byte("[routes]\n"), 0600)) | |||
| 528 | + | v := viper.New() | |||
| 529 | + | v.SetConfigFile(configFile) | |||
| 530 | + | svc := NewService(v, mocks.NewMockEventPublisher(t)) | |||
| 531 | + | ctx := testContext() | |||
| 532 | + | require.NoError(t, svc.Load(ctx)) | |||
| 533 | + | ||||
| 534 | + | require.NoError(t, svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest", HTTPS: true})) | |||
| 535 | + | route, err := svc.GetRoute(ctx, "app.example.com") | |||
| 536 | + | require.NoError(t, err) | |||
| 537 | + | assert.Equal(t, "app.example.com", route.Domain) | |||
| 538 | + | _, err = svc.GetRoute(ctx, "APP.EXAMPLE.COM") | |||
| 539 | + | require.NoError(t, err) | |||
| 540 | + | ||||
| 541 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com:8080", Image: "app:latest"}) | |||
| 542 | + | assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) | |||
| 543 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com.", Image: "app:latest"}) | |||
| 544 | + | assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 461 | func TestService_AddRoute(t *testing.T) { | 547 | func TestService_AddRoute(t *testing.T) { | ||
| 462 | t.Run("success", func(t *testing.T) { | 548 | t.Run("success", func(t *testing.T) { | ||
| 463 | // Create temp config file for Save() to work | 549 | // Create temp config file for Save() to work | ||
| @@ -1556,7 +1642,7 @@ func TestService_AutoRouteAllowedDomains(t *testing.T) { | |||||
| 1556 | func TestService_GetExternalRoutes(t *testing.T) { | 1642 | func TestService_GetExternalRoutes(t *testing.T) { | ||
| 1557 | v := viper.New() | 1643 | v := viper.New() | ||
| 1558 | v.Set("external_routes", map[string]interface{}{ | 1644 | v.Set("external_routes", map[string]interface{}{ | ||
| 1559 | - | "reg.example.com": "localhost:5000", | 1645 | + | "Reg.Example.com": "localhost:5000", |
| 1560 | "cache.example.com": "127.0.0.1:6379", | 1646 | "cache.example.com": "127.0.0.1:6379", | ||
| 1561 | }) | 1647 | }) | ||
| 1562 | 1648 | ||||
M internal/usecase/container/autoroute.go +59 -8
| @@ -174,6 +174,13 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, | |||||
| 174 | continue | 174 | continue | ||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(routeDomain) | |||
| 178 | + | if !ok { | |||
| 179 | + | log.Warn().Str("domain", routeDomain).Msg("auto-route rejected invalid domain") | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | routeDomain = canonicalDomain | |||
| 183 | + | ||||
| 177 | created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains) | 184 | created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains) | ||
| 178 | 185 | ||||
| 179 | if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" { | 186 | if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" { | ||
| @@ -259,6 +266,38 @@ func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route | |||||
| 259 | } | 266 | } | ||
| 260 | } | 267 | } | ||
| 261 | 268 | ||||
| 269 | + | func allowedAbsoluteEnvFileRoots() []string { | |||
| 270 | + | return []string{"/app", "/workspace", "/usr/src/app"} | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func validateAutoRouteEnvFilePath(envFilePath string) (string, error) { | |||
| 274 | + | envFilePath = strings.TrimSpace(envFilePath) | |||
| 275 | + | if envFilePath == "" { | |||
| 276 | + | return "", fmt.Errorf("env file path cannot be empty") | |||
| 277 | + | } | |||
| 278 | + | if strings.Contains(envFilePath, "\x00") { | |||
| 279 | + | return "", fmt.Errorf("env file path contains NUL byte") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | cleaned := filepath.Clean(envFilePath) | |||
| 283 | + | if cleaned == "." || cleaned == string(filepath.Separator) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { | |||
| 284 | + | return "", fmt.Errorf("unsafe env file path: %s", envFilePath) | |||
| 285 | + | } | |||
| 286 | + | if filepath.IsAbs(cleaned) && !pathHasAllowedRoot(cleaned, allowedAbsoluteEnvFileRoots()) { | |||
| 287 | + | return "", fmt.Errorf("unsafe env file path: %s", envFilePath) | |||
| 288 | + | } | |||
| 289 | + | return cleaned, nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func pathHasAllowedRoot(path string, roots []string) bool { | |||
| 293 | + | for _, root := range roots { | |||
| 294 | + | if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) { | |||
| 295 | + | return true | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | return false | |||
| 299 | + | } | |||
| 300 | + | ||||
| 262 | // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets. | 301 | // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets. | ||
| 263 | func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error { | 302 | func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error { | ||
| 264 | log := zerowrap.FromCtx(ctx) | 303 | log := zerowrap.FromCtx(ctx) | ||
| @@ -269,8 +308,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, | |||||
| 269 | Str("env_file", envFilePath). | 308 | Str("env_file", envFilePath). | ||
| 270 | Msg("extracting env file from image") | 309 | Msg("extracting env file from image") | ||
| 271 | 310 | ||||
| 311 | + | safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 315 | + | ||||
| 272 | // Extract env file from image | 316 | // Extract env file from image | ||
| 273 | - | envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath) | 317 | + | envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath) |
| 274 | if err != nil { | 318 | if err != nil { | ||
| 275 | return fmt.Errorf("failed to extract env file: %w", err) | 319 | return fmt.Errorf("failed to extract env file: %w", err) | ||
| 276 | } | 320 | } | ||
| @@ -291,13 +335,8 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, | |||||
| 291 | return nil | 335 | return nil | ||
| 292 | } | 336 | } | ||
| 293 | 337 | ||||
| 294 | - | // Security check: reject imported env files containing secret references | 338 | + | if err := rejectSecretReferences(imageEnv); err != nil { |
| 295 | - | // to prevent attacker-controlled images from persisting ${provider:path} | 339 | + | return err |
| 296 | - | // syntax that would later resolve against host secret providers. | |||
| 297 | - | for key, value := range imageEnv { | |||
| 298 | - | if domain.ContainsSecretReference(value) { | |||
| 299 | - | return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef) | |||
| 300 | - | } | |||
| 301 | } | 340 | } | ||
| 302 | 341 | ||||
| 303 | // Load existing env file for this domain | 342 | // Load existing env file for this domain | ||
| @@ -379,6 +418,18 @@ func domainToEnvFileName(domainName string) (string, error) { | |||||
| 379 | return storageKey.FileName(), nil | 418 | return storageKey.FileName(), nil | ||
| 380 | } | 419 | } | ||
| 381 | 420 | ||||
| 421 | + | func rejectSecretReferences(env map[string]string) error { | |||
| 422 | + | // Security check: reject imported env files containing secret references | |||
| 423 | + | // to prevent attacker-controlled images from persisting ${provider:path} | |||
| 424 | + | // syntax that would later resolve against host secret providers. | |||
| 425 | + | for key, value := range env { | |||
| 426 | + | if domain.ContainsSecretReference(value) { | |||
| 427 | + | return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | return nil | |||
| 431 | + | } | |||
| 432 | + | ||||
| 382 | // writeEnvFile writes environment variables to a file. | 433 | // writeEnvFile writes environment variables to a file. | ||
| 383 | func writeEnvFile(path string, env map[string]string) error { | 434 | func writeEnvFile(path string, env map[string]string) error { | ||
| 384 | var buf bytes.Buffer | 435 | var buf bytes.Buffer | ||
M internal/usecase/container/autoroute_test.go +47 -0
| @@ -910,6 +910,53 @@ func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, | |||||
| 910 | 910 | ||||
| 911 | // collectDomains tests | 911 | // collectDomains tests | ||
| 912 | 912 | ||||
| 913 | + | func TestValidateAutoRouteEnvFilePath(t *testing.T) { | |||
| 914 | + | tests := []struct { | |||
| 915 | + | name string | |||
| 916 | + | path string | |||
| 917 | + | want string | |||
| 918 | + | wantErr bool | |||
| 919 | + | }{ | |||
| 920 | + | {name: "relative path", path: ".env", want: ".env"}, | |||
| 921 | + | {name: "absolute app path", path: "/app/.env", want: "/app/.env"}, | |||
| 922 | + | {name: "absolute workspace path", path: "/workspace/.env", want: "/workspace/.env"}, | |||
| 923 | + | {name: "cleans safe path", path: "/app/config/../.env", want: "/app/.env"}, | |||
| 924 | + | {name: "absolute traversal escapes app root", path: "/app/../../etc/passwd", wantErr: true}, | |||
| 925 | + | {name: "absolute non app path", path: "/etc/passwd", wantErr: true}, | |||
| 926 | + | {name: "empty", path: "", wantErr: true}, | |||
| 927 | + | {name: "root", path: "/", wantErr: true}, | |||
| 928 | + | {name: "traversal", path: "../.env", wantErr: true}, | |||
| 929 | + | {name: "proc", path: "/proc/self/environ", wantErr: true}, | |||
| 930 | + | {name: "sys", path: "/sys/kernel", wantErr: true}, | |||
| 931 | + | {name: "dev", path: "/dev/null", wantErr: true}, | |||
| 932 | + | {name: "run", path: "/run/secrets", wantErr: true}, | |||
| 933 | + | {name: "var run", path: "/var/run/docker.sock", wantErr: true}, | |||
| 934 | + | } | |||
| 935 | + | ||||
| 936 | + | for _, tt := range tests { | |||
| 937 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 938 | + | got, err := validateAutoRouteEnvFilePath(tt.path) | |||
| 939 | + | if tt.wantErr { | |||
| 940 | + | assert.Error(t, err) | |||
| 941 | + | return | |||
| 942 | + | } | |||
| 943 | + | require.NoError(t, err) | |||
| 944 | + | assert.Equal(t, tt.want, got) | |||
| 945 | + | }) | |||
| 946 | + | } | |||
| 947 | + | } | |||
| 948 | + | ||||
| 949 | + | func TestAutoRouteHandler_ExtractAndMergeEnvFile_UsesCleanSafeEnvFilePath(t *testing.T) { | |||
| 950 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 951 | + | extractor := &testEnvExtractor{data: []byte("FOO=bar\n")} | |||
| 952 | + | handler := NewAutoRouteHandler(ctx, inmocks.NewMockConfigService(t), inmocks.NewMockContainerService(t), mocks.NewMockBlobStorage(t), "registry.example.com"). | |||
| 953 | + | WithEnvExtractor(extractor, t.TempDir()) | |||
| 954 | + | ||||
| 955 | + | err := handler.extractAndMergeEnvFile(ctx, "myapp:latest", "app.example.com", "/app/config/../.env") | |||
| 956 | + | require.NoError(t, err) | |||
| 957 | + | assert.Equal(t, "/app/.env", extractor.lastPath) | |||
| 958 | + | } | |||
| 959 | + | ||||
| 913 | func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) { | 960 | func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) { | ||
| 914 | tests := []struct { | 961 | tests := []struct { | ||
| 915 | name string | 962 | name string | ||
M internal/usecase/container/service.go +211 -21
| @@ -5,10 +5,12 @@ import ( | |||||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "encoding/binary" | 7 | "encoding/binary" | ||
| 8 | + | "encoding/hex" | |||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "fmt" | 10 | "fmt" | ||
| 10 | "io" | 11 | "io" | ||
| 11 | "maps" | 12 | "maps" | ||
| 13 | + | "net" | |||
| 12 | "slices" | 14 | "slices" | ||
| 13 | "strconv" | 15 | "strconv" | ||
| 14 | "strings" | 16 | "strings" | ||
| @@ -43,7 +45,11 @@ type Config struct { | |||||
| 43 | NetworkIsolation bool | 45 | NetworkIsolation bool | ||
| 44 | NetworkPrefix string | 46 | NetworkPrefix string | ||
| 45 | NetworkGroups map[string][]string | 47 | NetworkGroups map[string][]string | ||
| 48 | + | NetworkInternal bool | |||
| 46 | Attachments map[string][]string | 49 | Attachments map[string][]string | ||
| 50 | + | AllowedRegistries []string | |||
| 51 | + | RequireImageDigest bool | |||
| 52 | + | SecurityProfile string | |||
| 47 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 53 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | ||
| 48 | ReadinessMode string // Readiness strategy: auto, docker-health, delay | 54 | ReadinessMode string // Readiness strategy: auto, docker-health, delay | ||
| 49 | HealthTimeout time.Duration // Max wait for health-based readiness | 55 | HealthTimeout time.Duration // Max wait for health-based readiness | ||
| @@ -268,7 +274,7 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||||
| 268 | } | 274 | } | ||
| 269 | } | 275 | } | ||
| 270 | 276 | ||||
| 271 | - | return &domain.ContainerConfig{ | 277 | + | containerConfig := &domain.ContainerConfig{ |
| 272 | Image: in.ImageRef, | 278 | Image: in.ImageRef, | ||
| 273 | Name: containerName, | 279 | Name: containerName, | ||
| 274 | Ports: ports, | 280 | Ports: ports, | ||
| @@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||||
| 282 | NanoCPUs: cfg.DefaultNanoCPUs, | 288 | NanoCPUs: cfg.DefaultNanoCPUs, | ||
| 283 | PidsLimit: cfg.DefaultPidsLimit, | 289 | PidsLimit: cfg.DefaultPidsLimit, | ||
| 284 | } | 290 | } | ||
| 291 | + | applySecurityProfile(containerConfig, cfg) | |||
| 292 | + | return containerConfig | |||
| 285 | } | 293 | } | ||
| 286 | 294 | ||||
| 287 | // Deploy creates and starts a container for the given route. | 295 | // Deploy creates and starts a container for the given route. | ||
| @@ -561,7 +569,10 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 561 | log.WrapErr(err, "failed to cleanup orphaned containers") | 569 | log.WrapErr(err, "failed to cleanup orphaned containers") | ||
| 562 | } | 570 | } | ||
| 563 | 571 | ||||
| 564 | - | imageRef := s.buildImageRef(route.Image) | 572 | + | imageRef, err := s.buildValidatedImageRef(ctx, route.Image) |
| 573 | + | if err != nil { | |||
| 574 | + | return nil, err | |||
| 575 | + | } | |||
| 565 | actualImageRef, err := s.ensureImage(ctx, imageRef) | 576 | actualImageRef, err := s.ensureImage(ctx, imageRef) | ||
| 566 | if err != nil { | 577 | if err != nil { | ||
| 567 | return nil, err | 578 | return nil, err | ||
| @@ -1668,6 +1679,14 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam | |||||
| 1668 | } | 1679 | } | ||
| 1669 | } | 1680 | } | ||
| 1670 | 1681 | ||||
| 1682 | + | func (s *Service) buildValidatedImageRef(ctx context.Context, image string) (string, error) { | |||
| 1683 | + | imageRef := s.buildImageRef(image) | |||
| 1684 | + | if err := s.validateImageRef(ctx, imageRef); err != nil { | |||
| 1685 | + | return "", err | |||
| 1686 | + | } | |||
| 1687 | + | return imageRef, nil | |||
| 1688 | + | } | |||
| 1689 | + | ||||
| 1671 | func (s *Service) buildImageRef(image string) string { | 1690 | func (s *Service) buildImageRef(image string) string { | ||
| 1672 | s.mu.RLock() | 1691 | s.mu.RLock() | ||
| 1673 | cfg := s.config | 1692 | cfg := s.config | ||
| @@ -1743,6 +1762,135 @@ func hasExplicitRegistry(image string) bool { | |||||
| 1743 | return false | 1762 | return false | ||
| 1744 | } | 1763 | } | ||
| 1745 | 1764 | ||||
| 1765 | + | func (s *Service) validateImageRef(ctx context.Context, imageRef string) error { | |||
| 1766 | + | if !hasExplicitRegistry(imageRef) { | |||
| 1767 | + | return nil | |||
| 1768 | + | } | |||
| 1769 | + | return s.validateExternalImageRef(ctx, imageRef, imageRegistry(imageRef)) | |||
| 1770 | + | } | |||
| 1771 | + | ||||
| 1772 | + | func (s *Service) validateImagePullRef(ctx context.Context, imageRef string) error { | |||
| 1773 | + | return s.validateExternalImageRef(ctx, imageRef, imageRegistryForPolicy(imageRef)) | |||
| 1774 | + | } | |||
| 1775 | + | ||||
| 1776 | + | func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, registry string) error { | |||
| 1777 | + | s.mu.RLock() | |||
| 1778 | + | cfg := s.config | |||
| 1779 | + | s.mu.RUnlock() | |||
| 1780 | + | ||||
| 1781 | + | if registry == "" { | |||
| 1782 | + | return fmt.Errorf("image %q rejected: invalid image reference", imageRef) | |||
| 1783 | + | } | |||
| 1784 | + | ||||
| 1785 | + | if sameRegistry(registry, cfg.RegistryDomain) { | |||
| 1786 | + | return nil | |||
| 1787 | + | } | |||
| 1788 | + | dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry)) | |||
| 1789 | + | if err != nil { | |||
| 1790 | + | return fmt.Errorf("image %q rejected: registry %q could not be verified: %w", imageRef, registry, err) | |||
| 1791 | + | } | |||
| 1792 | + | if dangerous { | |||
| 1793 | + | return fmt.Errorf("image %q rejected: registry %q is not allowed", imageRef, registry) | |||
| 1794 | + | } | |||
| 1795 | + | if len(cfg.AllowedRegistries) == 0 { | |||
| 1796 | + | return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry) | |||
| 1797 | + | } | |||
| 1798 | + | for _, allowed := range cfg.AllowedRegistries { | |||
| 1799 | + | if sameRegistry(registry, allowed) { | |||
| 1800 | + | if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) { | |||
| 1801 | + | return fmt.Errorf("image %q rejected: digest reference required", imageRef) | |||
| 1802 | + | } | |||
| 1803 | + | return nil | |||
| 1804 | + | } | |||
| 1805 | + | } | |||
| 1806 | + | return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry) | |||
| 1807 | + | } | |||
| 1808 | + | ||||
| 1809 | + | func imageRegistry(imageRef string) string { | |||
| 1810 | + | slashIdx := strings.Index(imageRef, "/") | |||
| 1811 | + | if slashIdx == -1 { | |||
| 1812 | + | return "" | |||
| 1813 | + | } | |||
| 1814 | + | return normalizeRegistry(imageRef[:slashIdx]) | |||
| 1815 | + | } | |||
| 1816 | + | ||||
| 1817 | + | func imageRegistryForPolicy(imageRef string) string { | |||
| 1818 | + | if hasExplicitRegistry(imageRef) { | |||
| 1819 | + | return imageRegistry(imageRef) | |||
| 1820 | + | } | |||
| 1821 | + | return "docker.io" | |||
| 1822 | + | } | |||
| 1823 | + | ||||
| 1824 | + | // normalizeRegistry normalizes a registry string for comparison purposes only. | |||
| 1825 | + | // This function is used by sameRegistry to determine if two registry strings | |||
| 1826 | + | // refer to the same registry. The output intentionally strips IPv6 brackets | |||
| 1827 | + | // (e.g., "[fd00::1]:5000" -> "fd00::1:5000") which is acceptable for equality | |||
| 1828 | + | // checks but the result MUST NOT be used to construct or emit valid registry URLs. | |||
| 1829 | + | func normalizeRegistry(registry string) string { | |||
| 1830 | + | registry = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(registry), ".")) | |||
| 1831 | + | registry = strings.TrimSuffix(registry, "/") | |||
| 1832 | + | if strings.HasPrefix(registry, "[") { | |||
| 1833 | + | if end := strings.Index(registry, "]"); end != -1 { | |||
| 1834 | + | host := strings.Trim(registry[:end+1], "[]") | |||
| 1835 | + | port := registry[end+1:] | |||
| 1836 | + | return host + port | |||
| 1837 | + | } | |||
| 1838 | + | } | |||
| 1839 | + | return registry | |||
| 1840 | + | } | |||
| 1841 | + | ||||
| 1842 | + | func imageRegistryHost(registry string) string { | |||
| 1843 | + | if ip := net.ParseIP(registry); ip != nil { | |||
| 1844 | + | return registry | |||
| 1845 | + | } | |||
| 1846 | + | if h, _, err := net.SplitHostPort(registry); err == nil { | |||
| 1847 | + | return strings.Trim(h, "[]") | |||
| 1848 | + | } | |||
| 1849 | + | if strings.HasPrefix(registry, "[") && strings.Contains(registry, "]") { | |||
| 1850 | + | return strings.TrimPrefix(strings.Split(registry, "]")[0], "[") | |||
| 1851 | + | } | |||
| 1852 | + | if colon := strings.LastIndex(registry, ":"); colon != -1 && isNumeric(registry[colon+1:]) { | |||
| 1853 | + | return registry[:colon] | |||
| 1854 | + | } | |||
| 1855 | + | return registry | |||
| 1856 | + | } | |||
| 1857 | + | ||||
| 1858 | + | func sameRegistry(a, b string) bool { | |||
| 1859 | + | return normalizeRegistry(a) == normalizeRegistry(b) | |||
| 1860 | + | } | |||
| 1861 | + | ||||
| 1862 | + | func isDangerousRegistryHost(ctx context.Context, host string) (bool, error) { | |||
| 1863 | + | host = strings.ToLower(strings.TrimSuffix(host, ".")) | |||
| 1864 | + | if host == "localhost" || host == "metadata.google.internal" { | |||
| 1865 | + | return true, nil | |||
| 1866 | + | } | |||
| 1867 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 1868 | + | return isDangerousRegistryIP(ip), nil | |||
| 1869 | + | } | |||
| 1870 | + | addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) | |||
| 1871 | + | if err != nil { | |||
| 1872 | + | return false, err | |||
| 1873 | + | } | |||
| 1874 | + | for _, addr := range addrs { | |||
| 1875 | + | if isDangerousRegistryIP(addr.IP) { | |||
| 1876 | + | return true, nil | |||
| 1877 | + | } | |||
| 1878 | + | } | |||
| 1879 | + | return false, nil | |||
| 1880 | + | } | |||
| 1881 | + | ||||
| 1882 | + | func isDangerousRegistryIP(ip net.IP) bool { | |||
| 1883 | + | return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() | |||
| 1884 | + | } | |||
| 1885 | + | ||||
| 1886 | + | func applySecurityProfile(config *domain.ContainerConfig, cfg Config) { | |||
| 1887 | + | if strings.EqualFold(cfg.SecurityProfile, "strict") { | |||
| 1888 | + | config.ReadOnlyRootFS = true | |||
| 1889 | + | config.CapDrop = []string{"ALL"} | |||
| 1890 | + | config.CapAdd = []string{"NET_BIND_SERVICE"} | |||
| 1891 | + | } | |||
| 1892 | + | } | |||
| 1893 | + | ||||
| 1746 | // isNumeric checks if a string contains only digits. | 1894 | // isNumeric checks if a string contains only digits. | ||
| 1747 | func isNumeric(s string) bool { | 1895 | func isNumeric(s string) bool { | ||
| 1748 | for _, c := range s { | 1896 | for _, c := range s { | ||
| @@ -1767,7 +1915,16 @@ func normalizePullPolicy(policy string) string { | |||||
| 1767 | } | 1915 | } | ||
| 1768 | 1916 | ||||
| 1769 | func isDigestRef(imageRef string) bool { | 1917 | func isDigestRef(imageRef string) bool { | ||
| 1770 | - | return strings.Contains(imageRef, "@sha256:") | 1918 | + | return isValidSha256DigestRef(imageRef) |
| 1919 | + | } | |||
| 1920 | + | ||||
| 1921 | + | func isValidSha256DigestRef(imageRef string) bool { | |||
| 1922 | + | _, digest, ok := strings.Cut(imageRef, "@sha256:") | |||
| 1923 | + | if !ok || len(digest) != 64 { | |||
| 1924 | + | return false | |||
| 1925 | + | } | |||
| 1926 | + | _, err := hex.DecodeString(digest) | |||
| 1927 | + | return err == nil | |||
| 1771 | } | 1928 | } | ||
| 1772 | 1929 | ||||
| 1773 | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | 1930 | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | ||
| @@ -1794,6 +1951,9 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||||
| 1794 | 1951 | ||||
| 1795 | // Determine if this is an internal deploy and what reference to use for pulls. | 1952 | // Determine if this is an internal deploy and what reference to use for pulls. | ||
| 1796 | pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef) | 1953 | pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef) | ||
| 1954 | + | if err := s.validateImagePullRef(ctx, imageRef); err != nil { | |||
| 1955 | + | return "", err | |||
| 1956 | + | } | |||
| 1797 | if pullRef != imageRef { | 1957 | if pullRef != imageRef { | ||
| 1798 | log.Info(). | 1958 | log.Info(). | ||
| 1799 | Str("original_ref", imageRef). | 1959 | Str("original_ref", imageRef). | ||
| @@ -2101,7 +2261,10 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 2101 | } | 2261 | } | ||
| 2102 | 2262 | ||||
| 2103 | if !exists { | 2263 | if !exists { | ||
| 2104 | - | if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil { | 2264 | + | s.mu.RLock() |
| 2265 | + | internal := s.config.NetworkInternal | |||
| 2266 | + | s.mu.RUnlock() | |||
| 2267 | + | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil { | |||
| 2105 | return log.WrapErr(err, "failed to create network") | 2268 | return log.WrapErr(err, "failed to create network") | ||
| 2106 | } | 2269 | } | ||
| 2107 | log.Info().Msg("created network for app isolation") | 2270 | log.Info().Msg("created network for app isolation") | ||
| @@ -2415,31 +2578,23 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2415 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | 2578 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | ||
| 2416 | 2579 | ||||
| 2417 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | 2580 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | ||
| 2418 | - | imageRef := s.buildImageRef(serviceImage) | 2581 | + | imageRef, err := s.buildValidatedImageRef(ctx, serviceImage) |
| 2419 | - | actualImageRef, err := s.ensureImage(ctx, imageRef) | |||
| 2420 | if err != nil { | 2582 | if err != nil { | ||
| 2421 | return err | 2583 | return err | ||
| 2422 | } | 2584 | } | ||
| 2423 | - | 2585 | + | actualImageRef, err := s.ensureImage(ctx, imageRef) | |
| 2424 | - | // Get image metadata | |||
| 2425 | - | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef) | |||
| 2426 | if err != nil { | 2586 | if err != nil { | ||
| 2427 | - | log.WrapErr(err, "failed to get exposed ports for attachment, using defaults") | 2587 | + | return err |
| 2428 | - | exposedPorts = []int{} | |||
| 2429 | } | 2588 | } | ||
| 2430 | 2589 | ||||
| 2431 | - | // Setup volumes (attachments need persistent data) | 2590 | + | exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef) |
| 2432 | - | volumes, err := s.setupVolumes(ctx, containerName, actualImageRef) | 2591 | + | volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef) |
| 2433 | if err != nil { | 2592 | if err != nil { | ||
| 2434 | - | log.WrapErr(err, "failed to setup volumes for attachment") | 2593 | + | return err |
| 2435 | - | volumes = make(map[string]string) | |||
| 2436 | } | 2594 | } | ||
| 2437 | - | 2595 | + | envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef) | |
| 2438 | - | // Load environment (attachment-specific env file) | |||
| 2439 | - | envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef) | |||
| 2440 | if err != nil { | 2596 | if err != nil { | ||
| 2441 | - | log.WrapErr(err, "failed to load environment for attachment") | 2597 | + | return err |
| 2442 | - | envVars = []string{} | |||
| 2443 | } | 2598 | } | ||
| 2444 | envHash := hashEnvironment(envVars) | 2599 | envHash := hashEnvironment(envVars) | ||
| 2445 | 2600 | ||||
| @@ -2468,6 +2623,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2468 | NanoCPUs: cfg.DefaultNanoCPUs, | 2623 | NanoCPUs: cfg.DefaultNanoCPUs, | ||
| 2469 | PidsLimit: cfg.DefaultPidsLimit, | 2624 | PidsLimit: cfg.DefaultPidsLimit, | ||
| 2470 | } | 2625 | } | ||
| 2626 | + | applySecurityProfile(config, cfg) | |||
| 2471 | 2627 | ||||
| 2472 | container, err := s.runtime.CreateContainer(ctx, config) | 2628 | container, err := s.runtime.CreateContainer(ctx, config) | ||
| 2473 | if err != nil { | 2629 | if err != nil { | ||
| @@ -2502,8 +2658,38 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2502 | return nil | 2658 | return nil | ||
| 2503 | } | 2659 | } | ||
| 2504 | 2660 | ||||
| 2661 | + | func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) []int { | |||
| 2662 | + | log := zerowrap.FromCtx(ctx) | |||
| 2663 | + | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef) | |||
| 2664 | + | if err != nil { | |||
| 2665 | + | log.WrapErr(err, "failed to get exposed ports for attachment, using defaults") | |||
| 2666 | + | return []int{} | |||
| 2667 | + | } | |||
| 2668 | + | return exposedPorts | |||
| 2669 | + | } | |||
| 2670 | + | ||||
| 2671 | + | func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) (map[string]string, error) { | |||
| 2672 | + | volumes, err := s.setupVolumes(ctx, containerName, imageRef) | |||
| 2673 | + | if err != nil { | |||
| 2674 | + | return nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err) | |||
| 2675 | + | } | |||
| 2676 | + | return volumes, nil | |||
| 2677 | + | } | |||
| 2678 | + | ||||
| 2679 | + | func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) ([]string, error) { | |||
| 2680 | + | envVars, err := s.loadEnvironment(ctx, nil, containerName, imageRef) | |||
| 2681 | + | if err != nil { | |||
| 2682 | + | return nil, fmt.Errorf("failed to load environment for attachment %s with image %s: %w", containerName, imageRef, err) | |||
| 2683 | + | } | |||
| 2684 | + | return envVars, nil | |||
| 2685 | + | } | |||
| 2686 | + | ||||
| 2505 | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | 2687 | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | ||
| 2506 | - | currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage)) | 2688 | + | imageRef, err := s.buildValidatedImageRef(ctx, serviceImage) |
| 2689 | + | if err != nil { | |||
| 2690 | + | return false, err | |||
| 2691 | + | } | |||
| 2692 | + | currentEnv, err := s.loadEnvironment(ctx, nil, containerName, imageRef) | |||
| 2507 | if err != nil { | 2693 | if err != nil { | ||
| 2508 | return false, err | 2694 | return false, err | ||
| 2509 | } | 2695 | } | ||
| @@ -2598,6 +2784,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 2598 | // Utility functions | 2784 | // Utility functions | ||
| 2599 | 2785 | ||||
| 2600 | func normalizeImageRef(image string) string { | 2786 | func normalizeImageRef(image string) string { | ||
| 2787 | + | if isDigestRef(image) { | |||
| 2788 | + | return image | |||
| 2789 | + | } | |||
| 2790 | + | ||||
| 2601 | // Extract repository from image reference, handling port numbers correctly. | 2791 | // Extract repository from image reference, handling port numbers correctly. | ||
| 2602 | // Examples: | 2792 | // Examples: | ||
| 2603 | // nginx:latest -> docker.io/library/nginx | 2793 | // nginx:latest -> docker.io/library/nginx | ||
M internal/usecase/container/service_test.go +155 -6
| @@ -49,6 +49,7 @@ func dockerLogFrames(stream byte, lines ...string) io.ReadCloser { | |||||
| 49 | // suitable for unit tests that don't want to wait for real timeouts. | 49 | // suitable for unit tests that don't want to wait for real timeouts. | ||
| 50 | func testMinDelayConfig() Config { | 50 | func testMinDelayConfig() Config { | ||
| 51 | return Config{ | 51 | return Config{ | ||
| 52 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 52 | ReadinessDelay: time.Millisecond, | 53 | ReadinessDelay: time.Millisecond, | ||
| 53 | DrainDelay: time.Millisecond, | 54 | DrainDelay: time.Millisecond, | ||
| 54 | DrainDelayConfigured: true, | 55 | DrainDelayConfigured: true, | ||
| @@ -326,6 +327,7 @@ func TestService_Deploy_Success(t *testing.T) { | |||||
| 326 | eventBus := mocks.NewMockEventPublisher(t) | 327 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 327 | 328 | ||||
| 328 | config := Config{ | 329 | config := Config{ | ||
| 330 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 329 | NetworkIsolation: false, | 331 | NetworkIsolation: false, | ||
| 330 | VolumeAutoCreate: false, | 332 | VolumeAutoCreate: false, | ||
| 331 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 333 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| @@ -408,6 +410,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||||
| 408 | eventBus := mocks.NewMockEventPublisher(t) | 410 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 409 | 411 | ||||
| 410 | config := Config{ | 412 | config := Config{ | ||
| 413 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 411 | NetworkIsolation: false, | 414 | NetworkIsolation: false, | ||
| 412 | VolumeAutoCreate: false, | 415 | VolumeAutoCreate: false, | ||
| 413 | ReadinessDelay: time.Millisecond, | 416 | ReadinessDelay: time.Millisecond, | ||
| @@ -472,7 +475,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||||
| 472 | envLoader := mocks.NewMockEnvLoader(t) | 475 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 473 | eventBus := mocks.NewMockEventPublisher(t) | 476 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 474 | 477 | ||||
| 475 | - | config := Config{} | 478 | + | config := Config{AllowedRegistries: []string{"docker.io"}} |
| 476 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | 479 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | ||
| 477 | ctx := testContext() | 480 | ctx := testContext() | ||
| 478 | 481 | ||||
| @@ -514,7 +517,7 @@ func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing. | |||||
| 514 | envLoader := mocks.NewMockEnvLoader(t) | 517 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 515 | eventBus := mocks.NewMockEventPublisher(t) | 518 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 516 | 519 | ||||
| 517 | - | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil) | 520 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{AllowedRegistries: []string{"docker.io"}}, nil) |
| 518 | ctx := testContext() | 521 | ctx := testContext() | ||
| 519 | 522 | ||||
| 520 | route := domain.Route{ | 523 | route := domain.Route{ | ||
| @@ -555,6 +558,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing. | |||||
| 555 | eventBus := mocks.NewMockEventPublisher(t) | 558 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 556 | 559 | ||||
| 557 | config := Config{ | 560 | config := Config{ | ||
| 561 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 558 | ReadinessMode: "docker-health", | 562 | ReadinessMode: "docker-health", | ||
| 559 | HealthTimeout: 10 * time.Millisecond, | 563 | HealthTimeout: 10 * time.Millisecond, | ||
| 560 | ReadinessDelay: time.Millisecond, | 564 | ReadinessDelay: time.Millisecond, | ||
| @@ -613,6 +617,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing | |||||
| 613 | eventBus := mocks.NewMockEventPublisher(t) | 617 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 614 | 618 | ||||
| 615 | config := Config{ | 619 | config := Config{ | ||
| 620 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 616 | ReadinessMode: "docker-health", | 621 | ReadinessMode: "docker-health", | ||
| 617 | ReadinessDelay: time.Millisecond, | 622 | ReadinessDelay: time.Millisecond, | ||
| 618 | DrainDelay: time.Millisecond, | 623 | DrainDelay: time.Millisecond, | ||
| @@ -659,6 +664,7 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |||||
| 659 | runtime := mocks.NewMockContainerRuntime(t) | 664 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 660 | 665 | ||||
| 661 | config := Config{ | 666 | config := Config{ | ||
| 667 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 662 | RegistryAuthEnabled: true, | 668 | RegistryAuthEnabled: true, | ||
| 663 | ServiceTokenUsername: "gordon-service", | 669 | ServiceTokenUsername: "gordon-service", | ||
| 664 | ServiceToken: "service-token", | 670 | ServiceToken: "service-token", | ||
| @@ -678,6 +684,7 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |||||
| 678 | runtime := mocks.NewMockContainerRuntime(t) | 684 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 679 | 685 | ||||
| 680 | config := Config{ | 686 | config := Config{ | ||
| 687 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 681 | RegistryAuthEnabled: true, | 688 | RegistryAuthEnabled: true, | ||
| 682 | } | 689 | } | ||
| 683 | 690 | ||||
| @@ -694,6 +701,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | |||||
| 694 | runtime := mocks.NewMockContainerRuntime(t) | 701 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 695 | 702 | ||||
| 696 | config := Config{ | 703 | config := Config{ | ||
| 704 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 697 | RegistryAuthEnabled: true, | 705 | RegistryAuthEnabled: true, | ||
| 698 | InternalRegistryUsername: "internal", | 706 | InternalRegistryUsername: "internal", | ||
| 699 | InternalRegistryPassword: "secret", | 707 | InternalRegistryPassword: "secret", | ||
| @@ -721,6 +729,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 721 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 729 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 722 | 730 | ||||
| 723 | config := Config{ | 731 | config := Config{ | ||
| 732 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 724 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 733 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 725 | DrainDelay: time.Millisecond, | 734 | DrainDelay: time.Millisecond, | ||
| 726 | DrainDelayConfigured: true, | 735 | DrainDelayConfigured: true, | ||
| @@ -963,6 +972,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 963 | eventBus := mocks.NewMockEventPublisher(t) | 972 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 964 | 973 | ||||
| 965 | config := Config{ | 974 | config := Config{ | ||
| 975 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 966 | NetworkIsolation: true, | 976 | NetworkIsolation: true, | ||
| 967 | NetworkPrefix: "gordon", | 977 | NetworkPrefix: "gordon", | ||
| 968 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 978 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| @@ -989,7 +999,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 989 | 999 | ||||
| 990 | // Network isolation - should check and create network | 1000 | // Network isolation - should check and create network | ||
| 991 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil) | 1001 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil) | ||
| 992 | - | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil) | 1002 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil) |
| 993 | 1003 | ||||
| 994 | container := &domain.Container{ID: "container-123", Status: "created"} | 1004 | container := &domain.Container{ID: "container-123", Status: "created"} | ||
| 995 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | 1005 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | ||
| @@ -1020,6 +1030,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||||
| 1020 | eventBus := mocks.NewMockEventPublisher(t) | 1030 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1021 | 1031 | ||||
| 1022 | config := Config{ | 1032 | config := Config{ | ||
| 1033 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 1023 | VolumeAutoCreate: true, | 1034 | VolumeAutoCreate: true, | ||
| 1024 | VolumePrefix: "gordon", | 1035 | VolumePrefix: "gordon", | ||
| 1025 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 1036 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| @@ -1135,8 +1146,9 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) { | |||||
| 1135 | eventBus := mocks.NewMockEventPublisher(t) | 1146 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1136 | 1147 | ||||
| 1137 | config := Config{ | 1148 | config := Config{ | ||
| 1138 | - | NetworkIsolation: true, | 1149 | + | AllowedRegistries: []string{"docker.io"}, |
| 1139 | - | NetworkPrefix: "gordon", | 1150 | + | NetworkIsolation: true, |
| 1151 | + | NetworkPrefix: "gordon", | |||
| 1140 | } | 1152 | } | ||
| 1141 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | 1153 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | ||
| 1142 | ctx := testContext() | 1154 | ctx := testContext() | ||
| @@ -1663,6 +1675,16 @@ func TestNormalizeImageRef(t *testing.T) { | |||||
| 1663 | input: "registry.example.com:5000/image:v1.0", | 1675 | input: "registry.example.com:5000/image:v1.0", | ||
| 1664 | expected: "registry.example.com:5000/image", | 1676 | expected: "registry.example.com:5000/image", | ||
| 1665 | }, | 1677 | }, | ||
| 1678 | + | { | |||
| 1679 | + | name: "digest ref preserves digest", | |||
| 1680 | + | input: "registry.example.com/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |||
| 1681 | + | expected: "registry.example.com/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |||
| 1682 | + | }, | |||
| 1683 | + | { | |||
| 1684 | + | name: "docker hub digest ref preserves digest", | |||
| 1685 | + | input: "nginx@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", | |||
| 1686 | + | expected: "nginx@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", | |||
| 1687 | + | }, | |||
| 1666 | } | 1688 | } | ||
| 1667 | 1689 | ||||
| 1668 | for _, tt := range tests { | 1690 | for _, tt := range tests { | ||
| @@ -1932,6 +1954,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||||
| 1932 | eventBus := mocks.NewMockEventPublisher(t) | 1954 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1933 | 1955 | ||||
| 1934 | config := Config{ | 1956 | config := Config{ | ||
| 1957 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 1935 | RegistryAuthEnabled: true, | 1958 | RegistryAuthEnabled: true, | ||
| 1936 | RegistryDomain: "registry.example.com", | 1959 | RegistryDomain: "registry.example.com", | ||
| 1937 | RegistryPort: 5000, | 1960 | RegistryPort: 5000, | ||
| @@ -2011,6 +2034,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 2011 | eventBus := mocks.NewMockEventPublisher(t) | 2034 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2012 | 2035 | ||||
| 2013 | config := Config{ | 2036 | config := Config{ | ||
| 2037 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2014 | ReadinessDelay: time.Millisecond, | 2038 | ReadinessDelay: time.Millisecond, | ||
| 2015 | DrainDelay: time.Millisecond, | 2039 | DrainDelay: time.Millisecond, | ||
| 2016 | DrainDelayConfigured: true, | 2040 | DrainDelayConfigured: true, | ||
| @@ -2059,6 +2083,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 2059 | eventBus := mocks.NewMockEventPublisher(t) | 2083 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2060 | 2084 | ||||
| 2061 | config := Config{ | 2085 | config := Config{ | ||
| 2086 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2062 | ReadinessDelay: time.Millisecond, | 2087 | ReadinessDelay: time.Millisecond, | ||
| 2063 | DrainDelay: time.Millisecond, | 2088 | DrainDelay: time.Millisecond, | ||
| 2064 | DrainDelayConfigured: true, | 2089 | DrainDelayConfigured: true, | ||
| @@ -2106,7 +2131,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |||||
| 2106 | envLoader := mocks.NewMockEnvLoader(t) | 2131 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 2107 | eventBus := mocks.NewMockEventPublisher(t) | 2132 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2108 | 2133 | ||||
| 2109 | - | config := Config{} | 2134 | + | config := Config{AllowedRegistries: []string{"docker.io"}} |
| 2110 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | 2135 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | ||
| 2111 | ctx := testContext() | 2136 | ctx := testContext() | ||
| 2112 | 2137 | ||||
| @@ -2188,6 +2213,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 2188 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 2213 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 2189 | 2214 | ||||
| 2190 | config := Config{ | 2215 | config := Config{ | ||
| 2216 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2191 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 2217 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 2192 | DrainDelay: time.Millisecond, | 2218 | DrainDelay: time.Millisecond, | ||
| 2193 | DrainDelayConfigured: true, | 2219 | DrainDelayConfigured: true, | ||
| @@ -2282,6 +2308,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 2282 | eventBus := mocks.NewMockEventPublisher(t) | 2308 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2283 | 2309 | ||||
| 2284 | config := Config{ | 2310 | config := Config{ | ||
| 2311 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2285 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 2312 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 2286 | DrainDelay: time.Millisecond, | 2313 | DrainDelay: time.Millisecond, | ||
| 2287 | DrainDelayConfigured: true, | 2314 | DrainDelayConfigured: true, | ||
| @@ -2732,6 +2759,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||||
| 2732 | eventBus := mocks.NewMockEventPublisher(t) | 2759 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2733 | 2760 | ||||
| 2734 | config := Config{ | 2761 | config := Config{ | ||
| 2762 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2735 | ReadinessDelay: time.Millisecond, | 2763 | ReadinessDelay: time.Millisecond, | ||
| 2736 | DrainDelay: time.Millisecond, | 2764 | DrainDelay: time.Millisecond, | ||
| 2737 | DrainDelayConfigured: true, | 2765 | DrainDelayConfigured: true, | ||
| @@ -2890,6 +2918,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2890 | eventBus := mocks.NewMockEventPublisher(t) | 2918 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2891 | 2919 | ||||
| 2892 | config := Config{ | 2920 | config := Config{ | ||
| 2921 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2893 | ReadinessDelay: time.Millisecond, | 2922 | ReadinessDelay: time.Millisecond, | ||
| 2894 | DrainDelay: time.Millisecond, | 2923 | DrainDelay: time.Millisecond, | ||
| 2895 | DrainDelayConfigured: true, | 2924 | DrainDelayConfigured: true, | ||
| @@ -2949,6 +2978,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||||
| 2949 | eventBus := mocks.NewMockEventPublisher(t) | 2978 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2950 | 2979 | ||||
| 2951 | config := Config{ | 2980 | config := Config{ | ||
| 2981 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2952 | ReadinessDelay: time.Millisecond, | 2982 | ReadinessDelay: time.Millisecond, | ||
| 2953 | DrainDelay: time.Millisecond, | 2983 | DrainDelay: time.Millisecond, | ||
| 2954 | DrainDelayConfigured: true, | 2984 | DrainDelayConfigured: true, | ||
| @@ -3455,6 +3485,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) { | |||||
| 3455 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 3485 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 3456 | 3486 | ||||
| 3457 | config := Config{ | 3487 | config := Config{ | ||
| 3488 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3458 | ReadinessDelay: time.Millisecond, | 3489 | ReadinessDelay: time.Millisecond, | ||
| 3459 | DrainDelay: time.Millisecond, | 3490 | DrainDelay: time.Millisecond, | ||
| 3460 | DrainDelayConfigured: true, | 3491 | DrainDelayConfigured: true, | ||
| @@ -3548,6 +3579,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||||
| 3548 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 3579 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 3549 | 3580 | ||||
| 3550 | config := Config{ | 3581 | config := Config{ | ||
| 3582 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3551 | ReadinessDelay: time.Millisecond, | 3583 | ReadinessDelay: time.Millisecond, | ||
| 3552 | DrainDelay: time.Millisecond, | 3584 | DrainDelay: time.Millisecond, | ||
| 3553 | DrainDelayConfigured: true, | 3585 | DrainDelayConfigured: true, | ||
| @@ -3635,6 +3667,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) | |||||
| 3635 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 3667 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 3636 | 3668 | ||||
| 3637 | config := Config{ | 3669 | config := Config{ | ||
| 3670 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3638 | ReadinessDelay: time.Millisecond, | 3671 | ReadinessDelay: time.Millisecond, | ||
| 3639 | DrainDelay: time.Millisecond, | 3672 | DrainDelay: time.Millisecond, | ||
| 3640 | DrainDelayConfigured: true, | 3673 | DrainDelayConfigured: true, | ||
| @@ -3729,6 +3762,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) | |||||
| 3729 | eventBus := mocks.NewMockEventPublisher(t) | 3762 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 3730 | 3763 | ||||
| 3731 | config := Config{ | 3764 | config := Config{ | ||
| 3765 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3732 | ReadinessDelay: time.Millisecond, | 3766 | ReadinessDelay: time.Millisecond, | ||
| 3733 | DrainDelay: time.Millisecond, | 3767 | DrainDelay: time.Millisecond, | ||
| 3734 | DrainDelayConfigured: true, | 3768 | DrainDelayConfigured: true, | ||
| @@ -3818,6 +3852,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |||||
| 3818 | eventBus := mocks.NewMockEventPublisher(t) | 3852 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 3819 | 3853 | ||||
| 3820 | config := Config{ | 3854 | config := Config{ | ||
| 3855 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3821 | ReadinessDelay: time.Millisecond, | 3856 | ReadinessDelay: time.Millisecond, | ||
| 3822 | DrainDelay: time.Millisecond, | 3857 | DrainDelay: time.Millisecond, | ||
| 3823 | DrainDelayConfigured: true, | 3858 | DrainDelayConfigured: true, | ||
| @@ -3893,6 +3928,7 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) { | |||||
| 3893 | eventBus := mocks.NewMockEventPublisher(t) | 3928 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 3894 | 3929 | ||||
| 3895 | config := Config{ | 3930 | config := Config{ | ||
| 3931 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 3896 | NetworkIsolation: false, | 3932 | NetworkIsolation: false, | ||
| 3897 | VolumeAutoCreate: false, | 3933 | VolumeAutoCreate: false, | ||
| 3898 | ReadinessDelay: time.Millisecond, | 3934 | ReadinessDelay: time.Millisecond, | ||
| @@ -4168,3 +4204,116 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |||||
| 4168 | require.Len(t, attachIDs, 1) | 4204 | require.Len(t, attachIDs, 1) | ||
| 4169 | assert.Equal(t, "pg-container-1", attachIDs[0]) | 4205 | assert.Equal(t, "pg-container-1", attachIDs[0]) | ||
| 4170 | } | 4206 | } | ||
| 4207 | + | ||||
| 4208 | + | func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) { | |||
| 4209 | + | svc := NewService(nil, nil, nil, nil, Config{ | |||
| 4210 | + | RegistryAuthEnabled: true, | |||
| 4211 | + | RegistryDomain: "registry.example.com", | |||
| 4212 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 4213 | + | }, nil) | |||
| 4214 | + | ||||
| 4215 | + | ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest") | |||
| 4216 | + | require.NoError(t, err) | |||
| 4217 | + | assert.Equal(t, "registry.example.com/myapp:latest", ref) | |||
| 4218 | + | ||||
| 4219 | + | ref, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest") | |||
| 4220 | + | require.NoError(t, err) | |||
| 4221 | + | assert.Equal(t, "registry.example.com/myapp:latest", ref) | |||
| 4222 | + | ||||
| 4223 | + | ref, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest") | |||
| 4224 | + | require.NoError(t, err) | |||
| 4225 | + | assert.Equal(t, "docker.io/library/nginx:latest", ref) | |||
| 4226 | + | ||||
| 4227 | + | for _, image := range []string{ | |||
| 4228 | + | "ghcr.io/acme/app:latest", | |||
| 4229 | + | "localhost:5000/app:latest", | |||
| 4230 | + | "127.0.0.1:5000/app:latest", | |||
| 4231 | + | "10.0.0.2:5000/app:latest", | |||
| 4232 | + | "169.254.169.254/app:latest", | |||
| 4233 | + | } { | |||
| 4234 | + | t.Run(image, func(t *testing.T) { | |||
| 4235 | + | _, err := svc.buildValidatedImageRef(context.Background(), image) | |||
| 4236 | + | require.Error(t, err) | |||
| 4237 | + | }) | |||
| 4238 | + | } | |||
| 4239 | + | } | |||
| 4240 | + | ||||
| 4241 | + | func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) { | |||
| 4242 | + | svc := NewService(nil, nil, nil, nil, Config{}, nil) | |||
| 4243 | + | ||||
| 4244 | + | _, err := svc.buildValidatedImageRef(context.Background(), "ghcr.io/acme/app:latest") | |||
| 4245 | + | require.Error(t, err) | |||
| 4246 | + | assert.Contains(t, err.Error(), "images.allowed_registries") | |||
| 4247 | + | ||||
| 4248 | + | err = svc.validateImagePullRef(context.Background(), "nginx:latest") | |||
| 4249 | + | require.Error(t, err) | |||
| 4250 | + | assert.Contains(t, err.Error(), "docker.io") | |||
| 4251 | + | } | |||
| 4252 | + | ||||
| 4253 | + | func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) { | |||
| 4254 | + | svc := NewService(nil, nil, nil, nil, Config{ | |||
| 4255 | + | RegistryAuthEnabled: true, | |||
| 4256 | + | RegistryDomain: "registry.example.com", | |||
| 4257 | + | RequireImageDigest: true, | |||
| 4258 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 4259 | + | }, nil) | |||
| 4260 | + | ||||
| 4261 | + | _, err := svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest") | |||
| 4262 | + | require.Error(t, err) | |||
| 4263 | + | ||||
| 4264 | + | err = svc.validateImagePullRef(context.Background(), "nginx:latest") | |||
| 4265 | + | require.Error(t, err) | |||
| 4266 | + | ||||
| 4267 | + | _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") | |||
| 4268 | + | require.NoError(t, err) | |||
| 4269 | + | ||||
| 4270 | + | _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:not-a-digest") | |||
| 4271 | + | require.Error(t, err) | |||
| 4272 | + | ||||
| 4273 | + | ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest") | |||
| 4274 | + | require.NoError(t, err) | |||
| 4275 | + | assert.Equal(t, "registry.example.com/myapp:latest", ref) | |||
| 4276 | + | ||||
| 4277 | + | _, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest") | |||
| 4278 | + | require.NoError(t, err) | |||
| 4279 | + | } | |||
| 4280 | + | ||||
| 4281 | + | func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) { | |||
| 4282 | + | svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"docker.io:5000"}}, nil) | |||
| 4283 | + | ||||
| 4284 | + | _, err := svc.buildValidatedImageRef(context.Background(), "docker.io:5000/app:latest") | |||
| 4285 | + | require.NoError(t, err) | |||
| 4286 | + | ||||
| 4287 | + | _, err = svc.buildValidatedImageRef(context.Background(), "docker.io:5001/app:latest") | |||
| 4288 | + | require.Error(t, err) | |||
| 4289 | + | } | |||
| 4290 | + | ||||
| 4291 | + | func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) { | |||
| 4292 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4293 | + | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | |||
| 4294 | + | ||||
| 4295 | + | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil) | |||
| 4296 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil) | |||
| 4297 | + | ||||
| 4298 | + | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | |||
| 4299 | + | } | |||
| 4300 | + | ||||
| 4301 | + | func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) { | |||
| 4302 | + | svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil) | |||
| 4303 | + | ||||
| 4304 | + | cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"}) | |||
| 4305 | + | ||||
| 4306 | + | assert.True(t, cfg.ReadOnlyRootFS) | |||
| 4307 | + | assert.Equal(t, []string{"ALL"}, cfg.CapDrop) | |||
| 4308 | + | assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd) | |||
| 4309 | + | } | |||
| 4310 | + | ||||
| 4311 | + | func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) { | |||
| 4312 | + | svc := NewService(nil, nil, nil, nil, Config{}, nil) | |||
| 4313 | + | ||||
| 4314 | + | cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"}) | |||
| 4315 | + | ||||
| 4316 | + | assert.False(t, cfg.ReadOnlyRootFS) | |||
| 4317 | + | assert.Nil(t, cfg.CapDrop) | |||
| 4318 | + | assert.Nil(t, cfg.CapAdd) | |||
| 4319 | + | } | |||
M internal/usecase/proxy/service.go +52 -5
| @@ -3,6 +3,7 @@ package proxy | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "fmt" | |||
| 6 | "net" | 7 | "net" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "strconv" | 9 | "strconv" | ||
| @@ -65,6 +66,12 @@ func NewService( | |||||
| 65 | 66 | ||||
| 66 | // GetTarget returns the proxy target for a given domain. | 67 | // GetTarget returns the proxy target for a given domain. | ||
| 67 | func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) { | 68 | func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) { | ||
| 69 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |||
| 70 | + | if !ok { | |||
| 71 | + | return nil, domain.ErrNoTargetAvailable | |||
| 72 | + | } | |||
| 73 | + | domainName = canonicalDomain | |||
| 74 | + | ||||
| 68 | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | 75 | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | ||
| 69 | trace.WithAttributes(attribute.String("domain", domainName))) | 76 | trace.WithAttributes(attribute.String("domain", domainName))) | ||
| 70 | defer func() { | 77 | defer func() { | ||
| @@ -181,6 +188,9 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr | |||||
| 181 | if err != nil { | 188 | if err != nil { | ||
| 182 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | 189 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | ||
| 183 | } | 190 | } | ||
| 191 | + | if port < 1 || port > 65535 { | |||
| 192 | + | return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr) | |||
| 193 | + | } | |||
| 184 | 194 | ||||
| 185 | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | 195 | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | ||
| 186 | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | 196 | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | ||
| @@ -225,29 +235,44 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr | |||||
| 225 | 235 | ||||
| 226 | // RegisterTarget registers a new proxy target for a domain. | 236 | // RegisterTarget registers a new proxy target for a domain. | ||
| 227 | func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error { | 237 | func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error { | ||
| 238 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |||
| 239 | + | if !ok { | |||
| 240 | + | return domain.ErrRouteDomainInvalid | |||
| 241 | + | } | |||
| 242 | + | ||||
| 228 | s.mu.Lock() | 243 | s.mu.Lock() | ||
| 229 | defer s.mu.Unlock() | 244 | defer s.mu.Unlock() | ||
| 230 | 245 | ||||
| 231 | - | s.targets[domainName] = target | 246 | + | s.targets[canonicalDomain] = target |
| 232 | return nil | 247 | return nil | ||
| 233 | } | 248 | } | ||
| 234 | 249 | ||||
| 235 | // UnregisterTarget removes a proxy target for a domain. | 250 | // UnregisterTarget removes a proxy target for a domain. | ||
| 236 | func (s *Service) UnregisterTarget(_ context.Context, domainName string) error { | 251 | func (s *Service) UnregisterTarget(_ context.Context, domainName string) error { | ||
| 252 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |||
| 253 | + | if !ok { | |||
| 254 | + | return domain.ErrRouteDomainInvalid | |||
| 255 | + | } | |||
| 256 | + | ||||
| 237 | s.mu.Lock() | 257 | s.mu.Lock() | ||
| 238 | defer s.mu.Unlock() | 258 | defer s.mu.Unlock() | ||
| 239 | 259 | ||||
| 240 | - | delete(s.targets, domainName) | 260 | + | delete(s.targets, canonicalDomain) |
| 241 | return nil | 261 | return nil | ||
| 242 | } | 262 | } | ||
| 243 | 263 | ||||
| 244 | // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request. | 264 | // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request. | ||
| 245 | // This is used during zero-downtime deployments to switch traffic to a new container. | 265 | // This is used during zero-downtime deployments to switch traffic to a new container. | ||
| 246 | func (s *Service) InvalidateTarget(_ context.Context, domainName string) { | 266 | func (s *Service) InvalidateTarget(_ context.Context, domainName string) { | ||
| 267 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |||
| 268 | + | if !ok { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | ||||
| 247 | s.mu.Lock() | 272 | s.mu.Lock() | ||
| 248 | defer s.mu.Unlock() | 273 | defer s.mu.Unlock() | ||
| 249 | 274 | ||||
| 250 | - | delete(s.targets, domainName) | 275 | + | delete(s.targets, canonicalDomain) |
| 251 | } | 276 | } | ||
| 252 | 277 | ||||
| 253 | // WaitForNoInFlight waits until no requests are currently proxied to the | 278 | // WaitForNoInFlight waits until no requests are currently proxied to the | ||
| @@ -303,9 +328,31 @@ func (s *Service) UpdateConfig(config Config) { | |||||
| 303 | 328 | ||||
| 304 | // IsRegistryDomain returns true if the host matches the configured registry domain. | 329 | // IsRegistryDomain returns true if the host matches the configured registry domain. | ||
| 305 | func (s *Service) IsRegistryDomain(host string) bool { | 330 | func (s *Service) IsRegistryDomain(host string) bool { | ||
| 331 | + | canonicalHost, ok := domain.CanonicalRouteDomain(host) | |||
| 332 | + | if !ok { | |||
| 333 | + | return false | |||
| 334 | + | } | |||
| 335 | + | ||||
| 306 | s.mu.RLock() | 336 | s.mu.RLock() | ||
| 307 | - | defer s.mu.RUnlock() | 337 | + | registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain) |
| 308 | - | return s.config.RegistryDomain != "" && host == s.config.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 | |||
| 309 | } | 356 | } | ||
| 310 | 357 | ||||
| 311 | // TrackInFlight records an in-flight request for a container. | 358 | // TrackInFlight records an in-flight request for a container. | ||
M internal/usecase/proxy/service_test.go +51 -17
| @@ -47,6 +47,28 @@ func TestService_GetTarget_FromCache(t *testing.T) { | |||||
| 47 | assert.Equal(t, cachedTarget, result) | 47 | assert.Equal(t, cachedTarget, result) | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) { | |||
| 51 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 52 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 53 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 54 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 55 | + | ctx := testContext() | |||
| 56 | + | ||||
| 57 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |||
| 58 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | |||
| 59 | + | ||||
| 60 | + | result, err := svc.GetTarget(ctx, "App.Example.com") | |||
| 61 | + | assert.ErrorIs(t, err, domain.ErrNoTargetAvailable) | |||
| 62 | + | assert.Nil(t, result) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) { | |||
| 66 | + | svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{}) | |||
| 67 | + | result, err := svc.GetTarget(testContext(), "app.example.com:8080") | |||
| 68 | + | assert.ErrorIs(t, err, domain.ErrNoTargetAvailable) | |||
| 69 | + | assert.Nil(t, result) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 50 | func TestService_GetTarget_ContainerNotFound(t *testing.T) { | 72 | func TestService_GetTarget_ContainerNotFound(t *testing.T) { | ||
| 51 | runtime := outmocks.NewMockContainerRuntime(t) | 73 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 52 | containerSvc := inmocks.NewMockContainerService(t) | 74 | containerSvc := inmocks.NewMockContainerService(t) | ||
| @@ -169,22 +191,34 @@ func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |||||
| 169 | } | 191 | } | ||
| 170 | 192 | ||||
| 171 | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | 193 | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | ||
| 172 | - | runtime := outmocks.NewMockContainerRuntime(t) | 194 | + | tests := []struct { |
| 173 | - | containerSvc := inmocks.NewMockContainerService(t) | 195 | + | name string |
| 174 | - | configSvc := inmocks.NewMockConfigService(t) | 196 | + | target string |
| 197 | + | }{ | |||
| 198 | + | {name: "not a number", target: "localhost:abc"}, | |||
| 199 | + | {name: "zero", target: "localhost:0"}, | |||
| 200 | + | {name: "too high", target: "localhost:65536"}, | |||
| 201 | + | } | |||
| 175 | 202 | ||||
| 176 | - | svc := NewService(runtime, containerSvc, configSvc, Config{}) | 203 | + | for _, tt := range tests { |
| 177 | - | ctx := testContext() | 204 | + | t.Run(tt.name, func(t *testing.T) { |
| 205 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 206 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 207 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 178 | 208 | ||||
| 179 | - | // Mock external routes with invalid port | 209 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) |
| 180 | - | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | 210 | + | ctx := testContext() |
| 181 | - | "invalid.example.com": "localhost:abc", | |||
| 182 | - | }) | |||
| 183 | 211 | ||||
| 184 | - | result, err := svc.GetTarget(ctx, "invalid.example.com") | 212 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ |
| 213 | + | "invalid.example.com": tt.target, | |||
| 214 | + | }) | |||
| 185 | 215 | ||||
| 186 | - | assert.Error(t, err) | 216 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") |
| 187 | - | assert.Nil(t, result) | 217 | + | |
| 218 | + | assert.Error(t, err) | |||
| 219 | + | assert.Nil(t, result) | |||
| 220 | + | }) | |||
| 221 | + | } | |||
| 188 | } | 222 | } | ||
| 189 | 223 | ||||
| 190 | func TestService_RegisterTarget(t *testing.T) { | 224 | func TestService_RegisterTarget(t *testing.T) { | ||
| @@ -202,11 +236,11 @@ func TestService_RegisterTarget(t *testing.T) { | |||||
| 202 | Scheme: "http", | 236 | Scheme: "http", | ||
| 203 | } | 237 | } | ||
| 204 | 238 | ||||
| 205 | - | err := svc.RegisterTarget(ctx, "app.example.com", target) | 239 | + | err := svc.RegisterTarget(ctx, "App.Example.com", target) |
| 206 | 240 | ||||
| 207 | assert.NoError(t, err) | 241 | assert.NoError(t, err) | ||
| 208 | 242 | ||||
| 209 | - | // Verify target is cached | 243 | + | // Verify target is cached under the canonical domain. |
| 210 | svc.mu.RLock() | 244 | svc.mu.RLock() | ||
| 211 | cached := svc.targets["app.example.com"] | 245 | cached := svc.targets["app.example.com"] | ||
| 212 | svc.mu.RUnlock() | 246 | svc.mu.RUnlock() | ||
| @@ -228,7 +262,7 @@ func TestService_UnregisterTarget(t *testing.T) { | |||||
| 228 | Port: 8080, | 262 | Port: 8080, | ||
| 229 | } | 263 | } | ||
| 230 | 264 | ||||
| 231 | - | err := svc.UnregisterTarget(ctx, "app.example.com") | 265 | + | err := svc.UnregisterTarget(ctx, "App.Example.com") |
| 232 | 266 | ||||
| 233 | assert.NoError(t, err) | 267 | assert.NoError(t, err) | ||
| 234 | 268 | ||||
| @@ -313,8 +347,8 @@ func TestService_InvalidateTarget(t *testing.T) { | |||||
| 313 | ContainerID: "old-container", | 347 | ContainerID: "old-container", | ||
| 314 | } | 348 | } | ||
| 315 | 349 | ||||
| 316 | - | // Invalidate the target | 350 | + | // Invalidate the target using mixed-case input. |
| 317 | - | svc.InvalidateTarget(ctx, "app.example.com") | 351 | + | svc.InvalidateTarget(ctx, "App.Example.com") |
| 318 | 352 | ||||
| 319 | // Verify target is removed from cache | 353 | // Verify target is removed from cache | ||
| 320 | svc.mu.RLock() | 354 | svc.mu.RLock() | ||
M internal/usecase/registry/service.go +2 -2
| @@ -304,7 +304,7 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error) | |||||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | // AppendBlobChunk appends data to an in-progress blob upload. | 306 | // AppendBlobChunk appends data to an in-progress blob upload. | ||
| 307 | - | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) { | 307 | + | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) { |
| 308 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 308 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 309 | zerowrap.FieldLayer: "usecase", | 309 | zerowrap.FieldLayer: "usecase", | ||
| 310 | zerowrap.FieldUseCase: "AppendBlobChunk", | 310 | zerowrap.FieldUseCase: "AppendBlobChunk", | ||
| @@ -313,7 +313,7 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i | |||||
| 313 | }) | 313 | }) | ||
| 314 | log := zerowrap.FromCtx(ctx) | 314 | log := zerowrap.FromCtx(ctx) | ||
| 315 | 315 | ||||
| 316 | - | length, err := s.blobStorage.AppendBlobChunk(name, uuid, data) | 316 | + | length, err := s.blobStorage.AppendBlobChunk(name, uuid, data, contentLength, maxBlobSize) |
| 317 | if err != nil { | 317 | if err != nil { | ||
| 318 | return 0, log.WrapErr(err, "failed to append blob chunk") | 318 | return 0, log.WrapErr(err, "failed to append blob chunk") | ||
| 319 | } | 319 | } | ||
M internal/usecase/registry/service_test.go +32 -0
| @@ -306,6 +306,38 @@ func TestService_StartUpload_Error(t *testing.T) { | |||||
| 306 | assert.Contains(t, err.Error(), "failed to start blob upload") | 306 | assert.Contains(t, err.Error(), "failed to start blob upload") | ||
| 307 | } | 307 | } | ||
| 308 | 308 | ||||
| 309 | + | func TestService_AppendBlobChunk_PassesMaxBlobSize(t *testing.T) { | |||
| 310 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 311 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 312 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 313 | + | ||||
| 314 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 315 | + | ctx := testContext() | |||
| 316 | + | ||||
| 317 | + | blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(4), int64(10)).Return(int64(4), nil) | |||
| 318 | + | ||||
| 319 | + | length, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("data"), 4, 10) | |||
| 320 | + | ||||
| 321 | + | assert.NoError(t, err) | |||
| 322 | + | assert.Equal(t, int64(4), length) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func TestService_AppendBlobChunk_MaxBlobSizeExceeded(t *testing.T) { | |||
| 326 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 327 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 328 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 329 | + | ||||
| 330 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 331 | + | ctx := testContext() | |||
| 332 | + | ||||
| 333 | + | blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(11), int64(10)).Return(int64(0), domain.ErrBlobSizeExceeded) | |||
| 334 | + | ||||
| 335 | + | _, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("too large"), 11, 10) | |||
| 336 | + | ||||
| 337 | + | require.Error(t, err) | |||
| 338 | + | assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 309 | func TestService_FinishUpload_Success(t *testing.T) { | 341 | func TestService_FinishUpload_Success(t *testing.T) { | ||
| 310 | blobStorage := mocks.NewMockBlobStorage(t) | 342 | blobStorage := mocks.NewMockBlobStorage(t) | ||
| 311 | manifestStorage := mocks.NewMockManifestStorage(t) | 343 | manifestStorage := mocks.NewMockManifestStorage(t) | ||
M internal/usecase/volumes/service.go +8 -1
| @@ -54,7 +54,7 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||||
| 54 | var removed []*domain.VolumeInfo | 54 | var removed []*domain.VolumeInfo | ||
| 55 | 55 | ||||
| 56 | for _, vol := range vols { | 56 | for _, vol := range vols { | ||
| 57 | - | if vol.InUse { | 57 | + | if !isGordonManagedVolume(vol) || vol.InUse { |
| 58 | continue | 58 | continue | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| @@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||||
| 72 | 72 | ||||
| 73 | return report, removed, nil | 73 | return report, removed, nil | ||
| 74 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | func isGordonManagedVolume(vol *domain.VolumeInfo) bool { | |||
| 77 | + | if vol == nil || vol.Labels == nil { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | return vol.Labels[domain.LabelManaged] == "true" | |||
| 81 | + | } | |||
M internal/usecase/volumes/service_test.go +46 -5
| @@ -31,8 +31,8 @@ func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) { | |||||
| 31 | runtime := outmocks.NewMockContainerRuntime(t) | 31 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 32 | 32 | ||||
| 33 | vols := []*domain.VolumeInfo{ | 33 | vols := []*domain.VolumeInfo{ | ||
| 34 | - | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}}, | 34 | + | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 35 | - | {Name: "vol-orphaned", InUse: false, Size: 1024}, | 35 | + | {Name: "vol-orphaned", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 36 | } | 36 | } | ||
| 37 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 37 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 38 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil) | 38 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil) | ||
| @@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||||
| 50 | runtime := outmocks.NewMockContainerRuntime(t) | 50 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 51 | 51 | ||||
| 52 | vols := []*domain.VolumeInfo{ | 52 | vols := []*domain.VolumeInfo{ | ||
| 53 | - | {Name: "vol-orphaned", InUse: false, Size: 2048}, | 53 | + | {Name: "vol-orphaned", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 54 | } | 54 | } | ||
| 55 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 55 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 56 | 56 | ||||
| @@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||||
| 62 | assert.Len(t, removed, 1) | 62 | assert.Len(t, removed, 1) | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestService_PruneVolumes_OnlyRemovesUnusedGordonManagedVolumes(t *testing.T) { | |||
| 66 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 67 | + | ||||
| 68 | + | vols := []*domain.VolumeInfo{ | |||
| 69 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 70 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 71 | + | {Name: "other-unused", InUse: false, Size: 300}, | |||
| 72 | + | {Name: "other-labelled-false", InUse: false, Size: 400, Labels: map[string]string{domain.LabelManaged: "false"}}, | |||
| 73 | + | {Name: "other-used", InUse: true, Size: 500}, | |||
| 74 | + | } | |||
| 75 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |||
| 76 | + | runtime.EXPECT().RemoveVolume(context.Background(), "gordon-unused", false).Return(nil) | |||
| 77 | + | ||||
| 78 | + | svc := NewService(runtime) | |||
| 79 | + | report, removed, err := svc.PruneVolumes(context.Background(), false) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | assert.Equal(t, 1, report.VolumesRemoved) | |||
| 82 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |||
| 83 | + | require.Len(t, removed, 1) | |||
| 84 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestService_PruneVolumes_DryRunReportsOnlyUnusedGordonManagedVolumes(t *testing.T) { | |||
| 88 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 89 | + | ||||
| 90 | + | vols := []*domain.VolumeInfo{ | |||
| 91 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 92 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 93 | + | {Name: "other-unused", InUse: false, Size: 300}, | |||
| 94 | + | } | |||
| 95 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |||
| 96 | + | ||||
| 97 | + | svc := NewService(runtime) | |||
| 98 | + | report, removed, err := svc.PruneVolumes(context.Background(), true) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Equal(t, 1, report.VolumesRemoved) | |||
| 101 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |||
| 102 | + | require.Len(t, removed, 1) | |||
| 103 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 65 | func TestService_PruneVolumes_AllInUse(t *testing.T) { | 106 | func TestService_PruneVolumes_AllInUse(t *testing.T) { | ||
| 66 | runtime := outmocks.NewMockContainerRuntime(t) | 107 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 67 | 108 | ||||
| @@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) { | |||||
| 91 | runtime := outmocks.NewMockContainerRuntime(t) | 132 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 92 | 133 | ||||
| 93 | vols := []*domain.VolumeInfo{ | 134 | vols := []*domain.VolumeInfo{ | ||
| 94 | - | {Name: "vol-fail", InUse: false, Size: 1024}, | 135 | + | {Name: "vol-fail", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 95 | - | {Name: "vol-ok", InUse: false, Size: 2048}, | 136 | + | {Name: "vol-ok", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 96 | } | 137 | } | ||
| 97 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 138 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 98 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use")) | 139 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use")) | ||