@openchambery/relay-server 1.18.7-beta.2 → 1.18.7-beta.3

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,50 @@ 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. 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 `openchamber-relay` sees any non-empty `OPENCHAMBER_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 `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT`. Public `/healthz` and `/readyz` stay Layer 1. The standalone `openchamber-push-relay` entry is unchanged.
49
+
50
+ ```sh
51
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
52
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
53
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
54
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
55
+ export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
56
+ openchamber-relay --public-url wss://relay.example.com/ws
57
+ ```
58
+
59
+ Minimal Compose environment:
60
+
61
+ ```yaml
62
+ services:
63
+ relay:
64
+ image: openchamber-relay:<version>
65
+ environment:
66
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
67
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
68
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
69
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
70
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
71
+ OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
72
+ ports:
73
+ - "127.0.0.1:8787:8787"
74
+ ```
75
+
30
76
  ### Caddy
31
77
 
32
78
  ```caddyfile
@@ -39,6 +85,24 @@ relay.example.com {
39
85
 
40
86
  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
87
 
88
+ Shared hostname with Push:
89
+
90
+ ```caddyfile
91
+ relay.example.com {
92
+ handle /v1/push/* {
93
+ reverse_proxy 127.0.0.1:8788 {
94
+ header_up X-Forwarded-For {remote_host}
95
+ }
96
+ }
97
+
98
+ handle {
99
+ reverse_proxy 127.0.0.1:8787 {
100
+ header_up X-Forwarded-For {remote_host}
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
42
106
  ### Nginx
43
107
 
44
108
  ```nginx
@@ -60,11 +124,11 @@ server {
60
124
  }
61
125
  ```
62
126
 
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.
127
+ 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
128
 
65
129
  ## Docker
66
130
 
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:
131
+ 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
132
 
69
133
  ```text
70
134
  <DOCKERHUB_USERNAME>/openchamber-relay:<version>
@@ -109,6 +173,27 @@ curl -fsS https://relay.example.com/healthz
109
173
  curl -fsS https://relay.example.com/readyz
110
174
  ```
111
175
 
176
+ 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.
177
+
178
+ ```sh
179
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
180
+ RELAY_DOMAIN=relay.example.com \
181
+ ACME_EMAIL=admin@example.com \
182
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
183
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
184
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
185
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
186
+ docker compose -f docker-compose.relay-push.remote.yml up -d
187
+ ```
188
+
189
+ ```sh
190
+ docker compose -f docker-compose.relay-push.remote.yml ps
191
+ curl -fsS https://relay.example.com/healthz
192
+ curl -fsS https://relay.example.com/readyz
193
+ ```
194
+
195
+ 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.
196
+
112
197
  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
198
 
114
199
  ```sh
@@ -117,7 +202,7 @@ OPENCHAMBER_RELAY_PUBLISHED_PORT=8787 \
117
202
  docker compose -f docker-compose.relay.yml up -d --build
118
203
  ```
119
204
 
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.
205
+ 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
206
 
122
207
  ## Connect Hosts
123
208
 
@@ -134,7 +219,7 @@ Existing clients switch to a new Relay after a new pairing flow; generate a fres
134
219
 
135
220
  ## Configuration
136
221
 
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.
222
+ 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
223
 
139
224
  `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
225
 
@@ -179,12 +264,44 @@ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:
179
264
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
180
265
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
181
266
 
267
+ Host Push URL (OpenChamber Host, not the Push process):
268
+
269
+ | Variable | Default | Unit / purpose |
270
+ | --- | --- | --- |
271
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay URL | explicit `…/v1/push/send` override |
272
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | `true` disables Push Relay on the Host |
273
+
274
+ Push process:
275
+
276
+ | Variable | Default | Unit / purpose |
277
+ | --- | --- | --- |
278
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
279
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
280
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
281
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file |
282
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
283
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
284
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
285
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per client IP per minute |
286
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per client IP per minute |
287
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
288
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
289
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
290
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
291
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
292
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
293
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM |
294
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file |
295
+
296
+ 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.
297
+
182
298
  ## Operations
183
299
 
184
300
  - `GET` and `HEAD` requests to `/healthz` return process health. `/readyz` returns ready status after the listener reaches running state.
185
301
  - `SIGTERM` and `SIGINT` begin graceful Relay shutdown. Docker grants a 30-second stop period.
186
302
  - 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.
303
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, APNs `.p8` material, and device tokens.
304
+ - Run one Push process per SQLite database file.
188
305
 
189
306
  ### systemd
190
307
 
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,57 @@ 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
+ ### Combined mode (single port)
63
+
64
+ `openchamber-relay` can mount Push on the same listener. If any `OPENCHAMBER_PUSH_RELAY_APNS_*` variable is non-empty, Layer 1 loads Push and serves `/v1/push/*` on the Relay port. Partial APNs configuration fails startup; it does not silently disable Push. With no APNs variables set, Layer 1 behavior is unchanged and Push is not loaded.
65
+
66
+ In combined mode:
67
+
68
+ - Push routes share the Relay port at `/v1/push/*`.
69
+ - `/healthz` and `/readyz` remain Layer 1 endpoints.
70
+ - `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT` are ignored.
71
+ - `openchamber-push-relay` is unchanged and remains the isolated two-process option.
72
+
73
+ ```sh
74
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
75
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
76
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
77
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
78
+ export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
79
+ openchamber-relay --public-url wss://relay.example.com/ws
80
+ ```
81
+
82
+ Minimal Compose environment for combined mode:
83
+
84
+ ```yaml
85
+ services:
86
+ relay:
87
+ image: openchamber-relay:<version>
88
+ environment:
89
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
90
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
91
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
92
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
93
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
94
+ OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
95
+ ports:
96
+ - "127.0.0.1:8787:8787"
97
+ ```
98
+
99
+ 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.
100
+
39
101
  ### Build a standalone executable
40
102
 
41
103
  Run these commands from the repository root. `bun build --compile` creates a single executable for the current platform and architecture.
@@ -91,6 +153,18 @@ Available CLI options:
91
153
  --version, -v
92
154
  ```
93
155
 
156
+ Push CLI options:
157
+
158
+ ```text
159
+ --host HOST
160
+ --port PORT
161
+ --trust-proxy | --no-trust-proxy
162
+ --json
163
+ --quiet, -q
164
+ --help, -h
165
+ --version, -v
166
+ ```
167
+
94
168
  ## Connect OpenChamber Hosts
95
169
 
96
170
  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 +194,24 @@ relay.example.com {
120
194
 
121
195
  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
196
 
197
+ When Push shares the hostname, send `/v1/push/*` to the Push listener and replace `X-Forwarded-For` once on each upstream:
198
+
199
+ ```caddyfile
200
+ relay.example.com {
201
+ handle /v1/push/* {
202
+ reverse_proxy 127.0.0.1:8788 {
203
+ header_up X-Forwarded-For {remote_host}
204
+ }
205
+ }
206
+
207
+ handle {
208
+ reverse_proxy 127.0.0.1:8787 {
209
+ header_up X-Forwarded-For {remote_host}
210
+ }
211
+ }
212
+ }
213
+ ```
214
+
123
215
  ### Nginx
124
216
 
125
217
  ```nginx
@@ -143,6 +235,8 @@ server {
143
235
 
144
236
  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
237
 
238
+ 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.
239
+
146
240
  ## Trusted proxies and capacity
147
241
 
148
242
  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 +289,43 @@ Trusted-proxy mode accepts exactly one valid IP address in `X-Forwarded-For`. Cl
195
289
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
196
290
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
197
291
 
292
+ ### Host Push URL override
293
+
294
+ These variables belong on the OpenChamber Host, not on the Push process:
295
+
296
+ | Variable | Default | Unit / purpose |
297
+ | --- | --- | --- |
298
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay `ws`/`wss` URL | Host override for `https://` or `http://` `…/v1/push/send` |
299
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | Host-only; `true` skips Push Relay and uses direct APNs |
300
+
301
+ 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`. iOS Live Activity tokens use `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity` on that same origin. Live Activity APNs requests use topic `{bundleId}.push-type.liveactivity` and never include session IDs, titles, alerts, or collapse IDs.
302
+
303
+ ### Push process environment
304
+
305
+ Command flags take precedence over `OPENCHAMBER_PUSH_RELAY_*` variables, which take precedence over defaults. APNs Key ID, Team ID, and `.p8` material are required.
306
+
307
+ | Variable | Default | Unit / purpose |
308
+ | --- | --- | --- |
309
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
310
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
311
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical Client IP from proxy-replaced `X-Forwarded-For` |
312
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file; directory must be writable |
313
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
314
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
315
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
316
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per Client IP per minute |
317
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per Client IP per minute |
318
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
319
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
320
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
321
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
322
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
323
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
324
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM; literal `\n` accepted |
325
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file; used when `APNS_P8` is empty |
326
+
327
+ 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.
328
+
198
329
  ## systemd
199
330
 
200
331
  Create `/etc/openchamber-relay.env`:
@@ -236,11 +367,12 @@ sudo systemctl status openchamber-relay
236
367
  - `SIGTERM` and `SIGINT` start graceful Relay shutdown. Hosts reconnect after a process restart.
237
368
  - Size Host, Client, pending, socket, frame, and queue limits for expected concurrency and message volume.
238
369
  - 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.
370
+ - 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.
371
+ - Run a single Push process per SQLite file. WAL mode does not make multi-instance sharing safe.
240
372
 
241
373
  ## Docker delivery assets
242
374
 
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.
375
+ 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
376
 
245
377
  The `Relay Docker` workflow can republish only the current Relay package version without creating or modifying a GitHub Release or other platform artifacts.
246
378
 
@@ -270,6 +402,23 @@ docker compose -f docker-compose.relay.remote.yml up -d
270
402
 
271
403
  `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
404
 
405
+ 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):
406
+
407
+ ```sh
408
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
409
+ RELAY_DOMAIN=relay.example.com \
410
+ ACME_EMAIL=admin@example.com \
411
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
412
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
413
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
414
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
415
+ docker compose -f docker-compose.relay-push.remote.yml up -d
416
+ ```
417
+
418
+ 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.
419
+
420
+ 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.
421
+
273
422
  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
423
 
275
424
  ```sh
@@ -291,6 +440,9 @@ These assets define an optional follow-on deployment path. Validate the image, p
291
440
  | 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
441
  | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing `OPENCHAMBER_RELAY_URL`. |
293
442
  | WebSocket application traffic fails while HTTP works | Confirm the Host endpoint mints and supplies a short-lived `oc_url_token` for the WebSocket path. |
443
+ | 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. |
444
+ | 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. |
445
+ | 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
446
 
295
447
  ## Development and test coverage
296
448
 
@@ -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.18.7-beta.2",
3
+ "version": "1.18.7-beta.3",
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 && node --check src/push/handler.js && node --check src/push/combined.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 && node --check src/push/handler.js && node --check src/push/combined.js",
45
51
  "build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
46
52
  },
47
53
  "dependencies": {
package/src/cli.js CHANGED
@@ -71,6 +71,16 @@ export const buildRelayConfig = (parsed = {}, env = process.env) => {
71
71
 
72
72
  const helpText = 'Usage: openchamber-relay [--host HOST] [--port PORT] [--path PATH] [--public-url WS_URL] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
73
73
  const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
74
+ const hasPushApnsEnv = (env) => {
75
+ for (const [key, value] of Object.entries(env ?? {})) {
76
+ if (key.startsWith('OPENCHAMBER_PUSH_RELAY_APNS_') && typeof value === 'string' && value.trim().length > 0) return true;
77
+ }
78
+ return false;
79
+ };
80
+ const loadCombinedPushMount = async () => {
81
+ // Computed specifier keeps bun --compile from bundling Push/SQLite into the Layer 1 binary.
82
+ return (await import(['.', 'push', 'combined.js'].join('/'))).createCombinedPushMount;
83
+ };
74
84
 
75
85
  export const runRelayServerCli = async (argv, dependencies = {}) => {
76
86
  const processLike = dependencies.process ?? process; const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const version = dependencies.version ?? '0.0.0';
@@ -87,15 +97,24 @@ export const runRelayServerCli = async (argv, dependencies = {}) => {
87
97
  let config;
88
98
  try { config = buildRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
89
99
  try {
100
+ const env = processLike.env ?? {};
101
+ const createPushMount = dependencies.createPushMount ?? (
102
+ hasPushApnsEnv(env) ? await loadCombinedPushMount() : () => null
103
+ );
104
+ const mount = createPushMount(env, dependencies.pushMountDeps ?? {});
105
+ if (mount) config.requestHandler = mount.requestHandler;
90
106
  const relay = await (dependencies.start ?? startPrivateRelayServer)(config);
107
+ if (mount) await mount.start();
91
108
  const url = config.publicUrl ?? relay.wsUrl;
92
- respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path } : { message: `Relay listening at ${url}` });
109
+ respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path, ...(mount ? { push: true } : {}) } : { message: `Relay listening at ${url}` });
110
+ if (mount && !json) respond({ message: 'Push relay mounted at /v1/push/*' });
93
111
  let stopping = false;
94
112
  const stop = async () => {
95
113
  if (stopping) return Promise.resolve();
96
114
  stopping = true;
97
115
  processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
98
116
  try {
117
+ if (mount) await mount.stop();
99
118
  await relay.stop();
100
119
  processLike.exit?.(0);
101
120
  } catch {