dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.en.md +123 -0
- package/README.md +86 -116
- package/SECURITY.md +5 -3
- package/assets/brand/app-icon-master.png +0 -0
- package/assets/brand/repository-hero.png +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/cli.js +317 -80
- package/lib/client.js +623 -206
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +31 -1
- package/lib/index.mjs +716 -23
- package/lib/mobile-layout.js +227 -0
- package/lib/mobile-layout.js.map +1 -0
- package/package.json +19 -13
- package/README.zh.md +0 -206
package/lib/index.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
import z from "@deepseek-ai/schemastery";
|
|
4
6
|
import { connect, isIP } from "node:net";
|
|
5
|
-
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { chmod, lstat, mkdir, opendir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { createSocket } from "node:dgram";
|
|
9
|
+
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
6
10
|
import { createServer, request } from "node:http";
|
|
7
11
|
import { createServer as createServer$1 } from "node:https";
|
|
8
12
|
import { Transform } from "node:stream";
|
|
9
13
|
import { pipeline } from "node:stream/promises";
|
|
14
|
+
import Bonjour from "bonjour-service";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { generate } from "selfsigned";
|
|
10
18
|
//#region src/access.ts
|
|
11
19
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
12
20
|
var AccessError = class extends Error {
|
|
@@ -541,6 +549,9 @@ const Config = z.object({
|
|
|
541
549
|
controlFile: z.string().hidden().required(),
|
|
542
550
|
customCssFile: z.string().hidden(),
|
|
543
551
|
customScriptFile: z.string().hidden(),
|
|
552
|
+
mobileLayoutFile: z.string().hidden(),
|
|
553
|
+
instanceId: z.string().hidden(),
|
|
554
|
+
pairingCaFile: z.string().hidden(),
|
|
544
555
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
545
556
|
tls: z.object({
|
|
546
557
|
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
@@ -662,6 +673,11 @@ function parseGatewayConfig(raw) {
|
|
|
662
673
|
stateFile: absoluteFile(value.stateFile, "stateFile"),
|
|
663
674
|
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
664
675
|
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
676
|
+
mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
|
|
677
|
+
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
678
|
+
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
679
|
+
})(),
|
|
680
|
+
...value.pairingCaFile === void 0 ? {} : { pairingCaFile: absoluteFile(value.pairingCaFile, "pairingCaFile") },
|
|
665
681
|
tls,
|
|
666
682
|
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
667
683
|
deviceTtlMs,
|
|
@@ -679,7 +695,73 @@ function parseGatewayConfig(raw) {
|
|
|
679
695
|
});
|
|
680
696
|
}
|
|
681
697
|
//#endregion
|
|
698
|
+
//#region src/compatibility.ts
|
|
699
|
+
/** DeepSeek Harness prereleases verified by this plugin release. */
|
|
700
|
+
const SUPPORTED_DSH_VERSIONS = Object.freeze(["0.1.0-rc.5", "0.1.0-rc.6"]);
|
|
701
|
+
/**
|
|
702
|
+
* Reject an unverified DeepSeek Harness Host before opening the LAN listener.
|
|
703
|
+
* @param version - Version reported by the installed DSH WebServer package.
|
|
704
|
+
*/
|
|
705
|
+
function assertSupportedDshVersion(version) {
|
|
706
|
+
if (typeof version === "string" && SUPPORTED_DSH_VERSIONS.some((candidate) => candidate === version)) return;
|
|
707
|
+
throw new Error(`unsupported DeepSeek Harness version ${typeof version === "string" ? version : "(unknown)"}; supported versions: ${SUPPORTED_DSH_VERSIONS.join(", ")}`);
|
|
708
|
+
}
|
|
709
|
+
//#endregion
|
|
682
710
|
//#region src/control.ts
|
|
711
|
+
/** Keeps one runtime aligned with a changing network selection. */
|
|
712
|
+
var FollowingMobileAccessRuntime = class {
|
|
713
|
+
select;
|
|
714
|
+
onRefreshError;
|
|
715
|
+
runtime;
|
|
716
|
+
key;
|
|
717
|
+
queue = Promise.resolve();
|
|
718
|
+
closed = false;
|
|
719
|
+
timer;
|
|
720
|
+
constructor(select, onRefreshError) {
|
|
721
|
+
this.select = select;
|
|
722
|
+
this.onRefreshError = onRefreshError;
|
|
723
|
+
}
|
|
724
|
+
/** Start the current selection and optionally poll for later changes. */
|
|
725
|
+
async initialize(refreshIntervalMs) {
|
|
726
|
+
await this.refresh();
|
|
727
|
+
if (refreshIntervalMs === void 0) return;
|
|
728
|
+
this.timer = setInterval(() => {
|
|
729
|
+
this.refresh().catch(this.onRefreshError);
|
|
730
|
+
}, refreshIntervalMs);
|
|
731
|
+
this.timer.unref();
|
|
732
|
+
}
|
|
733
|
+
/** Reconcile the active runtime with the latest selection. */
|
|
734
|
+
refresh() {
|
|
735
|
+
return this.enqueue(async () => {
|
|
736
|
+
if (this.closed) return;
|
|
737
|
+
const selection = await this.select();
|
|
738
|
+
if (this.closed || this.runtime !== void 0 && this.key === selection.key) return;
|
|
739
|
+
const previous = this.runtime;
|
|
740
|
+
this.runtime = void 0;
|
|
741
|
+
this.key = void 0;
|
|
742
|
+
if (previous !== void 0) await previous.close();
|
|
743
|
+
this.runtime = await selection.start();
|
|
744
|
+
this.key = selection.key;
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
/** Stop polling and close the most recently selected runtime. */
|
|
748
|
+
close() {
|
|
749
|
+
if (this.closed) return this.queue;
|
|
750
|
+
this.closed = true;
|
|
751
|
+
if (this.timer !== void 0) clearInterval(this.timer);
|
|
752
|
+
return this.enqueue(async () => {
|
|
753
|
+
const runtime = this.runtime;
|
|
754
|
+
this.runtime = void 0;
|
|
755
|
+
this.key = void 0;
|
|
756
|
+
if (runtime !== void 0) await runtime.close();
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
enqueue(operation) {
|
|
760
|
+
const run = this.queue.then(operation, operation);
|
|
761
|
+
this.queue = run.then(() => {}, () => {});
|
|
762
|
+
return run;
|
|
763
|
+
}
|
|
764
|
+
};
|
|
683
765
|
/** Validate control state loaded across the filesystem boundary. */
|
|
684
766
|
function parseMobileAccessControlState(value) {
|
|
685
767
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
|
|
@@ -889,7 +971,7 @@ function setSecurityHeaders(response, tls) {
|
|
|
889
971
|
"object-src 'none'",
|
|
890
972
|
"frame-ancestors 'none'",
|
|
891
973
|
"form-action 'self'",
|
|
892
|
-
"script-src 'self' 'unsafe-inline'",
|
|
974
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
893
975
|
"style-src 'self' 'unsafe-inline'",
|
|
894
976
|
"img-src 'self' data: blob:",
|
|
895
977
|
"font-src 'self' data:",
|
|
@@ -1010,9 +1092,92 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1010
1092
|
if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
|
|
1011
1093
|
}
|
|
1012
1094
|
//#endregion
|
|
1095
|
+
//#region src/computer-images.ts
|
|
1096
|
+
const MAX_ENTRIES = 500;
|
|
1097
|
+
const MAX_IMAGE_BYTES = 20971520;
|
|
1098
|
+
const IMAGE_TYPES = Object.freeze({
|
|
1099
|
+
".gif": "image/gif",
|
|
1100
|
+
".jpeg": "image/jpeg",
|
|
1101
|
+
".jpg": "image/jpeg",
|
|
1102
|
+
".png": "image/png",
|
|
1103
|
+
".webp": "image/webp"
|
|
1104
|
+
});
|
|
1105
|
+
/** Normalize an optional mobile-browser path without rebasing relative input. */
|
|
1106
|
+
function resolveComputerImagePath(path) {
|
|
1107
|
+
if (path === null || path === "") return homedir();
|
|
1108
|
+
if (!isAbsolute(path) || path.includes("\0")) throw new HttpError(400, "bad_path");
|
|
1109
|
+
return resolve(path);
|
|
1110
|
+
}
|
|
1111
|
+
/** List folders and supported image files without following symbolic links. */
|
|
1112
|
+
async function listComputerImages(path) {
|
|
1113
|
+
const target = resolveComputerImagePath(path);
|
|
1114
|
+
const rows = [];
|
|
1115
|
+
let truncated = false;
|
|
1116
|
+
let directory;
|
|
1117
|
+
try {
|
|
1118
|
+
directory = await opendir(target);
|
|
1119
|
+
for await (const entry of directory) {
|
|
1120
|
+
if (entry.isSymbolicLink()) continue;
|
|
1121
|
+
const kind = entry.isDirectory() ? "directory" : IMAGE_TYPES[extname(entry.name).toLowerCase()] === void 0 ? void 0 : "image";
|
|
1122
|
+
if (kind === void 0) continue;
|
|
1123
|
+
if (rows.length === MAX_ENTRIES) {
|
|
1124
|
+
truncated = true;
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
rows.push({
|
|
1128
|
+
kind,
|
|
1129
|
+
name: entry.name,
|
|
1130
|
+
path: resolve(target, entry.name)
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
throw new HttpError(404, "directory_unavailable");
|
|
1135
|
+
} finally {
|
|
1136
|
+
await directory?.close().catch(() => void 0);
|
|
1137
|
+
}
|
|
1138
|
+
rows.sort((left, right) => left.kind === right.kind ? left.name.localeCompare(right.name) : left.kind === "directory" ? -1 : 1);
|
|
1139
|
+
const parent = dirname(target);
|
|
1140
|
+
return Object.freeze({
|
|
1141
|
+
path: target,
|
|
1142
|
+
...parent === target ? {} : { parent },
|
|
1143
|
+
entries: Object.freeze(rows),
|
|
1144
|
+
truncated
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
/** Read one bounded regular image file selected by an authenticated device. */
|
|
1148
|
+
async function readComputerImage(path) {
|
|
1149
|
+
const target = resolveComputerImagePath(path);
|
|
1150
|
+
const contentType = IMAGE_TYPES[extname(target).toLowerCase()];
|
|
1151
|
+
if (contentType === void 0) throw new HttpError(415, "unsupported_file_type");
|
|
1152
|
+
let info;
|
|
1153
|
+
try {
|
|
1154
|
+
info = await lstat(target);
|
|
1155
|
+
} catch {
|
|
1156
|
+
throw new HttpError(404, "file_unavailable");
|
|
1157
|
+
}
|
|
1158
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new HttpError(404, "file_unavailable");
|
|
1159
|
+
if (info.size > MAX_IMAGE_BYTES) throw new HttpError(413, "file_too_large");
|
|
1160
|
+
try {
|
|
1161
|
+
return {
|
|
1162
|
+
body: await readFile(target),
|
|
1163
|
+
contentType,
|
|
1164
|
+
name: basename(target)
|
|
1165
|
+
};
|
|
1166
|
+
} catch {
|
|
1167
|
+
throw new HttpError(404, "file_unavailable");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1013
1171
|
//#region src/gateway.ts
|
|
1014
1172
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
1015
1173
|
const MAX_HEADER_BYTES = 16384;
|
|
1174
|
+
const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
|
|
1175
|
+
const DISCOVERY_PROTOCOL = 1;
|
|
1176
|
+
const DISCOVERY_INTERVAL_MS = 3e3;
|
|
1177
|
+
const MDNS_SERVICE_TYPE = "dsh-mobile";
|
|
1178
|
+
const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
|
|
1179
|
+
const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
|
|
1180
|
+
const MOBILE_LAYOUT_DEPENDENCIES = Object.freeze(["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-theme"]);
|
|
1016
1181
|
const PAIR_PAGE = `<!doctype html>
|
|
1017
1182
|
<html lang="en">
|
|
1018
1183
|
<meta charset="utf-8">
|
|
@@ -1030,6 +1195,25 @@ const PAIR_PAGE = `<!doctype html>
|
|
|
1030
1195
|
<script src="/mobile-access/pair.js" defer><\/script>
|
|
1031
1196
|
</html>
|
|
1032
1197
|
`;
|
|
1198
|
+
/** Replace only DSH's layout client module while retaining its complete plugin graph. */
|
|
1199
|
+
function rewriteMobileIndex(html) {
|
|
1200
|
+
const assignment = "window.__DSH_BOOT__ = ";
|
|
1201
|
+
const start = html.indexOf(assignment);
|
|
1202
|
+
if (start < 0) throw new Error("upstream DSH index has no boot manifest");
|
|
1203
|
+
const valueStart = start + 22;
|
|
1204
|
+
const scriptEnd = html.indexOf("<\/script>", valueStart);
|
|
1205
|
+
if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
|
|
1206
|
+
const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
|
|
1207
|
+
const parsed = JSON.parse(source);
|
|
1208
|
+
if (typeof parsed.rev !== "string" || !Array.isArray(parsed.entries)) throw new Error("upstream DSH boot manifest is malformed");
|
|
1209
|
+
const layout = parsed.entries.filter((entry) => entry !== null && typeof entry === "object" && entry.id === MOBILE_LAYOUT_MODULE);
|
|
1210
|
+
if (layout.length !== 1 || typeof layout[0]?.url !== "string" || typeof layout[0].rev !== "string") throw new Error("upstream DSH boot manifest has no unique layout module");
|
|
1211
|
+
if (!Array.isArray(layout[0].inject) || MOBILE_LAYOUT_DEPENDENCIES.some((dependency) => !layout[0]?.inject?.includes(dependency))) throw new Error("upstream DSH layout module has unsupported dependencies");
|
|
1212
|
+
layout[0].url = MOBILE_LAYOUT_PATH;
|
|
1213
|
+
layout[0].rev = "dsh-mobile-layout-v1";
|
|
1214
|
+
const replacement = `window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment}${JSON.stringify(parsed)};`;
|
|
1215
|
+
return `${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`;
|
|
1216
|
+
}
|
|
1033
1217
|
const PAIR_SCRIPT = `(() => {
|
|
1034
1218
|
const form = document.getElementById('pair-form')
|
|
1035
1219
|
const token = document.getElementById('pair-token')
|
|
@@ -1279,6 +1463,29 @@ function mapError(error) {
|
|
|
1279
1463
|
if (error instanceof AccessError) return new HttpError(error.status, error.code);
|
|
1280
1464
|
return new HttpError(500, "internal_error");
|
|
1281
1465
|
}
|
|
1466
|
+
function discoveryDeviceName() {
|
|
1467
|
+
const value = hostname().trim().replaceAll(/[\u0000-\u001f\u007f]/gu, "");
|
|
1468
|
+
return (value === "" ? "DeepSeek Harness" : value).slice(0, 63);
|
|
1469
|
+
}
|
|
1470
|
+
function discoveryMdnsHost(instanceId) {
|
|
1471
|
+
const label = hostname().toLowerCase().replaceAll(/[^a-z0-9-]/gu, "-").replaceAll(/^-+|-+$/gu, "").slice(0, 40);
|
|
1472
|
+
return `${label === "" ? "dsh" : label}-${instanceId.slice(0, 8)}.local`;
|
|
1473
|
+
}
|
|
1474
|
+
function discoveryBroadcastTargets(cidrs) {
|
|
1475
|
+
const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
|
|
1476
|
+
for (const cidr of cidrs) {
|
|
1477
|
+
if (cidr.bits !== 32 || cidr.prefix >= 32) continue;
|
|
1478
|
+
const hostBits = BigInt(32 - cidr.prefix);
|
|
1479
|
+
const broadcast = cidr.network | (1n << hostBits) - 1n;
|
|
1480
|
+
targets.add([
|
|
1481
|
+
24n,
|
|
1482
|
+
16n,
|
|
1483
|
+
8n,
|
|
1484
|
+
0n
|
|
1485
|
+
].map((shift) => Number(broadcast >> shift & 255n)).join("."));
|
|
1486
|
+
}
|
|
1487
|
+
return [...targets];
|
|
1488
|
+
}
|
|
1282
1489
|
/** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
|
|
1283
1490
|
var MobileAccessGateway = class {
|
|
1284
1491
|
config;
|
|
@@ -1286,6 +1493,10 @@ var MobileAccessGateway = class {
|
|
|
1286
1493
|
tlsEnabled;
|
|
1287
1494
|
policy;
|
|
1288
1495
|
server;
|
|
1496
|
+
discoverySocket;
|
|
1497
|
+
discoveryTimer;
|
|
1498
|
+
bonjour;
|
|
1499
|
+
pairingCaCertificate;
|
|
1289
1500
|
listenerPort;
|
|
1290
1501
|
connectedSockets = /* @__PURE__ */ new Set();
|
|
1291
1502
|
activeRequests = /* @__PURE__ */ new Map();
|
|
@@ -1320,6 +1531,12 @@ var MobileAccessGateway = class {
|
|
|
1320
1531
|
this.started = true;
|
|
1321
1532
|
await this.access.initialize();
|
|
1322
1533
|
try {
|
|
1534
|
+
if (this.config.pairingCaFile !== void 0) {
|
|
1535
|
+
const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
|
|
1536
|
+
const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
1537
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
|
|
1538
|
+
this.pairingCaCertificate = certificate.raw.toString("base64");
|
|
1539
|
+
}
|
|
1323
1540
|
const handler = (request, response) => {
|
|
1324
1541
|
this.handleExternalRequest(request, response).catch((error) => {
|
|
1325
1542
|
const mapped = mapError(error);
|
|
@@ -1370,12 +1587,70 @@ var MobileAccessGateway = class {
|
|
|
1370
1587
|
if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
|
|
1371
1588
|
this.listenerPort = address.port;
|
|
1372
1589
|
this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
|
|
1590
|
+
await this.startDiscovery(address.port);
|
|
1373
1591
|
} catch (error) {
|
|
1374
1592
|
await this.closeFailedStart();
|
|
1375
1593
|
throw error;
|
|
1376
1594
|
}
|
|
1377
1595
|
}
|
|
1596
|
+
async startDiscovery(port) {
|
|
1597
|
+
const socket = createSocket("udp4");
|
|
1598
|
+
this.discoverySocket = socket;
|
|
1599
|
+
const announcement = this.discoveryAnnouncement(port);
|
|
1600
|
+
socket.on("message", (message, remote) => {
|
|
1601
|
+
if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
|
|
1602
|
+
socket.send(announcement, remote.port, remote.address, () => void 0);
|
|
1603
|
+
});
|
|
1604
|
+
await new Promise((resolve, reject) => {
|
|
1605
|
+
const failed = (error) => {
|
|
1606
|
+
reject(error);
|
|
1607
|
+
};
|
|
1608
|
+
socket.once("error", failed);
|
|
1609
|
+
socket.bind(port, "0.0.0.0", () => {
|
|
1610
|
+
socket.off("error", failed);
|
|
1611
|
+
socket.setBroadcast(true);
|
|
1612
|
+
resolve();
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
const announce = () => {
|
|
1616
|
+
for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
|
|
1617
|
+
};
|
|
1618
|
+
announce();
|
|
1619
|
+
this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
|
|
1620
|
+
this.discoveryTimer.unref();
|
|
1621
|
+
const deviceName = discoveryDeviceName();
|
|
1622
|
+
const bonjour = new Bonjour({ disableIPv6: true });
|
|
1623
|
+
this.bonjour = bonjour;
|
|
1624
|
+
bonjour.publish({
|
|
1625
|
+
name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
|
|
1626
|
+
type: MDNS_SERVICE_TYPE,
|
|
1627
|
+
protocol: "tcp",
|
|
1628
|
+
port,
|
|
1629
|
+
host: discoveryMdnsHost(this.config.instanceId),
|
|
1630
|
+
disableIPv6: true,
|
|
1631
|
+
txt: {
|
|
1632
|
+
deviceName,
|
|
1633
|
+
origin: this.address().origin,
|
|
1634
|
+
instanceId: this.config.instanceId,
|
|
1635
|
+
protocol: String(DISCOVERY_PROTOCOL)
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
discoveryAnnouncement(port) {
|
|
1640
|
+
return Buffer.from(JSON.stringify({
|
|
1641
|
+
deviceName: discoveryDeviceName(),
|
|
1642
|
+
origin: this.address().origin,
|
|
1643
|
+
port,
|
|
1644
|
+
protocol: DISCOVERY_PROTOCOL,
|
|
1645
|
+
instanceId: this.config.instanceId
|
|
1646
|
+
}), "utf8");
|
|
1647
|
+
}
|
|
1378
1648
|
async closeFailedStart() {
|
|
1649
|
+
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
1650
|
+
this.discoveryTimer = void 0;
|
|
1651
|
+
await this.closeBonjour();
|
|
1652
|
+
this.discoverySocket?.close();
|
|
1653
|
+
this.discoverySocket = void 0;
|
|
1379
1654
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
1380
1655
|
const server = this.server;
|
|
1381
1656
|
this.server = void 0;
|
|
@@ -1384,6 +1659,16 @@ var MobileAccessGateway = class {
|
|
|
1384
1659
|
});
|
|
1385
1660
|
await this.access.close();
|
|
1386
1661
|
}
|
|
1662
|
+
async closeBonjour() {
|
|
1663
|
+
const bonjour = this.bonjour;
|
|
1664
|
+
this.bonjour = void 0;
|
|
1665
|
+
if (bonjour === void 0) return;
|
|
1666
|
+
await new Promise((resolve) => {
|
|
1667
|
+
bonjour.unpublishAll(() => {
|
|
1668
|
+
bonjour.destroy(() => resolve());
|
|
1669
|
+
});
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1387
1672
|
/** Actual bound address, available after start and safe for loopback status output. */
|
|
1388
1673
|
address() {
|
|
1389
1674
|
if (this.listenerPort === void 0 || this.policy === void 0) throw new Error("gateway is not listening");
|
|
@@ -1467,6 +1752,33 @@ var MobileAccessGateway = class {
|
|
|
1467
1752
|
sessionExpiresAt: result.sessionExpiresAt
|
|
1468
1753
|
}, this.tlsEnabled);
|
|
1469
1754
|
}
|
|
1755
|
+
async handleNativePair(request, response) {
|
|
1756
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1757
|
+
if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
|
|
1758
|
+
const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
|
|
1759
|
+
sendJson(response, 201, {
|
|
1760
|
+
instanceId: this.config.instanceId,
|
|
1761
|
+
deviceId: result.deviceId,
|
|
1762
|
+
deviceToken: result.deviceToken,
|
|
1763
|
+
deviceExpiresAt: result.deviceExpiresAt,
|
|
1764
|
+
sessionToken: result.sessionToken,
|
|
1765
|
+
csrfToken: result.csrfToken,
|
|
1766
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1767
|
+
}, this.tlsEnabled);
|
|
1768
|
+
}
|
|
1769
|
+
async handleNativeRenew(request, response) {
|
|
1770
|
+
if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
1771
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1772
|
+
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
1773
|
+
const result = await this.access.renew(body.deviceToken);
|
|
1774
|
+
sendJson(response, 200, {
|
|
1775
|
+
instanceId: this.config.instanceId,
|
|
1776
|
+
deviceId: result.deviceId,
|
|
1777
|
+
sessionToken: result.sessionToken,
|
|
1778
|
+
csrfToken: result.csrfToken,
|
|
1779
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1780
|
+
}, this.tlsEnabled);
|
|
1781
|
+
}
|
|
1470
1782
|
async handleLogout(request, response) {
|
|
1471
1783
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1472
1784
|
const authorization = this.authorize(request);
|
|
@@ -1494,6 +1806,28 @@ var MobileAccessGateway = class {
|
|
|
1494
1806
|
sendJson(response, 200, { ok: true }, this.tlsEnabled);
|
|
1495
1807
|
return;
|
|
1496
1808
|
}
|
|
1809
|
+
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
|
|
1810
|
+
sendJson(response, 200, {
|
|
1811
|
+
deviceName: discoveryDeviceName(),
|
|
1812
|
+
origin: this.address().origin,
|
|
1813
|
+
port: this.address().port,
|
|
1814
|
+
protocol: DISCOVERY_PROTOCOL,
|
|
1815
|
+
instanceId: this.config.instanceId
|
|
1816
|
+
}, this.tlsEnabled);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/ca.cer`) {
|
|
1820
|
+
if (this.pairingCaCertificate === void 0) throw new HttpError(404, "not_found");
|
|
1821
|
+
const body = Buffer.from(this.pairingCaCertificate, "base64");
|
|
1822
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1823
|
+
response.writeHead(200, {
|
|
1824
|
+
"Content-Type": "application/pkix-cert",
|
|
1825
|
+
"Content-Length": body.length,
|
|
1826
|
+
"Cache-Control": "no-store"
|
|
1827
|
+
});
|
|
1828
|
+
response.end(body);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1497
1831
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
1498
1832
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
1499
1833
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
@@ -1524,10 +1858,20 @@ var MobileAccessGateway = class {
|
|
|
1524
1858
|
await this.handleRenew(request, response);
|
|
1525
1859
|
return;
|
|
1526
1860
|
}
|
|
1861
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-pair`) {
|
|
1862
|
+
await this.handleNativePair(request, response);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-renew`) {
|
|
1866
|
+
await this.handleNativeRenew(request, response);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1527
1869
|
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
|
|
1528
1870
|
await this.handleLogout(request, response);
|
|
1529
1871
|
return;
|
|
1530
1872
|
}
|
|
1873
|
+
const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
|
|
1874
|
+
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
1531
1875
|
const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
1532
1876
|
file: this.config.customCssFile,
|
|
1533
1877
|
contentType: "text/css; charset=utf-8",
|
|
@@ -1536,8 +1880,12 @@ var MobileAccessGateway = class {
|
|
|
1536
1880
|
file: this.config.customScriptFile,
|
|
1537
1881
|
contentType: "text/javascript; charset=utf-8",
|
|
1538
1882
|
fallback: "window.dshMobile?.register(() => undefined)\n"
|
|
1883
|
+
} : target.decodedPathname === MOBILE_LAYOUT_PATH ? {
|
|
1884
|
+
file: this.config.mobileLayoutFile,
|
|
1885
|
+
contentType: "text/javascript; charset=utf-8",
|
|
1886
|
+
fallback: void 0
|
|
1539
1887
|
} : void 0 : void 0;
|
|
1540
|
-
if (customAsset === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
1888
|
+
if (customAsset === void 0 && !computerImages && !computerImage && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
1541
1889
|
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
1542
1890
|
if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/")) throw new HttpError(405, "method_not_allowed");
|
|
1543
1891
|
let authorization;
|
|
@@ -1565,6 +1913,7 @@ var MobileAccessGateway = class {
|
|
|
1565
1913
|
body = await readFile(customAsset.file);
|
|
1566
1914
|
} catch (error) {
|
|
1567
1915
|
if (error.code !== "ENOENT") throw error;
|
|
1916
|
+
if (customAsset.fallback === void 0) throw new HttpError(503, "mobile_frontend_unavailable");
|
|
1568
1917
|
body = Buffer.from(customAsset.fallback);
|
|
1569
1918
|
}
|
|
1570
1919
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
@@ -1577,8 +1926,91 @@ var MobileAccessGateway = class {
|
|
|
1577
1926
|
response.end(body);
|
|
1578
1927
|
return;
|
|
1579
1928
|
}
|
|
1929
|
+
if (computerImages) {
|
|
1930
|
+
const query = new URL(target.raw, this.address().origin).searchParams;
|
|
1931
|
+
sendJson(response, 200, await listComputerImages(query.get("path")), this.tlsEnabled);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (computerImage) {
|
|
1935
|
+
const query = new URL(target.raw, this.address().origin).searchParams;
|
|
1936
|
+
const image = await readComputerImage(query.get("path"));
|
|
1937
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1938
|
+
response.writeHead(200, {
|
|
1939
|
+
"Content-Type": image.contentType,
|
|
1940
|
+
"Content-Length": image.body.byteLength,
|
|
1941
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(image.name)}`
|
|
1942
|
+
});
|
|
1943
|
+
response.end(image.body);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
|
|
1947
|
+
const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
|
|
1948
|
+
if (request.method === "GET" && target.decodedPathname === "/" && acceptsHtml && !stockFrontend) {
|
|
1949
|
+
await this.proxyMobileIndex(request, response, authorization);
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (stockFrontend && target.decodedPathname === "/") request.url = "/";
|
|
1580
1953
|
await this.proxyHttp(request, response, authorization);
|
|
1581
1954
|
}
|
|
1955
|
+
async proxyMobileIndex(request$1, response, authorization) {
|
|
1956
|
+
const holder = {};
|
|
1957
|
+
const operation = this.allocateRequest(authorization, response, holder);
|
|
1958
|
+
try {
|
|
1959
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$1, this.config.upstreamOrigin);
|
|
1960
|
+
upstreamHeaders["accept-encoding"] = "identity";
|
|
1961
|
+
const proxied = await new Promise((resolve, reject) => {
|
|
1962
|
+
const upstreamRequest = request({
|
|
1963
|
+
protocol: "http:",
|
|
1964
|
+
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
1965
|
+
port: Number(this.config.upstreamOrigin.port),
|
|
1966
|
+
method: "GET",
|
|
1967
|
+
path: "/",
|
|
1968
|
+
headers: upstreamHeaders,
|
|
1969
|
+
agent: false
|
|
1970
|
+
});
|
|
1971
|
+
holder.request = upstreamRequest;
|
|
1972
|
+
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
1973
|
+
upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
|
|
1974
|
+
});
|
|
1975
|
+
upstreamRequest.once("response", resolve);
|
|
1976
|
+
upstreamRequest.once("error", reject);
|
|
1977
|
+
upstreamRequest.end();
|
|
1978
|
+
});
|
|
1979
|
+
if ((proxied.statusCode ?? 502) !== 200) throw new HttpError(502, "upstream_unavailable");
|
|
1980
|
+
const chunks = [];
|
|
1981
|
+
let bytes = 0;
|
|
1982
|
+
for await (const chunk of proxied) {
|
|
1983
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1984
|
+
bytes += buffer.byteLength;
|
|
1985
|
+
if (bytes > 4194304) throw new HttpError(502, "upstream_unavailable");
|
|
1986
|
+
chunks.push(buffer);
|
|
1987
|
+
}
|
|
1988
|
+
let body;
|
|
1989
|
+
try {
|
|
1990
|
+
body = Buffer.from(rewriteMobileIndex(Buffer.concat(chunks).toString("utf8")));
|
|
1991
|
+
} catch {
|
|
1992
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
1993
|
+
}
|
|
1994
|
+
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
1995
|
+
delete headers["content-length"];
|
|
1996
|
+
delete headers["content-encoding"];
|
|
1997
|
+
delete headers.etag;
|
|
1998
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1999
|
+
response.writeHead(200, {
|
|
2000
|
+
...headers,
|
|
2001
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
2002
|
+
"Content-Length": body.byteLength
|
|
2003
|
+
});
|
|
2004
|
+
response.end(body);
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
holder.request?.destroy();
|
|
2007
|
+
if (error instanceof HttpError) throw error;
|
|
2008
|
+
if (response.headersSent) response.destroy();
|
|
2009
|
+
else throw new HttpError(502, "upstream_unavailable");
|
|
2010
|
+
} finally {
|
|
2011
|
+
operation.release();
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
1582
2014
|
allocateRequest(authorization, response, upstream) {
|
|
1583
2015
|
if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
|
|
1584
2016
|
const id = this.nextOperationId++;
|
|
@@ -1602,8 +2034,8 @@ var MobileAccessGateway = class {
|
|
|
1602
2034
|
}
|
|
1603
2035
|
};
|
|
1604
2036
|
}
|
|
1605
|
-
async proxyHttp(request$
|
|
1606
|
-
const declared = request$
|
|
2037
|
+
async proxyHttp(request$2, response, authorization) {
|
|
2038
|
+
const declared = request$2.headers["content-length"];
|
|
1607
2039
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
1608
2040
|
const holder = {};
|
|
1609
2041
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
@@ -1614,9 +2046,9 @@ var MobileAccessGateway = class {
|
|
|
1614
2046
|
protocol: "http:",
|
|
1615
2047
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
1616
2048
|
port: Number(this.config.upstreamOrigin.port),
|
|
1617
|
-
method: request$
|
|
1618
|
-
path: request$
|
|
1619
|
-
headers: sanitizeRequestHeaders(request$
|
|
2049
|
+
method: request$2.method,
|
|
2050
|
+
path: request$2.url,
|
|
2051
|
+
headers: sanitizeRequestHeaders(request$2, this.config.upstreamOrigin),
|
|
1620
2052
|
agent: false
|
|
1621
2053
|
});
|
|
1622
2054
|
holder.request = upstreamRequest;
|
|
@@ -1625,7 +2057,7 @@ var MobileAccessGateway = class {
|
|
|
1625
2057
|
});
|
|
1626
2058
|
upstreamRequest.once("response", resolve);
|
|
1627
2059
|
upstreamRequest.once("error", reject);
|
|
1628
|
-
bodyDone = pipeline(request$
|
|
2060
|
+
bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
1629
2061
|
bodyDone.catch(reject);
|
|
1630
2062
|
});
|
|
1631
2063
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
@@ -1720,7 +2152,9 @@ var MobileAccessGateway = class {
|
|
|
1720
2152
|
}
|
|
1721
2153
|
async handleUpgrade(request, client, head) {
|
|
1722
2154
|
const target = parseRequestTarget(request.url);
|
|
1723
|
-
|
|
2155
|
+
const policy = this.requirePolicy();
|
|
2156
|
+
assertExternalTrust(request, policy, false);
|
|
2157
|
+
if (!policy.acceptsOrigin(request.headers.origin)) throw new HttpError(403, "forbidden");
|
|
1724
2158
|
if (target.search !== "" || !WS_PATHS.has(target.decodedPathname)) throw new HttpError(404, "not_found");
|
|
1725
2159
|
if (request.method !== "GET" || headerValue(request.headers, "upgrade")?.toLowerCase() !== "websocket" || !hasToken(headerValue(request.headers, "connection"), "upgrade")) throw new HttpError(400, "bad_request");
|
|
1726
2160
|
const key = headerValue(request.headers, "sec-websocket-key");
|
|
@@ -1835,6 +2269,7 @@ var MobileAccessGateway = class {
|
|
|
1835
2269
|
const opened = await this.access.openPairing(body.ttlMs);
|
|
1836
2270
|
sendJson(response, 201, {
|
|
1837
2271
|
...opened,
|
|
2272
|
+
appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
|
|
1838
2273
|
pairUrl: `${this.address().origin}/mobile-access/pair#token=${opened.token}`
|
|
1839
2274
|
}, false);
|
|
1840
2275
|
return;
|
|
@@ -1877,6 +2312,14 @@ var MobileAccessGateway = class {
|
|
|
1877
2312
|
websocket.upstream.destroy();
|
|
1878
2313
|
}
|
|
1879
2314
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
2315
|
+
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
2316
|
+
this.discoveryTimer = void 0;
|
|
2317
|
+
await this.closeBonjour();
|
|
2318
|
+
const discoverySocket = this.discoverySocket;
|
|
2319
|
+
this.discoverySocket = void 0;
|
|
2320
|
+
if (discoverySocket !== void 0) await new Promise((resolve) => {
|
|
2321
|
+
discoverySocket.close(() => resolve());
|
|
2322
|
+
});
|
|
1880
2323
|
const server = this.server;
|
|
1881
2324
|
this.server = void 0;
|
|
1882
2325
|
if (server !== void 0 && server.listening) {
|
|
@@ -2015,14 +2458,225 @@ var MemoryDeviceStore = class {
|
|
|
2015
2458
|
return structuredClone(this.snapshot);
|
|
2016
2459
|
}
|
|
2017
2460
|
};
|
|
2461
|
+
promisify(execFile);
|
|
2462
|
+
const VIRTUAL_INTERFACE_MARKERS = [
|
|
2463
|
+
"bridge",
|
|
2464
|
+
"docker",
|
|
2465
|
+
"hyper-v",
|
|
2466
|
+
"mihomo",
|
|
2467
|
+
"radmin",
|
|
2468
|
+
"tailscale",
|
|
2469
|
+
"tap",
|
|
2470
|
+
"tun",
|
|
2471
|
+
"utun",
|
|
2472
|
+
"vbox",
|
|
2473
|
+
"veth",
|
|
2474
|
+
"virtual",
|
|
2475
|
+
"vmware",
|
|
2476
|
+
"vpn",
|
|
2477
|
+
"vethernet",
|
|
2478
|
+
"wsl",
|
|
2479
|
+
"zerotier"
|
|
2480
|
+
];
|
|
2481
|
+
function requiredString(value, name) {
|
|
2482
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
2483
|
+
return value;
|
|
2484
|
+
}
|
|
2485
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
2486
|
+
function parseManagedSetup(value) {
|
|
2487
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
2488
|
+
const record = value;
|
|
2489
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
2490
|
+
"version",
|
|
2491
|
+
"networkInterface",
|
|
2492
|
+
"listenPort",
|
|
2493
|
+
"upstreamOrigin",
|
|
2494
|
+
"tls"
|
|
2495
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2496
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
2497
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
2498
|
+
const tls = record.tls;
|
|
2499
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
2500
|
+
"mode",
|
|
2501
|
+
"caCertFile",
|
|
2502
|
+
"caKeyFile",
|
|
2503
|
+
"certFile",
|
|
2504
|
+
"keyFile"
|
|
2505
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
2506
|
+
return Object.freeze({
|
|
2507
|
+
version: 2,
|
|
2508
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
2509
|
+
listenPort: record.listenPort,
|
|
2510
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
2511
|
+
tls: Object.freeze({
|
|
2512
|
+
mode: "managed",
|
|
2513
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
2514
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
2515
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
2516
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
2517
|
+
})
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
function privateIpv4(value) {
|
|
2521
|
+
const parts = value.split(".").map(Number);
|
|
2522
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
2523
|
+
}
|
|
2524
|
+
function networkCidr(address, cidr) {
|
|
2525
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
2526
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
2527
|
+
return `${[
|
|
2528
|
+
24,
|
|
2529
|
+
16,
|
|
2530
|
+
8,
|
|
2531
|
+
0
|
|
2532
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
2533
|
+
}
|
|
2534
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
2535
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
2536
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
2537
|
+
name,
|
|
2538
|
+
address: entry.address,
|
|
2539
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
2540
|
+
})));
|
|
2541
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
2542
|
+
}
|
|
2543
|
+
function likelyVirtualInterface(name) {
|
|
2544
|
+
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
2545
|
+
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
2546
|
+
}
|
|
2547
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
2548
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
2549
|
+
const candidates = availableLanNetworks(table);
|
|
2550
|
+
if (requestedAddress !== void 0) {
|
|
2551
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
2552
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
2553
|
+
return match;
|
|
2554
|
+
}
|
|
2555
|
+
if (requestedInterface !== void 0) {
|
|
2556
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
2557
|
+
if (matches.length === 1) return matches[0];
|
|
2558
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
2559
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
2560
|
+
}
|
|
2561
|
+
if (candidates.length === 1) return candidates[0];
|
|
2562
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
2563
|
+
for (const name of preferredInterfaces) {
|
|
2564
|
+
const matches = candidates.filter((candidate) => candidate.name === name);
|
|
2565
|
+
if (matches.length === 1) return matches[0];
|
|
2566
|
+
}
|
|
2567
|
+
const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
|
|
2568
|
+
if (physicalCandidates.length === 1) return physicalCandidates[0];
|
|
2569
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
|
|
2570
|
+
}
|
|
2571
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
2572
|
+
const certificate = new X509Certificate(certPem);
|
|
2573
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
2574
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
2575
|
+
format: "der",
|
|
2576
|
+
type: "spki"
|
|
2577
|
+
});
|
|
2578
|
+
const certificatePublic = certificate.publicKey.export({
|
|
2579
|
+
format: "der",
|
|
2580
|
+
type: "spki"
|
|
2581
|
+
});
|
|
2582
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
2583
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
2584
|
+
return certificate;
|
|
2585
|
+
}
|
|
2586
|
+
async function atomicWrite(file, contents) {
|
|
2587
|
+
const directory = dirname(file);
|
|
2588
|
+
await mkdir(directory, {
|
|
2589
|
+
recursive: true,
|
|
2590
|
+
mode: 448
|
|
2591
|
+
});
|
|
2592
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
2593
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
2594
|
+
await chmod(temporary, 384);
|
|
2595
|
+
await rename(temporary, file);
|
|
2596
|
+
}
|
|
2597
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
2598
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
2599
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
2600
|
+
assertMatchingCa(caCert, caKey);
|
|
2601
|
+
const now = /* @__PURE__ */ new Date();
|
|
2602
|
+
const notAfter = new Date(now);
|
|
2603
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
2604
|
+
const server = await generate([{
|
|
2605
|
+
name: "commonName",
|
|
2606
|
+
value: "DeepSeek Harness Mobile"
|
|
2607
|
+
}], {
|
|
2608
|
+
keyType: "ec",
|
|
2609
|
+
curve: "P-256",
|
|
2610
|
+
algorithm: "sha256",
|
|
2611
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
2612
|
+
notAfterDate: notAfter,
|
|
2613
|
+
ca: {
|
|
2614
|
+
cert: caCert,
|
|
2615
|
+
key: caKey
|
|
2616
|
+
},
|
|
2617
|
+
extensions: [
|
|
2618
|
+
{
|
|
2619
|
+
name: "basicConstraints",
|
|
2620
|
+
cA: false,
|
|
2621
|
+
critical: true
|
|
2622
|
+
},
|
|
2623
|
+
{
|
|
2624
|
+
name: "keyUsage",
|
|
2625
|
+
digitalSignature: true,
|
|
2626
|
+
critical: true
|
|
2627
|
+
},
|
|
2628
|
+
{
|
|
2629
|
+
name: "extKeyUsage",
|
|
2630
|
+
serverAuth: true
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
name: "subjectAltName",
|
|
2634
|
+
altNames: [{
|
|
2635
|
+
type: 7,
|
|
2636
|
+
ip: address
|
|
2637
|
+
}]
|
|
2638
|
+
}
|
|
2639
|
+
]
|
|
2640
|
+
});
|
|
2641
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
2642
|
+
}
|
|
2643
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
2644
|
+
async function materializeManagedSetup(setup, table) {
|
|
2645
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
2646
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
2647
|
+
const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
|
|
2648
|
+
return {
|
|
2649
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
2650
|
+
listenHost: network.address,
|
|
2651
|
+
upstreamOrigin: setup.upstreamOrigin,
|
|
2652
|
+
allowedCidrs: [network.cidr],
|
|
2653
|
+
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
2654
|
+
pairingCaFile: setup.tls.caCertFile,
|
|
2655
|
+
tls: {
|
|
2656
|
+
mode: "provided",
|
|
2657
|
+
certFile: setup.tls.certFile,
|
|
2658
|
+
keyFile: setup.tls.keyFile
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2018
2662
|
//#endregion
|
|
2019
2663
|
//#region src/plugin.ts
|
|
2020
2664
|
/** Stable Cordis plugin name. */
|
|
2021
2665
|
const name = "dsh-mobile";
|
|
2022
2666
|
/** The stock WebServer is the only DSH Host service this plugin requires. */
|
|
2023
2667
|
const inject = ["webServer"];
|
|
2668
|
+
function installedDshVersion() {
|
|
2669
|
+
const manifest = createRequire(import.meta.url)("@deepseek-ai/dsh-host-webserver/package.json");
|
|
2670
|
+
if (manifest === null || typeof manifest !== "object") return void 0;
|
|
2671
|
+
return manifest.version;
|
|
2672
|
+
}
|
|
2024
2673
|
function mapAdminError(error) {
|
|
2025
|
-
|
|
2674
|
+
if (error instanceof HttpError) return error;
|
|
2675
|
+
const code = error.code;
|
|
2676
|
+
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
2677
|
+
if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
|
|
2678
|
+
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
2679
|
+
return new HttpError(500, "internal_error");
|
|
2026
2680
|
}
|
|
2027
2681
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
2028
2682
|
"version",
|
|
@@ -2032,16 +2686,29 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
2032
2686
|
"upstreamOrigin",
|
|
2033
2687
|
"publicAuthorities",
|
|
2034
2688
|
"allowedCidrs",
|
|
2689
|
+
"instanceId",
|
|
2690
|
+
"pairingCaFile",
|
|
2035
2691
|
"tls"
|
|
2036
2692
|
]);
|
|
2693
|
+
function withoutSetupKeys(config) {
|
|
2694
|
+
const merged = { ...config };
|
|
2695
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2696
|
+
return merged;
|
|
2697
|
+
}
|
|
2037
2698
|
async function loadSetup(config) {
|
|
2038
|
-
if (config.setupFile === void 0) return
|
|
2699
|
+
if (config.setupFile === void 0) return {
|
|
2700
|
+
kind: "fixed",
|
|
2701
|
+
config
|
|
2702
|
+
};
|
|
2039
2703
|
if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
|
|
2040
2704
|
let source;
|
|
2041
2705
|
try {
|
|
2042
2706
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
2043
2707
|
} catch (error) {
|
|
2044
|
-
if (error.code === "ENOENT") return
|
|
2708
|
+
if (error.code === "ENOENT") return {
|
|
2709
|
+
kind: "fixed",
|
|
2710
|
+
config
|
|
2711
|
+
};
|
|
2045
2712
|
throw error;
|
|
2046
2713
|
}
|
|
2047
2714
|
let parsed;
|
|
@@ -2052,20 +2719,28 @@ async function loadSetup(config) {
|
|
|
2052
2719
|
}
|
|
2053
2720
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2054
2721
|
const record = parsed;
|
|
2722
|
+
if (record.version === 2) return {
|
|
2723
|
+
kind: "managed",
|
|
2724
|
+
config: withoutSetupKeys(config),
|
|
2725
|
+
setup: parseManagedSetup(record)
|
|
2726
|
+
};
|
|
2055
2727
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2056
2728
|
const { version: _version, ...setup } = record;
|
|
2057
|
-
const merged = { ...config };
|
|
2058
|
-
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2059
2729
|
return {
|
|
2060
|
-
|
|
2061
|
-
|
|
2730
|
+
kind: "fixed",
|
|
2731
|
+
config: {
|
|
2732
|
+
...withoutSetupKeys(config),
|
|
2733
|
+
...setup
|
|
2734
|
+
}
|
|
2062
2735
|
};
|
|
2063
2736
|
}
|
|
2064
2737
|
/** Mount the resident control route and its optional authenticated LAN gateway. */
|
|
2065
2738
|
async function apply(ctx, config) {
|
|
2066
|
-
|
|
2739
|
+
assertSupportedDshVersion(installedDshVersion());
|
|
2740
|
+
const loaded = await loadSetup(config);
|
|
2067
2741
|
let gateway;
|
|
2068
|
-
const
|
|
2742
|
+
const startGateway = async (candidateConfig) => {
|
|
2743
|
+
const resolved = parseGatewayConfig(candidateConfig);
|
|
2069
2744
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
|
|
2070
2745
|
await candidate.start();
|
|
2071
2746
|
gateway = candidate;
|
|
@@ -2073,7 +2748,25 @@ async function apply(ctx, config) {
|
|
|
2073
2748
|
if (gateway === candidate) gateway = void 0;
|
|
2074
2749
|
await candidate.close();
|
|
2075
2750
|
} };
|
|
2076
|
-
}
|
|
2751
|
+
};
|
|
2752
|
+
const startRuntime = async () => {
|
|
2753
|
+
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
2754
|
+
const following = new FollowingMobileAccessRuntime(async () => {
|
|
2755
|
+
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
2756
|
+
return {
|
|
2757
|
+
key: `${network.name}\0${network.address}\0${network.cidr}`,
|
|
2758
|
+
start: async () => startGateway({
|
|
2759
|
+
...loaded.config,
|
|
2760
|
+
...await materializeManagedSetup(loaded.setup)
|
|
2761
|
+
})
|
|
2762
|
+
};
|
|
2763
|
+
}, (error) => {
|
|
2764
|
+
process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
|
|
2765
|
+
});
|
|
2766
|
+
await following.initialize(2e3);
|
|
2767
|
+
return following;
|
|
2768
|
+
};
|
|
2769
|
+
const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
|
|
2077
2770
|
const adminRoute = {
|
|
2078
2771
|
kind: "prefix",
|
|
2079
2772
|
path: LOCAL_ADMIN_PREFIX,
|
|
@@ -2124,6 +2817,6 @@ async function apply(ctx, config) {
|
|
|
2124
2817
|
}, "dsh-mobile: local control and authenticated LAN gateway");
|
|
2125
2818
|
}
|
|
2126
2819
|
//#endregion
|
|
2127
|
-
export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, addressAllowed, apply, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority };
|
|
2820
|
+
export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, RequestTrustPolicy, SESSION_COOKIE, SUPPORTED_DSH_VERSIONS, WS_PATHS, addressAllowed, apply, assertSupportedDshVersion, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority, rewriteMobileIndex };
|
|
2128
2821
|
|
|
2129
2822
|
//# sourceMappingURL=index.mjs.map
|