Skip to content

feat!: listen on Unix socket only and bring TypeScript to parity - #38

Open
abienkowski wants to merge 5 commits into
mainfrom
feat/unix-socket-only
Open

abienkowski wants to merge 5 commits into
mainfrom
feat/unix-socket-only

Conversation

@abienkowski

@abienkowski abienkowski commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Makes the Unix socket the only transport the proxy listens on, and brings the TypeScript implementation up to parity with Go and Rust.

Access control here is built on socket ownership: a caller is authorised because it can connect(2) to a Unix socket whose filesystem permissions the operator controls. The TCP listener had no equivalent — no peer uid/gid, so anything able to reach the port was implicitly trusted. --listen-tcp defaulted to 127.0.0.1:2375 and deploy/docker-compose.yml bound 0.0.0.0:2375, so the weaker path was the one exercised by default.

The outbound direction already enforced exactly this rule, rejecting tcp:// for --docker-host (ts/src/flags.ts, Makefile). The inbound listener never got the same treatment.

TypeScript was also TCP-only with no --listen-socket at all, which contradicted the equal-peers rule in AGENTS.md. It now supports the same fd://3 socket activation and stale-socket cleanup as Go and Rust.

Two behaviour changes worth calling out beyond the removal itself:

  • Bind failure is now fatal. With a single listener, a running-but-unbound process is never useful. Previously the error was logged and the process stayed up serving nothing.
  • docker-compose.sock.yml race fixed. The proxies now create their listening sockets in the shared volume as non-root, which raced the fixture's chmod — depends_on: service_started only means the container process exists, not that its setup ran. The fixture now chmods before its slow apk fetch and exposes a healthcheck; the proxies gate on service_healthy.

The integration helpers move from wget to curl --unix-socket, since busybox wget cannot address a Unix socket. They also normalise connection failures to a synthetic 000, so a dead proxy reports a failed assertion instead of aborting the whole run under set -e — the old wget | awk pipeline masked this because awk always exits 0.

Closes #35

Type of change

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

--listen-tcp is removed. Deployments passing it, or connecting via DOCKER_HOST=tcp://, must switch to a mounted Unix socket. Removed outright rather than deprecated, so Go and Rust exit on the unknown argument rather than silently listening somewhere the caller does not expect.

Implementation(s) changed

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

The Quint spec models policy and invariants, not the transport layer, so no spec change was needed.

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (make test-integration)
  • Quint verification passes (make verify)
  • New tests added for the change

