herdr-remote-relay 0.2.0 → 0.2.1
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/LICENSE +0 -0
- package/README.md +0 -0
- package/config.example.json +0 -0
- package/deploy/cloudflared/config.yml.example +0 -0
- package/deploy/docker/compose.yaml +0 -0
- package/deploy/nginx/herdr-remote-relay.conf.example +0 -0
- package/deploy/systemd/herdr-remote-relay.service +0 -0
- package/deploy/systemd/relay.env.example +0 -0
- package/package.json +1 -1
- package/src/auth-store.js +63 -0
- package/src/metrics.js +0 -0
- package/src/relay-config.js +0 -0
- package/src/relay-server.js +87 -2
- package/src/scroll-input.js +0 -0
- package/src/state.js +0 -0
- package/src/stream-frame.js +0 -0
- package/web/dist/assets/index-D2ksY-SN.js +0 -39
- package/web/dist/assets/index-Zi2TgjUt.css +0 -32
- package/web/dist/assets/vendor-icons-VfRMalKD.js +0 -294
- package/web/dist/assets/vendor-react-B6szxdcU.js +0 -9
- package/web/dist/assets/vendor-xterm-Bldps5fz.js +0 -66
- package/web/dist/index.html +0 -38
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/config.example.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
package/src/auth-store.js
CHANGED
|
@@ -142,6 +142,69 @@ class AuthStore {
|
|
|
142
142
|
return { deviceId, hostId: pairing.hostId, token, expiresAt, expiresAtIso: nowIso(expiresAt), publicUrl: pairing.publicUrl };
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Record how a device presented itself, so an operator revoking a device can
|
|
147
|
+
* tell which phone or laptop they are about to cut off. Only the coarse
|
|
148
|
+
* user agent string and the address are kept — never terminal content.
|
|
149
|
+
*/
|
|
150
|
+
noteDeviceSeen(deviceId, { userAgent, ip } = {}, now = Date.now()) {
|
|
151
|
+
const device = this.state.devices[deviceId];
|
|
152
|
+
if (!device) return null;
|
|
153
|
+
let changed = false;
|
|
154
|
+
if (userAgent && device.userAgent !== userAgent) {
|
|
155
|
+
device.userAgent = String(userAgent).slice(0, 256);
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
if (ip && device.lastIp !== ip) {
|
|
159
|
+
device.lastIp = String(ip).slice(0, 64);
|
|
160
|
+
changed = true;
|
|
161
|
+
}
|
|
162
|
+
device.lastSeenAt = nowIso(now);
|
|
163
|
+
if (changed) this.save();
|
|
164
|
+
return { ...device };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Devices an operator may act on, newest first. Token hashes are never
|
|
169
|
+
* included: the dashboard has no use for them and they must not leave the
|
|
170
|
+
* process.
|
|
171
|
+
*/
|
|
172
|
+
listDevices(now = Date.now()) {
|
|
173
|
+
return Object.values(this.state.devices)
|
|
174
|
+
.filter((device) => device && device.expiresAt > now)
|
|
175
|
+
.map((device) => ({
|
|
176
|
+
deviceId: device.deviceId,
|
|
177
|
+
hostId: device.hostId,
|
|
178
|
+
createdAt: device.createdAt,
|
|
179
|
+
lastSeenAt: device.lastSeenAt,
|
|
180
|
+
expiresAt: device.expiresAt,
|
|
181
|
+
expiresAtIso: nowIso(device.expiresAt),
|
|
182
|
+
userAgent: device.userAgent || null,
|
|
183
|
+
lastIp: device.lastIp || null,
|
|
184
|
+
}))
|
|
185
|
+
.sort((a, b) => String(b.lastSeenAt || '').localeCompare(String(a.lastSeenAt || '')));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Permanently invalidate a paired device. The stored hash is dropped, so the
|
|
190
|
+
* token it was derived from can never authenticate again. Returns the removed
|
|
191
|
+
* record so the caller can also close whatever sockets it still holds.
|
|
192
|
+
*/
|
|
193
|
+
revokeDevice(deviceId) {
|
|
194
|
+
if (typeof deviceId !== 'string' || !deviceId) return null;
|
|
195
|
+
const device = this.state.devices[deviceId];
|
|
196
|
+
if (!device) return null;
|
|
197
|
+
delete this.state.devices[deviceId];
|
|
198
|
+
this.lastDeviceSaveAt.delete(deviceId);
|
|
199
|
+
this.save();
|
|
200
|
+
return {
|
|
201
|
+
deviceId: device.deviceId,
|
|
202
|
+
hostId: device.hostId,
|
|
203
|
+
userAgent: device.userAgent || null,
|
|
204
|
+
lastIp: device.lastIp || null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
145
208
|
authenticateDevice(token, now = Date.now()) {
|
|
146
209
|
if (typeof token !== 'string' || token.length < 16) return null;
|
|
147
210
|
const tokenHash = hash(token);
|
package/src/metrics.js
CHANGED
|
File without changes
|
package/src/relay-config.js
CHANGED
|
File without changes
|
package/src/relay-server.js
CHANGED
|
@@ -311,7 +311,28 @@ class RelayServer {
|
|
|
311
311
|
this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
|
|
312
312
|
return;
|
|
313
313
|
}
|
|
314
|
-
this.sendJsonResponse(res, 200, this.statusSnapshot());
|
|
314
|
+
this.sendJsonResponse(res, 200, this.statusSnapshot({ includeDevices: true }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (requestUrl.pathname.startsWith('/api/admin/devices/') && req.method === 'DELETE') {
|
|
318
|
+
if (!this.adminToken) {
|
|
319
|
+
this.sendJsonResponse(res, 503, { ok: false, code: 'admin_not_configured', message: 'configure RELAY_ADMIN_TOKEN to access the relay dashboard' });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!this.authorizedAdmin(req)) {
|
|
323
|
+
this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const deviceId = decodeURIComponent(requestUrl.pathname.slice('/api/admin/devices/'.length));
|
|
327
|
+
const revoked = this.auth.revokeDevice(deviceId);
|
|
328
|
+
if (!revoked) {
|
|
329
|
+
this.sendJsonResponse(res, 404, { ok: false, code: 'device_not_found', message: 'no such paired device' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Revocation has to take effect now, not at the next reconnect: drop any
|
|
333
|
+
// socket the device still holds so the terminal closes immediately.
|
|
334
|
+
const disconnected = this.detachDeviceSessions(deviceId);
|
|
335
|
+
this.sendJsonResponse(res, 200, { ok: true, deviceId: revoked.deviceId, disconnected });
|
|
315
336
|
return;
|
|
316
337
|
}
|
|
317
338
|
if (requestUrl.pathname === '/api/pair/start' && req.method === 'POST') {
|
|
@@ -500,6 +521,19 @@ class RelayServer {
|
|
|
500
521
|
if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
|
|
501
522
|
const host = this.hosts.get(device.hostId);
|
|
502
523
|
if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
|
|
524
|
+
|
|
525
|
+
// One live session per browser. A browser that reconnects before the
|
|
526
|
+
// relay has noticed the old socket is gone would otherwise end up
|
|
527
|
+
// holding two sessions: the stale one keeps the controller lease, so
|
|
528
|
+
// the reconnected tab is stuck read-only against its own ghost. Retire
|
|
529
|
+
// that session first; the controller lease is released with it and this
|
|
530
|
+
// connection can take it again.
|
|
531
|
+
//
|
|
532
|
+
// Keyed on the browser-supplied client id, not on the device token:
|
|
533
|
+
// sharing one token across two browsers is the supported multi-viewer
|
|
534
|
+
// case and must keep working.
|
|
535
|
+
this.detachSupersededSession(device.deviceId, message.clientId, host);
|
|
536
|
+
|
|
503
537
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
504
538
|
clearTimeout(deadline);
|
|
505
539
|
const clientId = randomId('client');
|
|
@@ -508,6 +542,9 @@ class RelayServer {
|
|
|
508
542
|
ws,
|
|
509
543
|
hostId: host.id,
|
|
510
544
|
deviceId: device.deviceId,
|
|
545
|
+
// Stable per browser profile; used to recognise a reconnect from the
|
|
546
|
+
// same browser rather than a genuinely separate viewer.
|
|
547
|
+
browserClientId: typeof message.clientId === 'string' ? message.clientId : null,
|
|
511
548
|
role: host.controllerId ? 'viewer' : 'controller',
|
|
512
549
|
controllerId: host.controllerId,
|
|
513
550
|
connectedAt: new Date().toISOString(),
|
|
@@ -523,6 +560,9 @@ class RelayServer {
|
|
|
523
560
|
};
|
|
524
561
|
if (client.role === 'controller') host.controllerId = client.id;
|
|
525
562
|
client.controllerId = host.controllerId;
|
|
563
|
+
// Persist how this device identifies itself so the operator dashboard
|
|
564
|
+
// can name it in the revoke list instead of showing a bare device id.
|
|
565
|
+
this.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
|
|
526
566
|
pending.authenticated = true;
|
|
527
567
|
pending.client = client;
|
|
528
568
|
this.clients.set(client.id, client);
|
|
@@ -635,6 +675,46 @@ class RelayServer {
|
|
|
635
675
|
}
|
|
636
676
|
}
|
|
637
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Close every existing session belonging to `deviceId` on `host`.
|
|
680
|
+
*
|
|
681
|
+
* Called just before a freshly authenticated connection is registered, so the
|
|
682
|
+
* same physical device never occupies two client slots (and two PTYs) at once.
|
|
683
|
+
*/
|
|
684
|
+
detachDeviceSessions(deviceId) {
|
|
685
|
+
if (!deviceId) return 0;
|
|
686
|
+
let closed = 0;
|
|
687
|
+
for (const existing of [...this.clients.values()]) {
|
|
688
|
+
if (!existing || existing.deviceId !== deviceId) continue;
|
|
689
|
+
this.detachClient(existing, { notify: false, reason: 'device_revoked' });
|
|
690
|
+
closeSocket(existing.ws, 1000, 'this device has been revoked by the relay operator');
|
|
691
|
+
closed += 1;
|
|
692
|
+
}
|
|
693
|
+
return closed;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Retire the session the same browser already holds on this host, if any.
|
|
698
|
+
*
|
|
699
|
+
* `browserClientId` is the identifier the browser persists for itself, so two
|
|
700
|
+
* tabs of one browser collapse to a single session while two genuinely
|
|
701
|
+
* different browsers sharing a device token stay independent viewers. A
|
|
702
|
+
* client that sends no id cannot be matched and is left alone.
|
|
703
|
+
*/
|
|
704
|
+
detachSupersededSession(deviceId, browserClientId, host) {
|
|
705
|
+
if (!deviceId || typeof browserClientId !== 'string' || !browserClientId) return 0;
|
|
706
|
+
let closed = 0;
|
|
707
|
+
for (const clientId of [...host.clients]) {
|
|
708
|
+
const existing = this.clients.get(clientId);
|
|
709
|
+
if (!existing || existing.deviceId !== deviceId) continue;
|
|
710
|
+
if (existing.browserClientId !== browserClientId) continue;
|
|
711
|
+
this.detachClient(existing, { notify: false, reason: 'superseded_by_new_session' });
|
|
712
|
+
closeSocket(existing.ws, 1000, 'replaced by a newer session from the same browser');
|
|
713
|
+
closed += 1;
|
|
714
|
+
}
|
|
715
|
+
return closed;
|
|
716
|
+
}
|
|
717
|
+
|
|
638
718
|
detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
|
|
639
719
|
if (!client || !this.clients.has(client.id)) return;
|
|
640
720
|
this.clients.delete(client.id);
|
|
@@ -717,7 +797,7 @@ class RelayServer {
|
|
|
717
797
|
this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
|
|
718
798
|
}
|
|
719
799
|
|
|
720
|
-
statusSnapshot({ sample = true } = {}) {
|
|
800
|
+
statusSnapshot({ sample = true, includeDevices = false } = {}) {
|
|
721
801
|
const clients = [...this.clients.values()].map((client) => ({
|
|
722
802
|
id: client.id,
|
|
723
803
|
role: client.role,
|
|
@@ -741,8 +821,13 @@ class RelayServer {
|
|
|
741
821
|
load: host.load,
|
|
742
822
|
}));
|
|
743
823
|
const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({ ...pty, hostId: host.id })));
|
|
824
|
+
// The paired-device roster identifies people's hardware, so it is served to
|
|
825
|
+
// the relay operator only — never on /api/status, which any paired device
|
|
826
|
+
// may read.
|
|
827
|
+
const devices = includeDevices ? this.auth.listDevices() : undefined;
|
|
744
828
|
return {
|
|
745
829
|
...this.metrics.snapshot({ clients, hosts, ptys, sample }),
|
|
830
|
+
...(devices ? { devices } : {}),
|
|
746
831
|
relayMode: this.relayMode,
|
|
747
832
|
isRemoteRelay: this.relayMode === 'remote',
|
|
748
833
|
remoteAdminUrl: this.relayMode === 'remote'
|
package/src/scroll-input.js
CHANGED
|
File without changes
|
package/src/state.js
CHANGED
|
File without changes
|
package/src/stream-frame.js
CHANGED
|
File without changes
|