Relay

Host your own relay.

When your phone and your Mac are on different networks, both dial out to a relay and it joins them up. The Mac needs no open port, no VPN and no port forwarding. We run one at grenade-relay-7a47b5a07a7d.herokuapp.com; you can run your own for yourself, your team or your company.

What a relay can and cannot see

Everything between phone and Mac is end-to-end encrypted (X25519 + ChaCha20-Poly1305). The phone pins the Mac's key when it pairs, from the QR code on the Mac's screen, so a relay can neither read the traffic nor pose as your Mac. It forwards opaque bytes.

It knows

  • each Mac's name and Grenade version
  • whether it is online, since when, and when it was last seen
  • the public IP its link came from, and the local IPs it reports
  • the SHA-256 of each paired phone's access key
  • for each push: the phone's device token, the address it came from, and the time

It never sees

  • terminal contents or keystrokes
  • pairing tokens (phones send a derived access key instead)
  • any Mac secret: it stores only hashes
  • what a push says: the session, the question and the Mac's name are sealed for the phone

Quick start: Docker with automatic TLS

You need a server with Docker, a static public IP (a DNS name is optional) and ports 80 and 443 open. Caddy sits in front and gets the certificate from Let's Encrypt, for an IP as well as a name.

server
git clone <grenade-relay repo> grenade-relay && cd grenade-relay
cp .env.example .env    # set RELAY_HOST, optionally the keys
docker compose up -d
curl https://203.0.113.7/health
.env
# Public IP of this server, or a DNS name pointing at it
RELAY_HOST=203.0.113.7
# Optional: only Macs with this key may register (private relay)
GRENADE_RELAY_REGISTRATION_KEY=
# Optional: turns on the dashboard and GET /v1/daemons
GRENADE_RELAY_ADMIN_KEY=
GRENADE_LOG=info
  • IP certificates last about 6 days and Caddy renews them itself, so keep port 80 and 443 open.
  • Use a static IP. Each Mac stores the relay URL, so a new address means running grenade relay on <new url> on every Mac again.
  • Records live in the relay-data volume. Back it up if you want Macs to keep their ids across rebuilds (they re-register on their own either way).

Other hosts

Without Docker

Node 22 or newer, behind anything that terminates TLS and passes WebSocket upgrades (Caddy, nginx, a load balancer). Set GRENADE_RELAY_TRUST_PROXY=1 if that proxy sets X-Forwarded-For.

grenade-relay
npm ci && npm run build
GRENADE_RELAY_ADMIN_KEY=$(openssl rand -hex 24) PORT=8787 npm start

Heroku

This is how the main relay runs. Heroku terminates TLS, so Caddy is not used. Keep it at one dyno: all live state is in one process. The disk is ephemeral, so records are lost on each restart; Macs re-register within seconds.

grenade-relay
heroku create my-grenade-relay
heroku config:set GRENADE_RELAY_TRUST_PROXY=1 GRENADE_RELAY_ADMIN_KEY=$(openssl rand -hex 24)
git push heroku main

Settings

All configuration is environment variables.

VariableDefaultWhat it does
PORT8787Port to listen on.
HOST0.0.0.0Address to bind.
GRENADE_RELAY_DATA./dataFolder for daemons.json, the records of Macs (mode 0600). /data in the Docker image.
GRENADE_RELAY_REGISTRATION_KEYunsetWhen set, a Mac must present this key to register. Use it for team and company relays. Unset means anyone's Mac may use the relay.
GRENADE_RELAY_ADMIN_KEYunsetTurns on the dashboard at / and GET /v1/daemons. Unset means both answer 404.
GRENADE_RELAY_TRUST_PROXYunset1 takes client IPs from X-Forwarded-For. Only set it behind a proxy you run (the compose file and Heroku do).
GRENADE_RELAY_PUSH_UPSTREAMthe main relayThe relay that pushes are passed on to. off turns push off on this relay. Ignored when an APNs key is set.
GRENADE_RELAY_PUSH_UPSTREAM_KEYunsetThe upstream relay's registration key, when it requires one.
GRENADE_RELAY_APNS_KEYunsetThe text of an APNs key (.p8), for a relay that sends pushes itself. Newlines may be written as \n.
GRENADE_RELAY_APNS_KEY_FILEunsetThe same key as a file path, in place of GRENADE_RELAY_APNS_KEY.
GRENADE_RELAY_APNS_KEY_IDunsetThe key's id (10 characters). Required with a key.
GRENADE_RELAY_APNS_TEAM_IDunsetThe Apple Developer team the key belongs to. Required with a key.
GRENADE_RELAY_APNS_TOPICScom.adamchew.grenadeBundle ids the key sends for, separated by commas.
GRENADE_LOGinfodebug also logs every phone connecting and leaving, and every push sent.

