dsh-mobile 0.6.17 → 0.6.18

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
@@ -323,6 +323,8 @@ interface PluginConfig {
323
323
  upstreamOrigin?: string;
324
324
  publicAuthorities?: string[];
325
325
  allowedCidrs?: string[];
326
+ /** Extra direct management peers; every non-loopback request still requires DSH authentication. */
327
+ adminAllowedCidrs?: string[];
326
328
  stateFile: string;
327
329
  /** Internal persisted on/off preference managed by the DSH plugin card. */
328
330
  controlFile: string;
@@ -384,6 +386,7 @@ interface ResolvedGatewayConfig {
384
386
  readonly upstreamOrigin: URL;
385
387
  readonly authorities: readonly AuthoritySpec[];
386
388
  readonly allowedCidrs: readonly ParsedCidr[];
389
+ readonly adminAllowedCidrs: readonly ParsedCidr[];
387
390
  readonly stateFile: string;
388
391
  /** Local extension root adjacent to the mobile-access state file. */
389
392
  readonly extensionsDir: string;
package/lib/index.mjs CHANGED
@@ -958,6 +958,7 @@ const Config = z.object({
958
958
  upstreamOrigin: z.string(),
959
959
  publicAuthorities: z.array(String).default(void 0),
960
960
  allowedCidrs: z.array(String).default(void 0),
961
+ adminAllowedCidrs: z.array(String).default([]),
961
962
  stateFile: String,
962
963
  controlFile: z.string().hidden().required(),
963
964
  customCssFile: z.string().hidden(),
@@ -1111,6 +1112,11 @@ function parseGatewayConfig(raw) {
1111
1112
  if (new Set(authorities.map((entry) => `${entry.hostname}:${String(entry.port ?? listenPort)}`)).size !== authorities.length) throw new Error("publicAuthorities must not contain duplicates");
1112
1113
  const allowedCidrs = stringArray(value.allowedCidrs ?? (isLoopbackAddress(listenHost) ? ["127.0.0.0/8", "::1/128"] : void 0), "allowedCidrs").map(parseCidr);
1113
1114
  if (new Set(allowedCidrs.map((entry) => `${String(entry.bits)}:${entry.network.toString(16)}:${String(entry.prefix)}`)).size !== allowedCidrs.length) throw new Error("allowedCidrs must not contain duplicates");
1115
+ const adminSources = value.adminAllowedCidrs === void 0 ? [] : value.adminAllowedCidrs;
1116
+ if (!Array.isArray(adminSources) || adminSources.length > 32 || adminSources.some((source) => typeof source !== "string")) throw new Error("adminAllowedCidrs must contain at most 32 CIDR strings");
1117
+ const adminAllowedCidrs = adminSources.map(parseCidr);
1118
+ if (adminAllowedCidrs.some((entry) => entry.prefix === 0)) throw new Error("adminAllowedCidrs must not allow every address");
1119
+ if (new Set(adminAllowedCidrs.map((entry) => `${String(entry.bits)}:${entry.network.toString(16)}:${String(entry.prefix)}`)).size !== adminAllowedCidrs.length) throw new Error("adminAllowedCidrs must not contain duplicates");
1114
1120
  const deviceTtlMs = integer(value.deviceTtlMs, "deviceTtlMs", 7776e6, 6e4, 316224e5);
1115
1121
  const sessionTtlMs = integer(value.sessionTtlMs, "sessionTtlMs", 288e5, 3e4, 864e5);
1116
1122
  if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
@@ -1121,6 +1127,7 @@ function parseGatewayConfig(raw) {
1121
1127
  upstreamOrigin,
1122
1128
  authorities: Object.freeze(authorities),
1123
1129
  allowedCidrs: Object.freeze(allowedCidrs),
1130
+ adminAllowedCidrs: Object.freeze(adminAllowedCidrs),
1124
1131
  stateFile: absoluteFile(value.stateFile, "stateFile"),
1125
1132
  extensionsDir: join(dirname(absoluteFile(value.stateFile, "stateFile")), "extensions"),
1126
1133
  customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
@@ -1896,9 +1903,11 @@ function localAuthority(header) {
1896
1903
  return;
1897
1904
  }
1898
1905
  }
1899
- /** Protect the inner management route from non-loopback and DNS-rebinding callers. */
1900
- function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false) {
1901
- if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1906
+ /** Protect management with direct-peer, Host, browser-source and optional authenticated-network admission. */
1907
+ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false, adminAllowedCidrs = []) {
1908
+ const peer = request.socket.remoteAddress;
1909
+ const loopback = peer !== void 0 && isLoopbackAddress(peer);
1910
+ if (!loopback && !addressAllowed(peer, adminAllowedCidrs)) throw new HttpError(403, "forbidden");
1902
1911
  const host = localAuthority(request.headers.host);
1903
1912
  if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1904
1913
  if (request.method === "OPTIONS") throw new HttpError(403, "forbidden");
@@ -1917,6 +1926,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1917
1926
  const webRequest = request.method === "POST" && (site === void 0 || site === "same-origin") && request.headers["x-dsh-mobile-web-admin"] === "same-origin-json" && request.headers["x-dsh-mobile-desktop-admin"] === void 0 && typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
1918
1927
  if (requireBrowserOrigin && origin === void 0 && !webRequest && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1919
1928
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1929
+ if (!loopback && !authenticateDesktop()) throw new HttpError(401, "admin_authentication_required");
1920
1930
  }
1921
1931
  //#endregion
1922
1932
  //#region src/browser-auth-cookies.ts
@@ -3111,6 +3121,52 @@ async function listWorkspaceDirectories(path, signal) {
3111
3121
  truncated
3112
3122
  });
3113
3123
  }
3124
+ /**
3125
+ * Resolve exactly one child name without drive-relative or reserved Win32 spellings.
3126
+ * @param path - Fully qualified parent without a home/current-drive fallback.
3127
+ * @param name - Single non-blank path segment.
3128
+ * @param platform - Host path rules, explicit for portable parser tests.
3129
+ * @returns The child path whose immediate parent is the normalized requested directory.
3130
+ */
3131
+ function workspaceDirectoryChildPath(path, name, platform = process.platform) {
3132
+ if (typeof path !== "string" || path.length === 0) throw new HttpError(400, "directory_create_invalid");
3133
+ if (typeof name !== "string" || name.length === 0 || name.length > 255 || name.trim() === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0") || isAbsolute(name) || platform === "win32" && (/[<>:"|?*\u0000-\u001f]/u.test(name) || /[. ]$/u.test(name) || /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/iu.test(name))) throw new HttpError(400, "directory_create_invalid");
3134
+ const paths = platform === "win32" ? win32 : posix;
3135
+ const parent = resolveWorkspaceDirectoryPath(path, platform);
3136
+ const child = paths.resolve(parent, name);
3137
+ if (paths.dirname(child) !== parent) throw new HttpError(400, "directory_create_invalid");
3138
+ return child;
3139
+ }
3140
+ /**
3141
+ * Create one child directory under an explicitly confirmed existing parent.
3142
+ * @param path - Fully qualified existing parent; no home/default fallback is used.
3143
+ * @param name - One non-blank segment accepted by the Host filesystem rules.
3144
+ * @param signal - Cancellation withdraws admission/results, never a completed mkdir.
3145
+ * @returns The created directory's absolute path after filesystem work settles.
3146
+ */
3147
+ async function createWorkspaceDirectory(path, name, signal) {
3148
+ signal?.throwIfAborted();
3149
+ const created = workspaceDirectoryChildPath(path, name);
3150
+ const parent = resolveWorkspaceDirectoryPath(path);
3151
+ let stats;
3152
+ try {
3153
+ stats = await stat(parent);
3154
+ } catch (error) {
3155
+ if (signal?.aborted) throw signal.reason;
3156
+ throw new HttpError(404, "directory_unavailable");
3157
+ }
3158
+ if (!stats.isDirectory()) throw new HttpError(400, "directory_create_invalid");
3159
+ signal?.throwIfAborted();
3160
+ try {
3161
+ await mkdir(created);
3162
+ } catch (error) {
3163
+ if (signal?.aborted) throw signal.reason;
3164
+ if (error.code === "EEXIST") throw new HttpError(409, "directory_exists");
3165
+ throw new HttpError(400, "directory_create_failed");
3166
+ }
3167
+ signal?.throwIfAborted();
3168
+ return Object.freeze({ path: created });
3169
+ }
3114
3170
  /** Read one bounded regular image file selected by an authenticated device. */
3115
3171
  async function readComputerImage(path, signal) {
3116
3172
  signal?.throwIfAborted();
@@ -6735,7 +6791,7 @@ var MobileAccessGateway = class {
6735
6791
  }
6736
6792
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
6737
6793
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
6738
- const workspaceDirectories = request.method === "GET" && target.decodedPathname === `/mobile-access/workspace-directories`;
6794
+ const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
6739
6795
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
6740
6796
  const requestedExtension = extensionTarget(target.decodedPathname);
6741
6797
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
@@ -6898,10 +6954,18 @@ var MobileAccessGateway = class {
6898
6954
  response.once("close", onClose);
6899
6955
  const task = (async () => {
6900
6956
  try {
6901
- const query = new URL(target.raw, this.address().origin).searchParams;
6902
- const listing = await listWorkspaceDirectories(query.get("path"), operation.signal);
6903
- operation.signal.throwIfAborted();
6904
- sendJson(response, 200, listing, this.tlsEnabled);
6957
+ if (request.method === "POST") {
6958
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
6959
+ if (typeof body.path !== "string" || body.path.length === 0 || typeof body.name !== "string") throw new HttpError(400, "directory_create_invalid");
6960
+ const created = await createWorkspaceDirectory(body.path, body.name, operation.signal);
6961
+ operation.signal.throwIfAborted();
6962
+ sendJson(response, 201, created, this.tlsEnabled);
6963
+ } else {
6964
+ const query = new URL(target.raw, this.address().origin).searchParams;
6965
+ const listing = await listWorkspaceDirectories(query.get("path"), operation.signal);
6966
+ operation.signal.throwIfAborted();
6967
+ sendJson(response, 200, listing, this.tlsEnabled);
6968
+ }
6905
6969
  } catch (error) {
6906
6970
  if (operation.signal.aborted && error === operation.signal.reason) return;
6907
6971
  throw error;
@@ -7617,8 +7681,7 @@ var MobileAccessGateway = class {
7617
7681
  let body;
7618
7682
  if (stockFrontend) body = Buffer.from(ensureMobileGatewayMarker(index.html));
7619
7683
  else {
7620
- const preference = await this.clientModulePreferences.read(authorization.deviceId);
7621
- const excludedClientModules = preference.excludedClientModules;
7684
+ const excludedClientModules = (await this.clientModulePreferences.read(authorization.deviceId)).excludedClientModules;
7622
7685
  try {
7623
7686
  const html = index.html;
7624
7687
  const plan = parseMobileBootPlan(html, excludedClientModules);
@@ -7645,22 +7708,15 @@ var MobileAccessGateway = class {
7645
7708
  this.lastModuleExclusionError = detail;
7646
7709
  process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
7647
7710
  }
7648
- if (preference.source === "device" || preference.source === "computer") {
7649
- operation.signal.throwIfAborted();
7650
- const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
7651
- const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
7652
- setSecurityHeaders(response, this.tlsEnabled);
7653
- response.writeHead(200, {
7654
- "Content-Type": "text/html; charset=utf-8",
7655
- "Content-Length": recovery.byteLength
7656
- });
7657
- response.end(recovery);
7658
- return;
7659
- }
7660
- sendJson(response, 409, {
7661
- error: "excluded_client_modules_invalid",
7662
- detail
7663
- }, this.tlsEnabled);
7711
+ operation.signal.throwIfAborted();
7712
+ const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
7713
+ const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
7714
+ setSecurityHeaders(response, this.tlsEnabled);
7715
+ response.writeHead(200, {
7716
+ "Content-Type": "text/html; charset=utf-8",
7717
+ "Content-Length": recovery.byteLength
7718
+ });
7719
+ response.end(recovery);
7664
7720
  return;
7665
7721
  }
7666
7722
  throw new HttpError(502, "upstream_unavailable");
@@ -11255,6 +11311,31 @@ function parseAdditionalTrustedNetworks(value) {
11255
11311
  }
11256
11312
  return Object.freeze(entries);
11257
11313
  }
11314
+ /**
11315
+ * Refresh the legacy `upstreamOrigin` snapshot to the DSH port actually in use.
11316
+ *
11317
+ * The runtime deliberately follows the WebServer port instead of this field (a Desktop
11318
+ * profile can start with `--port 0`), but a stale snapshot is indistinguishable from a
11319
+ * stale gateway to whoever reads the file while diagnosing a restart — #273 became a
11320
+ * manual edit because of it. Rewriting it keeps the file honest without changing which
11321
+ * upstream the gateway uses.
11322
+ *
11323
+ * @param setupFile - Existing managed setup file.
11324
+ * @param setup - The setup as it was read.
11325
+ * @param webServerPort - The port the active DSH WebServer bound.
11326
+ * @returns Whether the file needed rewriting.
11327
+ */
11328
+ async function refreshManagedUpstreamOrigin(setupFile, setup, webServerPort) {
11329
+ if (!Number.isInteger(webServerPort) || webServerPort <= 0) return false;
11330
+ const upstreamOrigin = `http://127.0.0.1:${String(webServerPort)}`;
11331
+ if (setup.upstreamOrigin === upstreamOrigin) return false;
11332
+ const next = Object.freeze({
11333
+ ...setup,
11334
+ upstreamOrigin
11335
+ });
11336
+ await atomicWrite(setupFile, `${JSON.stringify(next, null, 2)}\n`);
11337
+ return true;
11338
+ }
11258
11339
  /** Save extra trusted networks without replacing the interface or pairing identity.
11259
11340
  * @param setupFile - Existing managed setup file.
11260
11341
  * @param values - Explicitly selected additional networks.
@@ -18632,11 +18713,18 @@ async function apply(ctx, config) {
18632
18713
  const mobileAccess = createMobileAccessService(ctx);
18633
18714
  const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
18634
18715
  const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
18716
+ const adminLogin = upstreamLoginUrl === void 0 ? void 0 : new URL(upstreamLoginUrl);
18717
+ const supportsRemoteAdminAuthentication = adminLogin !== void 0 && adminLogin.origin === baseTemplate.upstreamOrigin.origin && adminLogin.pathname === "/" && adminLogin.username === "" && adminLogin.password === "" && adminLogin.hash === "" && (adminLogin.searchParams.get("token")?.length ?? 0) > 0;
18635
18718
  const instanceId = await stableInstanceId(loaded, baseTemplate);
18636
18719
  const stateDirectory = dirname(baseTemplate.stateFile);
18637
18720
  const logFile = await installMobileFileLogger(ctx, stateDirectory);
18638
18721
  const logger = ctx.logger("dsh-mobile");
18639
18722
  logger.info("logging initialized file=%s", logFile);
18723
+ if (loaded.kind === "managed" && typeof ctx.webServer.port === "number") try {
18724
+ if (await refreshManagedUpstreamOrigin(loaded.setupFile, loaded.setup, ctx.webServer.port)) logger.info("dsh-mobile: refreshed the managed setup upstream snapshot to the active DSH port");
18725
+ } catch (error) {
18726
+ logger.warn("dsh-mobile: could not refresh the managed setup upstream snapshot: %s", error instanceof Error ? error.message : String(error));
18727
+ }
18640
18728
  const taskEventHub = new TaskEventHub();
18641
18729
  const disposeTaskEvents = watchTaskCompletions(ctx, {
18642
18730
  onTaskCompleted: (event) => taskEventHub.broadcast(event),
@@ -19030,7 +19118,7 @@ async function apply(ctx, config) {
19030
19118
  handler: async (request, response) => {
19031
19119
  try {
19032
19120
  const target = parseRequestTarget(request.url);
19033
- assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
19121
+ assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), baseTemplate.adminAllowedCidrs);
19034
19122
  if (adminClosing) throw new HttpError(503, "unavailable");
19035
19123
  if (target.search !== "") throw new HttpError(400, "bad_request");
19036
19124
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;