# tsproxy A tiny TCP forwarder that joins your tailnet via [tsnet](https://pkg.go.dev/tailscale.com/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 ```sh 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): ```json "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 : **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: ```sh 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`: ```sh 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/` | 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](dev.tsproxy.plist). Install it per-user: ```sh cp dev.tsproxy.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/dev.tsproxy.plist ``` Manage it: ```sh 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//` means the node re-registers on next launch and will need a fresh `TS_AUTHKEY`. ## License [MIT](LICENSE)