dsh-mobile 0.8.0 → 0.8.1

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.d.mts CHANGED
@@ -542,6 +542,11 @@ export declare class MobileAccessGatewayController {
542
542
  isRunning(): boolean;
543
543
  /** Start or stop the runtime and persist only a successfully committed transition. */
544
544
  setRunning(running: boolean): Promise<void>;
545
+ /** Change configuration with the runtime stopped, without changing its durable enabled preference. */
546
+ reconfigure<T>(change: () => Promise<T>, restore: () => Promise<void>): Promise<{
547
+ value: T;
548
+ running: boolean;
549
+ }>;
545
550
  /** Stop the runtime after earlier transitions without changing the restart preference. */
546
551
  close(): Promise<void>;
547
552
  private enable;
package/lib/index.mjs CHANGED
@@ -1547,6 +1547,15 @@ var ClientModulePreferenceStore = class {
1547
1547
  };
1548
1548
  //#endregion
1549
1549
  //#region src/control.ts
1550
+ /** A configuration change failed; recovered distinguishes a restored runtime from an unknown configuration. */
1551
+ var MobileAccessReconfigurationError = class extends Error {
1552
+ recovered;
1553
+ constructor(recovered, cause) {
1554
+ super(recovered ? "mobile access configuration restored" : "mobile access configuration requires recovery", { cause });
1555
+ this.recovered = recovered;
1556
+ this.name = "MobileAccessReconfigurationError";
1557
+ }
1558
+ };
1550
1559
  /** Keeps one runtime aligned with a changing network selection. */
1551
1560
  var FollowingMobileAccessRuntime = class {
1552
1561
  select;
@@ -1737,6 +1746,36 @@ var MobileAccessGatewayController = class {
1737
1746
  else await this.disable();
1738
1747
  });
1739
1748
  }
1749
+ /** Change configuration with the runtime stopped, without changing its durable enabled preference. */
1750
+ reconfigure(change, restore) {
1751
+ if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("mobile-access control is closing"));
1752
+ return this.enqueue(async () => {
1753
+ if (!this.initialized) throw new Error("mobile-access control is not initialized");
1754
+ const previous = this.runtime;
1755
+ if (previous !== void 0) try {
1756
+ await previous.close();
1757
+ } catch (error) {
1758
+ throw new MobileAccessReconfigurationError(false, error);
1759
+ }
1760
+ this.runtime = void 0;
1761
+ try {
1762
+ const value = await change();
1763
+ if (previous !== void 0) this.runtime = await this.startRuntime();
1764
+ return {
1765
+ value,
1766
+ running: previous !== void 0
1767
+ };
1768
+ } catch (error) {
1769
+ try {
1770
+ await restore();
1771
+ if (previous !== void 0) this.runtime = await this.startRuntime();
1772
+ } catch (restoreError) {
1773
+ throw new MobileAccessReconfigurationError(false, new AggregateError([error, restoreError]));
1774
+ }
1775
+ throw new MobileAccessReconfigurationError(true, error);
1776
+ }
1777
+ });
1778
+ }
1740
1779
  /** Stop the runtime after earlier transitions without changing the restart preference. */
1741
1780
  close() {
1742
1781
  if (this.closeTask !== void 0) return this.closeTask;
@@ -11829,6 +11868,11 @@ async function saveManagedTrustedNetworks(setupFile, values) {
11829
11868
  await restrictPrivateFile(setupFile);
11830
11869
  return next;
11831
11870
  }
11871
+ /** Persist an existing managed setup without resetting its identity, paths or extra trusted networks. */
11872
+ async function saveManagedSetup(setupFile, setup) {
11873
+ const validated = parseManagedSetup(setup);
11874
+ await atomicWrite(setupFile, `${JSON.stringify(validated, null, 2)}\n`);
11875
+ }
11832
11876
  function privateIpv4(value) {
11833
11877
  const parts = value.split(".").map(Number);
11834
11878
  return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
@@ -17647,6 +17691,44 @@ async function uninstallVps(serverAddress, uninstall, input, options = {}) {
17647
17691
  });
17648
17692
  }
17649
17693
  //#endregion
17694
+ //#region src/lan-rebind.ts
17695
+ /** An unreadable setup is rejected before stopping the listener or replacing any file. */
17696
+ var ManagedLanConfigurationError = class extends Error {
17697
+ constructor(cause) {
17698
+ super("managed LAN configuration is unreadable", { cause });
17699
+ this.name = "ManagedLanConfigurationError";
17700
+ }
17701
+ };
17702
+ /** Rebind an existing listener; callers serialize this call with all other setup-file writes. */
17703
+ async function rebindManagedLan(options) {
17704
+ let previous;
17705
+ try {
17706
+ previous = parseManagedSetup(JSON.parse(await readFile(options.setupFile, "utf8")));
17707
+ await readManagedCa(previous.tls);
17708
+ } catch (error) {
17709
+ throw new ManagedLanConfigurationError(error);
17710
+ }
17711
+ const result = await options.controller.reconfigure(async () => {
17712
+ const next = Object.freeze({
17713
+ ...previous,
17714
+ networkInterface: options.network.name
17715
+ });
17716
+ await refreshManagedServerCertificate(next, options.network.address);
17717
+ await saveManagedSetup(options.setupFile, next);
17718
+ options.publish(next);
17719
+ return next;
17720
+ }, async () => {
17721
+ await saveManagedSetup(options.setupFile, previous);
17722
+ options.publish(previous);
17723
+ const network = options.selectPrevious?.(previous) ?? selectLanNetwork(void 0, previous.networkInterface);
17724
+ await refreshManagedServerCertificate(previous, network.address);
17725
+ });
17726
+ return {
17727
+ setup: result.value,
17728
+ running: result.running
17729
+ };
17730
+ }
17731
+ //#endregion
17650
17732
  //#region src/funnel.ts
