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/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
- if (snapshot.devices.length > this.options.maxDevices) throw new Error("device state exceeds configured maxDevices");
120
- this.devices = [...snapshot.devices];
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
- /** Persist revocation, then end every Session owned by that device. */
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 index = this.devices.findIndex((device) => device.id === deviceId);
320
- const device = this.devices[index];
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
- /** Set the gateway-owned browser protections and non-cacheability. */
1126
- function setSecurityHeaders(response, tls) {
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 || host.hostname !== "localhost" && !isLoopbackAddress(host.hostname)) throw new HttpError(403, "forbidden");
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 && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
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$2(path, maximum, field) {
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$2(targetReal, maximum, field);
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$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
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 = input;
1881
+ let parsed;
1776
1882
  try {
1777
- parsed = action.input?.parse(input) ?? 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$2(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
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
- await new Promise((resolve, reject) => {
3033
- const failed = (error) => {
3034
- reject(error);
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", failed);
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", failed);
3162
+ socket.off("error", onBindError);
3040
3163
  socket.setBroadcast(true);
3041
- resolve();
3164
+ resolve(true);
3042
3165
  });
3043
- });
3044
- const announce = () => {
3045
- for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3046
- };
3047
- announce();
3048
- this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3049
- this.discoveryTimer.unref();
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$1(parent, child) {
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$1(file) {
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$1(bytes) {
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$1(extracted)) throw new Error("frp_archive_executable_invalid");
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$1(artifact, signal) {
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$1(stateDirectory, child)) throw new Error("frp component path escaped its state directory");
4801
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
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$1(this.executable);
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$1(bytes) !== artifact.downloadSha256) throw new Error("frp_download_hash_mismatch");
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$1(extracted)) throw new Error("frp_executable_missing");
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$1(value) {
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$1(normalized)) throw new Error("frp_server_address_invalid");
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$1(publicHost)) throw new Error("frp_public_origin_invalid");
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
- "frp"
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 frp");
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$1 = 45e3;
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$2(status) {
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$2({
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$2(this.latest);
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$2(status);
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$1);
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/task-events.ts
5957
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
5958
- function turnNumber(value) {
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
- * Subscribe to completed root turns. Subagent turns are skipped so one task
5976
- * announces once, and rapid turn boundaries collapse into a single event.
5977
- * Returns a disposer that also drops pending debounces.
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
- function watchTaskCompletions(ctx, options) {
5980
- const debounceMs = options.debounceMs ?? 1e3;
5981
- const pending = /* @__PURE__ */ new Map();
5982
- const disposeListener = ctx.on("session/event", (session, event) => {
5983
- if (event.type !== "turn/end") return;
5984
- const { turn, completed } = turnData(event.data);
5985
- if (!completed) return;
5986
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
5987
- const sessionId = String(session.id);
5988
- options.log?.("task-completed-observed", {
5989
- sessionId,
5990
- turn
5991
- });
5992
- const previous = pending.get(sessionId);
5993
- if (previous !== void 0) clearTimeout(previous);
5994
- pending.set(sessionId, setTimeout(() => {
5995
- pending.delete(sessionId);
5996
- options.log?.("task-completed-announced", {
5997
- sessionId,
5998
- turn
5999
- });
6000
- options.onTaskCompleted(Object.freeze({
6001
- sessionId,
6002
- turn
6003
- }));
6004
- }, debounceMs));
6005
- });
6006
- return () => {
6007
- disposeListener();
6008
- for (const timer of pending.values()) clearTimeout(timer);
6009
- pending.clear();
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
- /** One subscription feeding every live gateway; gateways register on start. */
6013
- var TaskEventHub = class {
6014
- sinks = /* @__PURE__ */ new Set();
6015
- /** Register a sink; returns its disposer. */
6016
- add(sink) {
6017
- this.sinks.add(sink);
6018
- return () => {
6019
- this.sinks.delete(sink);
6020
- };
6021
- }
6022
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
6023
- broadcast(event) {
6024
- for (const sink of [...this.sinks]) try {
6025
- sink.broadcastTaskEvent(event);
6026
- } catch {}
6027
- }
6028
- /** Visible for tests. */
6029
- get size() {
6030
- return this.sinks.size;
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
- //#endregion
6034
- //#region src/diagnostics.ts
6035
- const REMOTE_ERROR_GUIDANCE = Object.freeze({
6036
- component_missing: "重新安装完整插件包。",
6037
- funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
6038
- funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
6039
- funnel_start_failed: "重新打开授权页并允许 Funnel。",
6040
- funnel_start_timeout: "检查网络后点击“重新连接”。",
6041
- tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
6042
- sidecar_launch_failed: "重新安装完整插件包后重试。",
6043
- sidecar_stopped: "点击“重新连接”。",
6044
- sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
6045
- control_channel_failed: "点击“重新连接”。",
6046
- cpolar_component_missing: "先安装 cpolar 官方组件。",
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
- id,
6070
- status,
6071
- reason,
6072
- ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
6073
- label,
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 maskLanOrigin(origin) {
6079
- if (origin === void 0) return "未分配";
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 url = new URL(origin);
6082
- const octets = url.hostname.split(".");
6083
- const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
6084
- return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
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
- const hostname = new URL(origin).hostname;
6093
- if (hostname.endsWith(".ts.net")) return "*.ts.net";
6094
- for (const suffix of [
6095
- ".cpolar.cn",
6096
- ".cpolar.io",
6097
- ".cpolar.top",
6098
- ".cpolar.com"
6099
- ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
6100
- return "公共 HTTPS 地址";
6101
- } catch {
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
- function defaultFirewallProbe(platform = process.platform) {
6106
- return async (port) => {
6107
- if (platform !== "win32") return { state: "not-applicable" };
6108
- if (port === void 0) return { state: "unknown" };
6109
- const script = [
6110
- "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
6111
- "$ready = $true",
6112
- "$specs | ForEach-Object {",
6113
- " $spec = $_",
6114
- " $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",
6115
- " if ($null -eq $rule) { $ready = $false; return }",
6116
- " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
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,api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
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
- const response = await fetch(url, {
8113
- redirect: "error",
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
- windowsHide: true
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
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, frpComponent, frpConfiguration) {
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
- frp: remoteControllers.frp.status()
9539
- }, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
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
- frp: frpStore
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
- "tailscale",
9926
- "cpolar",
9927
- "frp"
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