dsh-mobile 0.4.0 → 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 +40 -8
- package/README.en.md +48 -20
- package/README.md +41 -20
- package/SECURITY.md +2 -0
- 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 +77 -0
- package/docs/SELF_HOSTED_FRP.md +3 -1
- package/docs/SELF_HOSTED_ORIGIN.en.md +34 -0
- package/docs/SELF_HOSTED_ORIGIN.md +85 -0
- package/lib/cli.js +3 -1
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1193 -112
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +361 -4
- package/lib/index.mjs +1831 -252
- 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 +26 -9
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");
|
|
@@ -389,7 +385,7 @@ var AccessController = class {
|
|
|
389
385
|
};
|
|
390
386
|
//#endregion
|
|
391
387
|
//#region src/network.ts
|
|
392
|
-
function parseIpv4(address) {
|
|
388
|
+
function parseIpv4$1(address) {
|
|
393
389
|
const parts = address.split(".");
|
|
394
390
|
if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
395
391
|
let value = 0n;
|
|
@@ -403,7 +399,7 @@ function parseIpv4(address) {
|
|
|
403
399
|
}
|
|
404
400
|
function parseIpv6Part(part, address) {
|
|
405
401
|
if (part.includes(".")) {
|
|
406
|
-
const ipv4 = parseIpv4(part);
|
|
402
|
+
const ipv4 = parseIpv4$1(part);
|
|
407
403
|
return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
|
|
408
404
|
}
|
|
409
405
|
if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
@@ -433,12 +429,12 @@ function parseIp(address) {
|
|
|
433
429
|
const mapped = mappedIpv4(unwrapped);
|
|
434
430
|
if (mapped !== void 0) return {
|
|
435
431
|
bits: 32,
|
|
436
|
-
value: parseIpv4(mapped)
|
|
432
|
+
value: parseIpv4$1(mapped)
|
|
437
433
|
};
|
|
438
434
|
const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
|
|
439
435
|
if (version === 4) return {
|
|
440
436
|
bits: 32,
|
|
441
|
-
value: parseIpv4(unwrapped)
|
|
437
|
+
value: parseIpv4$1(unwrapped)
|
|
442
438
|
};
|
|
443
439
|
if (version === 6) return {
|
|
444
440
|
bits: 128,
|
|
@@ -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
|
})(),
|
|
@@ -1079,6 +1077,46 @@ var MobileAccessGatewayController = class {
|
|
|
1079
1077
|
}
|
|
1080
1078
|
};
|
|
1081
1079
|
//#endregion
|
|
1080
|
+
//#region src/local-admin-host.ts
|
|
1081
|
+
/** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
|
|
1082
|
+
function unwrapBrackets(hostname) {
|
|
1083
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1084
|
+
}
|
|
1085
|
+
function parseIpv4(hostname) {
|
|
1086
|
+
const parts = hostname.split(".");
|
|
1087
|
+
if (parts.length !== 4) return void 0;
|
|
1088
|
+
const octets = [];
|
|
1089
|
+
for (const part of parts) {
|
|
1090
|
+
if (!/^(?:0|[1-9]\d{0,2})$/u.test(part)) return void 0;
|
|
1091
|
+
const value = Number(part);
|
|
1092
|
+
if (value > 255) return void 0;
|
|
1093
|
+
octets.push(value);
|
|
1094
|
+
}
|
|
1095
|
+
return octets;
|
|
1096
|
+
}
|
|
1097
|
+
function isLoopbackIpv4(octets) {
|
|
1098
|
+
return octets[0] === 127;
|
|
1099
|
+
}
|
|
1100
|
+
function isPrivateIpv4(octets) {
|
|
1101
|
+
return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
|
|
1102
|
+
}
|
|
1103
|
+
function isLinkLocalIpv4(octets) {
|
|
1104
|
+
return octets[0] === 169 && octets[1] === 254;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Hostnames that may appear on the DSH desktop admin surface.
|
|
1108
|
+
* Loopback, RFC1918, and IPv4 link-local are accepted. Public IPs,
|
|
1109
|
+
* CGNAT, and arbitrary DNS names stay rejected so DNS rebinding
|
|
1110
|
+
* cannot reach `/api/mobile-access`.
|
|
1111
|
+
*/
|
|
1112
|
+
function isLocalAdminHostname(hostname) {
|
|
1113
|
+
const host = unwrapBrackets(hostname).trim().toLowerCase();
|
|
1114
|
+
if (host === "localhost" || host === "::1") return true;
|
|
1115
|
+
const octets = parseIpv4(host);
|
|
1116
|
+
if (octets === void 0) return false;
|
|
1117
|
+
return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
|
|
1118
|
+
}
|
|
1119
|
+
//#endregion
|
|
1082
1120
|
//#region src/http-security.ts
|
|
1083
1121
|
const DEVICE_COOKIE = "dsh_ma_device";
|
|
1084
1122
|
const SESSION_COOKIE = "dsh_ma_session";
|
|
@@ -1122,26 +1160,43 @@ function parseRequestTarget(raw) {
|
|
|
1122
1160
|
search: parsed.search
|
|
1123
1161
|
});
|
|
1124
1162
|
}
|
|
1125
|
-
/**
|
|
1126
|
-
|
|
1163
|
+
/**
|
|
1164
|
+
* The frame sources a proxied GUI document may embed: its own routes, blob:
|
|
1165
|
+
* (the PDF viewer's object URL) and external http(s) pages (the sidebar
|
|
1166
|
+
* browser tab). The framed document runs in the tab's existing sandbox
|
|
1167
|
+
* (opaque origin, no same-origin privileges), so this does not hand the
|
|
1168
|
+
* embedded page anything it did not already have.
|
|
1169
|
+
*/
|
|
1170
|
+
const PROXIED_FRAME_SRC = "frame-src 'self' blob: https: http:";
|
|
1171
|
+
/**
|
|
1172
|
+
* Set the gateway browser protections and non-cacheability.
|
|
1173
|
+
* @param response - the response to decorate.
|
|
1174
|
+
* @param tls - whether the connection is TLS (adds HSTS).
|
|
1175
|
+
* @param framing - the framing policy; defaults to the strict gateway one, so
|
|
1176
|
+
* every existing call site keeps refusing to be framed. Only the two PROXIED
|
|
1177
|
+
* forwarders pass `'proxied'`.
|
|
1178
|
+
*/
|
|
1179
|
+
function setSecurityHeaders(response, tls, framing = "gateway") {
|
|
1180
|
+
const proxied = framing === "proxied";
|
|
1127
1181
|
response.setHeader("Cache-Control", "no-store");
|
|
1128
1182
|
response.setHeader("Content-Security-Policy", [
|
|
1129
1183
|
"default-src 'self'",
|
|
1130
1184
|
"base-uri 'none'",
|
|
1131
1185
|
"object-src 'none'",
|
|
1132
|
-
"frame-ancestors 'none'",
|
|
1186
|
+
proxied ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
|
|
1133
1187
|
"form-action 'self'",
|
|
1134
1188
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1135
1189
|
"style-src 'self' 'unsafe-inline'",
|
|
1136
1190
|
"img-src 'self' data: blob:",
|
|
1137
1191
|
"font-src 'self' data:",
|
|
1138
1192
|
"connect-src 'self'",
|
|
1139
|
-
"worker-src 'self' blob:"
|
|
1193
|
+
"worker-src 'self' blob:",
|
|
1194
|
+
...proxied ? [PROXIED_FRAME_SRC] : []
|
|
1140
1195
|
].join("; "));
|
|
1141
1196
|
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
1142
1197
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1143
1198
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
1144
|
-
response.setHeader("X-Frame-Options", "DENY");
|
|
1199
|
+
response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
|
|
1145
1200
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1146
1201
|
if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
|
|
1147
1202
|
}
|
|
@@ -1238,7 +1293,7 @@ function localAuthority(header) {
|
|
|
1238
1293
|
function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
1239
1294
|
if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
|
|
1240
1295
|
const host = localAuthority(request.headers.host);
|
|
1241
|
-
if (host === void 0 ||
|
|
1296
|
+
if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
|
|
1242
1297
|
const site = request.headers["sec-fetch-site"];
|
|
1243
1298
|
if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
|
|
1244
1299
|
const origin = request.headers.origin;
|
|
@@ -1249,7 +1304,52 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1249
1304
|
if (error instanceof HttpError) throw error;
|
|
1250
1305
|
throw new HttpError(403, "forbidden");
|
|
1251
1306
|
}
|
|
1252
|
-
if (requireBrowserOrigin &&
|
|
1307
|
+
if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
|
|
1308
|
+
if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
|
|
1309
|
+
}
|
|
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)}`;
|
|
1253
1353
|
}
|
|
1254
1354
|
//#endregion
|
|
1255
1355
|
//#region src/version.ts
|
|
@@ -1434,7 +1534,7 @@ function normalizeRelativePath(value, field) {
|
|
|
1434
1534
|
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1435
1535
|
return normalized;
|
|
1436
1536
|
}
|
|
1437
|
-
async function regularFile$
|
|
1537
|
+
async function regularFile$3(path, maximum, field) {
|
|
1438
1538
|
let info;
|
|
1439
1539
|
try {
|
|
1440
1540
|
info = await lstat(path);
|
|
@@ -1455,7 +1555,7 @@ async function containedPath(root, relativePath, maximum, field) {
|
|
|
1455
1555
|
const targetReal = await realpath(target);
|
|
1456
1556
|
const relation = relative(rootReal, targetReal);
|
|
1457
1557
|
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
|
|
1458
|
-
return regularFile$
|
|
1558
|
+
return regularFile$3(targetReal, maximum, field);
|
|
1459
1559
|
}
|
|
1460
1560
|
async function optionalFile(root, name, maximum, field) {
|
|
1461
1561
|
try {
|
|
@@ -1532,7 +1632,7 @@ async function assetSnapshot(extensionRootReal) {
|
|
|
1532
1632
|
}
|
|
1533
1633
|
async function extensionFingerprint(directory) {
|
|
1534
1634
|
const root = await realExtensionRoot(directory);
|
|
1535
|
-
const manifestFile = await regularFile$
|
|
1635
|
+
const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
1536
1636
|
const manifestBody = await readFile(manifestFile.path);
|
|
1537
1637
|
const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
|
|
1538
1638
|
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
@@ -1641,6 +1741,12 @@ function combineSignalLifetime(first, second) {
|
|
|
1641
1741
|
cleanup
|
|
1642
1742
|
};
|
|
1643
1743
|
}
|
|
1744
|
+
/** Validate and normalize an action input through either supported schema form. */
|
|
1745
|
+
function parseActionInput(schema, input) {
|
|
1746
|
+
if (schema === void 0) return input;
|
|
1747
|
+
if (typeof schema === "function") return schema(input);
|
|
1748
|
+
return schema.parse(input) ?? input;
|
|
1749
|
+
}
|
|
1644
1750
|
/** Host registry and service consumed by both npm plugins and local extensions. */
|
|
1645
1751
|
var MobileAccessService = class extends Service {
|
|
1646
1752
|
registered = /* @__PURE__ */ new Map();
|
|
@@ -1772,9 +1878,9 @@ var MobileAccessService = class extends Service {
|
|
|
1772
1878
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
1773
1879
|
const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
|
|
1774
1880
|
if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
|
|
1775
|
-
let parsed
|
|
1881
|
+
let parsed;
|
|
1776
1882
|
try {
|
|
1777
|
-
parsed = action.input
|
|
1883
|
+
parsed = parseActionInput(action.input, input);
|
|
1778
1884
|
} catch {
|
|
1779
1885
|
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
1780
1886
|
}
|
|
@@ -2022,7 +2128,7 @@ async function abortAndDisposeLocal(entries) {
|
|
|
2022
2128
|
}
|
|
2023
2129
|
async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
2024
2130
|
const root = await realExtensionRoot(directory);
|
|
2025
|
-
const manifestFile = await regularFile$
|
|
2131
|
+
const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
2026
2132
|
const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
|
|
2027
2133
|
if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
|
|
2028
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;
|
|
@@ -2457,7 +2563,7 @@ function rewriteMobileIndexWithBatch(html) {
|
|
|
2457
2563
|
}
|
|
2458
2564
|
const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
|
|
2459
2565
|
return Object.freeze({
|
|
2460
|
-
html: ensureMobileViewport(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`),
|
|
2566
|
+
html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`)),
|
|
2461
2567
|
...mobileBatch === void 0 ? {} : { batch: mobileBatch }
|
|
2462
2568
|
});
|
|
2463
2569
|
}
|
|
@@ -2882,6 +2988,15 @@ var MobileAccessGateway = class {
|
|
|
2882
2988
|
server;
|
|
2883
2989
|
discoverySocket;
|
|
2884
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;
|
|
2885
3000
|
bonjour;
|
|
2886
3001
|
pairingCaCertificate;
|
|
2887
3002
|
listenerPort;
|
|
@@ -2957,6 +3072,9 @@ var MobileAccessGateway = class {
|
|
|
2957
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");
|
|
2958
3073
|
this.pairingCaCertificate = certificate.raw.toString("base64");
|
|
2959
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("_")}`;
|
|
2960
3078
|
const handler = (request, response) => {
|
|
2961
3079
|
this.handleExternalRequest(request, response).catch((error) => {
|
|
2962
3080
|
const mapped = mapError(error);
|
|
@@ -3029,24 +3147,35 @@ var MobileAccessGateway = class {
|
|
|
3029
3147
|
if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
|
|
3030
3148
|
socket.send(announcement, remote.port, remote.address, () => void 0);
|
|
3031
3149
|
});
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
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);
|
|
3035
3159
|
};
|
|
3036
|
-
socket.once("error",
|
|
3037
|
-
const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
|
|
3160
|
+
socket.once("error", onBindError);
|
|
3038
3161
|
socket.bind(port, bindHost, () => {
|
|
3039
|
-
socket.off("error",
|
|
3162
|
+
socket.off("error", onBindError);
|
|
3040
3163
|
socket.setBroadcast(true);
|
|
3041
|
-
resolve();
|
|
3164
|
+
resolve(true);
|
|
3042
3165
|
});
|
|
3043
|
-
})
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
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
|
+
}
|
|
3050
3179
|
const deviceName = discoveryDeviceName();
|
|
3051
3180
|
const bonjour = new Bonjour({ disableIPv6: true });
|
|
3052
3181
|
this.bonjour = bonjour;
|
|
@@ -3112,6 +3241,21 @@ var MobileAccessGateway = class {
|
|
|
3112
3241
|
origin
|
|
3113
3242
|
});
|
|
3114
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
|
+
}
|
|
3115
3259
|
requirePolicy() {
|
|
3116
3260
|
if (this.policy === void 0 || this.closing) throw new HttpError(503, "unavailable");
|
|
3117
3261
|
return this.policy;
|
|
@@ -3350,6 +3494,10 @@ var MobileAccessGateway = class {
|
|
|
3350
3494
|
file: this.config.mobileLayoutFile,
|
|
3351
3495
|
contentType: "text/javascript; charset=utf-8",
|
|
3352
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
|
|
3353
3501
|
} : void 0 : void 0;
|
|
3354
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");
|
|
3355
3503
|
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
|
|
@@ -3766,7 +3914,7 @@ var MobileAccessGateway = class {
|
|
|
3766
3914
|
delete headers["content-length"];
|
|
3767
3915
|
delete headers["content-encoding"];
|
|
3768
3916
|
delete headers.etag;
|
|
3769
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
3917
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
3770
3918
|
response.writeHead(200, {
|
|
3771
3919
|
...headers,
|
|
3772
3920
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -4018,7 +4166,7 @@ var MobileAccessGateway = class {
|
|
|
4018
4166
|
}
|
|
4019
4167
|
bodyDone.catch(reject);
|
|
4020
4168
|
});
|
|
4021
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
4169
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
4022
4170
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
4023
4171
|
const cacheControl = revisionedStaticCacheControl(request$2);
|
|
4024
4172
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
@@ -4338,7 +4486,8 @@ var MobileAccessGateway = class {
|
|
|
4338
4486
|
connections: this.connectedSockets.size,
|
|
4339
4487
|
activeRequests: this.activeRequests.size,
|
|
4340
4488
|
webSockets: this.activeWebSockets.size
|
|
4341
|
-
}
|
|
4489
|
+
},
|
|
4490
|
+
discovery: this.discoveryStatus()
|
|
4342
4491
|
}, false);
|
|
4343
4492
|
return;
|
|
4344
4493
|
}
|
|
@@ -4629,11 +4778,11 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4629
4778
|
downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_darwin_arm64.tar.gz`
|
|
4630
4779
|
}
|
|
4631
4780
|
].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
|
|
4632
|
-
function inside$
|
|
4781
|
+
function inside$2(parent, child) {
|
|
4633
4782
|
const candidate = relative(parent, child);
|
|
4634
4783
|
return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
|
|
4635
4784
|
}
|
|
4636
|
-
async function regularFile$
|
|
4785
|
+
async function regularFile$2(file) {
|
|
4637
4786
|
try {
|
|
4638
4787
|
const entry = await lstat(file);
|
|
4639
4788
|
return entry.isFile() && !entry.isSymbolicLink();
|
|
@@ -4673,7 +4822,7 @@ async function replaceDirectory(target, candidate) {
|
|
|
4673
4822
|
});
|
|
4674
4823
|
}
|
|
4675
4824
|
}
|
|
4676
|
-
function sha256$
|
|
4825
|
+
function sha256$2(bytes) {
|
|
4677
4826
|
return createHash("sha256").update(bytes).digest("hex");
|
|
4678
4827
|
}
|
|
4679
4828
|
async function runCapture(file, args) {
|
|
@@ -4725,10 +4874,10 @@ async function defaultExtractArtifact$1(archive, destination, executableName) {
|
|
|
4725
4874
|
executableEntry
|
|
4726
4875
|
]);
|
|
4727
4876
|
const extracted = join(unpacked, ...validatedArchiveEntry(executableEntry));
|
|
4728
|
-
if (!await regularFile$
|
|
4877
|
+
if (!await regularFile$2(extracted)) throw new Error("frp_archive_executable_invalid");
|
|
4729
4878
|
await copyFile(extracted, join(destination, executableName));
|
|
4730
4879
|
}
|
|
4731
|
-
async function defaultFetchArtifact$
|
|
4880
|
+
async function defaultFetchArtifact$2(artifact, signal) {
|
|
4732
4881
|
const response = await fetch(artifact.downloadUrl, {
|
|
4733
4882
|
redirect: "follow",
|
|
4734
4883
|
signal
|
|
@@ -4797,14 +4946,14 @@ var FrpComponentManager = class {
|
|
|
4797
4946
|
this.componentStorage,
|
|
4798
4947
|
this.logRoot,
|
|
4799
4948
|
this.stagingRoot
|
|
4800
|
-
]) if (!inside$
|
|
4801
|
-
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;
|
|
4802
4951
|
this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact$1;
|
|
4803
4952
|
this.inspectExecutable = options.inspectExecutable ?? defaultInspectExecutable;
|
|
4804
4953
|
}
|
|
4805
4954
|
/** Inspect the managed executable without relying on global FRP installations. */
|
|
4806
4955
|
async initialize() {
|
|
4807
|
-
this.installed = await regularFile$
|
|
4956
|
+
this.installed = await regularFile$2(this.executable);
|
|
4808
4957
|
this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
|
|
4809
4958
|
if (this.installed) try {
|
|
4810
4959
|
if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
@@ -4851,7 +5000,7 @@ var FrpComponentManager = class {
|
|
|
4851
5000
|
clearTimeout(timeout);
|
|
4852
5001
|
}
|
|
4853
5002
|
if (bytes.byteLength !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
|
|
4854
|
-
if (sha256$
|
|
5003
|
+
if (sha256$2(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
|
|
4855
5004
|
const archive = join(staging, artifact.archiveName);
|
|
4856
5005
|
await writeFile(archive, bytes, {
|
|
4857
5006
|
flag: "wx",
|
|
@@ -4859,7 +5008,7 @@ var FrpComponentManager = class {
|
|
|
4859
5008
|
});
|
|
4860
5009
|
await this.extractArtifact(archive, staging, artifact.executableName);
|
|
4861
5010
|
const extracted = join(staging, artifact.executableName);
|
|
4862
|
-
if (!await regularFile$
|
|
5011
|
+
if (!await regularFile$2(extracted)) throw new Error("frp_executable_missing");
|
|
4863
5012
|
await chmod(extracted, 448);
|
|
4864
5013
|
if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
4865
5014
|
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
@@ -5115,8 +5264,8 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
|
5115
5264
|
}
|
|
5116
5265
|
//#endregion
|
|
5117
5266
|
//#region src/frp-config.ts
|
|
5118
|
-
const MAX_SETTINGS_BYTES = 8192;
|
|
5119
|
-
function hostname$
|
|
5267
|
+
const MAX_SETTINGS_BYTES$1 = 8192;
|
|
5268
|
+
function hostname$2(value) {
|
|
5120
5269
|
if (value.length > 253 || !value.includes(".")) return false;
|
|
5121
5270
|
return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
|
|
5122
5271
|
}
|
|
@@ -5124,7 +5273,7 @@ function hostname$1(value) {
|
|
|
5124
5273
|
function validateFrpServerAddress(value) {
|
|
5125
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");
|
|
5126
5275
|
const normalized = value.toLowerCase().replace(/\.$/u, "");
|
|
5127
|
-
if (isIP(normalized) === 0 && !hostname$
|
|
5276
|
+
if (isIP(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
|
|
5128
5277
|
return normalized;
|
|
5129
5278
|
}
|
|
5130
5279
|
/** Validate the FRP control port. */
|
|
@@ -5147,7 +5296,7 @@ function validateFrpPublicOrigin(value) {
|
|
|
5147
5296
|
throw new Error("frp_public_origin_invalid");
|
|
5148
5297
|
}
|
|
5149
5298
|
const publicHost = url.hostname;
|
|
5150
|
-
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");
|
|
5151
5300
|
if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
|
|
5152
5301
|
return url.origin;
|
|
5153
5302
|
}
|
|
@@ -5226,7 +5375,7 @@ function createFrpcToml(settings, localPort) {
|
|
|
5226
5375
|
function createFrpServerTemplate(settings) {
|
|
5227
5376
|
return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
|
|
5228
5377
|
}
|
|
5229
|
-
async function atomicPrivateWrite(file, body) {
|
|
5378
|
+
async function atomicPrivateWrite$2(file, body) {
|
|
5230
5379
|
const directory = dirname(file);
|
|
5231
5380
|
await mkdir(directory, {
|
|
5232
5381
|
recursive: true,
|
|
@@ -5274,7 +5423,7 @@ var FrpConfigStore = class {
|
|
|
5274
5423
|
if (error.code === "ENOENT") return;
|
|
5275
5424
|
throw error;
|
|
5276
5425
|
}
|
|
5277
|
-
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES) {
|
|
5426
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_SETTINGS_BYTES$1) {
|
|
5278
5427
|
this.errorCode = "frp_config_invalid";
|
|
5279
5428
|
return;
|
|
5280
5429
|
}
|
|
@@ -5309,7 +5458,7 @@ var FrpConfigStore = class {
|
|
|
5309
5458
|
/** Atomically replace private FRP settings. */
|
|
5310
5459
|
async configure(value) {
|
|
5311
5460
|
const settings = parseFrpSettings(value);
|
|
5312
|
-
await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
|
|
5461
|
+
await atomicPrivateWrite$2(this.settingsFile, `${JSON.stringify(settings)}\n`);
|
|
5313
5462
|
await rm(this.runtimeConfigFile, { force: true });
|
|
5314
5463
|
this.settingsValue = settings;
|
|
5315
5464
|
this.errorCode = void 0;
|
|
@@ -5319,7 +5468,7 @@ var FrpConfigStore = class {
|
|
|
5319
5468
|
async writeRuntimeConfig(localPort) {
|
|
5320
5469
|
const settings = this.settingsValue;
|
|
5321
5470
|
if (settings === void 0) throw new Error("frp_config_missing");
|
|
5322
|
-
await atomicPrivateWrite(this.runtimeConfigFile, createFrpcToml(settings, localPort));
|
|
5471
|
+
await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort));
|
|
5323
5472
|
return this.runtimeConfigFile;
|
|
5324
5473
|
}
|
|
5325
5474
|
/** Remove only configuration files owned by the FRP provider. */
|
|
@@ -5335,11 +5484,13 @@ var FrpConfigStore = class {
|
|
|
5335
5484
|
};
|
|
5336
5485
|
//#endregion
|
|
5337
5486
|
//#region src/remote.ts
|
|
5338
|
-
const REMOTE_PROVIDERS = [
|
|
5487
|
+
const REMOTE_PROVIDERS = Object.freeze([
|
|
5339
5488
|
"tailscale",
|
|
5340
5489
|
"cpolar",
|
|
5341
|
-
"
|
|
5342
|
-
|
|
5490
|
+
"cloudflared",
|
|
5491
|
+
"frp",
|
|
5492
|
+
"origin"
|
|
5493
|
+
]);
|
|
5343
5494
|
function aggregateErrors(errors, message) {
|
|
5344
5495
|
if (errors.length === 0) return void 0;
|
|
5345
5496
|
if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
|
|
@@ -5465,7 +5616,7 @@ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTim
|
|
|
5465
5616
|
function parseRemoteProviderState(value) {
|
|
5466
5617
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
|
|
5467
5618
|
const record = value;
|
|
5468
|
-
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");
|
|
5469
5620
|
return Object.freeze({
|
|
5470
5621
|
version: 1,
|
|
5471
5622
|
provider: record.provider
|
|
@@ -5531,17 +5682,17 @@ var JsonRemoteProviderStore = class {
|
|
|
5531
5682
|
/** Resolve the first-run provider without letting environment values bypass validation. */
|
|
5532
5683
|
function configuredRemoteProvider(environment) {
|
|
5533
5684
|
const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
|
|
5534
|
-
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");
|
|
5535
5686
|
return value;
|
|
5536
5687
|
}
|
|
5537
5688
|
//#endregion
|
|
5538
5689
|
//#region src/frp.ts
|
|
5539
|
-
const START_TIMEOUT_MS$
|
|
5690
|
+
const START_TIMEOUT_MS$2 = 45e3;
|
|
5540
5691
|
const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
|
|
5541
5692
|
const DISCOVERY_RETRY_MS = 1e3;
|
|
5542
5693
|
const MAX_DISCOVERY_BYTES = 16384;
|
|
5543
5694
|
const VHOST_PROBE_TIMEOUT_MS = 1500;
|
|
5544
|
-
function publicStatus$
|
|
5695
|
+
function publicStatus$3(status) {
|
|
5545
5696
|
return Object.freeze({
|
|
5546
5697
|
enabled: status.enabled,
|
|
5547
5698
|
state: status.state,
|
|
@@ -5677,7 +5828,7 @@ var FrpController = class {
|
|
|
5677
5828
|
child;
|
|
5678
5829
|
gatewayValue;
|
|
5679
5830
|
generation = 0;
|
|
5680
|
-
latest = publicStatus$
|
|
5831
|
+
latest = publicStatus$3({
|
|
5681
5832
|
enabled: false,
|
|
5682
5833
|
state: "off"
|
|
5683
5834
|
});
|
|
@@ -5705,7 +5856,7 @@ var FrpController = class {
|
|
|
5705
5856
|
}
|
|
5706
5857
|
/** Return state safe for the desktop control UI. */
|
|
5707
5858
|
status() {
|
|
5708
|
-
return publicStatus$
|
|
5859
|
+
return publicStatus$3(this.latest);
|
|
5709
5860
|
}
|
|
5710
5861
|
/** Enable or disable FRP without changing LAN or another provider. */
|
|
5711
5862
|
async setEnabled(enabled) {
|
|
@@ -5771,7 +5922,7 @@ var FrpController = class {
|
|
|
5771
5922
|
return task;
|
|
5772
5923
|
}
|
|
5773
5924
|
publish(status) {
|
|
5774
|
-
this.latest = publicStatus$
|
|
5925
|
+
this.latest = publicStatus$3(status);
|
|
5775
5926
|
try {
|
|
5776
5927
|
this.options.onStatus?.(this.status());
|
|
5777
5928
|
} catch {}
|
|
@@ -5886,7 +6037,7 @@ var FrpController = class {
|
|
|
5886
6037
|
this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
|
|
5887
6038
|
}
|
|
5888
6039
|
async waitForDiscovery(generation, origin, signal) {
|
|
5889
|
-
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$
|
|
6040
|
+
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
|
|
5890
6041
|
const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
|
|
5891
6042
|
while (!signal.aborted && Date.now() < deadline) {
|
|
5892
6043
|
try {
|
|
@@ -5953,167 +6104,1475 @@ var FrpController = class {
|
|
|
5953
6104
|
}
|
|
5954
6105
|
};
|
|
5955
6106
|
//#endregion
|
|
5956
|
-
//#region src/
|
|
5957
|
-
const
|
|
5958
|
-
|
|
5959
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
5960
|
-
}
|
|
5961
|
-
function turnData(value) {
|
|
5962
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return {
|
|
5963
|
-
turn: 0,
|
|
5964
|
-
completed: false
|
|
5965
|
-
};
|
|
5966
|
-
const record = value;
|
|
5967
|
-
const reason = record.reason;
|
|
5968
|
-
const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
|
|
5969
|
-
return {
|
|
5970
|
-
turn: turnNumber(record.turn),
|
|
5971
|
-
completed
|
|
5972
|
-
};
|
|
5973
|
-
}
|
|
6107
|
+
//#region src/origin-proxy-config.ts
|
|
6108
|
+
const DEFAULT_ORIGIN_LISTEN_PORT = 3444;
|
|
6109
|
+
const MAX_SETTINGS_BYTES = 8192;
|
|
5974
6110
|
/**
|
|
5975
|
-
*
|
|
5976
|
-
*
|
|
5977
|
-
*
|
|
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.
|
|
5978
6114
|
*/
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
};
|
|
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;
|
|
6011
6146
|
}
|
|
6012
|
-
/**
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
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
|
+
}
|
|
6031
6171
|
}
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
|
|
6048
|
-
cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
|
|
6049
|
-
cpolar_config_invalid: "重新保存 cpolar Authtoken。",
|
|
6050
|
-
cpolar_start_timeout: "检查网络后点击“重新连接”。",
|
|
6051
|
-
cpolar_stopped: "点击“重新连接”。",
|
|
6052
|
-
cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
|
|
6053
|
-
frp_component_missing: "先安装 FRP 官方组件。",
|
|
6054
|
-
frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
|
|
6055
|
-
frp_config_missing: "先保存自建 FRP 连接配置。",
|
|
6056
|
-
frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
|
|
6057
|
-
frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
|
|
6058
|
-
frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
|
|
6059
|
-
frp_launch_failed: "重新安装 FRP 官方组件后重试。",
|
|
6060
|
-
frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
|
|
6061
|
-
frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
|
|
6062
|
-
frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
|
|
6063
|
-
frp_stopped: "点击“重新连接”。",
|
|
6064
|
-
frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
|
|
6065
|
-
gateway_start_failed: "确认 DSH 正在运行后重新连接。"
|
|
6066
|
-
});
|
|
6067
|
-
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);
|
|
6068
6187
|
return Object.freeze({
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
detail,
|
|
6075
|
-
...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)
|
|
6076
6193
|
});
|
|
6077
6194
|
}
|
|
6078
|
-
function
|
|
6079
|
-
|
|
6195
|
+
async function atomicPrivateWrite$1(file, body) {
|
|
6196
|
+
const directory = dirname(file);
|
|
6197
|
+
await mkdir(directory, {
|
|
6198
|
+
recursive: true,
|
|
6199
|
+
mode: 448
|
|
6200
|
+
});
|
|
6080
6201
|
try {
|
|
6081
|
-
const
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
} catch {
|
|
6086
|
-
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;
|
|
6087
6206
|
}
|
|
6088
|
-
|
|
6089
|
-
function remoteSuffix(origin) {
|
|
6090
|
-
if (origin === void 0) return "未分配";
|
|
6207
|
+
const temporary = join(directory, "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
6091
6208
|
try {
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
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;
|
|
6103
6219
|
}
|
|
6104
6220
|
}
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
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;
|
|
6236
|
+
try {
|
|
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)",
|
|
6117
7576
|
` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
|
|
6118
7577
|
" if ($matching.Count -eq 0) { $ready = $false }",
|
|
6119
7578
|
"}",
|
|
@@ -6183,7 +7642,7 @@ function reportLine(entry) {
|
|
|
6183
7642
|
/** Run bounded read-only checks and return a report safe to paste into an issue. */
|
|
6184
7643
|
async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
6185
7644
|
const checks = [];
|
|
6186
|
-
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" });
|
|
6187
7646
|
const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
|
|
6188
7647
|
checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
|
|
6189
7648
|
if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
|
|
@@ -6200,6 +7659,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
6200
7659
|
else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
|
|
6201
7660
|
else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
|
|
6202
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" }));
|
|
6203
7663
|
else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
|
|
6204
7664
|
const endpointSuffix = remoteSuffix(snapshot.remote.origin);
|
|
6205
7665
|
const facts = {
|
|
@@ -6283,11 +7743,11 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
6283
7743
|
2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
|
|
6284
7744
|
- 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
|
|
6285
7745
|
- extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
|
|
6286
|
-
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn)
|
|
7746
|
+
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
|
|
6287
7747
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
6288
7748
|
- mobile.css:手机端样式(可选)
|
|
6289
7749
|
- assets/:手机端静态资源(可选)
|
|
6290
|
-
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action
|
|
7750
|
+
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
|
|
6291
7751
|
- 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
|
|
6292
7752
|
|
|
6293
7753
|
安全约束:
|
|
@@ -8109,16 +9569,12 @@ async function run(file, args) {
|
|
|
8109
9569
|
});
|
|
8110
9570
|
}
|
|
8111
9571
|
async function defaultFetchArtifact(url, signal) {
|
|
8112
|
-
|
|
8113
|
-
|
|
9572
|
+
return downloadPinnedArtifact({
|
|
9573
|
+
url,
|
|
9574
|
+
expectedBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
|
|
9575
|
+
errorPrefix: "cpolar",
|
|
8114
9576
|
signal
|
|
8115
9577
|
});
|
|
8116
|
-
if (!response.ok) throw new Error(`cpolar_download_http_${String(response.status)}`);
|
|
8117
|
-
const length = Number(response.headers.get("content-length"));
|
|
8118
|
-
if (Number.isFinite(length) && length !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
|
|
8119
|
-
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
8120
|
-
if (bytes.byteLength !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
|
|
8121
|
-
return bytes;
|
|
8122
9578
|
}
|
|
8123
9579
|
async function defaultExtractArtifact(archive, destination) {
|
|
8124
9580
|
if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
|
|
@@ -8803,6 +10259,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
|
|
|
8803
10259
|
"wsl",
|
|
8804
10260
|
"zerotier"
|
|
8805
10261
|
];
|
|
10262
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
10263
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
8806
10264
|
function requiredString(value, name) {
|
|
8807
10265
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
8808
10266
|
return value;
|
|
@@ -8872,7 +10330,7 @@ function likelyVirtualInterface(name) {
|
|
|
8872
10330
|
async function runRouteCommand(file, args) {
|
|
8873
10331
|
return (await execFileText(file, [...args], {
|
|
8874
10332
|
encoding: "utf8",
|
|
8875
|
-
|
|
10333
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
8876
10334
|
})).stdout;
|
|
8877
10335
|
}
|
|
8878
10336
|
function uniqueLines(output) {
|
|
@@ -9224,6 +10682,7 @@ function mapAdminError(error) {
|
|
|
9224
10682
|
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
9225
10683
|
if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
|
|
9226
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);
|
|
9227
10686
|
if (error instanceof Error && [
|
|
9228
10687
|
"frp_server_address_invalid",
|
|
9229
10688
|
"frp_server_port_invalid",
|
|
@@ -9232,6 +10691,15 @@ function mapAdminError(error) {
|
|
|
9232
10691
|
"frp_settings_invalid"
|
|
9233
10692
|
].includes(error.message)) return new HttpError(400, error.message);
|
|
9234
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);
|
|
9235
10703
|
if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
|
|
9236
10704
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
9237
10705
|
if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
|
|
@@ -9340,12 +10808,23 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
9340
10808
|
discovery: false
|
|
9341
10809
|
});
|
|
9342
10810
|
}
|
|
9343
|
-
|
|
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) {
|
|
9344
10822
|
return {
|
|
9345
10823
|
provider,
|
|
9346
10824
|
running: status.enabled,
|
|
9347
10825
|
state: status.state,
|
|
9348
10826
|
...status.origin === void 0 ? {} : { origin: status.origin },
|
|
10827
|
+
...status.backendOrigin === void 0 ? {} : { backendOrigin: status.backendOrigin },
|
|
9349
10828
|
...status.loginUrl === void 0 ? {} : { loginUrl: status.loginUrl },
|
|
9350
10829
|
...status.setupUrl === void 0 ? {} : { setupUrl: status.setupUrl },
|
|
9351
10830
|
...status.errorCode === void 0 ? {} : { errorCode: status.errorCode },
|
|
@@ -9362,12 +10841,25 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
|
|
|
9362
10841
|
state: providerStatuses.cpolar.state,
|
|
9363
10842
|
component: cpolarComponent
|
|
9364
10843
|
},
|
|
10844
|
+
cloudflared: {
|
|
10845
|
+
bundled: false,
|
|
10846
|
+
running: providerStatuses.cloudflared.enabled,
|
|
10847
|
+
state: providerStatuses.cloudflared.state,
|
|
10848
|
+
component: cloudflaredComponent,
|
|
10849
|
+
configuration: cloudflaredConfiguration
|
|
10850
|
+
},
|
|
9365
10851
|
frp: {
|
|
9366
10852
|
bundled: false,
|
|
9367
10853
|
running: providerStatuses.frp.enabled,
|
|
9368
10854
|
state: providerStatuses.frp.state,
|
|
9369
10855
|
component: frpComponent,
|
|
9370
10856
|
configuration: frpConfiguration
|
|
10857
|
+
},
|
|
10858
|
+
origin: {
|
|
10859
|
+
bundled: true,
|
|
10860
|
+
running: providerStatuses.origin.enabled,
|
|
10861
|
+
state: providerStatuses.origin.state,
|
|
10862
|
+
configuration: originConfiguration
|
|
9371
10863
|
}
|
|
9372
10864
|
}
|
|
9373
10865
|
};
|
|
@@ -9398,10 +10890,16 @@ async function apply(ctx, config) {
|
|
|
9398
10890
|
const initialRemoteProvider = (await remoteProviderStore.load()).provider;
|
|
9399
10891
|
const cpolarComponent = new CpolarComponentManager({ stateDirectory });
|
|
9400
10892
|
await cpolarComponent.initialize();
|
|
10893
|
+
const cloudflaredComponent = new CloudflaredComponentManager({ stateDirectory });
|
|
10894
|
+
await cloudflaredComponent.initialize();
|
|
9401
10895
|
const frpComponent = new FrpComponentManager({ stateDirectory });
|
|
9402
10896
|
await frpComponent.initialize();
|
|
9403
10897
|
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"));
|
|
9404
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();
|
|
9405
10903
|
const unregisterBuiltin = mobileAccess.registerExtension({
|
|
9406
10904
|
schemaVersion: 1,
|
|
9407
10905
|
id: "computer-images",
|
|
@@ -9495,9 +10993,22 @@ async function apply(ctx, config) {
|
|
|
9495
10993
|
await candidate.start();
|
|
9496
10994
|
return candidate;
|
|
9497
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
|
+
};
|
|
9498
11007
|
const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
|
|
9499
11008
|
const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
|
|
11009
|
+
const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
|
|
9500
11010
|
const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
|
|
11011
|
+
const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
|
|
9501
11012
|
const remoteControllers = {
|
|
9502
11013
|
tailscale: new FunnelController({
|
|
9503
11014
|
store: tailscaleStore,
|
|
@@ -9512,21 +11023,28 @@ async function apply(ctx, config) {
|
|
|
9512
11023
|
configFile: cpolarComponent.configFile,
|
|
9513
11024
|
createGateway: createRemoteGateway
|
|
9514
11025
|
}),
|
|
11026
|
+
cloudflared: new CloudflaredController({
|
|
11027
|
+
store: cloudflaredStore,
|
|
11028
|
+
executable: cloudflaredComponent.executable,
|
|
11029
|
+
tunnel: cloudflaredTunnel,
|
|
11030
|
+
createGateway: createRemoteGateway
|
|
11031
|
+
}),
|
|
9515
11032
|
frp: new FrpController({
|
|
9516
11033
|
store: frpStore,
|
|
9517
11034
|
executable: frpComponent.executable,
|
|
9518
11035
|
config: frpConfig,
|
|
9519
11036
|
instanceId,
|
|
9520
11037
|
createGateway: createRemoteGateway
|
|
11038
|
+
}),
|
|
11039
|
+
origin: new OriginController({
|
|
11040
|
+
store: originStore,
|
|
11041
|
+
config: originConfig,
|
|
11042
|
+
createGateway: createOriginGateway
|
|
9521
11043
|
})
|
|
9522
11044
|
};
|
|
9523
11045
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
9524
11046
|
const remoteController = () => remoteProviders.controller();
|
|
9525
|
-
for (const provider of
|
|
9526
|
-
"tailscale",
|
|
9527
|
-
"cpolar",
|
|
9528
|
-
"frp"
|
|
9529
|
-
]) {
|
|
11047
|
+
for (const provider of REMOTE_PROVIDERS) {
|
|
9530
11048
|
const controller = remoteControllers[provider];
|
|
9531
11049
|
taskEventHub.add({ broadcastTaskEvent: (event) => {
|
|
9532
11050
|
controller.gateway()?.broadcastTaskEvent(event);
|
|
@@ -9535,8 +11053,10 @@ async function apply(ctx, config) {
|
|
|
9535
11053
|
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
9536
11054
|
tailscale: remoteControllers.tailscale.status(),
|
|
9537
11055
|
cpolar: remoteControllers.cpolar.status(),
|
|
9538
|
-
|
|
9539
|
-
|
|
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());
|
|
9540
11060
|
const lanPayload = () => ({
|
|
9541
11061
|
...loaded.kind === "unconfigured" ? {
|
|
9542
11062
|
configured: preparedLanSetup !== void 0,
|
|
@@ -9671,11 +11191,29 @@ async function apply(ctx, config) {
|
|
|
9671
11191
|
}
|
|
9672
11192
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
|
|
9673
11193
|
const body = await readJsonObject(request, 4096);
|
|
9674
|
-
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");
|
|
9675
11195
|
await remoteProviders.select(body.provider);
|
|
9676
11196
|
sendJson(response, 200, remotePayload(), false);
|
|
9677
11197
|
return;
|
|
9678
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
|
+
}
|
|
9679
11217
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
|
|
9680
11218
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
9681
11219
|
await remoteProviders.mutate(async () => cpolarComponent.install());
|
|
@@ -9697,6 +11235,46 @@ async function apply(ctx, config) {
|
|
|
9697
11235
|
sendJson(response, 200, remotePayload(), false);
|
|
9698
11236
|
return;
|
|
9699
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
|
+
}
|
|
9700
11278
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
|
|
9701
11279
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
9702
11280
|
logger.info("frpc component install started");
|
|
@@ -9915,17 +11493,18 @@ async function apply(ctx, config) {
|
|
|
9915
11493
|
const stores = {
|
|
9916
11494
|
tailscale: tailscaleStore,
|
|
9917
11495
|
cpolar: cpolarStore,
|
|
9918
|
-
|
|
11496
|
+
cloudflared: cloudflaredStore,
|
|
11497
|
+
frp: frpStore,
|
|
11498
|
+
origin: originStore
|
|
9919
11499
|
};
|
|
9920
11500
|
await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].save({
|
|
9921
11501
|
version: 1,
|
|
9922
11502
|
enabled: false
|
|
9923
11503
|
})));
|
|
9924
|
-
for (const provider of [
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
]) 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);
|
|
9929
11508
|
} catch (error) {
|
|
9930
11509
|
try {
|
|
9931
11510
|
await settleCleanupSteps([
|
|
@@ -9962,6 +11541,6 @@ async function apply(ctx, config) {
|
|
|
9962
11541
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
9963
11542
|
}
|
|
9964
11543
|
//#endregion
|
|
9965
|
-
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 };
|
|
9966
11545
|
|
|
9967
11546
|
//# sourceMappingURL=index.mjs.map
|