Skip to content

Remote servers

Mert Cobanov edited this page Jul 29, 2026 · 1 revision

Remote servers

On your own network the handset dials 192.168.x.x:22 and there is nothing to set up. For anything else you need a bridge — the handset cannot run a VPN, and many networks have no raw TCP entrance.

The bridge speaks HTTP on one side and TCP on the other. Put it on your VPN and it reaches everything there. The phone never joins; the bridge does.

handset ──ws://──▶ your proxy ──LAN──▶ wsbridge ──tcp:22──▶ servers
                                       (VPN member)

1. Install it on an internal host

Not the machine facing the internet — if that box is also a VPN member, compromising it hands over a VPN identity.

mkdir -p /opt/wsbridge
curl -fsSL -o /opt/wsbridge/wsbridge.py \
  https://raw.githubusercontent.com/cobanov/berryssh/main/tools/wsbridge.py

2. Give it a restricted VPN identity

This is the layer that bounds the damage, and the one people skip.

Tailscale — tag it, and grant that tag only port 22 to chosen machines:

{
  "tagOwners": { "tag:ssh-bridge":      ["autogroup:admin"],
                 "tag:phone-reachable": ["autogroup:admin"] },
  "grants": [
    { "src": ["tag:ssh-bridge"], "dst": ["tag:phone-reachable"], "ip": ["tcp:22"] }
  ]
}
tailscale up --auth-key=tskey-... --advertise-tags=tag:ssh-bridge

WireGuard or OpenVPN: outbound firewall rules on the bridge host instead. No VPN: skip, and rely on the remaining layers.

3. Configure

head -c 24 /dev/urandom | base64        # the key; 16 chars minimum

/opt/wsbridge/config.json, then chmod 600:

{
  "listen": 8022,
  "bind": "127.0.0.1",
  "psk": "the string above",
  "targets": {
    "web":  ["100.64.0.3", 22],
    "home": ["192.168.1.50", 22]
  }
}

Names are what the handset shows; addresses are never sent to it. Nothing outside targets is ever dialled.

4. Run it

# /etc/systemd/system/wsbridge.service
[Unit]
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/wsbridge/wsbridge.py /opt/wsbridge/config.json
Restart=always
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now wsbridge

5. Put HTTP in front of it

Caddy needs nothing extra:

ssh.example.com:80 {
	reverse_proxy 192.168.1.10:8022
}

nginx needs the upgrade headers, and the timeout or an idle session dies every minute:

location / {
    proxy_pass http://192.168.1.10:8022;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400s;
}

The hostname must not redirect to HTTPS.

6. On the handset

Host ssh.example.com, Port 80, WebSocket path /, Bridge key the string from step 3. Then open Bridge target — it asks the bridge and lists the names. No address is ever typed into the phone.


Is this safe?

You are putting a path to sshd on the internet. Three layers, each holding if the others fail: the VPN policy bounds what the bridge can reach at all; the bridge key means nothing is disclosed, not even the list of machines, before it is proved; SSH decides who logs in and whether the server is real.

The outer layer is plaintext — the device has no TLS 1.2 stack and its trust store predates every current CA. SSH already provides confidentiality, integrity and both identities, so the bridge carries ciphertext it cannot read and a tampered bridge is refused by the handset. What leaks is metadata: that you connected, and which name you asked for.

(This is also why Tailscale Funnel cannot be used — TLS only, and Let's Encrypt roots the handset does not have.)

Clone this wiki locally