@yuriai/relay-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ # Relay Server Package Documentation
2
+
3
+ `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the isolated Push Relay process, the `yuri-relay` and `yuri-push-relay` CLIs, and the package release and deployment contract.
4
+
5
+ ## Purpose and security boundary
6
+
7
+ The Relay server brokers Layer 1 routing: Host control connections, client route requests, and matching Host data connections. It forwards opaque Layer 2/3 frames verbatim.
8
+
9
+ Host and Client terminate the E2EE channel. Each Host authenticates Relay connections with its long-lived P-256 signing key. Pairing secrets and client bearer credentials continue through endpoint validation; Relay reachability grants transport access.
10
+
11
+ Relay v1 admission accepts anonymous Client route requests. Per-IP, global, pending-connection, raw-socket, frame, and queue limits bound that public entry point. Host-control upgrades are separately bounded per IP and per `serverId` so a Host reconnect loop cannot monopolize the listener; a limited Host-control upgrade is `4029` and does not replace a live control socket. Relay does not inspect Host or Client application versions — mixed app versions are a Host/Client concern, and unsupported Layer 1 fields are ignored rather than version-gated. Configure limits for the expected traffic volume and keep the Relay behind TLS. Pair queues pause the fast sender at half the per-connection byte limit so a slow peer applies TCP backpressure instead of filling memory until `4029`. Ready pairs send one frame per tick so one tunnel cannot monopolize the event loop.
12
+
13
+ The Relay keeps process-local routing state only. Hosts reconnect after Relay restarts, and a control disconnect retains its Host route for the 30-second grace period.
14
+
15
+ Layer 1 and Push are separate processes in this package. Layer 1 never holds APNs credentials or the device-token database. Push never sees Relay tunnel frames, pairing secrets, or client bearer credentials. Give Apple secrets only to the Push process. SQLite token storage is single-instance: one Push process per database file.
16
+
17
+ ## Quick deployment
18
+
19
+ Install the package, then start the Relay:
20
+
21
+ ```sh
22
+ npm install -g @yuriai/relay-server
23
+ yuri-relay
24
+ ```
25
+
26
+ The default listener is `127.0.0.1:8787` and the WebSocket path is `/ws`. Deploy with the default loopback listener and a TLS reverse proxy. Set the public URL to the same public scheme, host, and path:
27
+
28
+ ```sh
29
+ yuri-relay --public-url wss://relay.example.com/ws
30
+ ```
31
+
32
+ Push is a second executable from the same package. Default listen address is `127.0.0.1:8788`:
33
+
34
+ ```sh
35
+ export YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
36
+ export YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
37
+ export YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri
38
+ export YURI_PUSH_RELAY_APNS_P8_PATH=/etc/yuri/AuthKey.p8
39
+ yuri-push-relay --host 127.0.0.1 --port 8788
40
+ ```
41
+
42
+ The Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://` `/v1/push/send`. Set `YURI_PUSH_RELAY_URL` on the Host to override. After a Relay switch, the Host re-registers persisted tokens and binds them before the first send. iOS Live Activity uses the same Push origin: `POST /v1/push/register-live-activity-token`, `POST /v1/push/unregister-live-activity-token`, and `POST /v1/push/live-activity`. Each Live Activity APNs request authenticates with the Host signing key, uses topic `{bundleId}.push-type.liveactivity`, and carries only `aps.timestamp`, `aps.event`, `aps.content-state`, and optional `dismissal-date` / `stale-date`. Successful `end` deliveries delete the token binding.
43
+
44
+ ### Combined mode (single port)
45
+
46
+ When `yuri-relay` sees any non-empty `YURI_PUSH_RELAY_APNS_*` variable, it mounts Push HTTP on the same listener at `/v1/push/*`. Missing required APNs fields fail startup instead of silently skipping Push. With no such variables, Layer 1 does not load the Push module.
47
+
48
+ Combined mode ignores `YURI_PUSH_RELAY_HOST` and `YURI_PUSH_RELAY_PORT`. Public `/healthz` and `/readyz` stay Layer 1. The standalone `yuri-push-relay` entry is unchanged.
49
+
50
+ Push token persistence is owned by `src/push/store-worker.js`; all SQLite initialization and writes run in a Worker thread, including in combined mode. `cached-store.js` loads existing bindings before Push becomes ready and owns a memory index bounded by `maxTokens`. Reads and snapshot counts do no database or Worker work. An existing database larger than the configured capacity fails initialization rather than silently dropping registrations. The schema and database path are unchanged; run one Push owner per file and do not mutate it externally while running.
51
+
52
+ Push request bodies must complete within 5 seconds and the existing byte limit. Aborted uploads release their HTTP work slot, preventing disconnected or slow clients from permanently exhausting the bounded request capacity.
53
+
54
+ Writes are serialized and acknowledged only after SQLite succeeds, then the memory index is updated. Failed writes leave the previous binding intact and registration retries remain possible. APNs completion deletes a binding only if it still matches the record observed before sending, so an old delivery cannot delete a newer registration. SQLite lock waits are capped at 250 ms in the Worker. Pending store writes and registration mutations are each capped at `maxInFlight`, with a 2-second queue deadline; a dispatched store operation must finish within its remaining 2-second store deadline. Push HTTP work is capped at twice `maxInFlight`. Saturation and unavailable storage return HTTP 503, without affecting Layer 1 routes.
55
+
56
+ On Worker failure or write timeout, cached bindings are invalidated (never used for authorization while persistence is uncertain), pending writes fail, and the old Worker is terminated. A later Push request can reload the database after a 5-second cooldown; initialization has a 10-second deadline and waits for old-thread termination before creating a replacement. There is no background retry loop. `getSnapshot().storeAvailable` distinguishes unavailable storage from a successfully empty store. Push shutdown drains requests within the existing deadline before closing the Worker. This isolates SQLite blocking, not process-wide OOM, CPU exhaustion, or host failure.
57
+
58
+ ```sh
59
+ export YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
60
+ export YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
61
+ export YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri
62
+ export YURI_PUSH_RELAY_APNS_P8_PATH=/etc/yuri/AuthKey.p8
63
+ export YURI_PUSH_RELAY_DATABASE_PATH=/var/lib/yuri/push-relay.sqlite
64
+ yuri-relay --public-url wss://relay.example.com/ws
65
+ ```
66
+
67
+ Minimal Compose environment:
68
+
69
+ ```yaml
70
+ services:
71
+ relay:
72
+ image: yuri-relay:<version>
73
+ environment:
74
+ YURI_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
75
+ YURI_PUSH_RELAY_APNS_KEY_ID: ${YURI_PUSH_RELAY_APNS_KEY_ID}
76
+ YURI_PUSH_RELAY_APNS_TEAM_ID: ${YURI_PUSH_RELAY_APNS_TEAM_ID}
77
+ YURI_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.yuri
78
+ YURI_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
79
+ YURI_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
80
+ ports:
81
+ - "127.0.0.1:8787:8787"
82
+ ```
83
+
84
+ ### Caddy
85
+
86
+ ```caddyfile
87
+ relay.example.com {
88
+ reverse_proxy 127.0.0.1:8787 {
89
+ header_up X-Forwarded-For {remote_host}
90
+ }
91
+ }
92
+ ```
93
+
94
+ Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy proxies WebSocket upgrades for `/ws` and serves `/healthz` and `/readyz` through the same upstream. `header_up X-Forwarded-For {remote_host}` replaces the inbound value with the single client source IP.
95
+
96
+ Shared hostname with Push:
97
+
98
+ ```caddyfile
99
+ relay.example.com {
100
+ handle /v1/push/* {
101
+ reverse_proxy 127.0.0.1:8788 {
102
+ header_up X-Forwarded-For {remote_host}
103
+ }
104
+ }
105
+
106
+ handle {
107
+ reverse_proxy 127.0.0.1:8787 {
108
+ header_up X-Forwarded-For {remote_host}
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### Nginx
115
+
116
+ ```nginx
117
+ server {
118
+ listen 443 ssl;
119
+ server_name relay.example.com;
120
+
121
+ ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
122
+ ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
123
+
124
+ location / {
125
+ proxy_pass http://127.0.0.1:8787;
126
+ proxy_http_version 1.1;
127
+ proxy_set_header Host $host;
128
+ proxy_set_header X-Forwarded-For $remote_addr;
129
+ proxy_set_header Upgrade $http_upgrade;
130
+ proxy_set_header Connection "upgrade";
131
+ }
132
+ }
133
+ ```
134
+
135
+ Run the Relay with `--public-url wss://relay.example.com/ws`. The `/ws` path in the public URL and Relay configuration must match. `proxy_set_header X-Forwarded-For $remote_addr;` replaces the inbound value with the single client source IP. For Push on the same hostname, proxy `/v1/push/` to `127.0.0.1:8788` and keep `/` including `/ws` on `127.0.0.1:8787`, replacing `X-Forwarded-For` on both locations.
136
+
137
+ ## Docker
138
+
139
+ Each non-dry-run Yuri `v*` release and each `relay/v*` Relay-only release publishes a multi-platform Relay image for `linux/amd64` and `linux/arm64` to Docker Hub. The image default entrypoint is Layer 1. The same image includes Node 24 plus `yuri-push-relay` source/bin/package files. Layer 1 is compiled with Bun 1.3.14; Push is not Bun-compiled and runs under Node 24 (`node:sqlite`). The container user is non-root, ports `8787` and `8788` are exposed, and the image health check uses Node against Layer 1 `/healthz`. CI builds each architecture natively in parallel (`ubuntu-latest` and `ubuntu-24.04-arm`), then merges digests into a single multi-arch manifest tagged as:
140
+
141
+ ```text
142
+ <DOCKERHUB_USERNAME>/yuri-relay:<version>
143
+ <DOCKERHUB_USERNAME>/yuri-relay:latest
144
+ ```
145
+
146
+ The release workflow reads the Docker Hub account from the `DOCKERHUB_USERNAME` GitHub Actions repository variable and authenticates with the `DOCKERHUB_TOKEN` repository secret. Use a Docker Hub personal access token with Read and Write permissions; Delete permission is not required. A failed image build or push blocks final publication of the GitHub Release.
147
+
148
+ For an image-only republish of the current package version, run the `Relay Docker` workflow directly or dispatch the `Release` workflow with `relay_only` enabled. The image-only path does not create or modify a GitHub Release or any desktop and mobile artifacts.
149
+
150
+ Pull and run a published image behind a host TLS reverse proxy:
151
+
152
+ ```sh
153
+ docker pull <dockerhub-username>/yuri-relay:<version>
154
+ docker run -d \
155
+ --name yuri-relay \
156
+ --restart unless-stopped \
157
+ --read-only \
158
+ --tmpfs /tmp \
159
+ --security-opt no-new-privileges:true \
160
+ -p 127.0.0.1:8787:8787 \
161
+ -e YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
162
+ <dockerhub-username>/yuri-relay:<version>
163
+ ```
164
+
165
+ Use an immutable version tag in production. Each non-dry-run release updates `latest` together with its immutable version tag.
166
+
167
+ For a complete public deployment using the published immutable image, automatic HTTPS, and an internal-only Relay listener, use [`docker-compose.relay.remote.yml`](../../deploy/relay/docker-compose.relay.remote.yml). Docker Compose 2.23.1 or later is required for its inline Caddy config. Run all commands from the repository root with `--project-directory .` to preserve existing project names and relative file paths. Point the domain's `A` and/or `AAAA` record at the server, allow inbound TCP ports 80 and 443 plus UDP port 443, then run:
168
+
169
+ ```sh
170
+ YURI_RELAY_IMAGE='<dockerhub-username>/yuri-relay:<version>@sha256:<manifest-digest>' \
171
+ RELAY_DOMAIN=relay.example.com \
172
+ ACME_EMAIL=admin@example.com \
173
+ docker compose --project-directory . -f deploy/relay/docker-compose.relay.remote.yml up -d
174
+ ```
175
+
176
+ `YURI_RELAY_IMAGE` is required; the deployment file must never bind to a personal registry namespace. Supply an immutable version-and-manifest-digest reference in production. The file keeps Relay off host ports, replaces forwarded client IPs at Caddy, persists Caddy certificates and configuration, and enables trusted-proxy mode on Relay. Inspect startup and readiness with:
177
+
178
+ ```sh
179
+ docker compose --project-directory . -f deploy/relay/docker-compose.relay.remote.yml ps
180
+ curl -fsS https://relay.example.com/healthz
181
+ curl -fsS https://relay.example.com/readyz
182
+ ```
183
+
184
+ Layer 1 plus Push from the same immutable image uses [`docker-compose.relay-push.remote.yml`](../../deploy/relay/docker-compose.relay-push.remote.yml). Layer 1 receives no APNs secrets. Push receives only APNs Key ID / Team ID / Bundle ID and the `.p8` Docker secret path, plus a persistent SQLite volume, with a read-only root filesystem. Caddy serves one hostname: `/v1/push/*` to `push:8788`, everything else including `/ws` to `relay:8787`, each replacing `X-Forwarded-For` once. Both services have health checks; Caddy waits until both are healthy.
185
+
186
+ ```sh
187
+ YURI_RELAY_IMAGE='<dockerhub-username>/yuri-relay:<version>@sha256:<manifest-digest>' \
188
+ RELAY_DOMAIN=relay.example.com \
189
+ ACME_EMAIL=admin@example.com \
190
+ YURI_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
191
+ YURI_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
192
+ YURI_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.yuri \
193
+ YURI_PUSH_RELAY_APNS_P8_FILE=/etc/yuri/AuthKey.p8 \
194
+ docker compose --project-directory . -f deploy/relay/docker-compose.relay-push.remote.yml up -d
195
+ ```
196
+
197
+ ```sh
198
+ docker compose --project-directory . -f deploy/relay/docker-compose.relay-push.remote.yml ps
199
+ curl -fsS https://relay.example.com/healthz
200
+ curl -fsS https://relay.example.com/readyz
201
+ ```
202
+
203
+ Public `/healthz` and `/readyz` are Layer 1. Push health remains on the internal `8788` listener and the Compose health check. Existing Layer-1-only Compose files keep their previous behavior.
204
+
205
+ From the repository root, build and start the supplied service. The deployment assets are [`Dockerfile.relay`](../../deploy/relay/Dockerfile.relay) and [`docker-compose.relay.yml`](../../deploy/relay/docker-compose.relay.yml):
206
+
207
+ ```sh
208
+ YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
209
+ YURI_RELAY_PUBLISHED_PORT=8787 \
210
+ docker compose --project-directory . -f deploy/relay/docker-compose.relay.yml up -d --build
211
+ ```
212
+
213
+ Compose publishes `127.0.0.1:${YURI_RELAY_PUBLISHED_PORT:-8787}` by default; use `YURI_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each Yuri Host. Its image health check uses Node to call Layer 1 `GET /healthz`. Terminate public TLS at an external reverse proxy and publish `wss://relay.example.com/ws`. A public Relay port binding requires firewall rules and TLS; loopback publishing with a TLS reverse proxy is the deployment path.
214
+
215
+ ## Connect Hosts
216
+
217
+ Set the Relay URL on every Yuri Host, start the Host, then generate a Relay pairing link or enable Relay pairing in the application:
218
+
219
+ ```sh
220
+ export YURI_RELAY_URL=wss://relay.example.com/ws
221
+ yuri
222
+ ```
223
+
224
+ The **Add a device** dialog can select this endpoint per pairing. An owner UI session or the local Desktop shell (`desktop-local`) may persist a custom Host endpoint and switch the control connection; the effective `relayUrl` is embedded in the pairing-v2 candidate before the QR code. Endpoints must be `ws://` or `wss://` without userinfo; query and fragment are not part of identity and are stripped. `YURI_RELAY_URL` pins the endpoint and disables the override. The creating client remembers its last effective choice locally; consuming Mobile and Desktop clients persist the endpoint snapshot with the saved connection.
225
+
226
+ Existing clients switch to a new Relay after a new pairing flow; generate a fresh pairing link when endpoint replacement is required.
227
+
228
+ ## Configuration
229
+
230
+ Configuration precedence is command flags, then `YURI_RELAY_SERVER_*` variables, then defaults. `--host`, `--port`, `--path`, `--public-url`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet` are available. Push flags are `--host`, `--port`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet`, with `YURI_PUSH_RELAY_*` variables.
231
+
232
+ `YURI_RELAY_SERVER_PUBLIC_URL` affects startup output. `YURI_RELAY_SERVER_PATH` selects the actual WebSocket upgrade endpoint. Relay listens on loopback by default. Enable `YURI_RELAY_SERVER_TRUST_PROXY=true` when a trusted reverse proxy fully isolates Relay ingress and replaces any client-supplied `X-Forwarded-For` value with one canonical client IP. Relay accepts one forwarded IP in this mode.
233
+
234
+ With correctly configured trusted proxying, client and admission limits use that canonical client IP. `YURI_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` always counts the TCP peer that connects to Relay; reverse-proxy deployments therefore count raw sockets against the proxy and require a higher value sized for aggregate concurrent traffic.
235
+
236
+ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:db8::1]/ws`.
237
+
238
+ | Variable | Default | Unit / purpose |
239
+ | --- | --- | --- |
240
+ | `YURI_RELAY_SERVER_HOST` | `127.0.0.1` | Listener address |
241
+ | `YURI_RELAY_SERVER_PORT` | `8787` | TCP port |
242
+ | `YURI_RELAY_SERVER_PATH` | `/ws` | WebSocket upgrade path |
243
+ | `YURI_RELAY_SERVER_PUBLIC_URL` | unset | Startup output URL; `ws://` or `wss://`, same path as `PATH` |
244
+ | `YURI_RELAY_SERVER_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
245
+ | `YURI_RELAY_SERVER_MAX_URL_BYTES` | `4096` | bytes |
246
+ | `YURI_RELAY_SERVER_MAX_FIELD_BYTES` | `512` | bytes per routing field |
247
+ | `YURI_RELAY_SERVER_MAX_HOSTS` | `256` | active Host routes |
248
+ | `YURI_RELAY_SERVER_MAX_SOCKETS` | `2048` | upgraded WebSockets |
249
+ | `YURI_RELAY_SERVER_MAX_CONNECTIONS` | `1000` | global client connections |
250
+ | `YURI_RELAY_SERVER_MAX_CLIENTS_PER_HOST` | `100` | connections per Host |
251
+ | `YURI_RELAY_SERVER_MAX_CLIENTS_PER_IP` | `30` | connections per client IP |
252
+ | `YURI_RELAY_SERVER_MAX_PENDING_CLIENTS` | `30` | clients awaiting Host data connection |
253
+ | `YURI_RELAY_SERVER_PENDING_MS` | `15000` | ms awaiting Host data connection |
254
+ | `YURI_RELAY_SERVER_MAX_RAW_SOCKETS` | `4096` | accepted TCP sockets before upgrade |
255
+ | `YURI_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` | `128` | TCP sockets per Relay TCP peer IP |
256
+ | `YURI_RELAY_SERVER_GRACE_MS` | `30000` | ms Host control disconnect grace |
257
+ | `YURI_RELAY_SERVER_TIMESTAMP_SKEW_MS` | `60000` | ms Host signature timestamp window |
258
+ | `YURI_RELAY_SERVER_REPLAY_MS` | `120000` | ms replay-record lifetime; at least twice timestamp skew |
259
+ | `YURI_RELAY_SERVER_MAX_REPLAY_ENTRIES` | `10000` | Host signature replay records |
260
+ | `YURI_RELAY_SERVER_MAX_FRAME_BYTES` | `131072` | bytes per forwarded frame |
261
+ | `YURI_RELAY_SERVER_MAX_QUEUED_BYTES_PER_CONNECTION` | `2097152` | bytes per client pair |
262
+ | `YURI_RELAY_SERVER_MAX_GLOBAL_QUEUED_BYTES` | `33554432` | bytes across all queues |
263
+ | `YURI_RELAY_SERVER_MAX_BUFFERED_AMOUNT` | `2097152` | bytes buffered by a WebSocket before pump retry |
264
+ | `YURI_RELAY_SERVER_MAX_CONTROL_QUEUE_ENTRIES` | `256` | queued Host control messages |
265
+ | `YURI_RELAY_SERVER_MAX_CONTROL_QUEUED_BYTES` | `2097152` | bytes queued for Host control |
266
+ | `YURI_RELAY_SERVER_PUMP_RETRY_MS` | `25` | ms between backpressure retries |
267
+ | `YURI_RELAY_SERVER_HEARTBEAT_MS` | `30000` | ms WebSocket ping interval |
268
+ | `YURI_RELAY_SERVER_HANDSHAKE_MS` | `10000` | ms for TCP and WebSocket admission |
269
+ | `YURI_RELAY_SERVER_CLOSE_DEADLINE_MS` | `5000` | ms before forced socket close |
270
+ | `YURI_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms admission window |
271
+ | `YURI_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | Client, Host-data, and malformed upgrades per role/IP per window |
272
+ | `YURI_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_IP` | `30` | Host-control upgrades per IP per window |
273
+ | `YURI_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER` | `12` | Host-control upgrades per `serverId` per window |
274
+ | `YURI_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked admission records |
275
+ | `YURI_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
276
+
277
+ Host Push URL (Yuri Host, not the Push process):
278
+
279
+ | Variable | Default | Unit / purpose |
280
+ | --- | --- | --- |
281
+ | `YURI_PUSH_RELAY_URL` | derived from the effective Relay URL | explicit `…/v1/push/send` override |
282
+ | `YURI_PUSH_RELAY_DISABLED` | unset | `true` disables Push Relay on the Host |
283
+
284
+ Push process:
285
+
286
+ | Variable | Default | Unit / purpose |
287
+ | --- | --- | --- |
288
+ | `YURI_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
289
+ | `YURI_PUSH_RELAY_PORT` | `8788` | TCP port |
290
+ | `YURI_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
291
+ | `YURI_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file |
292
+ | `YURI_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
293
+ | `YURI_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
294
+ | `YURI_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
295
+ | `YURI_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per client IP per minute |
296
+ | `YURI_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per client IP per minute |
297
+ | `YURI_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
298
+ | `YURI_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
299
+ | `YURI_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
300
+ | `YURI_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
301
+ | `YURI_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
302
+ | `YURI_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.yuri` | App bundle ID |
303
+ | `YURI_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM |
304
+ | `YURI_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file |
305
+
306
+ TestFlight and App Store Hosts use `YURI_APNS_ENVIRONMENT=production`. Each send request carries `env`; the Push process does not pick sandbox vs production itself.
307
+
308
+ ## Operations
309
+
310
+ - `GET` and `HEAD` requests to `/healthz` return process health. `/readyz` returns ready status after the listener reaches running state.
311
+ - `SIGTERM` and `SIGINT` begin graceful Relay shutdown. Docker grants a 30-second stop period.
312
+ - Hosts automatically reconnect after a Relay process restart. Relay state remains ephemeral.
313
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, APNs `.p8` material, and device tokens.
314
+ - Run one Push process per SQLite database file.
315
+
316
+ ### systemd
317
+
318
+ Create `/etc/yuri-relay.env`:
319
+
320
+ ```sh
321
+ YURI_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws
322
+ ```
323
+
324
+ Create `/etc/systemd/system/yuri-relay.service`:
325
+
326
+ ```ini
327
+ [Unit]
328
+ Description=Yuri Private Relay
329
+ After=network-online.target
330
+ Wants=network-online.target
331
+
332
+ [Service]
333
+ Type=simple
334
+ EnvironmentFile=/etc/yuri-relay.env
335
+ ExecStart=/usr/local/bin/yuri-relay
336
+ Restart=on-failure
337
+ RestartSec=5
338
+
339
+ [Install]
340
+ WantedBy=multi-user.target
341
+ ```
342
+
343
+ Enable it:
344
+
345
+ ```sh
346
+ sudo systemctl daemon-reload
347
+ sudo systemctl enable --now yuri-relay
348
+ ```