@openchambery/relay-server 1.19.0-beta.4 → 1.19.0-beta.41

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.
package/DOCUMENTATION.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Relay Server Package Documentation
2
2
 
3
- `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the `openchamber-relay` CLI, and the package release and deployment contract.
3
+ `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the isolated Push Relay process, the `openchamber-relay` and `openchamber-push-relay` CLIs, and the package release and deployment contract.
4
4
 
5
5
  ## Purpose and security boundary
6
6
 
@@ -12,6 +12,8 @@ Relay v1 admission accepts anonymous Client route requests. Per-IP, global, pend
12
12
 
13
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
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
+
15
17
  ## Quick deployment
16
18
 
17
19
  Install the package, then start the Relay:
@@ -27,6 +29,18 @@ The default listener is `127.0.0.1:8787` and the WebSocket path is `/ws`. Deploy
27
29
  openchamber-relay --public-url wss://relay.example.com/ws
28
30
  ```
29
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 OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
36
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
37
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
38
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
39
+ openchamber-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 `OPENCHAMBER_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.
43
+
30
44
  ### Caddy
31
45
 
32
46
  ```caddyfile
@@ -39,6 +53,24 @@ relay.example.com {
39
53
 
40
54
  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.
41
55
 
56
+ Shared hostname with Push:
57
+
58
+ ```caddyfile
59
+ relay.example.com {
60
+ handle /v1/push/* {
61
+ reverse_proxy 127.0.0.1:8788 {
62
+ header_up X-Forwarded-For {remote_host}
63
+ }
64
+ }
65
+
66
+ handle {
67
+ reverse_proxy 127.0.0.1:8787 {
68
+ header_up X-Forwarded-For {remote_host}
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
42
74
  ### Nginx
43
75
 
44
76
  ```nginx
@@ -60,11 +92,11 @@ server {
60
92
  }
61
93
  ```
62
94
 
63
- 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.
95
+ 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.
64
96
 
65
97
  ## Docker
66
98
 
67
- Each non-dry-run OpenChamber release publishes a multi-platform Relay image for `linux/amd64` and `linux/arm64` to Docker Hub. 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:
99
+ Each non-dry-run OpenChamber `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 `openchamber-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:
68
100
 
69
101
  ```text
70
102
  <DOCKERHUB_USERNAME>/openchamber-relay:<version>
@@ -109,6 +141,27 @@ curl -fsS https://relay.example.com/healthz
109
141
  curl -fsS https://relay.example.com/readyz
110
142
  ```
111
143
 
144
+ Layer 1 plus Push from the same immutable image uses [`docker-compose.relay-push.remote.yml`](../../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.
145
+
146
+ ```sh
147
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
148
+ RELAY_DOMAIN=relay.example.com \
149
+ ACME_EMAIL=admin@example.com \
150
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
151
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
152
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
153
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
154
+ docker compose -f docker-compose.relay-push.remote.yml up -d
155
+ ```
156
+
157
+ ```sh
158
+ docker compose -f docker-compose.relay-push.remote.yml ps
159
+ curl -fsS https://relay.example.com/healthz
160
+ curl -fsS https://relay.example.com/readyz
161
+ ```
162
+
163
+ 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.
164
+
112
165
  From the repository root, build and start the supplied service. The compatibility assets are [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml):
113
166
 
114
167
  ```sh
@@ -117,7 +170,7 @@ OPENCHAMBER_RELAY_PUBLISHED_PORT=8787 \
117
170
  docker compose -f docker-compose.relay.yml up -d --build
118
171
  ```
119
172
 
120
- Compose publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` by default; use `OPENCHAMBER_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each OpenChamber Host. Its image health check calls `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.
173
+ Compose publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` by default; use `OPENCHAMBER_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each OpenChamber 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.
121
174
 
122
175
  ## Connect Hosts
123
176
 
@@ -134,7 +187,7 @@ Existing clients switch to a new Relay after a new pairing flow; generate a fres
134
187
 
135
188
  ## Configuration
136
189
 
137
- Configuration precedence is command flags, then `OPENCHAMBER_RELAY_SERVER_*` variables, then defaults. `--host`, `--port`, `--path`, `--public-url`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet` are available.
190
+ Configuration precedence is command flags, then `OPENCHAMBER_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 `OPENCHAMBER_PUSH_RELAY_*` variables.
138
191
 
139
192
  `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` affects startup output. `OPENCHAMBER_RELAY_SERVER_PATH` selects the actual WebSocket upgrade endpoint. Relay listens on loopback by default. Enable `OPENCHAMBER_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.
140
193
 
@@ -179,12 +232,44 @@ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:
179
232
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
180
233
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
181
234
 
235
+ Host Push URL (OpenChamber Host, not the Push process):
236
+
237
+ | Variable | Default | Unit / purpose |
238
+ | --- | --- | --- |
239
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay URL | explicit `…/v1/push/send` override |
240
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | `true` disables Push Relay on the Host |
241
+
242
+ Push process:
243
+
244
+ | Variable | Default | Unit / purpose |
245
+ | --- | --- | --- |
246
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
247
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
248
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
249
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file |
250
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
251
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
252
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
253
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per client IP per minute |
254
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per client IP per minute |
255
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
256
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
257
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
258
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
259
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
260
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
261
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM |
262
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file |
263
+
264
+ TestFlight and App Store Hosts use `OPENCHAMBER_APNS_ENVIRONMENT=production`. Each send request carries `env`; the Push process does not pick sandbox vs production itself.
265
+
182
266
  ## Operations
183
267
 
184
268
  - `GET` and `HEAD` requests to `/healthz` return process health. `/readyz` returns ready status after the listener reaches running state.
185
269
  - `SIGTERM` and `SIGINT` begin graceful Relay shutdown. Docker grants a 30-second stop period.
186
270
  - Hosts automatically reconnect after a Relay process restart. Relay state remains ephemeral.
187
- - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, and encrypted payloads.
271
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, APNs `.p8` material, and device tokens.
272
+ - Run one Push process per SQLite database file.
188
273
 
189
274
  ### systemd
190
275
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `openchamber-relay` is the self-hosted Layer 1 relay for OpenChamber remote access. It gives OpenChamber Hosts an outbound Relay connection and carries encrypted client traffic through that connection.
4
4
 
5
+ The same `@openchambery/relay-server` package also ships `openchamber-push-relay`, an isolated APNs push process. Layer 1 never receives Apple credentials. Push never terminates E2EE tunnels.
6
+
5
7
  The Relay routes opaque Layer 2/3 frames verbatim. E2EE terminates at the OpenChamber Host and Client, so the Relay has routing metadata and transport state while the endpoints hold application plaintext, pairing secrets, and client bearer credentials. Host Relay connections authenticate with the Host's long-lived P-256 signing key. Relay reachability grants transport access; endpoint validation continues to enforce pairing and client credentials.
6
8
 
7
9
  ## Architecture and transport
@@ -19,10 +21,19 @@ Relay state lives in process memory. Hosts reconnect after a Relay restart. A di
19
21
 
20
22
  Relay v1 accepts anonymous Client route requests. Admission, connection, frame, queue, and socket limits bound this public entry point.
21
23
 
24
+ ## Dual-process security boundary
25
+
26
+ Layer 1 (`openchamber-relay`) and Push (`openchamber-push-relay`) are separate processes in one package:
27
+
28
+ - Layer 1 authenticates Hosts, brokers WebSocket routes, and forwards opaque frames. It has no APNs key, no device-token database, and no `/v1/push/*` handlers.
29
+ - Push verifies Host signatures, binds `token → serverId` in a local SQLite database, and holds the project APNs `.p8` key. It has no access to Relay tunnels, pairing secrets, or client bearer credentials.
30
+ - Deploy them as two containers from the same immutable image. Give APNs Key ID / Team ID / Bundle ID and the `.p8` secret only to the Push container. Keep Layer 1 free of those secrets.
31
+ - SQLite is a single-writer store. Run one Push instance per database file. Do not share that volume across replicas.
32
+
22
33
  ## Requirements
23
34
 
24
- - `@openchambery/relay-server` installation: Node.js 22 or later and a supported package manager such as npm, pnpm, yarn, or Bun.
25
- - Single-file bundle build: Bun. This repository uses Bun 1.3.14.
35
+ - `@openchambery/relay-server` installation: Node.js 22.13 or later and a supported package manager such as npm, pnpm, yarn, or Bun. Push uses Node's built-in `node:sqlite`.
36
+ - Single-file Layer 1 bundle build: Bun. This repository uses Bun 1.3.14. Do not Bun-compile the Push entry; Docker runs it with Node 24.
26
37
  - Public deployment: a DNS name, TLS certificate, reverse proxy, and firewall policy appropriate for the deployment.
27
38
 
28
39
  ## Install and quick start
@@ -36,6 +47,20 @@ openchamber-relay --public-url wss://relay.example.com/ws
36
47
 
37
48
  The default listener is `127.0.0.1:8787` and the default WebSocket upgrade path is `/ws`. Keep this loopback listener behind a TLS reverse proxy and set `--public-url` to the public `ws://` or `wss://` URL with the same path.
38
49
 
50
+ Start the isolated Push process from the same package. The default listener is `127.0.0.1:8788`. APNs Key ID, Team ID, and a `.p8` value or file path are required:
51
+
52
+ ```sh
53
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
54
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
55
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
56
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
57
+ openchamber-push-relay --host 127.0.0.1 --port 8788
58
+ ```
59
+
60
+ Keep Push on loopback behind the same TLS reverse proxy. Route only `/v1/push/*` to port 8788.
61
+
62
+ OpenChamber Hosts do not need a separate Push URL when they already have a Relay URL. The effective `wss://` or `ws://` Relay URL maps to the same host as `https://` or `http://` `/v1/push/send` (register is `/v1/push/register-token`). Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override that mapping. After a Relay switch, the Host re-registers persisted device tokens and binds them again before the first send.
63
+
39
64
  ### Build a standalone executable
40
65
 
41
66
  Run these commands from the repository root. `bun build --compile` creates a single executable for the current platform and architecture.
@@ -91,6 +116,18 @@ Available CLI options:
91
116
  --version, -v
92
117
  ```
93
118
 
119
+ Push CLI options:
120
+
121
+ ```text
122
+ --host HOST
123
+ --port PORT
124
+ --trust-proxy | --no-trust-proxy
125
+ --json
126
+ --quiet, -q
127
+ --help, -h
128
+ --version, -v
129
+ ```
130
+
94
131
  ## Connect OpenChamber Hosts
95
132
 
96
133
  Set the public Relay URL in every OpenChamber Host environment, then start the Host and create a Relay pairing link or enable Relay pairing in the application.
@@ -120,6 +157,24 @@ relay.example.com {
120
157
 
121
158
  Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy forwards WebSocket upgrades and serves `/healthz` and `/readyz` from the same upstream. The `X-Forwarded-For` rule writes one canonical Client source IP.
122
159
 
160
+ When Push shares the hostname, send `/v1/push/*` to the Push listener and replace `X-Forwarded-For` once on each upstream:
161
+
162
+ ```caddyfile
163
+ relay.example.com {
164
+ handle /v1/push/* {
165
+ reverse_proxy 127.0.0.1:8788 {
166
+ header_up X-Forwarded-For {remote_host}
167
+ }
168
+ }
169
+
170
+ handle {
171
+ reverse_proxy 127.0.0.1:8787 {
172
+ header_up X-Forwarded-For {remote_host}
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
123
178
  ### Nginx
124
179
 
125
180
  ```nginx
@@ -143,6 +198,8 @@ server {
143
198
 
144
199
  Run the Relay with `--public-url wss://relay.example.com/ws`. Nginx writes one canonical Client source IP with `$remote_addr` and forwards WebSocket upgrades over HTTP/1.1.
145
200
 
201
+ To share the hostname with Push, proxy `/v1/push/` to `127.0.0.1:8788` and keep `/` (including `/ws`) on `127.0.0.1:8787`. Replace `X-Forwarded-For` with `$remote_addr` on both locations.
202
+
146
203
  ## Trusted proxies and capacity
147
204
 
148
205
  Enable `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true` or `--trust-proxy` when a trusted reverse proxy fully isolates Relay ingress and replaces each inbound `X-Forwarded-For` value with one canonical Client IP.
@@ -195,6 +252,43 @@ Trusted-proxy mode accepts exactly one valid IP address in `X-Forwarded-For`. Cl
195
252
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
196
253
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
197
254
 
255
+ ### Host Push URL override
256
+
257
+ These variables belong on the OpenChamber Host, not on the Push process:
258
+
259
+ | Variable | Default | Unit / purpose |
260
+ | --- | --- | --- |
261
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay `ws`/`wss` URL | Host override for `https://` or `http://` `…/v1/push/send` |
262
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | Host-only; `true` skips Push Relay and uses direct APNs |
263
+
264
+ The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`.
265
+
266
+ ### Push process environment
267
+
268
+ Command flags take precedence over `OPENCHAMBER_PUSH_RELAY_*` variables, which take precedence over defaults. APNs Key ID, Team ID, and `.p8` material are required.
269
+
270
+ | Variable | Default | Unit / purpose |
271
+ | --- | --- | --- |
272
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
273
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
274
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical Client IP from proxy-replaced `X-Forwarded-For` |
275
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file; directory must be writable |
276
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
277
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
278
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
279
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per Client IP per minute |
280
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per Client IP per minute |
281
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
282
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
283
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
284
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
285
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
286
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
287
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM; literal `\n` accepted |
288
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file; used when `APNS_P8` is empty |
289
+
290
+ TestFlight and App Store Hosts send `env: production` (`OPENCHAMBER_APNS_ENVIRONMENT=production` on the Host). Xcode development builds use `sandbox`. The Push process does not choose the APNs environment; each send request carries it.
291
+
198
292
  ## systemd
199
293
 
200
294
  Create `/etc/openchamber-relay.env`:
@@ -236,11 +330,12 @@ sudo systemctl status openchamber-relay
236
330
  - `SIGTERM` and `SIGINT` start graceful Relay shutdown. Hosts reconnect after a process restart.
237
331
  - Size Host, Client, pending, socket, frame, and queue limits for expected concurrency and message volume.
238
332
  - Keep the default loopback listener, terminate public TLS at a reverse proxy, restrict ingress with firewall rules, and publish the matching `wss://` URL.
239
- - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, pairing material, and bearer credentials.
333
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, pairing material, bearer credentials, APNs `.p8` contents, Key ID / Team ID values, and device tokens.
334
+ - Run a single Push process per SQLite file. WAL mode does not make multi-instance sharing safe.
240
335
 
241
336
  ## Docker delivery assets
242
337
 
243
- Each non-dry-run OpenChamber release publishes a Docker Hub image for `linux/amd64` and `linux/arm64` as `<DOCKERHUB_USERNAME>/openchamber-relay:<version>` and `<DOCKERHUB_USERNAME>/openchamber-relay:latest`. The release pipeline requires the `DOCKERHUB_USERNAME` GitHub Actions repository variable and a `DOCKERHUB_TOKEN` repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized.
338
+ Each non-dry-run OpenChamber `v*` release and each `relay/v*` Relay-only release publishes a Docker Hub image for `linux/amd64` and `linux/arm64` as `<DOCKERHUB_USERNAME>/openchamber-relay:<version>` and `<DOCKERHUB_USERNAME>/openchamber-relay:latest`. The image default entrypoint is Layer 1. The same image also contains Node 24 and the `openchamber-push-relay` source/bin/package files. Layer 1 is a Bun 1.3.14 compile standalone; Push is executed with Node 24 and `node:sqlite`. The container runs as a non-root user, exposes `8787` and `8788`, and health-checks Layer 1 with Node. The release pipeline requires the `DOCKERHUB_USERNAME` GitHub Actions repository variable and a `DOCKERHUB_TOKEN` repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized. `relay/v*` publishes the same npm package and Docker image without desktop, mobile, TestFlight, or OTA artifacts.
244
339
 
245
340
  The `Relay Docker` workflow can republish only the current Relay package version without creating or modifying a GitHub Release or other platform artifacts.
246
341
 
@@ -270,6 +365,23 @@ docker compose -f docker-compose.relay.remote.yml up -d
270
365
 
271
366
  `OPENCHAMBER_RELAY_IMAGE` is required so the repository never binds this reusable deployment file to a personal registry namespace. Use an immutable version-and-digest reference in production.
272
367
 
368
+ To run Layer 1 and Push from that same immutable image, with APNs secrets only on Push, a persistent SQLite volume, read-only root filesystems, and Caddy routing `/v1/push/*` to Push, use [`docker-compose.relay-push.remote.yml`](../../docker-compose.relay-push.remote.yml):
369
+
370
+ ```sh
371
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
372
+ RELAY_DOMAIN=relay.example.com \
373
+ ACME_EMAIL=admin@example.com \
374
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
375
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
376
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
377
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
378
+ docker compose -f docker-compose.relay-push.remote.yml up -d
379
+ ```
380
+
381
+ The Push container reads the `.p8` from the Docker secret path `/run/secrets/apns_p8`. Layer 1 does not receive that secret. Inspect both health checks with `docker compose -f docker-compose.relay-push.remote.yml ps`. Public `/healthz` and `/readyz` are Layer 1. Push `/healthz` stays on the internal listener.
382
+
383
+ Existing Layer-1-only Compose files keep their current behavior: [`docker-compose.relay.yml`](../../docker-compose.relay.yml) for loopback, and [`docker-compose.relay.remote.yml`](../../docker-compose.relay.remote.yml) for public Layer 1 without Push.
384
+
273
385
  The repository provides optional Docker delivery assets at [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml). The Compose service publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` and accepts `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` plus selected Relay limits.
274
386
 
275
387
  ```sh
@@ -291,6 +403,9 @@ These assets define an optional follow-on deployment path. Validate the image, p
291
403
  | Per-Client IP limits behave as proxy limits | Enable trusted-proxy mode, fully isolate Relay ingress behind that proxy, and configure a single replaced `X-Forwarded-For` IP. |
292
404
  | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing `OPENCHAMBER_RELAY_URL`. |
293
405
  | WebSocket application traffic fails while HTTP works | Confirm the Host endpoint mints and supplies a short-lived `oc_url_token` for the WebSocket path. |
406
+ | Push register or send returns 404 on the public hostname | Confirm the reverse proxy sends `/v1/push/*` to the Push listener on port 8788, not to Layer 1. |
407
+ | Push container is unhealthy | Confirm APNs Key ID / Team ID / Bundle ID and the `.p8` secret path, and that the SQLite volume is writable by the non-root user. |
408
+ | Device tokens stop receiving after a Relay URL change | Confirm the Host re-registered against the new Push origin before the first send, or set `OPENCHAMBER_PUSH_RELAY_URL` explicitly. |
294
409
 
295
410
  ## Development and test coverage
296
411
 
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import packageJson from '../package.json' with { type: 'json' };
3
+
4
+ import { isModuleCliExecution } from './cli-entry.js';
5
+ import { runPushRelayCli } from '../src/push/cli.js';
6
+
7
+ const version = packageJson.version;
8
+
9
+ if (isModuleCliExecution(process.argv[1], import.meta.url, undefined, 'openchamber-push-relay')) {
10
+ runPushRelayCli(process.argv.slice(2), { version }).then((code) => { process.exitCode = code; });
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.0-beta.4",
3
+ "version": "1.19.0-beta.41",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
@@ -11,16 +11,22 @@
11
11
  "types": "./src/index.d.ts",
12
12
  "import": "./src/index.js",
13
13
  "default": "./src/index.js"
14
+ },
15
+ "./push": {
16
+ "types": "./src/push/index.d.ts",
17
+ "import": "./src/push/index.js",
18
+ "default": "./src/push/index.js"
14
19
  }
15
20
  },
16
21
  "bin": {
17
- "openchamber-relay": "./bin/openchamber-relay.js"
22
+ "openchamber-relay": "./bin/openchamber-relay.js",
23
+ "openchamber-push-relay": "./bin/openchamber-push-relay.js"
18
24
  },
19
25
  "publishConfig": {
20
26
  "access": "public"
21
27
  },
22
28
  "engines": {
23
- "node": ">=22.0.0"
29
+ "node": ">=22.13.0"
24
30
  },
25
31
  "license": "MIT",
26
32
  "repository": {
@@ -40,8 +46,8 @@
40
46
  "scripts": {
41
47
  "test": "vitest run",
42
48
  "test:node": "node test/node-smoke.js",
43
- "type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
44
- "lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
49
+ "type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
50
+ "lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
45
51
  "build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
46
52
  },
47
53
  "dependencies": {
@@ -0,0 +1,137 @@
1
+ import crypto from 'node:crypto';
2
+ import http2 from 'node:http2';
3
+
4
+ const APNS_HOST = {
5
+ production: 'https://api.push.apple.com',
6
+ sandbox: 'https://api.sandbox.push.apple.com',
7
+ };
8
+ const JWT_TTL_MS = 50 * 60 * 1000;
9
+ const SESSION_TTL_MS = 30 * 60 * 1000;
10
+ const REQUEST_TIMEOUT_MS = 5_000;
11
+ const MAX_RESPONSE_BYTES = 4_096;
12
+ export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
13
+
14
+ const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
15
+
16
+ export const createApnsProvider = (options = {}) => {
17
+ const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
18
+ const connect = options.http2?.connect ?? http2.connect;
19
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
20
+ let privateKey;
21
+ try {
22
+ privateKey = crypto.createPrivateKey(normalizePem(options.p8));
23
+ } catch {
24
+ throw new Error('Invalid APNs key');
25
+ }
26
+ const bundleId = options.bundleId;
27
+ const sessions = new Map();
28
+ let cachedJwt = null;
29
+
30
+ const signJwt = () => {
31
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: options.keyId })).toString('base64url');
32
+ const claims = Buffer.from(JSON.stringify({ iss: options.teamId, iat: Math.floor(clock.now() / 1000) })).toString('base64url');
33
+ const signingInput = `${header}.${claims}`;
34
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
35
+ return `${signingInput}.${signature}`;
36
+ };
37
+
38
+ const getJwt = (force) => {
39
+ const now = clock.now();
40
+ if (!force && cachedJwt && now - cachedJwt.issuedAtMs < JWT_TTL_MS) return cachedJwt.token;
41
+ cachedJwt = { token: signJwt(), issuedAtMs: now };
42
+ return cachedJwt.token;
43
+ };
44
+
45
+ const dropSession = (env, client) => {
46
+ const current = sessions.get(env);
47
+ if (current?.client === client) sessions.delete(env);
48
+ try { client.close(); } catch { /* session already gone */ }
49
+ };
50
+
51
+ const getSession = (env) => {
52
+ const existing = sessions.get(env);
53
+ if (existing?.client && !existing.client.closed && existing.client.destroyed !== true) {
54
+ if (clock.now() - existing.createdAt < sessionTtlMs) return existing.client;
55
+ dropSession(env, existing.client);
56
+ }
57
+ const client = connect(APNS_HOST[env]);
58
+ client.on('error', () => dropSession(env, client));
59
+ client.on('close', () => { if (sessions.get(env)?.client === client) sessions.delete(env); });
60
+ sessions.set(env, { client, createdAt: clock.now() });
61
+ return client;
62
+ };
63
+
64
+ const dispatch = (input, forceJwt) => new Promise((resolve) => {
65
+ const jwt = getJwt(forceJwt);
66
+ let client;
67
+ try { client = getSession(input.env); } catch { resolve({ ok: false }); return; }
68
+ const headers = {
69
+ ':method': 'POST',
70
+ ':path': `/3/device/${input.token}`,
71
+ authorization: `bearer ${jwt}`,
72
+ 'apns-topic': bundleId,
73
+ 'apns-push-type': 'alert',
74
+ 'apns-priority': '10',
75
+ };
76
+ if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
77
+ let req;
78
+ try { req = client.request(headers); } catch {
79
+ dropSession(input.env, client);
80
+ resolve({ ok: false });
81
+ return;
82
+ }
83
+ let status = 0;
84
+ const chunks = [];
85
+ let responseBytes = 0;
86
+ let settled = false;
87
+ const finish = (result) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ clock.clearTimeout(timer);
91
+ resolve(result);
92
+ };
93
+ const timer = clock.setTimeout(() => {
94
+ try { req.close(); } catch { /* ignore */ }
95
+ finish({ ok: false });
96
+ }, options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
97
+ req.on('response', (responseHeaders) => { status = Number(responseHeaders[':status']) || 0; });
98
+ req.on('data', (chunk) => {
99
+ if (responseBytes >= MAX_RESPONSE_BYTES) return;
100
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
101
+ const take = buf.length > MAX_RESPONSE_BYTES - responseBytes ? buf.subarray(0, MAX_RESPONSE_BYTES - responseBytes) : buf;
102
+ chunks.push(take);
103
+ responseBytes += take.length;
104
+ });
105
+ req.on('end', () => {
106
+ if (status === 200) { finish({ ok: true }); return; }
107
+ let reason = '';
108
+ try { reason = JSON.parse(Buffer.concat(chunks, responseBytes).toString('utf8'))?.reason || ''; } catch { /* non-JSON */ }
109
+ if (reason === 'ExpiredProviderToken') { finish({ ok: false, expired: true }); return; }
110
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { finish({ ok: false, drop: true }); return; }
111
+ finish({ ok: false });
112
+ });
113
+ req.on('error', () => {
114
+ if (!settled) dropSession(input.env, client);
115
+ finish({ ok: false });
116
+ });
117
+ req.end(JSON.stringify(input.payload));
118
+ });
119
+
120
+ return {
121
+ async send(input) {
122
+ const first = await dispatch(input, false);
123
+ if (first.expired) {
124
+ const retry = await dispatch(input, true);
125
+ return { ok: retry.ok === true, drop: retry.drop === true ? true : undefined };
126
+ }
127
+ return { ok: first.ok === true, drop: first.drop === true ? true : undefined };
128
+ },
129
+ close() {
130
+ for (const [env, entry] of sessions) {
131
+ sessions.delete(env);
132
+ try { entry.client.close(); } catch { /* ignore */ }
133
+ }
134
+ cachedJwt = null;
135
+ },
136
+ };
137
+ };
@@ -0,0 +1,73 @@
1
+ import { startPushRelayServer } from './server.js';
2
+ import { buildPushRelayConfig, fail, formatPushRelayUrl } from './config.js';
3
+
4
+ export { buildPushRelayConfig } from './config.js';
5
+
6
+ export const parsePushRelayArgs = (argv = []) => {
7
+ const parsed = {};
8
+ const values = new Map([['--host', 'host'], ['--port', 'port']]);
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const arg = argv[index];
11
+ if (values.has(arg)) {
12
+ const value = argv[++index];
13
+ if (!value || value.startsWith('--')) fail(arg);
14
+ parsed[values.get(arg)] = value;
15
+ continue;
16
+ }
17
+ if (arg === '--trust-proxy') { parsed.trustProxy = true; continue; }
18
+ if (arg === '--no-trust-proxy') { parsed.trustProxy = false; continue; }
19
+ if (arg === '--json') { parsed.json = true; continue; }
20
+ if (arg === '--quiet' || arg === '-q') { parsed.quiet = true; continue; }
21
+ if (arg === '--help' || arg === '-h') { parsed.help = true; continue; }
22
+ if (arg === '--version' || arg === '-v') { parsed.version = true; continue; }
23
+ fail(arg);
24
+ }
25
+ return parsed;
26
+ };
27
+
28
+ const helpText = 'Usage: openchamber-push-relay [--host HOST] [--port PORT] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
29
+ const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
30
+
31
+ export const runPushRelayCli = async (argv, dependencies = {}) => {
32
+ const processLike = dependencies.process ?? process;
33
+ const stdout = dependencies.stdout ?? process.stdout;
34
+ const stderr = dependencies.stderr ?? process.stderr;
35
+ const version = dependencies.version ?? '0.0.0';
36
+ let parsed;
37
+ try { parsed = parsePushRelayArgs(argv ?? processLike.argv?.slice(2) ?? []); } catch (error) {
38
+ const json = (argv ?? processLike.argv?.slice(2) ?? []).includes('--json');
39
+ if (json) writeJson(stdout, { status: 'error', error: error.message }); else stderr.write(`${error.message}\n`);
40
+ processLike.exitCode = 1; return 1;
41
+ }
42
+ const json = parsed.json;
43
+ const respond = (payload, error = false, essential = false) => {
44
+ if (json) writeJson(stdout, payload);
45
+ else if (payload.message && (essential || !parsed.quiet || error)) (error ? stderr : stdout).write(`${payload.message}\n`);
46
+ };
47
+ if (parsed.help) { respond(json ? { status: 'ok', help: helpText.trim() } : { message: helpText.trim() }, false, true); return 0; }
48
+ if (parsed.version) { respond(json ? { status: 'ok', version } : { message: version }, false, true); return 0; }
49
+ let config;
50
+ try { config = buildPushRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
51
+ try {
52
+ const relay = await (dependencies.start ?? startPushRelayServer)(config);
53
+ const port = relay.address?.()?.port ?? config.port;
54
+ const url = formatPushRelayUrl(config.host, port);
55
+ respond(json ? { status: 'ok', url, host: config.host, port } : { message: `Push relay listening at ${url}` });
56
+ let stopping = false;
57
+ const stop = async () => {
58
+ if (stopping) return Promise.resolve();
59
+ stopping = true;
60
+ processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
61
+ try {
62
+ await relay.stop();
63
+ processLike.exit?.(0);
64
+ } catch {
65
+ processLike.exitCode = 1;
66
+ if (json) writeJson(stderr, { status: 'error', error: 'Push relay stop failed' }); else stderr.write('Push relay stop failed\n');
67
+ processLike.exit?.(1);
68
+ }
69
+ };
70
+ processLike.on?.('SIGINT', stop); processLike.on?.('SIGTERM', stop);
71
+ return 0;
72
+ } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
73
+ };