| .dirac-symbol-index | ||
| .gitignore | ||
| dev.tsproxy.plist | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
tsproxy
A tiny TCP forwarder that joins your tailnet via tsnet
and exposes one or more tailnet services as local ports — without running
tailscaled on the host.
Useful when you want to reach a single service on your tailnet from a machine you'd rather not fully enroll (work laptop, ephemeral VM, etc). The proxy itself becomes a tailnet node, so it can be tightly scoped with tags and ACLs.
Install
go install github.com/smw/tsproxy@latest # or build locally:
go build -o /usr/local/bin/tsproxy .
Quick start
-
(Optional) In your tailnet policy, add a tag for the proxy and an ACL that limits what it can reach. Example (adapt to your hosts):
"tagOwners": { "tag:tsproxy": ["you@example.com"] }, "acls": [ { "action": "accept", "src": ["autogroup:members"], "dst": ["*:*"] }, { "action": "accept", "src": ["tag:tsproxy"], "dst": ["webhost:80", "dbhost:5432"] } ] -
Mint a one-shot auth key at https://login.tailscale.com/admin/settings/keys: not reusable, not ephemeral, tagged
tag:tsproxy. The key is only needed for the first run — tsnet persists its own credentials after that. -
First run:
TS_AUTHKEY=tskey-auth-... tsproxy \ --name tsproxy \ --forward 127.0.0.1:8080=webhost:80 \ --forward 127.0.0.1:5432=dbhost:5432 -
Subsequent runs don't need
TS_AUTHKEY:tsproxy -f 127.0.0.1:8080=webhost:80 -f 127.0.0.1:5432=dbhost:5432Then
curl http://127.0.0.1:8080hitswebhost:80on your tailnet.
Flags
| Flag | Short | Default | Description |
|---|---|---|---|
--forward |
-f |
(required, repeatable) | Forward rule LOCAL=TARGET, e.g. 127.0.0.1:8080=myhost:80. |
--name |
-n |
tsproxy |
Hostname advertised on the tailnet. |
--dir |
~/.config/tsproxy/<name> |
State directory (node identity, keys). | |
--verbose |
-v |
false |
Verbose tsnet logging. |
--probe-interval |
5s |
How often each target is probed for reachability. | |
--probe-timeout |
3s |
How long a probe may take before the target counts as unreachable. |
Target can be any MagicDNS name, short hostname, or tailnet IP.
Running under launchd (macOS)
An example LaunchAgent plist is included as dev.tsproxy.plist. Install it per-user:
cp dev.tsproxy.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/dev.tsproxy.plist
Manage it:
launchctl list | grep tsproxy
launchctl kickstart -k gui/$(id -u)/dev.tsproxy # restart after edits
launchctl unload ~/Library/LaunchAgents/dev.tsproxy.plist
tail -f /tmp/tsproxy.log
On first run, add the auth key via an EnvironmentVariables block in the
plist, then remove it once the state directory has been populated.
Notes
- One node, many forwards. All
--forwardrules share a single tsnet identity, so you get one device in the admin console and one ACL subject. - Startup is strict. Every local address is bound once at startup as a check; if any fails, the process exits — partial success is confusing under a supervisor.
- The local port tracks the target. Each forward dials its target every
--probe-intervaland only keeps the local port bound while that succeeds. So an unavailable target meansconnection refusedon the local port, not a connect that immediately EOFs, and clients back off the way they would against a genuinely down service. - One failed connection closes the port. A client that reconnects the instant its connection breaks would otherwise beat the next probe and be accepted into a forward with nothing behind it. So any connection that fails against the target unbinds the port immediately, and it stays unbound until a probe says the target is back. That probe is scheduled straight away, so a one-off failure against a healthy target costs a few milliseconds of refusal, not a whole interval. Other live connections are left alone — one failure stops new work but isn't enough to declare the target dead.
- Dials are bounded by
--probe-timeout. A tailnet peer that is routable but dead neither accepts nor refuses, so an unbounded dial parks forever holding a local socket that nothing can reclaim: the probe loop only tears down live connections when a probe fails, and a recovering target makes the probe succeed. Forwarded connections use the same timeout as probes. - Target loss drops live connections. A tailnet peer can vanish without the
userspace TCP stack ever erroring on an established connection, which leaves
local sockets hanging and apps waiting on a dead link. When a probe fails,
every connection on that forward is closed so clients see the drop and
reconnect. Detection takes up to
--probe-interval+--probe-timeout. - Failures are reset, not closed. How a connection ends is forwarded faithfully. A target that closes cleanly gives the local client a FIN (an ordinary EOF); a target that resets, errors, or disappears gives it an RST. This matters for clients that hold idle connections: a FIN leaves the socket writable, so a pooled client's next write still succeeds and only the one after it fails, whereas an RST fails the very next read or write. It also keeps a truncated response from looking like a complete one. The trade-off is that an RST discards whatever was still queued in the send buffer — a stream torn down this way was already incomplete, so flagging it beats delivering a partial result that looks whole.
- Probes are real connections. Each probe opens and immediately closes a
TCP connection to the target. Chatty services may log these; raise
--probe-intervalto quiet them down, at the cost of slower detection. - State directory matters. Losing
~/.config/tsproxy/<name>/means the node re-registers on next launch and will need a freshTS_AUTHKEY.