herdr-remote-relay 0.3.12 → 0.3.13

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.
Files changed (64) hide show
  1. package/bin/herdr-remote-relay.js +20 -20
  2. package/dist/auth-store.d.ts +114 -0
  3. package/dist/auth-store.js +276 -0
  4. package/dist/metrics.d.ts +76 -0
  5. package/dist/metrics.js +249 -0
  6. package/dist/protocol/frames.d.ts +27 -0
  7. package/dist/protocol/frames.js +110 -0
  8. package/dist/protocol/http.d.ts +161 -0
  9. package/dist/protocol/http.js +3 -0
  10. package/dist/protocol/index.d.ts +5 -0
  11. package/dist/protocol/index.js +7 -0
  12. package/dist/protocol/messages.d.ts +321 -0
  13. package/dist/protocol/messages.js +18 -0
  14. package/dist/protocol/paste.d.ts +14 -0
  15. package/dist/protocol/paste.js +50 -0
  16. package/dist/protocol/terminal.d.ts +79 -0
  17. package/dist/protocol/terminal.js +150 -0
  18. package/dist/relay-config.d.ts +74 -0
  19. package/dist/relay-config.js +316 -0
  20. package/dist/relay-server.d.ts +64 -0
  21. package/dist/relay-server.js +194 -0
  22. package/dist/scroll-input.d.ts +17 -0
  23. package/dist/scroll-input.js +124 -0
  24. package/dist/server/client-channel.d.ts +3 -0
  25. package/dist/server/client-channel.js +365 -0
  26. package/dist/server/font-proxy.d.ts +19 -0
  27. package/dist/server/font-proxy.js +155 -0
  28. package/dist/server/host-broadcasts.d.ts +25 -0
  29. package/dist/server/host-broadcasts.js +79 -0
  30. package/dist/server/host-channel.d.ts +3 -0
  31. package/dist/server/host-channel.js +266 -0
  32. package/dist/server/http-api.d.ts +3 -0
  33. package/dist/server/http-api.js +214 -0
  34. package/dist/server/maintenance.d.ts +5 -0
  35. package/dist/server/maintenance.js +64 -0
  36. package/dist/server/requests.d.ts +34 -0
  37. package/dist/server/requests.js +142 -0
  38. package/dist/server/sessions.d.ts +36 -0
  39. package/dist/server/sessions.js +225 -0
  40. package/dist/server/sockets.d.ts +12 -0
  41. package/dist/server/sockets.js +64 -0
  42. package/dist/server/status.d.ts +11 -0
  43. package/dist/server/status.js +95 -0
  44. package/dist/server/transport.d.ts +16 -0
  45. package/dist/server/transport.js +110 -0
  46. package/dist/server/types.d.ts +123 -0
  47. package/dist/server/types.js +3 -0
  48. package/dist/server/version.d.ts +2 -0
  49. package/dist/server/version.js +5 -0
  50. package/dist/state.d.ts +6 -0
  51. package/dist/state.js +47 -0
  52. package/package.json +32 -8
  53. package/web/dist/assets/index-B-5zRmL_.js +61 -0
  54. package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
  55. package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
  56. package/web/dist/index.html +3 -3
  57. package/src/auth-store.js +0 -256
  58. package/src/metrics.js +0 -241
  59. package/src/relay-config.js +0 -312
  60. package/src/relay-server.js +0 -1799
  61. package/src/scroll-input.js +0 -139
  62. package/src/state.js +0 -47
  63. package/src/stream-frame.js +0 -238
  64. package/web/dist/assets/index-6WucmdNP.js +0 -61
