dsh-mobile 0.4.4 → 0.4.6
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 +20 -0
- package/CONTRIBUTORS.md +7 -3
- package/README.en.md +27 -14
- package/README.md +27 -15
- 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 +1339 -162
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +393 -10
- package/lib/index.mjs +2178 -593
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-compat.js +1 -1
- package/lib/mobile-compat.js.map +1 -1
- package/package.json +25 -21
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. */
|
|
@@ -817,8 +937,13 @@ function execFileText(file, args, options = {}) {
|
|
|
817
937
|
//#endregion
|
|
818
938
|
//#region src/private-file.ts
|
|
819
939
|
let userSidTask;
|
|
940
|
+
function windowsSystemTool(name) {
|
|
941
|
+
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
|
|
942
|
+
if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
|
|
943
|
+
return win32.join(systemRoot, "System32", name);
|
|
944
|
+
}
|
|
820
945
|
async function currentWindowsUserSid() {
|
|
821
|
-
userSidTask ??= execFileText("whoami.exe", [
|
|
946
|
+
userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
|
|
822
947
|
"/user",
|
|
823
948
|
"/fo",
|
|
824
949
|
"csv",
|
|
@@ -841,11 +966,12 @@ async function currentWindowsUserSid() {
|
|
|
841
966
|
async function restrictPrivateFile(file, mode = 384) {
|
|
842
967
|
await chmod(file, mode);
|
|
843
968
|
if (process.platform !== "win32") return;
|
|
844
|
-
await
|
|
969
|
+
const userSid = await currentWindowsUserSid();
|
|
970
|
+
await execFileText(windowsSystemTool("icacls.exe"), [
|
|
845
971
|
file,
|
|
846
972
|
"/inheritance:r",
|
|
847
973
|
"/grant:r",
|
|
848
|
-
`*${
|
|
974
|
+
`*${userSid}:(F)`,
|
|
849
975
|
"*S-1-5-18:(F)",
|
|
850
976
|
"*S-1-5-32-544:(F)",
|
|
851
977
|
"/remove:g",
|
|
@@ -2451,6 +2577,16 @@ const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
|
|
|
2451
2577
|
const MAX_MOBILE_BOOT_BATCHES = 8;
|
|
2452
2578
|
const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
|
|
2453
2579
|
const MOBILE_BOOT_RETRY_DELAY_MS = 150;
|
|
2580
|
+
function upstreamPluginBundleUrl(source, upstreamOrigin) {
|
|
2581
|
+
if (!source.startsWith("/plugins/") && !source.startsWith("plugins/") || source.includes("#")) return void 0;
|
|
2582
|
+
try {
|
|
2583
|
+
const target = new URL(source, new URL("/", upstreamOrigin));
|
|
2584
|
+
if (target.origin !== upstreamOrigin.origin || !target.pathname.startsWith("/plugins/")) return void 0;
|
|
2585
|
+
return target;
|
|
2586
|
+
} catch {
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2454
2590
|
const TRANSIENT_UPSTREAM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2455
2591
|
"EAI_AGAIN",
|
|
2456
2592
|
"ECONNABORTED",
|
|
@@ -2492,6 +2628,57 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2492
2628
|
})]);
|
|
2493
2629
|
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})};})();`;
|
|
2494
2630
|
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}})();`;
|
|
2631
|
+
/** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
|
|
2632
|
+
const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
|
|
2633
|
+
const view=globalThis;
|
|
2634
|
+
const port=view.navigator;
|
|
2635
|
+
const nativeFetch=typeof view.fetch==='function'?view.fetch.bind(view):undefined;
|
|
2636
|
+
let reachable=true;
|
|
2637
|
+
let checking=false;
|
|
2638
|
+
let replaying=false;
|
|
2639
|
+
let disposed=false;
|
|
2640
|
+
let epoch=0;
|
|
2641
|
+
let retryTimer;
|
|
2642
|
+
let activeController;
|
|
2643
|
+
const clearRetry=()=>{if(retryTimer!==undefined){view.clearTimeout(retryTimer);retryTimer=undefined}};
|
|
2644
|
+
const scheduleRetry=()=>{if(!disposed&&!reachable&&retryTimer===undefined){retryTimer=view.setTimeout(()=>{retryTimer=undefined;check()},5000)}};
|
|
2645
|
+
const check=()=>{
|
|
2646
|
+
if(checking||disposed||nativeFetch===undefined)return;
|
|
2647
|
+
checking=true;
|
|
2648
|
+
const current=++epoch;
|
|
2649
|
+
const controller=new AbortController();
|
|
2650
|
+
activeController=controller;
|
|
2651
|
+
const timer=view.setTimeout(()=>controller.abort(),2000);
|
|
2652
|
+
void nativeFetch('/mobile-access/health',{cache:'no-store',credentials:'same-origin',signal:controller.signal})
|
|
2653
|
+
.then(response=>response.ok,()=>false)
|
|
2654
|
+
.then(ok=>{
|
|
2655
|
+
if(disposed||current!==epoch)return;
|
|
2656
|
+
if(ok){
|
|
2657
|
+
const wasOffline=!reachable;
|
|
2658
|
+
reachable=true;
|
|
2659
|
+
clearRetry();
|
|
2660
|
+
if(wasOffline)view.dispatchEvent(new Event('online'));
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
const wasOnline=reachable;
|
|
2664
|
+
reachable=false;
|
|
2665
|
+
if(wasOnline){
|
|
2666
|
+
replaying=true;
|
|
2667
|
+
try{view.dispatchEvent(new Event('offline'))}finally{replaying=false}
|
|
2668
|
+
}
|
|
2669
|
+
scheduleRetry();
|
|
2670
|
+
})
|
|
2671
|
+
.finally(()=>{view.clearTimeout(timer);activeController=undefined;checking=false});
|
|
2672
|
+
};
|
|
2673
|
+
if(port!==undefined&&typeof Object.defineProperty==='function'){
|
|
2674
|
+
try{Object.defineProperty(port,'onLine',{configurable:true,get:()=>reachable})}catch{/* A frozen navigator retains its native value. */}
|
|
2675
|
+
}
|
|
2676
|
+
if(typeof view.addEventListener==='function'){
|
|
2677
|
+
view.addEventListener('offline',event=>{if(replaying||nativeFetch===undefined)return;event.stopImmediatePropagation();check()},true);
|
|
2678
|
+
view.addEventListener('online',()=>{++epoch;reachable=true;clearRetry()},true);
|
|
2679
|
+
view.addEventListener('pagehide',()=>{disposed=true;++epoch;clearRetry();activeController?.abort()},true);
|
|
2680
|
+
}
|
|
2681
|
+
})();`;
|
|
2495
2682
|
const gzipBuffer = promisify(gzip);
|
|
2496
2683
|
function ensureMobileViewport(html) {
|
|
2497
2684
|
const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
|
|
@@ -2668,7 +2855,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
2668
2855
|
batches: plan.batches
|
|
2669
2856
|
})).digest("hex").slice(0, 16);
|
|
2670
2857
|
}
|
|
2671
|
-
const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
2858
|
+
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)};`;
|
|
2672
2859
|
return Object.freeze({
|
|
2673
2860
|
html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
|
|
2674
2861
|
batches
|
|
@@ -2870,8 +3057,9 @@ function shouldCompressResponse(request, response) {
|
|
|
2870
3057
|
const pathname = request.url?.split("?", 1)[0] ?? "";
|
|
2871
3058
|
return (request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) || request.method === "POST" && pathname === SESSION_HISTORY_PATH) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
|
|
2872
3059
|
}
|
|
2873
|
-
function revisionedStaticCacheControl(request) {
|
|
3060
|
+
function revisionedStaticCacheControl(request, statusCode) {
|
|
2874
3061
|
if (request.method !== "GET" && request.method !== "HEAD") return void 0;
|
|
3062
|
+
if (statusCode !== 200) return void 0;
|
|
2875
3063
|
let target;
|
|
2876
3064
|
try {
|
|
2877
3065
|
target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
|
|
@@ -2991,6 +3179,12 @@ function discoveryBroadcastTargets(cidrs) {
|
|
|
2991
3179
|
}
|
|
2992
3180
|
return [...targets];
|
|
2993
3181
|
}
|
|
3182
|
+
/** Keep Node's upgrade-owned socket safe after the HTTP parser removes its listener. */
|
|
3183
|
+
function guardUpgradeSocket(socket) {
|
|
3184
|
+
socket.on("error", () => {
|
|
3185
|
+
if (!socket.destroyed) socket.destroy();
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
2994
3188
|
function discoveryFailure(error) {
|
|
2995
3189
|
const code = error instanceof Error && typeof error.code === "string" ? error.code : "unknown";
|
|
2996
3190
|
return Object.freeze({
|
|
@@ -3225,9 +3419,11 @@ var MobileAccessGateway = class {
|
|
|
3225
3419
|
socket.destroy();
|
|
3226
3420
|
});
|
|
3227
3421
|
server.on("upgrade", (request, socket, head) => {
|
|
3228
|
-
|
|
3422
|
+
const client = socket;
|
|
3423
|
+
guardUpgradeSocket(client);
|
|
3424
|
+
this.handleUpgrade(request, client, head).catch((error) => {
|
|
3229
3425
|
const mapped = mapError(error);
|
|
3230
|
-
rejectUpgrade(
|
|
3426
|
+
rejectUpgrade(client, mapped.status, mapped.code);
|
|
3231
3427
|
});
|
|
3232
3428
|
});
|
|
3233
3429
|
server.on("clientError", (_error, socket) => {
|
|
@@ -3258,6 +3454,15 @@ var MobileAccessGateway = class {
|
|
|
3258
3454
|
throw error;
|
|
3259
3455
|
}
|
|
3260
3456
|
}
|
|
3457
|
+
/** Install a newly signed provided leaf for future TLS handshakes without dropping sessions. */
|
|
3458
|
+
async refreshProvidedTls() {
|
|
3459
|
+
if (!this.listenerTlsEnabled || this.config.tls.mode !== "provided") throw new Error("gateway does not terminate provided TLS");
|
|
3460
|
+
const server = this.server;
|
|
3461
|
+
if (server === void 0 || !this.started || this.closing) throw new Error("gateway is not running");
|
|
3462
|
+
const options = await tlsOptions(this.config);
|
|
3463
|
+
if (this.server !== server || this.closing) throw new Error("gateway is not running");
|
|
3464
|
+
server.setSecureContext(options);
|
|
3465
|
+
}
|
|
3261
3466
|
async startDiscovery(port) {
|
|
3262
3467
|
const socket = createSocket("udp4");
|
|
3263
3468
|
this.discoverySocket = socket;
|
|
@@ -4033,11 +4238,11 @@ var MobileAccessGateway = class {
|
|
|
4033
4238
|
this.upstreamAuthRequest = void 0;
|
|
4034
4239
|
}
|
|
4035
4240
|
}
|
|
4036
|
-
async proxyMobileIndex(request$
|
|
4241
|
+
async proxyMobileIndex(request$2, response, authorization) {
|
|
4037
4242
|
const holder = {};
|
|
4038
4243
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4039
4244
|
try {
|
|
4040
|
-
const upstreamHeaders = sanitizeRequestHeaders(request$
|
|
4245
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
|
|
4041
4246
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4042
4247
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4043
4248
|
upstreamHeaders["accept-encoding"] = "identity";
|
|
@@ -4118,7 +4323,7 @@ var MobileAccessGateway = class {
|
|
|
4118
4323
|
async resolveMobileBootSizes(planEntries) {
|
|
4119
4324
|
const sizes = /* @__PURE__ */ new Map();
|
|
4120
4325
|
const passThrough = /* @__PURE__ */ new Set();
|
|
4121
|
-
const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && entry.url.
|
|
4326
|
+
const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && upstreamPluginBundleUrl(entry.url, this.config.upstreamOrigin) !== void 0);
|
|
4122
4327
|
let cursor = 0;
|
|
4123
4328
|
const worker = async () => {
|
|
4124
4329
|
while (cursor < candidates.length) {
|
|
@@ -4142,11 +4347,10 @@ var MobileAccessGateway = class {
|
|
|
4142
4347
|
* cached for a short window.
|
|
4143
4348
|
*/
|
|
4144
4349
|
async upstreamBundleSize(source) {
|
|
4145
|
-
|
|
4350
|
+
const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
|
|
4351
|
+
if (target === void 0) return void 0;
|
|
4146
4352
|
const cached = this.bootEntrySizeCache.get(source);
|
|
4147
4353
|
if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
|
|
4148
|
-
const target = new URL(source, this.config.upstreamOrigin);
|
|
4149
|
-
if (target.origin !== this.config.upstreamOrigin.origin) return void 0;
|
|
4150
4354
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4151
4355
|
let upstreamRequest;
|
|
4152
4356
|
try {
|
|
@@ -4312,9 +4516,8 @@ var MobileAccessGateway = class {
|
|
|
4312
4516
|
}
|
|
4313
4517
|
async readUpstreamClientBundle(source, signal) {
|
|
4314
4518
|
signal.throwIfAborted();
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
if (target.origin !== this.config.upstreamOrigin.origin) throw new HttpError(502, "upstream_unavailable");
|
|
4519
|
+
const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
|
|
4520
|
+
if (target === void 0) throw new HttpError(502, "upstream_unavailable");
|
|
4318
4521
|
let upstreamRequest;
|
|
4319
4522
|
const aborted = () => {
|
|
4320
4523
|
upstreamRequest?.destroy(/* @__PURE__ */ new Error("request aborted"));
|
|
@@ -4423,15 +4626,15 @@ var MobileAccessGateway = class {
|
|
|
4423
4626
|
}
|
|
4424
4627
|
throw new HttpError(502, "upstream_unavailable");
|
|
4425
4628
|
}
|
|
4426
|
-
async proxyHttpOnce(request$
|
|
4427
|
-
const declared = request$
|
|
4629
|
+
async proxyHttpOnce(request$3, response, authorization) {
|
|
4630
|
+
const declared = request$3.headers["content-length"];
|
|
4428
4631
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
4429
4632
|
const holder = {};
|
|
4430
4633
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4431
4634
|
let bodyDone;
|
|
4432
4635
|
try {
|
|
4433
|
-
const bufferedBody = request$
|
|
4434
|
-
const upstreamHeaders = sanitizeRequestHeaders(request$
|
|
4636
|
+
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;
|
|
4637
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
|
|
4435
4638
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4436
4639
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4437
4640
|
if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
|
|
@@ -4440,8 +4643,8 @@ var MobileAccessGateway = class {
|
|
|
4440
4643
|
protocol: "http:",
|
|
4441
4644
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
4442
4645
|
port: Number(this.config.upstreamOrigin.port),
|
|
4443
|
-
method: request$
|
|
4444
|
-
path: request$
|
|
4646
|
+
method: request$3.method,
|
|
4647
|
+
path: request$3.url,
|
|
4445
4648
|
headers: upstreamHeaders,
|
|
4446
4649
|
agent: false
|
|
4447
4650
|
});
|
|
@@ -4451,7 +4654,7 @@ var MobileAccessGateway = class {
|
|
|
4451
4654
|
});
|
|
4452
4655
|
upstreamRequest.once("response", resolve);
|
|
4453
4656
|
upstreamRequest.once("error", reject);
|
|
4454
|
-
if (bufferedBody === void 0) bodyDone = pipeline(request$
|
|
4657
|
+
if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
4455
4658
|
else {
|
|
4456
4659
|
upstreamRequest.end(bufferedBody);
|
|
4457
4660
|
bodyDone = Promise.resolve();
|
|
@@ -4460,9 +4663,10 @@ var MobileAccessGateway = class {
|
|
|
4460
4663
|
});
|
|
4461
4664
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
4462
4665
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
4463
|
-
const
|
|
4666
|
+
const statusCode = proxied.statusCode ?? 502;
|
|
4667
|
+
const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
|
|
4464
4668
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
4465
|
-
const compressed = shouldCompressResponse(request$
|
|
4669
|
+
const compressed = shouldCompressResponse(request$3, proxied);
|
|
4466
4670
|
if (compressed) {
|
|
4467
4671
|
delete headers["accept-ranges"];
|
|
4468
4672
|
delete headers["content-length"];
|
|
@@ -4470,7 +4674,7 @@ var MobileAccessGateway = class {
|
|
|
4470
4674
|
headers["content-encoding"] = "gzip";
|
|
4471
4675
|
addVaryAcceptEncoding(headers);
|
|
4472
4676
|
}
|
|
4473
|
-
response.writeHead(
|
|
4677
|
+
response.writeHead(statusCode, headers);
|
|
4474
4678
|
await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
|
|
4475
4679
|
} catch (error) {
|
|
4476
4680
|
holder.request?.destroy();
|
|
@@ -4680,6 +4884,7 @@ var MobileAccessGateway = class {
|
|
|
4680
4884
|
const authorization = this.authorize(request);
|
|
4681
4885
|
if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
|
|
4682
4886
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4887
|
+
if (client.destroyed) return;
|
|
4683
4888
|
const upstream = connect({
|
|
4684
4889
|
host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
4685
4890
|
port: Number(this.config.upstreamOrigin.port)
|
|
@@ -4791,7 +4996,10 @@ var MobileAccessGateway = class {
|
|
|
4791
4996
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
4792
4997
|
if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
|
|
4793
4998
|
const opened = await this.access.openPairing(body.ttlMs);
|
|
4794
|
-
const
|
|
4999
|
+
const requiresRemoteCa = this.config.pairingCaFile !== void 0 && !this.config.discovery;
|
|
5000
|
+
const appKey = `dsh${requiresRemoteCa ? "2" : "1"}.${this.config.instanceId}.${opened.token}`;
|
|
5001
|
+
const fragment = requiresRemoteCa ? `key=${appKey}` : `instance=${this.config.instanceId}&token=${opened.token}`;
|
|
5002
|
+
const pairUrl = `${this.address().origin}/mobile-access/pair#${fragment}`;
|
|
4795
5003
|
const appPairUrl = pairUrl;
|
|
4796
5004
|
let qrSvg = "";
|
|
4797
5005
|
try {
|
|
@@ -4802,7 +5010,7 @@ var MobileAccessGateway = class {
|
|
|
4802
5010
|
} catch {}
|
|
4803
5011
|
sendJson(response, 201, {
|
|
4804
5012
|
...opened,
|
|
4805
|
-
appKey
|
|
5013
|
+
appKey,
|
|
4806
5014
|
pairUrl,
|
|
4807
5015
|
appPairUrl,
|
|
4808
5016
|
qrSvg
|
|
@@ -5467,8 +5675,6 @@ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
|
|
|
5467
5675
|
const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
|
|
5468
5676
|
/** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
|
|
5469
5677
|
const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
|
|
5470
|
-
/** Directory holding the public-IPv4 certificates installed by certbot. */
|
|
5471
|
-
const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
|
|
5472
5678
|
function publicIpv4Address(value) {
|
|
5473
5679
|
const parts = value.split(".");
|
|
5474
5680
|
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
|
|
@@ -5486,8 +5692,31 @@ function parsePublicOrigin(publicOrigin) {
|
|
|
5486
5692
|
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");
|
|
5487
5693
|
return url.hostname;
|
|
5488
5694
|
}
|
|
5489
|
-
|
|
5490
|
-
|
|
5695
|
+
function resolveVhostHttpPort(options) {
|
|
5696
|
+
const port = options.vhostHttpPort ?? 7080;
|
|
5697
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_template_input_invalid");
|
|
5698
|
+
return port;
|
|
5699
|
+
}
|
|
5700
|
+
/**
|
|
5701
|
+
* A self-signed entry deliberately never reaches Caddy: the tunnel is a raw TCP
|
|
5702
|
+
* passthrough and the DSH gateway is the TLS endpoint. Refuse loudly instead of
|
|
5703
|
+
* emitting a Caddy site that would need the gateway's private CA on the VPS.
|
|
5704
|
+
*/
|
|
5705
|
+
function rejectSelfSignedCaddySite(options) {
|
|
5706
|
+
if (options.entryTls === "self-signed") throw new Error("frp_entry_tls_invalid");
|
|
5707
|
+
}
|
|
5708
|
+
/**
|
|
5709
|
+
* Build the Caddy site for one public host (without markers or import wiring).
|
|
5710
|
+
*
|
|
5711
|
+
* The second parameter accepts either a bare certificate directory (the legacy
|
|
5712
|
+
* signature) or a full {@link CaddySiteOptions} object, so existing callers keep
|
|
5713
|
+
* working unchanged.
|
|
5714
|
+
*/
|
|
5715
|
+
function createCaddySite(publicHost, options = {}) {
|
|
5716
|
+
const resolved = typeof options === "string" ? { certDir: options } : options;
|
|
5717
|
+
rejectSelfSignedCaddySite(resolved);
|
|
5718
|
+
const certDir = resolved.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
|
|
5719
|
+
const vhostHttpPort = resolveVhostHttpPort(resolved);
|
|
5491
5720
|
if (publicIpv4Address(publicHost)) return [
|
|
5492
5721
|
"{",
|
|
5493
5722
|
` default_sni ${publicHost}`,
|
|
@@ -5499,14 +5728,14 @@ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
|
|
|
5499
5728
|
"",
|
|
5500
5729
|
`https://${publicHost} {`,
|
|
5501
5730
|
` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
|
|
5502
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5731
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5503
5732
|
"}",
|
|
5504
5733
|
""
|
|
5505
5734
|
].join("\n");
|
|
5506
5735
|
if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
|
|
5507
5736
|
return [
|
|
5508
5737
|
`${publicHost} {`,
|
|
5509
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5738
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5510
5739
|
"}",
|
|
5511
5740
|
""
|
|
5512
5741
|
].join("\n");
|
|
@@ -5529,15 +5758,24 @@ function manualIpCertificateGuide(publicHost) {
|
|
|
5529
5758
|
"#"
|
|
5530
5759
|
].join("\n");
|
|
5531
5760
|
}
|
|
5532
|
-
/**
|
|
5533
|
-
|
|
5761
|
+
/**
|
|
5762
|
+
* Build the only supported frps config and Caddy snippet from validated user inputs.
|
|
5763
|
+
*
|
|
5764
|
+
* Default `options` reproduce the upstream artefact byte for byte; `attach` mode
|
|
5765
|
+
* passes the user's real vhost port, and `self-signed` is rejected because that
|
|
5766
|
+
* mode publishes a raw TCP proxy instead of an HTTP vhost.
|
|
5767
|
+
*/
|
|
5768
|
+
function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, options = {}) {
|
|
5534
5769
|
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");
|
|
5770
|
+
rejectSelfSignedCaddySite(options);
|
|
5771
|
+
const vhostHttpPort = resolveVhostHttpPort(options);
|
|
5772
|
+
const certDir = options.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
|
|
5535
5773
|
const publicHost = parsePublicOrigin(publicOrigin);
|
|
5536
5774
|
const lines = [
|
|
5537
5775
|
"# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
|
|
5538
5776
|
`bindPort = ${String(serverPort)}`,
|
|
5539
5777
|
"proxyBindAddr = \"127.0.0.1\"",
|
|
5540
|
-
`vhostHTTPPort = ${String(
|
|
5778
|
+
`vhostHTTPPort = ${String(vhostHttpPort)}`,
|
|
5541
5779
|
"auth.method = \"token\"",
|
|
5542
5780
|
`auth.token = ${JSON.stringify(token)}`,
|
|
5543
5781
|
"",
|
|
@@ -5548,7 +5786,10 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5548
5786
|
"# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
|
|
5549
5787
|
"# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
|
|
5550
5788
|
`${FRP_CADDY_SNIPPET_MARKER}`,
|
|
5551
|
-
createCaddySite(publicHost
|
|
5789
|
+
createCaddySite(publicHost, {
|
|
5790
|
+
certDir,
|
|
5791
|
+
vhostHttpPort
|
|
5792
|
+
}).trimEnd(),
|
|
5552
5793
|
""
|
|
5553
5794
|
];
|
|
5554
5795
|
if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
|
|
@@ -5557,6 +5798,26 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5557
5798
|
//#endregion
|
|
5558
5799
|
//#region src/frp-config.ts
|
|
5559
5800
|
const MAX_SETTINGS_BYTES$1 = 8192;
|
|
5801
|
+
/** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
|
|
5802
|
+
const FRP_DEFAULT_PUBLIC_PORT = 33080;
|
|
5803
|
+
/** Ports owned by other DSH Mobile listeners; the public entry may never reuse them. */
|
|
5804
|
+
const FRP_RESERVED_PORTS = Object.freeze([
|
|
5805
|
+
3080,
|
|
5806
|
+
3443,
|
|
5807
|
+
3444
|
|
5808
|
+
]);
|
|
5809
|
+
/** Every key `settings.json` may contain. Unknown keys stay rejected. */
|
|
5810
|
+
const FRP_SETTINGS_KEYS = /* @__PURE__ */ new Set([
|
|
5811
|
+
"version",
|
|
5812
|
+
"serverAddress",
|
|
5813
|
+
"serverPort",
|
|
5814
|
+
"token",
|
|
5815
|
+
"publicOrigin",
|
|
5816
|
+
"mode",
|
|
5817
|
+
"entryTls",
|
|
5818
|
+
"vhostHttpPort",
|
|
5819
|
+
"publicPort"
|
|
5820
|
+
]);
|
|
5560
5821
|
function hostname$2(value) {
|
|
5561
5822
|
if (value.length > 253 || !value.includes(".")) return false;
|
|
5562
5823
|
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
@@ -5592,24 +5853,90 @@ function validateFrpPublicOrigin(value) {
|
|
|
5592
5853
|
if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5593
5854
|
return url.origin;
|
|
5594
5855
|
}
|
|
5595
|
-
/**
|
|
5856
|
+
/** Validate the provisioning mode. */
|
|
5857
|
+
function validateFrpMode(value) {
|
|
5858
|
+
if (value !== "deploy" && value !== "attach") throw new Error("frp_settings_invalid");
|
|
5859
|
+
return value;
|
|
5860
|
+
}
|
|
5861
|
+
/** Validate the entry TLS mode. */
|
|
5862
|
+
function validateFrpEntryTls(value) {
|
|
5863
|
+
if (value !== "public-ip-cert" && value !== "self-signed") throw new Error("frp_entry_tls_invalid");
|
|
5864
|
+
return value;
|
|
5865
|
+
}
|
|
5866
|
+
function validatePort(value, rejectReserved) {
|
|
5867
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("frp_settings_invalid");
|
|
5868
|
+
const port = Number(value);
|
|
5869
|
+
if (rejectReserved && FRP_RESERVED_PORTS.includes(port)) throw new Error("frp_settings_invalid");
|
|
5870
|
+
return port;
|
|
5871
|
+
}
|
|
5872
|
+
/** Validate the loopback vhost port of the target frps. */
|
|
5873
|
+
function validateFrpVhostHttpPort(value) {
|
|
5874
|
+
return validatePort(value, false);
|
|
5875
|
+
}
|
|
5876
|
+
/** Validate the public entry port of the self-signed TCP proxy. */
|
|
5877
|
+
function validateFrpPublicPort(value) {
|
|
5878
|
+
return validatePort(value, true);
|
|
5879
|
+
}
|
|
5880
|
+
/** Effective provisioning mode; an absent field keeps the upstream behaviour. */
|
|
5881
|
+
function resolveFrpMode(settings) {
|
|
5882
|
+
return settings.mode ?? "deploy";
|
|
5883
|
+
}
|
|
5884
|
+
/** Effective entry TLS mode; an absent field keeps the upstream behaviour. */
|
|
5885
|
+
function resolveFrpEntryTls(settings) {
|
|
5886
|
+
return settings.entryTls ?? "public-ip-cert";
|
|
5887
|
+
}
|
|
5888
|
+
/** Effective loopback vhost port; an absent field keeps the upstream behaviour. */
|
|
5889
|
+
function resolveFrpVhostHttpPort(settings) {
|
|
5890
|
+
return settings.vhostHttpPort ?? 7080;
|
|
5891
|
+
}
|
|
5892
|
+
/** Effective public entry port of the self-signed passthrough. */
|
|
5893
|
+
function resolveFrpPublicPort(settings) {
|
|
5894
|
+
return settings.publicPort ?? 33080;
|
|
5895
|
+
}
|
|
5896
|
+
/** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
|
|
5897
|
+
function frpEntryOrigin(settings) {
|
|
5898
|
+
if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
|
|
5899
|
+
return `https://${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
|
|
5900
|
+
}
|
|
5901
|
+
/** True when the gateway terminates TLS itself behind a raw frps TCP proxy. */
|
|
5902
|
+
function isFrpSelfSignedIngress(settings) {
|
|
5903
|
+
return resolveFrpEntryTls(settings) === "self-signed";
|
|
5904
|
+
}
|
|
5905
|
+
/**
|
|
5906
|
+
* Parse FRP settings at the loopback request and filesystem boundaries.
|
|
5907
|
+
*
|
|
5908
|
+
* Optional keys are kept only when supplied, so a legacy `settings.json` still
|
|
5909
|
+
* round-trips byte for byte and every default stays the upstream behaviour.
|
|
5910
|
+
* Two cross-field rules are enforced here because neither can be recovered later:
|
|
5911
|
+
* the self-signed entry is a TCP passthrough and therefore attach-only, and
|
|
5912
|
+
* attach mode with a public-CA entry must state the user's real vhost port —
|
|
5913
|
+
* silently assuming 7080 would disable the plaintext-exposure gate.
|
|
5914
|
+
*/
|
|
5596
5915
|
function parseFrpSettings(value) {
|
|
5597
5916
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
|
|
5598
5917
|
const record = value;
|
|
5599
|
-
if (Reflect.ownKeys(record).some((key) => !
|
|
5600
|
-
"version",
|
|
5601
|
-
"serverAddress",
|
|
5602
|
-
"serverPort",
|
|
5603
|
-
"token",
|
|
5604
|
-
"publicOrigin"
|
|
5605
|
-
].includes(String(key)))) throw new Error("frp_settings_invalid");
|
|
5918
|
+
if (Reflect.ownKeys(record).some((key) => !FRP_SETTINGS_KEYS.has(String(key)))) throw new Error("frp_settings_invalid");
|
|
5606
5919
|
if (record.version !== void 0 && record.version !== 1) throw new Error("frp_settings_invalid");
|
|
5920
|
+
const mode = record.mode === void 0 ? void 0 : validateFrpMode(record.mode);
|
|
5921
|
+
const entryTls = record.entryTls === void 0 ? void 0 : validateFrpEntryTls(record.entryTls);
|
|
5922
|
+
const vhostHttpPort = record.vhostHttpPort === void 0 ? void 0 : validateFrpVhostHttpPort(record.vhostHttpPort);
|
|
5923
|
+
const publicPort = record.publicPort === void 0 ? void 0 : validateFrpPublicPort(record.publicPort);
|
|
5924
|
+
const resolvedMode = mode ?? "deploy";
|
|
5925
|
+
const resolvedEntryTls = entryTls ?? "public-ip-cert";
|
|
5926
|
+
if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
|
|
5927
|
+
if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
|
|
5928
|
+
const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
|
|
5929
|
+
if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
|
|
5607
5930
|
return Object.freeze({
|
|
5608
5931
|
version: 1,
|
|
5609
5932
|
serverAddress: validateFrpServerAddress(record.serverAddress),
|
|
5610
5933
|
serverPort: validateFrpServerPort(record.serverPort),
|
|
5611
5934
|
token: validateFrpToken(record.token),
|
|
5612
|
-
publicOrigin
|
|
5935
|
+
publicOrigin,
|
|
5936
|
+
...mode === void 0 ? {} : { mode },
|
|
5937
|
+
...entryTls === void 0 ? {} : { entryTls },
|
|
5938
|
+
...vhostHttpPort === void 0 ? {} : { vhostHttpPort },
|
|
5939
|
+
...publicPort === void 0 ? {} : { publicPort }
|
|
5613
5940
|
});
|
|
5614
5941
|
}
|
|
5615
5942
|
/**
|
|
@@ -5619,11 +5946,24 @@ function parseFrpSettings(value) {
|
|
|
5619
5946
|
* missing configuration instead of silently deploying blanks.
|
|
5620
5947
|
*/
|
|
5621
5948
|
function mergeSavedFrpSettings(partial, saved) {
|
|
5949
|
+
const pick = (key) => {
|
|
5950
|
+
const supplied = partial[key];
|
|
5951
|
+
if (supplied === "" || supplied === void 0) return saved?.[key];
|
|
5952
|
+
return supplied;
|
|
5953
|
+
};
|
|
5954
|
+
const mode = pick("mode");
|
|
5955
|
+
const entryTls = pick("entryTls");
|
|
5956
|
+
const vhostHttpPort = pick("vhostHttpPort");
|
|
5957
|
+
const publicPort = pick("publicPort");
|
|
5622
5958
|
const merged = {
|
|
5623
5959
|
serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
|
|
5624
5960
|
serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
|
|
5625
5961
|
token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
|
|
5626
|
-
publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin
|
|
5962
|
+
publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin,
|
|
5963
|
+
...mode === "" || mode === void 0 ? {} : { mode },
|
|
5964
|
+
...entryTls === "" || entryTls === void 0 ? {} : { entryTls },
|
|
5965
|
+
...vhostHttpPort === "" || vhostHttpPort === void 0 ? {} : { vhostHttpPort },
|
|
5966
|
+
...publicPort === "" || publicPort === void 0 ? {} : { publicPort }
|
|
5627
5967
|
};
|
|
5628
5968
|
if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
|
|
5629
5969
|
return parseFrpSettings(merged);
|
|
@@ -5641,11 +5981,16 @@ function mergeSavedFrpTarget(partial, saved) {
|
|
|
5641
5981
|
function tomlString(value) {
|
|
5642
5982
|
return JSON.stringify(value);
|
|
5643
5983
|
}
|
|
5644
|
-
/**
|
|
5984
|
+
/**
|
|
5985
|
+
* Build the single-purpose frpc configuration for the current loopback gateway.
|
|
5986
|
+
*
|
|
5987
|
+
* The default (public-CA) entry is an HTTP vhost behind Caddy and is emitted byte
|
|
5988
|
+
* for byte as before. The self-signed entry cannot use a vhost at all: frps only
|
|
5989
|
+
* forwards raw TCP, and the gateway terminates TLS on that connection.
|
|
5990
|
+
*/
|
|
5645
5991
|
function createFrpcToml(settings, localPort) {
|
|
5646
5992
|
if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
|
|
5647
|
-
const
|
|
5648
|
-
return [
|
|
5993
|
+
const header = [
|
|
5649
5994
|
`serverAddr = ${tomlString(settings.serverAddress)}`,
|
|
5650
5995
|
`serverPort = ${String(settings.serverPort)}`,
|
|
5651
5996
|
"auth.method = \"token\"",
|
|
@@ -5653,7 +5998,21 @@ function createFrpcToml(settings, localPort) {
|
|
|
5653
5998
|
"transport.tls.enable = true",
|
|
5654
5999
|
"",
|
|
5655
6000
|
"[[proxies]]",
|
|
5656
|
-
"name = \"dsh-mobile\""
|
|
6001
|
+
"name = \"dsh-mobile\""
|
|
6002
|
+
];
|
|
6003
|
+
if (isFrpSelfSignedIngress(settings)) return [
|
|
6004
|
+
...header,
|
|
6005
|
+
"type = \"tcp\"",
|
|
6006
|
+
"localIP = \"127.0.0.1\"",
|
|
6007
|
+
`localPort = ${String(localPort)}`,
|
|
6008
|
+
`remotePort = ${String(resolveFrpPublicPort(settings))}`,
|
|
6009
|
+
"transport.useEncryption = true",
|
|
6010
|
+
"transport.useCompression = true",
|
|
6011
|
+
""
|
|
6012
|
+
].join("\n");
|
|
6013
|
+
const hostnameValue = new URL(settings.publicOrigin).hostname;
|
|
6014
|
+
return [
|
|
6015
|
+
...header,
|
|
5657
6016
|
"type = \"http\"",
|
|
5658
6017
|
"localIP = \"127.0.0.1\"",
|
|
5659
6018
|
`localPort = ${String(localPort)}`,
|
|
@@ -5665,7 +6024,10 @@ function createFrpcToml(settings, localPort) {
|
|
|
5665
6024
|
}
|
|
5666
6025
|
/** Build the matching restricted frps and Caddy templates for one VPS. */
|
|
5667
6026
|
function createFrpServerTemplate(settings) {
|
|
5668
|
-
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin
|
|
6027
|
+
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin, {
|
|
6028
|
+
vhostHttpPort: resolveFrpVhostHttpPort(settings),
|
|
6029
|
+
entryTls: resolveFrpEntryTls(settings)
|
|
6030
|
+
});
|
|
5669
6031
|
}
|
|
5670
6032
|
async function atomicPrivateWrite$2(file, body) {
|
|
5671
6033
|
const directory = dirname(file);
|
|
@@ -5728,9 +6090,17 @@ var FrpConfigStore = class {
|
|
|
5728
6090
|
this.errorCode = "frp_config_invalid";
|
|
5729
6091
|
}
|
|
5730
6092
|
}
|
|
5731
|
-
/**
|
|
6093
|
+
/**
|
|
6094
|
+
* Return configuration metadata without exposing the FRP token.
|
|
6095
|
+
*
|
|
6096
|
+
* The optional keys appear only when the saved configuration departs from the
|
|
6097
|
+
* upstream defaults, so the pre-existing field set is unchanged for every
|
|
6098
|
+
* legacy configuration.
|
|
6099
|
+
*/
|
|
5732
6100
|
status() {
|
|
5733
6101
|
const settings = this.settingsValue;
|
|
6102
|
+
const mode = settings === void 0 ? void 0 : resolveFrpMode(settings);
|
|
6103
|
+
const entryTls = settings === void 0 ? void 0 : resolveFrpEntryTls(settings);
|
|
5734
6104
|
return Object.freeze({
|
|
5735
6105
|
configured: settings !== void 0,
|
|
5736
6106
|
...settings === void 0 ? {} : {
|
|
@@ -5738,9 +6108,12 @@ var FrpConfigStore = class {
|
|
|
5738
6108
|
serverPort: settings.serverPort,
|
|
5739
6109
|
publicOrigin: settings.publicOrigin
|
|
5740
6110
|
},
|
|
5741
|
-
vhostHttpPort: FRP_VHOST_HTTP_PORT,
|
|
6111
|
+
vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
|
|
5742
6112
|
storagePath: this.stateRoot,
|
|
5743
|
-
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
6113
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode },
|
|
6114
|
+
...mode === void 0 || mode === "deploy" ? {} : { mode },
|
|
6115
|
+
...entryTls === void 0 || entryTls === "public-ip-cert" ? {} : { entryTls },
|
|
6116
|
+
...settings?.publicPort === void 0 ? {} : { publicPort: settings.publicPort }
|
|
5744
6117
|
});
|
|
5745
6118
|
}
|
|
5746
6119
|
/** Return private settings only to the provider lifecycle. */
|
|
@@ -5775,78 +6148,1104 @@ var FrpConfigStore = class {
|
|
|
5775
6148
|
}
|
|
5776
6149
|
};
|
|
5777
6150
|
//#endregion
|
|
5778
|
-
//#region src/
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
6151
|
+
//#region src/frp-attach.ts
|
|
6152
|
+
/** Default proof path used to confirm the public entry answered from this computer. */
|
|
6153
|
+
const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
|
|
6154
|
+
/** Declaration printed above the VPS half of an attach template. */
|
|
6155
|
+
const FRP_ATTACH_VPS_DECLARATION = "# 本模板不安装、不改动、不重启你的 frps;只新增 Caddy 片段与证书。frps 侧需你自行确认 vhostHTTPPort=<N> 与 proxyBindAddr=\"127.0.0.1\"。";
|
|
6156
|
+
/** Declaration printed above the local half of an attach template. */
|
|
6157
|
+
const FRP_ATTACH_LOCAL_DECLARATION = "# 本机只写入 frpc.toml 并启动 frpc:不安装、不改动本机 frps,也不改动 DSH 自身配置。";
|
|
6158
|
+
/** Declaration for the self-signed passthrough, which keeps its CA on this computer. */
|
|
6159
|
+
const FRP_ATTACH_SELF_SIGNED_DECLARATION = "# 自签穿透档不使用 Caddy 或公开证书:frps 只做 TCP 透传,由本机 DSH 网关终止 TLS;服务端证书会在运行时续签,CA 到期需重新配对。";
|
|
6160
|
+
/**
|
|
6161
|
+
* One-line notice printed above the masked frpc.toml preview.
|
|
6162
|
+
*
|
|
6163
|
+
* It states both facts a reader cannot verify from the text itself: the real
|
|
6164
|
+
* file is written with 0600 permissions, and an unmasked copy would survive in
|
|
6165
|
+
* the system clipboard.
|
|
6166
|
+
*/
|
|
6167
|
+
function frpAttachTokenMaskNotice(configFile) {
|
|
6168
|
+
return `# 预览已打码:真正的 frpc.toml 已由插件以 0600 权限写入 ${configFile};明文 token 不打码会随复制进入系统剪贴板,故此处只显示占位符。`;
|
|
5790
6169
|
}
|
|
5791
|
-
/**
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
6170
|
+
/** One-line notice printed above an explicitly revealed frpc.toml preview. */
|
|
6171
|
+
const FRP_ATTACH_TOKEN_REVEAL_NOTICE = "# 明文 token 已按你的显式操作显示:复制后即进入系统剪贴板并长期留存,请勿粘贴到公开位置。";
|
|
6172
|
+
/** Matches the single `auth.token` assignment of a generated frpc.toml. */
|
|
6173
|
+
const FRPC_TOKEN_ASSIGNMENT = /^auth\.token\s*=\s*"(?:[^"\\]|\\.)*"/mu;
|
|
6174
|
+
/**
|
|
6175
|
+
* Replace the `auth.token` value of a rendered frpc.toml with a fixed placeholder.
|
|
6176
|
+
*
|
|
6177
|
+
* Only the preview and clipboard paths call this: the file written to disk keeps
|
|
6178
|
+
* the real token, because frpc has to read it. The mapping never fails open — a
|
|
6179
|
+
* token line that cannot be masked raises instead of leaking the secret.
|
|
6180
|
+
*/
|
|
6181
|
+
function redactFrpcTomlToken(frpcToml) {
|
|
6182
|
+
if (!/(^|\n)auth\.token/u.test(frpcToml)) return frpcToml;
|
|
6183
|
+
const masked = frpcToml.replace(FRPC_TOKEN_ASSIGNMENT, `auth.token = "***"`);
|
|
6184
|
+
if (!masked.includes(`auth.token = "***"`)) throw new Error("frp_token_mask_failed");
|
|
6185
|
+
return masked;
|
|
5795
6186
|
}
|
|
5796
6187
|
/**
|
|
5797
|
-
*
|
|
5798
|
-
*
|
|
6188
|
+
* The frpc.toml exactly as the panel displays or copies it.
|
|
6189
|
+
*
|
|
6190
|
+
* `options.revealToken` is the only way to obtain the plaintext variant; the
|
|
6191
|
+
* default is the masked text. `createFrpcToml` itself is untouched, so the file
|
|
6192
|
+
* the provider writes stays byte-for-byte identical to the upstream output.
|
|
5799
6193
|
*/
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
6194
|
+
function createFrpAttachFrpcToml(settings, options = {}) {
|
|
6195
|
+
const frpcToml = createFrpcToml(settings, attachLocalPort(options));
|
|
6196
|
+
return options.revealToken === true ? frpcToml : redactFrpcTomlToken(frpcToml);
|
|
6197
|
+
}
|
|
6198
|
+
const DEFAULT_ATTACH_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
|
|
6199
|
+
function attachConfigFile(options) {
|
|
6200
|
+
const file = options.configFile;
|
|
6201
|
+
if (file === void 0 || file === "") return DEFAULT_ATTACH_CONFIG_FILE;
|
|
6202
|
+
return file;
|
|
6203
|
+
}
|
|
6204
|
+
function attachLocalPort(options) {
|
|
6205
|
+
const port = options.localPort ?? 3443;
|
|
6206
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_local_port_invalid");
|
|
6207
|
+
return port;
|
|
6208
|
+
}
|
|
6209
|
+
/**
|
|
6210
|
+
* Reject every attach request that could not be carried out safely before any
|
|
6211
|
+
* artefact is produced. Attach never installs frps, so it must know the user's
|
|
6212
|
+
* real vhost port (unless the self-signed passthrough removes the vhost entirely).
|
|
6213
|
+
*/
|
|
6214
|
+
function validateAttachSettings(settings, options = {}) {
|
|
6215
|
+
if (resolveFrpMode(settings) !== "attach") throw new Error("frp_settings_invalid");
|
|
6216
|
+
if (isFrpSelfSignedIngress(settings)) {
|
|
6217
|
+
if (settings.mode !== "attach") throw new Error("frp_entry_tls_invalid");
|
|
6218
|
+
resolveFrpPublicPort(settings);
|
|
6219
|
+
return;
|
|
5821
6220
|
}
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
6221
|
+
const vhostHttpPort = options.vhostHttpPort ?? settings.vhostHttpPort;
|
|
6222
|
+
if (vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
|
|
6223
|
+
if (!Number.isSafeInteger(vhostHttpPort) || vhostHttpPort < 1 || vhostHttpPort > 65535) throw new Error("frp_settings_invalid");
|
|
6224
|
+
}
|
|
6225
|
+
/** Strip the leading `# ` of a guide block so it can be pasted as runnable shell. */
|
|
6226
|
+
function guideCommands(guide) {
|
|
6227
|
+
return guide.split("\n").map((line) => line.replace(/^#\s?/u, "").trimEnd()).filter((line) => line !== "");
|
|
6228
|
+
}
|
|
6229
|
+
/**
|
|
6230
|
+
* Derive every VPS-side value once so the copied runbook and the panel plan can
|
|
6231
|
+
* never drift apart.
|
|
6232
|
+
*/
|
|
6233
|
+
function frpAttachVpsParts(settings, options = {}) {
|
|
6234
|
+
validateAttachSettings(settings, options);
|
|
6235
|
+
const publicHost = new URL(settings.publicOrigin).hostname;
|
|
6236
|
+
if (isFrpSelfSignedIngress(settings)) return Object.freeze({
|
|
6237
|
+
publicHost,
|
|
6238
|
+
selfSigned: true,
|
|
6239
|
+
publicPort: resolveFrpPublicPort(settings),
|
|
6240
|
+
snippet: "",
|
|
6241
|
+
certGuide: Object.freeze([])
|
|
6242
|
+
});
|
|
6243
|
+
const vhostHttpPort = options.vhostHttpPort ?? resolveFrpVhostHttpPort(settings);
|
|
6244
|
+
const certDir = options.certDir;
|
|
6245
|
+
const site = createCaddySite(publicHost, {
|
|
6246
|
+
vhostHttpPort,
|
|
6247
|
+
...certDir === void 0 ? {} : { certDir }
|
|
6248
|
+
});
|
|
6249
|
+
return Object.freeze({
|
|
6250
|
+
publicHost,
|
|
6251
|
+
selfSigned: false,
|
|
6252
|
+
vhostHttpPort,
|
|
6253
|
+
snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
|
|
6254
|
+
certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
|
|
6255
|
+
});
|
|
6256
|
+
}
|
|
6257
|
+
/**
|
|
6258
|
+
* Build the attach artefact for an existing frps.
|
|
6259
|
+
*
|
|
6260
|
+
* The result is deliberately split in two: the VPS half contains only what the
|
|
6261
|
+
* user's own server needs (Caddy snippet + import + certificate guidance, or a
|
|
6262
|
+
* single firewall rule for the self-signed passthrough), and the local half
|
|
6263
|
+
* contains the generated frpc.toml plus its `frpc verify` self-check. Neither
|
|
6264
|
+
* half installs, rewrites, or restarts the user's frps.
|
|
6265
|
+
*/
|
|
6266
|
+
function createFrpAttachTemplateParts(settings, options = {}) {
|
|
6267
|
+
const parts = frpAttachVpsParts(settings, options);
|
|
6268
|
+
const configFile = attachConfigFile(options);
|
|
6269
|
+
const revealed = options.revealToken === true;
|
|
6270
|
+
const frpcToml = createFrpAttachFrpcToml(settings, options);
|
|
6271
|
+
const local = [
|
|
6272
|
+
"# ---- (b) 本机侧(在这台电脑上执行)----",
|
|
6273
|
+
FRP_ATTACH_LOCAL_DECLARATION,
|
|
6274
|
+
revealed ? FRP_ATTACH_TOKEN_REVEAL_NOTICE : frpAttachTokenMaskNotice(configFile),
|
|
6275
|
+
`# frpc.toml 全文(插件同样会以 0600 权限写入 ${configFile})`,
|
|
6276
|
+
frpcToml.trimEnd(),
|
|
6277
|
+
"",
|
|
6278
|
+
"# 启动前自检:",
|
|
6279
|
+
`frpc verify -c "${configFile}"`,
|
|
6280
|
+
""
|
|
6281
|
+
].join("\n");
|
|
6282
|
+
if (parts.selfSigned) {
|
|
6283
|
+
const publicPort = parts.publicPort ?? resolveFrpPublicPort(settings);
|
|
6284
|
+
const vps = [
|
|
6285
|
+
"# ---- (a) VPS 侧(在你的服务器上执行,共 3 项)----",
|
|
6286
|
+
FRP_ATTACH_SELF_SIGNED_DECLARATION,
|
|
6287
|
+
"#",
|
|
6288
|
+
"# 1) 放行公网入口端口(就是这台电脑网关的 HTTPS 入口,TCP 透传不解密):",
|
|
6289
|
+
`ufw allow ${String(publicPort)}/tcp`,
|
|
6290
|
+
`ufw status | grep ${String(publicPort)}`,
|
|
6291
|
+
"# 若使用 firewalld / 云厂商安全组,请放行同样的 TCP 端口。",
|
|
6292
|
+
"# 前提:现有 frps 的 TCP 代理已监听公网地址;若 proxyBindAddr=127.0.0.1,放行防火墙仍无法从公网访问。",
|
|
6293
|
+
"# 插件不会修改你现有的 frps;不满足此前提时请改选受信任证书入口。",
|
|
6294
|
+
"#",
|
|
6295
|
+
"# 2) 确认你既有的 frps 已就绪(无需修改它的配置):",
|
|
6296
|
+
"systemctl is-active frps || true",
|
|
6297
|
+
`ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`,
|
|
6298
|
+
"#",
|
|
6299
|
+
"# 3) frpc 启动后,用 -k 检查连通性;此命令不验证身份,App 配对后会固定网关 CA:",
|
|
6300
|
+
`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`,
|
|
6301
|
+
"# 期望输出 200。",
|
|
6302
|
+
""
|
|
6303
|
+
].join("\n");
|
|
6304
|
+
return Object.freeze({
|
|
6305
|
+
mode: "attach",
|
|
6306
|
+
vps,
|
|
6307
|
+
local,
|
|
6308
|
+
text: `${vps}${local}`
|
|
6309
|
+
});
|
|
6310
|
+
}
|
|
6311
|
+
const vps = [
|
|
6312
|
+
"# ---- (a) VPS 侧(在你的服务器上执行)----",
|
|
6313
|
+
FRP_ATTACH_VPS_DECLARATION,
|
|
6314
|
+
"#",
|
|
6315
|
+
`# 1) 保存 Caddy 片段到 ${FRP_CADDY_SNIPPET_PATH}(只新增这一个文件):`,
|
|
6316
|
+
`cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
|
|
6317
|
+
parts.snippet.trimEnd(),
|
|
6318
|
+
"DSH_MOBILE_CADDY_SNIPPET",
|
|
6319
|
+
"#",
|
|
6320
|
+
"# 2) 确认 Caddyfile 顶部含下面这一行(全局块必须在前;没有则加到文件最上方):",
|
|
6321
|
+
`# ${FRP_CADDY_IMPORT_LINE}`,
|
|
6322
|
+
"caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
|
|
6323
|
+
"#",
|
|
6324
|
+
...parts.certGuide.length === 0 ? ["# 3) 域名 HTTPS:确认 DNS 指向 VPS,开放 80/443;Caddy 会自动申请和续期证书。"] : [
|
|
6325
|
+
"# 3) 公网 IP 的 HTTPS 证书(Caddy 不能为 IP 自动签发,故用 certbot 签发):",
|
|
6326
|
+
...parts.certGuide.map((line) => `# ${line}`),
|
|
6327
|
+
"#",
|
|
6328
|
+
"# 4) 确认证书续期任务,并在续期后将新证书安装到 Caddy:",
|
|
6329
|
+
"systemctl list-timers certbot.timer --all || true"
|
|
6330
|
+
],
|
|
6331
|
+
"#",
|
|
6332
|
+
"# 端到端验证(公网入口为 443,与 frps 的 vhost 端口无关):",
|
|
6333
|
+
`curl -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}${FRP_ATTACH_DISCOVERY_PATH}`,
|
|
6334
|
+
"# 期望输出 200。",
|
|
6335
|
+
""
|
|
6336
|
+
].join("\n");
|
|
6337
|
+
return Object.freeze({
|
|
6338
|
+
mode: "attach",
|
|
6339
|
+
vps,
|
|
6340
|
+
local,
|
|
6341
|
+
text: `${vps}${local}`
|
|
6342
|
+
});
|
|
6343
|
+
}
|
|
6344
|
+
/** Combined attach artefact, ready for the clipboard. */
|
|
6345
|
+
function createFrpAttachTemplate(settings, options = {}) {
|
|
6346
|
+
return createFrpAttachTemplateParts(settings, options).text;
|
|
6347
|
+
}
|
|
6348
|
+
//#endregion
|
|
6349
|
+
//#region src/frp-attach-plan.ts
|
|
6350
|
+
const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
|
|
6351
|
+
function planConfigFile(options) {
|
|
6352
|
+
const file = options.configFile;
|
|
6353
|
+
return file === void 0 || file === "" ? DEFAULT_PLAN_CONFIG_FILE : file;
|
|
6354
|
+
}
|
|
6355
|
+
/**
|
|
6356
|
+
* Build the copy-only attach plan. It performs no network access, no SSH, and no
|
|
6357
|
+
* filesystem write: the caller renders or copies the steps and the local frpc
|
|
6358
|
+
* configuration is written by the ordinary provider lifecycle.
|
|
6359
|
+
*/
|
|
6360
|
+
function createFrpAttachPlan(settings, options = {}) {
|
|
6361
|
+
const parts = frpAttachVpsParts(settings, options);
|
|
6362
|
+
const configFile = planConfigFile(options);
|
|
6363
|
+
const localPort = options.localPort ?? 3443;
|
|
6364
|
+
const frpcToml = createFrpAttachFrpcToml(settings, {
|
|
6365
|
+
...options,
|
|
6366
|
+
localPort
|
|
6367
|
+
});
|
|
6368
|
+
const tokenMasked = options.revealToken !== true;
|
|
6369
|
+
const verifyCommand = `frpc verify -c "${configFile}"`;
|
|
6370
|
+
const commonWarnings = ["本机探测 ≠ 公网验证:请在外部(手机流量或另一台机器)再执行一次 VPS 侧清单里的 curl 验证。", "插件不会安装、不会改动、不会重启你既有的 frps,也不会改动 Caddyfile 里的其他内容。"];
|
|
6371
|
+
if (isFrpSelfSignedIngress(settings)) {
|
|
6372
|
+
const publicPort = parts.publicPort ?? 0;
|
|
6373
|
+
const publicHost = parts.publicHost;
|
|
6374
|
+
return Object.freeze({
|
|
6375
|
+
mode: "attach",
|
|
6376
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
6377
|
+
publicPort,
|
|
6378
|
+
local: Object.freeze({
|
|
6379
|
+
frpcToml,
|
|
6380
|
+
tokenMasked,
|
|
6381
|
+
verifyCommand,
|
|
6382
|
+
configFile,
|
|
6383
|
+
declaration: FRP_ATTACH_LOCAL_DECLARATION
|
|
6384
|
+
}),
|
|
6385
|
+
vps: Object.freeze([
|
|
6386
|
+
Object.freeze({
|
|
6387
|
+
id: "open-public-port",
|
|
6388
|
+
title: "frpAttachStepOpenPort",
|
|
6389
|
+
label: `放行公网入口端口 ${String(publicPort)}/tcp`,
|
|
6390
|
+
commands: Object.freeze([`ufw allow ${String(publicPort)}/tcp`, `ufw status | grep ${String(publicPort)}`]),
|
|
6391
|
+
optional: false,
|
|
6392
|
+
verifyHint: `ufw status 中应出现 ${String(publicPort)}/tcp ALLOW;云厂商安全组也需放行同一 TCP 端口。`
|
|
6393
|
+
}),
|
|
6394
|
+
Object.freeze({
|
|
6395
|
+
id: "verify-frps",
|
|
6396
|
+
title: "frpAttachStepVerifyFrps",
|
|
6397
|
+
label: "确认既有 frps 已就绪(不改动它的配置)",
|
|
6398
|
+
commands: Object.freeze(["systemctl is-active frps || true", `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`]),
|
|
6399
|
+
optional: false,
|
|
6400
|
+
verifyHint: `frps 控制端口 ${String(settings.serverPort)} 应监听;入口 ${String(publicPort)} 的 Local Address 不应是 127.0.0.1。若仅回环监听,防火墙放行也无法公网访问。`
|
|
6401
|
+
}),
|
|
6402
|
+
Object.freeze({
|
|
6403
|
+
id: "verify-entry",
|
|
6404
|
+
title: "frpAttachStepVerifyEntry",
|
|
6405
|
+
label: "frpc 启动后端到端验证(自签证书需 -k)",
|
|
6406
|
+
commands: Object.freeze([`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`]),
|
|
6407
|
+
optional: false,
|
|
6408
|
+
verifyHint: "期望输出 200;curl -k 仅验证连通性,不验证服务器身份;App 配对后会固定网关 CA。返回 000 时检查端口与转发。"
|
|
6409
|
+
})
|
|
6410
|
+
]),
|
|
6411
|
+
warnings: Object.freeze([
|
|
6412
|
+
...commonWarnings,
|
|
6413
|
+
FRP_ATTACH_SELF_SIGNED_DECLARATION,
|
|
6414
|
+
"现有 frps 的 TCP 代理必须已监听公网地址;若 proxyBindAddr=127.0.0.1,单靠开放防火墙端口无法连通,请改选公网证书 + Caddy 模式。插件不会修改你的 frps。",
|
|
6415
|
+
"手机浏览器访问自签入口会提示证书不受信任 —— 请用 App 扫码配对;App 已固定网关 CA,不需要公开证书。",
|
|
6416
|
+
"切勿把网关 listenHost 改成 0.0.0.0:公网入口由 frps 的 TCP 代理提供,网关只监听 127.0.0.1。"
|
|
6417
|
+
])
|
|
6418
|
+
});
|
|
6419
|
+
}
|
|
6420
|
+
const vhostHttpPort = parts.vhostHttpPort ?? 0;
|
|
6421
|
+
const publicHost = parts.publicHost;
|
|
6422
|
+
const certGuide = parts.certGuide;
|
|
6423
|
+
const certificateSteps = certGuide.length === 0 ? [] : [Object.freeze({
|
|
6424
|
+
id: "issue-ip-cert",
|
|
6425
|
+
title: "frpAttachStepIssueCert",
|
|
6426
|
+
label: "为公网 IP 签发受信任证书(certbot)",
|
|
6427
|
+
commands: Object.freeze([...certGuide]),
|
|
6428
|
+
optional: false,
|
|
6429
|
+
verifyHint: `证书应落到 /etc/letsencrypt/live/${publicHost}/ 并安装到 Caddy 证书目录。`
|
|
6430
|
+
}), Object.freeze({
|
|
6431
|
+
id: "enable-cert-timer",
|
|
6432
|
+
title: "frpAttachStepCertTimer",
|
|
6433
|
+
label: "确认 certbot 续期定时器并更新 Caddy 证书文件",
|
|
6434
|
+
commands: Object.freeze(["systemctl list-timers certbot.timer --all || true"]),
|
|
6435
|
+
optional: false,
|
|
6436
|
+
verifyHint: "公网 IP 证书有效期较短;续期后还须将新证书安装到 Caddy 并重载。"
|
|
6437
|
+
})];
|
|
6438
|
+
return Object.freeze({
|
|
6439
|
+
mode: "attach",
|
|
6440
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
6441
|
+
vhostHttpPort,
|
|
6442
|
+
local: Object.freeze({
|
|
6443
|
+
frpcToml,
|
|
6444
|
+
tokenMasked,
|
|
6445
|
+
verifyCommand,
|
|
6446
|
+
configFile,
|
|
6447
|
+
declaration: FRP_ATTACH_LOCAL_DECLARATION
|
|
6448
|
+
}),
|
|
6449
|
+
vps: Object.freeze([
|
|
6450
|
+
Object.freeze({
|
|
6451
|
+
id: "write-snippet",
|
|
6452
|
+
title: "frpAttachStepWriteSnippet",
|
|
6453
|
+
label: `新增 Caddy 片段 ${FRP_CADDY_SNIPPET_PATH}`,
|
|
6454
|
+
commands: Object.freeze([
|
|
6455
|
+
`cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
|
|
6456
|
+
parts.snippet.trimEnd(),
|
|
6457
|
+
"DSH_MOBILE_CADDY_SNIPPET"
|
|
6458
|
+
]),
|
|
6459
|
+
optional: false,
|
|
6460
|
+
verifyHint: `片段内容里的反代目标是 127.0.0.1:${String(vhostHttpPort)},即你 frps 的 vhostHTTPPort。`
|
|
6461
|
+
}),
|
|
6462
|
+
Object.freeze({
|
|
6463
|
+
id: "add-import",
|
|
6464
|
+
title: "frpAttachStepAddImport",
|
|
6465
|
+
label: "在 Caddyfile 顶部加入 import 行并重载",
|
|
6466
|
+
commands: Object.freeze([`# 确认 /etc/caddy/Caddyfile 顶部包含:${FRP_CADDY_IMPORT_LINE}`, "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy"]),
|
|
6467
|
+
optional: false,
|
|
6468
|
+
verifyHint: "caddy validate 通过且 reload 无报错;Caddyfile 中你自己原有的站点内容保持不变。"
|
|
6469
|
+
}),
|
|
6470
|
+
...certificateSteps,
|
|
6471
|
+
Object.freeze({
|
|
6472
|
+
id: "verify-https",
|
|
6473
|
+
title: "frpAttachStepVerifyHttps",
|
|
6474
|
+
label: "端到端验证公网 HTTPS 入口",
|
|
6475
|
+
commands: Object.freeze([`curl -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}${FRP_ATTACH_DISCOVERY_PATH}`]),
|
|
6476
|
+
optional: true,
|
|
6477
|
+
verifyHint: certGuide.length === 0 ? "期望输出 200;证书错误时检查 DNS、80/443 端口和 Caddy 日志。" : "期望输出 200;证书错误时检查 certbot 证书是否安装并重载 Caddy。"
|
|
6478
|
+
})
|
|
6479
|
+
]),
|
|
6480
|
+
warnings: Object.freeze([
|
|
6481
|
+
...commonWarnings,
|
|
6482
|
+
FRP_ATTACH_VPS_DECLARATION,
|
|
6483
|
+
`你 frps 的明文 vhost 必须只监听 127.0.0.1(proxyBindAddr)且 vhostHTTPPort=${String(vhostHttpPort)};插件启动前会探测该端口,若公网可达将直接拒绝启动。`
|
|
6484
|
+
])
|
|
6485
|
+
});
|
|
6486
|
+
}
|
|
6487
|
+
//#endregion
|
|
6488
|
+
//#region src/cert-renewal.ts
|
|
6489
|
+
/** Days left below which the entry certificate is reported as expiring. */
|
|
6490
|
+
const CERT_EXPIRING_DAYS = 2;
|
|
6491
|
+
const DAY_MS = 864e5;
|
|
6492
|
+
/** Evaluate one `notAfter` timestamp against the current clock. */
|
|
6493
|
+
function evaluateCertificateLifetime(notAfter, now = Date.now()) {
|
|
6494
|
+
if (!Number.isFinite(notAfter) || !Number.isFinite(now)) return Object.freeze({
|
|
6495
|
+
state: "unknown",
|
|
6496
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6497
|
+
});
|
|
6498
|
+
const daysRemaining = Math.floor((notAfter - now) / DAY_MS);
|
|
6499
|
+
if (notAfter <= now) return Object.freeze({
|
|
6500
|
+
state: "expired",
|
|
6501
|
+
notAfter,
|
|
6502
|
+
daysRemaining
|
|
6503
|
+
});
|
|
6504
|
+
if (daysRemaining <= 2) return Object.freeze({
|
|
6505
|
+
state: "expiring",
|
|
6506
|
+
notAfter,
|
|
6507
|
+
daysRemaining
|
|
6508
|
+
});
|
|
6509
|
+
return Object.freeze({
|
|
6510
|
+
state: "ok",
|
|
6511
|
+
notAfter,
|
|
6512
|
+
daysRemaining
|
|
6513
|
+
});
|
|
6514
|
+
}
|
|
6515
|
+
function statusFromCertificate(certificate, now) {
|
|
6516
|
+
const lifetime = evaluateCertificateLifetime(Date.parse(certificate.validTo), now);
|
|
6517
|
+
return Object.freeze({
|
|
6518
|
+
...lifetime,
|
|
6519
|
+
subject: certificate.subject,
|
|
6520
|
+
issuer: certificate.issuer
|
|
6521
|
+
});
|
|
6522
|
+
}
|
|
6523
|
+
/**
|
|
6524
|
+
* Read one PEM certificate from disk and report only its lifetime.
|
|
6525
|
+
*
|
|
6526
|
+
* A missing or unreadable file yields `unknown` with the stable
|
|
6527
|
+
* `frp_attach_cert_unknown` code so the panel can explain how to re-issue it.
|
|
6528
|
+
*/
|
|
6529
|
+
async function readCertificateRenewal(file, now = Date.now()) {
|
|
6530
|
+
try {
|
|
6531
|
+
return statusFromCertificate(new X509Certificate(await readFile(file)), now);
|
|
6532
|
+
} catch {
|
|
6533
|
+
return Object.freeze({
|
|
6534
|
+
state: "unknown",
|
|
6535
|
+
errorCode: "frp_attach_cert_unknown"
|
|
5843
6536
|
});
|
|
5844
6537
|
}
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
6538
|
+
}
|
|
6539
|
+
/**
|
|
6540
|
+
* Ask a live TLS endpoint for its leaf certificate and report its lifetime.
|
|
6541
|
+
*
|
|
6542
|
+
* Verification is intentionally skipped: this is a read-only lifetime probe for
|
|
6543
|
+
* an endpoint whose trust anchor is already pinned elsewhere, and a self-signed
|
|
6544
|
+
* leaf is the expected answer for the passthrough entry.
|
|
6545
|
+
*/
|
|
6546
|
+
async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
|
|
6547
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return Object.freeze({
|
|
6548
|
+
state: "unknown",
|
|
6549
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6550
|
+
});
|
|
6551
|
+
return new Promise((resolveProbe) => {
|
|
6552
|
+
let finished = false;
|
|
6553
|
+
const socket = connect$1({
|
|
6554
|
+
host,
|
|
6555
|
+
port,
|
|
6556
|
+
rejectUnauthorized: false,
|
|
6557
|
+
servername: void 0
|
|
6558
|
+
});
|
|
6559
|
+
const finish = (status) => {
|
|
6560
|
+
if (finished) return;
|
|
6561
|
+
finished = true;
|
|
6562
|
+
clearTimeout(timer);
|
|
6563
|
+
socket.destroy();
|
|
6564
|
+
resolveProbe(status);
|
|
6565
|
+
};
|
|
6566
|
+
const timer = setTimeout(() => {
|
|
6567
|
+
finish(Object.freeze({
|
|
6568
|
+
state: "unknown",
|
|
6569
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6570
|
+
}));
|
|
6571
|
+
}, timeoutMs);
|
|
6572
|
+
timer.unref();
|
|
6573
|
+
socket.once("secureConnect", () => {
|
|
6574
|
+
const certificate = socket.getPeerX509Certificate();
|
|
6575
|
+
finish(certificate === void 0 ? Object.freeze({
|
|
6576
|
+
state: "unknown",
|
|
6577
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6578
|
+
}) : statusFromCertificate(certificate, Date.now()));
|
|
6579
|
+
});
|
|
6580
|
+
socket.once("error", () => {
|
|
6581
|
+
finish(Object.freeze({
|
|
6582
|
+
state: "unknown",
|
|
6583
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6584
|
+
}));
|
|
6585
|
+
});
|
|
6586
|
+
socket.once("close", () => {
|
|
6587
|
+
finish(Object.freeze({
|
|
6588
|
+
state: "unknown",
|
|
6589
|
+
errorCode: "frp_attach_cert_unknown"
|
|
6590
|
+
}));
|
|
6591
|
+
});
|
|
6592
|
+
});
|
|
6593
|
+
}
|
|
6594
|
+
//#endregion
|
|
6595
|
+
//#region src/managed-setup.ts
|
|
6596
|
+
const VIRTUAL_INTERFACE_MARKERS = [
|
|
6597
|
+
"bridge",
|
|
6598
|
+
"docker",
|
|
6599
|
+
"hyper-v",
|
|
6600
|
+
"mihomo",
|
|
6601
|
+
"radmin",
|
|
6602
|
+
"tailscale",
|
|
6603
|
+
"tap",
|
|
6604
|
+
"tun",
|
|
6605
|
+
"utun",
|
|
6606
|
+
"vbox",
|
|
6607
|
+
"veth",
|
|
6608
|
+
"virtual",
|
|
6609
|
+
"vmware",
|
|
6610
|
+
"vpn",
|
|
6611
|
+
"vethernet",
|
|
6612
|
+
"wsl",
|
|
6613
|
+
"zerotier"
|
|
6614
|
+
];
|
|
6615
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
6616
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
6617
|
+
function requiredString(value, name) {
|
|
6618
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
6619
|
+
return value;
|
|
6620
|
+
}
|
|
6621
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
6622
|
+
function parseManagedSetup(value) {
|
|
6623
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
6624
|
+
const record = value;
|
|
6625
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
6626
|
+
"version",
|
|
6627
|
+
"networkInterface",
|
|
6628
|
+
"listenPort",
|
|
6629
|
+
"upstreamOrigin",
|
|
6630
|
+
"tls"
|
|
6631
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
6632
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
6633
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
6634
|
+
const tls = record.tls;
|
|
6635
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
6636
|
+
"mode",
|
|
6637
|
+
"caCertFile",
|
|
6638
|
+
"caKeyFile",
|
|
6639
|
+
"certFile",
|
|
6640
|
+
"keyFile"
|
|
6641
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
6642
|
+
return Object.freeze({
|
|
6643
|
+
version: 2,
|
|
6644
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
6645
|
+
listenPort: record.listenPort,
|
|
6646
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
6647
|
+
tls: Object.freeze({
|
|
6648
|
+
mode: "managed",
|
|
6649
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
6650
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
6651
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
6652
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
6653
|
+
})
|
|
6654
|
+
});
|
|
6655
|
+
}
|
|
6656
|
+
function privateIpv4(value) {
|
|
6657
|
+
const parts = value.split(".").map(Number);
|
|
6658
|
+
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);
|
|
6659
|
+
}
|
|
6660
|
+
function networkCidr(address, cidr) {
|
|
6661
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
6662
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
6663
|
+
return `${[
|
|
6664
|
+
24,
|
|
6665
|
+
16,
|
|
6666
|
+
8,
|
|
6667
|
+
0
|
|
6668
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
6669
|
+
}
|
|
6670
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
6671
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
6672
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
6673
|
+
name,
|
|
6674
|
+
address: entry.address,
|
|
6675
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
6676
|
+
})));
|
|
6677
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
6678
|
+
}
|
|
6679
|
+
function likelyVirtualInterface(name) {
|
|
6680
|
+
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
6681
|
+
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
6682
|
+
}
|
|
6683
|
+
async function runRouteCommand(file, args) {
|
|
6684
|
+
return (await execFileText(file, [...args], {
|
|
6685
|
+
encoding: "utf8",
|
|
6686
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
6687
|
+
})).stdout;
|
|
6688
|
+
}
|
|
6689
|
+
function uniqueLines(output) {
|
|
6690
|
+
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
6691
|
+
}
|
|
6692
|
+
/** Return operating-system default-route interfaces in routing preference order. */
|
|
6693
|
+
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
6694
|
+
try {
|
|
6695
|
+
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
6696
|
+
"-NoProfile",
|
|
6697
|
+
"-NonInteractive",
|
|
6698
|
+
"-Command",
|
|
6699
|
+
[
|
|
6700
|
+
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
6701
|
+
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
6702
|
+
" $route = $_",
|
|
6703
|
+
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
6704
|
+
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
6705
|
+
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
6706
|
+
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
6707
|
+
" }",
|
|
6708
|
+
"}",
|
|
6709
|
+
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
6710
|
+
].join("; ")
|
|
6711
|
+
]));
|
|
6712
|
+
if (platform === "linux") {
|
|
6713
|
+
const routes = uniqueLines(await run("ip", [
|
|
6714
|
+
"-o",
|
|
6715
|
+
"-4",
|
|
6716
|
+
"route",
|
|
6717
|
+
"show",
|
|
6718
|
+
"default"
|
|
6719
|
+
])).map((line) => ({
|
|
6720
|
+
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
6721
|
+
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
6722
|
+
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
6723
|
+
return [...new Set(routes.map((route) => route.name))];
|
|
6724
|
+
}
|
|
6725
|
+
if (platform === "darwin") {
|
|
6726
|
+
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
6727
|
+
"-n",
|
|
6728
|
+
"get",
|
|
6729
|
+
"default"
|
|
6730
|
+
]))?.[1];
|
|
6731
|
+
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
6732
|
+
}
|
|
6733
|
+
} catch {}
|
|
6734
|
+
return [];
|
|
6735
|
+
}
|
|
6736
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
6737
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
6738
|
+
const candidates = availableLanNetworks(table);
|
|
6739
|
+
if (requestedAddress !== void 0) {
|
|
6740
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
6741
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
6742
|
+
return match;
|
|
6743
|
+
}
|
|
6744
|
+
if (requestedInterface !== void 0) {
|
|
6745
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
6746
|
+
if (matches.length === 1) return matches[0];
|
|
6747
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
6748
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
6749
|
+
}
|
|
6750
|
+
if (candidates.length === 1) return candidates[0];
|
|
6751
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
6752
|
+
for (const name of preferredInterfaces) {
|
|
6753
|
+
const matches = candidates.filter((candidate) => candidate.name === name);
|
|
6754
|
+
if (matches.length === 1) return matches[0];
|
|
6755
|
+
}
|
|
6756
|
+
const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
|
|
6757
|
+
if (physicalCandidates.length === 1) return physicalCandidates[0];
|
|
6758
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
|
|
6759
|
+
}
|
|
6760
|
+
/**
|
|
6761
|
+
* True when the failure came from LAN selection (the configured network is
|
|
6762
|
+
* gone, nothing is up, or several are) rather than from config/TLS code.
|
|
6763
|
+
* Callers use it to degrade (warn + stay down) instead of failing boot.
|
|
6764
|
+
* Pinned to the selectLanNetwork message contract — keep in sync.
|
|
6765
|
+
*/
|
|
6766
|
+
function isNetworkSelectionError(error) {
|
|
6767
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
6768
|
+
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");
|
|
6769
|
+
}
|
|
6770
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
6771
|
+
const certificate = new X509Certificate(certPem);
|
|
6772
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
6773
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
6774
|
+
format: "der",
|
|
6775
|
+
type: "spki"
|
|
6776
|
+
});
|
|
6777
|
+
const certificatePublic = certificate.publicKey.export({
|
|
6778
|
+
format: "der",
|
|
6779
|
+
type: "spki"
|
|
6780
|
+
});
|
|
6781
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
6782
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
6783
|
+
return certificate;
|
|
6784
|
+
}
|
|
6785
|
+
/** Read an existing managed CA without ever generating a replacement identity. */
|
|
6786
|
+
async function readManagedCa(setup) {
|
|
6787
|
+
const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
6788
|
+
return assertMatchingCa(certPem, keyPem);
|
|
6789
|
+
}
|
|
6790
|
+
async function atomicWrite(file, contents) {
|
|
6791
|
+
const directory = dirname(file);
|
|
6792
|
+
await mkdir(directory, {
|
|
6793
|
+
recursive: true,
|
|
6794
|
+
mode: 448
|
|
6795
|
+
});
|
|
6796
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
6797
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
6798
|
+
await rename(temporary, file);
|
|
6799
|
+
await restrictPrivateFile(file);
|
|
6800
|
+
}
|
|
6801
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
6802
|
+
async function ensureManagedCa(setup, legacy) {
|
|
6803
|
+
let certPem;
|
|
6804
|
+
let keyPem;
|
|
6805
|
+
try {
|
|
6806
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
6807
|
+
} catch (error) {
|
|
6808
|
+
if (error.code !== "ENOENT") throw error;
|
|
6809
|
+
let migrated = false;
|
|
6810
|
+
if (legacy !== void 0) try {
|
|
6811
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
6812
|
+
assertMatchingCa(certPem, keyPem);
|
|
6813
|
+
migrated = true;
|
|
6814
|
+
} catch (legacyError) {
|
|
6815
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
6816
|
+
}
|
|
6817
|
+
if (!migrated) {
|
|
6818
|
+
const now = /* @__PURE__ */ new Date();
|
|
6819
|
+
const notAfter = new Date(now);
|
|
6820
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
6821
|
+
const generated = await generate([{
|
|
6822
|
+
name: "commonName",
|
|
6823
|
+
value: "DeepSeek Harness Mobile CA"
|
|
6824
|
+
}], {
|
|
6825
|
+
keyType: "ec",
|
|
6826
|
+
curve: "P-256",
|
|
6827
|
+
algorithm: "sha256",
|
|
6828
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
6829
|
+
notAfterDate: notAfter,
|
|
6830
|
+
extensions: [{
|
|
6831
|
+
name: "basicConstraints",
|
|
6832
|
+
cA: true,
|
|
6833
|
+
critical: true
|
|
6834
|
+
}, {
|
|
6835
|
+
name: "keyUsage",
|
|
6836
|
+
digitalSignature: true,
|
|
6837
|
+
keyCertSign: true,
|
|
6838
|
+
cRLSign: true,
|
|
6839
|
+
critical: true
|
|
6840
|
+
}]
|
|
6841
|
+
});
|
|
6842
|
+
certPem = generated.cert;
|
|
6843
|
+
keyPem = generated.private;
|
|
6844
|
+
}
|
|
6845
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
6846
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
6847
|
+
}
|
|
6848
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
6849
|
+
await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
|
|
6850
|
+
return assertMatchingCa(certPem, keyPem);
|
|
6851
|
+
}
|
|
6852
|
+
function subjectAltNames(target) {
|
|
6853
|
+
const names = [];
|
|
6854
|
+
for (const ip of target.ipAddresses ?? []) names.push({
|
|
6855
|
+
type: 7,
|
|
6856
|
+
ip
|
|
6857
|
+
});
|
|
6858
|
+
for (const dns of target.dnsNames ?? []) names.push({
|
|
6859
|
+
type: 2,
|
|
6860
|
+
value: dns
|
|
6861
|
+
});
|
|
6862
|
+
if (names.length === 0) throw new Error("managed TLS leaf needs at least one server name");
|
|
6863
|
+
return names;
|
|
6864
|
+
}
|
|
6865
|
+
/**
|
|
6866
|
+
* Sign one server leaf with an existing self-signed CA and install it privately.
|
|
6867
|
+
*
|
|
6868
|
+
* Both the LAN listener and the self-signed FRP ingress use this one path, so a
|
|
6869
|
+
* certificate can never be produced by two divergent code paths. IP entries are
|
|
6870
|
+
* required for console addresses (`type: 7`), DNS entries (`type: 2`) cover named
|
|
6871
|
+
* hosts; the Android client accepts either through its pinned CA.
|
|
6872
|
+
*/
|
|
6873
|
+
async function issueServerCertificate(authority, target, output, lifetimeDays = 397) {
|
|
6874
|
+
const altNames = subjectAltNames(target);
|
|
6875
|
+
await Promise.all([restrictPrivateFile(authority.caCertFile), restrictPrivateFile(authority.caKeyFile)]);
|
|
6876
|
+
const [caCert, caKey] = await Promise.all([readFile(authority.caCertFile, "utf8"), readFile(authority.caKeyFile, "utf8")]);
|
|
6877
|
+
assertMatchingCa(caCert, caKey);
|
|
6878
|
+
const now = /* @__PURE__ */ new Date();
|
|
6879
|
+
const notAfter = new Date(now);
|
|
6880
|
+
notAfter.setDate(notAfter.getDate() + lifetimeDays);
|
|
6881
|
+
const server = await generate([{
|
|
6882
|
+
name: "commonName",
|
|
6883
|
+
value: target.commonName
|
|
6884
|
+
}], {
|
|
6885
|
+
keyType: "ec",
|
|
6886
|
+
curve: "P-256",
|
|
6887
|
+
algorithm: "sha256",
|
|
6888
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
6889
|
+
notAfterDate: notAfter,
|
|
6890
|
+
ca: {
|
|
6891
|
+
cert: caCert,
|
|
6892
|
+
key: caKey
|
|
6893
|
+
},
|
|
6894
|
+
extensions: [
|
|
6895
|
+
{
|
|
6896
|
+
name: "basicConstraints",
|
|
6897
|
+
cA: false,
|
|
6898
|
+
critical: true
|
|
6899
|
+
},
|
|
6900
|
+
{
|
|
6901
|
+
name: "keyUsage",
|
|
6902
|
+
digitalSignature: true,
|
|
6903
|
+
critical: true
|
|
6904
|
+
},
|
|
6905
|
+
{
|
|
6906
|
+
name: "extKeyUsage",
|
|
6907
|
+
serverAuth: true
|
|
6908
|
+
},
|
|
6909
|
+
{
|
|
6910
|
+
name: "subjectAltName",
|
|
6911
|
+
altNames
|
|
6912
|
+
}
|
|
6913
|
+
]
|
|
6914
|
+
});
|
|
6915
|
+
await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
|
|
6916
|
+
}
|
|
6917
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
6918
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
6919
|
+
await issueServerCertificate({
|
|
6920
|
+
caCertFile: setup.tls.caCertFile,
|
|
6921
|
+
caKeyFile: setup.tls.caKeyFile
|
|
6922
|
+
}, {
|
|
6923
|
+
commonName: "DeepSeek Harness Mobile",
|
|
6924
|
+
ipAddresses: [address]
|
|
6925
|
+
}, {
|
|
6926
|
+
certFile: setup.tls.certFile,
|
|
6927
|
+
keyFile: setup.tls.keyFile
|
|
6928
|
+
});
|
|
6929
|
+
}
|
|
6930
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
6931
|
+
async function materializeManagedSetup(setup, table) {
|
|
6932
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
6933
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
6934
|
+
const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
|
|
6935
|
+
return {
|
|
6936
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
6937
|
+
listenHost: network.address,
|
|
6938
|
+
allowedCidrs: [network.cidr],
|
|
6939
|
+
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
6940
|
+
pairingCaFile: setup.tls.caCertFile,
|
|
6941
|
+
tls: {
|
|
6942
|
+
mode: "provided",
|
|
6943
|
+
certFile: setup.tls.certFile,
|
|
6944
|
+
keyFile: setup.tls.keyFile
|
|
6945
|
+
}
|
|
6946
|
+
};
|
|
6947
|
+
}
|
|
6948
|
+
//#endregion
|
|
6949
|
+
//#region src/frp-ingress.ts
|
|
6950
|
+
/** Private ingress directory, always a sibling of the remote device file. */
|
|
6951
|
+
function frpIngressPaths(stateFile) {
|
|
6952
|
+
const directory = join(dirname(stateFile), "ingress");
|
|
6953
|
+
return Object.freeze({
|
|
6954
|
+
directory,
|
|
6955
|
+
caCertFile: join(directory, "ca.pem"),
|
|
6956
|
+
caKeyFile: join(directory, "ca-key.pem"),
|
|
6957
|
+
certFile: join(directory, "server.pem"),
|
|
6958
|
+
keyFile: join(directory, "server-key.pem"),
|
|
6959
|
+
statusFile: join(dirname(stateFile), "ingress-identity.json")
|
|
6960
|
+
});
|
|
6961
|
+
}
|
|
6962
|
+
async function regularFileExists(file) {
|
|
6963
|
+
try {
|
|
6964
|
+
const entry = await lstat(file);
|
|
6965
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
6966
|
+
return true;
|
|
6967
|
+
} catch (error) {
|
|
6968
|
+
if (error.code === "ENOENT") return false;
|
|
6969
|
+
throw error;
|
|
6970
|
+
}
|
|
6971
|
+
}
|
|
6972
|
+
async function readIngressIdentity(file) {
|
|
6973
|
+
if (!await regularFileExists(file)) return void 0;
|
|
6974
|
+
try {
|
|
6975
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
6976
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
|
|
6977
|
+
const record = parsed;
|
|
6978
|
+
if (record.version !== 1 || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
|
|
6979
|
+
return record.caFingerprint;
|
|
6980
|
+
} catch (error) {
|
|
6981
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
6982
|
+
}
|
|
6983
|
+
}
|
|
6984
|
+
async function writeIngressIdentity(file, fingerprint) {
|
|
6985
|
+
const temporary = join(dirname(file), `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
6986
|
+
try {
|
|
6987
|
+
await writeFile(temporary, `${JSON.stringify({
|
|
6988
|
+
version: 1,
|
|
6989
|
+
caFingerprint: fingerprint
|
|
6990
|
+
})}\n`, {
|
|
6991
|
+
flag: "wx",
|
|
6992
|
+
mode: 384
|
|
6993
|
+
});
|
|
6994
|
+
await rename(temporary, file);
|
|
6995
|
+
await restrictPrivateFile(file);
|
|
6996
|
+
} finally {
|
|
6997
|
+
try {
|
|
6998
|
+
await unlink(temporary);
|
|
6999
|
+
} catch (error) {
|
|
7000
|
+
if (error.code !== "ENOENT") throw error;
|
|
7001
|
+
}
|
|
7002
|
+
}
|
|
7003
|
+
}
|
|
7004
|
+
function coversPublicIp(certificate, address) {
|
|
7005
|
+
return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
|
|
7006
|
+
}
|
|
7007
|
+
/**
|
|
7008
|
+
* Materialize the CA and leaf the gateway terminates TLS with.
|
|
7009
|
+
*
|
|
7010
|
+
* The CA remains stable for paired phones; an expired CA must be re-paired, not
|
|
7011
|
+
* replaced silently. The leaf is re-signed when expiring or no longer valid for
|
|
7012
|
+
* the public IPv4, and the running gateway reloads it after a renewal check.
|
|
7013
|
+
*/
|
|
7014
|
+
async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
|
|
7015
|
+
if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
|
|
7016
|
+
const paths = frpIngressPaths(stateFile);
|
|
7017
|
+
try {
|
|
7018
|
+
const directory = await lstat(paths.directory);
|
|
7019
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
|
|
7020
|
+
} catch (error) {
|
|
7021
|
+
if (error.code !== "ENOENT") throw error;
|
|
7022
|
+
}
|
|
7023
|
+
const previousFingerprint = await readIngressIdentity(paths.statusFile);
|
|
7024
|
+
const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
|
|
7025
|
+
regularFileExists(paths.caCertFile),
|
|
7026
|
+
regularFileExists(paths.caKeyFile),
|
|
7027
|
+
regularFileExists(paths.certFile),
|
|
7028
|
+
regularFileExists(paths.keyFile)
|
|
7029
|
+
]);
|
|
7030
|
+
if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
|
|
7031
|
+
if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
|
|
7032
|
+
if (previousFingerprint === void 0) {
|
|
7033
|
+
await mkdir(dirname(paths.statusFile), {
|
|
7034
|
+
recursive: true,
|
|
7035
|
+
mode: 448
|
|
7036
|
+
});
|
|
7037
|
+
try {
|
|
7038
|
+
await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
|
|
7039
|
+
flag: "wx",
|
|
7040
|
+
mode: 384
|
|
7041
|
+
});
|
|
7042
|
+
await restrictPrivateFile(paths.statusFile);
|
|
7043
|
+
} catch (error) {
|
|
7044
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
7045
|
+
}
|
|
7046
|
+
}
|
|
7047
|
+
const caFiles = {
|
|
7048
|
+
mode: "managed",
|
|
7049
|
+
caCertFile: paths.caCertFile,
|
|
7050
|
+
caKeyFile: paths.caKeyFile,
|
|
7051
|
+
certFile: paths.certFile,
|
|
7052
|
+
keyFile: paths.keyFile
|
|
7053
|
+
};
|
|
7054
|
+
let ca;
|
|
7055
|
+
try {
|
|
7056
|
+
ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
|
|
7057
|
+
} catch (error) {
|
|
7058
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
7059
|
+
}
|
|
7060
|
+
const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
7061
|
+
if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
7062
|
+
if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
7063
|
+
if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
|
|
7064
|
+
const publicHost = new URL(settings.publicOrigin).hostname;
|
|
7065
|
+
let leaf;
|
|
7066
|
+
try {
|
|
7067
|
+
leaf = new X509Certificate(await readFile(paths.certFile));
|
|
7068
|
+
} catch {
|
|
7069
|
+
leaf = void 0;
|
|
7070
|
+
}
|
|
7071
|
+
if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
|
|
7072
|
+
await issueServerCertificate({
|
|
7073
|
+
caCertFile: paths.caCertFile,
|
|
7074
|
+
caKeyFile: paths.caKeyFile
|
|
7075
|
+
}, {
|
|
7076
|
+
commonName: "DSH Mobile FRP ingress",
|
|
7077
|
+
ipAddresses: [publicHost]
|
|
7078
|
+
}, {
|
|
7079
|
+
certFile: paths.certFile,
|
|
7080
|
+
keyFile: paths.keyFile
|
|
7081
|
+
});
|
|
7082
|
+
leaf = new X509Certificate(await readFile(paths.certFile));
|
|
7083
|
+
}
|
|
7084
|
+
const signed = leaf ?? (() => {
|
|
7085
|
+
throw new Error("frp_attach_cert_unknown");
|
|
7086
|
+
})();
|
|
7087
|
+
return Object.freeze({
|
|
7088
|
+
paths,
|
|
7089
|
+
ca,
|
|
7090
|
+
leaf: signed,
|
|
7091
|
+
caFingerprint,
|
|
7092
|
+
status: await readCertificateRenewal(paths.certFile, now)
|
|
7093
|
+
});
|
|
7094
|
+
}
|
|
7095
|
+
/**
|
|
7096
|
+
* Build the read-only self-check payload shown in the desktop panel.
|
|
7097
|
+
*
|
|
7098
|
+
* No secret ever leaves this function: the CA is reported by fingerprint only,
|
|
7099
|
+
* and the token is never read.
|
|
7100
|
+
*/
|
|
7101
|
+
async function frpIngressSelfCheck(settings, stateFile) {
|
|
7102
|
+
const paths = frpIngressPaths(stateFile);
|
|
7103
|
+
const base = {
|
|
7104
|
+
mode: resolveFrpMode(settings),
|
|
7105
|
+
entryTls: resolveFrpEntryTls(settings),
|
|
7106
|
+
vhostHttpPort: resolveFrpVhostHttpPort(settings),
|
|
7107
|
+
publicPort: resolveFrpPublicPort(settings),
|
|
7108
|
+
publicOrigin: settings.publicOrigin,
|
|
7109
|
+
entryOrigin: frpEntryOrigin(settings),
|
|
7110
|
+
serverAddress: settings.serverAddress,
|
|
7111
|
+
serverPort: settings.serverPort,
|
|
7112
|
+
inbound: {
|
|
7113
|
+
listenHost: "127.0.0.1",
|
|
7114
|
+
allowedCidrs: Object.freeze(["127.0.0.0/8"])
|
|
7115
|
+
}
|
|
7116
|
+
};
|
|
7117
|
+
if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
|
|
7118
|
+
let caFingerprint;
|
|
7119
|
+
try {
|
|
7120
|
+
caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
|
|
7121
|
+
} catch {
|
|
7122
|
+
caFingerprint = void 0;
|
|
7123
|
+
}
|
|
7124
|
+
return Object.freeze({
|
|
7125
|
+
...base,
|
|
7126
|
+
...caFingerprint === void 0 ? {} : { caFingerprint },
|
|
7127
|
+
certificate: await readCertificateRenewal(paths.certFile),
|
|
7128
|
+
caCertificate: await readCertificateRenewal(paths.caCertFile)
|
|
7129
|
+
});
|
|
7130
|
+
}
|
|
7131
|
+
/** Remove only the five files owned by the local self-signed FRP entry. */
|
|
7132
|
+
async function purgeFrpIngressCertificates(stateFile) {
|
|
7133
|
+
const paths = frpIngressPaths(stateFile);
|
|
7134
|
+
try {
|
|
7135
|
+
const marker = await lstat(paths.statusFile);
|
|
7136
|
+
if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
7137
|
+
await unlink(paths.statusFile);
|
|
7138
|
+
} catch (error) {
|
|
7139
|
+
if (error.code !== "ENOENT") throw error;
|
|
7140
|
+
}
|
|
7141
|
+
let directory;
|
|
7142
|
+
try {
|
|
7143
|
+
directory = await lstat(paths.directory);
|
|
7144
|
+
} catch (error) {
|
|
7145
|
+
if (error.code === "ENOENT") return;
|
|
7146
|
+
throw error;
|
|
7147
|
+
}
|
|
7148
|
+
if (directory.isSymbolicLink()) {
|
|
7149
|
+
await unlink(paths.directory);
|
|
7150
|
+
return;
|
|
7151
|
+
}
|
|
7152
|
+
if (!directory.isDirectory()) throw new Error("frp_ingress_directory_invalid");
|
|
7153
|
+
for (const file of [
|
|
7154
|
+
paths.caCertFile,
|
|
7155
|
+
paths.caKeyFile,
|
|
7156
|
+
paths.certFile,
|
|
7157
|
+
paths.keyFile
|
|
7158
|
+
]) {
|
|
7159
|
+
let entry;
|
|
7160
|
+
try {
|
|
7161
|
+
entry = await lstat(file);
|
|
7162
|
+
} catch (error) {
|
|
7163
|
+
if (error.code === "ENOENT") continue;
|
|
7164
|
+
throw error;
|
|
7165
|
+
}
|
|
7166
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
7167
|
+
await unlink(file);
|
|
7168
|
+
}
|
|
7169
|
+
try {
|
|
7170
|
+
await rmdir(paths.directory);
|
|
7171
|
+
} catch (error) {
|
|
7172
|
+
if (error.code === "ENOENT") return;
|
|
7173
|
+
throw error;
|
|
7174
|
+
}
|
|
7175
|
+
}
|
|
7176
|
+
//#endregion
|
|
7177
|
+
//#region src/remote.ts
|
|
7178
|
+
const REMOTE_PROVIDERS = Object.freeze([
|
|
7179
|
+
"tailscale",
|
|
7180
|
+
"cpolar",
|
|
7181
|
+
"cloudflared",
|
|
7182
|
+
"frp",
|
|
7183
|
+
"origin"
|
|
7184
|
+
]);
|
|
7185
|
+
function aggregateErrors(errors, message) {
|
|
7186
|
+
if (errors.length === 0) return void 0;
|
|
7187
|
+
if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
|
|
7188
|
+
return new AggregateError(errors, message);
|
|
7189
|
+
}
|
|
7190
|
+
/** Settle independent remote cleanup work before reporting any collected failure. */
|
|
7191
|
+
async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
|
|
7192
|
+
const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
|
|
7193
|
+
if (failure !== void 0) throw failure;
|
|
7194
|
+
}
|
|
7195
|
+
/**
|
|
7196
|
+
* Serialize all provider mutations and preserve the single-provider invariant.
|
|
7197
|
+
* Operations read the selected controller only after reaching the front of the queue.
|
|
7198
|
+
*/
|
|
7199
|
+
var RemoteProviderCoordinator = class {
|
|
7200
|
+
controllers;
|
|
7201
|
+
store;
|
|
7202
|
+
selectedValue;
|
|
7203
|
+
queue = Promise.resolve();
|
|
7204
|
+
constructor(selected, controllers, store) {
|
|
7205
|
+
this.controllers = controllers;
|
|
7206
|
+
this.store = store;
|
|
7207
|
+
this.selectedValue = selected;
|
|
7208
|
+
}
|
|
7209
|
+
/** Return the durable provider currently selected by the desktop UI. */
|
|
7210
|
+
get selected() {
|
|
7211
|
+
return this.selectedValue;
|
|
7212
|
+
}
|
|
7213
|
+
/** Return the controller selected when this method is called. */
|
|
7214
|
+
controller() {
|
|
7215
|
+
return this.controllers[this.selectedValue];
|
|
7216
|
+
}
|
|
7217
|
+
/** Run a provider-owned mutation after all earlier provider work settles. */
|
|
7218
|
+
mutate(operation) {
|
|
7219
|
+
return this.enqueue(() => operation(this.controller()));
|
|
7220
|
+
}
|
|
7221
|
+
/** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
|
|
7222
|
+
select(provider) {
|
|
7223
|
+
return this.enqueue(async () => {
|
|
7224
|
+
if (provider === this.selectedValue) return;
|
|
7225
|
+
const previous = this.controllers[this.selectedValue];
|
|
7226
|
+
const restore = previous.status().enabled;
|
|
7227
|
+
if (restore) await previous.setEnabled(false);
|
|
7228
|
+
try {
|
|
7229
|
+
await this.store.save({
|
|
7230
|
+
version: 1,
|
|
7231
|
+
provider
|
|
7232
|
+
});
|
|
7233
|
+
this.selectedValue = provider;
|
|
7234
|
+
} catch (error) {
|
|
7235
|
+
if (restore) try {
|
|
7236
|
+
await previous.setEnabled(true);
|
|
7237
|
+
} catch (restoreError) {
|
|
7238
|
+
throw new AggregateError([error, restoreError], "remote provider selection rollback failed");
|
|
7239
|
+
}
|
|
7240
|
+
throw error;
|
|
7241
|
+
}
|
|
7242
|
+
});
|
|
7243
|
+
}
|
|
7244
|
+
enqueue(operation) {
|
|
7245
|
+
const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
|
|
7246
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
7247
|
+
return task;
|
|
7248
|
+
}
|
|
5850
7249
|
async runAndEnforce(operation) {
|
|
5851
7250
|
let value;
|
|
5852
7251
|
let operationError;
|
|
@@ -5984,6 +7383,7 @@ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
|
|
|
5984
7383
|
const DISCOVERY_RETRY_MS = 1e3;
|
|
5985
7384
|
const MAX_DISCOVERY_BYTES = 16384;
|
|
5986
7385
|
const VHOST_PROBE_TIMEOUT_MS = 1500;
|
|
7386
|
+
const INGRESS_CERT_CHECK_MS = 432e5;
|
|
5987
7387
|
function publicStatus$3(status) {
|
|
5988
7388
|
return Object.freeze({
|
|
5989
7389
|
enabled: status.enabled,
|
|
@@ -6078,38 +7478,108 @@ async function boundedResponseBytes(response) {
|
|
|
6078
7478
|
}
|
|
6079
7479
|
return bytes;
|
|
6080
7480
|
}
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
const
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
7481
|
+
/** Web view over an HTTPS response so the byte cap applies to both paths alike. */
|
|
7482
|
+
function discoveryResponse(message) {
|
|
7483
|
+
const headers = new Headers();
|
|
7484
|
+
const declaredLength = message.headers["content-length"];
|
|
7485
|
+
if (declaredLength !== void 0) headers.set("content-length", declaredLength);
|
|
7486
|
+
return new Response(Readable.toWeb(message), {
|
|
7487
|
+
status: message.statusCode ?? 200,
|
|
7488
|
+
headers
|
|
7489
|
+
});
|
|
7490
|
+
}
|
|
7491
|
+
/**
|
|
7492
|
+
* Perform one discovery request against the public entry.
|
|
7493
|
+
*
|
|
7494
|
+
* `node:https` carries this request instead of the global `fetch` for a single
|
|
7495
|
+
* reason: only here can the trust anchor be stated. The self-signed entry
|
|
7496
|
+
* presents a leaf the plugin's own ingress CA signed, so the default chain
|
|
7497
|
+
* always fails there (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`) even though the
|
|
7498
|
+
* transport itself is healthy. The anchor is *added* to the trust store — never
|
|
7499
|
+
* `rejectUnauthorized: false` — so the peer must still prove its identity, and
|
|
7500
|
+
* the verified name stays the host of the origin, which is the name the app
|
|
7501
|
+
* dials as well (Node matches IP literals against `IP Address:` SANs).
|
|
7502
|
+
*
|
|
7503
|
+
* A non-200 answer resolves `undefined` for retry; discovery requires a body.
|
|
7504
|
+
* transport, TLS, and timeout failures reject so the caller keeps retrying.
|
|
7505
|
+
* The request stays cancellable until the bounded response body has completed.
|
|
7506
|
+
*/
|
|
7507
|
+
function requestDiscovery(target, signal) {
|
|
7508
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_aborted"));
|
|
7509
|
+
const url = new URL(`${target.origin}/mobile-access/discovery`);
|
|
7510
|
+
if (url.protocol !== "https:" || url.hostname === "") return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_invalid"));
|
|
7511
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
7512
|
+
let settled = false;
|
|
7513
|
+
let request;
|
|
7514
|
+
const release = () => {
|
|
7515
|
+
clearTimeout(timeout);
|
|
7516
|
+
signal.removeEventListener("abort", onAbort);
|
|
7517
|
+
};
|
|
7518
|
+
const fail = (error) => {
|
|
7519
|
+
if (settled) return;
|
|
7520
|
+
settled = true;
|
|
7521
|
+
release();
|
|
7522
|
+
request?.destroy();
|
|
7523
|
+
rejectRequest(error);
|
|
7524
|
+
};
|
|
7525
|
+
const succeed = (value) => {
|
|
7526
|
+
if (settled) return;
|
|
7527
|
+
settled = true;
|
|
7528
|
+
release();
|
|
7529
|
+
resolveRequest(value);
|
|
7530
|
+
};
|
|
7531
|
+
const onAbort = () => {
|
|
7532
|
+
fail(/* @__PURE__ */ new Error("frp_discovery_aborted"));
|
|
7533
|
+
};
|
|
7534
|
+
const timeout = setTimeout(() => {
|
|
7535
|
+
fail(/* @__PURE__ */ new Error("frp_discovery_timeout"));
|
|
7536
|
+
}, DISCOVERY_REQUEST_TIMEOUT_MS);
|
|
7537
|
+
timeout.unref();
|
|
7538
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7539
|
+
request = request$1({
|
|
7540
|
+
host: url.hostname,
|
|
7541
|
+
port: url.port === "" ? 443 : Number(url.port),
|
|
7542
|
+
path: url.pathname,
|
|
6091
7543
|
method: "GET",
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
7544
|
+
headers: { accept: "application/json" },
|
|
7545
|
+
...target.trustAnchorPem === void 0 ? {} : { ca: target.trustAnchorPem },
|
|
7546
|
+
agent: false
|
|
7547
|
+
}, (message) => {
|
|
7548
|
+
if ((message.statusCode ?? 0) !== 200) {
|
|
7549
|
+
succeed(void 0);
|
|
7550
|
+
message.destroy();
|
|
7551
|
+
return;
|
|
7552
|
+
}
|
|
7553
|
+
boundedResponseBytes(discoveryResponse(message)).then(succeed, fail);
|
|
6096
7554
|
});
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
7555
|
+
request.once("error", (error) => {
|
|
7556
|
+
fail(error instanceof Error ? error : /* @__PURE__ */ new Error("frp_discovery_request_failed"));
|
|
7557
|
+
});
|
|
7558
|
+
request.end();
|
|
7559
|
+
});
|
|
7560
|
+
}
|
|
7561
|
+
/**
|
|
7562
|
+
* Read the public discovery advertisement and compare it with the expected
|
|
7563
|
+
* identity.
|
|
7564
|
+
*
|
|
7565
|
+
* `frp_discovery_invalid` (malformed body) and `frp_discovery_mismatch` (the
|
|
7566
|
+
* entry advertises another identity) stay immediate failures; transport, TLS,
|
|
7567
|
+
* and timeout problems reject so the caller retries until its deadline.
|
|
7568
|
+
*/
|
|
7569
|
+
async function defaultProbeDiscovery(target, expectedInstanceId, signal) {
|
|
7570
|
+
const bytes = await requestDiscovery(target, signal);
|
|
7571
|
+
if (bytes === void 0) return false;
|
|
7572
|
+
let value;
|
|
7573
|
+
try {
|
|
7574
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
7575
|
+
} catch {
|
|
7576
|
+
throw new Error("frp_discovery_invalid");
|
|
6112
7577
|
}
|
|
7578
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
|
|
7579
|
+
const actual = value.instanceId;
|
|
7580
|
+
if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
|
|
7581
|
+
if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
|
|
7582
|
+
return true;
|
|
6113
7583
|
}
|
|
6114
7584
|
/** Owns frpc, its generation-specific configuration, and the remote gateway. */
|
|
6115
7585
|
var FrpController = class {
|
|
@@ -6126,10 +7596,12 @@ var FrpController = class {
|
|
|
6126
7596
|
});
|
|
6127
7597
|
queue = Promise.resolve();
|
|
6128
7598
|
startupAbort;
|
|
7599
|
+
ingressCertificateTimer;
|
|
6129
7600
|
constructor(options) {
|
|
6130
7601
|
this.options = options;
|
|
6131
7602
|
if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
|
|
6132
7603
|
if (!/^[a-f0-9]{64}$/u.test(options.instanceId)) throw new Error("FRP instance ID is invalid");
|
|
7604
|
+
if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
|
|
6133
7605
|
}
|
|
6134
7606
|
/** Restore the remembered FRP switch without changing LAN or other providers. */
|
|
6135
7607
|
async initialize() {
|
|
@@ -6249,41 +7721,45 @@ var FrpController = class {
|
|
|
6249
7721
|
});
|
|
6250
7722
|
return;
|
|
6251
7723
|
}
|
|
7724
|
+
const entryOrigin = frpEntryOrigin(settings);
|
|
6252
7725
|
this.publish({
|
|
6253
7726
|
enabled: true,
|
|
6254
7727
|
state: "starting",
|
|
6255
|
-
origin:
|
|
7728
|
+
origin: entryOrigin
|
|
6256
7729
|
});
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
7730
|
+
if (!isFrpSelfSignedIngress(settings)) {
|
|
7731
|
+
let exposed;
|
|
7732
|
+
try {
|
|
7733
|
+
exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, resolveFrpVhostHttpPort(settings));
|
|
7734
|
+
} catch {
|
|
7735
|
+
this.publish({
|
|
7736
|
+
enabled: true,
|
|
7737
|
+
state: "error",
|
|
7738
|
+
origin: entryOrigin,
|
|
7739
|
+
errorCode: "frp_vhost_probe_failed"
|
|
7740
|
+
});
|
|
7741
|
+
return;
|
|
7742
|
+
}
|
|
7743
|
+
if (exposed) {
|
|
7744
|
+
this.publish({
|
|
7745
|
+
enabled: true,
|
|
7746
|
+
state: "error",
|
|
7747
|
+
origin: entryOrigin,
|
|
7748
|
+
errorCode: "frp_vhost_publicly_reachable"
|
|
7749
|
+
});
|
|
7750
|
+
return;
|
|
7751
|
+
}
|
|
6277
7752
|
}
|
|
6278
7753
|
let gateway;
|
|
6279
7754
|
try {
|
|
6280
|
-
gateway = await this.options.createGateway(settings.publicOrigin);
|
|
6281
|
-
} catch {
|
|
7755
|
+
gateway = await this.options.createGateway(settings.publicOrigin, settings);
|
|
7756
|
+
} catch (error) {
|
|
7757
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "gateway_start_failed";
|
|
6282
7758
|
this.publish({
|
|
6283
7759
|
enabled: true,
|
|
6284
7760
|
state: "error",
|
|
6285
|
-
origin:
|
|
6286
|
-
errorCode:
|
|
7761
|
+
origin: entryOrigin,
|
|
7762
|
+
errorCode: code
|
|
6287
7763
|
});
|
|
6288
7764
|
return;
|
|
6289
7765
|
}
|
|
@@ -6322,26 +7798,66 @@ var FrpController = class {
|
|
|
6322
7798
|
this.publish({
|
|
6323
7799
|
enabled: true,
|
|
6324
7800
|
state: "connecting",
|
|
6325
|
-
origin:
|
|
7801
|
+
origin: entryOrigin
|
|
6326
7802
|
});
|
|
7803
|
+
let check;
|
|
7804
|
+
try {
|
|
7805
|
+
check = await this.discoveryCheck(settings);
|
|
7806
|
+
} catch (error) {
|
|
7807
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_ca_invalid";
|
|
7808
|
+
await this.failGeneration(generation, code);
|
|
7809
|
+
return;
|
|
7810
|
+
}
|
|
7811
|
+
if (generation !== this.generation || !this.enabled) return;
|
|
6327
7812
|
const controller = new AbortController();
|
|
6328
7813
|
this.startupAbort = controller;
|
|
6329
|
-
this.waitForDiscovery(generation,
|
|
7814
|
+
this.waitForDiscovery(generation, check, gateway.config.instanceId, settings, gateway, controller.signal);
|
|
7815
|
+
}
|
|
7816
|
+
/**
|
|
7817
|
+
* Derive the self-check target and trust anchor for the effective entry.
|
|
7818
|
+
*
|
|
7819
|
+
* The public-CA entry is reached on the saved origin itself: Caddy terminates
|
|
7820
|
+
* TLS on 443 with a publicly trusted certificate, so the probe must keep the
|
|
7821
|
+
* system trust store and the origin URL untouched. The self-signed entry is a
|
|
7822
|
+
* raw TCP passthrough on `publicPort` whose leaf chains to the ingress CA the
|
|
7823
|
+
* app pins from `pairingCaFile`; probing `publicOrigin` there would knock on
|
|
7824
|
+
* 443 — where nothing listens — with a chain the system cannot verify. Both
|
|
7825
|
+
* facts used to be implicit, and together they kept the channel out of `ready`
|
|
7826
|
+
* until `frp_start_timeout`.
|
|
7827
|
+
*/
|
|
7828
|
+
async discoveryCheck(settings) {
|
|
7829
|
+
if (!isFrpSelfSignedIngress(settings)) return {
|
|
7830
|
+
publicOrigin: settings.publicOrigin,
|
|
7831
|
+
targetOrigin: settings.publicOrigin
|
|
7832
|
+
};
|
|
7833
|
+
const targetOrigin = frpEntryOrigin(settings);
|
|
7834
|
+
const trustAnchorPem = await this.options.resolveDiscoveryTrustAnchor?.(settings);
|
|
7835
|
+
if (this.options.resolveDiscoveryTrustAnchor !== void 0 && trustAnchorPem === void 0) throw new Error("frp_ingress_ca_invalid");
|
|
7836
|
+
return Object.freeze({
|
|
7837
|
+
publicOrigin: targetOrigin,
|
|
7838
|
+
targetOrigin,
|
|
7839
|
+
...trustAnchorPem === void 0 ? {} : { trustAnchorPem }
|
|
7840
|
+
});
|
|
6330
7841
|
}
|
|
6331
|
-
async waitForDiscovery(generation,
|
|
7842
|
+
async waitForDiscovery(generation, check, advertisedInstanceId, settings, gateway, signal) {
|
|
6332
7843
|
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
|
|
6333
7844
|
const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
|
|
7845
|
+
const target = Object.freeze({
|
|
7846
|
+
origin: check.targetOrigin,
|
|
7847
|
+
...check.trustAnchorPem === void 0 ? {} : { trustAnchorPem: check.trustAnchorPem }
|
|
7848
|
+
});
|
|
6334
7849
|
while (!signal.aborted && Date.now() < deadline) {
|
|
6335
7850
|
try {
|
|
6336
|
-
if (await probe(
|
|
7851
|
+
if (await probe(target, advertisedInstanceId, signal)) {
|
|
6337
7852
|
await this.enqueue(async () => {
|
|
6338
7853
|
if (generation !== this.generation || signal.aborted || !this.enabled) return;
|
|
6339
7854
|
this.startupAbort = void 0;
|
|
6340
7855
|
this.publish({
|
|
6341
7856
|
enabled: true,
|
|
6342
7857
|
state: "ready",
|
|
6343
|
-
origin
|
|
7858
|
+
origin: check.publicOrigin
|
|
6344
7859
|
});
|
|
7860
|
+
this.scheduleIngressCertificateCheck(generation, settings, gateway);
|
|
6345
7861
|
});
|
|
6346
7862
|
return;
|
|
6347
7863
|
}
|
|
@@ -6368,6 +7884,33 @@ var FrpController = class {
|
|
|
6368
7884
|
}
|
|
6369
7885
|
if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
|
|
6370
7886
|
}
|
|
7887
|
+
scheduleIngressCertificateCheck(generation, settings, gateway) {
|
|
7888
|
+
const maintain = this.options.maintainIngressCertificate;
|
|
7889
|
+
if (!isFrpSelfSignedIngress(settings) || maintain === void 0) return;
|
|
7890
|
+
const timer = setTimeout(() => {
|
|
7891
|
+
if (this.ingressCertificateTimer !== timer) return;
|
|
7892
|
+
this.ingressCertificateTimer = void 0;
|
|
7893
|
+
this.enqueue(async () => {
|
|
7894
|
+
if (generation !== this.generation || !this.enabled || this.disposed || this.gatewayValue !== gateway) return;
|
|
7895
|
+
try {
|
|
7896
|
+
await maintain(settings, gateway);
|
|
7897
|
+
} catch (error) {
|
|
7898
|
+
const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_renewal_failed";
|
|
7899
|
+
await this.failGeneration(generation, code);
|
|
7900
|
+
return;
|
|
7901
|
+
}
|
|
7902
|
+
if (generation === this.generation && this.enabled && !this.disposed && this.gatewayValue === gateway) this.scheduleIngressCertificateCheck(generation, settings, gateway);
|
|
7903
|
+
}).catch(() => {
|
|
7904
|
+
if (generation === this.generation && this.enabled && !this.disposed) this.publish({
|
|
7905
|
+
enabled: true,
|
|
7906
|
+
state: "error",
|
|
7907
|
+
errorCode: "frp_ingress_renewal_failed"
|
|
7908
|
+
});
|
|
7909
|
+
});
|
|
7910
|
+
}, this.options.ingressCertificateCheckMs ?? INGRESS_CERT_CHECK_MS);
|
|
7911
|
+
timer.unref();
|
|
7912
|
+
this.ingressCertificateTimer = timer;
|
|
7913
|
+
}
|
|
6371
7914
|
async failGeneration(generation, code) {
|
|
6372
7915
|
if (generation !== this.generation) return;
|
|
6373
7916
|
await this.stopProcessAndGateway();
|
|
@@ -6382,6 +7925,8 @@ var FrpController = class {
|
|
|
6382
7925
|
await this.stopProcessAndGateway();
|
|
6383
7926
|
}
|
|
6384
7927
|
async stopProcessAndGateway() {
|
|
7928
|
+
clearTimeout(this.ingressCertificateTimer);
|
|
7929
|
+
this.ingressCertificateTimer = void 0;
|
|
6385
7930
|
this.startupAbort?.abort();
|
|
6386
7931
|
this.startupAbort = void 0;
|
|
6387
7932
|
const child = this.child;
|
|
@@ -7962,21 +9507,212 @@ function defaultFirewallProbe(platform = process.platform) {
|
|
|
7962
9507
|
}
|
|
7963
9508
|
};
|
|
7964
9509
|
}
|
|
7965
|
-
/**
|
|
7966
|
-
function remoteDiagnosticTimeoutMs(
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
9510
|
+
/** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
|
|
9511
|
+
function remoteDiagnosticTimeoutMs(_origin) {
|
|
9512
|
+
return 6e3;
|
|
9513
|
+
}
|
|
9514
|
+
const TUNNEL_HEADER_LIMIT_BYTES = 65536;
|
|
9515
|
+
function proxyExcludedByNoProxy(target, raw) {
|
|
9516
|
+
const text = raw.trim().toLowerCase();
|
|
9517
|
+
if (text === "") return false;
|
|
9518
|
+
const hostname = target.hostname.toLowerCase();
|
|
9519
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
9520
|
+
const targetPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
|
|
9521
|
+
for (const entry of text.split(/[\s,]+/)) {
|
|
9522
|
+
let value = entry;
|
|
9523
|
+
if (value === "") continue;
|
|
9524
|
+
if (value === "*") return true;
|
|
9525
|
+
if (value.includes("/")) continue;
|
|
9526
|
+
let port;
|
|
9527
|
+
if (value.startsWith("[")) {
|
|
9528
|
+
const bracket = value.indexOf("]");
|
|
9529
|
+
if (bracket < 0) continue;
|
|
9530
|
+
const tail = value.slice(bracket + 1);
|
|
9531
|
+
if (tail !== "") {
|
|
9532
|
+
if (!/^:\d+$/u.test(tail)) continue;
|
|
9533
|
+
port = Number(tail.slice(1));
|
|
9534
|
+
}
|
|
9535
|
+
value = value.slice(1, bracket);
|
|
9536
|
+
} else if ((value.match(/:/gu) ?? []).length === 1) {
|
|
9537
|
+
const separator = value.lastIndexOf(":");
|
|
9538
|
+
const suffix = value.slice(separator + 1);
|
|
9539
|
+
if (!/^\d+$/u.test(suffix)) continue;
|
|
9540
|
+
port = Number(suffix);
|
|
9541
|
+
value = value.slice(0, separator);
|
|
9542
|
+
}
|
|
9543
|
+
if (port !== void 0 && port !== targetPort) continue;
|
|
9544
|
+
if (value.startsWith("*.")) value = value.slice(2);
|
|
9545
|
+
else if (value.startsWith(".")) value = value.slice(1);
|
|
9546
|
+
if (value === "") continue;
|
|
9547
|
+
if (host === value || host.endsWith(`.${value}`)) return true;
|
|
9548
|
+
}
|
|
9549
|
+
return false;
|
|
9550
|
+
}
|
|
9551
|
+
/**
|
|
9552
|
+
* Resolve the relay egress configured in the environment for `origin`.
|
|
9553
|
+
*
|
|
9554
|
+
* Honors `HTTPS_PROXY` (then `ALL_PROXY`, then the generic `HTTP_PROXY`) for an
|
|
9555
|
+
* HTTPS target, and `http_proxy`/`HTTP_PROXY`/`ALL_PROXY` for a plain one, unless
|
|
9556
|
+
* `NO_PROXY`/`no_proxy` covers the host. Only a plain `http://<host>:<port>`
|
|
9557
|
+
* relay is usable: SOCKS or a TLS-to-proxy configuration would need a different
|
|
9558
|
+
* tunnel, so absent that the probe keeps its direct semantics.
|
|
9559
|
+
*/
|
|
9560
|
+
function configuredProxyFor(origin, env = process.env) {
|
|
9561
|
+
let target;
|
|
9562
|
+
try {
|
|
9563
|
+
target = new URL(origin);
|
|
9564
|
+
} catch {
|
|
9565
|
+
return;
|
|
9566
|
+
}
|
|
9567
|
+
if (proxyExcludedByNoProxy(target, env.NO_PROXY ?? env.no_proxy ?? "")) return void 0;
|
|
9568
|
+
const candidates = target.protocol === "https:" ? [
|
|
9569
|
+
env.https_proxy ?? env.HTTPS_PROXY,
|
|
9570
|
+
env.all_proxy ?? env.ALL_PROXY,
|
|
9571
|
+
env.http_proxy ?? env.HTTP_PROXY
|
|
9572
|
+
] : [env.http_proxy ?? env.HTTP_PROXY, env.all_proxy ?? env.ALL_PROXY];
|
|
9573
|
+
for (const candidate of candidates) {
|
|
9574
|
+
if (typeof candidate !== "string" || candidate.trim() === "") continue;
|
|
9575
|
+
try {
|
|
9576
|
+
const proxy = new URL(candidate.trim());
|
|
9577
|
+
if (proxy.protocol !== "http:" || proxy.hostname === "") continue;
|
|
9578
|
+
return proxy;
|
|
9579
|
+
} catch {
|
|
9580
|
+
continue;
|
|
9581
|
+
}
|
|
9582
|
+
}
|
|
9583
|
+
}
|
|
9584
|
+
/** One reachability probe through an HTTP relay: CONNECT, TLS, then a status line. */
|
|
9585
|
+
async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnosticTimeoutMs(target.origin)) {
|
|
9586
|
+
const started = performance.now();
|
|
9587
|
+
return new Promise((resolve, reject) => {
|
|
9588
|
+
let settled = false;
|
|
9589
|
+
let hopRequest;
|
|
9590
|
+
let rawSocket;
|
|
9591
|
+
let secureSocket;
|
|
9592
|
+
let timer;
|
|
9593
|
+
const finish = (outcome) => {
|
|
9594
|
+
if (settled) return;
|
|
9595
|
+
settled = true;
|
|
9596
|
+
clearTimeout(timer);
|
|
9597
|
+
secureSocket?.destroy();
|
|
9598
|
+
rawSocket?.end();
|
|
9599
|
+
rawSocket?.destroy();
|
|
9600
|
+
hopRequest?.destroy();
|
|
9601
|
+
if (outcome.error !== void 0) reject(outcome.error);
|
|
9602
|
+
else resolve(outcome.value ?? {
|
|
9603
|
+
status: 0,
|
|
9604
|
+
latencyMs: 0
|
|
9605
|
+
});
|
|
9606
|
+
};
|
|
9607
|
+
timer = setTimeout(() => {
|
|
9608
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_timeout") });
|
|
9609
|
+
}, Math.max(1, budgetMs));
|
|
9610
|
+
timer.unref();
|
|
9611
|
+
const hopPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
|
|
9612
|
+
const hopPath = `${target.hostname}:${hopPort}`;
|
|
9613
|
+
const headers = {
|
|
9614
|
+
host: hopPath,
|
|
9615
|
+
"proxy-connection": "keep-alive"
|
|
9616
|
+
};
|
|
9617
|
+
if (proxy.username !== "") headers["proxy-authorization"] = `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`, "utf8").toString("base64")}`;
|
|
9618
|
+
hopRequest = request({
|
|
9619
|
+
host: proxy.hostname,
|
|
9620
|
+
port: proxy.port === "" ? 80 : Number(proxy.port),
|
|
9621
|
+
method: "CONNECT",
|
|
9622
|
+
path: hopPath,
|
|
9623
|
+
headers,
|
|
9624
|
+
agent: false
|
|
9625
|
+
});
|
|
9626
|
+
hopRequest.once("connect", (response, socket) => {
|
|
9627
|
+
if (settled) {
|
|
9628
|
+
socket.destroy();
|
|
9629
|
+
return;
|
|
9630
|
+
}
|
|
9631
|
+
rawSocket = socket;
|
|
9632
|
+
if (response.statusCode !== 200) {
|
|
9633
|
+
finish({ error: /* @__PURE__ */ new Error(`proxy_tunnel_rejected_${response.statusCode ?? "unknown"}`) });
|
|
9634
|
+
return;
|
|
9635
|
+
}
|
|
9636
|
+
const secure = connect$1({
|
|
9637
|
+
socket,
|
|
9638
|
+
servername: target.hostname,
|
|
9639
|
+
rejectUnauthorized: true
|
|
9640
|
+
});
|
|
9641
|
+
secureSocket = secure;
|
|
9642
|
+
secure.once("secureConnect", () => {
|
|
9643
|
+
if (settled) return;
|
|
9644
|
+
secure.write([
|
|
9645
|
+
`GET ${target.pathname}${target.search} HTTP/1.1`,
|
|
9646
|
+
`Host: ${target.host}`,
|
|
9647
|
+
"User-Agent: dsh-mobile-diagnostics",
|
|
9648
|
+
"Accept-Encoding: identity",
|
|
9649
|
+
"Connection: close",
|
|
9650
|
+
"",
|
|
9651
|
+
""
|
|
9652
|
+
].join("\r\n"));
|
|
9653
|
+
});
|
|
9654
|
+
let buffered = Buffer.alloc(0);
|
|
9655
|
+
secure.on("data", (chunk) => {
|
|
9656
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
9657
|
+
if (buffered.byteLength > TUNNEL_HEADER_LIMIT_BYTES) {
|
|
9658
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_oversized_response") });
|
|
9659
|
+
return;
|
|
9660
|
+
}
|
|
9661
|
+
const end = buffered.indexOf("\r\n\r\n");
|
|
9662
|
+
if (end < 0) return;
|
|
9663
|
+
const statusLine = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/u.exec(buffered.subarray(0, end).toString("latin1"))?.[1];
|
|
9664
|
+
const status = Number.parseInt(statusLine ?? "", 10);
|
|
9665
|
+
if (!Number.isSafeInteger(status) || status < 100) {
|
|
9666
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_invalid_response") });
|
|
9667
|
+
return;
|
|
9668
|
+
}
|
|
9669
|
+
finish({ value: {
|
|
9670
|
+
status,
|
|
9671
|
+
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
9672
|
+
} });
|
|
9673
|
+
});
|
|
9674
|
+
secure.once("error", (error) => {
|
|
9675
|
+
finish({ error });
|
|
9676
|
+
});
|
|
9677
|
+
secure.once("close", () => {
|
|
9678
|
+
finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_closed") });
|
|
9679
|
+
});
|
|
9680
|
+
});
|
|
9681
|
+
hopRequest.once("error", (error) => {
|
|
9682
|
+
finish({ error });
|
|
9683
|
+
});
|
|
9684
|
+
hopRequest.end();
|
|
9685
|
+
});
|
|
9686
|
+
}
|
|
9687
|
+
/** Bounded supplementary DNS context for a failed direct attempt. */
|
|
9688
|
+
async function unreachableWithDnsContext(hostname, budgetMs) {
|
|
9689
|
+
let fakeIp = false;
|
|
9690
|
+
let timer;
|
|
9691
|
+
try {
|
|
9692
|
+
fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
|
|
9693
|
+
timer = setTimeout(() => {
|
|
9694
|
+
reject(/* @__PURE__ */ new Error("dns_context_timeout"));
|
|
9695
|
+
}, Math.max(1, budgetMs));
|
|
9696
|
+
timer.unref();
|
|
9697
|
+
})])).some(({ address }) => {
|
|
9698
|
+
const [first, second] = address.split(".").map(Number);
|
|
9699
|
+
return first === 198 && (second === 18 || second === 19);
|
|
9700
|
+
});
|
|
9701
|
+
} catch {} finally {
|
|
9702
|
+
clearTimeout(timer);
|
|
9703
|
+
}
|
|
9704
|
+
return {
|
|
9705
|
+
state: "unreachable",
|
|
9706
|
+
...fakeIp ? { fakeIp: true } : {}
|
|
9707
|
+
};
|
|
7970
9708
|
}
|
|
7971
|
-
async function
|
|
7972
|
-
if (origin === void 0) return { state: "not-applicable" };
|
|
7973
|
-
const hostname = new URL(origin).hostname;
|
|
9709
|
+
async function fetchRemoteHealth(target, budgetMs) {
|
|
7974
9710
|
const started = performance.now();
|
|
7975
9711
|
try {
|
|
7976
|
-
const response = await fetch(
|
|
9712
|
+
const response = await fetch(target, {
|
|
7977
9713
|
cache: "no-store",
|
|
7978
9714
|
redirect: "error",
|
|
7979
|
-
signal: AbortSignal.timeout(
|
|
9715
|
+
signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
|
|
7980
9716
|
});
|
|
7981
9717
|
const latencyMs = Math.max(0, Math.round(performance.now() - started));
|
|
7982
9718
|
if (response.status === 429) return {
|
|
@@ -7991,19 +9727,53 @@ async function defaultRemoteProbe(origin) {
|
|
|
7991
9727
|
latencyMs
|
|
7992
9728
|
};
|
|
7993
9729
|
} catch {
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
9730
|
+
return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
|
|
9731
|
+
}
|
|
9732
|
+
}
|
|
9733
|
+
/**
|
|
9734
|
+
* Answer "is the public endpoint reachable" from the machine's own egress.
|
|
9735
|
+
*
|
|
9736
|
+
* A direct attempt stays authoritative first. When it fails and the environment
|
|
9737
|
+
* configures an HTTP relay that `NO_PROXY` does not exclude, one tunnelled
|
|
9738
|
+
* attempt runs: a self-managed Funnel origin otherwise resolves to this
|
|
9739
|
+
* machine's tailnet address, where nothing serves the funnel port locally, so
|
|
9740
|
+
* the direct measurement reports an outage while the relay's egress — the same
|
|
9741
|
+
* public path a phone takes — still succeeds. When both fail, the original
|
|
9742
|
+
* direct observation (including its Fake-IP context) stands unchanged.
|
|
9743
|
+
*/
|
|
9744
|
+
async function probeRemoteHealth(origin, options = {}) {
|
|
9745
|
+
if (origin === void 0) return { state: "not-applicable" };
|
|
9746
|
+
let target;
|
|
9747
|
+
try {
|
|
9748
|
+
target = new URL("/mobile-access/health", origin);
|
|
9749
|
+
} catch {
|
|
9750
|
+
return { state: "not-applicable" };
|
|
9751
|
+
}
|
|
9752
|
+
const budgetMs = remoteDiagnosticTimeoutMs(origin);
|
|
9753
|
+
const observation = await (options.direct ?? fetchRemoteHealth)(target, budgetMs);
|
|
9754
|
+
if (observation.state !== "unreachable") return observation;
|
|
9755
|
+
const relay = configuredProxyFor(origin, options.env ?? process.env);
|
|
9756
|
+
if (relay === void 0) return observation;
|
|
9757
|
+
const tunnel = options.tunnel ?? relayProbeThroughProxy;
|
|
9758
|
+
try {
|
|
9759
|
+
const { status, latencyMs } = await tunnel(target, relay, budgetMs);
|
|
9760
|
+
if (status === 429) return {
|
|
9761
|
+
state: "rate-limited",
|
|
9762
|
+
latencyMs,
|
|
9763
|
+
viaProxy: true
|
|
8004
9764
|
};
|
|
9765
|
+
return status >= 200 && status < 300 ? {
|
|
9766
|
+
state: "ready",
|
|
9767
|
+
latencyMs,
|
|
9768
|
+
viaProxy: true
|
|
9769
|
+
} : observation;
|
|
9770
|
+
} catch {
|
|
9771
|
+
return observation;
|
|
8005
9772
|
}
|
|
8006
9773
|
}
|
|
9774
|
+
async function defaultRemoteProbe(origin) {
|
|
9775
|
+
return probeRemoteHealth(origin);
|
|
9776
|
+
}
|
|
8007
9777
|
function reportLine(entry) {
|
|
8008
9778
|
return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
|
|
8009
9779
|
}
|
|
@@ -8033,10 +9803,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
8033
9803
|
const facts = {
|
|
8034
9804
|
provider: snapshot.remote.provider,
|
|
8035
9805
|
endpointSuffix,
|
|
8036
|
-
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
|
|
9806
|
+
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
|
|
9807
|
+
...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
|
|
8037
9808
|
};
|
|
8038
|
-
if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
|
|
8039
|
-
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
9809
|
+
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));
|
|
9810
|
+
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
8040
9811
|
else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
|
|
8041
9812
|
else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
|
|
8042
9813
|
} else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
|
|
@@ -10599,394 +12370,76 @@ var PluginReleaseManager = class {
|
|
|
10599
12370
|
return status;
|
|
10600
12371
|
}
|
|
10601
12372
|
/** Install the latest npm release into the active profile, then require a DSH restart. */
|
|
10602
|
-
async update() {
|
|
10603
|
-
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
10604
|
-
this.activeUpdate = this.updateOnce();
|
|
10605
|
-
try {
|
|
10606
|
-
return await this.activeUpdate;
|
|
10607
|
-
} finally {
|
|
10608
|
-
this.activeUpdate = void 0;
|
|
10609
|
-
}
|
|
10610
|
-
}
|
|
10611
|
-
async updateOnce() {
|
|
10612
|
-
const profileDirectory = this.profileDirectory;
|
|
10613
|
-
if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
|
|
10614
|
-
const status = await this.status(true);
|
|
10615
|
-
if (!status.updateSupported) throw new Error("plugin_update_unsupported");
|
|
10616
|
-
if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
|
|
10617
|
-
await this.runner(profileDirectory, status.latestVersion);
|
|
10618
|
-
const installed = await this.installedVersionReader(profileDirectory);
|
|
10619
|
-
if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
|
|
10620
|
-
this.cache = void 0;
|
|
10621
|
-
return Object.freeze({
|
|
10622
|
-
installedVersion: installed,
|
|
10623
|
-
restartRequired: true
|
|
10624
|
-
});
|
|
10625
|
-
}
|
|
10626
|
-
};
|
|
10627
|
-
//#endregion
|
|
10628
|
-
//#region src/file-logger.ts
|
|
10629
|
-
const MAX_LOG_BYTES = 5242880;
|
|
10630
|
-
/** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
|
|
10631
|
-
async function installMobileFileLogger(ctx, stateDirectory) {
|
|
10632
|
-
const directory = join(stateDirectory, "logs");
|
|
10633
|
-
const file = join(directory, "dsh-mobile.log");
|
|
10634
|
-
const previous = `${file}.1`;
|
|
10635
|
-
await mkdir(directory, {
|
|
10636
|
-
recursive: true,
|
|
10637
|
-
mode: 448
|
|
10638
|
-
});
|
|
10639
|
-
const entry = await lstat(file).catch(() => void 0);
|
|
10640
|
-
if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
|
|
10641
|
-
await rm(previous, { force: true });
|
|
10642
|
-
await rename(file, previous);
|
|
10643
|
-
}
|
|
10644
|
-
const stream = createWriteStream(file, {
|
|
10645
|
-
flags: "a",
|
|
10646
|
-
encoding: "utf8",
|
|
10647
|
-
mode: 384
|
|
10648
|
-
});
|
|
10649
|
-
const exporter = {
|
|
10650
|
-
colors: false,
|
|
10651
|
-
maxLength: 16384,
|
|
10652
|
-
levels: {
|
|
10653
|
-
default: -1,
|
|
10654
|
-
"dsh-mobile": 3
|
|
10655
|
-
},
|
|
10656
|
-
export(message) {
|
|
10657
|
-
if (message.name !== "dsh-mobile") return;
|
|
10658
|
-
const record = {
|
|
10659
|
-
timestamp: new Date(message.ts).toISOString(),
|
|
10660
|
-
level: message.type,
|
|
10661
|
-
logger: message.name,
|
|
10662
|
-
message: Logger.format(exporter, message)
|
|
10663
|
-
};
|
|
10664
|
-
stream.write(`${JSON.stringify(record)}\n`);
|
|
10665
|
-
}
|
|
10666
|
-
};
|
|
10667
|
-
ctx.logger.exporter(exporter);
|
|
10668
|
-
ctx.effect(() => () => {
|
|
10669
|
-
stream.end();
|
|
10670
|
-
}, "dsh-mobile file logger");
|
|
10671
|
-
return file;
|
|
10672
|
-
}
|
|
10673
|
-
//#endregion
|
|
10674
|
-
//#region src/managed-setup.ts
|
|
10675
|
-
const VIRTUAL_INTERFACE_MARKERS = [
|
|
10676
|
-
"bridge",
|
|
10677
|
-
"docker",
|
|
10678
|
-
"hyper-v",
|
|
10679
|
-
"mihomo",
|
|
10680
|
-
"radmin",
|
|
10681
|
-
"tailscale",
|
|
10682
|
-
"tap",
|
|
10683
|
-
"tun",
|
|
10684
|
-
"utun",
|
|
10685
|
-
"vbox",
|
|
10686
|
-
"veth",
|
|
10687
|
-
"virtual",
|
|
10688
|
-
"vmware",
|
|
10689
|
-
"vpn",
|
|
10690
|
-
"vethernet",
|
|
10691
|
-
"wsl",
|
|
10692
|
-
"zerotier"
|
|
10693
|
-
];
|
|
10694
|
-
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
10695
|
-
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
10696
|
-
function requiredString(value, name) {
|
|
10697
|
-
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
10698
|
-
return value;
|
|
10699
|
-
}
|
|
10700
|
-
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
10701
|
-
function parseManagedSetup(value) {
|
|
10702
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
10703
|
-
const record = value;
|
|
10704
|
-
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
10705
|
-
"version",
|
|
10706
|
-
"networkInterface",
|
|
10707
|
-
"listenPort",
|
|
10708
|
-
"upstreamOrigin",
|
|
10709
|
-
"tls"
|
|
10710
|
-
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
10711
|
-
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
10712
|
-
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
10713
|
-
const tls = record.tls;
|
|
10714
|
-
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
10715
|
-
"mode",
|
|
10716
|
-
"caCertFile",
|
|
10717
|
-
"caKeyFile",
|
|
10718
|
-
"certFile",
|
|
10719
|
-
"keyFile"
|
|
10720
|
-
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
10721
|
-
return Object.freeze({
|
|
10722
|
-
version: 2,
|
|
10723
|
-
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
10724
|
-
listenPort: record.listenPort,
|
|
10725
|
-
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
10726
|
-
tls: Object.freeze({
|
|
10727
|
-
mode: "managed",
|
|
10728
|
-
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
10729
|
-
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
10730
|
-
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
10731
|
-
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
10732
|
-
})
|
|
10733
|
-
});
|
|
10734
|
-
}
|
|
10735
|
-
function privateIpv4(value) {
|
|
10736
|
-
const parts = value.split(".").map(Number);
|
|
10737
|
-
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);
|
|
10738
|
-
}
|
|
10739
|
-
function networkCidr(address, cidr) {
|
|
10740
|
-
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
10741
|
-
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
10742
|
-
return `${[
|
|
10743
|
-
24,
|
|
10744
|
-
16,
|
|
10745
|
-
8,
|
|
10746
|
-
0
|
|
10747
|
-
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
10748
|
-
}
|
|
10749
|
-
/** List current private IPv4 candidates with their interface identity. */
|
|
10750
|
-
function availableLanNetworks(table = networkInterfaces()) {
|
|
10751
|
-
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
10752
|
-
name,
|
|
10753
|
-
address: entry.address,
|
|
10754
|
-
cidr: networkCidr(entry.address, entry.cidr)
|
|
10755
|
-
})));
|
|
10756
|
-
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
10757
|
-
}
|
|
10758
|
-
function likelyVirtualInterface(name) {
|
|
10759
|
-
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
10760
|
-
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
10761
|
-
}
|
|
10762
|
-
async function runRouteCommand(file, args) {
|
|
10763
|
-
return (await execFileText(file, [...args], {
|
|
10764
|
-
encoding: "utf8",
|
|
10765
|
-
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
10766
|
-
})).stdout;
|
|
10767
|
-
}
|
|
10768
|
-
function uniqueLines(output) {
|
|
10769
|
-
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
10770
|
-
}
|
|
10771
|
-
/** Return operating-system default-route interfaces in routing preference order. */
|
|
10772
|
-
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
10773
|
-
try {
|
|
10774
|
-
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
10775
|
-
"-NoProfile",
|
|
10776
|
-
"-NonInteractive",
|
|
10777
|
-
"-Command",
|
|
10778
|
-
[
|
|
10779
|
-
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
10780
|
-
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
10781
|
-
" $route = $_",
|
|
10782
|
-
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10783
|
-
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10784
|
-
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
10785
|
-
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
10786
|
-
" }",
|
|
10787
|
-
"}",
|
|
10788
|
-
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
10789
|
-
].join("; ")
|
|
10790
|
-
]));
|
|
10791
|
-
if (platform === "linux") {
|
|
10792
|
-
const routes = uniqueLines(await run("ip", [
|
|
10793
|
-
"-o",
|
|
10794
|
-
"-4",
|
|
10795
|
-
"route",
|
|
10796
|
-
"show",
|
|
10797
|
-
"default"
|
|
10798
|
-
])).map((line) => ({
|
|
10799
|
-
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
10800
|
-
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
10801
|
-
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
10802
|
-
return [...new Set(routes.map((route) => route.name))];
|
|
10803
|
-
}
|
|
10804
|
-
if (platform === "darwin") {
|
|
10805
|
-
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
10806
|
-
"-n",
|
|
10807
|
-
"get",
|
|
10808
|
-
"default"
|
|
10809
|
-
]))?.[1];
|
|
10810
|
-
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
10811
|
-
}
|
|
10812
|
-
} catch {}
|
|
10813
|
-
return [];
|
|
10814
|
-
}
|
|
10815
|
-
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
10816
|
-
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
10817
|
-
const candidates = availableLanNetworks(table);
|
|
10818
|
-
if (requestedAddress !== void 0) {
|
|
10819
|
-
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
10820
|
-
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
10821
|
-
return match;
|
|
10822
|
-
}
|
|
10823
|
-
if (requestedInterface !== void 0) {
|
|
10824
|
-
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
10825
|
-
if (matches.length === 1) return matches[0];
|
|
10826
|
-
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
10827
|
-
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
12373
|
+
async update() {
|
|
12374
|
+
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
12375
|
+
this.activeUpdate = this.updateOnce();
|
|
12376
|
+
try {
|
|
12377
|
+
return await this.activeUpdate;
|
|
12378
|
+
} finally {
|
|
12379
|
+
this.activeUpdate = void 0;
|
|
12380
|
+
}
|
|
10828
12381
|
}
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
const
|
|
10833
|
-
if (
|
|
12382
|
+
async updateOnce() {
|
|
12383
|
+
const profileDirectory = this.profileDirectory;
|
|
12384
|
+
if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
|
|
12385
|
+
const status = await this.status(true);
|
|
12386
|
+
if (!status.updateSupported) throw new Error("plugin_update_unsupported");
|
|
12387
|
+
if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
|
|
12388
|
+
await this.runner(profileDirectory, status.latestVersion);
|
|
12389
|
+
const installed = await this.installedVersionReader(profileDirectory);
|
|
12390
|
+
if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
|
|
12391
|
+
this.cache = void 0;
|
|
12392
|
+
return Object.freeze({
|
|
12393
|
+
installedVersion: installed,
|
|
12394
|
+
restartRequired: true
|
|
12395
|
+
});
|
|
10834
12396
|
}
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
/**
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
*/
|
|
10845
|
-
function isNetworkSelectionError(error) {
|
|
10846
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
10847
|
-
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");
|
|
10848
|
-
}
|
|
10849
|
-
function assertMatchingCa(certPem, keyPem) {
|
|
10850
|
-
const certificate = new X509Certificate(certPem);
|
|
10851
|
-
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
10852
|
-
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
10853
|
-
format: "der",
|
|
10854
|
-
type: "spki"
|
|
10855
|
-
});
|
|
10856
|
-
const certificatePublic = certificate.publicKey.export({
|
|
10857
|
-
format: "der",
|
|
10858
|
-
type: "spki"
|
|
10859
|
-
});
|
|
10860
|
-
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
10861
|
-
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
10862
|
-
return certificate;
|
|
10863
|
-
}
|
|
10864
|
-
async function atomicWrite(file, contents) {
|
|
10865
|
-
const directory = dirname(file);
|
|
12397
|
+
};
|
|
12398
|
+
//#endregion
|
|
12399
|
+
//#region src/file-logger.ts
|
|
12400
|
+
const MAX_LOG_BYTES = 5242880;
|
|
12401
|
+
/** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
|
|
12402
|
+
async function installMobileFileLogger(ctx, stateDirectory) {
|
|
12403
|
+
const directory = join(stateDirectory, "logs");
|
|
12404
|
+
const file = join(directory, "dsh-mobile.log");
|
|
12405
|
+
const previous = `${file}.1`;
|
|
10866
12406
|
await mkdir(directory, {
|
|
10867
12407
|
recursive: true,
|
|
10868
12408
|
mode: 448
|
|
10869
12409
|
});
|
|
10870
|
-
const
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
10874
|
-
}
|
|
10875
|
-
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
10876
|
-
async function ensureManagedCa(setup, legacy) {
|
|
10877
|
-
let certPem;
|
|
10878
|
-
let keyPem;
|
|
10879
|
-
try {
|
|
10880
|
-
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
10881
|
-
} catch (error) {
|
|
10882
|
-
if (error.code !== "ENOENT") throw error;
|
|
10883
|
-
let migrated = false;
|
|
10884
|
-
if (legacy !== void 0) try {
|
|
10885
|
-
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
10886
|
-
assertMatchingCa(certPem, keyPem);
|
|
10887
|
-
migrated = true;
|
|
10888
|
-
} catch (legacyError) {
|
|
10889
|
-
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
10890
|
-
}
|
|
10891
|
-
if (!migrated) {
|
|
10892
|
-
const now = /* @__PURE__ */ new Date();
|
|
10893
|
-
const notAfter = new Date(now);
|
|
10894
|
-
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
10895
|
-
const generated = await generate([{
|
|
10896
|
-
name: "commonName",
|
|
10897
|
-
value: "DeepSeek Harness Mobile CA"
|
|
10898
|
-
}], {
|
|
10899
|
-
keyType: "ec",
|
|
10900
|
-
curve: "P-256",
|
|
10901
|
-
algorithm: "sha256",
|
|
10902
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10903
|
-
notAfterDate: notAfter,
|
|
10904
|
-
extensions: [{
|
|
10905
|
-
name: "basicConstraints",
|
|
10906
|
-
cA: true,
|
|
10907
|
-
critical: true
|
|
10908
|
-
}, {
|
|
10909
|
-
name: "keyUsage",
|
|
10910
|
-
digitalSignature: true,
|
|
10911
|
-
keyCertSign: true,
|
|
10912
|
-
cRLSign: true,
|
|
10913
|
-
critical: true
|
|
10914
|
-
}]
|
|
10915
|
-
});
|
|
10916
|
-
certPem = generated.cert;
|
|
10917
|
-
keyPem = generated.private;
|
|
10918
|
-
}
|
|
10919
|
-
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
10920
|
-
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
12410
|
+
const entry = await lstat(file).catch(() => void 0);
|
|
12411
|
+
if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
|
|
12412
|
+
await rm(previous, { force: true });
|
|
12413
|
+
await rename(file, previous);
|
|
10921
12414
|
}
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
10926
|
-
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
10927
|
-
async function refreshManagedServerCertificate(setup, address) {
|
|
10928
|
-
await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
|
|
10929
|
-
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
10930
|
-
assertMatchingCa(caCert, caKey);
|
|
10931
|
-
const now = /* @__PURE__ */ new Date();
|
|
10932
|
-
const notAfter = new Date(now);
|
|
10933
|
-
notAfter.setDate(notAfter.getDate() + 397);
|
|
10934
|
-
const server = await generate([{
|
|
10935
|
-
name: "commonName",
|
|
10936
|
-
value: "DeepSeek Harness Mobile"
|
|
10937
|
-
}], {
|
|
10938
|
-
keyType: "ec",
|
|
10939
|
-
curve: "P-256",
|
|
10940
|
-
algorithm: "sha256",
|
|
10941
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10942
|
-
notAfterDate: notAfter,
|
|
10943
|
-
ca: {
|
|
10944
|
-
cert: caCert,
|
|
10945
|
-
key: caKey
|
|
10946
|
-
},
|
|
10947
|
-
extensions: [
|
|
10948
|
-
{
|
|
10949
|
-
name: "basicConstraints",
|
|
10950
|
-
cA: false,
|
|
10951
|
-
critical: true
|
|
10952
|
-
},
|
|
10953
|
-
{
|
|
10954
|
-
name: "keyUsage",
|
|
10955
|
-
digitalSignature: true,
|
|
10956
|
-
critical: true
|
|
10957
|
-
},
|
|
10958
|
-
{
|
|
10959
|
-
name: "extKeyUsage",
|
|
10960
|
-
serverAuth: true
|
|
10961
|
-
},
|
|
10962
|
-
{
|
|
10963
|
-
name: "subjectAltName",
|
|
10964
|
-
altNames: [{
|
|
10965
|
-
type: 7,
|
|
10966
|
-
ip: address
|
|
10967
|
-
}]
|
|
10968
|
-
}
|
|
10969
|
-
]
|
|
12415
|
+
const stream = createWriteStream(file, {
|
|
12416
|
+
flags: "a",
|
|
12417
|
+
encoding: "utf8",
|
|
12418
|
+
mode: 384
|
|
10970
12419
|
});
|
|
10971
|
-
|
|
10972
|
-
|
|
10973
|
-
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
10977
|
-
|
|
10978
|
-
|
|
10979
|
-
|
|
10980
|
-
|
|
10981
|
-
|
|
10982
|
-
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
|
|
10986
|
-
|
|
10987
|
-
keyFile: setup.tls.keyFile
|
|
12420
|
+
const exporter = {
|
|
12421
|
+
colors: false,
|
|
12422
|
+
maxLength: 16384,
|
|
12423
|
+
levels: {
|
|
12424
|
+
default: -1,
|
|
12425
|
+
"dsh-mobile": 3
|
|
12426
|
+
},
|
|
12427
|
+
export(message) {
|
|
12428
|
+
if (message.name !== "dsh-mobile") return;
|
|
12429
|
+
const record = {
|
|
12430
|
+
timestamp: new Date(message.ts).toISOString(),
|
|
12431
|
+
level: message.type,
|
|
12432
|
+
logger: message.name,
|
|
12433
|
+
message: Logger.format(exporter, message)
|
|
12434
|
+
};
|
|
12435
|
+
stream.write(`${JSON.stringify(record)}\n`);
|
|
10988
12436
|
}
|
|
10989
12437
|
};
|
|
12438
|
+
ctx.logger.exporter(exporter);
|
|
12439
|
+
ctx.effect(() => () => {
|
|
12440
|
+
stream.end();
|
|
12441
|
+
}, "dsh-mobile file logger");
|
|
12442
|
+
return file;
|
|
10990
12443
|
}
|
|
10991
12444
|
//#endregion
|
|
10992
12445
|
//#region src/lan-setup.ts
|
|
@@ -11250,6 +12703,56 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
11250
12703
|
discovery: false
|
|
11251
12704
|
});
|
|
11252
12705
|
}
|
|
12706
|
+
/**
|
|
12707
|
+
* Gateway config for the self-signed FRP ingress.
|
|
12708
|
+
*
|
|
12709
|
+
* Unlike {@link remoteGatewayConfig}, the gateway itself is the TLS endpoint:
|
|
12710
|
+
* frps only forwards raw TCP, so the listener speaks HTTPS with the leaf that
|
|
12711
|
+
* `pairingCaFile` signed, and the pairing CA is deliberately retained so the
|
|
12712
|
+
* gateway can serve `GET /mobile-access/ca.cer` for the app to pin. The listener
|
|
12713
|
+
* stays on loopback because the public entry is the frps TCP proxy, never a
|
|
12714
|
+
* direct bind.
|
|
12715
|
+
*/
|
|
12716
|
+
function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenPort = 0) {
|
|
12717
|
+
if (!isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
|
|
12718
|
+
const publicAuthority = `${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
|
|
12719
|
+
const { pairingCaFile: _templateCaFile, ...shared } = template;
|
|
12720
|
+
return Object.freeze({
|
|
12721
|
+
...shared,
|
|
12722
|
+
listenHost: "127.0.0.1",
|
|
12723
|
+
listenPort,
|
|
12724
|
+
authorities: Object.freeze([parseAuthority(publicAuthority)]),
|
|
12725
|
+
allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
|
|
12726
|
+
stateFile,
|
|
12727
|
+
instanceId: ingress.caFingerprint,
|
|
12728
|
+
pairingCaFile: ingress.paths.caCertFile,
|
|
12729
|
+
tls: Object.freeze({
|
|
12730
|
+
mode: "provided",
|
|
12731
|
+
certFile: ingress.paths.certFile,
|
|
12732
|
+
keyFile: ingress.paths.keyFile
|
|
12733
|
+
}),
|
|
12734
|
+
publicTls: true,
|
|
12735
|
+
discovery: false
|
|
12736
|
+
});
|
|
12737
|
+
}
|
|
12738
|
+
/**
|
|
12739
|
+
* CA the FRP start-up self-check must pin, read from the ingress directory.
|
|
12740
|
+
*
|
|
12741
|
+
* The self-signed entry presents a leaf signed by this CA, which is absent from
|
|
12742
|
+
* the system trust store, so the probe must anchor it exactly as the app does
|
|
12743
|
+
* when it pins `pairingCaFile`. The public-CA entry answers with a publicly
|
|
12744
|
+
* trusted certificate and therefore returns `undefined` (system trust store).
|
|
12745
|
+
* An unreadable self-signed CA fails explicitly: falling back to the system
|
|
12746
|
+
* trust store would hide a broken pairing identity behind a start timeout.
|
|
12747
|
+
*/
|
|
12748
|
+
async function readFrpIngressTrustAnchor(settings, stateFile) {
|
|
12749
|
+
if (!isFrpSelfSignedIngress(settings)) return void 0;
|
|
12750
|
+
try {
|
|
12751
|
+
return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
|
|
12752
|
+
} catch (error) {
|
|
12753
|
+
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
12754
|
+
}
|
|
12755
|
+
}
|
|
11253
12756
|
/** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
|
|
11254
12757
|
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
|
|
11255
12758
|
const validated = parseOriginSettings(settings);
|
|
@@ -11444,6 +12947,27 @@ async function apply(ctx, config) {
|
|
|
11444
12947
|
await candidate.start();
|
|
11445
12948
|
return candidate;
|
|
11446
12949
|
};
|
|
12950
|
+
/**
|
|
12951
|
+
* Gateway factory for the FRP provider.
|
|
12952
|
+
*
|
|
12953
|
+
* The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
|
|
12954
|
+
* self-signed entry terminates TLS inside the gateway, so it first materializes
|
|
12955
|
+
* a leaf the pairing CA signed for the public IPv4 and then binds the ingress
|
|
12956
|
+
* configuration that keeps that CA pinned.
|
|
12957
|
+
*/
|
|
12958
|
+
const createFrpGateway = async (publicOrigin, settings) => {
|
|
12959
|
+
if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
|
|
12960
|
+
const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
|
|
12961
|
+
const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
|
|
12962
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
12963
|
+
try {
|
|
12964
|
+
await candidate.start();
|
|
12965
|
+
} catch (error) {
|
|
12966
|
+
await candidate.close();
|
|
12967
|
+
throw error;
|
|
12968
|
+
}
|
|
12969
|
+
return candidate;
|
|
12970
|
+
};
|
|
11447
12971
|
const createOriginGateway = async (settings) => {
|
|
11448
12972
|
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
11449
12973
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
@@ -11485,7 +13009,18 @@ async function apply(ctx, config) {
|
|
|
11485
13009
|
executable: frpComponent.executable,
|
|
11486
13010
|
config: frpConfig,
|
|
11487
13011
|
instanceId,
|
|
11488
|
-
createGateway:
|
|
13012
|
+
createGateway: createFrpGateway,
|
|
13013
|
+
/**
|
|
13014
|
+
* Hand the start-up self-check the CA the app itself pins.
|
|
13015
|
+
*
|
|
13016
|
+
* Only this module knows where the ingress material lives, so the anchor is
|
|
13017
|
+
* read here from the ingress directory instead of being guessed downstream.
|
|
13018
|
+
*/
|
|
13019
|
+
resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
|
|
13020
|
+
maintainIngressCertificate: async (settings, gateway) => {
|
|
13021
|
+
await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
|
|
13022
|
+
await gateway.refreshProvidedTls();
|
|
13023
|
+
}
|
|
11489
13024
|
}),
|
|
11490
13025
|
origin: new OriginController({
|
|
11491
13026
|
store: originStore,
|
|
@@ -11547,6 +13082,25 @@ async function apply(ctx, config) {
|
|
|
11547
13082
|
networkError = "network_interface_unavailable";
|
|
11548
13083
|
}
|
|
11549
13084
|
const remote = remoteController().status();
|
|
13085
|
+
const frpOrigin = remote.origin;
|
|
13086
|
+
const frpSettings = remoteProviders.selected === "frp" ? frpConfig.settings() : void 0;
|
|
13087
|
+
const frpGateway = remoteControllers.frp.gateway();
|
|
13088
|
+
const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
|
|
13089
|
+
try {
|
|
13090
|
+
const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
|
|
13091
|
+
if (trustAnchorPem === void 0) return { state: "unreachable" };
|
|
13092
|
+
const started = performance.now();
|
|
13093
|
+
return await defaultProbeDiscovery({
|
|
13094
|
+
origin: frpOrigin,
|
|
13095
|
+
trustAnchorPem
|
|
13096
|
+
}, frpGateway.config.instanceId, AbortSignal.timeout(5500)) ? {
|
|
13097
|
+
state: "ready",
|
|
13098
|
+
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
13099
|
+
} : { state: "unreachable" };
|
|
13100
|
+
} catch {
|
|
13101
|
+
return { state: "unreachable" };
|
|
13102
|
+
}
|
|
13103
|
+
} : void 0;
|
|
11550
13104
|
return collectConnectionDiagnostics({
|
|
11551
13105
|
dshVersion,
|
|
11552
13106
|
lan: {
|
|
@@ -11570,7 +13124,7 @@ async function apply(ctx, config) {
|
|
|
11570
13124
|
...remote.origin === void 0 ? {} : { origin: remote.origin },
|
|
11571
13125
|
...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
|
|
11572
13126
|
}
|
|
11573
|
-
});
|
|
13127
|
+
}, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
|
|
11574
13128
|
};
|
|
11575
13129
|
const adminRoute = {
|
|
11576
13130
|
kind: "prefix",
|
|
@@ -11748,6 +13302,34 @@ async function apply(ctx, config) {
|
|
|
11748
13302
|
sendJson(response, 200, remotePayload(), false);
|
|
11749
13303
|
return;
|
|
11750
13304
|
}
|
|
13305
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
|
|
13306
|
+
const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
|
|
13307
|
+
const options = { configFile: frpConfig.runtimeConfigFile };
|
|
13308
|
+
logger.info("frp attach plan requested mode=%s entryTls=%s vhostHttpPort=%d", settings.mode ?? "deploy", settings.entryTls ?? "public-ip-cert", resolveFrpVhostHttpPort(settings));
|
|
13309
|
+
sendJson(response, 200, {
|
|
13310
|
+
...remotePayload(),
|
|
13311
|
+
frpAttachPlan: createFrpAttachPlan(settings, options),
|
|
13312
|
+
frpAttachTemplate: createFrpAttachTemplate(settings, options)
|
|
13313
|
+
}, false);
|
|
13314
|
+
return;
|
|
13315
|
+
}
|
|
13316
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
|
|
13317
|
+
const settings = frpConfig.settings();
|
|
13318
|
+
if (settings === void 0) throw new HttpError(409, "frp_config_missing");
|
|
13319
|
+
const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
|
|
13320
|
+
const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
|
|
13321
|
+
sendJson(response, 200, {
|
|
13322
|
+
...remotePayload(),
|
|
13323
|
+
frpSelfCheck: {
|
|
13324
|
+
...check,
|
|
13325
|
+
frpsReachable,
|
|
13326
|
+
entryReachable,
|
|
13327
|
+
frpc: remoteController().status(),
|
|
13328
|
+
component: frpComponent.status()
|
|
13329
|
+
}
|
|
13330
|
+
}, false);
|
|
13331
|
+
return;
|
|
13332
|
+
}
|
|
11751
13333
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/websocket-paths`) {
|
|
11752
13334
|
sendJson(response, 200, { paths: webSocketPaths.list() }, false);
|
|
11753
13335
|
return;
|
|
@@ -11848,7 +13430,11 @@ async function apply(ctx, config) {
|
|
|
11848
13430
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11849
13431
|
await remoteProviders.mutate(async () => {
|
|
11850
13432
|
await remoteControllers.frp.setEnabled(false);
|
|
11851
|
-
await Promise.all([
|
|
13433
|
+
await Promise.all([
|
|
13434
|
+
frpComponent.purge(),
|
|
13435
|
+
frpConfig.purge(),
|
|
13436
|
+
purgeFrpIngressCertificates(remoteDeviceFile)
|
|
13437
|
+
]);
|
|
11852
13438
|
});
|
|
11853
13439
|
sendJson(response, 200, remotePayload(), false);
|
|
11854
13440
|
return;
|
|
@@ -11926,8 +13512,7 @@ async function apply(ctx, config) {
|
|
|
11926
13512
|
text: `${guide}\n\n用户需求:${task}`
|
|
11927
13513
|
}],
|
|
11928
13514
|
source: {
|
|
11929
|
-
kind: "plugin",
|
|
11930
|
-
plugin: "dsh-mobile",
|
|
13515
|
+
kind: "plugin:dsh-mobile",
|
|
11931
13516
|
form: "notice",
|
|
11932
13517
|
summary: boundContextSummary(`/mobile ${task}`)
|
|
11933
13518
|
}
|
|
@@ -11992,6 +13577,6 @@ async function apply(ctx, config) {
|
|
|
11992
13577
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
11993
13578
|
}
|
|
11994
13579
|
//#endregion
|
|
11995
|
-
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 };
|
|
13580
|
+
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 };
|
|
11996
13581
|
|
|
11997
13582
|
//# sourceMappingURL=index.mjs.map
|