dsh-mobile 0.6.17 → 0.7.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +47 -11
  2. package/CONTRIBUTORS.md +12 -6
  3. package/README.en.md +22 -14
  4. package/README.md +21 -14
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY_NOTICES.md +33 -0
  7. package/apps/mobile/README.md +28 -3
  8. package/apps/mobile/README.zh-CN.md +28 -3
  9. package/apps/mobile/release.json +3 -3
  10. package/cordis.patch.yml +5 -9
  11. package/docs/ATTACH_EXISTING_FRPS.en.md +32 -2
  12. package/docs/ATTACH_EXISTING_FRPS.md +2 -2
  13. package/docs/CLIENT_MODULES.en.md +2 -0
  14. package/docs/CLIENT_MODULES.md +2 -0
  15. package/docs/CLOUDFLARE_TUNNEL.en.md +2 -0
  16. package/docs/CLOUDFLARE_TUNNEL.md +2 -0
  17. package/docs/IROH.en.md +44 -0
  18. package/docs/IROH.md +44 -0
  19. package/docs/MOBILE_PLUGIN_ADAPTATION.en.md +196 -0
  20. package/docs/MOBILE_PLUGIN_ADAPTATION.md +196 -0
  21. package/docs/README.md +9 -0
  22. package/docs/SELF_HOSTED_ORIGIN.en.md +26 -1
  23. package/docs/SELF_HOSTED_ORIGIN.md +27 -2
  24. package/docs/SKIN_COMPATIBILITY.en.md +2 -0
  25. package/docs/SKIN_COMPATIBILITY.md +2 -0
  26. package/docs/SLOW_CONNECTIONS.en.md +10 -2
  27. package/docs/SLOW_CONNECTIONS.md +10 -2
  28. package/docs/TRUSTED_NETWORKS.en.md +17 -0
  29. package/docs/TRUSTED_NETWORKS.md +17 -0
  30. package/lib/cli.js.map +1 -1
  31. package/lib/client.js +1081 -70
  32. package/lib/client.js.map +1 -1
  33. package/lib/index.d.mts +271 -2
  34. package/lib/index.mjs +1908 -245
  35. package/lib/index.mjs.map +1 -1
  36. package/lib/mobile-layout.js +14 -6
  37. package/lib/mobile-layout.js.map +1 -1
  38. package/native/iroh-host/LICENSE.dsh-mobile-iroh +21 -0
  39. package/native/iroh-host/LICENSE.dsh-tether +21 -0
  40. package/native/iroh-host/NOTICE.dsh-mobile-iroh +13 -0
  41. package/native/iroh-host/README.md +66 -0
  42. package/package.json +18 -5
  43. package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomB
