# rafb-relay A small WebSocket-to-TCP relay that lets browser-based SSH, SFTP and FTP clients (like [rafb.me/ssh](https://rafb.me/ssh)) reach servers. Browsers can't open TCP connections, so the page sends its bytes over a WebSocket to this relay, which connects to the server and copies bytes both ways. That's all it does. - **One file, standard library only.** `rafb_relay.py` is a few hundred lines of Python 3.9+ with no dependencies. Read it before you run it. - **It can't read your SSH sessions.** SSH and SFTP are encrypted between the browser and the server. The relay only sees ciphertext. The browser checks the server's host key, so a relay can't impersonate your server either. - **Plain FTP is not encrypted.** Whoever runs the relay could read FTP passwords and files, so use FTP only through a relay you run yourself. ## Quick start ```sh python3 rafb_relay.py --make-token # a random token cp config.example.json config.json # paste the token into auth.tokens python3 rafb_relay.py --config config.json # listens on 127.0.0.1:8022 ``` Put it behind a TLS reverse proxy, because pages served over HTTPS can only open `wss://` WebSockets. With Caddy: ``` relay.example.com { reverse_proxy 127.0.0.1:8022 } ``` Then on rafb.me/ssh choose **My own relay**, enter `wss://relay.example.com` and your token, and press **Test**. A systemd unit (`rafb-relay.service`) and a `Dockerfile` are included. ## Configuration Settings not in your file keep their defaults (shown in `config.example.json`). | Setting | Meaning | |---|---| | `listen` | `host:port` to listen on. Keep it on `127.0.0.1` behind a proxy. | | `allowed_origins` | Websites allowed to use the relay, e.g. `["https://rafb.me"]`. Checked before anything else. | | `auth.mode` | `token`: callers must send one of `auth.tokens`. `ticket`: short-lived tickets signed with `auth.ticket_secret` by a site you control (this is how rafb.me's own relay admits only subscribers). `none`: anyone who can reach it. Only use `none` on a private network. | | `targets.ports` | Destination ports allowed. Default `[22]`. | | `targets.allow_private` | Allow private, loopback and link-local addresses, such as your LAN. Default `false`. A relay on your home network can turn this on to reach machines behind it. | | `targets.allow_hosts` | If set, only these hosts can be reached: hostnames (wildcards like `*.example.com`) or CIDR ranges. | | `targets.deny_hosts` | Hosts or ranges that are always refused. | | `ftp.enabled` | Allow FTP. Each transfer opens a second connection to a port the server picks, so a user with a live connection to an FTP control port (`ftp.control_ports`) may also connect to ports `>= ftp.data_port_min` on that same address. | | `limits.*` | Connections per user and in total, connect timeout, idle timeout, maximum session length, maximum WebSocket message size. | | `trust_proxy` | Log the client address from `X-Forwarded-For`. Only enable it behind a proxy. | Secrets can come from the environment instead of the file: `RELAY_TICKET_SECRET`, or `RELAY_TOKENS` (comma-separated). Destination checks resolve the hostname once, check every address, and connect to the address that was checked. That way a DNS change between the check and the connection can't redirect it somewhere forbidden. ## Protocol (version 1) - `GET /` returns JSON describing the relay: name, version, protocol, auth mode, allowed ports, FTP and private address support. It allows any origin, so pages can show what a relay permits. - `GET /healthz` returns `ok`. - **WebSocket** `GET /v1/connect?host=HOST&port=PORT&auth=TOKEN_OR_TICKET` - The `Origin` header must be in `allowed_origins`; otherwise the relay answers `403` and doesn't upgrade. - On success the relay sends one text message, `{"type":"connected","address":"203.0.113.5","port":22}`. After that, **binary messages are raw TCP bytes** in both directions. - On failure it sends `{"type":"error","code":...,"message":...}` and closes with: - `4400` bad request - `4401` unauthorized - `4403` forbidden (by policy) - `4429` too many connections - `4502` couldn't connect - When the server closes the TCP connection, the relay closes the WebSocket with `1000`. If a limit is hit, it closes with `4408` and the reason. When the browser closes the WebSocket, the relay closes the TCP connection. - **Tickets** (`auth.mode = "ticket"`): - The format is `"v1." + base64url(payload) + "." + base64url(HMAC-SHA256(secret, "v1." + base64url(payload)))`, with `payload = {"sub": "", "exp": }`. - `sub` is used for per-user limits and logs. - A ticket can open any number of connections until `exp`. ## Logs One line per connection opened, closed or refused: the user (token hash, ticket `sub` or client IP), client address, origin, target, resolved address, bytes each way and duration. The relay never logs what was sent. ## Tests ```sh python3 -m unittest -v test_rafb_relay ``` ## License MIT