2026-05-11 15:12:13 +00:00
|
|
|
# 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. |
|
2026-07-31 21:32:27 +00:00
|
|
|
| `--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. |
|
2026-05-11 15:12:13 +00:00
|
|
|
|
|
|
|
|
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.
|
2026-07-31 21:32:27 +00:00
|
|
|
- **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.
|
2026-05-11 15:12:13 +00:00
|
|
|
- **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)
|