Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
66dd061
spec: design for dockerd-parity listening socket (Unix path only)
abienkowski Sep 29, 2026
b28df18
spec: model the listener pipeline and single-instance lock (#45)
abienkowski Sep 29, 2026
6529d01
feat(go)!: drop fd:// socket activation and --listen-socket-mode (#45)
abienkowski Sep 30, 2026
9e005e6
feat(rs)!: drop fd:// socket activation and --listen-socket-mode (#45)
abienkowski Sep 30, 2026
8ba760a
feat(ts)!: drop fd:// socket activation and --listen-socket-mode (#45)
abienkowski Sep 30, 2026
5fe424a
feat(go): dockerd-style default socket group (#45)
abienkowski Sep 30, 2026
a55bd25
feat(go): single-instance lock and live-socket probe (#45)
abienkowski Sep 30, 2026
477c99b
feat(rs): dockerd-style default socket group (#45)
abienkowski Sep 30, 2026
baa12c7
feat(rs): single-instance lock and live-socket probe (#45)
abienkowski Sep 30, 2026
541d7a6
feat(ts): dockerd-style socket group and live-socket probe (#45)
abienkowski Sep 30, 2026
b76ed96
test(deploy): dockerd-style group paths in the socket suite (#45)
abienkowski Sep 30, 2026
bf462bb
docs: dockerd-style socket model; drop socket activation (#45)
abienkowski Sep 30, 2026
769648b
docs: numeric --listen-socket-group is used as-is (#45)
abienkowski Sep 30, 2026
d485729
fix: reject out-of-range numeric --listen-socket-group (#45)
abienkowski Sep 30, 2026
1d2faaa
docs: listener operations notes (#45)
abienkowski Sep 30, 2026
bfa0841
chore(release): run Quint test-spec in release-verify (#45)
abienkowski Sep 30, 2026
8465bea
fix: treat only digit strings as numeric gids (#45)
abienkowski Sep 30, 2026
78dd93b
chore(ci): run Quint test-spec in ci-verify (#45)
abienkowski Sep 30, 2026
22050d1
docs: align design doc and README anchor with shipped behaviour (#45)
abienkowski Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
node-version: "22"
- run: npm install -g @informalsystems/quint
- run: make typecheck
- run: make test-spec
- run: make verify BACKEND=typescript

go:
Expand Down
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ deploy/ — Docker Compose + integration tests
- `make build-go` / `make test-go` / `make lint-go` — Go only
- `make build-rs` / `make test-rs` / `make lint-rs` — Rust only
- `make build-ts` / `make test-ts` / `make lint-ts` — TypeScript only
- `make verify` — Quint spec simulation
- `make verify` — Quint spec simulation (request handling + listener)
- `make test-spec` — Quint `run` tests for `spec/listener.qnt`
- `make test-integration` — Docker Compose integration tests
- `make test-integration-sock` — listening-socket integration tests (`IMPL=rs|ts` for the others)

## Architecture (same across all 3 languages)
- `policy/` — Policy types + Manager (loads YAML from config dir)
Expand All @@ -35,16 +37,17 @@ deploy/ — Docker Compose + integration tests
- Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml)

## Test Coverage
- Go: 74 unit tests (policy: 10, middleware: 29, proxy: 31, audit: 4)
- Rust: 112 unit tests (policy: 15, middleware: 50, proxy: 37, handler: 4, audit: 4, transport: 2)
- TypeScript: 108 unit tests (policy: 10, middleware: 37, proxy: 26, transport: 5, handler: 6, flags: 16, audit: 4)
- 26 integration tests via deploy/test.sh + docker-compose
- Go: 97 unit tests (main/listener: 23, policy: 10, middleware: 29, proxy: 31, audit: 4)
- Rust: 135 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 37, handler: 4, audit: 4, transport: 2)
- TypeScript: 154 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 26, policy: 10, handler: 6, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 27 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose)
- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests

## Test Conventions
- Go: stdlib `testing` package, `go test ./...`
- Rust: `#[cfg(test)]` inline modules, `cargo test`
- TypeScript: `node:test` framework, `npm run build && node --test dist/*.test.js`
- Integration: `make test-integration` (26 test cases via Docker Compose)
- Integration: `make test-integration` (27 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose

## Contribution Workflow

Expand Down
18 changes: 14 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ OUTPUT_DIR ?= .
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
QUINT ?= $(shell command -v quint 2>/dev/null || echo node $$HOME/.hermes/node/lib/node_modules/@informalsystems/quint/dist/src/cli.js)
SPEC ?= spec/docker_socket_policy.qnt
LISTENER_SPEC ?= spec/listener.qnt
BACKEND ?=

.PHONY: build clean test lint verify typecheck validate ci-verify release-verify
.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify
.PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts

# ─── Go ──────────────────────────────────────────────
Expand Down Expand Up @@ -65,26 +66,35 @@ clean:

typecheck:
$(QUINT) typecheck $(SPEC)
$(QUINT) typecheck $(LISTENER_SPEC)

verify:
if [ -n "$(BACKEND)" ]; then \
$(QUINT) run --max-steps=100 --invariants allInvariants --backend $(BACKEND) $(SPEC); \
$(QUINT) run $(SPEC) --max-steps=100 --invariants allInvariants --backend $(BACKEND) && \
$(QUINT) run $(LISTENER_SPEC) --main=listener_locked --max-steps=30 --invariant allListenerInvariants --backend $(BACKEND); \
else \
$(QUINT) run --max-steps=100 --invariants allInvariants $(SPEC); \
$(QUINT) run $(SPEC) --max-steps=100 --invariants allInvariants && \
$(QUINT) run $(LISTENER_SPEC) --main=listener_locked --max-steps=30 --invariant allListenerInvariants; \
fi

test-spec:
$(QUINT) test $(LISTENER_SPEC) --main=listener_locked
$(QUINT) test $(LISTENER_SPEC) --main=listener_unlocked

verify-ts:
$(QUINT) run --max-steps=50 --invariants allInvariants --backend typescript $(SPEC)
$(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript

ci-verify:
$(MAKE) typecheck
$(MAKE) test-spec
$(MAKE) verify BACKEND=typescript
$(MAKE) test-all
$(MAKE) test-integration
$(MAKE) verify-reproducible-all

release-verify:
$(MAKE) typecheck
$(MAKE) test-spec
$(MAKE) verify BACKEND=rust
$(MAKE) test-all
$(MAKE) test-integration
Expand Down
143 changes: 109 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,22 @@ make validate
### Run

```bash
./docker-socket-policy \
sudo groupadd --system docker-socket-policy
sudo usermod -aG docker-socket-policy alice

sudo ./docker-socket-policy \
--listen-socket=/var/run/docker-socket-policy.sock \
--listen-socket-group=builders \
--docker-host=/var/run/docker.sock \
--config-dir=./config \
--log-file=/tmp/docker-socket-policy.log
```

The socket is created `0660` owned by `--listen-socket-group`, so members of
that group can connect and nobody else can. Omit the flag and only the proxy's
own user can reach it.
Like `docker.sock`, the socket is always created `0660` and owned by the
`docker-socket-policy` group, so members of that group can connect and nobody
else can. Grant or revoke access with group membership alone. If the group
does not exist, the proxy warns and uses its own group instead. See the
Unix socket security boundary note under [CLI flags](#cli-flags) for the
details.

### Configure a Service

Expand Down Expand Up @@ -217,13 +222,12 @@ docker pull attacker/malware:latest # denied: image not in allowlist

| Flag | Default | Description |
|------|---------|-------------|
| `--listen-socket` | `/var/run/docker-socket-policy.sock` | Unix socket to listen on (or `fd://3` for systemd) |
| `--listen-socket` | `/var/run/docker-socket-policy.sock` | Unix socket path to listen on (absolute filesystem path only) |
| `--docker-host` | `/var/run/docker.sock` | Docker daemon socket path (Unix socket only) |
| `--config-dir` | `/etc/docker-socket-policy/services` | Policy config directory |
| `--log-file` | `/var/log/docker-socket-policy.log` | Audit log path |
| `--readonly` | `false` | Enable read-only mode |
| `--listen-socket-mode` | `0660` | Octal mode for the listening socket (ignored for `fd://3`) |
| `--listen-socket-group` | *(none)* | Group name or gid owning the listening socket (ignored for `fd://3`) |
| `--listen-socket-group` | `docker-socket-policy` | Group owning the socket (default `docker-socket-policy`; `""` = the proxy's own group) |

> **Unix socket security boundary**: the proxy listens on a Unix socket only,
> in all three implementations. Access control is the file permissions and Unix
Expand All @@ -235,25 +239,76 @@ docker pull attacker/malware:latest # denied: image not in allowlist
> Docker daemon over Unix sockets exclusively and reject `tcp://` and `http://`
> schemes for `--docker-host`.
>
> To grant access, set `--listen-socket-group` to a group, place the caller's
> container user in that group, and bind-mount the socket in; to revoke it,
> remove the group membership. If the proxy cannot reach the daemon socket
> because of its own group permissions, requests surface as `403`.
> The socket works like `docker.sock`. It is always `0660`, whatever the
> ambient umask, and there is no flag to change the mode. `connect(2)` on a
> Unix socket needs **write** permission, so any other mode either locks the
> group out or opens the socket to every local uid. The group is chosen the way
> dockerd chooses the `docker` group:
>
> | `--listen-socket-group` | Group exists | Socket group |
> |---|---|---|
> | not passed | yes | `docker-socket-policy` |
> | not passed | no | the proxy's own group, with the warning `group docker-socket-policy not found, using the proxy's own group <gid>` |
> | `=name` | yes | that group |
> | `=name` | no | none: startup fails, exit 2 |
> | `=gid` | — | that gid, used as-is (no lookup). Digits only, `0-4294967294`; a larger value fails with exit 2 |
> | `=""` | — | the proxy's own group, no warning |
>
> To grant access, create the group once and add callers to it:
>
> ```bash
> groupadd --system docker-socket-policy
> usermod -aG docker-socket-policy alice
> ```
>
> For a container caller, give its user that group (`group_add:`) and
> bind-mount the socket in. To revoke access, remove the group membership. If
> the proxy cannot reach the daemon socket because of its own group
> permissions, requests surface as `403`.
>
> To give the socket a group other than its own, a non-root proxy must be a
> member of that group (`SupplementaryGroups=` / `group_add:`). Otherwise
> startup fails with exit 1 and the message `cannot give <path> to group <gid>:
> the proxy's user must be a member of it`. The proxy does not fall back to
> another group, because a group that exists was chosen on purpose.
>
> The socket is bound at `0600`, then given its group, and only then widened
> to `0660`. It is never reachable by the wrong group, even for a moment.
>
> The socket is created at `--listen-socket-mode` (default `0660`) regardless of
> the ambient umask. This matters: `bind(2)` applies `0777 & ~umask`, so left to
> a default umask the socket would be `0755`, and `connect(2)` on a Unix socket
> requires **write** permission — the group grant above would silently not work.
> Under `umask 0` it would be `0777`, reachable by every local uid. A
> world-writable mode is rejected at startup and there is no opt-out.
> **One instance per socket path.** At startup the proxy handles what it finds
> at the path as follows:
>
> Without `--listen-socket-group` the socket is `0660` owned by the proxy's own
> user and group, so only that user can connect. The group is what makes the
> mode useful.
> - A stale socket (`connect(2)` is refused) is removed and replaced.
> - A live socket is refused with `<path> is in use by another process`, exit 1.
> The proxy leaves that socket untouched.
> - A socket that fails `connect(2)` in any other way (for example `EACCES`)
> is refused, and the proxy does not remove it.
> - Anything that is not a socket is refused, and the proxy does not remove it.
>
> Under `fd://3` the socket belongs to systemd: use `SocketMode=` and
> `SocketGroup=` in the `.socket` unit instead, as in the example below. Both
> flags are ignored in that mode.
> The Go and Rust implementations also take an exclusive `flock` on
> `<path>.lock` (mode `0600`) before they touch the socket. They hold it for
> the life of the process. A second instance fails with `<path> is in use by
> another instance (lock <path>.lock held)`, exit 1. The kernel releases the
> lock on any exit, including `SIGKILL`, so a crash never leaves a stale lock.
> The `.lock` file stays next to the socket after shutdown. **Do not delete
> it**, especially while the proxy is running: deleting it lets a second
> instance take the socket. A `.lock` left by another uid (for example an
> earlier run as root on a persistent volume) makes startup fail with
> `opening lock …` and a permission-denied error, exit 1; delete that lock file only when
> no instance is running.
>
> *TypeScript exception:* Node has no `flock`, so the TypeScript
> implementation takes no lock and relies on the live-socket check alone. If
> two TypeScript instances start on the same path within the same few
> milliseconds, both can see the old socket as stale. The second one then
> removes the first one's new socket and binds its own, and the first keeps
> running but nothing can reach it. An instance that starts after another is
> already listening is still refused. Node also unlinks its socket path on
> close, so stopping the orphaned instance (the obvious remedy) deletes the
> surviving instance's live socket; restart the survivor afterwards. The same
> holds when a Go or Rust instance is the orphan in a race with TypeScript.
> This gap is tracked in
> [#46](https://github.com/ChainSafe/docker-socket-policy/issues/46).
>
> **What the socket does not give you is per-service isolation.** The proxy
> performs no caller authentication: it selects a policy from the `Image` field
Expand All @@ -264,39 +319,59 @@ docker pull attacker/malware:latest # denied: image not in allowlist
> services from one another, run a proxy instance per service, each with its own
> socket and a `--config-dir` containing only that service's policy.

### Systemd Socket Activation
### systemd Service

**`docker-socket-policy.socket`**:
```ini
[Socket]
ListenStream=/var/run/docker-socket-policy.sock
SocketMode=0660
SocketGroup=builders
The proxy always creates its own socket. systemd socket activation
(`fd://`) is not supported, so the proxy never listens on a socket it did not
create. Run it as a plain service:

```bash
sudo groupadd --system docker-socket-policy # skip if it already exists
sudo useradd --system --no-create-home -g docker-socket-policy docker-socket-policy
sudo usermod -aG docker-socket-policy alice # grant a caller access
```

**`docker-socket-policy.service`**:
```ini
[Service]
ExecStart=/usr/local/bin/docker-socket-policy \
--listen-socket=fd://3 \
--listen-socket=/run/docker-socket-policy/docker-socket-policy.sock \
--docker-host=/var/run/docker.sock \
--config-dir=/etc/docker-socket-policy/services \
--log-file=/var/log/docker-socket-policy.log
--log-file=/var/log/docker-socket-policy/audit.log
User=docker-socket-policy
Group=docker-socket-policy
# Reach the Docker daemon socket.
SupplementaryGroups=docker
# A non-root proxy cannot create files in /var/run; systemd creates this
# directory for it, owned by User=/Group=.
RuntimeDirectory=docker-socket-policy
RuntimeDirectoryMode=0755
LogsDirectory=docker-socket-policy
Restart=on-failure
NoNewPrivileges=true
```

`Group=docker-socket-policy` makes that group the proxy's own group, so it
can give the socket to it. `docker-socket-policy.sock.lock` is created next
to the socket in the same directory. Callers then use
`DOCKER_HOST=unix:///run/docker-socket-policy/docker-socket-policy.sock`.

To use a different group, pass `--listen-socket-group=<name>` and add that
group to `SupplementaryGroups=`. Otherwise startup fails with the
"must be a member of it" error.

## Formal Verification

This project includes a [Quint](https://quint-lang.org/) formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 9 invariants on every state transition.
This project includes a [Quint](https://quint-lang.org/) formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 9 invariants on every state transition. A second module, `spec/listener.qnt`, models listening-socket startup (group selection, existing-path checks, the single-instance lock) with 6 more invariants.

The CI pipeline runs verification on every push and PR. A violation blocks the build.

```bash
make typecheck # Quint type-check (proves type safety)
make verify # Random-simulation verification (default evaluator)
make verify BACKEND=rust # Same, using the faster Rust backend
make test-spec # Quint `run` tests for listener.qnt (one per design-table row)
make validate # All checks: typecheck + verify + go vet + go test
```

Expand Down
1 change: 1 addition & 0 deletions deploy/config/group
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker-socket-policy:x:2001:
30 changes: 24 additions & 6 deletions deploy/docker-compose.sock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,28 @@ services:
command:
- --docker-host=/sock/docker.sock
- --listen-socket=/sock/granted.sock
# Exercises the #40 flags: the socket must come out 0660 owned by this
# group regardless of the image's umask.
- --listen-socket-mode=0660
- --listen-socket-group=2001
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

proxy-default-group:
build:
context: ../${IMPL:-go}
dockerfile: Dockerfile
# Primary GID 65532, with 2001 only as a supplementary group. The mounted
# /etc/group names 2001 docker-socket-policy, so the socket must come out
# owned by that default group rather than by the proxy's egid.
user: 65532:65532
group_add: ["2001"]
depends_on:
sock-perms:
condition: service_healthy
volumes:
- ./config:/etc/docker-socket-policy/services:ro
- ./config/group:/etc/group:ro
- sock-data:/sock
command:
- --docker-host=/sock/docker.sock
- --listen-socket=/sock/default.sock
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

Expand All @@ -81,8 +99,6 @@ services:
command:
- --docker-host=/sock/docker.sock
- --listen-socket=/sock/denied.sock
- --listen-socket-mode=0660
- --listen-socket-group=3001
- --config-dir=/etc/docker-socket-policy/services
- --log-file=/tmp/docker-socket-policy.log

Expand All @@ -93,9 +109,11 @@ services:
depends_on:
- proxy-granted
- proxy-denied
- proxy-default-group
environment:
PROXY_GRANTED_SOCK: /sock/granted.sock
PROXY_DENIED_SOCK: /sock/denied.sock
PROXY_DEFAULT_SOCK: /sock/default.sock
volumes:
- ./test-sock.sh:/test-sock.sh:ro
- sock-data:/sock
Expand Down
Loading
Loading