herdr-remote-relay 0.2.2 → 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 +9 -1
- package/README.zh-CN.md +9 -1
- package/bin/herdr-remote-relay.js +4 -0
- package/config.example.json +5 -1
- package/deploy/systemd/relay.env.example +5 -0
- package/package.json +1 -1
- package/src/auth-store.js +10 -5
- package/src/metrics.js +77 -6
- package/src/relay-config.js +31 -0
- package/src/relay-server.js +345 -90
package/README.md
CHANGED
|
@@ -45,9 +45,17 @@ docker run -d --name herdr-relay --restart unless-stopped \
|
|
|
45
45
|
| `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Auth state file path |
|
|
46
46
|
| `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Allowed CORS origins, comma-separated |
|
|
47
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 |
|
|
48
52
|
| `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
|
|
49
53
|
|
|
50
|
-
Reverse proxies must pass WebSocket `Upgrade` headers and maintain long idle timeouts. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
|
|
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.
|
|
51
59
|
|
|
52
60
|
## Connecting a Workstation
|
|
53
61
|
|
package/README.zh-CN.md
CHANGED
|
@@ -45,9 +45,17 @@ docker run -d --name herdr-relay --restart unless-stopped \
|
|
|
45
45
|
| `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | 认证状态文件路径 |
|
|
46
46
|
| `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(同源)* | 允许的跨域源,逗号分隔 |
|
|
47
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` | 工作站断线恢复宽限时间 |
|
|
48
52
|
| `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
|
|
49
53
|
|
|
50
|
-
反向代理必须转发 WebSocket `Upgrade`
|
|
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` 不公开工作站和客户端数量。
|
|
51
59
|
|
|
52
60
|
## 工作站连接
|
|
53
61
|
|
|
@@ -27,6 +27,10 @@ Options:
|
|
|
27
27
|
--state-file <file> Auth state file path (RELAY_AUTH_STATE_FILE)
|
|
28
28
|
--allowed-origins <list> Allowed browser origins, comma-separated
|
|
29
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)
|
|
30
34
|
--config <file> JSON config file (HERDR_RELAY_CONFIG)
|
|
31
35
|
-h, --help Show help
|
|
32
36
|
-v, --version Show version
|
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
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 = {
|
|
@@ -97,6 +166,7 @@ class RelayMetrics {
|
|
|
97
166
|
// distinction that does not exist. The host is still worth naming, and any
|
|
98
167
|
// client knows which workstation it is on.
|
|
99
168
|
const anyClient = clients[0];
|
|
169
|
+
const scopedThroughput = scopeHostId ? this.hostThroughput(scopeHostId, now) : throughput;
|
|
100
170
|
return {
|
|
101
171
|
version: this.version,
|
|
102
172
|
protocolVersion: this.protocolVersion,
|
|
@@ -111,7 +181,7 @@ class RelayMetrics {
|
|
|
111
181
|
clientCount: clients.length,
|
|
112
182
|
hostCount: hosts.length,
|
|
113
183
|
ptyCount: ptys.length,
|
|
114
|
-
throughput,
|
|
184
|
+
throughput: scopedThroughput,
|
|
115
185
|
cpu: {
|
|
116
186
|
load1m: finite(load[0]),
|
|
117
187
|
load5m: finite(load[1]),
|
|
@@ -134,6 +204,7 @@ class RelayMetrics {
|
|
|
134
204
|
|
|
135
205
|
close() {
|
|
136
206
|
this.eventLoop.disable();
|
|
207
|
+
this.hostTraffic.clear();
|
|
137
208
|
}
|
|
138
209
|
}
|
|
139
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);
|
package/src/relay-server.js
CHANGED
|
@@ -83,11 +83,24 @@ function tokenMatches(candidate, expected) {
|
|
|
83
83
|
|
|
84
84
|
class RelayServer {
|
|
85
85
|
constructor(config = loadRelayConfig().config, options = {}) {
|
|
86
|
-
this.config =
|
|
86
|
+
this.config = {
|
|
87
|
+
...config,
|
|
88
|
+
relay: {
|
|
89
|
+
...config.relay,
|
|
90
|
+
maxHosts: config.relay?.maxHosts ?? 1024,
|
|
91
|
+
maxPendingHandshakes: config.relay?.maxPendingHandshakes ?? 1024,
|
|
92
|
+
maxBufferedBytesPerClient: config.relay?.maxBufferedBytesPerClient ?? 4 * 1024 * 1024,
|
|
93
|
+
hostReconnectGraceMs: config.relay?.hostReconnectGraceMs ?? 30 * 1000,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
config = this.config;
|
|
87
97
|
this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
|
|
88
98
|
this.hosts = new Map();
|
|
89
99
|
this.clients = new Map();
|
|
90
100
|
this.pairAttempts = new Map();
|
|
101
|
+
this.clientHandshakeAttempts = new Map();
|
|
102
|
+
this.hostHandshakeAttempts = new Map();
|
|
103
|
+
this.pendingHandshakes = new Set();
|
|
91
104
|
this.startedAt = Date.now();
|
|
92
105
|
this.metrics = options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
|
|
93
106
|
this.stateFile = options.stateFile || config.auth?.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
|
|
@@ -102,7 +115,14 @@ class RelayServer {
|
|
|
102
115
|
password: this.password,
|
|
103
116
|
});
|
|
104
117
|
this.server = http.createServer((req, res) => this.handleHttp(req, res));
|
|
105
|
-
this.wss = new WebSocketServer({
|
|
118
|
+
this.wss = new WebSocketServer({
|
|
119
|
+
noServer: true,
|
|
120
|
+
clientTracking: false,
|
|
121
|
+
maxPayload: config.relay.maxPayloadBytes,
|
|
122
|
+
// Terminal data is already compact and latency-sensitive. Compression
|
|
123
|
+
// adds CPU and buffering without helping the usual ANSI payloads.
|
|
124
|
+
perMessageDeflate: false,
|
|
125
|
+
});
|
|
106
126
|
this.heartbeatTimer = null;
|
|
107
127
|
this.cleanupTimer = null;
|
|
108
128
|
this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
@@ -140,6 +160,10 @@ class RelayServer {
|
|
|
140
160
|
this.cleanupTimer = null;
|
|
141
161
|
for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
|
|
142
162
|
for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
|
|
163
|
+
this.pairAttempts.clear();
|
|
164
|
+
this.clientHandshakeAttempts.clear();
|
|
165
|
+
this.hostHandshakeAttempts.clear();
|
|
166
|
+
this.pendingHandshakes.clear();
|
|
143
167
|
this.metrics.close();
|
|
144
168
|
await new Promise((resolve) => {
|
|
145
169
|
if (!this.server.listening) return resolve();
|
|
@@ -177,7 +201,21 @@ class RelayServer {
|
|
|
177
201
|
socket.destroy();
|
|
178
202
|
return;
|
|
179
203
|
}
|
|
204
|
+
if (this.pendingHandshakes.size >= this.config.relay.maxPendingHandshakes) {
|
|
205
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
206
|
+
socket.destroy();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Small ANSI/input frames should not wait behind Nagle's timer. This is
|
|
210
|
+
// safe for both plain HTTP and TLS sockets and also benefits a relay behind
|
|
211
|
+
// a reverse proxy by keeping the relay leg immediately writable.
|
|
212
|
+
try {
|
|
213
|
+
socket.setNoDelay(true);
|
|
214
|
+
socket.setKeepAlive?.(true, this.config.cleanup.heartbeatIntervalMs);
|
|
215
|
+
} catch {}
|
|
180
216
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
217
|
+
this.pendingHandshakes.add(ws);
|
|
218
|
+
ws.once('close', () => this.finishHandshake(ws));
|
|
181
219
|
if (pathname === '/ws/host') this.handleHostConnection(ws, req);
|
|
182
220
|
else this.handleClientConnection(ws, req);
|
|
183
221
|
});
|
|
@@ -189,7 +227,7 @@ class RelayServer {
|
|
|
189
227
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
190
228
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
191
229
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
192
|
-
res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
|
|
230
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
|
|
193
231
|
}
|
|
194
232
|
|
|
195
233
|
sendJsonResponse(res, status, payload) {
|
|
@@ -216,6 +254,27 @@ class RelayServer {
|
|
|
216
254
|
return token ? this.auth.authenticateDevice(token) : null;
|
|
217
255
|
}
|
|
218
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Resolve the request to one tenant. Supplying both authentication schemes is
|
|
259
|
+
* allowed only when they identify the same host; otherwise a caller could
|
|
260
|
+
* accidentally combine credentials from two workstations and receive the
|
|
261
|
+
* result selected by whichever branch happened to run first.
|
|
262
|
+
*/
|
|
263
|
+
authorizedSubject(req) {
|
|
264
|
+
const hostIdHeader = req.headers['x-herdr-host-id'];
|
|
265
|
+
const hostTokenHeader = req.headers['x-herdr-host-token'];
|
|
266
|
+
const hasHostCredentials = hostIdHeader !== undefined || hostTokenHeader !== undefined;
|
|
267
|
+
const hasBearer = req.headers.authorization !== undefined;
|
|
268
|
+
const hostId = this.authorizedHost(req);
|
|
269
|
+
const device = this.authorizedDevice(req);
|
|
270
|
+
if (hasHostCredentials && !hostId) return null;
|
|
271
|
+
if (hasBearer && !device) return null;
|
|
272
|
+
if (hostId && device && hostId !== device.hostId) return null;
|
|
273
|
+
if (hostId) return { kind: 'host', hostId };
|
|
274
|
+
if (device) return { kind: 'device', hostId: device.hostId, deviceId: device.deviceId };
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
219
278
|
/** Authenticate the operator of this relay, not a workstation or device. */
|
|
220
279
|
authorizedAdmin(req) {
|
|
221
280
|
return tokenMatches(req.headers['x-relay-admin-token'], this.adminToken);
|
|
@@ -239,19 +298,42 @@ class RelayServer {
|
|
|
239
298
|
return req.socket.remoteAddress || 'unknown';
|
|
240
299
|
}
|
|
241
300
|
|
|
242
|
-
|
|
301
|
+
allowAttempt(store, req, limit = 20) {
|
|
243
302
|
const key = this.rateLimitKey(req);
|
|
244
303
|
const now = Date.now();
|
|
245
|
-
const current =
|
|
304
|
+
const current = store.get(key);
|
|
246
305
|
if (!current || now - current.startedAt >= 60_000) {
|
|
247
|
-
|
|
306
|
+
// Bound the map even when an attacker rotates source addresses. Expired
|
|
307
|
+
// entries are removed by sweep; the oldest live entry is the least
|
|
308
|
+
// useful one to retain when the cap is reached.
|
|
309
|
+
if (store.size >= 4096) {
|
|
310
|
+
const oldest = store.keys().next().value;
|
|
311
|
+
if (oldest !== undefined) store.delete(oldest);
|
|
312
|
+
}
|
|
313
|
+
store.set(key, { startedAt: now, count: 1 });
|
|
248
314
|
return true;
|
|
249
315
|
}
|
|
250
|
-
if (current.count >=
|
|
316
|
+
if (current.count >= limit) return false;
|
|
251
317
|
current.count += 1;
|
|
252
318
|
return true;
|
|
253
319
|
}
|
|
254
320
|
|
|
321
|
+
allowPairAttempt(req) {
|
|
322
|
+
return this.allowAttempt(this.pairAttempts, req, 20);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
allowClientHandshake(req) {
|
|
326
|
+
return this.allowAttempt(this.clientHandshakeAttempts, req, 60);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
allowHostHandshake(req) {
|
|
330
|
+
return this.allowAttempt(this.hostHandshakeAttempts, req, 60);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
finishHandshake(ws) {
|
|
334
|
+
this.pendingHandshakes.delete(ws);
|
|
335
|
+
}
|
|
336
|
+
|
|
255
337
|
handleHttp(req, res) {
|
|
256
338
|
let requestUrl;
|
|
257
339
|
try {
|
|
@@ -268,7 +350,7 @@ class RelayServer {
|
|
|
268
350
|
res.writeHead(204, {
|
|
269
351
|
'Access-Control-Allow-Origin': req.headers.origin || '*',
|
|
270
352
|
'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Herdr-Host-Id, X-Herdr-Host-Token, X-Relay-Admin-Token',
|
|
271
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
353
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
272
354
|
Vary: 'Origin',
|
|
273
355
|
});
|
|
274
356
|
res.end();
|
|
@@ -278,16 +360,21 @@ class RelayServer {
|
|
|
278
360
|
this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
|
|
279
361
|
return;
|
|
280
362
|
}
|
|
363
|
+
// Only echo an origin after the exact allowlist/same-host check above. This
|
|
364
|
+
// makes explicitly configured cross-origin WebUI profiles usable without
|
|
365
|
+
// reflecting an attacker-controlled Origin header.
|
|
366
|
+
if (req.headers.origin) {
|
|
367
|
+
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
|
368
|
+
res.setHeader('Vary', 'Origin');
|
|
369
|
+
}
|
|
281
370
|
if (requestUrl.pathname === '/healthz' && req.method === 'GET') {
|
|
282
|
-
|
|
371
|
+
// Liveness is intentionally tenant-blind. Host/client counts let an
|
|
372
|
+
// unauthenticated caller learn whether other workstations are present.
|
|
283
373
|
this.sendJsonResponse(res, 200, {
|
|
284
374
|
ok: true,
|
|
285
375
|
version: VERSION,
|
|
286
376
|
protocol: PROTOCOL_VERSION,
|
|
287
|
-
|
|
288
|
-
clients: snapshot.clientCount,
|
|
289
|
-
uptimeSeconds: snapshot.uptimeSeconds,
|
|
290
|
-
load1m: snapshot.cpu.load1m,
|
|
377
|
+
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
291
378
|
});
|
|
292
379
|
return;
|
|
293
380
|
}
|
|
@@ -308,11 +395,12 @@ class RelayServer {
|
|
|
308
395
|
return;
|
|
309
396
|
}
|
|
310
397
|
if (requestUrl.pathname === '/api/status' && req.method === 'GET') {
|
|
311
|
-
|
|
398
|
+
const subject = this.authorizedSubject(req);
|
|
399
|
+
if (!subject) {
|
|
312
400
|
this.sendJsonResponse(res, 401, { ok: false, code: 'auth_required', message: 'an authorized device or host token is required' });
|
|
313
401
|
return;
|
|
314
402
|
}
|
|
315
|
-
this.sendJsonResponse(res, 200, this.statusSnapshot());
|
|
403
|
+
this.sendJsonResponse(res, 200, this.statusSnapshot({ scopeHostId: subject.hostId }));
|
|
316
404
|
return;
|
|
317
405
|
}
|
|
318
406
|
if (requestUrl.pathname === '/api/admin/status' && req.method === 'GET') {
|
|
@@ -358,7 +446,8 @@ class RelayServer {
|
|
|
358
446
|
this.sendJsonResponse(res, 401, { ok: false, code: 'host_auth_required', message: 'a valid host id and token are required' });
|
|
359
447
|
return;
|
|
360
448
|
}
|
|
361
|
-
|
|
449
|
+
const host = this.hosts.get(hostId);
|
|
450
|
+
if (!host || host.reconnecting || !isOpen(host.ws)) {
|
|
362
451
|
this.sendJsonResponse(res, 409, { ok: false, code: 'host_offline', message: 'no Herdr host is connected' });
|
|
363
452
|
return;
|
|
364
453
|
}
|
|
@@ -417,6 +506,66 @@ class RelayServer {
|
|
|
417
506
|
});
|
|
418
507
|
}
|
|
419
508
|
|
|
509
|
+
createHostRecord(message, ws, pending, clients = new Set()) {
|
|
510
|
+
const capabilities = Array.isArray(message.capabilities) ? message.capabilities : [];
|
|
511
|
+
return {
|
|
512
|
+
id: message.hostId,
|
|
513
|
+
ws,
|
|
514
|
+
hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
|
|
515
|
+
platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
|
|
516
|
+
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
517
|
+
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
518
|
+
connectedAtMs: pending.connectedAt,
|
|
519
|
+
// One shared terminal per workstation. Every browser attached to this
|
|
520
|
+
// host reads and writes the same PTY, so what one of them shows is what
|
|
521
|
+
// all of them show.
|
|
522
|
+
session: null,
|
|
523
|
+
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
524
|
+
lastSeenAt: Date.now(),
|
|
525
|
+
clients,
|
|
526
|
+
controllerId: null,
|
|
527
|
+
load: {},
|
|
528
|
+
ptys: [],
|
|
529
|
+
reconnecting: false,
|
|
530
|
+
reconnectTimer: null,
|
|
531
|
+
connectionGeneration: randomId('host-connection'),
|
|
532
|
+
handoffCapable: capabilities.includes('host_handoff'),
|
|
533
|
+
shutdownRequested: false,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
canHandoffHost(host) {
|
|
538
|
+
if (!host?.handoffCapable || host.clients.size === 0) return false;
|
|
539
|
+
for (const clientId of host.clients) {
|
|
540
|
+
const client = this.clients.get(clientId);
|
|
541
|
+
if (!client?.handoffCapable) return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
beginHostReconnect(host, reason = 'host_disconnected') {
|
|
547
|
+
if (!host || this.hosts.get(host.id) !== host || host.reconnecting) return;
|
|
548
|
+
if (!this.canHandoffHost(host)) {
|
|
549
|
+
this.detachHost(host, { notify: true, reason });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
host.ws = null;
|
|
553
|
+
host.reconnecting = true;
|
|
554
|
+
host.reconnectStartedAt = Date.now();
|
|
555
|
+
host.lastSeenAt = Date.now();
|
|
556
|
+
host.load = {};
|
|
557
|
+
host.ptys = [];
|
|
558
|
+
host.session = null;
|
|
559
|
+
this.broadcastToClients(host, () => ({ type: 'host_reconnecting', code: reason }));
|
|
560
|
+
host.reconnectTimer = setTimeout(() => {
|
|
561
|
+
host.reconnectTimer = null;
|
|
562
|
+
if (this.hosts.get(host.id) === host && host.reconnecting) {
|
|
563
|
+
this.detachHost(host, { notify: true, reason: 'host_reconnect_timeout' });
|
|
564
|
+
}
|
|
565
|
+
}, this.config.relay.hostReconnectGraceMs);
|
|
566
|
+
host.reconnectTimer.unref?.();
|
|
567
|
+
}
|
|
568
|
+
|
|
420
569
|
handleHostConnection(ws, req) {
|
|
421
570
|
const pending = { ws, remoteAddress: req.socket.remoteAddress, connectedAt: Date.now(), authenticated: false };
|
|
422
571
|
const deadline = setTimeout(() => {
|
|
@@ -432,43 +581,65 @@ class RelayServer {
|
|
|
432
581
|
if (isBinary) return this.rejectHandshake(ws, 'host hello must be JSON');
|
|
433
582
|
const message = parseJson(raw.toString());
|
|
434
583
|
if (!message || message.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid host hello');
|
|
584
|
+
if (!this.allowHostHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
|
|
585
|
+
const oldHost = this.hosts.get(message.hostId);
|
|
586
|
+
if (!oldHost && this.hosts.size >= this.config.relay.maxHosts) {
|
|
587
|
+
return this.rejectHandshake(ws, 'relay host limit reached', 'too_many_hosts');
|
|
588
|
+
}
|
|
435
589
|
const registration = this.auth.registerHost(message.hostId, message.token, message.password ?? null);
|
|
436
590
|
if (!registration.ok) return this.rejectHandshake(ws, registration.message, registration.code);
|
|
437
591
|
clearTimeout(deadline);
|
|
438
592
|
pending.authenticated = true;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
ptys: [],
|
|
461
|
-
};
|
|
593
|
+
this.finishHandshake(ws);
|
|
594
|
+
|
|
595
|
+
const handoff = this.canHandoffHost(oldHost);
|
|
596
|
+
const retainedClients = handoff ? oldHost.clients : new Set();
|
|
597
|
+
if (oldHost) {
|
|
598
|
+
if (oldHost.reconnectTimer) clearTimeout(oldHost.reconnectTimer);
|
|
599
|
+
oldHost.reconnectTimer = null;
|
|
600
|
+
if (handoff) {
|
|
601
|
+
if (oldHost.session && isOpen(oldHost.ws)) {
|
|
602
|
+
jsonSend(oldHost.ws, { type: 'session_stop', clientId: oldHost.session.streamId, streamId: oldHost.session.streamId });
|
|
603
|
+
}
|
|
604
|
+
oldHost.session = null;
|
|
605
|
+
oldHost.load = {};
|
|
606
|
+
oldHost.ptys = [];
|
|
607
|
+
this.broadcastToClients(oldHost, () => ({ type: 'host_reconnecting', code: 'host_replaced' }));
|
|
608
|
+
} else {
|
|
609
|
+
this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const host = this.createHostRecord(message, ws, pending, retainedClients);
|
|
462
614
|
pending.host = host;
|
|
615
|
+
// Install the new record before closing the old socket. Its delayed
|
|
616
|
+
// close handler then fails the identity check instead of detaching the
|
|
617
|
+
// freshly authenticated host.
|
|
463
618
|
this.hosts.set(host.id, host);
|
|
464
|
-
|
|
619
|
+
if (oldHost && handoff) closeSocket(oldHost.ws, 1000, 'host_replaced');
|
|
620
|
+
jsonSend(ws, {
|
|
621
|
+
type: 'host_ready',
|
|
622
|
+
protocol: PROTOCOL_VERSION,
|
|
623
|
+
hostId: host.id,
|
|
624
|
+
clientCount: host.clients.size,
|
|
625
|
+
});
|
|
626
|
+
this.notifyHostClientCount(host);
|
|
627
|
+
if (handoff) this.startSession(host, { restarted: true });
|
|
465
628
|
return;
|
|
466
629
|
}
|
|
467
630
|
this.handleHostMessage(pending.host, raw, isBinary);
|
|
468
631
|
});
|
|
469
|
-
ws.on('close', () => {
|
|
632
|
+
ws.on('close', (_code, rawReason) => {
|
|
470
633
|
clearTimeout(deadline);
|
|
471
|
-
|
|
634
|
+
const host = pending.host;
|
|
635
|
+
if (!host || this.hosts.get(host.id) !== host || host.ws !== ws) return;
|
|
636
|
+
const reason = rawReason ? rawReason.toString() : '';
|
|
637
|
+
if (reason === 'host_shutdown') host.shutdownRequested = true;
|
|
638
|
+
if (host.shutdownRequested || !this.canHandoffHost(host)) {
|
|
639
|
+
this.detachHost(host, { notify: true, reason: host.shutdownRequested ? 'host_shutdown' : 'host_disconnected' });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.beginHostReconnect(host, 'host_disconnected');
|
|
472
643
|
});
|
|
473
644
|
ws.on('error', () => {});
|
|
474
645
|
}
|
|
@@ -494,12 +665,10 @@ class RelayServer {
|
|
|
494
665
|
const session = host.session;
|
|
495
666
|
if (frame.type !== 'output' || !session || session.streamId !== frame.streamId) return;
|
|
496
667
|
this.rememberOutput(session, frame.payload);
|
|
497
|
-
for (const clientId of host.clients) {
|
|
668
|
+
for (const clientId of [...host.clients]) {
|
|
498
669
|
const client = this.clients.get(clientId);
|
|
499
670
|
if (!client || !isOpen(client.ws)) continue;
|
|
500
|
-
|
|
501
|
-
client.bytesSent += frame.payload.length;
|
|
502
|
-
this.metrics.recordOut(frame.payload.length);
|
|
671
|
+
this.sendClientBinary(host, client, frame.payload);
|
|
503
672
|
}
|
|
504
673
|
return;
|
|
505
674
|
}
|
|
@@ -510,6 +679,11 @@ class RelayServer {
|
|
|
510
679
|
host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
|
|
511
680
|
return;
|
|
512
681
|
}
|
|
682
|
+
if (message.type === 'host_shutdown') {
|
|
683
|
+
host.shutdownRequested = true;
|
|
684
|
+
if (this.hosts.get(host.id) === host) this.detachHost(host, { notify: true, reason: 'host_shutdown' });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
513
687
|
// Session-level news concerns the whole room: the host talks about the one
|
|
514
688
|
// shared stream, and every attached browser has to hear it.
|
|
515
689
|
const session = host.session;
|
|
@@ -535,6 +709,36 @@ class RelayServer {
|
|
|
535
709
|
}
|
|
536
710
|
}
|
|
537
711
|
|
|
712
|
+
/** Notify the host whether any browser currently needs business telemetry. */
|
|
713
|
+
notifyHostClientCount(host) {
|
|
714
|
+
if (!host || !isOpen(host.ws)) return;
|
|
715
|
+
jsonSend(host.ws, { type: 'client_count', clientCount: host.clients.size });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Forward output without allowing one slow browser to grow an unbounded ws
|
|
720
|
+
* queue. Closing only that browser preserves low latency for the other views.
|
|
721
|
+
*/
|
|
722
|
+
sendClientBinary(host, client, payload) {
|
|
723
|
+
if (!client || !isOpen(client.ws)) return false;
|
|
724
|
+
const limit = this.config.relay.maxBufferedBytesPerClient;
|
|
725
|
+
const buffered = Number(client.ws.bufferedAmount) || 0;
|
|
726
|
+
if (buffered + payload.length > limit) {
|
|
727
|
+
this.metrics.recordCleanup('slowClientsDropped');
|
|
728
|
+
this.detachClient(client, { notify: true, reason: 'slow_client', closeCode: 1013 });
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
client.ws.send(payload);
|
|
733
|
+
client.bytesSent += payload.length;
|
|
734
|
+
this.metrics.recordOut(payload.length, host.id);
|
|
735
|
+
return true;
|
|
736
|
+
} catch {
|
|
737
|
+
this.detachClient(client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
538
742
|
/** Send one JSON message to every browser attached to `host`. */
|
|
539
743
|
broadcastToClients(host, build) {
|
|
540
744
|
for (const clientId of [...host.clients]) {
|
|
@@ -580,6 +784,43 @@ class RelayServer {
|
|
|
580
784
|
};
|
|
581
785
|
}
|
|
582
786
|
|
|
787
|
+
/** Start the one shared PTY for a host's currently attached clients. */
|
|
788
|
+
startSession(host, { restarted = false } = {}) {
|
|
789
|
+
const firstClientId = [...host.clients][0];
|
|
790
|
+
const firstClient = firstClientId ? this.clients.get(firstClientId) : null;
|
|
791
|
+
if (!firstClient || !isOpen(host.ws)) return;
|
|
792
|
+
host.session = {
|
|
793
|
+
streamId: randomId('session'),
|
|
794
|
+
cols: firstClient.cols,
|
|
795
|
+
rows: firstClient.rows,
|
|
796
|
+
ready: false,
|
|
797
|
+
replay: [],
|
|
798
|
+
replayBytes: 0,
|
|
799
|
+
};
|
|
800
|
+
const dims = this.sharedDimensions(host) || { cols: firstClient.cols, rows: firstClient.rows };
|
|
801
|
+
host.session.cols = dims.cols;
|
|
802
|
+
host.session.rows = dims.rows;
|
|
803
|
+
jsonSend(host.ws, {
|
|
804
|
+
type: 'session_start',
|
|
805
|
+
clientId: host.session.streamId,
|
|
806
|
+
streamId: host.session.streamId,
|
|
807
|
+
cols: dims.cols,
|
|
808
|
+
rows: dims.rows,
|
|
809
|
+
role: 'controller',
|
|
810
|
+
});
|
|
811
|
+
this.broadcastToClients(host, () => ({ type: 'shared_resize', cols: dims.cols, rows: dims.rows }));
|
|
812
|
+
if (restarted) {
|
|
813
|
+
this.broadcastToClients(host, () => ({
|
|
814
|
+
type: 'session_restarted',
|
|
815
|
+
streamId: host.session.streamId,
|
|
816
|
+
cols: dims.cols,
|
|
817
|
+
rows: dims.rows,
|
|
818
|
+
hostname: host.hostname,
|
|
819
|
+
terminalPalette: host.terminalPalette || null,
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
583
824
|
/**
|
|
584
825
|
* Attach `client` to the workstation's shared terminal, starting it if this
|
|
585
826
|
* is the first browser through the door.
|
|
@@ -591,29 +832,7 @@ class RelayServer {
|
|
|
591
832
|
*/
|
|
592
833
|
attachSession(host, client) {
|
|
593
834
|
if (!host.session) {
|
|
594
|
-
host
|
|
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 });
|
|
835
|
+
this.startSession(host);
|
|
617
836
|
return;
|
|
618
837
|
}
|
|
619
838
|
|
|
@@ -621,8 +840,7 @@ class RelayServer {
|
|
|
621
840
|
if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
622
841
|
for (const chunk of session.replay) {
|
|
623
842
|
if (!isOpen(client.ws)) break;
|
|
624
|
-
|
|
625
|
-
client.bytesSent += chunk.length;
|
|
843
|
+
this.sendClientBinary(host, client, chunk);
|
|
626
844
|
}
|
|
627
845
|
// Geometry may now be smaller than it was; the resize doubles as the
|
|
628
846
|
// repaint that puts the newcomer on the same screen as everyone else.
|
|
@@ -684,6 +902,7 @@ class RelayServer {
|
|
|
684
902
|
if (isBinary) return this.rejectHandshake(ws, 'client hello must be JSON');
|
|
685
903
|
const message = parseJson(raw.toString());
|
|
686
904
|
if (!message || message.type !== 'hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid client hello');
|
|
905
|
+
if (!this.allowClientHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
|
|
687
906
|
let device = null;
|
|
688
907
|
let paired = null;
|
|
689
908
|
if (message.pairCode) {
|
|
@@ -694,7 +913,7 @@ class RelayServer {
|
|
|
694
913
|
else if (message.token) device = this.auth.authenticateDevice(message.token);
|
|
695
914
|
if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
|
|
696
915
|
const host = this.hosts.get(device.hostId);
|
|
697
|
-
if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
|
|
916
|
+
if (!host || host.reconnecting || !isOpen(host.ws)) return this.rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
|
|
698
917
|
|
|
699
918
|
// Two tabs of one browser are two windows onto the same terminal, not
|
|
700
919
|
// rivals. Nothing is retired here: the relay used to close whichever
|
|
@@ -704,6 +923,7 @@ class RelayServer {
|
|
|
704
923
|
// back, forever.
|
|
705
924
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
706
925
|
clearTimeout(deadline);
|
|
926
|
+
this.finishHandshake(ws);
|
|
707
927
|
const clientId = randomId('client');
|
|
708
928
|
const client = {
|
|
709
929
|
id: clientId,
|
|
@@ -712,7 +932,8 @@ class RelayServer {
|
|
|
712
932
|
deviceId: device.deviceId,
|
|
713
933
|
// Stable per browser profile; used to recognise a reconnect from the
|
|
714
934
|
// same browser rather than a genuinely separate viewer.
|
|
715
|
-
browserClientId: typeof message.clientId === 'string' ? message.clientId : null,
|
|
935
|
+
browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
|
|
936
|
+
handoffCapable: Array.isArray(message.capabilities) && message.capabilities.includes('host_handoff'),
|
|
716
937
|
// Every paired window may type. Pairing is the permission boundary;
|
|
717
938
|
// once a device is through it, holding a second window read-only
|
|
718
939
|
// serves nobody — they are all views of one shared terminal.
|
|
@@ -737,6 +958,7 @@ class RelayServer {
|
|
|
737
958
|
pending.client = client;
|
|
738
959
|
this.clients.set(client.id, client);
|
|
739
960
|
host.clients.add(client.id);
|
|
961
|
+
this.notifyHostClientCount(host);
|
|
740
962
|
ws.isAlive = true;
|
|
741
963
|
ws.on('pong', () => {
|
|
742
964
|
ws.isAlive = true;
|
|
@@ -756,6 +978,7 @@ class RelayServer {
|
|
|
756
978
|
// `null` is exactly the answer that means "nobody does".
|
|
757
979
|
controllerId: null,
|
|
758
980
|
hostId: host.id,
|
|
981
|
+
hostname: host.hostname,
|
|
759
982
|
clientId: client.id,
|
|
760
983
|
// Delivered with `ready`, before the first PTY byte, so the terminal
|
|
761
984
|
// is painted in the host's colors from its very first frame.
|
|
@@ -787,9 +1010,13 @@ class RelayServer {
|
|
|
787
1010
|
// with the session's stream id rather than the sender's.
|
|
788
1011
|
const frame = packStreamFrame('input', session.streamId, raw);
|
|
789
1012
|
if (isOpen(host.ws)) {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1013
|
+
try {
|
|
1014
|
+
host.ws.send(frame);
|
|
1015
|
+
client.bytesReceived += raw.length;
|
|
1016
|
+
this.metrics.recordIn(raw.length, host.id);
|
|
1017
|
+
} catch {
|
|
1018
|
+
this.beginHostReconnect(host, 'host_send_failed');
|
|
1019
|
+
}
|
|
793
1020
|
}
|
|
794
1021
|
return;
|
|
795
1022
|
}
|
|
@@ -858,7 +1085,7 @@ class RelayServer {
|
|
|
858
1085
|
return closed;
|
|
859
1086
|
}
|
|
860
1087
|
|
|
861
|
-
detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
|
|
1088
|
+
detachClient(client, { notify = true, reason = 'client_disconnected', closeCode = 1000 } = {}) {
|
|
862
1089
|
if (!client || !this.clients.has(client.id)) return;
|
|
863
1090
|
this.clients.delete(client.id);
|
|
864
1091
|
const host = this.hosts.get(client.hostId);
|
|
@@ -876,17 +1103,24 @@ class RelayServer {
|
|
|
876
1103
|
}
|
|
877
1104
|
host.controllerId = null;
|
|
878
1105
|
} else {
|
|
1106
|
+
this.notifyHostClientCount(host);
|
|
879
1107
|
this.syncDimensions(host);
|
|
880
1108
|
this.broadcastControlState(host);
|
|
881
1109
|
}
|
|
1110
|
+
if (host.clients.size === 0) {
|
|
1111
|
+
this.notifyHostClientCount(host);
|
|
1112
|
+
if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
|
|
1113
|
+
}
|
|
882
1114
|
}
|
|
883
1115
|
if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
|
|
884
|
-
closeSocket(client.ws,
|
|
1116
|
+
closeSocket(client.ws, closeCode, reason);
|
|
885
1117
|
this.metrics.recordCleanup('closedPtysCleaned');
|
|
886
1118
|
}
|
|
887
1119
|
|
|
888
1120
|
detachHost(host, { notify = true, reason = 'host_disconnected' } = {}) {
|
|
889
1121
|
if (!host || this.hosts.get(host.id) !== host) return;
|
|
1122
|
+
if (host.reconnectTimer) clearTimeout(host.reconnectTimer);
|
|
1123
|
+
host.reconnectTimer = null;
|
|
890
1124
|
this.hosts.delete(host.id);
|
|
891
1125
|
for (const clientId of [...host.clients]) {
|
|
892
1126
|
const client = this.clients.get(clientId);
|
|
@@ -896,14 +1130,17 @@ class RelayServer {
|
|
|
896
1130
|
closeSocket(client.ws, 1012, reason);
|
|
897
1131
|
}
|
|
898
1132
|
host.clients.clear();
|
|
899
|
-
|
|
1133
|
+
this.metrics.forgetHost(host.id);
|
|
1134
|
+
const hostSocket = host.ws;
|
|
1135
|
+
host.ws = null;
|
|
1136
|
+
closeSocket(hostSocket, 1000, reason);
|
|
900
1137
|
}
|
|
901
1138
|
|
|
902
1139
|
heartbeat() {
|
|
903
1140
|
const sockets = [
|
|
904
1141
|
...[...this.hosts.values()].map((host) => host.ws),
|
|
905
1142
|
...[...this.clients.values()].map((client) => client.ws),
|
|
906
|
-
];
|
|
1143
|
+
].filter(isOpen);
|
|
907
1144
|
for (const socket of sockets) {
|
|
908
1145
|
if (!socket.isAlive) {
|
|
909
1146
|
this.metrics.recordCleanup('deadConnectionsClosed');
|
|
@@ -923,6 +1160,12 @@ class RelayServer {
|
|
|
923
1160
|
for (const [key, attempt] of this.pairAttempts.entries()) {
|
|
924
1161
|
if (now - attempt.startedAt >= 60_000) this.pairAttempts.delete(key);
|
|
925
1162
|
}
|
|
1163
|
+
for (const [key, attempt] of this.clientHandshakeAttempts.entries()) {
|
|
1164
|
+
if (now - attempt.startedAt >= 60_000) this.clientHandshakeAttempts.delete(key);
|
|
1165
|
+
}
|
|
1166
|
+
for (const [key, attempt] of this.hostHandshakeAttempts.entries()) {
|
|
1167
|
+
if (now - attempt.startedAt >= 60_000) this.hostHandshakeAttempts.delete(key);
|
|
1168
|
+
}
|
|
926
1169
|
for (const client of [...this.clients.values()]) {
|
|
927
1170
|
if (now - client.lastSeenAt > staleAfter) {
|
|
928
1171
|
this.metrics.recordCleanup('staleClientsPurged');
|
|
@@ -930,6 +1173,7 @@ class RelayServer {
|
|
|
930
1173
|
}
|
|
931
1174
|
}
|
|
932
1175
|
for (const host of [...this.hosts.values()]) {
|
|
1176
|
+
if (host.reconnecting) continue;
|
|
933
1177
|
if (now - host.lastSeenAt > staleAfter) {
|
|
934
1178
|
this.metrics.recordCleanup('deadConnectionsClosed');
|
|
935
1179
|
this.detachHost(host, { notify: true, reason: 'stale_host' });
|
|
@@ -940,12 +1184,18 @@ class RelayServer {
|
|
|
940
1184
|
this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
|
|
941
1185
|
}
|
|
942
1186
|
|
|
943
|
-
statusSnapshot({ sample = true, includeDevices = false } = {}) {
|
|
944
|
-
const
|
|
1187
|
+
statusSnapshot({ sample = true, includeDevices = false, scopeHostId = null } = {}) {
|
|
1188
|
+
const scopedClients = scopeHostId
|
|
1189
|
+
? [...this.clients.values()].filter((client) => client.hostId === scopeHostId)
|
|
1190
|
+
: [...this.clients.values()];
|
|
1191
|
+
const scopedHosts = scopeHostId
|
|
1192
|
+
? [...this.hosts.values()].filter((host) => host.id === scopeHostId)
|
|
1193
|
+
: [...this.hosts.values()];
|
|
1194
|
+
const clients = scopedClients.map((client) => ({
|
|
945
1195
|
id: client.id,
|
|
946
1196
|
role: client.role,
|
|
947
|
-
hostId: client.hostId,
|
|
948
|
-
deviceId: client.deviceId,
|
|
1197
|
+
...(scopeHostId ? {} : { hostId: client.hostId }),
|
|
1198
|
+
...(includeDevices ? { deviceId: client.deviceId } : {}),
|
|
949
1199
|
userAgent: client.userAgent,
|
|
950
1200
|
connectedAt: client.connectedAt,
|
|
951
1201
|
lastPingAt: client.lastPingAt,
|
|
@@ -953,22 +1203,23 @@ class RelayServer {
|
|
|
953
1203
|
bytesSent: client.bytesSent,
|
|
954
1204
|
ip: client.ip,
|
|
955
1205
|
}));
|
|
956
|
-
const hosts =
|
|
1206
|
+
const hosts = scopedHosts.map((host) => ({
|
|
957
1207
|
id: host.id,
|
|
958
1208
|
hostname: host.hostname,
|
|
959
1209
|
platform: host.platform,
|
|
960
1210
|
arch: host.arch,
|
|
961
|
-
status: host.clients.size ? 'busy' : 'online',
|
|
1211
|
+
status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
|
|
962
1212
|
connectedAt: host.connectedAt,
|
|
963
1213
|
activePtyCount: host.ptys.length,
|
|
964
1214
|
load: host.load,
|
|
965
1215
|
}));
|
|
966
1216
|
// The workstation counts one PTY per stream and cannot know how many
|
|
967
1217
|
// windows are watching it; the relay does, and that is the number an
|
|
968
|
-
// operator needs when the session is shared.
|
|
969
|
-
|
|
1218
|
+
// operator needs when the session is shared. Scoped callers only receive
|
|
1219
|
+
// the PTYs belonging to their authenticated host.
|
|
1220
|
+
const ptys = scopedHosts.flatMap((host) => host.ptys.map((pty) => ({
|
|
970
1221
|
...pty,
|
|
971
|
-
hostId: host.id,
|
|
1222
|
+
...(scopeHostId ? {} : { hostId: host.id }),
|
|
972
1223
|
activeClients: host.clients.size,
|
|
973
1224
|
})));
|
|
974
1225
|
// The paired-device roster identifies people's hardware, so it is served to
|
|
@@ -976,7 +1227,7 @@ class RelayServer {
|
|
|
976
1227
|
// may read.
|
|
977
1228
|
const devices = includeDevices ? this.auth.listDevices() : undefined;
|
|
978
1229
|
return {
|
|
979
|
-
...this.metrics.snapshot({ clients, hosts, ptys, sample }),
|
|
1230
|
+
...this.metrics.snapshot({ clients, hosts, ptys, sample, scopeHostId }),
|
|
980
1231
|
...(devices ? { devices } : {}),
|
|
981
1232
|
relayMode: this.relayMode,
|
|
982
1233
|
isRemoteRelay: this.relayMode === 'remote',
|
|
@@ -989,6 +1240,10 @@ class RelayServer {
|
|
|
989
1240
|
bind: this.config.relay.host,
|
|
990
1241
|
port: this.address()?.port || this.config.relay.port,
|
|
991
1242
|
maxClientsPerHost: this.config.relay.maxClientsPerHost,
|
|
1243
|
+
maxHosts: this.config.relay.maxHosts,
|
|
1244
|
+
maxPendingHandshakes: this.config.relay.maxPendingHandshakes,
|
|
1245
|
+
maxBufferedBytesPerClient: this.config.relay.maxBufferedBytesPerClient,
|
|
1246
|
+
hostReconnectGraceMs: this.config.relay.hostReconnectGraceMs,
|
|
992
1247
|
adminConfigured: Boolean(this.adminToken),
|
|
993
1248
|
adminStatusPath: '/api/admin/status',
|
|
994
1249
|
dashboardPath: '/admin',
|