@linxin666/dsh-remote-web-ui 0.4.5 → 0.4.7
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 +13 -13
- package/README.zh.md +13 -13
- package/lib/client.js +25 -92
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -53
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +3 -15
- package/lib/types/client/RemotePanel.d.ts +1 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -4
- package/lib/types/client/locales.d.ts +4 -8
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +30 -3
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +2 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -1
- package/lib/types/client/settings-entry-form.js +0 -1
- package/lib/types/detached-work.d.ts +7 -7
- package/lib/types/detached-work.d.ts.map +1 -1
- package/lib/types/detached-work.js +9 -8
- package/lib/types/index.d.ts +14 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +67 -17
- package/lib/types/lan-source.d.ts +65 -0
- package/lib/types/lan-source.d.ts.map +1 -0
- package/lib/types/lan-source.js +113 -0
- package/lib/types/lan.d.ts +49 -3
- package/lib/types/lan.d.ts.map +1 -1
- package/lib/types/lan.js +120 -6
- package/lib/types/remote-api.d.ts +7 -2
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +17 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +4 -1
- package/lib/types/routes.d.ts +71 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +157 -27
- package/lib/types/tunnel-plan.d.ts +8 -2
- package/lib/types/tunnel-plan.d.ts.map +1 -1
- package/lib/types/tunnel-plan.js +9 -1
- package/package.json +20 -25
- package/src/client/RemoteEntry.tsx +2 -18
- package/src/client/RemotePanel.tsx +9 -43
- package/src/client/locales.ts +4 -8
- package/src/client/mobile-adapt.ts +30 -3
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/remote.module.css +2 -48
- package/src/client/settings-entry-form.ts +0 -1
- package/src/detached-work.ts +9 -8
- package/src/index.ts +77 -17
- package/src/lan-source.ts +132 -0
- package/src/lan.ts +126 -6
- package/src/remote-api.ts +24 -4
- package/src/remote-channel-boot.ts +4 -1
- package/src/routes.ts +178 -27
- package/src/tunnel-plan.ts +9 -2
- package/lib/invariant.js +0 -21
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.d.ts.map +0 -1
- package/lib/types/invariant.js +0 -23
- package/src/invariant.ts +0 -32
package/lib/types/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { DEFAULT_IDLE_EXPIRE_MS, PairingService } from "./pairing.js";
|
|
|
14
14
|
import { dshHome } from "./dsh-home.js";
|
|
15
15
|
import { isPairedDeviceRequest, makeGateListener } from "./gate.js";
|
|
16
16
|
import { RemoteWebUiPairing } from "./pairing-access.js";
|
|
17
|
-
import { makeRoutes } from "./routes.js";
|
|
17
|
+
import { makeRoutes, PAIRING_SOURCE_OPTIONS } from "./routes.js";
|
|
18
18
|
import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from "./remote-api.js";
|
|
19
19
|
import { startRemotePresencePet } from "./remote-presence-pet.js";
|
|
20
20
|
import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from "./posture.js";
|
|
@@ -81,9 +81,15 @@ export const Config = z.object({
|
|
|
81
81
|
lanBind: z.boolean().volatile(),
|
|
82
82
|
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
83
83
|
enabled: z.boolean().default(true).volatile(),
|
|
84
|
+
pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default('auto').volatile(),
|
|
84
85
|
});
|
|
85
86
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
86
87
|
const SWEEP_INTERVAL_MS = 10_000;
|
|
88
|
+
/**
|
|
89
|
+
* Re-plan retry while the quick tunnel waits for the webServer port to bind
|
|
90
|
+
* (a recompose pass can run this apply ahead of the listener's bind).
|
|
91
|
+
*/
|
|
92
|
+
const PORT_SETTLE_RETRY_MS = 1_000;
|
|
87
93
|
/** Read one resolved config field, following the live reference the schema produces. */
|
|
88
94
|
function readConfigField(field, fallback) {
|
|
89
95
|
if (field === undefined)
|
|
@@ -143,6 +149,7 @@ const DEFAULTS = {
|
|
|
143
149
|
lanBind: undefined,
|
|
144
150
|
profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
|
|
145
151
|
enabled: true,
|
|
152
|
+
pairingSource: 'auto',
|
|
146
153
|
};
|
|
147
154
|
/**
|
|
148
155
|
* The launched profile the Host publishes on its `profileContext` service
|
|
@@ -186,6 +193,7 @@ function applyImpl(ctx, config) {
|
|
|
186
193
|
lanBind: readOptionalConfigField(config?.lanBind),
|
|
187
194
|
profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
|
|
188
195
|
enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
|
|
196
|
+
pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource),
|
|
189
197
|
});
|
|
190
198
|
const service = new PairingService(pairingConfigOf(resolve()));
|
|
191
199
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
@@ -261,6 +269,14 @@ function applyImpl(ctx, config) {
|
|
|
261
269
|
// 'off' until a sync pass turns a mode on; the phase listener only feeds
|
|
262
270
|
// the public base while a plugin-managed tunnel (quick or named) runs.
|
|
263
271
|
let tunnelMode = resolve().autoTunnel ? 'quick' : 'off';
|
|
272
|
+
/**
|
|
273
|
+
* Retry timer while the quick tunnel waits for the webServer port to bind
|
|
274
|
+
* (undefined when no deferral is pending). Unref'd: never keeps the
|
|
275
|
+
* process alive on its own.
|
|
276
|
+
*/
|
|
277
|
+
let portSettleTimer;
|
|
278
|
+
/** Dedupes the unbound-port warning to one line per unsettled episode. */
|
|
279
|
+
let portSettleWarned = false;
|
|
264
280
|
tunnel.onPhase((info) => {
|
|
265
281
|
if (tunnelMode === 'off')
|
|
266
282
|
return;
|
|
@@ -291,6 +307,8 @@ function applyImpl(ctx, config) {
|
|
|
291
307
|
disposeRelayRegistrar();
|
|
292
308
|
publicBase.dispose();
|
|
293
309
|
tunnel.dispose();
|
|
310
|
+
if (portSettleTimer !== undefined)
|
|
311
|
+
clearTimeout(portSettleTimer);
|
|
294
312
|
}, 'remote-web-ui: auto tunnel');
|
|
295
313
|
// The bind facts are known by now (webServer is an inject edge): the LAN
|
|
296
314
|
// bases are frozen per process, matching the CLI's once-per-invocation
|
|
@@ -413,6 +431,7 @@ function applyImpl(ctx, config) {
|
|
|
413
431
|
...makeRoutes({
|
|
414
432
|
service,
|
|
415
433
|
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
434
|
+
pairingSource: () => resolve().pairingSource,
|
|
416
435
|
lanBindStatus,
|
|
417
436
|
indexDocument: fetchAppShell,
|
|
418
437
|
trustedHosts: () => {
|
|
@@ -446,13 +465,13 @@ function applyImpl(ctx, config) {
|
|
|
446
465
|
// credential to everything it forwards (issue #1665).
|
|
447
466
|
...makeRemoteApiRoutes({
|
|
448
467
|
service,
|
|
449
|
-
port: ctx.webServer.port,
|
|
468
|
+
port: () => ctx.webServer.port,
|
|
450
469
|
auth: innerAuth,
|
|
451
470
|
}),
|
|
452
471
|
];
|
|
453
472
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
454
473
|
service,
|
|
455
|
-
port: ctx.webServer.port,
|
|
474
|
+
port: () => ctx.webServer.port,
|
|
456
475
|
auth: innerAuth,
|
|
457
476
|
});
|
|
458
477
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
@@ -468,7 +487,13 @@ function applyImpl(ctx, config) {
|
|
|
468
487
|
const runPostureProbe = () => {
|
|
469
488
|
if (!resolve().enabled)
|
|
470
489
|
return;
|
|
471
|
-
|
|
490
|
+
// The probe and the LAN targets need the bound port; before it settles
|
|
491
|
+
// (a recompose pass) there is nothing honest to probe — keep the previous
|
|
492
|
+
// snapshot and let the next trigger retry.
|
|
493
|
+
const port = ctx.webServer.port;
|
|
494
|
+
if (port === undefined)
|
|
495
|
+
return;
|
|
496
|
+
const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port);
|
|
472
497
|
if (targets.length === 0) {
|
|
473
498
|
postureKey = undefined;
|
|
474
499
|
service.setPosture(undefined);
|
|
@@ -479,7 +504,7 @@ function applyImpl(ctx, config) {
|
|
|
479
504
|
if (!claim.run)
|
|
480
505
|
return;
|
|
481
506
|
postureKey = claim.next;
|
|
482
|
-
void probePosture({ port
|
|
507
|
+
void probePosture({ port, targets }).then((snapshot) => {
|
|
483
508
|
service.setPosture(snapshot);
|
|
484
509
|
const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host);
|
|
485
510
|
const exposed = exposedHosts.length > 0;
|
|
@@ -637,18 +662,13 @@ function applyImpl(ctx, config) {
|
|
|
637
662
|
}));
|
|
638
663
|
};
|
|
639
664
|
ctx.effect(() => () => { lanBindWorkPending = false; }, 'remote-web-ui: lan-bind work');
|
|
640
|
-
// Apply
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
// The plugin-managed tunnels own the public base while one runs: the URL
|
|
648
|
-
// lands in the service through the tunnel's phase listener (the minted
|
|
649
|
-
// quick URL, or the named tunnel's fixed public hostname). The manual
|
|
650
|
-
// publicBaseUrl applies only when no tunnel runs.
|
|
651
|
-
const plan = tunnelPlanOf(value, ctx.webServer.port);
|
|
665
|
+
// Apply one tunnel plan to the running surfaces. The plugin-managed tunnels
|
|
666
|
+
// own the public base while one runs: the URL lands in the service through
|
|
667
|
+
// the tunnel's phase listener (the minted quick URL, or the named tunnel's
|
|
668
|
+
// fixed public hostname). The manual publicBaseUrl applies only when no
|
|
669
|
+
// tunnel runs.
|
|
670
|
+
const applyTunnelPlan = (value, port) => {
|
|
671
|
+
const plan = tunnelPlanOf(value, port);
|
|
652
672
|
tunnelMode = plan.mode;
|
|
653
673
|
publicBase.setMode(plan.mode);
|
|
654
674
|
const liveTunnelUrl = publicBase.quickUrl();
|
|
@@ -714,6 +734,36 @@ function applyImpl(ctx, config) {
|
|
|
714
734
|
service.setPublicBaseUrl(value.publicBaseUrl);
|
|
715
735
|
}
|
|
716
736
|
}
|
|
737
|
+
};
|
|
738
|
+
// Apply the effective configuration to the running surfaces once per
|
|
739
|
+
// activation: the service tunables, the LAN-bind block/firewall, the tunnel
|
|
740
|
+
// plan, the pairing routes, the presence sweep, and the posture probe.
|
|
741
|
+
const sync = () => {
|
|
742
|
+
const value = resolve();
|
|
743
|
+
service.config = pairingConfigOf(value);
|
|
744
|
+
scheduleLanBindWork();
|
|
745
|
+
// The quick tunnel's loopback target URL needs the bound port. A recompose
|
|
746
|
+
// pass can run this apply before the listener binds: defer the tunnel
|
|
747
|
+
// branch (and re-plan on a short timer) instead of minting an
|
|
748
|
+
// http://127.0.0.1:undefined target; everything else in sync applies.
|
|
749
|
+
const livePort = ctx.webServer.port;
|
|
750
|
+
if (livePort === undefined && value.autoTunnel === true) {
|
|
751
|
+
if (!portSettleWarned) {
|
|
752
|
+
portSettleWarned = true;
|
|
753
|
+
console.warn('remote-web-ui: autoTunnel is on but the web server port is not bound yet — the quick tunnel starts once the port settles');
|
|
754
|
+
}
|
|
755
|
+
if (portSettleTimer === undefined) {
|
|
756
|
+
portSettleTimer = nodeSetTimeout(() => {
|
|
757
|
+
portSettleTimer = undefined;
|
|
758
|
+
sync();
|
|
759
|
+
}, PORT_SETTLE_RETRY_MS);
|
|
760
|
+
portSettleTimer.unref();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
portSettleWarned = false;
|
|
765
|
+
applyTunnelPlan(value, livePort);
|
|
766
|
+
}
|
|
717
767
|
const enabled = value.enabled;
|
|
718
768
|
if (!enabled) {
|
|
719
769
|
service.stop();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The landing page's network-source decision: which origin a paired phone
|
|
3
|
+
* should keep using.
|
|
4
|
+
*
|
|
5
|
+
* The QR link can only name one base, and the server cannot know whether the
|
|
6
|
+
* scanning phone sits on this machine's own network: the public (relayed)
|
|
7
|
+
* base is the only one that is always reachable, so the QR names it and the
|
|
8
|
+
* phone decides at connect time. The device-gated landing (/pair-app) is the
|
|
9
|
+
* plugin's own page already rendered ahead of the official shell's boot, so it
|
|
10
|
+
* is the natural place to probe: after the page is served, a short
|
|
11
|
+
* cross-origin `fetch` per LAN candidate (a no-cors GET at
|
|
12
|
+
* {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
|
|
13
|
+
* and the first one that does becomes the new origin. A probe that finds
|
|
14
|
+
* nothing, or that runs out of budget, changes nothing — the page the phone
|
|
15
|
+
* already holds stays paired and usable, so the probe can never block pairing.
|
|
16
|
+
*
|
|
17
|
+
* Browsers do not block this probe. An https page may not normally fetch
|
|
18
|
+
* `http://` subresources, but a host that is an IP literal is exempt from both
|
|
19
|
+
* the block and the automatic upgrade (verified against Chrome: the console
|
|
20
|
+
* reports "This request was not upgraded to HTTPS because its URL's host is an
|
|
21
|
+
* IP address" and delivers the response), which is exactly the shape every LAN
|
|
22
|
+
* candidate has.
|
|
23
|
+
*
|
|
24
|
+
* @module @linxin666/dsh-remote-web-ui/lan-source
|
|
25
|
+
*/
|
|
26
|
+
/** sessionStorage key holding this session's source decision. */
|
|
27
|
+
export declare const LAN_SOURCE_STORAGE_KEY = "dsh-remote-lan-source";
|
|
28
|
+
/**
|
|
29
|
+
* Probe budget for the whole candidate set. Long enough for a LAN round trip,
|
|
30
|
+
* short enough that a phone on another network never waits on it — the probe
|
|
31
|
+
* is fire-and-forget and the page it runs on is already usable.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LAN_SOURCE_PROBE_TIMEOUT_MS = 800;
|
|
34
|
+
/**
|
|
35
|
+
* How recently a LAN hop must have been taken for a return to a non-LAN origin
|
|
36
|
+
* to read as "the hop did not stick". A phone that fails to load the LAN page
|
|
37
|
+
* re-scans the QR (which always names the public base) within seconds, and the
|
|
38
|
+
* landing then has to choose the public source instead of hopping again into
|
|
39
|
+
* the same black hole; a deliberate return much later is a fresh decision and
|
|
40
|
+
* re-probes.
|
|
41
|
+
*/
|
|
42
|
+
export declare const LAN_SOURCE_RETRY_WINDOW_MS = 120000;
|
|
43
|
+
/** One probe payload: everything the injected script needs, JSON-serializable. */
|
|
44
|
+
export interface LanSourceHop {
|
|
45
|
+
/**
|
|
46
|
+
* The LAN origins the probe may hop to, normalized (`https://host:port` with
|
|
47
|
+
* a default port dropped) so they compare equal to `location.origin`.
|
|
48
|
+
*/
|
|
49
|
+
candidates: readonly string[];
|
|
50
|
+
/** The path the hop targets on the winning origin. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** The path the probe itself requests, on each candidate. */
|
|
53
|
+
pingPath: string;
|
|
54
|
+
/** The one-time grant the hop consumes on the winning origin. */
|
|
55
|
+
grant: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the inline script that runs the source decision on the landing page.
|
|
59
|
+
* Reads every browser global through `window`, so a test can run it against
|
|
60
|
+
* one fake object (the same seam the parse-time boot patch uses).
|
|
61
|
+
* @param hop - the probe payload for this landing render.
|
|
62
|
+
* @returns the `<script>` element to splice into the landing's head.
|
|
63
|
+
*/
|
|
64
|
+
export declare function appShellLanSwitchScript(hop: LanSourceHop): string;
|
|
65
|
+
//# sourceMappingURL=lan-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lan-source.d.ts","sourceRoot":"","sources":["../../src/lan-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,iEAAiE;AACjE,eAAO,MAAM,sBAAsB,0BAA0B,CAAA;AAE7D;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAA;AAE9C;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,SAAU,CAAA;AAEjD,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAA;IACZ,6DAA6D;IAC7D,QAAQ,EAAE,MAAM,CAAA;IAChB,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CA+DjE"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The landing page's network-source decision: which origin a paired phone
|
|
3
|
+
* should keep using.
|
|
4
|
+
*
|
|
5
|
+
* The QR link can only name one base, and the server cannot know whether the
|
|
6
|
+
* scanning phone sits on this machine's own network: the public (relayed)
|
|
7
|
+
* base is the only one that is always reachable, so the QR names it and the
|
|
8
|
+
* phone decides at connect time. The device-gated landing (/pair-app) is the
|
|
9
|
+
* plugin's own page already rendered ahead of the official shell's boot, so it
|
|
10
|
+
* is the natural place to probe: after the page is served, a short
|
|
11
|
+
* cross-origin `fetch` per LAN candidate (a no-cors GET at
|
|
12
|
+
* {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
|
|
13
|
+
* and the first one that does becomes the new origin. A probe that finds
|
|
14
|
+
* nothing, or that runs out of budget, changes nothing — the page the phone
|
|
15
|
+
* already holds stays paired and usable, so the probe can never block pairing.
|
|
16
|
+
*
|
|
17
|
+
* Browsers do not block this probe. An https page may not normally fetch
|
|
18
|
+
* `http://` subresources, but a host that is an IP literal is exempt from both
|
|
19
|
+
* the block and the automatic upgrade (verified against Chrome: the console
|
|
20
|
+
* reports "This request was not upgraded to HTTPS because its URL's host is an
|
|
21
|
+
* IP address" and delivers the response), which is exactly the shape every LAN
|
|
22
|
+
* candidate has.
|
|
23
|
+
*
|
|
24
|
+
* @module @linxin666/dsh-remote-web-ui/lan-source
|
|
25
|
+
*/
|
|
26
|
+
/** sessionStorage key holding this session's source decision. */
|
|
27
|
+
export const LAN_SOURCE_STORAGE_KEY = 'dsh-remote-lan-source';
|
|
28
|
+
/**
|
|
29
|
+
* Probe budget for the whole candidate set. Long enough for a LAN round trip,
|
|
30
|
+
* short enough that a phone on another network never waits on it — the probe
|
|
31
|
+
* is fire-and-forget and the page it runs on is already usable.
|
|
32
|
+
*/
|
|
33
|
+
export const LAN_SOURCE_PROBE_TIMEOUT_MS = 800;
|
|
34
|
+
/**
|
|
35
|
+
* How recently a LAN hop must have been taken for a return to a non-LAN origin
|
|
36
|
+
* to read as "the hop did not stick". A phone that fails to load the LAN page
|
|
37
|
+
* re-scans the QR (which always names the public base) within seconds, and the
|
|
38
|
+
* landing then has to choose the public source instead of hopping again into
|
|
39
|
+
* the same black hole; a deliberate return much later is a fresh decision and
|
|
40
|
+
* re-probes.
|
|
41
|
+
*/
|
|
42
|
+
export const LAN_SOURCE_RETRY_WINDOW_MS = 120_000;
|
|
43
|
+
/**
|
|
44
|
+
* Build the inline script that runs the source decision on the landing page.
|
|
45
|
+
* Reads every browser global through `window`, so a test can run it against
|
|
46
|
+
* one fake object (the same seam the parse-time boot patch uses).
|
|
47
|
+
* @param hop - the probe payload for this landing render.
|
|
48
|
+
* @returns the `<script>` element to splice into the landing's head.
|
|
49
|
+
*/
|
|
50
|
+
export function appShellLanSwitchScript(hop) {
|
|
51
|
+
const payload = JSON.stringify({
|
|
52
|
+
key: LAN_SOURCE_STORAGE_KEY,
|
|
53
|
+
candidates: hop.candidates,
|
|
54
|
+
path: hop.path,
|
|
55
|
+
ping: hop.pingPath,
|
|
56
|
+
grant: hop.grant,
|
|
57
|
+
timeout: LAN_SOURCE_PROBE_TIMEOUT_MS,
|
|
58
|
+
retry: LAN_SOURCE_RETRY_WINDOW_MS,
|
|
59
|
+
});
|
|
60
|
+
const body = '(function(){try{' +
|
|
61
|
+
'var w=window,loc=w.location;' +
|
|
62
|
+
'var P=' + payload + ';' +
|
|
63
|
+
'var S=w.sessionStorage;' +
|
|
64
|
+
// The candidate set is the decision's identity: a host whose interfaces
|
|
65
|
+
// changed hands the landing a different signature, which forces a re-probe.
|
|
66
|
+
'P.sig=P.candidates.join(",");' +
|
|
67
|
+
// The stored decision: which source this session settled on, the candidate
|
|
68
|
+
// set it was made against (so a host whose interfaces changed re-decides),
|
|
69
|
+
// and when. Anything unreadable is treated as "no decision".
|
|
70
|
+
'function rd(){try{' +
|
|
71
|
+
'var raw=S.getItem(P.key);' +
|
|
72
|
+
'if(typeof raw!=="string"||raw==="")return undefined;' +
|
|
73
|
+
'var d=JSON.parse(raw);' +
|
|
74
|
+
'if(typeof d!=="object"||d===null)return undefined;' +
|
|
75
|
+
'if(d.src!=="lan"&&d.src!=="public")return undefined;' +
|
|
76
|
+
'if(typeof d.sig!=="string"||typeof d.at!=="number")return undefined;' +
|
|
77
|
+
'return d' +
|
|
78
|
+
'}catch(e){return undefined}}' +
|
|
79
|
+
'function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}' +
|
|
80
|
+
// Already on a LAN candidate: nothing to decide, and a hop back to it
|
|
81
|
+
// would be the same origin. Recording the fact keeps a reload from probing.
|
|
82
|
+
'function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}' +
|
|
83
|
+
'function go(base){try{loc.replace(base+P.path+"?grant="+encodeURIComponent(P.grant))}catch(e){}}' +
|
|
84
|
+
'function probe(){' +
|
|
85
|
+
'if(typeof w.fetch!=="function")return;' +
|
|
86
|
+
'var settled=false;' +
|
|
87
|
+
'var timer=w.setTimeout(function(){finish(undefined)},P.timeout);' +
|
|
88
|
+
'function finish(base){' +
|
|
89
|
+
'if(settled)return;' +
|
|
90
|
+
'settled=true;' +
|
|
91
|
+
'try{w.clearTimeout(timer)}catch(e){}' +
|
|
92
|
+
// The first answer wins: one reachable candidate is all the hop needs, and
|
|
93
|
+
// the losers keep running their own no-cors GETs harmlessly.
|
|
94
|
+
'if(base===undefined){wr({src:"public",sig:P.sig,at:Date.now()});return}' +
|
|
95
|
+
'wr({src:"lan",sig:P.sig,base:base,at:Date.now()});' +
|
|
96
|
+
'go(base)}' +
|
|
97
|
+
'for(var i=0;i<P.candidates.length;i++){(function(base){' +
|
|
98
|
+
'var sent;' +
|
|
99
|
+
'try{sent=w.fetch(base+P.ping,{mode:"no-cors",cache:"no-store",credentials:"omit",referrerPolicy:"no-referrer"})}catch(e){sent=undefined}' +
|
|
100
|
+
'if(sent===undefined||typeof sent.then!=="function")return;' +
|
|
101
|
+
'sent.then(function(){finish(base)},function(){})' +
|
|
102
|
+
'})(P.candidates[i])}}' +
|
|
103
|
+
'var d=rd();' +
|
|
104
|
+
'if(own()){if(d===undefined||d.sig!==P.sig)wr({src:"lan",sig:P.sig,base:loc.origin,at:Date.now()});return}' +
|
|
105
|
+
'if(d!==undefined&&d.sig===P.sig){' +
|
|
106
|
+
'if(d.src==="public")return;' +
|
|
107
|
+
// A LAN decision taken moments ago, on a page that is not a LAN candidate,
|
|
108
|
+
// is a hop that did not stick. Fall back rather than hop again.
|
|
109
|
+
'if(Date.now()-d.at<P.retry){wr({src:"public",sig:P.sig,at:Date.now()});return}}' +
|
|
110
|
+
'probe()' +
|
|
111
|
+
'}catch(e){}})();';
|
|
112
|
+
return `<script>${body}</script>`;
|
|
113
|
+
}
|
package/lib/types/lan.d.ts
CHANGED
|
@@ -5,11 +5,57 @@
|
|
|
5
5
|
* with, so the same non-internal IPv4 derivation applies here — an external
|
|
6
6
|
* plugin cannot read the CLI's sampled snapshot, but the fence accepts
|
|
7
7
|
* exactly these literals, which is the property that matters.
|
|
8
|
+
*
|
|
9
|
+
* One derivation is deliberately narrower than the CLI's: everything this
|
|
10
|
+
* module returns is advertised to a phone (the QR base, the landing page's
|
|
11
|
+
* LAN probe, and the fence's trusted-literal list), and an advertised literal
|
|
12
|
+
* the peer can never reach costs a phone the whole pairing attempt. A macOS
|
|
13
|
+
* host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
|
|
14
|
+
* `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
|
|
15
|
+
* to; the old derivation advertised it like any other interface. The filter
|
|
16
|
+
* below drops the interface kinds and address ranges a peer cannot reach, and
|
|
17
|
+
* keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
|
|
18
|
+
* (a VPS whose NIC is its only reachable base must stay advertisable).
|
|
19
|
+
*
|
|
20
|
+
* @module @linxin666/dsh-remote-web-ui/lan
|
|
8
21
|
*/
|
|
22
|
+
import { networkInterfaces } from 'node:os';
|
|
9
23
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
24
|
+
* Whether an interface name belongs to a tunnel, VPN adapter, or virtual
|
|
25
|
+
* machine/container bridge, so its address is not an authority a LAN peer
|
|
26
|
+
* can dial.
|
|
27
|
+
* @param name - the interface name from `os.networkInterfaces()`.
|
|
28
|
+
* @returns true when the interface must not be advertised.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isUnadvertisableInterface(name: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether an IPv4 literal can be advertised to a pairing peer.
|
|
33
|
+
* @param address - a dotted-quad IPv4 literal.
|
|
34
|
+
* @returns true when a peer could plausibly reach it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isAdvertisableIPv4(address: string): boolean;
|
|
37
|
+
/** One sampled interface address. */
|
|
38
|
+
export interface LanCandidate {
|
|
39
|
+
/** The interface name the address was sampled from. */
|
|
40
|
+
iface: string;
|
|
41
|
+
/** The IPv4 literal. */
|
|
42
|
+
address: string;
|
|
43
|
+
}
|
|
44
|
+
/** The `networkInterfaces()` shape, injectable so the filter is testable. */
|
|
45
|
+
export type NetworkInterfaceMap = ReturnType<typeof networkInterfaces>;
|
|
46
|
+
/**
|
|
47
|
+
* Every IPv4 interface address this machine can advertise to a pairing peer:
|
|
48
|
+
* non-internal, on a non-tunnel and non-virtual interface, outside the ranges
|
|
49
|
+
* no peer can route to.
|
|
50
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
51
|
+
* @returns the candidates in interface order (possibly empty).
|
|
52
|
+
*/
|
|
53
|
+
export declare function lanIPv4Candidates(interfaces?: NetworkInterfaceMap): LanCandidate[];
|
|
54
|
+
/**
|
|
55
|
+
* Non-internal, reachable IPv4 interface addresses of this machine — the
|
|
56
|
+
* IP-literal authorities an all-interfaces bind is reachable by on the LAN.
|
|
57
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
12
58
|
* @returns the addresses in interface order (possibly empty).
|
|
13
59
|
*/
|
|
14
|
-
export declare function lanIPv4Addresses(): string[];
|
|
60
|
+
export declare function lanIPv4Addresses(interfaces?: NetworkInterfaceMap): string[];
|
|
15
61
|
//# sourceMappingURL=lan.d.ts.map
|
package/lib/types/lan.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lan.d.ts","sourceRoot":"","sources":["../../src/lan.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"lan.d.ts","sourceRoot":"","sources":["../../src/lan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAsD3C;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED,qCAAqC;AACrC,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAA;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAEtE;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,GAAE,mBAAyC,GAAG,YAAY,EAAE,CAWvG;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,GAAE,mBAAyC,GAAG,MAAM,EAAE,CAEhG"}
|
package/lib/types/lan.js
CHANGED
|
@@ -5,15 +5,129 @@
|
|
|
5
5
|
* with, so the same non-internal IPv4 derivation applies here — an external
|
|
6
6
|
* plugin cannot read the CLI's sampled snapshot, but the fence accepts
|
|
7
7
|
* exactly these literals, which is the property that matters.
|
|
8
|
+
*
|
|
9
|
+
* One derivation is deliberately narrower than the CLI's: everything this
|
|
10
|
+
* module returns is advertised to a phone (the QR base, the landing page's
|
|
11
|
+
* LAN probe, and the fence's trusted-literal list), and an advertised literal
|
|
12
|
+
* the peer can never reach costs a phone the whole pairing attempt. A macOS
|
|
13
|
+
* host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
|
|
14
|
+
* `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
|
|
15
|
+
* to; the old derivation advertised it like any other interface. The filter
|
|
16
|
+
* below drops the interface kinds and address ranges a peer cannot reach, and
|
|
17
|
+
* keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
|
|
18
|
+
* (a VPS whose NIC is its only reachable base must stay advertisable).
|
|
19
|
+
*
|
|
20
|
+
* @module @linxin666/dsh-remote-web-ui/lan
|
|
8
21
|
*/
|
|
9
22
|
import { networkInterfaces } from 'node:os';
|
|
10
23
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
24
|
+
* Interface-name stems of tunnels and VPN adapters: interfaces whose address
|
|
25
|
+
* is a private point-to-point endpoint on this machine, never an authority a
|
|
26
|
+
* peer on the local network can dial. Matching is case-insensitive and
|
|
27
|
+
* anchored at the start, so `utun0`/`utun4` both match while a hypothetical
|
|
28
|
+
* `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
|
|
29
|
+
* stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
|
|
30
|
+
* list covers by prefix.
|
|
31
|
+
*/
|
|
32
|
+
const TUNNEL_INTERFACE_PREFIXES = [
|
|
33
|
+
'utun', 'tun', 'tap', 'ppp', 'ipsec', 'wg', 'wireguard', 'tailscale', 'nordlynx', 'proton', 'openvpn', 'zt',
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Interface-name stems of virtual machine and container bridges: `docker0`,
|
|
37
|
+
* `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
|
|
38
|
+
* `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
|
|
39
|
+
* shares with its own guests, so a phone on the physical LAN cannot reach
|
|
40
|
+
* them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
|
|
41
|
+
* (macOS Internet Sharing) is exactly the address a phone does reach.
|
|
42
|
+
*/
|
|
43
|
+
const VIRTUAL_INTERFACE_PREFIXES = [
|
|
44
|
+
'docker', 'veth', 'virbr', 'vmnet', 'vboxnet', 'vethernet', 'br-',
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* IPv4 ranges no peer on the local network can reach. The list is a denial
|
|
48
|
+
* list rather than an RFC1918 allowlist on purpose: a host whose only
|
|
49
|
+
* interface is a routable public literal (a VPS, a standalone server) would
|
|
50
|
+
* otherwise lose its only constructible base and fall back to the
|
|
51
|
+
* `lan-required` banner.
|
|
52
|
+
* @param octets - the four address octets, in order.
|
|
53
|
+
* @returns true when an address in this range must not be advertised.
|
|
54
|
+
*/
|
|
55
|
+
function isUnreachableIPv4(octets) {
|
|
56
|
+
const [a = 0, b = 0] = octets;
|
|
57
|
+
// 0.0.0.0/8 "this network": never a usable address.
|
|
58
|
+
if (a === 0)
|
|
59
|
+
return true;
|
|
60
|
+
// 127.0.0.0/8 loopback (node already marks it internal; kept as the belt).
|
|
61
|
+
if (a === 127)
|
|
62
|
+
return true;
|
|
63
|
+
// 100.64.0.0/10 carrier-grade NAT: Tailscale and ISP-level NAT hand out
|
|
64
|
+
// addresses here, and a peer on the physical LAN cannot route to them.
|
|
65
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
66
|
+
return true;
|
|
67
|
+
// 169.254.0.0/16 link-local (APIPA / self-assigned).
|
|
68
|
+
if (a === 169 && b === 254)
|
|
69
|
+
return true;
|
|
70
|
+
// 198.18.0.0/15 benchmarking: the range a fake-IP TUN gateway takes.
|
|
71
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
72
|
+
return true;
|
|
73
|
+
// 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (includes the broadcast).
|
|
74
|
+
if (a >= 224)
|
|
75
|
+
return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether an interface name belongs to a tunnel, VPN adapter, or virtual
|
|
80
|
+
* machine/container bridge, so its address is not an authority a LAN peer
|
|
81
|
+
* can dial.
|
|
82
|
+
* @param name - the interface name from `os.networkInterfaces()`.
|
|
83
|
+
* @returns true when the interface must not be advertised.
|
|
84
|
+
*/
|
|
85
|
+
export function isUnadvertisableInterface(name) {
|
|
86
|
+
const lower = name.toLowerCase();
|
|
87
|
+
return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some(prefix => lower.startsWith(prefix));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether an IPv4 literal can be advertised to a pairing peer.
|
|
91
|
+
* @param address - a dotted-quad IPv4 literal.
|
|
92
|
+
* @returns true when a peer could plausibly reach it.
|
|
93
|
+
*/
|
|
94
|
+
export function isAdvertisableIPv4(address) {
|
|
95
|
+
const parts = address.split('.');
|
|
96
|
+
if (parts.length !== 4)
|
|
97
|
+
return false;
|
|
98
|
+
const octets = parts.map(part => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN));
|
|
99
|
+
if (octets.some(octet => !Number.isInteger(octet) || octet > 255))
|
|
100
|
+
return false;
|
|
101
|
+
return !isUnreachableIPv4(octets);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every IPv4 interface address this machine can advertise to a pairing peer:
|
|
105
|
+
* non-internal, on a non-tunnel and non-virtual interface, outside the ranges
|
|
106
|
+
* no peer can route to.
|
|
107
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
108
|
+
* @returns the candidates in interface order (possibly empty).
|
|
109
|
+
*/
|
|
110
|
+
export function lanIPv4Candidates(interfaces = networkInterfaces()) {
|
|
111
|
+
const candidates = [];
|
|
112
|
+
for (const [name, addresses] of Object.entries(interfaces)) {
|
|
113
|
+
if (isUnadvertisableInterface(name))
|
|
114
|
+
continue;
|
|
115
|
+
for (const iface of addresses ?? []) {
|
|
116
|
+
if (iface.family !== 'IPv4' || iface.internal)
|
|
117
|
+
continue;
|
|
118
|
+
if (!isAdvertisableIPv4(iface.address))
|
|
119
|
+
continue;
|
|
120
|
+
candidates.push({ iface: name, address: iface.address });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return candidates;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Non-internal, reachable IPv4 interface addresses of this machine — the
|
|
127
|
+
* IP-literal authorities an all-interfaces bind is reachable by on the LAN.
|
|
128
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
13
129
|
* @returns the addresses in interface order (possibly empty).
|
|
14
130
|
*/
|
|
15
|
-
export function lanIPv4Addresses() {
|
|
16
|
-
return
|
|
17
|
-
.filter((iface) => { return iface !== undefined && iface.family === 'IPv4' && !iface.internal; })
|
|
18
|
-
.map(iface => iface.address);
|
|
131
|
+
export function lanIPv4Addresses(interfaces = networkInterfaces()) {
|
|
132
|
+
return lanIPv4Candidates(interfaces).map(candidate => candidate.address);
|
|
19
133
|
}
|
|
@@ -35,8 +35,13 @@ export { REMOTE_API_PREFIX } from './remote-methods.ts';
|
|
|
35
35
|
export interface RemoteApiDeps {
|
|
36
36
|
/** The pairing service (device gate + cookie name). */
|
|
37
37
|
service: PairingService;
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Local webServer port the loopback proxy connects to, read per request:
|
|
40
|
+
* `undefined` before the listener binds (a recompose pass can run this
|
|
41
|
+
* apply ahead of the bind), in which case the handlers refuse with 503
|
|
42
|
+
* instead of minting an `:undefined` URL.
|
|
43
|
+
*/
|
|
44
|
+
port: () => number | undefined;
|
|
40
45
|
/**
|
|
41
46
|
* The process's inner browser-auth credential attached to re-issued
|
|
42
47
|
* requests (the connection plugin's /api route enforces that cookie and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,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
|
|
1
|
+
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,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;;;;;OAKG;IACH,IAAI,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAgDnE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAYzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAgDjF"}
|
package/lib/types/remote-api.js
CHANGED
|
@@ -101,7 +101,7 @@ export function pairedDeviceIdOf(req, service) {
|
|
|
101
101
|
* @returns the routes to register on webServer.
|
|
102
102
|
*/
|
|
103
103
|
export function makeRemoteApiRoutes(deps) {
|
|
104
|
-
const { service
|
|
104
|
+
const { service } = deps;
|
|
105
105
|
const handler = (req, res) => {
|
|
106
106
|
// Paired credential first — before any bytes are buffered or forwarded.
|
|
107
107
|
// Unconditional by design: this channel attaches the process's own
|
|
@@ -132,6 +132,14 @@ export function makeRemoteApiRoutes(deps) {
|
|
|
132
132
|
envelopeError(res, 403, 'invalid-request', 'forbidden', denied);
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
|
+
const port = deps.port();
|
|
136
|
+
if (port === undefined) {
|
|
137
|
+
// The listener has not bound yet (a recompose pass): refuse instead of
|
|
138
|
+
// minting an http://127.0.0.1:undefined target; the desktop retries.
|
|
139
|
+
req.resume();
|
|
140
|
+
envelopeError(res, 503, 'invalid-request', 'port-unbound', 'the local web server port is not bound yet; retry shortly');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
135
143
|
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
|
|
136
144
|
};
|
|
137
145
|
return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }];
|
|
@@ -174,7 +182,7 @@ export function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
174
182
|
* @returns the upgrade routes to register on webServer.
|
|
175
183
|
*/
|
|
176
184
|
export function makeRemoteApiUpgradeRoutes(deps) {
|
|
177
|
-
const { service
|
|
185
|
+
const { service } = deps;
|
|
178
186
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
179
187
|
// WebSocket handshakes cannot carry headers from the Web API, so the
|
|
180
188
|
// cookieless credential rides the query; the cookie stays the primary.
|
|
@@ -191,6 +199,13 @@ export function makeRemoteApiUpgradeRoutes(deps) {
|
|
|
191
199
|
socket.destroy();
|
|
192
200
|
return;
|
|
193
201
|
}
|
|
202
|
+
const port = deps.port();
|
|
203
|
+
if (port === undefined) {
|
|
204
|
+
// Same unbound-port refusal as the HTTP leg: no :undefined handshake.
|
|
205
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
206
|
+
socket.destroy();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
194
209
|
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
195
210
|
const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner);
|
|
196
211
|
if (denied !== undefined) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CAkKrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CAkKrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAmB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
|