@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +610 -74
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +676 -35
  7. package/lib/mobile.js +10 -7
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +83 -33
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/client/settings-form.d.ts.map +1 -1
  35. package/lib/types/client/settings-form.js +9 -6
  36. package/lib/types/dsh-home.d.ts +17 -0
  37. package/lib/types/dsh-home.d.ts.map +1 -0
  38. package/lib/types/dsh-home.js +34 -0
  39. package/lib/types/gate.d.ts.map +1 -1
  40. package/lib/types/gate.js +5 -2
  41. package/lib/types/index.d.ts +19 -12
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +73 -5
  44. package/lib/types/loopback-proxy.d.ts +28 -0
  45. package/lib/types/loopback-proxy.d.ts.map +1 -0
  46. package/lib/types/loopback-proxy.js +112 -0
  47. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  48. package/lib/types/mobile/views/SessionListView.js +6 -1
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +6 -2
  51. package/lib/types/mobile-routes.js +1 -1
  52. package/lib/types/pairing.d.ts +63 -11
  53. package/lib/types/pairing.d.ts.map +1 -1
  54. package/lib/types/pairing.js +137 -20
  55. package/lib/types/pairing.persist.test.js +60 -5
  56. package/lib/types/posture.d.ts +75 -0
  57. package/lib/types/posture.d.ts.map +1 -0
  58. package/lib/types/posture.js +116 -0
  59. package/lib/types/remote-api.d.ts +60 -0
  60. package/lib/types/remote-api.d.ts.map +1 -0
  61. package/lib/types/remote-api.js +181 -0
  62. package/lib/types/remote-methods.d.ts +33 -0
  63. package/lib/types/remote-methods.d.ts.map +1 -0
  64. package/lib/types/remote-methods.js +54 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/routes.d.ts.map +1 -1
  67. package/lib/types/routes.js +33 -4
  68. package/lib/types/update.d.ts.map +1 -1
  69. package/lib/types/update.js +10 -2
  70. package/package.json +13 -13
  71. package/src/client/FenceNotice.tsx +43 -0
  72. package/src/client/RemoteEntry.tsx +19 -9
  73. package/src/client/RemotePanel.tsx +83 -10
  74. package/src/client/RemoteSettingsCard.tsx +16 -0
  75. package/src/client/device-name.ts +31 -0
  76. package/src/client/index.ts +46 -0
  77. package/src/client/locales.ts +83 -33
  78. package/src/client/pair-api.ts +60 -0
  79. package/src/client/remote-channel.ts +269 -0
  80. package/src/client/remote.module.css +240 -16
  81. package/src/client/settings-form.ts +8 -6
  82. package/src/dsh-home.ts +36 -0
  83. package/src/gate.ts +5 -2
  84. package/src/index.ts +89 -17
  85. package/src/loopback-proxy.ts +126 -0
  86. package/src/mobile/views/SessionListView.tsx +6 -1
  87. package/src/mobile-api.ts +6 -2
  88. package/src/mobile-routes.ts +1 -1
  89. package/src/pairing.persist.test.ts +64 -5
  90. package/src/pairing.ts +167 -20
  91. package/src/posture.ts +154 -0
  92. package/src/remote-api.ts +217 -0
  93. package/src/remote-methods.ts +62 -0
  94. package/src/routes.ts +33 -4
  95. package/src/update.ts +14 -1
package/src/pairing.ts CHANGED
@@ -13,11 +13,14 @@
13
13
  * unknown one (no oracle for validity).
14
14
  * - `stop()` revokes every device session and clears the token, so paired
15
15
  * devices are cut off on their next gated request.
16
+ * - `revoke()` drops one device session; idle sessions older than
17
+ * `idleExpireMs` are deleted on sweep, load, and the next gated request.
16
18
  */
17
19
 
18
20
  import { randomBytes } from 'node:crypto'
19
21
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
22
  import { dirname } from 'node:path'
23
+ import type { PostureSnapshot } from './posture.ts'
21
24
 
22
25
  /** The observable pairing phases the panel renders. */
23
26
  export type PairingPhase =
@@ -48,12 +51,34 @@ export interface TokenRecord {
48
51
  address?: string
49
52
  }
50
53
 
