main bac21c6

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: [[services.<name>.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.

Plain device nodes under rootless Podman

Verified by source reading only. Confirm on the host: create a throwaway container with the same device IDs (podman run --rm --device example.com/vm=kvm --device example.com/vm=net-tun alpine ls -l /dev/kvm /dev/net/tun) as the rootless user before the first Gordon deploy.

CDI is not GPU-specific. Any device node can be exposed through a user-level CDI spec, so /dev/kvm and /dev/net/tun reach a rootless Podman service without raw /dev paths in manifests and without any privilege:

# ~/.config/cdi/vm.yaml  (rootless user, mode 0644)
cdiVersion: "0.6.0"
kind: example.com/vm
devices:
  - name: kvm
    containerEdits:
      deviceNodes:
        - path: /dev/kvm
  - name: net-tun
    containerEdits:
      deviceNodes:
        - path: /dev/net/tun
# ~/.config/containers/containers.conf (the rootless user that runs the Podman API service)
[engine]
# Setting cdi_spec_dirs replaces the defaults: keep /etc/cdi and /var/run/cdi if you use them.
cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi", "/home/<user>/.config/cdi"]
# gordon.toml
[app_devices.my-vm-device]
cdi = ["example.com/vm=kvm", "example.com/vm=net-tun"]
allowed_apps = ["myapp"]
allowed_services = ["worker"]
  • Status: verified by source reading (Podman 5.7.1), not by a live run. Confirm on your host before relying on it. No Gordon change appeared necessary. Gordon sends the IDs as a native cdi DeviceRequest; the Podman compat API forwards them to the same CDI injection as podman run --device <id>, which reads cdi_spec_dirs from the containers.conf of the user running the API service. Restart that service (systemctl --user restart podman.socket podman.service) after editing containers.conf; edits to the spec files themselves are picked up on the next container create.
  • Under rootless Podman the OCI runtime (crun or runc) cannot mknod in a user namespace and falls back to bind-mounting the host device node into the container. The node must therefore be accessible to the rootless user on the host: /dev/kvm and /dev/net/tun are usually 0666 or group-owned (kvm). Supplementary groups do not carry into the user namespace, so prefer a 0666/ACL-based permission over group membership.
  • CDI device names Gordon accepts match [A-Za-z0-9][A-Za-z0-9_.-]* (so kvm and net-tun are valid; names containing : are not).
  • A missing spec dir entry, an unreadable spec, or an unknown device name surfaces as a redacted create failure (runtime error redacted); check the Podman service journal for the CDI error.

Administrative app capabilities

App manifests cannot add Linux capabilities. A grant exists only when the operator declares a named policy in gordon.toml:

[app_capabilities.vm-net]
add = ["NET_ADMIN"]
allowed_apps = ["myapp"]    # exact, non-empty
allowed_services = ["worker"]     # exact, non-empty

The manifest references only the logical name: capabilities = ["vm-net"].

  • add holds explicit Linux capability names, written without the CAP_ prefix. Only this allowed set is accepted: AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, IPC_LOCK, KILL, MKNOD, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, SYS_NICE, SYS_RESOURCE. Everything else is rejected when the config loads, including ALL, SYS_ADMIN, SYS_PTRACE, SYS_MODULE, SYS_RAWIO, SYS_BOOT, SYS_TIME, DAC_READ_SEARCH, BPF, PERFMON, MAC_ADMIN, MAC_OVERRIDE, AUDIT_CONTROL, and SYSLOG. These can reach the host or defeat container confinement; a workload that needs one is not an unprivileged service.
  • The allowed capabilities are not harmless. The list only removes capabilities that clearly break confinement; each remaining one still needs a workload-specific assessment before you grant it. In particular: SETFCAP lets a process write file capabilities, which is dangerous together with writable binds or volumes shared with another service or the host; MKNOD creates device nodes (inert in a user namespace, but a risk if the runtime ever runs without one); NET_RAW allows packet spoofing and raw sockets inside the container's network namespace and on any shared network; SYS_CHROOT, SETPCAP, KILL, DAC_OVERRIDE, FOWNER and FSETID widen what a compromised process can do to files and processes it can reach; NET_ADMIN controls every interface, route, and firewall rule in the container's network namespace, including a shared network attachment. Grant the smallest set to the narrowest app+service pair.
  • allowed_apps and allowed_services are exact, non-empty allowlists. There is no wildcard, prefix, or empty-means-all form. Unknown keys inside [app_capabilities.<name>] fail closed.
  • Granted capabilities are added on top of Gordon's default set (CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, NET_BIND_SERVICE). Gordon's cap_drop = ALL and no-new-privileges stay in force, and containers are never privileged. The runtime guard re-checks the resolved list before every create.
  • Capabilities apply inside the container's own user and network namespaces. Under rootless Podman, NET_ADMIN lets the service create TAP devices and nftables rules in its own network namespace, not on the host. Because no-new-privileges stays on, a process only holds the added capabilities when it runs as container root (uid 0 in the user namespace, which is an unprivileged host user under rootless Podman); a non-root container user cannot gain them through file capabilities.
  • Gordon resolves logical names at activation time. Revisions persist the logical names, never the resolution. The grant is re-resolved before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys and restarts while the running service is untouched.
  • Narrowing a policy is enforced on reuse. A container keeps the capabilities it was created with, so before Gordon restarts, starts, or health-restarts an existing capability-bearing container it inspects the container's configured CapAdd and refuses the operation (capability-policy-violation, naming the excess capabilities) when it exceeds Gordon's defaults plus the currently authorized grant. If the container cannot be inspected the operation fails closed. Run gordon apps deploy to recreate the service with the narrowed set; the superseded container is retired by the normal replacement. A container missing from the runtime is rebuilt from the pinned revision under the current policy.
  • Device grants have the same gap, documented rather than fixed. Narrowing an [app_devices] mapping (removing a CDI ID) does not remove the device from an already created container, and a restart or recovery of that container keeps it. Resolution still fails closed for revoked or unknown policies, but Gordon cannot reliably read back the injected CDI devices (Podman's Docker-compatible inspect does not report them), so it cannot verify a narrowed mapping on reuse. After narrowing or revoking a device mapping, run gordon apps deploy for the affected service (or stop it) so the container is recreated with the current mapping.
  • Adding or removing a policy name shows as service/<name>/capabilities in diffs and recreates the container on the next deploy; reorder-only input is a no-op. Changing what a policy grants does not recreate a running container: the next deploy serves the new resolution.

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.

Runtime guard

Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains runtime guard violation. The guard refuses:

  • network mode host, container:<id>, or ns:<path>;
  • a volume name containing / or \, which the runtime would read as a host path;
  • a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.

Protected paths:

  • the data directory and the loaded config file;
  • files and directories set in the config: server.tls_key_file and the backup storage_dir values;
  • runtime sockets: /var/run/docker.sock, /run/docker.sock, /run/podman/podman.sock, $XDG_RUNTIME_DIR/docker.sock, $XDG_RUNTIME_DIR/podman/podman.sock, the unix:// path of DOCKER_HOST, and the server.runtime socket;
  • rootless runtime storage and config: ~/.local/share/containers, ~/.local/share/docker, ${XDG_CONFIG_HOME:-~/.config}/containers, and ~/.docker;
  • the systemd user units: ${XDG_CONFIG_HOME:-~/.config}/systemd/user;
  • Gordon runtime credentials: $XDG_RUNTIME_DIR/gordon, ~/.gordon/run, and $GORDON_CLOUDFLARE_API_TOKEN_FILE;
  • key stores: $GNUPGHOME, ~/.gnupg, $PASSWORD_STORE_DIR, ~/.password-store, $SOPS_AGE_KEY_FILE, and ${XDG_CONFIG_HOME:-~/.config}/sops/age.

A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.

Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and logging.container_logs.dir) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under <data_dir>/logs; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in $HOME, the log file itself stays protected and no read-only exception applies.

The only other exception is Gordon's own read-only secret file mount: <data_dir>/secrets-run/<app>/<container>/<KEY> at /run/secrets/<KEY>. A refused [app_mounts] policy fails before the running generation is replaced or restarted. Point the policy source outside every protected path.

Process memory protection

On Linux, gordon serve marks itself non-dumpable (PR_SET_DUMPABLE=0) as its first step, before loading its config file or any backend secret. Other processes of the same user can no longer read its /proc/<pid>/environ, mem, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set.

Limits:

  • Secrets passed as environment variables, such as GORDON_AUTH_TOKEN_SECRET, are readable for the short time between process start and that first step. Prefer a secrets backend.
  • Helper processes (pass, gpg, sops, journalctl) are dumpable again after exec; secret tools hold the values they decrypt while they run. Gordon starts them without its GORDON_* variables.
  • Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.

Hardened systemd user unit

Add these directives to the [Service] section of the user unit:

[Service]
NoNewPrivileges=yes
UMask=0077
LockPersonality=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
SystemCallArchitectures=native

Apply and check:

systemctl --user daemon-reload
systemctl --user restart gordon
systemctl --user status gordon
systemd-analyze --user security gordon.service

Credential scoping

  • Cloudflare DNS-01 token: grant only Zone:Read and DNS:Edit, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See Server.

  • Registry push tokens: issue one token per repository and pipeline instead of a global one:

    gordon auth token generate --subject ci-blog --scopes push --repo blog
    

Protection boundary

Gordon v3 protects against:

  • file-delivered secret values showing up in container env, docker inspect, or /proc/1/environ;
  • app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
  • same-user processes reading the Gordon process's memory or environment through /proc, within the limits above.

Gordon v3 does not protect against:

  • a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and pass;
  • a malicious image deployed for an app: it receives that app's secrets;
  • root on the host.
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: `[[services.<name>.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### Plain device nodes under rootless Podman
87
88> **Verified by source reading only. Confirm on the host:** create a throwaway container with the same device IDs (`podman run --rm --device example.com/vm=kvm --device example.com/vm=net-tun alpine ls -l /dev/kvm /dev/net/tun`) as the rootless user before the first Gordon deploy.
89
90CDI is not GPU-specific. Any device node can be exposed through a user-level CDI spec, so `/dev/kvm` and `/dev/net/tun` reach a rootless Podman service without raw `/dev` paths in manifests and without any privilege:
91
92```yaml
93# ~/.config/cdi/vm.yaml  (rootless user, mode 0644)
94cdiVersion: "0.6.0"
95kind: example.com/vm
96devices:
97  - name: kvm
98    containerEdits:
99      deviceNodes:
100        - path: /dev/kvm
101  - name: net-tun
102    containerEdits:
103      deviceNodes:
104        - path: /dev/net/tun
105```
106
107```toml
108# ~/.config/containers/containers.conf (the rootless user that runs the Podman API service)
109[engine]
110# Setting cdi_spec_dirs replaces the defaults: keep /etc/cdi and /var/run/cdi if you use them.
111cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi", "/home/<user>/.config/cdi"]
112```
113
114```toml
115# gordon.toml
116[app_devices.my-vm-device]
117cdi = ["example.com/vm=kvm", "example.com/vm=net-tun"]
118allowed_apps = ["myapp"]
119allowed_services = ["worker"]
120```
121
122- **Status: verified by source reading (Podman 5.7.1), not by a live run. Confirm on your host before relying on it.** No Gordon change appeared necessary. Gordon sends the IDs as a native `cdi` `DeviceRequest`; the Podman compat API forwards them to the same CDI injection as `podman run --device <id>`, which reads `cdi_spec_dirs` from the containers.conf of the user running the API service. Restart that service (`systemctl --user restart podman.socket podman.service`) after editing `containers.conf`; edits to the spec files themselves are picked up on the next container create.
123- Under rootless Podman the OCI runtime (crun or runc) cannot `mknod` in a user namespace and falls back to bind-mounting the host device node into the container. The node must therefore be accessible to the rootless user on the host: `/dev/kvm` and `/dev/net/tun` are usually `0666` or group-owned (`kvm`). Supplementary groups do not carry into the user namespace, so prefer a `0666`/ACL-based permission over group membership.
124- CDI device names Gordon accepts match `[A-Za-z0-9][A-Za-z0-9_.-]*` (so `kvm` and `net-tun` are valid; names containing `:` are not).
125- A missing spec dir entry, an unreadable spec, or an unknown device name surfaces as a redacted create failure (`runtime error redacted`); check the Podman service journal for the CDI error.
126
127## Administrative app capabilities
128
129App manifests cannot add Linux capabilities. A grant exists only when the operator declares a named policy in `gordon.toml`:
130
131```toml
132[app_capabilities.vm-net]
133add = ["NET_ADMIN"]
134allowed_apps = ["myapp"]    # exact, non-empty
135allowed_services = ["worker"]     # exact, non-empty
136```
137
138The manifest references only the logical name: `capabilities = ["vm-net"]`.
139
140- `add` holds explicit Linux capability names, written without the `CAP_` prefix. Only this allowed set is accepted: `AUDIT_WRITE`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `IPC_LOCK`, `KILL`, `MKNOD`, `NET_ADMIN`, `NET_BIND_SERVICE`, `NET_RAW`, `SETFCAP`, `SETGID`, `SETPCAP`, `SETUID`, `SYS_CHROOT`, `SYS_NICE`, `SYS_RESOURCE`. Everything else is rejected when the config loads, including `ALL`, `SYS_ADMIN`, `SYS_PTRACE`, `SYS_MODULE`, `SYS_RAWIO`, `SYS_BOOT`, `SYS_TIME`, `DAC_READ_SEARCH`, `BPF`, `PERFMON`, `MAC_ADMIN`, `MAC_OVERRIDE`, `AUDIT_CONTROL`, and `SYSLOG`. These can reach the host or defeat container confinement; a workload that needs one is not an unprivileged service.
141- **The allowed capabilities are not harmless.** The list only removes capabilities that clearly break confinement; each remaining one still needs a workload-specific assessment before you grant it. In particular: `SETFCAP` lets a process write file capabilities, which is dangerous together with writable binds or volumes shared with another service or the host; `MKNOD` creates device nodes (inert in a user namespace, but a risk if the runtime ever runs without one); `NET_RAW` allows packet spoofing and raw sockets inside the container's network namespace and on any shared network; `SYS_CHROOT`, `SETPCAP`, `KILL`, `DAC_OVERRIDE`, `FOWNER` and `FSETID` widen what a compromised process can do to files and processes it can reach; `NET_ADMIN` controls every interface, route, and firewall rule in the container's network namespace, including a shared network attachment. Grant the smallest set to the narrowest app+service pair.
142- `allowed_apps` and `allowed_services` are exact, non-empty allowlists. There is no wildcard, prefix, or empty-means-all form. Unknown keys inside `[app_capabilities.<name>]` fail closed.
143- Granted capabilities are added on top of Gordon's default set (`CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, `SETGID`, `NET_BIND_SERVICE`). Gordon's `cap_drop = ALL` and `no-new-privileges` stay in force, and containers are never privileged. The runtime guard re-checks the resolved list before every create.
144- Capabilities apply inside the container's own user and network namespaces. Under rootless Podman, `NET_ADMIN` lets the service create TAP devices and nftables rules in its own network namespace, not on the host. Because `no-new-privileges` stays on, a process only holds the added capabilities when it runs as container root (uid 0 in the user namespace, which is an unprivileged host user under rootless Podman); a non-root container user cannot gain them through file capabilities.
145- Gordon resolves logical names at activation time. Revisions persist the logical names, never the resolution. The grant is re-resolved before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys and restarts while the running service is untouched.
146- **Narrowing a policy is enforced on reuse.** A container keeps the capabilities it was created with, so before Gordon restarts, starts, or health-restarts an existing capability-bearing container it inspects the container's configured `CapAdd` and refuses the operation (`capability-policy-violation`, naming the excess capabilities) when it exceeds Gordon's defaults plus the currently authorized grant. If the container cannot be inspected the operation fails closed. Run `gordon apps deploy` to recreate the service with the narrowed set; the superseded container is retired by the normal replacement. A container missing from the runtime is rebuilt from the pinned revision under the current policy.
147- **Device grants have the same gap, documented rather than fixed.** Narrowing an `[app_devices]` mapping (removing a CDI ID) does not remove the device from an already created container, and a restart or recovery of that container keeps it. Resolution still fails closed for revoked or unknown policies, but Gordon cannot reliably read back the injected CDI devices (Podman's Docker-compatible inspect does not report them), so it cannot verify a narrowed mapping on reuse. After narrowing or revoking a device mapping, run `gordon apps deploy` for the affected service (or stop it) so the container is recreated with the current mapping.
148- Adding or removing a policy name shows as `service/<name>/capabilities` in diffs and recreates the container on the next deploy; reorder-only input is a no-op. Changing what a policy grants does not recreate a running container: the next deploy serves the new resolution.
149
150## External image registries
151
152Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
153
154```toml
155[images]
156allowed_registries = ["registry.internal:5000"]
157require_digest = true
158```
159
160- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
161- 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.
162- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
163- 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.
164
165## Smart TCP Raw Fallback
166
167Raw 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.
168
169```toml
170[entrypoints.edge]
171address = ":443"
172protocol = "smart_tcp"
173raw_fallback = "ssh-fallback"
174raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
175```
176
177Use `raw_fallback_trusted_cidrs` for private raw fallback. Public raw fallback requires an explicit acknowledgement:
178
179```toml
180[entrypoints.edge]
181address = ":443"
182protocol = "smart_tcp"
183raw_fallback = "public-raw"
184allow_public_raw_fallback = true
185```
186
187Security behavior:
188
189- PROXY protocol v1 and v2 prefixes are rejected; Gordon does not trust or parse PROXY headers on smart TCP entrypoints.
190- `trusted_cidrs` and `raw_fallback_trusted_cidrs` use the peer socket IP, not `X-Forwarded-For`.
191- HTTP-looking or TLS-looking malformed traffic is rejected and never bypasses to raw fallback.
192- Entry-point-wide `trusted_cidrs` applies before sniffing to all protocols; raw fallback has its own narrower policy for private fallback exposure.
193
194## Docker network isolation
195
196Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:
197
198```toml
199[network_isolation]
200enabled = true
201internal = true
202```
203
204`internal = false` remains the compatibility default because some applications need direct egress during startup.
205
206Every 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.
207
208## Readiness helper containers
209
210Probing 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.
211
212- 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.
213- The helper attaches only to the target app's private network. It never joins a shared network or a host network.
214- It never publishes a host port and has no mounts, volumes, secrets, environment, or runtime socket.
215- 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.
216- 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.
217- A target that listens only on its own loopback fails readiness.
218- 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.
219
220## Registry exposure
221
222- With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
223- 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.
224- 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.
225- 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.
226
227## Container runtime profile
228
229```toml
230[containers]
231security_profile = "compat" # or "strict"
232```
233
234- `compat` preserves broad image compatibility while retaining `no-new-privileges` and default capability restrictions.
235- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`.
236
237Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
238
239## Runtime guard
240
241Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. The guard refuses:
242
243- network mode `host`, `container:<id>`, or `ns:<path>`;
244- a volume name containing `/` or `\`, which the runtime would read as a host path;
245- a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.
246
247Protected paths:
248
249- the data directory and the loaded config file;
250- files and directories set in the config: `server.tls_key_file` and the backup `storage_dir` values;
251- runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
252- rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;
253- the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;
254- Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;
255- key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
256
257A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
258
259Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under `<data_dir>/logs`; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in `$HOME`, the log file itself stays protected and no read-only exception applies.
260
261The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
262
263## Process memory protection
264
265On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) as its first step, before loading its config file or any backend secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set.
266
267Limits:
268
269- Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend.
270- Helper processes (`pass`, `gpg`, `sops`, `journalctl`) are dumpable again after `exec`; secret tools hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
271- Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.
272
273## Hardened systemd user unit
274
275Add these directives to the `[Service]` section of the [user unit](../installation.md#user-service-recommended-for-rootless):
276
277```ini
278[Service]
279NoNewPrivileges=yes
280UMask=0077
281LockPersonality=yes
282RestrictSUIDSGID=yes
283RestrictRealtime=yes
284SystemCallArchitectures=native
285```
286
287Apply and check:
288
289```bash
290systemctl --user daemon-reload
291systemctl --user restart gordon
292systemctl --user status gordon
293systemd-analyze --user security gordon.service
294```
295
296## Credential scoping
297
298- **Cloudflare DNS-01 token**: grant only `Zone:Read` and `DNS:Edit`, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See [Server](./server.md).
299- **Registry push tokens**: issue one token per repository and pipeline instead of a global one:
300
301  ```bash
302  gordon auth token generate --subject ci-blog --scopes push --repo blog
303  ```
304
305## Protection boundary
306
307Gordon v3 protects against:
308
309- file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
310- app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
311- same-user processes reading the Gordon process's memory or environment through `/proc`, within the limits above.
312
313Gordon v3 does not protect against:
314
315- a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and `pass`;
316- a malicious image deployed for an app: it receives that app's secrets;
317- root on the host.
318
319## Related
320
321- [Auth](./auth.md)
322- [Secrets](./secrets.md)
323- [Images](./images.md)
324- [Network Isolation](./network-isolation.md)
325- [Volumes](./volumes.md)
326- [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