Skip to content

feat!: dockerd-parity listening socket, Unix path only - #47

Merged
abienkowski merged 19 commits into
mainfrom
feat/dockerd-socket-parity
Sep 30, 2026
Merged

abienkowski merged 19 commits into
mainfrom
feat/dockerd-socket-parity

Conversation

@abienkowski

Copy link
Copy Markdown
Collaborator

Description

With this change the proxy's listening socket works like docker.sock. The socket is always 0660 and owned by a well-known group (docker-socket-policy), and access is granted or revoked by group membership alone. No code path can listen on anything except a Unix socket file the proxy created itself.

Design: spec/listener-design.md.

Closes #45
Closes #29
Closes #44
Follow-up: #46 (TypeScript single-instance lock)

What changes

  • Unix socket path only. fd:// socket activation has been removed. fd://3 is now rejected like any other address that isn't a path (only supports Unix socket paths, exit 2). It was the last remaining way for a TCP listener to get in: ListenStream=127.0.0.1:2375 would hand one over. Removing it fixes TypeScript fd://3 socket activation is broken: every valid socket is rejected at startup #44 (TypeScript rejected every fd, valid ones included) and makes fd://3 socket activation: not verified to be listening, and sd_listen_fds env contract unchecked in all three #29's hardening moot.

  • The mode is always 0660. --listen-socket-mode has been removed. The socket is still created at 0600 under umask(0177), then chowned, then chmodded.

  • Group selection works like dockerd's (moby/daemon/listeners/listeners_linux.go):

    --listen-socket-group Group exists? Result
    not passed yes docker-socket-policy
    not passed no warning; the proxy's own group
    =name yes that group
    =name no exit 2
    =gid — used as-is; digits only, 0-4294967294, otherwise exit 2
    ="" — the proxy's own group, no warning

    If the proxy isn't a member of the selected group, chown fails with EPERM. The proxy then exits 1, and the error says it must be a member of that group.

  • Single-instance lock (Go and Rust). Each takes flock(LOCK_EX|LOCK_NB) on <path>.lock, opened with O_NOFOLLOW and mode 0600. The lock is held until exit and the file is never deleted. The kernel releases it on any exit, including SIGKILL. A second instance exits 1 with <path> is in use by another instance (lock <path>.lock held).

  • Live-socket check (all three). Before deleting an existing socket, the proxy tries to connect to it. If something answers, or the attempt times out, it exits 1 with in use by another process and leaves the socket alone. "Connection refused" means the socket is stale, so it is replaced. Any other connect error, or something at the path that isn't a socket, is refused and left untouched.

  • TypeScript exception. Node has no flock, so TypeScript does only the live-socket check. That leaves a small check-then-delete race when two TypeScript instances start at the same moment. The race is documented in the README and in the spec, and TypeScript: single-instance lock for the listening socket #46 tracks closing it.

On main before this PR

  • A second instance on the same path deleted the first instance's live socket and took it over silently, in all three implementations. That is now refused.
  • In TypeScript, fd://3 socket activation rejected every fd. Fixed by removing the feature.
  • In Go, --listen-socket-group=4294967296 (or +4294967296) was cut to 32 bits by chown and became gid 0. Rust and TypeScript differed on these inputs as well. All three now accept only a digit string in 0-4294967294.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure

Formal specification

New module spec/listener.qnt. It models three instances going through lock → check → delete → bind → chown → chmod, one step at a time, so their steps can interleave and any instance can crash at any point. It has two variants: listener_locked (Go and Rust) and listener_unlocked (TypeScript).

It checks six invariants: neverListensOnTcp, groupBeforeMode, neverWorldWritable, neverUnlinksNonSocket, noLiveTakeover and groupSelectionMatchesTable.

noLiveTakeover does catch the race it's there for. With the lock turned off, the simulator finds the takeover:

