A blind relay for Mirall. It connects two peers that cannot reach each other directly — office Wi-Fi, mobile hotspots, symmetric NAT, UDP-filtered networks — without ever being able to read what they send.
Anyone can run one. Start it, publish the public key it prints, and paste that key into Mirall under Settings → Network.
The relay bridges two encrypted streams. The peers run their own Noise handshake over the relayed connection, so the relay holds no session key and there is no API through which it could produce plaintext.
The relay cannot see:
- who the peers are (their Mirall identities)
- which spaces they share
- file names, folder structure, or file contents
The relay can see, unavoidably, what any middlebox sees:
- the IP addresses of both peers, and that they are talking to each other
- when they talk, and how many bytes cross
If that metadata matters to you, run your own relay rather than using someone else's. That is the point of this repository being public.
# 1. Generate an identity. The PUBLIC KEY is what users paste into Mirall.
# The SEED is secret: back it up, and never let it change.
docker run --rm ghcr.io/ok/mirall-relay:latest bin/mirall-relay.js keygen
# 2. Run it. The named volume is what keeps the identity stable.
docker compose up -d
# 3. Verify it is actually reachable.
curl -s localhost:9200/readyz
# {"ready": true, "firewalled": false, "publicKey": "…"}"firewalled": true means clients cannot reach you — open UDP 49737 and try
again. A firewalled relay starts, logs an error, returns 503 on /readyz, and
serves nothing.
docker build -t mirall-relay:local .
docker volume create mirall-relay-data
docker run -d --name mirall-relay \
-p 49737:49737/udp \
-p 127.0.0.1:9200:9200/tcp \
-v mirall-relay-data:/data \
mirall-relay:local
curl -s localhost:9200/readyzOn a laptop this will report "firewalled": true and /readyz will return
503, and that is correct: your machine is behind NAT, and on macOS/Windows
Docker Desktop adds a Linux VM in between. The container is healthy and every
endpoint works, but it is not a usable relay — no peer can hole-punch to it.
Use this to check the image, the endpoints and that the seed persists; use a host
with a public IP for anything real.
network_mode: host in docker-compose.yml is a Linux setting. On Docker
Desktop it does not attach to your machine's network, so comment it out and use
the ports: block instead.
Not a bug: while firewalled, the
addressin the startup log shows a random high port rather than your configured one. HyperDHT reports its ephemeral client socket in that state. Theportfield in the same log line is the one to open in your firewall.
npm ci --omit=dev
npm run keygen # prints the public key + seed
MIRALL_RELAY_SEED_FILE=./.keys/seed npm startA deploy/mirall-relay.service systemd unit and a documented
deploy/mirall-relay.env.example are included.
- Node.js 22+
- A public IP with unfiltered inbound/outbound UDP. HyperDHT hole-punches to the relay itself; behind a NAT that it cannot traverse, the relay is useless.
- Bandwidth. Every relayed byte enters and leaves this host. Bandwidth, not CPU, is the cost of running a relay — pick a host that bills egress kindly.
The runtime stage is distroless — glibc, so the Holepunch native addons load
(Alpine/musl would mean building libsodium and libudx from source), but with no
shell, no package manager and no userland. It runs as uid 65532, and the
prebuilt addons for the twelve architectures you are not building for are pruned
away, which is most of the difference between a ~280 MB image and a ~165 MB one.
The trade-off is deliberate: docker exec <container> sh does not exist. Debug
from outside with docker logs, the /metrics endpoint, and scripts/probe.js.
To run the CLI, override the command — the entrypoint is already node:
docker run --rm mirall-relay:local bin/mirall-relay.js keygen
docker run --rm mirall-relay:local bin/mirall-relay.js --helpEvery option is available as an environment variable (MIRALL_RELAY_*) and as a
CLI flag. Run mirall-relay --help for the full list; the essentials:
| Env | Default | Notes |
|---|---|---|
MIRALL_RELAY_SEED_FILE |
./.keys/seed |
Identity. Generated on first run, then never change it. |
MIRALL_RELAY_SEED |
— | 64-hex seed; overrides everything. Prefer a secret file. |
MIRALL_RELAY_SEED_SECRET_FILE |
/run/secrets/relay_seed |
Mounted secret holding the seed. Read in-process; wins over SEED_FILE. |
MIRALL_RELAY_PORT |
49737 |
UDP port. Pin it so firewall rules stay stable. |
MIRALL_RELAY_ASSUME_REACHABLE |
false |
Skip reachability probing. Only set it when you know the host is public. |
MIRALL_RELAY_ADMIN_HOST |
127.0.0.1 |
Admin/metrics bind. Never expose publicly. |
MIRALL_RELAY_ALLOWLIST |
— | Private relay: only these keys may connect. Everyone else is refused. |
MIRALL_RELAY_BANLIST |
— | Public relay: these keys are refused, everyone else is allowed. |
MIRALL_RELAY_MAX_SESSIONS_PER_KEY |
64 |
Sessions per peer device. See the note below. |
MIRALL_RELAY_MAX_ACTIVE_LINKS |
2000 |
Global bridged-stream ceiling (~1000 relayed connections). |
MIRALL_RELAY_MAX_LINK_RATE |
4MiB |
Per link, per direction. |
MIRALL_RELAY_MAX_LINK_BYTES |
512MB |
Per link, per direction. |
MIRALL_RELAY_REGION / _OPERATOR |
unknown |
Labels shown in metrics and the capability doc. |
ALLOWLIST and BANLIST are both comma- or space-separated lists of z-base-32 or
hex public keys, validated at startup — a typo refuses to boot rather than
silently locking everyone out later. Both are enforced during the Noise handshake,
so a refused peer never reaches a session, a pairing, or a byte of bridged
traffic. Refusals are counted in relay_sessions_rejected_total{reason=…}.
Setting MIRALL_RELAY_ALLOWLIST= (empty) means unset, not "allow nobody".
Two things to know before relying on either:
- Both peers of a relayed connection must be allowlisted. Each end dials the relay independently, so listing only one of them locks the pair out entirely.
- The key matched is the peer's DHT node key, not its app identity. For a Mirall client that key is currently generated fresh on every app start, so you cannot allowlist end users by name — only peers whose DHT identity you pin yourself (your own infrastructure, a fixed enterprise deployment).
For ad-hoc abuse handling on an open relay, prefer BANLIST; the meter also bans
a key automatically after repeated byte/rate cap violations.
Every relayed connection a device makes opens one session with the relay, and the
key it presents is the device's DHT node key — shared across all of that
device's relayed connections, on both of Mirall's planes. So relaying to N peers
costs 2 × N sessions against a single key.
The default of 64 therefore allows a device to relay to roughly 32 peers,
while capping any one key at about 3% of MAX_ACTIVE_LINKS. Lower it only if you
are deliberately rationing a constrained relay.
A relayed connection is two bridged streams, and each one only accounts the bytes
entering it. So MAX_LINK_BYTES=512MB allows up to 512 MB each way. This falls
out of how UDX's native forwarding works (see src/meter.js) and is documented
rather than silently halved.
Bound to 127.0.0.1:9200 by default.
| Path | Purpose |
|---|---|
/healthz |
Process is up. |
/readyz |
Listening, bootstrapped, and not firewalled. 503 otherwise. |
/metrics |
Prometheus. See deploy/prometheus-scrape.example.yml for the alerts worth having. |
/.well-known/mirall-relay.json |
Public key, region, operator, caps. |
node scripts/probe.js --relay <your-public-key>
# {"ok": true, "bridged": true, "bytes": 262144, "throughputMbps": 149.8}Two throwaway nodes pair through the relay exactly the way a real peer pair does,
push a payload across, and measure it. Exit 0 means the relay bridged real
traffic; exit 1 means it did not.
The probe pairs through the relay directly rather than using relayThrough,
because both of its peers run on one host: they would hole-punch to each other and
hyperdht would abandon the relayed path, producing a green result that proves
nothing about the relay.
Mirall reaches a relay purely by its public key — there is no host, port, token or account. Paste the key into Settings → Network → Add a relay, and use Test to confirm reachability.
Relaying only engages when a direct connection cannot be made, so configuring a relay costs nothing on networks that work.
Configure it on both peers when you can. One side is enough for a connection to succeed, but the recovery path is asymmetric: if the peer without a relay is the one behind the restrictive network, the connection only recovers after the other side's direct attempt times out.
npm ci
npm run lint
npm test # unit + integration (integration spins up a local DHT testnet)
npm run test:smoke # builds and runs the Docker imageThe integration suite runs a real relay against a real (local) HyperDHT and proves the load-bearing behaviours: relayed connections work, byte counters advance on natively-bridged streams, every cap tears real bridges down, and admission control holds.
- The seed is the only secret and the only durable state. Losing it strands every client configured with the derived public key. Back it up offline.
- The admin port exposes internals; keep it on loopback or a private network.
See SECURITY.md for what to report, what is already known and documented, and how to reach us privately.
AGPL-3.0-or-later. See LICENSE.