feat!: listen on Unix socket only and bring TypeScript to parity - #38
Open
abienkowski wants to merge 5 commits into
Open
abienkowski wants to merge 5 commits into
abienkowski wants to merge 5 commits into
Conversation
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.
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
marked this pull request as ready for review
September 23, 2026 16:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-tcpdefaulted to127.0.0.1:2375anddeploy/docker-compose.ymlbound0.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-socketat all, which contradicted the equal-peers rule inAGENTS.md. It now supports the samefd://3socket activation and stale-socket cleanup as Go and Rust.Two behaviour changes worth calling out beyond the removal itself:
docker-compose.sock.ymlrace fixed. The proxies now create their listening sockets in the shared volume as non-root, which raced the fixture'schmod—depends_on: service_startedonly means the container process exists, not that its setup ran. The fixture nowchmods before its slowapkfetch and exposes a healthcheck; the proxies gate onservice_healthy.The integration helpers move from
wgettocurl --unix-socket, since busyboxwgetcannot address a Unix socket. They also normalise connection failures to a synthetic000, so a dead proxy reports a failed assertion instead of aborting the whole run underset -e— the oldwget | awkpipeline masked this becauseawkalways exits 0.Closes #35
Type of change
--listen-tcpis removed. Deployments passing it, or connecting viaDOCKER_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
The Quint spec models policy and invariants, not the transport layer, so no spec change was needed.
Testing
make test-all)make test-integration)make verify)make test-all— Go 4/4 packages ok, Rust 115 passed, TypeScript 110 passed (was 108; threeparseHostPortcases replaced by fiveparseListenSocketcases covering path,fd://3, non-3 fd rejection, scheme rejection and empty input).Integration, all three implementations over the Unix socket:
Socket-permission suite, all three:
TypeScript running both suites is itself the parity evidence — it previously could only reach them over TCP.
make verifyis not ticked: it fails identically onorigin/mainwithNot enough non-option arguments: got 0, need at least 1from the local quint CLI. Pre-existing and unrelated; this change touches no spec file. Verified by stashing and re-running onorigin/main.Also smoke-tested the TypeScript listener directly: socket created,
GET /_ping→ 200 with anALLOWEDaudit entry, graceful shutdown, rebind over a stale socket file, andlsof -iTCP:2375confirming nothing listens on TCP any more.Checklist
README flag table, security boundary section and the systemd example (which documented
ListenStream=127.0.0.1:2375) are updated, as is thetest-integration-sockcomment 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
Imagefield 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 verifyfails identically onorigin/mainfor 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.FileListenerreturns whatever the fd is, so a unit withListenStream=127.0.0.1:2375made Go serve plain TCP while loggingnetwork=unix— reinstating the listener this PR removes, via the exact line the PR deleted from the README example:Node behaved the same; Rust escaped only by an accident of mio's address decoding. All three now verify the fd is
AF_UNIXand exit non-zero otherwise, with a regression test in Go and Rust.--listen-socketwas unvalidated in Go and Rust.tcp://…bound a file namedtcp:/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 bare0.0.0.0:2375that a--listen-tcpmigration would most likely produce.TypeScript silently ignored unknown flags — so
--listen-tcp=0.0.0.0:2375was a no-op there while Go and Rust exited 2, undercutting this PR's own migration story. It now rejects unrecognised arguments.--docker-hostwas only validated in TypeScript, making this PR's own README sentence ("all three … rejecttcp://") false. Ported to Go and Rust, so the claim is now true.Also fixed: stale-socket cleanup removed whatever was at the path (
os.Removealsormdirs 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 withEMFILEwhile it reportedfailed 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:
That guard took two attempts — the first version was itself a test passing for the wrong reason, and the second tripped the
set -etrap these helpers were already written to avoid.curlis baked intodeploy/Dockerfile.testrather thanapk added per run:docker:28-clidoes not ship it, so installing at runtime put the Alpine CDN on the critical path of every run and ofmake release-verify. Requests are bounded with--max-time, without which a hung proxy hangs CI instead of failing it.test-sock.shnow exits on readiness failure rather than warning and emitting eight misleading assertion failures. The sock fixture's directory is1777not0777, soproxy-deniedcannot unlink the socket whose inaccessibility the suite exists to demonstrate.Test counts
main)Deliberately not fixed here
0755by default), so the documented group-grant does not work andumask 0makes it world-connectable. Needs a new flag surface; split out so this can land.Neither is a regression from this PR, and the README no longer claims a boundary that is not enforced.