54
+ /** Default idle-expiry window: 7 days without heartbeat or a gated request. */
55
+ export const DEFAULT_IDLE_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000
56
+
57
+ /** Cap on the persisted/displayed User-Agent string. */
58
+ const MAX_USER_AGENT_CHARS = 180
59
+
51
60
  /** One paired device session, keyed by the device id stored in its cookie. */
52
61
  export interface DeviceSession {
53
62
  /** Pairing time (ms epoch). */
54
63
  createdAt: number
55
64
  /** Last time the device passed a gated request or heartbeat. */
56
65
  lastSeenAt: number
66
+ /** Sanitized User-Agent captured at accept (optional; missing on old files). */
67
+ userAgent?: string
68
+ }
69
+
70
+ /** One device row in a loopback snapshot (ids are session credentials). */
71
+ export interface DeviceSnapshot {
72
+ /** Cookie value / session credential of this device. */
73
+ id: string
74
+ /** Pairing time (ms epoch). */
75
+ createdAt: number
76
+ /** Last heartbeat or gated request (ms epoch). */
77
+ lastSeenAt: number
78
+ /** Whether lastSeenAt is within the offline window. */
79
+ online: boolean
80
+ /** Sanitized User-Agent captured at accept, when known. */
81
+ userAgent?: string
57
82
  }
58
83
 
59
84
  /** One tunnel status frame (auto-tunnel only; undefined when disabled). */
