v3.0.0-alpha.1 6fdddb8

security-hardening.md RawHistoryFolder

Security Hardening Controls

This page summarizes Gordon's security-related configuration knobs and the defaults operators should know before exposing an instance.

Registry upload quotas

Registry blob uploads are bounded by two limits under [server]:

[server]
max_blob_chunk_size = "95MB"
max_blob_size = "1GB"
  • max_blob_chunk_size limits a single upload chunk.
  • max_blob_size limits the cumulative blob/layer upload size.
  • Exceeding either limit returns an OCI-compatible size error and the failed upload is cleaned up.

Admin logs permission

Container and deploy logs can include environment-derived output. Gordon gates log access behind a dedicated scope:

gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
  • /admin/logs, app failure diagnostics, and deploy failure logs require admin:logs:read.
  • admin:status:read does not grant log access.
  • Operation errors returned to admin:apps:read callers are stable and log-free. Application diagnostics are a separate field, redacted of the affected service's resolved secret values before storage, and returned only to callers holding admin:logs:read. If a declared secret cannot be resolved for redaction, Gordon drops the diagnostics instead of storing unredacted output. Mutation responses never include application output.
  • Process and container log sources remain operator-owned data. Gordon redacts common credential patterns when serving logs through its API, but this is defense in depth rather than proof that arbitrary application output is secret-free. Restrict filesystem, journal, runtime, backup, and admin:logs:read access accordingly.

Volume pruning scope

Volume pruning removes a volume only when Gordon has a durable released ownership record, the runtime app/incarnation/service labels agree with that record, and no container mounts it. A gordon.managed=true label by itself is not deletion authority; retained, unknown, contradictory, and unrelated volumes survive.

Use gordon volumes prune --dry-run before deletion. Do not substitute docker volume prune or an equivalent runtime command: runtime-native pruning bypasses Gordon's ownership and retention checks.

Use dedicated admin scopes:

  • admin:volumes:read for listing volumes.
  • admin:volumes:write for prune operations.

Administrative app bind mounts

App manifests cannot name host paths. A host bind exists only when the operator declares a named policy in gordon.toml:

[app_mounts.app-logs]
source = "/srv/gordon/host-logs"
read_only = true
allowed_apps = ["metrics-agent"]    # exact, non-empty
allowed_services = ["web"]          # exact, non-empty
root = "/srv/gordon"                # optional boundary

The manifest references only the policy name: [[service.bind]] name = "app-logs" with an absolute container path.

  • allowed_apps and allowed_services are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
  • source is resolved through symlinks and must be a regular file or directory under root. When root is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused.
  • Read-only precedence: read_only on the policy or readonly on the manifest bind forces the mount read-only; a manifest never weakens its policy.
  • Destinations must be absolute, normalized, and outside reserved container paths (/, /proc, /sys, /dev, /boot, and their children).
  • The policy is re-resolved immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed.
  • gordon serve reload republishes validated policies atomically. An edit that fails validation is rejected and the previous policies stay live.
  • Gordon never creates, deletes, chowns, backs up, or prunes the host path; ownership, permissions, and backup remain the operator's responsibility.

Administrative app devices (CDI)

App manifests cannot name host devices. A device grant exists only when the operator declares a named policy in gordon.toml:

[app_devices.transcode-gpu]
cdi = ["example.com/gpu=GPU-device-uuid"]
allowed_apps = ["video"]          # exact, non-empty
allowed_services = ["transcoder"]  # exact, non-empty

The manifest references only the logical name: devices = ["transcode-gpu"].

  • cdi holds explicit CDI device IDs. Raw /dev paths, unqualified names, and aggregate =all selectors are rejected.
  • allowed_apps and allowed_services are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
  • Gordon resolves logical names to CDI IDs at activation time and encodes them as one native CDI DeviceRequest. Revisions persist the logical names, never the host resolution.
  • The grant is re-resolved immediately before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys while the running service is untouched; changing a mapping never recreates a running container.
  • Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured runtime-unsupported error and never run without devices.
  • Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.

Pass import plaintext handling

