@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/lib/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import {
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { setInterval as setInterval$1, setTimeout as setTimeout$1 } from "node:timers";
|
|
3
4
|
import { Service } from "@deepseek-ai/cordis";
|
|
4
5
|
import z from "schemastery";
|
|
5
6
|
import { randomBytes } from "node:crypto";
|
|
6
7
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
8
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
8
9
|
import { z as z$1 } from "zod";
|
|
9
10
|
import { readFile } from "node:fs/promises";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
|
|
12
|
-
import {
|
|
13
|
+
import http, { request } from "node:http";
|
|
14
|
+
import { connect } from "node:net";
|
|
13
15
|
import { Tunnel, bin, install } from "cloudflared";
|
|
14
16
|
import { spawn } from "node:child_process";
|
|
15
17
|
//#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.8_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_7616943ecf171cb97db9e2088de3356f/node_modules/@deepseek-ai/dsh-settings/lib/index.js
|
|
@@ -225,7 +227,13 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
|
225
227
|
* unknown one (no oracle for validity).
|
|
226
228
|
* - `stop()` revokes every device session and clears the token, so paired
|
|
227
229
|
* devices are cut off on their next gated request.
|
|
230
|
+
* - `revoke()` drops one device session; idle sessions older than
|
|
231
|
+
* `idleExpireMs` are deleted on sweep, load, and the next gated request.
|
|
228
232
|
*/
|
|
233
|
+
/** Default idle-expiry window: 7 days without heartbeat or a gated request. */
|
|
234
|
+
const DEFAULT_IDLE_EXPIRE_MS = 10080 * 60 * 1e3;
|
|
235
|
+
/** Cap on the persisted/displayed User-Agent string. */
|
|
236
|
+
const MAX_USER_AGENT_CHARS = 180;
|
|
229
237
|
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
230
238
|
var UnknownLanAddressError = class extends Error {
|
|
231
239
|
/**
|
|
@@ -262,6 +270,9 @@ var PairingService = class {
|
|
|
262
270
|
publicBase;
|
|
263
271
|
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
264
272
|
tunnelStatus;
|
|
273
|
+
posture;
|
|
274
|
+
/** True when lastSeenAt changed since the last persist (flushed on sweep). */
|
|
275
|
+
dirty = false;
|
|
265
276
|
/**
|
|
266
277
|
* @param config - tunables. The settings surface replaces the object (a
|
|
267
278
|
* fresh literal) when a committed section changes; every operation reads
|
|
@@ -287,14 +298,17 @@ var PairingService = class {
|
|
|
287
298
|
for (const [deviceId, session] of Object.entries(saved)) {
|
|
288
299
|
if (typeof deviceId !== "string") continue;
|
|
289
300
|
if (typeof session !== "object" || session === null) continue;
|
|
290
|
-
const { createdAt, lastSeenAt } = session;
|
|
301
|
+
const { createdAt, lastSeenAt, userAgent } = session;
|
|
291
302
|
if (typeof createdAt !== "number" || typeof lastSeenAt !== "number") continue;
|
|
303
|
+
const label = typeof userAgent === "string" ? sanitizeUserAgent(userAgent) : void 0;
|
|
292
304
|
this.devices.set(deviceId, {
|
|
293
305
|
createdAt,
|
|
294
|
-
lastSeenAt
|
|
306
|
+
lastSeenAt,
|
|
307
|
+
...label !== void 0 ? { userAgent: label } : {}
|
|
295
308
|
});
|
|
296
309
|
}
|
|
297
310
|
this.clampToMaxDevices();
|
|
311
|
+
if (this.evictIdle()) this.persist();
|
|
298
312
|
} catch {}
|
|
299
313
|
}
|
|
300
314
|
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
@@ -304,12 +318,22 @@ var PairingService = class {
|
|
|
304
318
|
const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
305
319
|
for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id);
|
|
306
320
|
}
|
|
321
|
+
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
322
|
+
evictIdle() {
|
|
323
|
+
const now = this.clock.now();
|
|
324
|
+
const limit = this.config.idleExpireMs ?? 6048e5;
|
|
325
|
+
let removed = false;
|
|
326
|
+
for (const [id, session] of [...this.devices]) if (now - session.lastSeenAt > limit) {
|
|
327
|
+
this.devices.delete(id);
|
|
328
|
+
removed = true;
|
|
329
|
+
}
|
|
330
|
+
return removed;
|
|
331
|
+
}
|
|
307
332
|
/**
|
|
308
333
|
* Write the current device table to the configured file. Called on the
|
|
309
|
-
* mutation boundaries that change the set of live sessions (accept
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* heartbeat re-warms its own session.
|
|
334
|
+
* mutation boundaries that change the set of live sessions (accept, stop,
|
|
335
|
+
* revoke, idle eviction) and, throttled, from sweep() so lastSeenAt
|
|
336
|
+
* survives a restart without a write per request.
|
|
313
337
|
*
|
|
314
338
|
* Device ids are session credentials (the gate authorizes requests by the
|
|
315
339
|
* cookie's device id), so the file is written 0600 via a temp file and
|
|
@@ -321,8 +345,11 @@ var PairingService = class {
|
|
|
321
345
|
try {
|
|
322
346
|
mkdirSync(dirname(file), { recursive: true });
|
|
323
347
|
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
324
|
-
|
|
348
|
+
const payload = {};
|
|
349
|
+
for (const [id, session] of this.devices) payload[id] = session;
|
|
350
|
+
writeFileSync(temp, JSON.stringify(payload), { mode: 384 });
|
|
325
351
|
renameSync(temp, file);
|
|
352
|
+
this.dirty = false;
|
|
326
353
|
} catch (error) {
|
|
327
354
|
console.error("remote-web-ui: failed to persist paired devices", error);
|
|
328
355
|
}
|
|
@@ -358,6 +385,11 @@ var PairingService = class {
|
|
|
358
385
|
this.tunnelStatus = status;
|
|
359
386
|
this.notify();
|
|
360
387
|
}
|
|
388
|
+
/** Set the latest /api posture probe result (see posture.ts). */
|
|
389
|
+
setPosture(snapshot) {
|
|
390
|
+
this.posture = snapshot;
|
|
391
|
+
this.notify();
|
|
392
|
+
}
|
|
361
393
|
/**
|
|
362
394
|
* Issue a fresh token, replacing (invalidating) any previous one. A
|
|
363
395
|
* stopped service re-arms through this call (the panel's refresh button).
|
|
@@ -397,9 +429,10 @@ var PairingService = class {
|
|
|
397
429
|
* successful call for the same token is impossible because the first
|
|
398
430
|
* consumes it.
|
|
399
431
|
* @param token - the token secret from the QR link.
|
|
432
|
+
* @param userAgent - optional User-Agent header captured at accept.
|
|
400
433
|
* @returns the new device id, or a refusal code.
|
|
401
434
|
*/
|
|
402
|
-
accept(token) {
|
|
435
|
+
accept(token, userAgent) {
|
|
403
436
|
const record = this.tokens.get(token);
|
|
404
437
|
if (record === void 0 || record.consumed || this.stopped || this.clock.now() > record.expiresAt) return {
|
|
405
438
|
ok: false,
|
|
@@ -416,9 +449,11 @@ var PairingService = class {
|
|
|
416
449
|
};
|
|
417
450
|
if (oldest !== void 0) this.devices.delete(oldest.id);
|
|
418
451
|
}
|
|
452
|
+
const label = sanitizeUserAgent(userAgent);
|
|
419
453
|
this.devices.set(deviceId, {
|
|
420
454
|
createdAt: now,
|
|
421
|
-
lastSeenAt: now
|
|
455
|
+
lastSeenAt: now,
|
|
456
|
+
...label !== void 0 ? { userAgent: label } : {}
|
|
422
457
|
});
|
|
423
458
|
this.persist();
|
|
424
459
|
this.notify();
|
|
@@ -440,16 +475,30 @@ var PairingService = class {
|
|
|
440
475
|
this.notify();
|
|
441
476
|
}
|
|
442
477
|
/**
|
|
478
|
+
* Revoke one paired device. The next gated request from that cookie is
|
|
479
|
+
* refused; other sessions stay live. Unknown ids are a no-op.
|
|
480
|
+
* @param deviceId - the cookie value of the device to drop.
|
|
481
|
+
* @returns true when a live session was removed.
|
|
482
|
+
*/
|
|
483
|
+
revoke(deviceId) {
|
|
484
|
+
if (this.stopped) return false;
|
|
485
|
+
if (!this.devices.delete(deviceId)) return false;
|
|
486
|
+
this.persist();
|
|
487
|
+
this.notify();
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
443
491
|
* The api/gate path: record activity for a device id and report whether
|
|
444
492
|
* the request may proceed. Unknown or revoked ids (including any device
|
|
445
|
-
* after stop()) are refused.
|
|
493
|
+
* after stop() or idle expiry) are refused.
|
|
446
494
|
* @param deviceId - the cookie value of the requesting device.
|
|
447
495
|
* @returns true when the device session is live and was refreshed.
|
|
448
496
|
*/
|
|
449
497
|
touchDevice(deviceId) {
|
|
450
|
-
const session = this.
|
|
451
|
-
if (session === void 0
|
|
498
|
+
const session = this.liveSession(deviceId);
|
|
499
|
+
if (session === void 0) return false;
|
|
452
500
|
session.lastSeenAt = this.clock.now();
|
|
501
|
+
this.dirty = true;
|
|
453
502
|
this.notify();
|
|
454
503
|
return true;
|
|
455
504
|
}
|
|
@@ -458,17 +507,20 @@ var PairingService = class {
|
|
|
458
507
|
return this.touchDevice(deviceId);
|
|
459
508
|
}
|
|
460
509
|
/**
|
|
461
|
-
* Periodic sweep:
|
|
462
|
-
* the
|
|
463
|
-
* the
|
|
510
|
+
* Periodic sweep: drop idle sessions, flush a dirty lastSeenAt, and
|
|
511
|
+
* re-evaluate the derived snapshot (a device aging past the offline
|
|
512
|
+
* window flips the phase to disconnected). Emits only when the snapshot
|
|
513
|
+
* actually changed.
|
|
464
514
|
*/
|
|
465
515
|
sweep() {
|
|
516
|
+
if (this.evictIdle() || this.dirty) this.persist();
|
|
466
517
|
this.notify();
|
|
467
518
|
}
|
|
468
519
|
/** The current snapshot (fresh object per call — stable between emits). */
|
|
469
520
|
snapshot() {
|
|
470
521
|
const now = this.clock.now();
|
|
471
|
-
const
|
|
522
|
+
const devices = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt).map(([id, session]) => this.toDeviceSnapshot(id, session, now));
|
|
523
|
+
const onlineCount = devices.filter((device) => device.online).length;
|
|
472
524
|
const token = this.activeToken();
|
|
473
525
|
return {
|
|
474
526
|
phase: this.derivePhase(onlineCount, token !== void 0),
|
|
@@ -476,17 +528,19 @@ var PairingService = class {
|
|
|
476
528
|
lanAddresses: [...this.lanBases.keys()],
|
|
477
529
|
...this.publicBase !== void 0 ? { publicUrl: this.publicBase } : {},
|
|
478
530
|
...this.tunnelStatus !== void 0 ? { tunnel: this.tunnelStatus } : {},
|
|
531
|
+
...this.posture !== void 0 ? { posture: this.posture } : {},
|
|
479
532
|
...token !== void 0 ? {
|
|
480
533
|
tokenId: token.record.id,
|
|
481
534
|
tokenExpiresAt: token.record.expiresAt
|
|
482
535
|
} : {},
|
|
483
536
|
deviceCount: this.devices.size,
|
|
484
|
-
onlineCount
|
|
537
|
+
onlineCount,
|
|
538
|
+
devices
|
|
485
539
|
};
|
|
486
540
|
}
|
|
487
|
-
/** Whether a cookie value names a currently live device session. */
|
|
541
|
+
/** Whether a cookie value names a currently live (non-idle) device session. */
|
|
488
542
|
hasDevice(deviceId) {
|
|
489
|
-
return this.
|
|
543
|
+
return this.liveSession(deviceId) !== void 0;
|
|
490
544
|
}
|
|
491
545
|
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
492
546
|
onState(listener) {
|
|
@@ -505,6 +559,32 @@ var PairingService = class {
|
|
|
505
559
|
};
|
|
506
560
|
}
|
|
507
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Return a live session, deleting it first when idle-expired. Side-effecting
|
|
564
|
+
* so a stale cookie cannot pass the gate between sweeps.
|
|
565
|
+
*/
|
|
566
|
+
liveSession(deviceId) {
|
|
567
|
+
if (this.stopped) return void 0;
|
|
568
|
+
const session = this.devices.get(deviceId);
|
|
569
|
+
if (session === void 0) return void 0;
|
|
570
|
+
const limit = this.config.idleExpireMs ?? 6048e5;
|
|
571
|
+
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
572
|
+
this.devices.delete(deviceId);
|
|
573
|
+
this.persist();
|
|
574
|
+
this.notify();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
return session;
|
|
578
|
+
}
|
|
579
|
+
toDeviceSnapshot(id, session, now) {
|
|
580
|
+
return {
|
|
581
|
+
id,
|
|
582
|
+
createdAt: session.createdAt,
|
|
583
|
+
lastSeenAt: session.lastSeenAt,
|
|
584
|
+
online: this.isOnlineAt(session, now),
|
|
585
|
+
...session.userAgent !== void 0 ? { userAgent: session.userAgent } : {}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
508
588
|
derivePhase(onlineCount, hasToken) {
|
|
509
589
|
if (this.lanBases.size === 0 && this.publicBase === void 0) return "lan-required";
|
|
510
590
|
if (this.stopped) return "stopped";
|
|
@@ -529,7 +609,14 @@ var PairingService = class {
|
|
|
529
609
|
};
|
|
530
610
|
/** Structural equality over the snapshot's wire fields. */
|
|
531
611
|
function snapshotsEqual(a, b) {
|
|
532
|
-
return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount;
|
|
612
|
+
return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
|
|
613
|
+
}
|
|
614
|
+
/** Per-device roster equality (order is pairing time). */
|
|
615
|
+
function devicesEqual(a, b) {
|
|
616
|
+
return a.length === b.length && a.every((device, index) => {
|
|
617
|
+
const other = b[index];
|
|
618
|
+
return other !== void 0 && device.id === other.id && device.createdAt === other.createdAt && device.lastSeenAt === other.lastSeenAt && device.online === other.online && device.userAgent === other.userAgent;
|
|
619
|
+
});
|
|
533
620
|
}
|
|
534
621
|
/** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
|
|
535
622
|
function tunnelEqual(a, b) {
|
|
@@ -539,6 +626,44 @@ function tunnelEqual(a, b) {
|
|
|
539
626
|
function sameStrings(a, b) {
|
|
540
627
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
541
628
|
}
|
|
629
|
+
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
630
|
+
function sanitizeUserAgent(raw) {
|
|
631
|
+
if (raw === void 0) return void 0;
|
|
632
|
+
const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim();
|
|
633
|
+
if (cleaned === "") return void 0;
|
|
634
|
+
return cleaned.length <= MAX_USER_AGENT_CHARS ? cleaned : cleaned.slice(0, MAX_USER_AGENT_CHARS);
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/dsh-home.ts
|
|
638
|
+
/**
|
|
639
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
640
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
641
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
642
|
+
*/
|
|
643
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
644
|
+
function expandHome(path, home = homedir()) {
|
|
645
|
+
if (path === "~") return home;
|
|
646
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
647
|
+
return path;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Resolve the DSH home directory.
|
|
651
|
+
* @param env - process environment to read DSH_HOME from.
|
|
652
|
+
* @param home - platform home directory fallback (test seam).
|
|
653
|
+
* @returns the absolute DSH home path.
|
|
654
|
+
*/
|
|
655
|
+
function resolveDshHome(env = process.env, home = homedir()) {
|
|
656
|
+
const raw = env.DSH_HOME;
|
|
657
|
+
if (raw !== void 0 && raw.trim() !== "") {
|
|
658
|
+
const expanded = expandHome(raw.trim(), home);
|
|
659
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
|
|
660
|
+
}
|
|
661
|
+
return join(home, ".dsh");
|
|
662
|
+
}
|
|
663
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
664
|
+
function dshHome() {
|
|
665
|
+
return resolveDshHome();
|
|
666
|
+
}
|
|
542
667
|
//#endregion
|
|
543
668
|
//#region src/loopback.ts
|
|
544
669
|
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
@@ -756,6 +881,7 @@ const PAIR_PATHS = {
|
|
|
756
881
|
issue: "/api/pair/issue",
|
|
757
882
|
accept: "/api/pair/accept",
|
|
758
883
|
stop: "/api/pair/stop",
|
|
884
|
+
revoke: "/api/pair/revoke",
|
|
759
885
|
heartbeat: "/api/pair/heartbeat",
|
|
760
886
|
status: "/api/pair/status",
|
|
761
887
|
events: "/api/pair/events"
|
|
@@ -773,6 +899,7 @@ const issuePayloadSchema = z$1.object({
|
|
|
773
899
|
address: z$1.string().min(1).optional()
|
|
774
900
|
});
|
|
775
901
|
const acceptPayloadSchema = z$1.object({ token: z$1.string().default("") });
|
|
902
|
+
const revokePayloadSchema = z$1.object({ deviceId: z$1.string().min(1) });
|
|
776
903
|
const pairActionPayloadSchema = z$1.object({}).passthrough();
|
|
777
904
|
/**
|
|
778
905
|
* Parse a pair request body through schema. A missing/empty or non-object
|
|
@@ -950,7 +1077,8 @@ function makeRoutes(deps) {
|
|
|
950
1077
|
});
|
|
951
1078
|
return;
|
|
952
1079
|
}
|
|
953
|
-
const
|
|
1080
|
+
const ua = req.headers["user-agent"];
|
|
1081
|
+
const result = service.accept(payload.token, typeof ua === "string" ? ua : void 0);
|
|
954
1082
|
if (!result.ok) {
|
|
955
1083
|
writeJson(res, result.code === "used" ? 409 : 404, {
|
|
956
1084
|
ok: false,
|
|
@@ -987,6 +1115,33 @@ function makeRoutes(deps) {
|
|
|
987
1115
|
service.stop();
|
|
988
1116
|
writeJson(res, 200, { ok: true });
|
|
989
1117
|
};
|
|
1118
|
+
const handleRevoke = async (req, res) => {
|
|
1119
|
+
if (!requireMethod(req, res, "POST")) return;
|
|
1120
|
+
if (!loopbackFence(req)) {
|
|
1121
|
+
writeJson(res, 403, {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
code: "forbidden"
|
|
1124
|
+
});
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const body = await readJsonBody$1(req);
|
|
1128
|
+
const payload = parsePairPayload(revokePayloadSchema, body);
|
|
1129
|
+
if (payload === void 0) {
|
|
1130
|
+
writeJson(res, 400, {
|
|
1131
|
+
ok: false,
|
|
1132
|
+
code: "bad-payload"
|
|
1133
|
+
});
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (!service.revoke(payload.deviceId)) {
|
|
1137
|
+
writeJson(res, 404, {
|
|
1138
|
+
ok: false,
|
|
1139
|
+
code: "unknown-device"
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
writeJson(res, 200, { ok: true });
|
|
1144
|
+
};
|
|
990
1145
|
const handleHeartbeat = async (req, res) => {
|
|
991
1146
|
if (!requireMethod(req, res, "POST")) return;
|
|
992
1147
|
if (!lanFence(req)) {
|
|
@@ -1026,10 +1181,11 @@ function makeRoutes(deps) {
|
|
|
1026
1181
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1027
1182
|
const paired = deviceId !== void 0 && service.hasDevice(deviceId);
|
|
1028
1183
|
const snapshot = service.snapshot();
|
|
1184
|
+
const { devices: _devices, ...rest } = snapshot;
|
|
1029
1185
|
writeJson(res, 200, {
|
|
1030
1186
|
ok: true,
|
|
1031
1187
|
paired,
|
|
1032
|
-
...paired ?
|
|
1188
|
+
...paired ? rest : {
|
|
1033
1189
|
phase: snapshot.phase,
|
|
1034
1190
|
lanAvailable: snapshot.lanAvailable,
|
|
1035
1191
|
lanAddresses: snapshot.lanAddresses
|
|
@@ -1064,6 +1220,11 @@ function makeRoutes(deps) {
|
|
|
1064
1220
|
path: PAIR_PATHS.stop,
|
|
1065
1221
|
handler: handleStop
|
|
1066
1222
|
},
|
|
1223
|
+
{
|
|
1224
|
+
kind: "exact",
|
|
1225
|
+
path: PAIR_PATHS.revoke,
|
|
1226
|
+
handler: handleRevoke
|
|
1227
|
+
},
|
|
1067
1228
|
{
|
|
1068
1229
|
kind: "exact",
|
|
1069
1230
|
path: PAIR_PATHS.heartbeat,
|
|
@@ -1117,7 +1278,7 @@ function pageHtml(bundleUrl) {
|
|
|
1117
1278
|
"<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
|
|
1118
1279
|
"<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
|
|
1119
1280
|
"<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
|
|
1120
|
-
"<title
|
|
1281
|
+
"<title>远程访问</title>",
|
|
1121
1282
|
"</head>",
|
|
1122
1283
|
"<body>",
|
|
1123
1284
|
"<div id=\"root\"></div>",
|
|
@@ -1319,7 +1480,8 @@ function makeMobileRoutes() {
|
|
|
1319
1480
|
* note the paired-device cookie also passes the global api/gate for the full
|
|
1320
1481
|
* ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
|
|
1321
1482
|
* the allowlist only constrains this /m/api proxy, not the cookie's reach.
|
|
1322
|
-
* stop() revokes every device;
|
|
1483
|
+
* stop() revokes every device; the loopback panel can also revoke one
|
|
1484
|
+
* device at a time.
|
|
1323
1485
|
*/
|
|
1324
1486
|
const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
1325
1487
|
"workspace.list",
|
|
@@ -1559,7 +1721,11 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
|
1559
1721
|
};
|
|
1560
1722
|
if (method === "session.list") {
|
|
1561
1723
|
const full = await apiProxy.sessions.list(request);
|
|
1562
|
-
if (!full.result.ok) return
|
|
1724
|
+
if (!full.result.ok) return {
|
|
1725
|
+
type: "server-response",
|
|
1726
|
+
rpcId,
|
|
1727
|
+
result: full.result
|
|
1728
|
+
};
|
|
1563
1729
|
const items = full.result.value.items;
|
|
1564
1730
|
const cursor = payload?.cursor;
|
|
1565
1731
|
items.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
@@ -1599,6 +1765,421 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
|
1599
1765
|
throw new Error(`unhandled allowlisted method ${method}`);
|
|
1600
1766
|
}
|
|
1601
1767
|
//#endregion
|
|
1768
|
+
//#region src/loopback-proxy.ts
|
|
1769
|
+
/** WebSocket handshake headers forwarded to the loopback upstream. */
|
|
1770
|
+
const WS_FORWARD_HEADERS = [
|
|
1771
|
+
"sec-websocket-key",
|
|
1772
|
+
"sec-websocket-version",
|
|
1773
|
+
"sec-websocket-extensions",
|
|
1774
|
+
"sec-websocket-protocol"
|
|
1775
|
+
];
|
|
1776
|
+
/** Response headers copied from the loopback upstream (no hop-by-hop). */
|
|
1777
|
+
const HTTP_FORWARD_RESPONSE_HEADERS = [
|
|
1778
|
+
"content-type",
|
|
1779
|
+
"content-length",
|
|
1780
|
+
"content-disposition",
|
|
1781
|
+
"cache-control",
|
|
1782
|
+
"etag",
|
|
1783
|
+
"last-modified"
|
|
1784
|
+
];
|
|
1785
|
+
/**
|
|
1786
|
+
* Pipe one HTTP request to loopback and stream the response back.
|
|
1787
|
+
* @param req - the already-gated outer request.
|
|
1788
|
+
* @param res - the outer response.
|
|
1789
|
+
* @param port - local webServer port.
|
|
1790
|
+
* @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
|
|
1791
|
+
*/
|
|
1792
|
+
function proxyLoopbackHttp(req, res, port, upstreamPath) {
|
|
1793
|
+
const headers = {
|
|
1794
|
+
host: `127.0.0.1:${String(port)}`,
|
|
1795
|
+
"sec-fetch-site": "same-origin"
|
|
1796
|
+
};
|
|
1797
|
+
const contentType = req.headers["content-type"];
|
|
1798
|
+
if (typeof contentType === "string") headers["content-type"] = contentType;
|
|
1799
|
+
const contentLength = req.headers["content-length"];
|
|
1800
|
+
if (typeof contentLength === "string") headers["content-length"] = contentLength;
|
|
1801
|
+
const accept = req.headers.accept;
|
|
1802
|
+
if (typeof accept === "string") headers.accept = accept;
|
|
1803
|
+
const upstream = request({
|
|
1804
|
+
host: "127.0.0.1",
|
|
1805
|
+
port,
|
|
1806
|
+
path: upstreamPath,
|
|
1807
|
+
method: req.method,
|
|
1808
|
+
headers
|
|
1809
|
+
}, (upstreamRes) => {
|
|
1810
|
+
const out = {};
|
|
1811
|
+
for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
|
|
1812
|
+
const value = upstreamRes.headers[name];
|
|
1813
|
+
if (value !== void 0) out[name] = value;
|
|
1814
|
+
}
|
|
1815
|
+
res.writeHead(upstreamRes.statusCode ?? 502, out);
|
|
1816
|
+
upstreamRes.pipe(res);
|
|
1817
|
+
});
|
|
1818
|
+
upstream.on("error", () => {
|
|
1819
|
+
if (!res.headersSent) {
|
|
1820
|
+
writeJson(res, 502, {
|
|
1821
|
+
ok: false,
|
|
1822
|
+
error: {
|
|
1823
|
+
code: "upstream-failure",
|
|
1824
|
+
message: "upstream request failed"
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
res.destroy();
|
|
1830
|
+
});
|
|
1831
|
+
req.pipe(upstream);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Rebuild a WebSocket handshake as loopback-shaped and pipe both directions.
|
|
1835
|
+
* @param req - the already-gated upgrade request.
|
|
1836
|
+
* @param socket - the client duplex.
|
|
1837
|
+
* @param head - bytes already read past the handshake.
|
|
1838
|
+
* @param port - local webServer port.
|
|
1839
|
+
* @param upstreamPath - path + query on 127.0.0.1.
|
|
1840
|
+
*/
|
|
1841
|
+
function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
|
|
1842
|
+
const lines = [
|
|
1843
|
+
`GET ${upstreamPath} HTTP/1.1`,
|
|
1844
|
+
`Host: 127.0.0.1:${String(port)}`,
|
|
1845
|
+
"Upgrade: websocket",
|
|
1846
|
+
"Connection: Upgrade"
|
|
1847
|
+
];
|
|
1848
|
+
for (const name of WS_FORWARD_HEADERS) {
|
|
1849
|
+
const value = req.headers[name];
|
|
1850
|
+
if (value === void 0) continue;
|
|
1851
|
+
lines.push(`${name}: ${Array.isArray(value) ? value.join(", ") : value}`);
|
|
1852
|
+
}
|
|
1853
|
+
const handshake = `${lines.join("\r\n")}\r\n\r\n`;
|
|
1854
|
+
const upstream = connect(port, "127.0.0.1");
|
|
1855
|
+
const tearDown = () => {
|
|
1856
|
+
upstream.destroy();
|
|
1857
|
+
socket.destroy();
|
|
1858
|
+
};
|
|
1859
|
+
upstream.on("error", tearDown);
|
|
1860
|
+
socket.on("error", tearDown);
|
|
1861
|
+
upstream.on("close", () => {
|
|
1862
|
+
socket.destroy();
|
|
1863
|
+
});
|
|
1864
|
+
socket.on("close", () => {
|
|
1865
|
+
upstream.destroy();
|
|
1866
|
+
});
|
|
1867
|
+
upstream.on("connect", () => {
|
|
1868
|
+
upstream.write(handshake);
|
|
1869
|
+
if (head.length > 0) upstream.write(head);
|
|
1870
|
+
socket.pipe(upstream);
|
|
1871
|
+
upstream.pipe(socket);
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
//#endregion
|
|
1875
|
+
//#region src/remote-methods.ts
|
|
1876
|
+
/**
|
|
1877
|
+
* Remote desktop channel constants — SDK-independent so tests and the
|
|
1878
|
+
* client half can pin them without importing the host SDK graph.
|
|
1879
|
+
*/
|
|
1880
|
+
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
1881
|
+
const REMOTE_PREFIX = "/remote";
|
|
1882
|
+
/** Connection-plugin method prefix under the gated channel. */
|
|
1883
|
+
const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
|
|
1884
|
+
/** WebSocket event-stream paths served by the channel (client rewrites to these). */
|
|
1885
|
+
const REMOTE_API_PATHS = {
|
|
1886
|
+
mux: `${REMOTE_API_PREFIX}/events.mux`,
|
|
1887
|
+
host: `${REMOTE_API_PREFIX}/events.host`
|
|
1888
|
+
};
|
|
1889
|
+
/**
|
|
1890
|
+
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
1891
|
+
* exact path, not prefix). Query strings ride on the request URL.
|
|
1892
|
+
*/
|
|
1893
|
+
const REMOTE_UPGRADE_PATHS = [
|
|
1894
|
+
REMOTE_API_PATHS.mux,
|
|
1895
|
+
REMOTE_API_PATHS.host,
|
|
1896
|
+
`${REMOTE_PREFIX}/sidebar/ws/terminal`,
|
|
1897
|
+
`${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
|
|
1898
|
+
`${REMOTE_API_PREFIX}/dsh-ssh/terminal`
|
|
1899
|
+
];
|
|
1900
|
+
/**
|
|
1901
|
+
* Loopback-only methods of the host API surface, mirrored from
|
|
1902
|
+
* client-connection's `PRIVILEGED_METHODS` (pinned by
|
|
1903
|
+
* tests/remote-contract.spec.ts against the installed SDK). They stay
|
|
1904
|
+
* unreachable from a paired remote desktop, matching the SDK's own stance
|
|
1905
|
+
* that the configuration plane is loopback-same-origin only.
|
|
1906
|
+
*/
|
|
1907
|
+
const LOOPBACK_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1908
|
+
"agentPreset.read",
|
|
1909
|
+
"agentPreset.copy",
|
|
1910
|
+
"agentPreset.openDocument",
|
|
1911
|
+
"agentPreset.remove",
|
|
1912
|
+
"host.pickDirectory",
|
|
1913
|
+
"host.openPath",
|
|
1914
|
+
"settings.describe",
|
|
1915
|
+
"settings.openDocument",
|
|
1916
|
+
"settings.update",
|
|
1917
|
+
"settings.replace",
|
|
1918
|
+
"settings.mutate",
|
|
1919
|
+
"credentials.describe",
|
|
1920
|
+
"credentials.set",
|
|
1921
|
+
"credentials.unset",
|
|
1922
|
+
"llm.discoverModels"
|
|
1923
|
+
]);
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region src/remote-api.ts
|
|
1926
|
+
const ALLOWED_METHODS = /* @__PURE__ */ new Set([
|
|
1927
|
+
"GET",
|
|
1928
|
+
"HEAD",
|
|
1929
|
+
"POST",
|
|
1930
|
+
"PUT",
|
|
1931
|
+
"PATCH",
|
|
1932
|
+
"DELETE"
|
|
1933
|
+
]);
|
|
1934
|
+
/** Reject traversal and empty segments; allow plugin file-path characters. */
|
|
1935
|
+
function isSafeSegment(segment) {
|
|
1936
|
+
if (segment === "") return false;
|
|
1937
|
+
let decoded;
|
|
1938
|
+
try {
|
|
1939
|
+
decoded = decodeURIComponent(segment);
|
|
1940
|
+
} catch {
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
return decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\") && !decoded.includes("\0");
|
|
1944
|
+
}
|
|
1945
|
+
/** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
|
|
1946
|
+
function envelopeError(res, status, rpcId, code, message) {
|
|
1947
|
+
writeJson(res, status, {
|
|
1948
|
+
type: "server-response",
|
|
1949
|
+
rpcId,
|
|
1950
|
+
result: {
|
|
1951
|
+
ok: false,
|
|
1952
|
+
error: {
|
|
1953
|
+
code,
|
|
1954
|
+
message,
|
|
1955
|
+
details: { issues: [] }
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Map `/remote/...` to the inner path, or undefined when the outer path is
|
|
1962
|
+
* not a safe rewrite target.
|
|
1963
|
+
*/
|
|
1964
|
+
function innerPathOf(pathname) {
|
|
1965
|
+
if (pathname === "/remote" || pathname === `/remote/`) return void 0;
|
|
1966
|
+
if (!pathname.startsWith(`/remote/`)) return void 0;
|
|
1967
|
+
const rest = pathname.slice(7);
|
|
1968
|
+
if (!rest.startsWith("/")) return void 0;
|
|
1969
|
+
const segments = rest.slice(1).split("/");
|
|
1970
|
+
if (segments.length === 0 || segments.some((segment) => !isSafeSegment(segment))) return;
|
|
1971
|
+
return rest;
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Whether a paired inner path must stay physically local.
|
|
1975
|
+
* @returns a denial message, or undefined when the path may be proxied.
|
|
1976
|
+
*/
|
|
1977
|
+
function loopbackOnlyDenial(innerPath) {
|
|
1978
|
+
if (innerPath === "/api/pair" || innerPath.startsWith("/api/pair/")) return "pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop";
|
|
1979
|
+
if (innerPath === "/api/update" || innerPath.startsWith("/api/update/")) return "update endpoints stay loopback-only and stay unreachable from a paired remote desktop";
|
|
1980
|
+
if (innerPath === "/api/plugin-manager" || innerPath.startsWith(`/api/plugin-manager/`)) return "plugin-manager stays loopback-only and stays unreachable from a paired remote desktop";
|
|
1981
|
+
if (innerPath === "/api/dsh-desktop-launcher" || innerPath.startsWith(`/api/dsh-desktop-launcher/`)) return "desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop";
|
|
1982
|
+
if (innerPath === "/api/dsh-web-ui-settings" || innerPath.startsWith(`/api/dsh-web-ui-settings/`)) return "settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop";
|
|
1983
|
+
if (!innerPath.startsWith("/api/")) return void 0;
|
|
1984
|
+
const method = innerPath.slice(5);
|
|
1985
|
+
if (method !== "" && !method.includes("/") && LOOPBACK_ONLY_METHODS.has(method)) return `${method} is loopback-only and stays unreachable from a paired remote desktop`;
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Build the remote desktop channel HTTP routes.
|
|
1989
|
+
* @param deps - pairing service + local port.
|
|
1990
|
+
* @returns the routes to register on webServer.
|
|
1991
|
+
*/
|
|
1992
|
+
function makeRemoteApiRoutes(deps) {
|
|
1993
|
+
const { service, port } = deps;
|
|
1994
|
+
const handler = (req, res) => {
|
|
1995
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1996
|
+
if (!(deviceId !== void 0 && service.touchDevice(deviceId))) {
|
|
1997
|
+
req.resume();
|
|
1998
|
+
envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const method = req.method ?? "GET";
|
|
2002
|
+
if (!ALLOWED_METHODS.has(method)) {
|
|
2003
|
+
req.resume();
|
|
2004
|
+
res.writeHead(405).end();
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2008
|
+
const inner = innerPathOf(url.pathname);
|
|
2009
|
+
if (inner === void 0) {
|
|
2010
|
+
req.resume();
|
|
2011
|
+
res.writeHead(404).end();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
const denied = loopbackOnlyDenial(inner);
|
|
2015
|
+
if (denied !== void 0) {
|
|
2016
|
+
req.resume();
|
|
2017
|
+
envelopeError(res, 403, "invalid-request", "forbidden", denied);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`);
|
|
2021
|
+
};
|
|
2022
|
+
return [{
|
|
2023
|
+
kind: "prefix",
|
|
2024
|
+
path: REMOTE_PREFIX,
|
|
2025
|
+
handler
|
|
2026
|
+
}];
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Map one outer upgrade URL onto the loopback path (query string included).
|
|
2030
|
+
*/
|
|
2031
|
+
function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
2032
|
+
if (reqUrl === void 0 || reqUrl === "") return fallbackPath;
|
|
2033
|
+
let url;
|
|
2034
|
+
try {
|
|
2035
|
+
url = new URL(reqUrl, "http://127.0.0.1");
|
|
2036
|
+
} catch {
|
|
2037
|
+
return fallbackPath;
|
|
2038
|
+
}
|
|
2039
|
+
const inner = innerPathOf(url.pathname);
|
|
2040
|
+
if (inner === void 0) return fallbackPath;
|
|
2041
|
+
return `${inner}${url.search}`;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Build the WebSocket upgrade routes for the event streams and known plugin
|
|
2045
|
+
* sockets. webServer matches upgrades by exact path.
|
|
2046
|
+
* @param deps - pairing service + local port.
|
|
2047
|
+
* @returns the upgrade routes to register on webServer.
|
|
2048
|
+
*/
|
|
2049
|
+
function makeRemoteApiUpgradeRoutes(deps) {
|
|
2050
|
+
const { service, port } = deps;
|
|
2051
|
+
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
2052
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
2053
|
+
if (deviceId === void 0 || !service.touchDevice(deviceId)) {
|
|
2054
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2055
|
+
socket.destroy();
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
2059
|
+
if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
|
|
2060
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2061
|
+
socket.destroy();
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
proxyLoopbackUpgrade(req, socket, head, port, inner);
|
|
2065
|
+
};
|
|
2066
|
+
return REMOTE_UPGRADE_PATHS.map((path) => ({
|
|
2067
|
+
path,
|
|
2068
|
+
handler: handlerFor(path.slice(7))
|
|
2069
|
+
}));
|
|
2070
|
+
}
|
|
2071
|
+
//#endregion
|
|
2072
|
+
//#region src/posture.ts
|
|
2073
|
+
/**
|
|
2074
|
+
* Build the forged Host values to probe: the public base authority (host or
|
|
2075
|
+
* host:port as written in the URL) plus every LAN base literal.
|
|
2076
|
+
* @param publicBaseUrl - the configured public base URL (or undefined).
|
|
2077
|
+
* @param lanAddresses - the LAN interface addresses the QR advertises.
|
|
2078
|
+
* @param port - the local webServer port (LAN hosts are probed as host:port).
|
|
2079
|
+
* @returns Host header values, de-duplicated.
|
|
2080
|
+
*/
|
|
2081
|
+
function postureTargets(publicBaseUrl, lanAddresses, port) {
|
|
2082
|
+
const targets = [];
|
|
2083
|
+
if (publicBaseUrl !== void 0) try {
|
|
2084
|
+
const url = new URL(publicBaseUrl);
|
|
2085
|
+
const authority = url.port === "" ? url.hostname : `${url.hostname}:${url.port}`;
|
|
2086
|
+
if (authority !== "") targets.push(authority);
|
|
2087
|
+
} catch {}
|
|
2088
|
+
for (const address of lanAddresses) targets.push(`${address}:${String(port)}`);
|
|
2089
|
+
return [...new Set(targets)];
|
|
2090
|
+
}
|
|
2091
|
+
const defaultRequest = (options, onStatus) => {
|
|
2092
|
+
const request = http.request(options, (response) => {
|
|
2093
|
+
onStatus(response.statusCode ?? 0);
|
|
2094
|
+
});
|
|
2095
|
+
request.on("error", () => {
|
|
2096
|
+
onStatus(0);
|
|
2097
|
+
});
|
|
2098
|
+
return request;
|
|
2099
|
+
};
|
|
2100
|
+
/**
|
|
2101
|
+
* Probe one forged Host against the local `/api` fence.
|
|
2102
|
+
* @param port - the local webServer port.
|
|
2103
|
+
* @param hostHeader - the Host header to forge.
|
|
2104
|
+
* @param request - transport seam.
|
|
2105
|
+
* @param timeoutMs - give up after this long (counts as not exposed; the
|
|
2106
|
+
* fence being unreachable is not evidence it is open).
|
|
2107
|
+
* @returns true when the probe got past the fence.
|
|
2108
|
+
*/
|
|
2109
|
+
async function probeHost(port, hostHeader, request, timeoutMs) {
|
|
2110
|
+
return await new Promise((resolve) => {
|
|
2111
|
+
let settled = false;
|
|
2112
|
+
const finish = (exposed) => {
|
|
2113
|
+
if (settled) return;
|
|
2114
|
+
settled = true;
|
|
2115
|
+
clearTimeout(timer);
|
|
2116
|
+
handle.destroy();
|
|
2117
|
+
resolve(exposed);
|
|
2118
|
+
};
|
|
2119
|
+
const handle = request({
|
|
2120
|
+
host: "127.0.0.1",
|
|
2121
|
+
port,
|
|
2122
|
+
method: "POST",
|
|
2123
|
+
path: "/api/session.list",
|
|
2124
|
+
headers: {
|
|
2125
|
+
host: hostHeader,
|
|
2126
|
+
"content-type": "application/json"
|
|
2127
|
+
},
|
|
2128
|
+
timeout: timeoutMs
|
|
2129
|
+
}, (status) => {
|
|
2130
|
+
finish(status !== 403);
|
|
2131
|
+
});
|
|
2132
|
+
const timer = setTimeout(() => {
|
|
2133
|
+
finish(false);
|
|
2134
|
+
}, timeoutMs + 1e3);
|
|
2135
|
+
handle.on("error", () => {
|
|
2136
|
+
finish(false);
|
|
2137
|
+
});
|
|
2138
|
+
handle.end("{}");
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Run one posture probe round.
|
|
2143
|
+
* @returns the snapshot for this round.
|
|
2144
|
+
*/
|
|
2145
|
+
async function probePosture(options) {
|
|
2146
|
+
const { port, targets } = options;
|
|
2147
|
+
const request = options.request ?? defaultRequest;
|
|
2148
|
+
const timeoutMs = options.timeoutMs ?? 3e3;
|
|
2149
|
+
const now = options.now ?? (() => Date.now());
|
|
2150
|
+
const hosts = [];
|
|
2151
|
+
for (const target of targets) hosts.push({
|
|
2152
|
+
host: target,
|
|
2153
|
+
exposed: await probeHost(port, target, request, timeoutMs)
|
|
2154
|
+
});
|
|
2155
|
+
return {
|
|
2156
|
+
checkedAt: now(),
|
|
2157
|
+
hosts
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Reserve an advertised-target key so a second trigger with the same set
|
|
2162
|
+
* does not overlap an in-flight round. Pair with {@link releasePostureKey}
|
|
2163
|
+
* on failure — otherwise that key never retries.
|
|
2164
|
+
*/
|
|
2165
|
+
function claimPostureKey(current, key) {
|
|
2166
|
+
if (current === key) return {
|
|
2167
|
+
run: false,
|
|
2168
|
+
next: current
|
|
2169
|
+
};
|
|
2170
|
+
return {
|
|
2171
|
+
run: true,
|
|
2172
|
+
next: key
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Drop a failed in-flight key so the next trigger re-probes the same targets.
|
|
2177
|
+
* A newer key that started meanwhile is left alone.
|
|
2178
|
+
*/
|
|
2179
|
+
function releasePostureKey(current, attempted) {
|
|
2180
|
+
return current === attempted ? void 0 : current;
|
|
2181
|
+
}
|
|
2182
|
+
//#endregion
|
|
1602
2183
|
//#region src/lan.ts
|
|
1603
2184
|
/**
|
|
1604
2185
|
* LAN address derivation for the pairing URLs. Mirrors the dsh CLI's
|
|
@@ -2430,6 +3011,7 @@ const Config = z.object({
|
|
|
2430
3011
|
tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
|
|
2431
3012
|
offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
|
|
2432
3013
|
maxDevices: z.number().step(1).min(1).max(64).default(4),
|
|
3014
|
+
idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS),
|
|
2433
3015
|
cookieName: z.string().min(1).default("dsh_pair"),
|
|
2434
3016
|
requirePairingForLan: z.boolean().default(true),
|
|
2435
3017
|
publicBaseUrl: z.string(),
|
|
@@ -2451,19 +3033,25 @@ function pairingConfigOf(resolved) {
|
|
|
2451
3033
|
tokenTtlMs: resolved.tokenTtlMs,
|
|
2452
3034
|
offlineAfterMs: resolved.offlineAfterMs,
|
|
2453
3035
|
maxDevices: resolved.maxDevices,
|
|
3036
|
+
idleExpireMs: resolved.idleExpireMs,
|
|
2454
3037
|
cookieName: resolved.cookieName,
|
|
2455
3038
|
devicesFile: resolved.devicesFile
|
|
2456
3039
|
};
|
|
2457
3040
|
}
|
|
3041
|
+
/** Default paired-session store: `$DSH_HOME/remote-web-ui-devices.json`. */
|
|
3042
|
+
function defaultDevicesFile(home = dshHome()) {
|
|
3043
|
+
return join(home, "remote-web-ui-devices.json");
|
|
3044
|
+
}
|
|
2458
3045
|
/** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
|
|
2459
3046
|
const DEFAULTS = {
|
|
2460
3047
|
tokenTtlMs: 10 * 6e4,
|
|
2461
3048
|
offlineAfterMs: 25e3,
|
|
2462
3049
|
maxDevices: 4,
|
|
3050
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
2463
3051
|
cookieName: "dsh_pair",
|
|
2464
3052
|
requirePairingForLan: true,
|
|
2465
3053
|
publicBaseUrl: void 0,
|
|
2466
|
-
devicesFile:
|
|
3054
|
+
devicesFile: defaultDevicesFile(),
|
|
2467
3055
|
autoTunnel: false,
|
|
2468
3056
|
mobileEnterToSend: true,
|
|
2469
3057
|
enabled: true
|
|
@@ -2479,10 +3067,11 @@ function applyImpl(ctx, config) {
|
|
|
2479
3067
|
tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
2480
3068
|
offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
2481
3069
|
maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
|
|
3070
|
+
idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
2482
3071
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
2483
3072
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
2484
3073
|
publicBaseUrl: config?.publicBaseUrl,
|
|
2485
|
-
devicesFile: config?.devicesFile,
|
|
3074
|
+
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
2486
3075
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
2487
3076
|
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
2488
3077
|
enabled: config?.enabled ?? DEFAULTS.enabled
|
|
@@ -2494,6 +3083,7 @@ function applyImpl(ctx, config) {
|
|
|
2494
3083
|
tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
2495
3084
|
offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
2496
3085
|
maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
|
|
3086
|
+
idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
2497
3087
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
2498
3088
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
2499
3089
|
publicBaseUrl: value.publicBaseUrl,
|
|
@@ -2514,6 +3104,7 @@ function applyImpl(ctx, config) {
|
|
|
2514
3104
|
state: "running",
|
|
2515
3105
|
url: info.url
|
|
2516
3106
|
});
|
|
3107
|
+
runPostureProbe();
|
|
2517
3108
|
} else if (info.phase === "starting") {
|
|
2518
3109
|
service.setPublicBaseUrl(void 0);
|
|
2519
3110
|
service.setTunnelStatus({ state: "starting" });
|
|
@@ -2601,10 +3192,53 @@ function applyImpl(ctx, config) {
|
|
|
2601
3192
|
apiProxy,
|
|
2602
3193
|
mobileEnterToSend: () => resolve().mobileEnterToSend
|
|
2603
3194
|
}) : [],
|
|
3195
|
+
...makeRemoteApiRoutes({
|
|
3196
|
+
service,
|
|
3197
|
+
port: ctx.webServer.port
|
|
3198
|
+
}),
|
|
2604
3199
|
...updateRoutes
|
|
2605
3200
|
];
|
|
3201
|
+
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
3202
|
+
service,
|
|
3203
|
+
port: ctx.webServer.port
|
|
3204
|
+
});
|
|
2606
3205
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
2607
3206
|
ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
|
|
3207
|
+
let postureKey;
|
|
3208
|
+
let postureWasExposed = false;
|
|
3209
|
+
const runPostureProbe = () => {
|
|
3210
|
+
if (!resolve().enabled) return;
|
|
3211
|
+
const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port);
|
|
3212
|
+
if (targets.length === 0) {
|
|
3213
|
+
postureKey = void 0;
|
|
3214
|
+
service.setPosture(void 0);
|
|
3215
|
+
return;
|
|
3216
|
+
}
|
|
3217
|
+
const key = targets.join("|");
|
|
3218
|
+
const claim = claimPostureKey(postureKey, key);
|
|
3219
|
+
if (!claim.run) return;
|
|
3220
|
+
postureKey = claim.next;
|
|
3221
|
+
probePosture({
|
|
3222
|
+
port: ctx.webServer.port,
|
|
3223
|
+
targets
|
|
3224
|
+
}).then((snapshot) => {
|
|
3225
|
+
service.setPosture(snapshot);
|
|
3226
|
+
const exposedHosts = snapshot.hosts.filter((host) => host.exposed).map((host) => host.host);
|
|
3227
|
+
const exposed = exposedHosts.length > 0;
|
|
3228
|
+
if (exposed && !postureWasExposed) console.error(`remote-web-ui: CRITICAL — the /api fence is OPEN for [${exposedHosts.join(", ")}]: unpaired clients reach the full host API. Remove --trusted-host for these hosts (pairing covers them) or bind loopback.`);
|
|
3229
|
+
else if (!exposed && postureWasExposed) console.log("remote-web-ui: the /api posture probe is clean again (every advertised origin refused with 403).");
|
|
3230
|
+
postureWasExposed = exposed;
|
|
3231
|
+
}).catch(() => {
|
|
3232
|
+
postureKey = releasePostureKey(postureKey, key);
|
|
3233
|
+
});
|
|
3234
|
+
};
|
|
3235
|
+
const initialPostureTimer = setTimeout$1(() => {
|
|
3236
|
+
runPostureProbe();
|
|
3237
|
+
}, 5e3);
|
|
3238
|
+
initialPostureTimer.unref();
|
|
3239
|
+
ctx.effect(() => () => {
|
|
3240
|
+
clearTimeout(initialPostureTimer);
|
|
3241
|
+
}, "remote-web-ui: posture probe boot");
|
|
2608
3242
|
new RemoteWebUiPairing(ctx, (request) => {
|
|
2609
3243
|
if (!resolve().enabled) return false;
|
|
2610
3244
|
return isPairedDeviceRequest(service, request);
|
|
@@ -2626,7 +3260,7 @@ function applyImpl(ctx, config) {
|
|
|
2626
3260
|
const enabled = value.enabled;
|
|
2627
3261
|
if (!enabled) service.stop();
|
|
2628
3262
|
if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
|
|
2629
|
-
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
3263
|
+
const disposers = [...routes.map((route) => ctx.webServer.register(route)), ...upgrades.map((route) => ctx.webServer.registerUpgrade(route))];
|
|
2630
3264
|
return () => {
|
|
2631
3265
|
for (const dispose of disposers) dispose();
|
|
2632
3266
|
};
|
|
@@ -2648,6 +3282,7 @@ function applyImpl(ctx, config) {
|
|
|
2648
3282
|
disposeSweep();
|
|
2649
3283
|
disposeSweep = void 0;
|
|
2650
3284
|
}
|
|
3285
|
+
runPostureProbe();
|
|
2651
3286
|
};
|
|
2652
3287
|
installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
2653
3288
|
setSource: (source) => {
|
|
@@ -2668,4 +3303,4 @@ function isHttpUrl(value) {
|
|
|
2668
3303
|
}
|
|
2669
3304
|
}
|
|
2670
3305
|
//#endregion
|
|
2671
|
-
export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name, pairingConfigOf };
|
|
3306
|
+
export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, defaultDevicesFile, inject, name, pairingConfigOf };
|