3
3
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { connect, createServer } from "node:net";
6
+ import { connect, createServer, isIP } from "node:net";
7
7
  import { chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
@@ -77,14 +77,14 @@ var BoundedRateLimiter = class {
77
77
  function opaqueToken() {
78
78
  return randomBytes(32).toString("base64url");
79
79
  }
80
- function digest$1(value) {
80
+ function digest$2(value) {
81
81
  return createHash("sha256").update(value, "utf8").digest();
82
82
  }
83
83
  function digestHex(value) {
84
- return digest$1(value).toString("hex");
84
+ return digest$2(value).toString("hex");
85
85
  }
86
86
  function matchesDigest(value, expected) {
87
- return timingSafeEqual(digest$1(value), expected);
87
+ return timingSafeEqual(digest$2(value), expected);
88
88
  }
89
89
  function normalizeLabel(value) {
90
90
  const label = (value ?? "Mobile device").normalize("NFC").trim();
@@ -187,7 +187,7 @@ var AccessController = class {
187
187
  const record = Object.freeze({
188
188
  key,
189
189
  deviceId: device.id,
190
- csrfDigest: digest$1(csrfToken),
190
+ csrfDigest: digest$2(csrfToken),
191
191
  createdAt: now,
192
192
  expiresAt: Math.min(now + this.options.sessionTtlMs, device.expiresAt)
193
193
  });
@@ -209,7 +209,7 @@ var AccessController = class {
209
209
  const token = opaqueToken();
210
210
  const expiresAt = this.now() + ttl;
211
211
  this.pairingWindow = Object.freeze({
212
- digest: digest$1(token),
212
+ digest: digest$2(token),
213
213
  expiresAt
214
214
  });
215
215
  return Object.freeze({
@@ -317,7 +317,7 @@ var AccessController = class {
317
317
  }
318
318
  requireDevice(deviceToken, now) {
319
319
  if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
320
- const tokenDigest = digest$1(deviceToken);
320
+ const tokenDigest = digest$2(deviceToken);
321
321
  const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
322
322
  if (device === void 0) throw new AccessError(401, "authentication_failed");
323
323
  if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
@@ -480,7 +480,7 @@ function countIpv6Groups(labels, allowDottedQuad) {
480
480
  * @param value - candidate address; non-strings are never an IP.
481
481
  * @returns `4` for IPv4, `6` for IPv6, `0` when the value is neither.
482
482
  */
483
- function isIP(value) {
483
+ function isIP$1(value) {
484
484
  if (typeof value !== "string") return 0;
485
485
  if (isStrictIpv4(value)) return 4;
486
486
  let text = value;
@@ -551,7 +551,7 @@ function parseIp(address) {
551
551
  bits: 32,
552
552
  value: parseIpv4$1(mapped)
553
553
  };
554
- const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
554
+ const version = isIP$1(unwrapped.split("%", 1)[0] ?? unwrapped);
555
555
  if (version === 4) return {
556
556
  bits: 32,
557
557
  value: parseIpv4$1(unwrapped)
@@ -958,6 +958,7 @@ const Config = z.object({
958
958
  upstreamOrigin: z.string(),
959
959
  publicAuthorities: z.array(String).default(void 0),
960
960
  allowedCidrs: z.array(String).default(void 0),
961
+ adminAllowedCidrs: z.array(String).default([]),
961
962
  stateFile: String,
962
963
  controlFile: z.string().hidden().required(),
963
964
  customCssFile: z.string().hidden(),
@@ -1089,7 +1090,7 @@ function parseGatewayConfig(raw) {
1089
1090
  if (publicOrigin !== void 0 && value.listenPort !== void 0) throw new Error("publicOrigin cannot be combined with listenPort");
1090
1091
  if (publicOrigin !== void 0 && value.publicAuthorities !== void 0) throw new Error("publicOrigin cannot be combined with publicAuthorities");
1091
1092
  const listenHost = value.listenHost ?? (publicOrigin === void 0 ? "127.0.0.1" : "0.0.0.0");
1092
- if (isIP(listenHost) === 0) throw new Error("listenHost must be an IP literal");
1093
+ if (isIP$1(listenHost) === 0) throw new Error("listenHost must be an IP literal");
1093
1094
  const listenPort = publicOrigin?.port ?? integer(value.listenPort, "listenPort", 3443, 0, 65535);
1094
1095
  const upstreamOrigin = parseUpstream(value.upstreamOrigin);
1095
1096
  const tls = parseTls(value.tls, listenHost);
@@ -1111,6 +1112,11 @@ function parseGatewayConfig(raw) {
1111
1112
  if (new Set(authorities.map((entry) => `${entry.hostname}:${String(entry.port ?? listenPort)}`)).size !== authorities.length) throw new Error("publicAuthorities must not contain duplicates");
1112
1113
  const allowedCidrs = stringArray(value.allowedCidrs ?? (isLoopbackAddress(listenHost) ? ["127.0.0.0/8", "::1/128"] : void 0), "allowedCidrs").map(parseCidr);
1113
1114
  if (new Set(allowedCidrs.map((entry) => `${String(entry.bits)}:${entry.network.toString(16)}:${String(entry.prefix)}`)).size !== allowedCidrs.length) throw new Error("allowedCidrs must not contain duplicates");
1115
+ const adminSources = value.adminAllowedCidrs === void 0 ? [] : value.adminAllowedCidrs;
1116
+ if (!Array.isArray(adminSources) || adminSources.length > 32 || adminSources.some((source) => typeof source !== "string")) throw new Error("adminAllowedCidrs must contain at most 32 CIDR strings");
1117
+ const adminAllowedCidrs = adminSources.map(parseCidr);
1118
+ if (adminAllowedCidrs.some((entry) => entry.prefix === 0)) throw new Error("adminAllowedCidrs must not allow every address");
1119
+ if (new Set(adminAllowedCidrs.map((entry) => `${String(entry.bits)}:${entry.network.toString(16)}:${String(entry.prefix)}`)).size !== adminAllowedCidrs.length) throw new Error("adminAllowedCidrs must not contain duplicates");
1114
1120
  const deviceTtlMs = integer(value.deviceTtlMs, "deviceTtlMs", 7776e6, 6e4, 316224e5);
1115
1121
  const sessionTtlMs = integer(value.sessionTtlMs, "sessionTtlMs", 288e5, 3e4, 864e5);
1116
1122
  if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
@@ -1121,6 +1127,7 @@ function parseGatewayConfig(raw) {
1121
1127
  upstreamOrigin,
1122
1128
  authorities: Object.freeze(authorities),
1123
1129
  allowedCidrs: Object.freeze(allowedCidrs),
1130
+ adminAllowedCidrs: Object.freeze(adminAllowedCidrs),
1124
1131
  stateFile: absoluteFile(value.stateFile, "stateFile"),
1125
1132
  extensionsDir: join(dirname(absoluteFile(value.stateFile, "stateFile")), "extensions"),
1126
1133
  customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
@@ -1278,6 +1285,45 @@ async function restrictPrivateFile(file, mode = 384) {
1278
1285
  timeout: 1e4
1279
1286
  }));
1280
1287
  }
1288
+ /**
1289
+ * Restrict a plugin-owned private directory (and what already sits inside it)
1290
+ * so a strict native consumer can verify every ancestor and the leaf itself.
1291
+ * Inheritance is disabled and re-granted to the current user, SYSTEM and
1292
+ * administrators only; future children inherit the same compliant entries.
1293
+ * Reparse points are never followed: a linked child is left alone and fails
1294
+ * closed downstream instead of being repaired here.
1295
+ */
1296
+ async function restrictPrivateDirectory(directory) {
1297
+ await chmod(directory, 448);
1298
+ const entry = await lstat(directory);
1299
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw Object.assign(/* @__PURE__ */ new Error("private directory is not a real directory"), { code: "PRIVATE_DIRECTORY_INVALID" });
1300
+ if (process.platform !== "win32") return;
1301
+ const userSid = await currentWindowsUserSid();
1302
+ const apply = (target) => permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
1303
+ target,
1304
+ "/inheritance:r",
1305
+ "/grant:r",
1306
+ `*${userSid}:(OI)(CI)(F)`,
1307
+ "*S-1-5-18:(OI)(CI)(F)",
1308
+ "*S-1-5-32-544:(OI)(CI)(F)",
1309
+ "/remove:g",
1310
+ "*S-1-1-0",
1311
+ "*S-1-5-11",
1312
+ "*S-1-5-32-545"
1313
+ ], {
1314
+ encoding: "utf8",
1315
+ windowsHide: true,
1316
+ timeout: 1e4
1317
+ }));
1318
+ await apply(directory);
1319
+ for (const name of await readdir(directory)) {
1320
+ const child = join(directory, name);
1321
+ const info = await lstat(child);
1322
+ if (info.isSymbolicLink()) continue;
1323
+ if (info.isDirectory()) await restrictPrivateDirectory(child);
1324
+ else if (info.isFile()) await restrictPrivateFile(child);
1325
+ }
1326
+ }
1281
1327
  //#endregion
1282
1328
  //#region src/client-module-preferences.ts
1283
1329
  const MAX_RECORD_BYTES = 1048576;
@@ -1896,9 +1942,11 @@ function localAuthority(header) {
1896
1942
  return;
1897
1943
  }
1898
1944
  }
1899
- /** Protect the inner management route from non-loopback and DNS-rebinding callers. */
1900
- function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false) {
1901
- if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1945
+ /** Protect management with direct-peer, Host, browser-source and optional authenticated-network admission. */
1946
+ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false, adminAllowedCidrs = []) {
1947
+ const peer = request.socket.remoteAddress;
1948
+ const loopback = peer !== void 0 && isLoopbackAddress(peer);
1949
+ if (!loopback && !addressAllowed(peer, adminAllowedCidrs)) throw new HttpError(403, "forbidden");
1902
1950
  const host = localAuthority(request.headers.host);
1903
1951
  if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1904
1952
  if (request.method === "OPTIONS") throw new HttpError(403, "forbidden");
@@ -1917,6 +1965,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1917
1965
  const webRequest = request.method === "POST" && (site === void 0 || site === "same-origin") && request.headers["x-dsh-mobile-web-admin"] === "same-origin-json" && request.headers["x-dsh-mobile-desktop-admin"] === void 0 && typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
1918
1966
  if (requireBrowserOrigin && origin === void 0 && !webRequest && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1919
1967
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1968
+ if (!loopback && !authenticateDesktop()) throw new HttpError(401, "admin_authentication_required");
1920
1969
  }
1921
1970
  //#endregion
1922
1971
  //#region src/browser-auth-cookies.ts
@@ -3111,6 +3160,52 @@ async function listWorkspaceDirectories(path, signal) {
3111
3160
  truncated
3112
3161
  });
3113
3162
  }
3163
+ /**
3164
+ * Resolve exactly one child name without drive-relative or reserved Win32 spellings.
3165
+ * @param path - Fully qualified parent without a home/current-drive fallback.
3166
+ * @param name - Single non-blank path segment.
3167
+ * @param platform - Host path rules, explicit for portable parser tests.
3168
+ * @returns The child path whose immediate parent is the normalized requested directory.
3169
+ */
3170
+ function workspaceDirectoryChildPath(path, name, platform = process.platform) {
3171
+ if (typeof path !== "string" || path.length === 0) throw new HttpError(400, "directory_create_invalid");
3172
+ if (typeof name !== "string" || name.length === 0 || name.length > 255 || name.trim() === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0") || isAbsolute(name) || platform === "win32" && (/[<>:"|?*\u0000-\u001f]/u.test(name) || /[. ]$/u.test(name) || /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/iu.test(name))) throw new HttpError(400, "directory_create_invalid");
3173
+ const paths = platform === "win32" ? win32 : posix;
3174
+ const parent = resolveWorkspaceDirectoryPath(path, platform);
3175
+ const child = paths.resolve(parent, name);
3176
+ if (paths.dirname(child) !== parent) throw new HttpError(400, "directory_create_invalid");
3177
+ return child;
3178
+ }
3179
+ /**
3180
+ * Create one child directory under an explicitly confirmed existing parent.
3181
+ * @param path - Fully qualified existing parent; no home/default fallback is used.
3182
+ * @param name - One non-blank segment accepted by the Host filesystem rules.
3183
+ * @param signal - Cancellation withdraws admission/results, never a completed mkdir.
3184
+ * @returns The created directory's absolute path after filesystem work settles.
3185
+ */
3186
+ async function createWorkspaceDirectory(path, name, signal) {
3187
+ signal?.throwIfAborted();
3188
+ const created = workspaceDirectoryChildPath(path, name);
3189
+ const parent = resolveWorkspaceDirectoryPath(path);
3190
+ let stats;
3191
+ try {
3192
+ stats = await stat(parent);
3193
+ } catch (error) {
3194
+ if (signal?.aborted) throw signal.reason;
3195
+ throw new HttpError(404, "directory_unavailable");
3196
+ }
3197
+ if (!stats.isDirectory()) throw new HttpError(400, "directory_create_invalid");
3198
+ signal?.throwIfAborted();
3199
+ try {
3200
+ await mkdir(created);
3201
+ } catch (error) {
3202
+ if (signal?.aborted) throw signal.reason;
3203
+ if (error.code === "EEXIST") throw new HttpError(409, "directory_exists");
3204
+ throw new HttpError(400, "directory_create_failed");
3205
+ }
3206
+ signal?.throwIfAborted();
3207
+ return Object.freeze({ path: created });
3208
+ }
3114
3209
  /** Read one bounded regular image file selected by an authenticated device. */
3115
3210
  async function readComputerImage(path, signal) {
3116
3211
  signal?.throwIfAborted();
@@ -5687,7 +5782,7 @@ async function tlsOptions(config) {
5687
5782
  const leaf = chain[0].certificate;
5688
5783
  for (const authority of config.authorities) {
5689
5784
  const hostname = stripIpv6Brackets(authority.hostname);
5690
- if ((isIP(hostname) === 0 ? leaf.checkHost(hostname) : leaf.checkIP(hostname)) === void 0) throw new Error(`TLS certificate does not cover configured authority ${hostname}`);
5785
+ if ((isIP$1(hostname) === 0 ? leaf.checkHost(hostname) : leaf.checkIP(hostname)) === void 0) throw new Error(`TLS certificate does not cover configured authority ${hostname}`);
5691
5786
  }
5692
5787
  return {
5693
5788
  cert: chain.map((entry) => entry.pem).join(""),
@@ -6281,7 +6376,7 @@ var MobileAccessGateway = class {
6281
6376
  if (this.closing || this.discoveryError !== void 0 || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
6282
6377
  sendAnnouncement(remote.port, remote.address);
6283
6378
  });
6284
- const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
6379
+ const bindHost = isIP$1(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
6285
6380
  if (await new Promise((resolve) => {
6286
6381
  const onBindError = (error) => {
6287
6382
  socket.off("error", onBindError);
@@ -6735,7 +6830,7 @@ var MobileAccessGateway = class {
6735
6830
  }
6736
6831
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
6737
6832
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
6738
- const workspaceDirectories = request.method === "GET" && target.decodedPathname === `/mobile-access/workspace-directories`;
6833
+ const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
6739
6834
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
6740
6835
  const requestedExtension = extensionTarget(target.decodedPathname);
6741
6836
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
@@ -6898,10 +6993,18 @@ var MobileAccessGateway = class {
6898
6993
  response.once("close", onClose);
6899
6994
  const task = (async () => {
6900
6995
  try {
6901
- const query = new URL(target.raw, this.address().origin).searchParams;
6902
- const listing = await listWorkspaceDirectories(query.get("path"), operation.signal);
6903
- operation.signal.throwIfAborted();
6904
- sendJson(response, 200, listing, this.tlsEnabled);
6996
+ if (request.method === "POST") {
6997
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
6998
+ if (typeof body.path !== "string" || body.path.length === 0 || typeof body.name !== "string") throw new HttpError(400, "directory_create_invalid");
6999
+ const created = await createWorkspaceDirectory(body.path, body.name, operation.signal);
7000
+ operation.signal.throwIfAborted();
7001
+ sendJson(response, 201, created, this.tlsEnabled);
7002
+ } else {
7003
+ const query = new URL(target.raw, this.address().origin).searchParams;
7004
+ const listing = await listWorkspaceDirectories(query.get("path"), operation.signal);
7005
+ operation.signal.throwIfAborted();
7006
+ sendJson(response, 200, listing, this.tlsEnabled);
7007
+ }
6905
7008
  } catch (error) {
6906
7009
  if (operation.signal.aborted && error === operation.signal.reason) return;
6907
7010
  throw error;
@@ -7617,8 +7720,7 @@ var MobileAccessGateway = class {
7617
7720
  let body;
7618
7721
  if (stockFrontend) body = Buffer.from(ensureMobileGatewayMarker(index.html));
7619
7722
  else {
7620
- const preference = await this.clientModulePreferences.read(authorization.deviceId);
7621
- const excludedClientModules = preference.excludedClientModules;
7723
+ const excludedClientModules = (await this.clientModulePreferences.read(authorization.deviceId)).excludedClientModules;
7622
7724
  try {
7623
7725
  const html = index.html;
7624
7726
  const plan = parseMobileBootPlan(html, excludedClientModules);
@@ -7645,22 +7747,15 @@ var MobileAccessGateway = class {
7645
7747
  this.lastModuleExclusionError = detail;
7646
7748
  process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
7647
7749
  }
7648
- if (preference.source === "device" || preference.source === "computer") {
7649
- operation.signal.throwIfAborted();
7650
- const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
7651
- const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
7652
- setSecurityHeaders(response, this.tlsEnabled);
7653
- response.writeHead(200, {
7654
- "Content-Type": "text/html; charset=utf-8",
7655
- "Content-Length": recovery.byteLength
7656
- });
7657
- response.end(recovery);
7658
- return;
7659
- }
7660
- sendJson(response, 409, {
7661
- error: "excluded_client_modules_invalid",
7662
- detail
7663
- }, this.tlsEnabled);
7750
+ operation.signal.throwIfAborted();
7751
+ const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
7752
+ const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
7753
+ setSecurityHeaders(response, this.tlsEnabled);
7754
+ response.writeHead(200, {
7755
+ "Content-Type": "text/html; charset=utf-8",
7756
+ "Content-Length": recovery.byteLength
7757
+ });
7758
+ response.end(recovery);
7664
7759
  return;
7665
7760
  }
7666
7761
  throw new HttpError(502, "upstream_unavailable");
@@ -10368,7 +10463,7 @@ function hostname$2(value) {
10368
10463
  function validateFrpServerAddress(value) {
10369
10464
  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");
10370
10465
  const normalized = value.toLowerCase().replace(/\.$/u, "");
10371
- if (isIP(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
10466
+ if (isIP$1(normalized) === 0 && !hostname$2(normalized)) throw new Error("frp_server_address_invalid");
10372
10467
  return normalized;
10373
10468
  }
10374
10469
  /** Validate the FRP control port. */
@@ -10391,8 +10486,8 @@ function validateFrpPublicOrigin(value) {
10391
10486
  throw new Error("frp_public_origin_invalid");
10392
10487
  }
10393
10488
  const publicHost = url.hostname;
10394
- 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");
10395
- if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
10489
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP$1(publicHost) !== 4 && !hostname$2(publicHost)) throw new Error("frp_public_origin_invalid");
10490
+ if (isIP$1(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
10396
10491
  return url.origin;
10397
10492
  }
10398
10493
  /** Validate the provisioning mode. */
@@ -10476,7 +10571,7 @@ function parseFrpSettings(value) {
10476
10571
  if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
10477
10572
  if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
10478
10573
  const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
10479
- if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
10574
+ if (resolvedEntryTls === "self-signed" && isIP$1(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
10480
10575
  return Object.freeze({
10481
10576
  version: 1,
10482
10577
  serverAddress: validateFrpServerAddress(record.serverAddress),
@@ -10830,7 +10925,7 @@ function frpAttachVpsParts(settings, options = {}) {
10830
10925
  selfSigned: false,
10831
10926
  vhostHttpPort,
10832
10927
  snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
10833
- certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
10928
+ certGuide: Object.freeze(isIP$1(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
10834
10929
  });
10835
10930
  }
10836
10931
  /**
@@ -11255,6 +11350,31 @@ function parseAdditionalTrustedNetworks(value) {
11255
11350
  }
11256
11351
  return Object.freeze(entries);
11257
11352
  }
11353
+ /**
11354
+ * Refresh the legacy `upstreamOrigin` snapshot to the DSH port actually in use.
11355
+ *
11356
+ * The runtime deliberately follows the WebServer port instead of this field (a Desktop
11357
+ * profile can start with `--port 0`), but a stale snapshot is indistinguishable from a
11358
+ * stale gateway to whoever reads the file while diagnosing a restart — #273 became a
11359
+ * manual edit because of it. Rewriting it keeps the file honest without changing which
11360
+ * upstream the gateway uses.
11361
+ *
11362
+ * @param setupFile - Existing managed setup file.
11363
+ * @param setup - The setup as it was read.
11364
+ * @param webServerPort - The port the active DSH WebServer bound.
11365
+ * @returns Whether the file needed rewriting.
11366
+ */
11367
+ async function refreshManagedUpstreamOrigin(setupFile, setup, webServerPort) {
11368
+ if (!Number.isInteger(webServerPort) || webServerPort <= 0) return false;
11369
+ const upstreamOrigin = `http://127.0.0.1:${String(webServerPort)}`;
11370
+ if (setup.upstreamOrigin === upstreamOrigin) return false;
11371
+ const next = Object.freeze({
11372
+ ...setup,
11373
+ upstreamOrigin
11374
+ });
11375
+ await atomicWrite(setupFile, `${JSON.stringify(next, null, 2)}\n`);
11376
+ return true;
11377
+ }
11258
11378
  /** Save extra trusted networks without replacing the interface or pairing identity.
11259
11379
  * @param setupFile - Existing managed setup file.
11260
11380
  * @param values - Explicitly selected additional networks.
@@ -11653,7 +11773,7 @@ function frpIngressPaths(stateFile) {
11653
11773
  statusFile: join(dirname(stateFile), "ingress-identity.json")
11654
11774
  });
11655
11775
  }
11656
- async function regularFileExists(file) {
11776
+ async function regularFileExists$1(file) {
11657
11777
  try {
11658
11778
  const entry = await lstat(file);
11659
11779
  if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
@@ -11664,7 +11784,7 @@ async function regularFileExists(file) {
11664
11784
  }
11665
11785
  }
11666
11786
  async function readIngressIdentity(file) {
11667
- if (!await regularFileExists(file)) return void 0;
11787
+ if (!await regularFileExists$1(file)) return void 0;
11668
11788
  try {
11669
11789
  const parsed = JSON.parse(await readFile(file, "utf8"));
11670
11790
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
@@ -11704,10 +11824,10 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
11704
11824
  }
11705
11825
  const previousFingerprint = await readIngressIdentity(paths.statusFile);
11706
11826
  const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
11707
- regularFileExists(paths.caCertFile),
11708
- regularFileExists(paths.caKeyFile),
11709
- regularFileExists(paths.certFile),
11710
- regularFileExists(paths.keyFile)
11827
+ regularFileExists$1(paths.caCertFile),
11828
+ regularFileExists$1(paths.caKeyFile),
11829
+ regularFileExists$1(paths.certFile),
11830
+ regularFileExists$1(paths.keyFile)
11711
11831
  ]);
11712
11832
  if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
11713
11833
  if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
@@ -11867,7 +11987,8 @@ const REMOTE_PROVIDERS = Object.freeze([
11867
11987
  "cpolar",
11868
11988
  "cloudflared",
11869
11989
  "frp",
11870
- "origin"
11990
+ "origin",
11991
+ "iroh"
11871
11992
  ]);
11872
11993
  function aggregateErrors(errors, message) {
11873
11994
  if (errors.length === 0) return void 0;
@@ -12027,7 +12148,7 @@ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTim
12027
12148
  function parseRemoteProviderState(value) {
12028
12149
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
12029
12150
  const record = value;
12030
- 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");
12151
+ if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" && record.provider !== "cloudflared" && record.provider !== "frp" && record.provider !== "origin" && record.provider !== "iroh" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
12031
12152
  return Object.freeze({
12032
12153
  version: 1,
12033
12154
  provider: record.provider
@@ -12093,7 +12214,7 @@ var JsonRemoteProviderStore = class {
12093
12214
  /** Resolve the first-run provider without letting environment values bypass validation. */
12094
12215
  function configuredRemoteProvider(environment) {
12095
12216
  const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
12096
- 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");
12217
+ if (value !== "tailscale" && value !== "cpolar" && value !== "cloudflared" && value !== "frp" && value !== "origin" && value !== "iroh") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale, cpolar, cloudflared, frp, origin, or iroh");
12097
12218
  return value;
12098
12219
  }
12099
12220
  //#endregion
@@ -12128,7 +12249,7 @@ const CLIENT_FAILURE_CODES = Object.freeze([
12128
12249
  function frpcDiagnosisCode(output) {
12129
12250
  return CLIENT_FAILURE_CODES.find(([pattern]) => pattern.test(output))?.[1];
12130
12251
  }
12131
- function publicStatus$3(status) {
12252
+ function publicStatus$4(status) {
12132
12253
  return Object.freeze({
12133
12254
  enabled: status.enabled,
12134
12255
  state: status.state,
@@ -12334,7 +12455,7 @@ var FrpController = class {
12334
12455
  child;
12335
12456
  gatewayValue;
12336
12457
  generation = 0;
12337
- latest = publicStatus$3({
12458
+ latest = publicStatus$4({
12338
12459
  enabled: false,
12339
12460
  state: "off"
12340
12461
  });
@@ -12364,7 +12485,7 @@ var FrpController = class {
12364
12485
  }
12365
12486
  /** Return state safe for the desktop control UI. */
12366
12487
  status() {
12367
- return publicStatus$3(this.latest);
12488
+ return publicStatus$4(this.latest);
12368
12489
  }
12369
12490
  /** Enable or disable FRP without changing LAN or another provider. */
12370
12491
  async setEnabled(enabled) {
@@ -12430,7 +12551,7 @@ var FrpController = class {
12430
12551
  return task;
12431
12552
  }
12432
12553
  publish(status) {
12433
- this.latest = publicStatus$3(status);
12554
+ this.latest = publicStatus$4(status);
12434
12555
  try {
12435
12556
  this.options.onStatus?.(this.status());
12436
12557
  } catch {}
@@ -12749,18 +12870,18 @@ function validateOriginPublicOrigin(value) {
12749
12870
  "internal",
12750
12871
  "home.arpa"
12751
12872
  ].some((suffix) => host === suffix || host.endsWith("." + suffix));
12752
- 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");
12873
+ if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP$1(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP$1(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
12753
12874
  return url.origin;
12754
12875
  }
12755
12876
  /** Bind one explicit loopback, RFC1918 IPv4 or IPv6 ULA interface; zone IDs are unsupported. */
12756
12877
  function validateOriginListenHost(value) {
12757
- const addressFamily = isIP(value);
12878
+ const addressFamily = isIP$1(value);
12758
12879
  if (typeof value !== "string" || value.includes("%") || addressFamily === 0 || !addressAllowed(value, TRUSTED_NETWORKS.filter((range) => range.bits === (addressFamily === 6 ? 128 : 32)))) throw new Error("origin_listen_host_invalid");
12759
12880
  return addressFamily === 6 ? new URL("http://[" + value + "]").hostname.slice(1, -1) : value;
12760
12881
  }
12761
12882
  /** Format a validated backend listener, including the brackets required by IPv6 URLs. */
12762
12883
  function formatOriginBackendOrigin(listenHost, listenPort) {
12763
- return "http://" + (isIP(listenHost) === 6 ? "[" + listenHost + "]" : listenHost) + ":" + String(listenPort);
12884
+ return "http://" + (isIP$1(listenHost) === 6 ? "[" + listenHost + "]" : listenHost) + ":" + String(listenPort);
12764
12885
  }
12765
12886
  function validateOriginListenPort(value) {
12766
12887
  if (!Number.isSafeInteger(value) || Number(value) < MIN_ORIGIN_LISTEN_PORT || Number(value) > 65535) throw new Error("origin_listen_port_invalid");
@@ -12769,12 +12890,12 @@ function validateOriginListenPort(value) {
12769
12890
  }
12770
12891
  /** Authorize direct proxy socket peers, not untrusted forwarded client headers. */
12771
12892
  function validateOriginAllowedCidrs(value, listenHost) {
12772
- const addressFamily = isIP(listenHost);
12893
+ const addressFamily = isIP$1(listenHost);
12773
12894
  const input = value === void 0 && isLoopbackAddress(listenHost) ? [addressFamily === 6 ? "::1/128" : "127.0.0.0/8"] : value;
12774
12895
  if (!Array.isArray(input) || input.length === 0 || input.length > 16) throw new Error("origin_allowed_cidrs_invalid");
12775
12896
  const cidrs = [];
12776
12897
  for (const entry of input) {
12777
- if (typeof entry !== "string" || entry.length > 64 || entry.includes("%") || isIP(entry.split("/")[0] ?? "") !== addressFamily) throw new Error("origin_allowed_cidrs_invalid");
12898
+ if (typeof entry !== "string" || entry.length > 64 || entry.includes("%") || isIP$1(entry.split("/")[0] ?? "") !== addressFamily) throw new Error("origin_allowed_cidrs_invalid");
12778
12899
  try {
12779
12900
  const cidr = parseCidr(entry);
12780
12901
  if (cidr.bits !== (addressFamily === 6 ? 128 : 32) || !TRUSTED_NETWORKS.some((range) => cidr.bits === range.bits && cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
@@ -13390,7 +13511,7 @@ const RESERVED_CLOUDFLARED_LABELS = Object.freeze([
13390
13511
  "update",
13391
13512
  "login"
13392
13513
  ]);
13393
- function publicStatus$2(status) {
13514
+ function publicStatus$3(status) {
13394
13515
  return Object.freeze({
13395
13516
  enabled: status.enabled,
13396
13517
  state: status.state,
@@ -13510,7 +13631,7 @@ var CloudflaredController = class {
13510
13631
  buffer = "";
13511
13632
  mode = "quick";
13512
13633
  namedOrigin;
13513
- latest = publicStatus$2({
13634
+ latest = publicStatus$3({
13514
13635
  enabled: false,
13515
13636
  state: "off"
13516
13637
  });
@@ -13538,7 +13659,7 @@ var CloudflaredController = class {
13538
13659
  }
13539
13660
  /** Return state safe for the desktop control UI. */
13540
13661
  status() {
13541
- return publicStatus$2(this.latest);
13662
+ return publicStatus$3(this.latest);
13542
13663
  }
13543
13664
  /** Enable or disable cloudflared without changing LAN or other provider state. */
13544
13665
  async setEnabled(enabled) {
@@ -13604,7 +13725,7 @@ var CloudflaredController = class {
13604
13725
  return task;
13605
13726
  }
13606
13727
  publish(status) {
13607
- this.latest = publicStatus$2(status);
13728
+ this.latest = publicStatus$3(status);
13608
13729
  try {
13609
13730
  this.options.onStatus?.(this.status());
13610
13731
  } catch {}
@@ -13911,7 +14032,7 @@ function hostname$1(value) {
13911
14032
  function validateCloudflaredTunnelHostname(value) {
13912
14033
  if (typeof value !== "string" || value !== value.trim() || value.length === 0 || value.length > 253) throw new Error("cloudflared_tunnel_hostname_invalid");
13913
14034
  const normalized = value.toLowerCase().replace(/\.$/u, "");
13914
- 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");
14035
+ if (isIP$1(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");
13915
14036
  return normalized;
13916
14037
  }
13917
14038
  /** Validate the stable loopback port Cloudflare's ingress forwards to. */
@@ -14082,186 +14203,1593 @@ var CloudflaredTunnelStore = class {
14082
14203
  }
14083
14204
  };
14084
14205
  //#endregion
14085
- //#region src/task-events.ts
14086
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
14087
- function turnNumber(value) {
14088
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
14206
+ //#region src/iroh-files.ts
14207
+ async function renameIrohPath(root, source, destination) {
14208
+ await renameManagedPath(root, source, destination, "iroh");
14089
14209
  }
14090
- function turnData(value) {
14091
- if (value === null || typeof value !== "object" || Array.isArray(value)) return {
14092
- turn: 0,
14093
- completed: false
14094
- };
14095
- const record = value;
14096
- const reason = record.reason;
14097
- const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
14098
- return {
14099
- turn: turnNumber(record.turn),
14100
- completed
14101
- };
14210
+ async function ensureIrohDirectory(root, directory) {
14211
+ await ensureManagedDirectory(root, directory, "iroh");
14102
14212
  }
14103
- /**
14104
- * Subscribe to completed root turns. Subagent turns are skipped so one task
14105
- * announces once, and rapid turn boundaries collapse into a single event.
14106
- * Returns a disposer that also drops pending debounces.
14107
- */
14108
- function watchTaskCompletions(ctx, options) {
14109
- const debounceMs = options.debounceMs ?? 1e3;
14110
- const pending = /* @__PURE__ */ new Map();
14111
- const disposeListener = ctx.on("session/event", (session, event) => {
14112
- if (event.type !== "turn/end") return;
14113
- const { turn, completed } = turnData(event.data);
14114
- if (!completed) return;
14115
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
14116
- const sessionId = String(session.id);
14117
- options.log?.("task-completed-observed", {
14118
- sessionId,
14119
- turn
14120
- });
14121
- const previous = pending.get(sessionId);
14122
- if (previous !== void 0) clearTimeout(previous);
14123
- pending.set(sessionId, setTimeout(() => {
14124
- pending.delete(sessionId);
14125
- options.log?.("task-completed-announced", {
14126
- sessionId,
14127
- turn
14128
- });
14129
- options.onTaskCompleted(Object.freeze({
14130
- sessionId,
14131
- turn
14132
- }));
14133
- }, debounceMs));
14134
- });
14135
- return () => {
14136
- disposeListener();
14137
- for (const timer of pending.values()) clearTimeout(timer);
14138
- pending.clear();
14139
- };
14213
+ async function removeIrohTree(root, target) {
14214
+ await removeManagedTree(root, target, "iroh");
14140
14215
  }
14141
- /** One subscription feeding every live gateway; gateways register on start. */
14142
- var TaskEventHub = class {
14143
- sinks = /* @__PURE__ */ new Set();
14144
- /** Register a sink; returns its disposer. */
14145
- add(sink) {
14146
- this.sinks.add(sink);
14147
- return () => {
14148
- this.sinks.delete(sink);
14149
- };
14150
- }
14151
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
14152
- broadcast(event) {
14153
- for (const sink of [...this.sinks]) try {
14154
- sink.broadcastTaskEvent(event);
14155
- } catch {}
14156
- }
14157
- /** Visible for tests. */
14158
- get size() {
14159
- return this.sinks.size;
14160
- }
14161
- };
14162
14216
  //#endregion
14163
- //#region src/diagnostics.ts
14164
- /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
14165
- function hasCompetingRemoteChannelBoot(rows) {
14166
- return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
14167
- }
14168
- const REMOTE_ERROR_GUIDANCE = Object.freeze({
14169
- component_missing: "重新安装完整插件包。",
14170
- funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
14171
- funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
14172
- funnel_start_failed: "重新打开授权页并允许 Funnel。",
14173
- funnel_start_timeout: "检查网络后点击“重新连接”。",
14174
- tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
14175
- sidecar_launch_failed: "重新安装完整插件包后重试。",
14176
- sidecar_stopped: "点击“重新连接”。",
14177
- sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14178
- control_channel_failed: "点击“重新连接”。",
14179
- cpolar_component_missing: "先安装 cpolar 官方组件。",
14180
- cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
14181
- cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
14182
- cpolar_config_invalid: "重新保存 cpolar Authtoken。",
14183
- cpolar_start_timeout: "检查网络后点击“重新连接”。",
14184
- cpolar_stopped: "点击“重新连接”。",
14185
- cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14186
- cloudflared_component_missing: "先安装 cloudflared 官方组件。",
14187
- cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
14188
- cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
14189
- cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
14190
- cloudflared_start_timeout: "检查网络后点击“重新连接”。",
14191
- cloudflared_stopped: "点击“重新连接”。",
14192
- cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
14193
- cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
14194
- cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
14195
- origin_config_missing: "先保存自有反向代理配置。",
14196
- origin_config_invalid: "重新保存自有反向代理配置。",
14197
- origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
14198
- origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
14199
- origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
14200
- frp_component_missing: "先安装 FRP 官方组件。",
14201
- frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
14202
- frp_config_missing: "先保存自建 FRP 连接配置。",
14203
- frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
14204
- frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
14205
- frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
14206
- frp_launch_failed: "重新安装 FRP 官方组件后重试。",
14207
- frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
14208
- frp_proxy_name_in_use: "该 frps 上已有同名代理:在另一台 DSH 电脑上关闭自建 FRP,或在 frps 上清掉该代理后重新连接。",
14209
- frp_remote_port_in_use: "公网入口端口被其他代理占用:改用其他入口端口后重新连接。",
14210
- frp_proxy_start_failed: "查看 frps 日志与 allowPorts,确认它能接受该代理名和入口端口。",
14211
- frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
14212
- frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
14213
- frp_stopped: "点击“重新连接”。",
14214
- frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
14215
- gateway_start_failed: "确认 DSH 正在运行后重新连接。"
14217
+ //#region src/iroh-manifest.ts
14218
+ /** Checksum-gated resolution of the iroh transport host binary.
14219
+ *
14220
+ * The sidecar is built and published by the independent `dsh-mobile-iroh`
14221
+ * companion (npm alpha channel); this stable plugin never bundles, downloads
14222
+ * or rebuilds it. Resolution points at an installed companion directory,
14223
+ * validates its manifest against that package's own version, then verifies
14224
+ * the platform binary byte-for-byte. Anything unexpected fails closed.
14225
+ */
14226
+ const IROH_NATIVE_TARGETS = Object.freeze({
14227
+ "win32-x64": "dsh-mobile-iroh-host-win32-x64.exe",
14228
+ "linux-x64": "dsh-mobile-iroh-host-linux-x64"
14216
14229
  });
14217
- function check(id, status, reason, label, detail, action, facts) {
14230
+ const MAX_BINARY_BYTES = 134217728;
14231
+ const MAX_MANIFEST_BYTES = 4096;
14232
+ function exactKeys(value, keys) {
14233
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
14234
+ return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
14235
+ }
14236
+ /** Validate the complete package-bound inventory; artifact paths are never supplied by metadata. */
14237
+ function validateIrohNativeManifest(value, packageVersion) {
14238
+ if (!exactKeys(value, [
14239
+ "schemaVersion",
14240
+ "packageVersion",
14241
+ "artifacts"
14242
+ ]) || value.schemaVersion !== 1 || value.packageVersion !== packageVersion || !exactKeys(value.artifacts, Object.keys(IROH_NATIVE_TARGETS))) throw new Error("iroh_native_manifest_invalid");
14243
+ const artifacts = {};
14244
+ for (const target of Object.keys(IROH_NATIVE_TARGETS)) {
14245
+ const entry = value.artifacts[target];
14246
+ if (!exactKeys(entry, [
14247
+ "file",
14248
+ "bytes",
14249
+ "sha256"
14250
+ ]) || entry.file !== IROH_NATIVE_TARGETS[target] || !Number.isSafeInteger(entry.bytes) || entry.bytes < 64 || entry.bytes > MAX_BINARY_BYTES || typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error("iroh_native_manifest_invalid");
14251
+ artifacts[target] = Object.freeze({
14252
+ file: entry.file,
14253
+ bytes: entry.bytes,
14254
+ sha256: entry.sha256
14255
+ });
14256
+ }
14218
14257
  return Object.freeze({
14219
- id,
14220
- status,
14221
- reason,
14222
- ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
14223
- label,
14224
- detail,
14225
- ...action === void 0 ? {} : { action }
14258
+ schemaVersion: 1,
14259
+ packageVersion: value.packageVersion,
14260
+ artifacts: Object.freeze(artifacts)
14226
14261
  });
14227
14262
  }
14228
- function maskLanOrigin(origin) {
14229
- if (origin === void 0) return "未分配";
14263
+ /**
14264
+ * Return the checksum-verified sidecar inside an installed companion package.
14265
+ * @param companionRoot - Companion package directory (contains `package.json` + `native/`).
14266
+ */
14267
+ async function resolveIrohSidecarAt(companionRoot, platform, arch) {
14268
+ const target = `${platform}-${arch}`;
14269
+ if (!Object.hasOwn(IROH_NATIVE_TARGETS, target)) throw new Error("iroh_native_platform_unsupported");
14270
+ const root = await realpath(resolve(companionRoot)).catch(() => {
14271
+ throw new Error("iroh_native_missing");
14272
+ });
14273
+ const nativeDirectory = join(root, "native");
14274
+ const directory = await lstat(nativeDirectory).catch(() => {
14275
+ throw new Error("iroh_native_missing");
14276
+ });
14277
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("iroh_native_file_invalid");
14278
+ const manifestPath = join(nativeDirectory, "manifest.json");
14279
+ const manifestStat = await lstat(manifestPath).catch(() => {
14280
+ throw new Error("iroh_native_missing");
14281
+ });
14282
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > MAX_MANIFEST_BYTES) throw new Error("iroh_native_manifest_invalid");
14283
+ let manifest;
14230
14284
  try {
14231
- const url = new URL(origin);
14232
- const octets = url.hostname.split(".");
14233
- const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
14234
- return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
14235
- } catch {
14236
- return "地址格式无效";
14285
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
14286
+ if (typeof pkg.version !== "string") throw new Error("iroh_native_manifest_invalid");
14287
+ manifest = validateIrohNativeManifest(JSON.parse(await readFile(manifestPath, "utf8")), pkg.version);
14288
+ } catch (error) {
14289
+ if (error instanceof Error && error.message === "iroh_native_manifest_invalid") throw error;
14290
+ throw new Error("iroh_native_manifest_invalid");
14237
14291
  }
14238
- }
14239
- function remoteSuffix(origin) {
14240
- if (origin === void 0) return "未分配";
14292
+ const expected = manifest.artifacts[target];
14293
+ const path = join(nativeDirectory, IROH_NATIVE_TARGETS[target]);
14294
+ const before = await lstat(path).catch(() => {
14295
+ throw new Error("iroh_native_missing");
14296
+ });
14297
+ if (!before.isFile() || before.isSymbolicLink() || before.size !== expected.bytes) throw new Error("iroh_native_file_invalid");
14298
+ const file = await open(path, "r").catch(() => {
14299
+ throw new Error("iroh_native_missing");
14300
+ });
14241
14301
  try {
14242
- const hostname = new URL(origin).hostname;
14243
- if (hostname.endsWith(".ts.net")) return "*.ts.net";
14244
- if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
14245
- for (const suffix of [
14246
- ".cpolar.cn",
14247
- ".cpolar.io",
14248
- ".cpolar.top",
14249
- ".cpolar.com"
14250
- ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
14251
- return "公共 HTTPS 地址";
14252
- } catch {
14253
- return "地址格式无效";
14302
+ const opened = await file.stat();
14303
+ if (!opened.isFile() || opened.size !== expected.bytes || opened.ino !== before.ino || opened.dev !== before.dev) throw new Error("iroh_native_file_invalid");
14304
+ const hash = createHash("sha256");
14305
+ let bytes = 0;
14306
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
14307
+ const buffer = chunk;
14308
+ bytes += buffer.length;
14309
+ if (bytes > expected.bytes) throw new Error("iroh_native_file_changed");
14310
+ hash.update(buffer);
14311
+ }
14312
+ if (hash.digest("hex") !== expected.sha256) throw new Error("iroh_native_hash_mismatch");
14313
+ const after = await lstat(path);
14314
+ if (after.isSymbolicLink() || after.ino !== opened.ino || after.dev !== opened.dev || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error("iroh_native_file_changed");
14315
+ } finally {
14316
+ await file.close();
14254
14317
  }
14318
+ if (process.platform !== "win32" && platform !== "win32" && (before.mode & 73) === 0) throw new Error("iroh_native_not_executable");
14319
+ return path;
14255
14320
  }
14256
- function defaultFirewallProbe(platform = process.platform) {
14257
- return async (port) => {
14258
- if (platform !== "win32") return { state: "not-applicable" };
14259
- if (port === void 0) return { state: "unknown" };
14260
- const script = [
14261
- "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
14262
- "$ready = $true",
14263
- "$specs | ForEach-Object {",
14264
- " $spec = $_",
14321
+ //#endregion
14322
+ //#region src/iroh-component.ts
14323
+ /** Managed view of the iroh transport host sidecar.
14324
+ *
14325
+ * Explicit installation verifies and promotes a pinned sidecar release into
14326
+ * private storage. A verified independent companion copy can be reused without
14327
+ * downloading. Enabling the channel alone never downloads a component.
14328
+ */
14329
+ const IROH_VERSION = "0.1.0-alpha.1";
14330
+ const IROH_RELEASE_PAGE = "https://github.com/saya-ch/dsh-mobile-iroh/releases";
14331
+ const IROH_COMPONENT_RELEASES = Object.freeze({
14332
+ "linux-x64": Object.freeze({
14333
+ version: IROH_VERSION,
14334
+ platform: "linux",
14335
+ arch: "x64",
14336
+ executableName: "dsh-mobile-iroh-host-linux-x64",
14337
+ downloadUrl: "https://unpkg.com/dsh-mobile-iroh@0.1.0-alpha.1/native/dsh-mobile-iroh-host-linux-x64",
14338
+ downloadBytes: 24451352,
14339
+ executableBytes: 24451352,
14340
+ downloadSha256: "be391dfba3f0f1323d4fe9989e56a0554ceaf74e00a44acdf4a3bfd163bdcab5",
14341
+ executableSha256: "be391dfba3f0f1323d4fe9989e56a0554ceaf74e00a44acdf4a3bfd163bdcab5"
14342
+ }),
14343
+ "win32-x64": Object.freeze({
14344
+ version: IROH_VERSION,
14345
+ platform: "win32",
14346
+ arch: "x64",
14347
+ executableName: "dsh-mobile-iroh-host-win32-x64.exe",
14348
+ downloadUrl: "https://unpkg.com/dsh-mobile-iroh@0.1.0-alpha.1/native/dsh-mobile-iroh-host-win32-x64.exe",
14349
+ downloadBytes: 14865920,
14350
+ executableBytes: 14865920,
14351
+ downloadSha256: "883987c636a379dc999a7f15fd1f9addbb7d28f430ed1f952ad5a610c37cc032",
14352
+ executableSha256: "883987c636a379dc999a7f15fd1f9addbb7d28f430ed1f952ad5a610c37cc032"
14353
+ })
14354
+ });
14355
+ async function digest$1(file) {
14356
+ return createHash("sha256").update(await readFile(file)).digest("hex");
14357
+ }
14358
+ function digestBytes(bytes) {
14359
+ return createHash("sha256").update(bytes).digest("hex");
14360
+ }
14361
+ var IrohComponentManager = class {
14362
+ stateDirectory;
14363
+ platform;
14364
+ arch;
14365
+ locateCompanionRoots;
14366
+ resolveSidecar;
14367
+ artifact;
14368
+ fetchArtifact;
14369
+ componentRoot;
14370
+ componentStorage;
14371
+ executablePath;
14372
+ stagingRoot;
14373
+ cached;
14374
+ cachedExecutable;
14375
+ origin;
14376
+ queue = Promise.resolve();
14377
+ constructor(options) {
14378
+ if (typeof options.stateDirectory !== "string" || options.stateDirectory === "" || !isAbsolute(options.stateDirectory)) throw new Error("iroh component state directory invalid");
14379
+ this.stateDirectory = resolve(options.stateDirectory);
14380
+ this.platform = options.platform ?? process.platform;
14381
+ this.arch = options.arch ?? process.arch;
14382
+ const artifact = options.artifact ?? IROH_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
14383
+ if (artifact !== void 0 && (artifact.platform !== this.platform || artifact.arch !== this.arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("iroh_artifact_invalid");
14384
+ this.artifact = artifact;
14385
+ this.locateCompanionRoots = options.locateCompanionRoots ?? (() => []);
14386
+ this.resolveSidecar = options.resolveSidecar ?? resolveIrohSidecarAt;
14387
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => downloadPinnedArtifact({
14388
+ url,
14389
+ signal,
14390
+ expectedBytes: artifact?.downloadBytes ?? 0,
14391
+ errorPrefix: "iroh",
14392
+ redirectHosts: ["unpkg.com"]
14393
+ }));
14394
+ this.componentRoot = join(this.stateDirectory, "components", "iroh");
14395
+ this.componentStorage = join(this.componentRoot, artifact?.version ?? "0.1.0-alpha.1");
14396
+ this.executablePath = join(this.componentStorage, artifact?.executableName ?? (this.platform === "win32" ? "dsh-mobile-iroh-host-win32-x64.exe" : "dsh-mobile-iroh-host-linux-x64"));
14397
+ this.stagingRoot = join(this.stateDirectory, "staging", "iroh");
14398
+ this.cached = this.baseStatus();
14399
+ }
14400
+ /** Resolved, verified sidecar executable, or undefined when unavailable. */
14401
+ get executable() {
14402
+ return this.cachedExecutable;
14403
+ }
14404
+ /** Cached component state; refresh with {@link initialize} or {@link install}. */
14405
+ status() {
14406
+ return this.cached;
14407
+ }
14408
+ /** Verify the sidecar once at startup; safe to call before any status read. */
14409
+ async initialize() {
14410
+ await this.enqueue(() => this.refresh());
14411
+ }
14412
+ /**
14413
+ * Confirm a usable sidecar without downloading anything.
14414
+ *
14415
+ * Enabling the channel calls this: starting must never trigger a download
14416
+ * on its own. Only the explicit install button below may fetch bytes.
14417
+ */
14418
+ async ensureAvailable() {
14419
+ return this.enqueue(async () => {
14420
+ const current = await this.refresh();
14421
+ if (!(this.artifact !== void 0)) throw new Error("iroh_platform_unsupported");
14422
+ if (!current.installed) throw new Error("iroh_companion_missing");
14423
+ return current;
14424
+ });
14425
+ }
14426
+ /**
14427
+ * Ensure a verified sidecar in the plugin private directory.
14428
+ *
14429
+ * Prefers an already installed companion copy (no network), otherwise
14430
+ * downloads the pinned release and verifies size and SHA-256 before
14431
+ * promoting it. A companion that exists but fails verification is reported
14432
+ * instead of being silently replaced by a download.
14433
+ */
14434
+ async install() {
14435
+ return this.enqueue(async () => {
14436
+ const artifact = this.artifact;
14437
+ if (artifact === void 0) throw new Error("iroh_platform_unsupported");
14438
+ const stateValid = await this.checkStateExecutable(artifact).catch(() => void 0);
14439
+ if (stateValid !== void 0) {
14440
+ this.publishInstalled(artifact, this.executablePath, stateValid, "state");
14441
+ return this.cached;
14442
+ }
14443
+ const companion = await this.findCompanionExecutable().catch(() => void 0);
14444
+ if (companion !== void 0) {
14445
+ if (companion.valid) {
14446
+ await this.publishBytes(artifact, await readFile(companion.executable));
14447
+ return this.cached;
14448
+ }
14449
+ this.origin = void 0;
14450
+ this.cachedExecutable = void 0;
14451
+ this.cached = Object.freeze({
14452
+ ...this.baseStatus(),
14453
+ installed: false,
14454
+ errorCode: "irohSidecarInvalid"
14455
+ });
14456
+ throw new Error("iroh_sidecar_invalid");
14457
+ }
14458
+ let bytes;
14459
+ try {
14460
+ bytes = await this.downloadArtifact(artifact);
14461
+ } catch (error) {
14462
+ if (error instanceof Error && error.message.startsWith("iroh_")) throw error;
14463
+ throw new Error("iroh_download_failed", { cause: error });
14464
+ }
14465
+ await this.publishBytes(artifact, bytes);
14466
+ return this.cached;
14467
+ });
14468
+ }
14469
+ enqueue(operation) {
14470
+ const task = this.queue.then(operation, operation);
14471
+ this.queue = task.then(() => void 0, () => void 0);
14472
+ return task;
14473
+ }
14474
+ baseStatus() {
14475
+ const supported = this.artifact !== void 0;
14476
+ return Object.freeze({
14477
+ supported,
14478
+ installed: false,
14479
+ version: "",
14480
+ downloadBytes: this.artifact?.downloadBytes ?? 0,
14481
+ installedBytes: 0,
14482
+ sourceUrl: this.artifact?.downloadUrl ?? "https://www.npmjs.com/package/dsh-mobile-iroh",
14483
+ releasePage: IROH_RELEASE_PAGE,
14484
+ storagePath: this.stateDirectory,
14485
+ ...supported ? {} : { errorCode: "irohPlatformUnsupported" }
14486
+ });
14487
+ }
14488
+ publishInstalled(artifact, executable, version, origin) {
14489
+ this.origin = origin;
14490
+ this.cachedExecutable = executable;
14491
+ this.cached = Object.freeze({
14492
+ supported: true,
14493
+ installed: true,
14494
+ version,
14495
+ downloadBytes: artifact.downloadBytes,
14496
+ installedBytes: artifact.executableBytes,
14497
+ sourceUrl: artifact.downloadUrl,
14498
+ releasePage: IROH_RELEASE_PAGE,
14499
+ storagePath: executable
14500
+ });
14501
+ }
14502
+ async checkStateExecutable(artifact) {
14503
+ const entry = await lstat(this.executablePath);
14504
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes) throw new Error("iroh_sidecar_invalid");
14505
+ if (this.platform !== "win32" && (entry.mode & 73) === 0) throw new Error("iroh_sidecar_invalid");
14506
+ if (await digest$1(this.executablePath) !== artifact.executableSha256) throw new Error("iroh_sidecar_invalid");
14507
+ return artifact.version;
14508
+ }
14509
+ async findCompanionExecutable() {
14510
+ for (const root of this.locateCompanionRoots()) try {
14511
+ const executable = await this.resolveSidecar(root, this.platform, this.arch);
14512
+ await lstat(executable);
14513
+ let version = "";
14514
+ try {
14515
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
14516
+ if (pkg.name === "dsh-mobile-iroh" && typeof pkg.version === "string") version = pkg.version;
14517
+ } catch {}
14518
+ return {
14519
+ executable,
14520
+ version,
14521
+ valid: true
14522
+ };
14523
+ } catch (error) {
14524
+ if (error instanceof Error && error.message === "iroh_native_platform_unsupported") continue;
14525
+ if (error instanceof Error && (error.message === "iroh_native_hash_mismatch" || error.message === "iroh_native_file_changed" || error.message === "iroh_native_file_invalid" || error.message === "iroh_native_manifest_invalid" || error.message === "iroh_native_not_executable")) return {
14526
+ executable: "",
14527
+ version: "",
14528
+ valid: false
14529
+ };
14530
+ }
14531
+ }
14532
+ async downloadArtifact(artifact) {
14533
+ const controller = new AbortController();
14534
+ const timeout = setTimeout(() => {
14535
+ controller.abort(/* @__PURE__ */ new Error("iroh_download_timeout"));
14536
+ }, 6e5);
14537
+ timeout.unref();
14538
+ try {
14539
+ const bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
14540
+ if (bytes.byteLength !== artifact.downloadBytes) throw new Error("iroh_download_size_mismatch");
14541
+ if (digestBytes(bytes) !== artifact.downloadSha256) throw new Error("iroh_download_hash_mismatch");
14542
+ return bytes;
14543
+ } finally {
14544
+ clearTimeout(timeout);
14545
+ }
14546
+ }
14547
+ async publishBytes(artifact, bytes) {
14548
+ if (bytes.byteLength !== artifact.executableBytes || digestBytes(bytes) !== artifact.executableSha256) throw new Error("iroh_download_hash_mismatch");
14549
+ await ensureIrohDirectory(this.stateDirectory, this.stagingRoot);
14550
+ await ensureIrohDirectory(this.stateDirectory, this.componentRoot);
14551
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
14552
+ const staged = join(staging, artifact.executableName);
14553
+ const backup = join(this.componentRoot, `.previous-${Date.now().toString(36)}`);
14554
+ let movedPrevious = false;
14555
+ try {
14556
+ await writeFile(staged, bytes, {
14557
+ flag: "wx",
14558
+ mode: 448
14559
+ });
14560
+ await chmod(staged, 448);
14561
+ if (digestBytes(await readFile(staged)) !== artifact.executableSha256) throw new Error("iroh_download_hash_mismatch");
14562
+ await ensureIrohDirectory(this.stateDirectory, this.componentStorage);
14563
+ try {
14564
+ await lstat(this.executablePath);
14565
+ await renameIrohPath(this.stateDirectory, this.componentStorage, backup);
14566
+ movedPrevious = true;
14567
+ await ensureIrohDirectory(this.stateDirectory, this.componentStorage);
14568
+ } catch (error) {
14569
+ if (error.code !== "ENOENT") throw error;
14570
+ }
14571
+ try {
14572
+ await rename(staged, this.executablePath);
14573
+ } catch (error) {
14574
+ if (movedPrevious) try {
14575
+ await renameIrohPath(this.stateDirectory, backup, this.componentStorage);
14576
+ movedPrevious = false;
14577
+ } catch {}
14578
+ throw error;
14579
+ }
14580
+ this.publishInstalled(artifact, this.executablePath, artifact.version, "state");
14581
+ } finally {
14582
+ const leftovers = movedPrevious ? [staging, backup] : [staging];
14583
+ const failures = [];
14584
+ for (const path of leftovers) try {
14585
+ await removeIrohTree(this.stateDirectory, path);
14586
+ } catch (error) {
14587
+ failures.push(error);
14588
+ }
14589
+ if (failures.length > 0) throw new AggregateError(failures, "iroh_component_cleanup_failed");
14590
+ }
14591
+ }
14592
+ async refresh() {
14593
+ if (this.artifact === void 0) {
14594
+ this.origin = void 0;
14595
+ this.cachedExecutable = void 0;
14596
+ this.cached = this.baseStatus();
14597
+ return this.cached;
14598
+ }
14599
+ const stateVersion = await this.checkStateExecutable(this.artifact).catch(() => void 0);
14600
+ if (stateVersion !== void 0) {
14601
+ this.publishInstalled(this.artifact, this.executablePath, stateVersion, "state");
14602
+ return this.cached;
14603
+ }
14604
+ const companion = await this.findCompanionExecutable().catch(() => void 0);
14605
+ if (companion !== void 0 && companion.valid) {
14606
+ this.publishInstalled(this.artifact, companion.executable, companion.version, "companion");
14607
+ return this.cached;
14608
+ }
14609
+ const stateExists = await lstat(this.executablePath).then((entry) => entry.isFile() && !entry.isSymbolicLink()).catch(() => false);
14610
+ this.origin = void 0;
14611
+ this.cachedExecutable = void 0;
14612
+ this.cached = Object.freeze({
14613
+ ...this.baseStatus(),
14614
+ installed: false,
14615
+ ...stateExists || companion !== void 0 && !companion.valid ? { errorCode: "irohSidecarInvalid" } : {}
14616
+ });
14617
+ return this.cached;
14618
+ }
14619
+ };
14620
+ //#endregion
14621
+ //#region src/iroh-sidecar.ts
14622
+ /** Versioned parent-to-sidecar protocol for the iroh transport host.
14623
+ *
14624
+ * Ported from the independent `dsh-mobile-iroh` companion's sidecar
14625
+ * controller. Only the pure command/event codec lives here; child-process
14626
+ * ownership stays with the provider controller so lifecycle, timeouts and
14627
+ * test doubles follow this repository's existing controller conventions.
14628
+ * Every message is versioned, bounded and rejected closed on unknown fields.
14629
+ */
14630
+ const MAX_LINE_BYTES$1 = 16384;
14631
+ const ENDPOINT_ID_PATTERN$1 = /^[0-9a-f]{64}$/u;
14632
+ function isPlainText(value, maximum) {
14633
+ return typeof value === "string" && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
14634
+ }
14635
+ function hasExactFields(value, fields) {
14636
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
14637
+ return Object.keys(value).sort().join(",") === [...fields].sort().join(",");
14638
+ }
14639
+ function stripBrackets(hostname) {
14640
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
14641
+ }
14642
+ function isEndpointId(value) {
14643
+ return typeof value === "string" && ENDPOINT_ID_PATTERN$1.test(value);
14644
+ }
14645
+ /** Validate the loopback socket destination; TLS remains end to end. */
14646
+ function validateIrohProxyTarget(value) {
14647
+ const target = new URL(value);
14648
+ if (!["http:", "https:"].includes(target.protocol) || !["127.0.0.1", "::1"].includes(stripBrackets(target.hostname)) || target.username !== "" || target.password !== "" || target.pathname !== "/" || target.search !== "" || target.hash !== "") throw new Error("sidecar proxy target must be an explicit HTTP(S) loopback origin");
14649
+ return target.origin;
14650
+ }
14651
+ /** Strict parent-to-sidecar v1 command encoder. */
14652
+ function encodeIrohCommand(message) {
14653
+ if (message?.version !== 1) throw new Error("invalid sidecar command version");
14654
+ if (!hasExactFields(message, message.type === "device-forget" ? [
14655
+ "version",
14656
+ "type",
14657
+ "id"
14658
+ ] : ["version", "type"]) || ![
14659
+ "pairing-begin",
14660
+ "device-list",
14661
+ "device-forget",
14662
+ "shutdown"
14663
+ ].includes(message.type) || message.type === "device-forget" && !isEndpointId(message.id)) throw new Error("invalid sidecar command");
14664
+ const line = JSON.stringify(message);
14665
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES$1) throw new Error("sidecar command too large");
14666
+ return `${line}\n`;
14667
+ }
14668
+ function isDirectAddress(value) {
14669
+ if (!isPlainText(value, 128)) return false;
14670
+ try {
14671
+ const url = new URL(`http://${value}`);
14672
+ return isIP(stripBrackets(url.hostname)) !== 0 && url.port !== "" && url.pathname === "/" && url.username === "" && url.password === "" && url.search === "" && url.hash === "";
14673
+ } catch {
14674
+ return false;
14675
+ }
14676
+ }
14677
+ /** Decode exact first-party stdout events; no extension fields grant authority. */
14678
+ function decodeIrohEvent(line) {
14679
+ if (typeof line !== "string" || Buffer.byteLength(line) > MAX_LINE_BYTES$1) throw new Error("sidecar event too large");
14680
+ let event;
14681
+ try {
14682
+ event = JSON.parse(line);
14683
+ } catch {
14684
+ throw new Error("invalid sidecar event JSON");
14685
+ }
14686
+ if (event?.version !== 1 || typeof event.type !== "string") throw new Error("invalid sidecar event version");
14687
+ const base = ["version", "type"];
14688
+ const fields = {
14689
+ ready: [
14690
+ "endpoint_id",
14691
+ "direct_addresses",
14692
+ "local_only"
14693
+ ],
14694
+ pairing: ["code", "expires_in_sec"],
14695
+ "pairing-closed": ["reason"],
14696
+ "pairing-done": ["peer", "name"],
14697
+ "peer-connected": ["peer", "name"],
14698
+ "peer-path": [
14699
+ "peer",
14700
+ "kind",
14701
+ "remote"
14702
+ ],
14703
+ "peer-disconnected": ["peer"],
14704
+ "proxy-opened": ["peer"],
14705
+ devices: ["devices"],
14706
+ error: ["code", "message"],
14707
+ stopped: []
14708
+ }[event.type];
14709
+ if (fields === void 0 || !hasExactFields(event, [...base, ...fields])) throw new Error("invalid sidecar event fields");
14710
+ if ("peer" in event && !isEndpointId(event.peer)) throw new Error("invalid sidecar event peer");
14711
+ if ("name" in event && !isPlainText(event.name, 128)) throw new Error("invalid sidecar event name");
14712
+ switch (event.type) {
14713
+ case "ready": {
14714
+ const addresses = event.direct_addresses;
14715
+ if (!isEndpointId(event.endpoint_id) || typeof event.local_only !== "boolean" || !Array.isArray(addresses) || addresses.length > 64 || !addresses.every(isDirectAddress)) throw new Error("invalid sidecar ready event");
14716
+ return Object.freeze({
14717
+ version: 1,
14718
+ type: "ready",
14719
+ endpoint_id: event.endpoint_id,
14720
+ direct_addresses: Object.freeze([...addresses]),
14721
+ local_only: event.local_only
14722
+ });
14723
+ }
14724
+ case "pairing":
14725
+ if (!/^[0-9]{6}$/u.test(String(event.code)) || !Number.isSafeInteger(event.expires_in_sec) || event.expires_in_sec < 1 || event.expires_in_sec > 3600) throw new Error("invalid sidecar pairing event");
14726
+ break;
14727
+ case "pairing-closed":
14728
+ if (!isPlainText(event.reason, 128)) throw new Error("invalid sidecar reason");
14729
+ break;
14730
+ case "peer-path":
14731
+ if (event.kind !== "direct" && event.kind !== "relay" || !isPlainText(event.remote, 512)) throw new Error("invalid sidecar path event");
14732
+ break;
14733
+ case "devices": {
14734
+ const devices = event.devices;
14735
+ if (!Array.isArray(devices) || devices.length > 256 || !devices.every((device) => hasExactFields(device, [
14736
+ "id",
14737
+ "name",
14738
+ "paired_at",
14739
+ "online"
14740
+ ]) && isEndpointId(device.id) && isPlainText(device.name, 128) && isPlainText(device.paired_at, 64) && typeof device.online === "boolean")) throw new Error("invalid sidecar device event");
14741
+ return Object.freeze({
14742
+ version: 1,
14743
+ type: "devices",
14744
+ devices: Object.freeze(devices.map((device) => Object.freeze({ ...device })))
14745
+ });
14746
+ }
14747
+ case "error": if (!isPlainText(event.code, 128) || !isPlainText(event.message, 1024)) throw new Error("invalid sidecar error event");
14748
+ }
14749
+ return Object.freeze(event);
14750
+ }
14751
+ //#endregion
14752
+ //#region src/iroh-origin.ts
14753
+ /** Identity-derived browser origins for the raw TLS-over-iroh carrier.
14754
+ *
14755
+ * Ported from the independent `dsh-mobile-iroh` companion's origin contract.
14756
+ * The local port is a deployment choice; changing it changes browser storage
14757
+ * identity, so the selected port is part of every persisted record.
14758
+ */
14759
+ const DEFAULT_IROH_LOCAL_PORT = 25443;
14760
+ const ENDPOINT_ID_PATTERN = /^[a-f0-9]{64}$/u;
14761
+ function checkEndpointId(endpointId) {
14762
+ if (typeof endpointId !== "string" || !ENDPOINT_ID_PATTERN.test(endpointId)) throw new Error("iroh endpoint id must be a lowercase 32-byte public key");
14763
+ }
14764
+ function checkLocalPort(localPort) {
14765
+ if (!Number.isSafeInteger(localPort) || localPort < 1024 || localPort > 65535) throw new Error("iroh local port must be from 1024 through 65535");
14766
+ }
14767
+ /** Keep the full endpoint key in DNS-safe labels rather than a colliding port hash. */
14768
+ function irohHostname(endpointId) {
14769
+ checkEndpointId(endpointId);
14770
+ return `${endpointId.slice(0, 32)}.${endpointId.slice(32)}.iroh.localhost`;
14771
+ }
14772
+ /** HTTPS origin the phone's local transport listener serves for one endpoint. */
14773
+ function irohOrigin(endpointId, localPort = DEFAULT_IROH_LOCAL_PORT) {
14774
+ checkLocalPort(localPort);
14775
+ return `https://${irohHostname(endpointId)}:${localPort}`;
14776
+ }
14777
+ /** Reject an Origin that does not belong to the selected cryptographic host identity. */
14778
+ function validateIrohOrigin(value, endpointId, localPort = DEFAULT_IROH_LOCAL_PORT) {
14779
+ const expected = irohOrigin(endpointId, localPort);
14780
+ if (value !== expected) throw new Error("iroh origin does not match the selected endpoint and local port");
14781
+ return expected;
14782
+ }
14783
+ //#endregion
14784
+ //#region src/iroh-pairing.ts
14785
+ /** Short-lived iroh pairing descriptors for QR transport.
14786
+ *
14787
+ * Byte-compatible with the independent `dsh-mobile-iroh` companion's
14788
+ * descriptor v1: the same field order, byte budget, CA checks and
14789
+ * duplicate-key rejection, so a future Phase 2 phone accepts QR codes from
14790
+ * either side. Error messages never contain the descriptor, pairing code,
14791
+ * or gateway token.
14792
+ */
14793
+ const PAIRING_DESCRIPTOR_VERSION = 1;
14794
+ const PAIRING_DESCRIPTOR_TYPE = "dsh-mobile-iroh-pairing";
14795
+ const MAX_PAIRING_DESCRIPTOR_BYTES = 2200;
14796
+ const MAX_PAIRING_LIFETIME_MS = 6e5;
14797
+ const FIELDS = Object.freeze([
14798
+ "version",
14799
+ "type",
14800
+ "endpointId",
14801
+ "origin",
14802
+ "localPort",
14803
+ "irohCode",
14804
+ "gatewayToken",
14805
+ "caCertificate",
14806
+ "instanceId",
14807
+ "expiresAt"
14808
+ ]);
14809
+ const HEX_ID = /^[a-f0-9]{64}$/u;
14810
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
14811
+ /** Error messages never contain the descriptor, pairing code, or gateway token. */
14812
+ var PairingDescriptorError = class extends Error {
14813
+ code;
14814
+ constructor(code) {
14815
+ super(code);
14816
+ this.name = "PairingDescriptorError";
14817
+ this.code = code;
14818
+ }
14819
+ };
14820
+ function reject(code) {
14821
+ throw new PairingDescriptorError(code);
14822
+ }
14823
+ function validateCertificate(value, instanceId, now, expiresAt) {
14824
+ if (typeof value !== "string" || value.length === 0 || value.length > Math.ceil(512) * 4 || !BASE64.test(value)) reject("invalid_pairing_ca_encoding");
14825
+ const encoded = value;
14826
+ const der = Buffer.from(encoded, "base64");
14827
+ if (der.length === 0 || der.length > 1536 || der.toString("base64") !== encoded) reject("invalid_pairing_ca_encoding");
14828
+ let ca;
14829
+ try {
14830
+ ca = new X509Certificate(der);
14831
+ } catch {
14832
+ reject("invalid_pairing_ca_certificate");
14833
+ }
14834
+ if (!ca.raw.equals(der)) reject("invalid_pairing_ca_certificate");
14835
+ try {
14836
+ if (!ca.ca || ca.subject !== ca.issuer || !ca.verify(ca.publicKey)) reject("pairing_ca_must_be_self_signed");
14837
+ } catch (error) {
14838
+ if (error instanceof PairingDescriptorError) throw error;
14839
+ reject("pairing_ca_must_be_self_signed");
14840
+ }
14841
+ const notBefore = Date.parse(ca.validFrom);
14842
+ const notAfter = Date.parse(ca.validTo);
14843
+ if (!Number.isFinite(notBefore) || !Number.isFinite(notAfter) || now < notBefore || expiresAt > notAfter) reject("pairing_ca_outside_validity");
14844
+ if (createHash("sha256").update(der).digest("hex") !== instanceId) reject("pairing_ca_fingerprint_mismatch");
14845
+ }
14846
+ /**
14847
+ * Validate QR transport material, not a DSH Session or persistent credential.
14848
+ * localPort is mandatory on the wire and must match the identity-derived HTTPS
14849
+ * Origin. The QR is sensitive: only the public CA may be retained or logged on
14850
+ * its own; neither pairing secret may be copied into logs or device-list text.
14851
+ *
14852
+ * Scanning this descriptor is the user's out-of-band trust action. A self-signed
14853
+ * CA does not independently authenticate a QR received from an untrusted party.
14854
+ */
14855
+ function validatePairingDescriptor(value, options = {}) {
14856
+ const now = options.now ?? Date.now();
14857
+ if (!Number.isSafeInteger(now) || now < 0) reject("invalid_pairing_clock");
14858
+ if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.keys(value).sort().join(",") !== [...FIELDS].sort().join(",")) reject("invalid_pairing_fields");
14859
+ const record = value;
14860
+ if (record.version !== 1 || record.type !== "dsh-mobile-iroh-pairing") reject("unsupported_pairing_descriptor");
14861
+ if (typeof record.endpointId !== "string" || !HEX_ID.test(record.endpointId)) reject("invalid_pairing_endpoint");
14862
+ if (!Number.isSafeInteger(record.localPort) || record.localPort < 1024 || record.localPort > 65535) reject("invalid_pairing_local_port");
14863
+ if (record.origin !== irohOrigin(record.endpointId, record.localPort)) reject("invalid_pairing_origin");
14864
+ if (typeof record.irohCode !== "string" || !/^[0-9]{6}$/u.test(record.irohCode)) reject("invalid_iroh_pairing_code");
14865
+ if (typeof record.gatewayToken !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(record.gatewayToken) || Buffer.from(record.gatewayToken, "base64url").toString("base64url") !== record.gatewayToken) reject("invalid_gateway_pairing_token");
14866
+ if (typeof record.instanceId !== "string" || !HEX_ID.test(record.instanceId)) reject("invalid_pairing_instance_id");
14867
+ if (!Number.isSafeInteger(record.expiresAt) || record.expiresAt <= now || record.expiresAt - now > 6e5) reject("invalid_pairing_expiry");
14868
+ validateCertificate(record.caCertificate, record.instanceId, now, record.expiresAt);
14869
+ const descriptor = Object.fromEntries(FIELDS.map((field) => [field, record[field]]));
14870
+ if (Buffer.byteLength(JSON.stringify(descriptor), "utf8") > 2200) reject("pairing_descriptor_too_large");
14871
+ return Object.freeze(descriptor);
14872
+ }
14873
+ /** Encode a bounded canonical-field-order QR payload without implicit defaults. */
14874
+ function encodePairingDescriptor(value, options = {}) {
14875
+ return JSON.stringify(validatePairingDescriptor(value, options));
14876
+ }
14877
+ /** Decode untrusted QR text, validating its expiry and public CA before use. */
14878
+ function decodePairingDescriptor(encoded, options = {}) {
14879
+ if (typeof encoded !== "string" || Buffer.byteLength(encoded, "utf8") > 2200) reject("pairing_descriptor_too_large");
14880
+ let value;
14881
+ try {
14882
+ value = JSON.parse(encoded);
14883
+ } catch {
14884
+ reject("invalid_pairing_json");
14885
+ }
14886
+ const descriptor = validatePairingDescriptor(value, options);
14887
+ if ((encoded.match(/"(?:[^"\\]|\\[\s\S])*"\s*:/gu) ?? []).length !== FIELDS.length) reject("duplicate_pairing_fields");
14888
+ return descriptor;
14889
+ }
14890
+ /** Phone-side listener port advertised in QR codes minted by this computer. */
14891
+ const PAIRING_DESCRIPTOR_LOCAL_PORT = DEFAULT_IROH_LOCAL_PORT;
14892
+ //#endregion
14893
+ //#region src/iroh.ts
14894
+ /** Experimental iroh remote transport as a first-class remote provider.
14895
+ *
14896
+ * The sidecar is distributed by this plugin itself: an explicit install
14897
+ * downloads a pinned release into the plugin private directory (an installed
14898
+ * companion copy is reused without downloading during the transition). The
14899
+ * controller owns exactly one sidecar child process plus one dedicated
14900
+ * loopback gateway, and status flows through the same coordinator, control
14901
+ * routes and task-event fan-out as every other remote provider. Enabling the
14902
+ * channel never downloads anything; only the install button may fetch bytes.
14903
+ * `ready` therefore means the sidecar is connected and the gateway serves
14904
+ * its loopback socket.
14905
+ */
14906
+ const READY_TIMEOUT_MS = 45e3;
14907
+ const IDENTITY_TIMEOUT_MS = 15e3;
14908
+ const IDENTITY_METADATA_MAX_BYTES = 8192;
14909
+ const PAIRING_TIMEOUT_MS = 15e3;
14910
+ const MAX_LINE_BYTES = 16384;
14911
+ /** Gateway pairing windows default to five minutes; the descriptor caps at ten. */
14912
+ const MAX_PAIRING_WINDOW_MS = 3e5;
14913
+ const MIN_PAIRING_WINDOW_MS = 1e4;
14914
+ function publicStatus$2(status) {
14915
+ return Object.freeze({ ...status });
14916
+ }
14917
+ /**
14918
+ * Prepare the sidecar's private identity/TLS state and resolve its endpoint.
14919
+ *
14920
+ * Failed termination is reported separately; the controller retains the child
14921
+ * until close so shutdown/reconnect can retry cleanup before starting a successor.
14922
+ * @param executable - verified sidecar path.
14923
+ * @param dataDir - private directory owning the endpoint identity.
14924
+ * @param options - child ownership callback and optional launcher/deadline overrides.
14925
+ * @returns the validated endpoint identity after the probe closes.
14926
+ */
14927
+ async function defaultResolveIdentity(executable, dataDir, options = {}) {
14928
+ const args = [
14929
+ "--lang",
14930
+ "en",
14931
+ "tls-prepare",
14932
+ "--data-dir",
14933
+ dataDir
14934
+ ];
14935
+ const child = options.launch === void 0 ? spawn(executable, args, {
14936
+ stdio: [
14937
+ "ignore",
14938
+ "pipe",
14939
+ "pipe"
14940
+ ],
14941
+ windowsHide: true
14942
+ }) : options.launch(executable, args);
14943
+ options.onSpawn?.(child);
14944
+ let output = Buffer.alloc(0);
14945
+ let settled = false;
14946
+ return await new Promise((resolve, reject) => {
14947
+ const fail = (code) => {
14948
+ if (settled) return;
14949
+ settled = true;
14950
+ clearTimeout(deadline);
14951
+ terminateRemoteProcess(child).then(() => {
14952
+ reject(new Error(code));
14953
+ }, () => {
14954
+ reject(new AggregateError([new Error(code), /* @__PURE__ */ new Error("iroh_probe_stop_failed")], "iroh_identity_cleanup_failed"));
14955
+ });
14956
+ };
14957
+ const deadline = setTimeout(() => {
14958
+ fail("iroh_identity_timeout");
14959
+ }, options.timeoutMs ?? IDENTITY_TIMEOUT_MS);
14960
+ deadline.unref?.();
14961
+ child.stdout.on("data", (chunk) => {
14962
+ if (settled) return;
14963
+ if (output.length + chunk.length > IDENTITY_METADATA_MAX_BYTES) {
14964
+ fail("iroh_identity_output_too_large");
14965
+ return;
14966
+ }
14967
+ output = Buffer.concat([output, chunk]);
14968
+ });
14969
+ child.stderr?.resume();
14970
+ child.once("error", () => {
14971
+ fail("iroh_identity_failed");
14972
+ });
14973
+ child.once("close", (code) => {
14974
+ clearTimeout(deadline);
14975
+ if (settled) return;
14976
+ settled = true;
14977
+ let metadata;
14978
+ try {
14979
+ metadata = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(output));
14980
+ } catch {
14981
+ reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
14982
+ return;
14983
+ }
14984
+ if (code !== 0 || typeof metadata !== "object" || metadata === null || !("version" in metadata) || metadata.version !== 1 || !("endpointId" in metadata) || typeof metadata.endpointId !== "string" || !/^[0-9a-f]{64}$/u.test(metadata.endpointId)) reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
14985
+ else resolve(metadata.endpointId);
14986
+ });
14987
+ });
14988
+ }
14989
+ var IrohController = class {
14990
+ options;
14991
+ enabled = false;
14992
+ initialized = false;
14993
+ disposed = false;
14994
+ generation = 0;
14995
+ queue = Promise.resolve();
14996
+ latest = publicStatus$2({
14997
+ enabled: false,
14998
+ state: "off"
14999
+ });
15000
+ gatewayValue;
15001
+ child;
15002
+ identityChild;
15003
+ closeTask;
15004
+ endpointId;
15005
+ constructor(options) {
15006
+ this.options = options;
15007
+ if (typeof options.dataDirectory !== "string" || options.dataDirectory === "") throw new Error("iroh data directory invalid");
15008
+ if (typeof options.createGateway !== "function") throw new Error("iroh gateway factory invalid");
15009
+ }
15010
+ async initialize(initialState) {
15011
+ await this.enqueue(async () => {
15012
+ const state = initialState ?? await this.options.store.load();
15013
+ if (this.disposed) return;
15014
+ this.enabled = state.enabled;
15015
+ this.initialized = true;
15016
+ if (this.enabled) await this.start();
15017
+ else this.publish({
15018
+ enabled: false,
15019
+ state: "off"
15020
+ });
15021
+ });
15022
+ }
15023
+ /** Return the active iroh-backed DSH gateway. */
15024
+ gateway() {
15025
+ return this.gatewayValue;
15026
+ }
15027
+ /** Return state safe for the desktop control UI. */
15028
+ status() {
15029
+ return this.latest;
15030
+ }
15031
+ async setEnabled(enabled) {
15032
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15033
+ await this.enqueue(async () => {
15034
+ if (this.enabled === enabled && (enabled ? this.latest.state === "ready" : this.child === void 0 && this.identityChild === void 0 && this.gatewayValue === void 0)) return;
15035
+ await this.options.store.save({
15036
+ version: 1,
15037
+ enabled
15038
+ });
15039
+ this.enabled = enabled;
15040
+ if (!enabled) await this.stop();
15041
+ if (enabled) await this.start();
15042
+ else this.publish({
15043
+ enabled: false,
15044
+ state: "off"
15045
+ });
15046
+ });
15047
+ return this.status();
15048
+ }
15049
+ /** Restart iroh while retaining its sidecar identity and devices. */
15050
+ async reconnect() {
15051
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15052
+ await this.enqueue(async () => {
15053
+ if (!this.enabled) {
15054
+ await this.options.store.save({
15055
+ version: 1,
15056
+ enabled: true
15057
+ });
15058
+ this.enabled = true;
15059
+ }
15060
+ await this.stop();
15061
+ await this.start();
15062
+ });
15063
+ return this.status();
15064
+ }
15065
+ /** Disable iroh without deleting its sidecar identity or companion files. */
15066
+ async reset() {
15067
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15068
+ await this.enqueue(async () => {
15069
+ await this.options.store.save({
15070
+ version: 1,
15071
+ enabled: false
15072
+ });
15073
+ this.enabled = false;
15074
+ await this.stop();
15075
+ this.publish({
15076
+ enabled: false,
15077
+ state: "off"
15078
+ });
15079
+ });
15080
+ return this.status();
15081
+ }
15082
+ /**
15083
+ * Mint one short-lived pairing descriptor for a QR code.
15084
+ *
15085
+ * Requires a live channel: the sidecar issues its six-digit code while the
15086
+ * gateway opens a one-time pairing window, and the descriptor binds both to
15087
+ * the pinned CA. Opening again replaces both windows without returning the
15088
+ * replaced secrets. The Phase 2 phone redeems the gateway token through the
15089
+ * ordinary pair endpoint over the iroh transport.
15090
+ */
15091
+ async beginPairing() {
15092
+ if (!this.initialized || this.disposed) throw new Error("iroh controller is unavailable");
15093
+ return this.enqueue(async () => {
15094
+ const generation = this.generation;
15095
+ const child = this.child;
15096
+ const gateway = this.gatewayValue;
15097
+ const endpointId = this.endpointId;
15098
+ if (!this.enabled || child === void 0 || gateway === void 0 || endpointId === void 0) throw new Error("iroh_not_ready");
15099
+ let buffered;
15100
+ do
15101
+ buffered = child.stdout.read();
15102
+ while (buffered !== null);
15103
+ try {
15104
+ child.stdin.write(encodeIrohCommand({
15105
+ version: 1,
15106
+ type: "pairing-begin"
15107
+ }));
15108
+ } catch {
15109
+ throw new Error("iroh_not_ready");
15110
+ }
15111
+ const pairing = await this.waitForPairingCode(child, this.options.pairingTimeoutMs ?? PAIRING_TIMEOUT_MS);
15112
+ if (generation !== this.generation || !this.enabled || this.child !== child || this.gatewayValue !== gateway) throw new Error("iroh_not_ready");
15113
+ if (pairing === void 0) throw new Error("iroh_pairing_failed");
15114
+ const ttlMs = Math.min(Math.max(pairing.expiresInSec * 1e3, MIN_PAIRING_WINDOW_MS), MAX_PAIRING_WINDOW_MS);
15115
+ const opened = await gateway.access.openPairing(ttlMs).catch((error) => {
15116
+ throw new Error("iroh_pairing_failed", { cause: error });
15117
+ });
15118
+ const expiresAt = Math.min(Date.now() + pairing.expiresInSec * 1e3, opened.expiresAt);
15119
+ if (expiresAt - Date.now() > 6e5) throw new Error("iroh_pairing_failed");
15120
+ const resolvePairingCa = this.options.resolvePairingCa;
15121
+ if (resolvePairingCa === void 0) throw new Error("iroh_pairing_failed");
15122
+ const ca = await resolvePairingCa().catch((error) => {
15123
+ throw new Error("iroh_pairing_failed", { cause: error });
15124
+ });
15125
+ const encoded = encodePairingDescriptor({
15126
+ version: 1,
15127
+ type: "dsh-mobile-iroh-pairing",
15128
+ endpointId,
15129
+ origin: irohOrigin(endpointId, DEFAULT_IROH_LOCAL_PORT),
15130
+ localPort: DEFAULT_IROH_LOCAL_PORT,
15131
+ irohCode: pairing.code,
15132
+ gatewayToken: opened.token,
15133
+ caCertificate: ca.der.toString("base64"),
15134
+ instanceId: ca.fingerprint,
15135
+ expiresAt
15136
+ });
15137
+ return {
15138
+ descriptor: JSON.parse(encoded),
15139
+ encoded,
15140
+ code: pairing.code,
15141
+ expiresAt
15142
+ };
15143
+ });
15144
+ }
15145
+ /** Stop all iroh resources without changing the remembered switch. */
15146
+ async close() {
15147
+ if (this.closeTask !== void 0) return this.closeTask;
15148
+ this.disposed = true;
15149
+ this.generation += 1;
15150
+ const close = this.queue.then(() => this.stop(), () => this.stop());
15151
+ this.closeTask = close;
15152
+ try {
15153
+ await close;
15154
+ } catch (error) {
15155
+ this.closeTask = void 0;
15156
+ throw error;
15157
+ }
15158
+ }
15159
+ enqueue(operation) {
15160
+ const run = () => this.disposed ? Promise.reject(/* @__PURE__ */ new Error("iroh controller is unavailable")) : operation();
15161
+ const task = this.queue.then(run, run);
15162
+ this.queue = task.then(() => void 0, () => void 0);
15163
+ return task;
15164
+ }
15165
+ publish(status) {
15166
+ this.latest = publicStatus$2(status);
15167
+ try {
15168
+ this.options.onStatus?.(this.status());
15169
+ } catch {}
15170
+ }
15171
+ async failGeneration(generation, errorCode, origin) {
15172
+ if (generation !== this.generation) return;
15173
+ this.generation += 1;
15174
+ this.publish({
15175
+ enabled: this.enabled,
15176
+ state: "error",
15177
+ ...origin === void 0 ? {} : { origin },
15178
+ errorCode
15179
+ });
15180
+ await this.stopResources();
15181
+ }
15182
+ async stop() {
15183
+ this.generation += 1;
15184
+ await this.stopResources();
15185
+ }
15186
+ async stopResources() {
15187
+ const child = this.child;
15188
+ const identityChild = this.identityChild;
15189
+ const gateway = this.gatewayValue;
15190
+ this.endpointId = void 0;
15191
+ await settleRemoteResources([
15192
+ async () => {
15193
+ if (child === void 0) return;
15194
+ try {
15195
+ child.stdin.write(encodeIrohCommand({
15196
+ version: 1,
15197
+ type: "shutdown"
15198
+ }));
15199
+ } catch {}
15200
+ await terminateRemoteProcess(child);
15201
+ if (this.child === child) this.child = void 0;
15202
+ },
15203
+ async () => {
15204
+ if (identityChild === void 0) return;
15205
+ await terminateRemoteProcess(identityChild);
15206
+ if (this.identityChild === identityChild) this.identityChild = void 0;
15207
+ },
15208
+ async () => {
15209
+ await gateway?.close();
15210
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
15211
+ }
15212
+ ], "iroh resource cleanup failed");
15213
+ }
15214
+ async start() {
15215
+ await this.stopResources();
15216
+ if (this.disposed || !this.enabled) return;
15217
+ const generation = ++this.generation;
15218
+ let executable;
15219
+ try {
15220
+ const available = await this.options.component.ensureAvailable();
15221
+ executable = this.options.component.executable ?? available.storagePath;
15222
+ } catch {
15223
+ this.publish({
15224
+ enabled: true,
15225
+ state: "unavailable",
15226
+ errorCode: "iroh_companion_missing"
15227
+ });
15228
+ return;
15229
+ }
15230
+ if (generation !== this.generation || !this.enabled) return;
15231
+ if (executable === void 0) {
15232
+ this.publish({
15233
+ enabled: true,
15234
+ state: "unavailable",
15235
+ errorCode: "iroh_companion_missing"
15236
+ });
15237
+ return;
15238
+ }
15239
+ await mkdir(this.options.dataDirectory, {
15240
+ recursive: true,
15241
+ mode: 448
15242
+ }).catch(() => {
15243
+ throw new Error("iroh_data_directory_invalid");
15244
+ });
15245
+ await restrictPrivateDirectory(this.options.dataDirectory).catch(() => {
15246
+ throw new Error("iroh_data_directory_invalid");
15247
+ });
15248
+ if (generation !== this.generation || !this.enabled) return;
15249
+ let endpointId;
15250
+ try {
15251
+ endpointId = this.options.resolveIdentity === void 0 ? await defaultResolveIdentity(executable, this.options.dataDirectory, { onSpawn: (child) => {
15252
+ this.identityChild = child;
15253
+ child.once("close", () => {
15254
+ if (this.identityChild === child) this.identityChild = void 0;
15255
+ });
15256
+ } }) : await this.options.resolveIdentity(executable, this.options.dataDirectory);
15257
+ } catch (error) {
15258
+ await this.failGeneration(generation, error instanceof AggregateError ? "iroh_identity_cleanup_failed" : "iroh_identity_failed");
15259
+ return;
15260
+ }
15261
+ if (generation !== this.generation || !this.enabled) return;
15262
+ const origin = irohOrigin(endpointId);
15263
+ this.publish({
15264
+ enabled: true,
15265
+ state: "starting",
15266
+ origin
15267
+ });
15268
+ let gateway;
15269
+ try {
15270
+ gateway = await this.options.createGateway(endpointId);
15271
+ } catch {
15272
+ await this.failGeneration(generation, "gateway_start_failed", origin);
15273
+ return;
15274
+ }
15275
+ if (generation !== this.generation || !this.enabled) {
15276
+ await gateway.close();
15277
+ return;
15278
+ }
15279
+ this.gatewayValue = gateway;
15280
+ try {
15281
+ validateIrohProxyTarget(`http://127.0.0.1:${gateway.address().port}/`);
15282
+ } catch {
15283
+ await this.failGeneration(generation, "gateway_start_failed", origin);
15284
+ return;
15285
+ }
15286
+ if (generation !== this.generation || !this.enabled) return;
15287
+ let child;
15288
+ try {
15289
+ child = (this.options.launchSidecar ?? ((file, args) => spawn(file, [...args], {
15290
+ stdio: [
15291
+ "pipe",
15292
+ "pipe",
15293
+ "pipe"
15294
+ ],
15295
+ windowsHide: true
15296
+ })))(executable, [
15297
+ "--lang",
15298
+ "en",
15299
+ "host",
15300
+ "--data-dir",
15301
+ this.options.dataDirectory,
15302
+ "--proxy-target",
15303
+ `127.0.0.1:${gateway.address().port}`
15304
+ ]);
15305
+ } catch {
15306
+ await this.failGeneration(generation, "iroh_launch_failed", origin);
15307
+ return;
15308
+ }
15309
+ this.child = child;
15310
+ const onFailure = () => {
15311
+ if (this.disposed || generation !== this.generation) return;
15312
+ this.enqueue(() => this.failGeneration(generation, "iroh_exited", origin)).catch(() => {
15313
+ if (!this.disposed && this.generation === generation + 1) this.publish({
15314
+ enabled: this.enabled,
15315
+ state: "error",
15316
+ origin,
15317
+ errorCode: "iroh_cleanup_failed"
15318
+ });
15319
+ });
15320
+ };
15321
+ child.once("error", onFailure);
15322
+ child.once("close", onFailure);
15323
+ child.stdin.on("error", onFailure);
15324
+ this.publish({
15325
+ enabled: true,
15326
+ state: "connecting",
15327
+ origin
15328
+ });
15329
+ try {
15330
+ const ready = await this.waitForReady(child, endpointId, this.options.readyTimeoutMs ?? READY_TIMEOUT_MS);
15331
+ if (generation !== this.generation || !this.enabled) return;
15332
+ if (!ready) {
15333
+ await this.failGeneration(generation, "iroh_start_timeout", origin);
15334
+ return;
15335
+ }
15336
+ this.endpointId = endpointId;
15337
+ this.publish({
15338
+ enabled: true,
15339
+ state: "ready",
15340
+ origin
15341
+ });
15342
+ } catch {
15343
+ await this.failGeneration(generation, "iroh_start_failed", origin);
15344
+ }
15345
+ }
15346
+ waitForReady(child, endpointId, timeoutMs) {
15347
+ return new Promise((resolve) => {
15348
+ let pending = Buffer.alloc(0);
15349
+ let settled = false;
15350
+ const finish = (ready) => {
15351
+ if (settled) return;
15352
+ settled = true;
15353
+ clearTimeout(deadline);
15354
+ child.stdout.off("data", onData);
15355
+ resolve(ready);
15356
+ };
15357
+ const deadline = setTimeout(() => finish(false), timeoutMs);
15358
+ deadline.unref?.();
15359
+ const onData = (chunk) => {
15360
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15361
+ if (pending.length + buffer.length > 64 * MAX_LINE_BYTES) {
15362
+ finish(false);
15363
+ return;
15364
+ }
15365
+ pending = Buffer.concat([pending, buffer]);
15366
+ let index = pending.indexOf(10);
15367
+ while (index !== -1) {
15368
+ const line = pending.subarray(0, index).toString("utf8");
15369
+ pending = pending.subarray(index + 1);
15370
+ let event;
15371
+ try {
15372
+ event = decodeIrohEvent(line);
15373
+ } catch {
15374
+ finish(false);
15375
+ return;
15376
+ }
15377
+ if (event.type === "ready") {
15378
+ finish(event.endpoint_id === endpointId);
15379
+ return;
15380
+ }
15381
+ if (event.type === "error") {
15382
+ finish(false);
15383
+ return;
15384
+ }
15385
+ index = pending.indexOf(10);
15386
+ }
15387
+ };
15388
+ child.stdout.on("data", onData);
15389
+ child.once("error", () => finish(false));
15390
+ child.once("close", () => finish(false));
15391
+ });
15392
+ }
15393
+ waitForPairingCode(child, timeoutMs) {
15394
+ return new Promise((resolve) => {
15395
+ let pending = Buffer.alloc(0);
15396
+ let settled = false;
15397
+ const finish = (pairing) => {
15398
+ if (settled) return;
15399
+ settled = true;
15400
+ clearTimeout(deadline);
15401
+ child.stdout.off("data", onData);
15402
+ resolve(pairing);
15403
+ };
15404
+ const deadline = setTimeout(() => finish(void 0), timeoutMs);
15405
+ deadline.unref?.();
15406
+ const onData = (chunk) => {
15407
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15408
+ if (pending.length + buffer.length > 64 * MAX_LINE_BYTES) {
15409
+ finish(void 0);
15410
+ return;
15411
+ }
15412
+ pending = Buffer.concat([pending, buffer]);
15413
+ let index = pending.indexOf(10);
15414
+ while (index !== -1) {
15415
+ const line = pending.subarray(0, index).toString("utf8");
15416
+ pending = pending.subarray(index + 1);
15417
+ let event;
15418
+ try {
15419
+ event = decodeIrohEvent(line);
15420
+ } catch {
15421
+ finish(void 0);
15422
+ return;
15423
+ }
15424
+ if (event.type === "pairing") {
15425
+ finish({
15426
+ code: event.code,
15427
+ expiresInSec: event.expires_in_sec
15428
+ });
15429
+ return;
15430
+ }
15431
+ if (event.type === "error") {
15432
+ finish(void 0);
15433
+ return;
15434
+ }
15435
+ index = pending.indexOf(10);
15436
+ }
15437
+ };
15438
+ child.stdout.on("data", onData);
15439
+ child.once("error", () => finish(void 0));
15440
+ child.once("close", () => finish(void 0));
15441
+ });
15442
+ }
15443
+ };
15444
+ //#endregion
15445
+ //#region src/iroh-tls.ts
15446
+ /** Self-signed TLS identity for the experimental iroh gateway.
15447
+ *
15448
+ * Reuses this repository's managed CA path (`managed-setup.ts`) instead of
15449
+ * the companion's native TLS manager: the CA persists under the iroh state
15450
+ * directory, one leaf carries the identity-derived DNS name, and corrupt or
15451
+ * foreign material fails closed rather than regenerating silently. The
15452
+ * gateway terminates TLS with this leaf (see `irohGatewayConfig`), so the
15453
+ * sidecar only proxies opaque bytes and the app pins the CA from
15454
+ * `GET /mobile-access/ca.cer` in Phase 2.
15455
+ */
15456
+ /** Leaf lifetime and renewal headroom, matching the companion's native policy. */
15457
+ const IROH_LEAF_VALIDITY_DAYS = 90;
15458
+ const IROH_LEAF_RENEW_BEFORE_DAYS = 30;
15459
+ /** Private TLS directory, always a sibling of the remote device file. */
15460
+ function irohTlsPaths(stateFile, ownedRoot) {
15461
+ const directory = join(ownedRoot ?? dirname(stateFile), "iroh-tls");
15462
+ return Object.freeze({
15463
+ directory,
15464
+ caCertFile: join(directory, "ca.pem"),
15465
+ caKeyFile: join(directory, "ca-key.pem"),
15466
+ certFile: join(directory, "server.pem"),
15467
+ keyFile: join(directory, "server-key.pem"),
15468
+ statusFile: join(dirname(stateFile), "iroh-identity.json")
15469
+ });
15470
+ }
15471
+ async function regularFileExists(file) {
15472
+ try {
15473
+ const entry = await lstat(file);
15474
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("iroh_tls_file_invalid");
15475
+ return true;
15476
+ } catch (error) {
15477
+ if (error.code !== "ENOENT") throw error;
15478
+ return false;
15479
+ }
15480
+ }
15481
+ async function readIrohIdentity(file) {
15482
+ if (!await regularFileExists(file)) return void 0;
15483
+ try {
15484
+ const parsed = JSON.parse(await readFile(file, "utf8"));
15485
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
15486
+ const record = parsed;
15487
+ if (record.version !== 1 || typeof record.endpointId !== "string" || !/^[a-f0-9]{64}$/u.test(record.endpointId) || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
15488
+ return Object.freeze({
15489
+ endpointId: record.endpointId,
15490
+ caFingerprint: record.caFingerprint
15491
+ });
15492
+ } catch (error) {
15493
+ throw new Error("iroh_tls_ca_invalid", { cause: error });
15494
+ }
15495
+ }
15496
+ async function writeIrohIdentity(file, identity, ownedRoot) {
15497
+ await writeManagedPrivateFile(ownedRoot, file, `${JSON.stringify({
15498
+ version: 1,
15499
+ endpointId: identity.endpointId,
15500
+ caFingerprint: identity.caFingerprint
15501
+ })}\n`, "iroh_tls");
15502
+ }
15503
+ function fingerprintOf(certificate) {
15504
+ return certificate.fingerprint256.replaceAll(":", "").toLowerCase();
15505
+ }
15506
+ function leafCoversEndpoint(leaf, endpointId, now) {
15507
+ const hostname = irohHostname(endpointId);
15508
+ if (!(leaf.subjectAltName ?? "").split(",").map((entry) => entry.trim()).includes(`DNS:${hostname}`)) return false;
15509
+ const notAfter = Date.parse(leaf.validTo);
15510
+ if (!Number.isFinite(notAfter)) return false;
15511
+ return notAfter - now > 2592e6 && Date.parse(leaf.validFrom) <= now;
15512
+ }
15513
+ /**
15514
+ * Materialize the CA and endpoint leaf the iroh gateway terminates TLS with.
15515
+ *
15516
+ * A recorded CA fingerprint that no longer matches fails closed: deleting or
15517
+ * replacing the CA is an explicit operator act, never an automatic recovery.
15518
+ * The endpoint may change under a stable CA; only the leaf is re-issued then.
15519
+ */
15520
+ async function ensureIrohTls(endpointId, stateFile, ownedRoot, now = Date.now()) {
15521
+ if (!/^[a-f0-9]{64}$/u.test(endpointId)) throw new Error("iroh endpoint id must be a lowercase 32-byte public key");
15522
+ await assertManagedParents(ownedRoot, stateFile, "iroh_tls");
15523
+ const paths = irohTlsPaths(stateFile, ownedRoot);
15524
+ const identity = await readIrohIdentity(paths.statusFile);
15525
+ const ca = await ensureManagedCa({
15526
+ mode: "managed",
15527
+ caCertFile: paths.caCertFile,
15528
+ caKeyFile: paths.caKeyFile,
15529
+ certFile: paths.certFile,
15530
+ keyFile: paths.keyFile
15531
+ }).catch((error) => {
15532
+ throw new Error("iroh_tls_ca_invalid", { cause: error });
15533
+ });
15534
+ const caFingerprint = fingerprintOf(ca);
15535
+ if (identity !== void 0 && identity.caFingerprint !== caFingerprint) throw new Error("iroh_tls_ca_invalid");
15536
+ let leaf;
15537
+ if (await regularFileExists(paths.certFile) && await regularFileExists(paths.keyFile)) try {
15538
+ const [certPem, keyPem] = await Promise.all([readFile(paths.certFile, "utf8"), readFile(paths.keyFile, "utf8")]);
15539
+ const candidate = new X509Certificate(certPem);
15540
+ const key = createPrivateKey(keyPem);
15541
+ if (!candidate.ca && candidate.verify(ca.publicKey) && candidate.checkPrivateKey(key) && leafCoversEndpoint(candidate, endpointId, now)) leaf = candidate;
15542
+ } catch {
15543
+ throw new Error("iroh_tls_ca_invalid");
15544
+ }
15545
+ if (leaf === void 0) {
15546
+ await issueServerCertificate({
15547
+ caCertFile: paths.caCertFile,
15548
+ caKeyFile: paths.caKeyFile
15549
+ }, {
15550
+ commonName: "DeepSeek Harness Mobile iroh",
15551
+ dnsNames: [irohHostname(endpointId)]
15552
+ }, {
15553
+ certFile: paths.certFile,
15554
+ keyFile: paths.keyFile
15555
+ }, 90).catch((error) => {
15556
+ throw new Error("iroh_tls_leaf_invalid", { cause: error });
15557
+ });
15558
+ try {
15559
+ const [certPem, keyPem] = await Promise.all([readFile(paths.certFile, "utf8"), readFile(paths.keyFile, "utf8")]);
15560
+ const candidate = new X509Certificate(certPem);
15561
+ const key = createPrivateKey(keyPem);
15562
+ if (candidate.ca || !candidate.verify(ca.publicKey) || !candidate.checkPrivateKey(key) || !leafCoversEndpoint(candidate, endpointId, Date.now())) throw new Error("iroh_tls_leaf_invalid");
15563
+ leaf = candidate;
15564
+ } catch (error) {
15565
+ if (error instanceof Error && (error.message === "iroh_tls_leaf_invalid" || error.message === "iroh_tls_ca_invalid")) throw error;
15566
+ throw new Error("iroh_tls_leaf_invalid", { cause: error });
15567
+ }
15568
+ }
15569
+ await writeIrohIdentity(paths.statusFile, {
15570
+ endpointId,
15571
+ caFingerprint
15572
+ }, ownedRoot);
15573
+ return Object.freeze({
15574
+ paths,
15575
+ ca,
15576
+ leaf,
15577
+ caFingerprint,
15578
+ endpointId
15579
+ });
15580
+ }
15581
+ /**
15582
+ * Gateway config for the iroh endpoint-pinned entry.
15583
+ *
15584
+ * Like the self-signed FRP ingress, the gateway itself terminates TLS: the
15585
+ * sidecar only forwards raw TCP, so the listener speaks HTTPS with the leaf
15586
+ * the iroh CA signed, and the pairing CA is retained so the app can pin it
15587
+ * from `GET /mobile-access/ca.cer`. The listener stays on loopback because
15588
+ * the public path is the iroh transport, never a direct bind.
15589
+ */
15590
+ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0) {
15591
+ const { pairingCaFile: _templateCaFile, ...shared } = template;
15592
+ const authority = new URL(irohOrigin(endpointId, DEFAULT_IROH_LOCAL_PORT)).host;
15593
+ return Object.freeze({
15594
+ ...shared,
15595
+ listenHost: "127.0.0.1",
15596
+ listenPort,
15597
+ authorities: Object.freeze([parseAuthority(authority)]),
15598
+ allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
15599
+ stateFile,
15600
+ instanceId: tls.caFingerprint,
15601
+ pairingCaFile: tls.paths.caCertFile,
15602
+ tls: Object.freeze({
15603
+ mode: "provided",
15604
+ certFile: tls.paths.certFile,
15605
+ keyFile: tls.paths.keyFile
15606
+ }),
15607
+ publicTls: true,
15608
+ discovery: false,
15609
+ pairingTtlMs: 3e5
15610
+ });
15611
+ }
15612
+ //#endregion
15613
+ //#region src/task-events.ts
15614
+ const TASK_EVENT_DEBOUNCE_MS = 1e3;
15615
+ function turnNumber(value) {
15616
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
15617
+ }
15618
+ function turnData(value) {
15619
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {
15620
+ turn: 0,
15621
+ completed: false
15622
+ };
15623
+ const record = value;
15624
+ const reason = record.reason;
15625
+ const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
15626
+ return {
15627
+ turn: turnNumber(record.turn),
15628
+ completed
15629
+ };
15630
+ }
15631
+ /**
15632
+ * Subscribe to completed root turns. Subagent turns are skipped so one task
15633
+ * announces once, and rapid turn boundaries collapse into a single event.
15634
+ * Returns a disposer that also drops pending debounces.
15635
+ */
15636
+ function watchTaskCompletions(ctx, options) {
15637
+ const debounceMs = options.debounceMs ?? 1e3;
15638
+ const pending = /* @__PURE__ */ new Map();
15639
+ const disposeListener = ctx.on("session/event", (session, event) => {
15640
+ if (event.type !== "turn/end") return;
15641
+ const { turn, completed } = turnData(event.data);
15642
+ if (!completed) return;
15643
+ if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
15644
+ const sessionId = String(session.id);
15645
+ options.log?.("task-completed-observed", {
15646
+ sessionId,
15647
+ turn
15648
+ });
15649
+ const previous = pending.get(sessionId);
15650
+ if (previous !== void 0) clearTimeout(previous);
15651
+ pending.set(sessionId, setTimeout(() => {
15652
+ pending.delete(sessionId);
15653
+ options.log?.("task-completed-announced", {
15654
+ sessionId,
15655
+ turn
15656
+ });
15657
+ options.onTaskCompleted(Object.freeze({
15658
+ sessionId,
15659
+ turn
15660
+ }));
15661
+ }, debounceMs));
15662
+ });
15663
+ return () => {
15664
+ disposeListener();
15665
+ for (const timer of pending.values()) clearTimeout(timer);
15666
+ pending.clear();
15667
+ };
15668
+ }
15669
+ /** One subscription feeding every live gateway; gateways register on start. */
15670
+ var TaskEventHub = class {
15671
+ sinks = /* @__PURE__ */ new Set();
15672
+ /** Register a sink; returns its disposer. */
15673
+ add(sink) {
15674
+ this.sinks.add(sink);
15675
+ return () => {
15676
+ this.sinks.delete(sink);
15677
+ };
15678
+ }
15679
+ /** Fan out to a snapshot so a failing sink cannot break its siblings. */
15680
+ broadcast(event) {
15681
+ for (const sink of [...this.sinks]) try {
15682
+ sink.broadcastTaskEvent(event);
15683
+ } catch {}
15684
+ }
15685
+ /** Visible for tests. */
15686
+ get size() {
15687
+ return this.sinks.size;
15688
+ }
15689
+ };
15690
+ //#endregion
15691
+ //#region src/diagnostics.ts
15692
+ /** Recognize the active remote-web-ui boot hook without evaluating or modifying it. */
15693
+ function hasCompetingRemoteChannelBoot(rows) {
15694
+ return rows.some((row) => row.kind === "script" && row.text?.includes("__DSH_REMOTE_CHANNEL_BOOT__") === true);
15695
+ }
15696
+ const REMOTE_ERROR_GUIDANCE = Object.freeze({
15697
+ component_missing: "重新安装完整插件包。",
15698
+ funnel_permission_required: "继续完成 Tailscale Funnel 授权。",
15699
+ funnel_https_required: "继续完成 Tailscale HTTPS 授权。",
15700
+ funnel_start_failed: "重新打开授权页并允许 Funnel。",
15701
+ funnel_start_timeout: "检查网络后点击“重新连接”。",
15702
+ tailscale_dns_missing: "确认 Tailscale 登录仍有效后重新连接。",
15703
+ sidecar_launch_failed: "重新安装完整插件包后重试。",
15704
+ sidecar_stopped: "点击“重新连接”。",
15705
+ sidecar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15706
+ control_channel_failed: "点击“重新连接”。",
15707
+ cpolar_component_missing: "先安装 cpolar 官方组件。",
15708
+ cpolar_component_invalid: "彻底移除 cpolar 组件后重新安装。",
15709
+ cpolar_config_missing: "保存 cpolar Authtoken 后重试。",
15710
+ cpolar_config_invalid: "重新保存 cpolar Authtoken。",
15711
+ cpolar_start_timeout: "检查网络后点击“重新连接”。",
15712
+ cpolar_stopped: "点击“重新连接”。",
15713
+ cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15714
+ cloudflared_component_missing: "先安装 cloudflared 官方组件。",
15715
+ cloudflared_component_invalid: "彻底移除 cloudflared 组件后重新安装。",
15716
+ cloudflared_port_unavailable: "无法分配本机远程网关端口,请重试。",
15717
+ cloudflared_launch_failed: "重新安装 cloudflared 官方组件后重试。",
15718
+ cloudflared_start_timeout: "检查网络后点击“重新连接”。",
15719
+ cloudflared_stopped: "点击“重新连接”。",
15720
+ cloudflared_exited: "点击“重新连接”;仍失败时复制诊断报告。",
15721
+ cloudflared_invalid_output: "cloudflared 返回了无法识别的状态。",
15722
+ cloudflared_invalid_origin: "cloudflared 返回的公网地址未通过校验。",
15723
+ origin_config_missing: "先保存自有反向代理配置。",
15724
+ origin_config_invalid: "重新保存自有反向代理配置。",
15725
+ origin_listen_port_in_use: "更换 HTTP 后端端口;不要使用局域网的 3443、DSH 自身的 3080,或已被 cloudflared 隧道占用的 3444。",
15726
+ origin_listen_address_unavailable: "监听地址不属于当前电脑,请重新选择本机的私网 IPv4 地址。",
15727
+ origin_gateway_start_failed: "检查 HTTP 后端监听地址和端口后重新连接。",
15728
+ frp_component_missing: "先安装 FRP 官方组件。",
15729
+ frp_component_invalid: "彻底清理 FRP 组件后重新安装。",
15730
+ frp_config_missing: "先保存自建 FRP 连接配置。",
15731
+ frp_config_verify_failed: "检查服务器地址、端口、Token 和公开域名。",
15732
+ frp_vhost_publicly_reachable: "将 frps 的 HTTP vhost 监听限制到 127.0.0.1。",
15733
+ frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
15734
+ frp_launch_failed: "重新安装 FRP 官方组件后重试。",
15735
+ frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
15736
+ frp_proxy_name_in_use: "该 frps 上已有同名代理:在另一台 DSH 电脑上关闭自建 FRP,或在 frps 上清掉该代理后重新连接。",
15737
+ frp_remote_port_in_use: "公网入口端口被其他代理占用:改用其他入口端口后重新连接。",
15738
+ frp_proxy_start_failed: "查看 frps 日志与 allowPorts,确认它能接受该代理名和入口端口。",
15739
+ frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
15740
+ frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
15741
+ frp_stopped: "点击“重新连接”。",
15742
+ frp_exited: "检查 VPS 配置后重新连接;仍失败时复制诊断报告。",
15743
+ gateway_start_failed: "确认 DSH 正在运行后重新连接。"
15744
+ });
15745
+ function check(id, status, reason, label, detail, action, facts) {
15746
+ return Object.freeze({
15747
+ id,
15748
+ status,
15749
+ reason,
15750
+ ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
15751
+ label,
15752
+ detail,
15753
+ ...action === void 0 ? {} : { action }
15754
+ });
15755
+ }
15756
+ function maskLanOrigin(origin) {
15757
+ if (origin === void 0) return "未分配";
15758
+ try {
15759
+ const url = new URL(origin);
15760
+ const octets = url.hostname.split(".");
15761
+ const host = octets.length === 4 ? `${octets[0]}.${octets[1]}.${octets[2]}.x` : "局域网地址";
15762
+ return `${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`;
15763
+ } catch {
15764
+ return "地址格式无效";
15765
+ }
15766
+ }
15767
+ function remoteSuffix(origin) {
15768
+ if (origin === void 0) return "未分配";
15769
+ try {
15770
+ const hostname = new URL(origin).hostname;
15771
+ if (hostname.endsWith(".ts.net")) return "*.ts.net";
15772
+ if (hostname.endsWith(".trycloudflare.com")) return "*.trycloudflare.com";
15773
+ for (const suffix of [
15774
+ ".cpolar.cn",
15775
+ ".cpolar.io",
15776
+ ".cpolar.top",
15777
+ ".cpolar.com"
15778
+ ]) if (hostname.endsWith(suffix)) return `*${suffix}`;
15779
+ return "公共 HTTPS 地址";
15780
+ } catch {
15781
+ return "地址格式无效";
15782
+ }
15783
+ }
15784
+ function defaultFirewallProbe(platform = process.platform) {
15785
+ return async (port) => {
15786
+ if (platform !== "win32") return { state: "not-applicable" };
15787
+ if (port === void 0) return { state: "unknown" };
15788
+ const script = [
15789
+ "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
15790
+ "$ready = $true",
15791
+ "$specs | ForEach-Object {",
15792
+ " $spec = $_",
14265
15793
  " $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",
14266
15794
  " if ($null -eq $rule) { $ready = $false; return }",
14267
15795
  " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
@@ -14559,7 +16087,7 @@ function reportLine(entry) {
14559
16087
  /** Run bounded read-only checks and return a report safe to paste into an issue. */
14560
16088
  async function collectConnectionDiagnostics(snapshot, probes = {}) {
14561
16089
  const checks = [];
14562
- 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" });
16090
+ const remoteProbe = snapshot.remote.provider !== "origin" && snapshot.remote.provider !== "iroh" && snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
14563
16091
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
14564
16092
  checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
14565
16093
  if (snapshot.competingRemoteChannelBoot === true) checks.push(check("remote-plugin-conflict", "warning", "competing-remote-channel", "第三方远程插件", "检测到另一套远程请求通道脚本;手机页面可能进入该插件自己的配对流程。", "在电脑端关闭 dsh-remote-web-ui 的远程访问后刷新,再使用 DSH Mobile 当前二维码配对;不要混用两种配对码。"));
@@ -14578,6 +16106,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
14578
16106
  else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
14579
16107
  if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
14580
16108
  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" }));
16109
+ else if (snapshot.remote.provider === "iroh" && snapshot.remote.state === "ready") checks.push(check("remote", "info", "remote-iroh-ready", "远程通道", "iroh 通道已就绪;端点身份为回环 TLS,不从电脑端探测手机可达性。", "在手机上用配对二维码验证连接;二维码过期或通道报错时重新生成并重连。", { provider: "iroh" }));
14581
16110
  else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
14582
16111
  const endpointSuffix = remoteSuffix(snapshot.remote.origin);
14583
16112
  const facts = {
@@ -14731,7 +16260,7 @@ function shellQuote(value) {
14731
16260
  * shared frpc settings stay permissive so local loopback test rigs keep working.
14732
16261
  */
14733
16262
  function assertPublicSshTarget(serverAddress) {
14734
- if (isIP(serverAddress) === 4 && !isGloballyRoutableIpv4(serverAddress)) throw new Error("vps_server_not_public");
16263
+ if (isIP$1(serverAddress) === 4 && !isGloballyRoutableIpv4(serverAddress)) throw new Error("vps_server_not_public");
14735
16264
  }
14736
16265
  /**
14737
16266
  * Validate a VPS address for every operation that opens a network connection
@@ -14740,7 +16269,7 @@ function assertPublicSshTarget(serverAddress) {
14740
16269
  */
14741
16270
  function validateVpsServerTarget(serverAddress) {
14742
16271
  const address = validateFrpServerAddress(serverAddress);
14743
- if (isIP(address) !== 0 && address.includes(":")) throw new Error("vps_ipv6_ssh_not_supported");
16272
+ if (isIP$1(address) !== 0 && address.includes(":")) throw new Error("vps_ipv6_ssh_not_supported");
14744
16273
  assertPublicSshTarget(address);
14745
16274
  return address;
14746
16275
  }
@@ -16958,7 +18487,7 @@ function parseCaddySettings(value) {
16958
18487
  3080
16959
18488
  ].includes(Number(listenPort))) throw new Error("caddy_listen_port_reserved");
16960
18489
  const publicOrigin = validateOriginPublicOrigin(record.publicOrigin);
16961
- if (isIP(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
18490
+ if (isIP$1(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
16962
18491
  return Object.freeze({
16963
18492
  version: 1,
16964
18493
  publicOrigin,
@@ -18395,6 +19924,7 @@ function mapAdminError(error) {
18395
19924
  "frp_settings_invalid"
18396
19925
  ].includes(error.message)) return new HttpError(400, error.message);
18397
19926
  if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
19927
+ if (error instanceof Error && error.message.startsWith("iroh_")) return new HttpError(409, error.message);
18398
19928
  if (error instanceof Error && [
18399
19929
  "origin_settings_invalid",
18400
19930
  "origin_public_origin_invalid",
@@ -18578,7 +20108,7 @@ function originGatewayConfig(template, settings, stateFile, instanceId, listenPo
18578
20108
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
18579
20109
  });
18580
20110
  }
18581
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus) {
20111
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus, irohComponent) {
18582
20112
  return {
18583
20113
  provider,
18584
20114
  running: status.enabled,
@@ -18621,10 +20151,34 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
18621
20151
  state: providerStatuses.origin.state,
18622
20152
  configuration: originConfiguration,
18623
20153
  component: caddyComponentStatus
20154
+ },
20155
+ iroh: {
20156
+ bundled: false,
20157
+ running: providerStatuses.iroh.enabled,
20158
+ state: providerStatuses.iroh.state,
20159
+ component: irohComponent
18624
20160
  }
18625
20161
  }
18626
20162
  };
18627
20163
  }
20164
+ /**
20165
+ * Candidate directories for an installed `dsh-mobile-iroh` companion package.
20166
+ *
20167
+ * A verified companion copy is reused without downloading during the
20168
+ * transition away from the experimental companion repository. An explicit
20169
+ * absolute override wins for tests and unusual layouts, otherwise the
20170
+ * sibling of this package covers the hoisted npm layout while realpath
20171
+ * (applied at verification) follows pnpm links.
20172
+ */
20173
+ function locateIrohCompanionRoots(importMetaUrl, environment = process.env) {
20174
+ const override = environment.DSH_MOBILE_IROH_COMPANION?.trim();
20175
+ if (override !== void 0 && override !== "") {
20176
+ if (!isAbsolute(override)) throw new Error("DSH_MOBILE_IROH_COMPANION must be an absolute path");
20177
+ return Object.freeze([resolve(override)]);
20178
+ }
20179
+ const ownPackageRoot = dirname(dirname(fileURLToPath(importMetaUrl)));
20180
+ return Object.freeze([join(dirname(ownPackageRoot), "dsh-mobile-iroh")]);
20181
+ }
18628
20182
  /** Mount the resident control route and its optional authenticated LAN gateway. */
18629
20183
  async function apply(ctx, config) {
18630
20184
  const dshVersion = installedDshVersion();
@@ -18632,11 +20186,18 @@ async function apply(ctx, config) {
18632
20186
  const mobileAccess = createMobileAccessService(ctx);
18633
20187
  const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
18634
20188
  const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
20189
+ const adminLogin = upstreamLoginUrl === void 0 ? void 0 : new URL(upstreamLoginUrl);
20190
+ const supportsRemoteAdminAuthentication = adminLogin !== void 0 && adminLogin.origin === baseTemplate.upstreamOrigin.origin && adminLogin.pathname === "/" && adminLogin.username === "" && adminLogin.password === "" && adminLogin.hash === "" && (adminLogin.searchParams.get("token")?.length ?? 0) > 0;
18635
20191
  const instanceId = await stableInstanceId(loaded, baseTemplate);
18636
20192
  const stateDirectory = dirname(baseTemplate.stateFile);
18637
20193
  const logFile = await installMobileFileLogger(ctx, stateDirectory);
18638
20194
  const logger = ctx.logger("dsh-mobile");
18639
20195
  logger.info("logging initialized file=%s", logFile);
20196
+ if (loaded.kind === "managed" && typeof ctx.webServer.port === "number") try {
20197
+ if (await refreshManagedUpstreamOrigin(loaded.setupFile, loaded.setup, ctx.webServer.port)) logger.info("dsh-mobile: refreshed the managed setup upstream snapshot to the active DSH port");
20198
+ } catch (error) {
20199
+ logger.warn("dsh-mobile: could not refresh the managed setup upstream snapshot: %s", error instanceof Error ? error.message : String(error));
20200
+ }
18640
20201
  const taskEventHub = new TaskEventHub();
18641
20202
  const disposeTaskEvents = watchTaskCompletions(ctx, {
18642
20203
  onTaskCompleted: (event) => taskEventHub.broadcast(event),
@@ -18655,6 +20216,11 @@ async function apply(ctx, config) {
18655
20216
  await cloudflaredComponent.initialize();
18656
20217
  const frpComponent = new FrpComponentManager({ stateDirectory });
18657
20218
  await frpComponent.initialize();
20219
+ const irohComponent = new IrohComponentManager({
20220
+ stateDirectory,
20221
+ locateCompanionRoots: () => locateIrohCompanionRoots(import.meta.url)
20222
+ });
20223
+ await irohComponent.initialize();
18658
20224
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
18659
20225
  const template = Object.freeze({
18660
20226
  ...baseTemplate,
@@ -18795,6 +20361,34 @@ async function apply(ctx, config) {
18795
20361
  return candidate;
18796
20362
  };
18797
20363
  /**
20364
+ * Gateway factory for the experimental iroh provider.
20365
+ *
20366
+ * The endpoint identity resolves inside the controller start, so TLS is
20367
+ * ensured here per endpoint: a stable CA with a leaf for the exact
20368
+ * identity-derived DNS name. The gateway terminates TLS itself (provided
20369
+ * mode) and retains the pairing CA for the app to pin, exactly like the
20370
+ * self-signed FRP ingress. Device pairings share the remote device file
20371
+ * with the other remote providers.
20372
+ */
20373
+ const createIrohGateway = async (endpointId) => {
20374
+ const tls = await ensureIrohTls(endpointId, remoteDeviceFile, stateDirectory);
20375
+ const resolved = irohGatewayConfig(template, endpointId, remoteDeviceFile, tls);
20376
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
20377
+ logger.warn("auth failure %s", JSON.stringify(entry));
20378
+ }, (entry) => {
20379
+ logger.warn("http failure %s", JSON.stringify(entry));
20380
+ }, (entry) => {
20381
+ logger.info("upstream auth %s", JSON.stringify(entry));
20382
+ });
20383
+ try {
20384
+ await candidate.start();
20385
+ } catch (error) {
20386
+ await candidate.close();
20387
+ throw error;
20388
+ }
20389
+ return candidate;
20390
+ };
20391
+ /**
18798
20392
  * Gateway factory for the FRP provider.
18799
20393
  *
18800
20394
  * The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
@@ -18847,6 +20441,7 @@ async function apply(ctx, config) {
18847
20441
  const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
18848
20442
  const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
18849
20443
  const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
20444
+ const irohStore = new JsonMobileAccessControlStore(join(remoteDirectory, "iroh", "control.json"), false);
18850
20445
  const caddyController = new CaddyController({
18851
20446
  store: createVolatileRemoteControlStore(),
18852
20447
  component: caddyComponent,
@@ -18864,6 +20459,35 @@ async function apply(ctx, config) {
18864
20459
  }),
18865
20460
  managed: caddyController
18866
20461
  });
20462
+ const irohController = new IrohController({
20463
+ store: irohStore,
20464
+ component: irohComponent,
20465
+ dataDirectory: join(remoteDirectory, "iroh", "sidecar"),
20466
+ createGateway: (endpointId) => createIrohGateway(endpointId),
20467
+ /**
20468
+ * Read the pairing CA the live gateway advertises. The fingerprint must
20469
+ * match the gateway instance id, otherwise a rotated CA would mint QR
20470
+ * codes no phone can pin.
20471
+ */
20472
+ resolvePairingCa: async () => {
20473
+ const gateway = irohController.gateway();
20474
+ if (gateway === void 0) throw new Error("iroh_not_ready");
20475
+ const paths = irohTlsPaths(remoteDeviceFile, stateDirectory);
20476
+ let pem;
20477
+ try {
20478
+ pem = await readFile(paths.caCertFile, "utf8");
20479
+ } catch (error) {
20480
+ throw new Error("iroh_pairing_failed", { cause: error });
20481
+ }
20482
+ const ca = new X509Certificate(pem);
20483
+ const fingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
20484
+ if (!ca.ca || fingerprint !== gateway.config.instanceId) throw new Error("iroh_pairing_failed");
20485
+ return {
20486
+ der: ca.raw,
20487
+ fingerprint
20488
+ };
20489
+ }
20490
+ });
18867
20491
  const remoteControllers = {
18868
20492
  tailscale: new FunnelController({
18869
20493
  store: tailscaleStore,
@@ -18905,7 +20529,8 @@ async function apply(ctx, config) {
18905
20529
  await gateway.refreshProvidedTls();
18906
20530
  }
18907
20531
  }),
18908
- origin: originController
20532
+ origin: originController,
20533
+ iroh: irohController
18909
20534
  };
18910
20535
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
18911
20536
  const remoteController = () => remoteProviders.controller();
@@ -18921,8 +20546,9 @@ async function apply(ctx, config) {
18921
20546
  cpolar: remoteControllers.cpolar.status(),
18922
20547
  cloudflared: remoteControllers.cloudflared.status(),
18923
20548
  frp: remoteControllers.frp.status(),
18924
- origin: remoteControllers.origin.status()
18925
- }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status()),
20549
+ origin: remoteControllers.origin.status(),
20550
+ iroh: remoteControllers.iroh.status()
20551
+ }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status(), irohComponent.status()),
18926
20552
  originMode: originController.mode(),
18927
20553
  caddyConfiguration: caddyConfig.status(),
18928
20554
  caddyState: caddyController.status()
@@ -19030,7 +20656,7 @@ async function apply(ctx, config) {
19030
20656
  handler: async (request, response) => {
19031
20657
  try {
19032
20658
  const target = parseRequestTarget(request.url);
19033
- assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
20659
+ assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), baseTemplate.adminAllowedCidrs);
19034
20660
  if (adminClosing) throw new HttpError(503, "unavailable");
19035
20661
  if (target.search !== "") throw new HttpError(400, "bad_request");
19036
20662
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
@@ -19159,7 +20785,7 @@ async function apply(ctx, config) {
19159
20785
  }
19160
20786
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
19161
20787
  const body = await readJsonObject(request, 4096);
19162
- if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin") throw new HttpError(400, "bad_request");
20788
+ if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin" && body.provider !== "iroh") throw new HttpError(400, "bad_request");
19163
20789
  await remoteProviders.select(body.provider);
19164
20790
  sendJson(response, 200, remotePayload(), false);
19165
20791
  return;
@@ -19330,6 +20956,42 @@ async function apply(ctx, config) {
19330
20956
  sendJson(response, 200, remotePayload(), false);
19331
20957
  return;
19332
20958
  }
20959
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/component/install`) {
20960
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
20961
+ logger.info("iroh component install started");
20962
+ try {
20963
+ await remoteProviders.mutate(async () => irohComponent.install());
20964
+ logger.info("iroh component install completed");
20965
+ } catch (error) {
20966
+ logger.error("iroh component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
20967
+ throw error;
20968
+ }
20969
+ sendJson(response, 200, remotePayload(), false);
20970
+ return;
20971
+ }
20972
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/pairing/begin`) {
20973
+ await readJsonObject(request, 4096);
20974
+ logger.info("iroh pairing window requested");
20975
+ const pairing = await remoteProviders.mutateSelected("iroh", async () => irohController.beginPairing()).catch((error) => {
20976
+ const code = error instanceof Error ? error.message : String(error);
20977
+ logger.error("iroh pairing failed: %s", code);
20978
+ if (error instanceof HttpError) throw error;
20979
+ throw new HttpError(409, code === "iroh_not_ready" ? "iroh_not_ready" : "iroh_pairing_failed");
20980
+ });
20981
+ let qrSvg = "";
20982
+ try {
20983
+ qrSvg = await QRCode.toString(pairing.encoded, {
20984
+ type: "svg",
20985
+ margin: 1
20986
+ });
20987
+ } catch {}
20988
+ logger.info("iroh pairing window opened");
20989
+ sendJson(response, 201, {
20990
+ ...pairing.descriptor,
20991
+ qrSvg
20992
+ }, false);
20993
+ return;
20994
+ }
19333
20995
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
19334
20996
  const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
19335
20997
  const options = {
@@ -19580,7 +21242,8 @@ async function apply(ctx, config) {
19580
21242
  cpolar: cpolarStore,
19581
21243
  cloudflared: cloudflaredStore,
19582
21244
  frp: frpStore,
19583
- origin: originStore
21245
+ origin: originStore,
21246
+ iroh: irohStore
19584
21247
  };
19585
21248
  const prepared = await prepareDisabledRemoteProviders(remoteProviders.selected, stores);
19586
21249
  for (const provider of REMOTE_PROVIDERS) if (provider === remoteProviders.selected) await remoteControllers[provider].initialize();
@@ -19628,6 +21291,6 @@ async function apply(ctx, config) {
19628
21291
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
19629
21292
  }
19630
21293
  //#endregion
19631
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CPOLAR_COMPONENT_RELEASE, CPOLAR_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, CpolarComponentManager, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
21294
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CPOLAR_COMPONENT_RELEASE, CPOLAR_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, CpolarComponentManager, DEFAULT_IROH_LOCAL_PORT, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, IROH_COMPONENT_RELEASES, IROH_LEAF_RENEW_BEFORE_DAYS, IROH_LEAF_VALIDITY_DAYS, IROH_VERSION, IrohComponentManager, IrohController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_PAIRING_DESCRIPTOR_BYTES, MAX_PAIRING_LIFETIME_MS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, PAIRING_DESCRIPTOR_LOCAL_PORT, PAIRING_DESCRIPTOR_TYPE, PAIRING_DESCRIPTOR_VERSION, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, decodePairingDescriptor, encodePairingDescriptor, ensureFrpIngressCertificate, ensureIrohTls, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, irohGatewayConfig, irohHostname, irohOrigin, irohTlsPaths, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateIrohOrigin, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validatePairingDescriptor, validateWebSocketPath, watchTaskCompletions };
19632
21295
 
19633
21296
  //# sourceMappingURL=index.mjs.map