With the pass backend, Gordon imports eligible plaintext .env files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review.

External image registries

Docker Hub, ghcr.io, quay.io, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:

[images]
allowed_registries = ["registry.internal:5000"]
require_digest = true
  • docker.io and Docker Hub's canonical pull host registry-1.docker.io are equivalent.
  • Other registries are accepted only when their exact hostname+port is configured. Allowlisting does not configure credentials; external resolution and pulls are anonymous unless the runtime already has suitable access.
  • require_digest = true requires every image, including Gordon registry images, to use @sha256:<64 hex chars>.
  • The allowlist restricts hostnames, not resolved IPs. It cannot prove a DNS hostname is non-private and does not replace firewall or runtime egress controls.

Smart TCP Raw Fallback

Raw TCP fallback on a smart_tcp edge entrypoint is disabled by default. Unknown non-HTTP/non-TLS bytes reach a backend only when the entrypoint explicitly sets raw_fallback to a TCP router and the fallback source policy allows the peer.

[entrypoints.edge]
address = ":443"
protocol = "smart_tcp"
raw_fallback = "ssh-fallback"
raw_fallback_trusted_cidrs = ["100.64.0.0/10"]

Use raw_fallback_trusted_cidrs for private raw fallback. Public raw fallback requires an explicit acknowledgement:

[entrypoints.edge]
address = ":443"
protocol = "smart_tcp"
raw_fallback = "public-raw"
allow_public_raw_fallback = true

Security behavior:

  • PROXY protocol v1 and v2 prefixes are rejected; Gordon does not trust or parse PROXY headers on smart TCP entrypoints.
  • trusted_cidrs and raw_fallback_trusted_cidrs use the peer socket IP, not X-Forwarded-For.
  • HTTP-looking or TLS-looking malformed traffic is rejected and never bypasses to raw fallback.
  • Entry-point-wide trusted_cidrs applies before sniffing to all protocols; raw fallback has its own narrower policy for private fallback exposure.

Docker network isolation

Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:

[network_isolation]
enabled = true
internal = true

internal = false remains the compatibility default because some applications need direct egress during startup.

Every app container joins an incarnation-owned private network derived from the app's internal UUID; shared memberships come only from explicit [[network.shared]] declarations, and memory/CPU/PID limits apply on every create and recovery path.

Readiness helper containers

Probing an internal HTTP port uses one bounded helper container per readiness attempt, removed immediately after. Gordon force-removes the helper under an independent cleanup context, including on failure and timeout. There is no operator configuration for the helper; its image and limits are fixed.

  • The helper image is digest-pinned and multi-arch: alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc. The image must be pre-provisioned and available offline on the target host, because the probe never pulls.
  • The helper attaches only to the target app's private network. It never joins a shared network or a host network.
  • It never publishes a host port and has no mounts, volumes, secrets, environment, or runtime socket.
  • It runs non-root (uid/gid 65534) with a read-only root filesystem, all capabilities dropped, no-new-privileges, and bounded CPU, memory, and PIDs.
  • It targets the exact inspected container IP on that network, never a service alias, and revalidates the container's execution start before trusting the result; a restarted generation is discarded.
  • A target that listens only on its own loopback fails readiness.
  • Helpers left behind by an abrupt daemon or host stop are reclaimed by the next probe once they are older than ten minutes; a live session is never touched.

Registry exposure

  • With auth.enabled = true, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
  • With auth.enabled = false, the registry is local-only: the public proxy refuses registry-domain requests, and the registry handler accepts only direct loopback connections carrying the instance credentials.
  • Registry requests are parsed once into a validated operation used by both authorization and dispatch, so the repository a token is checked against is exactly the repository served.
  • Blob and upload access is repository-scoped: an upload UUID is usable only by the repository that started it, and a blob is served only to a repository that completed an upload of that digest.

Container runtime profile

[containers]
security_profile = "compat" # or "strict"
  • compat preserves broad image compatibility while retaining no-new-privileges and default capability restrictions.
  • strict enables a read-only root filesystem, drops all capabilities, and only adds NET_BIND_SERVICE.

