Skip to main content

cowork-tunnel

SSH and HTTP proxy for environments whose only outbound path is HTTPS.

Why this exists

Anthropic Cowork sessions used to run locally, where SSH worked like it does anywhere else. They moved to cloud-only, and cloud sessions have no outbound path except HTTPS — so ssh myserver stopped working, and with it every workflow built on it: deploying, tailing logs, git push over SSH, running a build on a bigger box. Beyond SSH, the sandbox also intercepts some HTTPS requests — GitHub API calls, Reddit, and other destinations can be unreliable or blocked.

This puts that back. Two modes on one deployment:

  1. SSH tunneling -- carries SSH over a WebSocket, which is the one thing an HTTPS-only egress path reliably passes, so you get a normal SSH session to machines you already own.
  2. HTTP forwarding -- forwards arbitrary HTTP requests through the relay, bypassing HTTPS interception for GitHub API, package registries, or any other destination.

Nothing here is specific to Cowork. Any environment that permits HTTPS and blocks port 22 or intercepts HTTPS — a locked-down CI runner, a corporate proxy, a hotel network — has the same problem and the same fix.

Scope

Be clear about what this is and isn't:

  • SSH: It connects to servers you control, using your own SSH keys. SSH authentication is unchanged and still required end to end. This defeats no authentication anywhere — the far end's sshd is as strict as you configured it. It changes the transport SSH travels over. Nothing else. The relay is a byte pipe. Because SSH encrypts end to end, the relay only ever sees ciphertext — it cannot read or alter your session.
  • HTTP proxy: It forwards HTTP requests through the relay. The relay sees the plaintext of your requests (URLs, headers, bodies). Use it for API calls that the sandbox blocks, not for passing secrets you wouldn't trust the relay operator with. Upstream credentials (like GitHub PATs) travel in the envelope headers — the relay forwards them but does not store them.

Three Options

Option Install on Reach Best for
Standalone Each server That server only Single server, simplest setup
Universal One server Any server from there Multiple servers, one install
Cloudflare Worker Nothing Any server Zero infrastructure, serverless

All three options use the same SSH client (client/ws_proxy.py) and the same HTTP proxy client (client/http_proxy.py).

Security Model — Read This First

The bearer token is the access control. By default the universal relay and the Cloudflare Worker will open a TCP connection to any host and port you name. That is deliberate: a jump host that cannot reach your private network is not a jump host. But it means the token is the only thing standing between the internet and an arbitrary TCP proxy running on your server or your Cloudflare account.

Treat the token exactly like an SSH private key:

  • Generate it with secrets.token_urlsafe(32), never by hand.
  • Store it in a root-owned 600 file or a Cloudflare secret, never in the repo.
  • Rotate it the moment you think it has been exposed.
  • If it leaks, someone can relay traffic through your infrastructure — port scans and SSH brute force against third parties will be attributed to you.

Two locks protect a session, not one:

  1. Bearer token on the WebSocket upgrade — the relay refuses before any TCP connection is made.
  2. SSH key auth on the target server — unchanged, still required, still end to end.

What is and isn't protected

Control Standalone Universal Cloudflare Worker
Timing-safe token comparison yes yes yes (SHA-256 + timingSafeEqual)
Target allowlist (ALLOWED_HOSTS) n/a optional, off by default optional, off by default
Private-address guard (BLOCK_PRIVATE) n/a optional, off by default optional, off by default (lexical only)
Concurrent connection cap yes yes no
Connection rate limit yes, see below yes, see below no

The rate limit is a single shared bucket, not per-client. It keys on the peer address, and behind nginx every peer is 127.0.0.1. For the single-user case this project targets, that is fine — it is a brake on runaway reconnect loops, not a multi-tenant control. Do not rely on it to isolate users from each other.

