herdr-remote-relay 0.2.1 → 0.2.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/README.md +42 -70
- package/README.zh-CN.md +74 -0
- package/bin/herdr-remote-relay.js +16 -13
- package/config.example.json +5 -1
- package/deploy/systemd/relay.env.example +5 -0
- package/package.json +4 -3
- package/src/auth-store.js +10 -5
- package/src/metrics.js +84 -9
- package/src/relay-config.js +31 -0
- package/src/relay-server.js +564 -159
package/README.md
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
# herdr-remote-relay
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
it serves the mobile web terminal and brokers browser ↔ workstation sessions.
|
|
3
|
+
*[English](README.md) · [简体中文](README.zh-CN.md)*
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
compiler, no Herdr and no plugin. It never runs a shell and never touches your
|
|
8
|
-
Herdr socket — only the host connector on your own workstation does that.
|
|
5
|
+
Standalone relay server and WebUI for [Herdr Remote](https://www.npmjs.com/package/herdr-remote).
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
Brokers WebSocket connections between browsers and workstation host connectors. Runtime dependency is `ws` only; requires no compilers or Herdr binaries.
|
|
8
|
+
|
|
9
|
+
> **Note**: `herdr-remote` runs a local relay automatically. Install this package only when deploying a dedicated relay server.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
13
12
|
|
|
14
13
|
```bash
|
|
15
14
|
npm install -g herdr-remote-relay
|
|
16
|
-
herdr-remote-relay --public-url https://herdr.example.com
|
|
17
|
-
--password your-password --admin-token your-long-random-admin-token \
|
|
18
|
-
--trust-proxy
|
|
15
|
+
herdr-remote-relay --public-url https://herdr.example.com --password <password>
|
|
19
16
|
```
|
|
20
17
|
|
|
21
|
-
Requirements: Node.js 22+,
|
|
22
|
-
proxy in front of the relay — nginx, Caddy, Traefik and Cloudflare Tunnel all
|
|
23
|
-
work. Example configs for nginx, Cloudflare Tunnel, systemd and Docker Compose
|
|
24
|
-
ship in `deploy/` inside this package.
|
|
18
|
+
Requirements: Node.js 22+, TLS reverse proxy (nginx, Caddy, Cloudflare Tunnel).
|
|
25
19
|
|
|
26
20
|
## Docker
|
|
27
21
|
|
|
@@ -32,7 +26,7 @@ docker run -d --name herdr-relay --restart unless-stopped \
|
|
|
32
26
|
-e RELAY_BIND=0.0.0.0 \
|
|
33
27
|
-e RELAY_PUBLIC_URL=https://herdr.example.com \
|
|
34
28
|
-e RELAY_PASSWORD=your-password \
|
|
35
|
-
-e RELAY_ADMIN_TOKEN=your-
|
|
29
|
+
-e RELAY_ADMIN_TOKEN=your-admin-token \
|
|
36
30
|
-e RELAY_TRUST_PROXY=1 \
|
|
37
31
|
-e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
|
|
38
32
|
node:22-alpine npx -y herdr-remote-relay
|
|
@@ -40,63 +34,41 @@ docker run -d --name herdr-relay --restart unless-stopped \
|
|
|
40
34
|
|
|
41
35
|
## Options
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
|
48
|
-
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
On the workstation, run `herdr-remote`, open the **Relay** tab and set the
|
|
76
|
-
access mode to **Self-hosted relay**, the relay URL to
|
|
77
|
-
`wss://herdr.example.com`, and the relay password to the same `RELAY_PASSWORD`.
|
|
78
|
-
Then check:
|
|
79
|
-
|
|
37
|
+
| Option | Env Var | Default | Description |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | Public URL for browsers |
|
|
40
|
+
| `--password` | `RELAY_PASSWORD` | *(empty)* | Workstation join password (empty = public) |
|
|
41
|
+
| `--admin-token` | `RELAY_ADMIN_TOKEN` | *(empty)* | Operator token for `/admin` |
|
|
42
|
+
| `--bind` | `RELAY_BIND` | `127.0.0.1` | Listen address |
|
|
43
|
+
| `--port` | `RELAY_PORT` | `8787` | Listen port |
|
|
44
|
+
| `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | Trust `X-Forwarded-For` header (`1` behind proxy) |
|
|
45
|
+
| `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Auth state file path |
|
|
46
|
+
| `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Allowed CORS origins, comma-separated |
|
|
47
|
+
| `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | Maximum browser clients per host |
|
|
48
|
+
| `--max-hosts` | `RELAY_MAX_HOSTS` | `1024` | Maximum workstations on this relay |
|
|
49
|
+
| `--max-pending-handshakes` | `RELAY_MAX_PENDING_HANDSHAKES` | `1024` | Maximum unauthenticated WebSockets |
|
|
50
|
+
| `--max-buffered-bytes` | `RELAY_MAX_BUFFERED_BYTES_PER_CLIENT` | `4194304` | Maximum queued bytes per browser |
|
|
51
|
+
| `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | Host reconnect grace period |
|
|
52
|
+
| `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
|
|
53
|
+
|
|
54
|
+
Reverse proxies must pass WebSocket `Upgrade` headers and maintain long idle timeouts. The relay disables WebSocket compression and enables TCP NoDelay for terminal frames. When no browser is attached, business heartbeats stop and only WebSocket liveness probes remain. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
|
|
55
|
+
|
|
56
|
+
## Multiple workstations and isolation
|
|
57
|
+
|
|
58
|
+
A browser can save multiple workstation pairings. The switcher in the lower-left WebUI only shows profiles saved by that browser; it never enumerates other relay hosts. `/api/status` is scoped to the workstation bound to the presented credentials, while only `RELAY_ADMIN_TOKEN` can inspect relay-wide state. `/healthz` does not disclose host or client counts.
|
|
59
|
+
|
|
60
|
+
## Connecting a Workstation
|
|
61
|
+
|
|
62
|
+
In `herdr-remote` TUI under **Relay** tab:
|
|
63
|
+
- Set mode to **Self-hosted relay**
|
|
64
|
+
- Set Relay URL to `wss://herdr.example.com`
|
|
65
|
+
- Set Relay Password to matching `RELAY_PASSWORD`
|
|
66
|
+
|
|
67
|
+
Check connection status:
|
|
80
68
|
```bash
|
|
81
|
-
curl https://herdr.example.com/healthz
|
|
69
|
+
curl https://herdr.example.com/healthz
|
|
82
70
|
```
|
|
83
71
|
|
|
84
|
-
| Symptom | Cause |
|
|
85
|
-
|---|---|
|
|
86
|
-
| `hosts: 0` | Workstation not connected — check `herdr-remote status` |
|
|
87
|
-
| `relay_password_required` | Password differs between the two sides |
|
|
88
|
-
| Page loads, terminal never opens | Proxy is not forwarding `Upgrade` headers |
|
|
89
|
-
| Drops after ~60s idle | Proxy read timeout too short |
|
|
90
|
-
|
|
91
|
-
## Security
|
|
92
|
-
|
|
93
|
-
- Tokens are stored as SHA-256 hashes, never in clear text. Terminal content is
|
|
94
|
-
never written to disk.
|
|
95
|
-
- Pairing codes are single-use, expire in ten minutes, and are rate limited.
|
|
96
|
-
- The `/admin` operator credential is separate from the workstation join
|
|
97
|
-
password and from paired device tokens.
|
|
98
|
-
- Losing the state file just means re-pairing your devices.
|
|
99
|
-
|
|
100
72
|
## License
|
|
101
73
|
|
|
102
74
|
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# herdr-remote-relay
|
|
2
|
+
|
|
3
|
+
*[English](README.md) · [简体中文](README.zh-CN.md)*
|
|
4
|
+
|
|
5
|
+
[Herdr Remote](https://www.npmjs.com/package/herdr-remote) 的独立 Relay 服务端与 WebUI。
|
|
6
|
+
|
|
7
|
+
负责在浏览器与工作站主机连接器之间转发 WebSocket 连接。运行时仅依赖 `ws`,无需编译环境或 Herdr 二进制文件。
|
|
8
|
+
|
|
9
|
+
> **提示**:`herdr-remote` 默认在本地自动运行 relay。仅在需要部署独立中继服务器时安装本包。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g herdr-remote-relay
|
|
15
|
+
herdr-remote-relay --public-url https://herdr.example.com --password <密码>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
前置要求:Node.js 22+、TLS 反向代理(nginx、Caddy、Cloudflare Tunnel 等)。
|
|
19
|
+
|
|
20
|
+
## Docker 运行
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
docker run -d --name herdr-relay --restart unless-stopped \
|
|
24
|
+
-p 127.0.0.1:8787:8787 \
|
|
25
|
+
-v herdr-relay:/data \
|
|
26
|
+
-e RELAY_BIND=0.0.0.0 \
|
|
27
|
+
-e RELAY_PUBLIC_URL=https://herdr.example.com \
|
|
28
|
+
-e RELAY_PASSWORD=你的密码 \
|
|
29
|
+
-e RELAY_ADMIN_TOKEN=你的管理令牌 \
|
|
30
|
+
-e RELAY_TRUST_PROXY=1 \
|
|
31
|
+
-e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
|
|
32
|
+
node:22-alpine npx -y herdr-remote-relay
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 配置参数
|
|
36
|
+
|
|
37
|
+
| 参数 | 环境变量 | 默认值 | 说明 |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | 浏览器访问的公开地址 |
|
|
40
|
+
| `--password` | `RELAY_PASSWORD` | *(空)* | 工作站接入密码(留空为公用 Relay) |
|
|
41
|
+
| `--admin-token` | `RELAY_ADMIN_TOKEN` | *(空)* | `/admin` 管理后台操作员令牌 |
|
|
42
|
+
| `--bind` | `RELAY_BIND` | `127.0.0.1` | 监听地址 |
|
|
43
|
+
| `--port` | `RELAY_PORT` | `8787` | 监听端口 |
|
|
44
|
+
| `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | 信任 `X-Forwarded-For` 头(反代后设为 `1`) |
|
|
45
|
+
| `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | 认证状态文件路径 |
|
|
46
|
+
| `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(同源)* | 允许的跨域源,逗号分隔 |
|
|
47
|
+
| `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | 每台工作站最大客户端连接数 |
|
|
48
|
+
| `--max-hosts` | `RELAY_MAX_HOSTS` | `1024` | relay 最大工作站数 |
|
|
49
|
+
| `--max-pending-handshakes` | `RELAY_MAX_PENDING_HANDSHAKES` | `1024` | 最大未认证 WebSocket 握手数 |
|
|
50
|
+
| `--max-buffered-bytes` | `RELAY_MAX_BUFFERED_BYTES_PER_CLIENT` | `4194304` | 单个慢浏览器最大待发送缓冲 |
|
|
51
|
+
| `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | 工作站断线恢复宽限时间 |
|
|
52
|
+
| `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
|
|
53
|
+
|
|
54
|
+
反向代理必须转发 WebSocket `Upgrade` 头,并设置较长空闲超时时间。relay 会关闭 WebSocket 压缩并启用 TCP NoDelay;没有浏览器连接时,工作站会停止业务 heartbeat,仅保留 WebSocket 存活探测以节省流量。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
|
|
55
|
+
|
|
56
|
+
## 多工作站与隔离
|
|
57
|
+
|
|
58
|
+
同一个浏览器可以保存多个工作站配对,WebUI 左下角的实例切换器只显示本地已保存的实例,不会枚举 relay 上的其他工作站。`/api/status` 按设备令牌绑定的工作站隔离,只有 `RELAY_ADMIN_TOKEN` 才能查看 relay 全局状态;`/healthz` 不公开工作站和客户端数量。
|
|
59
|
+
|
|
60
|
+
## 工作站连接
|
|
61
|
+
|
|
62
|
+
在工作站的 `herdr-remote` TUI 中进入 **Relay** 页面:
|
|
63
|
+
- 访问方式选择 **自建 relay**
|
|
64
|
+
- Relay 地址填写 `wss://herdr.example.com`
|
|
65
|
+
- Relay 密码填写对应的 `RELAY_PASSWORD`
|
|
66
|
+
|
|
67
|
+
验证连接状态:
|
|
68
|
+
```bash
|
|
69
|
+
curl https://herdr.example.com/healthz
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 开源协议
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -17,22 +17,25 @@ const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remo
|
|
|
17
17
|
Usage: herdr-remote-relay [options]
|
|
18
18
|
|
|
19
19
|
Options:
|
|
20
|
-
--password <password>
|
|
21
|
-
|
|
22
|
-
--
|
|
23
|
-
--
|
|
24
|
-
--public-url <url> Public URL browsers use (RELAY_PUBLIC_URL)
|
|
20
|
+
--password <password> Workstation password (RELAY_PASSWORD, empty for public)
|
|
21
|
+
--admin-token <token> Admin dashboard token (RELAY_ADMIN_TOKEN)
|
|
22
|
+
--deployment-mode <mode> Deployment mode: local or remote (default remote)
|
|
23
|
+
--public-url <url> Public URL for browsers (RELAY_PUBLIC_URL)
|
|
25
24
|
--bind <address> Listen address (default 127.0.0.1, RELAY_BIND)
|
|
26
25
|
--port <number> Listen port (default 8787, RELAY_PORT)
|
|
27
|
-
--trust-proxy
|
|
28
|
-
--state-file <file>
|
|
29
|
-
--allowed-origins <list>
|
|
30
|
-
--max-clients <number> Max
|
|
31
|
-
--
|
|
32
|
-
-
|
|
33
|
-
-
|
|
26
|
+
--trust-proxy Trust X-Forwarded-For headers (RELAY_TRUST_PROXY)
|
|
27
|
+
--state-file <file> Auth state file path (RELAY_AUTH_STATE_FILE)
|
|
28
|
+
--allowed-origins <list> Allowed browser origins, comma-separated
|
|
29
|
+
--max-clients <number> Max clients per workstation (default 16)
|
|
30
|
+
--max-hosts <number> Max workstations on this relay (default 1024)
|
|
31
|
+
--max-pending-handshakes <n> Max unauthenticated WebSockets (default 1024)
|
|
32
|
+
--max-buffered-bytes <n> Max queued bytes per browser (default 4194304)
|
|
33
|
+
--host-reconnect-grace-ms <n> Grace period for host handoff (default 30000)
|
|
34
|
+
--config <file> JSON config file (HERDR_RELAY_CONFIG)
|
|
35
|
+
-h, --help Show help
|
|
36
|
+
-v, --version Show version
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
Guide: docs/self-hosted-relay.md
|
|
36
39
|
`;
|
|
37
40
|
|
|
38
41
|
function webUiBuilt() {
|
package/config.example.json
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
"host": "127.0.0.1",
|
|
5
5
|
"port": 8787,
|
|
6
6
|
"publicUrl": "https://herdr.example.com",
|
|
7
|
-
"trustProxy": true
|
|
7
|
+
"trustProxy": true,
|
|
8
|
+
"maxHosts": 1024,
|
|
9
|
+
"maxPendingHandshakes": 1024,
|
|
10
|
+
"maxBufferedBytesPerClient": 4194304,
|
|
11
|
+
"hostReconnectGraceMs": 30000
|
|
8
12
|
},
|
|
9
13
|
"auth": {
|
|
10
14
|
"password": "change-me",
|
|
@@ -5,6 +5,11 @@ RELAY_PUBLIC_URL=https://herdr.example.com
|
|
|
5
5
|
RELAY_DEPLOYMENT_MODE=remote
|
|
6
6
|
RELAY_BIND=127.0.0.1
|
|
7
7
|
RELAY_PORT=8787
|
|
8
|
+
RELAY_MAX_HOSTS=1024
|
|
9
|
+
RELAY_MAX_PENDING_HANDSHAKES=1024
|
|
10
|
+
RELAY_MAX_CLIENTS_PER_HOST=16
|
|
11
|
+
RELAY_MAX_BUFFERED_BYTES_PER_CLIENT=4194304
|
|
12
|
+
RELAY_HOST_RECONNECT_GRACE_MS=30000
|
|
8
13
|
|
|
9
14
|
# Password a workstation must present. Leave empty for a public relay.
|
|
10
15
|
RELAY_PASSWORD=change-me
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-remote-relay",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Standalone relay server
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Standalone relay server and web terminal for Herdr Remote",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"src",
|
|
38
38
|
"web/dist",
|
|
39
39
|
"deploy",
|
|
40
|
-
"config.example.json"
|
|
40
|
+
"config.example.json",
|
|
41
|
+
"README.zh-CN.md"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "npm --prefix web run build",
|
package/src/auth-store.js
CHANGED
|
@@ -18,6 +18,11 @@ function nowIso(now = Date.now()) {
|
|
|
18
18
|
return new Date(now).toISOString();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function validHostId(value) {
|
|
22
|
+
return typeof value === 'string'
|
|
23
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
class AuthStore {
|
|
22
27
|
constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null } = {}) {
|
|
23
28
|
if (!stateFile) throw new TypeError('stateFile is required');
|
|
@@ -57,7 +62,7 @@ class AuthStore {
|
|
|
57
62
|
* relay nobody else can impersonate an enrolled host or pair a device to it.
|
|
58
63
|
*/
|
|
59
64
|
registerHost(hostId, token, password = null, now = Date.now()) {
|
|
60
|
-
if (typeof
|
|
65
|
+
if (!validHostId(hostId) || typeof token !== 'string' || token.length < 16 || token.length > 4096) {
|
|
61
66
|
return { ok: false, code: 'invalid_host_credentials', message: 'hostId and token are required' };
|
|
62
67
|
}
|
|
63
68
|
if (!this.checkPassword(password)) {
|
|
@@ -84,7 +89,7 @@ class AuthStore {
|
|
|
84
89
|
|
|
85
90
|
/** Verify a host token without enrolling anything. */
|
|
86
91
|
authenticateHost(hostId, token) {
|
|
87
|
-
if (
|
|
92
|
+
if (!validHostId(hostId) || typeof token !== 'string' || token.length < 16 || token.length > 4096) return false;
|
|
88
93
|
const existing = this.state.hosts[hostId];
|
|
89
94
|
return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
|
|
90
95
|
}
|
|
@@ -94,7 +99,7 @@ class AuthStore {
|
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
startPairing(hostId, publicUrl, now = Date.now()) {
|
|
97
|
-
if (!this.state.hosts[hostId]) {
|
|
102
|
+
if (!validHostId(hostId) || !this.state.hosts[hostId]) {
|
|
98
103
|
const error = new Error('host is not connected or enrolled');
|
|
99
104
|
error.code = 'host_not_found';
|
|
100
105
|
throw error;
|
|
@@ -103,7 +108,7 @@ class AuthStore {
|
|
|
103
108
|
let code;
|
|
104
109
|
do {
|
|
105
110
|
code = randomToken(4).toUpperCase().replace(/[-_]/g, '').slice(0, 6);
|
|
106
|
-
} while ([...this.pairings.values()].some((pairing) => pairing.codeHash
|
|
111
|
+
} while ([...this.pairings.values()].some((pairing) => equalHash(pairing.codeHash, hash(code))));
|
|
107
112
|
const expiresAt = now + this.pairingTtlMs;
|
|
108
113
|
this.pairings.set(code, {
|
|
109
114
|
codeHash: hash(code),
|
|
@@ -119,7 +124,7 @@ class AuthStore {
|
|
|
119
124
|
this.cleanup(now);
|
|
120
125
|
const normalized = code.trim().toUpperCase();
|
|
121
126
|
const pairing = this.pairings.get(normalized);
|
|
122
|
-
if (!pairing || pairing.expiresAt <= now || pairing.codeHash
|
|
127
|
+
if (!pairing || pairing.expiresAt <= now || !equalHash(pairing.codeHash, hash(normalized))) return null;
|
|
123
128
|
this.pairings.delete(normalized);
|
|
124
129
|
|
|
125
130
|
const devices = Object.values(this.state.devices);
|
package/src/metrics.js
CHANGED
|
@@ -26,23 +26,92 @@ class RelayMetrics {
|
|
|
26
26
|
closedPtysCleaned: 0,
|
|
27
27
|
deadConnectionsClosed: 0,
|
|
28
28
|
idleHostsTerminated: 0,
|
|
29
|
+
slowClientsDropped: 0,
|
|
29
30
|
lastCleanupAt: null,
|
|
30
31
|
};
|
|
31
32
|
this.lastSample = { at: this.startedAt, bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 };
|
|
33
|
+
this.hostTraffic = new Map();
|
|
32
34
|
this.cpuSampleAt = process.hrtime.bigint();
|
|
33
35
|
this.cpuSample = process.cpuUsage();
|
|
34
36
|
this.eventLoop = monitorEventLoopDelay({ resolution: 20 });
|
|
35
37
|
this.eventLoop.enable();
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
hostCounter(hostId) {
|
|
41
|
+
if (typeof hostId !== 'string' || hostId.length === 0) return null;
|
|
42
|
+
let counter = this.hostTraffic.get(hostId);
|
|
43
|
+
if (!counter) {
|
|
44
|
+
counter = {
|
|
45
|
+
bytesIn: 0,
|
|
46
|
+
bytesOut: 0,
|
|
47
|
+
framesIn: 0,
|
|
48
|
+
framesOut: 0,
|
|
49
|
+
sampleAt: Date.now(),
|
|
50
|
+
sampleBytesIn: 0,
|
|
51
|
+
sampleBytesOut: 0,
|
|
52
|
+
sampleFramesIn: 0,
|
|
53
|
+
sampleFramesOut: 0,
|
|
54
|
+
};
|
|
55
|
+
this.hostTraffic.set(hostId, counter);
|
|
56
|
+
}
|
|
57
|
+
return counter;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
recordIn(bytes, hostId = null) {
|
|
61
|
+
const amount = Math.max(0, Number(bytes) || 0);
|
|
62
|
+
this.bytesIn += amount;
|
|
40
63
|
this.framesIn += 1;
|
|
64
|
+
const counter = this.hostCounter(hostId);
|
|
65
|
+
if (counter) {
|
|
66
|
+
counter.bytesIn += amount;
|
|
67
|
+
counter.framesIn += 1;
|
|
68
|
+
}
|
|
41
69
|
}
|
|
42
70
|
|
|
43
|
-
recordOut(bytes) {
|
|
44
|
-
|
|
71
|
+
recordOut(bytes, hostId = null) {
|
|
72
|
+
const amount = Math.max(0, Number(bytes) || 0);
|
|
73
|
+
this.bytesOut += amount;
|
|
45
74
|
this.framesOut += 1;
|
|
75
|
+
const counter = this.hostCounter(hostId);
|
|
76
|
+
if (counter) {
|
|
77
|
+
counter.bytesOut += amount;
|
|
78
|
+
counter.framesOut += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
forgetHost(hostId) {
|
|
83
|
+
if (typeof hostId === 'string') this.hostTraffic.delete(hostId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hostThroughput(hostId, now = Date.now()) {
|
|
87
|
+
const counter = this.hostCounter(hostId);
|
|
88
|
+
if (!counter) return {
|
|
89
|
+
bytesIn: 0,
|
|
90
|
+
bytesOut: 0,
|
|
91
|
+
bytesInPerSec: 0,
|
|
92
|
+
bytesOutPerSec: 0,
|
|
93
|
+
framesIn: 0,
|
|
94
|
+
framesOut: 0,
|
|
95
|
+
framesInPerSec: 0,
|
|
96
|
+
framesOutPerSec: 0,
|
|
97
|
+
};
|
|
98
|
+
const elapsedMs = now - counter.sampleAt;
|
|
99
|
+
const throughput = {
|
|
100
|
+
bytesIn: counter.bytesIn,
|
|
101
|
+
bytesOut: counter.bytesOut,
|
|
102
|
+
bytesInPerSec: bytesPerSecond(counter.bytesIn, counter.sampleBytesIn, elapsedMs),
|
|
103
|
+
bytesOutPerSec: bytesPerSecond(counter.bytesOut, counter.sampleBytesOut, elapsedMs),
|
|
104
|
+
framesIn: counter.framesIn,
|
|
105
|
+
framesOut: counter.framesOut,
|
|
106
|
+
framesInPerSec: bytesPerSecond(counter.framesIn, counter.sampleFramesIn, elapsedMs),
|
|
107
|
+
framesOutPerSec: bytesPerSecond(counter.framesOut, counter.sampleFramesOut, elapsedMs),
|
|
108
|
+
};
|
|
109
|
+
counter.sampleAt = now;
|
|
110
|
+
counter.sampleBytesIn = counter.bytesIn;
|
|
111
|
+
counter.sampleBytesOut = counter.bytesOut;
|
|
112
|
+
counter.sampleFramesIn = counter.framesIn;
|
|
113
|
+
counter.sampleFramesOut = counter.framesOut;
|
|
114
|
+
return throughput;
|
|
46
115
|
}
|
|
47
116
|
|
|
48
117
|
recordCleanup(name, amount = 1) {
|
|
@@ -74,7 +143,7 @@ class RelayMetrics {
|
|
|
74
143
|
};
|
|
75
144
|
}
|
|
76
145
|
|
|
77
|
-
snapshot({ clients = [], hosts = [], ptys = [], sample = true } = {}) {
|
|
146
|
+
snapshot({ clients = [], hosts = [], ptys = [], sample = true, scopeHostId = null } = {}) {
|
|
78
147
|
const now = Date.now();
|
|
79
148
|
const elapsedMs = now - this.lastSample.at;
|
|
80
149
|
const throughput = {
|
|
@@ -92,22 +161,27 @@ class RelayMetrics {
|
|
|
92
161
|
}
|
|
93
162
|
const memory = process.memoryUsage();
|
|
94
163
|
const load = os.loadavg();
|
|
95
|
-
|
|
164
|
+
// Every attached window has full input, so there is no "active controller"
|
|
165
|
+
// to name: reporting the first of them as one had an operator reading a
|
|
166
|
+
// distinction that does not exist. The host is still worth naming, and any
|
|
167
|
+
// client knows which workstation it is on.
|
|
168
|
+
const anyClient = clients[0];
|
|
169
|
+
const scopedThroughput = scopeHostId ? this.hostThroughput(scopeHostId, now) : throughput;
|
|
96
170
|
return {
|
|
97
171
|
version: this.version,
|
|
98
172
|
protocolVersion: this.protocolVersion,
|
|
99
173
|
uptimeSeconds: Math.floor((now - this.startedAt) / 1000),
|
|
100
174
|
startTime: new Date(this.startedAt).toISOString(),
|
|
101
175
|
serverTime: new Date(now).toISOString(),
|
|
102
|
-
activeControllerId:
|
|
103
|
-
activeHostId:
|
|
176
|
+
activeControllerId: null,
|
|
177
|
+
activeHostId: anyClient?.hostId || hosts[0]?.id || null,
|
|
104
178
|
clients,
|
|
105
179
|
hosts,
|
|
106
180
|
ptys,
|
|
107
181
|
clientCount: clients.length,
|
|
108
182
|
hostCount: hosts.length,
|
|
109
183
|
ptyCount: ptys.length,
|
|
110
|
-
throughput,
|
|
184
|
+
throughput: scopedThroughput,
|
|
111
185
|
cpu: {
|
|
112
186
|
load1m: finite(load[0]),
|
|
113
187
|
load5m: finite(load[1]),
|
|
@@ -130,6 +204,7 @@ class RelayMetrics {
|
|
|
130
204
|
|
|
131
205
|
close() {
|
|
132
206
|
this.eventLoop.disable();
|
|
207
|
+
this.hostTraffic.clear();
|
|
133
208
|
}
|
|
134
209
|
}
|
|
135
210
|
|
package/src/relay-config.js
CHANGED
|
@@ -33,8 +33,12 @@ const DEFAULTS = {
|
|
|
33
33
|
publicUrl: 'http://127.0.0.1:8787',
|
|
34
34
|
maxPayloadBytes: 1024 * 1024,
|
|
35
35
|
maxClientsPerHost: 16,
|
|
36
|
+
maxHosts: 1024,
|
|
37
|
+
maxPendingHandshakes: 1024,
|
|
38
|
+
maxBufferedBytesPerClient: 4 * 1024 * 1024,
|
|
36
39
|
allowedOrigins: [],
|
|
37
40
|
trustProxy: false,
|
|
41
|
+
hostReconnectGraceMs: 30 * 1000,
|
|
38
42
|
},
|
|
39
43
|
auth: {
|
|
40
44
|
pairingTtlMs: 10 * 60 * 1000,
|
|
@@ -147,6 +151,10 @@ function applyEnvironment(config, env) {
|
|
|
147
151
|
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
148
152
|
if (env.RELAY_MAX_PAYLOAD_BYTES) config.relay.maxPayloadBytes = env.RELAY_MAX_PAYLOAD_BYTES;
|
|
149
153
|
if (env.RELAY_MAX_CLIENTS_PER_HOST) config.relay.maxClientsPerHost = env.RELAY_MAX_CLIENTS_PER_HOST;
|
|
154
|
+
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
155
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
156
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
157
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
150
158
|
if (env.RELAY_ALLOWED_ORIGINS !== undefined) {
|
|
151
159
|
const origins = parseOriginList(env.RELAY_ALLOWED_ORIGINS);
|
|
152
160
|
if (origins) config.relay.allowedOrigins = origins;
|
|
@@ -180,6 +188,10 @@ function applyOptions(config, options) {
|
|
|
180
188
|
if (options['admin-token']) config.auth.adminToken = options['admin-token'];
|
|
181
189
|
if (options['state-file']) config.auth.stateFile = options['state-file'];
|
|
182
190
|
if (options['max-clients']) config.relay.maxClientsPerHost = options['max-clients'];
|
|
191
|
+
if (options['max-hosts']) config.relay.maxHosts = options['max-hosts'];
|
|
192
|
+
if (options['max-pending-handshakes']) config.relay.maxPendingHandshakes = options['max-pending-handshakes'];
|
|
193
|
+
if (options['max-buffered-bytes']) config.relay.maxBufferedBytesPerClient = options['max-buffered-bytes'];
|
|
194
|
+
if (options['host-reconnect-grace-ms']) config.relay.hostReconnectGraceMs = options['host-reconnect-grace-ms'];
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
function validate(config) {
|
|
@@ -192,6 +204,25 @@ function validate(config) {
|
|
|
192
204
|
16 * 1024 * 1024,
|
|
193
205
|
);
|
|
194
206
|
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
207
|
+
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
|
|
208
|
+
config.relay.maxPendingHandshakes = parseInteger(
|
|
209
|
+
config.relay.maxPendingHandshakes,
|
|
210
|
+
DEFAULTS.relay.maxPendingHandshakes,
|
|
211
|
+
16,
|
|
212
|
+
100000,
|
|
213
|
+
);
|
|
214
|
+
config.relay.maxBufferedBytesPerClient = parseInteger(
|
|
215
|
+
config.relay.maxBufferedBytesPerClient,
|
|
216
|
+
DEFAULTS.relay.maxBufferedBytesPerClient,
|
|
217
|
+
64 * 1024,
|
|
218
|
+
256 * 1024 * 1024,
|
|
219
|
+
);
|
|
220
|
+
config.relay.hostReconnectGraceMs = parseInteger(
|
|
221
|
+
config.relay.hostReconnectGraceMs,
|
|
222
|
+
DEFAULTS.relay.hostReconnectGraceMs,
|
|
223
|
+
1000,
|
|
224
|
+
24 * 60 * 60 * 1000,
|
|
225
|
+
);
|
|
195
226
|
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
196
227
|
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
197
228
|
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|