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 CHANGED
File without changes
package/README.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Standalone relay server for Herdr Remote: serves the mobile web terminal and brokers browser <-> workstation sessions",
5
5
  "license": "MIT",
6
6
  "repository": {
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
File without changes
@@ -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'
File without changes
package/src/state.js CHANGED
File without changes
File without changes