Skip to content

Repository files navigation

mirall-relay

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.


What "blind" means here

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.


Quick start (Docker)

# 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.

Running it locally (Docker Desktop) — smoke test only

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/readyz

On 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 address in the startup log shows a random high port rather than your configured one. HyperDHT reports its ephemeral client socket in that state. The port field in the same log line is the one to open in your firewall.

Quick start (Node, no Docker)

npm ci --omit=dev
npm run keygen                  # prints the public key + seed
MIRALL_RELAY_SEED_FILE=./.keys/seed npm start

A deploy/mirall-relay.service systemd unit and a documented deploy/mirall-relay.env.example are included.


Requirements

  • 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 image

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 --help

Configuration

Every 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.

Access control

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.

MAX_SESSIONS_PER_KEY counts devices, not users

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.

Caps are per direction

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.


Operator endpoints

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.

Verifying a deployment

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.


Using it from Mirall

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.


Development

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 image

The 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.


Security

  • 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.

Licence

AGPL-3.0-or-later. See LICENSE.

About

A self-hostable blind relay for Mirall — connects peers that cannot reach each other directly, without being able to read their traffic

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages