No description
Find a file
Stefan Wasilewski 24dd73364d ignore binary
2026-08-01 00:37:36 +00:00
.dirac-symbol-index initial import 2026-05-11 19:12:13 +04:00
.gitignore ignore binary 2026-08-01 00:37:36 +00:00
dev.tsproxy.plist initial import 2026-05-11 19:12:13 +04:00
go.mod initial import 2026-05-11 19:12:13 +04:00
go.sum initial import 2026-05-11 19:12:13 +04:00
LICENSE initial import 2026-05-11 19:12:13 +04:00
main.go better RST? 2026-08-01 04:36:31 +04:00
main_test.go better RST? 2026-08-01 04:36:31 +04:00
README.md better RST? 2026-08-01 04:36:31 +04:00

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

  1. (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"] }
    ]
    
  2. 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.

  3. 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
    
  4. Subsequent runs don't need TS_AUTHKEY:

    tsproxy -f 127.0.0.1:8080=webhost:80 -f 127.0.0.1:5432=dbhost:5432
    

    Then curl http://127.0.0.1:8080 hits webhost:80 on 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 --forward rules 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-interval and only keeps the local port bound while that succeeds. So an unavailable target means connection refused on 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-interval to 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 fresh TS_AUTHKEY.

License

MIT