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