dsh-mobile 0.4.1 → 0.4.2
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 +22 -4
- package/README.en.md +24 -14
- package/README.md +24 -14
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/CLOUDFLARE_TUNNEL.en.md +113 -0
- package/docs/CLOUDFLARE_TUNNEL.md +113 -0
- package/docs/README.md +25 -0
- package/docs/SELF_HOSTED_FRP.en.md +2 -0
- package/docs/SELF_HOSTED_ORIGIN.en.md +34 -0
- package/docs/SELF_HOSTED_ORIGIN.md +85 -0
- package/lib/client.js +1121 -98
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +358 -3
- package/lib/index.mjs +1759 -246
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-compat.js +2 -0
- package/lib/mobile-compat.js.map +1 -0
- package/package.json +9 -1
package/lib/index.mjs
CHANGED
|
@@ -116,8 +116,10 @@ var AccessController = class {
|
|
|
116
116
|
async initialize() {
|
|
117
117
|
if (this.initialized || this.closing) throw new Error("access controller cannot be initialized again");
|
|
118
118
|
const snapshot = await this.store.load();
|
|
119
|
-
|
|
120
|
-
this.
|
|
119
|
+
const devices = snapshot.devices.filter((device) => device.revokedAt === void 0);
|
|
120
|
+
if (devices.length > this.options.maxDevices) throw new Error("device state exceeds configured maxDevices");
|
|
121
|
+
if (devices.length !== snapshot.devices.length) await this.store.save(this.snapshot(devices));
|
|
122
|
+
this.devices = devices;
|
|
121
123
|
this.initialized = true;
|
|
122
124
|
}
|
|
123
125
|
requireInitialized() {
|
|
@@ -312,18 +314,12 @@ var AccessController = class {
|
|
|
312
314
|
logout(authorization) {
|
|
313
315
|
this.removeSession(authorization.sessionKey, "logout");
|
|
314
316
|
}
|
|
315
|
-
/**
|
|
317
|
+
/** Durably delete the device, then end every Session owned by that device. */
|
|
316
318
|
async revokeDevice(deviceId) {
|
|
317
319
|
this.requireInitialized();
|
|
318
320
|
return this.exclusive(async () => {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
if (device === void 0 || device.revokedAt !== void 0) return false;
|
|
322
|
-
const next = [...this.devices];
|
|
323
|
-
next[index] = Object.freeze({
|
|
324
|
-
...device,
|
|
325
|
-
revokedAt: this.now()
|
|
326
|
-
});
|
|
321
|
+
const next = this.devices.filter((device) => device.id !== deviceId);
|
|
322
|
+
if (next.length === this.devices.length) return false;
|
|
327
323
|
await this.store.save(this.snapshot(next));
|
|
328
324
|
this.devices = next;
|
|
329
325
|
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
|
|
@@ -644,6 +640,7 @@ const Config = z.object({
|
|
|
644
640
|
customCssFile: z.string().hidden(),
|
|
645
641
|
customScriptFile: z.string().hidden(),
|
|
646
642
|
mobileLayoutFile: z.string().hidden(),
|
|
643
|
+
mobileCompatibilityFile: z.string().hidden(),
|
|
647
644
|
instanceId: z.string().hidden(),
|
|
648
645
|
pairingCaFile: z.string().hidden(),
|
|
649
646
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
@@ -769,6 +766,7 @@ function parseGatewayConfig(raw) {
|
|
|
769
766
|
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
770
767
|
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
771
768
|
mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
|
|
769
|
+
mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
|
|
772
770
|
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
773
771
|
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
774
772
|
})(),
|
|
@@ -1310,6 +1308,50 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1310
1308
|
if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
|
|
1311
1309
|
}
|
|
1312
1310
|
//#endregion
|
|
1311
|
+
//#region src/mobile-compat-bootstrap.ts
|
|
1312
|
+
const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
|
|
1313
|
+
/**
|
|
1314
|
+
* Read one attribute out of a tag body.
|
|
1315
|
+
*
|
|
1316
|
+
* A plain `/\snonce\s*=/` search can be fooled by a value that merely contains the
|
|
1317
|
+
* text, and cannot tell a double-quoted attribute from a single-quoted one. Walking
|
|
1318
|
+
* `name=value` pairs consumes each quoted value whole, so the attribute that is
|
|
1319
|
+
* actually present is the one returned.
|
|
1320
|
+
*/
|
|
1321
|
+
function attributeValue(tag, name) {
|
|
1322
|
+
for (const match of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*')/gu)) {
|
|
1323
|
+
const attribute = match[1];
|
|
1324
|
+
const raw = match[2];
|
|
1325
|
+
if (attribute === void 0 || raw === void 0) continue;
|
|
1326
|
+
if (attribute.toLowerCase() === name) return raw.slice(1, -1);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/** Character ranges covered by an HTML comment, where markup is inert text. */
|
|
1330
|
+
function commentRanges(html) {
|
|
1331
|
+
return [...html.matchAll(/<!--[\s\S]*?-->/gu)].map((match) => [match.index, match.index + match[0].length]);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Load the compatibility bundle synchronously, before the first DSH script.
|
|
1335
|
+
*
|
|
1336
|
+
* Returns the HTML unchanged when there is no script to anchor to. The bundle is an
|
|
1337
|
+
* enhancement for WebViews without Iterator helpers, so a document this function
|
|
1338
|
+
* cannot place it in must still be served: throwing here took the entire dedicated
|
|
1339
|
+
* mobile frontend down with a 502.
|
|
1340
|
+
*/
|
|
1341
|
+
function ensureMobileCompatibility(html) {
|
|
1342
|
+
const comments = commentRanges(html);
|
|
1343
|
+
const isInert = (at) => comments.some(([start, end]) => at >= start && at < end);
|
|
1344
|
+
const scripts = [...html.matchAll(/<script\b[^>]*>/giu)].filter((match) => !isInert(match.index));
|
|
1345
|
+
if (scripts.length === 0) return html;
|
|
1346
|
+
if (scripts.some((match) => attributeValue(match[0], "src") === MOBILE_COMPAT_PATH)) return html;
|
|
1347
|
+
const first = scripts[0];
|
|
1348
|
+
if (first === void 0) return html;
|
|
1349
|
+
const nonce = attributeValue(first[0], "nonce")?.replace(/["'<>\s]/gu, "") ?? "";
|
|
1350
|
+
const attribute = nonce === "" ? "" : ` nonce="${nonce}"`;
|
|
1351
|
+
const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
|
|
1352
|
+
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
1353
|
+
}
|
|
1354
|
+
//#endregion
|
|
1313
1355
|
//#region src/version.ts
|
|
1314
1356
|
const manifest = createRequire(import.meta.url)("../package.json");
|
|
1315
1357
|
/** Version of the installed DSH Mobile plugin package. */
|
|
@@ -1492,7 +1534,7 @@ function normalizeRelativePath(value, field) {
|
|
|
1492
1534
|
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1493
1535
|
return normalized;
|
|
1494
1536
|
}
|
|
1495
|
-
async function regularFile$
|
|
1537
|
+
async function regularFile$3(path, maximum, field) {
|
|
1496
1538
|
let info;
|
|
1497
1539
|
try {
|
|
1498
1540
|
info = await lstat(path);
|
|
@@ -1513,7 +1555,7 @@ async function containedPath(root, relativePath, maximum, field) {
|
|
|
1513
1555
|
const targetReal = await realpath(target);
|
|
1514
1556
|
const relation = relative(rootReal, targetReal);
|
|
1515
1557
|
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1516
|
-
return regularFile$
|
|
1558
|
+
return regularFile$3(targetReal, maximum, field);
|
|
1517
1559
|
}
|
|
1518
1560
|
async function optionalFile(root, name, maximum, field) {
|
|
1519
1561
|
try {
|
|
@@ -1590,7 +1632,7 @@ async function assetSnapshot(extensionRootReal) {
|
|
|
1590
1632
|
}
|
|
1591
1633
|
async function extensionFingerprint(directory) {
|
|
1592
1634
|
const root = await realExtensionRoot(directory);
|
|
1593
|
-
const manifestFile = await regularFile$
|
|
1635
|
+
const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
1594
1636
|
const manifestBody = await readFile(manifestFile.path);
|
|
1595
1637
|
const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
|
|
1596
1638
|
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
@@ -2086,7 +2128,7 @@ async function abortAndDisposeLocal(entries) {
|
|
|
2086
2128
|
}
|
|
2087
2129
|
async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
2088
2130
|
const root = await realExtensionRoot(directory);
|
|
2089
|
-
const manifestFile = await regularFile$
|
|
2131
|
+
const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
2090
2132
|
const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
|
|
2091
2133
|
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
2092
2134
|
const scriptBody = known === void 0 ? await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js").then((path) => path === void 0 ? void 0 : readFile(path)) : known.scriptBody;
|
|
@@ -2521,7 +2563,7 @@ function rewriteMobileIndexWithBatch(html) {
|
|
|
2521
2563
|
}
|
|
2522
2564
|
const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
|
|
2523
2565
|
return Object.freeze({
|
|
2524
|
-
html: ensureMobileViewport(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`),
|
|
2566
|
+
html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`)),
|
|
2525
2567
|
...mobileBatch === void 0 ? {} : { batch: mobileBatch }
|
|
2526
2568
|
});
|
|
2527
2569
|
}
|
|
@@ -2946,6 +2988,15 @@ var MobileAccessGateway = class {
|
|
|
2946
2988
|
server;
|
|
2947
2989
|
discoverySocket;
|
|
2948
2990
|
discoveryTimer;
|
|
2991
|
+
/**
|
|
2992
|
+
* Why broadcast discovery is unavailable, if it is. Windows keeps separate TCP and
|
|
2993
|
+
* UDP port-exclusion tables, so the port the OS handed the TCP listener can be
|
|
2994
|
+
* refused for UDP. That degradation is survivable but must stay observable: without
|
|
2995
|
+
* it, "the phone cannot find this computer" has no diagnosable cause on the host.
|
|
2996
|
+
*/
|
|
2997
|
+
discoveryError;
|
|
2998
|
+
/** Names of the bundled mobile assets that could not be read, if any. */
|
|
2999
|
+
mobileAssetError;
|
|
2949
3000
|
bonjour;
|
|
2950
3001
|
pairingCaCertificate;
|
|
2951
3002
|
listenerPort;
|
|
@@ -3021,6 +3072,9 @@ var MobileAccessGateway = class {
|
|
|
3021
3072
|
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
|
|
3022
3073
|
this.pairingCaCertificate = certificate.raw.toString("base64");
|
|
3023
3074
|
}
|
|
3075
|
+
const missingAssets = [];
|
|
3076
|
+
for (const [label, file] of [["compatibility", this.config.mobileCompatibilityFile], ["layout", this.config.mobileLayoutFile]]) if (!(await lstat(file).catch(() => void 0))?.isFile()) missingAssets.push(label);
|
|
3077
|
+
this.mobileAssetError = missingAssets.length === 0 ? void 0 : `mobile_assets_missing_${missingAssets.join("_")}`;
|
|
3024
3078
|
const handler = (request, response) => {
|
|
3025
3079
|
this.handleExternalRequest(request, response).catch((error) => {
|
|
3026
3080
|
const mapped = mapError(error);
|
|
@@ -3093,24 +3147,35 @@ var MobileAccessGateway = class {
|
|
|
3093
3147
|
if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
|
|
3094
3148
|
socket.send(announcement, remote.port, remote.address, () => void 0);
|
|
3095
3149
|
});
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3150
|
+
const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
|
|
3151
|
+
if (await new Promise((resolve) => {
|
|
3152
|
+
const onBindError = (error) => {
|
|
3153
|
+
socket.off("error", onBindError);
|
|
3154
|
+
this.discoveryError = Object.freeze({
|
|
3155
|
+
code: typeof error.code === "string" ? error.code : "unknown",
|
|
3156
|
+
message: typeof error.message === "string" ? error.message : String(error)
|
|
3157
|
+
});
|
|
3158
|
+
resolve(false);
|
|
3099
3159
|
};
|
|
3100
|
-
socket.once("error",
|
|
3101
|
-
const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
|
|
3160
|
+
socket.once("error", onBindError);
|
|
3102
3161
|
socket.bind(port, bindHost, () => {
|
|
3103
|
-
socket.off("error",
|
|
3162
|
+
socket.off("error", onBindError);
|
|
3104
3163
|
socket.setBroadcast(true);
|
|
3105
|
-
resolve();
|
|
3164
|
+
resolve(true);
|
|
3106
3165
|
});
|
|
3107
|
-
})
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3166
|
+
})) {
|
|
3167
|
+
const announce = () => {
|
|
3168
|
+
for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
|
|
3169
|
+
};
|
|
3170
|
+
announce();
|
|
3171
|
+
this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
|
|
3172
|
+
this.discoveryTimer.unref();
|
|
3173
|
+
} else {
|
|
3174
|
+
this.discoverySocket = void 0;
|
|
3175
|
+
try {
|
|
3176
|
+
socket.close();
|
|
3177
|
+
} catch {}
|
|
3178
|
+
}
|
|
3114
3179
|
const deviceName = discoveryDeviceName();
|
|
3115
3180
|
const bonjour = new Bonjour({ disableIPv6: true });
|
|
3116
3181
|
this.bonjour = bonjour;
|
|
@@ -3176,6 +3241,21 @@ var MobileAccessGateway = class {
|
|
|
3176
3241
|
origin
|
|
3177
3242
|
});
|
|
3178
3243
|
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Report which discovery channels this gateway actually owns.
|
|
3246
|
+
*
|
|
3247
|
+
* Broadcast discovery degrades on its own when its UDP port cannot be bound, so
|
|
3248
|
+
* callers need to tell "discovery is off" from "discovery was never attempted".
|
|
3249
|
+
* mDNS is published independently and is unaffected by that failure.
|
|
3250
|
+
*/
|
|
3251
|
+
discoveryStatus() {
|
|
3252
|
+
return Object.freeze({
|
|
3253
|
+
broadcast: this.discoverySocket !== void 0 && this.discoveryTimer !== void 0,
|
|
3254
|
+
mdns: this.bonjour !== void 0,
|
|
3255
|
+
...this.discoveryError === void 0 ? {} : { errorCode: `discovery_broadcast_${this.discoveryError.code}` },
|
|
3256
|
+
...this.mobileAssetError === void 0 ? {} : { mobileAssetsErrorCode: this.mobileAssetError }
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3179
3259
|
requirePolicy() {
|
|
3180
3260
|
if (this.policy === void 0 || this.closing) throw new HttpError(503, "unavailable");
|
|
3181
3261
|
return this.policy;
|
|
@@ -3414,6 +3494,10 @@ var MobileAccessGateway = class {
|
|
|
3414
3494
|
file: this.config.mobileLayoutFile,
|
|
3415
3495
|
contentType: "text/javascript; charset=utf-8",
|
|
3416
3496
|
fallback: void 0
|
|
3497
|
+
} : target.decodedPathname === MOBILE_COMPAT_PATH ? {
|
|
3498
|
+
file: this.config.mobileCompatibilityFile,
|
|
3499
|
+
contentType: "text/javascript; charset=utf-8",
|
|
3500
|
+
fallback: void 0
|
|
3417
3501
|
} : void 0 : void 0;
|
|
3418
3502
|
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
3419
3503
|
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
|
|
@@ -4402,7 +4486,8 @@ var MobileAccessGateway = class {
|
|
|
4402
4486
|
connections: this.connectedSockets.size,
|
|
4403
4487
|
activeRequests: this.activeRequests.size,
|
|
4404
4488
|
webSockets: this.activeWebSockets.size
|
|
4405
|
-
}
|
|
4489
|
+
},
|
|
4490
|
+
discovery: this.discoveryStatus()
|
|
4406
4491
|
}, false);
|
|
4407
4492
|
return;
|
|
4408
4493
|
}
|
|
@@ -4693,11 +4778,11 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4693
4778
|
downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_darwin_arm64.tar.gz`
|
|
4694
4779
|
}
|
|
4695
4780
|
].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
|
|
4696
|
-
function inside$
|
|
4781
|
+
function inside$2(parent, child) {
|
|
4697
4782
|
const candidate = relative(parent, child);
|
|
4698
4783
|
return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
|
|
4699
4784
|
}
|
|
4700
|
-
async function regularFile$
|
|
4785
|
+
async function regularFile$2(file) {
|
|
4701
4786
|
try {
|
|
4702
4787
|
const entry = await lstat(file);
|
|
4703
4788
|
return entry.isFile() && !entry.isSymbolicLink();
|
|
@@ -4737,7 +4822,7 @@ async function replaceDirectory(target, candidate) {
|
|
|
4737
4822
|
});
|
|
4738
4823
|
}
|
|
4739
4824
|
}
|
|
4740
|
-
function sha256$
|
|
4825
|
+
function sha256$2(bytes) {
|
|
4741
4826
|
return createHash("sha256").update(bytes).digest("hex");
|
|
4742
4827
|
}
|
|
4743
4828
|
async function runCapture(file, args) {
|
|
@@ -4789,10 +4874,10 @@ async function defaultExtractArtifact$1(archive, destination, executableName) {
|
|
|
4789
4874
|
executableEntry
|
|
4790
4875
|
]);
|
|
4791
4876
|
const extracted = join(unpacked, ...validatedArchiveEntry(executableEntry));
|
|
4792
|
-
if (!await regularFile$
|
|
4877
|
+
if (!await regularFile$2(extracted)) throw new Error("frp_archive_executable_invalid");
|
|
4793
4878
|
await copyFile(extracted, join(destination, executableName));
|
|
4794
4879
|
}
|
|
4795
|
-
async function defaultFetchArtifact$
|
|
4880
|
+
async function defaultFetchArtifact$2(artifact, signal) {
|
|
4796
4881
|
const response = await fetch(artifact.downloadUrl, {
|
|
4797
4882
|
redirect: "follow",
|
|
4798
4883
|
signal
|
|
@@ -4861,14 +4946,14 @@ var FrpComponentManager = class {
|
|
|
4861
4946
|
this.componentStorage,
|
|
4862
4947
|
this.logRoot,
|
|
4863
4948
|
this.stagingRoot
|
|
4864
|
-
]) if (!inside$
|
|
4865
|
-
this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$
|
|
4949
|
+
]) if (!inside$2(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
|
|
4950
|
+
this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$2;
|
|
4866
4951
|
this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
|
|
4867
4952
|
this.inspectExecutable = options.inspectExecutable ?? defaultInspectExecutable;
|
|
4868
4953
|
}
|
|
4869
4954
|
/** Inspect the managed executable without relying on global FRP installations. */
|
|
4870
4955
|
async initialize() {
|
|
4871
|
-
this.installed = await regularFile$
|
|
4956
|
+
this.installed = await regularFile$2(this.executable);
|
|
4872
4957
|
this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
|
|
4873
4958
|
if (this.installed) try {
|
|
4874
4959
|
if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
@@ -4915,7 +5000,7 @@ var FrpComponentManager = class {
|
|
|
4915
5000
|
clearTimeout(timeout);
|
|
4916
5001
|
}
|
|
4917
5002
|
if (bytes.byteLength !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
|
|
4918
|
-
if (sha256$
|
|
5003
|
+
if (sha256$2(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
|
|
4919
5004
|
const archive = join(staging, artifact.archiveName);
|
|
4920
5005
|
await writeFile(archive, bytes, {
|
|
4921
5006
|
flag: "wx",
|
|
@@ -4923,7 +5008,7 @@ var FrpComponentManager = class {
|
|
|
4923
5008
|
});
|
|
4924
5009
|
await this.extractArtifact(archive, staging, artifact.executableName);
|
|
4925
5010
|
const extracted = join(staging, artifact.executableName);
|
|
4926
|
-
if (!await regularFile$
|
|
5011
|
+
if (!await regularFile$2(extracted)) throw new Error("frp_executable_missing");
|
|
4927
5012
|
await chmod(extracted, 448);
|
|
4928
5013
|
if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
4929
5014
|
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
@@ -5179,8 +5264,8 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5179
5264
|
}
|
|
5180
5265
|
//#endregion
|
|
5181
5266
|
//#region src/frp-config.ts
|
|
5182
|
-
const MAX_SETTINGS_BYTES = 8192;
|
|
5183
|
-
function hostname$
|
|
5267
|
+
const MAX_SETTINGS_BYTES$1 = 8192;
|
|
5268
|
+
function hostname$2(value) {
|
|
5184
5269
|
if (value.length > 253 || !value.includes(".")) return false;
|
|
5185
5270
|
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
5186
5271
|
}
|
|
@@ -5188,7 +5273,7 @@ function hostname$1(value) {
|
|
|
5188
5273
|
function validateFrpServerAddress(value) {
|
|
5189
5274
|
if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253 || /[\s\u0000-\u001f\u007f/\\@?#]/u.test(value)) throw new Error("frp_server_address_invalid");
|
|
5190
5275
|
const normalized = value.toLowerCase().replace(/\.$/u, "");
|
|
5191
|
-
if (isIP(normalized) === 0 && !hostname$
|
|
5276
|
+
if (isIP(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
|
|
5192
5277
|
return normalized;
|
|
5193
5278
|
}
|
|
5194
5279
|
/** Validate the FRP control port. */
|
|
@@ -5211,7 +5296,7 @@ function validateFrpPublicOrigin(value) {
|
|
|
5211
5296
|
throw new Error("frp_public_origin_invalid");
|
|
5212
5297
|
}
|
|
5213
5298
|
const publicHost = url.hostname;
|
|
5214
|
-
if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$
|
|
5299
|
+
if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$2(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5215
5300
|
if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5216
5301
|
return url.origin;
|
|
5217
5302
|
}
|
|
@@ -5290,7 +5375,7 @@ function createFrpcToml(settings, localPort) {
|
|
|
5290
5375
|
function createFrpServerTemplate(settings) {
|
|
5291
5376
|
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
|
|
5292
5377
|
}
|
|
5293
|
-
async function atomicPrivateWrite(file, body) {
|
|
5378
|
+
async function atomicPrivateWrite$2(file, body) {
|
|
5294
5379
|
const directory = dirname(file);
|
|
5295
5380
|
await mkdir(directory, {
|
|
5296
5381
|
recursive: true,
|
|
@@ -5338,7 +5423,7 @@ var FrpConfigStore = class {
|
|
|
5338
5423
|
if (error.code === "ENOENT") return;
|
|
5339
5424
|
throw error;
|
|
5340
5425
|
}
|
|
5341
|
-
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
|
|
5426
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES$1) {
|
|
5342
5427
|
this.errorCode = "frp_config_invalid";
|
|
5343
5428
|
return;
|
|
5344
5429
|
}
|
|
@@ -5373,7 +5458,7 @@ var FrpConfigStore = class {
|
|
|
5373
5458
|
/** Atomically replace private FRP settings. */
|
|
5374
5459
|
async configure(value) {
|
|
5375
5460
|
const settings = parseFrpSettings(value);
|
|
5376
|
-
await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
|
|
5461
|
+
await atomicPrivateWrite$2(this.settingsFile, `${JSON.stringify(settings)}\n`);
|
|
5377
5462
|
await rm(this.runtimeConfigFile, { force: true });
|
|
5378
5463
|
this.settingsValue = settings;
|
|
5379
5464
|
this.errorCode = void 0;
|
|
@@ -5383,7 +5468,7 @@ var FrpConfigStore = class {
|
|
|
5383
5468
|
async writeRuntimeConfig(localPort) {
|
|
5384
5469
|
const settings = this.settingsValue;
|
|
5385
5470
|
if (settings === void 0) throw new Error("frp_config_missing");
|
|
5386
|
-
await atomicPrivateWrite(this.runtimeConfigFile, createFrpcToml(settings, localPort));
|
|
5471
|
+
await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort));
|
|
5387
5472
|
return this.runtimeConfigFile;
|
|
5388
5473
|
}
|
|
5389
5474
|
/** Remove only configuration files owned by the FRP provider. */
|
|
@@ -5399,11 +5484,13 @@ var FrpConfigStore = class {
|
|
|
5399
5484
|
};
|
|
5400
5485
|
//#endregion
|
|
5401
5486
|
//#region src/remote.ts
|
|
5402
|
-
const REMOTE_PROVIDERS = [
|
|
5487
|
+
const REMOTE_PROVIDERS = Object.freeze([
|
|
5403
5488
|
"tailscale",
|
|
5404
5489
|
"cpolar",
|
|
5405
|
-
"
|
|
5406
|
-
|
|
5490
|
+
"cloudflared",
|
|
5491
|
+
"frp",
|
|
5492
|
+
"origin"
|
|
5493
|
+
]);
|
|
5407
5494
|
function aggregateErrors(errors, message) {
|
|
5408
5495
|
if (errors.length === 0) return void 0;
|
|
5409
5496
|
if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
|
|
@@ -5529,7 +5616,7 @@ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTim
|
|
|
5529
5616
|
function parseRemoteProviderState(value) {
|
|
5530
5617
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
|
|
5531
5618
|
const record = value;
|
|
5532
|
-
if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "frp" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
|
|
5619
|
+
if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "cloudflared" && record.provider !== "frp" && record.provider !== "origin" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
|
|
5533
5620
|
return Object.freeze({
|
|
5534
5621
|
version: 1,
|
|
5535
5622
|
provider: record.provider
|
|
@@ -5595,17 +5682,17 @@ var JsonRemoteProviderStore = class {
|
|
|
5595
5682
|
/** Resolve the first-run provider without letting environment values bypass validation. */
|
|
5596
5683
|
function configuredRemoteProvider(environment) {
|
|
5597
5684
|
const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
|
|
5598
|
-
if (value !== "tailscale" && value !== "cpolar" && value !== "frp") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, or
|
|
5685
|
+
if (value !== "tailscale" && value !== "cpolar" && value !== "cloudflared" && value !== "frp" && value !== "origin") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, cloudflared, frp, or origin");
|
|
5599
5686
|
return value;
|
|
5600
5687
|
}
|
|
5601
5688
|
//#endregion
|
|
5602
5689
|
//#region src/frp.ts
|
|
5603
|
-
const START_TIMEOUT_MS$
|
|
5690
|
+
const START_TIMEOUT_MS$2 = 45e3;
|
|
5604
5691
|
const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
|
|
5605
5692
|
const DISCOVERY_RETRY_MS = 1e3;
|
|
5606
5693
|
const MAX_DISCOVERY_BYTES = 16384;
|
|
5607
5694
|
const VHOST_PROBE_TIMEOUT_MS = 1500;
|
|
5608
|
-
function publicStatus$
|
|
5695
|
+
function publicStatus$3(status) {
|
|
5609
5696
|
return Object.freeze({
|
|
5610
5697
|
enabled: status.enabled,
|
|
5611
5698
|
state: status.state,
|
|
@@ -5741,7 +5828,7 @@ var FrpController = class {
|
|
|
5741
5828
|
child;
|
|
5742
5829
|
gatewayValue;
|
|
5743
5830
|
generation = 0;
|
|
5744
|
-
latest = publicStatus$
|
|
5831
|
+
latest = publicStatus$3({
|
|
5745
5832
|
enabled: false,
|
|
5746
5833
|
state: "off"
|
|
5747
5834
|
});
|
|
@@ -5769,7 +5856,7 @@ var FrpController = class {
|
|
|
5769
5856
|
}
|
|
5770
5857
|
/** Return state safe for the desktop control UI. */
|
|
5771
5858
|
status() {
|
|
5772
|
-
return publicStatus$
|
|
5859
|
+
return publicStatus$3(this.latest);
|
|
5773
5860
|
}
|
|
5774
5861
|
/** Enable or disable FRP without changing LAN or another provider. */
|
|
5775
5862
|
async setEnabled(enabled) {
|
|
@@ -5835,7 +5922,7 @@ var FrpController = class {
|
|
|
5835
5922
|
return task;
|
|
5836
5923
|
}
|
|
5837
5924
|
publish(status) {
|
|
5838
|
-
this.latest = publicStatus$
|
|
5925
|
+
this.latest = publicStatus$3(status);
|
|
5839
5926
|
try {
|
|
5840
5927
|
this.options.onStatus?.(this.status());
|
|
5841
5928
|
} catch {}
|
|
@@ -5950,7 +6037,7 @@ var FrpController = class {
|
|
|
5950
6037
|
this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
|
|
5951
6038
|
}
|
|
5952
6039
|
async waitForDiscovery(generation, origin, signal) {
|
|
5953
|
-
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$
|
|
6040
|
+
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
|
|
5954
6041
|
const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
|
|
5955
6042
|
while (!signal.aborted && Date.now() < deadline) {
|
|
5956
6043
|
try {
|
|
@@ -6017,180 +6104,1488 @@ var FrpController = class {
|
|
|
6017
6104
|
}
|
|
6018
6105
|
};
|
|
6019
6106
|
//#endregion
|
|
6020
|
-
//#region src/
|
|
6021
|
-
const
|
|
6022
|
-
|
|
6023
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
6024
|
-
}
|
|
6025
|
-
function turnData(value) {
|
|
6026
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return {
|
|
6027
|
-
turn: 0,
|
|
6028
|
-
completed: false
|
|
6029
|
-
};
|
|
6030
|
-
const record = value;
|
|
6031
|
-
const reason = record.reason;
|
|
6032
|
-
const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
|
|
6033
|
-
return {
|
|
6034
|
-
turn: turnNumber(record.turn),
|
|
6035
|
-
completed
|
|
6036
|
-
};
|
|
6037
|
-
}
|
|
6107
|
+
//#region src/origin-proxy-config.ts
|
|
6108
|
+
const DEFAULT_ORIGIN_LISTEN_PORT = 3444;
|
|
6109
|
+
const MAX_SETTINGS_BYTES = 8192;
|
|
6038
6110
|
/**
|
|
6039
|
-
*
|
|
6040
|
-
*
|
|
6041
|
-
*
|
|
6111
|
+
* Ports a user-space listener can actually hold on every supported platform. Below
|
|
6112
|
+
* this the bind needs privileges on Linux and macOS and fails with EACCES, which
|
|
6113
|
+
* surfaces as a generic start failure rather than a rejected choice.
|
|
6042
6114
|
*/
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
};
|
|
6115
|
+
const MIN_ORIGIN_LISTEN_PORT = 1024;
|
|
6116
|
+
/** The LAN gateway's HTTPS port, held for as long as DSH runs. */
|
|
6117
|
+
const RESERVED_LAN_GATEWAY_PORT$1 = 3443;
|
|
6118
|
+
/** DSH's own WebServer port; the plugin cannot function while it is taken. */
|
|
6119
|
+
const RESERVED_DSH_WEB_PORT = 3080;
|
|
6120
|
+
const PRIVATE_NETWORKS = [
|
|
6121
|
+
"10.0.0.0/8",
|
|
6122
|
+
"172.16.0.0/12",
|
|
6123
|
+
"192.168.0.0/16"
|
|
6124
|
+
].map(parseCidr);
|
|
6125
|
+
const TRUSTED_NETWORKS = [...PRIVATE_NETWORKS, parseCidr("127.0.0.0/8")];
|
|
6126
|
+
/** Require a public HTTPS origin; custom external ports are supported. */
|
|
6127
|
+
function validateOriginPublicOrigin(value) {
|
|
6128
|
+
if (typeof value !== "string" || value.length > 512 || /[\s\u0000-\u001f\u007f\\@?#]/u.test(value)) throw new Error("origin_public_origin_invalid");
|
|
6129
|
+
let url;
|
|
6130
|
+
try {
|
|
6131
|
+
url = new URL(value);
|
|
6132
|
+
} catch {
|
|
6133
|
+
throw new Error("origin_public_origin_invalid");
|
|
6134
|
+
}
|
|
6135
|
+
const host = url.hostname;
|
|
6136
|
+
const validHostname = host.length <= 253 && host.includes(".") && host.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label)) && ![
|
|
6137
|
+
"localhost",
|
|
6138
|
+
"local",
|
|
6139
|
+
"lan",
|
|
6140
|
+
"home",
|
|
6141
|
+
"internal",
|
|
6142
|
+
"home.arpa"
|
|
6143
|
+
].some((suffix) => host === suffix || host.endsWith("." + suffix));
|
|
6144
|
+
if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
|
|
6145
|
+
return url.origin;
|
|
6075
6146
|
}
|
|
6076
|
-
/**
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6147
|
+
/** Bind only one explicit loopback or RFC1918 IPv4 interface, never all interfaces. */
|
|
6148
|
+
function validateOriginListenHost(value) {
|
|
6149
|
+
if (typeof value !== "string" || isIP(value) !== 4 || !isLoopbackAddress(value) && !addressAllowed(value, PRIVATE_NETWORKS)) throw new Error("origin_listen_host_invalid");
|
|
6150
|
+
return value;
|
|
6151
|
+
}
|
|
6152
|
+
function validateOriginListenPort(value) {
|
|
6153
|
+
if (!Number.isSafeInteger(value) || Number(value) < MIN_ORIGIN_LISTEN_PORT || Number(value) > 65535) throw new Error("origin_listen_port_invalid");
|
|
6154
|
+
if (value === RESERVED_LAN_GATEWAY_PORT$1 || value === RESERVED_DSH_WEB_PORT) throw new Error("origin_listen_port_reserved");
|
|
6155
|
+
return Number(value);
|
|
6156
|
+
}
|
|
6157
|
+
/** Authorize direct proxy socket peers, not untrusted forwarded client headers. */
|
|
6158
|
+
function validateOriginAllowedCidrs(value, listenHost) {
|
|
6159
|
+
const input = value === void 0 && isLoopbackAddress(listenHost) ? ["127.0.0.0/8"] : value;
|
|
6160
|
+
if (!Array.isArray(input) || input.length === 0 || input.length > 16) throw new Error("origin_allowed_cidrs_invalid");
|
|
6161
|
+
const cidrs = [];
|
|
6162
|
+
for (const entry of input) {
|
|
6163
|
+
if (typeof entry !== "string" || entry.length > 64 || isIP(entry.split("/")[0] ?? "") !== 4) throw new Error("origin_allowed_cidrs_invalid");
|
|
6164
|
+
try {
|
|
6165
|
+
const cidr = parseCidr(entry);
|
|
6166
|
+
if (cidr.bits !== 32 || !TRUSTED_NETWORKS.some((range) => cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
|
|
6167
|
+
cidrs.push(cidr.source);
|
|
6168
|
+
} catch {
|
|
6169
|
+
throw new Error("origin_allowed_cidrs_invalid");
|
|
6170
|
+
}
|
|
6095
6171
|
}
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
|
|
6112
|
-
cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
|
|
6113
|
-
cpolar_config_invalid: "重新保存 cpolar Authtoken。",
|
|
6114
|
-
cpolar_start_timeout: "检查网络后点击“重新连接”。",
|
|
6115
|
-
cpolar_stopped: "点击“重新连接”。",
|
|
6116
|
-
cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
6117
|
-
frp_component_missing: "先安装 FRP 官方组件。",
|
|
6118
|
-
frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
|
|
6119
|
-
frp_config_missing: "先保存自建 FRP 连接配置。",
|
|
6120
|
-
frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
|
|
6121
|
-
frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
|
|
6122
|
-
frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
|
|
6123
|
-
frp_launch_failed: "重新安装 FRP 官方组件后重试。",
|
|
6124
|
-
frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
|
|
6125
|
-
frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
|
|
6126
|
-
frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
|
|
6127
|
-
frp_stopped: "点击“重新连接”。",
|
|
6128
|
-
frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
|
|
6129
|
-
gateway_start_failed: "确认 DSH 正在运行后重新连接。"
|
|
6130
|
-
});
|
|
6131
|
-
function check(id, status, reason, label, detail, action, facts) {
|
|
6172
|
+
if (!isLoopbackAddress(listenHost) && !cidrs.some((cidr) => !isLoopbackAddress(cidr.split("/")[0]))) throw new Error("origin_allowed_cidrs_invalid");
|
|
6173
|
+
return Object.freeze([...new Set(cidrs)]);
|
|
6174
|
+
}
|
|
6175
|
+
/** Validate both saved settings and local administrative requests. */
|
|
6176
|
+
function parseOriginSettings(value) {
|
|
6177
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("origin_settings_invalid");
|
|
6178
|
+
const record = value;
|
|
6179
|
+
if (Reflect.ownKeys(record).some((key) => ![
|
|
6180
|
+
"version",
|
|
6181
|
+
"publicOrigin",
|
|
6182
|
+
"listenHost",
|
|
6183
|
+
"listenPort",
|
|
6184
|
+
"allowedCidrs"
|
|
6185
|
+
].includes(String(key))) || record.version !== void 0 && record.version !== 1) throw new Error("origin_settings_invalid");
|
|
6186
|
+
const listenHost = validateOriginListenHost(record.listenHost === void 0 ? "127.0.0.1" : record.listenHost);
|
|
6132
6187
|
return Object.freeze({
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
detail,
|
|
6139
|
-
...action === void 0 ? {} : { action }
|
|
6188
|
+
version: 1,
|
|
6189
|
+
publicOrigin: validateOriginPublicOrigin(record.publicOrigin),
|
|
6190
|
+
listenHost,
|
|
6191
|
+
listenPort: validateOriginListenPort(record.listenPort === void 0 ? DEFAULT_ORIGIN_LISTEN_PORT : record.listenPort),
|
|
6192
|
+
allowedCidrs: validateOriginAllowedCidrs(record.allowedCidrs, listenHost)
|
|
6140
6193
|
});
|
|
6141
6194
|
}
|
|
6142
|
-
function
|
|
6143
|
-
|
|
6195
|
+
async function atomicPrivateWrite$1(file, body) {
|
|
6196
|
+
const directory = dirname(file);
|
|
6197
|
+
await mkdir(directory, {
|
|
6198
|
+
recursive: true,
|
|
6199
|
+
mode: 448
|
|
6200
|
+
});
|
|
6144
6201
|
try {
|
|
6145
|
-
const
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
} catch {
|
|
6150
|
-
return "地址格式无效";
|
|
6202
|
+
const current = await lstat(file);
|
|
6203
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("origin_config_target_invalid");
|
|
6204
|
+
} catch (error) {
|
|
6205
|
+
if (error.code !== "ENOENT") throw error;
|
|
6151
6206
|
}
|
|
6152
|
-
|
|
6153
|
-
function remoteSuffix(origin) {
|
|
6154
|
-
if (origin === void 0) return "未分配";
|
|
6207
|
+
const temporary = join(directory, "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
6155
6208
|
try {
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
return "地址格式无效";
|
|
6209
|
+
await writeFile(temporary, body, {
|
|
6210
|
+
encoding: "utf8",
|
|
6211
|
+
flag: "wx",
|
|
6212
|
+
mode: 384
|
|
6213
|
+
});
|
|
6214
|
+
await rename(temporary, file);
|
|
6215
|
+
await restrictPrivateFile(file);
|
|
6216
|
+
} catch (error) {
|
|
6217
|
+
await rm(temporary, { force: true });
|
|
6218
|
+
throw error;
|
|
6167
6219
|
}
|
|
6168
6220
|
}
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
"if ($ready) { 'ready' } else { 'missing' }"
|
|
6185
|
-
].join("; ");
|
|
6221
|
+
/** Owns only origin settings; shared paired-device storage is never removed. */
|
|
6222
|
+
var OriginConfigStore = class {
|
|
6223
|
+
stateRoot;
|
|
6224
|
+
settingsFile;
|
|
6225
|
+
settingsValue;
|
|
6226
|
+
errorCode;
|
|
6227
|
+
constructor(stateDirectory) {
|
|
6228
|
+
if (!isAbsolute(stateDirectory)) throw new Error("origin config state directory must be absolute");
|
|
6229
|
+
this.stateRoot = resolve(stateDirectory);
|
|
6230
|
+
this.settingsFile = join(this.stateRoot, "settings.json");
|
|
6231
|
+
}
|
|
6232
|
+
async initialize() {
|
|
6233
|
+
this.settingsValue = void 0;
|
|
6234
|
+
this.errorCode = void 0;
|
|
6235
|
+
let entry;
|
|
6186
6236
|
try {
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6237
|
+
entry = await lstat(this.settingsFile);
|
|
6238
|
+
} catch (error) {
|
|
6239
|
+
if (error.code === "ENOENT") return;
|
|
6240
|
+
throw error;
|
|
6241
|
+
}
|
|
6242
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
|
|
6243
|
+
this.errorCode = "origin_config_invalid";
|
|
6244
|
+
return;
|
|
6245
|
+
}
|
|
6246
|
+
await restrictPrivateFile(this.settingsFile);
|
|
6247
|
+
try {
|
|
6248
|
+
this.settingsValue = parseOriginSettings(JSON.parse(await readFile(this.settingsFile, "utf8")));
|
|
6249
|
+
} catch {
|
|
6250
|
+
this.errorCode = "origin_config_invalid";
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
6253
|
+
status() {
|
|
6254
|
+
const settings = this.settingsValue;
|
|
6255
|
+
return Object.freeze({
|
|
6256
|
+
configured: settings !== void 0,
|
|
6257
|
+
...settings === void 0 ? {} : {
|
|
6258
|
+
publicOrigin: settings.publicOrigin,
|
|
6259
|
+
listenHost: settings.listenHost,
|
|
6260
|
+
listenPort: settings.listenPort,
|
|
6261
|
+
allowedCidrs: settings.allowedCidrs,
|
|
6262
|
+
backendOrigin: "http://" + settings.listenHost + ":" + String(settings.listenPort)
|
|
6263
|
+
},
|
|
6264
|
+
storagePath: this.stateRoot,
|
|
6265
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
6266
|
+
});
|
|
6267
|
+
}
|
|
6268
|
+
settings() {
|
|
6269
|
+
return this.settingsValue;
|
|
6270
|
+
}
|
|
6271
|
+
async configure(value) {
|
|
6272
|
+
const settings = parseOriginSettings(value);
|
|
6273
|
+
await atomicPrivateWrite$1(this.settingsFile, JSON.stringify(settings) + "\n");
|
|
6274
|
+
this.settingsValue = settings;
|
|
6275
|
+
this.errorCode = void 0;
|
|
6276
|
+
return this.status();
|
|
6277
|
+
}
|
|
6278
|
+
async purge() {
|
|
6279
|
+
await rm(this.settingsFile, { force: true });
|
|
6280
|
+
this.settingsValue = void 0;
|
|
6281
|
+
this.errorCode = void 0;
|
|
6282
|
+
return this.status();
|
|
6283
|
+
}
|
|
6284
|
+
};
|
|
6285
|
+
//#endregion
|
|
6286
|
+
//#region src/origin-proxy.ts
|
|
6287
|
+
/** Owns an authenticated HTTP gateway, with no tunnel process or public network probes. */
|
|
6288
|
+
var OriginController = class {
|
|
6289
|
+
options;
|
|
6290
|
+
enabled = false;
|
|
6291
|
+
initialized = false;
|
|
6292
|
+
disposed = false;
|
|
6293
|
+
gatewayValue;
|
|
6294
|
+
latest = Object.freeze({
|
|
6295
|
+
enabled: false,
|
|
6296
|
+
state: "off"
|
|
6297
|
+
});
|
|
6298
|
+
queue = Promise.resolve();
|
|
6299
|
+
constructor(options) {
|
|
6300
|
+
this.options = options;
|
|
6301
|
+
}
|
|
6302
|
+
async initialize() {
|
|
6303
|
+
await this.enqueue(async () => {
|
|
6304
|
+
if (this.disposed) throw new Error("origin_controller_unavailable");
|
|
6305
|
+
if (this.initialized) return;
|
|
6306
|
+
this.enabled = (await this.options.store.load()).enabled;
|
|
6307
|
+
this.initialized = true;
|
|
6308
|
+
if (this.enabled) await this.start();
|
|
6309
|
+
else this.publish({
|
|
6310
|
+
enabled: false,
|
|
6311
|
+
state: "off"
|
|
6312
|
+
});
|
|
6313
|
+
});
|
|
6314
|
+
}
|
|
6315
|
+
gateway() {
|
|
6316
|
+
return this.gatewayValue;
|
|
6317
|
+
}
|
|
6318
|
+
status() {
|
|
6319
|
+
return this.latest;
|
|
6320
|
+
}
|
|
6321
|
+
async setEnabled(enabled) {
|
|
6322
|
+
this.assertAvailable();
|
|
6323
|
+
await this.enqueue(async () => {
|
|
6324
|
+
this.assertAvailable();
|
|
6325
|
+
if (this.enabled === enabled && (!enabled || this.gatewayValue !== void 0)) return;
|
|
6326
|
+
if (!enabled) await this.stop();
|
|
6327
|
+
await this.options.store.save({
|
|
6328
|
+
version: 1,
|
|
6329
|
+
enabled
|
|
6330
|
+
});
|
|
6331
|
+
this.enabled = enabled;
|
|
6332
|
+
if (enabled) await this.start();
|
|
6333
|
+
else this.publish({
|
|
6334
|
+
enabled: false,
|
|
6335
|
+
state: "off"
|
|
6336
|
+
});
|
|
6337
|
+
});
|
|
6338
|
+
return this.status();
|
|
6339
|
+
}
|
|
6340
|
+
async reconnect() {
|
|
6341
|
+
this.assertAvailable();
|
|
6342
|
+
await this.enqueue(async () => {
|
|
6343
|
+
this.assertAvailable();
|
|
6344
|
+
if (!this.enabled) {
|
|
6345
|
+
await this.options.store.save({
|
|
6346
|
+
version: 1,
|
|
6347
|
+
enabled: true
|
|
6348
|
+
});
|
|
6349
|
+
this.enabled = true;
|
|
6350
|
+
}
|
|
6351
|
+
await this.stop();
|
|
6352
|
+
await this.start();
|
|
6353
|
+
});
|
|
6354
|
+
return this.status();
|
|
6355
|
+
}
|
|
6356
|
+
/** Reset the switch, retaining both settings and shared remote device pairings. */
|
|
6357
|
+
async reset() {
|
|
6358
|
+
return this.setEnabled(false);
|
|
6359
|
+
}
|
|
6360
|
+
/** Stop the listener without changing the durable switch. */
|
|
6361
|
+
async close() {
|
|
6362
|
+
this.disposed = true;
|
|
6363
|
+
await this.enqueue(async () => {
|
|
6364
|
+
await this.stop();
|
|
6365
|
+
this.publish({
|
|
6366
|
+
enabled: this.enabled,
|
|
6367
|
+
state: "off"
|
|
6368
|
+
});
|
|
6369
|
+
});
|
|
6370
|
+
}
|
|
6371
|
+
assertAvailable() {
|
|
6372
|
+
if (!this.initialized || this.disposed) throw new Error("origin_controller_unavailable");
|
|
6373
|
+
}
|
|
6374
|
+
enqueue(operation) {
|
|
6375
|
+
const task = this.queue.then(operation, operation);
|
|
6376
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
6377
|
+
return task;
|
|
6378
|
+
}
|
|
6379
|
+
publish(status) {
|
|
6380
|
+
this.latest = Object.freeze({ ...status });
|
|
6381
|
+
try {
|
|
6382
|
+
this.options.onStatus?.(this.latest);
|
|
6383
|
+
} catch {}
|
|
6384
|
+
}
|
|
6385
|
+
async start() {
|
|
6386
|
+
const settings = this.options.config.settings();
|
|
6387
|
+
if (settings === void 0) {
|
|
6388
|
+
this.publish({
|
|
6389
|
+
enabled: true,
|
|
6390
|
+
state: "unavailable",
|
|
6391
|
+
errorCode: this.options.config.status().errorCode ?? "origin_config_missing"
|
|
6392
|
+
});
|
|
6393
|
+
return;
|
|
6394
|
+
}
|
|
6395
|
+
this.publish({
|
|
6396
|
+
enabled: true,
|
|
6397
|
+
state: "starting",
|
|
6398
|
+
origin: settings.publicOrigin
|
|
6399
|
+
});
|
|
6400
|
+
let gateway;
|
|
6401
|
+
try {
|
|
6402
|
+
gateway = await this.options.createGateway(settings);
|
|
6403
|
+
} catch (error) {
|
|
6404
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
6405
|
+
this.publish({
|
|
6406
|
+
enabled: true,
|
|
6407
|
+
state: "error",
|
|
6408
|
+
origin: settings.publicOrigin,
|
|
6409
|
+
errorCode: code === "EADDRINUSE" ? "origin_listen_port_in_use" : code === "EADDRNOTAVAIL" ? "origin_listen_address_unavailable" : "origin_gateway_start_failed"
|
|
6410
|
+
});
|
|
6411
|
+
return;
|
|
6412
|
+
}
|
|
6413
|
+
this.gatewayValue = gateway;
|
|
6414
|
+
if (this.disposed) {
|
|
6415
|
+
await this.stop();
|
|
6416
|
+
return;
|
|
6417
|
+
}
|
|
6418
|
+
this.publish({
|
|
6419
|
+
enabled: true,
|
|
6420
|
+
state: "ready",
|
|
6421
|
+
origin: settings.publicOrigin,
|
|
6422
|
+
backendOrigin: "http://" + settings.listenHost + ":" + String(gateway.address().port)
|
|
6423
|
+
});
|
|
6424
|
+
}
|
|
6425
|
+
async stop() {
|
|
6426
|
+
const gateway = this.gatewayValue;
|
|
6427
|
+
if (gateway === void 0) return;
|
|
6428
|
+
await gateway.close();
|
|
6429
|
+
this.gatewayValue = void 0;
|
|
6430
|
+
this.publish({
|
|
6431
|
+
enabled: this.enabled,
|
|
6432
|
+
state: "off"
|
|
6433
|
+
});
|
|
6434
|
+
}
|
|
6435
|
+
};
|
|
6436
|
+
//#endregion
|
|
6437
|
+
//#region src/component-download.ts
|
|
6438
|
+
/**
|
|
6439
|
+
* Hosts a pinned GitHub release download may redirect to.
|
|
6440
|
+
*
|
|
6441
|
+
* `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
|
|
6442
|
+
* answers 302 with a signed URL on a release-asset host. A fetch configured with
|
|
6443
|
+
* `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
|
|
6444
|
+
* before a single byte is transferred, which is exactly how the cloudflared component could never
|
|
6445
|
+
* install. Following one validated hop keeps the origin restriction while allowing the shape
|
|
6446
|
+
* every GitHub release download uses.
|
|
6447
|
+
*/
|
|
6448
|
+
const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
|
6449
|
+
/** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
|
|
6450
|
+
async function followValidatedRedirect(response, request) {
|
|
6451
|
+
const location = response.headers.get("location");
|
|
6452
|
+
if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
|
|
6453
|
+
let target;
|
|
6454
|
+
try {
|
|
6455
|
+
target = new URL(location, request.url);
|
|
6456
|
+
} catch {
|
|
6457
|
+
throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
|
|
6458
|
+
}
|
|
6459
|
+
const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
|
|
6460
|
+
if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
|
|
6461
|
+
return fetch(target, {
|
|
6462
|
+
redirect: "error",
|
|
6463
|
+
signal: request.signal
|
|
6464
|
+
});
|
|
6465
|
+
}
|
|
6466
|
+
/** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
|
|
6467
|
+
* real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
|
|
6468
|
+
* which is exactly the confusing "nothing happened" the user saw. */
|
|
6469
|
+
const DOWNLOAD_ATTEMPTS = 2;
|
|
6470
|
+
const RETRY_DELAY_MS = 750;
|
|
6471
|
+
/** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
|
|
6472
|
+
function isRetryable(error) {
|
|
6473
|
+
if (error instanceof TypeError) return true;
|
|
6474
|
+
if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
|
|
6475
|
+
return false;
|
|
6476
|
+
}
|
|
6477
|
+
function delay(ms) {
|
|
6478
|
+
return new Promise((resolve) => {
|
|
6479
|
+
setTimeout(resolve, ms);
|
|
6480
|
+
});
|
|
6481
|
+
}
|
|
6482
|
+
async function attemptDownload(request) {
|
|
6483
|
+
const first = await fetch(request.url, {
|
|
6484
|
+
redirect: "manual",
|
|
6485
|
+
signal: request.signal
|
|
6486
|
+
});
|
|
6487
|
+
const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
|
|
6488
|
+
if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
|
|
6489
|
+
const declared = Number(response.headers.get("content-length"));
|
|
6490
|
+
if (Number.isFinite(declared) && declared !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
6491
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
6492
|
+
if (bytes.byteLength !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
6493
|
+
return bytes;
|
|
6494
|
+
}
|
|
6495
|
+
/**
|
|
6496
|
+
* Download a pinned artifact, following at most one validated redirect, and check its exact length.
|
|
6497
|
+
* A transport failure is retried once; a length or redirect verdict is not. The caller still
|
|
6498
|
+
* verifies the SHA-256 before publishing anything.
|
|
6499
|
+
* @param request - the pinned URL, its exact byte length and the caller's error prefix.
|
|
6500
|
+
* @returns the artifact bytes.
|
|
6501
|
+
*/
|
|
6502
|
+
async function downloadPinnedArtifact(request) {
|
|
6503
|
+
let lastError;
|
|
6504
|
+
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
|
|
6505
|
+
return await attemptDownload(request);
|
|
6506
|
+
} catch (error) {
|
|
6507
|
+
lastError = error;
|
|
6508
|
+
if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
|
|
6509
|
+
await delay(RETRY_DELAY_MS);
|
|
6510
|
+
}
|
|
6511
|
+
throw lastError;
|
|
6512
|
+
}
|
|
6513
|
+
//#endregion
|
|
6514
|
+
//#region src/cloudflared-component.ts
|
|
6515
|
+
/**
|
|
6516
|
+
* Pinned cloudflared Windows component fetched only after an explicit user action.
|
|
6517
|
+
*
|
|
6518
|
+
* Unlike the cpolar archive this artifact IS the executable: there is nothing to
|
|
6519
|
+
* unpack, so the download digest and the installed digest are the same pair.
|
|
6520
|
+
* `--no-autoupdate` is passed at runtime as well, so the pinned bytes stay the
|
|
6521
|
+
* bytes that were verified.
|
|
6522
|
+
*/
|
|
6523
|
+
const CLOUDFLARED_COMPONENT_RELEASE = Object.freeze({
|
|
6524
|
+
version: "2026.9.1",
|
|
6525
|
+
platform: "win32",
|
|
6526
|
+
arch: "x64",
|
|
6527
|
+
downloadUrl: "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-windows-amd64.exe",
|
|
6528
|
+
downloadBytes: 54976432,
|
|
6529
|
+
downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
|
|
6530
|
+
downloadPage: "https://github.com/cloudflare/cloudflared/releases",
|
|
6531
|
+
termsUrl: "https://www.cloudflare.com/website-terms/"
|
|
6532
|
+
});
|
|
6533
|
+
function inside$1(parent, child) {
|
|
6534
|
+
const candidate = relative(parent, child);
|
|
6535
|
+
return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
|
|
6536
|
+
}
|
|
6537
|
+
async function sha256$1(file) {
|
|
6538
|
+
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
6539
|
+
}
|
|
6540
|
+
async function regularFile$1(file, expectedBytes) {
|
|
6541
|
+
try {
|
|
6542
|
+
const stat = await lstat(file);
|
|
6543
|
+
return stat.isFile() && !stat.isSymbolicLink() && (expectedBytes === void 0 || stat.size === expectedBytes);
|
|
6544
|
+
} catch (error) {
|
|
6545
|
+
if (error.code === "ENOENT") return false;
|
|
6546
|
+
throw error;
|
|
6547
|
+
}
|
|
6548
|
+
}
|
|
6549
|
+
async function defaultFetchArtifact$1(url, signal) {
|
|
6550
|
+
return downloadPinnedArtifact({
|
|
6551
|
+
url,
|
|
6552
|
+
expectedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
|
|
6553
|
+
errorPrefix: "cloudflared",
|
|
6554
|
+
signal
|
|
6555
|
+
});
|
|
6556
|
+
}
|
|
6557
|
+
/** Owns the optional cloudflared binary inside DSH Mobile state. */
|
|
6558
|
+
var CloudflaredComponentManager = class {
|
|
6559
|
+
executable;
|
|
6560
|
+
componentRoot;
|
|
6561
|
+
componentStorage;
|
|
6562
|
+
stateRoot;
|
|
6563
|
+
logRoot;
|
|
6564
|
+
stagingRoot;
|
|
6565
|
+
platform;
|
|
6566
|
+
arch;
|
|
6567
|
+
fetchArtifact;
|
|
6568
|
+
installed = false;
|
|
6569
|
+
errorCode;
|
|
6570
|
+
queue = Promise.resolve();
|
|
6571
|
+
constructor(options) {
|
|
6572
|
+
const stateDirectory = resolve(options.stateDirectory);
|
|
6573
|
+
if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
|
|
6574
|
+
this.platform = options.platform ?? process.platform;
|
|
6575
|
+
this.arch = options.arch ?? process.arch;
|
|
6576
|
+
this.componentRoot = join(stateDirectory, "components", "cloudflared");
|
|
6577
|
+
this.componentStorage = join(this.componentRoot, CLOUDFLARED_COMPONENT_RELEASE.version);
|
|
6578
|
+
this.executable = join(this.componentStorage, "cloudflared.exe");
|
|
6579
|
+
this.stateRoot = join(stateDirectory, "state", "cloudflared");
|
|
6580
|
+
this.logRoot = join(stateDirectory, "logs", "cloudflared");
|
|
6581
|
+
this.stagingRoot = join(stateDirectory, "staging", "cloudflared");
|
|
6582
|
+
for (const child of [
|
|
6583
|
+
this.componentRoot,
|
|
6584
|
+
this.componentStorage,
|
|
6585
|
+
this.stateRoot,
|
|
6586
|
+
this.logRoot,
|
|
6587
|
+
this.stagingRoot
|
|
6588
|
+
]) if (!inside$1(stateDirectory, child)) throw new Error("cloudflared component path escaped its state directory");
|
|
6589
|
+
this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
|
|
6590
|
+
}
|
|
6591
|
+
/** Inspect the managed binary without using any global cloudflared state. */
|
|
6592
|
+
async initialize() {
|
|
6593
|
+
this.installed = await regularFile$1(this.executable, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes);
|
|
6594
|
+
if (this.installed && await sha256$1(this.executable) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) {
|
|
6595
|
+
this.installed = false;
|
|
6596
|
+
this.errorCode = "cloudflared_component_invalid";
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
/** Return a safe status that never includes machine-specific account data. */
|
|
6600
|
+
status() {
|
|
6601
|
+
return Object.freeze({
|
|
6602
|
+
supported: this.platform === CLOUDFLARED_COMPONENT_RELEASE.platform && this.arch === CLOUDFLARED_COMPONENT_RELEASE.arch,
|
|
6603
|
+
installed: this.installed,
|
|
6604
|
+
version: CLOUDFLARED_COMPONENT_RELEASE.version,
|
|
6605
|
+
downloadBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
|
|
6606
|
+
installedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
|
|
6607
|
+
sourceUrl: CLOUDFLARED_COMPONENT_RELEASE.downloadUrl,
|
|
6608
|
+
downloadPage: CLOUDFLARED_COMPONENT_RELEASE.downloadPage,
|
|
6609
|
+
termsUrl: CLOUDFLARED_COMPONENT_RELEASE.termsUrl,
|
|
6610
|
+
storagePath: this.componentRoot,
|
|
6611
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
6612
|
+
});
|
|
6613
|
+
}
|
|
6614
|
+
/** Download, verify, and install the pinned cloudflared executable after explicit confirmation. */
|
|
6615
|
+
install() {
|
|
6616
|
+
return this.enqueue(async () => {
|
|
6617
|
+
if (this.platform !== CLOUDFLARED_COMPONENT_RELEASE.platform || this.arch !== CLOUDFLARED_COMPONENT_RELEASE.arch) throw new Error("cloudflared_component_unsupported");
|
|
6618
|
+
await mkdir(this.stagingRoot, {
|
|
6619
|
+
recursive: true,
|
|
6620
|
+
mode: 448
|
|
6621
|
+
});
|
|
6622
|
+
const staging = await mkdtemp(join(this.stagingRoot, "install-"));
|
|
6623
|
+
try {
|
|
6624
|
+
const controller = new AbortController();
|
|
6625
|
+
const timeout = setTimeout(() => {
|
|
6626
|
+
controller.abort();
|
|
6627
|
+
}, 3e5);
|
|
6628
|
+
timeout.unref();
|
|
6629
|
+
let bytes;
|
|
6630
|
+
try {
|
|
6631
|
+
bytes = await this.fetchArtifact(CLOUDFLARED_COMPONENT_RELEASE.downloadUrl, controller.signal);
|
|
6632
|
+
} finally {
|
|
6633
|
+
clearTimeout(timeout);
|
|
6634
|
+
}
|
|
6635
|
+
if (bytes.byteLength !== CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
|
|
6636
|
+
if (createHash("sha256").update(bytes).digest("hex") !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
|
|
6637
|
+
const staged = join(staging, "cloudflared.exe");
|
|
6638
|
+
await writeFile(staged, bytes, {
|
|
6639
|
+
flag: "wx",
|
|
6640
|
+
mode: 384
|
|
6641
|
+
});
|
|
6642
|
+
await chmod(staged, 448);
|
|
6643
|
+
if (!await regularFile$1(staged, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) || await sha256$1(staged) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
|
|
6644
|
+
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
6645
|
+
await mkdir(candidate, {
|
|
6646
|
+
recursive: true,
|
|
6647
|
+
mode: 448
|
|
6648
|
+
});
|
|
6649
|
+
await copyFile(staged, join(candidate, "cloudflared.exe"));
|
|
6650
|
+
await chmod(join(candidate, "cloudflared.exe"), 448);
|
|
6651
|
+
await rm(this.componentStorage, {
|
|
6652
|
+
recursive: true,
|
|
6653
|
+
force: true
|
|
6654
|
+
});
|
|
6655
|
+
await rename(candidate, this.componentStorage);
|
|
6656
|
+
this.installed = true;
|
|
6657
|
+
this.errorCode = void 0;
|
|
6658
|
+
} finally {
|
|
6659
|
+
await rm(staging, {
|
|
6660
|
+
recursive: true,
|
|
6661
|
+
force: true
|
|
6662
|
+
});
|
|
6663
|
+
}
|
|
6664
|
+
});
|
|
6665
|
+
}
|
|
6666
|
+
/** Remove every cloudflared file owned by DSH Mobile without touching global state. */
|
|
6667
|
+
purge() {
|
|
6668
|
+
return this.enqueue(async () => {
|
|
6669
|
+
await Promise.all([
|
|
6670
|
+
rm(this.componentRoot, {
|
|
6671
|
+
recursive: true,
|
|
6672
|
+
force: true
|
|
6673
|
+
}),
|
|
6674
|
+
rm(this.stateRoot, {
|
|
6675
|
+
recursive: true,
|
|
6676
|
+
force: true
|
|
6677
|
+
}),
|
|
6678
|
+
rm(this.logRoot, {
|
|
6679
|
+
recursive: true,
|
|
6680
|
+
force: true
|
|
6681
|
+
}),
|
|
6682
|
+
rm(this.stagingRoot, {
|
|
6683
|
+
recursive: true,
|
|
6684
|
+
force: true
|
|
6685
|
+
})
|
|
6686
|
+
]);
|
|
6687
|
+
this.installed = false;
|
|
6688
|
+
this.errorCode = void 0;
|
|
6689
|
+
});
|
|
6690
|
+
}
|
|
6691
|
+
enqueue(operation) {
|
|
6692
|
+
const task = this.queue.then(operation, operation);
|
|
6693
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
6694
|
+
return task.then(() => this.status());
|
|
6695
|
+
}
|
|
6696
|
+
};
|
|
6697
|
+
//#endregion
|
|
6698
|
+
//#region src/cloudflared.ts
|
|
6699
|
+
const MAX_LOG_BUFFER_BYTES$1 = 65536;
|
|
6700
|
+
/**
|
|
6701
|
+
* A quick tunnel has to be allocated and registered with the Cloudflare edge
|
|
6702
|
+
* before the public hostname appears. That is slower than a cpolar control
|
|
6703
|
+
* handshake but still bounded; 60 s keeps a genuinely stalled client from
|
|
6704
|
+
* looking healthy for longer than a user will wait.
|
|
6705
|
+
*/
|
|
6706
|
+
const START_TIMEOUT_MS$1 = 6e4;
|
|
6707
|
+
const CLOUDFLARED_HOST_SUFFIX = ".trycloudflare.com";
|
|
6708
|
+
/**
|
|
6709
|
+
* Subdomains Cloudflare reserves for its own control plane.
|
|
6710
|
+
*
|
|
6711
|
+
* The quick-tunnel banner prints `https://api.trycloudflare.com` (the registration API) before it
|
|
6712
|
+
* prints the tunnel's own hostname, and that address satisfies a bare suffix check. Adopting it
|
|
6713
|
+
* would put the API host in the pairing QR code, where the phone gets `{"code":10005","message":
|
|
6714
|
+
* "Method Not Allowed"}` instead of DSH. A quick tunnel is always one random label under the
|
|
6715
|
+
* suffix, so the reserved single labels are refused outright.
|
|
6716
|
+
*/
|
|
6717
|
+
const RESERVED_CLOUDFLARED_LABELS = Object.freeze([
|
|
6718
|
+
"api",
|
|
6719
|
+
"www",
|
|
6720
|
+
"update",
|
|
6721
|
+
"login"
|
|
6722
|
+
]);
|
|
6723
|
+
function publicStatus$2(status) {
|
|
6724
|
+
return Object.freeze({
|
|
6725
|
+
enabled: status.enabled,
|
|
6726
|
+
state: status.state,
|
|
6727
|
+
...status.origin === void 0 ? {} : { origin: status.origin },
|
|
6728
|
+
...status.errorCode === void 0 ? {} : { errorCode: status.errorCode }
|
|
6729
|
+
});
|
|
6730
|
+
}
|
|
6731
|
+
/**
|
|
6732
|
+
* Whether a hostname is a quick-tunnel host: exactly one random label under the provider suffix,
|
|
6733
|
+
* never one of the reserved control-plane labels.
|
|
6734
|
+
*/
|
|
6735
|
+
function isCloudflaredHost(hostname) {
|
|
6736
|
+
const lower = hostname.toLowerCase();
|
|
6737
|
+
if (!lower.endsWith(CLOUDFLARED_HOST_SUFFIX)) return false;
|
|
6738
|
+
const label = lower.slice(0, -18);
|
|
6739
|
+
return label !== "" && !label.includes(".") && !RESERVED_CLOUDFLARED_LABELS.includes(label);
|
|
6740
|
+
}
|
|
6741
|
+
/**
|
|
6742
|
+
* Extract a validated public HTTPS origin from one cloudflared output line.
|
|
6743
|
+
*
|
|
6744
|
+
* Quick tunnels print the public hostname inside a decorated banner, so the
|
|
6745
|
+
* candidate is taken from the line and then re-parsed as a URL: a lookalike such
|
|
6746
|
+
* as `https://x.trycloudflare.com.evil.test` fails the hostname check instead of
|
|
6747
|
+
* being truncated into an accepted origin.
|
|
6748
|
+
*/
|
|
6749
|
+
function parseCloudflaredOrigin(line) {
|
|
6750
|
+
if (!line.toLowerCase().includes("trycloudflare.com")) return void 0;
|
|
6751
|
+
const match = /https?:\/\/[^\s"'|<>]+/u.exec(line);
|
|
6752
|
+
if (match === null) return void 0;
|
|
6753
|
+
const candidate = match[0].replace(/[),.;:]+$/u, "");
|
|
6754
|
+
let url;
|
|
6755
|
+
try {
|
|
6756
|
+
url = new URL(candidate);
|
|
6757
|
+
} catch {
|
|
6758
|
+
throw new Error("invalid_cloudflared_origin");
|
|
6759
|
+
}
|
|
6760
|
+
if (url.protocol !== "https:" || url.port !== "" || !isCloudflaredHost(url.hostname) || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") throw new Error("invalid_cloudflared_origin");
|
|
6761
|
+
return url.origin;
|
|
6762
|
+
}
|
|
6763
|
+
/**
|
|
6764
|
+
* Whether one cloudflared log line reports an established edge connection.
|
|
6765
|
+
*
|
|
6766
|
+
* A named tunnel prints no banner, so registration is the only signal that the
|
|
6767
|
+
* connector reached Cloudflare and the public hostname can serve traffic. Both
|
|
6768
|
+
* the current and the older wording are accepted; anything else is ignored so a
|
|
6769
|
+
* chatty log line cannot be mistaken for readiness.
|
|
6770
|
+
*/
|
|
6771
|
+
function isCloudflaredRegistration(line) {
|
|
6772
|
+
return /registered tunnel connection/iu.test(line) || /connection\s+[0-9a-f][0-9a-f-]{7,}\s+registered/iu.test(line);
|
|
6773
|
+
}
|
|
6774
|
+
async function reserveLoopbackPort$1(requestedPort) {
|
|
6775
|
+
const server = createServer((socket) => {
|
|
6776
|
+
socket.destroy();
|
|
6777
|
+
});
|
|
6778
|
+
await new Promise((resolveListen, reject) => {
|
|
6779
|
+
server.once("error", reject);
|
|
6780
|
+
server.listen(requestedPort ?? 0, "127.0.0.1", () => {
|
|
6781
|
+
server.off("error", reject);
|
|
6782
|
+
resolveListen();
|
|
6783
|
+
});
|
|
6784
|
+
});
|
|
6785
|
+
const address = server.address();
|
|
6786
|
+
if (address === null || typeof address === "string") {
|
|
6787
|
+
server.close();
|
|
6788
|
+
throw new Error("cloudflared_port_reservation_failed");
|
|
6789
|
+
}
|
|
6790
|
+
let released = false;
|
|
6791
|
+
return {
|
|
6792
|
+
port: address.port,
|
|
6793
|
+
release: async () => {
|
|
6794
|
+
if (released) return;
|
|
6795
|
+
released = true;
|
|
6796
|
+
await new Promise((resolveClose) => {
|
|
6797
|
+
server.close(() => resolveClose());
|
|
6798
|
+
});
|
|
6799
|
+
}
|
|
6800
|
+
};
|
|
6801
|
+
}
|
|
6802
|
+
function spawnCloudflaredProcess(executable, args, environment) {
|
|
6803
|
+
return spawn(executable, [...args], {
|
|
6804
|
+
env: environment,
|
|
6805
|
+
shell: false,
|
|
6806
|
+
stdio: [
|
|
6807
|
+
"pipe",
|
|
6808
|
+
"pipe",
|
|
6809
|
+
"pipe"
|
|
6810
|
+
],
|
|
6811
|
+
windowsHide: true
|
|
6812
|
+
});
|
|
6813
|
+
}
|
|
6814
|
+
function withoutProxyEnvironment$1(environment) {
|
|
6815
|
+
const blocked = /* @__PURE__ */ new Set([
|
|
6816
|
+
"HTTP_PROXY",
|
|
6817
|
+
"HTTPS_PROXY",
|
|
6818
|
+
"ALL_PROXY",
|
|
6819
|
+
"NO_PROXY"
|
|
6820
|
+
]);
|
|
6821
|
+
return Object.fromEntries(Object.entries(environment).filter(([name]) => !blocked.has(name.toUpperCase())));
|
|
6822
|
+
}
|
|
6823
|
+
/**
|
|
6824
|
+
* Owns an installed cloudflared client and a provider-specific DSH remote gateway.
|
|
6825
|
+
*
|
|
6826
|
+
* Quick tunnels allocate a random hostname and a random forward port on every
|
|
6827
|
+
* start. Named tunnels instead read an account token and a stable public
|
|
6828
|
+
* hostname from the tunnel store, forward to the configured loopback port, and
|
|
6829
|
+
* learn readiness from the connector's registration line rather than a banner.
|
|
6830
|
+
*/
|
|
6831
|
+
var CloudflaredController = class {
|
|
6832
|
+
options;
|
|
6833
|
+
enabled = false;
|
|
6834
|
+
initialized = false;
|
|
6835
|
+
disposed = false;
|
|
6836
|
+
child;
|
|
6837
|
+
gatewayValue;
|
|
6838
|
+
reservation;
|
|
6839
|
+
generation = 0;
|
|
6840
|
+
buffer = "";
|
|
6841
|
+
mode = "quick";
|
|
6842
|
+
namedOrigin;
|
|
6843
|
+
latest = publicStatus$2({
|
|
6844
|
+
enabled: false,
|
|
6845
|
+
state: "off"
|
|
6846
|
+
});
|
|
6847
|
+
queue = Promise.resolve();
|
|
6848
|
+
startupTimer;
|
|
6849
|
+
constructor(options) {
|
|
6850
|
+
this.options = options;
|
|
6851
|
+
if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
|
|
6852
|
+
}
|
|
6853
|
+
/** Restore the remembered cloudflared switch independently from LAN and other providers. */
|
|
6854
|
+
async initialize() {
|
|
6855
|
+
const state = await this.options.store.load();
|
|
6856
|
+
this.enabled = state.enabled;
|
|
6857
|
+
this.initialized = true;
|
|
6858
|
+
if (this.enabled) await this.start();
|
|
6859
|
+
else this.publish({
|
|
6860
|
+
enabled: false,
|
|
6861
|
+
state: "off"
|
|
6862
|
+
});
|
|
6863
|
+
}
|
|
6864
|
+
/** Return the active cloudflared-backed DSH gateway. */
|
|
6865
|
+
gateway() {
|
|
6866
|
+
return this.gatewayValue;
|
|
6867
|
+
}
|
|
6868
|
+
/** Return state safe for the desktop control UI. */
|
|
6869
|
+
status() {
|
|
6870
|
+
return publicStatus$2(this.latest);
|
|
6871
|
+
}
|
|
6872
|
+
/** Enable or disable cloudflared without changing LAN or other provider state. */
|
|
6873
|
+
async setEnabled(enabled) {
|
|
6874
|
+
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
6875
|
+
await this.enqueue(async () => {
|
|
6876
|
+
if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
|
|
6877
|
+
if (!enabled) await this.stop();
|
|
6878
|
+
this.enabled = enabled;
|
|
6879
|
+
await this.options.store.save({
|
|
6880
|
+
version: 1,
|
|
6881
|
+
enabled
|
|
6882
|
+
});
|
|
6883
|
+
if (enabled) await this.start();
|
|
6884
|
+
else this.publish({
|
|
6885
|
+
enabled: false,
|
|
6886
|
+
state: "off"
|
|
6887
|
+
});
|
|
6888
|
+
});
|
|
6889
|
+
return this.status();
|
|
6890
|
+
}
|
|
6891
|
+
/** Restart cloudflared and allocate a fresh quick tunnel. */
|
|
6892
|
+
async reconnect() {
|
|
6893
|
+
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
6894
|
+
await this.enqueue(async () => {
|
|
6895
|
+
if (!this.enabled) {
|
|
6896
|
+
this.enabled = true;
|
|
6897
|
+
await this.options.store.save({
|
|
6898
|
+
version: 1,
|
|
6899
|
+
enabled: true
|
|
6900
|
+
});
|
|
6901
|
+
}
|
|
6902
|
+
await this.stop();
|
|
6903
|
+
await this.start();
|
|
6904
|
+
});
|
|
6905
|
+
return this.status();
|
|
6906
|
+
}
|
|
6907
|
+
/** Disable cloudflared without deleting the installed component. */
|
|
6908
|
+
async reset() {
|
|
6909
|
+
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
6910
|
+
await this.enqueue(async () => {
|
|
6911
|
+
await this.stop();
|
|
6912
|
+
this.enabled = false;
|
|
6913
|
+
await this.options.store.save({
|
|
6914
|
+
version: 1,
|
|
6915
|
+
enabled: false
|
|
6916
|
+
});
|
|
6917
|
+
this.publish({
|
|
6918
|
+
enabled: false,
|
|
6919
|
+
state: "off"
|
|
6920
|
+
});
|
|
6921
|
+
});
|
|
6922
|
+
return this.status();
|
|
6923
|
+
}
|
|
6924
|
+
/** Stop owned resources without changing the remembered switch. */
|
|
6925
|
+
async close() {
|
|
6926
|
+
if (this.disposed) return;
|
|
6927
|
+
this.disposed = true;
|
|
6928
|
+
await this.enqueue(() => this.stop());
|
|
6929
|
+
}
|
|
6930
|
+
enqueue(operation) {
|
|
6931
|
+
const task = this.queue.then(operation, operation);
|
|
6932
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
6933
|
+
return task;
|
|
6934
|
+
}
|
|
6935
|
+
publish(status) {
|
|
6936
|
+
this.latest = publicStatus$2(status);
|
|
6937
|
+
try {
|
|
6938
|
+
this.options.onStatus?.(this.status());
|
|
6939
|
+
} catch {}
|
|
6940
|
+
}
|
|
6941
|
+
async start() {
|
|
6942
|
+
const generation = ++this.generation;
|
|
6943
|
+
let executableEntry;
|
|
6944
|
+
try {
|
|
6945
|
+
executableEntry = await lstat(this.options.executable);
|
|
6946
|
+
} catch {
|
|
6947
|
+
this.publish({
|
|
6948
|
+
enabled: true,
|
|
6949
|
+
state: "unavailable",
|
|
6950
|
+
errorCode: "cloudflared_component_missing"
|
|
6951
|
+
});
|
|
6952
|
+
return;
|
|
6953
|
+
}
|
|
6954
|
+
if (!executableEntry.isFile() || executableEntry.isSymbolicLink()) {
|
|
6955
|
+
this.publish({
|
|
6956
|
+
enabled: true,
|
|
6957
|
+
state: "unavailable",
|
|
6958
|
+
errorCode: "cloudflared_component_invalid"
|
|
6959
|
+
});
|
|
6960
|
+
return;
|
|
6961
|
+
}
|
|
6962
|
+
const settings = this.options.tunnel.settings();
|
|
6963
|
+
const named = settings.mode === "named" ? settings : void 0;
|
|
6964
|
+
this.mode = settings.mode;
|
|
6965
|
+
this.namedOrigin = named === void 0 ? void 0 : `https://${named.hostname}`;
|
|
6966
|
+
let reservation;
|
|
6967
|
+
try {
|
|
6968
|
+
reservation = await reserveLoopbackPort$1(named?.port);
|
|
6969
|
+
} catch (error) {
|
|
6970
|
+
const conflict = error?.code === "EADDRINUSE";
|
|
6971
|
+
this.publish({
|
|
6972
|
+
enabled: true,
|
|
6973
|
+
state: "error",
|
|
6974
|
+
errorCode: conflict ? named === void 0 ? "cloudflared_port_unavailable" : "cloudflared_tunnel_port_unavailable" : "cloudflared_port_reservation_failed"
|
|
6975
|
+
});
|
|
6976
|
+
return;
|
|
6977
|
+
}
|
|
6978
|
+
this.reservation = reservation;
|
|
6979
|
+
this.buffer = "";
|
|
6980
|
+
this.publish({
|
|
6981
|
+
enabled: true,
|
|
6982
|
+
state: "starting"
|
|
6983
|
+
});
|
|
6984
|
+
if (named !== void 0) {
|
|
6985
|
+
const origin = this.namedOrigin;
|
|
6986
|
+
this.publish({
|
|
6987
|
+
enabled: true,
|
|
6988
|
+
state: "connecting",
|
|
6989
|
+
origin
|
|
6990
|
+
});
|
|
6991
|
+
await reservation.release();
|
|
6992
|
+
if (this.reservation === reservation) this.reservation = void 0;
|
|
6993
|
+
let gateway;
|
|
6994
|
+
try {
|
|
6995
|
+
gateway = await this.options.createGateway(origin, reservation.port);
|
|
6996
|
+
} catch (error) {
|
|
6997
|
+
const conflict = error?.code === "EADDRINUSE";
|
|
6998
|
+
this.publish({
|
|
6999
|
+
enabled: true,
|
|
7000
|
+
state: "error",
|
|
7001
|
+
errorCode: conflict ? "cloudflared_tunnel_port_unavailable" : "gateway_start_failed"
|
|
7002
|
+
});
|
|
7003
|
+
return;
|
|
7004
|
+
}
|
|
7005
|
+
if (generation !== this.generation || !this.enabled || this.disposed) {
|
|
7006
|
+
await gateway.close();
|
|
7007
|
+
return;
|
|
7008
|
+
}
|
|
7009
|
+
this.gatewayValue = gateway;
|
|
7010
|
+
}
|
|
7011
|
+
const args = named === void 0 ? [
|
|
7012
|
+
"tunnel",
|
|
7013
|
+
"--url",
|
|
7014
|
+
`http://127.0.0.1:${String(reservation.port)}`,
|
|
7015
|
+
"--no-autoupdate"
|
|
7016
|
+
] : [
|
|
7017
|
+
"tunnel",
|
|
7018
|
+
"--no-autoupdate",
|
|
7019
|
+
"run"
|
|
7020
|
+
];
|
|
7021
|
+
const environment = withoutProxyEnvironment$1(process.env);
|
|
7022
|
+
if (named !== void 0) environment.TUNNEL_TOKEN = named.token;
|
|
7023
|
+
const child = (this.options.spawnProcess ?? spawnCloudflaredProcess)(this.options.executable, args, environment);
|
|
7024
|
+
this.child = child;
|
|
7025
|
+
child.stdout.setEncoding("utf8");
|
|
7026
|
+
child.stderr.setEncoding("utf8");
|
|
7027
|
+
child.stdout.on("data", (chunk) => {
|
|
7028
|
+
this.consume(generation, String(chunk));
|
|
7029
|
+
});
|
|
7030
|
+
child.stderr.on("data", (chunk) => {
|
|
7031
|
+
this.consume(generation, String(chunk));
|
|
7032
|
+
});
|
|
7033
|
+
child.once("error", () => {
|
|
7034
|
+
this.enqueue(() => this.failGeneration(generation, "cloudflared_launch_failed"));
|
|
7035
|
+
});
|
|
7036
|
+
child.once("close", (code) => {
|
|
7037
|
+
if (generation !== this.generation || this.child !== child) return;
|
|
7038
|
+
this.child = void 0;
|
|
7039
|
+
if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "cloudflared_stopped" : "cloudflared_exited"));
|
|
7040
|
+
});
|
|
7041
|
+
this.startupTimer = setTimeout(() => {
|
|
7042
|
+
this.enqueue(() => this.failGeneration(generation, "cloudflared_start_timeout"));
|
|
7043
|
+
}, this.options.startupTimeoutMs ?? START_TIMEOUT_MS$1);
|
|
7044
|
+
this.startupTimer.unref();
|
|
7045
|
+
}
|
|
7046
|
+
consume(generation, chunk) {
|
|
7047
|
+
if (generation !== this.generation) return;
|
|
7048
|
+
this.buffer += chunk;
|
|
7049
|
+
if (Buffer.byteLength(this.buffer, "utf8") > MAX_LOG_BUFFER_BYTES$1 && !this.buffer.includes("\n")) {
|
|
7050
|
+
this.enqueue(() => this.failGeneration(generation, "cloudflared_invalid_output"));
|
|
7051
|
+
return;
|
|
7052
|
+
}
|
|
7053
|
+
while (true) {
|
|
7054
|
+
const newline = this.buffer.indexOf("\n");
|
|
7055
|
+
if (newline < 0) return;
|
|
7056
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
|
|
7057
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
7058
|
+
if (this.mode === "named") {
|
|
7059
|
+
if (isCloudflaredRegistration(line)) this.enqueue(() => this.confirmNamedReady(generation));
|
|
7060
|
+
continue;
|
|
7061
|
+
}
|
|
7062
|
+
let origin;
|
|
7063
|
+
try {
|
|
7064
|
+
origin = parseCloudflaredOrigin(line);
|
|
7065
|
+
} catch {
|
|
7066
|
+
this.enqueue(() => this.failGeneration(generation, "cloudflared_invalid_origin"));
|
|
7067
|
+
return;
|
|
7068
|
+
}
|
|
7069
|
+
if (origin !== void 0) this.enqueue(() => this.attachGateway(generation, origin));
|
|
7070
|
+
}
|
|
7071
|
+
}
|
|
7072
|
+
/** Mark a named tunnel ready once the connector has registered with the edge. */
|
|
7073
|
+
async confirmNamedReady(generation) {
|
|
7074
|
+
if (generation !== this.generation || !this.enabled || this.disposed) return;
|
|
7075
|
+
if (this.gatewayValue === void 0 || this.latest.state === "ready") return;
|
|
7076
|
+
if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
|
|
7077
|
+
this.startupTimer = void 0;
|
|
7078
|
+
this.publish({
|
|
7079
|
+
enabled: true,
|
|
7080
|
+
state: "ready",
|
|
7081
|
+
...this.namedOrigin === void 0 ? {} : { origin: this.namedOrigin }
|
|
7082
|
+
});
|
|
7083
|
+
}
|
|
7084
|
+
async attachGateway(generation, origin) {
|
|
7085
|
+
if (generation !== this.generation || !this.enabled || this.disposed) return;
|
|
7086
|
+
const current = this.gatewayValue;
|
|
7087
|
+
if (current !== void 0) {
|
|
7088
|
+
if (current.address().origin === origin) return;
|
|
7089
|
+
await this.rotateGateway(generation, origin, current);
|
|
7090
|
+
return;
|
|
7091
|
+
}
|
|
7092
|
+
const reservation = this.reservation;
|
|
7093
|
+
if (reservation === void 0) return;
|
|
7094
|
+
this.publish({
|
|
7095
|
+
enabled: true,
|
|
7096
|
+
state: "connecting",
|
|
7097
|
+
origin
|
|
7098
|
+
});
|
|
7099
|
+
await reservation.release();
|
|
7100
|
+
if (this.reservation === reservation) this.reservation = void 0;
|
|
7101
|
+
let gateway;
|
|
7102
|
+
try {
|
|
7103
|
+
gateway = await this.options.createGateway(origin, reservation.port);
|
|
7104
|
+
} catch {
|
|
7105
|
+
await this.failGeneration(generation, "gateway_start_failed");
|
|
7106
|
+
return;
|
|
7107
|
+
}
|
|
7108
|
+
if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
|
|
7109
|
+
await gateway.close();
|
|
7110
|
+
return;
|
|
7111
|
+
}
|
|
7112
|
+
this.gatewayValue = gateway;
|
|
7113
|
+
if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
|
|
7114
|
+
this.startupTimer = void 0;
|
|
7115
|
+
this.publish({
|
|
7116
|
+
enabled: true,
|
|
7117
|
+
state: "ready",
|
|
7118
|
+
origin
|
|
7119
|
+
});
|
|
7120
|
+
}
|
|
7121
|
+
/** Replace the gateway authority when a quick tunnel restarts on a new hostname. */
|
|
7122
|
+
async rotateGateway(generation, origin, current) {
|
|
7123
|
+
const listenPort = current.address().port;
|
|
7124
|
+
if (this.gatewayValue === current) this.gatewayValue = void 0;
|
|
7125
|
+
this.publish({
|
|
7126
|
+
enabled: true,
|
|
7127
|
+
state: "connecting",
|
|
7128
|
+
origin
|
|
7129
|
+
});
|
|
7130
|
+
try {
|
|
7131
|
+
await current.close();
|
|
7132
|
+
} catch {
|
|
7133
|
+
await this.failGeneration(generation, "gateway_start_failed");
|
|
7134
|
+
return;
|
|
7135
|
+
}
|
|
7136
|
+
if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) return;
|
|
7137
|
+
let replacement;
|
|
7138
|
+
try {
|
|
7139
|
+
replacement = await this.options.createGateway(origin, listenPort);
|
|
7140
|
+
} catch {
|
|
7141
|
+
await this.failGeneration(generation, "gateway_start_failed");
|
|
7142
|
+
return;
|
|
7143
|
+
}
|
|
7144
|
+
if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
|
|
7145
|
+
await replacement.close();
|
|
7146
|
+
return;
|
|
7147
|
+
}
|
|
7148
|
+
this.gatewayValue = replacement;
|
|
7149
|
+
this.publish({
|
|
7150
|
+
enabled: true,
|
|
7151
|
+
state: "ready",
|
|
7152
|
+
origin
|
|
7153
|
+
});
|
|
7154
|
+
}
|
|
7155
|
+
async failGeneration(generation, code) {
|
|
7156
|
+
if (generation !== this.generation) return;
|
|
7157
|
+
await this.stopProcessAndGateway();
|
|
7158
|
+
if (this.enabled) this.publish({
|
|
7159
|
+
enabled: true,
|
|
7160
|
+
state: "error",
|
|
7161
|
+
errorCode: code
|
|
7162
|
+
});
|
|
7163
|
+
}
|
|
7164
|
+
async stop() {
|
|
7165
|
+
++this.generation;
|
|
7166
|
+
await this.stopProcessAndGateway();
|
|
7167
|
+
}
|
|
7168
|
+
async stopProcessAndGateway() {
|
|
7169
|
+
if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
|
|
7170
|
+
this.startupTimer = void 0;
|
|
7171
|
+
const reservation = this.reservation;
|
|
7172
|
+
this.reservation = void 0;
|
|
7173
|
+
const child = this.child;
|
|
7174
|
+
this.child = void 0;
|
|
7175
|
+
const gateway = this.gatewayValue;
|
|
7176
|
+
this.gatewayValue = void 0;
|
|
7177
|
+
await settleRemoteResources([
|
|
7178
|
+
() => reservation?.release(),
|
|
7179
|
+
() => child !== void 0 && child.exitCode === null ? terminateRemoteProcess(child) : void 0,
|
|
7180
|
+
() => gateway?.close()
|
|
7181
|
+
], "cloudflared resource cleanup failed");
|
|
7182
|
+
}
|
|
7183
|
+
};
|
|
7184
|
+
//#endregion
|
|
7185
|
+
//#region src/cloudflared-tunnel.ts
|
|
7186
|
+
const MAX_CONFIG_BYTES = 8192;
|
|
7187
|
+
const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
|
|
7188
|
+
/** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
|
|
7189
|
+
const TUNNEL_ROUTING_SUFFIX = ".cfargotunnel.com";
|
|
7190
|
+
/**
|
|
7191
|
+
* The bare registrable names behind those suffixes. A suffix test alone accepts
|
|
7192
|
+
* them, yet Cloudflare owns both apexes outright, so neither can ever be routed to
|
|
7193
|
+
* a customer tunnel.
|
|
7194
|
+
*/
|
|
7195
|
+
const RESERVED_APEX_NAMES = Object.freeze([QUICK_TUNNEL_SUFFIX.slice(1), TUNNEL_ROUTING_SUFFIX.slice(1)]);
|
|
7196
|
+
/** Ports below this need privileges on every supported platform. */
|
|
7197
|
+
const MIN_PORT = 1024;
|
|
7198
|
+
const MAX_PORT = 65535;
|
|
7199
|
+
/**
|
|
7200
|
+
* The LAN gateway's HTTPS port, held by the plugin for as long as DSH runs. It is
|
|
7201
|
+
* never available to a tunnel, so a configuration naming it must be refused up
|
|
7202
|
+
* front rather than failing later as a generic "port in use".
|
|
7203
|
+
*/
|
|
7204
|
+
const RESERVED_LAN_GATEWAY_PORT = 3443;
|
|
7205
|
+
const MAX_TOKEN_LENGTH = 4096;
|
|
7206
|
+
function hostname$1(value) {
|
|
7207
|
+
if (value.length > 253 || !value.includes(".")) return false;
|
|
7208
|
+
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
7209
|
+
}
|
|
7210
|
+
/**
|
|
7211
|
+
* Validate the public hostname Cloudflare routes to this tunnel.
|
|
7212
|
+
*
|
|
7213
|
+
* The name must be a real DNS name, not an IP literal or a wildcard, and it
|
|
7214
|
+
* cannot sit under Cloudflare's own control-plane suffixes: `trycloudflare.com`
|
|
7215
|
+
* belongs to quick tunnels, and a `cfargotunnel.com` name is the routing target
|
|
7216
|
+
* rather than a routable public address.
|
|
7217
|
+
*/
|
|
7218
|
+
function validateCloudflaredTunnelHostname(value) {
|
|
7219
|
+
if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253) throw new Error("cloudflared_tunnel_hostname_invalid");
|
|
7220
|
+
const normalized = value.toLowerCase().replace(/\.$/u, "");
|
|
7221
|
+
if (isIP(normalized) !== 0 || !hostname$1(normalized) || normalized.includes("*") || RESERVED_APEX_NAMES.includes(normalized) || normalized.endsWith(QUICK_TUNNEL_SUFFIX) || normalized.endsWith(TUNNEL_ROUTING_SUFFIX)) throw new Error("cloudflared_tunnel_hostname_invalid");
|
|
7222
|
+
return normalized;
|
|
7223
|
+
}
|
|
7224
|
+
/** Validate the stable loopback port Cloudflare's ingress forwards to. */
|
|
7225
|
+
function validateCloudflaredTunnelPort(value) {
|
|
7226
|
+
if (!Number.isSafeInteger(value) || Number(value) < MIN_PORT || Number(value) > MAX_PORT) throw new Error("cloudflared_tunnel_port_invalid");
|
|
7227
|
+
if (value === RESERVED_LAN_GATEWAY_PORT) throw new Error("cloudflared_tunnel_port_reserved");
|
|
7228
|
+
return Number(value);
|
|
7229
|
+
}
|
|
7230
|
+
/**
|
|
7231
|
+
* Validate a connector token before durable storage.
|
|
7232
|
+
*
|
|
7233
|
+
* A token is a base64url-encoded JSON blob that carries the account, tunnel id
|
|
7234
|
+
* and tunnel secret; the provider passes it through the environment rather than
|
|
7235
|
+
* the command line, and it is never returned to any client.
|
|
7236
|
+
*/
|
|
7237
|
+
function validateCloudflaredTunnelToken(value) {
|
|
7238
|
+
if (typeof value !== "string" || value !== value.trim() || value.length < 32 || value.length > MAX_TOKEN_LENGTH || !/^[A-Za-z0-9_=+/-]+$/u.test(value)) throw new Error("cloudflared_tunnel_token_invalid");
|
|
7239
|
+
return value;
|
|
7240
|
+
}
|
|
7241
|
+
/** Parse tunnel settings at the request and filesystem boundaries. */
|
|
7242
|
+
function parseCloudflaredTunnelSettings(value) {
|
|
7243
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7244
|
+
const record = value;
|
|
7245
|
+
const allowed = [
|
|
7246
|
+
"version",
|
|
7247
|
+
"mode",
|
|
7248
|
+
"token",
|
|
7249
|
+
"hostname",
|
|
7250
|
+
"port"
|
|
7251
|
+
];
|
|
7252
|
+
if (Reflect.ownKeys(record).some((key) => !allowed.includes(String(key)))) throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7253
|
+
if (record.version !== void 0 && record.version !== 1) throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7254
|
+
const mode = record.mode ?? "quick";
|
|
7255
|
+
if (mode === "quick") {
|
|
7256
|
+
if (record.token !== void 0 || record.hostname !== void 0 || record.port !== void 0) throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7257
|
+
return Object.freeze({
|
|
7258
|
+
version: 1,
|
|
7259
|
+
mode: "quick"
|
|
7260
|
+
});
|
|
7261
|
+
}
|
|
7262
|
+
if (mode !== "named") throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7263
|
+
return Object.freeze({
|
|
7264
|
+
version: 1,
|
|
7265
|
+
mode: "named",
|
|
7266
|
+
token: validateCloudflaredTunnelToken(record.token),
|
|
7267
|
+
hostname: validateCloudflaredTunnelHostname(record.hostname),
|
|
7268
|
+
port: validateCloudflaredTunnelPort(record.port)
|
|
7269
|
+
});
|
|
7270
|
+
}
|
|
7271
|
+
/**
|
|
7272
|
+
* Merge a partial panel request with the saved named-tunnel settings so a blank
|
|
7273
|
+
* field keeps its stored value. The token in particular is write-only, so the
|
|
7274
|
+
* panel submits an empty string to mean "keep the existing connector token".
|
|
7275
|
+
*/
|
|
7276
|
+
function mergeSavedCloudflaredTunnelSettings(partial, saved) {
|
|
7277
|
+
const requested = partial.mode ?? saved?.mode ?? "quick";
|
|
7278
|
+
if (requested !== "quick" && requested !== "named") throw new Error("cloudflared_tunnel_settings_invalid");
|
|
7279
|
+
if (requested === "quick") return parseCloudflaredTunnelSettings({
|
|
7280
|
+
version: 1,
|
|
7281
|
+
mode: "quick"
|
|
7282
|
+
});
|
|
7283
|
+
const previous = saved?.mode === "named" ? saved : void 0;
|
|
7284
|
+
const token = partial.token === "" || partial.token === void 0 ? previous?.token : partial.token;
|
|
7285
|
+
const hostname = partial.hostname === "" || partial.hostname === void 0 ? previous?.hostname : partial.hostname;
|
|
7286
|
+
const port = partial.port === void 0 || partial.port === "" ? previous?.port : partial.port;
|
|
7287
|
+
if (token === void 0 || hostname === void 0 || port === void 0) throw new Error("cloudflared_tunnel_config_missing");
|
|
7288
|
+
return parseCloudflaredTunnelSettings({
|
|
7289
|
+
version: 1,
|
|
7290
|
+
mode: "named",
|
|
7291
|
+
token,
|
|
7292
|
+
hostname,
|
|
7293
|
+
port
|
|
7294
|
+
});
|
|
7295
|
+
}
|
|
7296
|
+
async function atomicPrivateWrite(file, body) {
|
|
7297
|
+
const directory = dirname(file);
|
|
7298
|
+
await mkdir(directory, {
|
|
7299
|
+
recursive: true,
|
|
7300
|
+
mode: 448
|
|
7301
|
+
});
|
|
7302
|
+
try {
|
|
7303
|
+
const current = await lstat(file);
|
|
7304
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("cloudflared_tunnel_target_invalid");
|
|
7305
|
+
} catch (error) {
|
|
7306
|
+
if (error.code !== "ENOENT") throw error;
|
|
7307
|
+
}
|
|
7308
|
+
const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
7309
|
+
try {
|
|
7310
|
+
await writeFile(temporary, body, {
|
|
7311
|
+
encoding: "utf8",
|
|
7312
|
+
flag: "wx",
|
|
7313
|
+
mode: 384
|
|
7314
|
+
});
|
|
7315
|
+
await rename(temporary, file);
|
|
7316
|
+
await restrictPrivateFile(file);
|
|
7317
|
+
} catch (error) {
|
|
7318
|
+
await rm(temporary, { force: true });
|
|
7319
|
+
throw error;
|
|
7320
|
+
}
|
|
7321
|
+
}
|
|
7322
|
+
/** Owns the private cloudflared tunnel configuration for one DSH installation. */
|
|
7323
|
+
var CloudflaredTunnelStore = class {
|
|
7324
|
+
stateRoot;
|
|
7325
|
+
settingsFile;
|
|
7326
|
+
settingsValue = Object.freeze({
|
|
7327
|
+
version: 1,
|
|
7328
|
+
mode: "quick"
|
|
7329
|
+
});
|
|
7330
|
+
errorCode;
|
|
7331
|
+
constructor(stateDirectory) {
|
|
7332
|
+
if (!isAbsolute(stateDirectory)) throw new Error("cloudflared tunnel state directory must be absolute");
|
|
7333
|
+
this.stateRoot = resolve(stateDirectory);
|
|
7334
|
+
this.settingsFile = join(this.stateRoot, "tunnel.json");
|
|
7335
|
+
}
|
|
7336
|
+
/** Load private settings while rejecting links, oversized files and unknown fields. */
|
|
7337
|
+
async initialize() {
|
|
7338
|
+
let entry;
|
|
7339
|
+
try {
|
|
7340
|
+
entry = await lstat(this.settingsFile);
|
|
7341
|
+
} catch (error) {
|
|
7342
|
+
if (error.code === "ENOENT") return;
|
|
7343
|
+
throw error;
|
|
7344
|
+
}
|
|
7345
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
|
|
7346
|
+
this.errorCode = "cloudflared_tunnel_config_invalid";
|
|
7347
|
+
return;
|
|
7348
|
+
}
|
|
7349
|
+
await restrictPrivateFile(this.settingsFile);
|
|
7350
|
+
try {
|
|
7351
|
+
this.settingsValue = parseCloudflaredTunnelSettings(JSON.parse(await readFile(this.settingsFile, "utf8")));
|
|
7352
|
+
this.errorCode = void 0;
|
|
7353
|
+
} catch {
|
|
7354
|
+
this.settingsValue = Object.freeze({
|
|
7355
|
+
version: 1,
|
|
7356
|
+
mode: "quick"
|
|
7357
|
+
});
|
|
7358
|
+
this.errorCode = "cloudflared_tunnel_config_invalid";
|
|
7359
|
+
}
|
|
7360
|
+
}
|
|
7361
|
+
/** Return configuration metadata without exposing the connector token. */
|
|
7362
|
+
status() {
|
|
7363
|
+
const settings = this.settingsValue;
|
|
7364
|
+
return Object.freeze({
|
|
7365
|
+
mode: settings.mode,
|
|
7366
|
+
configured: settings.mode === "named",
|
|
7367
|
+
...settings.mode === "named" ? {
|
|
7368
|
+
hostname: settings.hostname,
|
|
7369
|
+
port: settings.port
|
|
7370
|
+
} : {},
|
|
7371
|
+
storagePath: this.stateRoot,
|
|
7372
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
7373
|
+
});
|
|
7374
|
+
}
|
|
7375
|
+
/** Return private settings only to the provider lifecycle. */
|
|
7376
|
+
settings() {
|
|
7377
|
+
return this.settingsValue;
|
|
7378
|
+
}
|
|
7379
|
+
/** Atomically replace the tunnel configuration. */
|
|
7380
|
+
async configure(value) {
|
|
7381
|
+
const settings = parseCloudflaredTunnelSettings(value);
|
|
7382
|
+
if (settings.mode === "quick") await rm(this.settingsFile, { force: true });
|
|
7383
|
+
else await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
|
|
7384
|
+
this.settingsValue = settings;
|
|
7385
|
+
this.errorCode = void 0;
|
|
7386
|
+
return this.status();
|
|
7387
|
+
}
|
|
7388
|
+
/** Forget a named tunnel and its connector token. */
|
|
7389
|
+
async purge() {
|
|
7390
|
+
await rm(this.settingsFile, { force: true });
|
|
7391
|
+
this.settingsValue = Object.freeze({
|
|
7392
|
+
version: 1,
|
|
7393
|
+
mode: "quick"
|
|
7394
|
+
});
|
|
7395
|
+
this.errorCode = void 0;
|
|
7396
|
+
return this.status();
|
|
7397
|
+
}
|
|
7398
|
+
};
|
|
7399
|
+
//#endregion
|
|
7400
|
+
//#region src/task-events.ts
|
|
7401
|
+
const TASK_EVENT_DEBOUNCE_MS = 1e3;
|
|
7402
|
+
function turnNumber(value) {
|
|
7403
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
7404
|
+
}
|
|
7405
|
+
function turnData(value) {
|
|
7406
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return {
|
|
7407
|
+
turn: 0,
|
|
7408
|
+
completed: false
|
|
7409
|
+
};
|
|
7410
|
+
const record = value;
|
|
7411
|
+
const reason = record.reason;
|
|
7412
|
+
const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
|
|
7413
|
+
return {
|
|
7414
|
+
turn: turnNumber(record.turn),
|
|
7415
|
+
completed
|
|
7416
|
+
};
|
|
7417
|
+
}
|
|
7418
|
+
/**
|
|
7419
|
+
* Subscribe to completed root turns. Subagent turns are skipped so one task
|
|
7420
|
+
* announces once, and rapid turn boundaries collapse into a single event.
|
|
7421
|
+
* Returns a disposer that also drops pending debounces.
|
|
7422
|
+
*/
|
|
7423
|
+
function watchTaskCompletions(ctx, options) {
|
|
7424
|
+
const debounceMs = options.debounceMs ?? 1e3;
|
|
7425
|
+
const pending = /* @__PURE__ */ new Map();
|
|
7426
|
+
const disposeListener = ctx.on("session/event", (session, event) => {
|
|
7427
|
+
if (event.type !== "turn/end") return;
|
|
7428
|
+
const { turn, completed } = turnData(event.data);
|
|
7429
|
+
if (!completed) return;
|
|
7430
|
+
if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
|
|
7431
|
+
const sessionId = String(session.id);
|
|
7432
|
+
options.log?.("task-completed-observed", {
|
|
7433
|
+
sessionId,
|
|
7434
|
+
turn
|
|
7435
|
+
});
|
|
7436
|
+
const previous = pending.get(sessionId);
|
|
7437
|
+
if (previous !== void 0) clearTimeout(previous);
|
|
7438
|
+
pending.set(sessionId, setTimeout(() => {
|
|
7439
|
+
pending.delete(sessionId);
|
|
7440
|
+
options.log?.("task-completed-announced", {
|
|
7441
|
+
sessionId,
|
|
7442
|
+
turn
|
|
7443
|
+
});
|
|
7444
|
+
options.onTaskCompleted(Object.freeze({
|
|
7445
|
+
sessionId,
|
|
7446
|
+
turn
|
|
7447
|
+
}));
|
|
7448
|
+
}, debounceMs));
|
|
7449
|
+
});
|
|
7450
|
+
return () => {
|
|
7451
|
+
disposeListener();
|
|
7452
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
7453
|
+
pending.clear();
|
|
7454
|
+
};
|
|
7455
|
+
}
|
|
7456
|
+
/** One subscription feeding every live gateway; gateways register on start. */
|
|
7457
|
+
var TaskEventHub = class {
|
|
7458
|
+
sinks = /* @__PURE__ */ new Set();
|
|
7459
|
+
/** Register a sink; returns its disposer. */
|
|
7460
|
+
add(sink) {
|
|
7461
|
+
this.sinks.add(sink);
|
|
7462
|
+
return () => {
|
|
7463
|
+
this.sinks.delete(sink);
|
|
7464
|
+
};
|
|
7465
|
+
}
|
|
7466
|
+
/** Fan out to a snapshot so a failing sink cannot break its siblings. */
|
|
7467
|
+
broadcast(event) {
|
|
7468
|
+
for (const sink of [...this.sinks]) try {
|
|
7469
|
+
sink.broadcastTaskEvent(event);
|
|
7470
|
+
} catch {}
|
|
7471
|
+
}
|
|
7472
|
+
/** Visible for tests. */
|
|
7473
|
+
get size() {
|
|
7474
|
+
return this.sinks.size;
|
|
7475
|
+
}
|
|
7476
|
+
};
|
|
7477
|
+
//#endregion
|
|
7478
|
+
//#region src/diagnostics.ts
|
|
7479
|
+
const REMOTE_ERROR_GUIDANCE = Object.freeze({
|
|
7480
|
+
component_missing: "重新安装完整插件包。",
|
|
7481
|
+
funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
|
|
7482
|
+
funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
|
|
7483
|
+
funnel_start_failed: "重新打开授权页并允许 Funnel。",
|
|
7484
|
+
funnel_start_timeout: "检查网络后点击“重新连接”。",
|
|
7485
|
+
tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
|
|
7486
|
+
sidecar_launch_failed: "重新安装完整插件包后重试。",
|
|
7487
|
+
sidecar_stopped: "点击“重新连接”。",
|
|
7488
|
+
sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
7489
|
+
control_channel_failed: "点击“重新连接”。",
|
|
7490
|
+
cpolar_component_missing: "先安装 cpolar 官方组件。",
|
|
7491
|
+
cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
|
|
7492
|
+
cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
|
|
7493
|
+
cpolar_config_invalid: "重新保存 cpolar Authtoken。",
|
|
7494
|
+
cpolar_start_timeout: "检查网络后点击“重新连接”。",
|
|
7495
|
+
cpolar_stopped: "点击“重新连接”。",
|
|
7496
|
+
cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
7497
|
+
cloudflared_component_missing: "先安装 cloudflared 官方组件。",
|
|
7498
|
+
cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
|
|
7499
|
+
cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
|
|
7500
|
+
cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
|
|
7501
|
+
cloudflared_start_timeout: "检查网络后点击“重新连接”。",
|
|
7502
|
+
cloudflared_stopped: "点击“重新连接”。",
|
|
7503
|
+
cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
7504
|
+
cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
|
|
7505
|
+
cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
|
|
7506
|
+
origin_config_missing: "先保存自有反向代理配置。",
|
|
7507
|
+
origin_config_invalid: "重新保存自有反向代理配置。",
|
|
7508
|
+
origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
|
|
7509
|
+
origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
|
|
7510
|
+
origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
|
|
7511
|
+
frp_component_missing: "先安装 FRP 官方组件。",
|
|
7512
|
+
frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
|
|
7513
|
+
frp_config_missing: "先保存自建 FRP 连接配置。",
|
|
7514
|
+
frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
|
|
7515
|
+
frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
|
|
7516
|
+
frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
|
|
7517
|
+
frp_launch_failed: "重新安装 FRP 官方组件后重试。",
|
|
7518
|
+
frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
|
|
7519
|
+
frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
|
|
7520
|
+
frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
|
|
7521
|
+
frp_stopped: "点击“重新连接”。",
|
|
7522
|
+
frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
|
|
7523
|
+
gateway_start_failed: "确认 DSH 正在运行后重新连接。"
|
|
7524
|
+
});
|
|
7525
|
+
function check(id, status, reason, label, detail, action, facts) {
|
|
7526
|
+
return Object.freeze({
|
|
7527
|
+
id,
|
|
7528
|
+
status,
|
|
7529
|
+
reason,
|
|
7530
|
+
...facts === void 0 ? {} : { facts: Object.freeze(facts) },
|
|
7531
|
+
label,
|
|
7532
|
+
detail,
|
|
7533
|
+
...action === void 0 ? {} : { action }
|
|
7534
|
+
});
|
|
7535
|
+
}
|
|
7536
|
+
function maskLanOrigin(origin) {
|
|
7537
|
+
if (origin === void 0) return "未分配";
|
|
7538
|
+
try {
|
|
7539
|
+
const url = new URL(origin);
|
|
7540
|
+
const octets = url.hostname.split(".");
|
|
7541
|
+
const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
|
|
7542
|
+
return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
|
|
7543
|
+
} catch {
|
|
7544
|
+
return "地址格式无效";
|
|
7545
|
+
}
|
|
7546
|
+
}
|
|
7547
|
+
function remoteSuffix(origin) {
|
|
7548
|
+
if (origin === void 0) return "未分配";
|
|
7549
|
+
try {
|
|
7550
|
+
const hostname = new URL(origin).hostname;
|
|
7551
|
+
if (hostname.endsWith(".ts.net")) return "*.ts.net";
|
|
7552
|
+
if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
|
|
7553
|
+
for (const suffix of [
|
|
7554
|
+
".cpolar.cn",
|
|
7555
|
+
".cpolar.io",
|
|
7556
|
+
".cpolar.top",
|
|
7557
|
+
".cpolar.com"
|
|
7558
|
+
]) if (hostname.endsWith(suffix)) return `*${suffix}`;
|
|
7559
|
+
return "公共 HTTPS 地址";
|
|
7560
|
+
} catch {
|
|
7561
|
+
return "地址格式无效";
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
function defaultFirewallProbe(platform = process.platform) {
|
|
7565
|
+
return async (port) => {
|
|
7566
|
+
if (platform !== "win32") return { state: "not-applicable" };
|
|
7567
|
+
if (port === void 0) return { state: "unknown" };
|
|
7568
|
+
const script = [
|
|
7569
|
+
"$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
|
|
7570
|
+
"$ready = $true",
|
|
7571
|
+
"$specs | ForEach-Object {",
|
|
7572
|
+
" $spec = $_",
|
|
7573
|
+
" $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
|
|
7574
|
+
" if ($null -eq $rule) { $ready = $false; return }",
|
|
7575
|
+
" $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
|
|
7576
|
+
` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
|
|
7577
|
+
" if ($matching.Count -eq 0) { $ready = $false }",
|
|
7578
|
+
"}",
|
|
7579
|
+
"if ($ready) { 'ready' } else { 'missing' }"
|
|
7580
|
+
].join("; ");
|
|
7581
|
+
try {
|
|
7582
|
+
return { state: (await execFileText("powershell.exe", [
|
|
7583
|
+
"-NoProfile",
|
|
7584
|
+
"-NonInteractive",
|
|
7585
|
+
"-Command",
|
|
7586
|
+
script
|
|
7587
|
+
], {
|
|
7588
|
+
encoding: "utf8",
|
|
6194
7589
|
timeout: 3e3,
|
|
6195
7590
|
windowsHide: true
|
|
6196
7591
|
})).stdout.trim() === "ready" ? "ready" : "missing" };
|
|
@@ -6247,7 +7642,7 @@ function reportLine(entry) {
|
|
|
6247
7642
|
/** Run bounded read-only checks and return a report safe to paste into an issue. */
|
|
6248
7643
|
async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
6249
7644
|
const checks = [];
|
|
6250
|
-
const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
|
|
7645
|
+
const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
|
|
6251
7646
|
const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
|
|
6252
7647
|
checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
|
|
6253
7648
|
if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
|
|
@@ -6264,6 +7659,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
6264
7659
|
else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
|
|
6265
7660
|
else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
|
|
6266
7661
|
if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
|
|
7662
|
+
else if (snapshot.remote.provider === "origin" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-origin-ready", "远程通道", "私网 HTTP 后端已就绪;未检测公网 HTTPS、证书或 WebSocket 连通性。", "在手机上经反向代理验证连接;代理须保留原始 Host(含端口)并支持 WebSocket。", { provider: "origin" }));
|
|
6267
7663
|
else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
|
|
6268
7664
|
const endpointSuffix = remoteSuffix(snapshot.remote.origin);
|
|
6269
7665
|
const facts = {
|
|
@@ -8173,16 +9569,12 @@ async function run(file, args) {
|
|
|
8173
9569
|
});
|
|
8174
9570
|
}
|
|
8175
9571
|
async function defaultFetchArtifact(url, signal) {
|
|
8176
|
-
|
|
8177
|
-
|
|
9572
|
+
return downloadPinnedArtifact({
|
|
9573
|
+
url,
|
|
9574
|
+
expectedBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
|
|
9575
|
+
errorPrefix: "cpolar",
|
|
8178
9576
|
signal
|
|
8179
9577
|
});
|
|
8180
|
-
if (!response.ok) throw new Error(`cpolar_download_http_${String(response.status)}`);
|
|
8181
|
-
const length = Number(response.headers.get("content-length"));
|
|
8182
|
-
if (Number.isFinite(length) && length !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
|
|
8183
|
-
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
8184
|
-
if (bytes.byteLength !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
|
|
8185
|
-
return bytes;
|
|
8186
9578
|
}
|
|
8187
9579
|
async function defaultExtractArtifact(archive, destination) {
|
|
8188
9580
|
if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
|
|
@@ -9290,6 +10682,7 @@ function mapAdminError(error) {
|
|
|
9290
10682
|
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
9291
10683
|
if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
|
|
9292
10684
|
if (error instanceof Error && error.message.startsWith("cpolar_")) return new HttpError(409, error.message);
|
|
10685
|
+
if (error instanceof Error && error.message.startsWith("cloudflared_")) return new HttpError(409, error.message);
|
|
9293
10686
|
if (error instanceof Error && [
|
|
9294
10687
|
"frp_server_address_invalid",
|
|
9295
10688
|
"frp_server_port_invalid",
|
|
@@ -9298,6 +10691,15 @@ function mapAdminError(error) {
|
|
|
9298
10691
|
"frp_settings_invalid"
|
|
9299
10692
|
].includes(error.message)) return new HttpError(400, error.message);
|
|
9300
10693
|
if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
|
|
10694
|
+
if (error instanceof Error && [
|
|
10695
|
+
"origin_settings_invalid",
|
|
10696
|
+
"origin_public_origin_invalid",
|
|
10697
|
+
"origin_listen_host_invalid",
|
|
10698
|
+
"origin_listen_port_invalid",
|
|
10699
|
+
"origin_listen_port_reserved",
|
|
10700
|
+
"origin_allowed_cidrs_invalid"
|
|
10701
|
+
].includes(error.message)) return new HttpError(400, error.message);
|
|
10702
|
+
if (error instanceof Error && error.message.startsWith("origin_")) return new HttpError(409, error.message);
|
|
9301
10703
|
if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
|
|
9302
10704
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
9303
10705
|
if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
|
|
@@ -9406,12 +10808,23 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
9406
10808
|
discovery: false
|
|
9407
10809
|
});
|
|
9408
10810
|
}
|
|
9409
|
-
|
|
10811
|
+
/** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
|
|
10812
|
+
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
|
|
10813
|
+
const validated = parseOriginSettings(settings);
|
|
10814
|
+
if (listenPort !== 0) validateOriginListenPort(listenPort);
|
|
10815
|
+
return Object.freeze({
|
|
10816
|
+
...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort),
|
|
10817
|
+
listenHost: validated.listenHost,
|
|
10818
|
+
allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
|
|
10819
|
+
});
|
|
10820
|
+
}
|
|
10821
|
+
function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration) {
|
|
9410
10822
|
return {
|
|
9411
10823
|
provider,
|
|
9412
10824
|
running: status.enabled,
|
|
9413
10825
|
state: status.state,
|
|
9414
10826
|
...status.origin === void 0 ? {} : { origin: status.origin },
|
|
10827
|
+
...status.backendOrigin === void 0 ? {} : { backendOrigin: status.backendOrigin },
|
|
9415
10828
|
...status.loginUrl === void 0 ? {} : { loginUrl: status.loginUrl },
|
|
9416
10829
|
...status.setupUrl === void 0 ? {} : { setupUrl: status.setupUrl },
|
|
9417
10830
|
...status.errorCode === void 0 ? {} : { errorCode: status.errorCode },
|
|
@@ -9428,12 +10841,25 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
|
|
|
9428
10841
|
state: providerStatuses.cpolar.state,
|
|
9429
10842
|
component: cpolarComponent
|
|
9430
10843
|
},
|
|
10844
|
+
cloudflared: {
|
|
10845
|
+
bundled: false,
|
|
10846
|
+
running: providerStatuses.cloudflared.enabled,
|
|
10847
|
+
state: providerStatuses.cloudflared.state,
|
|
10848
|
+
component: cloudflaredComponent,
|
|
10849
|
+
configuration: cloudflaredConfiguration
|
|
10850
|
+
},
|
|
9431
10851
|
frp: {
|
|
9432
10852
|
bundled: false,
|
|
9433
10853
|
running: providerStatuses.frp.enabled,
|
|
9434
10854
|
state: providerStatuses.frp.state,
|
|
9435
10855
|
component: frpComponent,
|
|
9436
10856
|
configuration: frpConfiguration
|
|
10857
|
+
},
|
|
10858
|
+
origin: {
|
|
10859
|
+
bundled: true,
|
|
10860
|
+
running: providerStatuses.origin.enabled,
|
|
10861
|
+
state: providerStatuses.origin.state,
|
|
10862
|
+
configuration: originConfiguration
|
|
9437
10863
|
}
|
|
9438
10864
|
}
|
|
9439
10865
|
};
|
|
@@ -9464,10 +10890,16 @@ async function apply(ctx, config) {
|
|
|
9464
10890
|
const initialRemoteProvider = (await remoteProviderStore.load()).provider;
|
|
9465
10891
|
const cpolarComponent = new CpolarComponentManager({ stateDirectory });
|
|
9466
10892
|
await cpolarComponent.initialize();
|
|
10893
|
+
const cloudflaredComponent = new CloudflaredComponentManager({ stateDirectory });
|
|
10894
|
+
await cloudflaredComponent.initialize();
|
|
9467
10895
|
const frpComponent = new FrpComponentManager({ stateDirectory });
|
|
9468
10896
|
await frpComponent.initialize();
|
|
9469
10897
|
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"));
|
|
9470
10898
|
await frpConfig.initialize();
|
|
10899
|
+
const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
|
|
10900
|
+
await originConfig.initialize();
|
|
10901
|
+
const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
|
|
10902
|
+
await cloudflaredTunnel.initialize();
|
|
9471
10903
|
const unregisterBuiltin = mobileAccess.registerExtension({
|
|
9472
10904
|
schemaVersion: 1,
|
|
9473
10905
|
id: "computer-images",
|
|
@@ -9561,9 +10993,22 @@ async function apply(ctx, config) {
|
|
|
9561
10993
|
await candidate.start();
|
|
9562
10994
|
return candidate;
|
|
9563
10995
|
};
|
|
10996
|
+
const createOriginGateway = async (settings) => {
|
|
10997
|
+
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
10998
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
10999
|
+
try {
|
|
11000
|
+
await candidate.start();
|
|
11001
|
+
} catch (error) {
|
|
11002
|
+
await candidate.close();
|
|
11003
|
+
throw error;
|
|
11004
|
+
}
|
|
11005
|
+
return candidate;
|
|
11006
|
+
};
|
|
9564
11007
|
const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
|
|
9565
11008
|
const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
|
|
11009
|
+
const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
|
|
9566
11010
|
const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
|
|
11011
|
+
const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
|
|
9567
11012
|
const remoteControllers = {
|
|
9568
11013
|
tailscale: new FunnelController({
|
|
9569
11014
|
store: tailscaleStore,
|
|
@@ -9578,21 +11023,28 @@ async function apply(ctx, config) {
|
|
|
9578
11023
|
configFile: cpolarComponent.configFile,
|
|
9579
11024
|
createGateway: createRemoteGateway
|
|
9580
11025
|
}),
|
|
11026
|
+
cloudflared: new CloudflaredController({
|
|
11027
|
+
store: cloudflaredStore,
|
|
11028
|
+
executable: cloudflaredComponent.executable,
|
|
11029
|
+
tunnel: cloudflaredTunnel,
|
|
11030
|
+
createGateway: createRemoteGateway
|
|
11031
|
+
}),
|
|
9581
11032
|
frp: new FrpController({
|
|
9582
11033
|
store: frpStore,
|
|
9583
11034
|
executable: frpComponent.executable,
|
|
9584
11035
|
config: frpConfig,
|
|
9585
11036
|
instanceId,
|
|
9586
11037
|
createGateway: createRemoteGateway
|
|
11038
|
+
}),
|
|
11039
|
+
origin: new OriginController({
|
|
11040
|
+
store: originStore,
|
|
11041
|
+
config: originConfig,
|
|
11042
|
+
createGateway: createOriginGateway
|
|
9587
11043
|
})
|
|
9588
11044
|
};
|
|
9589
11045
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
9590
11046
|
const remoteController = () => remoteProviders.controller();
|
|
9591
|
-
for (const provider of
|
|
9592
|
-
"tailscale",
|
|
9593
|
-
"cpolar",
|
|
9594
|
-
"frp"
|
|
9595
|
-
]) {
|
|
11047
|
+
for (const provider of REMOTE_PROVIDERS) {
|
|
9596
11048
|
const controller = remoteControllers[provider];
|
|
9597
11049
|
taskEventHub.add({ broadcastTaskEvent: (event) => {
|
|
9598
11050
|
controller.gateway()?.broadcastTaskEvent(event);
|
|
@@ -9601,8 +11053,10 @@ async function apply(ctx, config) {
|
|
|
9601
11053
|
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
9602
11054
|
tailscale: remoteControllers.tailscale.status(),
|
|
9603
11055
|
cpolar: remoteControllers.cpolar.status(),
|
|
9604
|
-
|
|
9605
|
-
|
|
11056
|
+
cloudflared: remoteControllers.cloudflared.status(),
|
|
11057
|
+
frp: remoteControllers.frp.status(),
|
|
11058
|
+
origin: remoteControllers.origin.status()
|
|
11059
|
+
}, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status());
|
|
9606
11060
|
const lanPayload = () => ({
|
|
9607
11061
|
...loaded.kind === "unconfigured" ? {
|
|
9608
11062
|
configured: preparedLanSetup !== void 0,
|
|
@@ -9737,11 +11191,29 @@ async function apply(ctx, config) {
|
|
|
9737
11191
|
}
|
|
9738
11192
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
|
|
9739
11193
|
const body = await readJsonObject(request, 4096);
|
|
9740
|
-
if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "frp") throw new HttpError(400, "bad_request");
|
|
11194
|
+
if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin") throw new HttpError(400, "bad_request");
|
|
9741
11195
|
await remoteProviders.select(body.provider);
|
|
9742
11196
|
sendJson(response, 200, remotePayload(), false);
|
|
9743
11197
|
return;
|
|
9744
11198
|
}
|
|
11199
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/configure`) {
|
|
11200
|
+
const body = await readJsonObject(request, 8192);
|
|
11201
|
+
await remoteProviders.mutate(async () => {
|
|
11202
|
+
await originConfig.configure(body);
|
|
11203
|
+
if (remoteControllers.origin.status().enabled) await remoteControllers.origin.reconnect();
|
|
11204
|
+
});
|
|
11205
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11206
|
+
return;
|
|
11207
|
+
}
|
|
11208
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/purge`) {
|
|
11209
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11210
|
+
await remoteProviders.mutate(async () => {
|
|
11211
|
+
await remoteControllers.origin.setEnabled(false);
|
|
11212
|
+
await originConfig.purge();
|
|
11213
|
+
});
|
|
11214
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11215
|
+
return;
|
|
11216
|
+
}
|
|
9745
11217
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
|
|
9746
11218
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
9747
11219
|
await remoteProviders.mutate(async () => cpolarComponent.install());
|
|
@@ -9763,6 +11235,46 @@ async function apply(ctx, config) {
|
|
|
9763
11235
|
sendJson(response, 200, remotePayload(), false);
|
|
9764
11236
|
return;
|
|
9765
11237
|
}
|
|
11238
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/component/install`) {
|
|
11239
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11240
|
+
logger.info("cloudflared component install started");
|
|
11241
|
+
try {
|
|
11242
|
+
await remoteProviders.mutate(async () => cloudflaredComponent.install());
|
|
11243
|
+
logger.info("cloudflared component install completed");
|
|
11244
|
+
} catch (error) {
|
|
11245
|
+
logger.error("cloudflared component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
|
|
11246
|
+
throw error;
|
|
11247
|
+
}
|
|
11248
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11249
|
+
return;
|
|
11250
|
+
}
|
|
11251
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/component/purge`) {
|
|
11252
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11253
|
+
await remoteProviders.mutate(async () => {
|
|
11254
|
+
await remoteControllers.cloudflared.setEnabled(false);
|
|
11255
|
+
await cloudflaredComponent.purge();
|
|
11256
|
+
});
|
|
11257
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11258
|
+
return;
|
|
11259
|
+
}
|
|
11260
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
|
|
11261
|
+
const body = await readJsonObject(request, 8192);
|
|
11262
|
+
await remoteProviders.mutate(async () => {
|
|
11263
|
+
await cloudflaredTunnel.configure(mergeSavedCloudflaredTunnelSettings(body, cloudflaredTunnel.settings()));
|
|
11264
|
+
if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.reconnect();
|
|
11265
|
+
});
|
|
11266
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11267
|
+
return;
|
|
11268
|
+
}
|
|
11269
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel/purge`) {
|
|
11270
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
11271
|
+
await remoteProviders.mutate(async () => {
|
|
11272
|
+
await remoteControllers.cloudflared.setEnabled(false);
|
|
11273
|
+
await cloudflaredTunnel.purge();
|
|
11274
|
+
});
|
|
11275
|
+
sendJson(response, 200, remotePayload(), false);
|
|
11276
|
+
return;
|
|
11277
|
+
}
|
|
9766
11278
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
|
|
9767
11279
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
9768
11280
|
logger.info("frpc component install started");
|
|
@@ -9981,17 +11493,18 @@ async function apply(ctx, config) {
|
|
|
9981
11493
|
const stores = {
|
|
9982
11494
|
tailscale: tailscaleStore,
|
|
9983
11495
|
cpolar: cpolarStore,
|
|
9984
|
-
|
|
11496
|
+
cloudflared: cloudflaredStore,
|
|
11497
|
+
frp: frpStore,
|
|
11498
|
+
origin: originStore
|
|
9985
11499
|
};
|
|
9986
11500
|
await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].save({
|
|
9987
11501
|
version: 1,
|
|
9988
11502
|
enabled: false
|
|
9989
11503
|
})));
|
|
9990
|
-
for (const provider of [
|
|
9991
|
-
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
]) await remoteControllers[provider].initialize();
|
|
11504
|
+
for (const provider of REMOTE_PROVIDERS) await remoteControllers[provider].initialize();
|
|
11505
|
+
const lanDiscovery = lanGateway?.discoveryStatus();
|
|
11506
|
+
if (lanDiscovery !== void 0 && !lanDiscovery.broadcast) logger.warn("broadcast discovery is unavailable (%s); mDNS is still published, so pair manually or free the UDP port", lanDiscovery.errorCode ?? "unknown_cause");
|
|
11507
|
+
if (lanDiscovery?.mobileAssetsErrorCode !== void 0) logger.warn("a bundled mobile asset is missing (%s); the phone frontend will serve without it", lanDiscovery.mobileAssetsErrorCode);
|
|
9995
11508
|
} catch (error) {
|
|
9996
11509
|
try {
|
|
9997
11510
|
await settleCleanupSteps([
|
|
@@ -10028,6 +11541,6 @@ async function apply(ctx, config) {
|
|
|
10028
11541
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
10029
11542
|
}
|
|
10030
11543
|
//#endregion
|
|
10031
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, 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, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath, watchTaskCompletions };
|
|
11544
|
+
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 };
|
|
10032
11545
|
|
|
10033
11546
|
//# sourceMappingURL=index.mjs.map
|