dsh-mobile 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/CHANGELOG.md +23 -0
- package/CONTRIBUTORS.md +11 -3
- package/README.en.md +38 -11
- package/README.md +38 -11
- package/SECURITY.md +7 -5
- package/docs/ATTACH_EXISTING_FRPS.en.md +44 -0
- package/docs/ATTACH_EXISTING_FRPS.md +98 -0
- package/docs/README.md +3 -0
- package/docs/SELF_HOSTED_FRP.en.md +3 -1
- package/docs/SELF_HOSTED_FRP.md +7 -1
- package/lib/cli.js +52 -15
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1298 -156
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +390 -12
- package/lib/index.mjs +2268 -581
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-compat.js.map +1 -1
- package/lib/mobile-layout.js +75 -20
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +18 -14
package/lib/index.mjs
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
|
-
import { connect, createServer
|
|
7
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { connect, createServer } from "node:net";
|
|
7
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
8
8
|
import { execFile, spawn } from "node:child_process";
|
|
9
9
|
import { createSocket } from "node:dgram";
|
|
10
10
|
import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
|
|
11
11
|
import { createServer as createServer$1, request } from "node:http";
|
|
12
|
-
import { createServer as createServer$2 } from "node:https";
|
|
13
|
-
import { Transform, finished } from "node:stream";
|
|
12
|
+
import { createServer as createServer$2, request as request$1 } from "node:https";
|
|
13
|
+
import { Readable, Transform, finished } from "node:stream";
|
|
14
14
|
import { pipeline } from "node:stream/promises";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
16
|
import { createGzip, gzip } from "node:zlib";
|
|
17
17
|
import Bonjour from "bonjour-service";
|
|
18
18
|
import * as QRCode from "qrcode";
|
|
19
19
|
import { Logger, Service } from "@deepseek-ai/cordis";
|
|
20
|
+
import { connect as connect$1 } from "node:tls";
|
|
21
|
+
import { generate } from "selfsigned";
|
|
20
22
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
|
|
21
23
|
import { lookup } from "node:dns/promises";
|
|
22
24
|
import { createWriteStream } from "node:fs";
|
|
23
|
-
import { generate } from "selfsigned";
|
|
24
25
|
//#region src/access.ts
|
|
25
26
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
26
27
|
var AccessError = class extends Error {
|
|
@@ -384,6 +385,88 @@ var AccessController = class {
|
|
|
384
385
|
}
|
|
385
386
|
};
|
|
386
387
|
//#endregion
|
|
388
|
+
//#region src/ip.ts
|
|
389
|
+
/**
|
|
390
|
+
* Browser-safe IP literal classification.
|
|
391
|
+
*
|
|
392
|
+
* `node:net` cannot be imported from the browser Client face, so the pure
|
|
393
|
+
* predicate lives here instead. Semantics are deliberately identical to
|
|
394
|
+
* `net.isIP`: strict dotted-quad IPv4 (no leading zeros, no signs, no
|
|
395
|
+
* whitespace), IPv6 with at most one `::`, at most four hex digits per group,
|
|
396
|
+
* an optional dotted-quad tail in the final position, and an optional
|
|
397
|
+
* non-empty zone id after a single `%`.
|
|
398
|
+
*
|
|
399
|
+
* This module is dependency-free and side-effect-free on purpose: it is the
|
|
400
|
+
* single classification source shared by every face of the plugin — the Node
|
|
401
|
+
* side (`config.ts`, `cloudflared-tunnel.ts`, `origin-proxy-config.ts`,
|
|
402
|
+
* `vps-deploy.ts`, `gateway.ts`, `network.ts`, `frp-config.ts`) and the browser
|
|
403
|
+
* Client face that cannot reach `node:net` — so both classify a given literal
|
|
404
|
+
* identically. Callers rely on the strict reading above and never post-process
|
|
405
|
+
* the literal afterwards, so changing the accept/reject boundary here changes
|
|
406
|
+
* it everywhere, which is the intended contract.
|
|
407
|
+
*/
|
|
408
|
+
/** Strict dotted-quad IPv4: POSIX `inet_pton` rules, so `01.1.1.1` is rejected. */
|
|
409
|
+
function isStrictIpv4(value) {
|
|
410
|
+
const parts = value.split(".");
|
|
411
|
+
if (parts.length !== 4) return false;
|
|
412
|
+
for (const part of parts) {
|
|
413
|
+
if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(part)) return false;
|
|
414
|
+
if (Number(part) > 255) return false;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Count the 16-bit groups one side of an IPv6 address occupies.
|
|
420
|
+
*
|
|
421
|
+
* A dotted-quad tail counts as two groups and is accepted only where the caller
|
|
422
|
+
* allows it — that is, only in the final label of the whole address.
|
|
423
|
+
*
|
|
424
|
+
* @returns the group count, or `-1` when any label is malformed.
|
|
425
|
+
*/
|
|
426
|
+
function countIpv6Groups(labels, allowDottedQuad) {
|
|
427
|
+
let groups = 0;
|
|
428
|
+
for (let index = 0; index < labels.length; index += 1) {
|
|
429
|
+
const label = labels[index];
|
|
430
|
+
if (label.includes(".")) {
|
|
431
|
+
if (index !== labels.length - 1 || !allowDottedQuad || !isStrictIpv4(label)) return -1;
|
|
432
|
+
groups += 2;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!/^[\da-f]{1,4}$/iu.test(label)) return -1;
|
|
436
|
+
groups += 1;
|
|
437
|
+
}
|
|
438
|
+
return groups;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Classify an IP literal.
|
|
442
|
+
*
|
|
443
|
+
* @param value - candidate address; non-strings are never an IP.
|
|
444
|
+
* @returns `4` for IPv4, `6` for IPv6, `0` when the value is neither.
|
|
445
|
+
*/
|
|
446
|
+
function isIP(value) {
|
|
447
|
+
if (typeof value !== "string") return 0;
|
|
448
|
+
if (isStrictIpv4(value)) return 4;
|
|
449
|
+
let text = value;
|
|
450
|
+
const zone = text.indexOf("%");
|
|
451
|
+
if (zone !== -1) {
|
|
452
|
+
const suffix = text.slice(zone + 1);
|
|
453
|
+
if (suffix.length === 0 || suffix.includes("%")) return 0;
|
|
454
|
+
text = text.slice(0, zone);
|
|
455
|
+
}
|
|
456
|
+
if (text.length === 0) return 0;
|
|
457
|
+
const halves = text.split("::");
|
|
458
|
+
if (halves.length > 2) return 0;
|
|
459
|
+
if (halves.length === 1) return countIpv6Groups(text.split(":"), true) === 8 ? 6 : 0;
|
|
460
|
+
const leftText = halves[0];
|
|
461
|
+
const rightText = halves[1];
|
|
462
|
+
const left = leftText === "" ? [] : leftText.split(":");
|
|
463
|
+
const right = rightText === "" ? [] : rightText.split(":");
|
|
464
|
+
const leftGroups = countIpv6Groups(left, false);
|
|
465
|
+
const rightGroups = countIpv6Groups(right, right.length > 0);
|
|
466
|
+
if (leftGroups < 0 || rightGroups < 0) return 0;
|
|
467
|
+
return leftGroups + rightGroups >= 8 ? 0 : 6;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
387
470
|
//#region src/network.ts
|
|
388
471
|
function parseIpv4$1(address) {
|
|
389
472
|
const parts = address.split(".");
|
|
@@ -624,6 +707,43 @@ var RequestTrustPolicy = class {
|
|
|
624
707
|
return normalized;
|
|
625
708
|
}
|
|
626
709
|
};
|
|
710
|
+
/**
|
|
711
|
+
* Open a plain TCP connection only to learn whether something is listening.
|
|
712
|
+
*
|
|
713
|
+
* Used by the on-demand self-check so the panel can tell "frps is up" from
|
|
714
|
+
* "frps is not listening". A transparent proxy can acknowledge any connect, so
|
|
715
|
+
* this result is advisory and never authorises a security decision on its own.
|
|
716
|
+
*/
|
|
717
|
+
async function probeTcpReachable(host, port, timeoutMs = 1500) {
|
|
718
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return false;
|
|
719
|
+
return new Promise((resolveProbe) => {
|
|
720
|
+
let finished = false;
|
|
721
|
+
const socket = connect({
|
|
722
|
+
host,
|
|
723
|
+
port
|
|
724
|
+
});
|
|
725
|
+
const finish = (reachable) => {
|
|
726
|
+
if (finished) return;
|
|
727
|
+
finished = true;
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
socket.destroy();
|
|
730
|
+
resolveProbe(reachable);
|
|
731
|
+
};
|
|
732
|
+
const timer = setTimeout(() => {
|
|
733
|
+
finish(false);
|
|
734
|
+
}, timeoutMs);
|
|
735
|
+
timer.unref();
|
|
736
|
+
socket.once("connect", () => {
|
|
737
|
+
finish(true);
|
|
738
|
+
});
|
|
739
|
+
socket.once("error", () => {
|
|
740
|
+
finish(false);
|
|
741
|
+
});
|
|
742
|
+
socket.once("close", () => {
|
|
743
|
+
finish(false);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
}
|
|
627
747
|
//#endregion
|
|
628
748
|
//#region src/config.ts
|
|
629
749
|
/** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
|
|
@@ -641,6 +761,7 @@ const Config = z.object({
|
|
|
641
761
|
customScriptFile: z.string().hidden(),
|
|
642
762
|
mobileLayoutFile: z.string().hidden(),
|
|
643
763
|
mobileCompatibilityFile: z.string().hidden(),
|
|
764
|
+
excludedClientModules: z.array(String).default([]),
|
|
644
765
|
instanceId: z.string().hidden(),
|
|
645
766
|
pairingCaFile: z.string().hidden(),
|
|
646
767
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
@@ -677,6 +798,13 @@ function absoluteFile(value, name) {
|
|
|
677
798
|
if (typeof value !== "string" || value.length === 0 || !isAbsolute(value)) throw new Error(`${name} must be an absolute file path`);
|
|
678
799
|
return resolve(value);
|
|
679
800
|
}
|
|
801
|
+
function excludedClientModules(value) {
|
|
802
|
+
if (value === void 0) return Object.freeze([]);
|
|
803
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id)) throw new Error("excludedClientModules must be an array of exact, non-empty client module ids");
|
|
804
|
+
const ids = value;
|
|
805
|
+
if (new Set(ids).size !== ids.length) throw new Error("excludedClientModules must not contain duplicate ids");
|
|
806
|
+
return Object.freeze([...ids]);
|
|
807
|
+
}
|
|
680
808
|
/** Resolve the hidden runtime-control file independently from gateway configuration. */
|
|
681
809
|
function parseControlFile(value) {
|
|
682
810
|
return absoluteFile(value, "controlFile");
|
|
@@ -767,6 +895,7 @@ function parseGatewayConfig(raw) {
|
|
|
767
895
|
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
768
896
|
mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
|
|
769
897
|
mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
|
|
898
|
+
excludedClientModules: excludedClientModules(value.excludedClientModules),
|
|
770
899
|
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
771
900
|
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
772
901
|
})(),
|
|
@@ -817,8 +946,13 @@ function execFileText(file, args, options = {}) {
|
|
|
817
946
|
//#endregion
|
|
818
947
|
//#region src/private-file.ts
|
|
819
948
|
let userSidTask;
|
|
949
|
+
function windowsSystemTool(name) {
|
|
950
|
+
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
|
|
951
|
+
if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
|
|
952
|
+
return win32.join(systemRoot, "System32", name);
|
|
953
|
+
}
|
|
820
954
|
async function currentWindowsUserSid() {
|
|
821
|
-
userSidTask ??= execFileText("whoami.exe", [
|
|
955
|
+
userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
|
|
822
956
|
"/user",
|
|
823
957
|
"/fo",
|
|
824
958
|
"csv",
|
|
@@ -841,11 +975,12 @@ async function currentWindowsUserSid() {
|
|
|
841
975
|
async function restrictPrivateFile(file, mode = 384) {
|
|
842
976
|
await chmod(file, mode);
|
|
843
977
|
if (process.platform !== "win32") return;
|
|
844
|
-
await
|
|
978
|
+
const userSid = await currentWindowsUserSid();
|
|
979
|
+
await execFileText(windowsSystemTool("icacls.exe"), [
|
|
845
980
|
file,
|
|
846
981
|
"/inheritance:r",
|
|
847
982
|
"/grant:r",
|
|
848
|
-
`*${
|
|
983
|
+
`*${userSid}:(F)`,
|
|
849
984
|
"*S-1-5-18:(F)",
|
|
850
985
|
"*S-1-5-32-544:(F)",
|
|
851
986
|
"/remove:g",
|
|
@@ -2488,6 +2623,17 @@ const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
|
|
|
2488
2623
|
const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
|
|
2489
2624
|
const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
|
|
2490
2625
|
const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
|
|
2626
|
+
const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
|
|
2627
|
+
MOBILE_CLIENT_MODULE,
|
|
2628
|
+
MOBILE_LAYOUT_MODULE,
|
|
2629
|
+
CONNECTION_MODULE,
|
|
2630
|
+
RUNTIME_MODULE,
|
|
2631
|
+
RENDERER_MODULE,
|
|
2632
|
+
SIDEBAR_MODULE,
|
|
2633
|
+
SETTINGS_MODULE,
|
|
2634
|
+
API_GATEWAY_MODULE,
|
|
2635
|
+
API_REMOTES_MODULE
|
|
2636
|
+
]);
|
|
2491
2637
|
const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
2492
2638
|
slots: RUNTIME_MODULE,
|
|
2493
2639
|
dependencies: Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"])
|
|
@@ -2502,6 +2648,57 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2502
2648
|
})]);
|
|
2503
2649
|
const MOBILE_CSRF_FETCH_BOOTSTRAP = `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${CSRF_COOKIE}=`)};const token=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith(prefix))?.slice(prefix.length);if(token!==undefined)headers.set(${JSON.stringify(CSRF_HEADER)},token)}return nativeFetch(input,{...init,headers})};})();`;
|
|
2504
2650
|
const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true}})();`;
|
|
2651
|
+
/** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
|
|
2652
|
+
const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
|
|
2653
|
+
const view=globalThis;
|
|
2654
|
+
const port=view.navigator;
|
|
2655
|
+
const nativeFetch=typeof view.fetch==='function'?view.fetch.bind(view):undefined;
|
|
2656
|
+
let reachable=true;
|
|
2657
|
+
let checking=false;
|
|
2658
|
+
let replaying=false;
|
|
2659
|
+
let disposed=false;
|
|
2660
|
+
let epoch=0;
|
|
2661
|
+
let retryTimer;
|
|
2662
|
+
let activeController;
|
|
2663
|
+
const clearRetry=()=>{if(retryTimer!==undefined){view.clearTimeout(retryTimer);retryTimer=undefined}};
|
|
2664
|
+
const scheduleRetry=()=>{if(!disposed&&!reachable&&retryTimer===undefined){retryTimer=view.setTimeout(()=>{retryTimer=undefined;check()},5000)}};
|
|
2665
|
+
const check=()=>{
|
|
2666
|
+
if(checking||disposed||nativeFetch===undefined)return;
|
|
2667
|
+
checking=true;
|
|
2668
|
+
const current=++epoch;
|
|
2669
|
+
const controller=new AbortController();
|
|
2670
|
+
activeController=controller;
|
|
2671
|
+
const timer=view.setTimeout(()=>controller.abort(),2000);
|
|
2672
|
+
void nativeFetch('/mobile-access/health',{cache:'no-store',credentials:'same-origin',signal:controller.signal})
|
|
2673
|
+
.then(response=>response.ok,()=>false)
|
|
2674
|
+
.then(ok=>{
|
|
2675
|
+
if(disposed||current!==epoch)return;
|
|
2676
|
+
if(ok){
|
|
2677
|
+
const wasOffline=!reachable;
|
|
2678
|
+
reachable=true;
|
|
2679
|
+
clearRetry();
|
|
2680
|
+
if(wasOffline)view.dispatchEvent(new Event('online'));
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
const wasOnline=reachable;
|
|
2684
|
+
reachable=false;
|
|
2685
|
+
if(wasOnline){
|
|
2686
|
+
replaying=true;
|
|
2687
|
+
try{view.dispatchEvent(new Event('offline'))}finally{replaying=false}
|
|
2688
|
+
}
|
|
2689
|
+
scheduleRetry();
|
|
2690
|
+
})
|
|
2691
|
+
.finally(()=>{view.clearTimeout(timer);activeController=undefined;checking=false});
|
|
2692
|
+
};
|
|
2693
|
+
if(port!==undefined&&typeof Object.defineProperty==='function'){
|
|
2694
|
+
try{Object.defineProperty(port,'onLine',{configurable:true,get:()=>reachable})}catch{/* A frozen navigator retains its native value. */}
|
|
2695
|
+
}
|
|
2696
|
+
if(typeof view.addEventListener==='function'){
|
|
2697
|
+
view.addEventListener('offline',event=>{if(replaying||nativeFetch===undefined)return;event.stopImmediatePropagation();check()},true);
|
|
2698
|
+
view.addEventListener('online',()=>{++epoch;reachable=true;clearRetry()},true);
|
|
2699
|
+
view.addEventListener('pagehide',()=>{disposed=true;++epoch;clearRetry();activeController?.abort()},true);
|
|
2700
|
+
}
|
|
2701
|
+
})();`;
|
|
2505
2702
|
const gzipBuffer = promisify(gzip);
|
|
2506
2703
|
function ensureMobileViewport(html) {
|
|
2507
2704
|
const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
|
|
@@ -2542,13 +2739,49 @@ function revisionedMobileBatchPath(entries) {
|
|
|
2542
2739
|
path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
|
|
2543
2740
|
};
|
|
2544
2741
|
}
|
|
2742
|
+
var ExcludedClientModulesError = class extends Error {
|
|
2743
|
+
constructor(message) {
|
|
2744
|
+
super(`excludedClientModules: ${message}`);
|
|
2745
|
+
}
|
|
2746
|
+
};
|
|
2747
|
+
/** Validate graph edges before removing application rows from both manifest views. */
|
|
2748
|
+
function excludeMobileBootEntries(entries, batches, excludedIds) {
|
|
2749
|
+
if (excludedIds.length === 0) return;
|
|
2750
|
+
const excluded = new Set(excludedIds);
|
|
2751
|
+
if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
|
|
2752
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
2753
|
+
const phases = /* @__PURE__ */ new Map();
|
|
2754
|
+
for (const batch of batches) for (const id of batch.entries) {
|
|
2755
|
+
if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
|
|
2756
|
+
phases.set(id, batch.phase);
|
|
2757
|
+
}
|
|
2758
|
+
for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
|
|
2759
|
+
for (const id of excluded) {
|
|
2760
|
+
const entry = byId.get(id);
|
|
2761
|
+
if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
|
|
2762
|
+
if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
|
|
2763
|
+
}
|
|
2764
|
+
for (const entry of entries) {
|
|
2765
|
+
if (excluded.has(entry.id)) continue;
|
|
2766
|
+
for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
|
|
2767
|
+
if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
|
|
2768
|
+
for (const dependency of edges ?? []) {
|
|
2769
|
+
const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
|
|
2770
|
+
if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
|
|
2775
|
+
for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
|
|
2776
|
+
batches.splice(0, batches.length, ...batches.filter((batch) => batch.entries.length > 0));
|
|
2777
|
+
}
|
|
2545
2778
|
/**
|
|
2546
2779
|
* Parse one upstream boot manifest and rewire the layout entry onto the
|
|
2547
|
-
* gateway-owned layout module.
|
|
2548
|
-
*
|
|
2549
|
-
* gateway, so the
|
|
2780
|
+
* gateway-owned layout module. Optional exclusions remove application entries
|
|
2781
|
+
* and identify every affected batch for reassembly. The dedicated frontend is
|
|
2782
|
+
* authenticated in the gateway, so the layout needs no upstream address.
|
|
2550
2783
|
*/
|
|
2551
|
-
function parseMobileBootPlan(html) {
|
|
2784
|
+
function parseMobileBootPlan(html, excludedClientModules = []) {
|
|
2552
2785
|
const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
|
|
2553
2786
|
if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
|
|
2554
2787
|
const replaceStart = assignment.index;
|
|
@@ -2567,14 +2800,17 @@ function parseMobileBootPlan(html) {
|
|
|
2567
2800
|
layout[0].url = MOBILE_LAYOUT_PATH;
|
|
2568
2801
|
layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
|
|
2569
2802
|
const parsedBatches = parsed.batches;
|
|
2570
|
-
if (parsedBatches === void 0)
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2803
|
+
if (parsedBatches === void 0) {
|
|
2804
|
+
if (excludedClientModules.length > 0) throw new ExcludedClientModulesError("this DSH version has no batched boot manifest; disable exclusions or update DSH");
|
|
2805
|
+
return Object.freeze({
|
|
2806
|
+
parsed,
|
|
2807
|
+
entries,
|
|
2808
|
+
slotProvider: dependencyProfile.slots,
|
|
2809
|
+
assignment: assignment[0],
|
|
2810
|
+
replaceStart,
|
|
2811
|
+
replaceEnd: scriptEnd
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2578
2814
|
if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
|
|
2579
2815
|
const batches = parsedBatches;
|
|
2580
2816
|
const entryById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
@@ -2586,15 +2822,26 @@ function parseMobileBootPlan(html) {
|
|
|
2586
2822
|
}
|
|
2587
2823
|
if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
|
|
2588
2824
|
const layoutBatch = layoutBatches[0];
|
|
2589
|
-
const
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2825
|
+
const originalApplicationBatches = batches.filter((batch) => batch.phase === "application").map((batch) => ({
|
|
2826
|
+
...batch,
|
|
2827
|
+
entries: [...batch.entries]
|
|
2828
|
+
}));
|
|
2829
|
+
excludeMobileBootEntries(entries, batches, excludedClientModules);
|
|
2830
|
+
const affectedBatchUrls = new Set(originalApplicationBatches.filter((batch) => batch.entries.some((id) => excludedClientModules.includes(id))).map((batch) => batch.url));
|
|
2831
|
+
const rewrittenBatchEntries = batches.filter((batch) => batch === layoutBatch || batch.phase === "application" && affectedBatchUrls.has(batch.url)).map((batch) => ({
|
|
2832
|
+
batch,
|
|
2833
|
+
entries: [...batch.entries].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).map((id) => {
|
|
2834
|
+
const entry = entryById.get(id);
|
|
2835
|
+
if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
|
|
2836
|
+
return Object.freeze({
|
|
2837
|
+
id,
|
|
2838
|
+
url: entry.url,
|
|
2839
|
+
rev: entry.rev
|
|
2840
|
+
});
|
|
2841
|
+
})
|
|
2842
|
+
}));
|
|
2843
|
+
const planEntries = rewrittenBatchEntries.find((candidate) => candidate.batch === layoutBatch)?.entries;
|
|
2844
|
+
if (planEntries === void 0) throw new Error("upstream DSH boot manifest lost the application layout batch");
|
|
2598
2845
|
return Object.freeze({
|
|
2599
2846
|
parsed,
|
|
2600
2847
|
entries,
|
|
@@ -2604,7 +2851,9 @@ function parseMobileBootPlan(html) {
|
|
|
2604
2851
|
replaceStart,
|
|
2605
2852
|
replaceEnd: scriptEnd,
|
|
2606
2853
|
layoutBatch,
|
|
2607
|
-
planEntries
|
|
2854
|
+
planEntries,
|
|
2855
|
+
originalApplicationBatches,
|
|
2856
|
+
rewrittenBatchEntries
|
|
2608
2857
|
});
|
|
2609
2858
|
}
|
|
2610
2859
|
/**
|
|
@@ -2663,30 +2912,62 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
|
|
|
2663
2912
|
rows
|
|
2664
2913
|
};
|
|
2665
2914
|
}
|
|
2915
|
+
function escapeMobilePreloadAttribute(value) {
|
|
2916
|
+
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
2917
|
+
}
|
|
2918
|
+
function mobileApplicationPreload(url) {
|
|
2919
|
+
return `<link rel="preload" as="script" href="${escapeMobilePreloadAttribute(url)}">`;
|
|
2920
|
+
}
|
|
2921
|
+
function preloadAttribute(tag, name) {
|
|
2922
|
+
const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "iu").exec(tag);
|
|
2923
|
+
return match?.[1] ?? match?.[2];
|
|
2924
|
+
}
|
|
2925
|
+
/** Replace only DSH's application-script preloads; bootstrap and unrelated links are untouched. */
|
|
2926
|
+
function rewriteMobileApplicationPreloads(html, replacements) {
|
|
2927
|
+
if (replacements.size === 0) return html;
|
|
2928
|
+
const escaped = new Map([...replacements].map(([url, markup]) => [escapeMobilePreloadAttribute(url), markup]));
|
|
2929
|
+
return html.replace(/<link\b[^>]*>/giu, (tag) => {
|
|
2930
|
+
if (!preloadAttribute(tag, "rel")?.split(/\s+/u).includes("preload") || preloadAttribute(tag, "as") !== "script") return tag;
|
|
2931
|
+
return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2666
2934
|
function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
2667
|
-
const plan = parseMobileBootPlan(html);
|
|
2935
|
+
const plan = parseMobileBootPlan(html, options.excludedClientModules);
|
|
2668
2936
|
const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
|
|
2669
2937
|
let batches = [];
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
const
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2938
|
+
let preloadReplacements = /* @__PURE__ */ new Map();
|
|
2939
|
+
if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
|
|
2940
|
+
const rewrites = plan.rewrittenBatchEntries.map(({ batch, entries }) => ({
|
|
2941
|
+
batch,
|
|
2942
|
+
split: splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())
|
|
2943
|
+
}));
|
|
2944
|
+
if (rewrites.reduce((count, item) => count + item.split.plans.length, 0) > MAX_MOBILE_BOOT_BATCHES) throw new Error(`upstream DSH mobile boot requires more than ${String(MAX_MOBILE_BOOT_BATCHES)} merged batches`);
|
|
2945
|
+
for (const { batch, split } of rewrites) {
|
|
2946
|
+
const at = plan.batches.indexOf(batch);
|
|
2947
|
+
if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
|
|
2948
|
+
plan.batches.splice(at, 1, ...split.rows);
|
|
2949
|
+
batches = [...batches, ...split.plans];
|
|
2950
|
+
}
|
|
2951
|
+
for (const original of plan.originalApplicationBatches ?? []) {
|
|
2952
|
+
const replacement = rewrites.find((item) => item.batch.url === original.url);
|
|
2953
|
+
preloadReplacements.set(original.url, replacement === void 0 ? plan.batches.some((batch) => batch.url === original.url) ? mobileApplicationPreload(original.url) : "" : replacement.split.rows.map((row) => mobileApplicationPreload(row.url)).join(""));
|
|
2954
|
+
}
|
|
2955
|
+
batches = Object.freeze(batches);
|
|
2676
2956
|
plan.parsed.rev = createHash("sha256").update(JSON.stringify({
|
|
2677
2957
|
entries: plan.entries,
|
|
2678
2958
|
batches: plan.batches
|
|
2679
2959
|
})).digest("hex").slice(0, 16);
|
|
2680
2960
|
}
|
|
2681
|
-
const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
2961
|
+
const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
2962
|
+
const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
|
|
2682
2963
|
return Object.freeze({
|
|
2683
|
-
html: ensureMobileViewport(ensureMobileCompatibility(
|
|
2964
|
+
html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
|
|
2684
2965
|
batches
|
|
2685
2966
|
});
|
|
2686
2967
|
}
|
|
2687
|
-
/**
|
|
2688
|
-
function rewriteMobileIndex(html) {
|
|
2689
|
-
return rewriteMobileIndexWithBatch(html).html;
|
|
2968
|
+
/** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
|
|
2969
|
+
function rewriteMobileIndex(html, excludedClientModules = []) {
|
|
2970
|
+
return rewriteMobileIndexWithBatch(html, { excludedClientModules }).html;
|
|
2690
2971
|
}
|
|
2691
2972
|
var ByteLimitTransform = class extends Transform {
|
|
2692
2973
|
maximum;
|
|
@@ -3130,6 +3411,7 @@ var MobileAccessGateway = class {
|
|
|
3130
3411
|
mdnsError;
|
|
3131
3412
|
/** Names of the bundled mobile assets that could not be read, if any. */
|
|
3132
3413
|
mobileAssetError;
|
|
3414
|
+
lastModuleExclusionError;
|
|
3133
3415
|
bonjour;
|
|
3134
3416
|
pairingCaCertificate;
|
|
3135
3417
|
listenerPort;
|
|
@@ -3277,6 +3559,15 @@ var MobileAccessGateway = class {
|
|
|
3277
3559
|
throw error;
|
|
3278
3560
|
}
|
|
3279
3561
|
}
|
|
3562
|
+
/** Install a newly signed provided leaf for future TLS handshakes without dropping sessions. */
|
|
3563
|
+
async refreshProvidedTls() {
|
|
3564
|
+
if (!this.listenerTlsEnabled || this.config.tls.mode !== "provided") throw new Error("gateway does not terminate provided TLS");
|
|
3565
|
+
const server = this.server;
|
|
3566
|
+
if (server === void 0 || !this.started || this.closing) throw new Error("gateway is not running");
|
|
3567
|
+
const options = await tlsOptions(this.config);
|
|
3568
|
+
if (this.server !== server || this.closing) throw new Error("gateway is not running");
|
|
3569
|
+
server.setSecureContext(options);
|
|
3570
|
+
}
|
|
3280
3571
|
async startDiscovery(port) {
|
|
3281
3572
|
const socket = createSocket("udp4");
|
|
3282
3573
|
this.discoverySocket = socket;
|
|
@@ -4052,11 +4343,11 @@ var MobileAccessGateway = class {
|
|
|
4052
4343
|
this.upstreamAuthRequest = void 0;
|
|
4053
4344
|
}
|
|
4054
4345
|
}
|
|
4055
|
-
async proxyMobileIndex(request$
|
|
4346
|
+
async proxyMobileIndex(request$2, response, authorization) {
|
|
4056
4347
|
const holder = {};
|
|
4057
4348
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4058
4349
|
try {
|
|
4059
|
-
const upstreamHeaders = sanitizeRequestHeaders(request$
|
|
4350
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
|
|
4060
4351
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4061
4352
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4062
4353
|
upstreamHeaders["accept-encoding"] = "identity";
|
|
@@ -4090,11 +4381,26 @@ var MobileAccessGateway = class {
|
|
|
4090
4381
|
let body;
|
|
4091
4382
|
try {
|
|
4092
4383
|
const html = Buffer.concat(chunks).toString("utf8");
|
|
4093
|
-
const plan = parseMobileBootPlan(html);
|
|
4094
|
-
const rewritten = rewriteMobileIndexWithBatch(html, plan.
|
|
4384
|
+
const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
|
|
4385
|
+
const rewritten = rewriteMobileIndexWithBatch(html, plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
|
|
4386
|
+
...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
|
|
4387
|
+
excludedClientModules: this.config.excludedClientModules
|
|
4388
|
+
});
|
|
4095
4389
|
for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
|
|
4096
4390
|
body = Buffer.from(rewritten.html);
|
|
4097
|
-
} catch {
|
|
4391
|
+
} catch (error) {
|
|
4392
|
+
if (error instanceof ExcludedClientModulesError) {
|
|
4393
|
+
const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
|
|
4394
|
+
if (detail !== this.lastModuleExclusionError) {
|
|
4395
|
+
this.lastModuleExclusionError = detail;
|
|
4396
|
+
process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
|
|
4397
|
+
}
|
|
4398
|
+
sendJson(response, 409, {
|
|
4399
|
+
error: "excluded_client_modules_invalid",
|
|
4400
|
+
detail
|
|
4401
|
+
}, this.tlsEnabled);
|
|
4402
|
+
return;
|
|
4403
|
+
}
|
|
4098
4404
|
throw new HttpError(502, "upstream_unavailable");
|
|
4099
4405
|
}
|
|
4100
4406
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
@@ -4440,15 +4746,15 @@ var MobileAccessGateway = class {
|
|
|
4440
4746
|
}
|
|
4441
4747
|
throw new HttpError(502, "upstream_unavailable");
|
|
4442
4748
|
}
|
|
4443
|
-
async proxyHttpOnce(request$
|
|
4444
|
-
const declared = request$
|
|
4749
|
+
async proxyHttpOnce(request$3, response, authorization) {
|
|
4750
|
+
const declared = request$3.headers["content-length"];
|
|
4445
4751
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
4446
4752
|
const holder = {};
|
|
4447
4753
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4448
4754
|
let bodyDone;
|
|
4449
4755
|
try {
|
|
4450
|
-
const bufferedBody = request$
|
|
4451
|
-
const upstreamHeaders = sanitizeRequestHeaders(request$
|
|
4756
|
+
const bufferedBody = request$3.method === "POST" && request$3.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$3, await readBoundedBody(request$3, this.config.maxBodyBytes)) : void 0;
|
|
4757
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
|
|
4452
4758
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4453
4759
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4454
4760
|
if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
|
|
@@ -4457,8 +4763,8 @@ var MobileAccessGateway = class {
|
|
|
4457
4763
|
protocol: "http:",
|
|
4458
4764
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
4459
4765
|
port: Number(this.config.upstreamOrigin.port),
|
|
4460
|
-
method: request$
|
|
4461
|
-
path: request$
|
|
4766
|
+
method: request$3.method,
|
|
4767
|
+
path: request$3.url,
|
|
4462
4768
|
headers: upstreamHeaders,
|
|
4463
4769
|
agent: false
|
|
4464
4770
|
});
|
|
@@ -4468,7 +4774,7 @@ var MobileAccessGateway = class {
|
|
|
4468
4774
|
});
|
|
4469
4775
|
upstreamRequest.once("response", resolve);
|
|
4470
4776
|
upstreamRequest.once("error", reject);
|
|
4471
|
-
if (bufferedBody === void 0) bodyDone = pipeline(request$
|
|
4777
|
+
if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
4472
4778
|
else {
|
|
4473
4779
|
upstreamRequest.end(bufferedBody);
|
|
4474
4780
|
bodyDone = Promise.resolve();
|
|
@@ -4478,9 +4784,9 @@ var MobileAccessGateway = class {
|
|
|
4478
4784
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
4479
4785
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
4480
4786
|
const statusCode = proxied.statusCode ?? 502;
|
|
4481
|
-
const cacheControl = revisionedStaticCacheControl(request$
|
|
4787
|
+
const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
|
|
4482
4788
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
4483
|
-
const compressed = shouldCompressResponse(request$
|
|
4789
|
+
const compressed = shouldCompressResponse(request$3, proxied);
|
|
4484
4790
|
if (compressed) {
|
|
4485
4791
|
delete headers["accept-ranges"];
|
|
4486
4792
|
delete headers["content-length"];
|
|
@@ -4810,7 +5116,10 @@ var MobileAccessGateway = class {
|
|
|
4810
5116
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
4811
5117
|
if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
|
|
4812
5118
|
const opened = await this.access.openPairing(body.ttlMs);
|
|
4813
|
-
const
|
|
5119
|
+
const requiresRemoteCa = this.config.pairingCaFile !== void 0 && !this.config.discovery;
|
|
5120
|
+
const appKey = `dsh${requiresRemoteCa ? "2" : "1"}.${this.config.instanceId}.${opened.token}`;
|
|
5121
|
+
const fragment = requiresRemoteCa ? `key=${appKey}` : `instance=${this.config.instanceId}&token=${opened.token}`;
|
|
5122
|
+
const pairUrl = `${this.address().origin}/mobile-access/pair#${fragment}`;
|
|
4814
5123
|
const appPairUrl = pairUrl;
|
|
4815
5124
|
let qrSvg = "";
|
|
4816
5125
|
try {
|
|
@@ -4821,7 +5130,7 @@ var MobileAccessGateway = class {
|
|
|
4821
5130
|
} catch {}
|
|
4822
5131
|
sendJson(response, 201, {
|
|
4823
5132
|
...opened,
|
|
4824
|
-
appKey
|
|
5133
|
+
appKey,
|
|
4825
5134
|
pairUrl,
|
|
4826
5135
|
appPairUrl,
|
|
4827
5136
|
qrSvg
|
|
@@ -5486,8 +5795,6 @@ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
|
|
|
5486
5795
|
const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
|
|
5487
5796
|
/** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
|
|
5488
5797
|
const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
|
|
5489
|
-
/** Directory holding the public-IPv4 certificates installed by certbot. */
|
|
5490
|
-
const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
|
|
5491
5798
|
function publicIpv4Address(value) {
|
|
5492
5799
|
const parts = value.split(".");
|
|
5493
5800
|
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
|
|
@@ -5505,8 +5812,31 @@ function parsePublicOrigin(publicOrigin) {
|
|
|
5505
5812
|
if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicIpv4Address(url.hostname) && !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
|
|
5506
5813
|
return url.hostname;
|
|
5507
5814
|
}
|
|
5508
|
-
|
|
5509
|
-
|
|
5815
|
+
function resolveVhostHttpPort(options) {
|
|
5816
|
+
const port = options.vhostHttpPort ?? 7080;
|
|
5817
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_template_input_invalid");
|
|
5818
|
+
return port;
|
|
5819
|
+
}
|
|
5820
|
+
/**
|
|
5821
|
+
* A self-signed entry deliberately never reaches Caddy: the tunnel is a raw TCP
|
|
5822
|
+
* passthrough and the DSH gateway is the TLS endpoint. Refuse loudly instead of
|
|
5823
|
+
* emitting a Caddy site that would need the gateway's private CA on the VPS.
|
|
5824
|
+
*/
|
|
5825
|
+
function rejectSelfSignedCaddySite(options) {
|
|
5826
|
+
if (options.entryTls === "self-signed") throw new Error("frp_entry_tls_invalid");
|
|
5827
|
+
}
|
|
5828
|
+
/**
|
|
5829
|
+
* Build the Caddy site for one public host (without markers or import wiring).
|
|
5830
|
+
*
|
|
5831
|
+
* The second parameter accepts either a bare certificate directory (the legacy
|
|
5832
|
+
* signature) or a full {@link CaddySiteOptions} object, so existing callers keep
|
|
5833
|
+
* working unchanged.
|
|
5834
|
+
*/
|
|
5835
|
+
function createCaddySite(publicHost, options = {}) {
|
|
5836
|
+
const resolved = typeof options === "string" ? { certDir: options } : options;
|
|
5837
|
+
rejectSelfSignedCaddySite(resolved);
|
|
5838
|
+
const certDir = resolved.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
|
|
5839
|
+
const vhostHttpPort = resolveVhostHttpPort(resolved);
|
|
5510
5840
|
if (publicIpv4Address(publicHost)) return [
|
|
5511
5841
|
"{",
|
|
5512
5842
|
` default_sni ${publicHost}`,
|
|
@@ -5518,14 +5848,14 @@ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
|
|
|
5518
5848
|
"",
|
|
5519
5849
|
`https://${publicHost} {`,
|
|
5520
5850
|
` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
|
|
5521
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5851
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5522
5852
|
"}",
|
|
5523
5853
|
""
|
|
5524
5854
|
].join("\n");
|
|
5525
5855
|
if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
|
|
5526
5856
|
return [
|
|
5527
5857
|
`${publicHost} {`,
|
|
5528
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5858
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5529
5859
|
"}",
|
|
5530
5860
|
""
|
|
5531
5861
|
].join("\n");
|
|
@@ -5548,15 +5878,24 @@ function manualIpCertificateGuide(publicHost) {
|
|
|
5548
5878
|
"#"
|
|
5549
5879
|
].join("\n");
|
|
5550
5880
|
}
|
|
5551
|
-
/**
|
|
5552
|
-
|
|
5881
|
+
/**
|
|
5882
|
+
* Build the only supported frps config and Caddy snippet from validated user inputs.
|
|
5883
|
+
*
|
|
5884
|
+
* Default `options` reproduce the upstream artefact byte for byte; `attach` mode
|
|
5885
|
+
* passes the user's real vhost port, and `self-signed` is rejected because that
|
|
5886
|
+
* mode publishes a raw TCP proxy instead of an HTTP vhost.
|
|
5887
|
+
*/
|
|
5888
|
+
function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, options = {}) {
|
|
5553
5889
|
if (!Number.isSafeInteger(serverPort) || serverPort < 1 || serverPort > 65535 || token.length < 16 || token.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(token)) throw new Error("frp_template_input_invalid");
|
|
5890
|
+
rejectSelfSignedCaddySite(options);
|
|
5891
|
+
const vhostHttpPort = resolveVhostHttpPort(options);
|
|
5892
|
+
const certDir = options.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
|
|
5554
5893
|
const publicHost = parsePublicOrigin(publicOrigin);
|
|
5555
5894
|
const lines = [
|
|
5556
5895
|
"# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
|
|
5557
5896
|
`bindPort = ${String(serverPort)}`,
|
|
5558
5897
|
"proxyBindAddr = \"127.0.0.1\"",
|
|
5559
|
-
`vhostHTTPPort = ${String(
|
|
5898
|
+
`vhostHTTPPort = ${String(vhostHttpPort)}`,
|
|
5560
5899
|
"auth.method = \"token\"",
|
|
5561
5900
|
`auth.token = ${JSON.stringify(token)}`,
|
|
5562
5901
|
"",
|
|
@@ -5567,7 +5906,10 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5567
5906
|
"# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
|
|
5568
5907
|
"# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
|
|
5569
5908
|
`${FRP_CADDY_SNIPPET_MARKER}`,
|
|
5570
|
-
createCaddySite(publicHost
|
|
5909
|
+
createCaddySite(publicHost, {
|
|
5910
|
+
certDir,
|
|
5911
|
+
vhostHttpPort
|
|
5912
|
+
}).trimEnd(),
|
|
5571
5913
|
""
|
|
5572
5914
|
];
|
|
5573
5915
|
if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
|
|
@@ -5576,6 +5918,26 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5576
5918
|
//#endregion
|
|
5577
5919
|
//#region src/frp-config.ts
|
|
5578
5920
|
const MAX_SETTINGS_BYTES$1 = 8192;
|
|
5921
|
+
/** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
|
|
5922
|
+
const FRP_DEFAULT_PUBLIC_PORT = 33080;
|
|
5923
|
+
/** Ports owned by other DSH Mobile listeners; the public entry may never reuse them. */
|
|
5924
|
+
const FRP_RESERVED_PORTS = Object.freeze([
|
|
5925
|
+
3080,
|
|
5926
|
+
3443,
|
|
5927
|
+
3444
|
|
5928
|
+
]);
|
|
5929
|
+
/** Every key `settings.json` may contain. Unknown keys stay rejected. */
|
|
5930
|
+
const FRP_SETTINGS_KEYS = /* @__PURE__ */ new Set([
|
|
5931
|
+
"version",
|
|
5932
|
+
"serverAddress",
|
|
5933
|
+
"serverPort",
|
|
5934
|
+
"token",
|
|
5935
|
+
"publicOrigin",
|
|
5936
|
+
"mode",
|
|
5937
|
+
"entryTls",
|
|
5938
|
+
"vhostHttpPort",
|
|
5939
|
+
"publicPort"
|
|
5940
|
+
]);
|
|
5579
5941
|
function hostname$2(value) {
|
|
5580
5942
|
if (value.length > 253 || !value.includes(".")) return false;
|
|
5581
5943
|
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
@@ -5611,24 +5973,90 @@ function validateFrpPublicOrigin(value) {
|
|
|
5611
5973
|
if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5612
5974
|
return url.origin;
|
|
5613
5975
|
}
|
|
5614
|
-
/**
|
|
5976
|
+
/** Validate the provisioning mode. */
|
|
5977
|
+
function validateFrpMode(value) {
|
|
5978
|
+
if (value !== "deploy" && value !== "attach") throw new Error("frp_settings_invalid");
|
|
5979
|
+
return value;
|
|
5980
|
+
}
|
|
5981
|
+
/** Validate the entry TLS mode. */
|
|
5982
|
+
function validateFrpEntryTls(value) {
|
|
5983
|
+
if (value !== "public-ip-cert" && value !== "self-signed") throw new Error("frp_entry_tls_invalid");
|
|
5984
|
+
return value;
|
|
5985
|
+
}
|
|
5986
|
+
function validatePort(value, rejectReserved) {
|
|
5987
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("frp_settings_invalid");
|
|
5988
|
+
const port = Number(value);
|
|
5989
|
+
if (rejectReserved && FRP_RESERVED_PORTS.includes(port)) throw new Error("frp_settings_invalid");
|
|
5990
|
+
return port;
|
|
5991
|
+
}
|
|
5992
|
+
/** Validate the loopback vhost port of the target frps. */
|
|
5993
|
+
function validateFrpVhostHttpPort(value) {
|
|
5994
|
+
return validatePort(value, false);
|
|
5995
|
+
}
|
|
5996
|
+
/** Validate the public entry port of the self-signed TCP proxy. */
|
|
5997
|
+
function validateFrpPublicPort(value) {
|
|
5998
|
+
return validatePort(value, true);
|
|
5999
|
+
}
|
|
6000
|
+
/** Effective provisioning mode; an absent field keeps the upstream behaviour. */
|
|
6001
|
+
function resolveFrpMode(settings) {
|
|
6002
|
+
return settings.mode ?? "deploy";
|
|
6003
|
+
}
|
|
6004
|
+
/** Effective entry TLS mode; an absent field keeps the upstream behaviour. */
|
|
6005
|
+
function resolveFrpEntryTls(settings) {
|
|
6006
|
+
return settings.entryTls ?? "public-ip-cert";
|
|
6007
|
+
}
|
|
6008
|
+
/** Effective loopback vhost port; an absent field keeps the upstream behaviour. */
|
|
6009
|
+
function resolveFrpVhostHttpPort(settings) {
|
|
6010
|
+
return settings.vhostHttpPort ?? 7080;
|
|
6011
|
+
}
|
|
6012
|
+
/** Effective public entry port of the self-signed passthrough. */
|
|
6013
|
+
function resolveFrpPublicPort(settings) {
|
|
6014
|
+
return settings.publicPort ?? 33080;
|
|
6015
|
+
}
|
|
6016
|
+
/** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
|
|
6017
|
+
function frpEntryOrigin(settings) {
|
|
6018
|
+
if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
|
|
6019
|
+
return `https://${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
|
|
6020
|
+
}
|
|
6021
|
+
/** True when the gateway terminates TLS itself behind a raw frps TCP proxy. */
|
|
6022
|
+
function isFrpSelfSignedIngress(settings) {
|
|
6023
|
+
return resolveFrpEntryTls(settings) === "self-signed";
|
|
6024
|
+
}
|
|
6025
|
+
/**
|
|
6026
|
+
* Parse FRP settings at the loopback request and filesystem boundaries.
|
|
6027
|
+
*
|
|
6028
|
+
* Optional keys are kept only when supplied, so a legacy `settings.json` still
|
|
6029
|
+
* round-trips byte for byte and every default stays the upstream behaviour.
|
|
6030
|
+
* Two cross-field rules are enforced here because neither can be recovered later:
|
|
6031
|
+
* the self-signed entry is a TCP passthrough and therefore attach-only, and
|
|
6032
|
+
* attach mode with a public-CA entry must state the user's real vhost port —
|
|
6033
|
+
* silently assuming 7080 would disable the plaintext-exposure gate.
|
|
6034
|
+
*/
|
|
5615
6035
|
function parseFrpSettings(value) {
|
|
5616
6036
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
|
|
5617
6037
|
const record = value;
|
|
5618
|
-
if (Reflect.ownKeys(record).some((key) => !
|
|
5619
|
-
"version",
|
|
5620
|
-
"serverAddress",
|
|
5621
|
-
"serverPort",
|
|
5622
|
-
"token",
|
|
5623
|
-
"publicOrigin"
|
|
5624
|
-
].includes(String(key)))) throw new Error("frp_settings_invalid");
|
|
6038
|
+
if (Reflect.ownKeys(record).some((key) => !FRP_SETTINGS_KEYS.has(String(key)))) throw new Error("frp_settings_invalid");
|
|
5625
6039
|
if (record.version !== void 0 && record.version !== 1) throw new Error("frp_settings_invalid");
|
|
6040
|
+
const mode = record.mode === void 0 ? void 0 : validateFrpMode(record.mode);
|
|
6041
|
+
const entryTls = record.entryTls === void 0 ? void 0 : validateFrpEntryTls(record.entryTls);
|
|
6042
|
+
const vhostHttpPort = record.vhostHttpPort === void 0 ? void 0 : validateFrpVhostHttpPort(record.vhostHttpPort);
|
|
6043
|
+
const publicPort = record.publicPort === void 0 ? void 0 : validateFrpPublicPort(record.publicPort);
|
|
6044
|
+
const resolvedMode = mode ?? "deploy";
|
|
6045
|
+
const resolvedEntryTls = entryTls ?? "public-ip-cert";
|
|
6046
|
+
if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
|
|
6047
|
+
if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
|
|
6048
|
+
const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
|
|
6049
|
+
if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
|
|
5626
6050
|
return Object.freeze({
|
|
5627
6051
|
version: 1,
|
|
5628
6052
|
serverAddress: validateFrpServerAddress(record.serverAddress),
|
|
5629
6053
|
serverPort: validateFrpServerPort(record.serverPort),
|
|
5630
6054
|
token: validateFrpToken(record.token),
|
|
5631
|
-
publicOrigin
|
|
6055
|
+
publicOrigin,
|
|
6056
|
+
...mode === void 0 ? {} : { mode },
|
|
6057
|
+
...entryTls === void 0 ? {} : { entryTls },
|
|
6058
|
+
...vhostHttpPort === void 0 ? {} : { vhostHttpPort },
|
|
6059
|
+
...publicPort === void 0 ? {} : { publicPort }
|
|
5632
6060
|
});
|
|
5633
6061
|
}
|
|
5634
6062
|
/**
|
|
@@ -5638,11 +6066,24 @@ function parseFrpSettings(value) {
|
|
|
5638
6066
|
* missing configuration instead of silently deploying blanks.
|
|
5639
6067
|
*/
|
|
5640
6068
|
function mergeSavedFrpSettings(partial, saved) {
|
|
6069
|
+
const pick = (key) => {
|
|
6070
|
+
const supplied = partial[key];
|
|
6071
|
+
if (supplied === "" || supplied === void 0) return saved?.[key];
|
|
6072
|
+
return supplied;
|
|
6073
|
+
};
|
|
6074
|
+
const mode = pick("mode");
|
|
6075
|
+
const entryTls = pick("entryTls");
|
|
6076
|
+
const vhostHttpPort = pick("vhostHttpPort");
|
|
6077
|
+
const publicPort = pick("publicPort");
|
|
5641
6078
|
const merged = {
|
|
5642
6079
|
serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
|
|
5643
6080
|
serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
|
|
5644
6081
|
token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
|
|
5645
|
-
publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin
|
|
6082
|
+
publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin,
|
|
6083
|
+
...mode === "" || mode === void 0 ? {} : { mode },
|
|
6084
|
+
...entryTls === "" || entryTls === void 0 ? {} : { entryTls },
|
|
6085
|
+
...vhostHttpPort === "" || vhostHttpPort === void 0 ? {} : { vhostHttpPort },
|
|
6086
|
+
...publicPort === "" || publicPort === void 0 ? {} : { publicPort }
|
|
5646
6087
|
};
|
|
5647
6088
|
if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
|
|
5648
6089
|
return parseFrpSettings(merged);
|
|
@@ -5660,11 +6101,16 @@ function mergeSavedFrpTarget(partial, saved) {
|
|
|
5660
6101
|
function tomlString(value) {
|
|
5661
6102
|
return JSON.stringify(value);
|
|
5662
6103
|
}
|
|
5663
|
-
/**
|
|
6104
|
+
/**
|
|
6105
|
+
* Build the single-purpose frpc configuration for the current loopback gateway.
|
|
6106
|
+
*
|
|
6107
|
+
* The default (public-CA) entry is an HTTP vhost behind Caddy and is emitted byte
|
|
6108
|
+
* for byte as before. The self-signed entry cannot use a vhost at all: frps only
|
|
6109
|
+
* forwards raw TCP, and the gateway terminates TLS on that connection.
|
|
6110
|
+
*/
|
|
5664
6111
|
function createFrpcToml(settings, localPort) {
|
|
5665
6112
|
if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
|
|
5666
|
-
const
|
|
5667
|
-
return [
|
|
6113
|
+
const header = [
|
|
5668
6114
|
`serverAddr = ${tomlString(settings.serverAddress)}`,
|
|
5669
6115
|
`serverPort = ${String(settings.serverPort)}`,
|
|
5670
6116
|
"auth.method = \"token\"",
|
|
@@ -5672,7 +6118,21 @@ function createFrpcToml(settings, localPort) {
|
|
|
5672
6118
|
"transport.tls.enable = true",
|
|
5673
6119
|
"",
|
|
5674
6120
|
"[[proxies]]",
|
|
5675
|
-
"name = \"dsh-mobile\""
|
|
6121
|
+
"name = \"dsh-mobile\""
|
|
6122
|
+
];
|
|
6123
|
+
if (isFrpSelfSignedIngress(settings)) return [
|
|
6124
|
+
...header,
|
|
6125
|
+
"type = \"tcp\"",
|
|
6126
|
+
"localIP = \"127.0.0.1\"",
|
|
6127
|
+
`localPort = ${String(localPort)}`,
|
|
6128
|
+
`remotePort = ${String(resolveFrpPublicPort(settings))}`,
|
|
6129
|
+
"transport.useEncryption = true",
|
|
6130
|
+
"transport.useCompression = true",
|
|
6131
|
+
""
|
|
6132
|
+
].join("\n");
|
|
6133
|
+
const hostnameValue = new URL(settings.publicOrigin).hostname;
|
|
6134
|
+
return [
|
|
6135
|
+
...header,
|
|
5676
6136
|
"type = \"http\"",
|
|
5677
6137
|
"localIP = \"127.0.0.1\"",
|
|
5678
6138
|
`localPort = ${String(localPort)}`,
|
|
@@ -5684,7 +6144,10 @@ function createFrpcToml(settings, localPort) {
|
|
|
5684
6144
|
}
|
|
5685
6145
|
/** Build the matching restricted frps and Caddy templates for one VPS. */
|
|
5686
6146
|
function createFrpServerTemplate(settings) {
|
|
5687
|
-
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin
|
|
6147
|
+
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin, {
|
|
6148
|
+
vhostHttpPort: resolveFrpVhostHttpPort(settings),
|
|
6149
|
+
entryTls: resolveFrpEntryTls(settings)
|
|
6150
|
+
});
|
|
5688
6151
|
}
|
|
5689
6152
|
async function atomicPrivateWrite$2(file, body) {
|
|
5690
6153
|
const directory = dirname(file);
|
|
@@ -5747,9 +6210,17 @@ var FrpConfigStore = class {
|
|
|
5747
6210
|
this.errorCode = "frp_config_invalid";
|
|
5748
6211
|
}
|
|
5749
6212
|
}
|
|
5750
|
-
/**
|
|
6213
|
+
/**
|
|
6214
|
+
* Return configuration metadata without exposing the FRP token.
|
|
6215
|
+
*
|
|
6216
|
+
* The optional keys appear only when the saved configuration departs from the
|
|
6217
|
+
* upstream defaults, so the pre-existing field set is unchanged for every
|
|
6218
|
+
* legacy configuration.
|
|
6219
|
+
*/
|
|
5751
6220
|
status() {
|
|
5752
6221
|
const settings = this.settingsValue;
|
|
6222
|
+
const mode = settings === void 0 ? void 0 : resolveFrpMode(settings);
|
|
6223
|
+
const entryTls = settings === void 0 ? void 0 : resolveFrpEntryTls(settings);
|
|
5753
6224
|
return Object.freeze({
|
|
5754
6225
|
configured: settings !== void 0,
|
|
5755
6226
|
...settings === void 0 ? {} : {
|
|
@@ -5757,9 +6228,12 @@ var FrpConfigStore = class {
|
|
|
5757
6228
|
serverPort: settings.serverPort,
|
|
5758
6229
|
publicOrigin: settings.publicOrigin
|
|
5759
6230
|
},
|
|
5760
|
-
vhostHttpPort: FRP_VHOST_HTTP_PORT,
|
|
6231
|
+
vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
|
|
5761
6232
|
storagePath: this.stateRoot,
|
|
5762
|
-
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
6233
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode },
|
|
6234
|
+
...mode === void 0 || mode === "deploy" ? {} : { mode },
|
|
6235
|
+
...entryTls === void 0 || entryTls === "public-ip-cert" ? {} : { entryTls },
|
|
6236
|
+
...settings?.publicPort === void 0 ? {} : { publicPort: settings.publicPort }
|
|
5763
6237
|
});
|
|
5764
6238
|
}
|
|
5765
6239
|
/** Return private settings only to the provider lifecycle. */
|
|
@@ -5794,41 +6268,1067 @@ var FrpConfigStore = class {
|
|
|
5794
6268
|
}
|
|
5795
6269
|
};
|
|
5796
6270
|
//#endregion
|
|
5797
|
-
//#region src/
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
6271
|
+
//#region src/frp-attach.ts
|
|
6272
|
+
/** Default proof path used to confirm the public entry answered from this computer. */
|
|
6273
|
+
const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
|
|
6274
|
+
/** Declaration printed above the VPS half of an attach template. */
|
|
6275
|
+
const FRP_ATTACH_VPS_DECLARATION = "# 本模板不安装、不改动、不重启你的 frps;只新增 Caddy 片段与证书。frps 侧需你自行确认 vhostHTTPPort=<N> 与 proxyBindAddr=\"127.0.0.1\"。";
|
|
6276
|
+
/** Declaration printed above the local half of an attach template. */
|
|
6277
|
+
const FRP_ATTACH_LOCAL_DECLARATION = "# 本机只写入 frpc.toml 并启动 frpc:不安装、不改动本机 frps,也不改动 DSH 自身配置。";
|
|
6278
|
+
/** Declaration for the self-signed passthrough, which keeps its CA on this computer. */
|
|
6279
|
+
const FRP_ATTACH_SELF_SIGNED_DECLARATION = "# 自签穿透档不使用 Caddy 或公开证书:frps 只做 TCP 透传,由本机 DSH 网关终止 TLS;服务端证书会在运行时续签,CA 到期需重新配对。";
|
|
6280
|
+
/**
|
|
6281
|
+
* One-line notice printed above the masked frpc.toml preview.
|
|
6282
|
+
*
|
|
6283
|
+
* It states both facts a reader cannot verify from the text itself: the real
|
|
6284
|
+
* file is written with 0600 permissions, and an unmasked copy would survive in
|
|
6285
|
+
* the system clipboard.
|
|
6286
|
+
*/
|
|
6287
|
+
function frpAttachTokenMaskNotice(configFile) {
|
|
6288
|
+
return `# 预览已打码:真正的 frpc.toml 已由插件以 0600 权限写入 ${configFile};明文 token 不打码会随复制进入系统剪贴板,故此处只显示占位符。`;
|
|
5809
6289
|
}
|
|
5810
|
-
/**
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
6290
|
+
/** One-line notice printed above an explicitly revealed frpc.toml preview. */
|
|
6291
|
+
const FRP_ATTACH_TOKEN_REVEAL_NOTICE = "# 明文 token 已按你的显式操作显示:复制后即进入系统剪贴板并长期留存,请勿粘贴到公开位置。";
|
|
6292
|
+
/** Matches the single `auth.token` assignment of a generated frpc.toml. */
|
|
6293
|
+
const FRPC_TOKEN_ASSIGNMENT = /^auth\.token\s*=\s*"(?:[^"\\]|\\.)*"/mu;
|
|
6294
|
+
/**
|
|
6295
|
+
* Replace the `auth.token` value of a rendered frpc.toml with a fixed placeholder.
|
|
6296
|
+
*
|
|
6297
|
+
* Only the preview and clipboard paths call this: the file written to disk keeps
|
|
6298
|
+
* the real token, because frpc has to read it. The mapping never fails open — a
|
|
6299
|
+
* token line that cannot be masked raises instead of leaking the secret.
|
|
6300
|
+
*/
|
|
6301
|
+
function redactFrpcTomlToken(frpcToml) {
|
|
6302
|
+
if (!/(^|\n)auth\.token/u.test(frpcToml)) return frpcToml;
|
|
6303
|
+
const masked = frpcToml.replace(FRPC_TOKEN_ASSIGNMENT, `auth.token = "***"`);
|
|
6304
|
+
if (!masked.includes(`auth.token = "***"`)) throw new Error("frp_token_mask_failed");
|
|
6305
|
+
return masked;
|
|
5814
6306
|
}
|
|
5815
6307
|
/**
|
|
5816
|
-
*
|
|
5817
|
-
*
|
|
6308
|
+
* The frpc.toml exactly as the panel displays or copies it.
|
|
6309
|
+
*
|
|
6310
|
+
* `options.revealToken` is the only way to obtain the plaintext variant; the
|
|
6311
|
+
* default is the masked text. `createFrpcToml` itself is untouched, so the file
|
|
6312
|
+
* the provider writes stays byte-for-byte identical to the upstream output.
|
|
5818
6313
|
*/
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
6314
|
+
function createFrpAttachFrpcToml(settings, options = {}) {
|
|
6315
|
+
const frpcToml = createFrpcToml(settings, attachLocalPort(options));
|
|
6316
|
+
return options.revealToken === true ? frpcToml : redactFrpcTomlToken(frpcToml);
|
|
6317
|
+
}
|
|
6318
|
+
const DEFAULT_ATTACH_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
|
|
6319
|
+
function attachConfigFile(options) {
|
|
6320
|
+
const file = options.configFile;
|
|
6321
|
+
if (file === void 0 || file === "") return DEFAULT_ATTACH_CONFIG_FILE;
|
|
6322
|
+
return file;
|
|
6323
|
+
}
|
|
6324
|
+
function attachLocalPort(options) {
|
|
6325
|
+
const port = options.localPort ?? 3443;
|
|
6326
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_local_port_invalid");
|
|
6327
|
+
return port;
|
|
6328
|
+
}
|
|
6329
|
+
/**
|
|
6330
|
+
* Reject every attach request that could not be carried out safely before any
|
|
6331
|
+
* artefact is produced. Attach never installs frps, so it must know the user's
|
|
6332
|
+
* real vhost port (unless the self-signed passthrough removes the vhost entirely).
|
|
6333
|
+
*/
|
|
6334
|
+
function validateAttachSettings(settings, options = {}) {
|
|
6335
|
+
if (resolveFrpMode(settings) !== "attach") throw new Error("frp_settings_invalid");
|
|
6336
|
+
if (isFrpSelfSignedIngress(settings)) {
|
|
6337
|
+
if (settings.mode !== "attach") throw new Error("frp_entry_tls_invalid");
|
|
6338
|
+
resolveFrpPublicPort(settings);
|
|
6339
|
+
return;
|
|
5828
6340
|
}
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
6341
|
+
const vhostHttpPort = options.vhostHttpPort ?? settings.vhostHttpPort;
|
|
6342
|
+
if (vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
|
|
6343
|
+
if (!Number.isSafeInteger(vhostHttpPort) || vhostHttpPort < 1 || vhostHttpPort > 65535) throw new Error("frp_settings_invalid");
|
|
6344
|
+
}
|
|
6345
|
+
/** Strip the leading `# ` of a guide block so it can be pasted as runnable shell. */
|
|
6346
|
+
function guideCommands(guide) {
|
|
6347
|
+
return guide.split("\n").map((line) => line.replace(/^#\s?/u, "").trimEnd()).filter((line) => line !== "");
|
|
6348
|
+
}
|
|
6349
|
+
/**
|
|
6350
|
+
* Derive every VPS-side value once so the copied runbook and the panel plan can
|
|
6351
|
+
* never drift apart.
|
|
6352
|
+
*/
|
|
6353
|
+
function frpAttachVpsParts(settings, options = {}) {
|
|
6354
|
+
validateAttachSettings(settings, options);
|
|
6355
|
+
const publicHost = new URL(settings.publicOrigin).hostname;
|
|
6356
|
+
if (isFrpSelfSignedIngress(settings)) return Object.freeze({
|
|
6357
|
+
publicHost,
|
|
6358
|
+
selfSigned: true,
|
|
6359
|
+
publicPort: resolveFrpPublicPort(settings),
|
|
6360
|
+
snippet: "",
|
|
6361
|
+
certGuide: Object.freeze([])
|
|
6362
|
+
});
|
|
6363
|
+
const vhostHttpPort = options.vhostHttpPort ?? resolveFrpVhostHttpPort(settings);
|
|
6364
|
+
const certDir = options.certDir;
|
|
6365
|
+
const site = createCaddySite(publicHost, {
|
|
6366
|
+
vhostHttpPort,
|
|
6367
|
+
...certDir === void 0 ? {} : { certDir }
|
|
6368
|
+
});
|
|
6369
|
+
return Object.freeze({
|
|
6370
|
+
publicHost,
|
|
6371
|
+
selfSigned: false,
|
|
6372
|
+
vhostHttpPort,
|
|
6373
|
+
snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
|
|
6374
|
+
certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
|
|
6375
|
+
});
|
|
6376
|
+
}
|
|
6377
|
+
/**
|
|
6378
|
+
* Build the attach artefact for an existing frps.
|
|
6379
|
+
*
|
|
6380
|
+
* The result is deliberately split in two: the VPS half contains only what the
|
|
6381
|
+
* user's own server needs (Caddy snippet + import + certificate guidance, or a
|
|
6382
|
+
* single firewall rule for the self-signed passthrough), and the local half
|
|
6383
|
+
* contains the generated frpc.toml plus its `frpc verify` self-check. Neither
|
|
6384
|
+
* half installs, rewrites, or restarts the user's frps.
|
|
6385
|
+
*/
|
|
6386
|
+
function createFrpAttachTemplateParts(settings, options = {}) {
|
|
6387
|
+
const parts = frpAttachVpsParts(settings, options);
|
|
6388
|
+
const configFile = attachConfigFile(options);
|
|
6389
|
+
const revealed = options.revealToken === true;
|
|
6390
|
+
const frpcToml = createFrpAttachFrpcToml(settings, options);
|
|
6391
|
+
const local = [
|
|
6392
|
+
"# ---- (b) 本机侧(在这台电脑上执行)----",
|
|
6393
|
+
FRP_ATTACH_LOCAL_DECLARATION,
|
|
6394
|
+
revealed ? FRP_ATTACH_TOKEN_REVEAL_NOTICE : frpAttachTokenMaskNotice(configFile),
|
|
6395
|
+
`# frpc.toml 全文(插件同样会以 0600 权限写入 ${configFile})`,
|
|
6396
|
+
frpcToml.trimEnd(),
|
|
6397
|
+
"",
|
|
6398
|
+
"# 启动前自检:",
|
|
6399
|
+
`frpc verify -c "${configFile}"`,
|
|
6400
|
+
""
|
|
6401
|
+
].join("\n");
|
|
6402
|
+
if (parts.selfSigned) {
|
|
6403
|
+
const publicPort = parts.publicPort ?? resolveFrpPublicPort(settings);
|
|
6404
|
+
const vps = [
|
|
6405
|
+
"# ---- (a) VPS 侧(在你的服务器上执行,共 3 项)----",
|
|
6406
|
+
FRP_ATTACH_SELF_SIGNED_DECLARATION,
|
|
6407
|
+
"#",
|
|
6408
|
+
"# 1) 放行公网入口端口(就是这台电脑网关的 HTTPS 入口,TCP 透传不解密):",
|
|
6409
|
+
`ufw allow ${String(publicPort)}/tcp`,
|
|
6410
|
+
`ufw status | grep ${String(publicPort)}`,
|
|
6411
|
+
"# 若使用 firewalld / 云厂商安全组,请放行同样的 TCP 端口。",
|
|
6412
|
+
"# 前提:现有 frps 的 TCP 代理已监听公网地址;若 proxyBindAddr=127.0.0.1,放行防火墙仍无法从公网访问。",
|
|
6413
|
+
"# 插件不会修改你现有的 frps;不满足此前提时请改选受信任证书入口。",
|
|
6414
|
+
"#",
|
|
6415
|
+
"# 2) 确认你既有的 frps 已就绪(无需修改它的配置):",
|
|
6416
|
+
"systemctl is-active frps || true",
|
|
6417
|
+
`ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`,
|
|
6418
|
+
"#",
|
|
6419
|
+
"# 3) frpc 启动后,用 -k 检查连通性;此命令不验证身份,App 配对后会固定网关 CA:",
|
|
6420
|
+
`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`,
|
|
6421
|
+
"# 期望输出 200。",
|
|
6422
|
+
""
|
|
6423
|
+
].join("\n");
|
|
6424
|
+
return Object.freeze({
|
|
6425
|
+
mode: "attach",
|
|
6426
|
+
vps,
|
|
6427
|
+
local,
|
|
6428
|
+
text: `${vps}${local}`
|
|
6429
|
+
});
|
|
6430
|
+
}
|
|
6431
|
+
const vps = [
|
|
6432
|
+
"# ---- (a) VPS 侧(在你的服务器上执行)----",
|
|
6433
|
+
FRP_ATTACH_VPS_DECLARATION,
|
|
6434
|
+
"#",
|
|
6435
|
+
`# 1) 保存 Caddy 片段到 ${FRP_CADDY_SNIPPET_PATH}(只新增这一个文件):`,
|
|
6436
|
+
`cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
|
|
6437
|
+
parts.snippet.trimEnd(),
|
|
6438
|
+
"DSH_MOBILE_CADDY_SNIPPET",
|
|
6439
|
+
"#",
|
|
6440
|
+
"# 2) 确认 Caddyfile 顶部含下面这一行(全局块必须在前;没有则加到文件最上方):",
|
|
6441
|
+
`# ${FRP_CADDY_IMPORT_LINE}`,
|
|
6442
|
+
"caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
|
|
6443
|
+
"#",
|
|
6444
|
+
...parts.certGuide.length === 0 ? ["# 3) 域名 HTTPS:确认 DNS 指向 VPS,开放 80/443;Caddy 会自动申请和续期证书。"] : [
|
|
6445
|
+
"# 3) 公网 IP 的 HTTPS 证书(Caddy 不能为 IP 自动签发,故用 certbot 签发):",
|
|
6446
|
+
...parts.certGuide.map((line) => `# ${line}`),
|
|
6447
|
+
"#",
|
|
6448
|
+
"# 4) 确认证书续期任务,并在续期后将新证书安装到 Caddy:",
|
|
6449
|
+
"systemctl list-timers certbot.timer --all || true"
|
|
6450
|
+
],
|
|
6451
|
+
"#",
|
|
6452
|
+
"# 端到端验证(公网入口为 443,与 frps 的 vhost 端口无关):",
|
|
6453
|
+
`curl -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}${FRP_ATTACH_DISCOVERY_PATH}`,
|
|
6454
|
+
"# 期望输出 200。",
|
|
6455
|
+
""
|
|
6456
|
+
].join("\n");
|
|
6457
|
+
return Object.freeze({
|
|
6458
|
+
mode: "attach",
|
|
6459
|
+
vps,
|
|
6460
|
+
local,
|
|
6461
|
+
text: `${vps}${local}`
|
|
6462
|
+
});
|
|
6463
|
+
}
|
|
6464
|
+
/** Combined attach artefact, ready for the clipboard. */
|
|
6465
|
+
function createFrpAttachTemplate(settings, options = {}) {
|
|
6466
|
+
return createFrpAttachTemplateParts(settings, options).text;
|
|
6467
|
+
}
|
|
6468
|
+
//#endregion
|
|
6469
|
+
//#region src/frp-attach-plan.ts
|
|
6470
|
+
const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
|
|
6471
|
+
function planConfigFile(options) {
|
|
6472
|
+
const file = options.configFile;
|
|
6473
|
+
return file === void 0 || file === "" ? DEFAULT_PLAN_CONFIG_FILE : file;
|
|
6474
|
+
}
|
|
6475
|
+
/**
|
|
6476
|
+
* Build the copy-only attach plan. It performs no network access, no SSH, and no
|
|
6477
|
+
* filesystem write: the caller renders or copies the steps and the local frpc
|
|
6478
|
+
* configuration is written by the ordinary provider lifecycle.
|
|
6479
|
+
*/
|
|
6480
|
+
function createFrpAttachPlan(settings, options = {}) {
|
|
6481
|
+
const parts = frpAttachVpsParts(settings, options);
|
|
6482
|
+
const configFile = planConfigFile(options);
|
|
6483
|
+
const localPort = options.localPort ?? 3443;
|
|
6484
|
+
const frpcToml = createFrpAttachFrpcToml(settings, {
|
|
6485
|
+
...options,
|
|
6486
|
+
localPort
|
|
6487
|
+
});
|
|
6488
|
+
const tokenMasked = options.revealToken !== true;
|
|
6489
|
+
const verifyCommand = `frpc verify -c "${configFile}"`;
|
|
6490
|
+
const commonWarnings = ["本机探测 ≠ 公网验证:请在外部(手机流量或另一台机器)再执行一次 VPS 侧清单里的 curl 验证。", "插件不会安装、不会改动、不会重启你既有的 frps,也不会改动 Caddyfile 里的其他内容。"];
|
|
6491
|
+
if (isFrpSelfSignedIngress(settings)) {
|
|
6492
|
+
const publicPort = parts.publicPort ?? 0;
|
|
6493
|
+
const publicHost = parts.publicHost;
|
|
6494
|
+
return Object.freeze({
|
|
6495
|
+
mode: "attach",
|
|
6496
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
6497
|
+
publicPort,
|
|
6498
|
+
local: Object.freeze({
|
|
6499
|
+
frpcToml,
|
|
6500
|
+
tokenMasked,
|
|
6501
|
+
verifyCommand,
|
|
6502
|
+
configFile,
|
|
6503
|
+
declaration: FRP_ATTACH_LOCAL_DECLARATION
|
|
6504
|
+
}),
|
|
6505
|
+
vps: Object.freeze([
|
|
6506
|
+
Object.freeze({
|
|
6507
|
+
id: "open-public-port",
|
|
6508
|
+
title: "frpAttachStepOpenPort",
|
|
6509
|
+
label: `放行公网入口端口 ${String(publicPort)}/tcp`,
|
|
6510
|
+
commands: Object.freeze([`ufw allow ${String(publicPort)}/tcp`, `ufw status | grep ${String(publicPort)}`]),
|
|
6511
|
+
optional: false,
|
|
6512
|
+
verifyHint: `ufw status 中应出现 ${String(publicPort)}/tcp ALLOW;云厂商安全组也需放行同一 TCP 端口。`
|
|
6513
|
+
}),
|
|
6514
|
+
Object.freeze({
|
|
6515
|
+
id: "verify-frps",
|
|
6516
|
+
title: "frpAttachStepVerifyFrps",
|
|
6517
|
+
label: "确认既有 frps 已就绪(不改动它的配置)",
|
|
6518
|
+
commands: Object.freeze(["systemctl is-active frps || true", `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`]),
|
|
6519
|
+
optional: false,
|
|
6520
|
+
verifyHint: `frps 控制端口 ${String(settings.serverPort)} 应监听;入口 ${String(publicPort)} 的 Local Address 不应是 127.0.0.1。若仅回环监听,防火墙放行也无法公网访问。`
|
|
6521
|
+
}),
|
|
6522
|
+
Object.freeze({
|
|
6523
|
+
id: "verify-entry",
|
|
6524
|
+
title: "frpAttachStepVerifyEntry",
|
|
6525
|
+
label: "frpc 启动后端到端验证(自签证书需 -k)",
|
|
6526
|
+
commands: Object.freeze([`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`]),
|
|
6527
|
+
optional: false,
|
|
6528
|
+
verifyHint: "期望输出 200;curl -k 仅验证连通性,不验证服务器身份;App 配对后会固定网关 CA。返回 000 时检查端口与转发。"
|
|
6529
|
+
})
|
|
6530
|
+
]),
|
|
6531
|
+
warnings: Object.freeze([
|
|
6532
|
+
...commonWarnings,
|
|
6533
|
+
FRP_ATTACH_SELF_SIGNED_DECLARATION,
|
|
6534
|
+
"现有 frps 的 TCP 代理必须已监听公网地址;若 proxyBindAddr=127.0.0.1,单靠开放防火墙端口无法连通,请改选公网证书 + Caddy 模式。插件不会修改你的 frps。",
|
|
6535
|
+
"手机浏览器访问自签入口会提示证书不受信任 —— 请用 App 扫码配对;App 已固定网关 CA,不需要公开证书。",
|
|
6536
|
+
"切勿把网关 listenHost 改成 0.0.0.0:公网入口由 frps 的 TCP 代理提供,网关只监听 127.0.0.1。"
|
|
6537
|
+
])
|
|
6538
|
+
});
|
|
6539
|
+
}
|
|
6540
|
+
const vhostHttpPort = parts.vhostHttpPort ?? 0;
|
|
6541
|
+
const publicHost = parts.publicHost;
|
|
6542
|
+
const certGuide = parts.certGuide;
|
|
6543
|
+
const certificateSteps = certGuide.length === 0 ? [] : [Object.freeze({
|
|
6544
|
+
id: "issue-ip-cert",
|
|
6545
|
+
title: "frpAttachStepIssueCert",
|
|
6546
|
+
label: "为公网 IP 签发受信任证书(certbot)",
|
|
6547
|
+
commands: Object.freeze([...certGuide]),
|
|
6548
|
+
optional: false,
|
|
6549
|
+
verifyHint: `证书应落到 /etc/letsencrypt/live/${publicHost}/ 并安装到 Caddy 证书目录。`
|
|
6550
|
+
}), Object.freeze({
|
|
6551
|
+
id: "enable-cert-timer",
|
|
6552
|
+
title: "frpAttachStepCertTimer",
|
|
6553
|
+
label: "确认 certbot 续期定时器并更新 Caddy 证书文件",
|
|
6554
|
+
commands: Object.freeze(["systemctl list-timers certbot.timer --all || true"]),
|
|
6555
|
+
optional: false,
|
|
6556
|
+
verifyHint: "公网 IP 证书有效期较短;续期后还须将新证书安装到 Caddy 并重载。"
|
|
6557
|
+
})];
|
|
6558
|
+
return Object.freeze({
|
|
6559
|
+
mode: "attach",
|
|
6560
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
6561
|
+
vhostHttpPort,
|
|
6562
|
+
local: Object.freeze({
|
|
6563
|
+
frpcToml,
|
|
6564
|
+
tokenMasked,
|
|
6565
|
+
verifyCommand,
|
|
6566
|
+
configFile,
|
|
6567
|
+
declaration: FRP_ATTACH_LOCAL_DECLARATION
|
|
6568
|
+
}),
|
|
6569
|
+
vps: Object.freeze([
|
|
6570
|
+
Object.freeze({
|
|
6571
|
+
id: "write-snippet",
|
|
6572
|
+
title: "frpAttachStepWriteSnippet",
|
|
6573
|
+
label: `新增 Caddy 片段 ${FRP_CADDY_SNIPPET_PATH}`,
|
|
6574
|
+
commands: Object.freeze([
|
|
6575
|
+
`cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
|
|
6576
|
+
parts.snippet.trimEnd(),
|
|
6577
|
+
"DSH_MOBILE_CADDY_SNIPPET"
|
|
6578
|
+
]),
|
|
6579
|
+
optional: false,
|
|
6580
|
+
verifyHint: `片段内容里的反代目标是 127.0.0.1:${String(vhostHttpPort)},即你 frps 的 vhostHTTPPort。`
|
|
6581
|
+
}),
|
|
6582
|
+
Object.freeze({
|
|
6583
|
+
id: "add-import",
|
|
6584
|
+
title: "frpAttachStepAddImport",
|
|
6585
|
+
label: "在 Caddyfile 顶部加入 import 行并重载",
|
|
6586
|
+
commands: Object.freeze([`# 确认 /etc/caddy/Caddyfile 顶部包含:${FRP_CADDY_IMPORT_LINE}`, "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy"]),
|
|
6587
|
+
optional: false,
|
|
6588
|
+
verifyHint: "caddy validate 通过且 reload 无报错;Caddyfile 中你自己原有的站点内容保持不变。"
|
|
6589
|
+
}),
|
|
6590
|
+
...certificateSteps,
|
|
6591
|
+
Object.freeze({
|
|
6592
|
+
id: "verify-https",
|
|
6593
|
+
title: "frpAttachStepVerifyHttps",
|
|
6594
|
+
label: "端到端验证公网 HTTPS 入口",
|
|
6595
|
+
commands: Object.freeze([`curl -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}${FRP_ATTACH_DISCOVERY_PATH}`]),
|
|
6596
|
+
optional: true,
|
|
6597
|
+
verifyHint: certGuide.length === 0 ? "期望输出 200;证书错误时检查 DNS、80/443 端口和 Caddy 日志。" : "期望输出 200;证书错误时检查 certbot 证书是否安装并重载 Caddy。"
|
|
6598
|
+
})
|
|
6599
|
+
]),
|
|
6600
|
+
warnings: Object.freeze([
|
|
6601
|
+
...commonWarnings,
|
|
6602
|
+
FRP_ATTACH_VPS_DECLARATION,
|
|
6603
|
+
`你 frps 的明文 vhost 必须只监听 127.0.0.1(proxyBindAddr)且 vhostHTTPPort=${String(vhostHttpPort)};插件启动前会探测该端口,若公网可达将直接拒绝启动。`
|
|
6604
|
+
])
|
|
6605
|
+
});
|
|
6606
|
+
}
|
|
6607
|
+
//#endregion
|
|
6608
|
+
//#region src/cert-renewal.ts
|
|
6609
|
+
/** Days left below which the entry certificate is reported as expiring. */
|
|
6610
|
+
const CERT_EXPIRING_DAYS = 2;
|
|
6611
|
+
const DAY_MS = 864e5;
|
|
6612
|
+
/** Evaluate one `notAfter` timestamp against the current clock. */
|
|
6613
|
+
function evaluateCertificateLifetime(notAfter, now = Date.now()) {
|
|
6614
|
+
if (!Number.isFinite(notAfter) || !Number.isFinite(now)) return Object.freeze({
|
|
6615
|
+
state: "unknown",
|
|
6616
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6617
|
+
});
|
|
6618
|
+
const daysRemaining = Math.floor((notAfter - now) / DAY_MS);
|
|
6619
|
+
if (notAfter <= now) return Object.freeze({
|
|
6620
|
+
state: "expired",
|
|
6621
|
+
notAfter,
|
|
6622
|
+
daysRemaining
|
|
6623
|
+
});
|
|
6624
|
+
if (daysRemaining <= 2) return Object.freeze({
|
|
6625
|
+
state: "expiring",
|
|
6626
|
+
notAfter,
|
|
6627
|
+
daysRemaining
|
|
6628
|
+
});
|
|
6629
|
+
return Object.freeze({
|
|
6630
|
+
state: "ok",
|
|
6631
|
+
notAfter,
|
|
6632
|
+
daysRemaining
|
|
6633
|
+
});
|
|
6634
|
+
}
|
|
6635
|
+
function statusFromCertificate(certificate, now) {
|
|
6636
|
+
const lifetime = evaluateCertificateLifetime(Date.parse(certificate.validTo), now);
|
|
6637
|
+
return Object.freeze({
|
|
6638
|
+
...lifetime,
|
|
6639
|
+
subject: certificate.subject,
|
|
6640
|
+
issuer: certificate.issuer
|
|
6641
|
+
});
|
|
6642
|
+
}
|
|
6643
|
+
/**
|
|
6644
|
+
* Read one PEM certificate from disk and report only its lifetime.
|
|
6645
|
+
*
|
|
6646
|
+
* A missing or unreadable file yields `unknown` with the stable
|
|
6647
|
+
* `frp_attach_cert_unknown` code so the panel can explain how to re-issue it.
|
|
6648
|
+
*/
|
|
6649
|
+
async function readCertificateRenewal(file, now = Date.now()) {
|
|
6650
|
+
try {
|
|
6651
|
+
return statusFromCertificate(new X509Certificate(await readFile(file)), now);
|
|
6652
|
+
} catch {
|
|
6653
|
+
return Object.freeze({
|
|
6654
|
+
state: "unknown",
|
|
6655
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6656
|
+
});
|
|
6657
|
+
}
|
|
6658
|
+
}
|
|
6659
|
+
/**
|
|
6660
|
+
* Ask a live TLS endpoint for its leaf certificate and report its lifetime.
|
|
6661
|
+
*
|
|
6662
|
+
* Verification is intentionally skipped: this is a read-only lifetime probe for
|
|
6663
|
+
* an endpoint whose trust anchor is already pinned elsewhere, and a self-signed
|
|
6664
|
+
* leaf is the expected answer for the passthrough entry.
|
|
6665
|
+
*/
|
|
6666
|
+
async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
|
|
6667
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return Object.freeze({
|
|
6668
|
+
state: "unknown",
|
|
6669
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6670
|
+
});
|
|
6671
|
+
return new Promise((resolveProbe) => {
|
|
6672
|
+
let finished = false;
|
|
6673
|
+
const socket = connect$1({
|
|
6674
|
+
host,
|
|
6675
|
+
port,
|
|
6676
|
+
rejectUnauthorized: false,
|
|
6677
|
+
servername: void 0
|
|
6678
|
+
});
|
|
6679
|
+
const finish = (status) => {
|
|
6680
|
+
if (finished) return;
|
|
6681
|
+
finished = true;
|
|
6682
|
+
clearTimeout(timer);
|
|
6683
|
+
socket.destroy();
|
|
6684
|
+
resolveProbe(status);
|
|
6685
|
+
};
|
|
6686
|
+
const timer = setTimeout(() => {
|
|
6687
|
+
finish(Object.freeze({
|
|
6688
|
+
state: "unknown",
|
|
6689
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6690
|
+
}));
|
|
6691
|
+
}, timeoutMs);
|
|
6692
|
+
timer.unref();
|
|
6693
|
+
socket.once("secureConnect", () => {
|
|
6694
|
+
const certificate = socket.getPeerX509Certificate();
|
|
6695
|
+
finish(certificate === void 0 ? Object.freeze({
|
|
6696
|
+
state: "unknown",
|
|
6697
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6698
|
+
}) : statusFromCertificate(certificate, Date.now()));
|
|
6699
|
+
});
|
|
6700
|
+
socket.once("error", () => {
|
|
6701
|
+
finish(Object.freeze({
|
|
6702
|
+
state: "unknown",
|
|
6703
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6704
|
+
}));
|
|
6705
|
+
});
|
|
6706
|
+
socket.once("close", () => {
|
|
6707
|
+
finish(Object.freeze({
|
|
6708
|
+
state: "unknown",
|
|
6709
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6710
|
+
}));
|
|
6711
|
+
});
|
|
6712
|
+
});
|
|
6713
|
+
}
|
|
6714
|
+
//#endregion
|
|
6715
|
+
//#region src/managed-setup.ts
|
|
6716
|
+
const VIRTUAL_INTERFACE_MARKERS = [
|
|
6717
|
+
"bridge",
|
|
6718
|
+
"docker",
|
|
6719
|
+
"hyper-v",
|
|
6720
|
+
"mihomo",
|
|
6721
|
+
"radmin",
|
|
6722
|
+
"tailscale",
|
|
6723
|
+
"tap",
|
|
6724
|
+
"tun",
|
|
6725
|
+
"utun",
|
|
6726
|
+
"vbox",
|
|
6727
|
+
"veth",
|
|
6728
|
+
"virtual",
|
|
6729
|
+
"vmware",
|
|
6730
|
+
"vpn",
|
|
6731
|
+
"vethernet",
|
|
6732
|
+
"wsl",
|
|
6733
|
+
"zerotier"
|
|
6734
|
+
];
|
|
6735
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
6736
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
6737
|
+
function requiredString(value, name) {
|
|
6738
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
6739
|
+
return value;
|
|
6740
|
+
}
|
|
6741
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
6742
|
+
function parseManagedSetup(value) {
|
|
6743
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
6744
|
+
const record = value;
|
|
6745
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
6746
|
+
"version",
|
|
6747
|
+
"networkInterface",
|
|
6748
|
+
"listenPort",
|
|
6749
|
+
"upstreamOrigin",
|
|
6750
|
+
"tls"
|
|
6751
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
6752
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
6753
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
6754
|
+
const tls = record.tls;
|
|
6755
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
6756
|
+
"mode",
|
|
6757
|
+
"caCertFile",
|
|
6758
|
+
"caKeyFile",
|
|
6759
|
+
"certFile",
|
|
6760
|
+
"keyFile"
|
|
6761
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
6762
|
+
return Object.freeze({
|
|
6763
|
+
version: 2,
|
|
6764
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
6765
|
+
listenPort: record.listenPort,
|
|
6766
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
6767
|
+
tls: Object.freeze({
|
|
6768
|
+
mode: "managed",
|
|
6769
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
6770
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
6771
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
6772
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
6773
|
+
})
|
|
6774
|
+
});
|
|
6775
|
+
}
|
|
6776
|
+
function privateIpv4(value) {
|
|
6777
|
+
const parts = value.split(".").map(Number);
|
|
6778
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
6779
|
+
}
|
|
6780
|
+
function networkCidr(address, cidr) {
|
|
6781
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
6782
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
6783
|
+
return `${[
|
|
6784
|
+
24,
|
|
6785
|
+
16,
|
|
6786
|
+
8,
|
|
6787
|
+
0
|
|
6788
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
6789
|
+
}
|
|
6790
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
6791
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
6792
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
6793
|
+
name,
|
|
6794
|
+
address: entry.address,
|
|
6795
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
6796
|
+
})));
|
|
6797
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
6798
|
+
}
|
|
6799
|
+
function likelyVirtualInterface(name) {
|
|
6800
|
+
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
6801
|
+
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
6802
|
+
}
|
|
6803
|
+
async function runRouteCommand(file, args) {
|
|
6804
|
+
return (await execFileText(file, [...args], {
|
|
6805
|
+
encoding: "utf8",
|
|
6806
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
6807
|
+
})).stdout;
|
|
6808
|
+
}
|
|
6809
|
+
function uniqueLines(output) {
|
|
6810
|
+
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
6811
|
+
}
|
|
6812
|
+
/** Return operating-system default-route interfaces in routing preference order. */
|
|
6813
|
+
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
6814
|
+
try {
|
|
6815
|
+
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
6816
|
+
"-NoProfile",
|
|
6817
|
+
"-NonInteractive",
|
|
6818
|
+
"-Command",
|
|
6819
|
+
[
|
|
6820
|
+
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
6821
|
+
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
6822
|
+
" $route = $_",
|
|
6823
|
+
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
6824
|
+
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
6825
|
+
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
6826
|
+
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
6827
|
+
" }",
|
|
6828
|
+
"}",
|
|
6829
|
+
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
6830
|
+
].join("; ")
|
|
6831
|
+
]));
|
|
6832
|
+
if (platform === "linux") {
|
|
6833
|
+
const routes = uniqueLines(await run("ip", [
|
|
6834
|
+
"-o",
|
|
6835
|
+
"-4",
|
|
6836
|
+
"route",
|
|
6837
|
+
"show",
|
|
6838
|
+
"default"
|
|
6839
|
+
])).map((line) => ({
|
|
6840
|
+
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
6841
|
+
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
6842
|
+
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
6843
|
+
return [...new Set(routes.map((route) => route.name))];
|
|
6844
|
+
}
|
|
6845
|
+
if (platform === "darwin") {
|
|
6846
|
+
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
6847
|
+
"-n",
|
|
6848
|
+
"get",
|
|
6849
|
+
"default"
|
|
6850
|
+
]))?.[1];
|
|
6851
|
+
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
6852
|
+
}
|
|
6853
|
+
} catch {}
|
|
6854
|
+
return [];
|
|
6855
|
+
}
|
|
6856
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
6857
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
6858
|
+
const candidates = availableLanNetworks(table);
|
|
6859
|
+
if (requestedAddress !== void 0) {
|
|
6860
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
6861
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
6862
|
+
return match;
|
|
6863
|
+
}
|
|
6864
|
+
if (requestedInterface !== void 0) {
|
|
6865
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
6866
|
+
if (matches.length === 1) return matches[0];
|
|
6867
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
6868
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
6869
|
+
}
|
|
6870
|
+
if (candidates.length === 1) return candidates[0];
|
|
6871
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
6872
|
+
for (const name of preferredInterfaces) {
|
|
6873
|
+
const matches = candidates.filter((candidate) => candidate.name === name);
|
|
6874
|
+
if (matches.length === 1) return matches[0];
|
|
6875
|
+
}
|
|
6876
|
+
const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
|
|
6877
|
+
if (physicalCandidates.length === 1) return physicalCandidates[0];
|
|
6878
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
|
|
6879
|
+
}
|
|
6880
|
+
/**
|
|
6881
|
+
* True when the failure came from LAN selection (the configured network is
|
|
6882
|
+
* gone, nothing is up, or several are) rather than from config/TLS code.
|
|
6883
|
+
* Callers use it to degrade (warn + stay down) instead of failing boot.
|
|
6884
|
+
* Pinned to the selectLanNetwork message contract — keep in sync.
|
|
6885
|
+
*/
|
|
6886
|
+
function isNetworkSelectionError(error) {
|
|
6887
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
6888
|
+
return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
|
|
6889
|
+
}
|
|
6890
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
6891
|
+
const certificate = new X509Certificate(certPem);
|
|
6892
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
6893
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
6894
|
+
format: "der",
|
|
6895
|
+
type: "spki"
|
|
6896
|
+
});
|
|
6897
|
+
const certificatePublic = certificate.publicKey.export({
|
|
6898
|
+
format: "der",
|
|
6899
|
+
type: "spki"
|
|
6900
|
+
});
|
|
6901
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
6902
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
6903
|
+
return certificate;
|
|
6904
|
+
}
|
|
6905
|
+
/** Read an existing managed CA without ever generating a replacement identity. */
|
|
6906
|
+
async function readManagedCa(setup) {
|
|
6907
|
+
const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
6908
|
+
return assertMatchingCa(certPem, keyPem);
|
|
6909
|
+
}
|
|
6910
|
+
async function atomicWrite(file, contents) {
|
|
6911
|
+
const directory = dirname(file);
|
|
6912
|
+
await mkdir(directory, {
|
|
6913
|
+
recursive: true,
|
|
6914
|
+
mode: 448
|
|
6915
|
+
});
|
|
6916
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
6917
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
6918
|
+
await rename(temporary, file);
|
|
6919
|
+
await restrictPrivateFile(file);
|
|
6920
|
+
}
|
|
6921
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
6922
|
+
async function ensureManagedCa(setup, legacy) {
|
|
6923
|
+
let certPem;
|
|
6924
|
+
let keyPem;
|
|
6925
|
+
try {
|
|
6926
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
6927
|
+
} catch (error) {
|
|
6928
|
+
if (error.code !== "ENOENT") throw error;
|
|
6929
|
+
let migrated = false;
|
|
6930
|
+
if (legacy !== void 0) try {
|
|
6931
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
6932
|
+
assertMatchingCa(certPem, keyPem);
|
|
6933
|
+
migrated = true;
|
|
6934
|
+
} catch (legacyError) {
|
|
6935
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
6936
|
+
}
|
|
6937
|
+
if (!migrated) {
|
|
6938
|
+
const now = /* @__PURE__ */ new Date();
|
|
6939
|
+
const notAfter = new Date(now);
|
|
6940
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
6941
|
+
const generated = await generate([{
|
|
6942
|
+
name: "commonName",
|
|
6943
|
+
value: "DeepSeek Harness Mobile CA"
|
|
6944
|
+
}], {
|
|
6945
|
+
keyType: "ec",
|
|
6946
|
+
curve: "P-256",
|
|
6947
|
+
algorithm: "sha256",
|
|
6948
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
6949
|
+
notAfterDate: notAfter,
|
|
6950
|
+
extensions: [{
|
|
6951
|
+
name: "basicConstraints",
|
|
6952
|
+
cA: true,
|
|
6953
|
+
critical: true
|
|
6954
|
+
}, {
|
|
6955
|
+
name: "keyUsage",
|
|
6956
|
+
digitalSignature: true,
|
|
6957
|
+
keyCertSign: true,
|
|
6958
|
+
cRLSign: true,
|
|
6959
|
+
critical: true
|
|
6960
|
+
}]
|
|
6961
|
+
});
|
|
6962
|
+
certPem = generated.cert;
|
|
6963
|
+
keyPem = generated.private;
|
|
6964
|
+
}
|
|
6965
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
6966
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
6967
|
+
}
|
|
6968
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
6969
|
+
await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
|
|
6970
|
+
return assertMatchingCa(certPem, keyPem);
|
|
6971
|
+
}
|
|
6972
|
+
function subjectAltNames(target) {
|
|
6973
|
+
const names = [];
|
|
6974
|
+
for (const ip of target.ipAddresses ?? []) names.push({
|
|
6975
|
+
type: 7,
|
|
6976
|
+
ip
|
|
6977
|
+
});
|
|
6978
|
+
for (const dns of target.dnsNames ?? []) names.push({
|
|
6979
|
+
type: 2,
|
|
6980
|
+
value: dns
|
|
6981
|
+
});
|
|
6982
|
+
if (names.length === 0) throw new Error("managed TLS leaf needs at least one server name");
|
|
6983
|
+
return names;
|
|
6984
|
+
}
|
|
6985
|
+
/**
|
|
6986
|
+
* Sign one server leaf with an existing self-signed CA and install it privately.
|
|
6987
|
+
*
|
|
6988
|
+
* Both the LAN listener and the self-signed FRP ingress use this one path, so a
|
|
6989
|
+
* certificate can never be produced by two divergent code paths. IP entries are
|
|
6990
|
+
* required for console addresses (`type: 7`), DNS entries (`type: 2`) cover named
|
|
6991
|
+
* hosts; the Android client accepts either through its pinned CA.
|
|
6992
|
+
*/
|
|
6993
|
+
async function issueServerCertificate(authority, target, output, lifetimeDays = 397) {
|
|
6994
|
+
const altNames = subjectAltNames(target);
|
|
6995
|
+
await Promise.all([restrictPrivateFile(authority.caCertFile), restrictPrivateFile(authority.caKeyFile)]);
|
|
6996
|
+
const [caCert, caKey] = await Promise.all([readFile(authority.caCertFile, "utf8"), readFile(authority.caKeyFile, "utf8")]);
|
|
6997
|
+
assertMatchingCa(caCert, caKey);
|
|
6998
|
+
const now = /* @__PURE__ */ new Date();
|
|
6999
|
+
const notAfter = new Date(now);
|
|
7000
|
+
notAfter.setDate(notAfter.getDate() + lifetimeDays);
|
|
7001
|
+
const server = await generate([{
|
|
7002
|
+
name: "commonName",
|
|
7003
|
+
value: target.commonName
|
|
7004
|
+
}], {
|
|
7005
|
+
keyType: "ec",
|
|
7006
|
+
curve: "P-256",
|
|
7007
|
+
algorithm: "sha256",
|
|
7008
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
7009
|
+
notAfterDate: notAfter,
|
|
7010
|
+
ca: {
|
|
7011
|
+
cert: caCert,
|
|
7012
|
+
key: caKey
|
|
7013
|
+
},
|
|
7014
|
+
extensions: [
|
|
7015
|
+
{
|
|
7016
|
+
name: "basicConstraints",
|
|
7017
|
+
cA: false,
|
|
7018
|
+
critical: true
|
|
7019
|
+
},
|
|
7020
|
+
{
|
|
7021
|
+
name: "keyUsage",
|
|
7022
|
+
digitalSignature: true,
|
|
7023
|
+
critical: true
|
|
7024
|
+
},
|
|
7025
|
+
{
|
|
7026
|
+
name: "extKeyUsage",
|
|
7027
|
+
serverAuth: true
|
|
7028
|
+
},
|
|
7029
|
+
{
|
|
7030
|
+
name: "subjectAltName",
|
|
7031
|
+
altNames
|
|
7032
|
+
}
|
|
7033
|
+
]
|
|
7034
|
+
});
|
|
7035
|
+
await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
|
|
7036
|
+
}
|
|
7037
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
7038
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
7039
|
+
await issueServerCertificate({
|
|
7040
|
+
caCertFile: setup.tls.caCertFile,
|
|
7041
|
+
caKeyFile: setup.tls.caKeyFile
|
|
7042
|
+
}, {
|
|
7043
|
+
commonName: "DeepSeek Harness Mobile",
|
|
7044
|
+
ipAddresses: [address]
|
|
7045
|
+
}, {
|
|
7046
|
+
certFile: setup.tls.certFile,
|
|
7047
|
+
keyFile: setup.tls.keyFile
|
|
7048
|
+
});
|
|
7049
|
+
}
|
|
7050
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
7051
|
+
async function materializeManagedSetup(setup, table) {
|
|
7052
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
7053
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
7054
|
+
const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
|
|
7055
|
+
return {
|
|
7056
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
7057
|
+
listenHost: network.address,
|
|
7058
|
+
allowedCidrs: [network.cidr],
|
|
7059
|
+
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
7060
|
+
pairingCaFile: setup.tls.caCertFile,
|
|
7061
|
+
tls: {
|
|
7062
|
+
mode: "provided",
|
|
7063
|
+
certFile: setup.tls.certFile,
|
|
7064
|
+
keyFile: setup.tls.keyFile
|
|
7065
|
+
}
|
|
7066
|
+
};
|
|
7067
|
+
}
|
|
7068
|
+
//#endregion
|
|
7069
|
+
//#region src/frp-ingress.ts
|
|
7070
|
+
/** Private ingress directory, always a sibling of the remote device file. */
|
|
7071
|
+
function frpIngressPaths(stateFile) {
|
|
7072
|
+
const directory = join(dirname(stateFile), "ingress");
|
|
7073
|
+
return Object.freeze({
|
|
7074
|
+
directory,
|
|
7075
|
+
caCertFile: join(directory, "ca.pem"),
|
|
7076
|
+
caKeyFile: join(directory, "ca-key.pem"),
|
|
7077
|
+
certFile: join(directory, "server.pem"),
|
|
7078
|
+
keyFile: join(directory, "server-key.pem"),
|
|
7079
|
+
statusFile: join(dirname(stateFile), "ingress-identity.json")
|
|
7080
|
+
});
|
|
7081
|
+
}
|
|
7082
|
+
async function regularFileExists(file) {
|
|
7083
|
+
try {
|
|
7084
|
+
const entry = await lstat(file);
|
|
7085
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
7086
|
+
return true;
|
|
7087
|
+
} catch (error) {
|
|
7088
|
+
if (error.code === "ENOENT") return false;
|
|
7089
|
+
throw error;
|
|
7090
|
+
}
|
|
7091
|
+
}
|
|
7092
|
+
async function readIngressIdentity(file) {
|
|
7093
|
+
if (!await regularFileExists(file)) return void 0;
|
|
7094
|
+
try {
|
|
7095
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
7096
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
|
|
7097
|
+
const record = parsed;
|
|
7098
|
+
if (record.version !== 1 || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
|
|
7099
|
+
return record.caFingerprint;
|
|
7100
|
+
} catch (error) {
|
|
7101
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
7102
|
+
}
|
|
7103
|
+
}
|
|
7104
|
+
async function writeIngressIdentity(file, fingerprint) {
|
|
7105
|
+
const temporary = join(dirname(file), `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
7106
|
+
try {
|
|
7107
|
+
await writeFile(temporary, `${JSON.stringify({
|
|
7108
|
+
version: 1,
|
|
7109
|
+
caFingerprint: fingerprint
|
|
7110
|
+
})}\n`, {
|
|
7111
|
+
flag: "wx",
|
|
7112
|
+
mode: 384
|
|
7113
|
+
});
|
|
7114
|
+
await rename(temporary, file);
|
|
7115
|
+
await restrictPrivateFile(file);
|
|
7116
|
+
} finally {
|
|
7117
|
+
try {
|
|
7118
|
+
await unlink(temporary);
|
|
7119
|
+
} catch (error) {
|
|
7120
|
+
if (error.code !== "ENOENT") throw error;
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
7123
|
+
}
|
|
7124
|
+
function coversPublicIp(certificate, address) {
|
|
7125
|
+
return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
|
|
7126
|
+
}
|
|
7127
|
+
/**
|
|
7128
|
+
* Materialize the CA and leaf the gateway terminates TLS with.
|
|
7129
|
+
*
|
|
7130
|
+
* The CA remains stable for paired phones; an expired CA must be re-paired, not
|
|
7131
|
+
* replaced silently. The leaf is re-signed when expiring or no longer valid for
|
|
7132
|
+
* the public IPv4, and the running gateway reloads it after a renewal check.
|
|
7133
|
+
*/
|
|
7134
|
+
async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
|
|
7135
|
+
if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
|
|
7136
|
+
const paths = frpIngressPaths(stateFile);
|
|
7137
|
+
try {
|
|
7138
|
+
const directory = await lstat(paths.directory);
|
|
7139
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
|
|
7140
|
+
} catch (error) {
|
|
7141
|
+
if (error.code !== "ENOENT") throw error;
|
|
7142
|
+
}
|
|
7143
|
+
const previousFingerprint = await readIngressIdentity(paths.statusFile);
|
|
7144
|
+
const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
|
|
7145
|
+
regularFileExists(paths.caCertFile),
|
|
7146
|
+
regularFileExists(paths.caKeyFile),
|
|
7147
|
+
regularFileExists(paths.certFile),
|
|
7148
|
+
regularFileExists(paths.keyFile)
|
|
7149
|
+
]);
|
|
7150
|
+
if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
|
|
7151
|
+
if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
|
|
7152
|
+
if (previousFingerprint === void 0) {
|
|
7153
|
+
await mkdir(dirname(paths.statusFile), {
|
|
7154
|
+
recursive: true,
|
|
7155
|
+
mode: 448
|
|
7156
|
+
});
|
|
7157
|
+
try {
|
|
7158
|
+
await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
|
|
7159
|
+
flag: "wx",
|
|
7160
|
+
mode: 384
|
|
7161
|
+
});
|
|
7162
|
+
await restrictPrivateFile(paths.statusFile);
|
|
7163
|
+
} catch (error) {
|
|
7164
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
7165
|
+
}
|
|
7166
|
+
}
|
|
7167
|
+
const caFiles = {
|
|
7168
|
+
mode: "managed",
|
|
7169
|
+
caCertFile: paths.caCertFile,
|
|
7170
|
+
caKeyFile: paths.caKeyFile,
|
|
7171
|
+
certFile: paths.certFile,
|
|
7172
|
+
keyFile: paths.keyFile
|
|
7173
|
+
};
|
|
7174
|
+
let ca;
|
|
7175
|
+
try {
|
|
7176
|
+
ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
|
|
7177
|
+
} catch (error) {
|
|
7178
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
7179
|
+
}
|
|
7180
|
+
const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
7181
|
+
if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
7182
|
+
if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
7183
|
+
if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
|
|
7184
|
+
const publicHost = new URL(settings.publicOrigin).hostname;
|
|
7185
|
+
let leaf;
|
|
7186
|
+
try {
|
|
7187
|
+
leaf = new X509Certificate(await readFile(paths.certFile));
|
|
7188
|
+
} catch {
|
|
7189
|
+
leaf = void 0;
|
|
7190
|
+
}
|
|
7191
|
+
if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
|
|
7192
|
+
await issueServerCertificate({
|
|
7193
|
+
caCertFile: paths.caCertFile,
|
|
7194
|
+
caKeyFile: paths.caKeyFile
|
|
7195
|
+
}, {
|
|
7196
|
+
commonName: "DSH Mobile FRP ingress",
|
|
7197
|
+
ipAddresses: [publicHost]
|
|
7198
|
+
}, {
|
|
7199
|
+
certFile: paths.certFile,
|
|
7200
|
+
keyFile: paths.keyFile
|
|
7201
|
+
});
|
|
7202
|
+
leaf = new X509Certificate(await readFile(paths.certFile));
|
|
7203
|
+
}
|
|
7204
|
+
const signed = leaf ?? (() => {
|
|
7205
|
+
throw new Error("frp_attach_cert_unknown");
|
|
7206
|
+
})();
|
|
7207
|
+
return Object.freeze({
|
|
7208
|
+
paths,
|
|
7209
|
+
ca,
|
|
7210
|
+
leaf: signed,
|
|
7211
|
+
caFingerprint,
|
|
7212
|
+
status: await readCertificateRenewal(paths.certFile, now)
|
|
7213
|
+
});
|
|
7214
|
+
}
|
|
7215
|
+
/**
|
|
7216
|
+
* Build the read-only self-check payload shown in the desktop panel.
|
|
7217
|
+
*
|
|
7218
|
+
* No secret ever leaves this function: the CA is reported by fingerprint only,
|
|
7219
|
+
* and the token is never read.
|
|
7220
|
+
*/
|
|
7221
|
+
async function frpIngressSelfCheck(settings, stateFile) {
|
|
7222
|
+
const paths = frpIngressPaths(stateFile);
|
|
7223
|
+
const base = {
|
|
7224
|
+
mode: resolveFrpMode(settings),
|
|
7225
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
7226
|
+
vhostHttpPort: resolveFrpVhostHttpPort(settings),
|
|
7227
|
+
publicPort: resolveFrpPublicPort(settings),
|
|
7228
|
+
publicOrigin: settings.publicOrigin,
|
|
7229
|
+
entryOrigin: frpEntryOrigin(settings),
|
|
7230
|
+
serverAddress: settings.serverAddress,
|
|
7231
|
+
serverPort: settings.serverPort,
|
|
7232
|
+
inbound: {
|
|
7233
|
+
listenHost: "127.0.0.1",
|
|
7234
|
+
allowedCidrs: Object.freeze(["127.0.0.0/8"])
|
|
7235
|
+
}
|
|
7236
|
+
};
|
|
7237
|
+
if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
|
|
7238
|
+
let caFingerprint;
|
|
7239
|
+
try {
|
|
7240
|
+
caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
|
|
7241
|
+
} catch {
|
|
7242
|
+
caFingerprint = void 0;
|
|
7243
|
+
}
|
|
7244
|
+
return Object.freeze({
|
|
7245
|
+
...base,
|
|
7246
|
+
...caFingerprint === void 0 ? {} : { caFingerprint },
|
|
7247
|
+
certificate: await readCertificateRenewal(paths.certFile),
|
|
7248
|
+
caCertificate: await readCertificateRenewal(paths.caCertFile)
|
|
7249
|
+
});
|
|
7250
|
+
}
|
|
7251
|
+
/** Remove only the five files owned by the local self-signed FRP entry. */
|
|
7252
|
+
async function purgeFrpIngressCertificates(stateFile) {
|
|
7253
|
+
const paths = frpIngressPaths(stateFile);
|
|
7254
|
+
try {
|
|
7255
|
+
const marker = await lstat(paths.statusFile);
|
|
7256
|
+
if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
7257
|
+
await unlink(paths.statusFile);
|
|
7258
|
+
} catch (error) {
|
|
7259
|
+
if (error.code !== "ENOENT") throw error;
|
|
7260
|
+
}
|
|
7261
|
+
let directory;
|
|
7262
|
+
try {
|
|
7263
|
+
directory = await lstat(paths.directory);
|
|
7264
|
+
} catch (error) {
|
|
7265
|
+
if (error.code === "ENOENT") return;
|
|
7266
|
+
throw error;
|
|
7267
|
+
}
|
|
7268
|
+
if (directory.isSymbolicLink()) {
|
|
7269
|
+
await unlink(paths.directory);
|
|
7270
|
+
return;
|
|
7271
|
+
}
|
|
7272
|
+
if (!directory.isDirectory()) throw new Error("frp_ingress_directory_invalid");
|
|
7273
|
+
for (const file of [
|
|
7274
|
+
paths.caCertFile,
|
|
7275
|
+
paths.caKeyFile,
|
|
7276
|
+
paths.certFile,
|
|
7277
|
+
paths.keyFile
|
|
7278
|
+
]) {
|
|
7279
|
+
let entry;
|
|
7280
|
+
try {
|
|
7281
|
+
entry = await lstat(file);
|
|
7282
|
+
} catch (error) {
|
|
7283
|
+
if (error.code === "ENOENT") continue;
|
|
7284
|
+
throw error;
|
|
7285
|
+
}
|
|
7286
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
7287
|
+
await unlink(file);
|
|
7288
|
+
}
|
|
7289
|
+
try {
|
|
7290
|
+
await rmdir(paths.directory);
|
|
7291
|
+
} catch (error) {
|
|
7292
|
+
if (error.code === "ENOENT") return;
|
|
7293
|
+
throw error;
|
|
7294
|
+
}
|
|
7295
|
+
}
|
|
7296
|
+
//#endregion
|
|
7297
|
+
//#region src/remote.ts
|
|
7298
|
+
const REMOTE_PROVIDERS = Object.freeze([
|
|
7299
|
+
"tailscale",
|
|
7300
|
+
"cpolar",
|
|
7301
|
+
"cloudflared",
|
|
7302
|
+
"frp",
|
|
7303
|
+
"origin"
|
|
7304
|
+
]);
|
|
7305
|
+
function aggregateErrors(errors, message) {
|
|
7306
|
+
if (errors.length === 0) return void 0;
|
|
7307
|
+
if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
|
|
7308
|
+
return new AggregateError(errors, message);
|
|
7309
|
+
}
|
|
7310
|
+
/** Settle independent remote cleanup work before reporting any collected failure. */
|
|
7311
|
+
async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
|
|
7312
|
+
const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
|
|
7313
|
+
if (failure !== void 0) throw failure;
|
|
7314
|
+
}
|
|
7315
|
+
/**
|
|
7316
|
+
* Serialize all provider mutations and preserve the single-provider invariant.
|
|
7317
|
+
* Operations read the selected controller only after reaching the front of the queue.
|
|
7318
|
+
*/
|
|
7319
|
+
var RemoteProviderCoordinator = class {
|
|
7320
|
+
controllers;
|
|
7321
|
+
store;
|
|
7322
|
+
selectedValue;
|
|
7323
|
+
queue = Promise.resolve();
|
|
7324
|
+
constructor(selected, controllers, store) {
|
|
7325
|
+
this.controllers = controllers;
|
|
7326
|
+
this.store = store;
|
|
7327
|
+
this.selectedValue = selected;
|
|
7328
|
+
}
|
|
7329
|
+
/** Return the durable provider currently selected by the desktop UI. */
|
|
7330
|
+
get selected() {
|
|
7331
|
+
return this.selectedValue;
|
|
5832
7332
|
}
|
|
5833
7333
|
/** Return the controller selected when this method is called. */
|
|
5834
7334
|
controller() {
|
|
@@ -6003,6 +7503,7 @@ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
|
|
|
6003
7503
|
const DISCOVERY_RETRY_MS = 1e3;
|
|
6004
7504
|
const MAX_DISCOVERY_BYTES = 16384;
|
|
6005
7505
|
const VHOST_PROBE_TIMEOUT_MS = 1500;
|
|
7506
|
+
const INGRESS_CERT_CHECK_MS = 432e5;
|
|
6006
7507
|
function publicStatus$3(status) {
|
|
6007
7508
|
return Object.freeze({
|
|
6008
7509
|
enabled: status.enabled,
|
|
@@ -6097,38 +7598,108 @@ async function boundedResponseBytes(response) {
|
|
|
6097
7598
|
}
|
|
6098
7599
|
return bytes;
|
|
6099
7600
|
}
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
const
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
7601
|
+
/** Web view over an HTTPS response so the byte cap applies to both paths alike. */
|
|
7602
|
+
function discoveryResponse(message) {
|
|
7603
|
+
const headers = new Headers();
|
|
7604
|
+
const declaredLength = message.headers["content-length"];
|
|
7605
|
+
if (declaredLength !== void 0) headers.set("content-length", declaredLength);
|
|
7606
|
+
return new Response(Readable.toWeb(message), {
|
|
7607
|
+
status: message.statusCode ?? 200,
|
|
7608
|
+
headers
|
|
7609
|
+
});
|
|
7610
|
+
}
|
|
7611
|
+
/**
|
|
7612
|
+
* Perform one discovery request against the public entry.
|
|
7613
|
+
*
|
|
7614
|
+
* `node:https` carries this request instead of the global `fetch` for a single
|
|
7615
|
+
* reason: only here can the trust anchor be stated. The self-signed entry
|
|
7616
|
+
* presents a leaf the plugin's own ingress CA signed, so the default chain
|
|
7617
|
+
* always fails there (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`) even though the
|
|
7618
|
+
* transport itself is healthy. The anchor is *added* to the trust store — never
|
|
7619
|
+
* `rejectUnauthorized: false` — so the peer must still prove its identity, and
|
|
7620
|
+
* the verified name stays the host of the origin, which is the name the app
|
|
7621
|
+
* dials as well (Node matches IP literals against `IP Address:` SANs).
|
|
7622
|
+
*
|
|
7623
|
+
* A non-200 answer resolves `undefined` for retry; discovery requires a body.
|
|
7624
|
+
* transport, TLS, and timeout failures reject so the caller keeps retrying.
|
|
7625
|
+
* The request stays cancellable until the bounded response body has completed.
|
|
7626
|
+
*/
|
|
7627
|
+
function requestDiscovery(target, signal) {
|
|
7628
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_aborted"));
|
|
7629
|
+
const url = new URL(`${target.origin}/mobile-access/discovery`);
|
|
7630
|
+
if (url.protocol !== "https:" || url.hostname === "") return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_invalid"));
|
|
7631
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
7632
|
+
let settled = false;
|
|
7633
|
+
let request;
|
|
7634
|
+
const release = () => {
|
|
7635
|
+
clearTimeout(timeout);
|
|
7636
|
+
signal.removeEventListener("abort", onAbort);
|
|
7637
|
+
};
|
|
7638
|
+
const fail = (error) => {
|
|
7639
|
+
if (settled) return;
|
|
7640
|
+
settled = true;
|
|
7641
|
+
release();
|
|
7642
|
+
request?.destroy();
|
|
7643
|
+
rejectRequest(error);
|
|
7644
|
+
};
|
|
7645
|
+
const succeed = (value) => {
|
|
7646
|
+
if (settled) return;
|
|
7647
|
+
settled = true;
|
|
7648
|
+
release();
|
|
7649
|
+
resolveRequest(value);
|
|
7650
|
+
};
|
|
7651
|
+
const onAbort = () => {
|
|
7652
|
+
fail(/* @__PURE__ */ new Error("frp_discovery_aborted"));
|
|
7653
|
+
};
|
|
7654
|
+
const timeout = setTimeout(() => {
|
|
7655
|
+
fail(/* @__PURE__ */ new Error("frp_discovery_timeout"));
|
|
7656
|
+
}, DISCOVERY_REQUEST_TIMEOUT_MS);
|
|
7657
|
+
timeout.unref();
|
|
7658
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7659
|
+
request = request$1({
|
|
7660
|
+
host: url.hostname,
|
|
7661
|
+
port: url.port === "" ? 443 : Number(url.port),
|
|
7662
|
+
path: url.pathname,
|
|
6110
7663
|
method: "GET",
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
7664
|
+
headers: { accept: "application/json" },
|
|
7665
|
+
...target.trustAnchorPem === void 0 ? {} : { ca: target.trustAnchorPem },
|
|
7666
|
+
agent: false
|
|
7667
|
+
}, (message) => {
|
|
7668
|
+
if ((message.statusCode ?? 0) !== 200) {
|
|
7669
|
+
succeed(void 0);
|
|
7670
|
+
message.destroy();
|
|
7671
|
+
return;
|
|
7672
|
+
}
|
|
7673
|
+
boundedResponseBytes(discoveryResponse(message)).then(succeed, fail);
|
|
6115
7674
|
});
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
7675
|
+
request.once("error", (error) => {
|
|
7676
|
+
fail(error instanceof Error ? error : /* @__PURE__ */ new Error("frp_discovery_request_failed"));
|
|
7677
|
+
});
|
|
7678
|
+
request.end();
|
|
7679
|
+
});
|
|
7680
|
+
}
|
|
7681
|
+
/**
|
|
7682
|
+
* Read the public discovery advertisement and compare it with the expected
|
|
7683
|
+
* identity.
|
|
7684
|
+
*
|
|
7685
|
+
* `frp_discovery_invalid` (malformed body) and `frp_discovery_mismatch` (the
|
|
7686
|
+
* entry advertises another identity) stay immediate failures; transport, TLS,
|
|
7687
|
+
* and timeout problems reject so the caller retries until its deadline.
|
|
7688
|
+
*/
|
|
7689
|
+
async function defaultProbeDiscovery(target, expectedInstanceId, signal) {
|
|
7690
|
+
const bytes = await requestDiscovery(target, signal);
|
|
7691
|
+
if (bytes === void 0) return false;
|
|
7692
|
+
let value;
|
|
7693
|
+
try {
|
|
7694
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
7695
|
+
} catch {
|
|
7696
|
+
throw new Error("frp_discovery_invalid");
|
|
6131
7697
|
}
|
|
7698
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
|
|
7699
|
+
const actual = value.instanceId;
|
|
7700
|
+
if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
|
|
7701
|
+
if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
|
|
7702
|
+
return true;
|
|
6132
7703
|
}
|
|
6133
7704
|
/** Owns frpc, its generation-specific configuration, and the remote gateway. */
|
|
6134
7705
|
var FrpController = class {
|
|
@@ -6145,10 +7716,12 @@ var FrpController = class {
|
|
|
6145
7716
|
});
|
|
6146
7717
|
queue = Promise.resolve();
|
|
6147
7718
|
startupAbort;
|
|
7719
|
+
ingressCertificateTimer;
|
|
6148
7720
|
constructor(options) {
|
|
6149
7721
|
this.options = options;
|
|
6150
7722
|
if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
|
|
6151
7723
|
if (!/^[a-f0-9]{64}$/u.test(options.instanceId)) throw new Error("FRP instance ID is invalid");
|
|
7724
|
+
if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
|
|
6152
7725
|
}
|
|
6153
7726
|
/** Restore the remembered FRP switch without changing LAN or other providers. */
|
|
6154
7727
|
async initialize() {
|
|
@@ -6268,41 +7841,45 @@ var FrpController = class {
|
|
|
6268
7841
|
});
|
|
6269
7842
|
return;
|
|
6270
7843
|
}
|
|
7844
|
+
const entryOrigin = frpEntryOrigin(settings);
|
|
6271
7845
|
this.publish({
|
|
6272
7846
|
enabled: true,
|
|
6273
7847
|
state: "starting",
|
|
6274
|
-
origin:
|
|
7848
|
+
origin: entryOrigin
|
|
6275
7849
|
});
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
7850
|
+
if (!isFrpSelfSignedIngress(settings)) {
|
|
7851
|
+
let exposed;
|
|
7852
|
+
try {
|
|
7853
|
+
exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, resolveFrpVhostHttpPort(settings));
|
|
7854
|
+
} catch {
|
|
7855
|
+
this.publish({
|
|
7856
|
+
enabled: true,
|
|
7857
|
+
state: "error",
|
|
7858
|
+
origin: entryOrigin,
|
|
7859
|
+
errorCode: "frp_vhost_probe_failed"
|
|
7860
|
+
});
|
|
7861
|
+
return;
|
|
7862
|
+
}
|
|
7863
|
+
if (exposed) {
|
|
7864
|
+
this.publish({
|
|
7865
|
+
enabled: true,
|
|
7866
|
+
state: "error",
|
|
7867
|
+
origin: entryOrigin,
|
|
7868
|
+
errorCode: "frp_vhost_publicly_reachable"
|
|
7869
|
+
});
|
|
7870
|
+
return;
|
|
7871
|
+
}
|
|
6296
7872
|
}
|
|
6297
7873
|
let gateway;
|
|
6298
7874
|
try {
|
|
6299
|
-
gateway = await this.options.createGateway(settings.publicOrigin);
|
|
6300
|
-
} catch {
|
|
7875
|
+
gateway = await this.options.createGateway(settings.publicOrigin, settings);
|
|
7876
|
+
} catch (error) {
|
|
7877
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "gateway_start_failed";
|
|
6301
7878
|
this.publish({
|
|
6302
7879
|
enabled: true,
|
|
6303
7880
|
state: "error",
|
|
6304
|
-
origin:
|
|
6305
|
-
errorCode:
|
|
7881
|
+
origin: entryOrigin,
|
|
7882
|
+
errorCode: code
|
|
6306
7883
|
});
|
|
6307
7884
|
return;
|
|
6308
7885
|
}
|
|
@@ -6341,26 +7918,66 @@ var FrpController = class {
|
|
|
6341
7918
|
this.publish({
|
|
6342
7919
|
enabled: true,
|
|
6343
7920
|
state: "connecting",
|
|
6344
|
-
origin:
|
|
7921
|
+
origin: entryOrigin
|
|
6345
7922
|
});
|
|
7923
|
+
let check;
|
|
7924
|
+
try {
|
|
7925
|
+
check = await this.discoveryCheck(settings);
|
|
7926
|
+
} catch (error) {
|
|
7927
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_ca_invalid";
|
|
7928
|
+
await this.failGeneration(generation, code);
|
|
7929
|
+
return;
|
|
7930
|
+
}
|
|
7931
|
+
if (generation !== this.generation || !this.enabled) return;
|
|
6346
7932
|
const controller = new AbortController();
|
|
6347
7933
|
this.startupAbort = controller;
|
|
6348
|
-
this.waitForDiscovery(generation,
|
|
7934
|
+
this.waitForDiscovery(generation, check, gateway.config.instanceId, settings, gateway, controller.signal);
|
|
7935
|
+
}
|
|
7936
|
+
/**
|
|
7937
|
+
* Derive the self-check target and trust anchor for the effective entry.
|
|
7938
|
+
*
|
|
7939
|
+
* The public-CA entry is reached on the saved origin itself: Caddy terminates
|
|
7940
|
+
* TLS on 443 with a publicly trusted certificate, so the probe must keep the
|
|
7941
|
+
* system trust store and the origin URL untouched. The self-signed entry is a
|
|
7942
|
+
* raw TCP passthrough on `publicPort` whose leaf chains to the ingress CA the
|
|
7943
|
+
* app pins from `pairingCaFile`; probing `publicOrigin` there would knock on
|
|
7944
|
+
* 443 — where nothing listens — with a chain the system cannot verify. Both
|
|
7945
|
+
* facts used to be implicit, and together they kept the channel out of `ready`
|
|
7946
|
+
* until `frp_start_timeout`.
|
|
7947
|
+
*/
|
|
7948
|
+
async discoveryCheck(settings) {
|
|
7949
|
+
if (!isFrpSelfSignedIngress(settings)) return {
|
|
7950
|
+
publicOrigin: settings.publicOrigin,
|
|
7951
|
+
targetOrigin: settings.publicOrigin
|
|
7952
|
+
};
|
|
7953
|
+
const targetOrigin = frpEntryOrigin(settings);
|
|
7954
|
+
const trustAnchorPem = await this.options.resolveDiscoveryTrustAnchor?.(settings);
|
|
7955
|
+
if (this.options.resolveDiscoveryTrustAnchor !== void 0 && trustAnchorPem === void 0) throw new Error("frp_ingress_ca_invalid");
|
|
7956
|
+
return Object.freeze({
|
|
7957
|
+
publicOrigin: targetOrigin,
|
|
7958
|
+
targetOrigin,
|
|
7959
|
+
...trustAnchorPem === void 0 ? {} : { trustAnchorPem }
|
|
7960
|
+
});
|
|
6349
7961
|
}
|
|
6350
|
-
async waitForDiscovery(generation,
|
|
7962
|
+
async waitForDiscovery(generation, check, advertisedInstanceId, settings, gateway, signal) {
|
|
6351
7963
|
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
|
|
6352
7964
|
const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
|
|
7965
|
+
const target = Object.freeze({
|
|
7966
|
+
origin: check.targetOrigin,
|
|
7967
|
+
...check.trustAnchorPem === void 0 ? {} : { trustAnchorPem: check.trustAnchorPem }
|
|
7968
|
+
});
|
|
6353
7969
|
while (!signal.aborted && Date.now() < deadline) {
|
|
6354
7970
|
try {
|
|
6355
|
-
if (await probe(
|
|
7971
|
+
if (await probe(target, advertisedInstanceId, signal)) {
|
|
6356
7972
|
await this.enqueue(async () => {
|
|
6357
7973
|
if (generation !== this.generation || signal.aborted || !this.enabled) return;
|
|
6358
7974
|
this.startupAbort = void 0;
|
|
6359
7975
|
this.publish({
|
|
6360
7976
|
enabled: true,
|
|
6361
7977
|
state: "ready",
|
|
6362
|
-
origin
|
|
7978
|
+
origin: check.publicOrigin
|
|
6363
7979
|
});
|
|
7980
|
+
this.scheduleIngressCertificateCheck(generation, settings, gateway);
|
|
6364
7981
|
});
|
|
6365
7982
|
return;
|
|
6366
7983
|
}
|
|
@@ -6387,6 +8004,33 @@ var FrpController = class {
|
|
|
6387
8004
|
}
|
|
6388
8005
|
if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
|
|
6389
8006
|
}
|
|
8007
|
+
scheduleIngressCertificateCheck(generation, settings, gateway) {
|
|
8008
|
+
const maintain = this.options.maintainIngressCertificate;
|
|
8009
|
+
if (!isFrpSelfSignedIngress(settings) || maintain === void 0) return;
|
|
8010
|
+
const timer = setTimeout(() => {
|
|
8011
|
+
if (this.ingressCertificateTimer !== timer) return;
|
|
8012
|
+
this.ingressCertificateTimer = void 0;
|
|
8013
|
+
this.enqueue(async () => {
|
|
8014
|
+
if (generation !== this.generation || !this.enabled || this.disposed || this.gatewayValue !== gateway) return;
|
|
8015
|
+
try {
|
|
8016
|
+
await maintain(settings, gateway);
|
|
8017
|
+
} catch (error) {
|
|
8018
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_renewal_failed";
|
|
8019
|
+
await this.failGeneration(generation, code);
|
|
8020
|
+
return;
|
|
8021
|
+
}
|
|
8022
|
+
if (generation === this.generation && this.enabled && !this.disposed && this.gatewayValue === gateway) this.scheduleIngressCertificateCheck(generation, settings, gateway);
|
|
8023
|
+
}).catch(() => {
|
|
8024
|
+
if (generation === this.generation && this.enabled && !this.disposed) this.publish({
|
|
8025
|
+
enabled: true,
|
|
8026
|
+
state: "error",
|
|
8027
|
+
errorCode: "frp_ingress_renewal_failed"
|
|
8028
|
+
});
|
|
8029
|
+
});
|
|
8030
|
+
}, this.options.ingressCertificateCheckMs ?? INGRESS_CERT_CHECK_MS);
|
|
8031
|
+
timer.unref();
|
|
8032
|
+
this.ingressCertificateTimer = timer;
|
|
8033
|
+
}
|
|
6390
8034
|
async failGeneration(generation, code) {
|
|
6391
8035
|
if (generation !== this.generation) return;
|
|
6392
8036
|
await this.stopProcessAndGateway();
|
|
@@ -6401,6 +8045,8 @@ var FrpController = class {
|
|
|
6401
8045
|
await this.stopProcessAndGateway();
|
|
6402
8046
|
}
|
|
6403
8047
|
async stopProcessAndGateway() {
|
|
8048
|
+
clearTimeout(this.ingressCertificateTimer);
|
|
8049
|
+
this.ingressCertificateTimer = void 0;
|
|
6404
8050
|
this.startupAbort?.abort();
|
|
6405
8051
|
this.startupAbort = void 0;
|
|
6406
8052
|
const child = this.child;
|
|
@@ -7981,21 +9627,212 @@ function defaultFirewallProbe(platform = process.platform) {
|
|
|
7981
9627
|
}
|
|
7982
9628
|
};
|
|
7983
9629
|
}
|
|
7984
|
-
/**
|
|
7985
|
-
function remoteDiagnosticTimeoutMs(
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
9630
|
+
/** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
|
|
9631
|
+
function remoteDiagnosticTimeoutMs(_origin) {
|
|
9632
|
+
return 6e3;
|
|
9633
|
+
}
|
|
9634
|
+
const TUNNEL_HEADER_LIMIT_BYTES = 65536;
|
|
9635
|
+
function proxyExcludedByNoProxy(target, raw) {
|
|
9636
|
+
const text = raw.trim().toLowerCase();
|
|
9637
|
+
if (text === "") return false;
|
|
9638
|
+
const hostname = target.hostname.toLowerCase();
|
|
9639
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
9640
|
+
const targetPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
|
|
9641
|
+
for (const entry of text.split(/[\s,]+/)) {
|
|
9642
|
+
let value = entry;
|
|
9643
|
+
if (value === "") continue;
|
|
9644
|
+
if (value === "*") return true;
|
|
9645
|
+
if (value.includes("/")) continue;
|
|
9646
|
+
let port;
|
|
9647
|
+
if (value.startsWith("[")) {
|
|
9648
|
+
const bracket = value.indexOf("]");
|
|
9649
|
+
if (bracket < 0) continue;
|
|
9650
|
+
const tail = value.slice(bracket + 1);
|
|
9651
|
+
if (tail !== "") {
|
|
9652
|
+
if (!/^:\d+$/u.test(tail)) continue;
|
|
9653
|
+
port = Number(tail.slice(1));
|
|
9654
|
+
}
|
|
9655
|
+
value = value.slice(1, bracket);
|
|
9656
|
+
} else if ((value.match(/:/gu) ?? []).length === 1) {
|
|
9657
|
+
const separator = value.lastIndexOf(":");
|
|
9658
|
+
const suffix = value.slice(separator + 1);
|
|
9659
|
+
if (!/^\d+$/u.test(suffix)) continue;
|
|
9660
|
+
port = Number(suffix);
|
|
9661
|
+
value = value.slice(0, separator);
|
|
9662
|
+
}
|
|
9663
|
+
if (port !== void 0 && port !== targetPort) continue;
|
|
9664
|
+
if (value.startsWith("*.")) value = value.slice(2);
|
|
9665
|
+
else if (value.startsWith(".")) value = value.slice(1);
|
|
9666
|
+
if (value === "") continue;
|
|
9667
|
+
if (host === value || host.endsWith(`.${value}`)) return true;
|
|
9668
|
+
}
|
|
9669
|
+
return false;
|
|
9670
|
+
}
|
|
9671
|
+
/**
|
|
9672
|
+
* Resolve the relay egress configured in the environment for `origin`.
|
|
9673
|
+
*
|
|
9674
|
+
* Honors `HTTPS_PROXY` (then `ALL_PROXY`, then the generic `HTTP_PROXY`) for an
|
|
9675
|
+
* HTTPS target, and `http_proxy`/`HTTP_PROXY`/`ALL_PROXY` for a plain one, unless
|
|
9676
|
+
* `NO_PROXY`/`no_proxy` covers the host. Only a plain `http://<host>:<port>`
|
|
9677
|
+
* relay is usable: SOCKS or a TLS-to-proxy configuration would need a different
|
|
9678
|
+
* tunnel, so absent that the probe keeps its direct semantics.
|
|
9679
|
+
*/
|
|
9680
|
+
function configuredProxyFor(origin, env = process.env) {
|
|
9681
|
+
let target;
|
|
9682
|
+
try {
|
|
9683
|
+
target = new URL(origin);
|
|
9684
|
+
} catch {
|
|
9685
|
+
return;
|
|
9686
|
+
}
|
|
9687
|
+
if (proxyExcludedByNoProxy(target, env.NO_PROXY ?? env.no_proxy ?? "")) return void 0;
|
|
9688
|
+
const candidates = target.protocol === "https:" ? [
|
|
9689
|
+
env.https_proxy ?? env.HTTPS_PROXY,
|
|
9690
|
+
env.all_proxy ?? env.ALL_PROXY,
|
|
9691
|
+
env.http_proxy ?? env.HTTP_PROXY
|
|
9692
|
+
] : [env.http_proxy ?? env.HTTP_PROXY, env.all_proxy ?? env.ALL_PROXY];
|
|
9693
|
+
for (const candidate of candidates) {
|
|
9694
|
+
if (typeof candidate !== "string" || candidate.trim() === "") continue;
|
|
9695
|
+
try {
|
|
9696
|
+
const proxy = new URL(candidate.trim());
|
|
9697
|
+
if (proxy.protocol !== "http:" || proxy.hostname === "") continue;
|
|
9698
|
+
return proxy;
|
|
9699
|
+
} catch {
|
|
9700
|
+
continue;
|
|
9701
|
+
}
|
|
9702
|
+
}
|
|
9703
|
+
}
|
|
9704
|
+
/** One reachability probe through an HTTP relay: CONNECT, TLS, then a status line. */
|
|
9705
|
+
async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnosticTimeoutMs(target.origin)) {
|
|
9706
|
+
const started = performance.now();
|
|
9707
|
+
return new Promise((resolve, reject) => {
|
|
9708
|
+
let settled = false;
|
|
9709
|
+
let hopRequest;
|
|
9710
|
+
let rawSocket;
|
|
9711
|
+
let secureSocket;
|
|
9712
|
+
let timer;
|
|
9713
|
+
const finish = (outcome) => {
|
|
9714
|
+
if (settled) return;
|
|
9715
|
+
settled = true;
|
|
9716
|
+
clearTimeout(timer);
|
|
9717
|
+
secureSocket?.destroy();
|
|
9718
|
+
rawSocket?.end();
|
|
9719
|
+
rawSocket?.destroy();
|
|
9720
|
+
hopRequest?.destroy();
|
|
9721
|
+
if (outcome.error !== void 0) reject(outcome.error);
|
|
9722
|
+
else resolve(outcome.value ?? {
|
|
9723
|
+
status: 0,
|
|
9724
|
+
latencyMs: 0
|
|
9725
|
+
});
|
|
9726
|
+
};
|
|
9727
|
+
timer = setTimeout(() => {
|
|
9728
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_timeout") });
|
|
9729
|
+
}, Math.max(1, budgetMs));
|
|
9730
|
+
timer.unref();
|
|
9731
|
+
const hopPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
|
|
9732
|
+
const hopPath = `${target.hostname}:${hopPort}`;
|
|
9733
|
+
const headers = {
|
|
9734
|
+
host: hopPath,
|
|
9735
|
+
"proxy-connection": "keep-alive"
|
|
9736
|
+
};
|
|
9737
|
+
if (proxy.username !== "") headers["proxy-authorization"] = `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`, "utf8").toString("base64")}`;
|
|
9738
|
+
hopRequest = request({
|
|
9739
|
+
host: proxy.hostname,
|
|
9740
|
+
port: proxy.port === "" ? 80 : Number(proxy.port),
|
|
9741
|
+
method: "CONNECT",
|
|
9742
|
+
path: hopPath,
|
|
9743
|
+
headers,
|
|
9744
|
+
agent: false
|
|
9745
|
+
});
|
|
9746
|
+
hopRequest.once("connect", (response, socket) => {
|
|
9747
|
+
if (settled) {
|
|
9748
|
+
socket.destroy();
|
|
9749
|
+
return;
|
|
9750
|
+
}
|
|
9751
|
+
rawSocket = socket;
|
|
9752
|
+
if (response.statusCode !== 200) {
|
|
9753
|
+
finish({ error: /* @__PURE__ */ new Error(`proxy_tunnel_rejected_${response.statusCode ?? "unknown"}`) });
|
|
9754
|
+
return;
|
|
9755
|
+
}
|
|
9756
|
+
const secure = connect$1({
|
|
9757
|
+
socket,
|
|
9758
|
+
servername: target.hostname,
|
|
9759
|
+
rejectUnauthorized: true
|
|
9760
|
+
});
|
|
9761
|
+
secureSocket = secure;
|
|
9762
|
+
secure.once("secureConnect", () => {
|
|
9763
|
+
if (settled) return;
|
|
9764
|
+
secure.write([
|
|
9765
|
+
`GET ${target.pathname}${target.search} HTTP/1.1`,
|
|
9766
|
+
`Host: ${target.host}`,
|
|
9767
|
+
"User-Agent: dsh-mobile-diagnostics",
|
|
9768
|
+
"Accept-Encoding: identity",
|
|
9769
|
+
"Connection: close",
|
|
9770
|
+
"",
|
|
9771
|
+
""
|
|
9772
|
+
].join("\r\n"));
|
|
9773
|
+
});
|
|
9774
|
+
let buffered = Buffer.alloc(0);
|
|
9775
|
+
secure.on("data", (chunk) => {
|
|
9776
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
9777
|
+
if (buffered.byteLength > TUNNEL_HEADER_LIMIT_BYTES) {
|
|
9778
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_oversized_response") });
|
|
9779
|
+
return;
|
|
9780
|
+
}
|
|
9781
|
+
const end = buffered.indexOf("\r\n\r\n");
|
|
9782
|
+
if (end < 0) return;
|
|
9783
|
+
const statusLine = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/u.exec(buffered.subarray(0, end).toString("latin1"))?.[1];
|
|
9784
|
+
const status = Number.parseInt(statusLine ?? "", 10);
|
|
9785
|
+
if (!Number.isSafeInteger(status) || status < 100) {
|
|
9786
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_invalid_response") });
|
|
9787
|
+
return;
|
|
9788
|
+
}
|
|
9789
|
+
finish({ value: {
|
|
9790
|
+
status,
|
|
9791
|
+
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
9792
|
+
} });
|
|
9793
|
+
});
|
|
9794
|
+
secure.once("error", (error) => {
|
|
9795
|
+
finish({ error });
|
|
9796
|
+
});
|
|
9797
|
+
secure.once("close", () => {
|
|
9798
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_closed") });
|
|
9799
|
+
});
|
|
9800
|
+
});
|
|
9801
|
+
hopRequest.once("error", (error) => {
|
|
9802
|
+
finish({ error });
|
|
9803
|
+
});
|
|
9804
|
+
hopRequest.end();
|
|
9805
|
+
});
|
|
9806
|
+
}
|
|
9807
|
+
/** Bounded supplementary DNS context for a failed direct attempt. */
|
|
9808
|
+
async function unreachableWithDnsContext(hostname, budgetMs) {
|
|
9809
|
+
let fakeIp = false;
|
|
9810
|
+
let timer;
|
|
9811
|
+
try {
|
|
9812
|
+
fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
|
|
9813
|
+
timer = setTimeout(() => {
|
|
9814
|
+
reject(/* @__PURE__ */ new Error("dns_context_timeout"));
|
|
9815
|
+
}, Math.max(1, budgetMs));
|
|
9816
|
+
timer.unref();
|
|
9817
|
+
})])).some(({ address }) => {
|
|
9818
|
+
const [first, second] = address.split(".").map(Number);
|
|
9819
|
+
return first === 198 && (second === 18 || second === 19);
|
|
9820
|
+
});
|
|
9821
|
+
} catch {} finally {
|
|
9822
|
+
clearTimeout(timer);
|
|
9823
|
+
}
|
|
9824
|
+
return {
|
|
9825
|
+
state: "unreachable",
|
|
9826
|
+
...fakeIp ? { fakeIp: true } : {}
|
|
9827
|
+
};
|
|
7989
9828
|
}
|
|
7990
|
-
async function
|
|
7991
|
-
if (origin === void 0) return { state: "not-applicable" };
|
|
7992
|
-
const hostname = new URL(origin).hostname;
|
|
9829
|
+
async function fetchRemoteHealth(target, budgetMs) {
|
|
7993
9830
|
const started = performance.now();
|
|
7994
9831
|
try {
|
|
7995
|
-
const response = await fetch(
|
|
9832
|
+
const response = await fetch(target, {
|
|
7996
9833
|
cache: "no-store",
|
|
7997
9834
|
redirect: "error",
|
|
7998
|
-
signal: AbortSignal.timeout(
|
|
9835
|
+
signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
|
|
7999
9836
|
});
|
|
8000
9837
|
const latencyMs = Math.max(0, Math.round(performance.now() - started));
|
|
8001
9838
|
if (response.status === 429) return {
|
|
@@ -8010,19 +9847,53 @@ async function defaultRemoteProbe(origin) {
|
|
|
8010
9847
|
latencyMs
|
|
8011
9848
|
};
|
|
8012
9849
|
} catch {
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
9850
|
+
return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
|
|
9851
|
+
}
|
|
9852
|
+
}
|
|
9853
|
+
/**
|
|
9854
|
+
* Answer "is the public endpoint reachable" from the machine's own egress.
|
|
9855
|
+
*
|
|
9856
|
+
* A direct attempt stays authoritative first. When it fails and the environment
|
|
9857
|
+
* configures an HTTP relay that `NO_PROXY` does not exclude, one tunnelled
|
|
9858
|
+
* attempt runs: a self-managed Funnel origin otherwise resolves to this
|
|
9859
|
+
* machine's tailnet address, where nothing serves the funnel port locally, so
|
|
9860
|
+
* the direct measurement reports an outage while the relay's egress — the same
|
|
9861
|
+
* public path a phone takes — still succeeds. When both fail, the original
|
|
9862
|
+
* direct observation (including its Fake-IP context) stands unchanged.
|
|
9863
|
+
*/
|
|
9864
|
+
async function probeRemoteHealth(origin, options = {}) {
|
|
9865
|
+
if (origin === void 0) return { state: "not-applicable" };
|
|
9866
|
+
let target;
|
|
9867
|
+
try {
|
|
9868
|
+
target = new URL("/mobile-access/health", origin);
|
|
9869
|
+
} catch {
|
|
9870
|
+
return { state: "not-applicable" };
|
|
9871
|
+
}
|
|
9872
|
+
const budgetMs = remoteDiagnosticTimeoutMs(origin);
|
|
9873
|
+
const observation = await (options.direct ?? fetchRemoteHealth)(target, budgetMs);
|
|
9874
|
+
if (observation.state !== "unreachable") return observation;
|
|
9875
|
+
const relay = configuredProxyFor(origin, options.env ?? process.env);
|
|
9876
|
+
if (relay === void 0) return observation;
|
|
9877
|
+
const tunnel = options.tunnel ?? relayProbeThroughProxy;
|
|
9878
|
+
try {
|
|
9879
|
+
const { status, latencyMs } = await tunnel(target, relay, budgetMs);
|
|
9880
|
+
if (status === 429) return {
|
|
9881
|
+
state: "rate-limited",
|
|
9882
|
+
latencyMs,
|
|
9883
|
+
viaProxy: true
|
|
8023
9884
|
};
|
|
9885
|
+
return status >= 200 && status < 300 ? {
|
|
9886
|
+
state: "ready",
|
|
9887
|
+
latencyMs,
|
|
9888
|
+
viaProxy: true
|
|
9889
|
+
} : observation;
|
|
9890
|
+
} catch {
|
|
9891
|
+
return observation;
|
|
8024
9892
|
}
|
|
8025
9893
|
}
|
|
9894
|
+
async function defaultRemoteProbe(origin) {
|
|
9895
|
+
return probeRemoteHealth(origin);
|
|
9896
|
+
}
|
|
8026
9897
|
function reportLine(entry) {
|
|
8027
9898
|
return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
|
|
8028
9899
|
}
|
|
@@ -8052,10 +9923,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
8052
9923
|
const facts = {
|
|
8053
9924
|
provider: snapshot.remote.provider,
|
|
8054
9925
|
endpointSuffix,
|
|
8055
|
-
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
|
|
9926
|
+
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
|
|
9927
|
+
...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
|
|
8056
9928
|
};
|
|
8057
|
-
if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
|
|
8058
|
-
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
9929
|
+
if (remoteObservation.state === "ready") checks.push(check("remote", remoteObservation.viaProxy === true ? "warning" : "ok", "remote-ready", "远程通道", remoteObservation.viaProxy === true ? `电脑经代理可访问 ${endpointSuffix},但尚未验证手机能否连接。` : `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, remoteObservation.viaProxy === true ? "请在手机上测试远程连接。" : void 0, facts));
|
|
9930
|
+
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
8059
9931
|
else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
|
|
8060
9932
|
else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
|
|
8061
9933
|
} else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
|
|
@@ -10618,394 +12490,76 @@ var PluginReleaseManager = class {
|
|
|
10618
12490
|
return status;
|
|
10619
12491
|
}
|
|
10620
12492
|
/** Install the latest npm release into the active profile, then require a DSH restart. */
|
|
10621
|
-
async update() {
|
|
10622
|
-
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
10623
|
-
this.activeUpdate = this.updateOnce();
|
|
10624
|
-
try {
|
|
10625
|
-
return await this.activeUpdate;
|
|
10626
|
-
} finally {
|
|
10627
|
-
this.activeUpdate = void 0;
|
|
10628
|
-
}
|
|
10629
|
-
}
|
|
10630
|
-
async updateOnce() {
|
|
10631
|
-
const profileDirectory = this.profileDirectory;
|
|
10632
|
-
if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
|
|
10633
|
-
const status = await this.status(true);
|
|
10634
|
-
if (!status.updateSupported) throw new Error("plugin_update_unsupported");
|
|
10635
|
-
if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
|
|
10636
|
-
await this.runner(profileDirectory, status.latestVersion);
|
|
10637
|
-
const installed = await this.installedVersionReader(profileDirectory);
|
|
10638
|
-
if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
|
|
10639
|
-
this.cache = void 0;
|
|
10640
|
-
return Object.freeze({
|
|
10641
|
-
installedVersion: installed,
|
|
10642
|
-
restartRequired: true
|
|
10643
|
-
});
|
|
10644
|
-
}
|
|
10645
|
-
};
|
|
10646
|
-
//#endregion
|
|
10647
|
-
//#region src/file-logger.ts
|
|
10648
|
-
const MAX_LOG_BYTES = 5242880;
|
|
10649
|
-
/** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
|
|
10650
|
-
async function installMobileFileLogger(ctx, stateDirectory) {
|
|
10651
|
-
const directory = join(stateDirectory, "logs");
|
|
10652
|
-
const file = join(directory, "dsh-mobile.log");
|
|
10653
|
-
const previous = `${file}.1`;
|
|
10654
|
-
await mkdir(directory, {
|
|
10655
|
-
recursive: true,
|
|
10656
|
-
mode: 448
|
|
10657
|
-
});
|
|
10658
|
-
const entry = await lstat(file).catch(() => void 0);
|
|
10659
|
-
if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
|
|
10660
|
-
await rm(previous, { force: true });
|
|
10661
|
-
await rename(file, previous);
|
|
10662
|
-
}
|
|
10663
|
-
const stream = createWriteStream(file, {
|
|
10664
|
-
flags: "a",
|
|
10665
|
-
encoding: "utf8",
|
|
10666
|
-
mode: 384
|
|
10667
|
-
});
|
|
10668
|
-
const exporter = {
|
|
10669
|
-
colors: false,
|
|
10670
|
-
maxLength: 16384,
|
|
10671
|
-
levels: {
|
|
10672
|
-
default: -1,
|
|
10673
|
-
"dsh-mobile": 3
|
|
10674
|
-
},
|
|
10675
|
-
export(message) {
|
|
10676
|
-
if (message.name !== "dsh-mobile") return;
|
|
10677
|
-
const record = {
|
|
10678
|
-
timestamp: new Date(message.ts).toISOString(),
|
|
10679
|
-
level: message.type,
|
|
10680
|
-
logger: message.name,
|
|
10681
|
-
message: Logger.format(exporter, message)
|
|
10682
|
-
};
|
|
10683
|
-
stream.write(`${JSON.stringify(record)}\n`);
|
|
10684
|
-
}
|
|
10685
|
-
};
|
|
10686
|
-
ctx.logger.exporter(exporter);
|
|
10687
|
-
ctx.effect(() => () => {
|
|
10688
|
-
stream.end();
|
|
10689
|
-
}, "dsh-mobile file logger");
|
|
10690
|
-
return file;
|
|
10691
|
-
}
|
|
10692
|
-
//#endregion
|
|
10693
|
-
//#region src/managed-setup.ts
|
|
10694
|
-
const VIRTUAL_INTERFACE_MARKERS = [
|
|
10695
|
-
"bridge",
|
|
10696
|
-
"docker",
|
|
10697
|
-
"hyper-v",
|
|
10698
|
-
"mihomo",
|
|
10699
|
-
"radmin",
|
|
10700
|
-
"tailscale",
|
|
10701
|
-
"tap",
|
|
10702
|
-
"tun",
|
|
10703
|
-
"utun",
|
|
10704
|
-
"vbox",
|
|
10705
|
-
"veth",
|
|
10706
|
-
"virtual",
|
|
10707
|
-
"vmware",
|
|
10708
|
-
"vpn",
|
|
10709
|
-
"vethernet",
|
|
10710
|
-
"wsl",
|
|
10711
|
-
"zerotier"
|
|
10712
|
-
];
|
|
10713
|
-
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
10714
|
-
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
10715
|
-
function requiredString(value, name) {
|
|
10716
|
-
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
10717
|
-
return value;
|
|
10718
|
-
}
|
|
10719
|
-
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
10720
|
-
function parseManagedSetup(value) {
|
|
10721
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
10722
|
-
const record = value;
|
|
10723
|
-
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
10724
|
-
"version",
|
|
10725
|
-
"networkInterface",
|
|
10726
|
-
"listenPort",
|
|
10727
|
-
"upstreamOrigin",
|
|
10728
|
-
"tls"
|
|
10729
|
-
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
10730
|
-
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
10731
|
-
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
10732
|
-
const tls = record.tls;
|
|
10733
|
-
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
10734
|
-
"mode",
|
|
10735
|
-
"caCertFile",
|
|
10736
|
-
"caKeyFile",
|
|
10737
|
-
"certFile",
|
|
10738
|
-
"keyFile"
|
|
10739
|
-
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
10740
|
-
return Object.freeze({
|
|
10741
|
-
version: 2,
|
|
10742
|
-
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
10743
|
-
listenPort: record.listenPort,
|
|
10744
|
-
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
10745
|
-
tls: Object.freeze({
|
|
10746
|
-
mode: "managed",
|
|
10747
|
-
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
10748
|
-
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
10749
|
-
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
10750
|
-
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
10751
|
-
})
|
|
10752
|
-
});
|
|
10753
|
-
}
|
|
10754
|
-
function privateIpv4(value) {
|
|
10755
|
-
const parts = value.split(".").map(Number);
|
|
10756
|
-
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
10757
|
-
}
|
|
10758
|
-
function networkCidr(address, cidr) {
|
|
10759
|
-
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
10760
|
-
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
10761
|
-
return `${[
|
|
10762
|
-
24,
|
|
10763
|
-
16,
|
|
10764
|
-
8,
|
|
10765
|
-
0
|
|
10766
|
-
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
10767
|
-
}
|
|
10768
|
-
/** List current private IPv4 candidates with their interface identity. */
|
|
10769
|
-
function availableLanNetworks(table = networkInterfaces()) {
|
|
10770
|
-
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
10771
|
-
name,
|
|
10772
|
-
address: entry.address,
|
|
10773
|
-
cidr: networkCidr(entry.address, entry.cidr)
|
|
10774
|
-
})));
|
|
10775
|
-
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
10776
|
-
}
|
|
10777
|
-
function likelyVirtualInterface(name) {
|
|
10778
|
-
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
10779
|
-
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
10780
|
-
}
|
|
10781
|
-
async function runRouteCommand(file, args) {
|
|
10782
|
-
return (await execFileText(file, [...args], {
|
|
10783
|
-
encoding: "utf8",
|
|
10784
|
-
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
10785
|
-
})).stdout;
|
|
10786
|
-
}
|
|
10787
|
-
function uniqueLines(output) {
|
|
10788
|
-
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
10789
|
-
}
|
|
10790
|
-
/** Return operating-system default-route interfaces in routing preference order. */
|
|
10791
|
-
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
10792
|
-
try {
|
|
10793
|
-
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
10794
|
-
"-NoProfile",
|
|
10795
|
-
"-NonInteractive",
|
|
10796
|
-
"-Command",
|
|
10797
|
-
[
|
|
10798
|
-
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
10799
|
-
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
10800
|
-
" $route = $_",
|
|
10801
|
-
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10802
|
-
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10803
|
-
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
10804
|
-
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
10805
|
-
" }",
|
|
10806
|
-
"}",
|
|
10807
|
-
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
10808
|
-
].join("; ")
|
|
10809
|
-
]));
|
|
10810
|
-
if (platform === "linux") {
|
|
10811
|
-
const routes = uniqueLines(await run("ip", [
|
|
10812
|
-
"-o",
|
|
10813
|
-
"-4",
|
|
10814
|
-
"route",
|
|
10815
|
-
"show",
|
|
10816
|
-
"default"
|
|
10817
|
-
])).map((line) => ({
|
|
10818
|
-
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
10819
|
-
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
10820
|
-
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
10821
|
-
return [...new Set(routes.map((route) => route.name))];
|
|
10822
|
-
}
|
|
10823
|
-
if (platform === "darwin") {
|
|
10824
|
-
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
10825
|
-
"-n",
|
|
10826
|
-
"get",
|
|
10827
|
-
"default"
|
|
10828
|
-
]))?.[1];
|
|
10829
|
-
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
10830
|
-
}
|
|
10831
|
-
} catch {}
|
|
10832
|
-
return [];
|
|
10833
|
-
}
|
|
10834
|
-
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
10835
|
-
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
10836
|
-
const candidates = availableLanNetworks(table);
|
|
10837
|
-
if (requestedAddress !== void 0) {
|
|
10838
|
-
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
10839
|
-
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
10840
|
-
return match;
|
|
10841
|
-
}
|
|
10842
|
-
if (requestedInterface !== void 0) {
|
|
10843
|
-
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
10844
|
-
if (matches.length === 1) return matches[0];
|
|
10845
|
-
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
10846
|
-
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
12493
|
+
async update() {
|
|
12494
|
+
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
12495
|
+
this.activeUpdate = this.updateOnce();
|
|
12496
|
+
try {
|
|
12497
|
+
return await this.activeUpdate;
|
|
12498
|
+
} finally {
|
|
12499
|
+
this.activeUpdate = void 0;
|
|
12500
|
+
}
|
|
10847
12501
|
}
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
const
|
|
10852
|
-
if (
|
|
12502
|
+
async updateOnce() {
|
|
12503
|
+
const profileDirectory = this.profileDirectory;
|
|
12504
|
+
if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
|
|
12505
|
+
const status = await this.status(true);
|
|
12506
|
+
if (!status.updateSupported) throw new Error("plugin_update_unsupported");
|
|
12507
|
+
if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
|
|
12508
|
+
await this.runner(profileDirectory, status.latestVersion);
|
|
12509
|
+
const installed = await this.installedVersionReader(profileDirectory);
|
|
12510
|
+
if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
|
|
12511
|
+
this.cache = void 0;
|
|
12512
|
+
return Object.freeze({
|
|
12513
|
+
installedVersion: installed,
|
|
12514
|
+
restartRequired: true
|
|
12515
|
+
});
|
|
10853
12516
|
}
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
/**
|
|
10859
|
-
|
|
10860
|
-
|
|
10861
|
-
|
|
10862
|
-
|
|
10863
|
-
*/
|
|
10864
|
-
function isNetworkSelectionError(error) {
|
|
10865
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
10866
|
-
return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
|
|
10867
|
-
}
|
|
10868
|
-
function assertMatchingCa(certPem, keyPem) {
|
|
10869
|
-
const certificate = new X509Certificate(certPem);
|
|
10870
|
-
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
10871
|
-
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
10872
|
-
format: "der",
|
|
10873
|
-
type: "spki"
|
|
10874
|
-
});
|
|
10875
|
-
const certificatePublic = certificate.publicKey.export({
|
|
10876
|
-
format: "der",
|
|
10877
|
-
type: "spki"
|
|
10878
|
-
});
|
|
10879
|
-
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
10880
|
-
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
10881
|
-
return certificate;
|
|
10882
|
-
}
|
|
10883
|
-
async function atomicWrite(file, contents) {
|
|
10884
|
-
const directory = dirname(file);
|
|
12517
|
+
};
|
|
12518
|
+
//#endregion
|
|
12519
|
+
//#region src/file-logger.ts
|
|
12520
|
+
const MAX_LOG_BYTES = 5242880;
|
|
12521
|
+
/** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
|
|
12522
|
+
async function installMobileFileLogger(ctx, stateDirectory) {
|
|
12523
|
+
const directory = join(stateDirectory, "logs");
|
|
12524
|
+
const file = join(directory, "dsh-mobile.log");
|
|
12525
|
+
const previous = `${file}.1`;
|
|
10885
12526
|
await mkdir(directory, {
|
|
10886
12527
|
recursive: true,
|
|
10887
12528
|
mode: 448
|
|
10888
12529
|
});
|
|
10889
|
-
const
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
}
|
|
10894
|
-
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
10895
|
-
async function ensureManagedCa(setup, legacy) {
|
|
10896
|
-
let certPem;
|
|
10897
|
-
let keyPem;
|
|
10898
|
-
try {
|
|
10899
|
-
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
10900
|
-
} catch (error) {
|
|
10901
|
-
if (error.code !== "ENOENT") throw error;
|
|
10902
|
-
let migrated = false;
|
|
10903
|
-
if (legacy !== void 0) try {
|
|
10904
|
-
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
10905
|
-
assertMatchingCa(certPem, keyPem);
|
|
10906
|
-
migrated = true;
|
|
10907
|
-
} catch (legacyError) {
|
|
10908
|
-
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
10909
|
-
}
|
|
10910
|
-
if (!migrated) {
|
|
10911
|
-
const now = /* @__PURE__ */ new Date();
|
|
10912
|
-
const notAfter = new Date(now);
|
|
10913
|
-
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
10914
|
-
const generated = await generate([{
|
|
10915
|
-
name: "commonName",
|
|
10916
|
-
value: "DeepSeek Harness Mobile CA"
|
|
10917
|
-
}], {
|
|
10918
|
-
keyType: "ec",
|
|
10919
|
-
curve: "P-256",
|
|
10920
|
-
algorithm: "sha256",
|
|
10921
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10922
|
-
notAfterDate: notAfter,
|
|
10923
|
-
extensions: [{
|
|
10924
|
-
name: "basicConstraints",
|
|
10925
|
-
cA: true,
|
|
10926
|
-
critical: true
|
|
10927
|
-
}, {
|
|
10928
|
-
name: "keyUsage",
|
|
10929
|
-
digitalSignature: true,
|
|
10930
|
-
keyCertSign: true,
|
|
10931
|
-
cRLSign: true,
|
|
10932
|
-
critical: true
|
|
10933
|
-
}]
|
|
10934
|
-
});
|
|
10935
|
-
certPem = generated.cert;
|
|
10936
|
-
keyPem = generated.private;
|
|
10937
|
-
}
|
|
10938
|
-
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
10939
|
-
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
12530
|
+
const entry = await lstat(file).catch(() => void 0);
|
|
12531
|
+
if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
|
|
12532
|
+
await rm(previous, { force: true });
|
|
12533
|
+
await rename(file, previous);
|
|
10940
12534
|
}
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
10946
|
-
async function refreshManagedServerCertificate(setup, address) {
|
|
10947
|
-
await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
|
|
10948
|
-
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
10949
|
-
assertMatchingCa(caCert, caKey);
|
|
10950
|
-
const now = /* @__PURE__ */ new Date();
|
|
10951
|
-
const notAfter = new Date(now);
|
|
10952
|
-
notAfter.setDate(notAfter.getDate() + 397);
|
|
10953
|
-
const server = await generate([{
|
|
10954
|
-
name: "commonName",
|
|
10955
|
-
value: "DeepSeek Harness Mobile"
|
|
10956
|
-
}], {
|
|
10957
|
-
keyType: "ec",
|
|
10958
|
-
curve: "P-256",
|
|
10959
|
-
algorithm: "sha256",
|
|
10960
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10961
|
-
notAfterDate: notAfter,
|
|
10962
|
-
ca: {
|
|
10963
|
-
cert: caCert,
|
|
10964
|
-
key: caKey
|
|
10965
|
-
},
|
|
10966
|
-
extensions: [
|
|
10967
|
-
{
|
|
10968
|
-
name: "basicConstraints",
|
|
10969
|
-
cA: false,
|
|
10970
|
-
critical: true
|
|
10971
|
-
},
|
|
10972
|
-
{
|
|
10973
|
-
name: "keyUsage",
|
|
10974
|
-
digitalSignature: true,
|
|
10975
|
-
critical: true
|
|
10976
|
-
},
|
|
10977
|
-
{
|
|
10978
|
-
name: "extKeyUsage",
|
|
10979
|
-
serverAuth: true
|
|
10980
|
-
},
|
|
10981
|
-
{
|
|
10982
|
-
name: "subjectAltName",
|
|
10983
|
-
altNames: [{
|
|
10984
|
-
type: 7,
|
|
10985
|
-
ip: address
|
|
10986
|
-
}]
|
|
10987
|
-
}
|
|
10988
|
-
]
|
|
12535
|
+
const stream = createWriteStream(file, {
|
|
12536
|
+
flags: "a",
|
|
12537
|
+
encoding: "utf8",
|
|
12538
|
+
mode: 384
|
|
10989
12539
|
});
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
|
|
10995
|
-
|
|
10996
|
-
|
|
10997
|
-
|
|
10998
|
-
|
|
10999
|
-
|
|
11000
|
-
|
|
11001
|
-
|
|
11002
|
-
|
|
11003
|
-
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
keyFile: setup.tls.keyFile
|
|
12540
|
+
const exporter = {
|
|
12541
|
+
colors: false,
|
|
12542
|
+
maxLength: 16384,
|
|
12543
|
+
levels: {
|
|
12544
|
+
default: -1,
|
|
12545
|
+
"dsh-mobile": 3
|
|
12546
|
+
},
|
|
12547
|
+
export(message) {
|
|
12548
|
+
if (message.name !== "dsh-mobile") return;
|
|
12549
|
+
const record = {
|
|
12550
|
+
timestamp: new Date(message.ts).toISOString(),
|
|
12551
|
+
level: message.type,
|
|
12552
|
+
logger: message.name,
|
|
12553
|
+
message: Logger.format(exporter, message)
|
|
12554
|
+
};
|
|
12555
|
+
stream.write(`${JSON.stringify(record)}\n`);
|
|
11007
12556
|
}
|
|
11008
12557
|
};
|
|
12558
|
+
ctx.logger.exporter(exporter);
|
|
12559
|
+
ctx.effect(() => () => {
|
|
12560
|
+
stream.end();
|
|
12561
|
+
}, "dsh-mobile file logger");
|
|
12562
|
+
return file;
|
|
11009
12563
|
}
|
|
11010
12564
|
//#endregion
|
|
11011
12565
|
//#region src/lan-setup.ts
|
|
@@ -11269,6 +12823,56 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
11269
12823
|
discovery: false
|
|
11270
12824
|
});
|
|
11271
12825
|
}
|
|
12826
|
+
/**
|
|
12827
|
+
* Gateway config for the self-signed FRP ingress.
|
|
12828
|
+
*
|
|
12829
|
+
* Unlike {@link remoteGatewayConfig}, the gateway itself is the TLS endpoint:
|
|
12830
|
+
* frps only forwards raw TCP, so the listener speaks HTTPS with the leaf that
|
|
12831
|
+
* `pairingCaFile` signed, and the pairing CA is deliberately retained so the
|
|
12832
|
+
* gateway can serve `GET /mobile-access/ca.cer` for the app to pin. The listener
|
|
12833
|
+
* stays on loopback because the public entry is the frps TCP proxy, never a
|
|
12834
|
+
* direct bind.
|
|
12835
|
+
*/
|
|
12836
|
+
function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenPort = 0) {
|
|
12837
|
+
if (!isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
|
|
12838
|
+
const publicAuthority = `${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
|
|
12839
|
+
const { pairingCaFile: _templateCaFile, ...shared } = template;
|
|
12840
|
+
return Object.freeze({
|
|
12841
|
+
...shared,
|
|
12842
|
+
listenHost: "127.0.0.1",
|
|
12843
|
+
listenPort,
|
|
12844
|
+
authorities: Object.freeze([parseAuthority(publicAuthority)]),
|
|
12845
|
+
allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
|
|
12846
|
+
stateFile,
|
|
12847
|
+
instanceId: ingress.caFingerprint,
|
|
12848
|
+
pairingCaFile: ingress.paths.caCertFile,
|
|
12849
|
+
tls: Object.freeze({
|
|
12850
|
+
mode: "provided",
|
|
12851
|
+
certFile: ingress.paths.certFile,
|
|
12852
|
+
keyFile: ingress.paths.keyFile
|
|
12853
|
+
}),
|
|
12854
|
+
publicTls: true,
|
|
12855
|
+
discovery: false
|
|
12856
|
+
});
|
|
12857
|
+
}
|
|
12858
|
+
/**
|
|
12859
|
+
* CA the FRP start-up self-check must pin, read from the ingress directory.
|
|
12860
|
+
*
|
|
12861
|
+
* The self-signed entry presents a leaf signed by this CA, which is absent from
|
|
12862
|
+
* the system trust store, so the probe must anchor it exactly as the app does
|
|
12863
|
+
* when it pins `pairingCaFile`. The public-CA entry answers with a publicly
|
|
12864
|
+
* trusted certificate and therefore returns `undefined` (system trust store).
|
|
12865
|
+
* An unreadable self-signed CA fails explicitly: falling back to the system
|
|
12866
|
+
* trust store would hide a broken pairing identity behind a start timeout.
|
|
12867
|
+
*/
|
|
12868
|
+
async function readFrpIngressTrustAnchor(settings, stateFile) {
|
|
12869
|
+
if (!isFrpSelfSignedIngress(settings)) return void 0;
|
|
12870
|
+
try {
|
|
12871
|
+
return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
|
|
12872
|
+
} catch (error) {
|
|
12873
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
12874
|
+
}
|
|
12875
|
+
}
|
|
11272
12876
|
/** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
|
|
11273
12877
|
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
|
|
11274
12878
|
const validated = parseOriginSettings(settings);
|
|
@@ -11463,6 +13067,27 @@ async function apply(ctx, config) {
|
|
|
11463
13067
|
await candidate.start();
|
|
11464
13068
|
return candidate;
|
|
11465
13069
|
};
|
|
13070
|
+
/**
|
|
13071
|
+
* Gateway factory for the FRP provider.
|
|
13072
|
+
*
|
|
13073
|
+
* The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
|
|
13074
|
+
* self-signed entry terminates TLS inside the gateway, so it first materializes
|
|
13075
|
+
* a leaf the pairing CA signed for the public IPv4 and then binds the ingress
|
|
13076
|
+
* configuration that keeps that CA pinned.
|
|
13077
|
+
*/
|
|
13078
|
+
const createFrpGateway = async (publicOrigin, settings) => {
|
|
13079
|
+
if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
|
|
13080
|
+
const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
|
|
13081
|
+
const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
|
|
13082
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
13083
|
+
try {
|
|
13084
|
+
await candidate.start();
|
|
13085
|
+
} catch (error) {
|
|
13086
|
+
await candidate.close();
|
|
13087
|
+
throw error;
|
|
13088
|
+
}
|
|
13089
|
+
return candidate;
|
|
13090
|
+
};
|
|
11466
13091
|
const createOriginGateway = async (settings) => {
|
|
11467
13092
|
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
11468
13093
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
@@ -11504,7 +13129,18 @@ async function apply(ctx, config) {
|
|
|
11504
13129
|
executable: frpComponent.executable,
|
|
11505
13130
|
config: frpConfig,
|
|
11506
13131
|
instanceId,
|
|
11507
|
-
createGateway:
|
|
13132
|
+
createGateway: createFrpGateway,
|
|
13133
|
+
/**
|
|
13134
|
+
* Hand the start-up self-check the CA the app itself pins.
|
|
13135
|
+
*
|
|
13136
|
+
* Only this module knows where the ingress material lives, so the anchor is
|
|
13137
|
+
* read here from the ingress directory instead of being guessed downstream.
|
|
13138
|
+
*/
|
|
13139
|
+
resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
|
|
13140
|
+
maintainIngressCertificate: async (settings, gateway) => {
|
|
13141
|
+
await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
|
|
13142
|
+
await gateway.refreshProvidedTls();
|
|
13143
|
+
}
|
|
11508
13144
|
}),
|
|
11509
13145
|
origin: new OriginController({
|
|
11510
13146
|
store: originStore,
|
|
@@ -11566,6 +13202,25 @@ async function apply(ctx, config) {
|
|
|
11566
13202
|
networkError = "network_interface_unavailable";
|
|
11567
13203
|
}
|
|
11568
13204
|
const remote = remoteController().status();
|
|
13205
|
+
const frpOrigin = remote.origin;
|
|
13206
|
+
const frpSettings = remoteProviders.selected === "frp" ? frpConfig.settings() : void 0;
|
|
13207
|
+
const frpGateway = remoteControllers.frp.gateway();
|
|
13208
|
+
const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
|
|
13209
|
+
try {
|
|
13210
|
+
const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
|
|
13211
|
+
if (trustAnchorPem === void 0) return { state: "unreachable" };
|
|
13212
|
+
const started = performance.now();
|
|
13213
|
+
return await defaultProbeDiscovery({
|
|
13214
|
+
origin: frpOrigin,
|
|
13215
|
+
trustAnchorPem
|
|
13216
|
+
}, frpGateway.config.instanceId, AbortSignal.timeout(5500)) ? {
|
|
13217
|
+
state: "ready",
|
|
13218
|
+
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
13219
|
+
} : { state: "unreachable" };
|
|
13220
|
+
} catch {
|
|
13221
|
+
return { state: "unreachable" };
|
|
13222
|
+
}
|
|
13223
|
+
} : void 0;
|
|
11569
13224
|
return collectConnectionDiagnostics({
|
|
11570
13225
|
dshVersion,
|
|
11571
13226
|
lan: {
|
|
@@ -11589,7 +13244,7 @@ async function apply(ctx, config) {
|
|
|
11589
13244
|
...remote.origin === void 0 ? {} : { origin: remote.origin },
|
|
11590
13245
|
...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
|
|
11591
13246
|
}
|
|
11592
|
-
});
|
|
13247
|
+
}, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
|
|
11593
13248
|
};
|
|
11594
13249
|
const adminRoute = {
|
|
11595
13250
|
kind: "prefix",
|
|
@@ -11767,6 +13422,34 @@ async function apply(ctx, config) {
|
|
|
11767
13422
|
sendJson(response, 200, remotePayload(), false);
|
|
11768
13423
|
return;
|
|
11769
13424
|
}
|
|
13425
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
|
|
13426
|
+
const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
|
|
13427
|
+
const options = { configFile: frpConfig.runtimeConfigFile };
|
|
13428
|
+
logger.info("frp attach plan requested mode=%s entryTls=%s vhostHttpPort=%d", settings.mode ?? "deploy", settings.entryTls ?? "public-ip-cert", resolveFrpVhostHttpPort(settings));
|
|
13429
|
+
sendJson(response, 200, {
|
|
13430
|
+
...remotePayload(),
|
|
13431
|
+
frpAttachPlan: createFrpAttachPlan(settings, options),
|
|
13432
|
+
frpAttachTemplate: createFrpAttachTemplate(settings, options)
|
|
13433
|
+
}, false);
|
|
13434
|
+
return;
|
|
13435
|
+
}
|
|
13436
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
|
|
13437
|
+
const settings = frpConfig.settings();
|
|
13438
|
+
if (settings === void 0) throw new HttpError(409, "frp_config_missing");
|
|
13439
|
+
const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
|
|
13440
|
+
const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
|
|
13441
|
+
sendJson(response, 200, {
|
|
13442
|
+
...remotePayload(),
|
|
13443
|
+
frpSelfCheck: {
|
|
13444
|
+
...check,
|
|
13445
|
+
frpsReachable,
|
|
13446
|
+
entryReachable,
|
|
13447
|
+
frpc: remoteController().status(),
|
|
13448
|
+
component: frpComponent.status()
|
|
13449
|
+
}
|
|
13450
|
+
}, false);
|
|
13451
|
+
return;
|
|
13452
|
+
}
|
|
11770
13453
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/websocket-paths`) {
|
|
11771
13454
|
sendJson(response, 200, { paths: webSocketPaths.list() }, false);
|
|
11772
13455
|
return;
|
|
@@ -11867,7 +13550,11 @@ async function apply(ctx, config) {
|
|
|
11867
13550
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11868
13551
|
await remoteProviders.mutate(async () => {
|
|
11869
13552
|
await remoteControllers.frp.setEnabled(false);
|
|
11870
|
-
await Promise.all([
|
|
13553
|
+
await Promise.all([
|
|
13554
|
+
frpComponent.purge(),
|
|
13555
|
+
frpConfig.purge(),
|
|
13556
|
+
purgeFrpIngressCertificates(remoteDeviceFile)
|
|
13557
|
+
]);
|
|
11871
13558
|
});
|
|
11872
13559
|
sendJson(response, 200, remotePayload(), false);
|
|
11873
13560
|
return;
|
|
@@ -12010,6 +13697,6 @@ async function apply(ctx, config) {
|
|
|
12010
13697
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
12011
13698
|
}
|
|
12012
13699
|
//#endregion
|
|
12013
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isCloudflaredRegistration, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
|
|
13700
|
+
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
|
|
12014
13701
|
|
|
12015
13702
|
//# sourceMappingURL=index.mjs.map
|