@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/index.js
CHANGED
|
@@ -989,6 +989,62 @@ const REMOTE_CHANNEL_RULES = {
|
|
|
989
989
|
/** The window global the boot patch publishes its seat under. */
|
|
990
990
|
const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
991
991
|
//#endregion
|
|
992
|
+
//#region src/lan-source.ts
|
|
993
|
+
/**
|
|
994
|
+
* The landing page's network-source decision: which origin a paired phone
|
|
995
|
+
* should keep using.
|
|
996
|
+
*
|
|
997
|
+
* The QR link can only name one base, and the server cannot know whether the
|
|
998
|
+
* scanning phone sits on this machine's own network: the public (relayed)
|
|
999
|
+
* base is the only one that is always reachable, so the QR names it and the
|
|
1000
|
+
* phone decides at connect time. The device-gated landing (/pair-app) is the
|
|
1001
|
+
* plugin's own page already rendered ahead of the official shell's boot, so it
|
|
1002
|
+
* is the natural place to probe: after the page is served, a short
|
|
1003
|
+
* cross-origin `fetch` per LAN candidate (a no-cors GET at
|
|
1004
|
+
* {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
|
|
1005
|
+
* and the first one that does becomes the new origin. A probe that finds
|
|
1006
|
+
* nothing, or that runs out of budget, changes nothing — the page the phone
|
|
1007
|
+
* already holds stays paired and usable, so the probe can never block pairing.
|
|
1008
|
+
*
|
|
1009
|
+
* Browsers do not block this probe. An https page may not normally fetch
|
|
1010
|
+
* `http://` subresources, but a host that is an IP literal is exempt from both
|
|
1011
|
+
* the block and the automatic upgrade (verified against Chrome: the console
|
|
1012
|
+
* reports "This request was not upgraded to HTTPS because its URL's host is an
|
|
1013
|
+
* IP address" and delivers the response), which is exactly the shape every LAN
|
|
1014
|
+
* candidate has.
|
|
1015
|
+
*
|
|
1016
|
+
* @module @linxin666/dsh-remote-web-ui/lan-source
|
|
1017
|
+
*/
|
|
1018
|
+
/** sessionStorage key holding this session's source decision. */
|
|
1019
|
+
const LAN_SOURCE_STORAGE_KEY = "dsh-remote-lan-source";
|
|
1020
|
+
/**
|
|
1021
|
+
* How recently a LAN hop must have been taken for a return to a non-LAN origin
|
|
1022
|
+
* to read as "the hop did not stick". A phone that fails to load the LAN page
|
|
1023
|
+
* re-scans the QR (which always names the public base) within seconds, and the
|
|
1024
|
+
* landing then has to choose the public source instead of hopping again into
|
|
1025
|
+
* the same black hole; a deliberate return much later is a fresh decision and
|
|
1026
|
+
* re-probes.
|
|
1027
|
+
*/
|
|
1028
|
+
const LAN_SOURCE_RETRY_WINDOW_MS = 12e4;
|
|
1029
|
+
/**
|
|
1030
|
+
* Build the inline script that runs the source decision on the landing page.
|
|
1031
|
+
* Reads every browser global through `window`, so a test can run it against
|
|
1032
|
+
* one fake object (the same seam the parse-time boot patch uses).
|
|
1033
|
+
* @param hop - the probe payload for this landing render.
|
|
1034
|
+
* @returns the `<script>` element to splice into the landing's head.
|
|
1035
|
+
*/
|
|
1036
|
+
function appShellLanSwitchScript(hop) {
|
|
1037
|
+
return `<script>${"(function(){try{var w=window,loc=w.location;var P=" + JSON.stringify({
|
|
1038
|
+
key: LAN_SOURCE_STORAGE_KEY,
|
|
1039
|
+
candidates: hop.candidates,
|
|
1040
|
+
path: hop.path,
|
|
1041
|
+
ping: hop.pingPath,
|
|
1042
|
+
grant: hop.grant,
|
|
1043
|
+
timeout: 800,
|
|
1044
|
+
retry: LAN_SOURCE_RETRY_WINDOW_MS
|
|
1045
|
+
}) + ";var S=w.sessionStorage;P.sig=P.candidates.join(\",\");function rd(){try{var raw=S.getItem(P.key);if(typeof raw!==\"string\"||raw===\"\")return undefined;var d=JSON.parse(raw);if(typeof d!==\"object\"||d===null)return undefined;if(d.src!==\"lan\"&&d.src!==\"public\")return undefined;if(typeof d.sig!==\"string\"||typeof d.at!==\"number\")return undefined;return d}catch(e){return undefined}}function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}function go(base){try{loc.replace(base+P.path+\"?grant=\"+encodeURIComponent(P.grant))}catch(e){}}function probe(){if(typeof w.fetch!==\"function\")return;var settled=false;var timer=w.setTimeout(function(){finish(undefined)},P.timeout);function finish(base){if(settled)return;settled=true;try{w.clearTimeout(timer)}catch(e){}if(base===undefined){wr({src:\"public\",sig:P.sig,at:Date.now()});return}wr({src:\"lan\",sig:P.sig,base:base,at:Date.now()});go(base)}for(var i=0;i<P.candidates.length;i++){(function(base){var sent;try{sent=w.fetch(base+P.ping,{mode:\"no-cors\",cache:\"no-store\",credentials:\"omit\",referrerPolicy:\"no-referrer\"})}catch(e){sent=undefined}if(sent===undefined||typeof sent.then!==\"function\")return;sent.then(function(){finish(base)},function(){})})(P.candidates[i])}}var d=rd();if(own()){if(d===undefined||d.sig!==P.sig)wr({src:\"lan\",sig:P.sig,base:loc.origin,at:Date.now()});return}if(d!==undefined&&d.sig===P.sig){if(d.src===\"public\")return;if(Date.now()-d.at<P.retry){wr({src:\"public\",sig:P.sig,at:Date.now()});return}}probe()}catch(e){}})();"}<\/script>`;
|
|
1046
|
+
}
|
|
1047
|
+
//#endregion
|
|
992
1048
|
//#region src/routes.ts
|
|
993
1049
|
/**
|
|
994
1050
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
@@ -1208,18 +1264,26 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
|
|
|
1208
1264
|
* The cookieless device credential: pass the device id from the /pair-app
|
|
1209
1265
|
* URL into sessionStorage (and localStorage for tab reloads) before any app
|
|
1210
1266
|
* script runs - same key the boot patch and the channel gate read. The
|
|
1211
|
-
* replaceState
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1267
|
+
* replaceState drops the one-time grant from the address bar and leaves the
|
|
1268
|
+
* SPA on a path this plugin answers again; which path depends on the origin.
|
|
1269
|
+
*
|
|
1270
|
+
* A secure context registers the reopen service worker in the same breath,
|
|
1271
|
+
* and that worker owns navigations to '/', so the SPA keeps its canonical
|
|
1272
|
+
* root. A plain-HTTP LAN origin is never a secure context, so the worker
|
|
1273
|
+
* cannot exist there and '/' falls through to the harness index gate the
|
|
1274
|
+
* cookieless flow can never satisfy (issue #1867). The address bar therefore
|
|
1275
|
+
* keeps the plugin-owned landing path, which re-validates the device
|
|
1276
|
+
* credential on every reload and serves the bilingual re-pair page once the
|
|
1277
|
+
* pairing is gone. Both targets are bare paths: the one-time grant never
|
|
1278
|
+
* survives the rewrite, and no credential is ever left in the URL.
|
|
1216
1279
|
*/
|
|
1217
1280
|
const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
|
|
1218
1281
|
function appShellCaptureScript(deviceId) {
|
|
1219
1282
|
const safeId = JSON.stringify(deviceId);
|
|
1220
1283
|
const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL);
|
|
1284
|
+
const reopenPath = JSON.stringify(PAIR_PATHS.appPage);
|
|
1221
1285
|
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
|
|
1222
|
-
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()<\/script>`;
|
|
1286
|
+
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'',window.isSecureContext?'/':${reopenPath})}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()<\/script>`;
|
|
1223
1287
|
}
|
|
1224
1288
|
/**
|
|
1225
1289
|
* Patch the official index document with the device-capture script. The script
|
|
@@ -1228,9 +1292,19 @@ function appShellCaptureScript(deviceId) {
|
|
|
1228
1292
|
* hook only when this server-served marker is already set (see
|
|
1229
1293
|
* remote-channel-boot.ts), so host mode is granted by the device-gated landing
|
|
1230
1294
|
* instead of being asserted from the origin.
|
|
1295
|
+
*
|
|
1296
|
+
* A landing reached on a base other than the machine's own LAN origin also
|
|
1297
|
+
* carries the source-decision script (lan-source.ts): the phone decides at
|
|
1298
|
+
* connect time whether a LAN candidate answers, and switches its origin when
|
|
1299
|
+
* one does. It runs after the capture script so the granted page is fully
|
|
1300
|
+
* usable before the probe's outcome can move it.
|
|
1301
|
+
* @param html - the official index document.
|
|
1302
|
+
* @param deviceId - the device session this landing is being served for.
|
|
1303
|
+
* @param hop - the LAN candidates to probe, when a switch is possible.
|
|
1304
|
+
* @returns the patched document.
|
|
1231
1305
|
*/
|
|
1232
|
-
function patchAppShell(html, deviceId) {
|
|
1233
|
-
const script = appShellCaptureScript(deviceId);
|
|
1306
|
+
function patchAppShell(html, deviceId, hop) {
|
|
1307
|
+
const script = appShellCaptureScript(deviceId) + (hop === void 0 ? "" : appShellLanSwitchScript(hop));
|
|
1234
1308
|
const head = /<head[^>]*>/i.exec(html);
|
|
1235
1309
|
if (head === null) return script + html;
|
|
1236
1310
|
const end = head.index + head[0].length;
|
|
@@ -1244,9 +1318,10 @@ function patchAppShell(html, deviceId) {
|
|
|
1244
1318
|
* worker owns navigations to `/` instead: network-first through /pair-app
|
|
1245
1319
|
* (which validates the device cookie and refreshes its presence), the
|
|
1246
1320
|
* cached shell for offline opens, and a pass-through of the original
|
|
1247
|
-
* request when the plugin no longer answers.
|
|
1248
|
-
* secure
|
|
1249
|
-
*
|
|
1321
|
+
* request when the plugin no longer answers. A plain-HTTP LAN origin is
|
|
1322
|
+
* never a secure context, so the worker cannot exist there; the capture
|
|
1323
|
+
* script keeps the plugin-owned landing path in the address bar instead,
|
|
1324
|
+
* and that landing re-validates the device credential on every reload.
|
|
1250
1325
|
*
|
|
1251
1326
|
* Kept as a plain string (same pattern as the capture script): it must load
|
|
1252
1327
|
* with no build step, run on every JS engine that ships service workers,
|
|
@@ -1346,6 +1421,12 @@ const PAIR_PATHS = {
|
|
|
1346
1421
|
/** The cookieless app landing: serves the official shell for a paired device. */
|
|
1347
1422
|
appPage: "/pair-app",
|
|
1348
1423
|
/**
|
|
1424
|
+
* The landing page's LAN reachability beacon (lan-source.ts): an inert
|
|
1425
|
+
* no-cors GET target, so a probe proves the origin answers without touching
|
|
1426
|
+
* a credential or any state.
|
|
1427
|
+
*/
|
|
1428
|
+
ping: "/pair-ping",
|
|
1429
|
+
/**
|
|
1349
1430
|
* The reopen service worker (registered by the capture script). Root-level
|
|
1350
1431
|
* path so its default script-directory scope already covers `/`.
|
|
1351
1432
|
*/
|
|
@@ -1424,6 +1505,39 @@ var PairingEventsStream = class {
|
|
|
1424
1505
|
}
|
|
1425
1506
|
};
|
|
1426
1507
|
/**
|
|
1508
|
+
* The accepted {@link PairingSourcePreference} values, shared with the config
|
|
1509
|
+
* schema so the patch that admits a value and the handler that reads it cannot
|
|
1510
|
+
* drift apart.
|
|
1511
|
+
*/
|
|
1512
|
+
const PAIRING_SOURCE_OPTIONS = [
|
|
1513
|
+
"auto",
|
|
1514
|
+
"public",
|
|
1515
|
+
"lan"
|
|
1516
|
+
];
|
|
1517
|
+
/**
|
|
1518
|
+
* The LAN origins a landing page served from one authority may hop to: every
|
|
1519
|
+
* LAN base the service advertises except the authority serving the request
|
|
1520
|
+
* (hopping to the current origin would be a no-op), normalized through `URL`
|
|
1521
|
+
* so a default port compares equal to `location.origin`.
|
|
1522
|
+
* @param lanBases - the advertised LAN base URLs, in interface order.
|
|
1523
|
+
* @param currentOrigin - the origin serving the landing page.
|
|
1524
|
+
* @returns the distinct candidate origins, in interface order.
|
|
1525
|
+
*/
|
|
1526
|
+
function lanSourceCandidates(lanBases, currentOrigin) {
|
|
1527
|
+
const origins = [];
|
|
1528
|
+
for (const base of lanBases) {
|
|
1529
|
+
let origin;
|
|
1530
|
+
try {
|
|
1531
|
+
origin = new URL(base).origin;
|
|
1532
|
+
} catch {
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (origin === currentOrigin || origins.includes(origin)) continue;
|
|
1536
|
+
origins.push(origin);
|
|
1537
|
+
}
|
|
1538
|
+
return origins;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1427
1541
|
* Build the /api/pair route family.
|
|
1428
1542
|
* @param deps - service + fence inputs.
|
|
1429
1543
|
* @returns the exact routes to register on webServer.
|
|
@@ -1431,6 +1545,26 @@ var PairingEventsStream = class {
|
|
|
1431
1545
|
function makeRoutes(deps) {
|
|
1432
1546
|
const { service, requirePairingForLan = true } = deps;
|
|
1433
1547
|
const pairingRequired = () => typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan;
|
|
1548
|
+
/** The manual network override, re-read per request (default: automatic). */
|
|
1549
|
+
const sourcePreference = () => deps.pairingSource?.() ?? "auto";
|
|
1550
|
+
/**
|
|
1551
|
+
* Every authority this surface serves, as {@link isTrustedHost} wants them
|
|
1552
|
+
* (bare `host` or `host:port` literals). The LAN literals are re-read per
|
|
1553
|
+
* request: a hot rebind (the lan-bind toggle) updates them mid-process, and
|
|
1554
|
+
* both the fence and the reachability beacon must follow.
|
|
1555
|
+
*/
|
|
1556
|
+
const servedAuthorities = () => {
|
|
1557
|
+
const publicHost = publicHostOf(service.publicBaseUrl);
|
|
1558
|
+
const extraHosts = typeof deps.trustedHosts === "function" ? deps.trustedHosts() : deps.trustedHosts ?? [];
|
|
1559
|
+
return [
|
|
1560
|
+
...service.lanAddresses,
|
|
1561
|
+
...publicHost !== void 0 ? [publicHost] : [],
|
|
1562
|
+
...extraHosts,
|
|
1563
|
+
...dynamicTrustedHosts
|
|
1564
|
+
];
|
|
1565
|
+
};
|
|
1566
|
+
/** Every LAN base URL the service currently advertises, in interface order. */
|
|
1567
|
+
const lanBaseUrls = () => service.lanAddresses.map((address) => service.lanBaseUrlFor(address)).filter((base) => base !== void 0);
|
|
1434
1568
|
const grants = deps.grants ?? createPairGrantStore();
|
|
1435
1569
|
const events = new PairingEventsStream(service);
|
|
1436
1570
|
const dynamicTrustedHosts = /* @__PURE__ */ new Set();
|
|
@@ -1443,15 +1577,7 @@ function makeRoutes(deps) {
|
|
|
1443
1577
|
* pairing entry pages), never for an API or subresource request.
|
|
1444
1578
|
*/
|
|
1445
1579
|
const lanFence = (req, navigation = false) => {
|
|
1446
|
-
const
|
|
1447
|
-
const bases = service.lanAddresses;
|
|
1448
|
-
const extraHosts = typeof deps.trustedHosts === "function" ? deps.trustedHosts() : deps.trustedHosts ?? [];
|
|
1449
|
-
const combined = [
|
|
1450
|
-
...bases,
|
|
1451
|
-
...publicHost !== void 0 ? [publicHost] : [],
|
|
1452
|
-
...extraHosts,
|
|
1453
|
-
...dynamicTrustedHosts
|
|
1454
|
-
];
|
|
1580
|
+
const combined = servedAuthorities();
|
|
1455
1581
|
if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined)) return true;
|
|
1456
1582
|
const privateLanHost = privateLanHostOf(req, navigation);
|
|
1457
1583
|
if (privateLanHost !== void 0) {
|
|
@@ -1552,7 +1678,8 @@ function makeRoutes(deps) {
|
|
|
1552
1678
|
const { address } = payload;
|
|
1553
1679
|
try {
|
|
1554
1680
|
const { token, expiresAt } = service.issue(void 0, address);
|
|
1555
|
-
const
|
|
1681
|
+
const preference = sourcePreference();
|
|
1682
|
+
const base = address !== void 0 ? service.lanBaseUrlFor(address) : preference === "lan" ? service.lanBaseUrl : preference === "public" ? service.publicBaseUrl : service.publicBaseUrl ?? service.lanBaseUrl;
|
|
1556
1683
|
if (base === void 0) throw new Error("remote-web-ui: base unavailable");
|
|
1557
1684
|
writeJson(res, 200, {
|
|
1558
1685
|
ok: true,
|
|
@@ -1868,7 +1995,56 @@ function makeRoutes(deps) {
|
|
|
1868
1995
|
"referrer-policy": "no-referrer",
|
|
1869
1996
|
"set-cookie": [deviceCookie(req, service.config.cookieName, id)]
|
|
1870
1997
|
});
|
|
1871
|
-
res.end(patchAppShell(html, id));
|
|
1998
|
+
res.end(patchAppShell(html, id, lanHop(req, id)));
|
|
1999
|
+
};
|
|
2000
|
+
/**
|
|
2001
|
+
* The LAN hop this landing may take, or undefined when there is nothing to
|
|
2002
|
+
* decide. Three conditions have to hold together: the automatic preference
|
|
2003
|
+
* (both overrides answer the question already — `lan` serves the QR from a
|
|
2004
|
+
* LAN origin, `public` forbids the move), at least one LAN candidate other
|
|
2005
|
+
* than this origin, and a fresh one-time grant for the target landing. The
|
|
2006
|
+
* grant is what lets the target origin serve the shell: the device cookie is
|
|
2007
|
+
* origin-scoped, so the LAN origin has no credential of its own, and the
|
|
2008
|
+
* grant table lives in this process regardless of which interface answered.
|
|
2009
|
+
* The candidate set is carried to the client as origins (lan-source.ts
|
|
2010
|
+
* compares them against `location.origin`).
|
|
2011
|
+
* @param req - the landing request (its authority is the current origin).
|
|
2012
|
+
* @param deviceId - the device session the landing is served for.
|
|
2013
|
+
* @returns the hop payload, or undefined when no switch is possible.
|
|
2014
|
+
*/
|
|
2015
|
+
const lanHop = (req, deviceId) => {
|
|
2016
|
+
if (sourcePreference() !== "auto") return void 0;
|
|
2017
|
+
const candidates = lanSourceCandidates(lanBaseUrls(), appOrigin(req));
|
|
2018
|
+
if (candidates.length === 0) return void 0;
|
|
2019
|
+
return {
|
|
2020
|
+
candidates,
|
|
2021
|
+
path: PAIR_PATHS.appPage,
|
|
2022
|
+
pingPath: PAIR_PATHS.ping,
|
|
2023
|
+
grant: grants.issue(deviceId).grant
|
|
2024
|
+
};
|
|
2025
|
+
};
|
|
2026
|
+
/**
|
|
2027
|
+
* The landing page's LAN reachability beacon: a 204 for a Host this plugin
|
|
2028
|
+
* serves, a 403 otherwise. It carries no credential and no state — a phone
|
|
2029
|
+
* that reaches it has proved the origin answers, which is all the source
|
|
2030
|
+
* decision needs, and the fence's browser-marker half is deliberately not
|
|
2031
|
+
* applied here: the probe is cross-site by construction (a page on the
|
|
2032
|
+
* public origin fetching a LAN origin), so the marker check would turn every
|
|
2033
|
+
* probe into the same 403 that means "not ours" and make the two
|
|
2034
|
+
* indistinguishable.
|
|
2035
|
+
*/
|
|
2036
|
+
const handlePing = (req, res) => {
|
|
2037
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
2038
|
+
if (!isTrustedHost(req, servedAuthorities())) {
|
|
2039
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
2040
|
+
res.end();
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
res.writeHead(204, {
|
|
2044
|
+
"cache-control": "no-store",
|
|
2045
|
+
"referrer-policy": "no-referrer"
|
|
2046
|
+
});
|
|
2047
|
+
res.end();
|
|
1872
2048
|
};
|
|
1873
2049
|
/**
|
|
1874
2050
|
* The reopen service worker script. Same phone-facing fence as the app
|
|
@@ -1938,6 +2114,11 @@ function makeRoutes(deps) {
|
|
|
1938
2114
|
path: PAIR_PATHS.acceptPage,
|
|
1939
2115
|
handler: handleAcceptPage
|
|
1940
2116
|
});
|
|
2117
|
+
routes.push({
|
|
2118
|
+
kind: "exact",
|
|
2119
|
+
path: PAIR_PATHS.ping,
|
|
2120
|
+
handler: handlePing
|
|
2121
|
+
});
|
|
1941
2122
|
if (deps.indexDocument !== void 0) {
|
|
1942
2123
|
routes.push({
|
|
1943
2124
|
kind: "exact",
|
|
@@ -2186,7 +2367,7 @@ function pairedDeviceIdOf(req, service) {
|
|
|
2186
2367
|
* @returns the routes to register on webServer.
|
|
2187
2368
|
*/
|
|
2188
2369
|
function makeRemoteApiRoutes(deps) {
|
|
2189
|
-
const { service
|
|
2370
|
+
const { service } = deps;
|
|
2190
2371
|
const handler = (req, res) => {
|
|
2191
2372
|
if (pairedDeviceIdOf(req, service) === void 0) {
|
|
2192
2373
|
req.resume();
|
|
@@ -2212,6 +2393,12 @@ function makeRemoteApiRoutes(deps) {
|
|
|
2212
2393
|
envelopeError(res, 403, "invalid-request", "forbidden", denied);
|
|
2213
2394
|
return;
|
|
2214
2395
|
}
|
|
2396
|
+
const port = deps.port();
|
|
2397
|
+
if (port === void 0) {
|
|
2398
|
+
req.resume();
|
|
2399
|
+
envelopeError(res, 503, "invalid-request", "port-unbound", "the local web server port is not bound yet; retry shortly");
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2215
2402
|
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
|
|
2216
2403
|
};
|
|
2217
2404
|
return [{
|
|
@@ -2255,7 +2442,7 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
2255
2442
|
* @returns the upgrade routes to register on webServer.
|
|
2256
2443
|
*/
|
|
2257
2444
|
function makeRemoteApiUpgradeRoutes(deps) {
|
|
2258
|
-
const { service
|
|
2445
|
+
const { service } = deps;
|
|
2259
2446
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
2260
2447
|
let queryDevice;
|
|
2261
2448
|
try {
|
|
@@ -2266,6 +2453,12 @@ function makeRemoteApiUpgradeRoutes(deps) {
|
|
|
2266
2453
|
socket.destroy();
|
|
2267
2454
|
return;
|
|
2268
2455
|
}
|
|
2456
|
+
const port = deps.port();
|
|
2457
|
+
if (port === void 0) {
|
|
2458
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
|
|
2459
|
+
socket.destroy();
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2269
2462
|
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
2270
2463
|
if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
|
|
2271
2464
|
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
@@ -2475,16 +2668,132 @@ function releasePostureKey(current, attempted) {
|
|
|
2475
2668
|
* with, so the same non-internal IPv4 derivation applies here — an external
|
|
2476
2669
|
* plugin cannot read the CLI's sampled snapshot, but the fence accepts
|
|
2477
2670
|
* exactly these literals, which is the property that matters.
|
|
2671
|
+
*
|
|
2672
|
+
* One derivation is deliberately narrower than the CLI's: everything this
|
|
2673
|
+
* module returns is advertised to a phone (the QR base, the landing page's
|
|
2674
|
+
* LAN probe, and the fence's trusted-literal list), and an advertised literal
|
|
2675
|
+
* the peer can never reach costs a phone the whole pairing attempt. A macOS
|
|
2676
|
+
* host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
|
|
2677
|
+
* `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
|
|
2678
|
+
* to; the old derivation advertised it like any other interface. The filter
|
|
2679
|
+
* below drops the interface kinds and address ranges a peer cannot reach, and
|
|
2680
|
+
* keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
|
|
2681
|
+
* (a VPS whose NIC is its only reachable base must stay advertisable).
|
|
2682
|
+
*
|
|
2683
|
+
* @module @linxin666/dsh-remote-web-ui/lan
|
|
2684
|
+
*/
|
|
2685
|
+
/**
|
|
2686
|
+
* Interface-name stems of tunnels and VPN adapters: interfaces whose address
|
|
2687
|
+
* is a private point-to-point endpoint on this machine, never an authority a
|
|
2688
|
+
* peer on the local network can dial. Matching is case-insensitive and
|
|
2689
|
+
* anchored at the start, so `utun0`/`utun4` both match while a hypothetical
|
|
2690
|
+
* `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
|
|
2691
|
+
* stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
|
|
2692
|
+
* list covers by prefix.
|
|
2693
|
+
*/
|
|
2694
|
+
const TUNNEL_INTERFACE_PREFIXES = [
|
|
2695
|
+
"utun",
|
|
2696
|
+
"tun",
|
|
2697
|
+
"tap",
|
|
2698
|
+
"ppp",
|
|
2699
|
+
"ipsec",
|
|
2700
|
+
"wg",
|
|
2701
|
+
"wireguard",
|
|
2702
|
+
"tailscale",
|
|
2703
|
+
"nordlynx",
|
|
2704
|
+
"proton",
|
|
2705
|
+
"openvpn",
|
|
2706
|
+
"zt"
|
|
2707
|
+
];
|
|
2708
|
+
/**
|
|
2709
|
+
* Interface-name stems of virtual machine and container bridges: `docker0`,
|
|
2710
|
+
* `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
|
|
2711
|
+
* `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
|
|
2712
|
+
* shares with its own guests, so a phone on the physical LAN cannot reach
|
|
2713
|
+
* them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
|
|
2714
|
+
* (macOS Internet Sharing) is exactly the address a phone does reach.
|
|
2715
|
+
*/
|
|
2716
|
+
const VIRTUAL_INTERFACE_PREFIXES = [
|
|
2717
|
+
"docker",
|
|
2718
|
+
"veth",
|
|
2719
|
+
"virbr",
|
|
2720
|
+
"vmnet",
|
|
2721
|
+
"vboxnet",
|
|
2722
|
+
"vethernet",
|
|
2723
|
+
"br-"
|
|
2724
|
+
];
|
|
2725
|
+
/**
|
|
2726
|
+
* IPv4 ranges no peer on the local network can reach. The list is a denial
|
|
2727
|
+
* list rather than an RFC1918 allowlist on purpose: a host whose only
|
|
2728
|
+
* interface is a routable public literal (a VPS, a standalone server) would
|
|
2729
|
+
* otherwise lose its only constructible base and fall back to the
|
|
2730
|
+
* `lan-required` banner.
|
|
2731
|
+
* @param octets - the four address octets, in order.
|
|
2732
|
+
* @returns true when an address in this range must not be advertised.
|
|
2733
|
+
*/
|
|
2734
|
+
function isUnreachableIPv4(octets) {
|
|
2735
|
+
const [a = 0, b = 0] = octets;
|
|
2736
|
+
if (a === 0) return true;
|
|
2737
|
+
if (a === 127) return true;
|
|
2738
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
2739
|
+
if (a === 169 && b === 254) return true;
|
|
2740
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
2741
|
+
if (a >= 224) return true;
|
|
2742
|
+
return false;
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Whether an interface name belongs to a tunnel, VPN adapter, or virtual
|
|
2746
|
+
* machine/container bridge, so its address is not an authority a LAN peer
|
|
2747
|
+
* can dial.
|
|
2748
|
+
* @param name - the interface name from `os.networkInterfaces()`.
|
|
2749
|
+
* @returns true when the interface must not be advertised.
|
|
2750
|
+
*/
|
|
2751
|
+
function isUnadvertisableInterface(name) {
|
|
2752
|
+
const lower = name.toLowerCase();
|
|
2753
|
+
return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some((prefix) => lower.startsWith(prefix));
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Whether an IPv4 literal can be advertised to a pairing peer.
|
|
2757
|
+
* @param address - a dotted-quad IPv4 literal.
|
|
2758
|
+
* @returns true when a peer could plausibly reach it.
|
|
2759
|
+
*/
|
|
2760
|
+
function isAdvertisableIPv4(address) {
|
|
2761
|
+
const parts = address.split(".");
|
|
2762
|
+
if (parts.length !== 4) return false;
|
|
2763
|
+
const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
|
|
2764
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) return false;
|
|
2765
|
+
return !isUnreachableIPv4(octets);
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Every IPv4 interface address this machine can advertise to a pairing peer:
|
|
2769
|
+
* non-internal, on a non-tunnel and non-virtual interface, outside the ranges
|
|
2770
|
+
* no peer can route to.
|
|
2771
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
2772
|
+
* @returns the candidates in interface order (possibly empty).
|
|
2478
2773
|
*/
|
|
2774
|
+
function lanIPv4Candidates(interfaces = networkInterfaces()) {
|
|
2775
|
+
const candidates = [];
|
|
2776
|
+
for (const [name, addresses] of Object.entries(interfaces)) {
|
|
2777
|
+
if (isUnadvertisableInterface(name)) continue;
|
|
2778
|
+
for (const iface of addresses ?? []) {
|
|
2779
|
+
if (iface.family !== "IPv4" || iface.internal) continue;
|
|
2780
|
+
if (!isAdvertisableIPv4(iface.address)) continue;
|
|
2781
|
+
candidates.push({
|
|
2782
|
+
iface: name,
|
|
2783
|
+
address: iface.address
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
return candidates;
|
|
2788
|
+
}
|
|
2479
2789
|
/**
|
|
2480
|
-
* Non-internal IPv4 interface addresses of this machine — the
|
|
2481
|
-
* authorities an all-interfaces bind is reachable by on the LAN.
|
|
2790
|
+
* Non-internal, reachable IPv4 interface addresses of this machine — the
|
|
2791
|
+
* IP-literal authorities an all-interfaces bind is reachable by on the LAN.
|
|
2792
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
2482
2793
|
* @returns the addresses in interface order (possibly empty).
|
|
2483
2794
|
*/
|
|
2484
|
-
function lanIPv4Addresses() {
|
|
2485
|
-
return
|
|
2486
|
-
return iface !== void 0 && iface.family === "IPv4" && !iface.internal;
|
|
2487
|
-
}).map((iface) => iface.address);
|
|
2795
|
+
function lanIPv4Addresses(interfaces = networkInterfaces()) {
|
|
2796
|
+
return lanIPv4Candidates(interfaces).map((candidate) => candidate.address);
|
|
2488
2797
|
}
|
|
2489
2798
|
//#endregion
|
|
2490
2799
|
//#region src/firewall.ts
|
|
@@ -2979,11 +3288,18 @@ function isHttpUrl(value) {
|
|
|
2979
3288
|
* token AND a valid public hostname (the token does not carry the hostname,
|
|
2980
3289
|
* so the QR base and the fence trust would have no source without it);
|
|
2981
3290
|
* otherwise no tunnel and the manual public base applies.
|
|
3291
|
+
*
|
|
3292
|
+
* The quick target URL names the local webServer port, so a plan made before
|
|
3293
|
+
* the listener binds cannot be quick: it comes back `off` and the caller —
|
|
3294
|
+
* which can observe the port settle — re-plans. The planner itself never
|
|
3295
|
+
* emits an `http://127.0.0.1:undefined` target.
|
|
2982
3296
|
* @param value - the resolved settings slice that drives the tunnel.
|
|
2983
|
-
* @param port - the local webServer port the tunnel forwards to
|
|
3297
|
+
* @param port - the local webServer port the tunnel forwards to (undefined
|
|
3298
|
+
* before the listener binds).
|
|
2984
3299
|
*/
|
|
2985
3300
|
function tunnelPlanOf(value, port) {
|
|
2986
3301
|
if (value.autoTunnel === true) {
|
|
3302
|
+
if (port === void 0) return { mode: "off" };
|
|
2987
3303
|
const ignored = [];
|
|
2988
3304
|
if (value.tunnelToken !== void 0 && value.tunnelToken !== "") ignored.push("tunnelToken");
|
|
2989
3305
|
if (value.publicBaseUrl !== void 0 && value.publicBaseUrl !== "") ignored.push("publicBaseUrl");
|
|
@@ -4000,12 +4316,12 @@ function mountOnce(packageName, fn) {
|
|
|
4000
4316
|
/**
|
|
4001
4317
|
* Run one piece of work outside the caller's async context.
|
|
4002
4318
|
*
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4008
|
-
*
|
|
4319
|
+
* A Host mutation that runs inside `hmr.runExclusive` marks the running async
|
|
4320
|
+
* context with an AsyncLocalStorage store. Side effects on that path that write
|
|
4321
|
+
* a file the HMR config watcher refreshes from - `cordis.patch.yml` above all -
|
|
4322
|
+
* must not run under that mark, or the watcher's refresh re-enters the
|
|
4323
|
+
* exclusive transaction and is refused with "HMR transactions cannot be nested"
|
|
4324
|
+
* (#1751, #1754, #1816).
|
|
4009
4325
|
*
|
|
4010
4326
|
* Deferring is NOT enough, and the earlier belt that this one replaces was
|
|
4011
4327
|
* built on a disproven premise: Node propagates an AsyncLocalStorage store into
|
|
@@ -4027,7 +4343,7 @@ function mountOnce(packageName, fn) {
|
|
|
4027
4343
|
* // -> undefined
|
|
4028
4344
|
* ```
|
|
4029
4345
|
*
|
|
4030
|
-
* @module
|
|
4346
|
+
* @module dsh-web-shared/host/detached-work
|
|
4031
4347
|
*/
|
|
4032
4348
|
/**
|
|
4033
4349
|
* The escape hatch, created at module scope on purpose: a resource built while
|
|
@@ -4035,7 +4351,7 @@ function mountOnce(packageName, fn) {
|
|
|
4035
4351
|
* instance is the only one the module ever needs. It carries no context of its
|
|
4036
4352
|
* own, so every job run through it starts from the process's root context.
|
|
4037
4353
|
*/
|
|
4038
|
-
const DETACHED = new AsyncResource("dsh-
|
|
4354
|
+
const DETACHED = new AsyncResource("dsh-web:detached-work", { triggerAsyncId: 0 });
|
|
4039
4355
|
/**
|
|
4040
4356
|
* Run `work` outside the caller's AsyncLocalStorage context.
|
|
4041
4357
|
*
|
|
@@ -4095,7 +4411,7 @@ function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
|
|
|
4095
4411
|
}
|
|
4096
4412
|
/** Boot-watchdog latch: one self-reload per session while the boot is broken. */
|
|
4097
4413
|
const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
|
|
4098
|
-
/**
|
|
4414
|
+
/** Post-load wait for the app's first conversation surface before the reload. */
|
|
4099
4415
|
const BOOT_WATCHDOG_WAIT_MS = 15e3;
|
|
4100
4416
|
/** Watchdog poll cadence. */
|
|
4101
4417
|
const BOOT_WATCHDOG_POLL_MS = 1e3;
|
|
@@ -4110,7 +4426,7 @@ const BOOT_WATCHDOG_POLL_MS = 1e3;
|
|
|
4110
4426
|
*/
|
|
4111
4427
|
function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS) {
|
|
4112
4428
|
const key = JSON.stringify(BOOT_WATCHDOG_KEY);
|
|
4113
|
-
return ";function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector(\"[data-conversation-scroll]\")||w.document.querySelector(\"[data-slot=\\\"conversation\\\"]\")))}catch(e){return false}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
|
|
4429
|
+
return ";function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector(\"[data-conversation-scroll]\")||w.document.querySelector(\"[data-slot=\\\"conversation\\\"]\")))}catch(e){return false}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(!w.document||w.document.readyState!==\"complete\"){w.setTimeout(function(){wTick(0)}," + pollMs + ");return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
|
|
4114
4430
|
}
|
|
4115
4431
|
/** The script this plugin contributes; built once from the live rules. */
|
|
4116
4432
|
const REMOTE_CHANNEL_BOOT_SCRIPT = buildRemoteChannelBootScript();
|
|
@@ -4206,10 +4522,16 @@ const Config = z.object({
|
|
|
4206
4522
|
relay: z.boolean().default(true).volatile(),
|
|
4207
4523
|
lanBind: z.boolean().volatile(),
|
|
4208
4524
|
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
4209
|
-
enabled: z.boolean().default(true).volatile()
|
|
4525
|
+
enabled: z.boolean().default(true).volatile(),
|
|
4526
|
+
pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default("auto").volatile()
|
|
4210
4527
|
});
|
|
4211
4528
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
4212
4529
|
const SWEEP_INTERVAL_MS = 1e4;
|
|
4530
|
+
/**
|
|
4531
|
+
* Re-plan retry while the quick tunnel waits for the webServer port to bind
|
|
4532
|
+
* (a recompose pass can run this apply ahead of the listener's bind).
|
|
4533
|
+
*/
|
|
4534
|
+
const PORT_SETTLE_RETRY_MS = 1e3;
|
|
4213
4535
|
/** Read one resolved config field, following the live reference the schema produces. */
|
|
4214
4536
|
function readConfigField(field, fallback) {
|
|
4215
4537
|
if (field === void 0) return fallback;
|
|
@@ -4264,7 +4586,8 @@ const DEFAULTS = {
|
|
|
4264
4586
|
relay: true,
|
|
4265
4587
|
lanBind: void 0,
|
|
4266
4588
|
profile: resolveManagedProfile(void 0, void 0, process.env.DSH_PROFILE),
|
|
4267
|
-
enabled: true
|
|
4589
|
+
enabled: true,
|
|
4590
|
+
pairingSource: "auto"
|
|
4268
4591
|
};
|
|
4269
4592
|
/**
|
|
4270
4593
|
* The launched profile the Host publishes on its `profileContext` service
|
|
@@ -4302,7 +4625,8 @@ function applyImpl(ctx, config) {
|
|
|
4302
4625
|
relay: readConfigField(config?.relay, DEFAULTS.relay),
|
|
4303
4626
|
lanBind: readOptionalConfigField(config?.lanBind),
|
|
4304
4627
|
profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
|
|
4305
|
-
enabled: readConfigField(config?.enabled, DEFAULTS.enabled)
|
|
4628
|
+
enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
|
|
4629
|
+
pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource)
|
|
4306
4630
|
});
|
|
4307
4631
|
const service = new PairingService(pairingConfigOf(resolve()));
|
|
4308
4632
|
const tunnel = new TunnelManager();
|
|
@@ -4346,6 +4670,14 @@ function applyImpl(ctx, config) {
|
|
|
4346
4670
|
registrar.announce(target);
|
|
4347
4671
|
};
|
|
4348
4672
|
let tunnelMode = resolve().autoTunnel ? "quick" : "off";
|
|
4673
|
+
/**
|
|
4674
|
+
* Retry timer while the quick tunnel waits for the webServer port to bind
|
|
4675
|
+
* (undefined when no deferral is pending). Unref'd: never keeps the
|
|
4676
|
+
* process alive on its own.
|
|
4677
|
+
*/
|
|
4678
|
+
let portSettleTimer;
|
|
4679
|
+
/** Dedupes the unbound-port warning to one line per unsettled episode. */
|
|
4680
|
+
let portSettleWarned = false;
|
|
4349
4681
|
tunnel.onPhase((info) => {
|
|
4350
4682
|
if (tunnelMode === "off") return;
|
|
4351
4683
|
if (info.phase === "running" && info.url !== void 0) {
|
|
@@ -4372,6 +4704,7 @@ function applyImpl(ctx, config) {
|
|
|
4372
4704
|
disposeRelayRegistrar();
|
|
4373
4705
|
publicBase.dispose();
|
|
4374
4706
|
tunnel.dispose();
|
|
4707
|
+
if (portSettleTimer !== void 0) clearTimeout(portSettleTimer);
|
|
4375
4708
|
}, "remote-web-ui: auto tunnel");
|
|
4376
4709
|
if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
|
|
4377
4710
|
const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
|
|
@@ -4445,6 +4778,7 @@ function applyImpl(ctx, config) {
|
|
|
4445
4778
|
const routes = [...makeRoutes({
|
|
4446
4779
|
service,
|
|
4447
4780
|
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
4781
|
+
pairingSource: () => resolve().pairingSource,
|
|
4448
4782
|
lanBindStatus,
|
|
4449
4783
|
indexDocument: fetchAppShell,
|
|
4450
4784
|
trustedHosts: () => {
|
|
@@ -4465,12 +4799,12 @@ function applyImpl(ctx, config) {
|
|
|
4465
4799
|
}
|
|
4466
4800
|
}), ...makeRemoteApiRoutes({
|
|
4467
4801
|
service,
|
|
4468
|
-
port: ctx.webServer.port,
|
|
4802
|
+
port: () => ctx.webServer.port,
|
|
4469
4803
|
auth: innerAuth
|
|
4470
4804
|
})];
|
|
4471
4805
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
4472
4806
|
service,
|
|
4473
|
-
port: ctx.webServer.port,
|
|
4807
|
+
port: () => ctx.webServer.port,
|
|
4474
4808
|
auth: innerAuth
|
|
4475
4809
|
});
|
|
4476
4810
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
@@ -4479,7 +4813,9 @@ function applyImpl(ctx, config) {
|
|
|
4479
4813
|
let postureWasExposed = false;
|
|
4480
4814
|
const runPostureProbe = () => {
|
|
4481
4815
|
if (!resolve().enabled) return;
|
|
4482
|
-
const
|
|
4816
|
+
const port = ctx.webServer.port;
|
|
4817
|
+
if (port === void 0) return;
|
|
4818
|
+
const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port);
|
|
4483
4819
|
if (targets.length === 0) {
|
|
4484
4820
|
postureKey = void 0;
|
|
4485
4821
|
service.setPosture(void 0);
|
|
@@ -4490,7 +4826,7 @@ function applyImpl(ctx, config) {
|
|
|
4490
4826
|
if (!claim.run) return;
|
|
4491
4827
|
postureKey = claim.next;
|
|
4492
4828
|
probePosture({
|
|
4493
|
-
port
|
|
4829
|
+
port,
|
|
4494
4830
|
targets
|
|
4495
4831
|
}).then((snapshot) => {
|
|
4496
4832
|
service.setPosture(snapshot);
|
|
@@ -4573,11 +4909,8 @@ function applyImpl(ctx, config) {
|
|
|
4573
4909
|
ctx.effect(() => () => {
|
|
4574
4910
|
lanBindWorkPending = false;
|
|
4575
4911
|
}, "remote-web-ui: lan-bind work");
|
|
4576
|
-
const
|
|
4577
|
-
const
|
|
4578
|
-
service.config = pairingConfigOf(value);
|
|
4579
|
-
scheduleLanBindWork();
|
|
4580
|
-
const plan = tunnelPlanOf(value, ctx.webServer.port);
|
|
4912
|
+
const applyTunnelPlan = (value, port) => {
|
|
4913
|
+
const plan = tunnelPlanOf(value, port);
|
|
4581
4914
|
tunnelMode = plan.mode;
|
|
4582
4915
|
publicBase.setMode(plan.mode);
|
|
4583
4916
|
const liveTunnelUrl = publicBase.quickUrl();
|
|
@@ -4615,6 +4948,28 @@ function applyImpl(ctx, config) {
|
|
|
4615
4948
|
service.setPublicBaseUrl(void 0);
|
|
4616
4949
|
} else service.setPublicBaseUrl(value.publicBaseUrl);
|
|
4617
4950
|
}
|
|
4951
|
+
};
|
|
4952
|
+
const sync = () => {
|
|
4953
|
+
const value = resolve();
|
|
4954
|
+
service.config = pairingConfigOf(value);
|
|
4955
|
+
scheduleLanBindWork();
|
|
4956
|
+
const livePort = ctx.webServer.port;
|
|
4957
|
+
if (livePort === void 0 && value.autoTunnel === true) {
|
|
4958
|
+
if (!portSettleWarned) {
|
|
4959
|
+
portSettleWarned = true;
|
|
4960
|
+
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");
|
|
4961
|
+
}
|
|
4962
|
+
if (portSettleTimer === void 0) {
|
|
4963
|
+
portSettleTimer = setTimeout$1(() => {
|
|
4964
|
+
portSettleTimer = void 0;
|
|
4965
|
+
sync();
|
|
4966
|
+
}, PORT_SETTLE_RETRY_MS);
|
|
4967
|
+
portSettleTimer.unref();
|
|
4968
|
+
}
|
|
4969
|
+
} else {
|
|
4970
|
+
portSettleWarned = false;
|
|
4971
|
+
applyTunnelPlan(value, livePort);
|
|
4972
|
+
}
|
|
4618
4973
|
const enabled = value.enabled;
|
|
4619
4974
|
if (!enabled) {
|
|
4620
4975
|
service.stop();
|