fix: harden install and supply chain defaults
- commit
- e73e6fcbdaca4af3335f13ebb400048dde9b80d7
- author
- brice · · 2026-04-25 10:28 UTC
- committer
- brice · · 2026-04-25 10:36 UTC
- parent
- 56bea54
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +355 -37
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 | |
| @@ -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 +57 -9
| @@ -26,6 +26,8 @@ runs: | |||
| 26 | 26 | - name: Install Gordon | |
| 27 | 27 | id: install | |
| 28 | 28 | shell: bash | |
| 29 | + | env: | |
| 30 | + | GORDON_VERSION_INPUT: ${{ inputs.version }} | |
| 29 | 31 | run: | | |
| 30 | 32 | set -euo pipefail | |
| 31 | 33 | ||
| @@ -43,7 +45,11 @@ runs: | |||
| 43 | 45 | *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;; | |
| 44 | 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 | 54 | # Resolve 'latest' to an actual tag via redirect URL | |
| 49 | 55 | if [ "${version}" = "latest" ]; then | |
| @@ -53,24 +59,66 @@ runs: | |||
| 53 | 59 | echo "Failed to resolve latest Gordon version" >&2 | |
| 54 | 60 | exit 1 | |
| 55 | 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 | 66 | fi | |
| 57 | 67 | ||
| 58 | 68 | archive="gordon_${os}_${arch}.tar.gz" | |
| 59 | - | url="https://github.com/bnema/gordon/releases/download/${version}/${archive}" | |
| 60 | - | dest="/tmp/${archive}" | |
| 69 | + | base_url="https://github.com/bnema/gordon/releases/download/${version}" | |
| 70 | + | url="${base_url}/${archive}" | |
| 71 | + | checksums_url="${base_url}/checksums.txt" | |
| 72 | + | work_dir="$(mktemp -d)" | |
| 73 | + | dest="${work_dir}/${archive}" | |
| 74 | + | checksums="${work_dir}/checksums.txt" | |
| 75 | + | extract_dir="${work_dir}/extract" | |
| 61 | 76 | install_path="/usr/local/bin/gordon" | |
| 77 | + | trap 'rm -rf "${work_dir}"' EXIT | |
| 62 | 78 | ||
| 63 | 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 | 83 | curl -fsSL -o "${dest}" "${url}" | |
| 67 | 84 | ||
| 68 | - | # Extract just the gordon binary and clean up | |
| 69 | - | tar -xzf "${dest}" -C /usr/local/bin/ gordon | |
| 70 | - | rm -f "${dest}" | |
| 85 | + | # Verify checksum before extraction | |
| 86 | + | checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive}$" "${checksums}" || true)" | |
| 87 | + | if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then | |
| 88 | + | echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2 | |
| 89 | + | exit 1 | |
| 90 | + | fi | |
| 91 | + | expected="$(printf '%s\n' "${checksum_lines}" | awk '{print $1}')" | |
| 92 | + | if command -v sha256sum >/dev/null 2>&1; then | |
| 93 | + | actual="$(sha256sum "${dest}" | awk '{print $1}')" | |
| 94 | + | elif command -v shasum >/dev/null 2>&1; then | |
| 95 | + | actual="$(shasum -a 256 "${dest}" | awk '{print $1}')" | |
| 96 | + | else | |
| 97 | + | echo "Neither sha256sum nor shasum is available for checksum verification" >&2 | |
| 98 | + | exit 1 | |
| 99 | + | fi | |
| 100 | + | if [ "${expected}" != "${actual}" ]; then | |
| 101 | + | echo "Checksum verification failed for ${archive}" >&2 | |
| 102 | + | echo "Expected: ${expected}" >&2 | |
| 103 | + | echo "Actual: ${actual}" >&2 | |
| 104 | + | exit 1 | |
| 105 | + | fi | |
| 106 | + | ||
| 107 | + | # Extract just the gordon binary and reject symlinks/directories | |
| 108 | + | mkdir -p "${extract_dir}" | |
| 109 | + | tar -xzf "${dest}" -C "${extract_dir}" gordon | |
| 110 | + | binary="${extract_dir}/gordon" | |
| 111 | + | if [ ! -f "${binary}" ] || [ -L "${binary}" ]; then | |
| 112 | + | echo "Archive did not contain a regular Gordon binary" >&2 | |
| 113 | + | exit 1 | |
| 114 | + | fi | |
| 71 | 115 | ||
| 72 | - | # Make executable and verify | |
| 73 | - | chmod +x "${install_path}" | |
| 116 | + | # Install with explicit executable permissions and verify | |
| 117 | + | if [ -w "$(dirname "${install_path}")" ]; then | |
| 118 | + | install -m 0755 "${binary}" "${install_path}" | |
| 119 | + | else | |
| 120 | + | sudo install -m 0755 "${binary}" "${install_path}" | |
| 121 | + | fi | |
| 74 | 122 | gordon version | |
| 75 | 123 | ||
| 76 | 124 | # Set outputs | |
| @@ -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,66 @@ 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 | + | checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive}$" "${checksums}" || true)" |
| 70 | - | rm -f "${dest}" | 87 | + | if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then |
| 88 | + | echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2 | |||
| 89 | + | exit 1 | |||
| 90 | + | fi | |||
| 91 | + | expected="$(printf '%s\n' "${checksum_lines}" | awk '{print $1}')" | |||
| 92 | + | if command -v sha256sum >/dev/null 2>&1; then | |||
| 93 | + | actual="$(sha256sum "${dest}" | awk '{print $1}')" | |||
| 94 | + | elif command -v shasum >/dev/null 2>&1; then | |||
| 95 | + | actual="$(shasum -a 256 "${dest}" | awk '{print $1}')" | |||
| 96 | + | else | |||
| 97 | + | echo "Neither sha256sum nor shasum is available for checksum verification" >&2 | |||
| 98 | + | exit 1 | |||
| 99 | + | fi | |||
| 100 | + | if [ "${expected}" != "${actual}" ]; then | |||
| 101 | + | echo "Checksum verification failed for ${archive}" >&2 | |||
| 102 | + | echo "Expected: ${expected}" >&2 | |||
| 103 | + | echo "Actual: ${actual}" >&2 | |||
| 104 | + | exit 1 | |||
| 105 | + | fi | |||
| 106 | + | ||||
| 107 | + | # Extract just the gordon binary and reject symlinks/directories | |||
| 108 | + | mkdir -p "${extract_dir}" | |||
| 109 | + | tar -xzf "${dest}" -C "${extract_dir}" gordon | |||
| 110 | + | binary="${extract_dir}/gordon" | |||
| 111 | + | if [ ! -f "${binary}" ] || [ -L "${binary}" ]; then | |||
| 112 | + | echo "Archive did not contain a regular Gordon binary" >&2 | |||
| 113 | + | exit 1 | |||
| 114 | + | fi | |||
| 71 | 115 | ||||
| 72 | - | # Make executable and verify | 116 | + | # Install with explicit executable permissions and verify |
| 73 | - | chmod +x "${install_path}" | 117 | + | if [ -w "$(dirname "${install_path}")" ]; then |
| 118 | + | install -m 0755 "${binary}" "${install_path}" | |||
| 119 | + | else | |||
| 120 | + | sudo install -m 0755 "${binary}" "${install_path}" | |||
| 121 | + | fi | |||
| 74 | gordon version | 122 | gordon version | ||
| 75 | 123 | ||||
| 76 | # Set outputs | 124 | # 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 | + | ] | |
| @@ -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 | 2 | # Multi-stage build for optimized container image | |
| 3 | 3 | ||
| 4 | 4 | # Build stage | |
| 5 | - | FROM golang:1.25-alpine AS builder | |
| 5 | + | FROM golang:1.26.1-alpine3.22 AS builder | |
| 6 | 6 | ||
| 7 | 7 | # Install build dependencies | |
| 8 | 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 | 26 | -o gordon . | |
| 27 | 27 | ||
| 28 | 28 | # Runtime stage | |
| 29 | - | FROM alpine:latest | |
| 29 | + | FROM alpine:3.22 | |
| 30 | 30 | ||
| 31 | 31 | # Install runtime dependencies | |
| 32 | 32 | RUN apk add --no-cache \ | |
| @@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example | |||
| 56 | 56 | USER gordon | |
| 57 | 57 | ||
| 58 | 58 | # Expose ports | |
| 59 | - | EXPOSE 8080 5000 | |
| 59 | + | EXPOSE 8088 5000 | |
| 60 | 60 | ||
| 61 | - | # Health check | |
| 62 | - | HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ | |
| 63 | - | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | |
| 61 | + | # Admin health is served on the registry/admin listener and is gated by auth in | |
| 62 | + | # normal deployments, so this image does not declare a Docker healthcheck. | |
| 64 | 63 | ||
| 65 | 64 | # Default command | |
| 66 | 65 | CMD ["./gordon", "serve"] | |
| @@ -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/config/index.md +18 -1
| @@ -63,6 +63,10 @@ drain_mode = "auto" # auto, inflight, delay | |||
| 63 | 63 | drain_timeout = "30s" # Max wait for in-flight drain | |
| 64 | 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 | 70 | # Logging | |
| 67 | 71 | [logging] | |
| 68 | 72 | level = "info" # trace, debug, info, warn, error | |
| @@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal | |||
| 106 | 110 | [network_isolation] | |
| 107 | 111 | enabled = true # Per-app isolated networks | |
| 108 | 112 | network_prefix = "gordon" # Network name prefix | |
| 113 | + | internal = false # Set true to block direct egress from isolated networks | |
| 109 | 114 | ||
| 110 | 115 | # Auto-route | |
| 111 | 116 | [auto_route] | |
| @@ -138,6 +143,10 @@ weekly = 4 | |||
| 138 | 143 | monthly = 12 | |
| 139 | 144 | ||
| 140 | 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 | 150 | [images.prune] | |
| 142 | 151 | enabled = false | |
| 143 | 152 | schedule = "daily" | |
| @@ -164,6 +173,7 @@ keep_last = 3 | |||
| 164 | 173 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 165 | 174 | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 166 | 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 | 178 | ## Default Values | |
| 169 | 179 | ||
| @@ -172,6 +182,8 @@ keep_last = 3 | |||
| 172 | 182 | | `server.port` | `8088` | | |
| 173 | 183 | | `server.registry_port` | `5000` | | |
| 174 | 184 | | `server.data_dir` | `~/.gordon` | | |
| 185 | + | | `server.max_blob_chunk_size` | `"95MB"` | | |
| 186 | + | | `server.max_blob_size` | `"1GB"` | | |
| 175 | 187 | | `auth.enabled` | `true` | | |
| 176 | 188 | | `auth.secrets_backend` | `"unsafe"` | | |
| 177 | 189 | | `auth.token_expiry` | `"30d"` | | |
| @@ -187,6 +199,7 @@ keep_last = 3 | |||
| 187 | 199 | | `deploy.drain_mode` | `"auto"` | | |
| 188 | 200 | | `deploy.drain_timeout` | `"30s"` | | |
| 189 | 201 | | `deploy.drain_delay` | `"2s"` | | |
| 202 | + | | `containers.security_profile` | `"compat"` | | |
| 190 | 203 | | `logging.level` | `"info"` | | |
| 191 | 204 | | `logging.format` | `"console"` | | |
| 192 | 205 | | `logging.file.enabled` | `false` | | |
| @@ -198,9 +211,12 @@ keep_last = 3 | |||
| 198 | 211 | | `volumes.prefix` | `"gordon"` | | |
| 199 | 212 | | `volumes.preserve` | `true` | | |
| 200 | 213 | | `network_isolation.enabled` | `true` | | |
| 214 | + | | `network_isolation.internal` | `false` | | |
| 201 | 215 | | `auto_route.enabled` | `false` | | |
| 202 | 216 | | `backups.enabled` | `false` | | |
| 203 | 217 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | |
| 218 | + | | `images.allowed_registries` | `[]` | | |
| 219 | + | | `images.require_digest` | `false` | | |
| 204 | 220 | | `images.prune.enabled` | `false` | | |
| 205 | 221 | | `images.prune.schedule` | `"daily"` | | |
| 206 | 222 | | `images.prune.keep_last` | `3` | | |
| @@ -229,7 +245,6 @@ gordon reload | |||
| 229 | 245 | | `server.gordon_domain` (registry domain) | | |
| 230 | 246 | | `server.registry_port` | | |
| 231 | 247 | | `server.max_proxy_body_size` | | |
| 232 | - | | `server.max_blob_chunk_size` | | |
| 233 | 248 | | `server.max_proxy_response_size` | | |
| 234 | 249 | | `server.max_concurrent_conns` | | |
| 235 | 250 | ||
| @@ -241,6 +256,8 @@ gordon reload | |||
| 241 | 256 | |---------| | |
| 242 | 257 | | `server.port` | | |
| 243 | 258 | | `server.data_dir` | | |
| 259 | + | | `server.max_blob_chunk_size` | | |
| 260 | + | | `server.max_blob_size` | | |
| 244 | 261 | | `auth.*` | | |
| 245 | 262 | | `deploy.readiness_mode` | | |
| 246 | 263 | | `deploy.readiness_delay` | | |
| @@ -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/reference.md +4 -2
| @@ -17,7 +17,8 @@ tls_key_file = "" # PEM key path (optional, must be s | |||
| 17 | 17 | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) | |
| 18 | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| 19 | 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 | 22 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | |
| 22 | 23 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | |
| 23 | 24 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | |
| @@ -189,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 189 | 190 | | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) | | |
| 190 | 191 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 191 | 192 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 192 | - | | `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 | | |
| 193 | 195 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 194 | 196 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 195 | 197 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |
| @@ -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) | ||
| @@ -189,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 189 | | `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) | | ||
| 190 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 191 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 191 | | `server.data_dir` | `~/.gordon` | Data directory | | 192 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 192 | - | | `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 | | |||
| 193 | | `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) | | ||
| 194 | | `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) | | ||
| 195 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | 197 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | ||
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) | |
| @@ -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 | 14 | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | |
| 15 | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |
| 16 | 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 | 21 | ## Options | |
| @@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 31 | 33 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 32 | 34 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 33 | 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 | 37 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 35 | 38 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 36 | 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 | 277 | - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine | |
| 275 | 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 | 296 | ## Registry IP Allowlist | |
| 278 | 297 | ||
| 279 | 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). | |
| @@ -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 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"] | |
| @@ -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 +17 -7
| @@ -101,11 +101,13 @@ fi | |||
| 101 | 101 | ||
| 102 | 102 | # Verify checksum | |
| 103 | 103 | echo "Verifying checksum..." | |
| 104 | - | EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}') | |
| 105 | - | if [ -z "$EXPECTED_CHECKSUM" ]; then | |
| 106 | - | echo "Error: Could not find checksum for ${TARBALL} in checksums.txt" | |
| 104 | + | CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL}\$" "$TMP_DIR/checksums.txt" || true) | |
| 105 | + | CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ') | |
| 106 | + | if [ "$CHECKSUM_COUNT" != "1" ]; then | |
| 107 | + | echo "Error: Expected exactly one checksum for ${TARBALL} in checksums.txt" | |
| 107 | 108 | exit 1 | |
| 108 | 109 | fi | |
| 110 | + | EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}') | |
| 109 | 111 | ||
| 110 | 112 | # Calculate actual checksum (works on both Linux and macOS) | |
| 111 | 113 | if command -v sha256sum >/dev/null 2>&1; then | |
| @@ -132,15 +134,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then | |||
| 132 | 134 | fi | |
| 133 | 135 | echo "Checksum verified successfully." | |
| 134 | 136 | ||
| 135 | - | echo "Extracting..." | |
| 136 | - | tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" | |
| 137 | + | echo "Extracting Gordon binary..." | |
| 138 | + | EXTRACT_DIR="$TMP_DIR/extract" | |
| 139 | + | mkdir -p "$EXTRACT_DIR" | |
| 140 | + | tar -xzf "$TMP_DIR/$TARBALL" -C "$EXTRACT_DIR" gordon | |
| 141 | + | ||
| 142 | + | BINARY="$EXTRACT_DIR/gordon" | |
| 143 | + | if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then | |
| 144 | + | echo "Error: Archive did not contain a regular Gordon binary" | |
| 145 | + | exit 1 | |
| 146 | + | fi | |
| 137 | 147 | ||
| 138 | 148 | echo "Installing to ${INSTALL_DIR}..." | |
| 139 | 149 | if [ -w "$INSTALL_DIR" ]; then | |
| 140 | - | mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | |
| 150 | + | install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 141 | 151 | else | |
| 142 | 152 | echo "sudo required to install to ${INSTALL_DIR}" | |
| 143 | - | sudo mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | |
| 153 | + | sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 144 | 154 | fi | |
| 145 | 155 | ||
| 146 | 156 | # Verify installation | |
| @@ -101,11 +101,13 @@ 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 | + | CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL}\$" "$TMP_DIR/checksums.txt" || true) |
| 105 | - | if [ -z "$EXPECTED_CHECKSUM" ]; then | 105 | + | CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ') |
| 106 | - | echo "Error: Could not find checksum for ${TARBALL} in checksums.txt" | 106 | + | if [ "$CHECKSUM_COUNT" != "1" ]; then |
| 107 | + | echo "Error: Expected exactly one checksum for ${TARBALL} in checksums.txt" | |||
| 107 | exit 1 | 108 | exit 1 | ||
| 108 | fi | 109 | fi | ||
| 110 | + | EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}') | |||
| 109 | 111 | ||||
| 110 | # Calculate actual checksum (works on both Linux and macOS) | 112 | # Calculate actual checksum (works on both Linux and macOS) | ||
| 111 | if command -v sha256sum >/dev/null 2>&1; then | 113 | if command -v sha256sum >/dev/null 2>&1; then | ||
| @@ -132,15 +134,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then | |||||
| 132 | fi | 134 | fi | ||
| 133 | echo "Checksum verified successfully." | 135 | echo "Checksum verified successfully." | ||
| 134 | 136 | ||||
| 135 | - | echo "Extracting..." | 137 | + | echo "Extracting Gordon binary..." |
| 136 | - | tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" | 138 | + | EXTRACT_DIR="$TMP_DIR/extract" |
| 139 | + | mkdir -p "$EXTRACT_DIR" | |||
| 140 | + | tar -xzf "$TMP_DIR/$TARBALL" -C "$EXTRACT_DIR" gordon | |||
| 141 | + | ||||
| 142 | + | BINARY="$EXTRACT_DIR/gordon" | |||
| 143 | + | if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then | |||
| 144 | + | echo "Error: Archive did not contain a regular Gordon binary" | |||
| 145 | + | exit 1 | |||
| 146 | + | fi | |||
| 137 | 147 | ||||
| 138 | echo "Installing to ${INSTALL_DIR}..." | 148 | echo "Installing to ${INSTALL_DIR}..." | ||
| 139 | if [ -w "$INSTALL_DIR" ]; then | 149 | if [ -w "$INSTALL_DIR" ]; then | ||
| 140 | - | mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | 150 | + | install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" |
| 141 | else | 151 | else | ||
| 142 | echo "sudo required to install to ${INSTALL_DIR}" | 152 | echo "sudo required to install to ${INSTALL_DIR}" | ||
| 143 | - | sudo mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon" | 153 | + | sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" |
| 144 | fi | 154 | fi | ||
| 145 | 155 | ||||
| 146 | # Verify installation | 156 | # Verify installation | ||
M internal/adapters/in/http/registry/handler.go +2 -1
| @@ -253,7 +253,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) { | |||
| 253 | 253 | } | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | - | func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) { | |
| 256 | + | func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) { | |
| 257 | 257 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 258 | 258 | w.WriteHeader(http.StatusOK) | |
| 259 | 259 | } | |
| @@ -427,6 +427,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||
| 427 | 427 | var maxBytesErr *http.MaxBytesError | |
| 428 | 428 | if errors.As(err, &maxBytesErr) { | |
| 429 | 429 | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") | |
| 430 | + | _ = h.registrySvc.CancelUpload(ctx, uuid) | |
| 430 | 431 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | |
| 431 | 432 | return | |
| 432 | 433 | } | |
| @@ -253,7 +253,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) { | |||||
| 253 | } | 253 | } | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | - | func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) { | 256 | + | func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) { |
| 257 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 257 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
| 258 | w.WriteHeader(http.StatusOK) | 258 | w.WriteHeader(http.StatusOK) | ||
| 259 | } | 259 | } | ||
| @@ -427,6 +427,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 427 | var maxBytesErr *http.MaxBytesError | 427 | var maxBytesErr *http.MaxBytesError | ||
| 428 | if errors.As(err, &maxBytesErr) { | 428 | if errors.As(err, &maxBytesErr) { | ||
| 429 | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") | 429 | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") | ||
| 430 | + | _ = h.registrySvc.CancelUpload(ctx, uuid) | |||
| 430 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | 431 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | ||
| 431 | return | 432 | return | ||
| 432 | } | 433 | } | ||
M internal/adapters/in/http/registry/handler_test.go +3 -1
| @@ -455,10 +455,12 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing. | |||
| 455 | 455 | // The mock must read the body to trigger MaxBytesError, which the handler detects. | |
| 456 | 456 | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything). | |
| 457 | 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 | 460 | _, _ = io.ReadAll(data) | |
| 460 | 461 | }). | |
| 461 | 462 | Return(int64(0), &http.MaxBytesError{Limit: 5}) | |
| 463 | + | registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil) | |
| 462 | 464 | ||
| 463 | 465 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content"))) | |
| 464 | 466 | rec := httptest.NewRecorder() | |
| @@ -455,10 +455,12 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing. | |||||
| 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, mock.Anything, 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, _ int64, _ int64) { | 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() | ||
M internal/testutils/mocks/runtime_gen.go +2 -2 generated file: collapsed
M pkg/runtime/interface.go +1 -8
| @@ -25,13 +25,6 @@ type NetworkInfo struct { | |||
| 25 | 25 | Labels map[string]string | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | // NetworkConfig holds options for creating a network. | |
| 29 | - | type NetworkConfig struct { | |
| 30 | - | Driver string | |
| 31 | - | Internal bool | |
| 32 | - | Labels map[string]string | |
| 33 | - | } | |
| 34 | - | ||
| 35 | 28 | // ContainerConfig holds configuration for creating a container | |
| 36 | 29 | type ContainerConfig struct { | |
| 37 | 30 | Image string | |
| @@ -117,7 +110,7 @@ type Runtime interface { | |||
| 117 | 110 | InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) | |
| 118 | 111 | ||
| 119 | 112 | // Network management | |
| 120 | - | CreateNetwork(ctx context.Context, name string, config NetworkConfig) error | |
| 113 | + | CreateNetwork(ctx context.Context, name string, options map[string]string) error | |
| 121 | 114 | RemoveNetwork(ctx context.Context, name string) error | |
| 122 | 115 | ListNetworks(ctx context.Context) ([]*NetworkInfo, error) | |
| 123 | 116 | NetworkExists(ctx context.Context, name string) (bool, error) | |
| @@ -25,13 +25,6 @@ type NetworkInfo struct { | |||||
| 25 | Labels map[string]string | 25 | Labels map[string]string | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | // NetworkConfig holds options for creating a network. | |||
| 29 | - | type NetworkConfig struct { | |||
| 30 | - | Driver string | |||
| 31 | - | Internal bool | |||
| 32 | - | Labels map[string]string | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | // ContainerConfig holds configuration for creating a container | 28 | // ContainerConfig holds configuration for creating a container | ||
| 36 | type ContainerConfig struct { | 29 | type ContainerConfig struct { | ||
| 37 | Image string | 30 | Image string | ||
| @@ -117,7 +110,7 @@ type Runtime interface { | |||||
| 117 | InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) | 110 | InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) | ||
| 118 | 111 | ||||
| 119 | // Network management | 112 | // Network management | ||
| 120 | - | CreateNetwork(ctx context.Context, name string, config NetworkConfig) error | 113 | + | CreateNetwork(ctx context.Context, name string, options map[string]string) error |
| 121 | RemoveNetwork(ctx context.Context, name string) error | 114 | RemoveNetwork(ctx context.Context, name string) error | ||
| 122 | ListNetworks(ctx context.Context) ([]*NetworkInfo, error) | 115 | ListNetworks(ctx context.Context) ([]*NetworkInfo, error) | ||
| 123 | NetworkExists(ctx context.Context, name string) (bool, error) | 116 | NetworkExists(ctx context.Context, name string) (bool, error) | ||