tailscale / tailscale/tailscale
serve: ProxyProtocol + TCPForward + TerminateTLS breaks TLS termination (0 bytes sent to client)
- Dominant language
- Go
- Stars
- 36.5k
- Forks
- 3.2k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 123
Description
## Summary
When `ProxyProtocol: 2` is combined with `TCPForward` + `TerminateTLS` in a ServeConfig applied via the LocalAPI (`POST /localapi/v0/serve-config`), Tailscale accepts TCP connections on port 443 but sends **0 bytes** back to the client — the TLS ServerHello is never sent and the handshake fails entirely.
Removing `"ProxyProtocol": 2` from the config immediately restores TLS termination with no other changes.
## Environment
- **Tailscale version:** 1.94.2
- **OS:** Linux (Unraid, kernel 6.12.8)
- **Running in:** Docker container (`network_mode: host`, `NET_ADMIN` capability, `/dev/net/tun`)
- **Config applied via:** `curl --unix-socket /run/tailscale/tailscaled.sock -X POST -H "Content-Type: application/json" -d @serve.json http://local-tailscaled.sock/localapi/v0/serve-config`
## ServeConfig that triggers the bug
```json
{
"TCP": {
"443": {
"TCPForward": "127.0.0.1:8080",
"TerminateTLS": "jellyfin.gate-hexatonic.ts.net",
"ProxyProtocol": 2
}
},
"AllowFunnel": {
"jellyfin.gate-hexatonic.ts.net:443": true
}
}
```
## Working config (ProxyProtocol removed)
```json
{
"TCP": {
"443": {
"TCPForward": "127.0.0.1:8080",
"TerminateTLS": "jellyfin.gate-hexatonic.ts.net"
}
},
"AllowFunnel": {
"jellyfin.gate-hexatonic.ts.net:443": true
}
}
```
## Symptoms
With the `ProxyProtocol: 2` config applied:
```
$ openssl s_client -connect :443
CONNECTED(00000003)
^C # hangs indefinitely, no ServerHello
```
```
$ curl -v https://jellyfin.gate-hexatonic.ts.net/health
* SSL connection timeout
```
After removing `ProxyProtocol` (POST the working config), TLS works immediately:
```
$ curl -sI https://jellyfin.gate-hexatonic.ts.net/health
HTTP/2 200
```
## Debugging notes
- The backend (Caddy on `127.0.0.1:8080`) receives no connections — confirmed via `tcpdump -i lo port 8080` showing zero packets while the TLS client hangs
- `tailscale serve status` shows the config applied correctly with ProxyProtocol
- `tailscale status` shows the node as connected and healthy
- The issue is reproducible 100% of the time with the buggy config and resolves 100% of the time without it
## Expected behaviour
With `TCPForward + TerminateTLS + ProxyProtocol: 2`, Tailscale should:
1. Accept TLS on port 443 (TerminateTLS handles TLS termination)
2. Forward decrypted TCP to `127.0.0.1:8080` prepended with a PROXY protocol v2 header containing the original client IP
This would allow the backend (Caddy, nginx, etc.) to read the real client IP from the PROXY header, which is otherwise unavailable since Tailscale Funnel doesn't set `X-Forwarded-For`.
## Use case
Tailscale Funnel is an L4 proxy — it does not add `X-Forwarded-For` headers (open FR since 2023). PROXY protocol v2 is the standard mechanism for L4 proxies to communicate the original client IP to the L7 backend. Without it, all traffic from Funnel appears to originate from `127.0.0.1`, making IP-based WAF/rate-limiting ineffective.
## Related
- Issue #18172 / PR #18175 — ProxyProtocol bug in VIP Services (different code path, fixed Dec 2025)
- PR #17592 — Initial ProxyProtocol implementation for regular Serve/Funnel
The VIP services fix (#18172) appears to be a different code path from regular Funnel. The regular Serve/Funnel path with `TCPForward + TerminateTLS + ProxyProtocol` appears to still be broken.
## Workaround
Remove `"ProxyProtocol": 2` from the ServeConfig. Funnel traffic will forward correctly but all connections appear as `127.0.0.1` at the backend.
Contributor guide
Assessment
This issue has not been assessed yet.