17651
17733
  const MAX_PROTOCOL_LINE_BYTES = 16384;
17652
17734
  const FUNNEL_START_TIMEOUT_MS = 45e3;
@@ -23614,6 +23696,9 @@ function locateIrohCompanionRoots(importMetaUrl, environment = process.env) {
23614
23696
  async function apply(ctx, config) {
23615
23697
  const dshVersion = installedDshVersion();
23616
23698
  const loaded = await loadSetup(config);
23699
+ /** Rebind overrides the managed setup at runtime; the file on disk is the source of truth on restart. */
23700
+ let managedSetupOverride;
23701
+ let lanRebindRecoveryRequired = false;
23617
23702
  const mobileAccess = createMobileAccessService(ctx);
23618
23703
  const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
23619
23704
  const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
@@ -23910,8 +23995,17 @@ async function apply(ctx, config) {
23910
23995
  let lanGateway;
23911
23996
  let preparedLanSetup;
23912
23997
  let trustedNetworksRestartRequired = false;
23913
- let trustedNetworksWrite = Promise.resolve();
23998
+ let lanConfigurationWork = Promise.resolve();
23914
23999
  let adminClosing = false;
24000
+ const mutateLan = (operation) => {
24001
+ if (adminClosing) return Promise.reject(new HttpError(503, "unavailable"));
24002
+ const run = lanConfigurationWork.then(() => {
24003
+ if (adminClosing) throw new HttpError(503, "unavailable");
24004
+ return operation();
24005
+ });
24006
+ lanConfigurationWork = run.then(() => {}, () => {});
24007
+ return run;
24008
+ };
23915
24009
  const startGateway = async (candidateConfig) => {
23916
24010
  const resolved = withInstallationIdentity(parseGatewayConfig({
23917
24011
  ...candidateConfig,
@@ -23941,12 +24035,13 @@ async function apply(ctx, config) {
23941
24035
  if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
23942
24036
  if (loaded.kind === "fixed") return startGateway(loaded.config);
23943
24037
  const following = new FollowingMobileAccessRuntime(async () => {
23944
- const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
24038
+ const setup = managedSetupOverride ?? loaded.setup;
24039
+ const network = selectLanNetwork(void 0, setup.networkInterface);
23945
24040
  return {
23946
24041
  key: `${network.name}\0${network.address}\0${network.cidr}`,
23947
24042
  start: async () => startGateway({
23948
24043
  ...loaded.config,
23949
- ...await materializeManagedSetup(loaded.setup)
24044
+ ...await materializeManagedSetup(setup)
23950
24045
  })
23951
24046
  };
23952
24047
  }, (error) => {
@@ -24194,6 +24289,8 @@ async function apply(ctx, config) {
24194
24289
  restartRequired: preparedLanSetup !== void 0
24195
24290
  } : {},
24196
24291
  running: lanController.isRunning(),
24292
+ rebindSupported: loaded.kind === "managed",
24293
+ ...lanRebindRecoveryRequired ? { recoveryRequired: true } : {},
24197
24294
  origin: lanGateway?.address().origin,
24198
24295
  ...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
24199
24296
  ...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
@@ -24356,13 +24453,10 @@ async function apply(ctx, config) {
24356
24453
  } catch {
24357
24454
  throw new HttpError(400, "lan_trusted_networks_invalid");
24358
24455
  }
24359
- const write = trustedNetworksWrite.then(async () => {
24360
- if (adminClosing) throw new HttpError(503, "unavailable");
24456
+ await mutateLan(async () => {
24361
24457
  await saveManagedTrustedNetworks(loaded.setupFile, values);
24362
24458
  trustedNetworksRestartRequired = true;
24363
24459
  });
24364
- trustedNetworksWrite = write.catch(() => void 0);
24365
- await write;
24366
24460
  sendJson(response, 200, await trustedNetworksPayload(), false);
24367
24461
  return;
24368
24462
  }
@@ -24391,10 +24485,13 @@ async function apply(ctx, config) {
24391
24485
  return;
24392
24486
  }
24393
24487
  if (request.method === "POST" && lanControl) {
24394
- const body = await readJsonObject(request, 4096);
24395
- if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
24396
- if (body.running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
24397
- await lanController.setRunning(body.running);
24488
+ const running = (await readJsonObject(request, 4096)).running;
24489
+ if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
24490
+ if (running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
24491
+ await mutateLan(async () => {
24492
+ if (running && lanRebindRecoveryRequired) throw new HttpError(409, "lan_rebind_recovery_required");
24493
+ await lanController.setRunning(running);
24494
+ });
24398
24495
  sendJson(response, 200, lanPayload(), false);
24399
24496
  return;
24400
24497
  }
@@ -24424,6 +24521,74 @@ async function apply(ctx, config) {
24424
24521
  sendJson(response, 200, await lanSetupPayload(), false);
24425
24522
  return;
24426
24523
  }
24524
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/networks`) {
24525
+ await lanConfigurationWork;
24526
+ const networks = availableLanNetworks();
24527
+ const preferred = await preferredLanInterfaceNames();
24528
+ let recommendedAddress;
24529
+ try {
24530
+ recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
24531
+ } catch {}
24532
+ const currentInterface = loaded.kind === "managed" ? (managedSetupOverride ?? loaded.setup).networkInterface : void 0;
24533
+ sendJson(response, 200, {
24534
+ supported: loaded.kind === "managed",
24535
+ currentInterface,
24536
+ listenPort: loaded.kind === "managed" ? loaded.setup.listenPort : 3443,
24537
+ platform: process.platform,
24538
+ networks: networks.map((network) => ({
24539
+ name: network.name,
24540
+ address: network.address,
24541
+ cidr: network.cidr,
24542
+ current: network.name === currentInterface,
24543
+ recommended: network.address === recommendedAddress
24544
+ }))
24545
+ }, false);
24546
+ return;
24547
+ }
24548
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/rebind`) {
24549
+ if (loaded.kind !== "managed") throw new HttpError(409, "lan_rebind_unsupported");
24550
+ const body = await readJsonObject(request, 4096);
24551
+ const networkInterface = typeof body.networkInterface === "string" ? body.networkInterface : "";
24552
+ if (networkInterface === "" || Object.keys(body).some((key) => key !== "networkInterface")) throw new HttpError(400, "bad_request");
24553
+ sendJson(response, 200, await mutateLan(async () => {
24554
+ let network;
24555
+ try {
24556
+ network = selectLanNetwork(void 0, networkInterface);
24557
+ } catch {
24558
+ throw new HttpError(409, "lan_rebind_network_unavailable");
24559
+ }
24560
+ const current = managedSetupOverride ?? loaded.setup;
24561
+ if (!lanRebindRecoveryRequired && current.networkInterface === networkInterface) throw new HttpError(409, "lan_rebind_unchanged");
24562
+ const previousOrigin = lanGateway?.address().origin;
24563
+ try {
24564
+ const result = await rebindManagedLan({
24565
+ setupFile: loaded.setupFile,
24566
+ controller: lanController,
24567
+ network,
24568
+ publish: (setup) => {
24569
+ managedSetupOverride = setup;
24570
+ }
24571
+ });
24572
+ trustedNetworksRestartRequired = false;
24573
+ lanRebindRecoveryRequired = false;
24574
+ return {
24575
+ origin: lanGateway?.address().origin ?? `https://${network.address}:${String(result.setup.listenPort)}`,
24576
+ previousOrigin,
24577
+ restartRequired: false,
24578
+ disconnected: result.running,
24579
+ ready: lanGateway !== void 0
24580
+ };
24581
+ } catch (error) {
24582
+ if (error instanceof ManagedLanConfigurationError) throw new HttpError(409, "lan_rebind_configuration_invalid");
24583
+ if (!(error instanceof MobileAccessReconfigurationError)) throw new HttpError(503, "lan_rebind_unavailable");
24584
+ const recovered = error.recovered;
24585
+ if (!recovered) lanRebindRecoveryRequired = true;
24586
+ logger.error("managed LAN rebind failed; prior configuration restored: %s", String(recovered));
24587
+ throw new HttpError(500, recovered ? "lan_rebind_failed" : "lan_rebind_recovery_required");
24588
+ }
24589
+ }), false);
24590
+ return;
24591
+ }
24427
24592
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
24428
24593
  sendJson(response, 200, remotePayload(), false);
24429
24594
  return;
@@ -24942,7 +25107,7 @@ async function apply(ctx, config) {
24942
25107
  stopAdmin,
24943
25108
  drainRemoteWork,
24944
25109
  () => releaseManager.dispose(),
24945
- () => trustedNetworksWrite,
25110
+ () => lanConfigurationWork,
24946
25111
  disposeMobileCommand,
24947
25112
  disposeTaskEvents,
24948
25113
  async () => {
@@ -24965,7 +25130,7 @@ async function apply(ctx, config) {
24965
25130
  stopAdmin,
24966
25131
  drainRemoteWork,
24967
25132
  () => releaseManager.dispose(),
24968
- () => trustedNetworksWrite,
25133
+ () => lanConfigurationWork,
24969
25134
  disposeMobileCommand,
24970
25135
  disposeTaskEvents,
24971
25136
  async () => {