@openchambery/relay-server 1.19.0-beta.37 → 1.19.0-beta.39
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 +91 -6
- package/README.md +119 -4
- package/package.json +1 -1
- package/src/push/apns.js +29 -12
- package/src/push/config.js +1 -1
- package/src/push/guard.js +73 -8
- package/src/push/server.js +46 -15
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/package.json
CHANGED
package/src/push/apns.js
CHANGED
|
@@ -6,6 +6,7 @@ const APNS_HOST = {
|
|
|
6
6
|
sandbox: 'https://api.sandbox.push.apple.com',
|
|
7
7
|
};
|
|
8
8
|
const JWT_TTL_MS = 50 * 60 * 1000;
|
|
9
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 5_000;
|
|
10
11
|
const MAX_RESPONSE_BYTES = 4_096;
|
|
11
12
|
export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
|
|
@@ -15,6 +16,7 @@ const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/
|
|
|
15
16
|
export const createApnsProvider = (options = {}) => {
|
|
16
17
|
const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
|
|
17
18
|
const connect = options.http2?.connect ?? http2.connect;
|
|
19
|
+
const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
|
|
18
20
|
let privateKey;
|
|
19
21
|
try {
|
|
20
22
|
privateKey = crypto.createPrivateKey(normalizePem(options.p8));
|
|
@@ -41,17 +43,21 @@ export const createApnsProvider = (options = {}) => {
|
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
const dropSession = (env, client) => {
|
|
44
|
-
|
|
46
|
+
const current = sessions.get(env);
|
|
47
|
+
if (current?.client === client) sessions.delete(env);
|
|
45
48
|
try { client.close(); } catch { /* session already gone */ }
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
const getSession = (env) => {
|
|
49
52
|
const existing = sessions.get(env);
|
|
50
|
-
if (existing && !existing.closed && existing.destroyed !== true)
|
|
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
|
+
}
|
|
51
57
|
const client = connect(APNS_HOST[env]);
|
|
52
58
|
client.on('error', () => dropSession(env, client));
|
|
53
|
-
client.on('close', () => { if (sessions.get(env) === client) sessions.delete(env); });
|
|
54
|
-
sessions.set(env, client);
|
|
59
|
+
client.on('close', () => { if (sessions.get(env)?.client === client) sessions.delete(env); });
|
|
60
|
+
sessions.set(env, { client, createdAt: clock.now() });
|
|
55
61
|
return client;
|
|
56
62
|
};
|
|
57
63
|
|
|
@@ -69,9 +75,14 @@ export const createApnsProvider = (options = {}) => {
|
|
|
69
75
|
};
|
|
70
76
|
if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
|
|
71
77
|
let req;
|
|
72
|
-
try { req = client.request(headers); } catch {
|
|
78
|
+
try { req = client.request(headers); } catch {
|
|
79
|
+
dropSession(input.env, client);
|
|
80
|
+
resolve({ ok: false });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
73
83
|
let status = 0;
|
|
74
|
-
|
|
84
|
+
const chunks = [];
|
|
85
|
+
let responseBytes = 0;
|
|
75
86
|
let settled = false;
|
|
76
87
|
const finish = (result) => {
|
|
77
88
|
if (settled) return;
|
|
@@ -84,19 +95,25 @@ export const createApnsProvider = (options = {}) => {
|
|
|
84
95
|
finish({ ok: false });
|
|
85
96
|
}, options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
86
97
|
req.on('response', (responseHeaders) => { status = Number(responseHeaders[':status']) || 0; });
|
|
87
|
-
req.setEncoding('utf8');
|
|
88
98
|
req.on('data', (chunk) => {
|
|
89
|
-
if (
|
|
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;
|
|
90
104
|
});
|
|
91
105
|
req.on('end', () => {
|
|
92
106
|
if (status === 200) { finish({ ok: true }); return; }
|
|
93
107
|
let reason = '';
|
|
94
|
-
try { reason = JSON.parse(
|
|
108
|
+
try { reason = JSON.parse(Buffer.concat(chunks, responseBytes).toString('utf8'))?.reason || ''; } catch { /* non-JSON */ }
|
|
95
109
|
if (reason === 'ExpiredProviderToken') { finish({ ok: false, expired: true }); return; }
|
|
96
110
|
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { finish({ ok: false, drop: true }); return; }
|
|
97
111
|
finish({ ok: false });
|
|
98
112
|
});
|
|
99
|
-
req.on('error', () =>
|
|
113
|
+
req.on('error', () => {
|
|
114
|
+
if (!settled) dropSession(input.env, client);
|
|
115
|
+
finish({ ok: false });
|
|
116
|
+
});
|
|
100
117
|
req.end(JSON.stringify(input.payload));
|
|
101
118
|
});
|
|
102
119
|
|
|
@@ -110,9 +127,9 @@ export const createApnsProvider = (options = {}) => {
|
|
|
110
127
|
return { ok: first.ok === true, drop: first.drop === true ? true : undefined };
|
|
111
128
|
},
|
|
112
129
|
close() {
|
|
113
|
-
for (const [env,
|
|
130
|
+
for (const [env, entry] of sessions) {
|
|
114
131
|
sessions.delete(env);
|
|
115
|
-
try { client.close(); } catch { /* ignore */ }
|
|
132
|
+
try { entry.client.close(); } catch { /* ignore */ }
|
|
116
133
|
}
|
|
117
134
|
cachedJwt = null;
|
|
118
135
|
},
|
package/src/push/config.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isIP } from 'node:net';
|
|
|
4
4
|
export const DEFAULT_HOST = '127.0.0.1';
|
|
5
5
|
export const DEFAULT_PORT = 8788;
|
|
6
6
|
export const DEFAULT_DATABASE_PATH = './data/push-relay.sqlite';
|
|
7
|
-
export const DEFAULT_BUNDLE_ID = 'com.openchamber
|
|
7
|
+
export const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
|
|
8
8
|
export const ENV_PREFIX = 'OPENCHAMBER_PUSH_RELAY_';
|
|
9
9
|
export const DEFAULT_LIMITS = {
|
|
10
10
|
timestampSkewMs: 300_000,
|
package/src/push/guard.js
CHANGED
|
@@ -57,24 +57,89 @@ export const createSlidingWindowLimiter = ({ windowMs, maxCount, maxEntries, now
|
|
|
57
57
|
|
|
58
58
|
export const createInFlightGate = (maxInFlight) => {
|
|
59
59
|
const maxWaiters = maxInFlight;
|
|
60
|
+
let generation = 1;
|
|
60
61
|
let active = 0;
|
|
62
|
+
let accepting = true;
|
|
61
63
|
const waiters = [];
|
|
64
|
+
const idleWaiters = [];
|
|
65
|
+
const notifyIdle = () => {
|
|
66
|
+
if (active !== 0) return;
|
|
67
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
68
|
+
};
|
|
62
69
|
return {
|
|
63
70
|
acquire() {
|
|
64
|
-
if (
|
|
71
|
+
if (!accepting) return Promise.resolve(false);
|
|
72
|
+
if (active < maxInFlight) {
|
|
73
|
+
active += 1;
|
|
74
|
+
return Promise.resolve(generation);
|
|
75
|
+
}
|
|
65
76
|
if (waiters.length >= maxWaiters) return Promise.resolve(false);
|
|
66
|
-
|
|
77
|
+
const gen = generation;
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
waiters.push(() => {
|
|
80
|
+
if (!accepting || generation !== gen) { resolve(false); return; }
|
|
81
|
+
resolve(generation);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
release(ticket) {
|
|
86
|
+
if (ticket !== generation) return;
|
|
87
|
+
if (accepting) {
|
|
88
|
+
const next = waiters.shift();
|
|
89
|
+
if (next) { next(); return; }
|
|
90
|
+
} else {
|
|
91
|
+
while (waiters.length) waiters.shift()();
|
|
92
|
+
}
|
|
93
|
+
active = Math.max(0, active - 1);
|
|
94
|
+
notifyIdle();
|
|
67
95
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
else active = Math.max(0, active - 1);
|
|
96
|
+
rejectWaiters() {
|
|
97
|
+
accepting = false;
|
|
98
|
+
while (waiters.length) waiters.shift()();
|
|
72
99
|
},
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
reset() {
|
|
101
|
+
generation += 1;
|
|
102
|
+
accepting = true;
|
|
75
103
|
active = 0;
|
|
104
|
+
waiters.length = 0;
|
|
105
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
106
|
+
},
|
|
107
|
+
whenIdle() {
|
|
108
|
+
if (active === 0) return Promise.resolve();
|
|
109
|
+
return new Promise((resolve) => { idleWaiters.push(resolve); });
|
|
76
110
|
},
|
|
77
111
|
get active() { return active; },
|
|
78
112
|
get waiting() { return waiters.length; },
|
|
79
113
|
};
|
|
80
114
|
};
|
|
115
|
+
|
|
116
|
+
export const createWorkTracker = () => {
|
|
117
|
+
let generation = 1;
|
|
118
|
+
let active = 0;
|
|
119
|
+
const idleWaiters = [];
|
|
120
|
+
const notifyIdle = () => {
|
|
121
|
+
if (active !== 0) return;
|
|
122
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
begin() {
|
|
126
|
+
active += 1;
|
|
127
|
+
const gen = generation;
|
|
128
|
+
return () => {
|
|
129
|
+
if (gen !== generation) return;
|
|
130
|
+
active = Math.max(0, active - 1);
|
|
131
|
+
notifyIdle();
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
whenIdle() {
|
|
135
|
+
if (active === 0) return Promise.resolve();
|
|
136
|
+
return new Promise((resolve) => { idleWaiters.push(resolve); });
|
|
137
|
+
},
|
|
138
|
+
reset() {
|
|
139
|
+
generation += 1;
|
|
140
|
+
active = 0;
|
|
141
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
142
|
+
},
|
|
143
|
+
get active() { return active; },
|
|
144
|
+
};
|
|
145
|
+
};
|
package/src/push/server.js
CHANGED
|
@@ -3,11 +3,12 @@ import http from 'node:http';
|
|
|
3
3
|
import { createApnsProvider } from './apns.js';
|
|
4
4
|
import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
|
|
5
5
|
import { deriveServerId, verifyP1363 } from './crypto.js';
|
|
6
|
-
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter } from './guard.js';
|
|
6
|
+
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
|
|
7
7
|
import { JSON_BODY_BYTES, validateRegisterBody, validateSendBody } from './schema.js';
|
|
8
8
|
import { createTokenStore } from './store.js';
|
|
9
9
|
|
|
10
10
|
const WINDOW_MS = 60_000;
|
|
11
|
+
const STOP_DEADLINE_MS = 5_500;
|
|
11
12
|
|
|
12
13
|
const sendJson = (response, status, payload, method = 'GET') => {
|
|
13
14
|
if (response.writableEnded) return;
|
|
@@ -25,6 +26,10 @@ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
|
|
|
25
26
|
error.code = 'PAYLOAD_TOO_LARGE';
|
|
26
27
|
fail(error);
|
|
27
28
|
};
|
|
29
|
+
const drain = () => {
|
|
30
|
+
request.removeListener('data', onData);
|
|
31
|
+
request.resume();
|
|
32
|
+
};
|
|
28
33
|
const declared = Number(request.headers['content-length']);
|
|
29
34
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
30
35
|
tooLarge();
|
|
@@ -33,15 +38,16 @@ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
|
|
|
33
38
|
}
|
|
34
39
|
const chunks = [];
|
|
35
40
|
let size = 0;
|
|
36
|
-
|
|
41
|
+
const onData = (chunk) => {
|
|
37
42
|
size += chunk.length;
|
|
38
43
|
if (size > maxBytes) {
|
|
44
|
+
drain();
|
|
39
45
|
tooLarge();
|
|
40
|
-
request.destroy();
|
|
41
46
|
return;
|
|
42
47
|
}
|
|
43
48
|
chunks.push(chunk);
|
|
44
|
-
}
|
|
49
|
+
};
|
|
50
|
+
request.on('data', onData);
|
|
45
51
|
request.on('end', () => succeed(Buffer.concat(chunks)));
|
|
46
52
|
request.on('error', fail);
|
|
47
53
|
});
|
|
@@ -65,6 +71,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
65
71
|
}
|
|
66
72
|
const liveStore = () => {
|
|
67
73
|
if (!ownedStore) return store;
|
|
74
|
+
if (state === 'stopping') return store;
|
|
68
75
|
try { store.count(); return store; } catch {
|
|
69
76
|
store = createTokenStore(config.databasePath);
|
|
70
77
|
return store;
|
|
@@ -75,6 +82,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
75
82
|
const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
76
83
|
const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
77
84
|
const inFlight = createInFlightGate(limits.maxInFlight);
|
|
85
|
+
const httpWork = createWorkTracker();
|
|
78
86
|
const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
|
|
79
87
|
let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
|
|
80
88
|
const snapshot = () => {
|
|
@@ -93,9 +101,12 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
93
101
|
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
|
|
94
102
|
if (authError) return { status: 401, body: { error: authError } };
|
|
95
103
|
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
104
|
+
const replayKey = `register.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
105
|
+
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
96
106
|
const tokens = liveStore();
|
|
97
107
|
const existing = tokens.get(parsed.token);
|
|
98
108
|
if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
|
|
109
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
99
110
|
tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
|
|
100
111
|
return { status: 200, body: { ok: true } };
|
|
101
112
|
};
|
|
@@ -105,7 +116,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
105
116
|
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
|
|
106
117
|
if (authError) return { status: 401, body: { error: authError } };
|
|
107
118
|
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
108
|
-
const replayKey =
|
|
119
|
+
const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
109
120
|
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
110
121
|
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
111
122
|
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
@@ -125,7 +136,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
125
136
|
} catch {
|
|
126
137
|
return { token, ok: false };
|
|
127
138
|
} finally {
|
|
128
|
-
inFlight.release();
|
|
139
|
+
inFlight.release(acquired);
|
|
129
140
|
}
|
|
130
141
|
}));
|
|
131
142
|
return { status: 200, body: { results } };
|
|
@@ -142,10 +153,11 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
142
153
|
}
|
|
143
154
|
const isRegister = pathname === '/v1/push/register-token';
|
|
144
155
|
const isSend = pathname === '/v1/push/send';
|
|
145
|
-
if (request.method !== 'POST' || (!isRegister && !isSend)) { response.writeHead(404); response.end(); return; }
|
|
156
|
+
if (request.method !== 'POST' || (!isRegister && !isSend) || state !== 'running') { response.writeHead(404); response.end(); return; }
|
|
146
157
|
const ip = resolveClientIp(request);
|
|
147
158
|
const limiter = isRegister ? registerIpLimit : sendIpLimit;
|
|
148
159
|
if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
|
|
160
|
+
const endHttp = httpWork.begin();
|
|
149
161
|
readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
|
|
150
162
|
let body;
|
|
151
163
|
try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
|
|
@@ -160,7 +172,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
160
172
|
}).catch((error) => {
|
|
161
173
|
if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
|
|
162
174
|
sendJson(response, 500, { error: 'internal' });
|
|
163
|
-
});
|
|
175
|
+
}).finally(endHttp);
|
|
164
176
|
};
|
|
165
177
|
|
|
166
178
|
const start = () => {
|
|
@@ -168,6 +180,8 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
168
180
|
if (state === 'stopping') return stopPromise.then(() => start());
|
|
169
181
|
if (startPromise) return startPromise;
|
|
170
182
|
liveStore();
|
|
183
|
+
inFlight.reset();
|
|
184
|
+
httpWork.reset();
|
|
171
185
|
if (ownedApns && state === 'stopped') apns = openApns();
|
|
172
186
|
state = 'starting';
|
|
173
187
|
const localGeneration = ++generation;
|
|
@@ -208,19 +222,36 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
208
222
|
state = 'stopping';
|
|
209
223
|
generation += 1;
|
|
210
224
|
const localServer = server;
|
|
211
|
-
stopPromise =
|
|
212
|
-
inFlight.
|
|
225
|
+
stopPromise = Promise.resolve().then(async () => {
|
|
226
|
+
inFlight.rejectWaiters();
|
|
213
227
|
replay.clear();
|
|
214
228
|
registerIpLimit.clear();
|
|
215
229
|
sendIpLimit.clear();
|
|
216
230
|
sendServerLimit.clear();
|
|
231
|
+
let deadlineTimer = null;
|
|
232
|
+
try {
|
|
233
|
+
const closed = new Promise((resolve) => {
|
|
234
|
+
if (!localServer) { resolve(); return; }
|
|
235
|
+
localServer.close(() => resolve());
|
|
236
|
+
try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
|
|
237
|
+
});
|
|
238
|
+
const httpIdle = httpWork.whenIdle().then(() => {
|
|
239
|
+
try { localServer?.closeIdleConnections?.(); } catch { /* ignore */ }
|
|
240
|
+
});
|
|
241
|
+
const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
|
|
242
|
+
const deadline = new Promise((resolve) => {
|
|
243
|
+
deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
|
|
244
|
+
});
|
|
245
|
+
const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
|
|
246
|
+
if (winner === 'deadline') {
|
|
247
|
+
try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
|
|
251
|
+
}
|
|
217
252
|
try { apns.close?.(); } catch { /* ignore */ }
|
|
218
|
-
if (!localServer) return resolve();
|
|
219
|
-
localServer.close(() => resolve());
|
|
220
|
-
clock.setTimeout(resolve, 100);
|
|
221
|
-
}).then(() => {
|
|
222
|
-
if (server === localServer) { server = null; state = 'stopped'; }
|
|
223
253
|
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
254
|
+
if (server === localServer) { server = null; state = 'stopped'; }
|
|
224
255
|
stopPromise = null;
|
|
225
256
|
});
|
|
226
257
|
return stopPromise;
|