dsh-mobile 0.4.1 → 0.4.2

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