Use strict for images designed to write only to mounted volumes and run without extra Linux capabilities.

source
1# Security Hardening Controls
2
3This page summarizes Gordon's security-related configuration knobs and the defaults operators should know before exposing an instance.
4
5## Registry upload quotas
6
7Registry blob uploads are bounded by two limits under `[server]`:
8
9```toml
10[server]
11max_blob_chunk_size = "95MB"
12max_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
21Container and deploy logs can include environment-derived output. Gordon gates log access behind a dedicated scope:
22
23```bash
24gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
25```
26
27- `/admin/logs`, app failure diagnostics, and deploy failure logs require `admin:logs:read`.
28- `admin:status:read` does not grant log access.
29- Operation errors returned to `admin:apps:read` callers are stable and log-free. Application diagnostics are a separate field, redacted of the affected service's resolved secret values before storage, and returned only to callers holding `admin:logs:read`. If a declared secret cannot be resolved for redaction, Gordon drops the diagnostics instead of storing unredacted output. Mutation responses never include application output.
30- Process and container log sources remain operator-owned data. Gordon redacts common credential patterns when serving logs through its API, but this is defense in depth rather than proof that arbitrary application output is secret-free. Restrict filesystem, journal, runtime, backup, and `admin:logs:read` access accordingly.
31
32## Volume pruning scope
33
34Volume pruning removes a volume only when Gordon has a durable `released` ownership record, the runtime app/incarnation/service labels agree with that record, and no container mounts it. A `gordon.managed=true` label by itself is not deletion authority; retained, unknown, contradictory, and unrelated volumes survive.
35
36Use `gordon volumes prune --dry-run` before deletion. Do not substitute `docker volume prune` or an equivalent runtime command: runtime-native pruning bypasses Gordon's ownership and retention checks.
37
38Use dedicated admin scopes:
39
40- `admin:volumes:read` for listing volumes.
41- `admin:volumes:write` for prune operations.
42
43## Administrative app bind mounts
44
45App manifests cannot name host paths. A host bind exists only when the operator declares a named policy in `gordon.toml`:
46
47```toml
48[app_mounts.app-logs]
49source = "/srv/gordon/host-logs"
50read_only = true
51allowed_apps = ["metrics-agent"]    # exact, non-empty
52allowed_services = ["web"]          # exact, non-empty
53root = "/srv/gordon"                # optional boundary
54```
55
56The manifest references only the policy name: `[[service.bind]] name = "app-logs"` with an absolute container `path`.
57
58- `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
59- `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused.
60- Read-only precedence: `read_only` on the policy or `readonly` on the manifest bind forces the mount read-only; a manifest never weakens its policy.
61- Destinations must be absolute, normalized, and outside reserved container paths (`/`, `/proc`, `/sys`, `/dev`, `/boot`, and their children).
62- The policy is re-resolved immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed.
63- `gordon serve` reload republishes validated policies atomically. An edit that fails validation is rejected and the previous policies stay live.
64- Gordon never creates, deletes, chowns, backs up, or prunes the host path; ownership, permissions, and backup remain the operator's responsibility.
65
66## Administrative app devices (CDI)
67
68App manifests cannot name host devices. A device grant exists only when the operator declares a named policy in `gordon.toml`:
69
70```toml
71[app_devices.transcode-gpu]
72cdi = ["example.com/gpu=GPU-device-uuid"]
73allowed_apps = ["video"]          # exact, non-empty
74allowed_services = ["transcoder"]  # exact, non-empty
75```
76
77The manifest references only the logical name: `devices = ["transcode-gpu"]`.
78
79- `cdi` holds explicit CDI device IDs. Raw `/dev` paths, unqualified names, and aggregate `=all` selectors are rejected.
80- `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
81- Gordon resolves logical names to CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution.
82- The grant is re-resolved immediately before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys while the running service is untouched; changing a mapping never recreates a running container.
83- Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.
84- Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
85
86## Pass import plaintext handling
87
88With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review.
89
90## External image registries
91
92Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
93
94```toml
95[images]
96allowed_registries = ["registry.internal:5000"]
97require_digest = true
98```
99
100- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
101- Other registries are accepted only when their exact hostname+port is configured. Allowlisting does not configure credentials; external resolution and pulls are anonymous unless the runtime already has suitable access.
102- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
103- The allowlist restricts hostnames, not resolved IPs. It cannot prove a DNS hostname is non-private and does not replace firewall or runtime egress controls.
104
105## Smart TCP Raw Fallback
106
107Raw TCP fallback on a `smart_tcp` edge entrypoint is disabled by default. Unknown non-HTTP/non-TLS bytes reach a backend only when the entrypoint explicitly sets `raw_fallback` to a TCP router and the fallback source policy allows the peer.
108
109```toml
110[entrypoints.edge]
111address = ":443"
112protocol = "smart_tcp"
113raw_fallback = "ssh-fallback"
114raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
115```
116
117Use `raw_fallback_trusted_cidrs` for private raw fallback. Public raw fallback requires an explicit acknowledgement:
118
119```toml
120[entrypoints.edge]
121address = ":443"
122protocol = "smart_tcp"
123raw_fallback = "public-raw"
124allow_public_raw_fallback = true
125```
126
127Security behavior:
128
129- PROXY protocol v1 and v2 prefixes are rejected; Gordon does not trust or parse PROXY headers on smart TCP entrypoints.
130- `trusted_cidrs` and `raw_fallback_trusted_cidrs` use the peer socket IP, not `X-Forwarded-For`.
131- HTTP-looking or TLS-looking malformed traffic is rejected and never bypasses to raw fallback.
132- Entry-point-wide `trusted_cidrs` applies before sniffing to all protocols; raw fallback has its own narrower policy for private fallback exposure.
133
134## Docker network isolation
135
136Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:
137
138```toml
139[network_isolation]
140enabled = true
141internal = true
142```
143
144`internal = false` remains the compatibility default because some applications need direct egress during startup.
145
146Every app container joins an incarnation-owned private network derived from the app's internal UUID; shared memberships come only from explicit `[[network.shared]]` declarations, and memory/CPU/PID limits apply on every create and recovery path.
147
148## Readiness helper containers
149
150Probing an internal HTTP port uses one bounded helper container per readiness attempt, removed immediately after. Gordon force-removes the helper under an independent cleanup context, including on failure and timeout. There is no operator configuration for the helper; its image and limits are fixed.
151
152- The helper image is digest-pinned and multi-arch: `alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc`. The image must be pre-provisioned and available offline on the target host, because the probe never pulls.
153- The helper attaches only to the target app's private network. It never joins a shared network or a host network.
154- It never publishes a host port and has no mounts, volumes, secrets, environment, or runtime socket.
155- It runs non-root (uid/gid `65534`) with a read-only root filesystem, all capabilities dropped, `no-new-privileges`, and bounded CPU, memory, and PIDs.
156- It targets the exact inspected container IP on that network, never a service alias, and revalidates the container's execution start before trusting the result; a restarted generation is discarded.
157- A target that listens only on its own loopback fails readiness.
158- Helpers left behind by an abrupt daemon or host stop are reclaimed by the next probe once they are older than ten minutes; a live session is never touched.
159
160## Registry exposure
161
162- With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
163- With `auth.enabled = false`, the registry is local-only: the public proxy refuses registry-domain requests, and the registry handler accepts only direct loopback connections carrying the instance credentials.
164- Registry requests are parsed once into a validated operation used by both authorization and dispatch, so the repository a token is checked against is exactly the repository served.
165- Blob and upload access is repository-scoped: an upload UUID is usable only by the repository that started it, and a blob is served only to a repository that completed an upload of that digest.
166
167## Container runtime profile
168
169```toml
170[containers]
171security_profile = "compat" # or "strict"
172```
173
174- `compat` preserves broad image compatibility while retaining `no-new-privileges` and default capability restrictions.
175- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`.
176
177Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
178
179## Related
180
181- [Auth](./auth.md)
182- [Images](./images.md)
183- [Network Isolation](./network-isolation.md)
184- [Volumes](./volumes.md)
185- [Reference](./reference.md)

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