dsh-mobile 0.4.5 → 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 +11 -0
- package/CONTRIBUTORS.md +5 -3
- package/README.en.md +23 -11
- package/README.md +23 -11
- package/SECURITY.md +7 -5
- package/docs/ATTACH_EXISTING_FRPS.en.md +44 -0
- package/docs/ATTACH_EXISTING_FRPS.md +98 -0
- package/docs/README.md +3 -0
- package/docs/SELF_HOSTED_FRP.en.md +3 -1
- package/docs/SELF_HOSTED_FRP.md +7 -1
- package/lib/cli.js +52 -15
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1235 -155
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +384 -10
- package/lib/index.mjs +2204 -637
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-compat.js.map +1 -1
- package/package.json +18 -14
package/lib/index.mjs
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
|
-
import { connect, createServer
|
|
7
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { connect, createServer } from "node:net";
|
|
7
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
8
8
|
import { execFile, spawn } from "node:child_process";
|
|
9
9
|
import { createSocket } from "node:dgram";
|
|
10
10
|
import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
|
|
11
11
|
import { createServer as createServer$1, request } from "node:http";
|
|
12
|
-
import { createServer as createServer$2 } from "node:https";
|
|
13
|
-
import { Transform, finished } from "node:stream";
|
|
12
|
+
import { createServer as createServer$2, request as request$1 } from "node:https";
|
|
13
|
+
import { Readable, Transform, finished } from "node:stream";
|
|
14
14
|
import { pipeline } from "node:stream/promises";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
16
|
import { createGzip, gzip } from "node:zlib";
|
|
17
17
|
import Bonjour from "bonjour-service";
|
|
18
18
|
import * as QRCode from "qrcode";
|
|
19
19
|
import { Logger, Service } from "@deepseek-ai/cordis";
|
|
20
|
+
import { connect as connect$1 } from "node:tls";
|
|
21
|
+
import { generate } from "selfsigned";
|
|
20
22
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
|
|
21
23
|
import { lookup } from "node:dns/promises";
|
|
22
24
|
import { createWriteStream } from "node:fs";
|
|
23
|
-
import { generate } from "selfsigned";
|
|
24
25
|
//#region src/access.ts
|
|
25
26
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
26
27
|
var AccessError = class extends Error {
|
|
@@ -384,6 +385,88 @@ var AccessController = class {
|
|
|
384
385
|
}
|
|
385
386
|
};
|
|
386
387
|
//#endregion
|
|
388
|
+
//#region src/ip.ts
|
|
389
|
+
/**
|
|
390
|
+
* Browser-safe IP literal classification.
|
|
391
|
+
*
|
|
392
|
+
* `node:net` cannot be imported from the browser Client face, so the pure
|
|
393
|
+
* predicate lives here instead. Semantics are deliberately identical to
|
|
394
|
+
* `net.isIP`: strict dotted-quad IPv4 (no leading zeros, no signs, no
|
|
395
|
+
* whitespace), IPv6 with at most one `::`, at most four hex digits per group,
|
|
396
|
+
* an optional dotted-quad tail in the final position, and an optional
|
|
397
|
+
* non-empty zone id after a single `%`.
|
|
398
|
+
*
|
|
399
|
+
* This module is dependency-free and side-effect-free on purpose: it is the
|
|
400
|
+
* single classification source shared by every face of the plugin — the Node
|
|
401
|
+
* side (`config.ts`, `cloudflared-tunnel.ts`, `origin-proxy-config.ts`,
|
|
402
|
+
* `vps-deploy.ts`, `gateway.ts`, `network.ts`, `frp-config.ts`) and the browser
|
|
403
|
+
* Client face that cannot reach `node:net` — so both classify a given literal
|
|
404
|
+
* identically. Callers rely on the strict reading above and never post-process
|
|
405
|
+
* the literal afterwards, so changing the accept/reject boundary here changes
|
|
406
|
+
* it everywhere, which is the intended contract.
|
|
407
|
+
*/
|
|
408
|
+
/** Strict dotted-quad IPv4: POSIX `inet_pton` rules, so `01.1.1.1` is rejected. */
|
|
409
|
+
function isStrictIpv4(value) {
|
|
410
|
+
const parts = value.split(".");
|
|
411
|
+
if (parts.length !== 4) return false;
|
|
412
|
+
for (const part of parts) {
|
|
413
|
+
if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(part)) return false;
|
|
414
|
+
if (Number(part) > 255) return false;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Count the 16-bit groups one side of an IPv6 address occupies.
|
|
420
|
+
*
|
|
421
|
+
* A dotted-quad tail counts as two groups and is accepted only where the caller
|
|
422
|
+
* allows it — that is, only in the final label of the whole address.
|
|
423
|
+
*
|
|
424
|
+
* @returns the group count, or `-1` when any label is malformed.
|
|
425
|
+
*/
|
|
426
|
+
function countIpv6Groups(labels, allowDottedQuad) {
|
|
427
|
+
let groups = 0;
|
|
428
|
+
for (let index = 0; index < labels.length; index += 1) {
|
|
429
|
+
const label = labels[index];
|
|
430
|
+
if (label.includes(".")) {
|
|
431
|
+
if (index !== labels.length - 1 || !allowDottedQuad || !isStrictIpv4(label)) return -1;
|
|
432
|
+
groups += 2;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!/^[\da-f]{1,4}$/iu.test(label)) return -1;
|
|
436
|
+
groups += 1;
|
|
437
|
+
}
|
|
438
|
+
return groups;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Classify an IP literal.
|
|
442
|
+
*
|
|
443
|
+
* @param value - candidate address; non-strings are never an IP.
|
|
444
|
+
* @returns `4` for IPv4, `6` for IPv6, `0` when the value is neither.
|
|
445
|
+
*/
|
|
446
|
+
function isIP(value) {
|
|
447
|
+
if (typeof value !== "string") return 0;
|
|
448
|
+
if (isStrictIpv4(value)) return 4;
|
|
449
|
+
let text = value;
|
|
450
|
+
const zone = text.indexOf("%");
|
|
451
|
+
if (zone !== -1) {
|
|
452
|
+
const suffix = text.slice(zone + 1);
|
|
453
|
+
if (suffix.length === 0 || suffix.includes("%")) return 0;
|
|
454
|
+
text = text.slice(0, zone);
|
|
455
|
+
}
|
|
456
|
+
if (text.length === 0) return 0;
|
|
457
|
+
const halves = text.split("::");
|
|
458
|
+
if (halves.length > 2) return 0;
|
|
459
|
+
if (halves.length === 1) return countIpv6Groups(text.split(":"), true) === 8 ? 6 : 0;
|
|
460
|
+
const leftText = halves[0];
|
|
461
|
+
const rightText = halves[1];
|
|
462
|
+
const left = leftText === "" ? [] : leftText.split(":");
|
|
463
|
+
const right = rightText === "" ? [] : rightText.split(":");
|
|
464
|
+
const leftGroups = countIpv6Groups(left, false);
|
|
465
|
+
const rightGroups = countIpv6Groups(right, right.length > 0);
|
|
466
|
+
if (leftGroups < 0 || rightGroups < 0) return 0;
|
|
467
|
+
return leftGroups + rightGroups >= 8 ? 0 : 6;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
387
470
|
//#region src/network.ts
|
|
388
471
|
function parseIpv4$1(address) {
|
|
389
472
|
const parts = address.split(".");
|
|
@@ -624,6 +707,43 @@ var RequestTrustPolicy = class {
|
|
|
624
707
|
return normalized;
|
|
625
708
|
}
|
|
626
709
|
};
|
|
710
|
+
/**
|
|
711
|
+
* Open a plain TCP connection only to learn whether something is listening.
|
|
712
|
+
*
|
|
713
|
+
* Used by the on-demand self-check so the panel can tell "frps is up" from
|
|
714
|
+
* "frps is not listening". A transparent proxy can acknowledge any connect, so
|
|
715
|
+
* this result is advisory and never authorises a security decision on its own.
|
|
716
|
+
*/
|
|
717
|
+
async function probeTcpReachable(host, port, timeoutMs = 1500) {
|
|
718
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return false;
|
|
719
|
+
return new Promise((resolveProbe) => {
|
|
720
|
+
let finished = false;
|
|
721
|
+
const socket = connect({
|
|
722
|
+
host,
|
|
723
|
+
port
|
|
724
|
+
});
|
|
725
|
+
const finish = (reachable) => {
|
|
726
|
+
if (finished) return;
|
|
727
|
+
finished = true;
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
socket.destroy();
|
|
730
|
+
resolveProbe(reachable);
|
|
731
|
+
};
|
|
732
|
+
const timer = setTimeout(() => {
|
|
733
|
+
finish(false);
|
|
734
|
+
}, timeoutMs);
|
|
735
|
+
timer.unref();
|
|
736
|
+
socket.once("connect", () => {
|
|
737
|
+
finish(true);
|
|
738
|
+
});
|
|
739
|
+
socket.once("error", () => {
|
|
740
|
+
finish(false);
|
|
741
|
+
});
|
|
742
|
+
socket.once("close", () => {
|
|
743
|
+
finish(false);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
}
|
|
627
747
|
//#endregion
|
|
628
748
|
//#region src/config.ts
|
|
629
749
|
/** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
|
|
@@ -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",
|
|
@@ -2502,6 +2628,57 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2502
2628
|
})]);
|
|
2503
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})};})();`;
|
|
2504
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
|
+
})();`;
|
|
2505
2682
|
const gzipBuffer = promisify(gzip);
|
|
2506
2683
|
function ensureMobileViewport(html) {
|
|
2507
2684
|
const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
|
|
@@ -2678,7 +2855,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
2678
2855
|
batches: plan.batches
|
|
2679
2856
|
})).digest("hex").slice(0, 16);
|
|
2680
2857
|
}
|
|
2681
|
-
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)};`;
|
|
2682
2859
|
return Object.freeze({
|
|
2683
2860
|
html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
|
|
2684
2861
|
batches
|
|
@@ -3277,6 +3454,15 @@ var MobileAccessGateway = class {
|
|
|
3277
3454
|
throw error;
|
|
3278
3455
|
}
|
|
3279
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
|
+
}
|
|
3280
3466
|
async startDiscovery(port) {
|
|
3281
3467
|
const socket = createSocket("udp4");
|
|
3282
3468
|
this.discoverySocket = socket;
|
|
@@ -4052,11 +4238,11 @@ var MobileAccessGateway = class {
|
|
|
4052
4238
|
this.upstreamAuthRequest = void 0;
|
|
4053
4239
|
}
|
|
4054
4240
|
}
|
|
4055
|
-
async proxyMobileIndex(request$
|
|
4241
|
+
async proxyMobileIndex(request$2, response, authorization) {
|
|
4056
4242
|
const holder = {};
|
|
4057
4243
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4058
4244
|
try {
|
|
4059
|
-
const upstreamHeaders = sanitizeRequestHeaders(request$
|
|
4245
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
|
|
4060
4246
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4061
4247
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4062
4248
|
upstreamHeaders["accept-encoding"] = "identity";
|
|
@@ -4440,15 +4626,15 @@ var MobileAccessGateway = class {
|
|
|
4440
4626
|
}
|
|
4441
4627
|
throw new HttpError(502, "upstream_unavailable");
|
|
4442
4628
|
}
|
|
4443
|
-
async proxyHttpOnce(request$
|
|
4444
|
-
const declared = request$
|
|
4629
|
+
async proxyHttpOnce(request$3, response, authorization) {
|
|
4630
|
+
const declared = request$3.headers["content-length"];
|
|
4445
4631
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
4446
4632
|
const holder = {};
|
|
4447
4633
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4448
4634
|
let bodyDone;
|
|
4449
4635
|
try {
|
|
4450
|
-
const bufferedBody = request$
|
|
4451
|
-
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);
|
|
4452
4638
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4453
4639
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4454
4640
|
if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
|
|
@@ -4457,8 +4643,8 @@ var MobileAccessGateway = class {
|
|
|
4457
4643
|
protocol: "http:",
|
|
4458
4644
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
4459
4645
|
port: Number(this.config.upstreamOrigin.port),
|
|
4460
|
-
method: request$
|
|
4461
|
-
path: request$
|
|
4646
|
+
method: request$3.method,
|
|
4647
|
+
path: request$3.url,
|
|
4462
4648
|
headers: upstreamHeaders,
|
|
4463
4649
|
agent: false
|
|
4464
4650
|
});
|
|
@@ -4468,7 +4654,7 @@ var MobileAccessGateway = class {
|
|
|
4468
4654
|
});
|
|
4469
4655
|
upstreamRequest.once("response", resolve);
|
|
4470
4656
|
upstreamRequest.once("error", reject);
|
|
4471
|
-
if (bufferedBody === void 0) bodyDone = pipeline(request$
|
|
4657
|
+
if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
4472
4658
|
else {
|
|
4473
4659
|
upstreamRequest.end(bufferedBody);
|
|
4474
4660
|
bodyDone = Promise.resolve();
|
|
@@ -4478,9 +4664,9 @@ var MobileAccessGateway = class {
|
|
|
4478
4664
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
4479
4665
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
4480
4666
|
const statusCode = proxied.statusCode ?? 502;
|
|
4481
|
-
const cacheControl = revisionedStaticCacheControl(request$
|
|
4667
|
+
const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
|
|
4482
4668
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
4483
|
-
const compressed = shouldCompressResponse(request$
|
|
4669
|
+
const compressed = shouldCompressResponse(request$3, proxied);
|
|
4484
4670
|
if (compressed) {
|
|
4485
4671
|
delete headers["accept-ranges"];
|
|
4486
4672
|
delete headers["content-length"];
|
|
@@ -4810,7 +4996,10 @@ var MobileAccessGateway = class {
|
|
|
4810
4996
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
4811
4997
|
if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
|
|
4812
4998
|
const opened = await this.access.openPairing(body.ttlMs);
|
|
4813
|
-
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}`;
|
|
4814
5003
|
const appPairUrl = pairUrl;
|
|
4815
5004
|
let qrSvg = "";
|
|
4816
5005
|
try {
|
|
@@ -4821,7 +5010,7 @@ var MobileAccessGateway = class {
|
|
|
4821
5010
|
} catch {}
|
|
4822
5011
|
sendJson(response, 201, {
|
|
4823
5012
|
...opened,
|
|
4824
|
-
appKey
|
|
5013
|
+
appKey,
|
|
4825
5014
|
pairUrl,
|
|
4826
5015
|
appPairUrl,
|
|
4827
5016
|
qrSvg
|
|
@@ -5486,8 +5675,6 @@ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
|
|
|
5486
5675
|
const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
|
|
5487
5676
|
/** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
|
|
5488
5677
|
const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
|
|
5489
|
-
/** Directory holding the public-IPv4 certificates installed by certbot. */
|
|
5490
|
-
const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
|
|
5491
5678
|
function publicIpv4Address(value) {
|
|
5492
5679
|
const parts = value.split(".");
|
|
5493
5680
|
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
|
|
@@ -5505,8 +5692,31 @@ function parsePublicOrigin(publicOrigin) {
|
|
|
5505
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");
|
|
5506
5693
|
return url.hostname;
|
|
5507
5694
|
}
|
|
5508
|
-
|
|
5509
|
-
|
|
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);
|
|
5510
5720
|
if (publicIpv4Address(publicHost)) return [
|
|
5511
5721
|
"{",
|
|
5512
5722
|
` default_sni ${publicHost}`,
|
|
@@ -5518,14 +5728,14 @@ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
|
|
|
5518
5728
|
"",
|
|
5519
5729
|
`https://${publicHost} {`,
|
|
5520
5730
|
` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
|
|
5521
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5731
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5522
5732
|
"}",
|
|
5523
5733
|
""
|
|
5524
5734
|
].join("\n");
|
|
5525
5735
|
if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
|
|
5526
5736
|
return [
|
|
5527
5737
|
`${publicHost} {`,
|
|
5528
|
-
` reverse_proxy 127.0.0.1:${String(
|
|
5738
|
+
` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
|
|
5529
5739
|
"}",
|
|
5530
5740
|
""
|
|
5531
5741
|
].join("\n");
|
|
@@ -5548,15 +5758,24 @@ function manualIpCertificateGuide(publicHost) {
|
|
|
5548
5758
|
"#"
|
|
5549
5759
|
].join("\n");
|
|
5550
5760
|
}
|
|
5551
|
-
/**
|
|
5552
|
-
|
|
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 = {}) {
|
|
5553
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";
|
|
5554
5773
|
const publicHost = parsePublicOrigin(publicOrigin);
|
|
5555
5774
|
const lines = [
|
|
5556
5775
|
"# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
|
|
5557
5776
|
`bindPort = ${String(serverPort)}`,
|
|
5558
5777
|
"proxyBindAddr = \"127.0.0.1\"",
|
|
5559
|
-
`vhostHTTPPort = ${String(
|
|
5778
|
+
`vhostHTTPPort = ${String(vhostHttpPort)}`,
|
|
5560
5779
|
"auth.method = \"token\"",
|
|
5561
5780
|
`auth.token = ${JSON.stringify(token)}`,
|
|
5562
5781
|
"",
|
|
@@ -5567,7 +5786,10 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5567
5786
|
"# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
|
|
5568
5787
|
"# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
|
|
5569
5788
|
`${FRP_CADDY_SNIPPET_MARKER}`,
|
|
5570
|
-
createCaddySite(publicHost
|
|
5789
|
+
createCaddySite(publicHost, {
|
|
5790
|
+
certDir,
|
|
5791
|
+
vhostHttpPort
|
|
5792
|
+
}).trimEnd(),
|
|
5571
5793
|
""
|
|
5572
5794
|
];
|
|
5573
5795
|
if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
|
|
@@ -5576,6 +5798,26 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5576
5798
|
//#endregion
|
|
5577
5799
|
//#region src/frp-config.ts
|
|
5578
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
|
+
]);
|
|
5579
5821
|
function hostname$2(value) {
|
|
5580
5822
|
if (value.length > 253 || !value.includes(".")) return false;
|
|
5581
5823
|
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
@@ -5611,24 +5853,90 @@ function validateFrpPublicOrigin(value) {
|
|
|
5611
5853
|
if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5612
5854
|
return url.origin;
|
|
5613
5855
|
}
|
|
5614
|
-
/**
|
|
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
|
+
*/
|
|
5615
5915
|
function parseFrpSettings(value) {
|
|
5616
5916
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
|
|
5617
5917
|
const record = value;
|
|
5618
|
-
if (Reflect.ownKeys(record).some((key) => !
|
|
5619
|
-
"version",
|
|
5620
|
-
"serverAddress",
|
|
5621
|
-
"serverPort",
|
|
5622
|
-
"token",
|
|
5623
|
-
"publicOrigin"
|
|
5624
|
-
].includes(String(key)))) throw new Error("frp_settings_invalid");
|
|
5918
|
+
if (Reflect.ownKeys(record).some((key) => !FRP_SETTINGS_KEYS.has(String(key)))) throw new Error("frp_settings_invalid");
|
|
5625
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");
|
|
5626
5930
|
return Object.freeze({
|
|
5627
5931
|
version: 1,
|
|
5628
5932
|
serverAddress: validateFrpServerAddress(record.serverAddress),
|
|
5629
5933
|
serverPort: validateFrpServerPort(record.serverPort),
|
|
5630
5934
|
token: validateFrpToken(record.token),
|
|
5631
|
-
publicOrigin
|
|
5935
|
+
publicOrigin,
|
|
5936
|
+
...mode === void 0 ? {} : { mode },
|
|
5937
|
+
...entryTls === void 0 ? {} : { entryTls },
|
|
5938
|
+
...vhostHttpPort === void 0 ? {} : { vhostHttpPort },
|
|
5939
|
+
...publicPort === void 0 ? {} : { publicPort }
|
|
5632
5940
|
});
|
|
5633
5941
|
}
|
|
5634
5942
|
/**
|
|
@@ -5638,11 +5946,24 @@ function parseFrpSettings(value) {
|
|
|
5638
5946
|
* missing configuration instead of silently deploying blanks.
|
|
5639
5947
|
*/
|
|
5640
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");
|
|
5641
5958
|
const merged = {
|
|
5642
5959
|
serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
|
|
5643
5960
|
serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
|
|
5644
5961
|
token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
|
|
5645
|
-
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 }
|
|
5646
5967
|
};
|
|
5647
5968
|
if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
|
|
5648
5969
|
return parseFrpSettings(merged);
|
|
@@ -5660,11 +5981,16 @@ function mergeSavedFrpTarget(partial, saved) {
|
|
|
5660
5981
|
function tomlString(value) {
|
|
5661
5982
|
return JSON.stringify(value);
|
|
5662
5983
|
}
|
|
5663
|
-
/**
|
|
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
|
+
*/
|
|
5664
5991
|
function createFrpcToml(settings, localPort) {
|
|
5665
5992
|
if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
|
|
5666
|
-
const
|
|
5667
|
-
return [
|
|
5993
|
+
const header = [
|
|
5668
5994
|
`serverAddr = ${tomlString(settings.serverAddress)}`,
|
|
5669
5995
|
`serverPort = ${String(settings.serverPort)}`,
|
|
5670
5996
|
"auth.method = \"token\"",
|
|
@@ -5672,7 +5998,21 @@ function createFrpcToml(settings, localPort) {
|
|
|
5672
5998
|
"transport.tls.enable = true",
|
|
5673
5999
|
"",
|
|
5674
6000
|
"[[proxies]]",
|
|
5675
|
-
"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,
|
|
5676
6016
|
"type = \"http\"",
|
|
5677
6017
|
"localIP = \"127.0.0.1\"",
|
|
5678
6018
|
`localPort = ${String(localPort)}`,
|
|
@@ -5684,7 +6024,10 @@ function createFrpcToml(settings, localPort) {
|
|
|
5684
6024
|
}
|
|
5685
6025
|
/** Build the matching restricted frps and Caddy templates for one VPS. */
|
|
5686
6026
|
function createFrpServerTemplate(settings) {
|
|
5687
|
-
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
|
+
});
|
|
5688
6031
|
}
|
|
5689
6032
|
async function atomicPrivateWrite$2(file, body) {
|
|
5690
6033
|
const directory = dirname(file);
|
|
@@ -5747,9 +6090,17 @@ var FrpConfigStore = class {
|
|
|
5747
6090
|
this.errorCode = "frp_config_invalid";
|
|
5748
6091
|
}
|
|
5749
6092
|
}
|
|
5750
|
-
/**
|
|
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
|
+
*/
|
|
5751
6100
|
status() {
|
|
5752
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);
|
|
5753
6104
|
return Object.freeze({
|
|
5754
6105
|
configured: settings !== void 0,
|
|
5755
6106
|
...settings === void 0 ? {} : {
|
|
@@ -5757,9 +6108,12 @@ var FrpConfigStore = class {
|
|
|
5757
6108
|
serverPort: settings.serverPort,
|
|
5758
6109
|
publicOrigin: settings.publicOrigin
|
|
5759
6110
|
},
|
|
5760
|
-
vhostHttpPort: FRP_VHOST_HTTP_PORT,
|
|
6111
|
+
vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
|
|
5761
6112
|
storagePath: this.stateRoot,
|
|
5762
|
-
...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 }
|
|
5763
6117
|
});
|
|
5764
6118
|
}
|
|
5765
6119
|
/** Return private settings only to the provider lifecycle. */
|
|
@@ -5794,133 +6148,1159 @@ var FrpConfigStore = class {
|
|
|
5794
6148
|
}
|
|
5795
6149
|
};
|
|
5796
6150
|
//#endregion
|
|
5797
|
-
//#region src/
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
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 不打码会随复制进入系统剪贴板,故此处只显示占位符。`;
|
|
5809
6169
|
}
|
|
5810
|
-
/**
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
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;
|
|
5814
6186
|
}
|
|
5815
6187
|
/**
|
|
5816
|
-
*
|
|
5817
|
-
*
|
|
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.
|
|
5818
6193
|
*/
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
const previous = this.controllers[this.selectedValue];
|
|
5846
|
-
const restore = previous.status().enabled;
|
|
5847
|
-
if (restore) await previous.setEnabled(false);
|
|
5848
|
-
try {
|
|
5849
|
-
await this.store.save({
|
|
5850
|
-
version: 1,
|
|
5851
|
-
provider
|
|
5852
|
-
});
|
|
5853
|
-
this.selectedValue = provider;
|
|
5854
|
-
} catch (error) {
|
|
5855
|
-
if (restore) try {
|
|
5856
|
-
await previous.setEnabled(true);
|
|
5857
|
-
} catch (restoreError) {
|
|
5858
|
-
throw new AggregateError([error, restoreError], "remote provider selection rollback failed");
|
|
5859
|
-
}
|
|
5860
|
-
throw error;
|
|
5861
|
-
}
|
|
5862
|
-
});
|
|
5863
|
-
}
|
|
5864
|
-
enqueue(operation) {
|
|
5865
|
-
const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
|
|
5866
|
-
this.queue = task.then(() => void 0, () => void 0);
|
|
5867
|
-
return task;
|
|
5868
|
-
}
|
|
5869
|
-
async runAndEnforce(operation) {
|
|
5870
|
-
let value;
|
|
5871
|
-
let operationError;
|
|
5872
|
-
try {
|
|
5873
|
-
value = await operation();
|
|
5874
|
-
} catch (error) {
|
|
5875
|
-
operationError = error;
|
|
5876
|
-
}
|
|
5877
|
-
const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== this.selectedValue).map((provider) => this.controllers[provider].setEnabled(false)));
|
|
5878
|
-
const failure = aggregateErrors([...operationError === void 0 ? [] : [operationError], ...results.filter((result) => result.status === "rejected").map((result) => result.reason)], "remote provider operation failed");
|
|
5879
|
-
if (failure !== void 0) throw failure;
|
|
5880
|
-
return value;
|
|
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;
|
|
5881
6220
|
}
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
}
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
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"
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
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
|
+
}
|
|
7249
|
+
async runAndEnforce(operation) {
|
|
7250
|
+
let value;
|
|
7251
|
+
let operationError;
|
|
7252
|
+
try {
|
|
7253
|
+
value = await operation();
|
|
7254
|
+
} catch (error) {
|
|
7255
|
+
operationError = error;
|
|
7256
|
+
}
|
|
7257
|
+
const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== this.selectedValue).map((provider) => this.controllers[provider].setEnabled(false)));
|
|
7258
|
+
const failure = aggregateErrors([...operationError === void 0 ? [] : [operationError], ...results.filter((result) => result.status === "rejected").map((result) => result.reason)], "remote provider operation failed");
|
|
7259
|
+
if (failure !== void 0) throw failure;
|
|
7260
|
+
return value;
|
|
7261
|
+
}
|
|
7262
|
+
};
|
|
7263
|
+
/** Stop an owned provider process and do not report completion before its close event. */
|
|
7264
|
+
async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTimeoutMs = 1500) {
|
|
7265
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
7266
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
7267
|
+
let gracefulTimer;
|
|
7268
|
+
let forcedTimer;
|
|
7269
|
+
let settled = false;
|
|
7270
|
+
const finish = (error) => {
|
|
7271
|
+
if (settled) return;
|
|
7272
|
+
settled = true;
|
|
7273
|
+
if (gracefulTimer !== void 0) clearTimeout(gracefulTimer);
|
|
7274
|
+
if (forcedTimer !== void 0) clearTimeout(forcedTimer);
|
|
7275
|
+
child.off("close", onClose);
|
|
7276
|
+
if (error === void 0) resolveClose();
|
|
7277
|
+
else rejectClose(error);
|
|
7278
|
+
};
|
|
7279
|
+
const onClose = () => {
|
|
7280
|
+
finish();
|
|
7281
|
+
};
|
|
7282
|
+
child.once("close", onClose);
|
|
7283
|
+
try {
|
|
7284
|
+
child.kill("SIGTERM");
|
|
7285
|
+
} catch (error) {
|
|
7286
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
7287
|
+
return;
|
|
7288
|
+
}
|
|
7289
|
+
if (settled) return;
|
|
7290
|
+
gracefulTimer = setTimeout(() => {
|
|
7291
|
+
try {
|
|
7292
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
7293
|
+
} catch (error) {
|
|
7294
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
7295
|
+
return;
|
|
7296
|
+
}
|
|
7297
|
+
if (settled) return;
|
|
7298
|
+
forcedTimer = setTimeout(() => {
|
|
7299
|
+
finish(/* @__PURE__ */ new Error("remote_process_stop_timeout"));
|
|
7300
|
+
}, forcedTimeoutMs);
|
|
7301
|
+
forcedTimer.unref();
|
|
7302
|
+
}, gracefulTimeoutMs);
|
|
7303
|
+
gracefulTimer.unref();
|
|
5924
7304
|
});
|
|
5925
7305
|
}
|
|
5926
7306
|
/** Validate the provider selection loaded across the filesystem boundary. */
|
|
@@ -6003,6 +7383,7 @@ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
|
|
|
6003
7383
|
const DISCOVERY_RETRY_MS = 1e3;
|
|
6004
7384
|
const MAX_DISCOVERY_BYTES = 16384;
|
|
6005
7385
|
const VHOST_PROBE_TIMEOUT_MS = 1500;
|
|
7386
|
+
const INGRESS_CERT_CHECK_MS = 432e5;
|
|
6006
7387
|
function publicStatus$3(status) {
|
|
6007
7388
|
return Object.freeze({
|
|
6008
7389
|
enabled: status.enabled,
|
|
@@ -6097,38 +7478,108 @@ async function boundedResponseBytes(response) {
|
|
|
6097
7478
|
}
|
|
6098
7479
|
return bytes;
|
|
6099
7480
|
}
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
const
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
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,
|
|
6110
7543
|
method: "GET",
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
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);
|
|
6115
7554
|
});
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
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");
|
|
6131
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;
|
|
6132
7583
|
}
|
|
6133
7584
|
/** Owns frpc, its generation-specific configuration, and the remote gateway. */
|
|
6134
7585
|
var FrpController = class {
|
|
@@ -6145,10 +7596,12 @@ var FrpController = class {
|
|
|
6145
7596
|
});
|
|
6146
7597
|
queue = Promise.resolve();
|
|
6147
7598
|
startupAbort;
|
|
7599
|
+
ingressCertificateTimer;
|
|
6148
7600
|
constructor(options) {
|
|
6149
7601
|
this.options = options;
|
|
6150
7602
|
if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
|
|
6151
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");
|
|
6152
7605
|
}
|
|
6153
7606
|
/** Restore the remembered FRP switch without changing LAN or other providers. */
|
|
6154
7607
|
async initialize() {
|
|
@@ -6268,41 +7721,45 @@ var FrpController = class {
|
|
|
6268
7721
|
});
|
|
6269
7722
|
return;
|
|
6270
7723
|
}
|
|
7724
|
+
const entryOrigin = frpEntryOrigin(settings);
|
|
6271
7725
|
this.publish({
|
|
6272
7726
|
enabled: true,
|
|
6273
7727
|
state: "starting",
|
|
6274
|
-
origin:
|
|
7728
|
+
origin: entryOrigin
|
|
6275
7729
|
});
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
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
|
+
}
|
|
6296
7752
|
}
|
|
6297
7753
|
let gateway;
|
|
6298
7754
|
try {
|
|
6299
|
-
gateway = await this.options.createGateway(settings.publicOrigin);
|
|
6300
|
-
} 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";
|
|
6301
7758
|
this.publish({
|
|
6302
7759
|
enabled: true,
|
|
6303
7760
|
state: "error",
|
|
6304
|
-
origin:
|
|
6305
|
-
errorCode:
|
|
7761
|
+
origin: entryOrigin,
|
|
7762
|
+
errorCode: code
|
|
6306
7763
|
});
|
|
6307
7764
|
return;
|
|
6308
7765
|
}
|
|
@@ -6341,26 +7798,66 @@ var FrpController = class {
|
|
|
6341
7798
|
this.publish({
|
|
6342
7799
|
enabled: true,
|
|
6343
7800
|
state: "connecting",
|
|
6344
|
-
origin:
|
|
7801
|
+
origin: entryOrigin
|
|
6345
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;
|
|
6346
7812
|
const controller = new AbortController();
|
|
6347
7813
|
this.startupAbort = controller;
|
|
6348
|
-
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
|
+
});
|
|
6349
7841
|
}
|
|
6350
|
-
async waitForDiscovery(generation,
|
|
7842
|
+
async waitForDiscovery(generation, check, advertisedInstanceId, settings, gateway, signal) {
|
|
6351
7843
|
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
|
|
6352
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
|
+
});
|
|
6353
7849
|
while (!signal.aborted && Date.now() < deadline) {
|
|
6354
7850
|
try {
|
|
6355
|
-
if (await probe(
|
|
7851
|
+
if (await probe(target, advertisedInstanceId, signal)) {
|
|
6356
7852
|
await this.enqueue(async () => {
|
|
6357
7853
|
if (generation !== this.generation || signal.aborted || !this.enabled) return;
|
|
6358
7854
|
this.startupAbort = void 0;
|
|
6359
7855
|
this.publish({
|
|
6360
7856
|
enabled: true,
|
|
6361
7857
|
state: "ready",
|
|
6362
|
-
origin
|
|
7858
|
+
origin: check.publicOrigin
|
|
6363
7859
|
});
|
|
7860
|
+
this.scheduleIngressCertificateCheck(generation, settings, gateway);
|
|
6364
7861
|
});
|
|
6365
7862
|
return;
|
|
6366
7863
|
}
|
|
@@ -6387,6 +7884,33 @@ var FrpController = class {
|
|
|
6387
7884
|
}
|
|
6388
7885
|
if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
|
|
6389
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
|
+
}
|
|
6390
7914
|
async failGeneration(generation, code) {
|
|
6391
7915
|
if (generation !== this.generation) return;
|
|
6392
7916
|
await this.stopProcessAndGateway();
|
|
@@ -6401,6 +7925,8 @@ var FrpController = class {
|
|
|
6401
7925
|
await this.stopProcessAndGateway();
|
|
6402
7926
|
}
|
|
6403
7927
|
async stopProcessAndGateway() {
|
|
7928
|
+
clearTimeout(this.ingressCertificateTimer);
|
|
7929
|
+
this.ingressCertificateTimer = void 0;
|
|
6404
7930
|
this.startupAbort?.abort();
|
|
6405
7931
|
this.startupAbort = void 0;
|
|
6406
7932
|
const child = this.child;
|
|
@@ -7981,21 +9507,212 @@ function defaultFirewallProbe(platform = process.platform) {
|
|
|
7981
9507
|
}
|
|
7982
9508
|
};
|
|
7983
9509
|
}
|
|
7984
|
-
/**
|
|
7985
|
-
function remoteDiagnosticTimeoutMs(
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
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
|
+
};
|
|
7989
9708
|
}
|
|
7990
|
-
async function
|
|
7991
|
-
if (origin === void 0) return { state: "not-applicable" };
|
|
7992
|
-
const hostname = new URL(origin).hostname;
|
|
9709
|
+
async function fetchRemoteHealth(target, budgetMs) {
|
|
7993
9710
|
const started = performance.now();
|
|
7994
9711
|
try {
|
|
7995
|
-
const response = await fetch(
|
|
9712
|
+
const response = await fetch(target, {
|
|
7996
9713
|
cache: "no-store",
|
|
7997
9714
|
redirect: "error",
|
|
7998
|
-
signal: AbortSignal.timeout(
|
|
9715
|
+
signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
|
|
7999
9716
|
});
|
|
8000
9717
|
const latencyMs = Math.max(0, Math.round(performance.now() - started));
|
|
8001
9718
|
if (response.status === 429) return {
|
|
@@ -8010,19 +9727,53 @@ async function defaultRemoteProbe(origin) {
|
|
|
8010
9727
|
latencyMs
|
|
8011
9728
|
};
|
|
8012
9729
|
} catch {
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
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
|
|
8023
9764
|
};
|
|
9765
|
+
return status >= 200 && status < 300 ? {
|
|
9766
|
+
state: "ready",
|
|
9767
|
+
latencyMs,
|
|
9768
|
+
viaProxy: true
|
|
9769
|
+
} : observation;
|
|
9770
|
+
} catch {
|
|
9771
|
+
return observation;
|
|
8024
9772
|
}
|
|
8025
9773
|
}
|
|
9774
|
+
async function defaultRemoteProbe(origin) {
|
|
9775
|
+
return probeRemoteHealth(origin);
|
|
9776
|
+
}
|
|
8026
9777
|
function reportLine(entry) {
|
|
8027
9778
|
return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
|
|
8028
9779
|
}
|
|
@@ -8052,10 +9803,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
8052
9803
|
const facts = {
|
|
8053
9804
|
provider: snapshot.remote.provider,
|
|
8054
9805
|
endpointSuffix,
|
|
8055
|
-
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
|
|
9806
|
+
...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
|
|
9807
|
+
...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
|
|
8056
9808
|
};
|
|
8057
|
-
if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
|
|
8058
|
-
else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
|
|
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));
|
|
8059
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));
|
|
8060
9812
|
else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
|
|
8061
9813
|
} else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
|
|
@@ -10618,394 +12370,76 @@ var PluginReleaseManager = class {
|
|
|
10618
12370
|
return status;
|
|
10619
12371
|
}
|
|
10620
12372
|
/** Install the latest npm release into the active profile, then require a DSH restart. */
|
|
10621
|
-
async update() {
|
|
10622
|
-
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
10623
|
-
this.activeUpdate = this.updateOnce();
|
|
10624
|
-
try {
|
|
10625
|
-
return await this.activeUpdate;
|
|
10626
|
-
} finally {
|
|
10627
|
-
this.activeUpdate = void 0;
|
|
10628
|
-
}
|
|
10629
|
-
}
|
|
10630
|
-
async updateOnce() {
|
|
10631
|
-
const profileDirectory = this.profileDirectory;
|
|
10632
|
-
if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
|
|
10633
|
-
const status = await this.status(true);
|
|
10634
|
-
if (!status.updateSupported) throw new Error("plugin_update_unsupported");
|
|
10635
|
-
if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
|
|
10636
|
-
await this.runner(profileDirectory, status.latestVersion);
|
|
10637
|
-
const installed = await this.installedVersionReader(profileDirectory);
|
|
10638
|
-
if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
|
|
10639
|
-
this.cache = void 0;
|
|
10640
|
-
return Object.freeze({
|
|
10641
|
-
installedVersion: installed,
|
|
10642
|
-
restartRequired: true
|
|
10643
|
-
});
|
|
10644
|
-
}
|
|
10645
|
-
};
|
|
10646
|
-
//#endregion
|
|
10647
|
-
//#region src/file-logger.ts
|
|
10648
|
-
const MAX_LOG_BYTES = 5242880;
|
|
10649
|
-
/** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
|
|
10650
|
-
async function installMobileFileLogger(ctx, stateDirectory) {
|
|
10651
|
-
const directory = join(stateDirectory, "logs");
|
|
10652
|
-
const file = join(directory, "dsh-mobile.log");
|
|
10653
|
-
const previous = `${file}.1`;
|
|
10654
|
-
await mkdir(directory, {
|
|
10655
|
-
recursive: true,
|
|
10656
|
-
mode: 448
|
|
10657
|
-
});
|
|
10658
|
-
const entry = await lstat(file).catch(() => void 0);
|
|
10659
|
-
if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
|
|
10660
|
-
await rm(previous, { force: true });
|
|
10661
|
-
await rename(file, previous);
|
|
10662
|
-
}
|
|
10663
|
-
const stream = createWriteStream(file, {
|
|
10664
|
-
flags: "a",
|
|
10665
|
-
encoding: "utf8",
|
|
10666
|
-
mode: 384
|
|
10667
|
-
});
|
|
10668
|
-
const exporter = {
|
|
10669
|
-
colors: false,
|
|
10670
|
-
maxLength: 16384,
|
|
10671
|
-
levels: {
|
|
10672
|
-
default: -1,
|
|
10673
|
-
"dsh-mobile": 3
|
|
10674
|
-
},
|
|
10675
|
-
export(message) {
|
|
10676
|
-
if (message.name !== "dsh-mobile") return;
|
|
10677
|
-
const record = {
|
|
10678
|
-
timestamp: new Date(message.ts).toISOString(),
|
|
10679
|
-
level: message.type,
|
|
10680
|
-
logger: message.name,
|
|
10681
|
-
message: Logger.format(exporter, message)
|
|
10682
|
-
};
|
|
10683
|
-
stream.write(`${JSON.stringify(record)}\n`);
|
|
10684
|
-
}
|
|
10685
|
-
};
|
|
10686
|
-
ctx.logger.exporter(exporter);
|
|
10687
|
-
ctx.effect(() => () => {
|
|
10688
|
-
stream.end();
|
|
10689
|
-
}, "dsh-mobile file logger");
|
|
10690
|
-
return file;
|
|
10691
|
-
}
|
|
10692
|
-
//#endregion
|
|
10693
|
-
//#region src/managed-setup.ts
|
|
10694
|
-
const VIRTUAL_INTERFACE_MARKERS = [
|
|
10695
|
-
"bridge",
|
|
10696
|
-
"docker",
|
|
10697
|
-
"hyper-v",
|
|
10698
|
-
"mihomo",
|
|
10699
|
-
"radmin",
|
|
10700
|
-
"tailscale",
|
|
10701
|
-
"tap",
|
|
10702
|
-
"tun",
|
|
10703
|
-
"utun",
|
|
10704
|
-
"vbox",
|
|
10705
|
-
"veth",
|
|
10706
|
-
"virtual",
|
|
10707
|
-
"vmware",
|
|
10708
|
-
"vpn",
|
|
10709
|
-
"vethernet",
|
|
10710
|
-
"wsl",
|
|
10711
|
-
"zerotier"
|
|
10712
|
-
];
|
|
10713
|
-
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
10714
|
-
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
10715
|
-
function requiredString(value, name) {
|
|
10716
|
-
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
10717
|
-
return value;
|
|
10718
|
-
}
|
|
10719
|
-
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
10720
|
-
function parseManagedSetup(value) {
|
|
10721
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
10722
|
-
const record = value;
|
|
10723
|
-
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
10724
|
-
"version",
|
|
10725
|
-
"networkInterface",
|
|
10726
|
-
"listenPort",
|
|
10727
|
-
"upstreamOrigin",
|
|
10728
|
-
"tls"
|
|
10729
|
-
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
10730
|
-
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
10731
|
-
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
10732
|
-
const tls = record.tls;
|
|
10733
|
-
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
10734
|
-
"mode",
|
|
10735
|
-
"caCertFile",
|
|
10736
|
-
"caKeyFile",
|
|
10737
|
-
"certFile",
|
|
10738
|
-
"keyFile"
|
|
10739
|
-
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
10740
|
-
return Object.freeze({
|
|
10741
|
-
version: 2,
|
|
10742
|
-
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
10743
|
-
listenPort: record.listenPort,
|
|
10744
|
-
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
10745
|
-
tls: Object.freeze({
|
|
10746
|
-
mode: "managed",
|
|
10747
|
-
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
10748
|
-
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
10749
|
-
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
10750
|
-
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
10751
|
-
})
|
|
10752
|
-
});
|
|
10753
|
-
}
|
|
10754
|
-
function privateIpv4(value) {
|
|
10755
|
-
const parts = value.split(".").map(Number);
|
|
10756
|
-
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
10757
|
-
}
|
|
10758
|
-
function networkCidr(address, cidr) {
|
|
10759
|
-
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
10760
|
-
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
10761
|
-
return `${[
|
|
10762
|
-
24,
|
|
10763
|
-
16,
|
|
10764
|
-
8,
|
|
10765
|
-
0
|
|
10766
|
-
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
10767
|
-
}
|
|
10768
|
-
/** List current private IPv4 candidates with their interface identity. */
|
|
10769
|
-
function availableLanNetworks(table = networkInterfaces()) {
|
|
10770
|
-
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
10771
|
-
name,
|
|
10772
|
-
address: entry.address,
|
|
10773
|
-
cidr: networkCidr(entry.address, entry.cidr)
|
|
10774
|
-
})));
|
|
10775
|
-
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
10776
|
-
}
|
|
10777
|
-
function likelyVirtualInterface(name) {
|
|
10778
|
-
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
10779
|
-
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
10780
|
-
}
|
|
10781
|
-
async function runRouteCommand(file, args) {
|
|
10782
|
-
return (await execFileText(file, [...args], {
|
|
10783
|
-
encoding: "utf8",
|
|
10784
|
-
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
10785
|
-
})).stdout;
|
|
10786
|
-
}
|
|
10787
|
-
function uniqueLines(output) {
|
|
10788
|
-
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
10789
|
-
}
|
|
10790
|
-
/** Return operating-system default-route interfaces in routing preference order. */
|
|
10791
|
-
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
10792
|
-
try {
|
|
10793
|
-
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
10794
|
-
"-NoProfile",
|
|
10795
|
-
"-NonInteractive",
|
|
10796
|
-
"-Command",
|
|
10797
|
-
[
|
|
10798
|
-
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
10799
|
-
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
10800
|
-
" $route = $_",
|
|
10801
|
-
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10802
|
-
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
10803
|
-
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
10804
|
-
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
10805
|
-
" }",
|
|
10806
|
-
"}",
|
|
10807
|
-
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
10808
|
-
].join("; ")
|
|
10809
|
-
]));
|
|
10810
|
-
if (platform === "linux") {
|
|
10811
|
-
const routes = uniqueLines(await run("ip", [
|
|
10812
|
-
"-o",
|
|
10813
|
-
"-4",
|
|
10814
|
-
"route",
|
|
10815
|
-
"show",
|
|
10816
|
-
"default"
|
|
10817
|
-
])).map((line) => ({
|
|
10818
|
-
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
10819
|
-
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
10820
|
-
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
10821
|
-
return [...new Set(routes.map((route) => route.name))];
|
|
10822
|
-
}
|
|
10823
|
-
if (platform === "darwin") {
|
|
10824
|
-
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
10825
|
-
"-n",
|
|
10826
|
-
"get",
|
|
10827
|
-
"default"
|
|
10828
|
-
]))?.[1];
|
|
10829
|
-
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
10830
|
-
}
|
|
10831
|
-
} catch {}
|
|
10832
|
-
return [];
|
|
10833
|
-
}
|
|
10834
|
-
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
10835
|
-
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
10836
|
-
const candidates = availableLanNetworks(table);
|
|
10837
|
-
if (requestedAddress !== void 0) {
|
|
10838
|
-
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
10839
|
-
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
10840
|
-
return match;
|
|
10841
|
-
}
|
|
10842
|
-
if (requestedInterface !== void 0) {
|
|
10843
|
-
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
10844
|
-
if (matches.length === 1) return matches[0];
|
|
10845
|
-
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
10846
|
-
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
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
|
+
}
|
|
10847
12381
|
}
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
const
|
|
10852
|
-
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
|
+
});
|
|
10853
12396
|
}
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
/**
|
|
10859
|
-
|
|
10860
|
-
|
|
10861
|
-
|
|
10862
|
-
|
|
10863
|
-
*/
|
|
10864
|
-
function isNetworkSelectionError(error) {
|
|
10865
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
10866
|
-
return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
|
|
10867
|
-
}
|
|
10868
|
-
function assertMatchingCa(certPem, keyPem) {
|
|
10869
|
-
const certificate = new X509Certificate(certPem);
|
|
10870
|
-
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
10871
|
-
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
10872
|
-
format: "der",
|
|
10873
|
-
type: "spki"
|
|
10874
|
-
});
|
|
10875
|
-
const certificatePublic = certificate.publicKey.export({
|
|
10876
|
-
format: "der",
|
|
10877
|
-
type: "spki"
|
|
10878
|
-
});
|
|
10879
|
-
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
10880
|
-
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
10881
|
-
return certificate;
|
|
10882
|
-
}
|
|
10883
|
-
async function atomicWrite(file, contents) {
|
|
10884
|
-
const directory = dirname(file);
|
|
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`;
|
|
10885
12406
|
await mkdir(directory, {
|
|
10886
12407
|
recursive: true,
|
|
10887
12408
|
mode: 448
|
|
10888
12409
|
});
|
|
10889
|
-
const
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
}
|
|
10894
|
-
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
10895
|
-
async function ensureManagedCa(setup, legacy) {
|
|
10896
|
-
let certPem;
|
|
10897
|
-
let keyPem;
|
|
10898
|
-
try {
|
|
10899
|
-
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
10900
|
-
} catch (error) {
|
|
10901
|
-
if (error.code !== "ENOENT") throw error;
|
|
10902
|
-
let migrated = false;
|
|
10903
|
-
if (legacy !== void 0) try {
|
|
10904
|
-
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
10905
|
-
assertMatchingCa(certPem, keyPem);
|
|
10906
|
-
migrated = true;
|
|
10907
|
-
} catch (legacyError) {
|
|
10908
|
-
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
10909
|
-
}
|
|
10910
|
-
if (!migrated) {
|
|
10911
|
-
const now = /* @__PURE__ */ new Date();
|
|
10912
|
-
const notAfter = new Date(now);
|
|
10913
|
-
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
10914
|
-
const generated = await generate([{
|
|
10915
|
-
name: "commonName",
|
|
10916
|
-
value: "DeepSeek Harness Mobile CA"
|
|
10917
|
-
}], {
|
|
10918
|
-
keyType: "ec",
|
|
10919
|
-
curve: "P-256",
|
|
10920
|
-
algorithm: "sha256",
|
|
10921
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10922
|
-
notAfterDate: notAfter,
|
|
10923
|
-
extensions: [{
|
|
10924
|
-
name: "basicConstraints",
|
|
10925
|
-
cA: true,
|
|
10926
|
-
critical: true
|
|
10927
|
-
}, {
|
|
10928
|
-
name: "keyUsage",
|
|
10929
|
-
digitalSignature: true,
|
|
10930
|
-
keyCertSign: true,
|
|
10931
|
-
cRLSign: true,
|
|
10932
|
-
critical: true
|
|
10933
|
-
}]
|
|
10934
|
-
});
|
|
10935
|
-
certPem = generated.cert;
|
|
10936
|
-
keyPem = generated.private;
|
|
10937
|
-
}
|
|
10938
|
-
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
10939
|
-
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
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);
|
|
10940
12414
|
}
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
10946
|
-
async function refreshManagedServerCertificate(setup, address) {
|
|
10947
|
-
await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
|
|
10948
|
-
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
10949
|
-
assertMatchingCa(caCert, caKey);
|
|
10950
|
-
const now = /* @__PURE__ */ new Date();
|
|
10951
|
-
const notAfter = new Date(now);
|
|
10952
|
-
notAfter.setDate(notAfter.getDate() + 397);
|
|
10953
|
-
const server = await generate([{
|
|
10954
|
-
name: "commonName",
|
|
10955
|
-
value: "DeepSeek Harness Mobile"
|
|
10956
|
-
}], {
|
|
10957
|
-
keyType: "ec",
|
|
10958
|
-
curve: "P-256",
|
|
10959
|
-
algorithm: "sha256",
|
|
10960
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
10961
|
-
notAfterDate: notAfter,
|
|
10962
|
-
ca: {
|
|
10963
|
-
cert: caCert,
|
|
10964
|
-
key: caKey
|
|
10965
|
-
},
|
|
10966
|
-
extensions: [
|
|
10967
|
-
{
|
|
10968
|
-
name: "basicConstraints",
|
|
10969
|
-
cA: false,
|
|
10970
|
-
critical: true
|
|
10971
|
-
},
|
|
10972
|
-
{
|
|
10973
|
-
name: "keyUsage",
|
|
10974
|
-
digitalSignature: true,
|
|
10975
|
-
critical: true
|
|
10976
|
-
},
|
|
10977
|
-
{
|
|
10978
|
-
name: "extKeyUsage",
|
|
10979
|
-
serverAuth: true
|
|
10980
|
-
},
|
|
10981
|
-
{
|
|
10982
|
-
name: "subjectAltName",
|
|
10983
|
-
altNames: [{
|
|
10984
|
-
type: 7,
|
|
10985
|
-
ip: address
|
|
10986
|
-
}]
|
|
10987
|
-
}
|
|
10988
|
-
]
|
|
12415
|
+
const stream = createWriteStream(file, {
|
|
12416
|
+
flags: "a",
|
|
12417
|
+
encoding: "utf8",
|
|
12418
|
+
mode: 384
|
|
10989
12419
|
});
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
|
|
10995
|
-
|
|
10996
|
-
|
|
10997
|
-
|
|
10998
|
-
|
|
10999
|
-
|
|
11000
|
-
|
|
11001
|
-
|
|
11002
|
-
|
|
11003
|
-
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
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`);
|
|
11007
12436
|
}
|
|
11008
12437
|
};
|
|
12438
|
+
ctx.logger.exporter(exporter);
|
|
12439
|
+
ctx.effect(() => () => {
|
|
12440
|
+
stream.end();
|
|
12441
|
+
}, "dsh-mobile file logger");
|
|
12442
|
+
return file;
|
|
11009
12443
|
}
|
|
11010
12444
|
//#endregion
|
|
11011
12445
|
//#region src/lan-setup.ts
|
|
@@ -11269,6 +12703,56 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
11269
12703
|
discovery: false
|
|
11270
12704
|
});
|
|
11271
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
|
+
}
|
|
11272
12756
|
/** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
|
|
11273
12757
|
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
|
|
11274
12758
|
const validated = parseOriginSettings(settings);
|
|
@@ -11463,6 +12947,27 @@ async function apply(ctx, config) {
|
|
|
11463
12947
|
await candidate.start();
|
|
11464
12948
|
return candidate;
|
|
11465
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
|
+
};
|
|
11466
12971
|
const createOriginGateway = async (settings) => {
|
|
11467
12972
|
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
11468
12973
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
@@ -11504,7 +13009,18 @@ async function apply(ctx, config) {
|
|
|
11504
13009
|
executable: frpComponent.executable,
|
|
11505
13010
|
config: frpConfig,
|
|
11506
13011
|
instanceId,
|
|
11507
|
-
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
|
+
}
|
|
11508
13024
|
}),
|
|
11509
13025
|
origin: new OriginController({
|
|
11510
13026
|
store: originStore,
|
|
@@ -11566,6 +13082,25 @@ async function apply(ctx, config) {
|
|
|
11566
13082
|
networkError = "network_interface_unavailable";
|
|
11567
13083
|
}
|
|
11568
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;
|
|
11569
13104
|
return collectConnectionDiagnostics({
|
|
11570
13105
|
dshVersion,
|
|
11571
13106
|
lan: {
|
|
@@ -11589,7 +13124,7 @@ async function apply(ctx, config) {
|
|
|
11589
13124
|
...remote.origin === void 0 ? {} : { origin: remote.origin },
|
|
11590
13125
|
...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
|
|
11591
13126
|
}
|
|
11592
|
-
});
|
|
13127
|
+
}, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
|
|
11593
13128
|
};
|
|
11594
13129
|
const adminRoute = {
|
|
11595
13130
|
kind: "prefix",
|
|
@@ -11767,6 +13302,34 @@ async function apply(ctx, config) {
|
|
|
11767
13302
|
sendJson(response, 200, remotePayload(), false);
|
|
11768
13303
|
return;
|
|
11769
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
|
+
}
|
|
11770
13333
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/websocket-paths`) {
|
|
11771
13334
|
sendJson(response, 200, { paths: webSocketPaths.list() }, false);
|
|
11772
13335
|
return;
|
|
@@ -11867,7 +13430,11 @@ async function apply(ctx, config) {
|
|
|
11867
13430
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11868
13431
|
await remoteProviders.mutate(async () => {
|
|
11869
13432
|
await remoteControllers.frp.setEnabled(false);
|
|
11870
|
-
await Promise.all([
|
|
13433
|
+
await Promise.all([
|
|
13434
|
+
frpComponent.purge(),
|
|
13435
|
+
frpConfig.purge(),
|
|
13436
|
+
purgeFrpIngressCertificates(remoteDeviceFile)
|
|
13437
|
+
]);
|
|
11871
13438
|
});
|
|
11872
13439
|
sendJson(response, 200, remotePayload(), false);
|
|
11873
13440
|
return;
|
|
@@ -12010,6 +13577,6 @@ async function apply(ctx, config) {
|
|
|
12010
13577
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
12011
13578
|
}
|
|
12012
13579
|
//#endregion
|
|
12013
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isCloudflaredRegistration, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
|
|
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 };
|
|
12014
13581
|
|
|
12015
13582
|
//# sourceMappingURL=index.mjs.map
|