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