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 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` 头,并设置较长空闲超时时间。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
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 hostId !== 'string' || hostId.length < 1 || hostId.length > 128 || typeof token !== 'string' || token.length < 16) {
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 (typeof hostId !== 'string' || typeof token !== 'string' || token.length < 16) return false;
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 === hash(code)));
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 !== hash(normalized)) return null;
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
- recordIn(bytes) {
39
- this.bytesIn += bytes;
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
- this.bytesOut += bytes;
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
 
@@ -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);
@@ -83,11 +83,24 @@ function tokenMatches(candidate, expected) {
83
83
 
84
84
  class RelayServer {
85
85
  constructor(config = loadRelayConfig().config, options = {}) {
86
- this.config = 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({ noServer: true, clientTracking: false, maxPayload: config.relay.maxPayloadBytes });
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
- allowPairAttempt(req) {
301
+ allowAttempt(store, req, limit = 20) {
243
302
  const key = this.rateLimitKey(req);
244
303
  const now = Date.now();
245
- const current = this.pairAttempts.get(key);
304
+ const current = store.get(key);
246
305
  if (!current || now - current.startedAt >= 60_000) {
247
- this.pairAttempts.set(key, { startedAt: now, count: 1 });
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 >= 20) return false;
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
- const snapshot = this.statusSnapshot({ sample: false });
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
- hosts: snapshot.hostCount,
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
- if (!this.authorizedHost(req) && !this.authorizedDevice(req)) {
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
- if (!this.hosts.has(hostId)) {
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
- const oldHost = this.hosts.get(message.hostId);
440
- if (oldHost) this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
441
- const host = {
442
- id: message.hostId,
443
- ws,
444
- hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
445
- platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
446
- arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
447
- connectedAt: new Date(pending.connectedAt).toISOString(),
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,
453
- // Colors are the workstation's to declare, but only in the one shape
454
- // a browser renderer accepts.
455
- terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
456
- lastSeenAt: Date.now(),
457
- clients: new Set(),
458
- controllerId: null,
459
- load: {},
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
- jsonSend(ws, { type: 'host_ready', protocol: PROTOCOL_VERSION, hostId: host.id });
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
- if (pending.host) this.detachHost(pending.host, { notify: true, reason: 'host_disconnected' });
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
- client.ws.send(frame.payload);
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.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 });
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
- client.ws.send(chunk);
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
- host.ws.send(frame);
791
- client.bytesReceived += raw.length;
792
- this.metrics.recordIn(raw.length);
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, 1000, reason);
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
- closeSocket(host.ws, 1000, reason);
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 clients = [...this.clients.values()].map((client) => ({
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 = [...this.hosts.values()].map((host) => ({
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
- const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({
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',