package/src/auth-store.js DELETED
@@ -1,256 +0,0 @@
1
- 'use strict';
2
-
3
- const crypto = require('node:crypto');
4
- const { randomToken, readJson, writeJsonAtomic, ensureDir } = require('./state');
5
-
6
- function hash(value) {
7
- return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
8
- }
9
-
10
- function equalHash(left, right) {
11
- if (typeof left !== 'string' || typeof right !== 'string') return false;
12
- const a = Buffer.from(left, 'hex');
13
- const b = Buffer.from(right, 'hex');
14
- return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b);
15
- }
16
-
17
- function nowIso(now = Date.now()) {
18
- return new Date(now).toISOString();
19
- }
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
-
26
- class AuthStore {
27
- constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null } = {}) {
28
- if (!stateFile) throw new TypeError('stateFile is required');
29
- this.stateFile = stateFile;
30
- this.pairingTtlMs = pairingTtlMs;
31
- this.deviceTtlMs = deviceTtlMs;
32
- this.maxDevices = maxDevices;
33
- // Optional shared password. When unset the relay is public: anyone may
34
- // enrol a workstation. That is safe because a workstation is only ever
35
- // reachable through its own host token, which the relay never hands out.
36
- this.password = password || null;
37
- this.pairings = new Map();
38
- this.lastDeviceSaveAt = new Map();
39
- this.state = readJson(stateFile, { version: 1, hosts: {}, devices: {} });
40
- this.state.version = 1;
41
- this.state.hosts = this.state.hosts && typeof this.state.hosts === 'object' ? this.state.hosts : {};
42
- this.state.devices = this.state.devices && typeof this.state.devices === 'object' ? this.state.devices : {};
43
- ensureDir(require('node:path').dirname(stateFile));
44
- }
45
-
46
- save() {
47
- writeJsonAtomic(this.stateFile, this.state);
48
- }
49
-
50
- /** Does this request carry the relay password, if one is required at all? */
51
- checkPassword(supplied) {
52
- if (!this.password) return true;
53
- return typeof supplied === 'string' && equalHash(hash(supplied), hash(this.password));
54
- }
55
-
56
- /**
57
- * Enrol a workstation, or re-authenticate one that is already enrolled.
58
- *
59
- * The password (when the relay has one) decides who may *join*. The host
60
- * token decides who may act *as a given workstation*: it is generated on the
61
- * workstation and only its hash is ever stored here, so even on a public
62
- * relay nobody else can impersonate an enrolled host or pair a device to it.
63
- */
64
- registerHost(hostId, token, password = null, now = Date.now()) {
65
- if (!validHostId(hostId) || typeof token !== 'string' || token.length < 16 || token.length > 4096) {
66
- return { ok: false, code: 'invalid_host_credentials', message: 'hostId and token are required' };
67
- }
68
- if (!this.checkPassword(password)) {
69
- return { ok: false, code: 'relay_password_required', message: 'the relay password is missing or wrong' };
70
- }
71
- const existing = this.state.hosts[hostId];
72
- if (existing) {
73
- if (!equalHash(existing.tokenHash, hash(token))) {
74
- return { ok: false, code: 'host_auth_failed', message: 'host token is invalid' };
75
- }
76
- existing.lastSeenAt = nowIso(now);
77
- this.save();
78
- return { ok: true, hostId, firstSeen: false };
79
- }
80
-
81
- this.state.hosts[hostId] = {
82
- tokenHash: hash(token),
83
- createdAt: nowIso(now),
84
- lastSeenAt: nowIso(now),
85
- };
86
- this.save();
87
- return { ok: true, hostId, firstSeen: true };
88
- }
89
-
90
- /** Verify a host token without enrolling anything. */
91
- authenticateHost(hostId, token) {
92
- if (!validHostId(hostId) || typeof token !== 'string' || token.length < 16 || token.length > 4096) return false;
93
- const existing = this.state.hosts[hostId];
94
- return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
95
- }
96
-
97
- hostCount() {
98
- return Object.keys(this.state.hosts).length;
99
- }
100
-
101
- startPairing(hostId, publicUrl, now = Date.now()) {
102
- if (!validHostId(hostId) || !this.state.hosts[hostId]) {
103
- const error = new Error('host is not connected or enrolled');
104
- error.code = 'host_not_found';
105
- throw error;
106
- }
107
- this.cleanup(now);
108
- let code;
109
- do {
110
- code = randomToken(4).toUpperCase().replace(/[-_]/g, '').slice(0, 6);
111
- } while ([...this.pairings.values()].some((pairing) => equalHash(pairing.codeHash, hash(code))));
112
- const expiresAt = now + this.pairingTtlMs;
113
- this.pairings.set(code, {
114
- codeHash: hash(code),
115
- hostId,
116
- expiresAt,
117
- publicUrl,
118
- });
119
- return { code, hostId, publicUrl, expiresAt, expiresAtIso: nowIso(expiresAt) };
120
- }
121
-
122
- completePairing(code, now = Date.now()) {
123
- if (typeof code !== 'string' || code.length < 4 || code.length > 32) return null;
124
- this.cleanup(now);
125
- const normalized = code.trim().toUpperCase();
126
- const pairing = this.pairings.get(normalized);
127
- if (!pairing || pairing.expiresAt <= now || !equalHash(pairing.codeHash, hash(normalized))) return null;
128
- this.pairings.delete(normalized);
129
-
130
- const devices = Object.values(this.state.devices);
131
- if (devices.length >= this.maxDevices) {
132
- devices.sort((a, b) => (a.lastSeenAt || '').localeCompare(b.lastSeenAt || ''));
133
- delete this.state.devices[devices[0].deviceId];
134
- }
135
- const deviceId = `device-${randomToken(9)}`;
136
- const token = randomToken(32);
137
- const expiresAt = now + this.deviceTtlMs;
138
- this.state.devices[deviceId] = {
139
- deviceId,
140
- hostId: pairing.hostId,
141
- tokenHash: hash(token),
142
- createdAt: nowIso(now),
143
- lastSeenAt: nowIso(now),
144
- expiresAt,
145
- };
146
- this.save();
147
- return { deviceId, hostId: pairing.hostId, token, expiresAt, expiresAtIso: nowIso(expiresAt), publicUrl: pairing.publicUrl };
148
- }
149
-
150
- /**
151
- * Record how a device presented itself, so an operator revoking a device can
152
- * tell which phone or laptop they are about to cut off. Only the coarse
153
- * user agent string and the address are kept — never terminal content.
154
- */
155
- noteDeviceSeen(deviceId, { userAgent, ip } = {}, now = Date.now()) {
156
- const device = this.state.devices[deviceId];
157
- if (!device) return null;
158
- let changed = false;
159
- if (userAgent && device.userAgent !== userAgent) {
160
- device.userAgent = String(userAgent).slice(0, 256);
161
- changed = true;
162
- }
163
- if (ip && device.lastIp !== ip) {
164
- device.lastIp = String(ip).slice(0, 64);
165
- changed = true;
166
- }
167
- device.lastSeenAt = nowIso(now);
168
- if (changed) this.save();
169
- return { ...device };
170
- }
171
-
172
- /**
173
- * Devices an operator may act on, newest first. Token hashes are never
174
- * included: the dashboard has no use for them and they must not leave the
175
- * process.
176
- */
177
- listDevices(now = Date.now()) {
178
- return Object.values(this.state.devices)
179
- .filter((device) => device && device.expiresAt > now)
180
- .map((device) => ({
181
- deviceId: device.deviceId,
182
- hostId: device.hostId,
183
- createdAt: device.createdAt,
184
- lastSeenAt: device.lastSeenAt,
185
- expiresAt: device.expiresAt,
186
- expiresAtIso: nowIso(device.expiresAt),
187
- userAgent: device.userAgent || null,
188
- lastIp: device.lastIp || null,
189
- }))
190
- .sort((a, b) => String(b.lastSeenAt || '').localeCompare(String(a.lastSeenAt || '')));
191
- }
192
-
193
- /**
194
- * Permanently invalidate a paired device. The stored hash is dropped, so the
195
- * token it was derived from can never authenticate again. Returns the removed
196
- * record so the caller can also close whatever sockets it still holds.
197
- */
198
- revokeDevice(deviceId) {
199
- if (typeof deviceId !== 'string' || !deviceId) return null;
200
- const device = this.state.devices[deviceId];
201
- if (!device) return null;
202
- delete this.state.devices[deviceId];
203
- this.lastDeviceSaveAt.delete(deviceId);
204
- this.save();
205
- return {
206
- deviceId: device.deviceId,
207
- hostId: device.hostId,
208
- userAgent: device.userAgent || null,
209
- lastIp: device.lastIp || null,
210
- };
211
- }
212
-
213
- authenticateDevice(token, now = Date.now()) {
214
- if (typeof token !== 'string' || token.length < 16) return null;
215
- const tokenHash = hash(token);
216
- for (const device of Object.values(this.state.devices)) {
217
- if (device.expiresAt <= now) continue;
218
- if (!equalHash(device.tokenHash, tokenHash)) continue;
219
- device.lastSeenAt = nowIso(now);
220
- device.expiresAt = now + this.deviceTtlMs;
221
- const lastSaveAt = this.lastDeviceSaveAt.get(device.deviceId) || 0;
222
- if (now - lastSaveAt >= 60 * 1000) {
223
- this.lastDeviceSaveAt.set(device.deviceId, now);
224
- this.save();
225
- }
226
- return { ...device };
227
- }
228
- return null;
229
- }
230
-
231
- cleanup(now = Date.now()) {
232
- let removedDevices = 0;
233
- for (const [deviceId, device] of Object.entries(this.state.devices)) {
234
- if (!device || device.expiresAt <= now) {
235
- delete this.state.devices[deviceId];
236
- this.lastDeviceSaveAt.delete(deviceId);
237
- removedDevices += 1;
238
- }
239
- }
240
- let removedPairings = 0;
241
- for (const [code, pairing] of this.pairings.entries()) {
242
- if (pairing.expiresAt <= now) {
243
- this.pairings.delete(code);
244
- removedPairings += 1;
245
- }
246
- }
247
- if (removedDevices) this.save();
248
- return { removedDevices, removedPairings };
249
- }
250
-
251
- deviceCount() {
252
- return Object.keys(this.state.devices).length;
253
- }
254
- }
255
-
256
- module.exports = { AuthStore, hash, equalHash, nowIso };
package/src/metrics.js DELETED
@@ -1,241 +0,0 @@
1
- 'use strict';
2
-
3
- const os = require('node:os');
4
- const { monitorEventLoopDelay } = require('node:perf_hooks');
5
-
6
- function finite(value, fallback = 0) {
7
- return Number.isFinite(value) ? value : fallback;
8
- }
9
-
10
- function bytesPerSecond(current, previous, elapsedMs) {
11
- if (!elapsedMs || elapsedMs <= 0) return 0;
12
- return Math.max(0, (current - previous) * 1000 / elapsedMs);
13
- }
14
-
15
- /**
16
- * How many people are attached, rather than how many sockets are open.
17
- *
18
- * A phone and a laptop belonging to one person are two connections but one
19
- * paired device each, and one person opening three browser windows is three
20
- * connections and one device — so the distinct paired device is the closest
21
- * thing the relay has to a user. Connections that predate device pairing carry
22
- * no `deviceId`; they fall back to their own connection id so they still count
23
- * once instead of collapsing into a single anonymous user.
24
- */
25
- function countActiveUsers(clients = []) {
26
- const identities = new Set();
27
- for (const client of clients) {
28
- if (!client) continue;
29
- identities.add(
30
- typeof client.deviceId === 'string' && client.deviceId
31
- ? `device:${client.deviceId}`
32
- : `client:${client.id}`
33
- );
34
- }
35
- return identities.size;
36
- }
37
-
38
- class RelayMetrics {
39
- constructor({ version = '0.1.0', protocolVersion = 1 } = {}) {
40
- this.version = version;
41
- this.protocolVersion = protocolVersion;
42
- this.startedAt = Date.now();
43
- this.bytesIn = 0;
44
- this.bytesOut = 0;
45
- this.framesIn = 0;
46
- this.framesOut = 0;
47
- this.cleanup = {
48
- staleClientsPurged: 0,
49
- closedPtysCleaned: 0,
50
- deadConnectionsClosed: 0,
51
- idleHostsTerminated: 0,
52
- slowClientsDropped: 0,
53
- lastCleanupAt: null,
54
- };
55
- this.lastSample = { at: this.startedAt, bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 };
56
- this.hostTraffic = new Map();
57
- this.cpuSampleAt = process.hrtime.bigint();
58
- this.cpuSample = process.cpuUsage();
59
- this.eventLoop = monitorEventLoopDelay({ resolution: 20 });
60
- this.eventLoop.enable();
61
- }
62
-
63
- hostCounter(hostId) {
64
- if (typeof hostId !== 'string' || hostId.length === 0) return null;
65
- let counter = this.hostTraffic.get(hostId);
66
- if (!counter) {
67
- counter = {
68
- bytesIn: 0,
69
- bytesOut: 0,
70
- framesIn: 0,
71
- framesOut: 0,
72
- sampleAt: Date.now(),
73
- sampleBytesIn: 0,
74
- sampleBytesOut: 0,
75
- sampleFramesIn: 0,
76
- sampleFramesOut: 0,
77
- };
78
- this.hostTraffic.set(hostId, counter);
79
- }
80
- return counter;
81
- }
82
-
83
- recordIn(bytes, hostId = null) {
84
- const amount = Math.max(0, Number(bytes) || 0);
85
- this.bytesIn += amount;
86
- this.framesIn += 1;
87
- const counter = this.hostCounter(hostId);
88
- if (counter) {
89
- counter.bytesIn += amount;
90
- counter.framesIn += 1;
91
- }
92
- }
93
-
94
- recordOut(bytes, hostId = null) {
95
- const amount = Math.max(0, Number(bytes) || 0);
96
- this.bytesOut += amount;
97
- this.framesOut += 1;
98
- const counter = this.hostCounter(hostId);
99
- if (counter) {
100
- counter.bytesOut += amount;
101
- counter.framesOut += 1;
102
- }
103
- }
104
-
105
- forgetHost(hostId) {
106
- if (typeof hostId === 'string') this.hostTraffic.delete(hostId);
107
- }
108
-
109
- hostThroughput(hostId, now = Date.now()) {
110
- const counter = this.hostCounter(hostId);
111
- if (!counter) return {
112
- bytesIn: 0,
113
- bytesOut: 0,
114
- bytesInPerSec: 0,
115
- bytesOutPerSec: 0,
116
- framesIn: 0,
117
- framesOut: 0,
118
- framesInPerSec: 0,
119
- framesOutPerSec: 0,
120
- };
121
- const elapsedMs = now - counter.sampleAt;
122
- const throughput = {
123
- bytesIn: counter.bytesIn,
124
- bytesOut: counter.bytesOut,
125
- bytesInPerSec: bytesPerSecond(counter.bytesIn, counter.sampleBytesIn, elapsedMs),
126
- bytesOutPerSec: bytesPerSecond(counter.bytesOut, counter.sampleBytesOut, elapsedMs),
127
- framesIn: counter.framesIn,
128
- framesOut: counter.framesOut,
129
- framesInPerSec: bytesPerSecond(counter.framesIn, counter.sampleFramesIn, elapsedMs),
130
- framesOutPerSec: bytesPerSecond(counter.framesOut, counter.sampleFramesOut, elapsedMs),
131
- };
132
- counter.sampleAt = now;
133
- counter.sampleBytesIn = counter.bytesIn;
134
- counter.sampleBytesOut = counter.bytesOut;
135
- counter.sampleFramesIn = counter.framesIn;
136
- counter.sampleFramesOut = counter.framesOut;
137
- return throughput;
138
- }
139
-
140
- recordCleanup(name, amount = 1) {
141
- if (Object.prototype.hasOwnProperty.call(this.cleanup, name) && typeof this.cleanup[name] === 'number') {
142
- this.cleanup[name] += amount;
143
- }
144
- this.cleanup.lastCleanupAt = new Date().toISOString();
145
- }
146
-
147
- cpuPercent() {
148
- const now = process.hrtime.bigint();
149
- const elapsedNs = Number(now - this.cpuSampleAt);
150
- const usage = process.cpuUsage(this.cpuSample);
151
- this.cpuSampleAt = now;
152
- this.cpuSample = process.cpuUsage();
153
- if (elapsedNs <= 0) return 0;
154
- const percent = ((usage.user + usage.system) * 100000) / elapsedNs;
155
- return Math.max(0, Math.round(percent * 100) / 100);
156
- }
157
-
158
- eventLoopDelay() {
159
- const percentile = (value) => Math.round(finite(value / 1e6) * 100) / 100;
160
- return {
161
- p50Ms: percentile(this.eventLoop.percentile(50)),
162
- p90Ms: percentile(this.eventLoop.percentile(90)),
163
- p99Ms: percentile(this.eventLoop.percentile(99)),
164
- maxMs: percentile(this.eventLoop.max),
165
- meanMs: percentile(this.eventLoop.mean),
166
- };
167
- }
168
-
169
- /**
170
- * `activeUserCount` is passed in rather than derived from `clients`: the
171
- * public `/api/status` rows deliberately omit `deviceId`, so only the caller
172
- * still holding the full connection records can count devices correctly.
173
- * Omitting it falls back to counting whatever the given rows identify.
174
- */
175
- snapshot({ clients = [], hosts = [], ptys = [], sample = true, scopeHostId = null, activeUserCount = null } = {}) {
176
- const now = Date.now();
177
- const elapsedMs = now - this.lastSample.at;
178
- const throughput = {
179
- bytesIn: this.bytesIn,
180
- bytesOut: this.bytesOut,
181
- bytesInPerSec: bytesPerSecond(this.bytesIn, this.lastSample.bytesIn, elapsedMs),
182
- bytesOutPerSec: bytesPerSecond(this.bytesOut, this.lastSample.bytesOut, elapsedMs),
183
- framesIn: this.framesIn,
184
- framesOut: this.framesOut,
185
- framesInPerSec: bytesPerSecond(this.framesIn, this.lastSample.framesIn, elapsedMs),
186
- framesOutPerSec: bytesPerSecond(this.framesOut, this.lastSample.framesOut, elapsedMs),
187
- };
188
- if (sample) {
189
- this.lastSample = { at: now, bytesIn: this.bytesIn, bytesOut: this.bytesOut, framesIn: this.framesIn, framesOut: this.framesOut };
190
- }
191
- const memory = process.memoryUsage();
192
- const load = os.loadavg();
193
- // Every attached window has full input, so there is no "active controller"
194
- // to name: reporting the first of them as one had an operator reading a
195
- // distinction that does not exist. The host is still worth naming, and any
196
- // client knows which workstation it is on.
197
- const anyClient = clients[0];
198
- const scopedThroughput = scopeHostId ? this.hostThroughput(scopeHostId, now) : throughput;
199
- return {
200
- version: this.version,
201
- protocolVersion: this.protocolVersion,
202
- uptimeSeconds: Math.floor((now - this.startedAt) / 1000),
203
- startTime: new Date(this.startedAt).toISOString(),
204
- serverTime: new Date(now).toISOString(),
205
- activeControllerId: null,
206
- activeHostId: anyClient?.hostId || hosts[0]?.id || null,
207
- clients,
208
- hosts,
209
- ptys,
210
- clientCount: clients.length,
211
- hostCount: hosts.length,
212
- ptyCount: ptys.length,
213
- activeUserCount: Number.isFinite(activeUserCount) ? activeUserCount : countActiveUsers(clients),
214
- throughput: scopedThroughput,
215
- cpu: {
216
- load1m: finite(load[0]),
217
- load5m: finite(load[1]),
218
- load15m: finite(load[2]),
219
- cpuPercent: this.cpuPercent(),
220
- cores: os.cpus().length,
221
- },
222
- memory: {
223
- rssBytes: memory.rss,
224
- heapUsedBytes: memory.heapUsed,
225
- heapTotalBytes: memory.heapTotal,
226
- externalBytes: memory.external,
227
- systemTotalBytes: os.totalmem(),
228
- systemFreeBytes: os.freemem(),
229
- },
230
- eventLoopDelay: this.eventLoopDelay(),
231
- cleanup: { ...this.cleanup },
232
- };
233
- }
234
-
235
- close() {
236
- this.eventLoop.disable();
237
- this.hostTraffic.clear();
238
- }
239
- }
240
-
241
- module.exports = { RelayMetrics, countActiveUsers };