From 8da639f54277fa74d4befe4c20d9be628380e842 Mon Sep 17 00:00:00 2001 From: Lee Briggs Date: Mon, 13 Apr 2026 14:57:46 +0000 Subject: [PATCH] fix(ts2021): document and verify control-protocol upgrade handling Covers the /ts2021 proxy-compatibility docs, added regression test, and supporting logs. Signed-off-by: Lee Briggs --- README.md | 9 ++++++--- cmd/serve.go | 40 ++++++++++++++++++++++++++++++++++++---- cmd/serve_test.go | 27 +++++++++++++++++++++++++-- docs/hosting.md | 35 +++++++++++++++++++++++++++++++---- docs/troubleshooting.md | 18 +++++++++++++++++- 5 files changed, 115 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2b7a63c..acd4693 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ A lightweight, preconfigured proxy for the Tailscale control plane that enables Tailscale access in the event the Tailscale control plane is being blocked. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/proxyt?referralCode=ftkvtR) - ## Overview Tailscale connections between peers are incredibly resiliant. If you are authenticated to Tailscale, it will endeavour to use all mechanisms at its disposal to forge connections it needs between clients. @@ -13,6 +10,12 @@ However, some networks block the Tailscale _control plane_ (ie, the tailscale.co Proxyt allows you to host a proxy to the Tailscale control plane which can be used by clients. You can host Proxyt anywhere, register your own domain or even expose it via [Funnel](https://tailscale.com/kb/1223/funnel) giving you a reliable way of accessing the Tailscale control plane to authenticate clients. +## Deployment Note + +ProxyT depends on the frontend preserving Tailscale's `/ts2021` control-protocol upgrade request intact. Direct deployments, Tailscale Funnel, and traditional reverse proxies such as Nginx, Apache, and Caddy are the most reliable options. + +Managed CDN and edge-proxy platforms that only support standard WebSocket `GET` handshakes, or that normalize non-standard upgrade traffic, are not compatible. In practice this means Cloudflare proxy/tunnel/workers are not supported, and platforms such as CloudFront, Fastly free tier, and Railway-style managed HTTP edges may fail depending on how their edge handles `POST` upgrades. + ## 📖 Documentation **Full documentation:** [proxyt.io](https://proxyt.io) diff --git a/cmd/serve.go b/cmd/serve.go index 037a8e9..f41f3df 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -539,7 +539,12 @@ func handleTailscaleControlProtocol(w http.ResponseWriter, r *http.Request) { logDebugRequest("TS2021_HANDLER", r) } - logger.Info("Handling Tailscale control protocol upgrade request", log.String("remote_addr", r.RemoteAddr)) + logger.Info("Handling Tailscale control protocol upgrade request", + log.String("remote_addr", r.RemoteAddr), + log.String("method", r.Method), + log.String("host", r.Host), + log.String("connection", r.Header.Get("Connection")), + log.String("upgrade", r.Header.Get("Upgrade"))) // Check if we can hijack the connection immediately hijacker, ok := w.(http.Hijacker) @@ -554,7 +559,12 @@ func handleTailscaleControlProtocol(w http.ResponseWriter, r *http.Request) { ServerName: "controlplane.tailscale.com", }) if err != nil { - logger.Error("Error connecting to backend", log.Error(err)) + logger.Error("Error connecting to backend", + log.String("method", r.Method), + log.String("path", r.URL.Path), + log.String("connection", r.Header.Get("Connection")), + log.String("upgrade", r.Header.Get("Upgrade")), + log.Error(err)) http.Error(w, "Bad Gateway", http.StatusBadGateway) return } @@ -563,7 +573,10 @@ func handleTailscaleControlProtocol(w http.ResponseWriter, r *http.Request) { // Write the original request to the backend err = r.Write(backendConn) if err != nil { - logger.Error("Error writing request to backend", log.Error(err)) + logger.Error("Error writing request to backend", + log.String("method", r.Method), + log.String("path", r.URL.Path), + log.Error(err)) http.Error(w, "Bad Gateway", http.StatusBadGateway) return } @@ -572,11 +585,22 @@ func handleTailscaleControlProtocol(w http.ResponseWriter, r *http.Request) { reader := bufio.NewReader(backendConn) resp, err := http.ReadResponse(reader, r) if err != nil { - logger.Error("Error reading response from backend", log.Error(err)) + logger.Error("Error reading response from backend", + log.String("method", r.Method), + log.String("path", r.URL.Path), + log.Error(err)) http.Error(w, "Bad Gateway", http.StatusBadGateway) return } + logger.Info("Received control protocol response from upstream", + log.String("method", r.Method), + log.String("path", r.URL.Path), + log.Int("status_code", resp.StatusCode), + log.String("status", resp.Status), + log.String("connection", resp.Header.Get("Connection")), + log.String("upgrade", resp.Header.Get("Upgrade"))) + // Copy response headers to client for name, values := range resp.Header { for _, value := range values { @@ -622,6 +646,14 @@ func handleTailscaleControlProtocol(w http.ResponseWriter, r *http.Request) { return } + logger.Error("Control protocol upgrade did not switch protocols", + log.String("method", r.Method), + log.String("path", r.URL.Path), + log.Int("status_code", resp.StatusCode), + log.String("status", resp.Status), + log.String("connection", resp.Header.Get("Connection")), + log.String("upgrade", resp.Header.Get("Upgrade"))) + // For non-upgrade responses, copy the body normally if resp.Body != nil { io.Copy(w, resp.Body) diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 8e94468..0b99055 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -268,13 +268,20 @@ func TestBuildMainHandlerRewritesResponses(t *testing.T) { } } -func TestTS2021HandlerUsesInjectedDialer(t *testing.T) { +func TestTS2021HandlerPreservesMethodAndUpgradeHeaders(t *testing.T) { withProxyTestGlobals(t) + var receivedMethod string + var receivedConnection string + var receivedUpgrade string + backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ts2021" { t.Fatalf("backend path = %q, want /ts2021", r.URL.Path) } + receivedMethod = r.Method + receivedConnection = r.Header.Get("Connection") + receivedUpgrade = r.Header.Get("Upgrade") w.Header().Set("X-Upstream", "local-fake") _, _ = w.Write([]byte("controlplane ok")) })) @@ -289,7 +296,14 @@ func TestTS2021HandlerUsesInjectedDialer(t *testing.T) { proxy := httptest.NewServer(buildMainHandler(nil)) t.Cleanup(proxy.Close) - resp, err := proxy.Client().Get(proxy.URL + "/ts2021") + req, err := http.NewRequest(http.MethodPost, proxy.URL+"/ts2021", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Connection", "upgrade") + req.Header.Set("Upgrade", "tailscale-control-protocol") + + resp, err := proxy.Client().Do(req) if err != nil { t.Fatalf("ts2021 request: %v", err) } @@ -304,6 +318,15 @@ func TestTS2021HandlerUsesInjectedDialer(t *testing.T) { if body := readBody(t, resp.Body); body != "controlplane ok" { t.Fatalf("body = %q, want controlplane ok", body) } + if receivedMethod != http.MethodPost { + t.Fatalf("backend method = %q, want POST", receivedMethod) + } + if receivedConnection != "upgrade" { + t.Fatalf("backend Connection = %q, want upgrade", receivedConnection) + } + if receivedUpgrade != "tailscale-control-protocol" { + t.Fatalf("backend Upgrade = %q, want tailscale-control-protocol", receivedUpgrade) + } } func TestTS2021HandlerReturnsBadGatewayOnDialFailure(t *testing.T) { diff --git a/docs/hosting.md b/docs/hosting.md index 1edba70..4c40bbf 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -1,8 +1,20 @@ # Hosting -Railway is by far the easiest way to deploy Proxyt. It's preconfigured, and provides a valid domain and certificate for you. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/proxyt?referralCode=ftkvtR) +ProxyT works best when the frontend either terminates TLS directly on ProxyT or preserves arbitrary HTTP/1.1 upgrade requests all the way through to `/ts2021`. +## Compatibility Matrix + +The Tailscale control protocol uses a non-standard upgrade flow on `/ts2021`. Some CDNs and managed reverse proxies only support standard WebSocket `GET` handshakes or specific upgrade tokens, which breaks login flows before the request reaches ProxyT. + +| Frontend | Status | Notes | +| --- | --- | --- | +| Direct public host | Supported | Best option if you control ports 80/443 | +| Nginx / Apache / Caddy | Supported | Must preserve HTTP/1.1 upgrade semantics | +| Tailscale Funnel | Supported | Known-good path for exposing ProxyT | +| L4 TCP/TLS passthrough load balancer | Supported | Avoids HTTP upgrade rewriting at the edge | +| Railway / other managed HTTP edge proxies | Fragile / provider-dependent | Works only if the platform forwards `POST` upgrade requests unchanged | +| Cloudflare proxy / tunnel / workers | Not supported | Cloudflare does not support the Tailscale control protocol upgrade flow | +| AWS CloudFront | Not supported | CloudFront commonly drops or normalizes the `/ts2021` upgrade request | +| Fastly free tier | Not supported | Free-tier feature limits commonly block the required upgrade flow | # Deployment Scenarios @@ -48,6 +60,8 @@ proxyt serve --domain proxy.example.com --http-only --port 3000 Then configure your proxy to forward `https://proxy.example.com` to `http://localhost:3000`. +This only works when the proxy preserves `/ts2021` as an HTTP/1.1 upgrade request. If the provider rewrites or rejects `POST` upgrades, mobile and browser login will fail. + ## Behind Traditional Reverse Proxy (Nginx/Apache) ```bash @@ -56,6 +70,11 @@ proxyt serve --domain proxy.example.com --http-only --port 8080 --bind 127.0.0.1 **Nginx configuration example:** ```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 443 ssl; server_name proxy.example.com; @@ -73,11 +92,19 @@ server { # Important for Tailscale protocol upgrades proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; } } ``` +Important requirements for any reverse proxy: + +- Preserve the original HTTP method for `/ts2021` +- Forward `Connection` and `Upgrade` headers unchanged +- Allow non-standard upgrade tokens used by the Tailscale client +- Do not force HTTP/2 or transform the request before it reaches ProxyT + ## Docker Deployment ### Standalone with Let's Encrypt diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c54d255..78e4d2b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,18 +16,27 @@ - Enable debug mode (`--debug`) to see detailed request logs - Check that Tailscale client can resolve your domain - Verify proxy can reach `*.tailscale.com` domains +- Verify your frontend actually forwards `/ts2021` to ProxyT **HTTP-Only Mode Issues** - Ensure your reverse proxy is properly forwarding X-Forwarded headers - Verify the reverse proxy supports HTTP/1.1 upgrades for `/ts2021` endpoints +- Verify the reverse proxy preserves the original request method for `/ts2021` - Check that the bind address and port are correct - Confirm your reverse proxy is handling TLS termination properly **Protocol Upgrade Failures** - Ensure reverse proxy supports WebSocket/HTTP upgrades - Check that `Connection: upgrade` and `Upgrade` headers are forwarded +- Check whether your provider only supports standard WebSocket `GET` handshakes +- Check whether your provider only allows `Upgrade: websocket` and rejects other upgrade tokens - Verify no intermediate proxies are stripping upgrade headers +**Known Unsupported Frontends** +- Cloudflare proxy, Cloudflare Tunnel, and Cloudflare Workers are not supported for `/ts2021` +- CloudFront and some managed edge platforms may also fail because they do not forward the control-protocol upgrade intact +- If ProxyT works behind Nginx, Caddy, or Funnel but fails behind a CDN, the CDN is the likely incompatibility point + ## Debug Mode Enable debug logging to troubleshoot issues: @@ -36,4 +45,11 @@ Enable debug logging to troubleshoot issues: proxyt serve --domain proxy.example.com --email admin@example.com --cert-dir /tmp/certs --debug ``` -This provides detailed request/response logging including headers and routing decisions. \ No newline at end of file +This provides detailed request/response logging including headers and routing decisions. + +For `/ts2021`, debug logs are most useful when they show: + +- The incoming request method +- The `Connection` and `Upgrade` headers from the client-facing side +- Whether ProxyT received a `101 Switching Protocols` response from upstream +- Any non-101 status returned before tunneling started