herdr-remote-relay 0.2.1 → 0.2.2
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 +34 -70
- package/README.zh-CN.md +66 -0
- package/bin/herdr-remote-relay.js +12 -13
- package/package.json +4 -3
- package/src/metrics.js +7 -3
- package/src/relay-server.js +252 -102
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,33 @@ 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
|
-
workstation to it. That is safe to share, because each workstation is reachable
|
|
67
|
-
only through its own host token — generated on that machine, never handed out
|
|
68
|
-
by the relay. Nobody else can pair a device to your terminal.
|
|
69
|
-
|
|
70
|
-
What a public relay does give away is bandwidth and the fact that your
|
|
71
|
-
workstation is online. Set a password if that matters.
|
|
72
|
-
|
|
73
|
-
## Connect a workstation
|
|
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
|
+
| `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
|
|
49
|
+
|
|
50
|
+
Reverse proxies must pass WebSocket `Upgrade` headers and maintain long idle timeouts. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
|
|
51
|
+
|
|
52
|
+
## Connecting a Workstation
|
|
53
|
+
|
|
54
|
+
In `herdr-remote` TUI under **Relay** tab:
|
|
55
|
+
- Set mode to **Self-hosted relay**
|
|
56
|
+
- Set Relay URL to `wss://herdr.example.com`
|
|
57
|
+
- Set Relay Password to matching `RELAY_PASSWORD`
|
|
58
|
+
|
|
59
|
+
Check connection status:
|
|
80
60
|
```bash
|
|
81
|
-
curl https://herdr.example.com/healthz
|
|
61
|
+
curl https://herdr.example.com/healthz
|
|
82
62
|
```
|
|
83
63
|
|
|
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
64
|
## License
|
|
101
65
|
|
|
102
66
|
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
| `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
|
|
49
|
+
|
|
50
|
+
反向代理必须转发 WebSocket `Upgrade` 头,并设置较长空闲超时时间。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
|
|
51
|
+
|
|
52
|
+
## 工作站连接
|
|
53
|
+
|
|
54
|
+
在工作站的 `herdr-remote` TUI 中进入 **Relay** 页面:
|
|
55
|
+
- 访问方式选择 **自建 relay**
|
|
56
|
+
- Relay 地址填写 `wss://herdr.example.com`
|
|
57
|
+
- Relay 密码填写对应的 `RELAY_PASSWORD`
|
|
58
|
+
|
|
59
|
+
验证连接状态:
|
|
60
|
+
```bash
|
|
61
|
+
curl https://herdr.example.com/healthz
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 开源协议
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -17,22 +17,21 @@ 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
|
-
--config <file> JSON config file (
|
|
32
|
-
-h, --help Show
|
|
33
|
-
-v, --version Show
|
|
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
|
+
--config <file> JSON config file (HERDR_RELAY_CONFIG)
|
|
31
|
+
-h, --help Show help
|
|
32
|
+
-v, --version Show version
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
Guide: docs/self-hosted-relay.md
|
|
36
35
|
`;
|
|
37
36
|
|
|
38
37
|
function webUiBuilt() {
|
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.2",
|
|
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/metrics.js
CHANGED
|
@@ -92,15 +92,19 @@ class RelayMetrics {
|
|
|
92
92
|
}
|
|
93
93
|
const memory = process.memoryUsage();
|
|
94
94
|
const load = os.loadavg();
|
|
95
|
-
|
|
95
|
+
// Every attached window has full input, so there is no "active controller"
|
|
96
|
+
// to name: reporting the first of them as one had an operator reading a
|
|
97
|
+
// distinction that does not exist. The host is still worth naming, and any
|
|
98
|
+
// client knows which workstation it is on.
|
|
99
|
+
const anyClient = clients[0];
|
|
96
100
|
return {
|
|
97
101
|
version: this.version,
|
|
98
102
|
protocolVersion: this.protocolVersion,
|
|
99
103
|
uptimeSeconds: Math.floor((now - this.startedAt) / 1000),
|
|
100
104
|
startTime: new Date(this.startedAt).toISOString(),
|
|
101
105
|
serverTime: new Date(now).toISOString(),
|
|
102
|
-
activeControllerId:
|
|
103
|
-
activeHostId:
|
|
106
|
+
activeControllerId: null,
|
|
107
|
+
activeHostId: anyClient?.hostId || hosts[0]?.id || null,
|
|
104
108
|
clients,
|
|
105
109
|
hosts,
|
|
106
110
|
ptys,
|
package/src/relay-server.js
CHANGED
|
@@ -11,13 +11,26 @@ const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-conf
|
|
|
11
11
|
const { AuthStore } = require('./auth-store');
|
|
12
12
|
const { RelayMetrics } = require('./metrics');
|
|
13
13
|
const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
|
|
14
|
-
const { isWheelOnlyInput } = require('./scroll-input');
|
|
15
14
|
const { ensureDir } = require('./state');
|
|
16
15
|
|
|
17
16
|
const VERSION = require('../package.json').version;
|
|
18
17
|
const { PROTOCOL_VERSION } = require('./stream-frame');
|
|
19
18
|
const MAX_DIMENSION = 500;
|
|
20
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How much recent PTY output the relay keeps per shared session.
|
|
22
|
+
*
|
|
23
|
+
* A browser that joins a session already in progress has missed everything
|
|
24
|
+
* printed before it arrived. Replaying the tail of the stream is what makes
|
|
25
|
+
* "every window shows the same thing" true on the *first* frame rather than
|
|
26
|
+
* only after the next repaint.
|
|
27
|
+
*/
|
|
28
|
+
const SESSION_REPLAY_BYTES = 512 * 1024;
|
|
29
|
+
|
|
30
|
+
/** Never shrink a shared grid below something a program can still draw in. */
|
|
31
|
+
const MIN_SHARED_COLS = 20;
|
|
32
|
+
const MIN_SHARED_ROWS = 6;
|
|
33
|
+
|
|
21
34
|
function randomId(prefix) {
|
|
22
35
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
23
36
|
}
|
|
@@ -433,6 +446,10 @@ class RelayServer {
|
|
|
433
446
|
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
434
447
|
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
435
448
|
connectedAtMs: pending.connectedAt,
|
|
449
|
+
// One shared terminal per workstation. Every browser attached to this
|
|
450
|
+
// host reads and writes the same PTY, so what one window shows is
|
|
451
|
+
// what all of them show.
|
|
452
|
+
session: null,
|
|
436
453
|
// Colors are the workstation's to declare, but only in the one shape
|
|
437
454
|
// a browser renderer accepts.
|
|
438
455
|
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
@@ -472,9 +489,14 @@ class RelayServer {
|
|
|
472
489
|
closeSocket(host.ws, 1003, error.message);
|
|
473
490
|
return;
|
|
474
491
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
492
|
+
// Output belongs to the workstation's one shared session, so it goes to
|
|
493
|
+
// every browser attached to it rather than to a single stream owner.
|
|
494
|
+
const session = host.session;
|
|
495
|
+
if (frame.type !== 'output' || !session || session.streamId !== frame.streamId) return;
|
|
496
|
+
this.rememberOutput(session, frame.payload);
|
|
497
|
+
for (const clientId of host.clients) {
|
|
498
|
+
const client = this.clients.get(clientId);
|
|
499
|
+
if (!client || !isOpen(client.ws)) continue;
|
|
478
500
|
client.ws.send(frame.payload);
|
|
479
501
|
client.bytesSent += frame.payload.length;
|
|
480
502
|
this.metrics.recordOut(frame.payload.length);
|
|
@@ -488,16 +510,168 @@ class RelayServer {
|
|
|
488
510
|
host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
|
|
489
511
|
return;
|
|
490
512
|
}
|
|
491
|
-
|
|
492
|
-
|
|
513
|
+
// Session-level news concerns the whole room: the host talks about the one
|
|
514
|
+
// shared stream, and every attached browser has to hear it.
|
|
515
|
+
const session = host.session;
|
|
516
|
+
const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
|
|
517
|
+
if (!session || (streamId && streamId !== session.streamId)) return;
|
|
493
518
|
if (message.type === 'session_ready') {
|
|
494
|
-
|
|
519
|
+
session.ready = true;
|
|
520
|
+
this.broadcastToClients(host, (client) => ({ type: 'session_ready', clientId: client.id }));
|
|
495
521
|
} else if (message.type === 'session_exit') {
|
|
496
|
-
|
|
497
|
-
|
|
522
|
+
const code = Number.isInteger(message.code) ? message.code : null;
|
|
523
|
+
host.session = null;
|
|
524
|
+
this.broadcastToClients(host, () => ({ type: 'exit', code }));
|
|
525
|
+
for (const clientId of [...host.clients]) {
|
|
526
|
+
const client = this.clients.get(clientId);
|
|
527
|
+
if (client) this.detachClient(client, { notify: false });
|
|
528
|
+
}
|
|
498
529
|
} else if (message.type === 'error') {
|
|
499
|
-
|
|
530
|
+
this.broadcastToClients(host, () => ({
|
|
531
|
+
type: 'error',
|
|
532
|
+
code: message.code || 'host_error',
|
|
533
|
+
message: String(message.message || 'Host connector error'),
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Send one JSON message to every browser attached to `host`. */
|
|
539
|
+
broadcastToClients(host, build) {
|
|
540
|
+
for (const clientId of [...host.clients]) {
|
|
541
|
+
const client = this.clients.get(clientId);
|
|
542
|
+
if (!client) continue;
|
|
543
|
+
const payload = build(client);
|
|
544
|
+
if (payload) jsonSend(client.ws, payload);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Keep the tail of the shared stream so a late joiner can be caught up. */
|
|
549
|
+
rememberOutput(session, payload) {
|
|
550
|
+
session.replay.push(Buffer.from(payload));
|
|
551
|
+
session.replayBytes += payload.length;
|
|
552
|
+
while (session.replayBytes > SESSION_REPLAY_BYTES && session.replay.length > 1) {
|
|
553
|
+
session.replayBytes -= session.replay.shift().length;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The grid the shared PTY runs at.
|
|
559
|
+
*
|
|
560
|
+
* The smallest attached window wins, exactly as it does in tmux: a column a
|
|
561
|
+
* phone cannot show is a column the program must not paint, or every other
|
|
562
|
+
* window sees wrapped rubbish. Nothing else keeps a shared terminal legible
|
|
563
|
+
* on two different screens at once.
|
|
564
|
+
*/
|
|
565
|
+
sharedDimensions(host) {
|
|
566
|
+
let cols = MAX_DIMENSION;
|
|
567
|
+
let rows = MAX_DIMENSION;
|
|
568
|
+
let found = false;
|
|
569
|
+
for (const clientId of host.clients) {
|
|
570
|
+
const client = this.clients.get(clientId);
|
|
571
|
+
if (!client) continue;
|
|
572
|
+
found = true;
|
|
573
|
+
cols = Math.min(cols, client.cols);
|
|
574
|
+
rows = Math.min(rows, client.rows);
|
|
575
|
+
}
|
|
576
|
+
if (!found) return null;
|
|
577
|
+
return {
|
|
578
|
+
cols: Math.max(MIN_SHARED_COLS, cols),
|
|
579
|
+
rows: Math.max(MIN_SHARED_ROWS, rows),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Attach `client` to the workstation's shared terminal, starting it if this
|
|
585
|
+
* is the first browser through the door.
|
|
586
|
+
*
|
|
587
|
+
* A later arrival does not get its own PTY: it is handed the stream already
|
|
588
|
+
* running, the output that has been printed so far, and — once the geometry
|
|
589
|
+
* has settled — a repaint, so it lands on the same screen everyone else is
|
|
590
|
+
* looking at.
|
|
591
|
+
*/
|
|
592
|
+
attachSession(host, client) {
|
|
593
|
+
if (!host.session) {
|
|
594
|
+
host.session = {
|
|
595
|
+
streamId: randomId('session'),
|
|
596
|
+
cols: client.cols,
|
|
597
|
+
rows: client.rows,
|
|
598
|
+
ready: false,
|
|
599
|
+
replay: [],
|
|
600
|
+
replayBytes: 0,
|
|
601
|
+
};
|
|
602
|
+
const dims = this.sharedDimensions(host) || { cols: client.cols, rows: client.rows };
|
|
603
|
+
host.session.cols = dims.cols;
|
|
604
|
+
host.session.rows = dims.rows;
|
|
605
|
+
jsonSend(host.ws, {
|
|
606
|
+
type: 'session_start',
|
|
607
|
+
clientId: host.session.streamId,
|
|
608
|
+
streamId: host.session.streamId,
|
|
609
|
+
cols: dims.cols,
|
|
610
|
+
rows: dims.rows,
|
|
611
|
+
role: 'controller',
|
|
612
|
+
});
|
|
613
|
+
// Said out loud even when this window is the only one, so a browser that
|
|
614
|
+
// reconnects is never left painting the grid of a session that has since
|
|
615
|
+
// been torn down and started again at a different size.
|
|
616
|
+
jsonSend(client.ws, { type: 'shared_resize', cols: dims.cols, rows: dims.rows });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const session = host.session;
|
|
621
|
+
if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
622
|
+
for (const chunk of session.replay) {
|
|
623
|
+
if (!isOpen(client.ws)) break;
|
|
624
|
+
client.ws.send(chunk);
|
|
625
|
+
client.bytesSent += chunk.length;
|
|
626
|
+
}
|
|
627
|
+
// Geometry may now be smaller than it was; the resize doubles as the
|
|
628
|
+
// repaint that puts the newcomer on the same screen as everyone else.
|
|
629
|
+
this.syncDimensions(host, { force: true });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Push the shared grid to the workstation.
|
|
634
|
+
*
|
|
635
|
+
* `force` asks for a repaint even when the numbers did not move: a browser
|
|
636
|
+
* that just joined needs the program to draw itself again, and a resize is
|
|
637
|
+
* the only signal a PTY has for "paint everything".
|
|
638
|
+
*/
|
|
639
|
+
syncDimensions(host, { force = false } = {}) {
|
|
640
|
+
const session = host.session;
|
|
641
|
+
if (!session) return;
|
|
642
|
+
const dims = this.sharedDimensions(host);
|
|
643
|
+
if (!dims) return;
|
|
644
|
+
const changed = dims.cols !== session.cols || dims.rows !== session.rows;
|
|
645
|
+
session.cols = dims.cols;
|
|
646
|
+
session.rows = dims.rows;
|
|
647
|
+
if (!changed && !force) return;
|
|
648
|
+
// Every window has to be told what the shared grid became, not just the
|
|
649
|
+
// workstation. A browser that keeps rendering at its own width would wrap
|
|
650
|
+
// a stream written for a narrower terminal, which is the one thing a
|
|
651
|
+
// shared session must not do: the same bytes have to look the same in
|
|
652
|
+
// every window.
|
|
653
|
+
this.broadcastToClients(host, () => ({
|
|
654
|
+
type: 'shared_resize',
|
|
655
|
+
cols: dims.cols,
|
|
656
|
+
rows: dims.rows,
|
|
657
|
+
}));
|
|
658
|
+
if (!changed && force) {
|
|
659
|
+
// A no-op resize is ignored by the PTY, so bounce one row and come back.
|
|
660
|
+
jsonSend(host.ws, {
|
|
661
|
+
type: 'resize',
|
|
662
|
+
clientId: session.streamId,
|
|
663
|
+
streamId: session.streamId,
|
|
664
|
+
cols: dims.cols,
|
|
665
|
+
rows: Math.max(MIN_SHARED_ROWS, dims.rows - 1),
|
|
666
|
+
});
|
|
500
667
|
}
|
|
668
|
+
jsonSend(host.ws, {
|
|
669
|
+
type: 'resize',
|
|
670
|
+
clientId: session.streamId,
|
|
671
|
+
streamId: session.streamId,
|
|
672
|
+
cols: dims.cols,
|
|
673
|
+
rows: dims.rows,
|
|
674
|
+
});
|
|
501
675
|
}
|
|
502
676
|
|
|
503
677
|
handleClientConnection(ws, req) {
|
|
@@ -522,18 +696,12 @@ class RelayServer {
|
|
|
522
696
|
const host = this.hosts.get(device.hostId);
|
|
523
697
|
if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
|
|
524
698
|
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// Keyed on the browser-supplied client id, not on the device token:
|
|
533
|
-
// sharing one token across two browsers is the supported multi-viewer
|
|
534
|
-
// case and must keep working.
|
|
535
|
-
this.detachSupersededSession(device.deviceId, message.clientId, host);
|
|
536
|
-
|
|
699
|
+
// Two tabs of one browser are two windows onto the same terminal, not
|
|
700
|
+
// rivals. Nothing is retired here: the relay used to close whichever
|
|
701
|
+
// session shared this browser's client id, which made two open tabs
|
|
702
|
+
// evict each other in a loop that never converged — each eviction
|
|
703
|
+
// triggered the other tab's auto-reconnect, which evicted this one
|
|
704
|
+
// back, forever.
|
|
537
705
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
538
706
|
clearTimeout(deadline);
|
|
539
707
|
const clientId = randomId('client');
|
|
@@ -545,8 +713,11 @@ class RelayServer {
|
|
|
545
713
|
// Stable per browser profile; used to recognise a reconnect from the
|
|
546
714
|
// same browser rather than a genuinely separate viewer.
|
|
547
715
|
browserClientId: typeof message.clientId === 'string' ? message.clientId : null,
|
|
548
|
-
|
|
549
|
-
|
|
716
|
+
// Every paired window may type. Pairing is the permission boundary;
|
|
717
|
+
// once a device is through it, holding a second window read-only
|
|
718
|
+
// serves nobody — they are all views of one shared terminal.
|
|
719
|
+
role: 'controller',
|
|
720
|
+
controllerId: null,
|
|
550
721
|
connectedAt: new Date().toISOString(),
|
|
551
722
|
connectedAtMs: Date.now(),
|
|
552
723
|
lastSeenAt: Date.now(),
|
|
@@ -558,8 +729,7 @@ class RelayServer {
|
|
|
558
729
|
cols: clampDimension(message.cols, 80),
|
|
559
730
|
rows: clampDimension(message.rows, 24),
|
|
560
731
|
};
|
|
561
|
-
|
|
562
|
-
client.controllerId = host.controllerId;
|
|
732
|
+
client.controllerId = null;
|
|
563
733
|
// Persist how this device identifies itself so the operator dashboard
|
|
564
734
|
// can name it in the revoke list instead of showing a bare device id.
|
|
565
735
|
this.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
|
|
@@ -580,14 +750,19 @@ class RelayServer {
|
|
|
580
750
|
jsonSend(ws, {
|
|
581
751
|
type: 'ready',
|
|
582
752
|
role: client.role,
|
|
583
|
-
|
|
753
|
+
// There is no controller to name: every window has full input. The
|
|
754
|
+
// field stays in the message for clients built against protocol 1,
|
|
755
|
+
// which read it to decide whether somebody else held the lease — and
|
|
756
|
+
// `null` is exactly the answer that means "nobody does".
|
|
757
|
+
controllerId: null,
|
|
584
758
|
hostId: host.id,
|
|
585
759
|
clientId: client.id,
|
|
586
760
|
// Delivered with `ready`, before the first PTY byte, so the terminal
|
|
587
761
|
// is painted in the host's colors from its very first frame.
|
|
588
762
|
terminalPalette: host.terminalPalette || null,
|
|
763
|
+
clientCount: host.clients.size,
|
|
589
764
|
});
|
|
590
|
-
|
|
765
|
+
this.attachSession(host, client);
|
|
591
766
|
this.broadcastControlState(host);
|
|
592
767
|
return;
|
|
593
768
|
}
|
|
@@ -605,16 +780,12 @@ class RelayServer {
|
|
|
605
780
|
const host = this.hosts.get(client.hostId);
|
|
606
781
|
if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
|
|
607
782
|
if (isBinary) {
|
|
608
|
-
// A read-only device may still scroll. Like `resize` below, scrolling is
|
|
609
|
-
// not a shared-terminal action: each client drives its own PTY stream, so
|
|
610
|
-
// a wheel report moves only that viewer's own screen. Everything else —
|
|
611
|
-
// keystrokes, clicks, drags — stays behind the control lease.
|
|
612
|
-
if (client.role !== 'controller' && !isWheelOnlyInput(raw)) {
|
|
613
|
-
jsonSend(client.ws, { type: 'control_denied', message: 'this device is read-only' });
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
783
|
if (raw.length > this.config.relay.maxPayloadBytes) return;
|
|
617
|
-
const
|
|
784
|
+
const session = host.session;
|
|
785
|
+
if (!session) return;
|
|
786
|
+
// Every window writes into the one shared terminal, so input is stamped
|
|
787
|
+
// with the session's stream id rather than the sender's.
|
|
788
|
+
const frame = packStreamFrame('input', session.streamId, raw);
|
|
618
789
|
if (isOpen(host.ws)) {
|
|
619
790
|
host.ws.send(frame);
|
|
620
791
|
client.bytesReceived += raw.length;
|
|
@@ -631,47 +802,41 @@ class RelayServer {
|
|
|
631
802
|
client.lastPingAt = new Date().toISOString();
|
|
632
803
|
jsonSend(client.ws, { type: 'pong' });
|
|
633
804
|
} else if (message.type === 'resize') {
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
// session — so geometry is not a shared-terminal action and must not
|
|
637
|
-
// require the control lease. Gating it here left a viewer's PTY at the
|
|
638
|
-
// 80x24 it was opened with: the agent then painted into a grid the
|
|
639
|
-
// terminal did not have, leaving blank rows under the content and
|
|
640
|
-
// columns clipped off the right edge.
|
|
805
|
+
// The shared grid is the smallest attached window, so one client's resize
|
|
806
|
+
// is recomputed across the room rather than applied on its own.
|
|
641
807
|
client.cols = clampDimension(message.cols, client.cols);
|
|
642
808
|
client.rows = clampDimension(message.rows, client.rows);
|
|
643
|
-
|
|
809
|
+
this.syncDimensions(host);
|
|
644
810
|
} else if (message.type === 'claim_control') {
|
|
645
|
-
|
|
811
|
+
// Control is no longer a lease. Answering the old request keeps clients
|
|
812
|
+
// built against the previous protocol working.
|
|
813
|
+
client.role = 'controller';
|
|
814
|
+
jsonSend(client.ws, { type: 'control_granted' });
|
|
646
815
|
} else if (message.type === 'release_control') {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
claimControl(host, client, force) {
|
|
655
|
-
if (host.controllerId === client.id) return jsonSend(client.ws, { type: 'control_granted' });
|
|
656
|
-
if (host.controllerId && !force) return jsonSend(client.ws, { type: 'control_denied', message: 'another device currently controls this Herdr' });
|
|
657
|
-
const previous = host.controllerId ? this.clients.get(host.controllerId) : null;
|
|
658
|
-
if (previous) {
|
|
659
|
-
previous.role = 'viewer';
|
|
660
|
-
jsonSend(previous.ws, { type: 'control_revoked', controllerId: client.id });
|
|
816
|
+
// Nothing to release: the window keeps its input either way, and saying
|
|
817
|
+
// so beats a silence an older client would wait on.
|
|
818
|
+
jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
|
|
661
819
|
}
|
|
662
|
-
host.controllerId = client.id;
|
|
663
|
-
client.role = 'controller';
|
|
664
|
-
jsonSend(client.ws, { type: 'control_granted' });
|
|
665
|
-
this.broadcastControlState(host);
|
|
666
820
|
}
|
|
667
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Tell every window who is attached.
|
|
824
|
+
*
|
|
825
|
+
* There is no controller to announce any more, so this carries the one fact
|
|
826
|
+
* that changed: how many windows now share this terminal.
|
|
827
|
+
*/
|
|
668
828
|
broadcastControlState(host) {
|
|
669
829
|
for (const clientId of host.clients) {
|
|
670
830
|
const client = this.clients.get(clientId);
|
|
671
831
|
if (!client) continue;
|
|
672
|
-
client.role =
|
|
673
|
-
client.controllerId =
|
|
674
|
-
jsonSend(client.ws, {
|
|
832
|
+
client.role = 'controller';
|
|
833
|
+
client.controllerId = null;
|
|
834
|
+
jsonSend(client.ws, {
|
|
835
|
+
type: 'control_state',
|
|
836
|
+
role: 'controller',
|
|
837
|
+
controllerId: null,
|
|
838
|
+
clientCount: host.clients.size,
|
|
839
|
+
});
|
|
675
840
|
}
|
|
676
841
|
}
|
|
677
842
|
|
|
@@ -693,47 +858,25 @@ class RelayServer {
|
|
|
693
858
|
return closed;
|
|
694
859
|
}
|
|
695
860
|
|
|
696
|
-
/**
|
|
697
|
-
* Retire the session the same browser already holds on this host, if any.
|
|
698
|
-
*
|
|
699
|
-
* `browserClientId` is the identifier the browser persists for itself, so two
|
|
700
|
-
* tabs of one browser collapse to a single session while two genuinely
|
|
701
|
-
* different browsers sharing a device token stay independent viewers. A
|
|
702
|
-
* client that sends no id cannot be matched and is left alone.
|
|
703
|
-
*/
|
|
704
|
-
detachSupersededSession(deviceId, browserClientId, host) {
|
|
705
|
-
if (!deviceId || typeof browserClientId !== 'string' || !browserClientId) return 0;
|
|
706
|
-
let closed = 0;
|
|
707
|
-
for (const clientId of [...host.clients]) {
|
|
708
|
-
const existing = this.clients.get(clientId);
|
|
709
|
-
if (!existing || existing.deviceId !== deviceId) continue;
|
|
710
|
-
if (existing.browserClientId !== browserClientId) continue;
|
|
711
|
-
this.detachClient(existing, { notify: false, reason: 'superseded_by_new_session' });
|
|
712
|
-
closeSocket(existing.ws, 1000, 'replaced by a newer session from the same browser');
|
|
713
|
-
closed += 1;
|
|
714
|
-
}
|
|
715
|
-
return closed;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
861
|
detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
|
|
719
862
|
if (!client || !this.clients.has(client.id)) return;
|
|
720
863
|
this.clients.delete(client.id);
|
|
721
864
|
const host = this.hosts.get(client.hostId);
|
|
722
865
|
if (host) {
|
|
723
866
|
host.clients.delete(client.id);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
host.controllerId = next.id;
|
|
734
|
-
next.role = 'controller';
|
|
735
|
-
jsonSend(next.ws, { type: 'control_granted' });
|
|
867
|
+
// The shared terminal outlives any one window: it is torn down only when
|
|
868
|
+
// the last of them has gone, so closing a tab never kills the session the
|
|
869
|
+
// other tabs are still watching.
|
|
870
|
+
if (host.clients.size === 0) {
|
|
871
|
+
if (host.session) {
|
|
872
|
+
if (isOpen(host.ws)) {
|
|
873
|
+
jsonSend(host.ws, { type: 'session_stop', clientId: host.session.streamId, streamId: host.session.streamId });
|
|
874
|
+
}
|
|
875
|
+
host.session = null;
|
|
736
876
|
}
|
|
877
|
+
host.controllerId = null;
|
|
878
|
+
} else {
|
|
879
|
+
this.syncDimensions(host);
|
|
737
880
|
this.broadcastControlState(host);
|
|
738
881
|
}
|
|
739
882
|
}
|
|
@@ -820,7 +963,14 @@ class RelayServer {
|
|
|
820
963
|
activePtyCount: host.ptys.length,
|
|
821
964
|
load: host.load,
|
|
822
965
|
}));
|
|
823
|
-
|
|
966
|
+
// The workstation counts one PTY per stream and cannot know how many
|
|
967
|
+
// windows are watching it; the relay does, and that is the number an
|
|
968
|
+
// operator needs when the session is shared.
|
|
969
|
+
const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({
|
|
970
|
+
...pty,
|
|
971
|
+
hostId: host.id,
|
|
972
|
+
activeClients: host.clients.size,
|
|
973
|
+
})));
|
|
824
974
|
// The paired-device roster identifies people's hardware, so it is served to
|
|
825
975
|
// the relay operator only — never on /api/status, which any paired device
|
|
826
976
|
// may read.
|