tool: thumb #24
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
rss-curator
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
prskr/tools#24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
thumb — Implementation Plan
Status: planning complete, implementation not started
Location:
tools/thumb/in the tools monorepoLanguage: Go, built with Bazel (
rules_go+gazelle)Binaries:
thumb(hub, local machine),thumb-agent(VM)1. Problem
A cloud dev VM is used for regular software development. Commands like
npm run dev,dotnet run, orgit fetchwith an OAuth credential helper open TCP listeners on theVM — sometimes on well-known ports, sometimes on random high ports. Those services need
to be reachable from the local machine.
Requirements:
The current
bore-based solution fails the last two: it needs theboreserver reachablefrom the VM on a set of random high ports as part of its handshake, which means the local
machine has to be directly connectable.
Performance is explicitly not a concern.
2. Design decisions and why
Recorded because the reasoning matters more than the conclusions if any of this gets
revisited later.
2.1 SSH as the transport, not QUIC or a custom protocol
QUIC is UDP-based and awkward to forward through an SSH tunnel. SSH already multiplexes
arbitrary channels over one TCP connection and has battle-tested forwarding primitives
(
direct-tcpip,tcpip-forward). There is no reason to reimplement any of that.2.2 Not ControlMaster +
ssh -O forwardA viable approach exists using OpenSSH alone: remote-forward the ControlMaster socket
itself to the VM, then have a VM-side daemon issue
ssh -S <forwarded-socket> -O forwardcommands against it. This works and requires no custom binary.
Rejected because it means shelling out to the
sshCLI and parsing exit codes for everyport event, and because the ControlMaster mux protocol is an undocumented OpenSSH-internal
IPC format (
mux.c), versioned per release, with no third-party client library in anylanguage. Speaking it natively is not a realistic target.
Once you're willing to run a custom binary on the VM, defining your own control protocol
is strictly simpler than reverse-engineering OpenSSH's.
2.3 Control plane and data plane run in opposite directions
This is the single most important thing to keep straight:
-L-shaped. The hub listens onlocalhost:Nlocally and opens adirect-tcpipchannel to the VM'slocalhost:N. Driven entirely by the hub. The agentis not involved and no VM-side cooperation is needed beyond the port existing.
hub that a port appeared or disappeared, plus carrying agent logs and hub config.
All bulk traffic uses ordinary SSH forwarding. The control protocol carries only small
JSON messages.
2.4 Go over Rust
Considered Rust (existing Rust + Bazel + LLVM cross-compilation setup in the monorepo,
and
borewas Rust). Chose Go because:golang.org/x/crypto/sshis extremely battle-tested — Tailscale SSH, Teleport, and mostGo infra tooling build on it.
Client.Dial(direct-tcpip) is first-class.GOOS=linux GOARCH=arm64 go buildwith no toolchain setup, no cgo, produces a staticbinary. This matters concretely for the upload-and-exec deployment flow, where the agent
must just work on whatever arch the VM is without fussing over glibc/musl or target triples.
/proc/net/tcpparsing is trivial in either language.borecrate, andboreis gone from the design.2.5 JSON-RPC over gRPC
The deployment model kills gRPC's main advantage. The hub embeds a content-hashed agent
binary and uploads it, so hub and agent are always the same build — version skew is
structurally impossible, and protobuf's field-number discipline is insurance against a
scenario that cannot occur.
Beyond that:
model. Only one exchange (
agent.hello→ config) is a real request/response. gRPC wouldmean tunnelling all of this through a single long-lived bidi stream — a streaming RPC
used as a message bus.
proto_library/go_proto_libraryremoves the protoc toolchain from theBazel graph, which is one of the fiddlier parts of a
rules_gosetup — especiallycombined with the platform transitions already needed for the embedded agent.
2.6 Stdio, not a forwarded Unix socket
Earlier drafts of this plan remote-forwarded a Unix domain socket
(
streamlocal-forward@openssh.com) to carry the control protocol. That is unnecessary.The agent is exec'd over an SSH session channel, and that channel already provides stdin,
stdout, and stderr. Stdout and stdin together form a full-duplex byte stream — exactly what
JSON-RPC needs. This is how every LSP server works.
Note the distinction: it is not that hub→agent signalling is unneeded (
hub.setConfigfor dynamic log level is a real feature). It's that stdin already provides it, making
the forwarded socket pure redundancy.
Deleting the socket removes:
streamlocal-forward@openssh.comglobal request andforwarded-streamlocalchannel-open handling — the one genuinely hand-rolled, no-library-support part of the
entire SSH layer.
net.Listeneradapter and thenet.Connwrapper with its fake addressesand no-op deadline methods.
StreamLocalBindUnlinkconcerns.AllowStreamLocalForwardingbeing enabled — worth calling outseparately, since a hardened VM image could have it off and the forward would silently
not work.
starts executing; there is no dial that can fail and no window where logs have nowhere
to go.
closes, so the agent exits naturally.
2.7 Logs travel in the protocol, not just on stderr
Stderr already flows hub-ward for free, but gives unstructured text with no levels, no
filtering, and no correlation with hub-side events. The protocol path gives structured
records, hub-controlled verbosity, and a single merged timeline.
Both are retained — they cover different failure modes. Stderr is the fallback that
still works when the RPC layer itself is broken.
3. Architecture
Nothing binds a Unix socket. Nothing touches the local filesystem for IPC. No other local
process can reach the control channel.
4. Repository layout
Filesystem paths:
~/.cache/thumb/agent-<contenthash>~/.local/state/thumb/forwards.json~/.config/thumb/config.toml5. Protocol
Peer-to-peer JSON-RPC 2.0, newline-delimited JSON framing, over the exec channel's
stdout (agent→hub) and stdin (hub→agent). Message types live in
internal/wire/and areimported by both binaries — single source of truth, no codegen step.
5.1 Methods
Agent → Hub
agent.helloagent.snapshotagent.logHub → Agent
hub.setConfighub.pinghub.shutdownagent.helloreturns the initial config in its response, so there is no window wherethe agent runs unconfigured.
hub.setConfighandles runtime changes afterwards.5.2 Snapshots are full state, not deltas
Every poll sends the complete listener set. Idempotent, and reconciliation-by-diff on the
hub eliminates an entire category of state-drift bugs. At this scale the redundancy costs
nothing.
5.3 Types
5.4 Library choice — still open
github.com/creachadair/jrpc2vsgithub.com/sourcegraph/jsonrpc2. Both handle symmetricpeers and peer-initiated notifications.
jrpc2is currently favoured because its channel constructors take a separate reader andwriter, which maps directly onto
session.StdoutPipe()/session.StdinPipe().sourcegraph/jsonrpc2wants a singleio.ReadWriteCloser, requiring a trivial adapterjoining the two halves — five lines, not a real obstacle, but one fewer thing.
Worth a 20-minute spike against the actual message set before committing, specifically
checking how each handles high-frequency notifications with backpressure, since that is
what the log path needs.
6. Stdio transport — required care
Three non-obvious failure modes:
6.1 Stdout must be sacred
One stray
fmt.Println, one dependency that logs to stdout, and the JSON-RPC framing iscorrupt. Apply the standard LSP mitigation at agent startup:
os.Stdout = os.Stderr.After this, accidental stdout writes are harmless and visible rather than stream-corrupting.
Go panics already go to stderr and are fine.
6.2 The hub must actively drain stderr
SSH channels are windowed, and stderr is extended-data on the same channel as stdout.
If the hub does not continuously read the stderr pipe, the window fills and stalls the
entire channel — including the RPC stream. The failure looks like a total hang with nothing
obviously wrong on the stdout side.
6.3 No buffering
Write and flush per message on both sides.
7. Log streaming
Implemented in
internal/logstreamas a customslog.Handlerthat fans out to both sinks.The rest of the agent code just uses
slognormally and never knows where records go.Phase 1 (bootstrap and fallback) — stderr. Everything before
agent.hellocompletes,plus anything emitted after the RPC path fails. Reaches the hub via the exec channel's
stderr, so a total RPC failure is still debuggable. The hub prefixes these distinctly
(
source=agent-stderr) so "the agent's logging subsystem is broken" is distinguishablefrom normal operation.
Phase 2 (steady state) —
agent.lognotifications. Structured, level-filtered, mergedinto the hub's own
slogoutput withsource=agent.Details that matter:
drop-on-full semantics. Emit a
dropped=Ncount on the next batch rather than blockingor silently losing records.
the log transport. That path goes to stderr only, rate-limited to ~once per 10s.
tand attach hub receipt time as aseparate attr. VM clocks drift; both are needed when ordering looks wrong.
hub.setConfig— raise the agent to debug at runtime, no reconnect,no restart. This is the feature that will get the most use; wire it to a hub CLI
subcommand or signal handler early.
No pre-connect ring buffer is needed (see §2.6) — stderr is connected before the agent's
first instruction executes.
8. Port detection (
internal/procnet)Parse
/proc/net/tcpand/proc/net/tcp6on a poll loop. The details that will bite:st == 0A(TCP_LISTEN).local_addressis byte-reversed (little-endian):0100007Fis127.0.0.1,not
1.0.0.127. IPv6 is reversed per 32-bit word.::appears only intcp6but accepts IPv4 too; one bound to
0.0.0.0appears only intcp. Key the set on portand keep the most permissive bind address seen.
0.0.0.0and127.0.0.1→ dial127.0.0.1:Noverdirect-tcpip. A listener bound to a specific non-loopback interfaceIP must be dialled at that IP;
localhostwill not reach it.(sshd, systemd-resolved, chrony, docker-proxy, …) goes on an implicit ignore list. This is
what makes the tool zero-config in practice — only ports opened by actual dev work ever
surface.
Dev servers flap ports constantly on restart (every Vite full reload, every
dotnet watchrebuild) and listener churn on each one is not wanted.
appeared" short of eBPF. Netlink
sock_diagqueries faster but still requires polling.Reading a ~50-line text file at 1 Hz is free. eBPF is explicitly out of scope.
9. SSH layer (
internal/sshx)Built on
golang.org/x/crypto/sshplusgithub.com/pkg/sftp.9.1 Connection
SSH_AUTH_SOCK(golang.org/x/crypto/ssh/agent), falling back to a configuredkey file.
golang.org/x/crypto/ssh/knownhosts. Four lines — do not skip.x/crypto/sshdoes not parse~/.ssh/config. Options were pulling ingithub.com/kevinburke/ssh_configor defining a own config format. Chose own config:thumb-specific settings (filters, collision policy, log level) are needed anyway, and two
sources of truth is worse than one.
keepalive@openssh.comglobal requests every 20 s; a failed reply meansthe connection is dead.
9.2 Agent deployment
uname -mover a session channel →x86_64/aarch64.~/.cache/thumb/agent-<contenthash>; upload andchmod 0755if absent.Content-hash naming makes upgrades automatic and old versions trivially GC-able.
~/.cache/thumb/agent-<contenthash> --stdio.9.3 Agent lifetime
Not to survive disconnect, by design. Three independent mechanisms, all cheap:
exits.
hub.pingwatchdog — exit if no ping within 3× the ping interval. Belt-and-braces.10. Data plane (
internal/forward)On port added (e.g. 5173, bind
0.0.0.0):net.Listen("tcp", "127.0.0.1:5173").EADDRINUSE, apply the collision policy: default is a fixed offset (+10000→15173), falling back to an ephemeral port. Log the mapping loudly.client.Dial("tcp", "127.0.0.1:5173")(opens adirect-tcpipchannel), then bidirectional copy.
io.Copyin both directions withCloseWrite()on theSSH channel when the local side EOFs. Without this, long-lived connections (SSE,
websockets,
dotnet watchreload channels) hang on teardown.On port removed: close the listener immediately; let in-flight connections drain with a
~5 s grace period before force-closing.
Mapping discoverability: because collisions remap ports, the actual mapping must be
inspectable. Write
~/.local/state/thumb/forwards.jsonon every change;thumb statusreads it. No IPC needed.
11. Bazel
gazelle+rules_gofor all Go targets.proto_library/go_proto_library— removing protoc from the build graph is oneof the concrete wins of the JSON-RPC decision.
go_cross_binaryto build//tools/thumb/cmd/thumb-agentforlinux/amd64andlinux/arm64, then feed both into the hub'sembedsrcs. The agent must be fully builtbefore the hub compiles, and this needs platform transitions, not just
GOOS/GOARCHenvironment variables.
cross-builds are static and run on any VM regardless of libc.
12. Phasing
wiretypes +procnetparser/proc/net/tcp{,6}samples including dual-stack, IPv6, and odd bind addressesscp'd and exec'd;agent.hellolands and agent logs appear interleaved in hub outputnpm run devon the VM, browser on the laptop reacheslocalhost:5173uname, SFTP,go:embed, content-hash versioning/proc/*/fd,thumb status, runtime log-level control, systemd user unit for the hubLogging is deliberately in Phase 1, not Phase 5. It is the debugging substrate for
phases 2–4; building it afterwards means doing those phases blind.
13. Testing
procnetandwire: pure unit tests with golden files. Capture real/proc/net/tcp{,6}content from the dev VM in interesting states (dual-stack listener,IPv6-only, bound to a specific interface, thousands of entries) and commit them.
x/crypto/ssh's in-process server does not exercisesshd's actual exec-channel and windowing behaviour, which is precisely what is under test.
Use a containerized OpenSSH for integration tests.
bazel test //...stays fast.assert the RPC stream stalls — locks in the §6.2 requirement so it cannot silently
regress.
14. Open items
jrpc2vssourcegraph/jsonrpc2(§5.4). Spike before Phase 1.+10000offset vs ephemeral-with-lookup. Offset ismore predictable and more memorable; ephemeral never fails. Currently leaning offset with
ephemeral fallback.
practice. Defer until it's actually annoying.
but shared local port-space collision handling across them is unsolved. Out of scope
unless it comes up.
tool: portalto tool: thumb