- 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
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
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.
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
# 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-datavolume. 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.
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.
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.
| Variable | Default | What it does |
|---|---|---|
PORT | 8787 | Port to listen on. |
HOST | 0.0.0.0 | Address to bind. |
GRENADE_RELAY_DATA | ./data | Folder for daemons.json, the records of Macs (mode 0600). /data in the Docker image. |
GRENADE_RELAY_REGISTRATION_KEY | unset | When 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_KEY | unset | Turns on the dashboard at / and GET /v1/daemons. Unset means both answer 404. |
GRENADE_RELAY_TRUST_PROXY | unset | 1 takes client IPs from X-Forwarded-For. Only set it behind a proxy you run (the compose file and Heroku do). |
GRENADE_RELAY_PUSH_UPSTREAM | the main relay | The 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_KEY | unset | The upstream relay's registration key, when it requires one. |
GRENADE_RELAY_APNS_KEY | unset | The text of an APNs key (.p8), for a relay that sends pushes itself. Newlines may be written as \n. |
GRENADE_RELAY_APNS_KEY_FILE | unset | The same key as a file path, in place of GRENADE_RELAY_APNS_KEY. |
GRENADE_RELAY_APNS_KEY_ID | unset | The key's id (10 characters). Required with a key. |
GRENADE_RELAY_APNS_TEAM_ID | unset | The Apple Developer team the key belongs to. Required with a key. |
GRENADE_RELAY_APNS_TOPICS | com.adamchew.grenade | Bundle ids the key sends for, separated by commas. |
GRENADE_LOG | info | debug 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.
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.
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.
# 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
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
| Request | Auth | Reply |
|---|---|---|
GET /health | none | 200 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/daemons | Bearer <admin key> | 200 every Mac on the relay; 401 wrong key; 404 when no admin key is set. |
GET / | Basic, password = admin key | HTML dashboard of the same list, refreshes every 15 s; 404 when no admin key is set. |
POST /v1/push | Bearer <registration key>, when required | Sends 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/daemon | Bearer <registration key>, when required | The 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" }.
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.
{ "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 = 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
| type | Fields | Notes |
|---|---|---|
register | protocol: 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). |
update | name?, localIps?, access? | After pairing a phone, a rename or a network change. Each field present replaces the old value. |
data | conn, text | One frame for that phone. text is opaque ciphertext. |
close | conn, code?, reason? | The Mac closed that phone's pipe; the relay closes the phone's socket with the same code. |
{ "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
| type | Fields | Notes |
|---|---|---|
registered | publicIp? | Reply to register. publicIp is the address the link came from. |
error | code, message | code is unauthorized, id_taken or bad_frame. Sent just before the relay closes the link with 4400. |
open | conn, ip? | A phone connected. conn is unique on this link (c1, c2, …). |
data | conn, text | One frame from that phone, forwarded as is. |
close | conn | That 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.
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 }
| Status | error | Meaning |
|---|---|---|
400 | bad_request | The body is not a push request. |
401 | unauthorized | The relay has a registration key and the request did not bring it. |
403 | topic_not_served | The relay's key does not send for this app. |
410 | unregistered | The device token is dead (the app was removed). The Mac forgets that phone's registration. |
413 | too_large | The body is over 8 KB, or the push service found the payload too large. |
429 | rate_limited | Over 60 a minute from this address or 20 a minute to this phone. Retry-After says when to try again. |
502 | apns_failed | The push service, or the upstream relay, refused or could not be reached. |
503 | push_unavailable | This 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.
{ "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
| Code | Socket | Meaning |
|---|---|---|
4000 | Mac link | Replaced: another link registered the same id with the right secret. |
4400 | Mac link | Refused after an error frame (bad frame, wrong registration key, id taken). |
4408 | Mac link | No register frame within 5 s. |
4503 | Phone pipe | The Mac went offline, or reconnected on a new link. |
1001 | Both | The 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.