dsh-mobile 0.1.3 → 0.2.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.
package/lib/index.mjs CHANGED
@@ -3,20 +3,21 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomB
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { connect, isIP } from "node:net";
7
- import { chmod, lstat, mkdir, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { connect, createServer, isIP } from "node:net";
7
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { execFile, spawn } from "node:child_process";
9
+ import { promisify } from "node:util";
8
10
  import { createSocket } from "node:dgram";
9
11
  import { homedir, hostname, networkInterfaces } from "node:os";
10
- import { createServer, request } from "node:http";
11
- import { createServer as createServer$1 } from "node:https";
12
+ import { createServer as createServer$1, request } from "node:http";
13
+ import { createServer as createServer$2 } from "node:https";
12
14
  import { Transform } from "node:stream";
13
15
  import { pipeline } from "node:stream/promises";
16
+ import { createGzip } from "node:zlib";
14
17
  import Bonjour from "bonjour-service";
15
18
  import * as QRCode from "qrcode";
16
19
  import { Service } from "@deepseek-ai/cordis";
17
20
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
18
- import { execFile } from "node:child_process";
19
- import { promisify } from "node:util";
20
21
  import { generate } from "selfsigned";
21
22
  //#region src/access.ts
22
23
  /** Stable error categories converted to deliberately terse HTTP responses. */
@@ -683,6 +684,8 @@ function parseGatewayConfig(raw) {
683
684
  })(),
684
685
  ...value.pairingCaFile === void 0 ? {} : { pairingCaFile: absoluteFile(value.pairingCaFile, "pairingCaFile") },
685
686
  tls,
687
+ publicTls: tls.mode === "provided",
688
+ discovery: true,
686
689
  pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
687
690
  deviceTtlMs,
688
691
  sessionTtlMs,
@@ -716,6 +719,47 @@ function assertSupportedDshVersion(version) {
716
719
  throw new Error(`unsupported DeepSeek Harness version ${typeof version === "string" ? version : "(unknown)"}; supported versions: ${SUPPORTED_DSH_VERSIONS.join(", ")}`);
717
720
  }
718
721
  //#endregion
722
+ //#region src/private-file.ts
723
+ const execFile$2 = promisify(execFile);
724
+ let userSidTask;
725
+ async function currentWindowsUserSid() {
726
+ userSidTask ??= execFile$2("whoami.exe", [
727
+ "/user",
728
+ "/fo",
729
+ "csv",
730
+ "/nh"
731
+ ], {
732
+ encoding: "utf8",
733
+ windowsHide: true
734
+ }).then(({ stdout }) => {
735
+ const match = /,"(S-\d(?:-\d+)+)"\s*$/u.exec(stdout.trim());
736
+ if (match?.[1] === void 0) throw new Error("unable to resolve the current Windows user SID");
737
+ return match[1];
738
+ });
739
+ return userSidTask;
740
+ }
741
+ /** Restrict a sensitive regular file to the current user and Windows administrators. */
742
+ async function restrictPrivateFile(file, mode = 384) {
743
+ await chmod(file, mode);
744
+ if (process.platform !== "win32") return;
745
+ const userSid = await currentWindowsUserSid();
746
+ await execFile$2("icacls.exe", [
747
+ file,
748
+ "/inheritance:r",
749
+ "/grant:r",
750
+ `*${userSid}:(F)`,
751
+ "*S-1-5-18:(F)",
752
+ "*S-1-5-32-544:(F)",
753
+ "/remove:g",
754
+ "*S-1-1-0",
755
+ "*S-1-5-11",
756
+ "*S-1-5-32-545"
757
+ ], {
758
+ encoding: "utf8",
759
+ windowsHide: true
760
+ });
761
+ }
762
+ //#endregion
719
763
  //#region src/control.ts
720
764
  /** Keeps one runtime aligned with a changing network selection. */