An open relay (no registration key) lets any Mac register; phones still need a valid access key to see or reach a Mac. A private relay refuses Macs without the key.

Connect your Macs

On each Mac with the Grenade daemon installed. With no URL, grenade relay on uses the main relay.

Mac
grenade relay on https://203.0.113.7                # open relay, by IP
grenade relay on https://relay.example.com          # open relay, by name
grenade relay on https://relay.example.com --key <key>   # private relay
grenade relay status
grenade relay off

Phones that already paired with that Mac learn the relay the next time they connect. A new phone pairs through the relay too: grenade pair shows a QR code that carries the relay's address, the Mac's key and a one-time secret. For the two minutes that code lasts, the Mac adds one more access hash to its list, so the relay admits the phone; the secret and the token travel end-to-end encrypted like everything else.

Dashboard

With GRENADE_RELAY_ADMIN_KEY set, open https://<RELAY_HOST>/ and sign in with any user name and the admin key. It lists every Mac: online or last seen, public IP, local IPs, version.

Push notifications

A phone is told that an agent needs it, or has finished, even while the app is closed. The Mac seals each notification for the phone and posts it to a relay, which hands it to Apple's push service. That service needs a key tied to the app, and a Mac cannot hold it, so a relay does. The relay keeps nothing about a push, and never writes a device token to its log.

Only the main relay holds the key for the Grenade app from the App Store. So by default your relay passes each push on, unchanged and still sealed, to the main relay, which sends it. There is nothing to set up. The main relay then sees the device token and the time, with your relay's address as the sender.

Turn it off

Your relay then answers 503 to pushes, and phones notify only while the app is running.

.env
GRENADE_RELAY_PUSH_UPSTREAM=off

Send pushes yourself

If you ship your own build of the app, create an APNs key for your Apple Developer team and give it to your relay. A relay with a key passes nothing on.

.env
# Only for your own build of the app
GRENADE_RELAY_APNS_KEY_FILE=/run/secrets/apns.p8
GRENADE_RELAY_APNS_KEY_ID=ABC123DEFG
GRENADE_RELAY_APNS_TEAM_ID=TEAM123456
GRENADE_RELAY_APNS_TOPICS=com.example.yourapp
API reference

Relay API, version 1

You only need this to build your own client or monitoring; the daemon and the app already speak it. HTTP bodies are JSON. WebSocket messages are JSON text frames; binary frames are ignored. Max message size is 4 MB.

Endpoints

RequestAuthReply
GET /healthnone200 with the relay's version. Use it for load balancer and uptime checks.
GET /v1/presence/<relay id>Bearer <access>200 presence; 401 wrong or missing access; 404 unknown id. Answers while the Mac is off.
GET /v1/daemonsBearer <admin key>200 every Mac on the relay; 401 wrong key; 404 when no admin key is set.
GET /Basic, password = admin keyHTML dashboard of the same list, refreshes every 15 s; 404 when no admin key is set.
POST /v1/pushBearer <registration key>, when requiredSends one sealed push to a phone. 200, or one of the errors under Push route.
WS /v1/connect/<relay id>Bearer <access>A pipe to the Mac. Upgrade refused with 401, 404, 429 (8 pipes already open) or 503 (Mac offline).
WS /v1/daemonBearer <registration key>, when requiredThe Mac's link. First frame must be register within 5 s.

Other methods get 405 (the push route is the one POST), unknown paths 404. Errors have the shape { "error": "unauthorized" }.

try it
curl https://relay.example.com/health
curl -H "Authorization: Bearer $ADMIN_KEY" https://relay.example.com/v1/daemons
curl -H "Authorization: Bearer $ACCESS" https://relay.example.com/v1/presence/r_0123456789abcdef0123456789abcdef

Presence

Returned by /v1/presence/<id>, and as the items of { "daemons": [...] } from /v1/daemons. since is present only while online. lastSeen is now while online, else when the link dropped. Records unseen for 90 days are forgotten.

presence
{ "id": "r_…", "name": "MacBook Pro", "version": "0.1.0",
  "online": true, "since": "2026-09-27T12:00:00.000Z", "lastSeen": "2026-09-27T12:14:22.000Z",
  "publicIp": "203.0.113.7", "localIps": ["192.168.1.20"] }

Access keys

A phone never shows its pairing token to the relay. It derives an access key, the Mac uploads the key's hash, and the relay admits a phone whose key hashes to one on the list.