BLOCK_PRIVATE is a guardrail, not a boundary. In the Python relays it resolves the target once and checks the resolved address, then connects to that exact address — so a DNS rebind between check and connect cannot slip past. In the Cloudflare Worker it is a lexical check on the target string only, because Workers has no resolver API; a hostname pointing at a private address is not caught there. On Cloudflare this matters little (a Worker's egress cannot reach your LAN and there is no instance metadata service to hit), but do not mistake it for enforcement.

Entries in ALLOWED_HOSTS are treated as a deliberate decision and override BLOCK_PRIVATE, so allowlisting an internal host works as you would expect.

The token travels in the query string because some proxies strip Authorization headers. It is also sent as a header. Query strings land in access logs — the shipped nginx config sets access_log off for the tunnel location, and the Cloudflare Worker's request logs will contain it. Factor that into where you let logs go.

Quick Start

Client (in the restricted environment)

If pip works where you are, this is the short path:

pip install cowork-tunnel

export COWORK_RELAY_URL="wss://your-relay.example.com/ssh"
export COWORK_RELAY_TOKEN="your-secret-token"

cowork-tunnel-http GET https://api.github.com/zen --raw
ssh -o 'ProxyCommand=cowork-tunnel-ssh' user@server

The package installs two commands and pulls in websockets for you. The rest of this section is the fallback for environments where pip is blocked — the clients are standalone files by design and bootstrap.sh fetches them individually.

# Install deps (one time per session)
apt-get install -y openssh-client
pip install websockets --break-system-packages

# Set relay URL and token
export COWORK_RELAY_URL="wss://your-relay.example.com/ssh"
export COWORK_RELAY_TOKEN="your-secret-token"

# SSH as normal, with ProxyCommand
ssh -o 'ProxyCommand=python3 client/ws_proxy.py' user@server

# Or for universal/cloudflare mode, specify target in URL
export COWORK_RELAY_URL="wss://jump.example.com/ssh?target=192.168.1.1:22"
ssh -o 'ProxyCommand=python3 client/ws_proxy.py' user@server

Or use the bootstrap script:

export COWORK_RELAY_URL="wss://your-relay.example.com/ssh"
export COWORK_RELAY_TOKEN="your-token"
curl -fsSL https://raw.githubusercontent.com/dial481/cowork-tunnel/main/client/bootstrap.sh | bash
ssh user@server  # just works

The bootstrap script writes a Host * block to ~/.ssh/config, which routes all SSH in that session through the tunnel. Re-running it replaces that block rather than appending a second one. Delete the marked block to disable it.

Piping a script from the internet into bash runs whatever that URL returns. Read it first if that matters to you; it is short.

The script takes three optional overrides:

Variable Default Meaning
COWORK_TUNNEL_DIR /tmp/cowork-tunnel Where the client scripts are installed
COWORK_TUNNEL_PYTHON (unset) An interpreter you manage — a venv, a conda env, anything. Set it and bootstrap installs nothing and creates nothing
COWORK_TUNNEL_REF main Branch, tag or commit to fetch the clients from
COWORK_TUNNEL_REPO_RAW GitHub raw URL A different origin entirely, for a fork or a mirror

ws_proxy.py needs the websockets package; http_proxy.py needs nothing beyond the standard library. If your python3 already has websockets, bootstrap uses it as-is. If it does not, bootstrap builds a venv under COWORK_TUNNEL_DIR rather than installing into system Python — distro Pythons are marked externally-managed (PEP 668) and pip --break-system-packages exists to override that guard, which is not a thing an installer should do to you by default. Set COWORK_TUNNEL_PYTHON to opt out entirely and manage the environment yourself. Failing to install websockets is a warning, not an error: the HTTP proxy still installs and works.

Keep the clients and the relay on the same revision. Installing a client from main while running a relay from a branch is how you get behaviour that matches neither.

HTTP Proxy

The HTTP proxy forwards arbitrary HTTP requests through the relay. It uses the same relay URL and token as the SSH tunnel — one hostname, one token, routed by path. Unlike the SSH client it needs no websockets install: it is a plain HTTPS POST and uses only the standard library.

# Set relay URL and token (same as SSH)
export COWORK_RELAY_URL="https://your-relay.example.com"
export COWORK_RELAY_TOKEN="your-secret-token"

# Simple GET
python3 client/http_proxy.py GET https://api.github.com/repos/owner/repo

# POST with headers and body
python3 client/http_proxy.py POST https://api.github.com/repos/owner/repo/issues \
  -H "Authorization: token ghp_xxx" \
  -H "Content-Type: application/json" \
  -d '{"title": "Bug report"}'

# Pipe output (--raw suppresses status/headers on stderr)
python3 client/http_proxy.py GET https://example.com/data.json --raw | jq .

# Send a binary body from a file (-b), rather than a string (-d)
python3 client/http_proxy.py PUT https://example.com/upload -b ./payload.bin

Flags: -H adds a header (repeatable), -d sends a string body, -b sends a file as the body (binary safe), --raw prints only the response body so it can be piped.

As a library (from cowork_tunnel.http_proxy import ... when pip-installed, from http_proxy import ... when using the file directly):

from cowork_tunnel.http_proxy import relay_fetch

response = relay_fetch(
    method="GET",
    url="https://api.github.com/repos/owner/repo",
    headers={"Authorization": "token ghp_xxx"},
)
print(response["status"])   # 200
print(response["body"])     # b'{"id": 123, ...}'

The client auto-detects the relay URL format: if COWORK_RELAY_URL is a WebSocket URL like wss://host/ssh?target=..., it extracts the base host and appends /proxy.

The response dict carries status, headers, body (bytes) and body_b64. Two fields appear only when relevant:

  • set_cookie — a list of the target's Set-Cookie headers. Cookies are kept out of the flat headers dict on purpose: repeated headers are folded together with , , and a cookie's Expires date contains a comma, so folding them produces values no client can parse.
  • redirected / final_url — present when the target redirected and the final URL differs from the one you asked for.

Client environment variables:

Variable Default Meaning
COWORK_RELAY_URL (required) Relay base URL. wss:///ws:// accepted; path and query are stripped
COWORK_RELAY_TOKEN (empty) Bearer token
COWORK_RELAY_TIMEOUT 60 Seconds to wait for the relay before giving up
COWORK_RELAY_VERIFY_TLS off Verify the relay's certificate. Off by default because the Cowork sandbox re-signs TLS; turn it on anywhere with a clean path to the relay
COWORK_RELAY_USER_AGENT cowork-tunnel The agent this client identifies as, both to the relay and to the target when you don't set one yourself

The client reads COWORK_RELAY_* from the environment but never modifies it — proxy settings are bypassed per-request rather than by deleting HTTPS_PROXY process-wide.

User-Agent. Two different requests carry one: the hop to the relay, and the forwarded request the target sees. The client names itself on both. This is not cosmetic — Cloudflare's edge rejects urllib's default Python-urllib/x.y with a 403 (error 1010) before the request reaches a Worker-hosted relay at all, and GitHub answers 403 to a forwarded request carrying no agent. Set User-Agent in -H/headers to override what the target sees; the relays forward exactly what the envelope contains and invent nothing.

Option 1: Standalone Relay (per server)

Install on the server you want to reach. Bridges WebSocket to local sshd.

# On the server
pip install websockets --break-system-packages
export RELAY_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
echo "Save this token: $RELAY_TOKEN"

# Run in the foreground, or install as a systemd service (see below)
RELAY_TOKEN=$RELAY_TOKEN python3 relay/standalone.py

Add the nginx location block from relay/nginx-location.conf to proxy /ssh to the relay.

TLS certificate: The egress proxy validates the certificate your relay presents, so a self-signed one is rejected and you need a real cert. If you don't have a domain, use the sslip.io trick — it gives you a free domain that resolves to any IP, and Let's Encrypt will issue a cert for it:

sudo apt install certbot python3-certbot-nginx -y
# Open port 80 for the ACME challenge
sudo ufw allow 80/tcp
# Replace dashes for dots in your IP
sudo certbot --nginx -d YOUR-IP-WITH-DASHES.sslip.io
# e.g. for 203.0.113.42:
sudo certbot --nginx -d 203-0-113-42.sslip.io

If certbot can't auto-install, set server_name in your nginx config first:

sudo sed -i 's/server_name _;/server_name YOUR-IP-WITH-DASHES.sslip.io;/' /etc/nginx/sites-enabled/your-site
sudo certbot install --cert-name YOUR-IP-WITH-DASHES.sslip.io

Option 2: Universal Relay (one server, reach all)

Install on ONE server. Routes to any target host:port, including hosts on that server's private network. Acts as a jump host. Needs the same nginx + TLS setup as the standalone relay (see the TLS certificate section above).

# On your jump server
pip install websockets --break-system-packages
export RELAY_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")

# Reach anything (default)
RELAY_TOKEN=$RELAY_TOKEN python3 relay/universal.py

# Or restrict to specific targets
ALLOWED_HOSTS="server1.com,192.168.1.5,10.0.0.0/8" \
  RELAY_TOKEN=$RELAY_TOKEN python3 relay/universal.py

# Or refuse private targets entirely (public internet only)
BLOCK_PRIVATE=1 RELAY_TOKEN=$RELAY_TOKEN python3 relay/universal.py

Client usage adds a target parameter:

export COWORK_RELAY_URL="wss://jump.example.com/ssh?target=other-server.com:22"
ssh -o 'ProxyCommand=python3 client/ws_proxy.py' user@other-server

Targets may be host, host:port, [v6addr]:port, or a bare IPv6 literal. The port defaults to 22 and must be in range 1–65535.

Environment variables

Variable Default Meaning
RELAY_TOKEN (required) Bearer token
WS_HOST 127.0.0.1 Listen address for both ports
WS_PORT 8765 WebSocket/SSH port
PROXY_PORT 8766 HTTP proxy port, serving /proxy and /health. Must differ from WS_PORT
MAX_CONNECTIONS 10 Concurrent tunnels
RATE_LIMIT 20 New connections per minute (shared bucket)
ALLOWED_HOSTS (empty) Hostnames, IPs, or CIDRs. Empty = any target. SSH path only
BLOCK_PRIVATE off Refuse private/loopback targets. SSH path only
DEFAULT_PORT 22 Port used when the target omits one

HTTP proxy path (POST /proxy):

Variable Default Meaning
PROXY_RATE_LIMIT 120 Proxy requests per minute (separate bucket from RATE_LIMIT)
MAX_PROXY_BODY 8 MiB Largest request body accepted. This bounds the JSON envelope; a binary payload is base64-encoded inside it, so 8 MiB here is roughly 6 MiB of actual upload. Keep nginx's client_max_body_size in step
MAX_RESPONSE_BODY 32 MiB Largest upstream response returned. The body is buffered and base64-encoded, so relay memory peaks at roughly 2.5× this
PROXY_TIMEOUT 25 Seconds to wait for the upstream target
HEAD_TIMEOUT / BODY_TIMEOUT 15 / 30 Seconds a client may take to send a request head / body before the connection is dropped
INSECURE_UPSTREAM_TLS off Skip certificate verification on upstream HTTPS. Off by default — only enable if the relay itself sits behind a TLS-intercepting gateway

standalone.py takes the same variables minus the target-related ones, plus SSH_HOST / SSH_PORT for the local sshd. It is SSH-only and has no /proxy endpoint.

Upstream TLS is verified. The relay checks the target's certificate against the system trust store. The client skips verification when talking to the relay (the Cowork sandbox re-signs everything), but the relay's own hop to the internet is verified — that hop is what carries your forwarded credentials. INSECURE_UPSTREAM_TLS=1 turns it off if your relay genuinely sits behind an intercepting proxy.

ALLOWED_HOSTS and BLOCK_PRIVATE do not apply to /proxy. They are TCP target controls for the SSH path. The HTTP proxy forwards to any http(s) URL by design. Other schemes (file:, ftp:, data:) are refused — without that, a token holder could read files off the relay's own disk.

Two local ports, one public endpoint. The universal relay listens twice: WS_PORT (default 8765) for WebSocket/SSH, and PROXY_PORT (default 8766) for /proxy and /health. Both are plaintext and both expect to sit behind nginx, which terminates TLS on 443 and routes by path. Clients only ever reach 443 — the split is invisible to them, and neither port should be exposed directly.

If you upgrade an existing install, add the /proxy and /health location blocks pointing at 8766 and set PROXY_PORT in the unit's env file. Sending /proxy to 8765 gets a 426 from websockets. See Design notes for why it is built this way.

Option 3: Cloudflare Worker (serverless)

Deploy once to Cloudflare. No servers to maintain.

Runs on the free Workers plan — outbound TCP via cloudflare:sockets is not a paid-only feature. Verified by tunnelling a full SSH session and an 8 MiB transfer through a free account. What you can hit on the free plan is the daily request cap and the per-invocation CPU limit, neither of which a mostly-idle SSH tunnel comes close to.

# Install wrangler
npm install -g wrangler
wrangler login

# Deploy
cd cloudflare/
wrangler secret put RELAY_TOKEN  # paste your generated token
wrangler deploy

Client usage:

export COWORK_RELAY_URL="wss://cowork-tunnel.your-subdomain.workers.dev/?target=server.com:22"
ssh -o 'ProxyCommand=python3 client/ws_proxy.py' user@server

The Worker has no rate limit and no connection cap — Workers gives you no shared state to implement them in without a Durable Object. If that matters for your deployment, set ALLOWED_HOSTS and treat the token as the whole security model.

The Worker caps proxied response bodies at 32 MiB, matching the relay's MAX_RESPONSE_BODY. Override it with a MAX_RESPONSE_BODY var in wrangler.toml. The cap exists because the response is held three times over while it is base64-encoded — the buffer, the binary string, and the output — so an uncapped forward of a large download walks the isolate into its 128 MB memory limit and dies with nothing the caller can read. Past the cap you get a 502 naming the limit instead.

Proxied requests are logged as [proxy] METHOD host -> status, visible in wrangler tail. Host only, never the full URL: a forwarded URL can carry a token in its query string.

Architecture

The client only ever reaches port 443. nginx terminates TLS and routes by path;
the universal relay's two local ports are never exposed.

SSH Tunnel:
  Standalone:   restricted env --> WSS/443 --> nginx --> :8765 relay --> sshd (localhost:22)
  Universal:    restricted env --> WSS/443 --> nginx --> :8765 relay --> TCP --> any-server:22
  Cloudflare:   restricted env --> WSS/443 --> CF Edge --> Worker --> TCP --> any-server:22

HTTP Proxy:
  Universal:    restricted env --> POST /proxy --> nginx --> :8766 relay --> any URL
  Cloudflare:   restricted env --> POST /proxy --> CF Edge --> Worker --> fetch() --> any URL

SSH Config Integration

Add to ~/.ssh/config in the restricted environment for transparent use:

Host *
    ProxyCommand python3 /path/to/ws_proxy.py
    ServerAliveInterval 60
    ServerAliveCountMax 3

ServerAliveInterval 60 prevents the connection from being dropped by Cloudflare's 900-second idle timeout.

For Git

export GIT_SSH_COMMAND='ssh -o "ProxyCommand=python3 /path/to/ws_proxy.py"'
git push origin main  # works through the tunnel

Full nginx Setup

The standalone and universal relays need nginx in front for TLS termination. Here's a complete server block:

server {
    listen 443 ssl;
    server_name your-domain.com;

    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;

    # Universal relay -- SSH over WebSocket
    location /ssh {
        proxy_pass http://127.0.0.1:8765;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        access_log off;              # the token is in the query string
        # SSH sessions idle. Anything short and nginx closes the tunnel under you.
        proxy_read_timeout 7d;
        proxy_send_timeout 7d;
        proxy_buffering off;
    }

    # Universal relay -- HTTP proxy (note the different port)
    location /proxy {
        proxy_pass http://127.0.0.1:8766;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        access_log off;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
        client_max_body_size 8m;
    }

    # Health check, same port as the HTTP proxy. Optional.
    location /health {
        proxy_pass http://127.0.0.1:8766;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        access_log off;
    }

    # Standalone relay, if you run both on one host. Note 8767: the universal
    # relay already occupies 8765 (SSH) and 8766 (HTTP proxy).
    location /ssh-standalone {
        proxy_pass http://127.0.0.1:8767;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        access_log off;
        proxy_read_timeout 7d;
        proxy_send_timeout 7d;
        proxy_buffering off;
    }
}

# Port 80 exists so Certbot's HTTP-01 challenge can renew the certificate.
# Without it renewal fails and the tunnel dies when the cert expires.
server {
    listen 80;
    server_name your-domain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Save to /etc/nginx/sites-enabled/cowork-tunnel, then sudo nginx -t && sudo systemctl reload nginx.

Keep backups of that file outside sites-enabled/ — nginx includes every file in that directory, so a cowork-tunnel.bak is loaded as live config and produces a "conflicting server name" warning.

See relay/nginx-location.conf for the location block only.

Confirm renewal works. A certbot timer being armed is not evidence that renewal succeeds — if port 80 is unreachable the challenge fails, and when the certificate expires the egress proxy rejects it and every tunnel stops. Prove it end to end:

sudo ufw allow 80/tcp
sudo certbot renew --dry-run     # must succeed before you trust the timer

Running as a systemd service

relay/cowork-tunnel@.service is a templated unit — the instance name selects both the script and its config, so one file runs both relays.

# Install the scripts and the unit
sudo install -d -m 755 /opt/cowork-tunnel
sudo install -m 644 relay/universal.py relay/standalone.py /opt/cowork-tunnel/
sudo install -m 644 relay/cowork-tunnel@.service /etc/systemd/system/

# One config file per instance, root-owned and 0600. systemd reads these as
# root before dropping to User=nobody, so the relay account never needs them.
sudo install -d -m 700 /etc/cowork-tunnel
TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# The universal relay binds two ports (8765 SSH, 8766 HTTP proxy), so the
# standalone relay goes on 8767 rather than colliding with 8766.
printf 'RELAY_TOKEN=%s\nWS_PORT=8765\nPROXY_PORT=8766\n' "$TOKEN" | sudo tee /etc/cowork-tunnel/universal.env  >/dev/null
printf 'RELAY_TOKEN=%s\nWS_PORT=8767\n' "$TOKEN" | sudo tee /etc/cowork-tunnel/standalone.env >/dev/null
# Explicit paths, not a glob: /etc/cowork-tunnel is 0700 root-owned, so a glob
# expands in your unprivileged shell, fails, and the chmod silently never runs --
# leaving the token files world-readable.
sudo chmod 600 /etc/cowork-tunnel/universal.env /etc/cowork-tunnel/standalone.env

sudo systemctl daemon-reload
sudo systemctl enable --now cowork-tunnel@universal
sudo systemctl enable --now cowork-tunnel@standalone

Check them with systemctl status cowork-tunnel@universal and journalctl -u cowork-tunnel@universal -f. To rotate the token, edit the env files and sudo systemctl restart 'cowork-tunnel@*'.

Troubleshooting

HTTP 503 from nginx: The relay isn't listening. Check with ss -tlnp | grep 8765, then systemctl status cowork-tunnel@universal and journalctl -u cowork-tunnel@universal -n 50.

Service sits in activating (auto-restart): The relay is exiting immediately, almost always because it can't import websockets. The unit runs as User=nobody with ProtectHome=yes, so packages installed into a user's home (pip install --user, or plain pip install as a normal account) are invisible to it. Give the service its own venv — see the systemd section above.

No journal output at all from the unit: If journalctl reports "No journal files were found," the host isn't persisting logs. Create the directory and restart journald: sudo install -d -m 2755 -g systemd-journal /var/log/journal && sudo systemctl restart systemd-journald.

"externally-managed-environment" on pip install: Modern Debian/Ubuntu blocks system-wide pip. Use pip install websockets --break-system-packages.

Certbot times out, or the relay is unreachable even though ufw allows the port: Your host firewall is not the only one. Cloud providers put a second firewall in front of the instance — DigitalOcean Cloud Firewalls, AWS security groups, GCP VPC rules — and it is invisible to ufw status. A closed port refuses instantly; a filtered one times out, so a timeout is the tell. Confirm at the packet level rather than guessing: run sudo tcpdump -n -i any 'tcp port 443 and tcp[tcpflags] & tcp-syn != 0', then connect from somewhere off-box. No inbound SYN means the packets never arrived and the provider firewall is dropping them; open the port there too.

Self-signed cert rejected (503 with TLS_error): The egress proxy validates your relay's certificate. Use a real cert — see the sslip.io trick in the setup section above.

Connection reset by peer during TLS: An explicit HTTPS_PROXY in the environment interferes. The client clears it automatically; if you're using a custom client, unset HTTPS_PROXY before connecting.

"Pipe transport is only for pipes": ws_proxy.py is designed to run as an SSH ProxyCommand, not standalone. Always use it with ssh -o 'ProxyCommand=...'.

Error: relay rejected the token: The token the client sent doesn't match RELAY_TOKEN on the relay. Note that the relay accepts the WebSocket upgrade and then closes it with code 4003, so this shows up as a rejection after connect, not a failed connect.

Error: target host not allowed by the relay: ALLOWED_HOSTS is set and your target isn't in it, or BLOCK_PRIVATE is on and the target resolves to a private address. Add the host to ALLOWED_HOSTS — an explicit entry overrides BLOCK_PRIVATE.

Testing

python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
python3 tests/test_all.py        # relays and clients; add -v for per-check detail
node tests/test_worker.mjs       # Cloudflare Worker; needs Node 18+

The suite imports the relay scripts for unit tests and spawns them as real subprocesses for integration tests, so it covers the code that actually ships. It runs fully offline — every target is a loopback address or an IP literal. CI runs it on Python 3.10–3.12, plus shellcheck on the bootstrap script and a syntax check on the Worker.

The Worker is tested too, despite having no local runtime. tests/test_worker.mjs loads the real cloudflare/worker.js with its one cloudflare:sockets import redirected to a stub, then calls the exported fetch handler directly — Node supplies the rest of what the proxy path uses (fetch, Request, Response, Headers, atob/btoa) from the same web-standard implementations Workers exposes. That covers routing, auth, forwarding, response fidelity and malformed input.

The SSH bridge is covered too. bridge() is module-scoped, and driving it through fetch() would need a WebSocketPair and a 101 Response, neither of which Node can construct — so the harness exports it from the in-memory copy and calls it directly, with cloudflare:sockets backed by node:net. cloudflare/worker.js on disk is never modified; git diff proves it. What that leaves untested is the wiring between fetch() and bridge() on the real runtime, which is exercised against a deployed Worker instead.

websockets>=14.1 is the declared floor and CI pins that exact version in a separate job. Installing only the newest release would never prove the floor still works — and it did not: 14.0 and earlier lack ClientConnection.close_code, which ws_proxy.py needs to tell you why a tunnel was refused.

Design notes: why WebSocket, and why the setup looks like this

Two constraints drove the whole design. Neither is exotic — most HTTPS-only environments impose some version of them — but together they rule out the simpler approaches, and knowing them explains the setup steps that otherwise look arbitrary.

A plain TCP tunnel on 443 doesn't survive. An HTTPS-only egress path generally isn't a port filter; it's a proxy that understands HTTP and terminates TLS. Raw bytes to port 443 aren't HTTPS and get dropped. A WebSocket upgrade is a real HTTP request that negotiates into a bidirectional byte stream, which is exactly the shape SSH needs and exactly what such a proxy will carry. That's the whole reason for the WebSocket layer — it isn't obfuscation, it's the only transport that fits both ends.

The proxy validates your relay's certificate, so self-signed won't do. TLS is typically re-signed in transit, which is why the client sets verify_mode = CERT_NONE: it would otherwise reject a certificate it was never meant to see. But the intermediary still checks the certificate your relay presents, and rejects an untrusted one — usually surfacing as an opaque 503. Hence the Let's Encrypt requirement, and the sslip.io trick above if you don't own a domain.

There is also often an explicit HTTPS_PROXY in the environment. The websockets library doesn't speak CONNECT, so ws_proxy.py clears those variables and connects directly.

What that costs you. Since the client can't verify the relay's certificate, someone positioned between the two could read the bearer token in transit. Your SSH session is unaffected — it's encrypted end to end and host key verification still applies, so the tunnel operator and any observer see only ciphertext. The token is the exposed item, which is why it's worth rotating if you suspect it leaked.

Design notes: why the relay listens twice

Adding POST /proxy raised a question that looks trivial and isn't: how does one process serve both WebSocket upgrades and ordinary HTTP requests? The websockets library expects to own a listening socket and offers no public way to hand it a connection you have already accepted — serve() takes a listening socket, never a connected one. Nor can its process_request hook answer /proxy, because that hook never sees a request body.

Three designs were tried. The relay uses the third.

1. Swap the protocol in-process. Read the request head yourself, build a ServerConnection by hand, and attach it to the existing transport with set_protocol(). One socket, no copying.

It needs ServerProtocol's constructor, ServerConnection's constructor, the Server object it expects, and the way the handshake task is started — none of it public API. The first version of the v2 proxy was built this way. It worked on websockets 17.x and broke the SSH bridge on 13.1, 14.2, 15.0 and 16.0: four of the five versions tested. The failure mode is the bad kind — silent, total, and triggered by an unrelated pip install -U.

2. Dispatch in front of a loopback websockets server. Read the head on the public port, answer /proxy yourself, and for an upgrade open a second connection to a websockets server on 127.0.0.1, replay the head, and copy bytes both ways. Public API only, one public port — but it puts a hand-written HTTP parser in front of every tunnel and copies every byte an extra time.

3. Give each protocol its own local port. (chosen) websockets owns WS_PORT outright, exactly as it did before the proxy existed, so nothing sits between a client and the library's own handshake. The HTTP handlers own PROXY_PORT and never see tunnel traffic. nginx routes by path, so clients still reach exactly one endpoint on 443.

This is the least code and the least cleverness: no internals, no interposed parser, and the SSH path is byte-for-byte the arrangement that shipped in v1.0.0. The cost is one more port to configure, which is why the /proxy and /health location blocks point at 8766.

The thing that actually made it fast

All three designs measure the same — roughly 175–240 MiB/s over loopback for 100 MiB of random data. The architecture was never the bottleneck.

What was the bottleneck: serve() and connect() negotiate permessage-deflate by default, and this tunnel carries SSH. SSH is encrypted, so its bytes are indistinguishable from random and cannot compress. Every frame was being run through deflate to produce slightly larger output, on both ends.

100 MiB of random data, end to end throughput
permessage-deflate enabled (as shipped in v1.0.0) 12.5 MiB/s
compression=None 220 MiB/s

That is a ~17× throughput bug that was present in v1.0.0 and had nothing to do with the proxy work. It is now disabled in universal.py, standalone.py, and ws_proxy.py — the client declines it too, which also covers the Cloudflare path, where the edge would otherwise accept the offer. A test asserts the handshake response carries no Sec-WebSocket-Extensions header, so it cannot come back unnoticed.

An earlier draft of these notes blamed design 2 for a 4× slowdown. That was wrong: the measurement compared a compressed build against an uncompressed one, using a payload of repeated bytes that deflate handled unrealistically well. Real SSH traffic does not look like that.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cowork_tunnel-2.1.0.tar.gz (68.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cowork_tunnel-2.1.0-py3-none-any.whl (25.1 kB view details)

Uploaded Python 3

File details

Details for the file cowork_tunnel-2.1.0.tar.gz.

File metadata

  • Download URL: cowork_tunnel-2.1.0.tar.gz
  • Upload date:
  • Size: 68.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cowork_tunnel-2.1.0.tar.gz
Algorithm Hash digest
SHA256 1c7aff4d2bc1e93680dcc68e4aee60715561b28f4ab85893894c7edf9c2223b4
MD5 89bd220ec5c54067864808bb36d4acc0
BLAKE2b-256 460294518b8e993501b987241feaee4a42d74fc25248428c59603d648315bad6

See more details on using hashes here.

Provenance

The following attestation bundles were made for cowork_tunnel-2.1.0.tar.gz:

Publisher: publish.yml on dial481/cowork-tunnel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cowork_tunnel-2.1.0-py3-none-any.whl.

File metadata

  • Download URL: cowork_tunnel-2.1.0-py3-none-any.whl
  • Upload date:
  • Size: 25.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cowork_tunnel-2.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 aa132c4915dec85b80e1d679a710854bb0dde123c0a25fcc22119740403e2eee
MD5 d503855c3bc7623ceacaaa63c717a974
BLAKE2b-256 ec9255c42186709cf28ed408bcec58e4969e972acd223be24f43bbd842148000

See more details on using hashes here.

Provenance

The following attestation bundles were made for cowork_tunnel-2.1.0-py3-none-any.whl:

Publisher: publish.yml on dial481/cowork-tunnel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page