No description
Find a file
2026-08-01 01:32:27 +04:00
.dirac-symbol-index initial import 2026-05-11 19:12:13 +04: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 availability detector 2026-08-01 01:32:27 +04:00
main_test.go availability detector 2026-08-01 01:32:27 +04:00
README.md availability detector 2026-08-01 01:32:27 +04:00
tsproxy initial import 2026-05-11 19:12:13 +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.
  • 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.
  • 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