dsh-mobile 0.6.16 → 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 +34 -10
- package/CONTRIBUTORS.md +28 -10
- package/README.en.md +16 -10
- package/README.md +15 -10
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +6 -17
- package/docs/ATTACH_EXISTING_FRPS.en.md +32 -2
- package/docs/ATTACH_EXISTING_FRPS.md +2 -2
- package/docs/CLIENT_MODULES.en.md +10 -2
- package/docs/CLIENT_MODULES.md +10 -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 +13 -3
- package/docs/SLOW_CONNECTIONS.md +13 -3
- 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 +1310 -31
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +53 -1
- package/lib/index.mjs +686 -56
- package/lib/index.mjs.map +1 -1
- package/package.json +6 -1
- package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { connect, createServer } from "node:net";
|
|
@@ -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
|
|
@@ -1973,7 +1983,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
|
|
|
1973
1983
|
}
|
|
1974
1984
|
//#endregion
|
|
1975
1985
|
//#region src/auth-failure-log.ts
|
|
1976
|
-
const LOG_INTERVAL_MS$
|
|
1986
|
+
const LOG_INTERVAL_MS$3 = 3e4;
|
|
1977
1987
|
const MAX_LOGGED_ORIGIN_LENGTH = 512;
|
|
1978
1988
|
function requestPathCategory(path) {
|
|
1979
1989
|
switch (path) {
|
|
@@ -2070,7 +2080,7 @@ var AuthFailureRecorder = class {
|
|
|
2070
2080
|
return;
|
|
2071
2081
|
}
|
|
2072
2082
|
const suppressed = window.suppressed;
|
|
2073
|
-
window.nextLogAt = now + LOG_INTERVAL_MS$
|
|
2083
|
+
window.nextLogAt = now + LOG_INTERVAL_MS$3;
|
|
2074
2084
|
window.suppressed = 0;
|
|
2075
2085
|
try {
|
|
2076
2086
|
this.observe({
|
|
@@ -2124,7 +2134,7 @@ function classifyHttpParserFailure(nodeCode) {
|
|
|
2124
2134
|
parserCode
|
|
2125
2135
|
};
|
|
2126
2136
|
}
|
|
2127
|
-
const LOG_INTERVAL_MS$
|
|
2137
|
+
const LOG_INTERVAL_MS$2 = 3e4;
|
|
2128
2138
|
/**
|
|
2129
2139
|
* Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
|
|
2130
2140
|
* Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
|
|
@@ -2193,7 +2203,7 @@ var HttpFailureRecorder = class {
|
|
|
2193
2203
|
return;
|
|
2194
2204
|
}
|
|
2195
2205
|
const suppressed = window.suppressed;
|
|
2196
|
-
window.nextLogAt = now + LOG_INTERVAL_MS$
|
|
2206
|
+
window.nextLogAt = now + LOG_INTERVAL_MS$2;
|
|
2197
2207
|
window.suppressed = 0;
|
|
2198
2208
|
try {
|
|
2199
2209
|
this.observe({
|
|
@@ -2210,6 +2220,114 @@ var HttpFailureRecorder = class {
|
|
|
2210
2220
|
}
|
|
2211
2221
|
};
|
|
2212
2222
|
//#endregion
|
|
2223
|
+
//#region src/api-latency-log.ts
|
|
2224
|
+
/**
|
|
2225
|
+
* Bounded API latency samples for diagnosing slow mobile requests.
|
|
2226
|
+
*
|
|
2227
|
+
* A sample never carries a query string, request body, header or credential.
|
|
2228
|
+
* The path collapses to its first `/api/` segment, so the recorded key set is
|
|
2229
|
+
* limited to fixed route categories rather than session or turn identifiers, and the
|
|
2230
|
+
* same report stays comparable across devices and releases.
|
|
2231
|
+
*/
|
|
2232
|
+
/** Only methods with a fixed spelling are recorded; anything else collapses. */
|
|
2233
|
+
const BOUNDED_METHODS = /* @__PURE__ */ new Set([
|
|
2234
|
+
"GET",
|
|
2235
|
+
"HEAD",
|
|
2236
|
+
"POST",
|
|
2237
|
+
"PUT",
|
|
2238
|
+
"PATCH",
|
|
2239
|
+
"DELETE"
|
|
2240
|
+
]);
|
|
2241
|
+
/** Requests below this budget are ordinary traffic and stay out of the log. */
|
|
2242
|
+
const API_LATENCY_MIN_MS = 3e3;
|
|
2243
|
+
const LOG_INTERVAL_MS$1 = 3e4;
|
|
2244
|
+
const ROUTE_SEGMENTS = Object.freeze([
|
|
2245
|
+
"session",
|
|
2246
|
+
"sessions",
|
|
2247
|
+
"workspace",
|
|
2248
|
+
"workspaces",
|
|
2249
|
+
"settings",
|
|
2250
|
+
"llm",
|
|
2251
|
+
"remote",
|
|
2252
|
+
"mobile-access",
|
|
2253
|
+
"plugins",
|
|
2254
|
+
"plugin-manager",
|
|
2255
|
+
"interactions",
|
|
2256
|
+
"credentials",
|
|
2257
|
+
"auth"
|
|
2258
|
+
]);
|
|
2259
|
+
/**
|
|
2260
|
+
* Reduce a request target to a bounded diagnostic route.
|
|
2261
|
+
* @param url - The raw request target; its query string is ignored entirely.
|
|
2262
|
+
* @returns `/api/<category>` for a fixed category, `/api/other` for an
|
|
2263
|
+
* unrecognized one, or `undefined` when the target is not an API request.
|
|
2264
|
+
*/
|
|
2265
|
+
function boundedApiPath(url) {
|
|
2266
|
+
const pathname = url?.split("?", 1)[0] ?? "";
|
|
2267
|
+
if (!pathname.startsWith("/api/")) return void 0;
|
|
2268
|
+
const category = (pathname.slice(5).split("/", 1)[0] ?? "").toLowerCase();
|
|
2269
|
+
return ROUTE_SEGMENTS.includes(category) ? `/api/${category}` : "/api/other";
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Per-Gateway slow-request diagnostics.
|
|
2273
|
+
*
|
|
2274
|
+
* Records once immediately, then at most once per 30 seconds with a suppressed
|
|
2275
|
+
* count, mirroring the HTTP failure recorder. A sink failure cannot change a
|
|
2276
|
+
* response, retry a write or expose the diagnostic sink's error.
|
|
2277
|
+
*/
|
|
2278
|
+
var ApiLatencyRecorder = class {
|
|
2279
|
+
observe;
|
|
2280
|
+
now;
|
|
2281
|
+
minDurationMs;
|
|
2282
|
+
reportObserverFailure;
|
|
2283
|
+
nextLogAt = 0;
|
|
2284
|
+
suppressed = 0;
|
|
2285
|
+
observerFailureReported = false;
|
|
2286
|
+
constructor(observe, now = () => performance.now(), minDurationMs = API_LATENCY_MIN_MS, reportObserverFailure = () => {
|
|
2287
|
+
process.emitWarning("Mobile access latency diagnostics could not be recorded", { code: "DSH_MOBILE_LATENCY_LOG_FAILED" });
|
|
2288
|
+
}) {
|
|
2289
|
+
this.observe = observe;
|
|
2290
|
+
this.now = now;
|
|
2291
|
+
this.minDurationMs = minDurationMs;
|
|
2292
|
+
this.reportObserverFailure = reportObserverFailure;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Record one completed request when it exceeded the reporting budget.
|
|
2296
|
+
* @param sample - The bounded route, method, status and measured duration.
|
|
2297
|
+
*/
|
|
2298
|
+
record(sample) {
|
|
2299
|
+
if (this.observe === void 0) return;
|
|
2300
|
+
if (!Number.isFinite(sample.durationMs) || sample.durationMs < this.minDurationMs) return;
|
|
2301
|
+
const now = this.now();
|
|
2302
|
+
if (now < this.nextLogAt) {
|
|
2303
|
+
this.suppressed = Math.min(Number.MAX_SAFE_INTEGER, this.suppressed + 1);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const suppressed = this.suppressed;
|
|
2307
|
+
this.nextLogAt = now + LOG_INTERVAL_MS$1;
|
|
2308
|
+
this.suppressed = 0;
|
|
2309
|
+
const method = sample.method?.toUpperCase() ?? "";
|
|
2310
|
+
const status = Number.isInteger(sample.status) && sample.status >= 100 && sample.status <= 599 ? sample.status : 0;
|
|
2311
|
+
const entry = {
|
|
2312
|
+
kind: "api",
|
|
2313
|
+
method: BOUNDED_METHODS.has(method) ? method : "OTHER",
|
|
2314
|
+
path: sample.path,
|
|
2315
|
+
status,
|
|
2316
|
+
durationMs: Math.round(sample.durationMs),
|
|
2317
|
+
...suppressed === 0 ? {} : { suppressed }
|
|
2318
|
+
};
|
|
2319
|
+
try {
|
|
2320
|
+
this.observe(entry);
|
|
2321
|
+
} catch (observerError) {
|
|
2322
|
+
if (this.observerFailureReported) return;
|
|
2323
|
+
this.observerFailureReported = true;
|
|
2324
|
+
try {
|
|
2325
|
+
this.reportObserverFailure();
|
|
2326
|
+
} catch (reporterError) {}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
//#endregion
|
|
2213
2331
|
//#region src/upstream-auth-log.ts
|
|
2214
2332
|
const LOG_INTERVAL_MS = 3e4;
|
|
2215
2333
|
/** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
|
|
@@ -2928,6 +3046,127 @@ async function listComputerImages(path, signal) {
|
|
|
2928
3046
|
truncated
|
|
2929
3047
|
});
|
|
2930
3048
|
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Resolve a workspace path without a relative or current-drive fallback.
|
|
3051
|
+
*
|
|
3052
|
+
* @param path - Fully qualified directory, or null/empty for the Host home.
|
|
3053
|
+
* @param platform - Host path syntax; explicit for cross-platform parser tests.
|
|
3054
|
+
* @returns The normalized fully qualified path in the selected syntax.
|
|
3055
|
+
*/
|
|
3056
|
+
function resolveWorkspaceDirectoryPath(path, platform = process.platform) {
|
|
3057
|
+
if (path === null || path === "") return homedir();
|
|
3058
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
3059
|
+
const qualified = platform !== "win32" || /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+(?:[\\/]|$))/u.test(path) && !/^[\\/]{2}[?.][\\/]/u.test(path);
|
|
3060
|
+
if (path.includes("\0") || !paths.isAbsolute(path) || !qualified) throw new HttpError(400, "bad_path");
|
|
3061
|
+
return paths.resolve(path);
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* List folders only for the dedicated mobile workspace picker.
|
|
3065
|
+
*
|
|
3066
|
+
* Returns at most 500 enterable folders, including directory links. A truncated
|
|
3067
|
+
* level retains its first filesystem-enumerated rows; callers can enter an
|
|
3068
|
+
* absolute path to reach an omitted row. Cancellation preserves its reason;
|
|
3069
|
+
* pending filesystem operations and handle closure settle before this request
|
|
3070
|
+
* releases its Gateway work slot.
|
|
3071
|
+
*/
|
|
3072
|
+
async function listWorkspaceDirectories(path, signal) {
|
|
3073
|
+
signal?.throwIfAborted();
|
|
3074
|
+
const target = resolveWorkspaceDirectoryPath(path);
|
|
3075
|
+
const rows = [];
|
|
3076
|
+
let truncated = false;
|
|
3077
|
+
let directory;
|
|
3078
|
+
try {
|
|
3079
|
+
directory = await opendir(target);
|
|
3080
|
+
for (;;) {
|
|
3081
|
+
signal?.throwIfAborted();
|
|
3082
|
+
const entry = await directory.read();
|
|
3083
|
+
signal?.throwIfAborted();
|
|
3084
|
+
if (entry === null) break;
|
|
3085
|
+
const entryPath = resolve(target, entry.name);
|
|
3086
|
+
let enterable = entry.isDirectory();
|
|
3087
|
+
if (!enterable && entry.isSymbolicLink()) try {
|
|
3088
|
+
enterable = (await stat(entryPath)).isDirectory();
|
|
3089
|
+
signal?.throwIfAborted();
|
|
3090
|
+
} catch {
|
|
3091
|
+
signal?.throwIfAborted();
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
if (!enterable) continue;
|
|
3095
|
+
if (rows.length === MAX_ENTRIES) {
|
|
3096
|
+
truncated = true;
|
|
3097
|
+
break;
|
|
3098
|
+
}
|
|
3099
|
+
rows.push({
|
|
3100
|
+
name: entry.name,
|
|
3101
|
+
path: entryPath
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
if (signal?.aborted) throw signal.reason;
|
|
3106
|
+
throw new HttpError(404, "directory_unavailable");
|
|
3107
|
+
} finally {
|
|
3108
|
+
try {
|
|
3109
|
+
await directory?.close();
|
|
3110
|
+
} catch {
|
|
3111
|
+
throw new HttpError(500, "directory_cleanup_failed");
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
signal?.throwIfAborted();
|
|
3115
|
+
rows.sort((left, right) => left.name.localeCompare(right.name));
|
|
3116
|
+
const parent = dirname(target);
|
|
3117
|
+
return Object.freeze({
|
|
3118
|
+
path: target,
|
|
3119
|
+
...parent === target ? {} : { parent },
|
|
3120
|
+
entries: Object.freeze(rows),
|
|
3121
|
+
truncated
|
|
3122
|
+
});
|
|
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
|
+
}
|
|
2931
3170
|
/** Read one bounded regular image file selected by an authenticated device. */
|
|
2932
3171
|
async function readComputerImage(path, signal) {
|
|
2933
3172
|
signal?.throwIfAborted();
|
|
@@ -4945,21 +5184,43 @@ const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
|
|
|
4945
5184
|
const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
|
|
4946
5185
|
const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
|
|
4947
5186
|
/**
|
|
4948
|
-
*
|
|
4949
|
-
*
|
|
4950
|
-
*
|
|
4951
|
-
*
|
|
5187
|
+
* Target source-byte size for merged batches. A module larger than this target
|
|
5188
|
+
* remains whole, subject to the separate per-entry and assembly limits. Excess
|
|
5189
|
+
* merged batches use the original individual module URLs instead of rejecting
|
|
5190
|
+
* an otherwise loadable graph. Smaller responses do not guarantee a page-load
|
|
5191
|
+
* deadline: transfer time also depends on the complete graph and connection.
|
|
4952
5192
|
*/
|
|
4953
|
-
const MOBILE_BOOT_CHUNK_BYTES =
|
|
5193
|
+
const MOBILE_BOOT_CHUNK_BYTES = 3145728;
|
|
5194
|
+
/** Bodies below this size cost more to gzip than they save on the wire. */
|
|
5195
|
+
const MIN_COMPRESSIBLE_DOCUMENT_BYTES = 1024;
|
|
4954
5196
|
/** Bytes the assembly emits after each entry (`body` plus `\n;\n`). */
|
|
4955
5197
|
const MOBILE_BOOT_SEPARATOR_BYTES = 3;
|
|
4956
5198
|
/** Freshness window for cached upstream bundle byte sizes. */
|
|
5199
|
+
/** Catalog sizes change only on a plugin rebuild or host restart. */
|
|
5200
|
+
const CLIENT_MODULE_SIZE_TTL_MS = 6e5;
|
|
5201
|
+
/** Explicit metadata measurement is optional and must fit one short UI operation. */
|
|
5202
|
+
const CLIENT_MODULE_MEASUREMENT_BUDGET_MS = 5e3;
|
|
4957
5203
|
const MOBILE_BOOT_SIZE_CACHE_TTL_MS = 3e4;
|
|
4958
5204
|
/** Marker header distinguishing size probes from ordinary proxied bundle fetches. */
|
|
4959
5205
|
const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
|
|
4960
|
-
const MAX_MOBILE_BOOT_BATCHES =
|
|
5206
|
+
const MAX_MOBILE_BOOT_BATCHES = 16;
|
|
4961
5207
|
const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
|
|
4962
5208
|
const MOBILE_BOOT_RETRY_DELAY_MS = 150;
|
|
5209
|
+
/**
|
|
5210
|
+
* Sanitize an installed package's description for the settings panel.
|
|
5211
|
+
*
|
|
5212
|
+
* The text comes from the plugin's own package metadata, not from an upstream
|
|
5213
|
+
* certification, so URLs are dropped and control characters collapse; the panel
|
|
5214
|
+
* keeps the wording as published instead of translating or editorializing it.
|
|
5215
|
+
* @param value - The package.json `description` field, possibly absent.
|
|
5216
|
+
* @returns A single-line description, or `undefined` when nothing usable remains.
|
|
5217
|
+
*/
|
|
5218
|
+
function sanitizeModuleDescription(value) {
|
|
5219
|
+
if (typeof value !== "string") return void 0;
|
|
5220
|
+
const text = value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/https?:\/\/\S+/giu, " ").replace(/\s+/gu, " ").trim();
|
|
5221
|
+
if (text === "") return void 0;
|
|
5222
|
+
return text.length <= 200 ? text : `${text.slice(0, 197)}…`;
|
|
5223
|
+
}
|
|
4963
5224
|
function upstreamPluginBundleUrl(source, upstreamOrigin) {
|
|
4964
5225
|
if (!source.startsWith("/plugins/") && !source.startsWith("plugins/") || source.includes("#")) return void 0;
|
|
4965
5226
|
try {
|
|
@@ -5320,6 +5581,41 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
|
|
|
5320
5581
|
rows
|
|
5321
5582
|
};
|
|
5322
5583
|
}
|
|
5584
|
+
/**
|
|
5585
|
+
* Retain the layout replacement while bounding the graph's stored merged batches.
|
|
5586
|
+
* @param splits - Application batches already partitioned against the byte target.
|
|
5587
|
+
* @returns The same module coverage, with overflow served by valid upstream URLs.
|
|
5588
|
+
*/
|
|
5589
|
+
function limitMobileBootBatches(splits) {
|
|
5590
|
+
const layoutPlan = splits.flatMap((split) => split.plans).find((plan) => plan.entries.some((entry) => entry.id === MOBILE_LAYOUT_MODULE));
|
|
5591
|
+
let remaining = MAX_MOBILE_BOOT_BATCHES - (layoutPlan === void 0 ? 0 : 1);
|
|
5592
|
+
const upstreamOrigin = new URL("http://dsh-mobile.invalid");
|
|
5593
|
+
return splits.map((split) => {
|
|
5594
|
+
const byPath = new Map(split.plans.map((plan) => [plan.path, plan]));
|
|
5595
|
+
const plans = [];
|
|
5596
|
+
return {
|
|
5597
|
+
plans,
|
|
5598
|
+
rows: split.rows.flatMap((row) => {
|
|
5599
|
+
const plan = byPath.get(row.url);
|
|
5600
|
+
if (plan === void 0) return [row];
|
|
5601
|
+
if (plan === layoutPlan || remaining > 0) {
|
|
5602
|
+
if (plan !== layoutPlan) remaining -= 1;
|
|
5603
|
+
plans.push(plan);
|
|
5604
|
+
return [row];
|
|
5605
|
+
}
|
|
5606
|
+
return plan.entries.map((entry) => {
|
|
5607
|
+
if (upstreamPluginBundleUrl(entry.url, upstreamOrigin) === void 0) throw new Error("upstream DSH mobile boot has no valid module URL for an overflow batch");
|
|
5608
|
+
return {
|
|
5609
|
+
phase: "application",
|
|
5610
|
+
url: entry.url,
|
|
5611
|
+
rev: entry.rev,
|
|
5612
|
+
entries: [entry.id]
|
|
5613
|
+
};
|
|
5614
|
+
});
|
|
5615
|
+
})
|
|
5616
|
+
};
|
|
5617
|
+
});
|
|
5618
|
+
}
|
|
5323
5619
|
function escapeMobilePreloadAttribute(value) {
|
|
5324
5620
|
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
5325
5621
|
}
|
|
@@ -5356,11 +5652,11 @@ function projectMobileBootGraph(plan, options) {
|
|
|
5356
5652
|
let batches = [];
|
|
5357
5653
|
let preloadReplacements = /* @__PURE__ */ new Map();
|
|
5358
5654
|
if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
|
|
5359
|
-
const
|
|
5655
|
+
const splits = limitMobileBootBatches(plan.rewrittenBatchEntries.map(({ entries }) => splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())));
|
|
5656
|
+
const rewrites = plan.rewrittenBatchEntries.map(({ batch }, index) => ({
|
|
5360
5657
|
batch,
|
|
5361
|
-
split:
|
|
5658
|
+
split: splits[index]
|
|
5362
5659
|
}));
|
|
5363
|
-
if (rewrites.reduce((count, item) => count + item.split.plans.length, 0) > MAX_MOBILE_BOOT_BATCHES) throw new Error(`upstream DSH mobile boot requires more than ${String(MAX_MOBILE_BOOT_BATCHES)} merged batches`);
|
|
5364
5660
|
for (const { batch, split } of rewrites) {
|
|
5365
5661
|
const at = plan.batches.indexOf(batch);
|
|
5366
5662
|
if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
|
|
@@ -5576,9 +5872,29 @@ function isCompressibleContentType(value) {
|
|
|
5576
5872
|
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
5577
5873
|
return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
|
|
5578
5874
|
}
|
|
5875
|
+
/** Media types the client must receive frame by frame, without gzip blocking. */
|
|
5876
|
+
const INCREMENTAL_MEDIA_TYPES = /* @__PURE__ */ new Set(["text/event-stream"]);
|
|
5877
|
+
/** Bodies below this size cost more to gzip than they save on the wire. */
|
|
5878
|
+
const MIN_COMPRESSIBLE_BYTES = 1024;
|
|
5879
|
+
function responseMediaType(value) {
|
|
5880
|
+
return (Array.isArray(value) ? value[0] : value)?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
5881
|
+
}
|
|
5882
|
+
/** Static asset paths keep their existing compression contract unchanged. */
|
|
5883
|
+
function isStaticAssetPath(url) {
|
|
5884
|
+
const pathname = url?.split("?", 1)[0] ?? "";
|
|
5885
|
+
return pathname.startsWith("/plugins/") || pathname.startsWith("/assets/");
|
|
5886
|
+
}
|
|
5579
5887
|
function canCompressResponse(request, response) {
|
|
5580
|
-
|
|
5581
|
-
|
|
5888
|
+
if (!(request.method === "GET" || request.method === "HEAD")) return false;
|
|
5889
|
+
if (response.statusCode !== 200) return false;
|
|
5890
|
+
if (request.headers.range !== void 0) return false;
|
|
5891
|
+
if (response.headers["content-range"] !== void 0) return false;
|
|
5892
|
+
if (response.headers["content-encoding"] !== void 0) return false;
|
|
5893
|
+
if (!isCompressibleContentType(response.headers["content-type"])) return false;
|
|
5894
|
+
if (isStaticAssetPath(request.url)) return true;
|
|
5895
|
+
if (INCREMENTAL_MEDIA_TYPES.has(responseMediaType(response.headers["content-type"]))) return false;
|
|
5896
|
+
const declaredLength = Number(response.headers["content-length"]);
|
|
5897
|
+
return Number.isFinite(declaredLength) && declaredLength >= MIN_COMPRESSIBLE_BYTES;
|
|
5582
5898
|
}
|
|
5583
5899
|
function revisionedStaticCacheControl(request, statusCode) {
|
|
5584
5900
|
if (request.method !== "GET" && request.method !== "HEAD") return void 0;
|
|
@@ -5813,6 +6129,7 @@ var MobileAccessGateway = class {
|
|
|
5813
6129
|
mobileBootBatches = /* @__PURE__ */ new Map();
|
|
5814
6130
|
mobileDocumentViews = /* @__PURE__ */ new Map();
|
|
5815
6131
|
mobileGraphTasks = /* @__PURE__ */ new Set();
|
|
6132
|
+
workspaceDirectoryTasks = /* @__PURE__ */ new Set();
|
|
5816
6133
|
/** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
|
|
5817
6134
|
bootEntrySizeCache = /* @__PURE__ */ new Map();
|
|
5818
6135
|
extensionEventListeners = /* @__PURE__ */ new Set();
|
|
@@ -5840,13 +6157,18 @@ var MobileAccessGateway = class {
|
|
|
5840
6157
|
probeDeviceLimiter;
|
|
5841
6158
|
probeInFlight = /* @__PURE__ */ new Set();
|
|
5842
6159
|
clientModulePreferences;
|
|
6160
|
+
clientModuleSizeCache = /* @__PURE__ */ new Map();
|
|
6161
|
+
clientModuleDescriptionCache = /* @__PURE__ */ new Map();
|
|
6162
|
+
clientModuleMetadataRevision;
|
|
6163
|
+
clientModuleMeasurementActive = false;
|
|
5843
6164
|
clientModuleOperations = /* @__PURE__ */ new Set();
|
|
5844
6165
|
clientModuleTasks = /* @__PURE__ */ new Set();
|
|
5845
6166
|
clientModuleRevocations = /* @__PURE__ */ new Set();
|
|
5846
6167
|
authFailures;
|
|
5847
6168
|
httpFailures;
|
|
6169
|
+
apiLatency;
|
|
5848
6170
|
upstreamAuthDiagnostics;
|
|
5849
|
-
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog) {
|
|
6171
|
+
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog) {
|
|
5850
6172
|
this.config = config;
|
|
5851
6173
|
this.extensions = extensions;
|
|
5852
6174
|
this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
|
|
@@ -5855,6 +6177,7 @@ var MobileAccessGateway = class {
|
|
|
5855
6177
|
this.onDiscoveryDegraded = onDiscoveryDegraded;
|
|
5856
6178
|
this.authFailures = new AuthFailureRecorder(authFailureLog);
|
|
5857
6179
|
this.httpFailures = new HttpFailureRecorder(httpFailureLog);
|
|
6180
|
+
this.apiLatency = new ApiLatencyRecorder(apiLatencyLog);
|
|
5858
6181
|
this.upstreamAuthDiagnostics = new UpstreamAuthRecorder(upstreamAuthLog);
|
|
5859
6182
|
this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
|
|
5860
6183
|
this.listenerTlsEnabled = config.tls.mode === "provided";
|
|
@@ -6468,6 +6791,7 @@ var MobileAccessGateway = class {
|
|
|
6468
6791
|
}
|
|
6469
6792
|
const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
|
|
6470
6793
|
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
6794
|
+
const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
|
|
6471
6795
|
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
6472
6796
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
6473
6797
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
@@ -6488,7 +6812,7 @@ var MobileAccessGateway = class {
|
|
|
6488
6812
|
contentType: "text/javascript; charset=utf-8",
|
|
6489
6813
|
fallback: void 0
|
|
6490
6814
|
} : void 0 : void 0;
|
|
6491
|
-
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
6815
|
+
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
6492
6816
|
if (![
|
|
6493
6817
|
"GET",
|
|
6494
6818
|
"HEAD",
|
|
@@ -6542,6 +6866,7 @@ var MobileAccessGateway = class {
|
|
|
6542
6866
|
const keys = Object.keys(body);
|
|
6543
6867
|
if (keys.length !== 1) throw new HttpError(400, "bad_request");
|
|
6544
6868
|
if (keys[0] === "reset" && body.reset === true) kind = "reset";
|
|
6869
|
+
else if (keys[0] === "measure" && body.measure === true) kind = "measure";
|
|
6545
6870
|
else if (keys[0] === "excludedClientModules") {
|
|
6546
6871
|
kind = "configure";
|
|
6547
6872
|
excluded = body.excludedClientModules;
|
|
@@ -6588,16 +6913,17 @@ var MobileAccessGateway = class {
|
|
|
6588
6913
|
body = Buffer.from(customAsset.fallback);
|
|
6589
6914
|
}
|
|
6590
6915
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
6916
|
+
operation.signal.throwIfAborted();
|
|
6591
6917
|
const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
|
|
6592
6918
|
const responseHeaders = { ETag: etag };
|
|
6593
6919
|
if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
|
|
6920
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
6921
|
+
response.setHeader("Cache-Control", "private, no-cache");
|
|
6594
6922
|
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
6595
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
6596
6923
|
response.writeHead(304, responseHeaders);
|
|
6597
6924
|
response.end();
|
|
6598
6925
|
return;
|
|
6599
6926
|
}
|
|
6600
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
6601
6927
|
response.writeHead(200, {
|
|
6602
6928
|
...responseHeaders,
|
|
6603
6929
|
"Content-Type": customAsset.contentType,
|
|
@@ -6620,6 +6946,49 @@ var MobileAccessGateway = class {
|
|
|
6620
6946
|
operation.release();
|
|
6621
6947
|
}
|
|
6622
6948
|
}
|
|
6949
|
+
if (workspaceDirectories) {
|
|
6950
|
+
const operation = this.allocateRequest(authorization, response, {});
|
|
6951
|
+
const onClose = () => {
|
|
6952
|
+
operation.abort();
|
|
6953
|
+
};
|
|
6954
|
+
response.once("close", onClose);
|
|
6955
|
+
const task = (async () => {
|
|
6956
|
+
try {
|
|
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
|
+
}
|
|
6969
|
+
} catch (error) {
|
|
6970
|
+
if (operation.signal.aborted && error === operation.signal.reason) return;
|
|
6971
|
+
throw error;
|
|
6972
|
+
} finally {
|
|
6973
|
+
response.removeListener("close", onClose);
|
|
6974
|
+
operation.release();
|
|
6975
|
+
}
|
|
6976
|
+
})();
|
|
6977
|
+
const completion = task.then(() => ({
|
|
6978
|
+
status: "fulfilled",
|
|
6979
|
+
value: void 0
|
|
6980
|
+
}), (reason) => ({
|
|
6981
|
+
status: "rejected",
|
|
6982
|
+
reason
|
|
6983
|
+
}));
|
|
6984
|
+
this.workspaceDirectoryTasks.add(completion);
|
|
6985
|
+
try {
|
|
6986
|
+
await task;
|
|
6987
|
+
} finally {
|
|
6988
|
+
this.workspaceDirectoryTasks.delete(completion);
|
|
6989
|
+
}
|
|
6990
|
+
return;
|
|
6991
|
+
}
|
|
6623
6992
|
if (computerImage) {
|
|
6624
6993
|
const operation = this.allocateRequest(authorization, response, {});
|
|
6625
6994
|
try {
|
|
@@ -6967,6 +7336,14 @@ var MobileAccessGateway = class {
|
|
|
6967
7336
|
describeClientModules(deviceId, callerSignal) {
|
|
6968
7337
|
return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
|
|
6969
7338
|
}
|
|
7339
|
+
/** Measure optional catalog metadata without changing saved module selections.
|
|
7340
|
+
* @param deviceId - Authenticated device identity, or computer defaults when omitted.
|
|
7341
|
+
* @param callerSignal - Cancels measurement and drains its admitted requests.
|
|
7342
|
+
* @returns Current selections with the measurements completed within the budget.
|
|
7343
|
+
*/
|
|
7344
|
+
measureClientModules(deviceId, callerSignal) {
|
|
7345
|
+
return this.clientModulePreferenceOperation("measure", void 0, deviceId, callerSignal);
|
|
7346
|
+
}
|
|
6970
7347
|
/** Persist a validated device override or computer default.
|
|
6971
7348
|
* @param excluded - Untrusted module IDs to validate against the current boot graph.
|
|
6972
7349
|
* @param deviceId - Authenticated device identity; omitted for computer defaults.
|
|
@@ -6984,9 +7361,192 @@ var MobileAccessGateway = class {
|
|
|
6984
7361
|
resetClientModules(deviceId, callerSignal) {
|
|
6985
7362
|
return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
|
|
6986
7363
|
}
|
|
7364
|
+
/** Return cached metadata only; catalog reads and saves never start bundle probes. */
|
|
7365
|
+
describeMobileModulesWithSizes(html) {
|
|
7366
|
+
const plan = parseMobileBootPlan(html);
|
|
7367
|
+
if (this.clientModuleMetadataRevision !== plan.parsed.rev) {
|
|
7368
|
+
this.clientModuleMetadataRevision = plan.parsed.rev;
|
|
7369
|
+
this.clientModuleSizeCache.clear();
|
|
7370
|
+
this.clientModuleDescriptionCache.clear();
|
|
7371
|
+
}
|
|
7372
|
+
const urls = new Map(plan.entries.map((entry) => [entry.id, entry.url]));
|
|
7373
|
+
const now = Date.now();
|
|
7374
|
+
return Object.freeze(describeMobileClientModules(html).map((entry) => {
|
|
7375
|
+
const source = urls.get(entry.id);
|
|
7376
|
+
const size = this.clientModuleSizeCache.get(source);
|
|
7377
|
+
const description = this.clientModuleDescriptionCache.get(`${entry.id}\0${source}`);
|
|
7378
|
+
return Object.freeze({
|
|
7379
|
+
...entry,
|
|
7380
|
+
...size === void 0 || now - size.at >= CLIENT_MODULE_SIZE_TTL_MS ? {} : {
|
|
7381
|
+
bytes: size.bytes,
|
|
7382
|
+
gzipBytes: size.gzipBytes
|
|
7383
|
+
},
|
|
7384
|
+
...description === void 0 || now - description.at >= CLIENT_MODULE_SIZE_TTL_MS ? {} : { description: description.description }
|
|
7385
|
+
});
|
|
7386
|
+
}));
|
|
7387
|
+
}
|
|
7388
|
+
/** Refresh metadata only after an explicit user action, with bounded concurrency. */
|
|
7389
|
+
async measureMobileModules(html, signal) {
|
|
7390
|
+
this.describeMobileModulesWithSizes(html);
|
|
7391
|
+
const queue = [...parseMobileBootPlan(html).entries].sort((left, right) => Number(right.id === MOBILE_LAYOUT_MODULE) - Number(left.id === MOBILE_LAYOUT_MODULE));
|
|
7392
|
+
let cursor = 0;
|
|
7393
|
+
const worker = async () => {
|
|
7394
|
+
while (cursor < queue.length) {
|
|
7395
|
+
signal.throwIfAborted();
|
|
7396
|
+
const entry = queue[cursor++];
|
|
7397
|
+
await this.upstreamBundleMeasurement(entry.url, signal).catch((error) => {
|
|
7398
|
+
if (signal.aborted) throw error;
|
|
7399
|
+
});
|
|
7400
|
+
signal.throwIfAborted();
|
|
7401
|
+
await this.installedModuleDescription(entry.id, entry.url, signal);
|
|
7402
|
+
}
|
|
7403
|
+
};
|
|
7404
|
+
await Promise.allSettled(Array.from({ length: Math.min(4, queue.length) }, worker));
|
|
7405
|
+
signal.throwIfAborted();
|
|
7406
|
+
}
|
|
7407
|
+
/**
|
|
7408
|
+
* Read the installed package description for one catalog module.
|
|
7409
|
+
*
|
|
7410
|
+
* This is best-effort package metadata lookup from the state directory's Node
|
|
7411
|
+
* resolution context and this plugin's dependencies. Unexported or unavailable
|
|
7412
|
+
* package metadata stays unknown; arbitrary paths and subpaths are not read.
|
|
7413
|
+
*/
|
|
7414
|
+
async installedModuleDescription(id, source, signal) {
|
|
7415
|
+
if (!/^(?:@[a-z\d][a-z\d._-]*\/)?[a-z\d][a-z\d._-]*$/iu.test(id)) return;
|
|
7416
|
+
const key = `${id}\0${source}`;
|
|
7417
|
+
this.clientModuleDescriptionCache.delete(key);
|
|
7418
|
+
const resolvers = [createRequire(join(dirname(this.config.stateFile), "package.json")), createRequire(import.meta.url)];
|
|
7419
|
+
for (const resolver of resolvers) {
|
|
7420
|
+
signal.throwIfAborted();
|
|
7421
|
+
try {
|
|
7422
|
+
const file = resolver.resolve(`${id}/package.json`);
|
|
7423
|
+
const information = await stat(file);
|
|
7424
|
+
if (!information.isFile() || information.size > 262144) continue;
|
|
7425
|
+
const raw = await readFile(file, {
|
|
7426
|
+
encoding: "utf8",
|
|
7427
|
+
signal
|
|
7428
|
+
});
|
|
7429
|
+
const description = sanitizeModuleDescription(JSON.parse(raw).description);
|
|
7430
|
+
signal.throwIfAborted();
|
|
7431
|
+
if (description !== void 0) {
|
|
7432
|
+
this.clientModuleDescriptionCache.set(key, {
|
|
7433
|
+
at: Date.now(),
|
|
7434
|
+
description
|
|
7435
|
+
});
|
|
7436
|
+
return;
|
|
7437
|
+
}
|
|
7438
|
+
} catch (error) {
|
|
7439
|
+
if (signal.aborted) throw error;
|
|
7440
|
+
}
|
|
7441
|
+
}
|
|
7442
|
+
}
|
|
7443
|
+
/**
|
|
7444
|
+
* Measure one upstream bundle's source and gzipped size.
|
|
7445
|
+
*
|
|
7446
|
+
* Measurements are cached for ten minutes: the settings panel is opened rarely
|
|
7447
|
+
* and upstream bundle bytes only change on a plugin rebuild or host restart.
|
|
7448
|
+
*/
|
|
7449
|
+
async upstreamBundleMeasurement(source, signal) {
|
|
7450
|
+
signal.throwIfAborted();
|
|
7451
|
+
this.clientModuleSizeCache.delete(source);
|
|
7452
|
+
if (source === MOBILE_LAYOUT_PATH) {
|
|
7453
|
+
const information = await stat(this.config.mobileLayoutFile);
|
|
7454
|
+
if (!information.isFile() || information.size > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
|
|
7455
|
+
const body = await readFile(this.config.mobileLayoutFile, { signal });
|
|
7456
|
+
if (body.byteLength > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
|
|
7457
|
+
const gzipBytes = (await gzipBuffer(body)).byteLength;
|
|
7458
|
+
signal.throwIfAborted();
|
|
7459
|
+
this.clientModuleSizeCache.set(source, {
|
|
7460
|
+
at: Date.now(),
|
|
7461
|
+
bytes: body.byteLength,
|
|
7462
|
+
gzipBytes
|
|
7463
|
+
});
|
|
7464
|
+
return {
|
|
7465
|
+
bytes: body.byteLength,
|
|
7466
|
+
gzipBytes
|
|
7467
|
+
};
|
|
7468
|
+
}
|
|
7469
|
+
const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
|
|
7470
|
+
if (target === void 0) return void 0;
|
|
7471
|
+
let upstreamRequest;
|
|
7472
|
+
let upstreamClosed;
|
|
7473
|
+
const abort = () => {
|
|
7474
|
+
upstreamRequest?.destroy(requestAbortedError());
|
|
7475
|
+
};
|
|
7476
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
7477
|
+
try {
|
|
7478
|
+
const upstreamCookie = await waitForRequestTask(this.upstreamCookieHeader(), signal);
|
|
7479
|
+
signal.throwIfAborted();
|
|
7480
|
+
const proxied = await new Promise((resolve, reject) => {
|
|
7481
|
+
upstreamRequest = request({
|
|
7482
|
+
protocol: "http:",
|
|
7483
|
+
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
7484
|
+
port: Number(this.config.upstreamOrigin.port),
|
|
7485
|
+
method: "GET",
|
|
7486
|
+
path: `${target.pathname}${target.search}`,
|
|
7487
|
+
headers: {
|
|
7488
|
+
host: this.config.upstreamOrigin.host,
|
|
7489
|
+
accept: "text/javascript",
|
|
7490
|
+
"accept-encoding": "identity",
|
|
7491
|
+
[MOBILE_BOOT_SIZE_PROBE_HEADER]: "1",
|
|
7492
|
+
...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
|
|
7493
|
+
},
|
|
7494
|
+
agent: false
|
|
7495
|
+
});
|
|
7496
|
+
const opened = upstreamRequest;
|
|
7497
|
+
upstreamClosed = new Promise((closed) => {
|
|
7498
|
+
opened.once("close", () => {
|
|
7499
|
+
closed();
|
|
7500
|
+
});
|
|
7501
|
+
});
|
|
7502
|
+
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
7503
|
+
upstreamRequest?.destroy(upstreamTimeoutError());
|
|
7504
|
+
});
|
|
7505
|
+
upstreamRequest.once("response", resolve);
|
|
7506
|
+
upstreamRequest.once("error", reject);
|
|
7507
|
+
upstreamRequest.end();
|
|
7508
|
+
});
|
|
7509
|
+
if (proxied.statusCode !== 200) {
|
|
7510
|
+
proxied.resume();
|
|
7511
|
+
return;
|
|
7512
|
+
}
|
|
7513
|
+
const chunks = [];
|
|
7514
|
+
let bytes = 0;
|
|
7515
|
+
for await (const chunk of proxied) {
|
|
7516
|
+
signal.throwIfAborted();
|
|
7517
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
7518
|
+
bytes += buffer.byteLength;
|
|
7519
|
+
if (bytes > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
|
|
7520
|
+
chunks.push(buffer);
|
|
7521
|
+
}
|
|
7522
|
+
const body = Buffer.concat(chunks);
|
|
7523
|
+
const gzipBytes = (await gzipBuffer(body)).byteLength;
|
|
7524
|
+
signal.throwIfAborted();
|
|
7525
|
+
this.clientModuleSizeCache.set(source, {
|
|
7526
|
+
at: Date.now(),
|
|
7527
|
+
bytes: body.byteLength,
|
|
7528
|
+
gzipBytes
|
|
7529
|
+
});
|
|
7530
|
+
return {
|
|
7531
|
+
bytes: body.byteLength,
|
|
7532
|
+
gzipBytes
|
|
7533
|
+
};
|
|
7534
|
+
} finally {
|
|
7535
|
+
signal.removeEventListener("abort", abort);
|
|
7536
|
+
upstreamRequest?.destroy();
|
|
7537
|
+
await upstreamClosed;
|
|
7538
|
+
}
|
|
7539
|
+
}
|
|
6987
7540
|
async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
|
|
6988
7541
|
if (this.closing || !this.started) throw new HttpError(503, "unavailable");
|
|
7542
|
+
if (kind === "measure" && this.clientModuleMeasurementActive) throw new HttpError(429, "busy");
|
|
7543
|
+
if (kind === "measure") this.clientModuleMeasurementActive = true;
|
|
6989
7544
|
const controller = new AbortController();
|
|
7545
|
+
let measurementExpired = false;
|
|
7546
|
+
const measurementTimer = kind === "measure" ? setTimeout(() => {
|
|
7547
|
+
measurementExpired = true;
|
|
7548
|
+
controller.abort();
|
|
7549
|
+
}, CLIENT_MODULE_MEASUREMENT_BUDGET_MS) : void 0;
|
|
6990
7550
|
const abort = () => controller.abort();
|
|
6991
7551
|
if (callerSignal?.aborted === true) controller.abort();
|
|
6992
7552
|
callerSignal?.addEventListener("abort", abort, { once: true });
|
|
@@ -7020,8 +7580,13 @@ var MobileAccessGateway = class {
|
|
|
7020
7580
|
controller.signal.throwIfAborted();
|
|
7021
7581
|
try {
|
|
7022
7582
|
const plan = parseMobileBootPlan(html);
|
|
7583
|
+
if (kind === "measure") try {
|
|
7584
|
+
await this.measureMobileModules(html, controller.signal);
|
|
7585
|
+
} catch (error) {
|
|
7586
|
+
if (!measurementExpired || callerSignal?.aborted === true || this.closing) throw error;
|
|
7587
|
+
}
|
|
7023
7588
|
return Object.freeze({
|
|
7024
|
-
entries:
|
|
7589
|
+
entries: this.describeMobileModulesWithSizes(html),
|
|
7025
7590
|
...selection,
|
|
7026
7591
|
...plan.batches === void 0 ? {
|
|
7027
7592
|
excludedClientModules: Object.freeze([]),
|
|
@@ -7038,6 +7603,8 @@ var MobileAccessGateway = class {
|
|
|
7038
7603
|
try {
|
|
7039
7604
|
return await task;
|
|
7040
7605
|
} finally {
|
|
7606
|
+
clearTimeout(measurementTimer);
|
|
7607
|
+
if (kind === "measure") this.clientModuleMeasurementActive = false;
|
|
7041
7608
|
callerSignal?.removeEventListener("abort", abort);
|
|
7042
7609
|
this.clientModuleOperations.delete(controller);
|
|
7043
7610
|
this.clientModuleTasks.delete(task);
|
|
@@ -7114,8 +7681,7 @@ var MobileAccessGateway = class {
|
|
|
7114
7681
|
let body;
|
|
7115
7682
|
if (stockFrontend) body = Buffer.from(ensureMobileGatewayMarker(index.html));
|
|
7116
7683
|
else {
|
|
7117
|
-
const
|
|
7118
|
-
const excludedClientModules = preference.excludedClientModules;
|
|
7684
|
+
const excludedClientModules = (await this.clientModulePreferences.read(authorization.deviceId)).excludedClientModules;
|
|
7119
7685
|
try {
|
|
7120
7686
|
const html = index.html;
|
|
7121
7687
|
const plan = parseMobileBootPlan(html, excludedClientModules);
|
|
@@ -7142,22 +7708,15 @@ var MobileAccessGateway = class {
|
|
|
7142
7708
|
this.lastModuleExclusionError = detail;
|
|
7143
7709
|
process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
|
|
7144
7710
|
}
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
response.end(recovery);
|
|
7155
|
-
return;
|
|
7156
|
-
}
|
|
7157
|
-
sendJson(response, 409, {
|
|
7158
|
-
error: "excluded_client_modules_invalid",
|
|
7159
|
-
detail
|
|
7160
|
-
}, 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);
|
|
7161
7720
|
return;
|
|
7162
7721
|
}
|
|
7163
7722
|
throw new HttpError(502, "upstream_unavailable");
|
|
@@ -7169,12 +7728,18 @@ var MobileAccessGateway = class {
|
|
|
7169
7728
|
delete headers["content-encoding"];
|
|
7170
7729
|
delete headers.etag;
|
|
7171
7730
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
7172
|
-
|
|
7731
|
+
const documentGzip = body.byteLength >= MIN_COMPRESSIBLE_DOCUMENT_BYTES && acceptsGzip(request.headers["accept-encoding"]);
|
|
7732
|
+
const payload = documentGzip ? await gzipBuffer(body) : body;
|
|
7733
|
+
operation.signal.throwIfAborted();
|
|
7734
|
+
const documentHeaders = {
|
|
7173
7735
|
...headers,
|
|
7174
7736
|
"Content-Type": "text/html; charset=utf-8",
|
|
7175
|
-
"Content-Length":
|
|
7176
|
-
}
|
|
7177
|
-
|
|
7737
|
+
"Content-Length": payload.byteLength
|
|
7738
|
+
};
|
|
7739
|
+
addVaryAcceptEncoding(documentHeaders);
|
|
7740
|
+
if (documentGzip) documentHeaders["Content-Encoding"] = "gzip";
|
|
7741
|
+
response.writeHead(200, documentHeaders);
|
|
7742
|
+
response.end(payload);
|
|
7178
7743
|
} catch (error) {
|
|
7179
7744
|
holder.request?.destroy();
|
|
7180
7745
|
if (error instanceof HttpError) throw error;
|
|
@@ -7372,7 +7937,7 @@ var MobileAccessGateway = class {
|
|
|
7372
7937
|
const headers = {
|
|
7373
7938
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
7374
7939
|
"Content-Length": body.byteLength,
|
|
7375
|
-
"Cache-Control": "private,
|
|
7940
|
+
"Cache-Control": "private, max-age=30, must-revalidate",
|
|
7376
7941
|
ETag: etag
|
|
7377
7942
|
};
|
|
7378
7943
|
if (compressed) headers["Content-Encoding"] = "gzip";
|
|
@@ -7381,7 +7946,7 @@ var MobileAccessGateway = class {
|
|
|
7381
7946
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
7382
7947
|
response.writeHead(304, {
|
|
7383
7948
|
ETag: etag,
|
|
7384
|
-
"Cache-Control": "private,
|
|
7949
|
+
"Cache-Control": "private, max-age=30, must-revalidate",
|
|
7385
7950
|
Vary: String(headers.vary)
|
|
7386
7951
|
});
|
|
7387
7952
|
response.end();
|
|
@@ -7560,6 +8125,16 @@ var MobileAccessGateway = class {
|
|
|
7560
8125
|
operation.abort();
|
|
7561
8126
|
};
|
|
7562
8127
|
response.once("close", onClose);
|
|
8128
|
+
const startedAt = performance.now();
|
|
8129
|
+
const latencyPath = boundedApiPath(request.url);
|
|
8130
|
+
if (latencyPath !== void 0) response.once("close", () => {
|
|
8131
|
+
this.apiLatency.record({
|
|
8132
|
+
method: request.method,
|
|
8133
|
+
path: latencyPath,
|
|
8134
|
+
status: response.headersSent ? response.statusCode : 0,
|
|
8135
|
+
durationMs: performance.now() - startedAt
|
|
8136
|
+
});
|
|
8137
|
+
});
|
|
7563
8138
|
if (replayable) request.resume();
|
|
7564
8139
|
try {
|
|
7565
8140
|
const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
|
|
@@ -8068,6 +8643,8 @@ var MobileAccessGateway = class {
|
|
|
8068
8643
|
}
|
|
8069
8644
|
async performClose() {
|
|
8070
8645
|
this.closing = true;
|
|
8646
|
+
const failures = [];
|
|
8647
|
+
const directoryDrain = Promise.all(this.workspaceDirectoryTasks);
|
|
8071
8648
|
for (const controller of this.clientModuleOperations) controller.abort();
|
|
8072
8649
|
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
8073
8650
|
this.extensionChangeTimer = void 0;
|
|
@@ -8077,7 +8654,9 @@ var MobileAccessGateway = class {
|
|
|
8077
8654
|
this.removeSessionListener();
|
|
8078
8655
|
this.pendingDeviceRevocations.clear();
|
|
8079
8656
|
for (const view of this.mobileDocumentViews.values()) this.removeMobileDocumentView(view);
|
|
8080
|
-
const accessClose = this.access.close()
|
|
8657
|
+
const accessClose = this.access.close().catch((error) => {
|
|
8658
|
+
failures.push(error);
|
|
8659
|
+
});
|
|
8081
8660
|
for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
|
|
8082
8661
|
for (const request of this.activeRequests.values()) {
|
|
8083
8662
|
clearTimeout(request.timer);
|
|
@@ -8090,19 +8669,29 @@ var MobileAccessGateway = class {
|
|
|
8090
8669
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
8091
8670
|
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
8092
8671
|
this.discoveryTimer = void 0;
|
|
8093
|
-
await
|
|
8672
|
+
for (const result of await directoryDrain) if (result.status === "rejected") failures.push(result.reason);
|
|
8673
|
+
await this.closeBonjour().catch((error) => {
|
|
8674
|
+
failures.push(error);
|
|
8675
|
+
});
|
|
8094
8676
|
const discoverySocket = this.discoverySocket;
|
|
8095
8677
|
this.discoverySocket = void 0;
|
|
8096
8678
|
if (discoverySocket !== void 0) await new Promise((resolve) => {
|
|
8097
8679
|
discoverySocket.close(() => resolve());
|
|
8680
|
+
}).catch((error) => {
|
|
8681
|
+
failures.push(error);
|
|
8098
8682
|
});
|
|
8099
8683
|
const server = this.server;
|
|
8100
8684
|
this.server = void 0;
|
|
8101
|
-
if (server !== void 0 && server.listening) {
|
|
8685
|
+
if (server !== void 0 && server.listening) try {
|
|
8102
8686
|
server.closeAllConnections();
|
|
8103
|
-
await new Promise((resolve) => {
|
|
8104
|
-
server.close(() =>
|
|
8687
|
+
await new Promise((resolve, reject) => {
|
|
8688
|
+
server.close((error) => {
|
|
8689
|
+
if (error) reject(error);
|
|
8690
|
+
else resolve();
|
|
8691
|
+
});
|
|
8105
8692
|
});
|
|
8693
|
+
} catch (error) {
|
|
8694
|
+
failures.push(error);
|
|
8106
8695
|
}
|
|
8107
8696
|
await accessClose;
|
|
8108
8697
|
await Promise.allSettled(this.clientModuleTasks);
|
|
@@ -8113,6 +8702,7 @@ var MobileAccessGateway = class {
|
|
|
8113
8702
|
this.connectedSockets.clear();
|
|
8114
8703
|
this.policy = void 0;
|
|
8115
8704
|
this.listenerPort = void 0;
|
|
8705
|
+
if (failures.length > 0) throw new AggregateError(failures, "mobile gateway cleanup failed");
|
|
8116
8706
|
}
|
|
8117
8707
|
/** Safe metadata helper for direct loopback integrations. */
|
|
8118
8708
|
devices() {
|
|
@@ -10721,6 +11311,31 @@ function parseAdditionalTrustedNetworks(value) {
|
|
|
10721
11311
|
}
|
|
10722
11312
|
return Object.freeze(entries);
|
|
10723
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
|
+
}
|
|
10724
11339
|
/** Save extra trusted networks without replacing the interface or pairing identity.
|
|
10725
11340
|
* @param setupFile - Existing managed setup file.
|
|
10726
11341
|
* @param values - Explicitly selected additional networks.
|
|
@@ -18098,11 +18713,18 @@ async function apply(ctx, config) {
|
|
|
18098
18713
|
const mobileAccess = createMobileAccessService(ctx);
|
|
18099
18714
|
const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
|
|
18100
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;
|
|
18101
18718
|
const instanceId = await stableInstanceId(loaded, baseTemplate);
|
|
18102
18719
|
const stateDirectory = dirname(baseTemplate.stateFile);
|
|
18103
18720
|
const logFile = await installMobileFileLogger(ctx, stateDirectory);
|
|
18104
18721
|
const logger = ctx.logger("dsh-mobile");
|
|
18105
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
|
+
}
|
|
18106
18728
|
const taskEventHub = new TaskEventHub();
|
|
18107
18729
|
const disposeTaskEvents = watchTaskCompletions(ctx, {
|
|
18108
18730
|
onTaskCompleted: (event) => taskEventHub.broadcast(event),
|
|
@@ -18190,6 +18812,8 @@ async function apply(ctx, config) {
|
|
|
18190
18812
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18191
18813
|
}, (entry) => {
|
|
18192
18814
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18815
|
+
}, (entry) => {
|
|
18816
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18193
18817
|
});
|
|
18194
18818
|
await candidate.start();
|
|
18195
18819
|
lanGateway = candidate;
|
|
@@ -18252,6 +18876,8 @@ async function apply(ctx, config) {
|
|
|
18252
18876
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18253
18877
|
}, (entry) => {
|
|
18254
18878
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18879
|
+
}, (entry) => {
|
|
18880
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18255
18881
|
});
|
|
18256
18882
|
await candidate.start();
|
|
18257
18883
|
return candidate;
|
|
@@ -18274,6 +18900,8 @@ async function apply(ctx, config) {
|
|
|
18274
18900
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18275
18901
|
}, (entry) => {
|
|
18276
18902
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18903
|
+
}, (entry) => {
|
|
18904
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18277
18905
|
});
|
|
18278
18906
|
try {
|
|
18279
18907
|
await candidate.start();
|
|
@@ -18291,6 +18919,8 @@ async function apply(ctx, config) {
|
|
|
18291
18919
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18292
18920
|
}, (entry) => {
|
|
18293
18921
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18922
|
+
}, (entry) => {
|
|
18923
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18294
18924
|
});
|
|
18295
18925
|
try {
|
|
18296
18926
|
await candidate.start();
|
|
@@ -18488,7 +19118,7 @@ async function apply(ctx, config) {
|
|
|
18488
19118
|
handler: async (request, response) => {
|
|
18489
19119
|
try {
|
|
18490
19120
|
const target = parseRequestTarget(request.url);
|
|
18491
|
-
assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
|
|
19121
|
+
assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), baseTemplate.adminAllowedCidrs);
|
|
18492
19122
|
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
18493
19123
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
18494
19124
|
const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
|
|
@@ -18515,8 +19145,8 @@ async function apply(ctx, config) {
|
|
|
18515
19145
|
}
|
|
18516
19146
|
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
18517
19147
|
const body = await readJsonObject(request, template.maxBodyBytes);
|
|
18518
|
-
if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
|
|
18519
|
-
sendJson(response, 200, body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
|
|
19148
|
+
if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules" && key !== "measure") || "measure" in body && body.measure !== true) throw new HttpError(400, "bad_request");
|
|
19149
|
+
sendJson(response, 200, body.measure === true ? await gateway.measureClientModules(void 0, operation.signal) : body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
|
|
18520
19150
|
} catch (error) {
|
|
18521
19151
|
if (!(error instanceof ClientModuleConflictError)) throw error;
|
|
18522
19152
|
sendJson(response, 409, {
|