721
765
  var FollowingMobileAccessRuntime = class {
@@ -801,6 +845,7 @@ var JsonMobileAccessControlStore = class {
801
845
  throw error;
802
846
  }
803
847
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("mobile-access control state must be a regular file no larger than 4 KiB");
848
+ await restrictPrivateFile(this.file);
804
849
  let parsed;
805
850
  try {
806
851
  parsed = JSON.parse(await readFile(this.file, "utf8"));
@@ -829,8 +874,8 @@ var JsonMobileAccessControlStore = class {
829
874
  flag: "wx",
830
875
  mode: 384
831
876
  });
832
- await chmod(temporary, 384);
833
877
  await rename(temporary, this.file);
878
+ await restrictPrivateFile(this.file);
834
879
  } catch (error) {
835
880
  try {
836
881
  await rm(temporary, { force: true });
@@ -1118,7 +1163,8 @@ function resolveComputerImagePath(path) {
1118
1163
  return resolve(path);
1119
1164
  }
1120
1165
  /** List folders and supported image files without following symbolic links. */
1121
- async function listComputerImages(path) {
1166
+ async function listComputerImages(path, signal) {
1167
+ signal?.throwIfAborted();
1122
1168
  const target = resolveComputerImagePath(path);
1123
1169
  const rows = [];
1124
1170
  let truncated = false;
@@ -1126,6 +1172,7 @@ async function listComputerImages(path) {
1126
1172
  try {
1127
1173
  directory = await opendir(target);
1128
1174
  for await (const entry of directory) {
1175
+ signal?.throwIfAborted();
1129
1176
  if (entry.isSymbolicLink()) continue;
1130
1177
  const kind = entry.isDirectory() ? "directory" : IMAGE_TYPES[extname(entry.name).toLowerCase()] === void 0 ? void 0 : "image";
1131
1178
  if (kind === void 0) continue;
@@ -1139,7 +1186,8 @@ async function listComputerImages(path) {
1139
1186
  path: resolve(target, entry.name)
1140
1187
  });
1141
1188
  }
1142
- } catch {
1189
+ } catch (error) {
1190
+ if (signal?.aborted) throw signal.reason;
1143
1191
  throw new HttpError(404, "directory_unavailable");
1144
1192
  } finally {
1145
1193
  await directory?.close().catch(() => void 0);
@@ -1154,7 +1202,8 @@ async function listComputerImages(path) {
1154
1202
  });
1155
1203
  }
1156
1204
  /** Read one bounded regular image file selected by an authenticated device. */
1157
- async function readComputerImage(path) {
1205
+ async function readComputerImage(path, signal) {
1206
+ signal?.throwIfAborted();
1158
1207
  const target = resolveComputerImagePath(path);
1159
1208
  const contentType = IMAGE_TYPES[extname(target).toLowerCase()];
1160
1209
  if (contentType === void 0) throw new HttpError(415, "unsupported_file_type");
@@ -1168,11 +1217,12 @@ async function readComputerImage(path) {
1168
1217
  if (info.size > MAX_IMAGE_BYTES) throw new HttpError(413, "file_too_large");
1169
1218
  try {
1170
1219
  return {
1171
- body: await readFile(target),
1220
+ body: await readFile(target, { signal }),
1172
1221
  contentType,
1173
1222
  name: basename(target)
1174
1223
  };
1175
- } catch {
1224
+ } catch (error) {
1225
+ if (signal?.aborted) throw signal.reason;
1176
1226
  throw new HttpError(404, "file_unavailable");
1177
1227
  }
1178
1228
  }
@@ -1249,7 +1299,7 @@ function normalizeRelativePath(value, field) {
1249
1299
  if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1250
1300
  return normalized;
1251
1301
  }
1252
- async function regularFile(path, maximum, field) {
1302
+ async function regularFile$1(path, maximum, field) {
1253
1303
  let info;
1254
1304
  try {
1255
1305
  info = await lstat(path);
@@ -1270,7 +1320,7 @@ async function containedPath(root, relativePath, maximum, field) {
1270
1320
  const targetReal = await realpath(target);
1271
1321
  const relation = relative(rootReal, targetReal);
1272
1322
  if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1273
- return regularFile(targetReal, maximum, field);
1323
+ return regularFile$1(targetReal, maximum, field);
1274
1324
  }
1275
1325
  async function optionalFile(root, name, maximum, field) {
1276
1326
  try {
@@ -1286,7 +1336,7 @@ async function optionalBytes(root, name, maximum, field) {
1286
1336
  return path === void 0 ? Buffer.alloc(0) : readFile(path);
1287
1337
  }
1288
1338
  async function extensionFingerprint(directory) {
1289
- const manifestFile = await regularFile(join(directory, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1339
+ const manifestFile = await regularFile$1(join(directory, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1290
1340
  const manifestBody = await readFile(manifestFile.path);
1291
1341
  const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
1292
1342
  if (manifest.id !== basename(resolve(directory))) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
@@ -1429,20 +1479,22 @@ var MobileAccessService = class extends Service {
1429
1479
  return this.local.get(id)?.controller.signal;
1430
1480
  }
1431
1481
  /** Read a local client entry after validating that it remains inside its directory. */
1432
- async readClientFile(id, kind) {
1482
+ async readClientFile(id, kind, signal) {
1483
+ signal?.throwIfAborted();
1433
1484
  const active = this.local.get(id);
1434
1485
  if (active === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1435
1486
  const path = kind === "script" ? active.scriptFile : active.styleFile;
1436
1487
  if (path === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1437
- const file = await regularFile(path, kind === "script" ? EXTENSION_LIMITS.script : EXTENSION_LIMITS.css, kind);
1438
- const body = await readFile(file.path);
1488
+ const file = await regularFile$1(path, kind === "script" ? EXTENSION_LIMITS.script : EXTENSION_LIMITS.css, kind);
1489
+ const body = await readFile(file.path, { signal });
1439
1490
  return {
1440
1491
  body,
1441
1492
  digest: createHash("sha256").update(body).digest("hex")
1442
1493
  };
1443
1494
  }
1444
1495
  /** Read a local static asset after containment and size checks. */
1445
- async readAsset(id, assetPath) {
1496
+ async readAsset(id, assetPath, signal) {
1497
+ signal?.throwIfAborted();
1446
1498
  const active = this.local.get(id);
1447
1499
  if (active === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1448
1500
  let file;
@@ -1452,7 +1504,7 @@ var MobileAccessService = class extends Service {
1452
1504
  if (error.code === "ENOENT") throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1453
1505
  throw error;
1454
1506
  }
1455
- const body = await readFile(file.path);
1507
+ const body = await readFile(file.path, { signal });
1456
1508
  return {
1457
1509
  body,
1458
1510
  digest: createHash("sha256").update(body).digest("hex"),
@@ -1607,7 +1659,7 @@ async function loadLocalExtension(directory, context, known) {
1607
1659
  const root = resolve(directory);
1608
1660
  const rootStat = await lstat(root);
1609
1661
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "extension directory must be real");
1610
- const manifestFile = await regularFile(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1662
+ const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1611
1663
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
1612
1664
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
1613
1665
  const scriptFile = await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js");
@@ -1691,6 +1743,8 @@ function createMobileAccessService(ctx) {
1691
1743
  //#region src/gateway.ts
1692
1744
  const MAX_CONTROL_BODY_BYTES = 16384;
1693
1745
  const MAX_HEADER_BYTES = 16384;
1746
+ const MOBILE_HISTORY_PAGE_MESSAGES = 10;
1747
+ const SESSION_HISTORY_PATH = "/api/session.history";
1694
1748
  const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
1695
1749
  const DISCOVERY_PROTOCOL = 1;
1696
1750
  const DISCOVERY_INTERVAL_MS = 3e3;
@@ -2002,6 +2056,61 @@ function sanitizeResponseHeaders(headers, upstream) {
2002
2056
  }
2003
2057
  return clean;
2004
2058
  }
2059
+ function acceptsGzip(header) {
2060
+ if (header === void 0) return false;
2061
+ let wildcard;
2062
+ for (const entry of header.split(",")) {
2063
+ const [rawName, ...parameters] = entry.split(";");
2064
+ const name = rawName?.trim().toLowerCase();
2065
+ if (name === void 0 || name === "") continue;
2066
+ let quality = 1;
2067
+ for (const parameter of parameters) {
2068
+ const match = /^\s*q\s*=\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*$/iu.exec(parameter);
2069
+ if (match !== null) quality = Number(match[1]);
2070
+ }
2071
+ if (name === "gzip") return quality > 0;
2072
+ if (name === "*") wildcard = quality > 0;
2073
+ }
2074
+ return wildcard ?? false;
2075
+ }
2076
+ function isCompressibleContentType(value) {
2077
+ const contentType = Array.isArray(value) ? value[0] : value;
2078
+ if (contentType === void 0) return false;
2079
+ const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
2080
+ return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
2081
+ }
2082
+ function shouldCompressResponse(request, response) {
2083
+ const pathname = request.url?.split("?", 1)[0] ?? "";
2084
+ return (request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) || request.method === "POST" && pathname === SESSION_HISTORY_PATH) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
2085
+ }
2086
+ function isJsonRecord(value) {
2087
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2088
+ }
2089
+ function mobileHistoryRequestBody(request, body) {
2090
+ if (request.method !== "POST" || request.url?.split("?", 1)[0] !== SESSION_HISTORY_PATH) return body;
2091
+ let parsed;
2092
+ try {
2093
+ parsed = JSON.parse(body.toString("utf8"));
2094
+ } catch {
2095
+ return body;
2096
+ }
2097
+ if (!isJsonRecord(parsed) || parsed.method !== "session.history" || !isJsonRecord(parsed.payload)) return body;
2098
+ const requested = parsed.payload.maxMessages;
2099
+ if (typeof requested === "number" && Number.isInteger(requested) && requested > 0 && requested <= MOBILE_HISTORY_PAGE_MESSAGES) return body;
2100
+ return Buffer.from(JSON.stringify({
2101
+ ...parsed,
2102
+ payload: {
2103
+ ...parsed.payload,
2104
+ maxMessages: MOBILE_HISTORY_PAGE_MESSAGES
2105
+ }
2106
+ }));
2107
+ }
2108
+ function addVaryAcceptEncoding(headers) {
2109
+ const existing = headers.vary;
2110
+ const values = (Array.isArray(existing) ? existing.map((value) => String(value)) : existing === void 0 ? [] : [String(existing)]).flatMap((value) => value.split(",").map((part) => part.trim()).filter(Boolean));
2111
+ if (!values.some((value) => value.toLowerCase() === "accept-encoding")) values.push("Accept-Encoding");
2112
+ headers.vary = values.join(", ");
2113
+ }
2005
2114
  function requestCookies(request) {
2006
2115
  const cookies = parseCookies(request.headers.cookie);
2007
2116
  if (cookies === void 0) throw new HttpError(401, "authentication_failed");
@@ -2118,6 +2227,7 @@ var MobileAccessGateway = class {
2118
2227
  config;
2119
2228
  extensions;
2120
2229
  access;
2230
+ listenerTlsEnabled;
2121
2231
  tlsEnabled;
2122
2232
  policy;
2123
2233
  server;
@@ -2138,7 +2248,8 @@ var MobileAccessGateway = class {
2138
2248
  constructor(config, store, extensions) {
2139
2249
  this.config = config;
2140
2250
  this.extensions = extensions;
2141
- this.tlsEnabled = config.tls.mode === "provided";
2251
+ this.listenerTlsEnabled = config.tls.mode === "provided";
2252
+ this.tlsEnabled = config.publicTls;
2142
2253
  this.access = new AccessController(store, {
2143
2254
  pairingTtlMs: config.pairingTtlMs,
2144
2255
  deviceTtlMs: config.deviceTtlMs,
@@ -2173,7 +2284,7 @@ var MobileAccessGateway = class {
2173
2284
  else sendFailure(response, mapped.status, mapped.code, this.tlsEnabled);
2174
2285
  });
2175
2286
  };
2176
- const server = this.tlsEnabled ? createServer$1(await tlsOptions(this.config), handler) : createServer({ maxHeaderSize: MAX_HEADER_BYTES }, handler);
2287
+ const server = this.listenerTlsEnabled ? createServer$2(await tlsOptions(this.config), handler) : createServer$1({ maxHeaderSize: MAX_HEADER_BYTES }, handler);
2177
2288
  this.server = server;
2178
2289
  server.maxHeadersCount = 64;
2179
2290
  server.maxConnections = this.config.maxConnections;
@@ -2186,6 +2297,9 @@ var MobileAccessGateway = class {
2186
2297
  return;
2187
2298
  }
2188
2299
  this.connectedSockets.add(socket);
2300
+ socket.on("error", () => {
2301
+ socket.destroy();
2302
+ });
2189
2303
  socket.once("close", () => {
2190
2304
  this.connectedSockets.delete(socket);
2191
2305
  });
@@ -2216,7 +2330,7 @@ var MobileAccessGateway = class {
2216
2330
  if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
2217
2331
  this.listenerPort = address.port;
2218
2332
  this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
2219
- await this.startDiscovery(address.port);
2333
+ if (this.config.discovery) await this.startDiscovery(address.port);
2220
2334
  } catch (error) {
2221
2335
  await this.closeFailedStart();
2222
2336
  throw error;
@@ -2544,54 +2658,69 @@ var MobileAccessGateway = class {
2544
2658
  return;
2545
2659
  }
2546
2660
  if (customAsset !== void 0) {
2547
- let body;
2548
- let mtime;
2661
+ const operation = this.allocateRequest(authorization, response, {});
2549
2662
  try {
2550
- body = await readFile(customAsset.file);
2663
+ let body;
2664
+ let mtime;
2551
2665
  try {
2552
- mtime = (await stat(customAsset.file)).mtime;
2553
- } catch {}
2554
- } catch (error) {
2555
- if (error.code !== "ENOENT") throw error;
2556
- if (customAsset.fallback === void 0) throw new HttpError(503, "mobile_frontend_unavailable");
2557
- body = Buffer.from(customAsset.fallback);
2558
- }
2559
- if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
2560
- const etag = createHash("sha256").update(body).digest("hex");
2561
- const ifNoneMatch = headerValue(request.headers, "if-none-match");
2562
- if (ifNoneMatch !== void 0 && ifNoneMatch === etag) {
2666
+ body = await readFile(customAsset.file, { signal: operation.signal });
2667
+ try {
2668
+ mtime = (await stat(customAsset.file)).mtime;
2669
+ } catch {}
2670
+ } catch (error) {
2671
+ if (error.code !== "ENOENT") throw error;
2672
+ if (customAsset.fallback === void 0) throw new HttpError(503, "mobile_frontend_unavailable");
2673
+ body = Buffer.from(customAsset.fallback);
2674
+ }
2675
+ if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
2676
+ const etag = createHash("sha256").update(body).digest("hex");
2677
+ const ifNoneMatch = headerValue(request.headers, "if-none-match");
2678
+ if (ifNoneMatch !== void 0 && ifNoneMatch === etag) {
2679
+ setSecurityHeaders(response, this.tlsEnabled);
2680
+ response.writeHead(304);
2681
+ response.end();
2682
+ return;
2683
+ }
2563
2684
  setSecurityHeaders(response, this.tlsEnabled);
2564
- response.writeHead(304);
2565
- response.end();
2685
+ const responseHeaders = {
2686
+ "Content-Type": customAsset.contentType,
2687
+ "Content-Length": body.byteLength,
2688
+ "ETag": etag
2689
+ };
2690
+ if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
2691
+ response.writeHead(200, responseHeaders);
2692
+ response.end(body);
2566
2693
  return;
2694
+ } finally {
2695
+ operation.release();
2567
2696
  }
2568
- setSecurityHeaders(response, this.tlsEnabled);
2569
- const responseHeaders = {
2570
- "Content-Type": customAsset.contentType,
2571
- "Content-Length": body.byteLength,
2572
- "ETag": etag
2573
- };
2574
- if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
2575
- response.writeHead(200, responseHeaders);
2576
- response.end(body);
2577
- return;
2578
2697
  }
2579
2698
  if (computerImages) {
2580
- const query = new URL(target.raw, this.address().origin).searchParams;
2581
- sendJson(response, 200, await listComputerImages(query.get("path")), this.tlsEnabled);
2582
- return;
2699
+ const operation = this.allocateRequest(authorization, response, {});
2700
+ try {
2701
+ const query = new URL(target.raw, this.address().origin).searchParams;
2702
+ sendJson(response, 200, await listComputerImages(query.get("path"), operation.signal), this.tlsEnabled);
2703
+ return;
2704
+ } finally {
2705
+ operation.release();
2706
+ }
2583
2707
  }
2584
2708
  if (computerImage) {
2585
- const query = new URL(target.raw, this.address().origin).searchParams;
2586
- const image = await readComputerImage(query.get("path"));
2587
- setSecurityHeaders(response, this.tlsEnabled);
2588
- response.writeHead(200, {
2589
- "Content-Type": image.contentType,
2590
- "Content-Length": image.body.byteLength,
2591
- "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(image.name)}`
2592
- });
2593
- response.end(image.body);
2594
- return;
2709
+ const operation = this.allocateRequest(authorization, response, {});
2710
+ try {
2711
+ const query = new URL(target.raw, this.address().origin).searchParams;
2712
+ const image = await readComputerImage(query.get("path"), operation.signal);
2713
+ setSecurityHeaders(response, this.tlsEnabled);
2714
+ response.writeHead(200, {
2715
+ "Content-Type": image.contentType,
2716
+ "Content-Length": image.body.byteLength,
2717
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(image.name)}`
2718
+ });
2719
+ response.end(image.body);
2720
+ return;
2721
+ } finally {
2722
+ operation.release();
2723
+ }
2595
2724
  }
2596
2725
  const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
2597
2726
  const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
@@ -2608,46 +2737,57 @@ var MobileAccessGateway = class {
2608
2737
  if (request.method !== "GET" && request.method !== "HEAD") this.requireCsrf(request, authorization);
2609
2738
  if (targetInfo.kind === "manifest") {
2610
2739
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
2611
- const body = Buffer.from(JSON.stringify({
2612
- protocol: 1,
2613
- extensions: extensions.manifest()
2614
- }));
2615
- const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex");
2616
- if (headerValue(request.headers, "if-none-match") === etag) {
2740
+ const operation = this.allocateRequest(authorization, response, {});
2741
+ try {
2742
+ operation.signal.throwIfAborted();
2743
+ const body = Buffer.from(JSON.stringify({
2744
+ protocol: 1,
2745
+ extensions: extensions.manifest()
2746
+ }));
2747
+ const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex");
2748
+ if (headerValue(request.headers, "if-none-match") === etag) {
2749
+ setSecurityHeaders(response, this.tlsEnabled);
2750
+ response.writeHead(304);
2751
+ response.end();
2752
+ return;
2753
+ }
2617
2754
  setSecurityHeaders(response, this.tlsEnabled);
2618
- response.writeHead(304);
2619
- response.end();
2755
+ response.writeHead(200, {
2756
+ "Content-Type": "application/json; charset=utf-8",
2757
+ "Content-Length": body.byteLength,
2758
+ ETag: etag
2759
+ });
2760
+ if (request.method === "HEAD") response.end();
2761
+ else response.end(body);
2620
2762
  return;
2763
+ } finally {
2764
+ operation.release();
2621
2765
  }
2622
- setSecurityHeaders(response, this.tlsEnabled);
2623
- response.writeHead(200, {
2624
- "Content-Type": "application/json; charset=utf-8",
2625
- "Content-Length": body.byteLength,
2626
- ETag: etag
2627
- });
2628
- if (request.method === "HEAD") response.end();
2629
- else response.end(body);
2630
- return;
2631
2766
  }
2632
2767
  if (targetInfo.kind === "script" || targetInfo.kind === "style" || targetInfo.kind === "asset") {
2633
2768
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
2634
- const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script") : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style") : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "");
2635
- if (headerValue(request.headers, "if-none-match") === file.digest) {
2769
+ const operation = this.allocateRequest(authorization, response, {});
2770
+ try {
2771
+ const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal);
2772
+ if (headerValue(request.headers, "if-none-match") === file.digest) {
2773
+ setSecurityHeaders(response, this.tlsEnabled);
2774
+ response.writeHead(304);
2775
+ response.end();
2776
+ return;
2777
+ }
2778
+ const contentType = targetInfo.kind === "script" ? "text/javascript; charset=utf-8" : targetInfo.kind === "style" ? "text/css; charset=utf-8" : extensionContentType(targetInfo.path ?? "");
2636
2779
  setSecurityHeaders(response, this.tlsEnabled);
2637
- response.writeHead(304);
2638
- response.end();
2780
+ response.writeHead(200, {
2781
+ "Content-Type": contentType,
2782
+ "Content-Length": file.body.byteLength,
2783
+ ETag: file.digest
2784
+ });
2785
+ if (request.method === "HEAD") response.end();
2786
+ else response.end(file.body);
2639
2787
  return;
2788
+ } finally {
2789
+ operation.release();
2640
2790
  }
2641
- const contentType = targetInfo.kind === "script" ? "text/javascript; charset=utf-8" : targetInfo.kind === "style" ? "text/css; charset=utf-8" : extensionContentType(targetInfo.path ?? "");
2642
- setSecurityHeaders(response, this.tlsEnabled);
2643
- response.writeHead(200, {
2644
- "Content-Type": contentType,
2645
- "Content-Length": file.body.byteLength,
2646
- ETag: file.digest
2647
- });
2648
- if (request.method === "HEAD") response.end();
2649
- else response.end(file.body);
2650
- return;
2651
2791
  }
2652
2792
  if (targetInfo.kind === "action") {
2653
2793
  if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
@@ -2819,7 +2959,9 @@ var MobileAccessGateway = class {
2819
2959
  allocateRequest(authorization, response, upstream) {
2820
2960
  if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
2821
2961
  const id = this.nextOperationId++;
2962
+ const controller = new AbortController();
2822
2963
  const abort = () => {
2964
+ controller.abort();
2823
2965
  upstream.request?.destroy();
2824
2966
  if (!response.destroyed) response.destroy();
2825
2967
  };
@@ -2832,6 +2974,7 @@ var MobileAccessGateway = class {
2832
2974
  }));
2833
2975
  return {
2834
2976
  id,
2977
+ signal: controller.signal,
2835
2978
  release: () => {
2836
2979
  const entry = this.activeRequests.get(id);
2837
2980
  if (entry !== void 0) clearTimeout(entry.timer);
@@ -2846,6 +2989,9 @@ var MobileAccessGateway = class {
2846
2989
  const operation = this.allocateRequest(authorization, response, holder);
2847
2990
  let bodyDone;
2848
2991
  try {
2992
+ const bufferedBody = request$2.method === "POST" && request$2.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$2, await readBoundedBody(request$2, this.config.maxBodyBytes)) : void 0;
2993
+ const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
2994
+ if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
2849
2995
  const proxied = await new Promise((resolve, reject) => {
2850
2996
  const upstreamRequest = request({
2851
2997
  protocol: "http:",
@@ -2853,7 +2999,7 @@ var MobileAccessGateway = class {
2853
2999
  port: Number(this.config.upstreamOrigin.port),
2854
3000
  method: request$2.method,
2855
3001
  path: request$2.url,
2856
- headers: sanitizeRequestHeaders(request$2, this.config.upstreamOrigin),
3002
+ headers: upstreamHeaders,
2857
3003
  agent: false
2858
3004
  });
2859
3005
  holder.request = upstreamRequest;
@@ -2862,12 +3008,25 @@ var MobileAccessGateway = class {
2862
3008
  });
2863
3009
  upstreamRequest.once("response", resolve);
2864
3010
  upstreamRequest.once("error", reject);
2865
- bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
3011
+ if (bufferedBody === void 0) bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
3012
+ else {
3013
+ upstreamRequest.end(bufferedBody);
3014
+ bodyDone = Promise.resolve();
3015
+ }
2866
3016
  bodyDone.catch(reject);
2867
3017
  });
2868
3018
  setSecurityHeaders(response, this.tlsEnabled);
2869
- response.writeHead(proxied.statusCode ?? 502, sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin));
2870
- await Promise.all([bodyDone, pipeline(proxied, response)]);
3019
+ const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
3020
+ const compressed = shouldCompressResponse(request$2, proxied);
3021
+ if (compressed) {
3022
+ delete headers["accept-ranges"];
3023
+ delete headers["content-length"];
3024
+ delete headers.etag;
3025
+ headers["content-encoding"] = "gzip";
3026
+ addVaryAcceptEncoding(headers);
3027
+ }
3028
+ response.writeHead(proxied.statusCode ?? 502, headers);
3029
+ await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
2871
3030
  } catch (error) {
2872
3031
  holder.request?.destroy();
2873
3032
  await bodyDone?.catch(() => void 0);
@@ -2983,6 +3142,8 @@ var MobileAccessGateway = class {
2983
3142
  client.destroy();
2984
3143
  upstream.destroy();
2985
3144
  };
3145
+ client.on("error", closeBoth);
3146
+ upstream.on("error", closeBoth);
2986
3147
  const timer = setTimeout(closeBoth, Math.max(1, authorization.expiresAt - Date.now()));
2987
3148
  timer.unref();
2988
3149
  const record = Object.freeze({
@@ -3008,8 +3169,16 @@ var MobileAccessGateway = class {
3008
3169
  upstream.setTimeout(this.config.upstreamTimeoutMs, closeBoth);
3009
3170
  try {
3010
3171
  await new Promise((resolve, reject) => {
3011
- upstream.once("connect", resolve);
3012
- upstream.once("error", reject);
3172
+ const connected = () => {
3173
+ upstream.off("error", failed);
3174
+ resolve();
3175
+ };
3176
+ const failed = (error) => {
3177
+ upstream.off("connect", connected);
3178
+ reject(error);
3179
+ };
3180
+ upstream.once("connect", connected);
3181
+ upstream.once("error", failed);
3013
3182
  });
3014
3183
  const requestLines = [
3015
3184
  `GET ${target.raw} HTTP/1.1`,
@@ -3042,16 +3211,16 @@ var MobileAccessGateway = class {
3042
3211
  }
3043
3212
  }
3044
3213
  /** Loopback-only DSH WebServer route for opening pairing and managing devices. */
3045
- localAdminRoute() {
3214
+ localAdminRoute(prefix = LOCAL_ADMIN_PREFIX) {
3046
3215
  return {
3047
3216
  kind: "prefix",
3048
- path: LOCAL_ADMIN_PREFIX,
3217
+ path: prefix,
3049
3218
  handler: async (request, response) => {
3050
3219
  try {
3051
3220
  const target = parseRequestTarget(request.url);
3052
3221
  assertLocalAdminTrust(request, request.method === "POST");
3053
3222
  if (target.search !== "") throw new HttpError(400, "bad_request");
3054
- if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/status`) {
3223
+ if (request.method === "GET" && target.decodedPathname === `${prefix}/status`) {
3055
3224
  sendJson(response, 200, {
3056
3225
  gateway: this.address(),
3057
3226
  pairing: this.access.pairingStatus(),
@@ -3064,18 +3233,19 @@ var MobileAccessGateway = class {
3064
3233
  }, false);
3065
3234
  return;
3066
3235
  }
3067
- if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/devices`) {
3236
+ if (request.method === "GET" && target.decodedPathname === `${prefix}/devices`) {
3068
3237
  sendJson(response, 200, { devices: this.access.listDevices() }, false);
3069
3238
  return;
3070
3239
  }
3071
- if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/pairing/open`) {
3240
+ if (request.method === "POST" && target.decodedPathname === `${prefix}/pairing/open`) {
3072
3241
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
3073
3242
  if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
3074
3243
  const opened = await this.access.openPairing(body.ttlMs);
3075
- const pairUrl = `${this.address().origin}/mobile-access/pair#token=${opened.token}`;
3244
+ const pairUrl = `${this.address().origin}/mobile-access/pair#instance=${this.config.instanceId}&token=${opened.token}`;
3245
+ const appPairUrl = pairUrl;
3076
3246
  let qrSvg = "";
3077
3247
  try {
3078
- qrSvg = await QRCode.toString(pairUrl, {
3248
+ qrSvg = await QRCode.toString(appPairUrl, {
3079
3249
  type: "svg",
3080
3250
  margin: 1
3081
3251
  });
@@ -3084,18 +3254,19 @@ var MobileAccessGateway = class {
3084
3254
  ...opened,
3085
3255
  appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
3086
3256
  pairUrl,
3257
+ appPairUrl,
3087
3258
  qrSvg
3088
3259
  }, false);
3089
3260
  return;
3090
3261
  }
3091
- if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/devices/revoke`) {
3262
+ if (request.method === "POST" && target.decodedPathname === `${prefix}/devices/revoke`) {
3092
3263
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
3093
3264
  if (typeof body.deviceId !== "string" || !/^[a-f\d]{32}$/u.test(body.deviceId)) throw new HttpError(400, "bad_request");
3094
3265
  if (!await this.access.revokeDevice(body.deviceId)) throw new HttpError(404, "not_found");
3095
3266
  sendJson(response, 200, { revoked: true }, false);
3096
3267
  return;
3097
3268
  }
3098
- if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/devices/reset`) {
3269
+ if (request.method === "POST" && target.decodedPathname === `${prefix}/devices/reset`) {
3099
3270
  if ((await readJsonObject(request, MAX_CONTROL_BODY_BYTES)).confirm !== true) throw new HttpError(400, "bad_request");
3100
3271
  await this.access.resetDevices();
3101
3272
  sendJson(response, 200, { reset: true }, false);
@@ -3219,6 +3390,7 @@ var JsonDeviceStore = class {
3219
3390
  throw error;
3220
3391
  }
3221
3392
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) throw new Error("device state must be a regular file no larger than 1 MiB");
3393
+ await restrictPrivateFile(this.file);
3222
3394
  let parsed;
3223
3395
  try {
3224
3396
  parsed = JSON.parse(await readFile(this.file, "utf8"));
@@ -3248,7 +3420,7 @@ var JsonDeviceStore = class {
3248
3420
  mode: 384
3249
3421
  });
3250
3422
  await rename(temporary, this.file);
3251
- await chmod(this.file, 384);
3423
+ await restrictPrivateFile(this.file);
3252
3424
  } catch (error) {
3253
3425
  try {
3254
3426
  await rm(temporary, { force: true });
@@ -3312,6 +3484,1105 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
3312
3484
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
3313
3485
 
3314
3486
  请执行用户需求:外观或交互类改 mobile.css / mobile.js;需要电脑能力的创建或修改扩展。完成后简要说明改了什么、手机端会有什么变化。`;
3487
+ //#endregion
3488
+ //#region src/funnel.ts
3489
+ const MAX_PROTOCOL_LINE_BYTES = 16384;
3490
+ function publicStatus$1(status) {
3491
+ return Object.freeze({
3492
+ enabled: status.enabled,
3493
+ state: status.state,
3494
+ ...status.origin === void 0 ? {} : { origin: status.origin },
3495
+ ...status.loginUrl === void 0 ? {} : { loginUrl: status.loginUrl },
3496
+ ...status.setupUrl === void 0 ? {} : { setupUrl: status.setupUrl },
3497
+ ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode }
3498
+ });
3499
+ }
3500
+ const FUNNEL_SETUP_URLS = /* @__PURE__ */ new Set(["https://tailscale.com/s/no-funnel", "https://tailscale.com/s/https"]);
3501
+ function parseSetupUrl(value) {
3502
+ if (value === void 0) return void 0;
3503
+ if (typeof value !== "string" || value.length > 2048) throw new Error("invalid_sidecar_protocol");
3504
+ const url = new URL(value);
3505
+ const normalized = url.toString().replace(/\/$/u, "");
3506
+ const officialInteractive = url.protocol === "https:" && url.hostname === "login.tailscale.com" && url.port === "" && url.username === "" && url.password === "";
3507
+ if (!FUNNEL_SETUP_URLS.has(normalized) && !officialInteractive) throw new Error("invalid_sidecar_protocol");
3508
+ return officialInteractive ? url.toString() : normalized;
3509
+ }
3510
+ function parseOrigin(value) {
3511
+ if (typeof value !== "string" || value.length > 512) throw new Error("invalid_funnel_origin");
3512
+ let url;
3513
+ try {
3514
+ url = new URL(value);
3515
+ } catch {
3516
+ throw new Error("invalid_funnel_origin");
3517
+ }
3518
+ if (url.protocol !== "https:" || !url.hostname.endsWith(".ts.net") || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") throw new Error("invalid_funnel_origin");
3519
+ return url.origin;
3520
+ }
3521
+ /** Parse one sidecar protocol line while restricting every browser-opened URL. */
3522
+ function parseFunnelEvent(line) {
3523
+ if (Buffer.byteLength(line, "utf8") === 0 || Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) throw new Error("invalid_sidecar_protocol");
3524
+ let value;
3525
+ try {
3526
+ value = JSON.parse(line);
3527
+ } catch {
3528
+ throw new Error("invalid_sidecar_protocol");
3529
+ }
3530
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("invalid_sidecar_protocol");
3531
+ const record = value;
3532
+ if (record.version !== 1 || typeof record.type !== "string") throw new Error("invalid_sidecar_protocol");
3533
+ if (record.type === "login") {
3534
+ if (typeof record.url !== "string" || record.url.length > 2048) throw new Error("invalid_sidecar_protocol");
3535
+ const url = new URL(record.url);
3536
+ if (url.protocol !== "https:" || url.hostname !== "login.tailscale.com") throw new Error("invalid_sidecar_protocol");
3537
+ return Object.freeze({
3538
+ version: 1,
3539
+ type: "login",
3540
+ url: url.toString()
3541
+ });
3542
+ }
3543
+ if (record.type === "ready" || record.type === "serving") return Object.freeze({
3544
+ version: 1,
3545
+ type: record.type,
3546
+ origin: parseOrigin(record.origin)
3547
+ });
3548
+ if (record.type === "error") {
3549
+ if (typeof record.code !== "string" || !/^[a-z][a-z0-9_]{0,63}$/u.test(record.code)) throw new Error("invalid_sidecar_protocol");
3550
+ const setupUrl = parseSetupUrl(record.url);
3551
+ return Object.freeze({
3552
+ version: 1,
3553
+ type: "error",
3554
+ code: record.code,
3555
+ ...setupUrl === void 0 ? {} : { url: setupUrl }
3556
+ });
3557
+ }
3558
+ throw new Error("invalid_sidecar_protocol");
3559
+ }
3560
+ function withoutProvisioningSecrets(environment) {
3561
+ const blocked = /* @__PURE__ */ new Set([
3562
+ "TS_AUTHKEY",
3563
+ "TAILSCALE_AUTHKEY",
3564
+ "TS_OAUTH_CLIENT_SECRET"
3565
+ ]);
3566
+ return Object.fromEntries(Object.entries(environment).filter(([name]) => !blocked.has(name.toUpperCase())));
3567
+ }
3568
+ /** Owns the source-built tsnet sidecar, remote gateway, and persisted remote switch. */
3569
+ var FunnelController = class {
3570
+ options;
3571
+ enabled = false;
3572
+ initialized = false;
3573
+ disposed = false;
3574
+ child;
3575
+ gatewayValue;
3576
+ generation = 0;
3577
+ buffer = "";
3578
+ latest = publicStatus$1({
3579
+ enabled: false,
3580
+ state: "off"
3581
+ });
3582
+ queue = Promise.resolve();
3583
+ constructor(options) {
3584
+ this.options = options;
3585
+ if (!isAbsolute(options.executable) || !isAbsolute(options.stateDirectory)) throw new Error("Funnel paths must be absolute");
3586
+ }
3587
+ /** Restore the remote switch without coupling it to LAN availability. */
3588
+ async initialize() {
3589
+ const state = await this.options.store.load();
3590
+ this.enabled = state.enabled;
3591
+ this.initialized = true;
3592
+ if (this.enabled) await this.start();
3593
+ else this.publish({
3594
+ enabled: false,
3595
+ state: "off"
3596
+ });
3597
+ }
3598
+ /** Return the currently attached authenticated remote gateway. */
3599
+ gateway() {
3600
+ return this.gatewayValue;
3601
+ }
3602
+ /** Return state safe for the local desktop control UI. */
3603
+ status() {
3604
+ return publicStatus$1(this.latest);
3605
+ }
3606
+ /** Enable or disable Funnel without changing the LAN listener. */
3607
+ async setEnabled(enabled) {
3608
+ if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
3609
+ await this.enqueue(async () => {
3610
+ if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
3611
+ if (!enabled) await this.stop();
3612
+ this.enabled = enabled;
3613
+ await this.options.store.save({
3614
+ version: 1,
3615
+ enabled
3616
+ });
3617
+ if (enabled) await this.start();
3618
+ else this.publish({
3619
+ enabled: false,
3620
+ state: "off"
3621
+ });
3622
+ });
3623
+ return this.status();
3624
+ }
3625
+ /** Restart a failed or interrupted Funnel session while retaining sign-in state. */
3626
+ async reconnect() {
3627
+ if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
3628
+ await this.enqueue(async () => {
3629
+ if (!this.enabled) {
3630
+ this.enabled = true;
3631
+ await this.options.store.save({
3632
+ version: 1,
3633
+ enabled: true
3634
+ });
3635
+ }
3636
+ await this.stop();
3637
+ await this.start();
3638
+ });
3639
+ return this.status();
3640
+ }
3641
+ /** Disable Funnel and remove only its private Tailscale node state. */
3642
+ async reset() {
3643
+ if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
3644
+ await this.enqueue(async () => {
3645
+ await this.stop();
3646
+ this.enabled = false;
3647
+ await this.options.store.save({
3648
+ version: 1,
3649
+ enabled: false
3650
+ });
3651
+ await rm(resolve(this.options.stateDirectory), {
3652
+ recursive: true,
3653
+ force: true
3654
+ });
3655
+ this.publish({
3656
+ enabled: false,
3657
+ state: "off"
3658
+ });
3659
+ });
3660
+ return this.status();
3661
+ }
3662
+ /** Stop all remote resources without modifying the remembered switch. */
3663
+ async close() {
3664
+ if (this.disposed) return;
3665
+ this.disposed = true;
3666
+ await this.enqueue(() => this.stop());
3667
+ }
3668
+ enqueue(operation) {
3669
+ const task = this.queue.then(operation, operation);
3670
+ this.queue = task.then(() => void 0, () => void 0);
3671
+ return task;
3672
+ }
3673
+ publish(status) {
3674
+ this.latest = publicStatus$1(status);
3675
+ try {
3676
+ this.options.onStatus?.(this.status());
3677
+ } catch {}
3678
+ }
3679
+ async start() {
3680
+ const generation = ++this.generation;
3681
+ let entry;
3682
+ try {
3683
+ entry = await lstat(this.options.executable);
3684
+ } catch {
3685
+ this.publish({
3686
+ enabled: true,
3687
+ state: "unavailable",
3688
+ errorCode: "component_missing"
3689
+ });
3690
+ return;
3691
+ }
3692
+ if (!entry.isFile() || entry.isSymbolicLink()) {
3693
+ this.publish({
3694
+ enabled: true,
3695
+ state: "unavailable",
3696
+ errorCode: "component_invalid"
3697
+ });
3698
+ return;
3699
+ }
3700
+ this.buffer = "";
3701
+ this.publish({
3702
+ enabled: true,
3703
+ state: "starting"
3704
+ });
3705
+ const child = spawn(this.options.executable, [
3706
+ "--state-dir",
3707
+ resolve(this.options.stateDirectory),
3708
+ "--hostname",
3709
+ this.options.hostname
3710
+ ], {
3711
+ env: withoutProvisioningSecrets(process.env),
3712
+ shell: false,
3713
+ stdio: [
3714
+ "pipe",
3715
+ "pipe",
3716
+ "pipe"
3717
+ ],
3718
+ windowsHide: true
3719
+ });
3720
+ this.child = child;
3721
+ child.stderr.resume();
3722
+ child.stdout.setEncoding("utf8");
3723
+ child.stdout.on("data", (chunk) => {
3724
+ this.consume(generation, String(chunk));
3725
+ });
3726
+ child.once("error", () => {
3727
+ this.enqueue(() => this.failGeneration(generation, "sidecar_launch_failed"));
3728
+ });
3729
+ child.once("close", (code) => {
3730
+ if (generation !== this.generation || this.child !== child) return;
3731
+ this.child = void 0;
3732
+ if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "sidecar_stopped" : "sidecar_exited"));
3733
+ });
3734
+ }
3735
+ consume(generation, chunk) {
3736
+ if (generation !== this.generation) return;
3737
+ this.buffer += chunk;
3738
+ if (Buffer.byteLength(this.buffer, "utf8") > MAX_PROTOCOL_LINE_BYTES && !this.buffer.includes("\n")) {
3739
+ this.enqueue(() => this.failGeneration(generation, "invalid_sidecar_protocol"));
3740
+ return;
3741
+ }
3742
+ while (true) {
3743
+ const newline = this.buffer.indexOf("\n");
3744
+ if (newline < 0) return;
3745
+ const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
3746
+ this.buffer = this.buffer.slice(newline + 1);
3747
+ let event;
3748
+ try {
3749
+ event = parseFunnelEvent(line);
3750
+ } catch {
3751
+ this.enqueue(() => this.failGeneration(generation, "invalid_sidecar_protocol"));
3752
+ return;
3753
+ }
3754
+ this.enqueue(() => this.handleEvent(generation, event));
3755
+ }
3756
+ }
3757
+ async handleEvent(generation, event) {
3758
+ if (generation !== this.generation || !this.enabled) return;
3759
+ if (event.type === "login") {
3760
+ this.publish({
3761
+ enabled: true,
3762
+ state: "needs-login",
3763
+ loginUrl: event.url
3764
+ });
3765
+ return;
3766
+ }
3767
+ if (event.type === "error") {
3768
+ await this.failGeneration(generation, event.code ?? "funnel_failed", event.url);
3769
+ return;
3770
+ }
3771
+ const origin = parseOrigin(event.origin);
3772
+ if (event.type === "ready") {
3773
+ let gateway;
3774
+ try {
3775
+ await this.gatewayValue?.close();
3776
+ this.gatewayValue = void 0;
3777
+ gateway = await this.options.createGateway(origin);
3778
+ } catch {
3779
+ await this.failGeneration(generation, "gateway_start_failed");
3780
+ return;
3781
+ }
3782
+ if (generation !== this.generation || !this.enabled) {
3783
+ await gateway.close();
3784
+ return;
3785
+ }
3786
+ this.gatewayValue = gateway;
3787
+ const address = gateway.address();
3788
+ const child = this.child;
3789
+ if (child === void 0) {
3790
+ await this.failGeneration(generation, "sidecar_stopped");
3791
+ return;
3792
+ }
3793
+ child.stdin.write(`${JSON.stringify({
3794
+ version: 1,
3795
+ type: "serve",
3796
+ target: `http://${address.host}:${String(address.port)}`
3797
+ })}\n`, (error) => {
3798
+ if (error !== null && error !== void 0) this.enqueue(() => this.failGeneration(generation, "control_channel_failed"));
3799
+ });
3800
+ this.publish({
3801
+ enabled: true,
3802
+ state: "connecting",
3803
+ origin
3804
+ });
3805
+ return;
3806
+ }
3807
+ this.publish({
3808
+ enabled: true,
3809
+ state: "ready",
3810
+ origin
3811
+ });
3812
+ }
3813
+ async failGeneration(generation, code, setupUrl) {
3814
+ if (generation !== this.generation) return;
3815
+ await this.stopProcessAndGateway();
3816
+ if (this.enabled) this.publish({
3817
+ enabled: true,
3818
+ state: "error",
3819
+ errorCode: code,
3820
+ ...setupUrl === void 0 ? {} : { setupUrl }
3821
+ });
3822
+ }
3823
+ async stop() {
3824
+ ++this.generation;
3825
+ await this.stopProcessAndGateway();
3826
+ }
3827
+ async stopProcessAndGateway() {
3828
+ const child = this.child;
3829
+ this.child = void 0;
3830
+ child?.stdin.end();
3831
+ if (child !== void 0 && child.exitCode === null) {
3832
+ child.kill("SIGTERM");
3833
+ await new Promise((resolveClose) => {
3834
+ let completed = false;
3835
+ const finish = () => {
3836
+ if (completed) return;
3837
+ completed = true;
3838
+ clearTimeout(timer);
3839
+ resolveClose();
3840
+ };
3841
+ const timer = setTimeout(() => {
3842
+ if (child.exitCode === null) child.kill("SIGKILL");
3843
+ finish();
3844
+ }, 1500);
3845
+ timer.unref();
3846
+ child.once("close", finish);
3847
+ });
3848
+ }
3849
+ const gateway = this.gatewayValue;
3850
+ this.gatewayValue = void 0;
3851
+ await gateway?.close();
3852
+ }
3853
+ };
3854
+ /** Locate the current platform's bundled Funnel executable, with one local development override. */
3855
+ function funnelExecutable(importMetaUrl, environment = process.env) {
3856
+ const override = environment.DSH_MOBILE_FUNNEL_SIDECAR;
3857
+ if (override !== void 0) {
3858
+ if (!isAbsolute(override)) throw new Error("DSH_MOBILE_FUNNEL_SIDECAR must be an absolute path");
3859
+ return resolve(override);
3860
+ }
3861
+ const suffix = process.platform === "win32" ? ".exe" : "";
3862
+ const file = `dsh-mobile-funnel-${process.platform}-${process.arch}${suffix}`;
3863
+ return resolve(fileURLToPath(new URL(`../bin/${file}`, importMetaUrl)));
3864
+ }
3865
+ //#endregion
3866
+ //#region src/cpolar.ts
3867
+ const MAX_LOG_BUFFER_BYTES = 65536;
3868
+ const START_TIMEOUT_MS = 45e3;
3869
+ const CPOLAR_HOST_SUFFIXES = Object.freeze([
3870
+ ".cpolar.cn",
3871
+ ".cpolar.io",
3872
+ ".cpolar.top",
3873
+ ".cpolar.com"
3874
+ ]);
3875
+ function publicStatus(status) {
3876
+ return Object.freeze({
3877
+ enabled: status.enabled,
3878
+ state: status.state,
3879
+ ...status.origin === void 0 ? {} : { origin: status.origin },
3880
+ ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode }
3881
+ });
3882
+ }
3883
+ function isCpolarHost(hostname) {
3884
+ return CPOLAR_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
3885
+ }
3886
+ /** Extract a validated public HTTPS origin from one cpolar log line. */
3887
+ function parseCpolarOrigin(line) {
3888
+ if (!line.includes("Tunnel established at ")) return void 0;
3889
+ const match = /Tunnel established at (https:\/\/[^"\s]+)/u.exec(line);
3890
+ if (match === null) return void 0;
3891
+ let url;
3892
+ try {
3893
+ url = new URL(match[1]);
3894
+ } catch {
3895
+ throw new Error("invalid_cpolar_origin");
3896
+ }
3897
+ if (url.protocol !== "https:" || url.port !== "" || !isCpolarHost(url.hostname) || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") throw new Error("invalid_cpolar_origin");
3898
+ return url.origin;
3899
+ }
3900
+ async function reserveLoopbackPort() {
3901
+ const server = createServer((socket) => {
3902
+ socket.destroy();
3903
+ });
3904
+ await new Promise((resolveListen, reject) => {
3905
+ server.once("error", reject);
3906
+ server.listen(0, "127.0.0.1", () => {
3907
+ server.off("error", reject);
3908
+ resolveListen();
3909
+ });
3910
+ });
3911
+ const address = server.address();
3912
+ if (address === null || typeof address === "string") {
3913
+ server.close();
3914
+ throw new Error("cpolar_port_reservation_failed");
3915
+ }
3916
+ let released = false;
3917
+ return {
3918
+ port: address.port,
3919
+ release: async () => {
3920
+ if (released) return;
3921
+ released = true;
3922
+ await new Promise((resolveClose) => {
3923
+ server.close(() => resolveClose());
3924
+ });
3925
+ }
3926
+ };
3927
+ }
3928
+ function withoutProxyEnvironment(environment) {
3929
+ const blocked = /* @__PURE__ */ new Set([
3930
+ "HTTP_PROXY",
3931
+ "HTTPS_PROXY",
3932
+ "ALL_PROXY",
3933
+ "NO_PROXY"
3934
+ ]);
3935
+ return Object.fromEntries(Object.entries(environment).filter(([name]) => !blocked.has(name.toUpperCase())));
3936
+ }
3937
+ /** Owns an installed cpolar client and a provider-specific DSH remote gateway. */
3938
+ var CpolarController = class {
3939
+ options;
3940
+ enabled = false;
3941
+ initialized = false;
3942
+ disposed = false;
3943
+ child;
3944
+ gatewayValue;
3945
+ reservation;
3946
+ generation = 0;
3947
+ buffer = "";
3948
+ latest = publicStatus({
3949
+ enabled: false,
3950
+ state: "off"
3951
+ });
3952
+ queue = Promise.resolve();
3953
+ startupTimer;
3954
+ constructor(options) {
3955
+ this.options = options;
3956
+ if (!isAbsolute(options.executable) || !isAbsolute(options.configFile)) throw new Error("cpolar paths must be absolute");
3957
+ if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
3958
+ }
3959
+ /** Restore the remembered cpolar switch independently from LAN and Funnel state. */
3960
+ async initialize() {
3961
+ const state = await this.options.store.load();
3962
+ this.enabled = state.enabled;
3963
+ this.initialized = true;
3964
+ if (this.enabled) await this.start();
3965
+ else this.publish({
3966
+ enabled: false,
3967
+ state: "off"
3968
+ });
3969
+ }
3970
+ /** Return the active cpolar-backed DSH gateway. */
3971
+ gateway() {
3972
+ return this.gatewayValue;
3973
+ }
3974
+ /** Return state safe for the desktop control UI. */
3975
+ status() {
3976
+ return publicStatus(this.latest);
3977
+ }
3978
+ /** Enable or disable cpolar without changing LAN or Tailscale state. */
3979
+ async setEnabled(enabled) {
3980
+ if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
3981
+ await this.enqueue(async () => {
3982
+ if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
3983
+ if (!enabled) await this.stop();
3984
+ this.enabled = enabled;
3985
+ await this.options.store.save({
3986
+ version: 1,
3987
+ enabled
3988
+ });
3989
+ if (enabled) await this.start();
3990
+ else this.publish({
3991
+ enabled: false,
3992
+ state: "off"
3993
+ });
3994
+ });
3995
+ return this.status();
3996
+ }
3997
+ /** Restart cpolar while retaining its account configuration and DSH device store. */
3998
+ async reconnect() {
3999
+ if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
4000
+ await this.enqueue(async () => {
4001
+ if (!this.enabled) {
4002
+ this.enabled = true;
4003
+ await this.options.store.save({
4004
+ version: 1,
4005
+ enabled: true
4006
+ });
4007
+ }
4008
+ await this.stop();
4009
+ await this.start();
4010
+ });
4011
+ return this.status();
4012
+ }
4013
+ /** Disable cpolar without modifying the user's cpolar account or global tunnels. */
4014
+ async reset() {
4015
+ if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
4016
+ await this.enqueue(async () => {
4017
+ await this.stop();
4018
+ this.enabled = false;
4019
+ await this.options.store.save({
4020
+ version: 1,
4021
+ enabled: false
4022
+ });
4023
+ this.publish({
4024
+ enabled: false,
4025
+ state: "off"
4026
+ });
4027
+ });
4028
+ return this.status();
4029
+ }
4030
+ /** Stop owned resources without changing the remembered switch. */
4031
+ async close() {
4032
+ if (this.disposed) return;
4033
+ this.disposed = true;
4034
+ await this.enqueue(() => this.stop());
4035
+ }
4036
+ enqueue(operation) {
4037
+ const task = this.queue.then(operation, operation);
4038
+ this.queue = task.then(() => void 0, () => void 0);
4039
+ return task;
4040
+ }
4041
+ publish(status) {
4042
+ this.latest = publicStatus(status);
4043
+ try {
4044
+ this.options.onStatus?.(this.status());
4045
+ } catch {}
4046
+ }
4047
+ async start() {
4048
+ const generation = ++this.generation;
4049
+ let executableEntry;
4050
+ try {
4051
+ executableEntry = await lstat(this.options.executable);
4052
+ } catch {
4053
+ this.publish({
4054
+ enabled: true,
4055
+ state: "unavailable",
4056
+ errorCode: "cpolar_component_missing"
4057
+ });
4058
+ return;
4059
+ }
4060
+ if (!executableEntry.isFile() || executableEntry.isSymbolicLink()) {
4061
+ this.publish({
4062
+ enabled: true,
4063
+ state: "unavailable",
4064
+ errorCode: "cpolar_component_invalid"
4065
+ });
4066
+ return;
4067
+ }
4068
+ let configEntry;
4069
+ try {
4070
+ configEntry = await lstat(this.options.configFile);
4071
+ } catch {
4072
+ this.publish({
4073
+ enabled: true,
4074
+ state: "unavailable",
4075
+ errorCode: "cpolar_config_missing"
4076
+ });
4077
+ return;
4078
+ }
4079
+ if (!configEntry.isFile() || configEntry.isSymbolicLink()) {
4080
+ this.publish({
4081
+ enabled: true,
4082
+ state: "unavailable",
4083
+ errorCode: "cpolar_config_invalid"
4084
+ });
4085
+ return;
4086
+ }
4087
+ let reservation;
4088
+ try {
4089
+ reservation = await reserveLoopbackPort();
4090
+ } catch {
4091
+ this.publish({
4092
+ enabled: true,
4093
+ state: "error",
4094
+ errorCode: "cpolar_port_unavailable"
4095
+ });
4096
+ return;
4097
+ }
4098
+ this.reservation = reservation;
4099
+ this.buffer = "";
4100
+ this.publish({
4101
+ enabled: true,
4102
+ state: "starting"
4103
+ });
4104
+ const args = [
4105
+ "http",
4106
+ `-config=${resolve(this.options.configFile)}`,
4107
+ `-region=${this.options.region ?? "cn"}`,
4108
+ "-inspect-addr=false",
4109
+ "-redirect-https=true",
4110
+ "-log=stdout",
4111
+ "-log-level=INFO",
4112
+ String(reservation.port)
4113
+ ];
4114
+ const child = spawn(this.options.executable, args, {
4115
+ env: withoutProxyEnvironment(process.env),
4116
+ shell: false,
4117
+ stdio: [
4118
+ "pipe",
4119
+ "pipe",
4120
+ "pipe"
4121
+ ],
4122
+ windowsHide: true
4123
+ });
4124
+ this.child = child;
4125
+ child.stdout.setEncoding("utf8");
4126
+ child.stderr.setEncoding("utf8");
4127
+ child.stdout.on("data", (chunk) => {
4128
+ this.consume(generation, String(chunk));
4129
+ });
4130
+ child.stderr.on("data", (chunk) => {
4131
+ this.consume(generation, String(chunk));
4132
+ });
4133
+ child.once("error", () => {
4134
+ this.enqueue(() => this.failGeneration(generation, "cpolar_launch_failed"));
4135
+ });
4136
+ child.once("close", (code) => {
4137
+ if (generation !== this.generation || this.child !== child) return;
4138
+ this.child = void 0;
4139
+ if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "cpolar_stopped" : "cpolar_exited"));
4140
+ });
4141
+ this.startupTimer = setTimeout(() => {
4142
+ this.enqueue(() => this.failGeneration(generation, "cpolar_start_timeout"));
4143
+ }, START_TIMEOUT_MS);
4144
+ this.startupTimer.unref();
4145
+ }
4146
+ consume(generation, chunk) {
4147
+ if (generation !== this.generation) return;
4148
+ this.buffer += chunk;
4149
+ if (Buffer.byteLength(this.buffer, "utf8") > MAX_LOG_BUFFER_BYTES && !this.buffer.includes("\n")) {
4150
+ this.enqueue(() => this.failGeneration(generation, "cpolar_invalid_output"));
4151
+ return;
4152
+ }
4153
+ while (true) {
4154
+ const newline = this.buffer.indexOf("\n");
4155
+ if (newline < 0) return;
4156
+ const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
4157
+ this.buffer = this.buffer.slice(newline + 1);
4158
+ let origin;
4159
+ try {
4160
+ origin = parseCpolarOrigin(line);
4161
+ } catch {
4162
+ this.enqueue(() => this.failGeneration(generation, "cpolar_invalid_origin"));
4163
+ return;
4164
+ }
4165
+ if (origin !== void 0) this.enqueue(() => this.attachGateway(generation, origin));
4166
+ }
4167
+ }
4168
+ async attachGateway(generation, origin) {
4169
+ if (generation !== this.generation || !this.enabled || this.gatewayValue !== void 0) return;
4170
+ const reservation = this.reservation;
4171
+ if (reservation === void 0) return;
4172
+ this.publish({
4173
+ enabled: true,
4174
+ state: "connecting",
4175
+ origin
4176
+ });
4177
+ await reservation.release();
4178
+ if (this.reservation === reservation) this.reservation = void 0;
4179
+ let gateway;
4180
+ try {
4181
+ gateway = await this.options.createGateway(origin, reservation.port);
4182
+ } catch {
4183
+ await this.failGeneration(generation, "gateway_start_failed");
4184
+ return;
4185
+ }
4186
+ if (generation !== this.generation || !this.enabled) {
4187
+ await gateway.close();
4188
+ return;
4189
+ }
4190
+ this.gatewayValue = gateway;
4191
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
4192
+ this.startupTimer = void 0;
4193
+ this.publish({
4194
+ enabled: true,
4195
+ state: "ready",
4196
+ origin
4197
+ });
4198
+ }
4199
+ async failGeneration(generation, code) {
4200
+ if (generation !== this.generation) return;
4201
+ await this.stopProcessAndGateway();
4202
+ if (this.enabled) this.publish({
4203
+ enabled: true,
4204
+ state: "error",
4205
+ errorCode: code
4206
+ });
4207
+ }
4208
+ async stop() {
4209
+ ++this.generation;
4210
+ await this.stopProcessAndGateway();
4211
+ }
4212
+ async stopProcessAndGateway() {
4213
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
4214
+ this.startupTimer = void 0;
4215
+ const reservation = this.reservation;
4216
+ this.reservation = void 0;
4217
+ await reservation?.release();
4218
+ const child = this.child;
4219
+ this.child = void 0;
4220
+ if (child !== void 0 && child.exitCode === null) {
4221
+ child.kill("SIGTERM");
4222
+ await new Promise((resolveClose) => {
4223
+ let completed = false;
4224
+ const finish = () => {
4225
+ if (completed) return;
4226
+ completed = true;
4227
+ clearTimeout(timer);
4228
+ resolveClose();
4229
+ };
4230
+ const timer = setTimeout(() => {
4231
+ if (child.exitCode === null) child.kill("SIGKILL");
4232
+ finish();
4233
+ }, 1500);
4234
+ timer.unref();
4235
+ child.once("close", finish);
4236
+ });
4237
+ }
4238
+ const gateway = this.gatewayValue;
4239
+ this.gatewayValue = void 0;
4240
+ await gateway?.close();
4241
+ }
4242
+ };
4243
+ //#endregion
4244
+ //#region src/cpolar-component.ts
4245
+ /** Pinned cpolar Windows component fetched only after an explicit user action. */
4246
+ const CPOLAR_COMPONENT_RELEASE = Object.freeze({
4247
+ version: "3.3.18",
4248
+ platform: "win32",
4249
+ arch: "x64",
4250
+ downloadUrl: "https://www.cpolar.com/static/downloads/releases/3.3.18/cpolar-stable-windows-amd64-setup.zip",
4251
+ downloadBytes: 7603505,
4252
+ downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
4253
+ executableBytes: 19637680,
4254
+ executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
4255
+ downloadPage: "https://www.cpolar.com/download",
4256
+ signupUrl: "https://dashboard.cpolar.com/signup",
4257
+ dashboardUrl: "https://dashboard.cpolar.com/auth",
4258
+ termsUrl: "https://www.cpolar.com/tos"
4259
+ });
4260
+ function inside(parent, child) {
4261
+ const candidate = relative(parent, child);
4262
+ return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
4263
+ }
4264
+ async function sha256(file) {
4265
+ return createHash("sha256").update(await readFile(file)).digest("hex");
4266
+ }
4267
+ async function regularFile(file, expectedBytes) {
4268
+ try {
4269
+ const stat = await lstat(file);
4270
+ return stat.isFile() && !stat.isSymbolicLink() && (expectedBytes === void 0 || stat.size === expectedBytes);
4271
+ } catch (error) {
4272
+ if (error.code === "ENOENT") return false;
4273
+ throw error;
4274
+ }
4275
+ }
4276
+ async function run(file, args) {
4277
+ await new Promise((resolveRun, reject) => {
4278
+ execFile(file, [...args], {
4279
+ windowsHide: true,
4280
+ timeout: 12e4
4281
+ }, (error) => {
4282
+ if (error === null) resolveRun();
4283
+ else reject(error);
4284
+ });
4285
+ });
4286
+ }
4287
+ async function defaultFetchArtifact(url, signal) {
4288
+ const response = await fetch(url, {
4289
+ redirect: "error",
4290
+ signal
4291
+ });
4292
+ if (!response.ok) throw new Error(`cpolar_download_http_${String(response.status)}`);
4293
+ const length = Number(response.headers.get("content-length"));
4294
+ if (Number.isFinite(length) && length !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
4295
+ const bytes = new Uint8Array(await response.arrayBuffer());
4296
+ if (bytes.byteLength !== CPOLAR_COMPONENT_RELEASE.downloadBytes) throw new Error("cpolar_download_size_mismatch");
4297
+ return bytes;
4298
+ }
4299
+ async function defaultExtractArtifact(archive, destination) {
4300
+ if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
4301
+ const unpacked = join(destination, "archive");
4302
+ const administrative = join(destination, "administrative");
4303
+ await mkdir(unpacked, {
4304
+ recursive: true,
4305
+ mode: 448
4306
+ });
4307
+ await mkdir(administrative, {
4308
+ recursive: true,
4309
+ mode: 448
4310
+ });
4311
+ await run("tar.exe", [
4312
+ "-xf",
4313
+ archive,
4314
+ "-C",
4315
+ unpacked
4316
+ ]);
4317
+ const msiRelative = (await readdir(unpacked, { recursive: true })).find((entry) => entry.toLowerCase().endsWith(".msi"));
4318
+ if (msiRelative === void 0) throw new Error("cpolar_installer_missing");
4319
+ await run("msiexec.exe", [
4320
+ "/a",
4321
+ join(unpacked, msiRelative),
4322
+ "/qn",
4323
+ `TARGETDIR=${administrative}`
4324
+ ]);
4325
+ const executableRelative = (await readdir(administrative, { recursive: true })).find((entry) => basename(entry).toLowerCase() === "cpolar.exe");
4326
+ if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
4327
+ await copyFile(join(administrative, executableRelative), join(destination, "cpolar.exe"));
4328
+ }
4329
+ /** Validate a cpolar Authtoken before it crosses the durable-file boundary. */
4330
+ function validateCpolarAuthtoken(value) {
4331
+ if (typeof value !== "string" || value.length < 20 || value.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(value)) throw new Error("cpolar_authtoken_invalid");
4332
+ return value;
4333
+ }
4334
+ /** Owns the optional cpolar binary and account configuration inside DSH Mobile state. */
4335
+ var CpolarComponentManager = class {
4336
+ executable;
4337
+ configFile;
4338
+ componentRoot;
4339
+ componentStorage;
4340
+ stateRoot;
4341
+ logRoot;
4342
+ stagingRoot;
4343
+ platform;
4344
+ arch;
4345
+ fetchArtifact;
4346
+ extractArtifact;
4347
+ installed = false;
4348
+ configured = false;
4349
+ errorCode;
4350
+ queue = Promise.resolve();
4351
+ constructor(options) {
4352
+ const stateDirectory = resolve(options.stateDirectory);
4353
+ if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
4354
+ this.platform = options.platform ?? process.platform;
4355
+ this.arch = options.arch ?? process.arch;
4356
+ this.componentRoot = join(stateDirectory, "components", "cpolar");
4357
+ this.componentStorage = join(this.componentRoot, CPOLAR_COMPONENT_RELEASE.version);
4358
+ this.executable = join(this.componentStorage, "cpolar.exe");
4359
+ this.stateRoot = join(stateDirectory, "state", "cpolar");
4360
+ this.configFile = join(this.stateRoot, "cpolar.yml");
4361
+ this.logRoot = join(stateDirectory, "logs", "cpolar");
4362
+ this.stagingRoot = join(stateDirectory, "staging", "cpolar");
4363
+ for (const child of [
4364
+ this.componentRoot,
4365
+ this.componentStorage,
4366
+ this.stateRoot,
4367
+ this.logRoot,
4368
+ this.stagingRoot
4369
+ ]) if (!inside(stateDirectory, child)) throw new Error("cpolar component path escaped its state directory");
4370
+ this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
4371
+ this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact;
4372
+ }
4373
+ /** Inspect the managed binary and configuration without using global cpolar state. */
4374
+ async initialize() {
4375
+ this.installed = await regularFile(this.executable, CPOLAR_COMPONENT_RELEASE.executableBytes);
4376
+ if (this.installed && await sha256(this.executable) !== CPOLAR_COMPONENT_RELEASE.executableSha256) {
4377
+ this.installed = false;
4378
+ this.errorCode = "cpolar_component_invalid";
4379
+ }
4380
+ this.configured = await regularFile(this.configFile);
4381
+ if (this.configured) await restrictPrivateFile(this.configFile);
4382
+ }
4383
+ /** Return a safe status that never includes the account token. */
4384
+ status() {
4385
+ return Object.freeze({
4386
+ supported: this.platform === CPOLAR_COMPONENT_RELEASE.platform && this.arch === CPOLAR_COMPONENT_RELEASE.arch,
4387
+ installed: this.installed,
4388
+ configured: this.configured,
4389
+ version: CPOLAR_COMPONENT_RELEASE.version,
4390
+ downloadBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
4391
+ installedBytes: CPOLAR_COMPONENT_RELEASE.executableBytes,
4392
+ sourceUrl: CPOLAR_COMPONENT_RELEASE.downloadUrl,
4393
+ downloadPage: CPOLAR_COMPONENT_RELEASE.downloadPage,
4394
+ signupUrl: CPOLAR_COMPONENT_RELEASE.signupUrl,
4395
+ dashboardUrl: CPOLAR_COMPONENT_RELEASE.dashboardUrl,
4396
+ termsUrl: CPOLAR_COMPONENT_RELEASE.termsUrl,
4397
+ storagePath: this.componentRoot,
4398
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
4399
+ });
4400
+ }
4401
+ /** Download, verify, and administratively extract cpolar after explicit confirmation. */
4402
+ install() {
4403
+ return this.enqueue(async () => {
4404
+ if (this.platform !== CPOLAR_COMPONENT_RELEASE.platform || this.arch !== CPOLAR_COMPONENT_RELEASE.arch) throw new Error("cpolar_component_unsupported");
4405
+ await mkdir(this.stagingRoot, {
4406
+ recursive: true,
4407
+ mode: 448
4408
+ });
4409
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
4410
+ try {
4411
+ const controller = new AbortController();
4412
+ const timeout = setTimeout(() => {
4413
+ controller.abort();
4414
+ }, 12e4);
4415
+ timeout.unref();
4416
+ let bytes;
4417
+ try {
4418
+ bytes = await this.fetchArtifact(CPOLAR_COMPONENT_RELEASE.downloadUrl, controller.signal);
4419
+ } finally {
4420
+ clearTimeout(timeout);
4421
+ }
4422
+ if (createHash("sha256").update(bytes).digest("hex") !== CPOLAR_COMPONENT_RELEASE.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
4423
+ const archive = join(staging, "cpolar.zip");
4424
+ await writeFile(archive, bytes, {
4425
+ flag: "wx",
4426
+ mode: 384
4427
+ });
4428
+ await this.extractArtifact(archive, staging);
4429
+ const extracted = join(staging, "cpolar.exe");
4430
+ if (!await regularFile(extracted, CPOLAR_COMPONENT_RELEASE.executableBytes) || await sha256(extracted) !== CPOLAR_COMPONENT_RELEASE.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
4431
+ const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
4432
+ await mkdir(candidate, {
4433
+ recursive: true,
4434
+ mode: 448
4435
+ });
4436
+ await copyFile(extracted, join(candidate, "cpolar.exe"));
4437
+ await chmod(join(candidate, "cpolar.exe"), 448);
4438
+ await rm(this.componentStorage, {
4439
+ recursive: true,
4440
+ force: true
4441
+ });
4442
+ await rename(candidate, this.componentStorage);
4443
+ this.installed = true;
4444
+ this.errorCode = void 0;
4445
+ } finally {
4446
+ await rm(staging, {
4447
+ recursive: true,
4448
+ force: true
4449
+ });
4450
+ }
4451
+ });
4452
+ }
4453
+ /** Store only the cpolar token in a private, self-update-disabled configuration. */
4454
+ configure(authtoken) {
4455
+ return this.enqueue(async () => {
4456
+ const token = validateCpolarAuthtoken(authtoken);
4457
+ await mkdir(this.stateRoot, {
4458
+ recursive: true,
4459
+ mode: 448
4460
+ });
4461
+ const temporary = join(this.stateRoot, `.cpolar.${randomBytes(12).toString("hex")}.tmp`);
4462
+ const body = `authtoken: ${JSON.stringify(token)}\nconsole_ui: false\nupdate: false\ninspect_db_size: -1\n`;
4463
+ try {
4464
+ await writeFile(temporary, body, {
4465
+ encoding: "utf8",
4466
+ flag: "wx",
4467
+ mode: 384
4468
+ });
4469
+ await rename(temporary, this.configFile);
4470
+ await restrictPrivateFile(this.configFile);
4471
+ } catch (error) {
4472
+ await rm(temporary, { force: true });
4473
+ throw error;
4474
+ }
4475
+ this.configured = true;
4476
+ this.errorCode = void 0;
4477
+ });
4478
+ }
4479
+ /** Remove every cpolar file owned by DSH Mobile without touching global state. */
4480
+ purge() {
4481
+ return this.enqueue(async () => {
4482
+ await Promise.all([
4483
+ rm(this.componentRoot, {
4484
+ recursive: true,
4485
+ force: true
4486
+ }),
4487
+ rm(this.stateRoot, {
4488
+ recursive: true,
4489
+ force: true
4490
+ }),
4491
+ rm(this.logRoot, {
4492
+ recursive: true,
4493
+ force: true
4494
+ }),
4495
+ rm(this.stagingRoot, {
4496
+ recursive: true,
4497
+ force: true
4498
+ })
4499
+ ]);
4500
+ this.installed = false;
4501
+ this.configured = false;
4502
+ this.errorCode = void 0;
4503
+ });
4504
+ }
4505
+ enqueue(operation) {
4506
+ const task = this.queue.then(operation, operation);
4507
+ this.queue = task.then(() => void 0, () => void 0);
4508
+ return task.then(() => this.status());
4509
+ }
4510
+ };
4511
+ //#endregion
4512
+ //#region src/remote.ts
4513
+ /** Validate the provider selection loaded across the filesystem boundary. */
4514
+ function parseRemoteProviderState(value) {
4515
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("remote provider state must be an object");
4516
+ const record = value;
4517
+ if (record.version !== 1 || record.provider !== "tailscale" && record.provider !== "cpolar" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "provider")) throw new Error("remote provider state has an unsupported format");
4518
+ return Object.freeze({
4519
+ version: 1,
4520
+ provider: record.provider
4521
+ });
4522
+ }
4523
+ /** Atomic selection store whose absent-file state uses the configured default. */
4524
+ var JsonRemoteProviderStore = class {
4525
+ file;
4526
+ defaultProvider;
4527
+ constructor(file, defaultProvider) {
4528
+ this.file = file;
4529
+ this.defaultProvider = defaultProvider;
4530
+ }
4531
+ async load() {
4532
+ let stat;
4533
+ try {
4534
+ stat = await lstat(this.file);
4535
+ } catch (error) {
4536
+ if (error.code === "ENOENT") return Object.freeze({
4537
+ version: 1,
4538
+ provider: this.defaultProvider
4539
+ });
4540
+ throw error;
4541
+ }
4542
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("remote provider state must be a regular file no larger than 4 KiB");
4543
+ await restrictPrivateFile(this.file);
4544
+ let parsed;
4545
+ try {
4546
+ parsed = JSON.parse(await readFile(this.file, "utf8"));
4547
+ } catch (error) {
4548
+ throw new Error("remote provider state is not valid JSON", { cause: error });
4549
+ }
4550
+ return parseRemoteProviderState(parsed);
4551
+ }
4552
+ async save(state) {
4553
+ const validated = parseRemoteProviderState(state);
4554
+ const directory = dirname(this.file);
4555
+ await mkdir(directory, {
4556
+ recursive: true,
4557
+ mode: 448
4558
+ });
4559
+ try {
4560
+ const current = await lstat(this.file);
4561
+ if (!current.isFile() || current.isSymbolicLink()) throw new Error("remote provider state target must remain a regular file");
4562
+ } catch (error) {
4563
+ if (error.code !== "ENOENT") throw error;
4564
+ }
4565
+ const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
4566
+ try {
4567
+ await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
4568
+ encoding: "utf8",
4569
+ flag: "wx",
4570
+ mode: 384
4571
+ });
4572
+ await rename(temporary, this.file);
4573
+ await restrictPrivateFile(this.file);
4574
+ } catch (error) {
4575
+ await rm(temporary, { force: true });
4576
+ throw error;
4577
+ }
4578
+ }
4579
+ };
4580
+ /** Resolve the first-run provider without letting environment values bypass validation. */
4581
+ function configuredRemoteProvider(environment) {
4582
+ const value = environment.DSH_MOBILE_REMOTE_PROVIDER ?? "tailscale";
4583
+ if (value !== "tailscale" && value !== "cpolar") throw new Error("DSH_MOBILE_REMOTE_PROVIDER must be tailscale or cpolar");
4584
+ return value;
4585
+ }
3315
4586
  promisify(execFile);
3316
4587
  const VIRTUAL_INTERFACE_MARKERS = [
3317
4588
  "bridge",
@@ -3445,11 +4716,12 @@ async function atomicWrite(file, contents) {
3445
4716
  });
3446
4717
  const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
3447
4718
  await writeFile(temporary, contents, { mode: 384 });
3448
- await chmod(temporary, 384);
3449
4719
  await rename(temporary, file);
4720
+ await restrictPrivateFile(file);
3450
4721
  }
3451
4722
  /** Sign and atomically install a server leaf for the interface's current address. */
3452
4723
  async function refreshManagedServerCertificate(setup, address) {
4724
+ await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
3453
4725
  const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
3454
4726
  assertMatchingCa(caCert, caKey);
3455
4727
  const now = /* @__PURE__ */ new Date();
@@ -3530,6 +4802,8 @@ function mapAdminError(error) {
3530
4802
  if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
3531
4803
  if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
3532
4804
  if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
4805
+ if (error instanceof Error && error.message === "cpolar_authtoken_invalid") return new HttpError(400, "cpolar_authtoken_invalid");
4806
+ if (error instanceof Error && error.message.startsWith("cpolar_")) return new HttpError(409, error.message);
3533
4807
  return new HttpError(500, "internal_error");
3534
4808
  }
3535
4809
  const SETUP_KEYS = /* @__PURE__ */ new Set([
@@ -3588,11 +4862,77 @@ async function loadSetup(config) {
3588
4862
  }
3589
4863
  };
3590
4864
  }
4865
+ function loopbackTemplate(loaded) {
4866
+ return parseGatewayConfig({
4867
+ ...withoutSetupKeys(loaded.config),
4868
+ ...loaded.kind === "managed" ? { upstreamOrigin: loaded.setup.upstreamOrigin } : loaded.config.upstreamOrigin === void 0 ? {} : { upstreamOrigin: loaded.config.upstreamOrigin },
4869
+ listenHost: "127.0.0.1",
4870
+ listenPort: 0,
4871
+ publicAuthorities: ["127.0.0.1"],
4872
+ allowedCidrs: ["127.0.0.0/8"],
4873
+ tls: { mode: "disabled" }
4874
+ });
4875
+ }
4876
+ async function stableInstanceId(loaded, template) {
4877
+ if (loaded.kind !== "managed") return loaded.config.instanceId ?? template.instanceId;
4878
+ return new X509Certificate(await readFile(loaded.setup.tls.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
4879
+ }
4880
+ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0) {
4881
+ const origin = new URL(publicOrigin);
4882
+ if (origin.protocol !== "https:" || origin.username !== "" || origin.password !== "" || origin.pathname !== "/" || origin.search !== "" || origin.hash !== "") throw new Error("remote public origin must be an HTTPS origin");
4883
+ const publicAuthority = origin.port === "" ? `${origin.hostname}:443` : origin.host;
4884
+ const { pairingCaFile: _pairingCaFile, ...shared } = template;
4885
+ return Object.freeze({
4886
+ ...shared,
4887
+ listenHost: "127.0.0.1",
4888
+ listenPort,
4889
+ authorities: Object.freeze([parseAuthority(publicAuthority)]),
4890
+ allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
4891
+ stateFile,
4892
+ instanceId,
4893
+ tls: Object.freeze({ mode: "disabled" }),
4894
+ publicTls: true,
4895
+ discovery: false
4896
+ });
4897
+ }
4898
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent) {
4899
+ return {
4900
+ provider,
4901
+ running: status.enabled,
4902
+ state: status.state,
4903
+ ...status.origin === void 0 ? {} : { origin: status.origin },
4904
+ ...status.loginUrl === void 0 ? {} : { loginUrl: status.loginUrl },
4905
+ ...status.setupUrl === void 0 ? {} : { setupUrl: status.setupUrl },
4906
+ ...status.errorCode === void 0 ? {} : { errorCode: status.errorCode },
4907
+ ...gateway === void 0 ? {} : { extensions: gateway.extensionStatus() },
4908
+ providers: {
4909
+ tailscale: {
4910
+ bundled: true,
4911
+ running: providerStatuses.tailscale.enabled,
4912
+ state: providerStatuses.tailscale.state
4913
+ },
4914
+ cpolar: {
4915
+ bundled: false,
4916
+ running: providerStatuses.cpolar.enabled,
4917
+ state: providerStatuses.cpolar.state,
4918
+ component: cpolarComponent
4919
+ }
4920
+ }
4921
+ };
4922
+ }
3591
4923
  /** Mount the resident control route and its optional authenticated LAN gateway. */
3592
4924
  async function apply(ctx, config) {
3593
4925
  assertSupportedDshVersion(installedDshVersion());
3594
4926
  const loaded = await loadSetup(config);
3595
4927
  const mobileAccess = createMobileAccessService(ctx);
4928
+ const template = loopbackTemplate(loaded);
4929
+ const instanceId = await stableInstanceId(loaded, template);
4930
+ const stateDirectory = dirname(template.stateFile);
4931
+ const remoteDirectory = join(stateDirectory, "remote");
4932
+ const remoteProviderStore = new JsonRemoteProviderStore(join(remoteDirectory, "provider.json"), configuredRemoteProvider(process.env));
4933
+ let remoteProvider = (await remoteProviderStore.load()).provider;
4934
+ const cpolarComponent = new CpolarComponentManager({ stateDirectory });
4935
+ await cpolarComponent.initialize();
3596
4936
  const unregisterBuiltin = mobileAccess.registerExtension({
3597
4937
  schemaVersion: 1,
3598
4938
  id: "computer-images",
@@ -3623,22 +4963,15 @@ async function apply(ctx, config) {
3623
4963
  }
3624
4964
  }]
3625
4965
  });
3626
- let gateway;
4966
+ let lanGateway;
3627
4967
  const startGateway = async (candidateConfig) => {
3628
4968
  const resolved = parseGatewayConfig(candidateConfig);
3629
- await mobileAccess.startLocal(resolved.extensionsDir, ctx);
3630
4969
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess);
3631
- try {
3632
- await candidate.start();
3633
- } catch (error) {
3634
- await mobileAccess.stopLocal();
3635
- throw error;
3636
- }
3637
- gateway = candidate;
4970
+ await candidate.start();
4971
+ lanGateway = candidate;
3638
4972
  return { close: async () => {
3639
- if (gateway === candidate) gateway = void 0;
4973
+ if (lanGateway === candidate) lanGateway = void 0;
3640
4974
  await candidate.close();
3641
- await mobileAccess.stopLocal();
3642
4975
  } };
3643
4976
  };
3644
4977
  const startRuntime = async () => {
@@ -3658,7 +4991,69 @@ async function apply(ctx, config) {
3658
4991
  await following.initialize(2e3);
3659
4992
  return following;
3660
4993
  };
3661
- const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
4994
+ const lanController = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
4995
+ const remoteDeviceFile = join(remoteDirectory, "devices.json");
4996
+ const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
4997
+ if (remoteProvider === "cpolar") try {
4998
+ await lstat(remoteDeviceFile);
4999
+ } catch (error) {
5000
+ if (error.code !== "ENOENT") throw error;
5001
+ try {
5002
+ await copyFile(legacyCpolarDeviceFile, remoteDeviceFile);
5003
+ } catch (copyError) {
5004
+ if (copyError.code !== "ENOENT") throw copyError;
5005
+ }
5006
+ }
5007
+ const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
5008
+ const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
5009
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess);
5010
+ await candidate.start();
5011
+ return candidate;
5012
+ };
5013
+ const tailscaleStore = new JsonMobileAccessControlStore(join(remoteDirectory, "control.json"), false);
5014
+ const cpolarStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cpolar", "control.json"), false);
5015
+ const remoteControllers = {
5016
+ tailscale: new FunnelController({
5017
+ store: tailscaleStore,
5018
+ executable: funnelExecutable(import.meta.url),
5019
+ stateDirectory: join(remoteDirectory, "tailscale"),
5020
+ hostname: `dsh-${instanceId.slice(0, 12)}`,
5021
+ createGateway: createRemoteGateway
5022
+ }),
5023
+ cpolar: new CpolarController({
5024
+ store: cpolarStore,
5025
+ executable: cpolarComponent.executable,
5026
+ configFile: cpolarComponent.configFile,
5027
+ region: "cn",
5028
+ createGateway: createRemoteGateway
5029
+ })
5030
+ };
5031
+ const remoteController = () => remoteControllers[remoteProvider];
5032
+ const remotePayload = () => remoteControlPayload(remoteProvider, remoteController().status(), remoteController().gateway(), {
5033
+ tailscale: remoteControllers.tailscale.status(),
5034
+ cpolar: remoteControllers.cpolar.status()
5035
+ }, cpolarComponent.status());
5036
+ const selectRemoteProvider = async (provider) => {
5037
+ if (provider === remoteProvider) return;
5038
+ const previous = remoteControllers[remoteProvider];
5039
+ const restore = previous.status().enabled;
5040
+ if (restore) await previous.setEnabled(false);
5041
+ try {
5042
+ await remoteProviderStore.save({
5043
+ version: 1,
5044
+ provider
5045
+ });
5046
+ remoteProvider = provider;
5047
+ } catch (error) {
5048
+ if (restore) await previous.setEnabled(true);
5049
+ throw error;
5050
+ }
5051
+ };
5052
+ const lanPayload = () => ({
5053
+ running: lanController.isRunning(),
5054
+ origin: lanGateway?.address().origin,
5055
+ ...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
5056
+ });
3662
5057
  const adminRoute = {
3663
5058
  kind: "prefix",
3664
5059
  path: LOCAL_ADMIN_PREFIX,
@@ -3667,26 +5062,81 @@ async function apply(ctx, config) {
3667
5062
  const target = parseRequestTarget(request.url);
3668
5063
  assertLocalAdminTrust(request, request.method === "POST");
3669
5064
  if (target.search !== "") throw new HttpError(400, "bad_request");
3670
- if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/control`) {
3671
- sendJson(response, 200, {
3672
- running: controller.isRunning(),
3673
- origin: gateway?.address().origin,
3674
- ...gateway === void 0 ? {} : { extensions: gateway.extensionStatus() }
3675
- }, false);
5065
+ const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
5066
+ if (request.method === "GET" && lanControl) {
5067
+ sendJson(response, 200, lanPayload(), false);
5068
+ return;
5069
+ }
5070
+ if (request.method === "POST" && lanControl) {
5071
+ const body = await readJsonObject(request, 4096);
5072
+ if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
5073
+ await lanController.setRunning(body.running);
5074
+ sendJson(response, 200, lanPayload(), false);
5075
+ return;
5076
+ }
5077
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
5078
+ sendJson(response, 200, remotePayload(), false);
5079
+ return;
5080
+ }
5081
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
5082
+ const body = await readJsonObject(request, 4096);
5083
+ if (body.provider !== "tailscale" && body.provider !== "cpolar") throw new HttpError(400, "bad_request");
5084
+ await selectRemoteProvider(body.provider);
5085
+ sendJson(response, 200, remotePayload(), false);
3676
5086
  return;
3677
5087
  }
3678
- if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/control`) {
5088
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/install`) {
5089
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
5090
+ await cpolarComponent.install();
5091
+ sendJson(response, 200, remotePayload(), false);
5092
+ return;
5093
+ }
5094
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/configure`) {
5095
+ const body = await readJsonObject(request, 4096);
5096
+ await cpolarComponent.configure(body.authtoken);
5097
+ sendJson(response, 200, remotePayload(), false);
5098
+ return;
5099
+ }
5100
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cpolar/component/purge`) {
5101
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
5102
+ await remoteControllers.cpolar.setEnabled(false);
5103
+ await cpolarComponent.purge();
5104
+ sendJson(response, 200, remotePayload(), false);
5105
+ return;
5106
+ }
5107
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/control`) {
3679
5108
  const body = await readJsonObject(request, 4096);
3680
5109
  if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
3681
- await controller.setRunning(body.running);
3682
- sendJson(response, 200, {
3683
- running: controller.isRunning(),
3684
- origin: gateway?.address().origin,
3685
- ...gateway === void 0 ? {} : { extensions: gateway.extensionStatus() }
3686
- }, false);
5110
+ await remoteController().setEnabled(body.running);
5111
+ sendJson(response, 200, remotePayload(), false);
3687
5112
  return;
3688
5113
  }
3689
- const active = gateway;
5114
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reconnect`) {
5115
+ await readJsonObject(request, 4096);
5116
+ await remoteController().reconnect();
5117
+ sendJson(response, 200, remotePayload(), false);
5118
+ return;
5119
+ }
5120
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/reset`) {
5121
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
5122
+ await remoteController().reset();
5123
+ await rm(remoteDeviceFile, { force: true });
5124
+ sendJson(response, 200, remotePayload(), false);
5125
+ return;
5126
+ }
5127
+ if (target.decodedPathname.startsWith(`/api/mobile-access/remote/`)) {
5128
+ const active = remoteController().gateway();
5129
+ if (active === void 0) throw new HttpError(409, "gateway_stopped");
5130
+ await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/remote`).handler(request, response);
5131
+ return;
5132
+ }
5133
+ if (target.decodedPathname.startsWith(`/api/mobile-access/lan/`)) {
5134
+ const active = lanGateway;
5135
+ if (active === void 0) throw new HttpError(409, "gateway_stopped");
5136
+ await active.localAdminRoute(`${LOCAL_ADMIN_PREFIX}/lan`).handler(request, response);
5137
+ return;
5138
+ }
5139
+ const active = lanGateway;
3690
5140
  if (active === void 0) throw new HttpError(409, "gateway_stopped");
3691
5141
  await active.localAdminRoute().handler(request, response);
3692
5142
  } catch (error) {
@@ -3727,20 +5177,36 @@ async function apply(ctx, config) {
3727
5177
  }
3728
5178
  });
3729
5179
  try {
3730
- await controller.initialize();
5180
+ await mobileAccess.startLocal(template.extensionsDir, ctx);
5181
+ await lanController.initialize();
5182
+ if (remoteProvider === "tailscale") await cpolarStore.save({
5183
+ version: 1,
5184
+ enabled: false
5185
+ });
5186
+ else await tailscaleStore.save({
5187
+ version: 1,
5188
+ enabled: false
5189
+ });
5190
+ await remoteControllers.tailscale.initialize();
5191
+ await remoteControllers.cpolar.initialize();
3731
5192
  } catch (error) {
3732
5193
  unregister();
3733
5194
  disposeMobileCommand();
5195
+ await Promise.all([remoteControllers.tailscale.close(), remoteControllers.cpolar.close()]);
5196
+ await lanController.close();
5197
+ await mobileAccess.stopLocal();
3734
5198
  unregisterBuiltin();
3735
5199
  throw error;
3736
5200
  }
3737
5201
  return async () => {
3738
5202
  unregister();
3739
5203
  disposeMobileCommand();
3740
- await controller.close();
5204
+ await Promise.all([remoteControllers.tailscale.close(), remoteControllers.cpolar.close()]);
5205
+ await lanController.close();
5206
+ await mobileAccess.stopLocal();
3741
5207
  unregisterBuiltin();
3742
5208
  };
3743
- }, "dsh-mobile: local control, authenticated LAN gateway, and /mobile command");
5209
+ }, "dsh-mobile: independent LAN and selectable remote access with /mobile command");
3744
5210
  }
3745
5211
  //#endregion
3746
5212
  export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, EXTENSION_LIMITS, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, SUPPORTED_DSH_VERSIONS, WS_PATHS, addressAllowed, apply, assertExtensionId, assertSupportedDshVersion, createMobileAccessService, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority, rewriteMobileIndex };