dsh-mobile 0.6.6 → 0.6.8
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 +20 -0
- package/CONTRIBUTORS.md +11 -1
- package/README.en.md +21 -13
- package/README.md +21 -13
- package/SECURITY.md +9 -0
- package/docs/CPOLAR_SUBDOMAIN.en.md +81 -0
- package/docs/CPOLAR_SUBDOMAIN.md +81 -0
- package/docs/README.md +1 -0
- package/docs/SKIN_COMPATIBILITY.en.md +1 -1
- package/docs/SKIN_COMPATIBILITY.md +1 -1
- package/lib/cli.js +2 -0
- package/lib/cli.js.map +1 -1
- package/lib/client.js +240 -25
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +31 -4
- package/lib/index.mjs +202 -95
- package/lib/index.mjs.map +1 -1
- package/package.json +6 -1
- package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32
|
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { connect, createServer } from "node:net";
|
|
7
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
8
8
|
import { performance as performance$1 } from "node:perf_hooks";
|
|
9
9
|
import { execFile, spawn } from "node:child_process";
|
|
10
10
|
import { createSocket } from "node:dgram";
|
|
@@ -144,10 +144,14 @@ var AccessController = class {
|
|
|
144
144
|
}
|
|
145
145
|
snapshot(devices) {
|
|
146
146
|
return Object.freeze({
|
|
147
|
-
version:
|
|
147
|
+
version: 2,
|
|
148
148
|
devices: Object.freeze([...devices])
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
|
+
nativeIdentity(device) {
|
|
152
|
+
const nativeInstanceId = device.nativeInstanceId ?? this.options.legacyInstanceId ?? this.options.pairingInstanceId;
|
|
153
|
+
return nativeInstanceId === void 0 ? {} : { nativeInstanceId };
|
|
154
|
+
}
|
|
151
155
|
emitSessionEnded(session, reason) {
|
|
152
156
|
const authorization = Object.freeze({
|
|
153
157
|
sessionKey: session.key,
|
|
@@ -165,7 +169,7 @@ var AccessController = class {
|
|
|
165
169
|
pruneSessions(now) {
|
|
166
170
|
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key, "expired");
|
|
167
171
|
}
|
|
168
|
-
createSession(
|
|
172
|
+
createSession(device, now) {
|
|
169
173
|
this.pruneSessions(now);
|
|
170
174
|
if (this.sessions.size >= this.options.maxSessions) {
|
|
171
175
|
const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
|
|
@@ -176,14 +180,15 @@ var AccessController = class {
|
|
|
176
180
|
const key = digestHex(sessionToken);
|
|
177
181
|
const record = Object.freeze({
|
|
178
182
|
key,
|
|
179
|
-
deviceId,
|
|
183
|
+
deviceId: device.id,
|
|
180
184
|
csrfDigest: digest$1(csrfToken),
|
|
181
185
|
createdAt: now,
|
|
182
|
-
expiresAt: Math.min(now + this.options.sessionTtlMs,
|
|
186
|
+
expiresAt: Math.min(now + this.options.sessionTtlMs, device.expiresAt)
|
|
183
187
|
});
|
|
184
188
|
this.sessions.set(key, record);
|
|
185
189
|
return Object.freeze({
|
|
186
|
-
|
|
190
|
+
...this.nativeIdentity(device),
|
|
191
|
+
deviceId: device.id,
|
|
187
192
|
sessionToken,
|
|
188
193
|
csrfToken,
|
|
189
194
|
sessionExpiresAt: record.expiresAt
|
|
@@ -228,12 +233,13 @@ var AccessController = class {
|
|
|
228
233
|
tokenDigest: digestHex(deviceToken),
|
|
229
234
|
createdAt: now,
|
|
230
235
|
expiresAt: now + this.options.deviceTtlMs,
|
|
231
|
-
lastSeenAt: now
|
|
236
|
+
lastSeenAt: now,
|
|
237
|
+
...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
|
|
232
238
|
});
|
|
233
239
|
const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
|
|
234
240
|
await this.store.save(this.snapshot(next));
|
|
235
241
|
this.devices = next;
|
|
236
|
-
const session = this.createSession(device
|
|
242
|
+
const session = this.createSession(device, now);
|
|
237
243
|
return Object.freeze({
|
|
238
244
|
...session,
|
|
239
245
|
deviceToken,
|
|
@@ -256,7 +262,7 @@ var AccessController = class {
|
|
|
256
262
|
next[index] = updated;
|
|
257
263
|
await this.store.save(this.snapshot(next));
|
|
258
264
|
this.devices = next;
|
|
259
|
-
return this.createSession(device
|
|
265
|
+
return this.createSession(device, now);
|
|
260
266
|
});
|
|
261
267
|
}
|
|
262
268
|
/** Validate a persistent device credential without consuming a Session slot. */
|
|
@@ -275,6 +281,7 @@ var AccessController = class {
|
|
|
275
281
|
await this.store.save(this.snapshot(next));
|
|
276
282
|
this.devices = next;
|
|
277
283
|
return Object.freeze({
|
|
284
|
+
...this.nativeIdentity(device),
|
|
278
285
|
deviceId: device.id,
|
|
279
286
|
deviceExpiresAt: device.expiresAt
|
|
280
287
|
});
|
|
@@ -285,6 +292,7 @@ var AccessController = class {
|
|
|
285
292
|
this.requireInitialized();
|
|
286
293
|
const device = this.requireDevice(deviceToken, this.now());
|
|
287
294
|
return Object.freeze({
|
|
295
|
+
...this.nativeIdentity(device),
|
|
288
296
|
deviceId: device.id,
|
|
289
297
|
deviceExpiresAt: device.expiresAt
|
|
290
298
|
});
|
|
@@ -956,6 +964,8 @@ const Config = z.object({
|
|
|
956
964
|
keyFile: z.string(),
|
|
957
965
|
caFile: z.string()
|
|
958
966
|
}),
|
|
967
|
+
cpolarSubdomain: z.string(),
|
|
968
|
+
cpolarRegion: z.string(),
|
|
959
969
|
pairingTtlMs: z.natural(),
|
|
960
970
|
deviceTtlMs: z.natural(),
|
|
961
971
|
sessionTtlMs: z.natural(),
|
|
@@ -1991,6 +2001,13 @@ function attributeValue(tag, name) {
|
|
|
1991
2001
|
function commentRanges(html) {
|
|
1992
2002
|
return [...html.matchAll(/<!--[\s\S]*?-->/gu)].map((match) => [match.index, match.index + match[0].length]);
|
|
1993
2003
|
}
|
|
2004
|
+
/** Mark an authenticated Gateway document without changing its layout, transport or module selection. */
|
|
2005
|
+
function ensureMobileGatewayMarker(html) {
|
|
2006
|
+
const comments = commentRanges(html);
|
|
2007
|
+
const first = [...html.matchAll(/<script\b[^>]*>/giu)].find((match) => !comments.some(([start, end]) => match.index >= start && match.index < end));
|
|
2008
|
+
if (first === void 0) return html;
|
|
2009
|
+
return `${html.slice(0, first.index)}<meta name="dsh-mobile-gateway" content="authenticated">${html.slice(first.index)}`;
|
|
2010
|
+
}
|
|
1994
2011
|
/**
|
|
1995
2012
|
* Load the compatibility bundle synchronously, before the first DSH script.
|
|
1996
2013
|
*
|
|
@@ -2010,6 +2027,16 @@ function ensureMobileCompatibility(html) {
|
|
|
2010
2027
|
const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
|
|
2011
2028
|
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
2012
2029
|
}
|
|
2030
|
+
/** Maximum sizes enforced when reading local extension files. */
|
|
2031
|
+
const EXTENSION_LIMITS = Object.freeze({
|
|
2032
|
+
manifest: 65536,
|
|
2033
|
+
script: 1048576,
|
|
2034
|
+
css: 524288,
|
|
2035
|
+
asset: 8388608,
|
|
2036
|
+
assetFiles: 256,
|
|
2037
|
+
assetBytes: 33554432,
|
|
2038
|
+
assetDepth: 8
|
|
2039
|
+
});
|
|
2013
2040
|
//#endregion
|
|
2014
2041
|
//#region src/http-cache.ts
|
|
2015
2042
|
/** Entity-tag handling for authenticated GET and HEAD cache validation. */
|
|
@@ -3066,16 +3093,6 @@ var WorkerStreamBridge = class extends Readable {
|
|
|
3066
3093
|
};
|
|
3067
3094
|
//#endregion
|
|
3068
3095
|
//#region src/extensions.ts
|
|
3069
|
-
/** Maximum sizes enforced at the local-extension filesystem boundary. */
|
|
3070
|
-
const EXTENSION_LIMITS = Object.freeze({
|
|
3071
|
-
manifest: 65536,
|
|
3072
|
-
script: 1048576,
|
|
3073
|
-
css: 524288,
|
|
3074
|
-
asset: 8388608,
|
|
3075
|
-
assetFiles: 256,
|
|
3076
|
-
assetBytes: 33554432,
|
|
3077
|
-
assetDepth: 8
|
|
3078
|
-
});
|
|
3079
3096
|
/** A misbehaving host activation must not wedge the local watcher forever. */
|
|
3080
3097
|
const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
|
|
3081
3098
|
/** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
|
|
@@ -4811,7 +4828,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
4811
4828
|
const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
4812
4829
|
const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
|
|
4813
4830
|
return Object.freeze({
|
|
4814
|
-
html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
|
|
4831
|
+
html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements)))),
|
|
4815
4832
|
batches
|
|
4816
4833
|
});
|
|
4817
4834
|
}
|
|
@@ -5280,6 +5297,8 @@ var MobileAccessGateway = class {
|
|
|
5280
5297
|
this.listenerTlsEnabled = config.tls.mode === "provided";
|
|
5281
5298
|
this.tlsEnabled = config.publicTls;
|
|
5282
5299
|
this.access = new AccessController(store, {
|
|
5300
|
+
pairingInstanceId: config.instanceId,
|
|
5301
|
+
...config.legacyInstanceId === void 0 ? {} : { legacyInstanceId: config.legacyInstanceId },
|
|
5283
5302
|
pairingTtlMs: config.pairingTtlMs,
|
|
5284
5303
|
deviceTtlMs: config.deviceTtlMs,
|
|
5285
5304
|
sessionTtlMs: config.sessionTtlMs,
|
|
@@ -5656,12 +5675,16 @@ var MobileAccessGateway = class {
|
|
|
5656
5675
|
sessionExpiresAt: result.sessionExpiresAt
|
|
5657
5676
|
}, this.tlsEnabled);
|
|
5658
5677
|
}
|
|
5678
|
+
/** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
|
|
5679
|
+
nativeResponseInstanceId(result) {
|
|
5680
|
+
return this.config.pairingCaFile === void 0 ? result.nativeInstanceId ?? this.config.instanceId : this.config.instanceId;
|
|
5681
|
+
}
|
|
5659
5682
|
async handleNativePair(request, response) {
|
|
5660
5683
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
5661
5684
|
if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
|
|
5662
5685
|
const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
|
|
5663
5686
|
sendJson(response, 201, {
|
|
5664
|
-
instanceId: this.
|
|
5687
|
+
instanceId: this.nativeResponseInstanceId(result),
|
|
5665
5688
|
deviceId: result.deviceId,
|
|
5666
5689
|
deviceToken: result.deviceToken,
|
|
5667
5690
|
deviceExpiresAt: result.deviceExpiresAt,
|
|
@@ -5676,7 +5699,7 @@ var MobileAccessGateway = class {
|
|
|
5676
5699
|
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
5677
5700
|
const result = await this.access.renew(body.deviceToken);
|
|
5678
5701
|
sendJson(response, 200, {
|
|
5679
|
-
instanceId: this.
|
|
5702
|
+
instanceId: this.nativeResponseInstanceId(result),
|
|
5680
5703
|
deviceId: result.deviceId,
|
|
5681
5704
|
sessionToken: result.sessionToken,
|
|
5682
5705
|
csrfToken: result.csrfToken,
|
|
@@ -5689,7 +5712,7 @@ var MobileAccessGateway = class {
|
|
|
5689
5712
|
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
5690
5713
|
const result = await this.access.probe(body.deviceToken);
|
|
5691
5714
|
sendJson(response, 200, {
|
|
5692
|
-
instanceId: this.
|
|
5715
|
+
instanceId: this.nativeResponseInstanceId(result),
|
|
5693
5716
|
deviceId: result.deviceId,
|
|
5694
5717
|
deviceExpiresAt: result.deviceExpiresAt
|
|
5695
5718
|
}, this.tlsEnabled);
|
|
@@ -5986,8 +6009,9 @@ var MobileAccessGateway = class {
|
|
|
5986
6009
|
const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
|
|
5987
6010
|
const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
|
|
5988
6011
|
const frontendDocument = target.decodedPathname === "/" || target.decodedPathname === "/index.html";
|
|
5989
|
-
if (request.method === "GET" && frontendDocument && acceptsHtml
|
|
5990
|
-
|
|
6012
|
+
if (request.method === "GET" && frontendDocument && acceptsHtml) {
|
|
6013
|
+
if (stockFrontend && target.decodedPathname === "/") request.url = "/";
|
|
6014
|
+
await this.proxyMobileIndex(request, response, authorization, stockFrontend);
|
|
5991
6015
|
return;
|
|
5992
6016
|
}
|
|
5993
6017
|
if (stockFrontend && target.decodedPathname === "/") request.url = "/";
|
|
@@ -6378,6 +6402,8 @@ var MobileAccessGateway = class {
|
|
|
6378
6402
|
signal.throwIfAborted();
|
|
6379
6403
|
if (upstreamCookie !== void 0) headers.cookie = upstreamCookie;
|
|
6380
6404
|
headers["accept-encoding"] = "identity";
|
|
6405
|
+
delete headers["if-none-match"];
|
|
6406
|
+
delete headers["if-modified-since"];
|
|
6381
6407
|
const proxied = await new Promise((resolve, reject) => {
|
|
6382
6408
|
upstreamRequest = request({
|
|
6383
6409
|
protocol: "http:",
|
|
@@ -6417,7 +6443,7 @@ var MobileAccessGateway = class {
|
|
|
6417
6443
|
upstreamRequest?.destroy();
|
|
6418
6444
|
}
|
|
6419
6445
|
}
|
|
6420
|
-
async proxyMobileIndex(request, response, authorization) {
|
|
6446
|
+
async proxyMobileIndex(request, response, authorization, stockFrontend = false) {
|
|
6421
6447
|
const holder = {};
|
|
6422
6448
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
6423
6449
|
const onClose = () => {
|
|
@@ -6427,50 +6453,53 @@ var MobileAccessGateway = class {
|
|
|
6427
6453
|
response.once("close", onClose);
|
|
6428
6454
|
try {
|
|
6429
6455
|
const index = await this.fetchAuthenticatedUpstreamIndex(operation.signal, request);
|
|
6430
|
-
const preference = await this.clientModulePreferences.read(authorization.deviceId);
|
|
6431
|
-
const excludedClientModules = preference.excludedClientModules;
|
|
6432
6456
|
let body;
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
const
|
|
6436
|
-
const
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
const
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6457
|
+
if (stockFrontend) body = Buffer.from(ensureMobileGatewayMarker(index.html));
|
|
6458
|
+
else {
|
|
6459
|
+
const preference = await this.clientModulePreferences.read(authorization.deviceId);
|
|
6460
|
+
const excludedClientModules = preference.excludedClientModules;
|
|
6461
|
+
try {
|
|
6462
|
+
const html = index.html;
|
|
6463
|
+
const plan = parseMobileBootPlan(html, excludedClientModules);
|
|
6464
|
+
const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
|
|
6465
|
+
...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
|
|
6466
|
+
excludedClientModules
|
|
6467
|
+
};
|
|
6468
|
+
const cookies = browserAuthCookies(request.headers.cookie);
|
|
6469
|
+
const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
|
|
6470
|
+
const rewritten = rewriteMobileIndexWithBatch(html, {
|
|
6471
|
+
...options,
|
|
6472
|
+
csrfCookie
|
|
6473
|
+
});
|
|
6474
|
+
for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
|
|
6475
|
+
body = Buffer.from(rewritten.html);
|
|
6476
|
+
} catch (error) {
|
|
6477
|
+
if (error instanceof ExcludedClientModulesError) {
|
|
6478
|
+
const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
|
|
6479
|
+
if (detail !== this.lastModuleExclusionError) {
|
|
6480
|
+
this.lastModuleExclusionError = detail;
|
|
6481
|
+
process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
|
|
6482
|
+
}
|
|
6483
|
+
if (preference.source === "device" || preference.source === "computer") {
|
|
6484
|
+
operation.signal.throwIfAborted();
|
|
6485
|
+
const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
|
|
6486
|
+
const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
|
|
6487
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
6488
|
+
response.writeHead(200, {
|
|
6489
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
6490
|
+
"Content-Length": recovery.byteLength
|
|
6491
|
+
});
|
|
6492
|
+
response.end(recovery);
|
|
6493
|
+
return;
|
|
6494
|
+
}
|
|
6495
|
+
sendJson(response, 409, {
|
|
6496
|
+
error: "excluded_client_modules_invalid",
|
|
6497
|
+
detail
|
|
6498
|
+
}, this.tlsEnabled);
|
|
6465
6499
|
return;
|
|
6466
6500
|
}
|
|
6467
|
-
|
|
6468
|
-
error: "excluded_client_modules_invalid",
|
|
6469
|
-
detail
|
|
6470
|
-
}, this.tlsEnabled);
|
|
6471
|
-
return;
|
|
6501
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
6472
6502
|
}
|
|
6473
|
-
throw new HttpError(502, "upstream_unavailable");
|
|
6474
6503
|
}
|
|
6475
6504
|
operation.signal.throwIfAborted();
|
|
6476
6505
|
const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
|
|
@@ -7352,6 +7381,7 @@ function parseDevice(value) {
|
|
|
7352
7381
|
if (typeof record.id !== "string" || !/^[a-f\d]{32}$/u.test(record.id)) throw new Error("device state contains an invalid id");
|
|
7353
7382
|
if (typeof record.label !== "string" || record.label.length < 1 || record.label.length > 64 || /[\u0000-\u001f\u007f]/u.test(record.label)) throw new Error("device state contains an invalid label");
|
|
7354
7383
|
if (typeof record.tokenDigest !== "string" || !/^[a-f\d]{64}$/u.test(record.tokenDigest)) throw new Error("device state contains an invalid credential digest");
|
|
7384
|
+
if (record.nativeInstanceId !== void 0 && (typeof record.nativeInstanceId !== "string" || !/^[a-f\d]{64}$/u.test(record.nativeInstanceId))) throw new Error("device state contains an invalid native instance id");
|
|
7355
7385
|
assertInteger(record.createdAt, "createdAt");
|
|
7356
7386
|
assertInteger(record.expiresAt, "expiresAt");
|
|
7357
7387
|
assertInteger(record.lastSeenAt, "lastSeenAt");
|
|
@@ -7364,6 +7394,7 @@ function parseDevice(value) {
|
|
|
7364
7394
|
createdAt: record.createdAt,
|
|
7365
7395
|
expiresAt: record.expiresAt,
|
|
7366
7396
|
lastSeenAt: record.lastSeenAt,
|
|
7397
|
+
...record.nativeInstanceId === void 0 ? {} : { nativeInstanceId: record.nativeInstanceId },
|
|
7367
7398
|
...record.revokedAt === void 0 ? {} : { revokedAt: record.revokedAt }
|
|
7368
7399
|
});
|
|
7369
7400
|
}
|
|
@@ -7371,14 +7402,21 @@ function parseDevice(value) {
|
|
|
7371
7402
|
function parseDeviceSnapshot(value, maximumDevices = 256) {
|
|
7372
7403
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("device state must be an object");
|
|
7373
7404
|
const snapshot = value;
|
|
7374
|
-
if (snapshot.version !== 1 || !Array.isArray(snapshot.devices) || snapshot.devices.length > maximumDevices) throw new Error("device state has an unsupported version or device count");
|
|
7405
|
+
if (snapshot.version !== 1 && snapshot.version !== 2 || !Array.isArray(snapshot.devices) || snapshot.devices.length > maximumDevices) throw new Error("device state has an unsupported version or device count");
|
|
7375
7406
|
const devices = snapshot.devices.map(parseDevice);
|
|
7376
7407
|
if (new Set(devices.map((device) => device.id)).size !== devices.length || new Set(devices.map((device) => device.tokenDigest)).size !== devices.length) throw new Error("device state contains duplicate device identities");
|
|
7377
7408
|
return Object.freeze({
|
|
7378
|
-
version:
|
|
7409
|
+
version: snapshot.version,
|
|
7379
7410
|
devices: Object.freeze(devices)
|
|
7380
7411
|
});
|
|
7381
7412
|
}
|
|
7413
|
+
function snapshotForSave(snapshot, maximumDevices) {
|
|
7414
|
+
const validated = parseDeviceSnapshot(snapshot, maximumDevices);
|
|
7415
|
+
return Object.freeze({
|
|
7416
|
+
version: 2,
|
|
7417
|
+
devices: validated.devices
|
|
7418
|
+
});
|
|
7419
|
+
}
|
|
7382
7420
|
/** Atomic JSON implementation with symlink refusal and owner-only file creation. */
|
|
7383
7421
|
var JsonDeviceStore = class {
|
|
7384
7422
|
file;
|
|
@@ -7409,7 +7447,7 @@ var JsonDeviceStore = class {
|
|
|
7409
7447
|
return parseDeviceSnapshot(parsed, this.maximumDevices);
|
|
7410
7448
|
}
|
|
7411
7449
|
async save(snapshot) {
|
|
7412
|
-
const validated =
|
|
7450
|
+
const validated = snapshotForSave(snapshot, this.maximumDevices);
|
|
7413
7451
|
const directory = dirname(this.file);
|
|
7414
7452
|
await mkdir(directory, {
|
|
7415
7453
|
recursive: true,
|
|
@@ -7453,7 +7491,7 @@ var MemoryDeviceStore = class {
|
|
|
7453
7491
|
return structuredClone(this.snapshot);
|
|
7454
7492
|
}
|
|
7455
7493
|
async save(snapshot) {
|
|
7456
|
-
this.snapshot = structuredClone(
|
|
7494
|
+
this.snapshot = structuredClone(snapshotForSave(snapshot));
|
|
7457
7495
|
}
|
|
7458
7496
|
/** Return a defensive copy for assertions or administrative export. */
|
|
7459
7497
|
inspect() {
|
|
@@ -13281,11 +13319,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
13281
13319
|
//#region src/mobile-guide.ts
|
|
13282
13320
|
/** Compose the guide with the current customization state injected. */
|
|
13283
13321
|
function buildMobileGuide(state) {
|
|
13284
|
-
const styleLine = state.hasCustomCss ? "
|
|
13285
|
-
const scriptLine = state.hasCustomJs ? "
|
|
13322
|
+
const styleLine = state.hasCustomCss ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(使用内置默认样式)";
|
|
13323
|
+
const scriptLine = state.hasCustomJs ? "存在(已检测到文件,需在已配对页面验证加载)" : "不存在(无自定义脚本)";
|
|
13286
13324
|
const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})${entry.executionMode === void 0 ? "" : ` · ${entry.executionMode}${entry.executionState === "unavailable" ? " · 当前不可用,需要显式重启扩展" : ""}`}`).join("\n");
|
|
13287
13325
|
const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
|
|
13288
|
-
return `${`##
|
|
13326
|
+
return `${`## 本机定制文件与扩展状态(改动前必读,避免覆盖已有定制)
|
|
13289
13327
|
|
|
13290
13328
|
- 定制目录:${state.directory}(所有改动只允许在这里进行)
|
|
13291
13329
|
- mobile.css:${styleLine}
|
|
@@ -13309,7 +13347,9 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
13309
13347
|
1. 界面与交互 —— 只改外观和交互,不需要碰电脑的文件或程序:
|
|
13310
13348
|
- $DSH_HOME/mobile-access/mobile.css:手机端样式
|
|
13311
13349
|
- $DSH_HOME/mobile-access/mobile.js:手机端脚本,用 window.dshMobile.register(({ root }) => { ... }) 把内容挂载到 root,返回清理函数
|
|
13312
|
-
-
|
|
13350
|
+
- 保存后已配对的 DSH Mobile 页面会自动检查并应用,无需重启;文件存在不表示某个页面已成功加载
|
|
13351
|
+
|
|
13352
|
+
定制资源通过 DSH Mobile Gateway 的移动配对会话认证。普通 DSH 网页的登录凭据不等于移动配对凭据;直接打开普通 DSH 地址或仅用手机视口预览,不能据此验证 mobile.css / mobile.js。若扩展 manifest 返回 401,页面不会继续加载定制样式与脚本;先确认是通过“移动访问”配对入口进入,并检查实际请求与页面中的定制样式,不要盲改 CSS 或放松认证。核对 Cookie 时只确认是否存在,不读取或公开其值。
|
|
13313
13353
|
|
|
13314
13354
|
动手前先读已有的定制文件。若要调整 DSH 自带的输入框、侧栏或设置等现有界面,先确认当前 DSH 版本与手机页面模式(专用移动页面或 ?frontend=stock),只读核对运行页面的 DOM、属性和样式;本机有同版本前端源码时可参考相应组件,没有源码时可查看已安装的客户端产物,不必下载源码。优先使用扩展 API、插槽和稳定的 data-* 标记,不凭猜测依赖构建类名。只读查看不改变前述写入范围。
|
|
13315
13355
|
|
|
@@ -14824,6 +14864,7 @@ const CPOLAR_HOST_SUFFIXES = Object.freeze([
|
|
|
14824
14864
|
".cpolar.top",
|
|
14825
14865
|
".cpolar.com"
|
|
14826
14866
|
]);
|
|
14867
|
+
const CPOLAR_SUBDOMAIN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/u;
|
|
14827
14868
|
function publicStatus(status) {
|
|
14828
14869
|
return Object.freeze({
|
|
14829
14870
|
enabled: status.enabled,
|
|
@@ -14918,7 +14959,16 @@ var CpolarController = class {
|
|
|
14918
14959
|
constructor(options) {
|
|
14919
14960
|
this.options = options;
|
|
14920
14961
|
if (!isAbsolute(options.executable) || !isAbsolute(options.configFile)) throw new Error("cpolar paths must be absolute");
|
|
14921
|
-
|
|
14962
|
+
const { subdomain: rawSubdomain, region: rawRegion, ...resources } = options;
|
|
14963
|
+
const subdomain = rawSubdomain?.trim() || void 0;
|
|
14964
|
+
const region = rawRegion?.trim() || void 0;
|
|
14965
|
+
if (region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(region)) throw new Error("cpolar region is invalid");
|
|
14966
|
+
if (subdomain !== void 0 && !CPOLAR_SUBDOMAIN_PATTERN.test(subdomain)) throw new Error("cpolar subdomain is invalid");
|
|
14967
|
+
this.options = {
|
|
14968
|
+
...resources,
|
|
14969
|
+
...subdomain === void 0 ? {} : { subdomain },
|
|
14970
|
+
...region === void 0 ? {} : { region }
|
|
14971
|
+
};
|
|
14922
14972
|
}
|
|
14923
14973
|
/** Restore the remembered cpolar switch independently from LAN and Funnel state. */
|
|
14924
14974
|
async initialize(initialState) {
|
|
@@ -15068,6 +15118,7 @@ var CpolarController = class {
|
|
|
15068
15118
|
const args = [
|
|
15069
15119
|
"http",
|
|
15070
15120
|
`-config=${resolve(this.options.configFile)}`,
|
|
15121
|
+
...this.options.subdomain === void 0 ? [] : [`-subdomain=${this.options.subdomain}`],
|
|
15071
15122
|
...this.options.region === void 0 ? [] : [`-region=${this.options.region}`],
|
|
15072
15123
|
"-inspect-addr=false",
|
|
15073
15124
|
"-redirect-https=true",
|
|
@@ -16663,23 +16714,75 @@ async function readInstallationId(file) {
|
|
|
16663
16714
|
await restrictPrivateFile(file);
|
|
16664
16715
|
return contents.slice(0, -1);
|
|
16665
16716
|
}
|
|
16666
|
-
/** Keep one random plugin identity across setup changes, restarts, and FRP resets. */
|
|
16717
|
+
/** Keep one random plugin identity across setup changes, restarts, and FRP resets; creation requires hard-link support in the state directory. */
|
|
16667
16718
|
async function loadOrCreateInstallationId(file) {
|
|
16668
16719
|
if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
|
|
16669
16720
|
const target = resolve(file);
|
|
16670
|
-
|
|
16671
|
-
|
|
16672
|
-
mode: 448
|
|
16673
|
-
});
|
|
16721
|
+
const directory = dirname(target);
|
|
16722
|
+
await ensureManagedDirectory(directory, directory, "installation_id");
|
|
16674
16723
|
try {
|
|
16675
|
-
await
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16724
|
+
return await readInstallationId(target);
|
|
16725
|
+
} catch (error) {
|
|
16726
|
+
if (error.code !== "ENOENT") throw error;
|
|
16727
|
+
}
|
|
16728
|
+
const temporary = join(directory, `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
16729
|
+
let created = false;
|
|
16730
|
+
let failure;
|
|
16731
|
+
try {
|
|
16732
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
16733
|
+
const handle = await open(temporary, "wx", 384);
|
|
16734
|
+
created = true;
|
|
16735
|
+
let writeFailure;
|
|
16736
|
+
try {
|
|
16737
|
+
await handle.writeFile(`${randomBytes(32).toString("hex")}\n`, "utf8");
|
|
16738
|
+
} catch (error) {
|
|
16739
|
+
writeFailure = error;
|
|
16740
|
+
throw error;
|
|
16741
|
+
} finally {
|
|
16742
|
+
try {
|
|
16743
|
+
await handle.close();
|
|
16744
|
+
} catch (closeError) {
|
|
16745
|
+
if (writeFailure !== void 0) throw new AggregateError([writeFailure, closeError], "installation ID candidate write and close failed");
|
|
16746
|
+
throw closeError;
|
|
16747
|
+
}
|
|
16748
|
+
}
|
|
16749
|
+
await restrictPrivateFile(temporary);
|
|
16750
|
+
await assertManagedParents(directory, target, "installation_id");
|
|
16751
|
+
try {
|
|
16752
|
+
await link(temporary, target);
|
|
16753
|
+
} catch (error) {
|
|
16754
|
+
if (error.code !== "EEXIST") throw new Error("installation ID publication failed; the state filesystem must support hard links", { cause: error });
|
|
16755
|
+
}
|
|
16756
|
+
return await readInstallationId(target);
|
|
16679
16757
|
} catch (error) {
|
|
16680
|
-
|
|
16758
|
+
failure = error;
|
|
16759
|
+
throw error;
|
|
16760
|
+
} finally {
|
|
16761
|
+
if (created) try {
|
|
16762
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
16763
|
+
await unlink(temporary);
|
|
16764
|
+
} catch (cleanupError) {
|
|
16765
|
+
if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
|
|
16766
|
+
throw cleanupError;
|
|
16767
|
+
}
|
|
16681
16768
|
}
|
|
16682
|
-
|
|
16769
|
+
}
|
|
16770
|
+
//#endregion
|
|
16771
|
+
//#region src/gateway-identity.ts
|
|
16772
|
+
/**
|
|
16773
|
+
* Use the persisted installation identity for an unpinned, automatically identified entry.
|
|
16774
|
+
* @param config - Validated gateway configuration containing the legacy identity.
|
|
16775
|
+
* @param installationId - Confirmed random identity read from this installation's state.
|
|
16776
|
+
* @param explicitIdentity - Whether the operator supplied the gateway identity.
|
|
16777
|
+
* @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
|
|
16778
|
+
*/
|
|
16779
|
+
function withInstallationIdentity(config, installationId, explicitIdentity) {
|
|
16780
|
+
if (config.pairingCaFile !== void 0 || explicitIdentity) return config;
|
|
16781
|
+
return Object.freeze({
|
|
16782
|
+
...config,
|
|
16783
|
+
instanceId: installationId,
|
|
16784
|
+
legacyInstanceId: config.instanceId
|
|
16785
|
+
});
|
|
16683
16786
|
}
|
|
16684
16787
|
//#endregion
|
|
16685
16788
|
//#region src/lan-setup.ts
|
|
@@ -16927,7 +17030,7 @@ async function stableInstanceId(loaded, template) {
|
|
|
16927
17030
|
if (loaded.kind !== "managed") return loaded.config.instanceId ?? template.instanceId;
|
|
16928
17031
|
return new X509Certificate(await readFile(loaded.setup.tls.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
|
|
16929
17032
|
}
|
|
16930
|
-
function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0) {
|
|
17033
|
+
function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0, legacyInstanceId) {
|
|
16931
17034
|
const origin = new URL(publicOrigin);
|
|
16932
17035
|
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");
|
|
16933
17036
|
const publicAuthority = origin.port === "" ? `${origin.hostname}:443` : origin.host;
|
|
@@ -16940,6 +17043,7 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
|
|
|
16940
17043
|
allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
|
|
16941
17044
|
stateFile,
|
|
16942
17045
|
instanceId,
|
|
17046
|
+
...legacyInstanceId === void 0 ? {} : { legacyInstanceId },
|
|
16943
17047
|
tls: Object.freeze({ mode: "disabled" }),
|
|
16944
17048
|
publicTls: true,
|
|
16945
17049
|
discovery: false
|
|
@@ -17000,11 +17104,11 @@ async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirnam
|
|
|
17000
17104
|
}
|
|
17001
17105
|
}
|
|
17002
17106
|
/** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
|
|
17003
|
-
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
|
|
17107
|
+
function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort, legacyInstanceId) {
|
|
17004
17108
|
const validated = parseOriginSettings(settings);
|
|
17005
17109
|
if (listenPort !== 0) validateOriginListenPort(listenPort);
|
|
17006
17110
|
return Object.freeze({
|
|
17007
|
-
...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort),
|
|
17111
|
+
...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort, legacyInstanceId),
|
|
17008
17112
|
listenHost: validated.listenHost,
|
|
17009
17113
|
allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
|
|
17010
17114
|
});
|
|
@@ -17087,6 +17191,7 @@ async function apply(ctx, config) {
|
|
|
17087
17191
|
const frpComponent = new FrpComponentManager({ stateDirectory });
|
|
17088
17192
|
await frpComponent.initialize();
|
|
17089
17193
|
const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
|
|
17194
|
+
const remoteInstanceId = loaded.kind === "managed" ? frpIdentity : loaded.config.instanceId ?? frpIdentity;
|
|
17090
17195
|
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
|
|
17091
17196
|
await frpConfig.initialize();
|
|
17092
17197
|
const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
|
|
@@ -17138,10 +17243,10 @@ async function apply(ctx, config) {
|
|
|
17138
17243
|
let trustedNetworksWrite = Promise.resolve();
|
|
17139
17244
|
let adminClosing = false;
|
|
17140
17245
|
const startGateway = async (candidateConfig) => {
|
|
17141
|
-
const resolved = parseGatewayConfig({
|
|
17246
|
+
const resolved = withInstallationIdentity(parseGatewayConfig({
|
|
17142
17247
|
...candidateConfig,
|
|
17143
17248
|
upstreamOrigin: template.upstreamOrigin.origin
|
|
17144
|
-
});
|
|
17249
|
+
}), frpIdentity, candidateConfig.instanceId !== void 0);
|
|
17145
17250
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
|
|
17146
17251
|
logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
|
|
17147
17252
|
}, clientModulePreferences);
|
|
@@ -17199,7 +17304,7 @@ async function apply(ctx, config) {
|
|
|
17199
17304
|
}
|
|
17200
17305
|
}
|
|
17201
17306
|
const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
|
|
17202
|
-
const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile,
|
|
17307
|
+
const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
|
|
17203
17308
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
17204
17309
|
await candidate.start();
|
|
17205
17310
|
return candidate;
|
|
@@ -17226,7 +17331,7 @@ async function apply(ctx, config) {
|
|
|
17226
17331
|
return candidate;
|
|
17227
17332
|
};
|
|
17228
17333
|
const createOriginGateway = async (settings) => {
|
|
17229
|
-
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
17334
|
+
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, remoteInstanceId, settings.listenPort, instanceId);
|
|
17230
17335
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
17231
17336
|
try {
|
|
17232
17337
|
await candidate.start();
|
|
@@ -17245,7 +17350,7 @@ async function apply(ctx, config) {
|
|
|
17245
17350
|
store: createVolatileRemoteControlStore(),
|
|
17246
17351
|
component: caddyComponent,
|
|
17247
17352
|
config: caddyConfig,
|
|
17248
|
-
instanceId,
|
|
17353
|
+
instanceId: remoteInstanceId,
|
|
17249
17354
|
createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
|
|
17250
17355
|
});
|
|
17251
17356
|
const originController = new ManagedOriginController({
|
|
@@ -17271,6 +17376,8 @@ async function apply(ctx, config) {
|
|
|
17271
17376
|
store: cpolarStore,
|
|
17272
17377
|
executable: cpolarComponent.executable,
|
|
17273
17378
|
configFile: cpolarComponent.configFile,
|
|
17379
|
+
...config.cpolarSubdomain === void 0 ? {} : { subdomain: config.cpolarSubdomain },
|
|
17380
|
+
...config.cpolarRegion === void 0 ? {} : { region: config.cpolarRegion },
|
|
17274
17381
|
createGateway: createRemoteGateway
|
|
17275
17382
|
}),
|
|
17276
17383
|
cloudflared: new CloudflaredController({
|
|
@@ -17283,7 +17390,7 @@ async function apply(ctx, config) {
|
|
|
17283
17390
|
store: frpStore,
|
|
17284
17391
|
executable: frpComponent.executable,
|
|
17285
17392
|
config: frpConfig,
|
|
17286
|
-
instanceId,
|
|
17393
|
+
instanceId: remoteInstanceId,
|
|
17287
17394
|
createGateway: createFrpGateway,
|
|
17288
17395
|
/**
|
|
17289
17396
|
* Hand the start-up self-check the CA the app itself pins.
|