Run your own relay
The relay program easysync-relay comes as a container image, so that anyone can run a relay of their own for their devices. This page is the operator's sheet for that image: its name and tags, what the container expects, how to run it with TLS in the relay or behind a reverse proxy, and how your devices use it. Relay explains what a relay does and the keys of its configuration; Hardening has the recommendations for exposed machines.
A relay you run sees what every relay sees: the IP addresses that connect to it, when, for how long and how much data. Read what the relay sees before you offer yours to other people.
The image #
The relay image is published on Docker Hub as monozeros/easysync-relay, a public repository: you pull it without an account.
- Platforms. One image index for
linux/amd64andlinux/arm64. Docker picks the platform of the host. - Contents. The program at
/usr/local/bin/easysync-relayon a minimal base image with no shell and no package manager. The entry point is the program itself; without arguments it runs the relay with the configuration file /etc/easysync-relay/relay.toml. - Labels. The image of each platform carries the version, the commit it was built from and, in
me.easysync.binary.sha256, the SHA-256 of the program of both platforms, asamd64=<sum> arm64=<sum>. The program is the one of the Linux archives of the command line, built by the same pipeline.
Tags and pinning #
| Tag | Names | Use it for |
|---|---|---|
X.Y.Z, for example 1.4.2 | one version; the tag has no v | pinning: this is the tag to write down |
X.Y | the newest X.Y.Z of that line; it moves | trying things out |
latest | the newest version; it moves | trying things out |
For a relay that other people rely on, pin the digest as well as the version. The digest decides which bytes run; the tag is only for the reader:
docker buildx imagetools inspect monozeros/easysync-relay:X.Y.Z
The line Digest: of the output holds the digest of the image index, for both platforms, as sha256: followed by hex digits. Write the image as monozeros/easysync-relay:X.Y.Z@<digest>, where <digest> is that whole value, in your docker run command or Compose file, and change both parts together when you update. Only versions are on Docker Hub; there are no development tags there.
What the container expects #
| What | Value |
|---|---|
| User | 65532:65532, not root. Every file the relay reads must be readable by this user. |
| Rights | It works with a read-only root file system, no capabilities and no new privileges: run it with docker run --read-only --cap-drop ALL --security-opt no-new-privileges, or the same settings in Compose. |
| Port | 8443 over TCP inside the container; the default listen is 0.0.0.0:8443. It is not a privileged port. Map it on the host, for example -p 443:8443, or do not publish it at all when a reverse proxy on the same container network is in front. |
| Configuration | relay.toml at /etc/easysync-relay/relay.toml, mounted read-only. Keep it at this path: the health check of the image reads the same file. A relay that issues access keys can have an operator switch control.toml (the key control_file of the [issue] table), also mounted read-only. |
| Secrets | Access token files and the TLS key are files, mounted read-only, owned by 65532 and readable by nobody else (mode 0400). Never put a secret into an environment variable or an argument; the relay reads none from there. |
| Volume | /var/lib/easysync-relay, owned by 65532. Only a relay that issues access keys writes there (one state file of hashed keys). A new named volume takes over the owner from the image; a directory of the host that you mount there must be owned by 65532. |
| Health check | Built into the image: easysync-relay healthcheck asks the health listener of the relay (127.0.0.1:8081 by default, inside the container only) and exits with 0 only if it answers. The listener must be a loopback address and serves nothing else. |
| Memory | Set a limit (docker run --memory, mem_limit in Compose) that fits the transfers you expect: it is the budget for the buffers of all running transfers. |
| Instances | One. Pairings and connections live in a single process; do not run replicas behind a load balancer. |
Make the access token #
The access token is one line of unpadded base64url text; the decoded token is at least 16 and at most 64 bytes long. Make it from 32 random bytes and give the file to the relay user:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n' > token
sudo chown 65532:65532 token && sudo chmod 0400 token
The same token goes into each device that uses the relay (below). The relay reads token files, certificates and keys when it starts; to change one, restart the container. To rotate a token, add a second token file, restart, move the devices to the new token and then remove the old file.
Run it with TLS in the relay #
With a certificate and its key, the relay terminates TLS itself and the host publishes its port. Put relay.toml, token, cert.pem and key.pem in one directory, here /srv/easysync-relay:
listen = "0.0.0.0:8443"
tls_cert_file = "/etc/easysync-relay/cert.pem"
tls_key_file = "/etc/easysync-relay/key.pem"
access_token_files = ["/etc/easysync-relay/token"]
docker run -d --name easysync-relay --restart unless-stopped \
--read-only --cap-drop ALL --security-opt no-new-privileges --memory 2g \
-p 443:8443 \
-v /srv/easysync-relay:/etc/easysync-relay:ro \
monozeros/easysync-relay:X.Y.Z@<digest>
The certificate must be one that your devices trust. For a private certificate authority, the command line and the daemon take --relay-ca.
Run it behind a reverse proxy #
A reverse proxy that terminates TLS, for example one that already serves other sites on the host, sits in front of the relay on a container network. The relay then serves plain WebSocket on that network and publishes no port. Set allow_plain_ws = true only in this case: between the proxy and the relay the access token travels in clear text, so that network must hold nothing but the two.
relay.toml:
listen = "0.0.0.0:8443"
allow_plain_ws = true
access_token_files = ["/run/secrets/relay_token"]
# Behind a proxy every client has the address of the proxy: raise the limits per address.
# Sample values: size them for the number of devices you expect.
[limits]
max_connections_per_ip = 20000
pair_register_per_min = 600
pair_join_per_min = 2400
dial_per_min = 7200
A Compose file with the relay on an internal network. The proxy joins the network internal, besides a network that reaches the outside, and forwards to relay:8443:
services:
relay:
image: monozeros/easysync-relay:X.Y.Z@<digest>
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
mem_limit: 2g
expose: ["8443"]
volumes:
- ./relay/relay.toml:/etc/easysync-relay/relay.toml:ro
- relay_state:/var/lib/easysync-relay
secrets:
- relay_token
networks: [internal]
secrets:
relay_token:
file: ./secrets/relay-token # owned by 65532, mode 0400
volumes:
relay_state:
networks:
internal:
internal: true
What the proxy must do:
- Forward the relay path only. Forward
/relay/v1and everything below it to the relay, as a WebSocket upgrade, without a time limit on the connection. Everything else can answer 404. The health listener stays inside the container and is never forwarded. - Log no secrets. The path of a WebSocket upgrade and its headers carry tokens. Configure the proxy not to log request paths and headers for the relay.
- No forwarding headers for the WebSocket routes. The relay does not read
X-Forwarded-Forthere, so the proxy can leave it out (the sample below removes it). All limits per address (connections, pairing registrations, pairing joins and dials) then apply to the proxy's address, which is why the example raises all four;listen_per_mincounts per control connection and is not affected. - Access keys. A relay that issues access keys (the
[issue]table) answers the requests for keys on/relay/v1/tokenand the paths below it, which are part of the relay path. On these routes the proxy setsX-Forwarded-Forto the address of the client itself, never a value taken from the request, and the relay trusts that header only from the addresses intrusted_proxiesof the[issue]table. Withallow_plain_wsthe relay refuses to start when issuing is on and that list is empty. Give the network a fixed subnet and list it there.
The server configuration archive of this site, easysync-site-<version>.server-config.tar.gz, holds a sample Caddyfile with such a site block for the relay (the host relay.easysync.me behind Caddy), next to the samples for nginx. It is a sample, not a hardened product: read it and adjust the host names before you use it.
The operator switch #
A relay that issues access keys can name a control file with control_file in the [issue] table. With it you can switch issuing off (issuance = false), end every issued key at once (revoke_serial) or block single keys by their hash (blocked), without a restart. The relay reads the file at start, where a missing or broken file is a start error, and again while it runs when the modification time of the file changes. The file can only tighten what relay.toml allows.
Mount it read-only as a single file, for example ./relay/control.toml:/etc/easysync-relay/control.toml:ro. Edit it in place: an editor that replaces the file gives it a new inode, which a single-file bind mount does not follow; restart the container after such an edit.
Point your devices at it #
The address of your relay is wss:// and its host name, with the port if it is not 443, for example wss://relay.example.org or wss://relay.example.org:8443. Without a path, the devices connect to /relay/v1 on that host.
The apps. In the form for adding a relay, choose "Another relay" instead of the preselected official relay, and enter the address and the access token.
The command line and the daemon. The command asks for the access token on the terminal:
easysync relay add wss://relay.example.orgAdd
--sharedto offer the relay to the devices you pair with, so that they learn about it during the pairing. If your relay issues access keys,easysync relay provision wss://relay.example.orgasks it for a key instead; nothing is typed.
The web app reaches a relay of your own with an access token you enter. It asks only the official relay for an access key, because its content security policy allows that one HTTPS origin.