@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
@@ -13,10 +13,16 @@
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
  import { randomBytes } from 'node:crypto';
18
20
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
21
  import { dirname } from 'node:path';
22
+ /** Default idle-expiry window: 7 days without heartbeat or a gated request. */
23
+ export const DEFAULT_IDLE_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000;
24
+ /** Cap on the persisted/displayed User-Agent string. */
25
+ const MAX_USER_AGENT_CHARS = 180;
20
26
  /** Thrown by issue() for an address outside the sampled LAN literals. */
21
27
  export class UnknownLanAddressError extends Error {
22
28
  /**
@@ -53,6 +59,9 @@ export class PairingService {
53
59
  publicBase;
54
60
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
55
61
  tunnelStatus;
62
+ posture;
63
+ /** True when lastSeenAt changed since the last persist (flushed on sweep). */
64
+ dirty = false;
56
65
  /**
57
66
  * @param config - tunables. The settings surface replaces the object (a
58
67
  * fresh literal) when a committed section changes; every operation reads
@@ -82,12 +91,19 @@ export class PairingService {
82
91
  continue;
83
92
  if (typeof session !== 'object' || session === null)
84
93
  continue;
85
- const { createdAt, lastSeenAt } = session;
94
+ const { createdAt, lastSeenAt, userAgent } = session;
86
95
  if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number')
87
96
  continue;
88
- this.devices.set(deviceId, { createdAt, lastSeenAt });
97
+ const label = typeof userAgent === 'string' ? sanitizeUserAgent(userAgent) : undefined;
98
+ this.devices.set(deviceId, {
99
+ createdAt,
100
+ lastSeenAt,
101
+ ...(label !== undefined ? { userAgent: label } : {}),
102
+ });
89
103
  }
90
104
  this.clampToMaxDevices();
105
+ if (this.evictIdle())
106
+ this.persist();
91
107
  }
92
108
  catch {
93
109
  // Unreadable/corrupt: start empty rather than refusing to boot.
@@ -102,12 +118,24 @@ export class PairingService {
102
118
  for (const [id] of ordered.slice(0, overflow))
103
119
  this.devices.delete(id);
104
120
  }
121
+ /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
122
+ evictIdle() {
123
+ const now = this.clock.now();
124
+ const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
125
+ let removed = false;
126
+ for (const [id, session] of [...this.devices]) {
127
+ if (now - session.lastSeenAt > limit) {
128
+ this.devices.delete(id);
129
+ removed = true;
130
+ }
131
+ }
132
+ return removed;
133
+ }
105
134
  /**
106
135
  * Write the current device table to the configured file. Called on the
107
- * mutation boundaries that change the set of live sessions (accept and
108
- * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
109
- * per-request write storm is avoided — after a restart the phone's first
110
- * heartbeat re-warms its own session.
136
+ * mutation boundaries that change the set of live sessions (accept, stop,
137
+ * revoke, idle eviction) and, throttled, from sweep() so lastSeenAt
138
+ * survives a restart without a write per request.
111
139
  *
112
140
  * Device ids are session credentials (the gate authorizes requests by the
113
141
  * cookie's device id), so the file is written 0600 via a temp file and
@@ -120,8 +148,12 @@ export class PairingService {
120
148
  try {
121
149
  mkdirSync(dirname(file), { recursive: true });
122
150
  const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
123
- writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 });
151
+ const payload = {};
152
+ for (const [id, session] of this.devices)
153
+ payload[id] = session;
154
+ writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 });
124
155
  renameSync(temp, file);
156
+ this.dirty = false;
125
157
  }
126
158
  catch (error) {
127
159
  console.error('remote-web-ui: failed to persist paired devices', error);
@@ -158,6 +190,11 @@ export class PairingService {
158
190
  this.tunnelStatus = status;
159
191
  this.notify();
160
192
  }
193
+ /** Set the latest /api posture probe result (see posture.ts). */
194
+ setPosture(snapshot) {
195
+ this.posture = snapshot;
196
+ this.notify();
197
+ }
161
198
  /**
162
199
  * Issue a fresh token, replacing (invalidating) any previous one. A
163
200
  * stopped service re-arms through this call (the panel's refresh button).
@@ -198,9 +235,10 @@ export class PairingService {
198
235
  * successful call for the same token is impossible because the first
199
236
  * consumes it.
200
237
  * @param token - the token secret from the QR link.
238
+ * @param userAgent - optional User-Agent header captured at accept.
201
239
  * @returns the new device id, or a refusal code.
202
240
  */
203
- accept(token) {
241
+ accept(token, userAgent) {
204
242
  const record = this.tokens.get(token);
205
243
  if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
206
244
  return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' };
@@ -218,7 +256,12 @@ export class PairingService {
218
256
  if (oldest !== undefined)
219
257
  this.devices.delete(oldest.id);
220
258
  }
221
- this.devices.set(deviceId, { createdAt: now, lastSeenAt: now });
259
+ const label = sanitizeUserAgent(userAgent);
260
+ this.devices.set(deviceId, {
261
+ createdAt: now,
262
+ lastSeenAt: now,
263
+ ...(label !== undefined ? { userAgent: label } : {}),
264
+ });
222
265
  this.persist();
223
266
  this.notify();
224
267
  return { ok: true, deviceId };
@@ -235,18 +278,34 @@ export class PairingService {
235
278
  this.stopped = true;
236
279
  this.notify();
237
280
  }
281
+ /**
282
+ * Revoke one paired device. The next gated request from that cookie is
283
+ * refused; other sessions stay live. Unknown ids are a no-op.
284
+ * @param deviceId - the cookie value of the device to drop.
285
+ * @returns true when a live session was removed.
286
+ */
287
+ revoke(deviceId) {
288
+ if (this.stopped)
289
+ return false;
290
+ if (!this.devices.delete(deviceId))
291
+ return false;
292
+ this.persist();
293
+ this.notify();
294
+ return true;
295
+ }
238
296
  /**
239
297
  * The api/gate path: record activity for a device id and report whether
240
298
  * the request may proceed. Unknown or revoked ids (including any device
241
- * after stop()) are refused.
299
+ * after stop() or idle expiry) are refused.
242
300
  * @param deviceId - the cookie value of the requesting device.
243
301
  * @returns true when the device session is live and was refreshed.
244
302
  */
245
303
  touchDevice(deviceId) {
246
- const session = this.devices.get(deviceId);
247
- if (session === undefined || this.stopped)
304
+ const session = this.liveSession(deviceId);
305
+ if (session === undefined)
248
306
  return false;
249
307
  session.lastSeenAt = this.clock.now();
308
+ this.dirty = true;
250
309
  this.notify();
251
310
  return true;
252
311
  }
@@ -255,17 +314,24 @@ export class PairingService {
255
314
  return this.touchDevice(deviceId);
256
315
  }
257
316
  /**
258
- * Periodic sweep: re-evaluate the derived snapshot (a device aging past
259
- * the offline window flips the phase to disconnected). Emits only when
260
- * the snapshot actually changed.
317
+ * Periodic sweep: drop idle sessions, flush a dirty lastSeenAt, and
318
+ * re-evaluate the derived snapshot (a device aging past the offline
319
+ * window flips the phase to disconnected). Emits only when the snapshot
320
+ * actually changed.
261
321
  */
262
322
  sweep() {
323
+ const evicted = this.evictIdle();
324
+ if (evicted || this.dirty)
325
+ this.persist();
263
326
  this.notify();
264
327
  }
265
328
  /** The current snapshot (fresh object per call — stable between emits). */
266
329
  snapshot() {
267
330
  const now = this.clock.now();
268
- const onlineCount = [...this.devices.values()].filter(session => this.isOnlineAt(session, now)).length;
331
+ const devices = [...this.devices.entries()]
332
+ .sort((a, b) => a[1].createdAt - b[1].createdAt)
333
+ .map(([id, session]) => this.toDeviceSnapshot(id, session, now));
334
+ const onlineCount = devices.filter(device => device.online).length;
269
335
  const token = this.activeToken();
270
336
  return {
271
337
  phase: this.derivePhase(onlineCount, token !== undefined),
@@ -273,15 +339,16 @@ export class PairingService {
273
339
  lanAddresses: [...this.lanBases.keys()],
274
340
  ...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
275
341
  ...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
342
+ ...(this.posture !== undefined ? { posture: this.posture } : {}),
276
343
  ...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
277
344
  deviceCount: this.devices.size,
278
345
  onlineCount,
346
+ devices,
279
347
  };
280
348
  }
281
- /** Whether a cookie value names a currently live device session. */
349
+ /** Whether a cookie value names a currently live (non-idle) device session. */
282
350
  hasDevice(deviceId) {
283
- const session = this.devices.get(deviceId);
284
- return session !== undefined && !this.stopped;
351
+ return this.liveSession(deviceId) !== undefined;
285
352
  }
286
353
  /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
287
354
  onState(listener) {
@@ -298,6 +365,34 @@ export class PairingService {
298
365
  }
299
366
  return undefined;
300
367
  }
368
+ /**
369
+ * Return a live session, deleting it first when idle-expired. Side-effecting
370
+ * so a stale cookie cannot pass the gate between sweeps.
371
+ */
372
+ liveSession(deviceId) {
373
+ if (this.stopped)
374
+ return undefined;
375
+ const session = this.devices.get(deviceId);
376
+ if (session === undefined)
377
+ return undefined;
378
+ const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
379
+ if (this.clock.now() - session.lastSeenAt > limit) {
380
+ this.devices.delete(deviceId);
381
+ this.persist();
382
+ this.notify();
383
+ return undefined;
384
+ }
385
+ return session;
386
+ }
387
+ toDeviceSnapshot(id, session, now) {
388
+ return {
389
+ id,
390
+ createdAt: session.createdAt,
391
+ lastSeenAt: session.lastSeenAt,
392
+ online: this.isOnlineAt(session, now),
393
+ ...(session.userAgent !== undefined ? { userAgent: session.userAgent } : {}),
394
+ };
395
+ }
301
396
  derivePhase(onlineCount, hasToken) {
302
397
  if (this.lanBases.size === 0 && this.publicBase === undefined)
303
398
  return 'lan-required';
@@ -340,7 +435,20 @@ function snapshotsEqual(a, b) {
340
435
  && a.tokenId === b.tokenId
341
436
  && a.tokenExpiresAt === b.tokenExpiresAt
342
437
  && a.deviceCount === b.deviceCount
343
- && a.onlineCount === b.onlineCount;
438
+ && a.onlineCount === b.onlineCount
439
+ && devicesEqual(a.devices, b.devices);
440
+ }
441
+ /** Per-device roster equality (order is pairing time). */
442
+ function devicesEqual(a, b) {
443
+ return a.length === b.length && a.every((device, index) => {
444
+ const other = b[index];
445
+ return other !== undefined
446
+ && device.id === other.id
447
+ && device.createdAt === other.createdAt
448
+ && device.lastSeenAt === other.lastSeenAt
449
+ && device.online === other.online
450
+ && device.userAgent === other.userAgent;
451
+ });
344
452
  }
345
453
  /** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
346
454
  function tunnelEqual(a, b) {
@@ -351,3 +459,12 @@ function tunnelEqual(a, b) {
351
459
  function sameStrings(a, b) {
352
460
  return a.length === b.length && a.every((value, index) => value === b[index]);
353
461
  }
462
+ /** Strip control characters and cap the User-Agent stored with a session. */
463
+ export function sanitizeUserAgent(raw) {
464
+ if (raw === undefined)
465
+ return undefined;
466
+ const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim();
467
+ if (cleaned === '')
468
+ return undefined;
469
+ return cleaned.length <= MAX_USER_AGENT_CHARS ? cleaned : cleaned.slice(0, MAX_USER_AGENT_CHARS);
470
+ }
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * PairingService device-session persistence: with `devicesFile` set, sessions
3
- * written on accept/stop survive a process restart (a new PairingService
4
- * instance), so a previously paired phone — whose cookie already lives 365
5
- * days — never needs to re-scan the QR after `dsh web` restarts.
3
+ * written on accept/stop/revoke/sweep survive a process restart (a new
4
+ * PairingService instance). lastSeenAt is flushed on sweep, not on every
5
+ * touch; idle sessions older than idleExpireMs are dropped on load.
6
6
  */
7
7
  import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11
11
  import { pairingConfigOf } from './index.js';
12
- import { PairingService } from './pairing.js';
12
+ import { DEFAULT_IDLE_EXPIRE_MS, PairingService } from './pairing.js';
13
13
  const BASE_CONFIG = {
14
14
  tokenTtlMs: 60_000,
15
15
  offlineAfterMs: 25_000,
16
16
  maxDevices: 4,
17
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
17
18
  cookieName: 'dsh_pair',
18
19
  };
19
20
  /** Deterministic clock: tokens are issued in a fixed, readable sequence. */
@@ -47,7 +48,11 @@ describe('PairingService device persistence', () => {
47
48
  const deviceId = pairDevice(first);
48
49
  expect(readFileSync(file, 'utf8')).toContain(deviceId);
49
50
  // Device ids are session credentials: the persisted file is 0600.
50
- expect(statSync(file).mode & 0o777).toBe(0o600);
51
+ // win32: NTFS has no POSIX mode bits and Node ignores the writeFileSync
52
+ // mode option there, so the mode assertion only applies on POSIX (#772).
53
+ if (process.platform !== 'win32') {
54
+ expect(statSync(file).mode & 0o777).toBe(0o600);
55
+ }
51
56
  // Simulated restart: a brand-new service instance reading the same file.
52
57
  const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
53
58
  expect(second.hasDevice(deviceId)).toBe(true);
@@ -58,6 +63,7 @@ describe('PairingService device persistence', () => {
58
63
  tokenTtlMs: 60_000,
59
64
  offlineAfterMs: 25_000,
60
65
  maxDevices: 4,
66
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
61
67
  cookieName: 'dsh_pair',
62
68
  devicesFile: file,
63
69
  });
@@ -72,6 +78,7 @@ describe('PairingService device persistence', () => {
72
78
  tokenTtlMs: 60_000,
73
79
  offlineAfterMs: 25_000,
74
80
  maxDevices: 4,
81
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
75
82
  cookieName: 'dsh_pair',
76
83
  devicesFile: file,
77
84
  });
@@ -125,4 +132,52 @@ describe('PairingService device persistence', () => {
125
132
  const ghost = join(dir, 'does-not-exist.json');
126
133
  expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow();
127
134
  });
135
+ it('does not persist lastSeenAt on touch; sweep flushes it', () => {
136
+ const file = join(dir, 'devices.json');
137
+ const now = { value: 1_000_000 };
138
+ let n = 0;
139
+ const clock = {
140
+ now: () => now.value,
141
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
142
+ };
143
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
144
+ const deviceId = pairDevice(first);
145
+ const afterAccept = JSON.parse(readFileSync(file, 'utf8'));
146
+ expect(afterAccept[deviceId]?.lastSeenAt).toBe(1_000_000);
147
+ now.value = 1_500_000;
148
+ expect(first.touchDevice(deviceId)).toBe(true);
149
+ const afterTouch = JSON.parse(readFileSync(file, 'utf8'));
150
+ expect(afterTouch[deviceId]?.lastSeenAt).toBe(1_000_000);
151
+ first.sweep();
152
+ const afterSweep = JSON.parse(readFileSync(file, 'utf8'));
153
+ expect(afterSweep[deviceId]?.lastSeenAt).toBe(1_500_000);
154
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
155
+ expect(second.hasDevice(deviceId)).toBe(true);
156
+ expect(second.snapshot().devices[0]?.lastSeenAt).toBe(1_500_000);
157
+ });
158
+ it('drops idle sessions on load instead of restoring them', () => {
159
+ const file = join(dir, 'devices.json');
160
+ writeFileSync(file, JSON.stringify({
161
+ stale: { createdAt: 1, lastSeenAt: 1 },
162
+ }));
163
+ const clock = {
164
+ now: () => 1 + DEFAULT_IDLE_EXPIRE_MS + 1,
165
+ randomToken: () => 'tok',
166
+ };
167
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
168
+ expect(service.hasDevice('stale')).toBe(false);
169
+ const saved = JSON.parse(readFileSync(file, 'utf8'));
170
+ expect(saved).toEqual({});
171
+ });
172
+ it('persists a User-Agent captured at accept', () => {
173
+ const file = join(dir, 'devices.json');
174
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
175
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com');
176
+ const { token } = service.issue();
177
+ const result = service.accept(token, 'Mozilla/5.0 TestPhone');
178
+ expect(result.ok).toBe(true);
179
+ const saved = JSON.parse(readFileSync(file, 'utf8'));
180
+ const deviceId = result.ok ? result.deviceId : '';
181
+ expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone');
182
+ });
128
183
  });
@@ -0,0 +1,75 @@
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
+ import type { ClientRequest, ClientRequestArgs } from 'node:http';
16
+ /** One probed origin. */
17
+ export interface PostureHost {
18
+ /** The forged Host header value that was probed. */
19
+ host: string;
20
+ /** True when the probe reached past the fence (anything but 403). */
21
+ exposed: boolean;
22
+ }
23
+ /** One probe round. */
24
+ export interface PostureSnapshot {
25
+ /** When the round completed (epoch ms). */
26
+ checkedAt: number;
27
+ /** Every probed origin and its verdict. */
28
+ hosts: PostureHost[];
29
+ }
30
+ /** Whether any probed origin is currently exposed. */
31
+ export declare function anyExposed(snapshot: PostureSnapshot): boolean;
32
+ /**
33
+ * Build the forged Host values to probe: the public base authority (host or
34
+ * host:port as written in the URL) plus every LAN base literal.
35
+ * @param publicBaseUrl - the configured public base URL (or undefined).
36
+ * @param lanAddresses - the LAN interface addresses the QR advertises.
37
+ * @param port - the local webServer port (LAN hosts are probed as host:port).
38
+ * @returns Host header values, de-duplicated.
39
+ */
40
+ export declare function postureTargets(publicBaseUrl: string | undefined, lanAddresses: string[], port: number): string[];
41
+ /** Injectable transport seam (defaults to node:http). */
42
+ export type ProbeRequest = (options: ClientRequestArgs, onStatus: (status: number) => void) => ClientRequest;
43
+ /** Probe options. */
44
+ export interface ProbePostureOptions {
45
+ /** The local webServer port. */
46
+ port: number;
47
+ /** Forged Host header values (see {@link postureTargets}). */
48
+ targets: string[];
49
+ /** Transport seam (defaults to node:http). */
50
+ request?: ProbeRequest;
51
+ /** Per-host timeout (default 3000 ms). */
52
+ timeoutMs?: number;
53
+ /** Clock seam (default Date.now). */
54
+ now?: () => number;
55
+ }
56
+ /**
57
+ * Run one posture probe round.
58
+ * @returns the snapshot for this round.
59
+ */
60
+ export declare function probePosture(options: ProbePostureOptions): Promise<PostureSnapshot>;
61
+ /**
62
+ * Reserve an advertised-target key so a second trigger with the same set
63
+ * does not overlap an in-flight round. Pair with {@link releasePostureKey}
64
+ * on failure — otherwise that key never retries.
65
+ */
66
+ export declare function claimPostureKey(current: string | undefined, key: string): {
67
+ run: boolean;
68
+ next: string;
69
+ };
70
+ /**
71
+ * Drop a failed in-flight key so the next trigger re-probes the same targets.
72
+ * A newer key that started meanwhile is left alone.
73
+ */
74
+ export declare function releasePostureKey(current: string | undefined, attempted: string): string | undefined;
75
+ //# sourceMappingURL=posture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAGjE,yBAAyB;AACzB,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,uBAAuB;AACvB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,KAAK,EAAE,WAAW,EAAE,CAAA;CACrB;AAED,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAehH;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,aAAa,CAAA;AAyC5G,qBAAqB;AACrB,MAAM,WAAW,mBAAmB;IAClC,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAWzF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAGxG;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpG"}
@@ -0,0 +1,116 @@
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
+ import http from 'node:http';
16
+ /** Whether any probed origin is currently exposed. */
17
+ export function anyExposed(snapshot) {
18
+ return snapshot.hosts.some(host => host.exposed);
19
+ }
20
+ /**
21
+ * Build the forged Host values to probe: the public base authority (host or
22
+ * host:port as written in the URL) plus every LAN base literal.
23
+ * @param publicBaseUrl - the configured public base URL (or undefined).
24
+ * @param lanAddresses - the LAN interface addresses the QR advertises.
25
+ * @param port - the local webServer port (LAN hosts are probed as host:port).
26
+ * @returns Host header values, de-duplicated.
27
+ */
28
+ export function postureTargets(publicBaseUrl, lanAddresses, port) {
29
+ const targets = [];
30
+ if (publicBaseUrl !== undefined) {
31
+ try {
32
+ const url = new URL(publicBaseUrl);
33
+ const authority = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`;
34
+ if (authority !== '')
35
+ targets.push(authority);
36
+ }
37
+ catch {
38
+ // A malformed base is ignored elsewhere with a warning; nothing to probe.
39
+ }
40
+ }
41
+ for (const address of lanAddresses) {
42
+ targets.push(`${address}:${String(port)}`);
43
+ }
44
+ return [...new Set(targets)];
45
+ }
46
+ const defaultRequest = (options, onStatus) => {
47
+ const request = http.request(options, response => { onStatus(response.statusCode ?? 0); });
48
+ request.on('error', () => { onStatus(0); });
49
+ return request;
50
+ };
51
+ /**
52
+ * Probe one forged Host against the local `/api` fence.
53
+ * @param port - the local webServer port.
54
+ * @param hostHeader - the Host header to forge.
55
+ * @param request - transport seam.
56
+ * @param timeoutMs - give up after this long (counts as not exposed; the
57
+ * fence being unreachable is not evidence it is open).
58
+ * @returns true when the probe got past the fence.
59
+ */
60
+ async function probeHost(port, hostHeader, request, timeoutMs) {
61
+ return await new Promise(resolve => {
62
+ let settled = false;
63
+ const finish = (exposed) => {
64
+ if (settled)
65
+ return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ handle.destroy();
69
+ resolve(exposed);
70
+ };
71
+ const handle = request({
72
+ host: '127.0.0.1',
73
+ port,
74
+ method: 'POST',
75
+ path: '/api/session.list',
76
+ headers: { host: hostHeader, 'content-type': 'application/json' },
77
+ timeout: timeoutMs,
78
+ }, status => { finish(status !== 403); });
79
+ const timer = setTimeout(() => { finish(false); }, timeoutMs + 1_000);
80
+ handle.on('error', () => { finish(false); });
81
+ handle.end('{}');
82
+ });
83
+ }
84
+ /**
85
+ * Run one posture probe round.
86
+ * @returns the snapshot for this round.
87
+ */
88
+ export async function probePosture(options) {
89
+ const { port, targets } = options;
90
+ const request = options.request ?? defaultRequest;
91
+ const timeoutMs = options.timeoutMs ?? 3_000;
92
+ const now = options.now ?? (() => Date.now());
93
+ const hosts = [];
94
+ for (const target of targets) {
95
+ // Sequential on purpose: one slow host must not multiply the round's latency.
96
+ hosts.push({ host: target, exposed: await probeHost(port, target, request, timeoutMs) });
97
+ }
98
+ return { checkedAt: now(), hosts };
99
+ }
100
+ /**
101
+ * Reserve an advertised-target key so a second trigger with the same set
102
+ * does not overlap an in-flight round. Pair with {@link releasePostureKey}
103
+ * on failure — otherwise that key never retries.
104
+ */
105
+ export function claimPostureKey(current, key) {
106
+ if (current === key)
107
+ return { run: false, next: current };
108
+ return { run: true, next: key };
109
+ }
110
+ /**
111
+ * Drop a failed in-flight key so the next trigger re-probes the same targets.
112
+ * A newer key that started meanwhile is left alone.
113
+ */
114
+ export function releasePostureKey(current, attempted) {
115
+ return current === attempted ? undefined : current;
116
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The remote desktop data channel: `/remote` is this plugin's own prefix, so
3
+ * the paired-device cookie is the access control (exactly like `/m/api`).
4
+ * After that gate, every fenced same-origin path the browser rewrote here is
5
+ * re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
6
+ * fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
7
+ * and no per-plugin pairing consult.
8
+ *
9
+ * Security model:
10
+ * - Every request must carry a live paired-device cookie, enforced before
11
+ * any bytes are forwarded and before any host call.
12
+ * - The SDK's loopback-only privileged methods (native dialogs, the settings
13
+ * plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
14
+ * are denied here. The set is pinned by tests/remote-contract.spec.ts.
15
+ * - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
16
+ * `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
17
+ * - Everything else is HTTP- or WebSocket-proxied to the local port with
18
+ * Host rewritten, Origin and cookies dropped, and a synthetic same-origin
19
+ * browser marker added after authentication. Plugin loopback fences then
20
+ * pass. The pairing cookie never leaves this process.
21
+ */
22
+ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver';
23
+ import type { PairingService } from './pairing.ts';
24
+ export { DESKTOP_LAUNCHER_PATH, LOOPBACK_ONLY_METHODS, PLUGIN_MANAGER_PATH, REMOTE_API_PATHS, REMOTE_PREFIX, REMOTE_UPGRADE_PATHS, WEB_UI_SETTINGS_BRIDGE_PATH, } from './remote-methods.ts';
25
+ export { REMOTE_API_PREFIX } from './remote-methods.ts';
26
+ /** Route-family dependencies. */
27
+ export interface RemoteApiDeps {
28
+ /** The pairing service (device gate + cookie name). */
29
+ service: PairingService;
30
+ /** The local webServer port the loopback proxy connects to. */
31
+ port: number;
32
+ }
33
+ /**
34
+ * Map `/remote/...` to the inner path, or undefined when the outer path is
35
+ * not a safe rewrite target.
36
+ */
37
+ export declare function innerPathOf(pathname: string): string | undefined;
38
+ /**
39
+ * Whether a paired inner path must stay physically local.
40
+ * @returns a denial message, or undefined when the path may be proxied.
41
+ */
42
+ export declare function loopbackOnlyDenial(innerPath: string): string | undefined;
43
+ /**
44
+ * Build the remote desktop channel HTTP routes.
45
+ * @param deps - pairing service + local port.
46
+ * @returns the routes to register on webServer.
47
+ */
48
+ export declare function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[];
49
+ /**
50
+ * Map one outer upgrade URL onto the loopback path (query string included).
51
+ */
52
+ export declare function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string;
53
+ /**
54
+ * Build the WebSocket upgrade routes for the event streams and known plugin
55
+ * sockets. webServer matches upgrades by exact path.
56
+ * @param deps - pairing service + local port.
57
+ * @returns the upgrade routes to register on webServer.
58
+ */
59
+ export declare function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[];
60
+ //# sourceMappingURL=remote-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAclD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;CACb;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBxE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAsCnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAwBjF"}