dsh-mobile 0.6.7 → 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
  });
@@ -5289,6 +5297,8 @@ var MobileAccessGateway = class {
5289
5297
  this.listenerTlsEnabled = config.tls.mode === "provided";
5290
5298
  this.tlsEnabled = config.publicTls;
5291
5299
  this.access = new AccessController(store, {
5300
+ pairingInstanceId: config.instanceId,
5301
+ ...config.legacyInstanceId === void 0 ? {} : { legacyInstanceId: config.legacyInstanceId },
5292
5302
  pairingTtlMs: config.pairingTtlMs,
5293
5303
  deviceTtlMs: config.deviceTtlMs,
5294
5304
  sessionTtlMs: config.sessionTtlMs,
@@ -5665,12 +5675,16 @@ var MobileAccessGateway = class {
5665
5675
  sessionExpiresAt: result.sessionExpiresAt
5666
5676
  }, this.tlsEnabled);
5667
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
+ }
5668
5682
  async handleNativePair(request, response) {
5669
5683
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5670
5684
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
5671
5685
  const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
5672
5686
  sendJson(response, 201, {
5673
- instanceId: this.config.instanceId,
5687
+ instanceId: this.nativeResponseInstanceId(result),
5674
5688
  deviceId: result.deviceId,
5675
5689
  deviceToken: result.deviceToken,
5676
5690
  deviceExpiresAt: result.deviceExpiresAt,
@@ -5685,7 +5699,7 @@ var MobileAccessGateway = class {
5685
5699
  if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5686
5700
  const result = await this.access.renew(body.deviceToken);
5687
5701
  sendJson(response, 200, {
5688
- instanceId: this.config.instanceId,
5702
+ instanceId: this.nativeResponseInstanceId(result),
5689
5703
  deviceId: result.deviceId,
5690
5704
  sessionToken: result.sessionToken,
5691
5705
  csrfToken: result.csrfToken,
@@ -5698,7 +5712,7 @@ var MobileAccessGateway = class {
5698
5712
  if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5699
5713
  const result = await this.access.probe(body.deviceToken);
5700
5714
  sendJson(response, 200, {
5701
- instanceId: this.config.instanceId,
5715
+ instanceId: this.nativeResponseInstanceId(result),
5702
5716
  deviceId: result.deviceId,
5703
5717
  deviceExpiresAt: result.deviceExpiresAt
5704
5718
  }, this.tlsEnabled);
@@ -7367,6 +7381,7 @@ function parseDevice(value) {
7367
7381
  if (typeof record.id !== "string" || !/^[a-f\d]{32}$/u.test(record.id)) throw new Error("device state contains an invalid id");
7368
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");
7369
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");
7370
7385
  assertInteger(record.createdAt, "createdAt");
7371
7386
  assertInteger(record.expiresAt, "expiresAt");
7372
7387
  assertInteger(record.lastSeenAt, "lastSeenAt");
@@ -7379,6 +7394,7 @@ function parseDevice(value) {
7379
7394
  createdAt: record.createdAt,
7380
7395
  expiresAt: record.expiresAt,
7381
7396
  lastSeenAt: record.lastSeenAt,
7397
+ ...record.nativeInstanceId === void 0 ? {} : { nativeInstanceId: record.nativeInstanceId },
7382
7398
  ...record.revokedAt === void 0 ? {} : { revokedAt: record.revokedAt }
7383
7399
  });
7384
7400
  }
@@ -7386,14 +7402,21 @@ function parseDevice(value) {
7386
7402
  function parseDeviceSnapshot(value, maximumDevices = 256) {
7387
7403
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("device state must be an object");
7388
7404
  const snapshot = value;
7389
- 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");
7390
7406
  const devices = snapshot.devices.map(parseDevice);
7391
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");
7392
7408
  return Object.freeze({
7393
- version: 1,
7409
+ version: snapshot.version,
7394
7410
  devices: Object.freeze(devices)
7395
7411
  });
7396
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
+ }
7397
7420
  /** Atomic JSON implementation with symlink refusal and owner-only file creation. */
7398
7421
  var JsonDeviceStore = class {
7399
7422
  file;
@@ -7424,7 +7447,7 @@ var JsonDeviceStore = class {
7424
7447
  return parseDeviceSnapshot(parsed, this.maximumDevices);
7425
7448
  }
7426
7449
  async save(snapshot) {
7427
- const validated = parseDeviceSnapshot(snapshot, this.maximumDevices);
7450
+ const validated = snapshotForSave(snapshot, this.maximumDevices);
7428
7451
  const directory = dirname(this.file);
7429
7452
  await mkdir(directory, {
7430
7453
  recursive: true,
@@ -7468,7 +7491,7 @@ var MemoryDeviceStore = class {
7468
7491
  return structuredClone(this.snapshot);
7469
7492
  }
7470
7493
  async save(snapshot) {
7471
- this.snapshot = structuredClone(parseDeviceSnapshot(snapshot));
7494
+ this.snapshot = structuredClone(snapshotForSave(snapshot));
7472
7495
  }
7473
7496
  /** Return a defensive copy for assertions or administrative export. */
7474
7497
  inspect() {
@@ -16691,23 +16714,75 @@ async function readInstallationId(file) {
16691
16714
  await restrictPrivateFile(file);
16692
16715
  return contents.slice(0, -1);
16693
16716
  }
16694
- /** 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. */
16695
16718
  async function loadOrCreateInstallationId(file) {
16696
16719
  if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
16697
16720
  const target = resolve(file);
16698
- await mkdir(dirname(target), {
16699
- recursive: true,
16700
- mode: 448
16701
- });
16721
+ const directory = dirname(target);
16722
+ await ensureManagedDirectory(directory, directory, "installation_id");
16702
16723
  try {
16703
- await writeFile(target, `${randomBytes(32).toString("hex")}\n`, {
16704
- flag: "wx",
16705
- mode: 384
16706
- });
16724
+ return await readInstallationId(target);
16707
16725
  } catch (error) {
16708
- if (error.code !== "EEXIST") throw error;
16726
+ if (error.code !== "ENOENT") throw error;
16709
16727
  }
16710
- return readInstallationId(target);
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);
16757
+ } catch (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
+ }
16768
+ }
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
+ });
16711
16786
  }
16712
16787
  //#endregion
16713
16788
  //#region src/lan-setup.ts
@@ -16955,7 +17030,7 @@ async function stableInstanceId(loaded, template) {
16955
17030
  if (loaded.kind !== "managed") return loaded.config.instanceId ?? template.instanceId;
16956
17031
  return new X509Certificate(await readFile(loaded.setup.tls.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
16957
17032
  }
16958
- function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0) {
17033
+ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0, legacyInstanceId) {
16959
17034
  const origin = new URL(publicOrigin);
16960
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");
16961
17036
  const publicAuthority = origin.port === "" ? `${origin.hostname}:443` : origin.host;
@@ -16968,6 +17043,7 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
16968
17043
  allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
16969
17044
  stateFile,
16970
17045
  instanceId,
17046
+ ...legacyInstanceId === void 0 ? {} : { legacyInstanceId },
16971
17047
  tls: Object.freeze({ mode: "disabled" }),
16972
17048
  publicTls: true,
16973
17049
  discovery: false
@@ -17028,11 +17104,11 @@ async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirnam
17028
17104
  }
17029
17105
  }
17030
17106
  /** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
17031
- function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
17107
+ function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort, legacyInstanceId) {
17032
17108
  const validated = parseOriginSettings(settings);
17033
17109
  if (listenPort !== 0) validateOriginListenPort(listenPort);
17034
17110
  return Object.freeze({
17035
- ...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort),
17111
+ ...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort, legacyInstanceId),
17036
17112
  listenHost: validated.listenHost,
17037
17113
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
17038
17114
  });
@@ -17115,6 +17191,7 @@ async function apply(ctx, config) {
17115
17191
  const frpComponent = new FrpComponentManager({ stateDirectory });
17116
17192
  await frpComponent.initialize();
17117
17193
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
17194
+ const remoteInstanceId = loaded.kind === "managed" ? frpIdentity : loaded.config.instanceId ?? frpIdentity;
17118
17195
  const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
17119
17196
  await frpConfig.initialize();
17120
17197
  const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
@@ -17166,10 +17243,10 @@ async function apply(ctx, config) {
17166
17243
  let trustedNetworksWrite = Promise.resolve();
17167
17244
  let adminClosing = false;
17168
17245
  const startGateway = async (candidateConfig) => {
17169
- const resolved = parseGatewayConfig({
17246
+ const resolved = withInstallationIdentity(parseGatewayConfig({
17170
17247
  ...candidateConfig,
17171
17248
  upstreamOrigin: template.upstreamOrigin.origin
17172
- });
17249
+ }), frpIdentity, candidateConfig.instanceId !== void 0);
17173
17250
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
17174
17251
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
17175
17252
  }, clientModulePreferences);
@@ -17227,7 +17304,7 @@ async function apply(ctx, config) {
17227
17304
  }
17228
17305
  }
17229
17306
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
17230
- const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
17307
+ const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
17231
17308
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17232
17309
  await candidate.start();
17233
17310
  return candidate;
@@ -17254,7 +17331,7 @@ async function apply(ctx, config) {
17254
17331
  return candidate;
17255
17332
  };
17256
17333
  const createOriginGateway = async (settings) => {
17257
- const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
17334
+ const resolved = originGatewayConfig(template, settings, remoteDeviceFile, remoteInstanceId, settings.listenPort, instanceId);
17258
17335
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17259
17336
  try {
17260
17337
  await candidate.start();
@@ -17273,7 +17350,7 @@ async function apply(ctx, config) {
17273
17350
  store: createVolatileRemoteControlStore(),
17274
17351
  component: caddyComponent,
17275
17352
  config: caddyConfig,
17276
- instanceId,
17353
+ instanceId: remoteInstanceId,
17277
17354
  createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
17278
17355
  });
17279
17356
  const originController = new ManagedOriginController({
@@ -17313,7 +17390,7 @@ async function apply(ctx, config) {
17313
17390
  store: frpStore,
17314
17391
  executable: frpComponent.executable,
17315
17392
  config: frpConfig,
17316
- instanceId,
17393
+ instanceId: remoteInstanceId,
17317
17394
  createGateway: createFrpGateway,
17318
17395
  /**
17319
17396
  * Hand the start-up self-check the CA the app itself pins.