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/CHANGELOG.md +25 -10
- package/CONTRIBUTORS.md +11 -5
- package/README.en.md +16 -10
- package/README.md +15 -10
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +5 -9
- package/docs/ATTACH_EXISTING_FRPS.en.md +32 -2
- package/docs/ATTACH_EXISTING_FRPS.md +2 -2
- package/docs/MOBILE_PLUGIN_ADAPTATION.en.md +188 -0
- package/docs/MOBILE_PLUGIN_ADAPTATION.md +188 -0
- package/docs/README.md +8 -0
- package/docs/SELF_HOSTED_ORIGIN.en.md +1 -1
- package/docs/SELF_HOSTED_ORIGIN.md +2 -2
- package/docs/SLOW_CONNECTIONS.en.md +2 -2
- package/docs/SLOW_CONNECTIONS.md +2 -2
- package/docs/TRUSTED_NETWORKS.en.md +17 -0
- package/docs/TRUSTED_NETWORKS.md +17 -0
- package/lib/cli.js.map +1 -1
- package/lib/client.js +420 -42
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +3 -0
- package/lib/index.mjs +115 -27
- package/lib/index.mjs.map +1 -1
- package/package.json +5 -1
- package/packages/question-fixes/package.json +1 -1
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
|
|
1900
|
-
function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDesktop = () => false) {
|
|
1901
|
-
|
|
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
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
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
|
|
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
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
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`;
|