make test-all — Go 4/4 packages ok, Rust 115 passed, TypeScript 110 passed (was 108; three parseHostPort cases replaced by five parseListenSocket cases covering path, fd://3, non-3 fd rejection, scheme rejection and empty input).

Integration, all three implementations over the Unix socket:

make test-integration      → ALL 26 TESTS PASSED   (go)
make test-integration-rs   → ALL 26 TESTS PASSED
make test-integration-ts   → ALL 26 TESTS PASSED

Socket-permission suite, all three:

make test-integration-sock     → ALL 8 TESTS PASSED   (go)
make test-integration-sock-rs  → ALL 8 TESTS PASSED
make test-integration-sock-ts  → ALL 8 TESTS PASSED

TypeScript running both suites is itself the parity evidence — it previously could only reach them over TCP.

make verify is not ticked: it fails identically on origin/main with Not enough non-option arguments: got 0, need at least 1 from the local quint CLI. Pre-existing and unrelated; this change touches no spec file. Verified by stashing and re-running on origin/main.

Also smoke-tested the TypeScript listener directly: socket created, GET /_ping → 200 with an ALLOWED audit entry, graceful shutdown, rebind over a stale socket file, and lsof -iTCP:2375 confirming nothing listens on TCP any more.

Checklist

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

README flag table, security boundary section and the systemd example (which documented ListenStream=127.0.0.1:2375) are updated, as is the test-integration-sock comment in the Makefile.


Follow-up surfaced during review

Reviewing the Quint spec for this change surfaced a pre-existing gap, filed as #39: the proxy selects a policy from the Image field of the request body and performs no caller authentication, so "per-service policy" is really per-image policy and one socket is one shared trust domain.

Not a regression and unchanged by this PR — but it bounds what this PR claims. Removing the TCP listener means reaching the proxy requires socket-level authorisation rather than the ability to open a port; it does not give per-service isolation. The README note in this branch was corrected in 5223db6 to say so explicitly rather than implying granularity that does not exist.

The spec needed no change here, which was verified rather than assumed: it contains no transport layer, and make verify fails identically on origin/main for an unrelated local quint CLI reason.


Review round

Adversarial review (security, cross-language parity, test quality) found that the original commit did not actually deliver its central claim. The two worst were reproduced directly before being fixed.

Socket activation did not check what it was handed. net.FileListener returns whatever the fd is, so a unit with ListenStream=127.0.0.1:2375 made Go serve plain TCP while logging network=unix — reinstating the listener this PR removes, via the exact line the PR deleted from the README example:

planted TCP listener at fd 3: 127.0.0.1:52701
listener type=*net.TCPListener addr=127.0.0.1:52701 network=tcp

Node behaved the same; Rust escaped only by an accident of mio's address decoding. All three now verify the fd is AF_UNIX and exit non-zero otherwise, with a regression test in Go and Rust.

--listen-socket was unvalidated in Go and Rust. tcp://… bound a file named tcp:/0.0.0.0:2375; on Linux a leading @ put Go in the abstract namespace, where a socket has no inode, no mode and no owner and any process in the namespace can connect. Validation now matches TypeScript's across all three, including rejecting the bare 0.0.0.0:2375 that a --listen-tcp migration would most likely produce.

TypeScript silently ignored unknown flags — so --listen-tcp=0.0.0.0:2375 was a no-op there while Go and Rust exited 2, undercutting this PR's own migration story. It now rejects unrecognised arguments.

--docker-host was only validated in TypeScript, making this PR's own README sentence ("all three … reject tcp://") false. Ported to Go and Rust, so the claim is now true.

Also fixed: stale-socket cleanup removed whatever was at the path (os.Remove also rmdirs directories), so a mistyped path could destroy data — all three now refuse non-sockets; and TypeScript's bind-error handler was permanent, so any caller could kill the proxy with EMFILE while it reported failed to bind.

Test changes

The suite could not have caught a re-introduced TCP listener — every assertion goes over the Unix socket and would pass equally with TCP alongside. Added a transport guard, now asserting connection-refused specifically rather than "curl failed somehow", which would also pass on a DNS failure and prove nothing:

--- Transport ---
  PASS: no TCP listener on 2375 (connection refused)
  ALL 27 TESTS PASSED        (go, rs, ts)

That guard took two attempts — the first version was itself a test passing for the wrong reason, and the second tripped the set -e trap these helpers were already written to avoid.

curl is baked into deploy/Dockerfile.test rather than apk added per run: docker:28-cli does not ship it, so installing at runtime put the Alpine CDN on the critical path of every run and of make release-verify. Requests are bounded with --max-time, without which a hung proxy hangs CI instead of failing it. test-sock.sh now exits on readiness failure rather than warning and emitting eight misleading assertion failures. The sock fixture's directory is 1777 not 0777, so proxy-denied cannot unlink the socket whose inaccessibility the suite exists to demonstrate.

Test counts

before after
Go 74 (none in main) 81
Rust 112 119
TypeScript 108 120
Integration (×3) 26 27

Deliberately not fixed here

Neither is a regression from this PR, and the README no longer claims a boundary that is not enforced.

Access control in this proxy is built on socket ownership: a caller is
authorised because it can connect(2) to a Unix socket whose filesystem
permissions the operator controls. The TCP listener had no equivalent —
no peer uid/gid, so anything able to reach the port was implicitly
trusted. --listen-tcp defaulted to 127.0.0.1:2375 and the compose setup
bound 0.0.0.0:2375, so the weaker path was the one exercised by default.

The outbound direction already enforced this rule, rejecting tcp:// for
--docker-host on exactly these grounds. The inbound listener never got
the same treatment.

Removes --listen-tcp from all three implementations and adds Unix socket
listening to TypeScript, which was TCP-only and had no --listen-socket at
all, in violation of the equal-peers rule. TypeScript now supports the
same fd://3 socket activation and stale socket cleanup as Go and Rust.

A bind failure is now fatal rather than logged. With a single listener a
running-but-unbound process is never useful; previously the error was
logged and the process stayed up serving nothing.

Migrates the integration suites from tcp://proxy:2375 to a shared-volume
Unix socket. The helpers move from wget to curl --unix-socket, since
busybox wget cannot address a Unix socket, and normalise connection
failures to a synthetic 000 so that a dead proxy reports a failed
assertion instead of aborting the run under set -e.

In docker-compose.sock.yml the proxies now create their own listening
sockets in the shared volume as non-root, which raced the fixture's
chmod: depends_on service_started only means the container process
exists. The fixture now chmods before its slow apk fetch and exposes a
healthcheck, and the proxies gate on service_healthy.

BREAKING CHANGE: --listen-tcp is removed. Deployments passing it, or
connecting via DOCKER_HOST=tcp://, must switch to a mounted Unix socket.
The flag is removed outright rather than deprecated, so Go and Rust exit
on the unknown argument instead of silently listening somewhere the
caller does not expect.
@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 23, 2026
The note added earlier in this branch implied the listening socket gives
per-service granularity: "to grant a service access, place its container
user in the group". It does not. The proxy performs no caller
authentication — policy is selected from the Image field of the request
body (router.go GetByImage, mirrored in the Quint spec), and there is no
peer-credential check in any of the three implementations.

Every caller of one socket therefore shares one trust domain and can act
under any policy in that proxy's config dir by naming its image. States
that explicitly and points at one-proxy-per-service for real isolation.
Review of the TCP removal found several ways the Unix-socket-only claim
did not actually hold.

Socket activation did not check what it was handed. net.FileListener
returns whatever the fd is, so a unit with ListenStream=127.0.0.1:2375
made Go serve plain TCP while logging "listening network=unix" —
reinstating the listener this change exists to remove. Verified by
planting a TCP listener at fd 3:

    listener type=*net.TCPListener addr=127.0.0.1:52701 network=tcp

Node behaved the same way; Rust escaped only by an accident of address
decoding in mio. All three now confirm the fd is an AF_UNIX socket and
exit non-zero otherwise.

--listen-socket was otherwise unvalidated in Go and Rust. "tcp://..."
bound a file named "tcp:/0.0.0.0:2375", and on Linux a leading "@" put
Go in the abstract namespace, where a socket has no inode, no mode and
no owner and any process in the namespace can connect. Validation now
matches TypeScript's and lives in all three, with the bare "0.0.0.0:2375"
migration mistake rejected rather than silently bound.

TypeScript silently ignored unknown flags, so --listen-tcp=0.0.0.0:2375
was a no-op there while Go and Rust exited 2. It now rejects unrecognised
arguments, which is the difference between a loud migration failure and a
caller believing TCP is still served.

--docker-host was only validated in TypeScript, making the README's claim
that all three reject tcp:// false. Ported to Go and Rust.

Stale socket cleanup removed whatever was at the path; os.Remove also
rmdir's empty directories, so a mistyped path could destroy data. All
three now refuse anything that is not a socket.

TypeScript's bind-error handler was registered permanently, but Node also
emits "error" on accept failures, so any caller could kill the proxy with
EMFILE while it reported "failed to bind". Scoped to the bind.

Tests: Go's main package had none; it now has 7 covering validation, the
non-socket refusal and the fd type check. Rust gains 4, TypeScript 10.
The integration suite gains a guard asserting nothing answers on TCP
2375 — every other assertion goes over the Unix socket and would pass
just as happily with a TCP listener alongside.

curl is baked into deploy/Dockerfile.test rather than apk-added per run,
since docker:28-cli does not ship it and installing at runtime put the
Alpine CDN on the critical path of every run. Requests are bounded with
--max-time, without which a hung proxy would hang CI rather than fail it,
and the status fallback no longer discards a real code on a mid-response
error. test-sock.sh now exits on readiness failure instead of warning and
reporting eight misleading assertion failures.

Refs #38. Socket mode/ownership is tracked separately in #40.
The guard added in f15c0fc treated any non-zero curl exit as proof that
no TCP listener exists. That also passes when the hostname fails to
resolve (exit 6), which proves nothing — the same class of test-passing-
for-the-wrong-reason the guard was written to catch.

Now requires exit 7 specifically, so the host must have resolved and
actively refused the connection, and reports anything else as
inconclusive rather than silently counting it as a pass.
… read

bc0ec98 replaced a protected `if curl ...; then` with a bare curl
followed by `rc=$?`. Under set -e the bare failing command terminates
the script immediately, so the probe never reported and the integration
job died right after printing '--- Transport ---'.

The same trap this suite's status helpers were already written to avoid.
Guarded with `|| rc=$?`, which keeps the expected failure from aborting
the run. Verified both forms directly: the guarded one reaches rc=7,
the bare one never reaches the next line.
@abienkowski
abienkowski marked this pull request as ready for review September 23, 2026 16:27
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

Development

Successfully merging this pull request may close these issues.

Listen on Unix socket only; remove TCP listener and bring TypeScript to parity

1 participant