SSH-over-WebSocket-over-TLS
Server setup
Deploy a CDN-fronted SSH server in about five minutes. The phone dials a Cloudflare anycast IP on port 443, Cloudflare proxies a WebSocket upgrade to your origin, and your VPS bridges the WebSocket frames straight into sshd. Carrier networks see normal HTTPS, so it works where direct-IP SSH is blocked.
CDN-fronted
Traffic looks like normal HTTPS to a Cloudflare anycast IP. Carrier DPI rules that block raw TLS to non-CDN IPs let it through.
Real LE cert
Let’s Encrypt HTTP-01 issuance works through Cloudflare orange cloud — no manual cert juggling, auto-renews via certbot.timer.
Hardened sshd
Dedicated vpnssh user, sshd Match block restricts it to direct-tcpip forwarding only. Your admin SSH on :22 is untouched.
🔗Architecture
End-to-end wire path. Each layer wraps the bytes inside it; the carrier only sees the outermost (plain HTTPS to Cloudflare).
Phone (cellular / Wi-Fi)
│
▼ TCP to Cloudflare anycast IP (104.18.x.x:443)
[ Cloudflare proxy ] ◄── orange-cloud DNS
│
▼ TLS terminate, forward HTTP/1.1 with Upgrade: websocket
[ Origin nginx :443 ] ◄── Let's Encrypt cert for ssh-tls.example.com
│ /ssh-ws
▼ WebSocket (binary frames)
[ websocat WS↔TCP bridge ] 127.0.0.1:8088
│
▼ raw bytes
[ sshd :22 ] ◄── vpnssh user, port-forwarding only
│
▼ SSH direct-tcpip channels
InternetField mapping (admin form ↔ wire)
- tunnelServer
- TCP target — a Cloudflare anycast IP. Phone dials this directly.
- proxyHost
- TLS SNI / cert hostname AND the WebSocket Host header.
- payload
- Repurposed as the WebSocket path (e.g.
/ssh-ws). - sshUser/Pass
- Credentials for the dedicated vpnssh system user.
Why it works on restricted carriers
- Cloudflare anycast IPs are universally whitelisted by mobile carriers’ “popular destinations” allowlists.
- The outer protocol on the wire is plain TLS 1.3 / HTTPS — no DPI signatures fire.
- WebSocket framing is what most modern web apps use, so it looks native.
- The SSH protocol itself runs entirely inside the encrypted tunnel — only your origin server can see it.
✅Prerequisites
- ✓
Ubuntu 22.04 or 24.04 VPS, root access
Tested on DigitalOcean, Hetzner, AWS Lightsail. Any cloud that gives you root over SSH works.
- ✓
A domain name on Cloudflare DNS
An A record like ssh-tls.example.com pointing at your VPS IP. Leave the orange cloud (proxy) ON — that is the whole point.
- ✓
Open ports 22, 80 and 443
Port 80 is needed briefly for Let's Encrypt HTTP-01 (Cloudflare passes ACME paths through). The installer adds the ufw rules automatically.
- ✓
Cloudflare WebSockets enabled
Free Cloudflare has WebSockets ON by default. If you have a custom firewall rule blocking them, allow Upgrade headers for your domain.
⚡Quick start
On the VPS, as root. Replace ssh-tls.example.com with your real domain and you@example.com with an email Let's Encrypt can use for cert-expiry notifications.
git clone <this-repo> /opt/full-setup-ssh-ws-tls cd /opt/full-setup-ssh-ws-tls chmod +x install.sh scripts/*.sh ./install.sh \ --domain ssh-tls.example.com \ --email you@example.com \ --port 443 \ --ws-path /ssh-ws \ --yes
~90 seconds. The installer tears down any prior full-setup-ssh-tls install (the bare-TLS variant), installs nginx + websocat + certbot, issues the cert, creates the hardened vpnssh user with a random 24-char password, configures everything, and prints a ready-to-paste client config at the end.
What the installer does
System packages
Installs nginx, openssh-server, certbot, jq, ufw. Downloads the static websocat binary from the official GitHub release (v1.13.0) for your CPU architecture.
Cert issuance
Drops a temporary HTTP-only nginx vhost just so the Let's Encrypt HTTP-01 challenge can land on the origin (Cloudflare passes /.well-known/acme-challenge/* through even with the orange cloud on). Once issued, the cert lives at /etc/letsencrypt/live/<domain>/fullchain.pem and renews automatically via certbot.timer.
Hardened SSH user
Creates a system user vpnssh with no shell. An sshd Match block scoped to that user only allows password auth and TCP forwarding — no PTY, no agent, no tunnel device, no shell. Your admin login on port 22 is unaffected.
WebSocket → TCP bridge
Runs websocat as a hardened systemd service ssh-ws-bridge.service (DynamicUser, ProtectSystem=strict, MemoryDenyWriteExecute) bound to 127.0.0.1:8088. Each accepted WebSocket connection opens a fresh TCP socket to 127.0.0.1:22 and forwards bytes both ways.
nginx vhost
Replaces the temp HTTP vhost with the real one: TLS on :443 with the LE cert, a friendly decoy site for any path that isn't the WS endpoint, and a location block on /ssh-ws that requires the Upgrade: websocket header (plain GETs return 404 so the endpoint stays hidden).
Client config snippet
The installer ends by printing a ready-to-paste server config in three forms: an admin-dashboard JSON record (paste it at /admin/servers), a raw-fields summary, and a Kotlin snippet for hard-coded debug injection.
🩺Verify the install
Two helpers are placed in /usr/local/bin. The healthcheck runs seven local probes — including a real TLS handshake through nginx, a WebSocket upgrade, and a peek at the inner stream to confirm it starts with SSH-2.0-OpenSSH.
# server-side end-to-end check (TLS + WS upgrade + inner SSH banner) ssh-ws-tls-healthcheck # print the client config (admin-DB JSON + raw fields + Kotlin snippet) ssh-ws-tls-client-config # tail the live logs from all three services journalctl -u nginx -u ssh-ws-bridge -u ssh -f
📱Hook it into the Android app
The mobile client dispatches serverType = "ssh-ws-tls" to the SSH-over-TLS service in WebSocket mode. Either paste the JSON below into the admin dashboard at /admin/servers or POST it to your server-management API. The app picks it up on the next refresh.
{
"id": "ssh-ws-tls-<your-hash>",
"name": "SSH-WS-TLS • ssh-tls.example.com",
"uuid": "<your-uuid>",
"proxyHost": "ssh-tls.example.com",
"proxyPort": 443,
"tunnelServer": "104.18.37.127",
"tunnelPort": 443,
"flagEmoji": "🌐",
"city": "CDN",
"ping": "0ms",
"operator": "Inwi",
"isOnline": true,
"serverType": "ssh-ws-tls",
"sshUser": "vpnssh",
"sshPassword": "<random-from-installer>",
"payload": "/ssh-ws"
}Notes
tunnelServeris a Cloudflare anycast IP — any IP fromcloudflare.com/ips-v4works. The app doesn't do DNS for it.proxyHostmust match what your TLS cert covers; CF passes the SNI through unchanged.payloadis reused as the WebSocket path — same convention as the existing VLESS-WS-TLS servers in this DB.- The Android client's SSH-TLS tunnel defaults
allowInsecure = true, but a real LE cert means you can flip it off later for stricter peer verification.
🛠️Troubleshooting
❓ Let’s Encrypt issuance fails with “unable to get local issuer certificate” or 5xx
The HTTP-01 challenge couldn't reach the origin. Make sure your Cloudflare SSL/TLS mode is at least “Flexible” (so HTTP on port 80 reaches the origin) and that no firewall in front of the VPS blocks port 80. The challenge path /.well-known/acme-challenge/* must NOT be forced to HTTPS by a CF page rule — disable “Always Use HTTPS” for that path or temporarily turn it off zone-wide.
❓ nginx returns 404 for /ssh-ws even with the right Upgrade header
The vhost is configured to require both Upgrade: websocket and Connection: Upgrade headers. If you're testing with curl, pass --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: <16 random bytes b64>" -H "Sec-WebSocket-Version: 13".
❓ TLS handshake completes but the WebSocket upgrade hangs
Check that ssh-ws-bridge is running: systemctl status ssh-ws-bridge. websocat may have failed to bind 127.0.0.1:8088 (port already taken) or sshd isn't reachable on 127.0.0.1:22. journalctl -u ssh-ws-bridge -n 50 will tell you which.
❓ Phone connects but apps fail with random hostname errors
sshd needs PasswordAuthentication enabled for the vpnssh user (the Match block does this). If your /etc/ssh/sshd_config has PasswordAuthentication no globally and you removed the Match block, JSch can't authenticate. Re-run the installer to restore it.
❓ Cloudflare returns 526 / 525
525 means CF couldn't complete the TLS handshake to your origin. 526 means CF doesn't trust the origin cert. Set CF SSL/TLS mode to “Full” (NOT “Full strict”) when you're using LE — strict requires CF Origin CA. Or upgrade to “Full strict” after switching to a CF Origin CA cert.