tsproxy/README.md
Stefan Wasilewski 34cb6339a8 better RST?
2026-08-01 04:36:31 +04:00

137 lines
5.9 KiB
Markdown

# 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
<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:
```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/<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](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/<name>/` means the
node re-registers on next launch and will need a fresh `TS_AUTHKEY`.
## License
[MIT](LICENSE)