detached e73e6fc

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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:
2626 - name: Install Gordon
2727 id: install
2828 shell: bash
29+ env:
30+ GORDON_VERSION_INPUT: ${{ inputs.version }}
2931 run: |
3032 set -euo pipefail
3133
@@ -43,7 +45,11 @@ runs:
4345 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
4446 esac
4547
46- version="${{ inputs.version }}"
48+ version="${GORDON_VERSION_INPUT}"
49+ if ! [[ "${version}" =~ ^(latest|v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?)$ ]]; then
50+ echo "Invalid Gordon version: ${version}" >&2
51+ exit 1
52+ fi
4753
4854 # Resolve 'latest' to an actual tag via redirect URL
4955 if [ "${version}" = "latest" ]; then
@@ -53,24 +59,66 @@ runs:
5359 echo "Failed to resolve latest Gordon version" >&2
5460 exit 1
5561 fi
62+ if ! [[ "${version}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
63+ echo "Resolved invalid Gordon version: ${version}" >&2
64+ exit 1
65+ fi
5666 fi
5767
5868 archive="gordon_${os}_${arch}.tar.gz"
59- url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"
60- dest="/tmp/${archive}"
69+ base_url="https://github.com/bnema/gordon/releases/download/${version}"
70+ url="${base_url}/${archive}"
71+ checksums_url="${base_url}/checksums.txt"
72+ work_dir="$(mktemp -d)"
73+ dest="${work_dir}/${archive}"
74+ checksums="${work_dir}/checksums.txt"
75+ extract_dir="${work_dir}/extract"
6176 install_path="/usr/local/bin/gordon"
77+ trap 'rm -rf "${work_dir}"' EXIT
6278
6379 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
6480
65- # Download the release archive
81+ # Download the release archive and checksums
82+ curl -fsSL -o "${checksums}" "${checksums_url}"
6683 curl -fsSL -o "${dest}" "${url}"
6784
68- # Extract just the gordon binary and clean up
69- tar -xzf "${dest}" -C /usr/local/bin/ gordon
70- rm -f "${dest}"
85+ # Verify checksum before extraction
86+ 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
71115
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
74122 gordon version
75123
76124 # Set outputs
@@ -26,6 +26,8 @@ runs:
26 - name: Install Gordon26 - name: Install Gordon
27 id: install27 id: install
28 shell: bash28 shell: bash
29+ env:
30+ GORDON_VERSION_INPUT: ${{ inputs.version }}
29 run: |31 run: |
30 set -euo pipefail32 set -euo pipefail
31 33
@@ -43,7 +45,11 @@ runs:
43 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;45 *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
44 esac46 esac
45 47
46- version="${{ inputs.version }}"48+ version="${GORDON_VERSION_INPUT}"
49+ if ! [[ "${version}" =~ ^(latest|v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?)$ ]]; then
50+ echo "Invalid Gordon version: ${version}" >&2
51+ exit 1
52+ fi
47 53
48 # Resolve 'latest' to an actual tag via redirect URL54 # Resolve 'latest' to an actual tag via redirect URL
49 if [ "${version}" = "latest" ]; then55 if [ "${version}" = "latest" ]; then
@@ -53,24 +59,66 @@ runs:
53 echo "Failed to resolve latest Gordon version" >&259 echo "Failed to resolve latest Gordon version" >&2
54 exit 160 exit 1
55 fi61 fi
62+ if ! [[ "${version}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
63+ echo "Resolved invalid Gordon version: ${version}" >&2
64+ exit 1
65+ fi
56 fi66 fi
57 67
58 archive="gordon_${os}_${arch}.tar.gz"68 archive="gordon_${os}_${arch}.tar.gz"
59- url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"69+ base_url="https://github.com/bnema/gordon/releases/download/${version}"
60- dest="/tmp/${archive}"70+ url="${base_url}/${archive}"
71+ checksums_url="${base_url}/checksums.txt"
72+ work_dir="$(mktemp -d)"
73+ dest="${work_dir}/${archive}"
74+ checksums="${work_dir}/checksums.txt"
75+ extract_dir="${work_dir}/extract"
61 install_path="/usr/local/bin/gordon"76 install_path="/usr/local/bin/gordon"
77+ trap 'rm -rf "${work_dir}"' EXIT
62 78
63 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"79 echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
64 80
65- # Download the release archive81+ # Download the release archive and checksums
82+ curl -fsSL -o "${checksums}" "${checksums_url}"
66 curl -fsSL -o "${dest}" "${url}"83 curl -fsSL -o "${dest}" "${url}"
67 84
68- # Extract just the gordon binary and clean up85+ # Verify checksum before extraction
69- tar -xzf "${dest}" -C /usr/local/bin/ gordon86+ 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 verify116+ # 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 version122 gordon version
75 123
76 # Set outputs124 # 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 @@
22 # Multi-stage build for optimized container image
33
44 # Build stage
5-FROM golang:1.25-alpine AS builder
5+FROM golang:1.26.1-alpine3.22 AS builder
66
77 # Install build dependencies
88 RUN apk add --no-cache git ca-certificates tzdata
@@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
2626 -o gordon .
2727
2828 # Runtime stage
29-FROM alpine:latest
29+FROM alpine:3.22
3030
3131 # Install runtime dependencies
3232 RUN apk add --no-cache \
@@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
5656 USER gordon
5757
5858 # Expose ports
59-EXPOSE 8080 5000
59+EXPOSE 8088 5000
6060
61-# Health check
62-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
63- CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
61+# Admin health is served on the registry/admin listener and is gated by auth in
62+# normal deployments, so this image does not declare a Docker healthcheck.
6463
6564 # Default command
6665 CMD ["./gordon", "serve"]
@@ -2,7 +2,7 @@
2 # Multi-stage build for optimized container image2 # Multi-stage build for optimized container image
3 3
4 # Build stage4 # Build stage
5-FROM golang:1.25-alpine AS builder5+FROM golang:1.26.1-alpine3.22 AS builder
6 6
7 # Install build dependencies7 # Install build dependencies
8 RUN apk add --no-cache git ca-certificates tzdata8 RUN apk add --no-cache git ca-certificates tzdata
@@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
26 -o gordon .26 -o gordon .
27 27
28 # Runtime stage28 # Runtime stage
29-FROM alpine:latest29+FROM alpine:3.22
30 30
31 # Install runtime dependencies31 # Install runtime dependencies
32 RUN apk add --no-cache \32 RUN apk add --no-cache \
@@ -56,11 +56,10 @@ COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
56 USER gordon56 USER gordon
57 57
58 # Expose ports58 # Expose ports
59-EXPOSE 8080 500059+EXPOSE 8088 5000
60 60
61-# Health check61+# Admin health is served on the registry/admin listener and is gated by auth in
62-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \62+# normal deployments, so this image does not declare a Docker healthcheck.
63- CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
64 63
65 # Default command64 # Default command
66 CMD ["./gordon", "serve"]65 CMD ["./gordon", "serve"]
M docs/config/index.md +18 -1
@@ -63,6 +63,10 @@ drain_mode = "auto" # auto, inflight, delay
6363 drain_timeout = "30s" # Max wait for in-flight drain
6464 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
6565
66+# Container runtime profile
67+[containers]
68+security_profile = "compat" # compat or strict
69+
6670 # Logging
6771 [logging]
6872 level = "info" # trace, debug, info, warn, error
@@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal
106110 [network_isolation]
107111 enabled = true # Per-app isolated networks
108112 network_prefix = "gordon" # Network name prefix
113+internal = false # Set true to block direct egress from isolated networks
109114
110115 # Auto-route
111116 [auto_route]
@@ -138,6 +143,10 @@ weekly = 4
138143 monthly = 12
139144
140145 # Images
146+[images]
147+allowed_registries = [] # Explicit external registries allowed for deploy/attachments
148+require_digest = false # Require digests for allowlisted external registries
149+
141150 [images.prune]
142151 enabled = false
143152 schedule = "daily"
@@ -164,6 +173,7 @@ keep_last = 3
164173 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
165174 | `[backups]` | Database backups | [Backups](./backups.md) |
166175 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
176+| Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
167177
168178 ## Default Values
169179
@@ -172,6 +182,8 @@ keep_last = 3
172182 | `server.port` | `8088` |
173183 | `server.registry_port` | `5000` |
174184 | `server.data_dir` | `~/.gordon` |
185+| `server.max_blob_chunk_size` | `"95MB"` |
186+| `server.max_blob_size` | `"1GB"` |
175187 | `auth.enabled` | `true` |
176188 | `auth.secrets_backend` | `"unsafe"` |
177189 | `auth.token_expiry` | `"30d"` |
@@ -187,6 +199,7 @@ keep_last = 3
187199 | `deploy.drain_mode` | `"auto"` |
188200 | `deploy.drain_timeout` | `"30s"` |
189201 | `deploy.drain_delay` | `"2s"` |
202+| `containers.security_profile` | `"compat"` |
190203 | `logging.level` | `"info"` |
191204 | `logging.format` | `"console"` |
192205 | `logging.file.enabled` | `false` |
@@ -198,9 +211,12 @@ keep_last = 3
198211 | `volumes.prefix` | `"gordon"` |
199212 | `volumes.preserve` | `true` |
200213 | `network_isolation.enabled` | `true` |
214+| `network_isolation.internal` | `false` |
201215 | `auto_route.enabled` | `false` |
202216 | `backups.enabled` | `false` |
203217 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
218+| `images.allowed_registries` | `[]` |
219+| `images.require_digest` | `false` |
204220 | `images.prune.enabled` | `false` |
205221 | `images.prune.schedule` | `"daily"` |
206222 | `images.prune.keep_last` | `3` |
@@ -229,7 +245,6 @@ gordon reload
229245 | `server.gordon_domain` (registry domain) |
230246 | `server.registry_port` |
231247 | `server.max_proxy_body_size` |
232-| `server.max_blob_chunk_size` |
233248 | `server.max_proxy_response_size` |
234249 | `server.max_concurrent_conns` |
235250
@@ -241,6 +256,8 @@ gordon reload
241256 |---------|
242257 | `server.port` |
243258 | `server.data_dir` |
259+| `server.max_blob_chunk_size` |
260+| `server.max_blob_size` |
244261 | `auth.*` |
245262 | `deploy.readiness_mode` |
246263 | `deploy.readiness_delay` |
@@ -63,6 +63,10 @@ drain_mode = "auto" # auto, inflight, delay
63 drain_timeout = "30s" # Max wait for in-flight drain63 drain_timeout = "30s" # Max wait for in-flight drain
64 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container64 drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
65 65
66+# Container runtime profile
67+[containers]
68+security_profile = "compat" # compat or strict
69+
66 # Logging70 # Logging
67 [logging]71 [logging]
68 level = "info" # trace, debug, info, warn, error72 level = "info" # trace, debug, info, warn, error
@@ -106,6 +110,7 @@ preserve = true # Keep volumes on container removal
106 [network_isolation]110 [network_isolation]
107 enabled = true # Per-app isolated networks111 enabled = true # Per-app isolated networks
108 network_prefix = "gordon" # Network name prefix112 network_prefix = "gordon" # Network name prefix
113+internal = false # Set true to block direct egress from isolated networks
109 114
110 # Auto-route115 # Auto-route
111 [auto_route]116 [auto_route]
@@ -138,6 +143,10 @@ weekly = 4
138 monthly = 12143 monthly = 12
139 144
140 # Images145 # Images
146+[images]
147+allowed_registries = [] # Explicit external registries allowed for deploy/attachments
148+require_digest = false # Require digests for allowlisted external registries
149+
141 [images.prune]150 [images.prune]
142 enabled = false151 enabled = false
143 schedule = "daily"152 schedule = "daily"
@@ -164,6 +173,7 @@ keep_last = 3
164 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |173 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
165 | `[backups]` | Database backups | [Backups](./backups.md) |174 | `[backups]` | Database backups | [Backups](./backups.md) |
166 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |175 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
176+| Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
167 177
168 ## Default Values178 ## Default Values
169 179
@@ -172,6 +182,8 @@ keep_last = 3
172 | `server.port` | `8088` |182 | `server.port` | `8088` |
173 | `server.registry_port` | `5000` |183 | `server.registry_port` | `5000` |
174 | `server.data_dir` | `~/.gordon` |184 | `server.data_dir` | `~/.gordon` |
185+| `server.max_blob_chunk_size` | `"95MB"` |
186+| `server.max_blob_size` | `"1GB"` |
175 | `auth.enabled` | `true` |187 | `auth.enabled` | `true` |
176 | `auth.secrets_backend` | `"unsafe"` |188 | `auth.secrets_backend` | `"unsafe"` |
177 | `auth.token_expiry` | `"30d"` |189 | `auth.token_expiry` | `"30d"` |
@@ -187,6 +199,7 @@ keep_last = 3
187 | `deploy.drain_mode` | `"auto"` |199 | `deploy.drain_mode` | `"auto"` |
188 | `deploy.drain_timeout` | `"30s"` |200 | `deploy.drain_timeout` | `"30s"` |
189 | `deploy.drain_delay` | `"2s"` |201 | `deploy.drain_delay` | `"2s"` |
202+| `containers.security_profile` | `"compat"` |
190 | `logging.level` | `"info"` |203 | `logging.level` | `"info"` |
191 | `logging.format` | `"console"` |204 | `logging.format` | `"console"` |
192 | `logging.file.enabled` | `false` |205 | `logging.file.enabled` | `false` |
@@ -198,9 +211,12 @@ keep_last = 3
198 | `volumes.prefix` | `"gordon"` |211 | `volumes.prefix` | `"gordon"` |
199 | `volumes.preserve` | `true` |212 | `volumes.preserve` | `true` |
200 | `network_isolation.enabled` | `true` |213 | `network_isolation.enabled` | `true` |
214+| `network_isolation.internal` | `false` |
201 | `auto_route.enabled` | `false` |215 | `auto_route.enabled` | `false` |
202 | `backups.enabled` | `false` |216 | `backups.enabled` | `false` |
203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |217 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
218+| `images.allowed_registries` | `[]` |
219+| `images.require_digest` | `false` |
204 | `images.prune.enabled` | `false` |220 | `images.prune.enabled` | `false` |
205 | `images.prune.schedule` | `"daily"` |221 | `images.prune.schedule` | `"daily"` |
206 | `images.prune.keep_last` | `3` |222 | `images.prune.keep_last` | `3` |
@@ -229,7 +245,6 @@ gordon reload
229 | `server.gordon_domain` (registry domain) |245 | `server.gordon_domain` (registry domain) |
230 | `server.registry_port` |246 | `server.registry_port` |
231 | `server.max_proxy_body_size` |247 | `server.max_proxy_body_size` |
232-| `server.max_blob_chunk_size` |
233 | `server.max_proxy_response_size` |248 | `server.max_proxy_response_size` |
234 | `server.max_concurrent_conns` |249 | `server.max_concurrent_conns` |
235 250
@@ -241,6 +256,8 @@ gordon reload
241 |---------|256 |---------|
242 | `server.port` |257 | `server.port` |
243 | `server.data_dir` |258 | `server.data_dir` |
259+| `server.max_blob_chunk_size` |
260+| `server.max_blob_size` |
244 | `auth.*` |261 | `auth.*` |
245 | `deploy.readiness_mode` |262 | `deploy.readiness_mode` |
246 | `deploy.readiness_delay` |263 | `deploy.readiness_delay` |
M docs/config/reference.md +4 -2
@@ -17,7 +17,8 @@ tls_key_file = "" # PEM key path (optional, must be s
1717 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)
1818 gordon_domain = "" # Required: Gordon domain (registry + API)
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
20-max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
20+max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk
21+max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload
2122 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
2223 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
2324 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
@@ -189,7 +190,8 @@ keep_last = 3 # Keep N newest tags per repository
189190 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |
190191 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
191192 | `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 |
193195 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
194196 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
195197 | `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 chunk20+max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk
21+max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload
21 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)22 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
22 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)23 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
23 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)24 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
@@ -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)
1414 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
1515 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1616 # data_dir = "~/.gordon" # Data storage directory (default)
17+max_blob_chunk_size = "95MB" # Max registry blob upload chunk
18+max_blob_size = "1GB" # Max cumulative registry blob/layer upload
1719 ```
1820
1921 ## Options
@@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
3133 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
3234 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3335 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
36+| `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
3437 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
3538 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
3639 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
@@ -274,6 +277,22 @@ max_blob_chunk_size = "1GB" # Allow larger layers
274277 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
275278 - Increase it only if you expect larger direct registry uploads from other clients
276279
280+## Maximum Registry Blob Size
281+
282+The `max_blob_size` setting limits the cumulative size of one blob/layer upload across all chunks:
283+
284+```toml
285+[server]
286+max_blob_size = "1GB" # Default
287+```
288+
289+**Purpose:** Prevents disk exhaustion from multi-chunk uploads where each individual chunk is below `max_blob_chunk_size` but the total blob grows without bound.
290+
291+**Behavior:**
292+- Gordon tracks the current upload size and rejects appends that would exceed the limit.
293+- Rejected uploads return an OCI-compatible `413` size error.
294+- Failed uploads are cleaned up so temporary blob data does not accumulate indefinitely.
295+
277296 ## Registry IP Allowlist
278297
279298 The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default).
@@ -14,6 +14,8 @@ tls_port = 8443 # HTTPS listener port (0 = disabled)
14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
16 # data_dir = "~/.gordon" # Data storage directory (default)16 # data_dir = "~/.gordon" # Data storage directory (default)
17+max_blob_chunk_size = "95MB" # Max registry blob upload chunk
18+max_blob_size = "1GB" # Max cumulative registry blob/layer upload
17 ```19 ```
18 20
19 ## Options21 ## Options
@@ -31,6 +33,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
31 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |33 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
32 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |34 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
33 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |35 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
36+| `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
34 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |37 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
35 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |38 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
36 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |39 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
@@ -274,6 +277,22 @@ max_blob_chunk_size = "1GB" # Allow larger layers
274 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine277 - Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
275 - Increase it only if you expect larger direct registry uploads from other clients278 - Increase it only if you expect larger direct registry uploads from other clients
276 279
280+## Maximum Registry Blob Size
281+
282+The `max_blob_size` setting limits the cumulative size of one blob/layer upload across all chunks:
283+
284+```toml
285+[server]
286+max_blob_size = "1GB" # Default
287+```
288+
289+**Purpose:** Prevents disk exhaustion from multi-chunk uploads where each individual chunk is below `max_blob_chunk_size` but the total blob grows without bound.
290+
291+**Behavior:**
292+- Gordon tracks the current upload size and rejects appends that would exceed the limit.
293+- Rejected uploads return an OCI-compatible `413` size error.
294+- Failed uploads are cleaned up so temporary blob data does not accumulate indefinitely.
295+
277 ## Registry IP Allowlist296 ## Registry IP Allowlist
278 297
279 The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default).298 The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default).
A 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
101101
102102 # Verify checksum
103103 echo "Verifying checksum..."
104-EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}')
105-if [ -z "$EXPECTED_CHECKSUM" ]; then
106- echo "Error: Could not find checksum for ${TARBALL} in checksums.txt"
104+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"
107108 exit 1
108109 fi
110+EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}')
109111
110112 # Calculate actual checksum (works on both Linux and macOS)
111113 if command -v sha256sum >/dev/null 2>&1; then
@@ -132,15 +134,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
132134 fi
133135 echo "Checksum verified successfully."
134136
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
137147
138148 echo "Installing to ${INSTALL_DIR}..."
139149 if [ -w "$INSTALL_DIR" ]; then
140- mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon"
150+ install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
141151 else
142152 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"
144154 fi
145155
146156 # Verify installation
@@ -101,11 +101,13 @@ fi
101 101
102 # Verify checksum102 # Verify checksum
103 echo "Verifying checksum..."103 echo "Verifying checksum..."
104-EXPECTED_CHECKSUM=$(grep -E "[[:space:]]${TARBALL}\$" "$TMP_DIR/checksums.txt" | awk '{print $1}')104+CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL}\$" "$TMP_DIR/checksums.txt" || true)
105-if [ -z "$EXPECTED_CHECKSUM" ]; then105+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 1108 exit 1
108 fi109 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; then113 if command -v sha256sum >/dev/null 2>&1; then
@@ -132,15 +134,23 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
132 fi134 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" ]; then149 if [ -w "$INSTALL_DIR" ]; then
140- mv "$TMP_DIR/gordon" "$INSTALL_DIR/gordon"150+ install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
141 else151 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 fi154 fi
145 155
146 # Verify installation156 # 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) {
253253 }
254254 }
255255
256-func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {
256+func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
257257 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
258258 w.WriteHeader(http.StatusOK)
259259 }
@@ -427,6 +427,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
427427 var maxBytesErr *http.MaxBytesError
428428 if errors.As(err, &maxBytesErr) {
429429 log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large")
430+ _ = h.registrySvc.CancelUpload(ctx, uuid)
430431 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
431432 return
432433 }
@@ -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.MaxBytesError427 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 return432 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.
455455 // The mock must read the body to trigger MaxBytesError, which the handler detects.
456456 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).
457457 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.
459460 _, _ = io.ReadAll(data)
460461 }).
461462 Return(int64(0), &http.MaxBytesError{Limit: 5})
463+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
462464
463465 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
464466 rec := httptest.NewRecorder()
@@ -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 MaxBytesError458+ // Drain the reader to trigger MaxBytesError after the storage layer may
459+ // have written the limit-sized prefix.
459 _, _ = io.ReadAll(data)460 _, _ = io.ReadAll(data)
460 }).461 }).
461 Return(int64(0), &http.MaxBytesError{Limit: 5})462 Return(int64(0), &http.MaxBytesError{Limit: 5})
463+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
462 464
463 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))465 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
464 rec := httptest.NewRecorder()466 rec := httptest.NewRecorder()
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 {
2525 Labels map[string]string
2626 }
2727
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-
3528 // ContainerConfig holds configuration for creating a container
3629 type ContainerConfig struct {
3730 Image string
@@ -117,7 +110,7 @@ type Runtime interface {
117110 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
118111
119112 // Network management
120- CreateNetwork(ctx context.Context, name string, config NetworkConfig) error
113+ CreateNetwork(ctx context.Context, name string, options map[string]string) error
121114 RemoveNetwork(ctx context.Context, name string) error
122115 ListNetworks(ctx context.Context) ([]*NetworkInfo, error)
123116 NetworkExists(ctx context.Context, name string) (bool, error)
@@ -25,13 +25,6 @@ type NetworkInfo struct {
25 Labels map[string]string25 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 container28 // ContainerConfig holds configuration for creating a container
36 type ContainerConfig struct {29 type ContainerConfig struct {
37 Image string30 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 management112 // Network management
120- CreateNetwork(ctx context.Context, name string, config NetworkConfig) error113+ CreateNetwork(ctx context.Context, name string, options map[string]string) error
121 RemoveNetwork(ctx context.Context, name string) error114 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)

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close