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/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: 1,
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(deviceId, now, deviceExpiresAt) {
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, deviceExpiresAt)
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
- deviceId,
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.id, now, device.expiresAt);
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.id, now, device.expiresAt);
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.config.instanceId,
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.config.instanceId,
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.config.instanceId,
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 && !stockFrontend) {
5990
- await this.proxyMobileIndex(request, response, authorization);
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
- try {
6434
- const html = index.html;
6435
- const plan = parseMobileBootPlan(html, excludedClientModules);
6436
- const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
6437
- ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
6438
- excludedClientModules
6439
- };
6440
- const cookies = browserAuthCookies(request.headers.cookie);
6441
- const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
6442
- const rewritten = rewriteMobileIndexWithBatch(html, {
6443
- ...options,
6444
- csrfCookie
6445
- });
6446
- for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
6447
- body = Buffer.from(rewritten.html);
6448
- } catch (error) {
6449
- if (error instanceof ExcludedClientModulesError) {
6450
- const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
6451
- if (detail !== this.lastModuleExclusionError) {
6452
- this.lastModuleExclusionError = detail;
6453
- process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
6454
- }
6455
- if (preference.source === "device" || preference.source === "computer") {
6456
- operation.signal.throwIfAborted();
6457
- const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
6458
- const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
6459
- setSecurityHeaders(response, this.tlsEnabled);
6460
- response.writeHead(200, {
6461
- "Content-Type": "text/html; charset=utf-8",
6462
- "Content-Length": recovery.byteLength
6463
- });
6464
- response.end(recovery);
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
- sendJson(response, 409, {
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: 1,
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 = parseDeviceSnapshot(snapshot, this.maximumDevices);
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(parseDeviceSnapshot(snapshot));
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
- if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
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
- await mkdir(dirname(target), {
16671
- recursive: true,
16672
- mode: 448
16673
- });
16721
+ const directory = dirname(target);
16722
+ await ensureManagedDirectory(directory, directory, "installation_id");
16674
16723
  try {
16675
- await writeFile(target, `${randomBytes(32).toString("hex")}\n`, {
16676
- flag: "wx",
16677
- mode: 384
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
- if (error.code !== "EEXIST") throw error;
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
- return readInstallationId(target);
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, instanceId, listenPort);
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.