@@ -77,6 +102,8 @@ export interface PairingSnapshot {
77
102
  publicUrl?: string
78
103
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
79
104
  tunnel?: TunnelStatus
105
+ /** Latest /api posture probe (undefined until the first round completes). */
106
+ posture?: PostureSnapshot
80
107
  /** Opaque (non-secret) id of the active token (undefined when stopped/lan-required). */
81
108
  tokenId?: string
82
109
  /** Absolute expiry of the active token. */
@@ -85,6 +112,8 @@ export interface PairingSnapshot {
85
112
  deviceCount: number
86
113
  /** Count of devices active within the offline window. */
87
114
  onlineCount: number
115
+ /** Per-device roster for the loopback panel (never sent on /api/pair/status). */
116
+ devices: DeviceSnapshot[]
88
117
  }
89
118
 
90
119
  /** Service tunables (config-validated upstream; plain numbers here). */
@@ -97,11 +126,16 @@ export interface PairingConfig {
97
126
  maxDevices: number
98
127
  /** Cookie name carrying the device id. */
99
128
  cookieName: string
129
+ /**
130
+ * Idle sessions older than this are deleted (memory and disk). Defaults
131
+ * to {@link DEFAULT_IDLE_EXPIRE_MS} when omitted.
132
+ */
133
+ idleExpireMs?: number
100
134
  /**
101
135
  * Path to a JSON file where paired device sessions are persisted. When
102
136
  * set, sessions survive process restarts (the phone keeps its 365-day
103
137
  * cookie), so re-pairing after a dsh web restart is not required. When
104
- * unset (default), sessions stay memory-only — the previous behavior.
138
+ * unset, sessions stay memory-only.
105
139
  */
106
140
  devicesFile?: string
107
141
  }
@@ -153,6 +187,9 @@ export class PairingService {
153
187
  private publicBase: string | undefined
154
188
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
155
189
  private tunnelStatus: TunnelStatus | undefined
190
+ private posture: PostureSnapshot | undefined
191
+ /** True when lastSeenAt changed since the last persist (flushed on sweep). */
192
+ private dirty = false
156
193
 
157
194
  /**
158
195
  * @param config - tunables. The settings surface replaces the object (a
@@ -181,11 +218,21 @@ export class PairingService {
181
218
  for (const [deviceId, session] of Object.entries(saved)) {
182
219
  if (typeof deviceId !== 'string') continue
183
220
  if (typeof session !== 'object' || session === null) continue
184
- const { createdAt, lastSeenAt } = session as { createdAt?: unknown; lastSeenAt?: unknown }
221
+ const { createdAt, lastSeenAt, userAgent } = session as {
222
+ createdAt?: unknown
223
+ lastSeenAt?: unknown
224
+ userAgent?: unknown
225
+ }
185
226
  if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number') continue
186
- this.devices.set(deviceId, { createdAt, lastSeenAt })
227
+ const label = typeof userAgent === 'string' ? sanitizeUserAgent(userAgent) : undefined
228
+ this.devices.set(deviceId, {
229
+ createdAt,
230
+ lastSeenAt,
231
+ ...(label !== undefined ? { userAgent: label } : {}),
232
+ })
187
233
  }
188
234
  this.clampToMaxDevices()
235
+ if (this.evictIdle()) this.persist()
189
236
  } catch {
190
237
  // Unreadable/corrupt: start empty rather than refusing to boot.
191
238
  }
@@ -199,12 +246,25 @@ export class PairingService {
199
246
  for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
200
247
  }
201
248
 
249
+ /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
250
+ private evictIdle(): boolean {
251
+ const now = this.clock.now()
252
+ const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS
253
+ let removed = false
254
+ for (const [id, session] of [...this.devices]) {
255
+ if (now - session.lastSeenAt > limit) {
256
+ this.devices.delete(id)
257
+ removed = true
258
+ }
259
+ }
260
+ return removed
261
+ }
262
+
202
263
  /**
203
264
  * Write the current device table to the configured file. Called on the
204
- * mutation boundaries that change the set of live sessions (accept and
205
- * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
206
- * per-request write storm is avoided — after a restart the phone's first
207
- * heartbeat re-warms its own session.
265
+ * mutation boundaries that change the set of live sessions (accept, stop,
266
+ * revoke, idle eviction) and, throttled, from sweep() so lastSeenAt
267
+ * survives a restart without a write per request.
208
268
  *
209
269
  * Device ids are session credentials (the gate authorizes requests by the
210
270
  * cookie's device id), so the file is written 0600 via a temp file and
@@ -216,8 +276,11 @@ export class PairingService {
216
276
  try {
217
277
  mkdirSync(dirname(file), { recursive: true })
218
278
  const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
219
- writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 })
279
+ const payload: Record<string, DeviceSession> = {}
280
+ for (const [id, session] of this.devices) payload[id] = session
281
+ writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 })
220
282
  renameSync(temp, file)
283
+ this.dirty = false
221
284
  } catch (error) {
222
285
  console.error('remote-web-ui: failed to persist paired devices', error)
223
286
  }
@@ -261,6 +324,12 @@ export class PairingService {
261
324
  this.notify()
262
325
  }
263
326
 
327
+ /** Set the latest /api posture probe result (see posture.ts). */
328
+ setPosture(snapshot: PostureSnapshot | undefined): void {
329
+ this.posture = snapshot
330
+ this.notify()
331
+ }
332
+
264
333
  /**
265
334
  * Issue a fresh token, replacing (invalidating) any previous one. A
266
335
  * stopped service re-arms through this call (the panel's refresh button).
@@ -302,9 +371,10 @@ export class PairingService {
302
371
  * successful call for the same token is impossible because the first
303
372
  * consumes it.
304
373
  * @param token - the token secret from the QR link.
374
+ * @param userAgent - optional User-Agent header captured at accept.
305
375
  * @returns the new device id, or a refusal code.
306
376
  */
307
- accept(token: string): AcceptResult {
377
+ accept(token: string, userAgent?: string): AcceptResult {
308
378
  const record = this.tokens.get(token)
309
379
  if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
310
380
  return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' }
@@ -320,7 +390,12 @@ export class PairingService {
320
390
  }
321
391
  if (oldest !== undefined) this.devices.delete(oldest.id)
322
392
  }
323
- this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
393
+ const label = sanitizeUserAgent(userAgent)
394
+ this.devices.set(deviceId, {
395
+ createdAt: now,
396
+ lastSeenAt: now,
397
+ ...(label !== undefined ? { userAgent: label } : {}),
398
+ })
324
399
  this.persist()
325
400
  this.notify()
326
401
  return { ok: true, deviceId }
@@ -339,17 +414,32 @@ export class PairingService {
339
414
  this.notify()
340
415
  }
341
416
 
417
+ /**
418
+ * Revoke one paired device. The next gated request from that cookie is
419
+ * refused; other sessions stay live. Unknown ids are a no-op.
420
+ * @param deviceId - the cookie value of the device to drop.
421
+ * @returns true when a live session was removed.
422
+ */
423
+ revoke(deviceId: string): boolean {
424
+ if (this.stopped) return false
425
+ if (!this.devices.delete(deviceId)) return false
426
+ this.persist()
427
+ this.notify()
428
+ return true
429
+ }
430
+
342
431
  /**
343
432
  * The api/gate path: record activity for a device id and report whether
344
433
  * the request may proceed. Unknown or revoked ids (including any device
345
- * after stop()) are refused.
434
+ * after stop() or idle expiry) are refused.
346
435
  * @param deviceId - the cookie value of the requesting device.
347
436
  * @returns true when the device session is live and was refreshed.
348
437
  */
349
438
  touchDevice(deviceId: string): boolean {
350
- const session = this.devices.get(deviceId)
351
- if (session === undefined || this.stopped) return false
439
+ const session = this.liveSession(deviceId)
440
+ if (session === undefined) return false
352
441
  session.lastSeenAt = this.clock.now()
442
+ this.dirty = true
353
443
  this.notify()
354
444
  return true
355
445
  }
@@ -360,18 +450,24 @@ export class PairingService {
360
450
  }
361
451
 
362
452
  /**
363
- * Periodic sweep: re-evaluate the derived snapshot (a device aging past
364
- * the offline window flips the phase to disconnected). Emits only when
365
- * the snapshot actually changed.
453
+ * Periodic sweep: drop idle sessions, flush a dirty lastSeenAt, and
454
+ * re-evaluate the derived snapshot (a device aging past the offline
455
+ * window flips the phase to disconnected). Emits only when the snapshot
456
+ * actually changed.
366
457
  */
367
458
  sweep(): void {
459
+ const evicted = this.evictIdle()
460
+ if (evicted || this.dirty) this.persist()
368
461
  this.notify()
369
462
  }
370
463
 
371
464
  /** The current snapshot (fresh object per call — stable between emits). */
372
465
  snapshot(): PairingSnapshot {
373
466
  const now = this.clock.now()
374
- const onlineCount = [...this.devices.values()].filter(session => this.isOnlineAt(session, now)).length
467
+ const devices = [...this.devices.entries()]
468
+ .sort((a, b) => a[1].createdAt - b[1].createdAt)
469
+ .map(([id, session]) => this.toDeviceSnapshot(id, session, now))
470
+ const onlineCount = devices.filter(device => device.online).length
375
471
  const token = this.activeToken()
376
472
  return {
377
473
  phase: this.derivePhase(onlineCount, token !== undefined),
@@ -379,16 +475,17 @@ export class PairingService {
379
475
  lanAddresses: [...this.lanBases.keys()],
380
476
  ...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
381
477
  ...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
478
+ ...(this.posture !== undefined ? { posture: this.posture } : {}),
382
479
  ...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
383
480
  deviceCount: this.devices.size,
384
481
  onlineCount,
482
+ devices,
385
483
  }
386
484
  }
387
485
 
388
- /** Whether a cookie value names a currently live device session. */
486
+ /** Whether a cookie value names a currently live (non-idle) device session. */
389
487
  hasDevice(deviceId: string): boolean {
390
- const session = this.devices.get(deviceId)
391
- return session !== undefined && !this.stopped
488
+ return this.liveSession(deviceId) !== undefined
392
489
  }
393
490
 
394
491
  /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
@@ -406,6 +503,34 @@ export class PairingService {
406
503
  return undefined
407
504
  }
408
505
 
506
+ /**
507
+ * Return a live session, deleting it first when idle-expired. Side-effecting
508
+ * so a stale cookie cannot pass the gate between sweeps.
509
+ */
510
+ private liveSession(deviceId: string): DeviceSession | undefined {
511
+ if (this.stopped) return undefined
512
+ const session = this.devices.get(deviceId)
513
+ if (session === undefined) return undefined
514
+ const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS
515
+ if (this.clock.now() - session.lastSeenAt > limit) {
516
+ this.devices.delete(deviceId)
517
+ this.persist()
518
+ this.notify()
519
+ return undefined
520
+ }
521
+ return session
522
+ }
523
+
524
+ private toDeviceSnapshot(id: string, session: DeviceSession, now: number): DeviceSnapshot {
525
+ return {
526
+ id,
527
+ createdAt: session.createdAt,
528
+ lastSeenAt: session.lastSeenAt,
529
+ online: this.isOnlineAt(session, now),
530
+ ...(session.userAgent !== undefined ? { userAgent: session.userAgent } : {}),
531
+ }
532
+ }
533
+
409
534
  private derivePhase(onlineCount: number, hasToken: boolean): PairingPhase {
410
535
  if (this.lanBases.size === 0 && this.publicBase === undefined) return 'lan-required'
411
536
  if (this.stopped) return 'stopped'
@@ -445,6 +570,20 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
445
570
  && a.tokenExpiresAt === b.tokenExpiresAt
446
571
  && a.deviceCount === b.deviceCount
447
572
  && a.onlineCount === b.onlineCount
573
+ && devicesEqual(a.devices, b.devices)
574
+ }
575
+
576
+ /** Per-device roster equality (order is pairing time). */
577
+ function devicesEqual(a: readonly DeviceSnapshot[], b: readonly DeviceSnapshot[]): boolean {
578
+ return a.length === b.length && a.every((device, index) => {
579
+ const other = b[index]
580
+ return other !== undefined
581
+ && device.id === other.id
582
+ && device.createdAt === other.createdAt
583
+ && device.lastSeenAt === other.lastSeenAt
584
+ && device.online === other.online
585
+ && device.userAgent === other.userAgent
586
+ })
448
587
  }
449
588
 
450
589
  /** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
@@ -457,3 +596,11 @@ function tunnelEqual(a: TunnelStatus | undefined, b: TunnelStatus | undefined):
457
596
  function sameStrings(a: readonly string[], b: readonly string[]): boolean {
458
597
  return a.length === b.length && a.every((value, index) => value === b[index])
459
598
  }
599
+
600
+ /** Strip control characters and cap the User-Agent stored with a session. */
601
+ export function sanitizeUserAgent(raw: string | undefined): string | undefined {
602
+ if (raw === undefined) return undefined
603
+ const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
604
+ if (cleaned === '') return undefined
605
+ return cleaned.length <= MAX_USER_AGENT_CHARS ? cleaned : cleaned.slice(0, MAX_USER_AGENT_CHARS)
606
+ }
package/src/posture.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Deployment posture probe: verify that the connection plugin's `/api` fence
3
+ * actually refuses non-loopback requests for every origin this deployment
4
+ * advertises (the public tunnel host, the LAN bases). The fence is the SDK's
5
+ * own Host check — the one seam a plugin cannot mount a gate into — so this
6
+ * probe is the guardrail that makes a re-opened `/api` (for example a
7
+ * re-added `--trusted-host`, or the SDK's LAN-literal auto-trust under
8
+ * `--host 0.0.0.0`) visible instead of silent.
9
+ *
10
+ * The probe issues loopback requests with a forged Host header — the exact
11
+ * shape a tunnel or LAN client produces — and treats anything other than a
12
+ * 403 as exposed: the fence is documented to refuse with 403, so any other
13
+ * status means the request reached the RPC bridge.
14
+ */
15
+
16
+ import type { ClientRequest, ClientRequestArgs } from 'node:http'
17
+ import http from 'node:http'
18
+
19
+ /** One probed origin. */
20
+ export interface PostureHost {
21
+ /** The forged Host header value that was probed. */
22
+ host: string
23
+ /** True when the probe reached past the fence (anything but 403). */
24
+ exposed: boolean
25
+ }
26
+
27
+ /** One probe round. */
28
+ export interface PostureSnapshot {
29
+ /** When the round completed (epoch ms). */
30
+ checkedAt: number
31
+ /** Every probed origin and its verdict. */
32
+ hosts: PostureHost[]
33
+ }
34
+
35
+ /** Whether any probed origin is currently exposed. */
36
+ export function anyExposed(snapshot: PostureSnapshot): boolean {
37
+ return snapshot.hosts.some(host => host.exposed)
38
+ }
39
+
40
+ /**
41
+ * Build the forged Host values to probe: the public base authority (host or
42
+ * host:port as written in the URL) plus every LAN base literal.
43
+ * @param publicBaseUrl - the configured public base URL (or undefined).
44
+ * @param lanAddresses - the LAN interface addresses the QR advertises.
45
+ * @param port - the local webServer port (LAN hosts are probed as host:port).
46
+ * @returns Host header values, de-duplicated.
47
+ */
48
+ export function postureTargets(publicBaseUrl: string | undefined, lanAddresses: string[], port: number): string[] {
49
+ const targets: string[] = []
50
+ if (publicBaseUrl !== undefined) {
51
+ try {
52
+ const url = new URL(publicBaseUrl)
53
+ const authority = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`
54
+ if (authority !== '') targets.push(authority)
55
+ } catch {
56
+ // A malformed base is ignored elsewhere with a warning; nothing to probe.
57
+ }
58
+ }
59
+ for (const address of lanAddresses) {
60
+ targets.push(`${address}:${String(port)}`)
61
+ }
62
+ return [...new Set(targets)]
63
+ }
64
+
65
+ /** Injectable transport seam (defaults to node:http). */
66
+ export type ProbeRequest = (options: ClientRequestArgs, onStatus: (status: number) => void) => ClientRequest
67
+
68
+ const defaultRequest: ProbeRequest = (options, onStatus) => {
69
+ const request = http.request(options, response => { onStatus(response.statusCode ?? 0) })
70
+ request.on('error', () => { onStatus(0) })
71
+ return request
72
+ }
73
+
74
+ /**
75
+ * Probe one forged Host against the local `/api` fence.
76
+ * @param port - the local webServer port.
77
+ * @param hostHeader - the Host header to forge.
78
+ * @param request - transport seam.
79
+ * @param timeoutMs - give up after this long (counts as not exposed; the
80
+ * fence being unreachable is not evidence it is open).
81
+ * @returns true when the probe got past the fence.
82
+ */
83
+ async function probeHost(port: number, hostHeader: string, request: ProbeRequest, timeoutMs: number): Promise<boolean> {
84
+ return await new Promise<boolean>(resolve => {
85
+ let settled = false
86
+ const finish = (exposed: boolean): void => {
87
+ if (settled) return
88
+ settled = true
89
+ clearTimeout(timer)
90
+ handle.destroy()
91
+ resolve(exposed)
92
+ }
93
+ const handle = request({
94
+ host: '127.0.0.1',
95
+ port,
96
+ method: 'POST',
97
+ path: '/api/session.list',
98
+ headers: { host: hostHeader, 'content-type': 'application/json' },
99
+ timeout: timeoutMs,
100
+ }, status => { finish(status !== 403) })
101
+ const timer = setTimeout(() => { finish(false) }, timeoutMs + 1_000)
102
+ handle.on('error', () => { finish(false) })
103
+ handle.end('{}')
104
+ })
105
+ }
106
+
107
+ /** Probe options. */
108
+ export interface ProbePostureOptions {
109
+ /** The local webServer port. */
110
+ port: number
111
+ /** Forged Host header values (see {@link postureTargets}). */
112
+ targets: string[]
113
+ /** Transport seam (defaults to node:http). */
114
+ request?: ProbeRequest
115
+ /** Per-host timeout (default 3000 ms). */
116
+ timeoutMs?: number
117
+ /** Clock seam (default Date.now). */
118
+ now?: () => number
119
+ }
120
+
121
+ /**
122
+ * Run one posture probe round.
123
+ * @returns the snapshot for this round.
124
+ */
125
+ export async function probePosture(options: ProbePostureOptions): Promise<PostureSnapshot> {
126
+ const { port, targets } = options
127
+ const request = options.request ?? defaultRequest
128
+ const timeoutMs = options.timeoutMs ?? 3_000
129
+ const now = options.now ?? ((): number => Date.now())
130
+ const hosts: PostureHost[] = []
131
+ for (const target of targets) {
132
+ // Sequential on purpose: one slow host must not multiply the round's latency.
133
+ hosts.push({ host: target, exposed: await probeHost(port, target, request, timeoutMs) })
134
+ }
135
+ return { checkedAt: now(), hosts }
136
+ }
137
+
138
+ /**
139
+ * Reserve an advertised-target key so a second trigger with the same set
140
+ * does not overlap an in-flight round. Pair with {@link releasePostureKey}
141
+ * on failure — otherwise that key never retries.
142
+ */
143
+ export function claimPostureKey(current: string | undefined, key: string): { run: boolean; next: string } {
144
+ if (current === key) return { run: false, next: current }
145
+ return { run: true, next: key }
146
+ }
147
+
148
+ /**
149
+ * Drop a failed in-flight key so the next trigger re-probes the same targets.
150
+ * A newer key that started meanwhile is left alone.
151
+ */
152
+ export function releasePostureKey(current: string | undefined, attempted: string): string | undefined {
153
+ return current === attempted ? undefined : current
154
+ }