access key
access = hex(HMAC-SHA256(key: utf8(pairing token), message: "grenade relay access v1"))
stored = hex(SHA-256(utf8(access)))     # what the Mac uploads in register/update
Authorization: Bearer <access>          # what the phone sends

Mac link: WS /v1/daemon

One socket per Mac. The first link to register an id owns it, by secret; the relay keeps only the secret's SHA-256. A later link with the right secret replaces the old one. The relay pings every 15 s and drops a link after 30 s without a pong, which is how a sleeping Mac goes offline.

Mac → relay

typeFieldsNotes
registerprotocol: 1, id, secret, name, version, localIps, access[]First frame. id is r_ + 32 hex; secret is 64 hex; access holds SHA-256 hex of each paired phone's access key (max 1000).
updatename?, localIps?, access?After pairing a phone, a rename or a network change. Each field present replaces the old value.
dataconn, textOne frame for that phone. text is opaque ciphertext.
closeconn, code?, reason?The Mac closed that phone's pipe; the relay closes the phone's socket with the same code.
register
{ "type": "register", "protocol": 1,
  "id": "r_0123456789abcdef0123456789abcdef",
  "secret": "<64 hex>", "name": "MacBook Pro", "version": "0.1.0",
  "localIps": ["192.168.1.20"], "access": ["<sha256 hex of an access key>"] }

Relay → Mac

typeFieldsNotes
registeredpublicIp?Reply to register. publicIp is the address the link came from.
errorcode, messagecode is unauthorized, id_taken or bad_frame. Sent just before the relay closes the link with 4400.
openconn, ip?A phone connected. conn is unique on this link (c1, c2, …).
dataconn, textOne frame from that phone, forwarded as is.
closeconnThat phone went away.

Phone pipe: WS /v1/connect/<relay id>

Checked before the upgrade, so a refused phone gets a plain HTTP status. Once open, the relay sends the Mac open, then wraps every phone text frame in data for the Mac and unwraps the Mac's data back to the phone. At most 8 pipes per Mac. The first frame each way is an unencrypted key exchange, { "e2e": 1, "e": "<X25519 public key>" }; everything after it is sealed and opaque to the relay.

Push route: POST /v1/push

One request sends one push. The relay reads the address (deviceToken, environment, topic) and collapse, which lets a newer push for the same session replace the older one on the phone and means nothing to the relay. e and c are the sealed content; only the phone can open it. The Mac sends the device token with every push, so the relay stores none.

push
POST /v1/push
{ "provider": "apns", "deviceToken": "9f3c…", "environment": "production",
  "topic": "com.adamchew.grenade", "collapse": "d6d79a39caa90eec489cbc7e44d632d0",
  "e": "<X25519 public key, base64>", "c": "<sealed content, base64>" }

200 { "ok": true }
StatuserrorMeaning
400bad_requestThe body is not a push request.
401unauthorizedThe relay has a registration key and the request did not bring it.
403topic_not_servedThe relay's key does not send for this app.
410unregisteredThe device token is dead (the app was removed). The Mac forgets that phone's registration.
413too_largeThe body is over 8 KB, or the push service found the payload too large.
429rate_limitedOver 60 a minute from this address or 20 a minute to this phone. Retry-After says when to try again.
502apns_failedThe push service, or the upstream relay, refused or could not be reached.
503push_unavailableThis relay has no push key and no upstream to pass the push to.

This is what the relay sends to the push service, with the push type alert, the collapse id and an expiry of one hour. The phone shows the alert only when it cannot open the content; otherwise the app replaces it with the content's own title and text.

to the push service
{ "aps": { "alert": { "title": "Grenade", "body": "An agent is waiting for you" },
           "sound": "default", "mutable-content": 1 },
  "g": { "v": 1, "e": "<X25519 public key>", "c": "<sealed content>" } }

A relay without a key passes the request to its upstream with the header X-Grenade-Push-Hops, and hands back what the upstream answered. A request that carries that header is never passed on again, so two relays that point at each other cannot loop.

Close codes

CodeSocketMeaning
4000Mac linkReplaced: another link registered the same id with the right secret.
4400Mac linkRefused after an error frame (bad frame, wrong registration key, id taken).
4408Mac linkNo register frame within 5 s.
4503Phone pipeThe Mac went offline, or reconnected on a new link.
1001BothThe relay is shutting down.

Known limits: one process holds all state, so a relay does not scale across instances. There is no rate limiting beyond 8 pipes per Mac, the 4 MB message cap and the push route's limits; put a public relay behind a proxy with connection limits if abuse shows up.