$ quint run spec/listener.qnt --main=listener_unlocked --max-steps=30 --invariant noLiveTakeover
[State 5]  I1 probed                  pathObj=absent          <- I1 checks: nothing there
[State 8]  I1 unlinked, I2 probed     pathObj=absent          <- I2 checks: nothing there
[State 10] I1 bound                   pathObj={id:2,socket} 0600
[State 12] I1 serving                 pathObj={id:2,socket} 0660   <- I1 is live
[State 13] I1 serving, I2 unlinked    pathObj=absent               <- I2 deletes I1's live socket
[violation] Found an issue
Use --seed=0xe6949bd341d450d4 --backend=rust to reproduce.

With the lock on, all six invariants hold ([ok] No violation found). Each invariant was also checked by removing its protection in a scratch copy of the model and confirming it then fails.

quint test has one run per row of the design tables. Each has a same-named unit test in Go, Rust and TypeScript, so a row can be traced across all four. The new make test-spec target runs these tests in the CI quint job and in release-verify.

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (make test-integration)
  • Quint verification passes (make verify)
  • New tests added for the change
before after
Go 88 97
Rust 128 135
TypeScript 138 154 (1 skipped: the concurrency test waiting on #46)
Socket integration suite 12 ×3 15 ×3
Integration suite 27 ×3 27 ×3
Quint run tests — 11 locked + 10 unlocked
$ make test-all            # rc=0
ok   github.com/ChainSafe/docker-socket-policy/go            (+ audit, middleware, policy, proxy)
test result: ok. 135 passed; 0 failed
# tests 154  # pass 153  # fail 0  # skipped 1
$ make lint-all            # rc=0
$ make test-spec
  11 passing
  10 passing
$ make verify BACKEND=rust # rc=0, no violations in either spec

Each suite passed in Go, Rust and TypeScript: make test-integration-sock{,-rs,-ts} (15/15) and make test-integration{,-rs,-ts} (27/27).

New tests were run against the old code first. They failed as expected:

  • Default-group check: changing the default group to nonexistent makes default.sock owned by docker-socket-policy (2001) fail with gid 65532.
  • Unit tests: each language's new unit tests fail before the corresponding change.

Checked by running the binaries, on macOS and in a Linux container:

  • A stale 0777 socket is recreated at 660.
  • A second instance exits 1 with the lock message, and the first instance's socket inode is unchanged.
  • A live socket held by a process that takes no lock is refused and left alone.
  • fd://3 exits 2.

Concurrency tests (Go, Rust): 8 instances started together, 50 times over. Each time exactly one serves, and the other seven get the lock message.

Breaking changes

  • --listen-socket-mode has been removed; passing it is an unknown-flag error (exit 2).
  • --listen-socket=fd://3 (systemd socket activation) has been removed. Use a plain .service instead; see the README's "systemd service" section.
  • When --listen-socket-group isn't passed, the socket now belongs to docker-socket-policy if that group exists. Before, it used the proxy's own group.

Release note. release.yml always bumps the patch number, so this merge will be tagged v0.2.22 even though it is feat!. Please edit the draft release notes before publishing so they include the breaking changes above. The claim in AGENTS.md that versions are derived from commit types is not true today; that needs a separate fix.

The squash commit should keep this footer:

BREAKING CHANGE: --listen-socket-mode and --listen-socket=fd://3 are removed; the socket is always 0660 and defaults to group docker-socket-policy.

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed

@abienkowski abienkowski added Status: Break Change Added to a PR or issue that would cause a breaking change Type: Enhancement Added to issues and PRs when a change includes improvements or optimizations. labels Sep 30, 2026
@abienkowski
abienkowski merged commit 05320aa into main Sep 30, 2026
6 checks passed
@abienkowski
abienkowski deleted the feat/dockerd-socket-parity branch September 30, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Break Change Added to a PR or issue that would cause a breaking change Type: Enhancement Added to issues and PRs when a change includes improvements or optimizations.

Projects

None yet

1 participant