@linxin666/dsh-remote-web-ui 0.2.4 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +602 -67
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -32
- package/lib/mobile.js +3 -1
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +21 -0
- package/lib/types/client/FenceNotice.d.ts.map +1 -0
- package/lib/types/client/FenceNotice.js +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +18 -11
- package/lib/types/client/RemotePanel.d.ts +10 -4
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -6
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/device-name.d.ts +3 -0
- package/lib/types/client/device-name.d.ts.map +1 -0
- package/lib/types/client/device-name.js +30 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/locales.d.ts +51 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +79 -29
- package/lib/types/client/pair-api.d.ts +35 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +39 -0
- package/lib/types/client/remote-channel.d.ts +93 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -0
- package/lib/types/client/remote-channel.js +246 -0
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +5 -2
- package/lib/types/index.d.ts +19 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +73 -5
- package/lib/types/loopback-proxy.d.ts +28 -0
- package/lib/types/loopback-proxy.d.ts.map +1 -0
- package/lib/types/loopback-proxy.js +112 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +6 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +6 -2
- package/lib/types/mobile-routes.js +1 -1
- package/lib/types/pairing.d.ts +63 -11
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +137 -20
- package/lib/types/pairing.persist.test.js +60 -5
- package/lib/types/posture.d.ts +75 -0
- package/lib/types/posture.d.ts.map +1 -0
- package/lib/types/posture.js +116 -0
- package/lib/types/remote-api.d.ts +60 -0
- package/lib/types/remote-api.d.ts.map +1 -0
- package/lib/types/remote-api.js +181 -0
- package/lib/types/remote-methods.d.ts +33 -0
- package/lib/types/remote-methods.d.ts.map +1 -0
- package/lib/types/remote-methods.js +54 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +33 -4
- package/package.json +2 -2
- package/src/client/FenceNotice.tsx +43 -0
- package/src/client/RemoteEntry.tsx +19 -9
- package/src/client/RemotePanel.tsx +83 -10
- package/src/client/RemoteSettingsCard.tsx +16 -0
- package/src/client/device-name.ts +31 -0
- package/src/client/index.ts +46 -0
- package/src/client/locales.ts +79 -29
- package/src/client/pair-api.ts +60 -0
- package/src/client/remote-channel.ts +269 -0
- package/src/client/remote.module.css +240 -16
- package/src/dsh-home.ts +36 -0
- package/src/gate.ts +5 -2
- package/src/index.ts +89 -17
- package/src/loopback-proxy.ts +126 -0
- package/src/mobile/views/SessionListView.tsx +6 -1
- package/src/mobile-api.ts +6 -2
- package/src/mobile-routes.ts +1 -1
- package/src/pairing.persist.test.ts +64 -5
- package/src/pairing.ts +167 -20
- package/src/posture.ts +154 -0
- package/src/remote-api.ts +217 -0
- package/src/remote-methods.ts +62 -0
- package/src/routes.ts +33 -4
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
351
|
-
if (session === undefined
|
|
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:
|
|
364
|
-
* the
|
|
365
|
-
* the
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|