dsh-mobile 0.6.2 → 0.6.4
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 +36 -0
- package/CONTRIBUTORS.md +19 -4
- package/README.en.md +16 -8
- package/README.md +16 -8
- package/SECURITY.md +3 -2
- package/THIRD_PARTY_NOTICES.md +29 -1
- package/apps/mobile/README.md +25 -5
- package/apps/mobile/release.json +3 -3
- package/cordis.patch.yml +7 -3
- package/docs/ATTACH_EXISTING_FRPS.en.md +1 -1
- package/docs/ATTACH_EXISTING_FRPS.md +2 -2
- package/docs/CADDY_MANAGED.en.md +15 -1
- package/docs/CADDY_MANAGED.md +15 -1
- package/docs/CLOUDFLARE_TUNNEL.en.md +2 -2
- package/docs/CLOUDFLARE_TUNNEL.md +3 -3
- package/docs/EXTENSION_WORKERS.en.md +49 -0
- package/docs/EXTENSION_WORKERS.md +49 -0
- package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +27 -0
- package/docs/MOBILE_COMPOSER_TOOLBAR.md +27 -0
- package/docs/README.md +2 -0
- package/docs/SELF_HOSTED_FRP.en.md +4 -2
- package/docs/SELF_HOSTED_FRP.md +4 -2
- package/docs/SLOW_CONNECTIONS.en.md +6 -0
- package/docs/SLOW_CONNECTIONS.md +6 -0
- package/docs/TRUSTED_NETWORKS.en.md +1 -1
- package/docs/TRUSTED_NETWORKS.md +1 -1
- package/lib/cli.js +140 -19
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1264 -106
- package/lib/client.js.map +1 -1
- package/lib/extension-worker-runtime.mjs +1646 -0
- package/lib/extension-worker-runtime.mjs.map +1 -0
- package/lib/index.d.mts +209 -12
- package/lib/index.mjs +2152 -811
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +17 -6
- package/packages/question-fixes/LICENSE +201 -0
- package/packages/question-fixes/package.json +9 -2
- package/node_modules/dsh-mobile-question-fixes/lib/client.js +0 -78
- package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +0 -1
- package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +0 -14
- package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +0 -1
- package/node_modules/dsh-mobile-question-fixes/package.json +0 -25
package/lib/index.mjs
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, 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";
|
|
7
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { performance as performance$1 } from "node:perf_hooks";
|
|
8
9
|
import { execFile, spawn } from "node:child_process";
|
|
9
10
|
import { createSocket } from "node:dgram";
|
|
10
11
|
import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
|
|
11
12
|
import { createServer as createServer$1, request } from "node:http";
|
|
12
13
|
import { createServer as createServer$2, request as request$1 } from "node:https";
|
|
13
14
|
import { Readable, Transform, finished } from "node:stream";
|
|
14
|
-
import { pipeline } from "node:stream/promises";
|
|
15
|
+
import { finished as finished$1, pipeline } from "node:stream/promises";
|
|
15
16
|
import { promisify } from "node:util";
|
|
16
17
|
import { createGzip, gzip } from "node:zlib";
|
|
17
18
|
import Bonjour from "bonjour-service";
|
|
18
19
|
import * as QRCode from "qrcode";
|
|
19
20
|
import WebSocket, { WebSocketServer } from "ws";
|
|
20
21
|
import { Logger, Service } from "@deepseek-ai/cordis";
|
|
22
|
+
import { Worker } from "node:worker_threads";
|
|
21
23
|
import { connect as connect$1 } from "node:tls";
|
|
22
24
|
import { generate } from "selfsigned";
|
|
23
25
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
|
|
24
26
|
import { lookup } from "node:dns/promises";
|
|
25
|
-
import {
|
|
27
|
+
import { constants } from "node:fs";
|
|
26
28
|
//#region src/access.ts
|
|
27
29
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
28
30
|
var AccessError = class extends Error {
|
|
@@ -860,9 +862,69 @@ var WebSocketPathStore = class {
|
|
|
860
862
|
}
|
|
861
863
|
};
|
|
862
864
|
//#endregion
|
|
865
|
+
//#region src/extension-worker-config.ts
|
|
866
|
+
function integer$1(value, name, fallback, minimum, maximum) {
|
|
867
|
+
const resolved = value ?? fallback;
|
|
868
|
+
if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`hostExecution.${name} must be an integer from ${minimum} through ${maximum}`);
|
|
869
|
+
return resolved;
|
|
870
|
+
}
|
|
871
|
+
/** Resolve configuration once; reject malformed modes, ids and incoherent stream windows. */
|
|
872
|
+
function resolveHostExecution(raw) {
|
|
873
|
+
if (raw !== void 0 && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error("hostExecution must be an object");
|
|
874
|
+
const value = raw ?? {};
|
|
875
|
+
const mode = value.mode ?? "in-process";
|
|
876
|
+
if (mode !== "worker" && mode !== "in-process") throw new Error("hostExecution.mode is invalid");
|
|
877
|
+
if (value.extensions !== void 0 && (!Array.isArray(value.extensions) || value.extensions.some((id) => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) || new Set(value.extensions).size !== value.extensions.length)) throw new Error("hostExecution.extensions must contain unique local extension ids");
|
|
878
|
+
const streamWindowBytes = integer$1(value.streamWindowBytes, "streamWindowBytes", 262144, 1024, 4194304);
|
|
879
|
+
const streamChunkBytes = integer$1(value.streamChunkBytes, "streamChunkBytes", 65536, 1024, streamWindowBytes);
|
|
880
|
+
const streamAggregateBytes = integer$1(value.streamAggregateBytes, "streamAggregateBytes", 1048576, streamWindowBytes, 33554432);
|
|
881
|
+
return Object.freeze({
|
|
882
|
+
mode,
|
|
883
|
+
extensions: value.extensions === void 0 ? void 0 : Object.freeze([...value.extensions]),
|
|
884
|
+
maxWorkers: integer$1(value.maxWorkers, "maxWorkers", 8, 1, 128),
|
|
885
|
+
maxRegistrations: integer$1(value.maxRegistrations, "maxRegistrations", 256, 1, 4096),
|
|
886
|
+
maxOldGenerationSizeMb: integer$1(value.maxOldGenerationSizeMb, "maxOldGenerationSizeMb", 128, 16, 4096),
|
|
887
|
+
maxYoungGenerationSizeMb: integer$1(value.maxYoungGenerationSizeMb, "maxYoungGenerationSizeMb", 16, 1, 512),
|
|
888
|
+
stackSizeMb: integer$1(value.stackSizeMb, "stackSizeMb", 4, 1, 64),
|
|
889
|
+
activationTimeoutMs: integer$1(value.activationTimeoutMs, "activationTimeoutMs", 5e3, 1, 3e5),
|
|
890
|
+
operationTimeoutMs: integer$1(value.operationTimeoutMs, "operationTimeoutMs", 3e4, 1, 3e5),
|
|
891
|
+
cancelGraceMs: integer$1(value.cancelGraceMs, "cancelGraceMs", 250, 1, 3e4),
|
|
892
|
+
streamCancelGraceMs: integer$1(value.streamCancelGraceMs, "streamCancelGraceMs", 1e3, 1, 3e4),
|
|
893
|
+
disposeGraceMs: integer$1(value.disposeGraceMs, "disposeGraceMs", 2e3, 1, 3e4),
|
|
894
|
+
resultMaxBytes: integer$1(value.resultMaxBytes, "resultMaxBytes", 4194304, 1024, 4194304),
|
|
895
|
+
streamWindowBytes,
|
|
896
|
+
streamChunkBytes,
|
|
897
|
+
streamAggregateBytes,
|
|
898
|
+
logWindowMs: integer$1(value.logWindowMs, "logWindowMs", 1e3, 1, 6e4),
|
|
899
|
+
logMessagesPerWindow: integer$1(value.logMessagesPerWindow, "logMessagesPerWindow", 64, 1, 1024),
|
|
900
|
+
logMessageBytes: integer$1(value.logMessageBytes, "logMessageBytes", 4096, 128, 65536)
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
863
904
|
//#region src/config.ts
|
|
864
905
|
/** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
|
|
865
906
|
const Config = z.object({
|
|
907
|
+
hostExecution: z.object({
|
|
908
|
+
mode: z.union([z.const("in-process"), z.const("worker")]),
|
|
909
|
+
extensions: z.union([z.const(void 0), z.array(String)]),
|
|
910
|
+
maxWorkers: z.natural(),
|
|
911
|
+
maxRegistrations: z.natural(),
|
|
912
|
+
maxOldGenerationSizeMb: z.natural(),
|
|
913
|
+
maxYoungGenerationSizeMb: z.natural(),
|
|
914
|
+
stackSizeMb: z.natural(),
|
|
915
|
+
activationTimeoutMs: z.natural(),
|
|
916
|
+
operationTimeoutMs: z.natural(),
|
|
917
|
+
cancelGraceMs: z.natural(),
|
|
918
|
+
streamCancelGraceMs: z.natural(),
|
|
919
|
+
disposeGraceMs: z.natural(),
|
|
920
|
+
resultMaxBytes: z.natural(),
|
|
921
|
+
streamWindowBytes: z.natural(),
|
|
922
|
+
streamChunkBytes: z.natural(),
|
|
923
|
+
streamAggregateBytes: z.natural(),
|
|
924
|
+
logWindowMs: z.natural(),
|
|
925
|
+
logMessagesPerWindow: z.natural(),
|
|
926
|
+
logMessageBytes: z.natural()
|
|
927
|
+
}),
|
|
866
928
|
setupFile: z.string().hidden(),
|
|
867
929
|
publicOrigin: z.string(),
|
|
868
930
|
listenHost: z.string(),
|
|
@@ -977,7 +1039,9 @@ function parsePublicOrigin$1(value) {
|
|
|
977
1039
|
});
|
|
978
1040
|
}
|
|
979
1041
|
function parseTls(value, listenHost) {
|
|
1042
|
+
if (value !== void 0 && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("tls must be an object");
|
|
980
1043
|
const mode = value?.mode ?? "provided";
|
|
1044
|
+
if (value?.mode === null || mode !== "provided" && mode !== "disabled") throw new Error("tls.mode must be provided or disabled");
|
|
981
1045
|
if (mode === "disabled") {
|
|
982
1046
|
if (!isLoopbackAddress(listenHost)) throw new Error("TLS may be disabled only on an IP loopback listener");
|
|
983
1047
|
return Object.freeze({ mode });
|
|
@@ -1024,6 +1088,7 @@ function parseGatewayConfig(raw) {
|
|
|
1024
1088
|
if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
|
|
1025
1089
|
return Object.freeze({
|
|
1026
1090
|
listenHost,
|
|
1091
|
+
hostExecution: resolveHostExecution(value.hostExecution),
|
|
1027
1092
|
listenPort,
|
|
1028
1093
|
upstreamOrigin,
|
|
1029
1094
|
authorities: Object.freeze(authorities),
|
|
@@ -1087,24 +1152,75 @@ function execFileText(file, args, options = {}) {
|
|
|
1087
1152
|
//#endregion
|
|
1088
1153
|
//#region src/private-file.ts
|
|
1089
1154
|
let userSidTask;
|
|
1155
|
+
function commandFailureDetails(error) {
|
|
1156
|
+
if (error === null || typeof error !== "object") return {};
|
|
1157
|
+
let descriptors;
|
|
1158
|
+
try {
|
|
1159
|
+
descriptors = Object.getOwnPropertyDescriptors(error);
|
|
1160
|
+
} catch (_error) {
|
|
1161
|
+
return {};
|
|
1162
|
+
}
|
|
1163
|
+
const code = descriptors.code?.value;
|
|
1164
|
+
const signal = descriptors.signal?.value;
|
|
1165
|
+
const killed = descriptors.killed?.value;
|
|
1166
|
+
return {
|
|
1167
|
+
...code === null || typeof code === "number" && Number.isSafeInteger(code) || typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/u.test(code) ? { code } : {},
|
|
1168
|
+
...signal === null || typeof signal === "string" && /^SIG[A-Z0-9]{1,16}$/u.test(signal) ? { signal } : {},
|
|
1169
|
+
...typeof killed === "boolean" ? { killed } : {}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/** Phase and full await duration, including subprocess creation, without arguments, output or private paths. */
|
|
1173
|
+
var PrivateFilePermissionError = class extends Error {
|
|
1174
|
+
stage;
|
|
1175
|
+
elapsedMs;
|
|
1176
|
+
code;
|
|
1177
|
+
signal;
|
|
1178
|
+
killed;
|
|
1179
|
+
constructor(stage, elapsedMs, error) {
|
|
1180
|
+
const details = commandFailureDetails(error);
|
|
1181
|
+
const fields = [
|
|
1182
|
+
...details.code === void 0 ? [] : [`code=${String(details.code)}`],
|
|
1183
|
+
...details.signal === void 0 ? [] : [`signal=${String(details.signal)}`],
|
|
1184
|
+
...details.killed === void 0 ? [] : [`killed=${String(details.killed)}`]
|
|
1185
|
+
];
|
|
1186
|
+
const summary = `Windows private-file ${stage} failed after ${String(elapsedMs)} ms${fields.length === 0 ? "" : ` (${fields.join(", ")})`}`;
|
|
1187
|
+
const cause = Object.assign(/* @__PURE__ */ new Error("Windows permission operation failed"), details);
|
|
1188
|
+
super(summary, { cause });
|
|
1189
|
+
this.stage = stage;
|
|
1190
|
+
this.elapsedMs = elapsedMs;
|
|
1191
|
+
this.name = "PrivateFilePermissionError";
|
|
1192
|
+
this.code = details.code;
|
|
1193
|
+
this.signal = details.signal;
|
|
1194
|
+
this.killed = details.killed;
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
async function permissionOperation(stage, operation) {
|
|
1198
|
+
const started = performance$1.now();
|
|
1199
|
+
try {
|
|
1200
|
+
return await operation();
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new PrivateFilePermissionError(stage, Math.max(0, Math.round(performance$1.now() - started)), error);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1090
1205
|
function windowsSystemTool(name) {
|
|
1091
1206
|
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
|
|
1092
|
-
if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
|
|
1207
|
+
if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw Object.assign(/* @__PURE__ */ new Error("Windows system root is unavailable"), { code: "WINDOWS_SYSTEM_ROOT_UNAVAILABLE" });
|
|
1093
1208
|
return win32.join(systemRoot, "System32", name);
|
|
1094
1209
|
}
|
|
1095
1210
|
async function currentWindowsUserSid() {
|
|
1096
|
-
userSidTask ??=
|
|
1097
|
-
"
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1211
|
+
userSidTask ??= permissionOperation("sid-query", async () => {
|
|
1212
|
+
const { stdout } = await execFileText(windowsSystemTool("whoami.exe"), [
|
|
1213
|
+
"/user",
|
|
1214
|
+
"/fo",
|
|
1215
|
+
"csv",
|
|
1216
|
+
"/nh"
|
|
1217
|
+
], {
|
|
1218
|
+
encoding: "utf8",
|
|
1219
|
+
windowsHide: true,
|
|
1220
|
+
timeout: 1e4
|
|
1221
|
+
});
|
|
1106
1222
|
const match = /,"(S-\d(?:-\d+)+)"\s*$/u.exec(stdout.trim());
|
|
1107
|
-
if (match?.[1] === void 0) throw new Error("unable to resolve the current Windows user SID");
|
|
1223
|
+
if (match?.[1] === void 0) throw Object.assign(/* @__PURE__ */ new Error("unable to resolve the current Windows user SID"), { code: "WINDOWS_USER_SID_INVALID" });
|
|
1108
1224
|
return match[1];
|
|
1109
1225
|
}).catch((error) => {
|
|
1110
1226
|
userSidTask = void 0;
|
|
@@ -1112,12 +1228,12 @@ async function currentWindowsUserSid() {
|
|
|
1112
1228
|
});
|
|
1113
1229
|
return userSidTask;
|
|
1114
1230
|
}
|
|
1115
|
-
/** Restrict a sensitive regular file to the current user and Windows administrators. */
|
|
1231
|
+
/** Restrict a sensitive regular file to the current user and Windows administrators; reject Windows permission failures with redacted phase and duration. */
|
|
1116
1232
|
async function restrictPrivateFile(file, mode = 384) {
|
|
1117
1233
|
await chmod(file, mode);
|
|
1118
1234
|
if (process.platform !== "win32") return;
|
|
1119
1235
|
const userSid = await currentWindowsUserSid();
|
|
1120
|
-
await execFileText(windowsSystemTool("icacls.exe"), [
|
|
1236
|
+
await permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
|
|
1121
1237
|
file,
|
|
1122
1238
|
"/inheritance:r",
|
|
1123
1239
|
"/grant:r",
|
|
@@ -1132,7 +1248,7 @@ async function restrictPrivateFile(file, mode = 384) {
|
|
|
1132
1248
|
encoding: "utf8",
|
|
1133
1249
|
windowsHide: true,
|
|
1134
1250
|
timeout: 1e4
|
|
1135
|
-
});
|
|
1251
|
+
}));
|
|
1136
1252
|
}
|
|
1137
1253
|
//#endregion
|
|
1138
1254
|
//#region src/client-module-preferences.ts
|
|
@@ -1393,24 +1509,23 @@ var JsonMobileAccessControlStore = class {
|
|
|
1393
1509
|
this.file = file;
|
|
1394
1510
|
this.initiallyEnabled = initiallyEnabled;
|
|
1395
1511
|
}
|
|
1396
|
-
/**
|
|
1512
|
+
/** Disable an existing preference, returning the state only after private-file preparation completes. */
|
|
1397
1513
|
async disableIfPresent() {
|
|
1514
|
+
const disabled = Object.freeze({
|
|
1515
|
+
version: 1,
|
|
1516
|
+
enabled: false
|
|
1517
|
+
});
|
|
1398
1518
|
try {
|
|
1399
1519
|
await lstat(this.file);
|
|
1400
1520
|
} catch (error) {
|
|
1401
1521
|
if (error.code === "ENOENT") {
|
|
1402
|
-
if (this.initiallyEnabled) await this.save(
|
|
1403
|
-
|
|
1404
|
-
enabled: false
|
|
1405
|
-
});
|
|
1406
|
-
return;
|
|
1522
|
+
if (this.initiallyEnabled) await this.save(disabled);
|
|
1523
|
+
return disabled;
|
|
1407
1524
|
}
|
|
1408
1525
|
throw error;
|
|
1409
1526
|
}
|
|
1410
|
-
await this.save(
|
|
1411
|
-
|
|
1412
|
-
enabled: false
|
|
1413
|
-
});
|
|
1527
|
+
await this.save(disabled);
|
|
1528
|
+
return disabled;
|
|
1414
1529
|
}
|
|
1415
1530
|
async load() {
|
|
1416
1531
|
let stat;
|
|
@@ -1453,8 +1568,8 @@ var JsonMobileAccessControlStore = class {
|
|
|
1453
1568
|
flag: "wx",
|
|
1454
1569
|
mode: 384
|
|
1455
1570
|
});
|
|
1571
|
+
await restrictPrivateFile(temporary);
|
|
1456
1572
|
await rename(temporary, this.file);
|
|
1457
|
-
await restrictPrivateFile(this.file);
|
|
1458
1573
|
} catch (error) {
|
|
1459
1574
|
try {
|
|
1460
1575
|
await rm(temporary, { force: true });
|
|
@@ -1896,6 +2011,44 @@ function ensureMobileCompatibility(html) {
|
|
|
1896
2011
|
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
1897
2012
|
}
|
|
1898
2013
|
//#endregion
|
|
2014
|
+
//#region src/http-cache.ts
|
|
2015
|
+
/** Entity-tag handling for authenticated GET and HEAD cache validation. */
|
|
2016
|
+
const ENTITY_TAG = /^[\t ]*(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")[\t ]*$/u;
|
|
2017
|
+
const ENTITY_TAG_LIST_ITEM = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/gu;
|
|
2018
|
+
/**
|
|
2019
|
+
* Compare a GET or HEAD If-None-Match field with an existing representation.
|
|
2020
|
+
* Invalid fields do not match; empty list elements are ignored per HTTP list syntax.
|
|
2021
|
+
* @param field - The received conditional request field.
|
|
2022
|
+
* @param etag - The selected representation's valid entity tag.
|
|
2023
|
+
* @returns Whether the representation is unchanged under weak comparison.
|
|
2024
|
+
*/
|
|
2025
|
+
function matchesIfNoneMatch(field, etag) {
|
|
2026
|
+
if (field === void 0) return false;
|
|
2027
|
+
const current = ENTITY_TAG.exec(etag)?.[1];
|
|
2028
|
+
if (current === void 0) return false;
|
|
2029
|
+
if (/^[\t ]*\*[\t ]*$/u.test(field)) return true;
|
|
2030
|
+
let cursor = 0;
|
|
2031
|
+
let count = 0;
|
|
2032
|
+
let matches = false;
|
|
2033
|
+
for (const item of field.matchAll(ENTITY_TAG_LIST_ITEM)) {
|
|
2034
|
+
const gap = field.slice(cursor, item.index);
|
|
2035
|
+
if (!(count === 0 ? /^[\t ,]*$/u : /^[\t ]*,[\t ,]*$/u).test(gap)) return false;
|
|
2036
|
+
if ((item[0].startsWith("W/") ? item[0].slice(2) : item[0]) === current) matches = true;
|
|
2037
|
+
cursor = item.index + item[0].length;
|
|
2038
|
+
count++;
|
|
2039
|
+
}
|
|
2040
|
+
return count > 0 && /^[\t ,]*$/u.test(field.slice(cursor)) && matches;
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Keep an upstream validator usable after lossless content encoding changes.
|
|
2044
|
+
* @param etag - An upstream entity tag, if supplied.
|
|
2045
|
+
* @returns A weak quoted entity tag, or undefined for an absent or invalid tag.
|
|
2046
|
+
*/
|
|
2047
|
+
function weakenEntityTag(etag) {
|
|
2048
|
+
const opaque = etag === void 0 ? void 0 : ENTITY_TAG.exec(etag)?.[1];
|
|
2049
|
+
return opaque === void 0 ? void 0 : `W/${opaque}`;
|
|
2050
|
+
}
|
|
2051
|
+
//#endregion
|
|
1899
2052
|
//#region src/browser-auth-cookies.ts
|
|
1900
2053
|
const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
|
|
1901
2054
|
const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
|
|
@@ -2258,6 +2411,660 @@ async function readComputerImage(path, signal) {
|
|
|
2258
2411
|
}
|
|
2259
2412
|
}
|
|
2260
2413
|
//#endregion
|
|
2414
|
+
//#region src/extension-worker.ts
|
|
2415
|
+
/** Supervise opt-in local hosts without executing extension code on the DSH thread. */
|
|
2416
|
+
/** JSON bytes serialized and size-checked inside the worker. */
|
|
2417
|
+
var PreparedJsonResult = class {
|
|
2418
|
+
bytes;
|
|
2419
|
+
constructor(bytes) {
|
|
2420
|
+
this.bytes = bytes;
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
/** Identify worker results without invoking an extension serializer in the gateway. */
|
|
2424
|
+
function isPreparedJson(value) {
|
|
2425
|
+
return value instanceof PreparedJsonResult;
|
|
2426
|
+
}
|
|
2427
|
+
/** Resolve the installed standalone entry adjacent to the plugin bundle. */
|
|
2428
|
+
function defaultExtensionWorkerModule() {
|
|
2429
|
+
return new URL("./extension-worker-runtime.mjs", import.meta.url);
|
|
2430
|
+
}
|
|
2431
|
+
/** Every thread counts until actual exit, including activation, retirement and termination. */
|
|
2432
|
+
var ExtensionWorkerBudget = class {
|
|
2433
|
+
maximum;
|
|
2434
|
+
count = 0;
|
|
2435
|
+
constructor(maximum) {
|
|
2436
|
+
this.maximum = maximum;
|
|
2437
|
+
}
|
|
2438
|
+
get current() {
|
|
2439
|
+
return this.count;
|
|
2440
|
+
}
|
|
2441
|
+
reserve() {
|
|
2442
|
+
if (this.count >= this.maximum) throw new MobileExtensionError("extension_worker_limit", "local extension worker limit reached", 503);
|
|
2443
|
+
this.count += 1;
|
|
2444
|
+
let released = false;
|
|
2445
|
+
return () => {
|
|
2446
|
+
if (!released) {
|
|
2447
|
+
released = true;
|
|
2448
|
+
this.count -= 1;
|
|
2449
|
+
}
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
};
|
|
2453
|
+
/** Diagnostic count; production admission uses an instance-owned budget. */
|
|
2454
|
+
const activeWorkerCount = { current: 0 };
|
|
2455
|
+
const LIFECYCLE_ID = "lifecycle";
|
|
2456
|
+
/** Node accepts quoted NODE_OPTIONS words; inspect option names without exposing their values. */
|
|
2457
|
+
function nodeOptionWords(value) {
|
|
2458
|
+
const words = [];
|
|
2459
|
+
let word = "";
|
|
2460
|
+
let quoted = false;
|
|
2461
|
+
let escaped = false;
|
|
2462
|
+
for (const character of value) {
|
|
2463
|
+
if (escaped) {
|
|
2464
|
+
word += character;
|
|
2465
|
+
escaped = false;
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
if (character === "\\") {
|
|
2469
|
+
escaped = true;
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
if (character === "\"") {
|
|
2473
|
+
quoted = !quoted;
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
if (/\s/u.test(character) && !quoted) {
|
|
2477
|
+
if (word !== "") {
|
|
2478
|
+
words.push(word);
|
|
2479
|
+
word = "";
|
|
2480
|
+
}
|
|
2481
|
+
continue;
|
|
2482
|
+
}
|
|
2483
|
+
word += character;
|
|
2484
|
+
}
|
|
2485
|
+
if (word !== "") words.push(word);
|
|
2486
|
+
return words;
|
|
2487
|
+
}
|
|
2488
|
+
/** Known parent V8 heap flags take precedence over Worker resourceLimits. */
|
|
2489
|
+
function parentWorkerHeapOverrides(argv, nodeOptions) {
|
|
2490
|
+
const heapFlags = /* @__PURE__ */ new Set([
|
|
2491
|
+
"--max-old-space-size",
|
|
2492
|
+
"--max-semi-space-size",
|
|
2493
|
+
"--max-heap-size",
|
|
2494
|
+
"--max-old-space-size-percentage"
|
|
2495
|
+
]);
|
|
2496
|
+
const found = /* @__PURE__ */ new Set();
|
|
2497
|
+
for (const argument of [...argv, ...nodeOptionWords(nodeOptions ?? "")]) {
|
|
2498
|
+
const name = argument.split("=", 1)[0].replaceAll("_", "-");
|
|
2499
|
+
if (heapFlags.has(name)) found.add(name);
|
|
2500
|
+
}
|
|
2501
|
+
return Object.freeze([...found]);
|
|
2502
|
+
}
|
|
2503
|
+
const initialNodeOptions = process.env.NODE_OPTIONS;
|
|
2504
|
+
var ExtensionWorkerHost = class {
|
|
2505
|
+
options;
|
|
2506
|
+
limits;
|
|
2507
|
+
worker;
|
|
2508
|
+
pending = /* @__PURE__ */ new Map();
|
|
2509
|
+
routePending = /* @__PURE__ */ new Map();
|
|
2510
|
+
streams = /* @__PURE__ */ new Map();
|
|
2511
|
+
detachedRoutes = /* @__PURE__ */ new Set();
|
|
2512
|
+
executionGrace = /* @__PURE__ */ new Map();
|
|
2513
|
+
exited;
|
|
2514
|
+
unavailableController = new AbortController();
|
|
2515
|
+
termination;
|
|
2516
|
+
activation;
|
|
2517
|
+
dead = false;
|
|
2518
|
+
disposed = false;
|
|
2519
|
+
maxBufferedBytes = 0;
|
|
2520
|
+
cancelledStreams = 0;
|
|
2521
|
+
logWindowStart = Date.now();
|
|
2522
|
+
logCount = 0;
|
|
2523
|
+
stdioBytes = 0;
|
|
2524
|
+
stdioResume;
|
|
2525
|
+
constructor(options) {
|
|
2526
|
+
this.options = options;
|
|
2527
|
+
this.limits = resolveHostExecution(options.limits);
|
|
2528
|
+
const overrides = new Set(parentWorkerHeapOverrides(process.execArgv, initialNodeOptions));
|
|
2529
|
+
for (const flag of parentWorkerHeapOverrides([], process.env.NODE_OPTIONS)) overrides.add(flag);
|
|
2530
|
+
if (overrides.size > 0) throw new MobileExtensionError("extension_worker_heap_override", `worker heap limits require launching DSH without ${[...overrides].join(", ")}`, 503);
|
|
2531
|
+
const release = options.budget?.reserve() ?? (() => void 0);
|
|
2532
|
+
try {
|
|
2533
|
+
this.worker = new Worker(options.workerModule, {
|
|
2534
|
+
workerData: { limits: this.limits },
|
|
2535
|
+
stdout: true,
|
|
2536
|
+
stderr: true,
|
|
2537
|
+
resourceLimits: {
|
|
2538
|
+
maxOldGenerationSizeMb: this.limits.maxOldGenerationSizeMb,
|
|
2539
|
+
maxYoungGenerationSizeMb: this.limits.maxYoungGenerationSizeMb,
|
|
2540
|
+
stackSizeMb: this.limits.stackSizeMb
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
release();
|
|
2545
|
+
throw error;
|
|
2546
|
+
}
|
|
2547
|
+
const drainConsole = (chunk) => {
|
|
2548
|
+
this.stdioBytes += chunk.byteLength;
|
|
2549
|
+
if (this.stdioBytes < this.limits.logMessageBytes * this.limits.logMessagesPerWindow || this.stdioResume !== void 0) return;
|
|
2550
|
+
this.worker.stdout?.pause();
|
|
2551
|
+
this.worker.stderr?.pause();
|
|
2552
|
+
this.stdioResume = setTimeout(() => {
|
|
2553
|
+
this.stdioResume = void 0;
|
|
2554
|
+
this.stdioBytes = 0;
|
|
2555
|
+
if (!this.dead) {
|
|
2556
|
+
this.worker.stdout?.resume();
|
|
2557
|
+
this.worker.stderr?.resume();
|
|
2558
|
+
}
|
|
2559
|
+
}, this.limits.logWindowMs);
|
|
2560
|
+
this.stdioResume.unref();
|
|
2561
|
+
};
|
|
2562
|
+
this.worker.stdout?.on("data", drainConsole);
|
|
2563
|
+
this.worker.stderr?.on("data", drainConsole);
|
|
2564
|
+
this.worker.unref();
|
|
2565
|
+
activeWorkerCount.current += 1;
|
|
2566
|
+
this.exited = new Promise((resolve) => {
|
|
2567
|
+
this.worker.once("exit", () => {
|
|
2568
|
+
this.dead = true;
|
|
2569
|
+
if (this.stdioResume !== void 0) clearTimeout(this.stdioResume);
|
|
2570
|
+
this.markUnavailable();
|
|
2571
|
+
release();
|
|
2572
|
+
activeWorkerCount.current -= 1;
|
|
2573
|
+
for (const timer of this.executionGrace.values()) clearTimeout(timer);
|
|
2574
|
+
this.executionGrace.clear();
|
|
2575
|
+
const error = this.unavailable();
|
|
2576
|
+
for (const entry of this.pending.values()) {
|
|
2577
|
+
entry.cleanup();
|
|
2578
|
+
entry.reject(error);
|
|
2579
|
+
}
|
|
2580
|
+
for (const entry of this.routePending.values()) {
|
|
2581
|
+
entry.cleanup();
|
|
2582
|
+
entry.reject(error);
|
|
2583
|
+
}
|
|
2584
|
+
this.pending.clear();
|
|
2585
|
+
this.routePending.clear();
|
|
2586
|
+
this.detachedRoutes.clear();
|
|
2587
|
+
for (const live of this.streams.values()) {
|
|
2588
|
+
if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
|
|
2589
|
+
live.bridge.destroy(error);
|
|
2590
|
+
}
|
|
2591
|
+
this.streams.clear();
|
|
2592
|
+
resolve();
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
this.worker.on("message", (raw) => {
|
|
2596
|
+
try {
|
|
2597
|
+
this.handleMessage(raw);
|
|
2598
|
+
} catch (error) {
|
|
2599
|
+
this.terminate("invalid-worker-message");
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
this.worker.on("error", () => {
|
|
2603
|
+
this.terminate("worker-error");
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
/** A stopped generation never silently respawns. */
|
|
2607
|
+
available() {
|
|
2608
|
+
return !this.dead && this.termination === void 0 && !this.disposed && !this.unavailableController.signal.aborted;
|
|
2609
|
+
}
|
|
2610
|
+
/** Activate once and receive executable-free registration metadata. */
|
|
2611
|
+
async activate() {
|
|
2612
|
+
let timer;
|
|
2613
|
+
try {
|
|
2614
|
+
const reply = new Promise((resolve, reject) => {
|
|
2615
|
+
this.pending.set(LIFECYCLE_ID, {
|
|
2616
|
+
resolve,
|
|
2617
|
+
reject,
|
|
2618
|
+
cleanup: () => void 0
|
|
2619
|
+
});
|
|
2620
|
+
this.post({
|
|
2621
|
+
kind: "activate",
|
|
2622
|
+
id: LIFECYCLE_ID,
|
|
2623
|
+
hostFile: this.options.hostFile,
|
|
2624
|
+
generation: this.options.generation,
|
|
2625
|
+
manifest: this.options.manifest
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
2628
|
+
await Promise.race([reply, new Promise((_, reject) => {
|
|
2629
|
+
timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", "extension worker activation timed out", 500)), this.options.activationTimeoutMs ?? this.limits.activationTimeoutMs);
|
|
2630
|
+
})]);
|
|
2631
|
+
if (this.activation === void 0) throw new MobileExtensionError("host_activation_failed", "extension worker activation failed", 500);
|
|
2632
|
+
return this.activation;
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
await this.terminate("activation-failed");
|
|
2635
|
+
throw error;
|
|
2636
|
+
} finally {
|
|
2637
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
actionMetadata() {
|
|
2641
|
+
return this.activation?.actions ?? [];
|
|
2642
|
+
}
|
|
2643
|
+
routeMetadata() {
|
|
2644
|
+
return this.activation?.routes ?? [];
|
|
2645
|
+
}
|
|
2646
|
+
/** Underlying action: settles only when execution completes or the thread exits. */
|
|
2647
|
+
invokeExecution(action, input, caller) {
|
|
2648
|
+
if (!this.available()) return Promise.reject(this.unavailable());
|
|
2649
|
+
if (caller.signal.aborted) return Promise.reject(caller.signal.reason);
|
|
2650
|
+
const id = randomUUID();
|
|
2651
|
+
return new Promise((resolve, reject) => {
|
|
2652
|
+
const onAbort = () => {
|
|
2653
|
+
this.post({
|
|
2654
|
+
kind: "cancel",
|
|
2655
|
+
id
|
|
2656
|
+
});
|
|
2657
|
+
this.armExecutionGrace(id);
|
|
2658
|
+
};
|
|
2659
|
+
caller.signal.addEventListener("abort", onAbort, { once: true });
|
|
2660
|
+
this.pending.set(id, {
|
|
2661
|
+
resolve,
|
|
2662
|
+
reject,
|
|
2663
|
+
cleanup: () => {
|
|
2664
|
+
caller.signal.removeEventListener("abort", onAbort);
|
|
2665
|
+
this.settleExecutionGrace(id);
|
|
2666
|
+
}
|
|
2667
|
+
});
|
|
2668
|
+
try {
|
|
2669
|
+
this.post({
|
|
2670
|
+
kind: "invoke",
|
|
2671
|
+
id,
|
|
2672
|
+
action,
|
|
2673
|
+
deviceId: caller.deviceId,
|
|
2674
|
+
input
|
|
2675
|
+
});
|
|
2676
|
+
} catch (error) {
|
|
2677
|
+
this.pending.get(id)?.cleanup();
|
|
2678
|
+
this.pending.delete(id);
|
|
2679
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2680
|
+
}
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
/** Direct caller budget; service dispatch observes invokeExecution separately. */
|
|
2684
|
+
invoke(action, input, caller) {
|
|
2685
|
+
return this.callerBudget((signal) => this.invokeExecution(action, input, {
|
|
2686
|
+
...caller,
|
|
2687
|
+
signal
|
|
2688
|
+
}), caller.signal, this.actionMetadata().find((entry) => entry.name === action)?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_action_timeout");
|
|
2689
|
+
}
|
|
2690
|
+
/** Underlying route: late streams are cancelled and observed to confirmed end. */
|
|
2691
|
+
handleRouteExecution(routeIndex, request) {
|
|
2692
|
+
if (!this.available()) return Promise.reject(this.unavailable());
|
|
2693
|
+
if (request.signal.aborted) return Promise.reject(request.signal.reason);
|
|
2694
|
+
const id = randomUUID();
|
|
2695
|
+
return new Promise((resolve, reject) => {
|
|
2696
|
+
const onAbort = () => {
|
|
2697
|
+
this.post({
|
|
2698
|
+
kind: "cancel",
|
|
2699
|
+
id
|
|
2700
|
+
});
|
|
2701
|
+
if (this.routePending.has(id)) {
|
|
2702
|
+
this.detachedRoutes.add(id);
|
|
2703
|
+
this.armExecutionGrace(id);
|
|
2704
|
+
}
|
|
2705
|
+
this.cancelStream(id);
|
|
2706
|
+
};
|
|
2707
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
2708
|
+
this.routePending.set(id, {
|
|
2709
|
+
resolve,
|
|
2710
|
+
reject,
|
|
2711
|
+
cleanup: () => {
|
|
2712
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
2713
|
+
this.settleExecutionGrace(id);
|
|
2714
|
+
}
|
|
2715
|
+
});
|
|
2716
|
+
try {
|
|
2717
|
+
this.post({
|
|
2718
|
+
kind: "route",
|
|
2719
|
+
id,
|
|
2720
|
+
routeIndex,
|
|
2721
|
+
method: request.method,
|
|
2722
|
+
path: request.pathname,
|
|
2723
|
+
query: [...request.query.entries()],
|
|
2724
|
+
headers: request.headers,
|
|
2725
|
+
body: new Uint8Array(request.body),
|
|
2726
|
+
deviceId: request.deviceId
|
|
2727
|
+
});
|
|
2728
|
+
} catch (error) {
|
|
2729
|
+
this.routePending.get(id)?.cleanup();
|
|
2730
|
+
this.routePending.delete(id);
|
|
2731
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
/** Handler budget ends at stream-start; stream lifetime remains cancellation-bound. */
|
|
2736
|
+
handleRoute(routeIndex, request) {
|
|
2737
|
+
return this.callerBudget((signal) => this.handleRouteExecution(routeIndex, {
|
|
2738
|
+
...request,
|
|
2739
|
+
signal
|
|
2740
|
+
}), request.signal, this.routeMetadata()[routeIndex]?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_route_timeout", true);
|
|
2741
|
+
}
|
|
2742
|
+
async callerBudget(start, external, timeoutMs, code, holdForStream = false) {
|
|
2743
|
+
const controller = new AbortController();
|
|
2744
|
+
let timer;
|
|
2745
|
+
let detach;
|
|
2746
|
+
const onAbort = () => {
|
|
2747
|
+
controller.abort(external.reason);
|
|
2748
|
+
detach?.(external.reason ?? new MobileExtensionError("extension_cancelled", "caller cancelled", 409));
|
|
2749
|
+
};
|
|
2750
|
+
const onUnavailable = () => {
|
|
2751
|
+
detach?.(this.unavailableController.signal.reason);
|
|
2752
|
+
};
|
|
2753
|
+
if (external.aborted) throw external.reason;
|
|
2754
|
+
if (!this.available()) throw this.unavailable();
|
|
2755
|
+
external.addEventListener("abort", onAbort, { once: true });
|
|
2756
|
+
this.unavailableController.signal.addEventListener("abort", onUnavailable, { once: true });
|
|
2757
|
+
const release = () => {
|
|
2758
|
+
external.removeEventListener("abort", onAbort);
|
|
2759
|
+
this.unavailableController.signal.removeEventListener("abort", onUnavailable);
|
|
2760
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2761
|
+
};
|
|
2762
|
+
let retained = false;
|
|
2763
|
+
try {
|
|
2764
|
+
const result = await Promise.race([start(controller.signal), new Promise((_, reject) => {
|
|
2765
|
+
detach = reject;
|
|
2766
|
+
timer = setTimeout(() => {
|
|
2767
|
+
const error = new MobileExtensionError(code, "extension operation timed out", 500);
|
|
2768
|
+
controller.abort(error);
|
|
2769
|
+
reject(error);
|
|
2770
|
+
}, timeoutMs);
|
|
2771
|
+
})]);
|
|
2772
|
+
if (holdForStream && result !== null && typeof result === "object" && "body" in result && result.body instanceof Readable) {
|
|
2773
|
+
retained = true;
|
|
2774
|
+
result.body.once("close", release);
|
|
2775
|
+
result.body.once("end", release);
|
|
2776
|
+
}
|
|
2777
|
+
return result;
|
|
2778
|
+
} finally {
|
|
2779
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2780
|
+
if (!retained) release();
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
streamStats() {
|
|
2784
|
+
return {
|
|
2785
|
+
activeStreams: [...this.streams.values()].filter((live) => !live.ended).length,
|
|
2786
|
+
maxBufferedBytes: this.maxBufferedBytes,
|
|
2787
|
+
cancelledStreams: this.cancelledStreams,
|
|
2788
|
+
bufferedBytes: this.totalUnacked()
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
/** Graceful effects cleanup followed by termination; completion requires actual exit. */
|
|
2792
|
+
async dispose() {
|
|
2793
|
+
if (!this.available()) {
|
|
2794
|
+
await this.terminate("dispose-unavailable");
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
this.disposed = true;
|
|
2798
|
+
let timer;
|
|
2799
|
+
try {
|
|
2800
|
+
const reply = new Promise((resolve, reject) => {
|
|
2801
|
+
this.pending.set(LIFECYCLE_ID, {
|
|
2802
|
+
resolve,
|
|
2803
|
+
reject,
|
|
2804
|
+
cleanup: () => void 0
|
|
2805
|
+
});
|
|
2806
|
+
this.post({
|
|
2807
|
+
kind: "dispose",
|
|
2808
|
+
id: LIFECYCLE_ID
|
|
2809
|
+
});
|
|
2810
|
+
});
|
|
2811
|
+
await Promise.race([reply, new Promise((resolve) => {
|
|
2812
|
+
timer = setTimeout(resolve, this.limits.disposeGraceMs);
|
|
2813
|
+
})]);
|
|
2814
|
+
} finally {
|
|
2815
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2816
|
+
await this.terminate("disposed");
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
whenExited() {
|
|
2820
|
+
return this.exited;
|
|
2821
|
+
}
|
|
2822
|
+
/** Stop admission now, retain execution/budget ownership until confirmed thread exit. */
|
|
2823
|
+
terminate(reason) {
|
|
2824
|
+
this.markUnavailable();
|
|
2825
|
+
this.termination ??= (async () => {
|
|
2826
|
+
if (!this.dead) await this.worker.terminate();
|
|
2827
|
+
await this.exited;
|
|
2828
|
+
})();
|
|
2829
|
+
this.termination.catch(() => void 0);
|
|
2830
|
+
return this.termination;
|
|
2831
|
+
}
|
|
2832
|
+
unavailable() {
|
|
2833
|
+
return new MobileExtensionError("extension_host_unavailable", `extension ${this.options.manifest.id} worker is unavailable; explicit recovery is required`, 503);
|
|
2834
|
+
}
|
|
2835
|
+
markUnavailable() {
|
|
2836
|
+
if (this.unavailableController.signal.aborted) return;
|
|
2837
|
+
const error = this.unavailable();
|
|
2838
|
+
this.unavailableController.abort(error);
|
|
2839
|
+
try {
|
|
2840
|
+
this.options.onUnavailable?.(error);
|
|
2841
|
+
} catch (cause) {}
|
|
2842
|
+
}
|
|
2843
|
+
armExecutionGrace(id) {
|
|
2844
|
+
if (this.executionGrace.has(id) || !this.available()) return;
|
|
2845
|
+
const timer = setTimeout(() => {
|
|
2846
|
+
this.executionGrace.delete(id);
|
|
2847
|
+
if (this.pending.has(id) || this.routePending.has(id)) this.terminate("cancel-unresponsive");
|
|
2848
|
+
}, this.limits.cancelGraceMs);
|
|
2849
|
+
timer.unref();
|
|
2850
|
+
this.executionGrace.set(id, timer);
|
|
2851
|
+
}
|
|
2852
|
+
settleExecutionGrace(id) {
|
|
2853
|
+
const timer = this.executionGrace.get(id);
|
|
2854
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2855
|
+
this.executionGrace.delete(id);
|
|
2856
|
+
}
|
|
2857
|
+
post(message) {
|
|
2858
|
+
if (!this.dead && this.termination === void 0) this.worker.postMessage(message);
|
|
2859
|
+
}
|
|
2860
|
+
handleMessage(raw) {
|
|
2861
|
+
if (this.dead || raw === null || typeof raw !== "object") return;
|
|
2862
|
+
const message = raw;
|
|
2863
|
+
if (message.kind === "log") {
|
|
2864
|
+
if (message.level !== "debug" && message.level !== "info" && message.level !== "warn" && message.level !== "error") return;
|
|
2865
|
+
if (!Array.isArray(message.args) || message.args.some((arg) => typeof arg !== "string")) return;
|
|
2866
|
+
if (Date.now() - this.logWindowStart >= this.limits.logWindowMs) {
|
|
2867
|
+
this.logWindowStart = Date.now();
|
|
2868
|
+
this.logCount = 0;
|
|
2869
|
+
}
|
|
2870
|
+
if (++this.logCount > this.limits.logMessagesPerWindow || Buffer.byteLength(message.args.join("")) > this.limits.logMessageBytes) return;
|
|
2871
|
+
try {
|
|
2872
|
+
this.options.logger[message.level](...message.args);
|
|
2873
|
+
} catch (error) {}
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
if (typeof message.runtimeId !== "string") return;
|
|
2877
|
+
if (this.activation !== void 0 && message.runtimeId !== this.activation.runtimeId) return;
|
|
2878
|
+
if (message.kind === "activated") {
|
|
2879
|
+
if (this.activation !== void 0 || !Array.isArray(message.actions) || !Array.isArray(message.routes)) throw new Error("invalid activation metadata");
|
|
2880
|
+
const actions = message.actions;
|
|
2881
|
+
const routes = message.routes;
|
|
2882
|
+
if (actions.length + routes.length > this.limits.maxRegistrations) throw new Error("worker registration limit exceeded");
|
|
2883
|
+
for (const action of actions) if (typeof action?.name !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(action.name)) throw new Error("invalid action metadata");
|
|
2884
|
+
for (const route of routes) if (typeof route?.method !== "string" || typeof route.path !== "string") throw new Error("invalid route metadata");
|
|
2885
|
+
this.activation = Object.freeze({
|
|
2886
|
+
runtimeId: message.runtimeId,
|
|
2887
|
+
actions: Object.freeze(actions),
|
|
2888
|
+
routes: Object.freeze(routes)
|
|
2889
|
+
});
|
|
2890
|
+
const pending = this.pending.get(LIFECYCLE_ID);
|
|
2891
|
+
this.pending.delete(LIFECYCLE_ID);
|
|
2892
|
+
pending?.cleanup();
|
|
2893
|
+
pending?.resolve(new PreparedJsonResult(Buffer.alloc(0)));
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
if (typeof message.id !== "string") return;
|
|
2897
|
+
const id = message.id;
|
|
2898
|
+
if (message.kind === "result" || message.kind === "error") {
|
|
2899
|
+
const pending = this.pending.get(id);
|
|
2900
|
+
this.pending.delete(id);
|
|
2901
|
+
pending?.cleanup();
|
|
2902
|
+
if (message.kind === "result") {
|
|
2903
|
+
if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
|
|
2904
|
+
pending?.reject(new MobileExtensionError("extension_failed", "invalid worker result", 500));
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
pending?.resolve(new PreparedJsonResult(Buffer.from(message.bytes)));
|
|
2908
|
+
} else {
|
|
2909
|
+
const error = new MobileExtensionError(typeof message.code === "string" ? message.code.slice(0, 64) : "extension_failed", typeof message.message === "string" ? message.message.slice(0, 500) : "extension failed", typeof message.status === "number" && Number.isInteger(message.status) && message.status >= 400 && message.status <= 599 ? message.status : 500);
|
|
2910
|
+
pending?.reject(error);
|
|
2911
|
+
const route = this.routePending.get(id);
|
|
2912
|
+
this.routePending.delete(id);
|
|
2913
|
+
this.detachedRoutes.delete(id);
|
|
2914
|
+
route?.cleanup();
|
|
2915
|
+
route?.reject(error);
|
|
2916
|
+
}
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
if (message.kind === "route-response" || message.kind === "stream-start") {
|
|
2920
|
+
const route = this.routePending.get(id);
|
|
2921
|
+
if (route === void 0) {
|
|
2922
|
+
if (message.kind === "stream-start") this.post({
|
|
2923
|
+
kind: "stream-cancel",
|
|
2924
|
+
id
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
if (message.kind === "stream-start" && this.detachedRoutes.has(id)) {
|
|
2929
|
+
this.cancelledStreams += 1;
|
|
2930
|
+
this.post({
|
|
2931
|
+
kind: "stream-cancel",
|
|
2932
|
+
id
|
|
2933
|
+
});
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
const response = responseMetadata(message);
|
|
2937
|
+
this.routePending.delete(id);
|
|
2938
|
+
this.detachedRoutes.delete(id);
|
|
2939
|
+
if (message.kind === "route-response") {
|
|
2940
|
+
route.cleanup();
|
|
2941
|
+
if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
|
|
2942
|
+
route.reject(new MobileExtensionError("invalid_route_response", "invalid worker route response", 500));
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
route.resolve({
|
|
2946
|
+
...response,
|
|
2947
|
+
body: Buffer.from(message.bytes)
|
|
2948
|
+
});
|
|
2949
|
+
} else {
|
|
2950
|
+
this.settleExecutionGrace(id);
|
|
2951
|
+
const live = this.openStream(id, route.cleanup);
|
|
2952
|
+
route.resolve({
|
|
2953
|
+
...response,
|
|
2954
|
+
body: live.bridge
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (message.kind === "stream-chunk") {
|
|
2960
|
+
const live = this.streams.get(id);
|
|
2961
|
+
if (live === void 0 || live.ended || live.bridge.destroyed) return;
|
|
2962
|
+
if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.streamChunkBytes || live.unacked + message.bytes.byteLength > this.limits.streamWindowBytes || this.totalUnacked() + message.bytes.byteLength > this.limits.streamAggregateBytes) throw new Error("worker exceeded stream credit");
|
|
2963
|
+
live.unacked += message.bytes.byteLength;
|
|
2964
|
+
this.maxBufferedBytes = Math.max(this.maxBufferedBytes, this.totalUnacked());
|
|
2965
|
+
live.bridge.push(Buffer.from(message.bytes));
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (message.kind === "stream-end" || message.kind === "stream-error") {
|
|
2969
|
+
this.settleExecutionGrace(id);
|
|
2970
|
+
const detached = this.routePending.get(id);
|
|
2971
|
+
if (this.detachedRoutes.delete(id)) {
|
|
2972
|
+
this.routePending.delete(id);
|
|
2973
|
+
detached?.cleanup();
|
|
2974
|
+
detached?.reject(new MobileExtensionError("extension_cancelled", "caller detached", 409));
|
|
2975
|
+
}
|
|
2976
|
+
const live = this.streams.get(id);
|
|
2977
|
+
if (live === void 0) return;
|
|
2978
|
+
live.ended = true;
|
|
2979
|
+
if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
|
|
2980
|
+
if (message.kind === "stream-error") live.bridge.destroy(new Error(typeof message.message === "string" ? message.message.slice(0, 500) : "worker stream failed"));
|
|
2981
|
+
else if (live.bridge.destroyed) this.streams.delete(id);
|
|
2982
|
+
else live.bridge.push(null);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
totalUnacked() {
|
|
2986
|
+
let total = 0;
|
|
2987
|
+
for (const live of this.streams.values()) total += live.unacked;
|
|
2988
|
+
return total;
|
|
2989
|
+
}
|
|
2990
|
+
openStream(id, cleanup) {
|
|
2991
|
+
const live = {
|
|
2992
|
+
bridge: new WorkerStreamBridge((bytes) => {
|
|
2993
|
+
live.unacked -= bytes;
|
|
2994
|
+
this.post({
|
|
2995
|
+
kind: "stream-ack",
|
|
2996
|
+
id,
|
|
2997
|
+
bytes
|
|
2998
|
+
});
|
|
2999
|
+
}),
|
|
3000
|
+
unacked: 0,
|
|
3001
|
+
ended: false
|
|
3002
|
+
};
|
|
3003
|
+
live.bridge.on("error", () => void 0);
|
|
3004
|
+
const settle = () => {
|
|
3005
|
+
cleanup();
|
|
3006
|
+
if (live.ended) {
|
|
3007
|
+
if (live.unacked > 0) this.post({
|
|
3008
|
+
kind: "stream-cancel",
|
|
3009
|
+
id
|
|
3010
|
+
});
|
|
3011
|
+
this.streams.delete(id);
|
|
3012
|
+
} else this.cancelStream(id);
|
|
3013
|
+
};
|
|
3014
|
+
live.bridge.once("end", settle);
|
|
3015
|
+
live.bridge.once("close", settle);
|
|
3016
|
+
this.streams.set(id, live);
|
|
3017
|
+
return live;
|
|
3018
|
+
}
|
|
3019
|
+
cancelStream(id) {
|
|
3020
|
+
const live = this.streams.get(id);
|
|
3021
|
+
if (live === void 0 || live.ended || live.cancelTimer !== void 0) return;
|
|
3022
|
+
this.cancelledStreams += 1;
|
|
3023
|
+
this.post({
|
|
3024
|
+
kind: "stream-cancel",
|
|
3025
|
+
id
|
|
3026
|
+
});
|
|
3027
|
+
live.cancelTimer = setTimeout(() => {
|
|
3028
|
+
if (!live.ended) this.terminate("stream-cancel-unresponsive");
|
|
3029
|
+
}, this.limits.streamCancelGraceMs);
|
|
3030
|
+
live.cancelTimer.unref();
|
|
3031
|
+
live.bridge.destroy();
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
function responseMetadata(message) {
|
|
3035
|
+
const headers = {};
|
|
3036
|
+
let bytes = typeof message.contentType === "string" ? Buffer.byteLength(message.contentType) : 0;
|
|
3037
|
+
if (message.headers !== void 0) {
|
|
3038
|
+
if (message.headers === null || typeof message.headers !== "object" || Array.isArray(message.headers)) throw new Error("invalid response headers");
|
|
3039
|
+
const entries = Object.entries(message.headers);
|
|
3040
|
+
if (entries.length > 3) throw new Error("invalid response header count");
|
|
3041
|
+
for (const [key, value] of entries) {
|
|
3042
|
+
if (typeof value !== "string" || !/^(?:content-disposition|cache-control|etag)$/iu.test(key)) throw new Error("invalid response header");
|
|
3043
|
+
bytes += Buffer.byteLength(key) + Buffer.byteLength(value);
|
|
3044
|
+
if (bytes > 16384) throw new Error("invalid response header bytes");
|
|
3045
|
+
headers[key] = value;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return {
|
|
3049
|
+
...typeof message.status === "number" ? { status: message.status } : {},
|
|
3050
|
+
...typeof message.contentType === "string" ? { contentType: message.contentType } : {},
|
|
3051
|
+
...message.headers === void 0 ? {} : { headers }
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
/** Credit belongs to bytes actually removed by the consumer, including reads after source EOF. */
|
|
3055
|
+
var WorkerStreamBridge = class extends Readable {
|
|
3056
|
+
consume;
|
|
3057
|
+
constructor(consume) {
|
|
3058
|
+
super({ highWaterMark: 16384 });
|
|
3059
|
+
this.consume = consume;
|
|
3060
|
+
}
|
|
3061
|
+
_read() {}
|
|
3062
|
+
emit(event, ...args) {
|
|
3063
|
+
if (event === "data" && Buffer.isBuffer(args[0])) this.consume(args[0].byteLength);
|
|
3064
|
+
return super.emit(event, ...args);
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
//#endregion
|
|
2261
3068
|
//#region src/extensions.ts
|
|
2262
3069
|
/** Maximum sizes enforced at the local-extension filesystem boundary. */
|
|
2263
3070
|
const EXTENSION_LIMITS = Object.freeze({
|
|
@@ -2275,15 +3082,15 @@ const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
|
|
|
2275
3082
|
const RETIRED_GENERATION_TTL_MS = 6e5;
|
|
2276
3083
|
/** Extension teardown is advisory and must never stop watcher progress. */
|
|
2277
3084
|
const HOST_TEARDOWN_TIMEOUT_MS = 2e3;
|
|
2278
|
-
async function withActivationTimeout(promise, id, signal) {
|
|
3085
|
+
async function withActivationTimeout(promise, id, signal, timeoutMs) {
|
|
2279
3086
|
let timer;
|
|
2280
3087
|
let onAbort;
|
|
2281
3088
|
try {
|
|
2282
3089
|
return await Promise.race([
|
|
2283
3090
|
promise,
|
|
2284
|
-
new Promise((_, reject) => {
|
|
2285
|
-
timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)),
|
|
2286
|
-
}),
|
|
3091
|
+
...timeoutMs === void 0 ? [] : [new Promise((_, reject) => {
|
|
3092
|
+
timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), timeoutMs);
|
|
3093
|
+
})],
|
|
2287
3094
|
new Promise((_, reject) => {
|
|
2288
3095
|
const abort = () => {
|
|
2289
3096
|
reject(new MobileExtensionError("host_activation_closed", `extension ${id} activation is closed`, 409));
|
|
@@ -2599,6 +3406,11 @@ var MobileAccessService = class extends Service {
|
|
|
2599
3406
|
localRefreshAbort;
|
|
2600
3407
|
localLifecycle = 0;
|
|
2601
3408
|
localClosed = true;
|
|
3409
|
+
localExecution;
|
|
3410
|
+
localExecutionKey;
|
|
3411
|
+
workerBudget;
|
|
3412
|
+
disposal = /* @__PURE__ */ new Set();
|
|
3413
|
+
recoveries = /* @__PURE__ */ new Map();
|
|
2602
3414
|
constructor(ctx) {
|
|
2603
3415
|
super(ctx, "mobileAccess");
|
|
2604
3416
|
}
|
|
@@ -2660,10 +3472,65 @@ var MobileAccessService = class extends Service {
|
|
|
2660
3472
|
}
|
|
2661
3473
|
/** Return loaded and failed local extension counts without exposing host errors. */
|
|
2662
3474
|
status() {
|
|
3475
|
+
const unavailable = [...this.local.values()].filter((entry) => entry.worker !== void 0 && !entry.worker.available()).length;
|
|
2663
3476
|
return Object.freeze({
|
|
2664
|
-
loaded: this.registered.size + this.local.size,
|
|
2665
|
-
failed: this.failures.size
|
|
3477
|
+
loaded: this.registered.size + this.local.size - unavailable,
|
|
3478
|
+
failed: this.failures.size + unavailable
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
/** Local Host status does not reveal filesystem paths, credentials or extension errors. */
|
|
3482
|
+
hostStatus() {
|
|
3483
|
+
const limits = resolveHostExecution(this.localExecution?.hostExecution);
|
|
3484
|
+
return {
|
|
3485
|
+
hosts: [...this.local.values()].map((entry) => ({
|
|
3486
|
+
id: entry.manifest.id,
|
|
3487
|
+
name: entry.manifest.name,
|
|
3488
|
+
mode: entry.worker === void 0 ? "in-process" : "worker",
|
|
3489
|
+
state: entry.worker === void 0 || entry.worker.available() ? "ready" : "unavailable",
|
|
3490
|
+
generation: entry.digest
|
|
3491
|
+
})),
|
|
3492
|
+
limits: {
|
|
3493
|
+
maxWorkers: limits.maxWorkers,
|
|
3494
|
+
workers: this.workerBudget?.current ?? 0
|
|
3495
|
+
}
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
/** Explicitly replace one unavailable worker without replaying requests or changing its files. */
|
|
3499
|
+
recoverExtension(id) {
|
|
3500
|
+
assertExtensionId(id);
|
|
3501
|
+
const existing = this.recoveries.get(id);
|
|
3502
|
+
if (existing !== void 0) return existing;
|
|
3503
|
+
const recovering = (async () => {
|
|
3504
|
+
if (this.localRefreshing !== void 0) await this.localRefreshing;
|
|
3505
|
+
const previous = this.local.get(id);
|
|
3506
|
+
if (this.localClosed || previous?.worker === void 0 || this.localContext === void 0) throw new MobileExtensionError("extension_recovery_unavailable", "local worker recovery is unavailable", 409);
|
|
3507
|
+
if (previous.worker.available()) throw new MobileExtensionError("extension_host_running", "extension worker is already running", 409);
|
|
3508
|
+
await previous.worker.terminate("explicit-recovery");
|
|
3509
|
+
const lifecycle = this.localLifecycle;
|
|
3510
|
+
const fingerprint = await extensionFingerprint(previous.directory);
|
|
3511
|
+
const fresh = await loadLocalExtension(previous.directory, this.localContext, fingerprint, void 0, this.localExecution);
|
|
3512
|
+
try {
|
|
3513
|
+
const confirmed = await extensionFingerprint(previous.directory);
|
|
3514
|
+
if (this.localClosed || this.localLifecycle !== lifecycle || this.local.get(id) !== previous || confirmed.digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", "extension changed during recovery", 409);
|
|
3515
|
+
} catch (error) {
|
|
3516
|
+
await abortAndDisposeLocal([fresh]);
|
|
3517
|
+
throw error;
|
|
3518
|
+
}
|
|
3519
|
+
this.local.set(id, fresh);
|
|
3520
|
+
this.failures.delete(id);
|
|
3521
|
+
this.queueDisposal([previous]);
|
|
3522
|
+
this.updateContentHash();
|
|
3523
|
+
})().finally(() => {
|
|
3524
|
+
if (this.recoveries.get(id) === recovering) this.recoveries.delete(id);
|
|
3525
|
+
});
|
|
3526
|
+
this.recoveries.set(id, recovering);
|
|
3527
|
+
return recovering;
|
|
3528
|
+
}
|
|
3529
|
+
queueDisposal(entries) {
|
|
3530
|
+
const pending = abortAndDisposeLocal(entries).finally(() => {
|
|
3531
|
+
this.disposal.delete(pending);
|
|
2666
3532
|
});
|
|
3533
|
+
this.disposal.add(pending);
|
|
2667
3534
|
}
|
|
2668
3535
|
/** Locate one active extension. */
|
|
2669
3536
|
extension(id, generation) {
|
|
@@ -2753,7 +3620,7 @@ var MobileAccessService = class extends Service {
|
|
|
2753
3620
|
});
|
|
2754
3621
|
}, {
|
|
2755
3622
|
external: external?.signal ?? context.signal,
|
|
2756
|
-
timeoutMs: action.timeoutMs ?? 3e4,
|
|
3623
|
+
timeoutMs: action.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
|
|
2757
3624
|
timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
|
|
2758
3625
|
onDetach: () => {
|
|
2759
3626
|
this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
|
|
@@ -2776,7 +3643,7 @@ var MobileAccessService = class extends Service {
|
|
|
2776
3643
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
2777
3644
|
const route = ("host" in extension ? extension.host : extension).routes?.find((candidate) => {
|
|
2778
3645
|
if (candidate.method !== method) return false;
|
|
2779
|
-
return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
|
|
3646
|
+
return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : candidate.path === "/" || pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
|
|
2780
3647
|
});
|
|
2781
3648
|
if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
|
|
2782
3649
|
if ("host" in extension) extension.controller.signal.throwIfAborted();
|
|
@@ -2791,7 +3658,7 @@ var MobileAccessService = class extends Service {
|
|
|
2791
3658
|
signal
|
|
2792
3659
|
}), {
|
|
2793
3660
|
external: external?.signal ?? request.signal,
|
|
2794
|
-
timeoutMs: route.timeoutMs ?? 3e4,
|
|
3661
|
+
timeoutMs: route.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
|
|
2795
3662
|
timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
|
|
2796
3663
|
onDetach: () => {
|
|
2797
3664
|
this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
|
|
@@ -2823,13 +3690,24 @@ var MobileAccessService = class extends Service {
|
|
|
2823
3690
|
}
|
|
2824
3691
|
}
|
|
2825
3692
|
/** Start the local directory watcher; an absent directory is intentionally inert. */
|
|
2826
|
-
async startLocal(root, context) {
|
|
3693
|
+
async startLocal(root, context, options) {
|
|
2827
3694
|
const targetRoot = resolve(root);
|
|
2828
|
-
|
|
3695
|
+
const limits = resolveHostExecution(options?.hostExecution);
|
|
3696
|
+
const executionKey = JSON.stringify({
|
|
3697
|
+
limits,
|
|
3698
|
+
module: options?.hostExecution?.workerModule
|
|
3699
|
+
});
|
|
3700
|
+
if (this.localRoot !== void 0 && (resolve(this.localRoot) !== targetRoot || this.localExecutionKey !== executionKey)) await this.stopLocal();
|
|
2829
3701
|
if (this.localTimer !== void 0) clearInterval(this.localTimer);
|
|
2830
3702
|
const lifecycle = ++this.localLifecycle;
|
|
3703
|
+
this.workerBudget ??= new ExtensionWorkerBudget(limits.maxWorkers);
|
|
2831
3704
|
this.localRoot = targetRoot;
|
|
2832
3705
|
this.localContext = context;
|
|
3706
|
+
this.localExecution = {
|
|
3707
|
+
...options,
|
|
3708
|
+
workerBudget: this.workerBudget
|
|
3709
|
+
};
|
|
3710
|
+
this.localExecutionKey = executionKey;
|
|
2833
3711
|
this.localClosed = false;
|
|
2834
3712
|
await mkdir(this.localRoot, { recursive: true });
|
|
2835
3713
|
if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
|
|
@@ -2863,6 +3741,8 @@ var MobileAccessService = class extends Service {
|
|
|
2863
3741
|
this.failures.clear();
|
|
2864
3742
|
this.updateContentHash();
|
|
2865
3743
|
await abortAndDisposeLocal(late);
|
|
3744
|
+
await Promise.allSettled([...this.disposal, ...this.recoveries.values()]);
|
|
3745
|
+
this.workerBudget = void 0;
|
|
2866
3746
|
if (this.localTimer !== void 0) clearInterval(this.localTimer);
|
|
2867
3747
|
this.localTimer = void 0;
|
|
2868
3748
|
}
|
|
@@ -2906,7 +3786,7 @@ var MobileAccessService = class extends Service {
|
|
|
2906
3786
|
const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : void 0;
|
|
2907
3787
|
if (previous?.digest === fingerprint.digest) staged.push(previous);
|
|
2908
3788
|
else {
|
|
2909
|
-
const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal);
|
|
3789
|
+
const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal, this.localExecution);
|
|
2910
3790
|
try {
|
|
2911
3791
|
signal.throwIfAborted();
|
|
2912
3792
|
if ((await extensionFingerprint(directory)).digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", `extension ${fingerprint.manifest.id} changed during activation`, 409);
|
|
@@ -2957,13 +3837,15 @@ var MobileAccessService = class extends Service {
|
|
|
2957
3837
|
for (const name of names) this.failures.delete(name);
|
|
2958
3838
|
for (const failure of this.failures.keys()) if (failure !== "local" && !names.includes(failure)) this.failures.delete(failure);
|
|
2959
3839
|
this.failures.delete("local");
|
|
2960
|
-
if (removed.length > 0)
|
|
3840
|
+
if (removed.length > 0) this.queueDisposal(removed);
|
|
2961
3841
|
this.updateContentHash();
|
|
2962
3842
|
} catch (error) {
|
|
2963
3843
|
await abortAndDisposeLocal(stagedFresh);
|
|
2964
3844
|
if (this.localClosed || signal.aborted) return;
|
|
2965
3845
|
const message = error instanceof Error ? error.message : String(error);
|
|
3846
|
+
const changedFailure = this.failures.get(failingName) !== message;
|
|
2966
3847
|
this.failures.set(failingName, message);
|
|
3848
|
+
if (changedFailure && error instanceof MobileExtensionError && error.code === "extension_worker_heap_override") this.ctx.logger.warn("local extension worker refused: %s", error.message);
|
|
2967
3849
|
if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
2968
3850
|
}
|
|
2969
3851
|
}
|
|
@@ -2973,12 +3855,12 @@ var MobileAccessService = class extends Service {
|
|
|
2973
3855
|
if (previous !== void 0) {
|
|
2974
3856
|
clearTimeout(previous.timer);
|
|
2975
3857
|
this.retired.delete(active.manifest.id);
|
|
2976
|
-
|
|
3858
|
+
this.queueDisposal([previous.active]);
|
|
2977
3859
|
}
|
|
2978
3860
|
const timer = setTimeout(() => {
|
|
2979
3861
|
if (this.retired.get(active.manifest.id)?.active !== active) return;
|
|
2980
3862
|
this.retired.delete(active.manifest.id);
|
|
2981
|
-
|
|
3863
|
+
this.queueDisposal([active]);
|
|
2982
3864
|
}, RETIRED_GENERATION_TTL_MS);
|
|
2983
3865
|
timer.unref();
|
|
2984
3866
|
this.retired.set(active.manifest.id, {
|
|
@@ -3100,8 +3982,9 @@ async function abortAndDisposeLocal(entries) {
|
|
|
3100
3982
|
pending.push(...invokeCleanups(entry.cleanups));
|
|
3101
3983
|
}
|
|
3102
3984
|
await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS);
|
|
3985
|
+
await Promise.all(entries.flatMap((entry) => entry.worker === void 0 ? [] : [entry.worker.dispose()]));
|
|
3103
3986
|
}
|
|
3104
|
-
async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
3987
|
+
async function loadLocalExtension(directory, context, known, parentSignal, execution) {
|
|
3105
3988
|
const root = await realExtensionRoot(directory);
|
|
3106
3989
|
const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
|
|
3107
3990
|
const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
|
|
@@ -3153,28 +4036,73 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
|
3153
4036
|
}
|
|
3154
4037
|
};
|
|
3155
4038
|
try {
|
|
3156
|
-
const
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
controller.signal.throwIfAborted();
|
|
3168
|
-
if (imported.default !== void 0) await imported.default(api);
|
|
4039
|
+
const limits = resolveHostExecution(execution?.hostExecution);
|
|
4040
|
+
const worker = limits.mode === "worker" && (limits.extensions === void 0 || limits.extensions.includes(manifest.id)) && hostFile !== void 0 ? new ExtensionWorkerHost({
|
|
4041
|
+
workerModule: execution?.hostExecution?.workerModule ?? defaultExtensionWorkerModule(),
|
|
4042
|
+
hostFile,
|
|
4043
|
+
manifest,
|
|
4044
|
+
generation: known?.digest ?? createHash("sha256").update(manifest.id).digest("hex"),
|
|
4045
|
+
logger: workerLoggerAdapter(context),
|
|
4046
|
+
limits,
|
|
4047
|
+
...execution?.workerBudget === void 0 ? {} : { budget: execution.workerBudget },
|
|
4048
|
+
onUnavailable: (error) => {
|
|
4049
|
+
controller.abort(error);
|
|
3169
4050
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
4051
|
+
}) : void 0;
|
|
4052
|
+
let host;
|
|
4053
|
+
if (worker !== void 0) {
|
|
4054
|
+
try {
|
|
4055
|
+
await withActivationTimeout(worker.activate(), manifest.id, controller.signal, void 0);
|
|
4056
|
+
} catch (error) {
|
|
4057
|
+
await worker.terminate("activation-failed");
|
|
4058
|
+
throw error;
|
|
4059
|
+
}
|
|
4060
|
+
const proxyActions = {};
|
|
4061
|
+
for (const metadata of worker.actionMetadata()) proxyActions[metadata.name] = {
|
|
4062
|
+
...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
|
|
4063
|
+
run: (actionContext, input) => worker.invokeExecution(metadata.name, input, actionContext)
|
|
4064
|
+
};
|
|
4065
|
+
const proxyRoutes = worker.routeMetadata().map((metadata, index) => ({
|
|
4066
|
+
method: metadata.method,
|
|
4067
|
+
path: metadata.path,
|
|
4068
|
+
...metadata.kind === void 0 ? {} : { kind: metadata.kind },
|
|
4069
|
+
...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
|
|
4070
|
+
handle: (request) => worker.handleRouteExecution(index, request)
|
|
4071
|
+
}));
|
|
4072
|
+
try {
|
|
4073
|
+
host = validateDefinition({
|
|
4074
|
+
...manifest,
|
|
4075
|
+
...Object.keys(proxyActions).length === 0 ? {} : { actions: proxyActions },
|
|
4076
|
+
...proxyRoutes.length === 0 ? {} : { routes: proxyRoutes }
|
|
4077
|
+
});
|
|
4078
|
+
} catch (error) {
|
|
4079
|
+
await worker.terminate("definition-invalid");
|
|
4080
|
+
throw error;
|
|
4081
|
+
}
|
|
4082
|
+
} else {
|
|
4083
|
+
const activate = async () => {
|
|
4084
|
+
controller.signal.throwIfAborted();
|
|
4085
|
+
if (hostFile !== void 0) {
|
|
4086
|
+
const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
|
|
4087
|
+
controller.signal.throwIfAborted();
|
|
4088
|
+
let imported;
|
|
4089
|
+
try {
|
|
4090
|
+
imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
|
|
4091
|
+
} catch {
|
|
4092
|
+
throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
|
|
4093
|
+
}
|
|
4094
|
+
controller.signal.throwIfAborted();
|
|
4095
|
+
if (imported.default !== void 0) await imported.default(api);
|
|
4096
|
+
}
|
|
4097
|
+
await Promise.all(pendingEffects);
|
|
4098
|
+
};
|
|
4099
|
+
await withActivationTimeout(activate(), manifest.id, controller.signal, HOST_ACTIVATION_TIMEOUT_MS);
|
|
4100
|
+
host = validateDefinition({
|
|
4101
|
+
...manifest,
|
|
4102
|
+
actions,
|
|
4103
|
+
routes
|
|
4104
|
+
});
|
|
4105
|
+
}
|
|
3178
4106
|
activationOpen = false;
|
|
3179
4107
|
const digest = known?.digest ?? createHash("sha256").update(manifest.id).digest("hex");
|
|
3180
4108
|
return Object.freeze({
|
|
@@ -3186,7 +4114,8 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
|
3186
4114
|
host,
|
|
3187
4115
|
controller,
|
|
3188
4116
|
cleanups: Object.freeze(cleanups),
|
|
3189
|
-
digest
|
|
4117
|
+
digest,
|
|
4118
|
+
...worker === void 0 ? {} : { worker }
|
|
3190
4119
|
});
|
|
3191
4120
|
} catch (error) {
|
|
3192
4121
|
activationOpen = false;
|
|
@@ -3198,6 +4127,23 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
|
|
|
3198
4127
|
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
3199
4128
|
}
|
|
3200
4129
|
}
|
|
4130
|
+
/** Forward the worker's logger-only context adapter into the plugin logger. */
|
|
4131
|
+
function workerLoggerAdapter(context) {
|
|
4132
|
+
return {
|
|
4133
|
+
debug: (...args) => {
|
|
4134
|
+
context.logger.debug(...args);
|
|
4135
|
+
},
|
|
4136
|
+
info: (...args) => {
|
|
4137
|
+
context.logger.info(...args);
|
|
4138
|
+
},
|
|
4139
|
+
warn: (...args) => {
|
|
4140
|
+
context.logger.warn(...args);
|
|
4141
|
+
},
|
|
4142
|
+
error: (...args) => {
|
|
4143
|
+
context.logger.error(...args);
|
|
4144
|
+
}
|
|
4145
|
+
};
|
|
4146
|
+
}
|
|
3201
4147
|
/** Construct the service in a Cordis plugin without importing DSH internals. */
|
|
3202
4148
|
function createMobileAccessService(ctx) {
|
|
3203
4149
|
return new MobileAccessService(ctx);
|
|
@@ -3213,8 +4159,14 @@ const COPY = {
|
|
|
3213
4159
|
deviceName: "Device name",
|
|
3214
4160
|
pair: "Pair",
|
|
3215
4161
|
pairing: "Pairing…",
|
|
3216
|
-
|
|
3217
|
-
|
|
4162
|
+
pairingCodeHint: "Use the 43-character pairing code shown on the computer. Keys starting with dsh1. or dsh2. are for the Android app.",
|
|
4163
|
+
invalidPairingCode: "Enter the 43-character pairing code from the current pairing window on the computer.",
|
|
4164
|
+
appKeyNotCode: "This is an Android app key. Use it in the app, or copy the browser pairing code from the computer.",
|
|
4165
|
+
pairFailed: "Pairing failed. Check the code or generate a new one on the computer.",
|
|
4166
|
+
pairingExpired: "The pairing code is invalid or has expired. Generate a new code on the computer and try again.",
|
|
4167
|
+
pairingLimited: "Too many pairing attempts. Wait a moment and try again.",
|
|
4168
|
+
deviceLimit: "The paired-device limit has been reached. Remove an unused device and generate a new pairing code on the computer.",
|
|
4169
|
+
unavailable: "The computer is unavailable. Check the connection and try again.",
|
|
3218
4170
|
reconnectTitle: "Reconnect DSH mobile access",
|
|
3219
4171
|
reconnectHeading: "Reconnect this device",
|
|
3220
4172
|
restoring: "Restoring the secure Session…",
|
|
@@ -3229,8 +4181,14 @@ const COPY = {
|
|
|
3229
4181
|
deviceName: "设备名称",
|
|
3230
4182
|
pair: "配对",
|
|
3231
4183
|
pairing: "正在配对…",
|
|
3232
|
-
|
|
3233
|
-
|
|
4184
|
+
pairingCodeHint: "请输入电脑端显示的 43 位配对码。以 dsh1. 或 dsh2. 开头的密钥用于 Android App。",
|
|
4185
|
+
invalidPairingCode: "请输入电脑端当前配对窗口中的 43 位配对码。",
|
|
4186
|
+
appKeyNotCode: "这是 Android App 密钥。请在 App 中使用,或回到电脑端复制浏览器配对码。",
|
|
4187
|
+
pairFailed: "配对失败,请检查输入或在电脑端重新生成配对码。",
|
|
4188
|
+
pairingExpired: "配对码无效或已过期,请在电脑端重新生成后重试。",
|
|
4189
|
+
pairingLimited: "配对尝试过于频繁,请稍后再试。",
|
|
4190
|
+
deviceLimit: "已达到配对设备上限,请在电脑端移除不需要的设备后重新生成配对码。",
|
|
4191
|
+
unavailable: "电脑当前不可用,请检查连接后重试。",
|
|
3234
4192
|
reconnectTitle: "重新连接 DSH 移动访问",
|
|
3235
4193
|
reconnectHeading: "重新连接此设备",
|
|
3236
4194
|
restoring: "正在恢复安全会话…",
|
|
@@ -3245,8 +4203,14 @@ const COPY = {
|
|
|
3245
4203
|
deviceName: "Nome dispositivo",
|
|
3246
4204
|
pair: "Abbina",
|
|
3247
4205
|
pairing: "Abbinamento…",
|
|
3248
|
-
|
|
3249
|
-
|
|
4206
|
+
pairingCodeHint: "Usa il codice di abbinamento di 43 caratteri mostrato sul computer. Le chiavi che iniziano con dsh1. o dsh2. sono per l’app Android.",
|
|
4207
|
+
invalidPairingCode: "Inserisci il codice di 43 caratteri dalla finestra di abbinamento attuale sul computer.",
|
|
4208
|
+
appKeyNotCode: "Questa è una chiave per l’app Android. Usala nell’app oppure copia il codice di abbinamento per il browser dal computer.",
|
|
4209
|
+
pairFailed: "Abbinamento non riuscito. Controlla il codice o generane uno nuovo sul computer.",
|
|
4210
|
+
pairingExpired: "Il codice non è valido o è scaduto. Genera un nuovo codice sul computer e riprova.",
|
|
4211
|
+
pairingLimited: "Troppi tentativi di abbinamento. Attendi un momento e riprova.",
|
|
4212
|
+
deviceLimit: "È stato raggiunto il limite di dispositivi abbinati. Rimuovi un dispositivo inutilizzato e genera un nuovo codice sul computer.",
|
|
4213
|
+
unavailable: "Il computer non è disponibile. Controlla la connessione e riprova.",
|
|
3250
4214
|
reconnectTitle: "Riconnetti accesso mobile DSH",
|
|
3251
4215
|
reconnectHeading: "Riconnetti questo dispositivo",
|
|
3252
4216
|
restoring: "Ripristino della sessione sicura…",
|
|
@@ -3285,7 +4249,8 @@ function renderPairPage(locale) {
|
|
|
3285
4249
|
<main>
|
|
3286
4250
|
<h1>${copy.pairHeading}</h1>
|
|
3287
4251
|
<form id="pair-form">
|
|
3288
|
-
<label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
4252
|
+
<label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" autocapitalize="off" spellcheck="false" aria-describedby="pair-token-hint pair-status" required></label>
|
|
4253
|
+
<p id="pair-token-hint">${copy.pairingCodeHint}</p>
|
|
3289
4254
|
<label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
3290
4255
|
<button type="submit">${copy.pair}</button>
|
|
3291
4256
|
<output id="pair-status" aria-live="polite"></output>
|
|
@@ -3303,27 +4268,54 @@ function renderPairScript(locale) {
|
|
|
3303
4268
|
const token = document.getElementById('pair-token')
|
|
3304
4269
|
const label = document.getElementById('device-label')
|
|
3305
4270
|
const status = document.getElementById('pair-status')
|
|
4271
|
+
const submit = form.querySelector('button[type="submit"]')
|
|
4272
|
+
let submitting = false
|
|
3306
4273
|
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
3307
4274
|
const supplied = fragment.get('token')
|
|
3308
4275
|
history.replaceState(null, '', location.pathname)
|
|
3309
4276
|
if (supplied) token.value = supplied
|
|
4277
|
+
token.addEventListener('input', () => {
|
|
4278
|
+
token.removeAttribute('aria-invalid')
|
|
4279
|
+
if (!submitting) status.value = ''
|
|
4280
|
+
})
|
|
3310
4281
|
form.addEventListener('submit', async (event) => {
|
|
3311
4282
|
event.preventDefault()
|
|
4283
|
+
if (submitting) return
|
|
4284
|
+
const code = token.value.trim()
|
|
4285
|
+
token.removeAttribute('aria-invalid')
|
|
4286
|
+
if (/^dsh[12]\\./u.test(code) || !/^[A-Za-z0-9_-]{43}$/u.test(code)) {
|
|
4287
|
+
status.value = /^dsh[12]\\./u.test(code)
|
|
4288
|
+
? ${JSON.stringify(copy.appKeyNotCode)} : ${JSON.stringify(copy.invalidPairingCode)}
|
|
4289
|
+
token.setAttribute('aria-invalid', 'true')
|
|
4290
|
+
token.focus()
|
|
4291
|
+
return
|
|
4292
|
+
}
|
|
4293
|
+
submitting = true
|
|
4294
|
+
submit.disabled = true
|
|
3312
4295
|
status.value = ${JSON.stringify(copy.pairing)}
|
|
3313
4296
|
try {
|
|
3314
4297
|
const response = await fetch('/mobile-access/auth/pair', {
|
|
3315
4298
|
method: 'POST',
|
|
3316
4299
|
credentials: 'same-origin',
|
|
3317
4300
|
headers: { 'content-type': 'application/json' },
|
|
3318
|
-
body: JSON.stringify({ token:
|
|
4301
|
+
body: JSON.stringify({ token: code, label: label.value || undefined }),
|
|
3319
4302
|
})
|
|
3320
4303
|
if (!response.ok) {
|
|
3321
|
-
status.value =
|
|
4304
|
+
if (token.value.trim() !== code) { status.value = ''; return }
|
|
4305
|
+
status.value = response.status === 401 ? ${JSON.stringify(copy.pairingExpired)}
|
|
4306
|
+
: response.status === 429 ? ${JSON.stringify(copy.pairingLimited)}
|
|
4307
|
+
: response.status === 409 ? ${JSON.stringify(copy.deviceLimit)}
|
|
4308
|
+
: response.status >= 500 ? ${JSON.stringify(copy.unavailable)}
|
|
4309
|
+
: ${JSON.stringify(copy.pairFailed)}
|
|
4310
|
+
if (response.status === 401) token.setAttribute('aria-invalid', 'true')
|
|
3322
4311
|
return
|
|
3323
4312
|
}
|
|
3324
4313
|
location.replace('/')
|
|
3325
4314
|
} catch {
|
|
3326
|
-
status.value = ${JSON.stringify(copy.unavailable)}
|
|
4315
|
+
status.value = token.value.trim() === code ? ${JSON.stringify(copy.unavailable)} : ''
|
|
4316
|
+
} finally {
|
|
4317
|
+
submitting = false
|
|
4318
|
+
submit.disabled = false
|
|
3327
4319
|
}
|
|
3328
4320
|
})
|
|
3329
4321
|
})()
|
|
@@ -3936,6 +4928,7 @@ function sanitizeRequestHeaders(request, upstream) {
|
|
|
3936
4928
|
"if-match",
|
|
3937
4929
|
"if-modified-since",
|
|
3938
4930
|
"if-none-match",
|
|
4931
|
+
"if-range",
|
|
3939
4932
|
"if-unmodified-since",
|
|
3940
4933
|
"range",
|
|
3941
4934
|
"user-agent"
|
|
@@ -4015,13 +5008,13 @@ function isCompressibleContentType(value) {
|
|
|
4015
5008
|
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
4016
5009
|
return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
|
|
4017
5010
|
}
|
|
4018
|
-
function
|
|
5011
|
+
function canCompressResponse(request, response) {
|
|
4019
5012
|
const pathname = request.url?.split("?", 1)[0] ?? "";
|
|
4020
|
-
return request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 &&
|
|
5013
|
+
return (request.method === "GET" || request.method === "HEAD") && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && isCompressibleContentType(response.headers["content-type"]);
|
|
4021
5014
|
}
|
|
4022
5015
|
function revisionedStaticCacheControl(request, statusCode) {
|
|
4023
5016
|
if (request.method !== "GET" && request.method !== "HEAD") return void 0;
|
|
4024
|
-
if (statusCode !== 200) return void 0;
|
|
5017
|
+
if (statusCode !== 200 && statusCode !== 304) return void 0;
|
|
4025
5018
|
let target;
|
|
4026
5019
|
try {
|
|
4027
5020
|
target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
|
|
@@ -4832,7 +5825,7 @@ var MobileAccessGateway = class {
|
|
|
4832
5825
|
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
4833
5826
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
4834
5827
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
4835
|
-
const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
5828
|
+
const customAsset = request.method === "GET" || request.method === "HEAD" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
4836
5829
|
file: this.config.customCssFile,
|
|
4837
5830
|
contentType: "text/css; charset=utf-8",
|
|
4838
5831
|
fallback: CUSTOM_STYLE_FALLBACK
|
|
@@ -4941,23 +5934,23 @@ var MobileAccessGateway = class {
|
|
|
4941
5934
|
body = Buffer.from(customAsset.fallback);
|
|
4942
5935
|
}
|
|
4943
5936
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
4944
|
-
const etag = createHash("sha256").update(body).digest("hex")
|
|
4945
|
-
const
|
|
4946
|
-
if (
|
|
5937
|
+
const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
|
|
5938
|
+
const responseHeaders = { ETag: etag };
|
|
5939
|
+
if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
|
|
5940
|
+
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
4947
5941
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
4948
|
-
response.writeHead(304);
|
|
5942
|
+
response.writeHead(304, responseHeaders);
|
|
4949
5943
|
response.end();
|
|
4950
5944
|
return;
|
|
4951
5945
|
}
|
|
4952
5946
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
4953
|
-
|
|
5947
|
+
response.writeHead(200, {
|
|
5948
|
+
...responseHeaders,
|
|
4954
5949
|
"Content-Type": customAsset.contentType,
|
|
4955
|
-
"Content-Length": body.byteLength
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
response.writeHead(200, responseHeaders);
|
|
4960
|
-
response.end(body);
|
|
5950
|
+
"Content-Length": body.byteLength
|
|
5951
|
+
});
|
|
5952
|
+
if (request.method === "HEAD") response.end();
|
|
5953
|
+
else response.end(body);
|
|
4961
5954
|
return;
|
|
4962
5955
|
} finally {
|
|
4963
5956
|
operation.release();
|
|
@@ -4992,7 +5985,8 @@ var MobileAccessGateway = class {
|
|
|
4992
5985
|
}
|
|
4993
5986
|
const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
|
|
4994
5987
|
const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
|
|
4995
|
-
|
|
5988
|
+
const frontendDocument = target.decodedPathname === "/" || target.decodedPathname === "/index.html";
|
|
5989
|
+
if (request.method === "GET" && frontendDocument && acceptsHtml && !stockFrontend) {
|
|
4996
5990
|
await this.proxyMobileIndex(request, response, authorization);
|
|
4997
5991
|
return;
|
|
4998
5992
|
}
|
|
@@ -5032,10 +6026,10 @@ var MobileAccessGateway = class {
|
|
|
5032
6026
|
styleRevision
|
|
5033
6027
|
}
|
|
5034
6028
|
}));
|
|
5035
|
-
const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex")
|
|
5036
|
-
if (headerValue(request.headers, "if-none-match")
|
|
6029
|
+
const etag = `"${createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex")}"`;
|
|
6030
|
+
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
5037
6031
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
5038
|
-
response.writeHead(304);
|
|
6032
|
+
response.writeHead(304, { ETag: etag });
|
|
5039
6033
|
response.end();
|
|
5040
6034
|
return;
|
|
5041
6035
|
}
|
|
@@ -5058,9 +6052,10 @@ var MobileAccessGateway = class {
|
|
|
5058
6052
|
const operation = this.allocateRequest(authorization, response, {});
|
|
5059
6053
|
try {
|
|
5060
6054
|
const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal, generation) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal, generation) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal, generation);
|
|
5061
|
-
|
|
6055
|
+
const etag = `"${file.digest}"`;
|
|
6056
|
+
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
5062
6057
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
5063
|
-
response.writeHead(304);
|
|
6058
|
+
response.writeHead(304, { ETag: etag });
|
|
5064
6059
|
response.end();
|
|
5065
6060
|
return;
|
|
5066
6061
|
}
|
|
@@ -5069,7 +6064,7 @@ var MobileAccessGateway = class {
|
|
|
5069
6064
|
response.writeHead(200, {
|
|
5070
6065
|
"Content-Type": contentType,
|
|
5071
6066
|
"Content-Length": file.body.byteLength,
|
|
5072
|
-
ETag:
|
|
6067
|
+
ETag: etag
|
|
5073
6068
|
});
|
|
5074
6069
|
if (request.method === "HEAD") response.end();
|
|
5075
6070
|
else response.end(file.body);
|
|
@@ -5101,14 +6096,27 @@ var MobileAccessGateway = class {
|
|
|
5101
6096
|
signal: abort.signal,
|
|
5102
6097
|
deviceId: authorization.deviceId
|
|
5103
6098
|
}, generation);
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
6099
|
+
if (isPreparedJson(result)) {
|
|
6100
|
+
if (result.bytes.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
|
|
6101
|
+
if (!response.headersSent && !response.destroyed) {
|
|
6102
|
+
const body = Buffer.concat([result.bytes, Buffer.from("\n")]);
|
|
6103
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
6104
|
+
response.writeHead(200, {
|
|
6105
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
6106
|
+
"Content-Length": body.byteLength
|
|
6107
|
+
});
|
|
6108
|
+
response.end(body);
|
|
6109
|
+
}
|
|
6110
|
+
} else {
|
|
6111
|
+
let serialized;
|
|
6112
|
+
try {
|
|
6113
|
+
serialized = Buffer.from(JSON.stringify(result));
|
|
6114
|
+
} catch {
|
|
6115
|
+
throw new MobileExtensionError("extension_failed", "extension action failed", 500);
|
|
6116
|
+
}
|
|
6117
|
+
if (serialized.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
|
|
6118
|
+
sendJson(response, 200, result, this.tlsEnabled);
|
|
5109
6119
|
}
|
|
5110
|
-
if (serialized.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
|
|
5111
|
-
sendJson(response, 200, result, this.tlsEnabled);
|
|
5112
6120
|
} finally {
|
|
5113
6121
|
generationSignal?.removeEventListener("abort", onGenerationAbort);
|
|
5114
6122
|
abort.abort();
|
|
@@ -5163,36 +6171,41 @@ var MobileAccessGateway = class {
|
|
|
5163
6171
|
}
|
|
5164
6172
|
}
|
|
5165
6173
|
async sendExtensionResponse(response, result, head) {
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
6174
|
+
try {
|
|
6175
|
+
const status = result.status ?? 200;
|
|
6176
|
+
if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
|
|
6177
|
+
const contentType = result.contentType ?? "application/octet-stream";
|
|
6178
|
+
if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
|
|
6179
|
+
const safeHeaders = {};
|
|
6180
|
+
for (const [name, value] of Object.entries(result.headers ?? {})) {
|
|
6181
|
+
if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
|
|
6182
|
+
safeHeaders[name] = value;
|
|
6183
|
+
}
|
|
6184
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
6185
|
+
if (typeof result.body === "string" || result.body instanceof Uint8Array) {
|
|
6186
|
+
const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
|
|
6187
|
+
if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
|
|
6188
|
+
response.writeHead(status, {
|
|
6189
|
+
...safeHeaders,
|
|
6190
|
+
"Content-Type": contentType,
|
|
6191
|
+
"Content-Length": body.byteLength
|
|
6192
|
+
});
|
|
6193
|
+
if (head) response.end();
|
|
6194
|
+
else response.end(body);
|
|
6195
|
+
return;
|
|
6196
|
+
}
|
|
5179
6197
|
response.writeHead(status, {
|
|
5180
6198
|
...safeHeaders,
|
|
5181
|
-
"Content-Type": contentType
|
|
5182
|
-
"Content-Length": body.byteLength
|
|
6199
|
+
"Content-Type": contentType
|
|
5183
6200
|
});
|
|
5184
|
-
if (head)
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
if (head) {
|
|
5193
|
-
result.body.destroy();
|
|
5194
|
-
response.end();
|
|
5195
|
-
return;
|
|
6201
|
+
if (head) {
|
|
6202
|
+
result.body.destroy();
|
|
6203
|
+
response.end();
|
|
6204
|
+
return;
|
|
6205
|
+
}
|
|
6206
|
+
} catch (error) {
|
|
6207
|
+
disposeLateExtensionResponse(result);
|
|
6208
|
+
throw error;
|
|
5196
6209
|
}
|
|
5197
6210
|
await pipeline(result.body, new ByteLimitTransform(4194304), response);
|
|
5198
6211
|
}
|
|
@@ -5371,7 +6384,7 @@ var MobileAccessGateway = class {
|
|
|
5371
6384
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
5372
6385
|
port: Number(this.config.upstreamOrigin.port),
|
|
5373
6386
|
method: "GET",
|
|
5374
|
-
path: "/",
|
|
6387
|
+
path: incoming?.url ?? "/",
|
|
5375
6388
|
headers,
|
|
5376
6389
|
agent: false
|
|
5377
6390
|
});
|
|
@@ -5608,7 +6621,7 @@ var MobileAccessGateway = class {
|
|
|
5608
6621
|
const assembled = stored.body;
|
|
5609
6622
|
if (assembled === void 0) throw new HttpError(502, "upstream_unavailable");
|
|
5610
6623
|
const body = compressed ? stored.gzipBody ??= await gzipBuffer(assembled) : assembled;
|
|
5611
|
-
const etag = compressed ? `${stored.etag}-gzip` : stored.etag
|
|
6624
|
+
const etag = `"${compressed ? `${stored.etag}-gzip` : stored.etag}"`;
|
|
5612
6625
|
const headers = {
|
|
5613
6626
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
5614
6627
|
"Content-Length": body.byteLength,
|
|
@@ -5617,7 +6630,7 @@ var MobileAccessGateway = class {
|
|
|
5617
6630
|
};
|
|
5618
6631
|
if (compressed) headers["Content-Encoding"] = "gzip";
|
|
5619
6632
|
addVaryAcceptEncoding(headers);
|
|
5620
|
-
if (headerValue(request.headers, "if-none-match")
|
|
6633
|
+
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
5621
6634
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
5622
6635
|
response.writeHead(304, {
|
|
5623
6636
|
ETag: etag,
|
|
@@ -5864,16 +6877,28 @@ var MobileAccessGateway = class {
|
|
|
5864
6877
|
const statusCode = proxied.statusCode ?? 502;
|
|
5865
6878
|
const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
|
|
5866
6879
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
5867
|
-
const
|
|
6880
|
+
const compressible = canCompressResponse(request$2, proxied);
|
|
6881
|
+
const compressed = compressible && acceptsGzip(request$2.headers["accept-encoding"]);
|
|
6882
|
+
if (compressible) addVaryAcceptEncoding(headers);
|
|
5868
6883
|
if (compressed) {
|
|
5869
6884
|
delete headers["accept-ranges"];
|
|
5870
6885
|
delete headers["content-length"];
|
|
5871
|
-
|
|
6886
|
+
const etag = weakenEntityTag(proxied.headers.etag);
|
|
6887
|
+
if (etag === void 0) delete headers.etag;
|
|
6888
|
+
else headers.etag = etag;
|
|
5872
6889
|
headers["content-encoding"] = "gzip";
|
|
6890
|
+
} else if (statusCode === 304 && (request$2.method === "GET" || request$2.method === "HEAD") && request$2.headers.range === void 0 && (request$2.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request$2.url?.split("?", 1)[0]?.startsWith("/assets/") === true)) {
|
|
6891
|
+
if (proxied.headers["content-encoding"] === void 0 && acceptsGzip(request$2.headers["accept-encoding"])) {
|
|
6892
|
+
delete headers["content-length"];
|
|
6893
|
+
delete headers["accept-ranges"];
|
|
6894
|
+
const etag = weakenEntityTag(proxied.headers.etag);
|
|
6895
|
+
if (etag === void 0) delete headers.etag;
|
|
6896
|
+
else headers.etag = etag;
|
|
6897
|
+
}
|
|
5873
6898
|
addVaryAcceptEncoding(headers);
|
|
5874
6899
|
}
|
|
5875
6900
|
response.writeHead(statusCode, headers);
|
|
5876
|
-
await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
|
|
6901
|
+
await Promise.all([bodyDone, compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
|
|
5877
6902
|
} catch (error) {
|
|
5878
6903
|
holder.request?.destroy();
|
|
5879
6904
|
await bodyDone?.catch(() => void 0);
|
|
@@ -6436,6 +7461,286 @@ var MemoryDeviceStore = class {
|
|
|
6436
7461
|
}
|
|
6437
7462
|
};
|
|
6438
7463
|
//#endregion
|
|
7464
|
+
//#region src/managed-files.ts
|
|
7465
|
+
/** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
|
|
7466
|
+
async function assertManagedParents(root, target, prefix) {
|
|
7467
|
+
const parent = resolve(root);
|
|
7468
|
+
const candidate = resolve(target);
|
|
7469
|
+
const offset = relative(parent, candidate);
|
|
7470
|
+
if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error(`${prefix}_path_invalid`);
|
|
7471
|
+
const paths = [parent];
|
|
7472
|
+
let cursor = parent;
|
|
7473
|
+
if (offset !== "") for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
|
|
7474
|
+
cursor = join(cursor, segment);
|
|
7475
|
+
paths.push(cursor);
|
|
7476
|
+
}
|
|
7477
|
+
for (const path of paths) {
|
|
7478
|
+
let entry;
|
|
7479
|
+
try {
|
|
7480
|
+
entry = await lstat(path);
|
|
7481
|
+
} catch (error) {
|
|
7482
|
+
if (error.code === "ENOENT") continue;
|
|
7483
|
+
throw error;
|
|
7484
|
+
}
|
|
7485
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
/** Retry Windows handle contention while checking ownership before every mutation. */
|
|
7489
|
+
async function mutateManagedPath(validate, mutate) {
|
|
7490
|
+
let original;
|
|
7491
|
+
for (let attempt = 0;; attempt++) {
|
|
7492
|
+
await validate();
|
|
7493
|
+
try {
|
|
7494
|
+
await mutate();
|
|
7495
|
+
return;
|
|
7496
|
+
} catch (error) {
|
|
7497
|
+
const code = error.code;
|
|
7498
|
+
if (process.platform !== "win32" || code !== "EPERM" && code !== "EBUSY") throw error;
|
|
7499
|
+
if (attempt === 0) original = error;
|
|
7500
|
+
if (attempt === 7) throw original;
|
|
7501
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50 * 2 ** attempt, 400)));
|
|
7502
|
+
}
|
|
7503
|
+
}
|
|
7504
|
+
}
|
|
7505
|
+
/** Move only owned paths, retaining parent checks on every Windows retry. */
|
|
7506
|
+
async function renameManagedPath(root, source, destination, prefix) {
|
|
7507
|
+
if (resolve(source) === resolve(root) || resolve(destination) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
|
|
7508
|
+
await mutateManagedPath(async () => {
|
|
7509
|
+
await assertManagedParents(root, source, prefix);
|
|
7510
|
+
await assertManagedParents(root, destination, prefix);
|
|
7511
|
+
}, () => rename(source, destination));
|
|
7512
|
+
}
|
|
7513
|
+
/** Create a real owned directory; secure new directories without changing existing child ACLs. */
|
|
7514
|
+
async function ensureManagedDirectory(root, directory, prefix) {
|
|
7515
|
+
await assertManagedParents(root, directory, prefix);
|
|
7516
|
+
const created = await mkdir(directory, {
|
|
7517
|
+
recursive: true,
|
|
7518
|
+
mode: 448
|
|
7519
|
+
});
|
|
7520
|
+
const entry = await lstat(directory);
|
|
7521
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
|
|
7522
|
+
if (created !== void 0) await restrictPrivateFile(directory, 448);
|
|
7523
|
+
}
|
|
7524
|
+
/** Delete an owned child tree; remove links themselves and never delete the root. */
|
|
7525
|
+
async function removeManagedTree(root, target, prefix) {
|
|
7526
|
+
if (resolve(target) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
|
|
7527
|
+
await assertManagedParents(root, target, prefix);
|
|
7528
|
+
let entry;
|
|
7529
|
+
try {
|
|
7530
|
+
entry = await lstat(target);
|
|
7531
|
+
} catch (error) {
|
|
7532
|
+
if (error.code === "ENOENT") return;
|
|
7533
|
+
throw error;
|
|
7534
|
+
}
|
|
7535
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
7536
|
+
await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => unlink(target));
|
|
7537
|
+
return;
|
|
7538
|
+
}
|
|
7539
|
+
for (const name of await readdir(target)) await removeManagedTree(root, join(target, name), prefix);
|
|
7540
|
+
await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => rmdir(target));
|
|
7541
|
+
}
|
|
7542
|
+
/** Validate every selected child, then await all deletion work even when one fails. */
|
|
7543
|
+
async function removeManagedPaths(root, paths, prefix) {
|
|
7544
|
+
for (const path of paths) {
|
|
7545
|
+
if (resolve(path) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
|
|
7546
|
+
await assertManagedParents(root, path, prefix);
|
|
7547
|
+
}
|
|
7548
|
+
const errors = (await Promise.allSettled(paths.map((path) => removeManagedTree(root, path, prefix)))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
7549
|
+
if (errors.length === 1) throw errors[0];
|
|
7550
|
+
if (errors.length > 1) throw new AggregateError(errors, `${prefix}_cleanup_failed`);
|
|
7551
|
+
}
|
|
7552
|
+
/** Atomically replace one sensitive file beneath real owned directories. */
|
|
7553
|
+
async function writeManagedPrivateFile(root, file, body, prefix) {
|
|
7554
|
+
await ensureManagedDirectory(root, dirname(file), prefix);
|
|
7555
|
+
try {
|
|
7556
|
+
const entry = await lstat(file);
|
|
7557
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`${prefix}_config_target_invalid`);
|
|
7558
|
+
} catch (error) {
|
|
7559
|
+
if (error.code !== "ENOENT") throw error;
|
|
7560
|
+
}
|
|
7561
|
+
const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
7562
|
+
let failed = false;
|
|
7563
|
+
try {
|
|
7564
|
+
await writeFile(temporary, body, {
|
|
7565
|
+
encoding: "utf8",
|
|
7566
|
+
flag: "wx",
|
|
7567
|
+
mode: 384
|
|
7568
|
+
});
|
|
7569
|
+
await restrictPrivateFile(temporary);
|
|
7570
|
+
await renameManagedPath(root, temporary, file, prefix);
|
|
7571
|
+
} catch (error) {
|
|
7572
|
+
failed = true;
|
|
7573
|
+
throw error;
|
|
7574
|
+
} finally {
|
|
7575
|
+
try {
|
|
7576
|
+
await removeManagedTree(root, temporary, prefix);
|
|
7577
|
+
} catch (error) {
|
|
7578
|
+
if (!failed) throw error;
|
|
7579
|
+
}
|
|
7580
|
+
}
|
|
7581
|
+
}
|
|
7582
|
+
/** Promote a verified directory, restoring the previous version if promotion fails. */
|
|
7583
|
+
async function replaceManagedDirectory(root, target, candidate, prefix, operations = {}) {
|
|
7584
|
+
if (resolve(target) === resolve(root) || resolve(candidate) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
|
|
7585
|
+
const move = async (source, destination) => {
|
|
7586
|
+
await assertManagedParents(root, source, prefix);
|
|
7587
|
+
await assertManagedParents(root, destination, prefix);
|
|
7588
|
+
if (operations.move === void 0) await renameManagedPath(root, source, destination, prefix);
|
|
7589
|
+
else await operations.move(source, destination);
|
|
7590
|
+
};
|
|
7591
|
+
const remove = operations.remove ?? ((path) => removeManagedTree(root, path, prefix));
|
|
7592
|
+
const backup = join(dirname(target), `.${basename(target)}.previous-${randomBytes(12).toString("hex")}`);
|
|
7593
|
+
let previous = false;
|
|
7594
|
+
let promoted = false;
|
|
7595
|
+
try {
|
|
7596
|
+
await assertManagedParents(root, join(candidate, "publication-check"), prefix);
|
|
7597
|
+
await assertManagedParents(root, target, prefix);
|
|
7598
|
+
try {
|
|
7599
|
+
const entry = await lstat(target);
|
|
7600
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
|
|
7601
|
+
await move(target, backup);
|
|
7602
|
+
previous = true;
|
|
7603
|
+
} catch (error) {
|
|
7604
|
+
if (error.code !== "ENOENT") throw error;
|
|
7605
|
+
}
|
|
7606
|
+
try {
|
|
7607
|
+
await move(candidate, target);
|
|
7608
|
+
promoted = true;
|
|
7609
|
+
} catch (error) {
|
|
7610
|
+
if (previous) try {
|
|
7611
|
+
await move(backup, target);
|
|
7612
|
+
previous = false;
|
|
7613
|
+
} catch (restoreError) {
|
|
7614
|
+
throw new AggregateError([error, restoreError], `${prefix}_component_replace_failed`);
|
|
7615
|
+
}
|
|
7616
|
+
throw error;
|
|
7617
|
+
}
|
|
7618
|
+
} finally {
|
|
7619
|
+
try {
|
|
7620
|
+
await remove(candidate);
|
|
7621
|
+
} catch (_error) {
|
|
7622
|
+
process.emitWarning(`${prefix} candidate cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
|
|
7623
|
+
}
|
|
7624
|
+
if (promoted && previous) try {
|
|
7625
|
+
await remove(backup);
|
|
7626
|
+
} catch (_error) {
|
|
7627
|
+
process.emitWarning(`${prefix} previous component cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
|
|
7628
|
+
}
|
|
7629
|
+
}
|
|
7630
|
+
}
|
|
7631
|
+
//#endregion
|
|
7632
|
+
//#region src/component-download.ts
|
|
7633
|
+
/**
|
|
7634
|
+
* Hosts a pinned GitHub release download may redirect to.
|
|
7635
|
+
*
|
|
7636
|
+
* `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
|
|
7637
|
+
* answers 302 with a signed URL on a release-asset host. A fetch configured with
|
|
7638
|
+
* `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
|
|
7639
|
+
* before a single byte is transferred, which is exactly how the cloudflared component could never
|
|
7640
|
+
* install. Following one validated hop keeps the origin restriction while allowing the shape
|
|
7641
|
+
* every GitHub release download uses.
|
|
7642
|
+
*/
|
|
7643
|
+
const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
|
7644
|
+
/** Release an unused body without replacing a download or validation failure. */
|
|
7645
|
+
async function discardBody(body) {
|
|
7646
|
+
if (body === null) return;
|
|
7647
|
+
try {
|
|
7648
|
+
await body.cancel();
|
|
7649
|
+
} catch (_error) {}
|
|
7650
|
+
}
|
|
7651
|
+
/** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
|
|
7652
|
+
async function followValidatedRedirect(response, request) {
|
|
7653
|
+
let target;
|
|
7654
|
+
try {
|
|
7655
|
+
const location = response.headers.get("location");
|
|
7656
|
+
if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
|
|
7657
|
+
try {
|
|
7658
|
+
target = new URL(location, request.url);
|
|
7659
|
+
} catch {
|
|
7660
|
+
throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
|
|
7661
|
+
}
|
|
7662
|
+
const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
|
|
7663
|
+
if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
|
|
7664
|
+
} finally {
|
|
7665
|
+
await discardBody(response.body);
|
|
7666
|
+
}
|
|
7667
|
+
return fetch(target, {
|
|
7668
|
+
redirect: "error",
|
|
7669
|
+
signal: request.signal
|
|
7670
|
+
});
|
|
7671
|
+
}
|
|
7672
|
+
/** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
|
|
7673
|
+
* real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
|
|
7674
|
+
* which is exactly the confusing "nothing happened" the user saw. */
|
|
7675
|
+
const DOWNLOAD_ATTEMPTS = 2;
|
|
7676
|
+
const RETRY_DELAY_MS = 750;
|
|
7677
|
+
/** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
|
|
7678
|
+
function isRetryable(error) {
|
|
7679
|
+
if (error instanceof TypeError) return true;
|
|
7680
|
+
if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
|
|
7681
|
+
return false;
|
|
7682
|
+
}
|
|
7683
|
+
function delay(ms) {
|
|
7684
|
+
return new Promise((resolve) => {
|
|
7685
|
+
setTimeout(resolve, ms);
|
|
7686
|
+
});
|
|
7687
|
+
}
|
|
7688
|
+
async function attemptDownload(request) {
|
|
7689
|
+
const first = await fetch(request.url, {
|
|
7690
|
+
redirect: "manual",
|
|
7691
|
+
signal: request.signal
|
|
7692
|
+
});
|
|
7693
|
+
const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
|
|
7694
|
+
let reader;
|
|
7695
|
+
let ended = false;
|
|
7696
|
+
try {
|
|
7697
|
+
if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
|
|
7698
|
+
const contentLength = response.headers.get("content-length");
|
|
7699
|
+
if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
7700
|
+
if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
7701
|
+
const bytes = new Uint8Array(request.expectedBytes);
|
|
7702
|
+
reader = response.body.getReader();
|
|
7703
|
+
let received = 0;
|
|
7704
|
+
while (true) {
|
|
7705
|
+
const chunk = await reader.read();
|
|
7706
|
+
if (chunk.done) {
|
|
7707
|
+
ended = true;
|
|
7708
|
+
break;
|
|
7709
|
+
}
|
|
7710
|
+
if (chunk.value.byteLength > bytes.byteLength - received) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
7711
|
+
bytes.set(chunk.value, received);
|
|
7712
|
+
received += chunk.value.byteLength;
|
|
7713
|
+
}
|
|
7714
|
+
if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
7715
|
+
return bytes;
|
|
7716
|
+
} finally {
|
|
7717
|
+
if (reader === void 0) await discardBody(response.body);
|
|
7718
|
+
else try {
|
|
7719
|
+
if (!ended) await discardBody(reader);
|
|
7720
|
+
} finally {
|
|
7721
|
+
reader.releaseLock();
|
|
7722
|
+
}
|
|
7723
|
+
}
|
|
7724
|
+
}
|
|
7725
|
+
/**
|
|
7726
|
+
* Download a pinned artifact, following at most one validated redirect, and check its exact length.
|
|
7727
|
+
* A transport failure is retried once; a length or redirect verdict is not. The caller still
|
|
7728
|
+
* verifies the SHA-256 before publishing anything.
|
|
7729
|
+
* @param request - the pinned URL, its exact byte length and the caller's error prefix.
|
|
7730
|
+
* @returns the artifact bytes.
|
|
7731
|
+
*/
|
|
7732
|
+
async function downloadPinnedArtifact(request) {
|
|
7733
|
+
let lastError;
|
|
7734
|
+
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
|
|
7735
|
+
return await attemptDownload(request);
|
|
7736
|
+
} catch (error) {
|
|
7737
|
+
lastError = error;
|
|
7738
|
+
if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
|
|
7739
|
+
await delay(RETRY_DELAY_MS);
|
|
7740
|
+
}
|
|
7741
|
+
throw lastError;
|
|
7742
|
+
}
|
|
7743
|
+
//#endregion
|
|
6439
7744
|
//#region src/frp-component.ts
|
|
6440
7745
|
const FRP_VERSION$1 = "0.70.1";
|
|
6441
7746
|
const MAX_ARCHIVE_ENTRIES$1 = 128;
|
|
@@ -6510,37 +7815,6 @@ async function regularFile$2(file) {
|
|
|
6510
7815
|
throw error;
|
|
6511
7816
|
}
|
|
6512
7817
|
}
|
|
6513
|
-
async function replaceDirectory(target, candidate) {
|
|
6514
|
-
const backup = `${target}.previous-${randomBytes(12).toString("hex")}`;
|
|
6515
|
-
let previous = false;
|
|
6516
|
-
try {
|
|
6517
|
-
try {
|
|
6518
|
-
await rename(target, backup);
|
|
6519
|
-
previous = true;
|
|
6520
|
-
} catch (error) {
|
|
6521
|
-
if (error.code !== "ENOENT") throw error;
|
|
6522
|
-
}
|
|
6523
|
-
try {
|
|
6524
|
-
await rename(candidate, target);
|
|
6525
|
-
} catch (error) {
|
|
6526
|
-
if (previous) try {
|
|
6527
|
-
await rename(backup, target);
|
|
6528
|
-
} catch (restoreError) {
|
|
6529
|
-
throw new AggregateError([error, restoreError], "frp_component_replace_failed");
|
|
6530
|
-
}
|
|
6531
|
-
throw error;
|
|
6532
|
-
}
|
|
6533
|
-
if (previous) await rm(backup, {
|
|
6534
|
-
recursive: true,
|
|
6535
|
-
force: true
|
|
6536
|
-
});
|
|
6537
|
-
} finally {
|
|
6538
|
-
await rm(candidate, {
|
|
6539
|
-
recursive: true,
|
|
6540
|
-
force: true
|
|
6541
|
-
});
|
|
6542
|
-
}
|
|
6543
|
-
}
|
|
6544
7818
|
function sha256$2(bytes) {
|
|
6545
7819
|
return createHash("sha256").update(bytes).digest("hex");
|
|
6546
7820
|
}
|
|
@@ -6597,39 +7871,12 @@ async function defaultExtractArtifact$2(archive, destination, executableName) {
|
|
|
6597
7871
|
await copyFile(extracted, join(destination, executableName));
|
|
6598
7872
|
}
|
|
6599
7873
|
async function defaultFetchArtifact$2(artifact, signal) {
|
|
6600
|
-
|
|
6601
|
-
|
|
7874
|
+
return downloadPinnedArtifact({
|
|
7875
|
+
url: artifact.downloadUrl,
|
|
7876
|
+
expectedBytes: artifact.downloadBytes,
|
|
7877
|
+
errorPrefix: "frp",
|
|
6602
7878
|
signal
|
|
6603
7879
|
});
|
|
6604
|
-
if (!response.ok) throw new Error(`frp_download_http_${String(response.status)}`);
|
|
6605
|
-
const finalUrl = new URL(response.url);
|
|
6606
|
-
const officialHost = finalUrl.hostname === "github.com" || finalUrl.hostname.endsWith(".githubusercontent.com");
|
|
6607
|
-
if (finalUrl.protocol !== "https:" || !officialHost) throw new Error("frp_download_origin_invalid");
|
|
6608
|
-
const lengthHeader = response.headers.get("content-length");
|
|
6609
|
-
const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
|
|
6610
|
-
if (declaredLength !== void 0 && (!Number.isFinite(declaredLength) || declaredLength !== artifact.downloadBytes)) throw new Error("frp_download_size_mismatch");
|
|
6611
|
-
if (response.body === null) throw new Error("frp_download_empty");
|
|
6612
|
-
const chunks = [];
|
|
6613
|
-
let received = 0;
|
|
6614
|
-
const reader = response.body.getReader();
|
|
6615
|
-
while (true) {
|
|
6616
|
-
const result = await reader.read();
|
|
6617
|
-
if (result.done) break;
|
|
6618
|
-
received += result.value.byteLength;
|
|
6619
|
-
if (received > artifact.downloadBytes) {
|
|
6620
|
-
await reader.cancel();
|
|
6621
|
-
throw new Error("frp_download_size_mismatch");
|
|
6622
|
-
}
|
|
6623
|
-
chunks.push(result.value);
|
|
6624
|
-
}
|
|
6625
|
-
if (received !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
|
|
6626
|
-
const bytes = new Uint8Array(received);
|
|
6627
|
-
let offset = 0;
|
|
6628
|
-
for (const chunk of chunks) {
|
|
6629
|
-
bytes.set(chunk, offset);
|
|
6630
|
-
offset += chunk.byteLength;
|
|
6631
|
-
}
|
|
6632
|
-
return bytes;
|
|
6633
7880
|
}
|
|
6634
7881
|
async function defaultInspectExecutable(executable) {
|
|
6635
7882
|
return (await runCapture$1(executable, ["--version"])).trim();
|
|
@@ -6641,6 +7888,7 @@ var FrpComponentManager = class {
|
|
|
6641
7888
|
componentStorage;
|
|
6642
7889
|
logRoot;
|
|
6643
7890
|
stagingRoot;
|
|
7891
|
+
stateDirectory;
|
|
6644
7892
|
artifact;
|
|
6645
7893
|
fetchArtifact;
|
|
6646
7894
|
extractArtifact;
|
|
@@ -6650,8 +7898,9 @@ var FrpComponentManager = class {
|
|
|
6650
7898
|
errorCode;
|
|
6651
7899
|
queue = Promise.resolve();
|
|
6652
7900
|
constructor(options) {
|
|
7901
|
+
if (!isAbsolute(options.stateDirectory)) throw new Error("frp state directory must be absolute");
|
|
6653
7902
|
const stateDirectory = resolve(options.stateDirectory);
|
|
6654
|
-
|
|
7903
|
+
this.stateDirectory = stateDirectory;
|
|
6655
7904
|
const platform = options.platform ?? process.platform;
|
|
6656
7905
|
const arch = options.arch ?? process.arch;
|
|
6657
7906
|
this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
|
|
@@ -6672,13 +7921,19 @@ var FrpComponentManager = class {
|
|
|
6672
7921
|
}
|
|
6673
7922
|
/** Inspect the managed executable without relying on global FRP installations. */
|
|
6674
7923
|
async initialize() {
|
|
6675
|
-
this.installed =
|
|
6676
|
-
this.installedBytes =
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
this.
|
|
6680
|
-
|
|
7924
|
+
this.installed = false;
|
|
7925
|
+
this.installedBytes = 0;
|
|
7926
|
+
this.errorCode = void 0;
|
|
7927
|
+
try {
|
|
7928
|
+
await assertManagedParents(this.stateDirectory, this.executable, "frp");
|
|
7929
|
+
this.installed = await regularFile$2(this.executable);
|
|
7930
|
+
this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
|
|
7931
|
+
if (this.installed) {
|
|
7932
|
+
if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
7933
|
+
}
|
|
7934
|
+
} catch (_error) {
|
|
6681
7935
|
this.installed = false;
|
|
7936
|
+
this.installedBytes = 0;
|
|
6682
7937
|
this.errorCode = "frp_component_invalid";
|
|
6683
7938
|
}
|
|
6684
7939
|
}
|
|
@@ -6701,10 +7956,8 @@ var FrpComponentManager = class {
|
|
|
6701
7956
|
return this.enqueue(async () => {
|
|
6702
7957
|
const artifact = this.artifact;
|
|
6703
7958
|
if (artifact === void 0) throw new Error("frp_component_unsupported");
|
|
6704
|
-
await
|
|
6705
|
-
|
|
6706
|
-
mode: 448
|
|
6707
|
-
});
|
|
7959
|
+
await assertManagedParents(this.stateDirectory, this.executable, "frp");
|
|
7960
|
+
await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "frp");
|
|
6708
7961
|
const staging = await mkdtemp(join(this.stagingRoot, "install-"));
|
|
6709
7962
|
try {
|
|
6710
7963
|
const controller = new AbortController();
|
|
@@ -6731,42 +7984,36 @@ var FrpComponentManager = class {
|
|
|
6731
7984
|
await chmod(extracted, 448);
|
|
6732
7985
|
if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
|
|
6733
7986
|
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
6734
|
-
await
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
7987
|
+
await ensureManagedDirectory(this.stateDirectory, candidate, "frp");
|
|
7988
|
+
try {
|
|
7989
|
+
const candidateExecutable = join(candidate, artifact.executableName);
|
|
7990
|
+
await copyFile(extracted, candidateExecutable);
|
|
7991
|
+
await chmod(candidateExecutable, 448);
|
|
7992
|
+
await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "frp");
|
|
7993
|
+
} catch (error) {
|
|
7994
|
+
try {
|
|
7995
|
+
await removeManagedTree(this.stateDirectory, candidate, "frp");
|
|
7996
|
+
} catch (_cleanupError) {
|
|
7997
|
+
process.emitWarning("FRP candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
|
|
7998
|
+
}
|
|
7999
|
+
throw error;
|
|
8000
|
+
}
|
|
6742
8001
|
this.installed = true;
|
|
6743
8002
|
this.installedBytes = (await stat(this.executable)).size;
|
|
6744
8003
|
this.errorCode = void 0;
|
|
6745
8004
|
} finally {
|
|
6746
|
-
await
|
|
6747
|
-
recursive: true,
|
|
6748
|
-
force: true
|
|
6749
|
-
});
|
|
8005
|
+
await removeManagedTree(this.stateDirectory, staging, "frp");
|
|
6750
8006
|
}
|
|
6751
8007
|
});
|
|
6752
8008
|
}
|
|
6753
8009
|
/** Remove all FRP executable, staging, and log files owned by DSH Mobile. */
|
|
6754
8010
|
purge() {
|
|
6755
8011
|
return this.enqueue(async () => {
|
|
6756
|
-
await
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
rm(this.logRoot, {
|
|
6762
|
-
recursive: true,
|
|
6763
|
-
force: true
|
|
6764
|
-
}),
|
|
6765
|
-
rm(this.stagingRoot, {
|
|
6766
|
-
recursive: true,
|
|
6767
|
-
force: true
|
|
6768
|
-
})
|
|
6769
|
-
]);
|
|
8012
|
+
await removeManagedPaths(this.stateDirectory, [
|
|
8013
|
+
this.componentRoot,
|
|
8014
|
+
this.logRoot,
|
|
8015
|
+
this.stagingRoot
|
|
8016
|
+
], "frp");
|
|
6770
8017
|
this.installed = false;
|
|
6771
8018
|
this.installedBytes = 0;
|
|
6772
8019
|
this.errorCode = void 0;
|
|
@@ -7188,34 +8435,9 @@ function createFrpServerTemplate(settings) {
|
|
|
7188
8435
|
entryTls: resolveFrpEntryTls(settings)
|
|
7189
8436
|
});
|
|
7190
8437
|
}
|
|
7191
|
-
async function atomicPrivateWrite$2(file, body) {
|
|
7192
|
-
const directory = dirname(file);
|
|
7193
|
-
await mkdir(directory, {
|
|
7194
|
-
recursive: true,
|
|
7195
|
-
mode: 448
|
|
7196
|
-
});
|
|
7197
|
-
try {
|
|
7198
|
-
const current = await lstat(file);
|
|
7199
|
-
if (!current.isFile() || current.isSymbolicLink()) throw new Error("frp_config_target_invalid");
|
|
7200
|
-
} catch (error) {
|
|
7201
|
-
if (error.code !== "ENOENT") throw error;
|
|
7202
|
-
}
|
|
7203
|
-
const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
7204
|
-
try {
|
|
7205
|
-
await writeFile(temporary, body, {
|
|
7206
|
-
encoding: "utf8",
|
|
7207
|
-
flag: "wx",
|
|
7208
|
-
mode: 384
|
|
7209
|
-
});
|
|
7210
|
-
await rename(temporary, file);
|
|
7211
|
-
await restrictPrivateFile(file);
|
|
7212
|
-
} catch (error) {
|
|
7213
|
-
await rm(temporary, { force: true });
|
|
7214
|
-
throw error;
|
|
7215
|
-
}
|
|
7216
|
-
}
|
|
7217
8438
|
/** Owns private FRP settings and generation-specific frpc configuration. */
|
|
7218
8439
|
var FrpConfigStore = class {
|
|
8440
|
+
ownedRoot;
|
|
7219
8441
|
stateRoot;
|
|
7220
8442
|
settingsFile;
|
|
7221
8443
|
runtimeConfigFile;
|
|
@@ -7229,7 +8451,8 @@ var FrpConfigStore = class {
|
|
|
7229
8451
|
proxyName;
|
|
7230
8452
|
settingsValue;
|
|
7231
8453
|
errorCode;
|
|
7232
|
-
constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME) {
|
|
8454
|
+
constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME, ownedRoot = stateDirectory) {
|
|
8455
|
+
this.ownedRoot = ownedRoot;
|
|
7233
8456
|
if (!isAbsolute(stateDirectory)) throw new Error("frp config state directory must be absolute");
|
|
7234
8457
|
this.stateRoot = resolve(stateDirectory);
|
|
7235
8458
|
this.settingsFile = join(this.stateRoot, "settings.json");
|
|
@@ -7238,6 +8461,14 @@ var FrpConfigStore = class {
|
|
|
7238
8461
|
}
|
|
7239
8462
|
/** Load private settings while rejecting links, oversized files, and unknown fields. */
|
|
7240
8463
|
async initialize() {
|
|
8464
|
+
this.settingsValue = void 0;
|
|
8465
|
+
this.errorCode = void 0;
|
|
8466
|
+
try {
|
|
8467
|
+
await assertManagedParents(this.ownedRoot, this.settingsFile, "frp");
|
|
8468
|
+
} catch (_error) {
|
|
8469
|
+
this.errorCode = "frp_config_invalid";
|
|
8470
|
+
return;
|
|
8471
|
+
}
|
|
7241
8472
|
let entry;
|
|
7242
8473
|
try {
|
|
7243
8474
|
entry = await lstat(this.settingsFile);
|
|
@@ -7292,8 +8523,8 @@ var FrpConfigStore = class {
|
|
|
7292
8523
|
/** Atomically replace private FRP settings. */
|
|
7293
8524
|
async configure(value) {
|
|
7294
8525
|
const settings = parseFrpSettings(value);
|
|
7295
|
-
await
|
|
7296
|
-
await
|
|
8526
|
+
await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "frp");
|
|
8527
|
+
await removeManagedTree(this.ownedRoot, this.runtimeConfigFile, "frp");
|
|
7297
8528
|
this.settingsValue = settings;
|
|
7298
8529
|
this.errorCode = void 0;
|
|
7299
8530
|
return this.status();
|
|
@@ -7302,15 +8533,12 @@ var FrpConfigStore = class {
|
|
|
7302
8533
|
async writeRuntimeConfig(localPort) {
|
|
7303
8534
|
const settings = this.settingsValue;
|
|
7304
8535
|
if (settings === void 0) throw new Error("frp_config_missing");
|
|
7305
|
-
await
|
|
8536
|
+
await writeManagedPrivateFile(this.ownedRoot, this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName), "frp");
|
|
7306
8537
|
return this.runtimeConfigFile;
|
|
7307
8538
|
}
|
|
7308
8539
|
/** Remove only configuration files owned by the FRP provider. */
|
|
7309
8540
|
async purge() {
|
|
7310
|
-
await
|
|
7311
|
-
recursive: true,
|
|
7312
|
-
force: true
|
|
7313
|
-
});
|
|
8541
|
+
await removeManagedPaths(this.ownedRoot, [this.settingsFile, this.runtimeConfigFile], "frp");
|
|
7314
8542
|
this.settingsValue = void 0;
|
|
7315
8543
|
this.errorCode = void 0;
|
|
7316
8544
|
return this.status();
|
|
@@ -7995,7 +9223,7 @@ async function readManagedCa(setup) {
|
|
|
7995
9223
|
const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
7996
9224
|
return assertMatchingCa(certPem, keyPem);
|
|
7997
9225
|
}
|
|
7998
|
-
async function
|
|
9226
|
+
async function stagePrivateFile(file, contents) {
|
|
7999
9227
|
const directory = dirname(file);
|
|
8000
9228
|
await mkdir(directory, {
|
|
8001
9229
|
recursive: true,
|
|
@@ -8003,15 +9231,83 @@ async function atomicWrite(file, contents) {
|
|
|
8003
9231
|
});
|
|
8004
9232
|
const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
8005
9233
|
try {
|
|
8006
|
-
await writeFile(temporary,
|
|
9234
|
+
await writeFile(temporary, "", {
|
|
8007
9235
|
mode: 384,
|
|
8008
9236
|
flag: "wx"
|
|
8009
9237
|
});
|
|
8010
9238
|
await restrictPrivateFile(temporary);
|
|
9239
|
+
await writeFile(temporary, contents);
|
|
9240
|
+
return temporary;
|
|
9241
|
+
} catch (error) {
|
|
9242
|
+
await rm(temporary, { force: true });
|
|
9243
|
+
throw error;
|
|
9244
|
+
}
|
|
9245
|
+
}
|
|
9246
|
+
async function atomicWrite(file, contents) {
|
|
9247
|
+
const temporary = await stagePrivateFile(file, contents);
|
|
9248
|
+
try {
|
|
8011
9249
|
await rename(temporary, file);
|
|
8012
9250
|
await restrictPrivateFile(file);
|
|
8013
9251
|
} finally {
|
|
8014
|
-
await rm(temporary, { force: true });
|
|
9252
|
+
await rm(temporary, { force: true });
|
|
9253
|
+
}
|
|
9254
|
+
}
|
|
9255
|
+
const certificatePublications = /* @__PURE__ */ new Map();
|
|
9256
|
+
/** Stage both files before promotion and retain a private rollback copy until the pair is installed. */
|
|
9257
|
+
async function installServerCertificatePair(output, cert, key) {
|
|
9258
|
+
const identity = `${output.certFile}\0${output.keyFile}`;
|
|
9259
|
+
const publication = (certificatePublications.get(identity) ?? Promise.resolve()).catch(() => void 0).then(async () => {
|
|
9260
|
+
const files = [output.certFile, output.keyFile];
|
|
9261
|
+
const contents = [cert, key];
|
|
9262
|
+
const staged = [];
|
|
9263
|
+
const backups = [];
|
|
9264
|
+
let promoted = 0;
|
|
9265
|
+
let restored = true;
|
|
9266
|
+
const failures = [];
|
|
9267
|
+
try {
|
|
9268
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
9269
|
+
const file = files[index];
|
|
9270
|
+
staged.push(await stagePrivateFile(file, contents[index]));
|
|
9271
|
+
let old;
|
|
9272
|
+
try {
|
|
9273
|
+
const metadata = await lstat(file);
|
|
9274
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("managed TLS output is not a regular file");
|
|
9275
|
+
old = await readFile(file);
|
|
9276
|
+
} catch (error) {
|
|
9277
|
+
if (error.code !== "ENOENT") throw error;
|
|
9278
|
+
}
|
|
9279
|
+
backups.push(old === void 0 ? void 0 : await stagePrivateFile(file, old));
|
|
9280
|
+
}
|
|
9281
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
9282
|
+
await rename(staged[index], files[index]);
|
|
9283
|
+
promoted += 1;
|
|
9284
|
+
}
|
|
9285
|
+
} catch (error) {
|
|
9286
|
+
failures.push(error);
|
|
9287
|
+
for (let index = promoted - 1; index >= 0; index -= 1) try {
|
|
9288
|
+
const backup = backups[index];
|
|
9289
|
+
if (backup === void 0) await rm(files[index], { force: true });
|
|
9290
|
+
else await rename(backup, files[index]);
|
|
9291
|
+
} catch (restoreError) {
|
|
9292
|
+
restored = false;
|
|
9293
|
+
failures.push(restoreError);
|
|
9294
|
+
}
|
|
9295
|
+
} finally {
|
|
9296
|
+
const cleanup = await Promise.allSettled(staged.map((file) => rm(file, { force: true })));
|
|
9297
|
+
for (const result of cleanup) if (result.status === "rejected") failures.push(result.reason);
|
|
9298
|
+
if (restored) {
|
|
9299
|
+
const backupCleanup = await Promise.allSettled(backups.filter((file) => file !== void 0).map((file) => rm(file, { force: true })));
|
|
9300
|
+
for (const result of backupCleanup) if (result.status === "rejected") failures.push(result.reason);
|
|
9301
|
+
}
|
|
9302
|
+
}
|
|
9303
|
+
if (failures.length > 1) throw new AggregateError(failures, "managed TLS certificate replacement, rollback or cleanup failed");
|
|
9304
|
+
if (failures.length === 1) throw failures[0];
|
|
9305
|
+
});
|
|
9306
|
+
certificatePublications.set(identity, publication);
|
|
9307
|
+
try {
|
|
9308
|
+
await publication;
|
|
9309
|
+
} finally {
|
|
9310
|
+
if (certificatePublications.get(identity) === publication) certificatePublications.delete(identity);
|
|
8015
9311
|
}
|
|
8016
9312
|
}
|
|
8017
9313
|
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
@@ -8128,7 +9424,7 @@ async function issueServerCertificate(authority, target, output, lifetimeDays =
|
|
|
8128
9424
|
}
|
|
8129
9425
|
]
|
|
8130
9426
|
});
|
|
8131
|
-
await
|
|
9427
|
+
await installServerCertificatePair(output, server.cert, server.private);
|
|
8132
9428
|
}
|
|
8133
9429
|
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
8134
9430
|
async function refreshManagedServerCertificate(setup, address) {
|
|
@@ -8197,25 +9493,11 @@ async function readIngressIdentity(file) {
|
|
|
8197
9493
|
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
8198
9494
|
}
|
|
8199
9495
|
}
|
|
8200
|
-
async function writeIngressIdentity(file, fingerprint) {
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
caFingerprint: fingerprint
|
|
8206
|
-
})}\n`, {
|
|
8207
|
-
flag: "wx",
|
|
8208
|
-
mode: 384
|
|
8209
|
-
});
|
|
8210
|
-
await rename(temporary, file);
|
|
8211
|
-
await restrictPrivateFile(file);
|
|
8212
|
-
} finally {
|
|
8213
|
-
try {
|
|
8214
|
-
await unlink(temporary);
|
|
8215
|
-
} catch (error) {
|
|
8216
|
-
if (error.code !== "ENOENT") throw error;
|
|
8217
|
-
}
|
|
8218
|
-
}
|
|
9496
|
+
async function writeIngressIdentity(file, fingerprint, ownedRoot) {
|
|
9497
|
+
await writeManagedPrivateFile(ownedRoot, file, `${JSON.stringify({
|
|
9498
|
+
version: 1,
|
|
9499
|
+
caFingerprint: fingerprint
|
|
9500
|
+
})}\n`, "frp_ingress");
|
|
8219
9501
|
}
|
|
8220
9502
|
function coversPublicIp(certificate, address) {
|
|
8221
9503
|
return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
|
|
@@ -8227,9 +9509,11 @@ function coversPublicIp(certificate, address) {
|
|
|
8227
9509
|
* replaced silently. The leaf is re-signed when expiring or no longer valid for
|
|
8228
9510
|
* the public IPv4, and the running gateway reloads it after a renewal check.
|
|
8229
9511
|
*/
|
|
8230
|
-
async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
|
|
9512
|
+
async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint, ownedRoot = dirname(stateFile)) {
|
|
8231
9513
|
if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
|
|
8232
9514
|
const paths = frpIngressPaths(stateFile);
|
|
9515
|
+
await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
|
|
9516
|
+
await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
|
|
8233
9517
|
try {
|
|
8234
9518
|
const directory = await lstat(paths.directory);
|
|
8235
9519
|
if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
|
|
@@ -8246,10 +9530,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
|
|
|
8246
9530
|
if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
|
|
8247
9531
|
if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
|
|
8248
9532
|
if (previousFingerprint === void 0) {
|
|
8249
|
-
await
|
|
8250
|
-
recursive: true,
|
|
8251
|
-
mode: 448
|
|
8252
|
-
});
|
|
9533
|
+
await ensureManagedDirectory(ownedRoot, dirname(paths.statusFile), "frp_ingress");
|
|
8253
9534
|
try {
|
|
8254
9535
|
await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
|
|
8255
9536
|
flag: "wx",
|
|
@@ -8269,6 +9550,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
|
|
|
8269
9550
|
};
|
|
8270
9551
|
let ca;
|
|
8271
9552
|
try {
|
|
9553
|
+
await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
|
|
8272
9554
|
ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
|
|
8273
9555
|
} catch (error) {
|
|
8274
9556
|
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
@@ -8276,15 +9558,16 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
|
|
|
8276
9558
|
const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
8277
9559
|
if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
8278
9560
|
if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
|
|
8279
|
-
if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
|
|
9561
|
+
if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint, ownedRoot);
|
|
8280
9562
|
const publicHost = new URL(settings.publicOrigin).hostname;
|
|
8281
9563
|
let leaf;
|
|
9564
|
+
let matchingKey = false;
|
|
8282
9565
|
try {
|
|
8283
9566
|
leaf = new X509Certificate(await readFile(paths.certFile));
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
9567
|
+
if (leafKeyExists) matchingKey = leaf.checkPrivateKey(createPrivateKey(await readFile(paths.keyFile)));
|
|
9568
|
+
} catch (_error) {}
|
|
9569
|
+
if (!(leaf !== void 0 && matchingKey && Date.parse(leaf.validFrom) <= now && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
|
|
9570
|
+
await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
|
|
8288
9571
|
await issueServerCertificate({
|
|
8289
9572
|
caCertFile: paths.caCertFile,
|
|
8290
9573
|
caKeyFile: paths.caKeyFile
|
|
@@ -8314,7 +9597,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
|
|
|
8314
9597
|
* No secret ever leaves this function: the CA is reported by fingerprint only,
|
|
8315
9598
|
* and the token is never read.
|
|
8316
9599
|
*/
|
|
8317
|
-
async function frpIngressSelfCheck(settings, stateFile) {
|
|
9600
|
+
async function frpIngressSelfCheck(settings, stateFile, ownedRoot = dirname(stateFile)) {
|
|
8318
9601
|
const paths = frpIngressPaths(stateFile);
|
|
8319
9602
|
const base = {
|
|
8320
9603
|
mode: resolveFrpMode(settings),
|
|
@@ -8331,6 +9614,8 @@ async function frpIngressSelfCheck(settings, stateFile) {
|
|
|
8331
9614
|
}
|
|
8332
9615
|
};
|
|
8333
9616
|
if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
|
|
9617
|
+
await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
|
|
9618
|
+
await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
|
|
8334
9619
|
let caFingerprint;
|
|
8335
9620
|
try {
|
|
8336
9621
|
caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
|
|
@@ -8345,8 +9630,10 @@ async function frpIngressSelfCheck(settings, stateFile) {
|
|
|
8345
9630
|
});
|
|
8346
9631
|
}
|
|
8347
9632
|
/** Remove only the five files owned by the local self-signed FRP entry. */
|
|
8348
|
-
async function purgeFrpIngressCertificates(stateFile) {
|
|
9633
|
+
async function purgeFrpIngressCertificates(stateFile, ownedRoot = dirname(stateFile)) {
|
|
8349
9634
|
const paths = frpIngressPaths(stateFile);
|
|
9635
|
+
await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
|
|
9636
|
+
await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
|
|
8350
9637
|
try {
|
|
8351
9638
|
const marker = await lstat(paths.statusFile);
|
|
8352
9639
|
if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
@@ -8372,6 +9659,7 @@ async function purgeFrpIngressCertificates(stateFile) {
|
|
|
8372
9659
|
paths.certFile,
|
|
8373
9660
|
paths.keyFile
|
|
8374
9661
|
]) {
|
|
9662
|
+
await assertManagedParents(ownedRoot, file, "frp_ingress");
|
|
8375
9663
|
let entry;
|
|
8376
9664
|
try {
|
|
8377
9665
|
entry = await lstat(file);
|
|
@@ -8383,6 +9671,7 @@ async function purgeFrpIngressCertificates(stateFile) {
|
|
|
8383
9671
|
await unlink(file);
|
|
8384
9672
|
}
|
|
8385
9673
|
try {
|
|
9674
|
+
await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
|
|
8386
9675
|
await rmdir(paths.directory);
|
|
8387
9676
|
} catch (error) {
|
|
8388
9677
|
if (error.code === "ENOENT") return;
|
|
@@ -8403,6 +9692,18 @@ function aggregateErrors(errors, message) {
|
|
|
8403
9692
|
if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
|
|
8404
9693
|
return new AggregateError(errors, message);
|
|
8405
9694
|
}
|
|
9695
|
+
/**
|
|
9696
|
+
* Prepare each inactive provider's disabled state and settle every write before reporting a failure.
|
|
9697
|
+
* @param selected - Provider whose startup must still read its durable preference.
|
|
9698
|
+
* @param stores - The same stores supplied to the corresponding provider controllers.
|
|
9699
|
+
* @returns Fresh disabled states for this initialization only; no permission-success cache is retained.
|
|
9700
|
+
*/
|
|
9701
|
+
async function prepareDisabledRemoteProviders(selected, stores) {
|
|
9702
|
+
const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== selected).map(async (provider) => [provider, await stores[provider].disableIfPresent()]));
|
|
9703
|
+
const failure = aggregateErrors(results.filter((result) => result.status === "rejected").map((result) => result.reason), "remote provider control preparation failed");
|
|
9704
|
+
if (failure !== void 0) throw failure;
|
|
9705
|
+
return new Map(results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []));
|
|
9706
|
+
}
|
|
8406
9707
|
/** Settle independent remote cleanup work before reporting any collected failure. */
|
|
8407
9708
|
async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
|
|
8408
9709
|
const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
|
|
@@ -8855,8 +10156,8 @@ var FrpController = class {
|
|
|
8855
10156
|
if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
|
|
8856
10157
|
}
|
|
8857
10158
|
/** Restore the remembered FRP switch without changing LAN or other providers. */
|
|
8858
|
-
async initialize() {
|
|
8859
|
-
const state = await this.options.store.load();
|
|
10159
|
+
async initialize(initialState) {
|
|
10160
|
+
const state = initialState ?? await this.options.store.load();
|
|
8860
10161
|
this.enabled = state.enabled;
|
|
8861
10162
|
this.initialized = true;
|
|
8862
10163
|
if (this.enabled) await this.start();
|
|
@@ -8878,12 +10179,12 @@ var FrpController = class {
|
|
|
8878
10179
|
if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
|
|
8879
10180
|
await this.enqueue(async () => {
|
|
8880
10181
|
if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
|
|
8881
|
-
if (!enabled) await this.stop();
|
|
8882
|
-
this.enabled = enabled;
|
|
8883
10182
|
await this.options.store.save({
|
|
8884
10183
|
version: 1,
|
|
8885
10184
|
enabled
|
|
8886
10185
|
});
|
|
10186
|
+
this.enabled = enabled;
|
|
10187
|
+
if (!enabled) await this.stop();
|
|
8887
10188
|
if (enabled) await this.start();
|
|
8888
10189
|
else this.publish({
|
|
8889
10190
|
enabled: false,
|
|
@@ -8897,11 +10198,11 @@ var FrpController = class {
|
|
|
8897
10198
|
if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
|
|
8898
10199
|
await this.enqueue(async () => {
|
|
8899
10200
|
if (!this.enabled) {
|
|
8900
|
-
this.enabled = true;
|
|
8901
10201
|
await this.options.store.save({
|
|
8902
10202
|
version: 1,
|
|
8903
10203
|
enabled: true
|
|
8904
10204
|
});
|
|
10205
|
+
this.enabled = true;
|
|
8905
10206
|
}
|
|
8906
10207
|
await this.stop();
|
|
8907
10208
|
await this.start();
|
|
@@ -8912,12 +10213,12 @@ var FrpController = class {
|
|
|
8912
10213
|
async reset() {
|
|
8913
10214
|
if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
|
|
8914
10215
|
await this.enqueue(async () => {
|
|
8915
|
-
await this.stop();
|
|
8916
|
-
this.enabled = false;
|
|
8917
10216
|
await this.options.store.save({
|
|
8918
10217
|
version: 1,
|
|
8919
10218
|
enabled: false
|
|
8920
10219
|
});
|
|
10220
|
+
this.enabled = false;
|
|
10221
|
+
await this.stop();
|
|
8921
10222
|
this.publish({
|
|
8922
10223
|
enabled: false,
|
|
8923
10224
|
state: "off"
|
|
@@ -9303,39 +10604,15 @@ function parseOriginSettings(value) {
|
|
|
9303
10604
|
allowedCidrs: validateOriginAllowedCidrs(record.allowedCidrs, listenHost)
|
|
9304
10605
|
});
|
|
9305
10606
|
}
|
|
9306
|
-
async function atomicPrivateWrite$1(file, body) {
|
|
9307
|
-
const directory = dirname(file);
|
|
9308
|
-
await mkdir(directory, {
|
|
9309
|
-
recursive: true,
|
|
9310
|
-
mode: 448
|
|
9311
|
-
});
|
|
9312
|
-
try {
|
|
9313
|
-
const current = await lstat(file);
|
|
9314
|
-
if (!current.isFile() || current.isSymbolicLink()) throw new Error("origin_config_target_invalid");
|
|
9315
|
-
} catch (error) {
|
|
9316
|
-
if (error.code !== "ENOENT") throw error;
|
|
9317
|
-
}
|
|
9318
|
-
const temporary = join(directory, "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
9319
|
-
try {
|
|
9320
|
-
await writeFile(temporary, body, {
|
|
9321
|
-
encoding: "utf8",
|
|
9322
|
-
flag: "wx",
|
|
9323
|
-
mode: 384
|
|
9324
|
-
});
|
|
9325
|
-
await rename(temporary, file);
|
|
9326
|
-
await restrictPrivateFile(file);
|
|
9327
|
-
} catch (error) {
|
|
9328
|
-
await rm(temporary, { force: true });
|
|
9329
|
-
throw error;
|
|
9330
|
-
}
|
|
9331
|
-
}
|
|
9332
10607
|
/** Owns only origin settings; shared paired-device storage is never removed. */
|
|
9333
10608
|
var OriginConfigStore = class {
|
|
10609
|
+
ownedRoot;
|
|
9334
10610
|
stateRoot;
|
|
9335
10611
|
settingsFile;
|
|
9336
10612
|
settingsValue;
|
|
9337
10613
|
errorCode;
|
|
9338
|
-
constructor(stateDirectory) {
|
|
10614
|
+
constructor(stateDirectory, ownedRoot = stateDirectory) {
|
|
10615
|
+
this.ownedRoot = ownedRoot;
|
|
9339
10616
|
if (!isAbsolute(stateDirectory)) throw new Error("origin config state directory must be absolute");
|
|
9340
10617
|
this.stateRoot = resolve(stateDirectory);
|
|
9341
10618
|
this.settingsFile = join(this.stateRoot, "settings.json");
|
|
@@ -9343,6 +10620,12 @@ var OriginConfigStore = class {
|
|
|
9343
10620
|
async initialize() {
|
|
9344
10621
|
this.settingsValue = void 0;
|
|
9345
10622
|
this.errorCode = void 0;
|
|
10623
|
+
try {
|
|
10624
|
+
await assertManagedParents(this.ownedRoot, this.settingsFile, "origin");
|
|
10625
|
+
} catch (_error) {
|
|
10626
|
+
this.errorCode = "origin_config_invalid";
|
|
10627
|
+
return;
|
|
10628
|
+
}
|
|
9346
10629
|
let entry;
|
|
9347
10630
|
try {
|
|
9348
10631
|
entry = await lstat(this.settingsFile);
|
|
@@ -9381,13 +10664,13 @@ var OriginConfigStore = class {
|
|
|
9381
10664
|
}
|
|
9382
10665
|
async configure(value) {
|
|
9383
10666
|
const settings = parseOriginSettings(value);
|
|
9384
|
-
await
|
|
10667
|
+
await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, JSON.stringify(settings) + "\n", "origin");
|
|
9385
10668
|
this.settingsValue = settings;
|
|
9386
10669
|
this.errorCode = void 0;
|
|
9387
10670
|
return this.status();
|
|
9388
10671
|
}
|
|
9389
10672
|
async purge() {
|
|
9390
|
-
await
|
|
10673
|
+
await removeManagedTree(this.ownedRoot, this.settingsFile, "origin");
|
|
9391
10674
|
this.settingsValue = void 0;
|
|
9392
10675
|
this.errorCode = void 0;
|
|
9393
10676
|
return this.status();
|
|
@@ -9545,96 +10828,6 @@ var OriginController = class {
|
|
|
9545
10828
|
}
|
|
9546
10829
|
};
|
|
9547
10830
|
//#endregion
|
|
9548
|
-
//#region src/component-download.ts
|
|
9549
|
-
/**
|
|
9550
|
-
* Hosts a pinned GitHub release download may redirect to.
|
|
9551
|
-
*
|
|
9552
|
-
* `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
|
|
9553
|
-
* answers 302 with a signed URL on a release-asset host. A fetch configured with
|
|
9554
|
-
* `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
|
|
9555
|
-
* before a single byte is transferred, which is exactly how the cloudflared component could never
|
|
9556
|
-
* install. Following one validated hop keeps the origin restriction while allowing the shape
|
|
9557
|
-
* every GitHub release download uses.
|
|
9558
|
-
*/
|
|
9559
|
-
const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
|
9560
|
-
/** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
|
|
9561
|
-
async function followValidatedRedirect(response, request) {
|
|
9562
|
-
const location = response.headers.get("location");
|
|
9563
|
-
if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
|
|
9564
|
-
let target;
|
|
9565
|
-
try {
|
|
9566
|
-
target = new URL(location, request.url);
|
|
9567
|
-
} catch {
|
|
9568
|
-
throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
|
|
9569
|
-
}
|
|
9570
|
-
const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
|
|
9571
|
-
if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
|
|
9572
|
-
return fetch(target, {
|
|
9573
|
-
redirect: "error",
|
|
9574
|
-
signal: request.signal
|
|
9575
|
-
});
|
|
9576
|
-
}
|
|
9577
|
-
/** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
|
|
9578
|
-
* real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
|
|
9579
|
-
* which is exactly the confusing "nothing happened" the user saw. */
|
|
9580
|
-
const DOWNLOAD_ATTEMPTS = 2;
|
|
9581
|
-
const RETRY_DELAY_MS = 750;
|
|
9582
|
-
/** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
|
|
9583
|
-
function isRetryable(error) {
|
|
9584
|
-
if (error instanceof TypeError) return true;
|
|
9585
|
-
if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
|
|
9586
|
-
return false;
|
|
9587
|
-
}
|
|
9588
|
-
function delay(ms) {
|
|
9589
|
-
return new Promise((resolve) => {
|
|
9590
|
-
setTimeout(resolve, ms);
|
|
9591
|
-
});
|
|
9592
|
-
}
|
|
9593
|
-
async function attemptDownload(request) {
|
|
9594
|
-
const first = await fetch(request.url, {
|
|
9595
|
-
redirect: "manual",
|
|
9596
|
-
signal: request.signal
|
|
9597
|
-
});
|
|
9598
|
-
const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
|
|
9599
|
-
if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
|
|
9600
|
-
const contentLength = response.headers.get("content-length");
|
|
9601
|
-
if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
9602
|
-
if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
9603
|
-
const bytes = new Uint8Array(request.expectedBytes);
|
|
9604
|
-
const reader = response.body.getReader();
|
|
9605
|
-
let received = 0;
|
|
9606
|
-
while (true) {
|
|
9607
|
-
const chunk = await reader.read();
|
|
9608
|
-
if (chunk.done) break;
|
|
9609
|
-
if (chunk.value.byteLength > bytes.byteLength - received) {
|
|
9610
|
-
await reader.cancel();
|
|
9611
|
-
throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
9612
|
-
}
|
|
9613
|
-
bytes.set(chunk.value, received);
|
|
9614
|
-
received += chunk.value.byteLength;
|
|
9615
|
-
}
|
|
9616
|
-
if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
|
|
9617
|
-
return bytes;
|
|
9618
|
-
}
|
|
9619
|
-
/**
|
|
9620
|
-
* Download a pinned artifact, following at most one validated redirect, and check its exact length.
|
|
9621
|
-
* A transport failure is retried once; a length or redirect verdict is not. The caller still
|
|
9622
|
-
* verifies the SHA-256 before publishing anything.
|
|
9623
|
-
* @param request - the pinned URL, its exact byte length and the caller's error prefix.
|
|
9624
|
-
* @returns the artifact bytes.
|
|
9625
|
-
*/
|
|
9626
|
-
async function downloadPinnedArtifact(request) {
|
|
9627
|
-
let lastError;
|
|
9628
|
-
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
|
|
9629
|
-
return await attemptDownload(request);
|
|
9630
|
-
} catch (error) {
|
|
9631
|
-
lastError = error;
|
|
9632
|
-
if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
|
|
9633
|
-
await delay(RETRY_DELAY_MS);
|
|
9634
|
-
}
|
|
9635
|
-
throw lastError;
|
|
9636
|
-
}
|
|
9637
|
-
//#endregion
|
|
9638
10831
|
//#region src/cloudflared-component.ts
|
|
9639
10832
|
/** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
|
|
9640
10833
|
const MAX_ARCHIVE_LIST_BYTES = 1048576;
|
|
@@ -9805,6 +10998,7 @@ var CloudflaredComponentManager = class {
|
|
|
9805
10998
|
stateRoot;
|
|
9806
10999
|
logRoot;
|
|
9807
11000
|
stagingRoot;
|
|
11001
|
+
stateDirectory;
|
|
9808
11002
|
platform;
|
|
9809
11003
|
arch;
|
|
9810
11004
|
release;
|
|
@@ -9814,8 +11008,9 @@ var CloudflaredComponentManager = class {
|
|
|
9814
11008
|
errorCode;
|
|
9815
11009
|
queue = Promise.resolve();
|
|
9816
11010
|
constructor(options) {
|
|
11011
|
+
if (!isAbsolute(options.stateDirectory)) throw new Error("cloudflared state directory must be absolute");
|
|
9817
11012
|
const stateDirectory = resolve(options.stateDirectory);
|
|
9818
|
-
|
|
11013
|
+
this.stateDirectory = stateDirectory;
|
|
9819
11014
|
this.platform = options.platform ?? process.platform;
|
|
9820
11015
|
this.arch = options.arch ?? process.arch;
|
|
9821
11016
|
this.release = lookupRelease(this.platform, this.arch);
|
|
@@ -9839,9 +11034,16 @@ var CloudflaredComponentManager = class {
|
|
|
9839
11034
|
/** Inspect the managed binary without using any global cloudflared state. */
|
|
9840
11035
|
async initialize() {
|
|
9841
11036
|
const release = this.release;
|
|
11037
|
+
this.installed = false;
|
|
9842
11038
|
this.errorCode = void 0;
|
|
9843
|
-
|
|
9844
|
-
|
|
11039
|
+
try {
|
|
11040
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
|
|
11041
|
+
this.installed = release !== void 0 && await regularFile$1(this.executable, release.executableBytes);
|
|
11042
|
+
if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.executableSha256) {
|
|
11043
|
+
this.installed = false;
|
|
11044
|
+
this.errorCode = "cloudflared_component_invalid";
|
|
11045
|
+
}
|
|
11046
|
+
} catch (_error) {
|
|
9845
11047
|
this.installed = false;
|
|
9846
11048
|
this.errorCode = "cloudflared_component_invalid";
|
|
9847
11049
|
}
|
|
@@ -9867,10 +11069,8 @@ var CloudflaredComponentManager = class {
|
|
|
9867
11069
|
return this.enqueue(async () => {
|
|
9868
11070
|
const release = this.release;
|
|
9869
11071
|
if (release === void 0) throw new Error("cloudflared_component_unsupported");
|
|
9870
|
-
await
|
|
9871
|
-
|
|
9872
|
-
mode: 448
|
|
9873
|
-
});
|
|
11072
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
|
|
11073
|
+
await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cloudflared");
|
|
9874
11074
|
const staging = await mkdtemp(join(this.stagingRoot, "install-"));
|
|
9875
11075
|
try {
|
|
9876
11076
|
const controller = new AbortController();
|
|
@@ -9906,48 +11106,43 @@ var CloudflaredComponentManager = class {
|
|
|
9906
11106
|
await chmod(staged, 448);
|
|
9907
11107
|
if (!await regularFile$1(staged, release.executableBytes) || await sha256$1(staged) !== release.executableSha256) throw new Error("cloudflared_executable_hash_mismatch");
|
|
9908
11108
|
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
9909
|
-
await
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
11109
|
+
await ensureManagedDirectory(this.stateDirectory, candidate, "cloudflared");
|
|
11110
|
+
try {
|
|
11111
|
+
await copyFile(staged, join(candidate, release.executableName));
|
|
11112
|
+
await chmod(join(candidate, release.executableName), 448);
|
|
11113
|
+
await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "cloudflared");
|
|
11114
|
+
} catch (error) {
|
|
11115
|
+
try {
|
|
11116
|
+
await removeManagedTree(this.stateDirectory, candidate, "cloudflared");
|
|
11117
|
+
} catch (_cleanupError) {
|
|
11118
|
+
process.emitWarning("cloudflared candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
|
|
11119
|
+
}
|
|
11120
|
+
throw error;
|
|
11121
|
+
}
|
|
9920
11122
|
this.installed = true;
|
|
9921
11123
|
this.errorCode = void 0;
|
|
11124
|
+
} catch (error) {
|
|
11125
|
+
try {
|
|
11126
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
|
|
11127
|
+
this.installed = await regularFile$1(this.executable, release.executableBytes) && await sha256$1(this.executable) === release.executableSha256;
|
|
11128
|
+
} catch (_inspectionError) {
|
|
11129
|
+
this.installed = false;
|
|
11130
|
+
}
|
|
11131
|
+
throw error;
|
|
9922
11132
|
} finally {
|
|
9923
|
-
await
|
|
9924
|
-
recursive: true,
|
|
9925
|
-
force: true
|
|
9926
|
-
});
|
|
11133
|
+
await removeManagedTree(this.stateDirectory, staging, "cloudflared");
|
|
9927
11134
|
}
|
|
9928
11135
|
});
|
|
9929
11136
|
}
|
|
9930
11137
|
/** Remove every cloudflared file owned by DSH Mobile without touching global state. */
|
|
9931
11138
|
purge() {
|
|
9932
11139
|
return this.enqueue(async () => {
|
|
9933
|
-
await
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
recursive: true,
|
|
9940
|
-
force: true
|
|
9941
|
-
}),
|
|
9942
|
-
rm(this.logRoot, {
|
|
9943
|
-
recursive: true,
|
|
9944
|
-
force: true
|
|
9945
|
-
}),
|
|
9946
|
-
rm(this.stagingRoot, {
|
|
9947
|
-
recursive: true,
|
|
9948
|
-
force: true
|
|
9949
|
-
})
|
|
9950
|
-
]);
|
|
11140
|
+
await removeManagedPaths(this.stateDirectory, [
|
|
11141
|
+
this.componentRoot,
|
|
11142
|
+
this.stateRoot,
|
|
11143
|
+
this.logRoot,
|
|
11144
|
+
this.stagingRoot
|
|
11145
|
+
], "cloudflared");
|
|
9951
11146
|
this.installed = false;
|
|
9952
11147
|
this.errorCode = void 0;
|
|
9953
11148
|
});
|
|
@@ -10125,8 +11320,8 @@ var CloudflaredController = class {
|
|
|
10125
11320
|
if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
|
|
10126
11321
|
}
|
|
10127
11322
|
/** Restore the remembered cloudflared switch independently from LAN and other providers. */
|
|
10128
|
-
async initialize() {
|
|
10129
|
-
const state = await this.options.store.load();
|
|
11323
|
+
async initialize(initialState) {
|
|
11324
|
+
const state = initialState ?? await this.options.store.load();
|
|
10130
11325
|
this.enabled = state.enabled;
|
|
10131
11326
|
this.initialized = true;
|
|
10132
11327
|
if (this.enabled) await this.start();
|
|
@@ -10148,12 +11343,12 @@ var CloudflaredController = class {
|
|
|
10148
11343
|
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
10149
11344
|
await this.enqueue(async () => {
|
|
10150
11345
|
if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
|
|
10151
|
-
if (!enabled) await this.stop();
|
|
10152
|
-
this.enabled = enabled;
|
|
10153
11346
|
await this.options.store.save({
|
|
10154
11347
|
version: 1,
|
|
10155
11348
|
enabled
|
|
10156
11349
|
});
|
|
11350
|
+
this.enabled = enabled;
|
|
11351
|
+
if (!enabled) await this.stop();
|
|
10157
11352
|
if (enabled) await this.start();
|
|
10158
11353
|
else this.publish({
|
|
10159
11354
|
enabled: false,
|
|
@@ -10167,11 +11362,11 @@ var CloudflaredController = class {
|
|
|
10167
11362
|
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
10168
11363
|
await this.enqueue(async () => {
|
|
10169
11364
|
if (!this.enabled) {
|
|
10170
|
-
this.enabled = true;
|
|
10171
11365
|
await this.options.store.save({
|
|
10172
11366
|
version: 1,
|
|
10173
11367
|
enabled: true
|
|
10174
11368
|
});
|
|
11369
|
+
this.enabled = true;
|
|
10175
11370
|
}
|
|
10176
11371
|
await this.stop();
|
|
10177
11372
|
await this.start();
|
|
@@ -10182,12 +11377,12 @@ var CloudflaredController = class {
|
|
|
10182
11377
|
async reset() {
|
|
10183
11378
|
if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
|
|
10184
11379
|
await this.enqueue(async () => {
|
|
10185
|
-
await this.stop();
|
|
10186
|
-
this.enabled = false;
|
|
10187
11380
|
await this.options.store.save({
|
|
10188
11381
|
version: 1,
|
|
10189
11382
|
enabled: false
|
|
10190
11383
|
});
|
|
11384
|
+
this.enabled = false;
|
|
11385
|
+
await this.stop();
|
|
10191
11386
|
this.publish({
|
|
10192
11387
|
enabled: false,
|
|
10193
11388
|
state: "off"
|
|
@@ -10589,34 +11784,9 @@ function mergeSavedCloudflaredTunnelSettings(partial, saved) {
|
|
|
10589
11784
|
port
|
|
10590
11785
|
});
|
|
10591
11786
|
}
|
|
10592
|
-
async function atomicPrivateWrite(file, body) {
|
|
10593
|
-
const directory = dirname(file);
|
|
10594
|
-
await mkdir(directory, {
|
|
10595
|
-
recursive: true,
|
|
10596
|
-
mode: 448
|
|
10597
|
-
});
|
|
10598
|
-
try {
|
|
10599
|
-
const current = await lstat(file);
|
|
10600
|
-
if (!current.isFile() || current.isSymbolicLink()) throw new Error("cloudflared_tunnel_target_invalid");
|
|
10601
|
-
} catch (error) {
|
|
10602
|
-
if (error.code !== "ENOENT") throw error;
|
|
10603
|
-
}
|
|
10604
|
-
const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
10605
|
-
try {
|
|
10606
|
-
await writeFile(temporary, body, {
|
|
10607
|
-
encoding: "utf8",
|
|
10608
|
-
flag: "wx",
|
|
10609
|
-
mode: 384
|
|
10610
|
-
});
|
|
10611
|
-
await rename(temporary, file);
|
|
10612
|
-
await restrictPrivateFile(file);
|
|
10613
|
-
} catch (error) {
|
|
10614
|
-
await rm(temporary, { force: true });
|
|
10615
|
-
throw error;
|
|
10616
|
-
}
|
|
10617
|
-
}
|
|
10618
11787
|
/** Owns the private cloudflared tunnel configuration for one DSH installation. */
|
|
10619
11788
|
var CloudflaredTunnelStore = class {
|
|
11789
|
+
ownedRoot;
|
|
10620
11790
|
stateRoot;
|
|
10621
11791
|
settingsFile;
|
|
10622
11792
|
settingsValue = Object.freeze({
|
|
@@ -10624,13 +11794,25 @@ var CloudflaredTunnelStore = class {
|
|
|
10624
11794
|
mode: "quick"
|
|
10625
11795
|
});
|
|
10626
11796
|
errorCode;
|
|
10627
|
-
constructor(stateDirectory) {
|
|
11797
|
+
constructor(stateDirectory, ownedRoot = stateDirectory) {
|
|
11798
|
+
this.ownedRoot = ownedRoot;
|
|
10628
11799
|
if (!isAbsolute(stateDirectory)) throw new Error("cloudflared tunnel state directory must be absolute");
|
|
10629
11800
|
this.stateRoot = resolve(stateDirectory);
|
|
10630
11801
|
this.settingsFile = join(this.stateRoot, "tunnel.json");
|
|
10631
11802
|
}
|
|
10632
11803
|
/** Load private settings while rejecting links, oversized files and unknown fields. */
|
|
10633
11804
|
async initialize() {
|
|
11805
|
+
this.settingsValue = Object.freeze({
|
|
11806
|
+
version: 1,
|
|
11807
|
+
mode: "quick"
|
|
11808
|
+
});
|
|
11809
|
+
this.errorCode = void 0;
|
|
11810
|
+
try {
|
|
11811
|
+
await assertManagedParents(this.ownedRoot, this.settingsFile, "cloudflared");
|
|
11812
|
+
} catch (_error) {
|
|
11813
|
+
this.errorCode = "cloudflared_tunnel_config_invalid";
|
|
11814
|
+
return;
|
|
11815
|
+
}
|
|
10634
11816
|
let entry;
|
|
10635
11817
|
try {
|
|
10636
11818
|
entry = await lstat(this.settingsFile);
|
|
@@ -10675,15 +11857,20 @@ var CloudflaredTunnelStore = class {
|
|
|
10675
11857
|
/** Atomically replace the tunnel configuration. */
|
|
10676
11858
|
async configure(value) {
|
|
10677
11859
|
const settings = parseCloudflaredTunnelSettings(value);
|
|
10678
|
-
if (settings.mode === "quick") await
|
|
10679
|
-
else
|
|
11860
|
+
if (settings.mode === "quick") await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
|
|
11861
|
+
else try {
|
|
11862
|
+
await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "cloudflared");
|
|
11863
|
+
} catch (error) {
|
|
11864
|
+
if (error instanceof Error && error.message === "cloudflared_config_target_invalid") throw new Error("cloudflared_tunnel_target_invalid", { cause: error });
|
|
11865
|
+
throw error;
|
|
11866
|
+
}
|
|
10680
11867
|
this.settingsValue = settings;
|
|
10681
11868
|
this.errorCode = void 0;
|
|
10682
11869
|
return this.status();
|
|
10683
11870
|
}
|
|
10684
11871
|
/** Forget a named tunnel and its connector token. */
|
|
10685
11872
|
async purge() {
|
|
10686
|
-
await
|
|
11873
|
+
await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
|
|
10687
11874
|
this.settingsValue = Object.freeze({
|
|
10688
11875
|
version: 1,
|
|
10689
11876
|
mode: "quick"
|
|
@@ -11242,7 +12429,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
11242
12429
|
function buildMobileGuide(state) {
|
|
11243
12430
|
const styleLine = state.hasCustomCss ? "存在(当前生效的自定义样式)" : "不存在(使用内置默认样式)";
|
|
11244
12431
|
const scriptLine = state.hasCustomJs ? "存在(当前生效的自定义脚本)" : "不存在(无自定义脚本)";
|
|
11245
|
-
const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version}
|
|
12432
|
+
const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})${entry.executionMode === void 0 ? "" : ` · ${entry.executionMode}${entry.executionState === "unavailable" ? " · 当前不可用,需要显式重启扩展" : ""}`}`).join("\n");
|
|
11246
12433
|
const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
|
|
11247
12434
|
return `${`## 手机端当前状态(改动前必读,避免覆盖已有定制)
|
|
11248
12435
|
|
|
@@ -11276,16 +12463,18 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
11276
12463
|
- 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
|
|
11277
12464
|
- extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
|
|
11278
12465
|
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
|
|
12466
|
+
- 执行方式见上方实际状态。默认 in-process;插件配置可按扩展选择 worker。Worker 中 api.context 只有 logger,不能调用 Cordis 服务;api.manifest、schema、action、route、effect 仍可用。不要把需要 Cordis 的扩展偷偷改成 Worker,也不要在 extension.json 中添加执行模式字段。
|
|
11279
12467
|
- 动作与路由的执行有时间预算:默认 30 秒(输入校验也计入),可用 api.action('名称', { input, run, timeoutMs }) 或 api.route({ ..., timeoutMs }) 按操作覆盖(1–300000 的整数毫秒)。超时或调用方取消后,调用方收到 500 extension_action_timeout / extension_route_timeout(或取消原因);这只表示调用方不再等待,底层代码可能仍在运行并已产生副作用,请在 run / handle 中响应 signal 实现协作式取消。路由的预算到 handle() 返回响应为止,不影响已返回流的存活。若某扩展存在调用方已离开(超时或取消)但仍未结束的操作,后续对该扩展的新调用会收到 503 extension_busy,直到该操作真正结束。
|
|
11280
12468
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
11281
12469
|
- mobile.css:手机端样式(可选)
|
|
11282
12470
|
- assets/:手机端静态资源(可选)
|
|
11283
12471
|
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
|
|
11284
|
-
-
|
|
12472
|
+
- DSH Web 可先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改;Desktop 请在上方实际定制目录创建文件,不要误改 Web profile
|
|
11285
12473
|
|
|
11286
12474
|
安全约束:
|
|
11287
12475
|
- host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
|
|
11288
|
-
-
|
|
12476
|
+
- in-process 的同步阻塞会卡住网关事件循环,超时不能打断;Worker 可约束 JavaScript 阻塞,但不是安全沙箱,不约束全部原生内存,也不回滚副作用。两种模式都应响应 signal,避免编写不可取消的同步循环
|
|
12477
|
+
- Worker 终止后其他未完成调用会失败,已开始的操作不会自动重试。相同内容的停止实例需要操作者在电脑端“移动访问 → 扩展需要重启”中确认恢复;不要通过无意义改文件或自动重放原动作实现恢复
|
|
11289
12478
|
- 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
|
|
11290
12479
|
|
|
11291
12480
|
完成前请自检:
|
|
@@ -11313,10 +12502,16 @@ const LINUX_ARTIFACTS = Object.freeze({
|
|
|
11313
12502
|
var VpsSshError = class extends Error {
|
|
11314
12503
|
stdout;
|
|
11315
12504
|
stderr;
|
|
12505
|
+
timedOut;
|
|
12506
|
+
exitCode;
|
|
12507
|
+
signal;
|
|
11316
12508
|
constructor(message, stdout, stderr, options) {
|
|
11317
12509
|
super(message, options);
|
|
11318
12510
|
this.stdout = stdout;
|
|
11319
12511
|
this.stderr = stderr;
|
|
12512
|
+
this.timedOut = options?.timedOut ?? false;
|
|
12513
|
+
this.exitCode = options?.exitCode;
|
|
12514
|
+
this.signal = options?.signal;
|
|
11320
12515
|
}
|
|
11321
12516
|
};
|
|
11322
12517
|
function shellQuote(value) {
|
|
@@ -11427,7 +12622,7 @@ function sshSessionOptions(knownHostsFile) {
|
|
|
11427
12622
|
`-o UserKnownHostsFile=${knownHostsFile}`
|
|
11428
12623
|
];
|
|
11429
12624
|
}
|
|
11430
|
-
/**
|
|
12625
|
+
/** Recognize usable public-key scan output without authenticating to the server. */
|
|
11431
12626
|
function tryParseKeyscanOutput(output) {
|
|
11432
12627
|
try {
|
|
11433
12628
|
return parseKeyscanOutput(output);
|
|
@@ -11466,58 +12661,20 @@ async function defaultRunKeyscan(input, serverAddress) {
|
|
|
11466
12661
|
}
|
|
11467
12662
|
return first?.stdout ?? "";
|
|
11468
12663
|
}
|
|
11469
|
-
/**
|
|
11470
|
-
* Read the server's public host keys over an authenticated connection with a
|
|
11471
|
-
* throwaway known_hosts file. Fallback for keyscan binaries that cannot
|
|
11472
|
-
* negotiate with modern servers; output feeds the same confirm-and-pin pipeline.
|
|
11473
|
-
*/
|
|
11474
|
-
async function defaultRunSshFetch(input, serverAddress) {
|
|
11475
|
-
const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
|
|
11476
|
-
const sshUser = validSshUser(input.sshUser);
|
|
11477
|
-
const sshPort = validSshPort(input.sshPort);
|
|
11478
|
-
const sshKeyPath = validSshKeyPath(input.sshKeyPath);
|
|
11479
|
-
const { stdout } = await runProcess(ssh, [
|
|
11480
|
-
"-o",
|
|
11481
|
-
"BatchMode=yes",
|
|
11482
|
-
"-o",
|
|
11483
|
-
"ConnectTimeout=15",
|
|
11484
|
-
"-o",
|
|
11485
|
-
"StrictHostKeyChecking=no",
|
|
11486
|
-
`-o UserKnownHostsFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
|
|
11487
|
-
...sshKeyPath === void 0 ? [] : ["-i", sshKeyPath],
|
|
11488
|
-
"-p",
|
|
11489
|
-
String(sshPort),
|
|
11490
|
-
`${sshUser}@${serverAddress}`,
|
|
11491
|
-
"cat /etc/ssh/ssh_host_*_key.pub"
|
|
11492
|
-
], void 0, 3e4).catch((error) => {
|
|
11493
|
-
throw new VpsSshError("vps_host_key_unavailable", "", error instanceof Error ? error.message : String(error));
|
|
11494
|
-
});
|
|
11495
|
-
const lines = [];
|
|
11496
|
-
for (const line of stdout.split(/\r?\n/u)) {
|
|
11497
|
-
const match = /^(ssh-rsa|ecdsa-sha2-nistp\d+|ssh-ed25519)\s+([A-Za-z0-9+/]+={0,2})(\s|$)/u.exec(line.trim());
|
|
11498
|
-
if (match?.[1] !== void 0 && match[2] !== void 0) lines.push(`${serverAddress} ${match[1]} ${match[2]}`);
|
|
11499
|
-
}
|
|
11500
|
-
return lines.length === 0 ? stdout : `${lines.join("\n")}\n`;
|
|
11501
|
-
}
|
|
11502
|
-
/**
|
|
11503
|
-
* Scan host keys with keyscan first, then fall back to an authenticated read
|
|
11504
|
-
* when the local keyscan binary cannot negotiate with the server. Both paths
|
|
11505
|
-
* feed the same confirm-and-pin pipeline, so a fallback never weakens the
|
|
11506
|
-
* user-confirmation gate.
|
|
11507
|
-
*/
|
|
12664
|
+
/** Obtain public host keys without using a private key, agent or unverified authenticated connection. */
|
|
11508
12665
|
async function scanHostKeys(input, serverAddress, options) {
|
|
11509
|
-
const
|
|
11510
|
-
|
|
11511
|
-
|
|
11512
|
-
|
|
11513
|
-
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
|
|
11517
|
-
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
12666
|
+
const runKeyscan = options.runKeyscan ?? defaultRunKeyscan;
|
|
12667
|
+
let scanned;
|
|
12668
|
+
try {
|
|
12669
|
+
scanned = await runKeyscan({
|
|
12670
|
+
sshUser: input.sshUser,
|
|
12671
|
+
sshPort: input.sshPort
|
|
12672
|
+
}, serverAddress);
|
|
12673
|
+
} catch (error) {
|
|
12674
|
+
throw new Error("vps_host_key_unavailable", { cause: error });
|
|
12675
|
+
}
|
|
12676
|
+
if (parseKeyscanOutput(scanned).length === 0) throw new Error("vps_host_key_unavailable");
|
|
12677
|
+
return scanned;
|
|
11521
12678
|
}
|
|
11522
12679
|
/**
|
|
11523
12680
|
* Fetch the server's current host keys and return them with OpenSSH-style
|
|
@@ -11767,17 +12924,6 @@ else
|
|
|
11767
12924
|
printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
|
|
11768
12925
|
chmod 0644 /etc/caddy/Caddyfile
|
|
11769
12926
|
caddyfile_ready=true
|
|
11770
|
-
elif grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
|
|
11771
|
-
# Legacy whole-file layout: the entire file is ours by construction.
|
|
11772
|
-
printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
|
|
11773
|
-
chmod 0644 /etc/caddy/Caddyfile
|
|
11774
|
-
caddyfile_ready=true
|
|
11775
|
-
elif grep -q '^:80[[:space:]]*{' /etc/caddy/Caddyfile \
|
|
11776
|
-
&& grep -q 'root [*] /usr/share/caddy' /etc/caddy/Caddyfile \
|
|
11777
|
-
&& grep -q '^[[:space:]]*file_server[[:space:]]*$' /etc/caddy/Caddyfile; then
|
|
11778
|
-
printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
|
|
11779
|
-
chmod 0644 /etc/caddy/Caddyfile
|
|
11780
|
-
caddyfile_ready=true
|
|
11781
12927
|
fi
|
|
11782
12928
|
fi
|
|
11783
12929
|
if [ "$caddyfile_ready" != true ]; then
|
|
@@ -11880,7 +13026,8 @@ check caddy ok "Caddy 已加载 ${publicHost}。"
|
|
|
11880
13026
|
echo DSH_MOBILE_DEPLOYMENT_OK
|
|
11881
13027
|
`;
|
|
11882
13028
|
}
|
|
11883
|
-
|
|
13029
|
+
/** Run one owned SSH/transfer utility; reject only after its process and stdio have closed. */
|
|
13030
|
+
async function runVpsProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
|
|
11884
13031
|
return new Promise((resolveRun, rejectRun) => {
|
|
11885
13032
|
const child = spawn(command, args, {
|
|
11886
13033
|
windowsHide: true,
|
|
@@ -11893,9 +13040,23 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
|
|
|
11893
13040
|
let stdout = "";
|
|
11894
13041
|
let stderr = "";
|
|
11895
13042
|
const append = (current, chunk) => `${current}${chunk.toString("utf8")}`.slice(-98304);
|
|
11896
|
-
|
|
13043
|
+
let timedOut = false;
|
|
13044
|
+
let failure;
|
|
13045
|
+
let failureCode = "vps_ssh_unavailable";
|
|
13046
|
+
let forceTimer;
|
|
13047
|
+
const terminate = () => {
|
|
11897
13048
|
child.kill();
|
|
11898
|
-
|
|
13049
|
+
forceTimer ??= setTimeout(() => {
|
|
13050
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
13051
|
+
child.stdin.destroy();
|
|
13052
|
+
child.stdout.destroy();
|
|
13053
|
+
child.stderr.destroy();
|
|
13054
|
+
}, 1e3);
|
|
13055
|
+
forceTimer.unref();
|
|
13056
|
+
};
|
|
13057
|
+
const timer = setTimeout(() => {
|
|
13058
|
+
timedOut = true;
|
|
13059
|
+
terminate();
|
|
11899
13060
|
}, timeoutMs);
|
|
11900
13061
|
timer.unref();
|
|
11901
13062
|
child.stdout.on("data", (chunk) => {
|
|
@@ -11905,12 +13066,32 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
|
|
|
11905
13066
|
stderr = append(stderr, Buffer.from(chunk));
|
|
11906
13067
|
});
|
|
11907
13068
|
child.once("error", (error) => {
|
|
11908
|
-
|
|
11909
|
-
rejectRun(new VpsSshError("vps_ssh_unavailable", stdout, stderr, { cause: error }));
|
|
13069
|
+
failure = error;
|
|
11910
13070
|
});
|
|
11911
|
-
child.
|
|
13071
|
+
child.stdin.on("error", (error) => {
|
|
13072
|
+
failure = error;
|
|
13073
|
+
failureCode = "vps_ssh_input_failed";
|
|
13074
|
+
terminate();
|
|
13075
|
+
});
|
|
13076
|
+
const failedOutput = (error) => {
|
|
13077
|
+
failure = error;
|
|
13078
|
+
failureCode = "vps_ssh_transport_failed";
|
|
13079
|
+
terminate();
|
|
13080
|
+
};
|
|
13081
|
+
child.stdout.on("error", failedOutput);
|
|
13082
|
+
child.stderr.on("error", failedOutput);
|
|
13083
|
+
child.once("close", (code, signal) => {
|
|
11912
13084
|
clearTimeout(timer);
|
|
11913
|
-
if (
|
|
13085
|
+
if (forceTimer !== void 0) clearTimeout(forceTimer);
|
|
13086
|
+
const facts = {
|
|
13087
|
+
timedOut,
|
|
13088
|
+
exitCode: code,
|
|
13089
|
+
signal,
|
|
13090
|
+
...failure === void 0 ? {} : { cause: failure }
|
|
13091
|
+
};
|
|
13092
|
+
if (timedOut) rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr, facts));
|
|
13093
|
+
else if (failure !== void 0) rejectRun(new VpsSshError(child.pid === void 0 ? "vps_ssh_unavailable" : failureCode, stdout, stderr, facts));
|
|
13094
|
+
else if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr, facts));
|
|
11914
13095
|
else resolveRun({
|
|
11915
13096
|
stdout,
|
|
11916
13097
|
stderr
|
|
@@ -11919,8 +13100,10 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
|
|
|
11919
13100
|
child.stdin.end(stdin, "utf8");
|
|
11920
13101
|
});
|
|
11921
13102
|
}
|
|
13103
|
+
const runProcess = runVpsProcess;
|
|
11922
13104
|
async function downloadArtifact(artifact, file) {
|
|
11923
|
-
|
|
13105
|
+
const curl = process.platform === "win32" ? "curl.exe" : "curl";
|
|
13106
|
+
await runProcess(curl, [
|
|
11924
13107
|
...process.platform === "win32" ? ["--ipv4"] : [],
|
|
11925
13108
|
"--fail",
|
|
11926
13109
|
"--location",
|
|
@@ -12161,13 +13344,8 @@ if grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:
|
|
|
12161
13344
|
fi
|
|
12162
13345
|
check caddy ok "已移除 DSH Mobile 站点引入;其余 Caddy 配置保持原样。"
|
|
12163
13346
|
elif [ -f /etc/caddy/Caddyfile ] && grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
|
|
12164
|
-
#
|
|
12165
|
-
|
|
12166
|
-
chmod 0644 /etc/caddy/Caddyfile
|
|
12167
|
-
if command -v caddy >/dev/null 2>&1; then
|
|
12168
|
-
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.service || systemctl restart caddy.service || true
|
|
12169
|
-
fi
|
|
12170
|
-
check caddy ok "已清空旧版 DSH Mobile 管理的 Caddy 站点;请按需恢复自己的配置。"
|
|
13347
|
+
# A legacy marker does not establish ownership of subsequently added sites.
|
|
13348
|
+
check caddy warning "已保留旧版 Caddyfile;请手动移除 DSH Mobile 站点,不要删除其他站点。"
|
|
12171
13349
|
else
|
|
12172
13350
|
check caddy ok "Caddyfile 非 DSH Mobile 管理,保持原样。"
|
|
12173
13351
|
fi
|
|
@@ -12374,14 +13552,30 @@ var FunnelController = class {
|
|
|
12374
13552
|
state: "off"
|
|
12375
13553
|
});
|
|
12376
13554
|
queue = Promise.resolve();
|
|
13555
|
+
closeTask;
|
|
12377
13556
|
startTimer;
|
|
13557
|
+
ownedRoot;
|
|
12378
13558
|
constructor(options) {
|
|
12379
13559
|
this.options = options;
|
|
12380
13560
|
if (!isAbsolute(options.executable) || !isAbsolute(options.stateDirectory)) throw new Error("Funnel paths must be absolute");
|
|
13561
|
+
this.ownedRoot = options.ownedRoot ?? dirname(resolve(options.stateDirectory));
|
|
13562
|
+
if (!isAbsolute(this.ownedRoot) || resolve(this.ownedRoot) === resolve(options.stateDirectory)) throw new Error("Funnel state must be a child of its owned root");
|
|
12381
13563
|
}
|
|
12382
13564
|
/** Restore the remote switch without coupling it to LAN availability. */
|
|
12383
|
-
async initialize() {
|
|
12384
|
-
|
|
13565
|
+
async initialize(initialState) {
|
|
13566
|
+
try {
|
|
13567
|
+
await this.assertStateParents();
|
|
13568
|
+
} catch (_error) {
|
|
13569
|
+
this.enabled = false;
|
|
13570
|
+
this.initialized = true;
|
|
13571
|
+
this.publish({
|
|
13572
|
+
enabled: false,
|
|
13573
|
+
state: "unavailable",
|
|
13574
|
+
errorCode: "funnel_state_invalid"
|
|
13575
|
+
});
|
|
13576
|
+
return;
|
|
13577
|
+
}
|
|
13578
|
+
const state = initialState ?? await this.options.store.load();
|
|
12385
13579
|
this.enabled = state.enabled;
|
|
12386
13580
|
this.initialized = true;
|
|
12387
13581
|
if (this.enabled) await this.start();
|
|
@@ -12402,18 +13596,67 @@ var FunnelController = class {
|
|
|
12402
13596
|
async setEnabled(enabled) {
|
|
12403
13597
|
if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
|
|
12404
13598
|
await this.enqueue(async () => {
|
|
12405
|
-
|
|
12406
|
-
if (
|
|
12407
|
-
|
|
13599
|
+
const ownsResources = this.child !== void 0 || this.gatewayValue !== void 0;
|
|
13600
|
+
if (this.enabled === enabled && (enabled ? this.child !== void 0 : !ownsResources && this.latest.state === "off")) return;
|
|
13601
|
+
if (!enabled) {
|
|
13602
|
+
try {
|
|
13603
|
+
await this.assertStateParents();
|
|
13604
|
+
} catch (error) {
|
|
13605
|
+
try {
|
|
13606
|
+
await this.stop();
|
|
13607
|
+
} catch (cleanupError) {
|
|
13608
|
+
this.publish({
|
|
13609
|
+
enabled: this.enabled,
|
|
13610
|
+
state: "error",
|
|
13611
|
+
errorCode: "funnel_cleanup_failed"
|
|
13612
|
+
});
|
|
13613
|
+
throw new AggregateError([error, cleanupError], "Funnel state validation and cleanup failed");
|
|
13614
|
+
}
|
|
13615
|
+
this.publish({
|
|
13616
|
+
enabled: this.enabled,
|
|
13617
|
+
state: "unavailable",
|
|
13618
|
+
errorCode: "funnel_state_invalid"
|
|
13619
|
+
});
|
|
13620
|
+
throw error;
|
|
13621
|
+
}
|
|
13622
|
+
await this.options.store.save({
|
|
13623
|
+
version: 1,
|
|
13624
|
+
enabled: false
|
|
13625
|
+
});
|
|
13626
|
+
this.enabled = false;
|
|
13627
|
+
try {
|
|
13628
|
+
await this.stop();
|
|
13629
|
+
} catch (error) {
|
|
13630
|
+
this.publish({
|
|
13631
|
+
enabled: false,
|
|
13632
|
+
state: "error",
|
|
13633
|
+
errorCode: "funnel_cleanup_failed"
|
|
13634
|
+
});
|
|
13635
|
+
throw error;
|
|
13636
|
+
}
|
|
13637
|
+
this.publish({
|
|
13638
|
+
enabled: false,
|
|
13639
|
+
state: "off"
|
|
13640
|
+
});
|
|
13641
|
+
return;
|
|
13642
|
+
}
|
|
13643
|
+
try {
|
|
13644
|
+
await this.assertStateParents();
|
|
13645
|
+
} catch (error) {
|
|
13646
|
+
this.publish({
|
|
13647
|
+
enabled: this.enabled,
|
|
13648
|
+
state: "unavailable",
|
|
13649
|
+
errorCode: "funnel_state_invalid"
|
|
13650
|
+
});
|
|
13651
|
+
throw error;
|
|
13652
|
+
}
|
|
12408
13653
|
await this.options.store.save({
|
|
12409
13654
|
version: 1,
|
|
12410
|
-
enabled
|
|
12411
|
-
});
|
|
12412
|
-
if (enabled) await this.start();
|
|
12413
|
-
else this.publish({
|
|
12414
|
-
enabled: false,
|
|
12415
|
-
state: "off"
|
|
13655
|
+
enabled: true
|
|
12416
13656
|
});
|
|
13657
|
+
this.enabled = true;
|
|
13658
|
+
if (ownsResources) await this.stop();
|
|
13659
|
+
await this.start();
|
|
12417
13660
|
});
|
|
12418
13661
|
return this.status();
|
|
12419
13662
|
}
|
|
@@ -12421,12 +13664,22 @@ var FunnelController = class {
|
|
|
12421
13664
|
async reconnect() {
|
|
12422
13665
|
if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
|
|
12423
13666
|
await this.enqueue(async () => {
|
|
13667
|
+
try {
|
|
13668
|
+
await this.assertStateParents();
|
|
13669
|
+
} catch (error) {
|
|
13670
|
+
this.publish({
|
|
13671
|
+
enabled: this.enabled,
|
|
13672
|
+
state: "unavailable",
|
|
13673
|
+
errorCode: "funnel_state_invalid"
|
|
13674
|
+
});
|
|
13675
|
+
throw error;
|
|
13676
|
+
}
|
|
12424
13677
|
if (!this.enabled) {
|
|
12425
|
-
this.enabled = true;
|
|
12426
13678
|
await this.options.store.save({
|
|
12427
13679
|
version: 1,
|
|
12428
13680
|
enabled: true
|
|
12429
13681
|
});
|
|
13682
|
+
this.enabled = true;
|
|
12430
13683
|
}
|
|
12431
13684
|
await this.stop();
|
|
12432
13685
|
await this.start();
|
|
@@ -12437,16 +13690,41 @@ var FunnelController = class {
|
|
|
12437
13690
|
async reset() {
|
|
12438
13691
|
if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
|
|
12439
13692
|
await this.enqueue(async () => {
|
|
12440
|
-
|
|
12441
|
-
|
|
13693
|
+
try {
|
|
13694
|
+
await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
|
|
13695
|
+
const selected = await lstat(this.options.stateDirectory).catch((error) => {
|
|
13696
|
+
if (error.code !== "ENOENT") throw error;
|
|
13697
|
+
});
|
|
13698
|
+
if (selected !== void 0 && (selected.isSymbolicLink() || !selected.isDirectory())) await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
|
|
13699
|
+
} catch (error) {
|
|
13700
|
+
this.publish({
|
|
13701
|
+
enabled: this.enabled,
|
|
13702
|
+
state: "off",
|
|
13703
|
+
errorCode: "funnel_state_invalid"
|
|
13704
|
+
});
|
|
13705
|
+
throw error;
|
|
13706
|
+
}
|
|
12442
13707
|
await this.options.store.save({
|
|
12443
13708
|
version: 1,
|
|
12444
13709
|
enabled: false
|
|
12445
13710
|
});
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
13711
|
+
this.enabled = false;
|
|
13712
|
+
await this.stop();
|
|
13713
|
+
try {
|
|
13714
|
+
await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
|
|
13715
|
+
await this.assertStateParents();
|
|
13716
|
+
await this.options.store.save({
|
|
13717
|
+
version: 1,
|
|
13718
|
+
enabled: false
|
|
13719
|
+
});
|
|
13720
|
+
} catch (error) {
|
|
13721
|
+
this.publish({
|
|
13722
|
+
enabled: false,
|
|
13723
|
+
state: "off",
|
|
13724
|
+
errorCode: "funnel_state_invalid"
|
|
13725
|
+
});
|
|
13726
|
+
throw error;
|
|
13727
|
+
}
|
|
12450
13728
|
this.publish({
|
|
12451
13729
|
enabled: false,
|
|
12452
13730
|
state: "off"
|
|
@@ -12455,10 +13733,15 @@ var FunnelController = class {
|
|
|
12455
13733
|
return this.status();
|
|
12456
13734
|
}
|
|
12457
13735
|
/** Stop all remote resources without modifying the remembered switch. */
|
|
12458
|
-
|
|
12459
|
-
if (this.
|
|
13736
|
+
close() {
|
|
13737
|
+
if (this.closeTask !== void 0) return this.closeTask;
|
|
12460
13738
|
this.disposed = true;
|
|
12461
|
-
|
|
13739
|
+
const task = this.enqueue(() => this.stop());
|
|
13740
|
+
this.closeTask = task.catch((error) => {
|
|
13741
|
+
this.closeTask = void 0;
|
|
13742
|
+
throw error;
|
|
13743
|
+
});
|
|
13744
|
+
return this.closeTask;
|
|
12462
13745
|
}
|
|
12463
13746
|
enqueue(operation) {
|
|
12464
13747
|
const task = this.queue.then(operation, operation);
|
|
@@ -12471,6 +13754,15 @@ var FunnelController = class {
|
|
|
12471
13754
|
this.options.onStatus?.(this.status());
|
|
12472
13755
|
} catch {}
|
|
12473
13756
|
}
|
|
13757
|
+
async assertStateParents() {
|
|
13758
|
+
await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
|
|
13759
|
+
try {
|
|
13760
|
+
const entry = await lstat(this.options.stateDirectory);
|
|
13761
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("funnel_path_invalid");
|
|
13762
|
+
} catch (error) {
|
|
13763
|
+
if (error.code !== "ENOENT") throw error;
|
|
13764
|
+
}
|
|
13765
|
+
}
|
|
12474
13766
|
async start() {
|
|
12475
13767
|
const generation = ++this.generation;
|
|
12476
13768
|
let entry;
|
|
@@ -12492,6 +13784,17 @@ var FunnelController = class {
|
|
|
12492
13784
|
});
|
|
12493
13785
|
return;
|
|
12494
13786
|
}
|
|
13787
|
+
try {
|
|
13788
|
+
await this.assertStateParents();
|
|
13789
|
+
await ensureManagedDirectory(this.ownedRoot, this.options.stateDirectory, "funnel");
|
|
13790
|
+
} catch (_error) {
|
|
13791
|
+
this.publish({
|
|
13792
|
+
enabled: true,
|
|
13793
|
+
state: "unavailable",
|
|
13794
|
+
errorCode: "funnel_state_invalid"
|
|
13795
|
+
});
|
|
13796
|
+
return;
|
|
13797
|
+
}
|
|
12495
13798
|
this.buffer = "";
|
|
12496
13799
|
this.publish({
|
|
12497
13800
|
enabled: true,
|
|
@@ -12628,13 +13931,17 @@ var FunnelController = class {
|
|
|
12628
13931
|
async stopProcessAndGateway() {
|
|
12629
13932
|
this.clearStartTimer();
|
|
12630
13933
|
const child = this.child;
|
|
12631
|
-
this.child = void 0;
|
|
12632
13934
|
const gateway = this.gatewayValue;
|
|
12633
|
-
this.gatewayValue = void 0;
|
|
12634
13935
|
await settleRemoteResources([async () => {
|
|
12635
13936
|
child?.stdin.end();
|
|
12636
13937
|
if (child !== void 0 && child.exitCode === null) await terminateRemoteProcess(child);
|
|
12637
|
-
|
|
13938
|
+
if (this.child === child) this.child = void 0;
|
|
13939
|
+
}, async () => {
|
|
13940
|
+
if (gateway !== void 0) {
|
|
13941
|
+
await gateway.close();
|
|
13942
|
+
if (this.gatewayValue === gateway) this.gatewayValue = void 0;
|
|
13943
|
+
}
|
|
13944
|
+
}], "Funnel resource cleanup failed");
|
|
12638
13945
|
}
|
|
12639
13946
|
clearStartTimer() {
|
|
12640
13947
|
if (this.startTimer === void 0) return;
|
|
@@ -12760,8 +14067,8 @@ var CpolarController = class {
|
|
|
12760
14067
|
if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
|
|
12761
14068
|
}
|
|
12762
14069
|
/** Restore the remembered cpolar switch independently from LAN and Funnel state. */
|
|
12763
|
-
async initialize() {
|
|
12764
|
-
const state = await this.options.store.load();
|
|
14070
|
+
async initialize(initialState) {
|
|
14071
|
+
const state = initialState ?? await this.options.store.load();
|
|
12765
14072
|
this.enabled = state.enabled;
|
|
12766
14073
|
this.initialized = true;
|
|
12767
14074
|
if (this.enabled) await this.start();
|
|
@@ -12783,12 +14090,12 @@ var CpolarController = class {
|
|
|
12783
14090
|
if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
|
|
12784
14091
|
await this.enqueue(async () => {
|
|
12785
14092
|
if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
|
|
12786
|
-
if (!enabled) await this.stop();
|
|
12787
|
-
this.enabled = enabled;
|
|
12788
14093
|
await this.options.store.save({
|
|
12789
14094
|
version: 1,
|
|
12790
14095
|
enabled
|
|
12791
14096
|
});
|
|
14097
|
+
this.enabled = enabled;
|
|
14098
|
+
if (!enabled) await this.stop();
|
|
12792
14099
|
if (enabled) await this.start();
|
|
12793
14100
|
else this.publish({
|
|
12794
14101
|
enabled: false,
|
|
@@ -12802,11 +14109,11 @@ var CpolarController = class {
|
|
|
12802
14109
|
if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
|
|
12803
14110
|
await this.enqueue(async () => {
|
|
12804
14111
|
if (!this.enabled) {
|
|
12805
|
-
this.enabled = true;
|
|
12806
14112
|
await this.options.store.save({
|
|
12807
14113
|
version: 1,
|
|
12808
14114
|
enabled: true
|
|
12809
14115
|
});
|
|
14116
|
+
this.enabled = true;
|
|
12810
14117
|
}
|
|
12811
14118
|
await this.stop();
|
|
12812
14119
|
await this.start();
|
|
@@ -12817,12 +14124,12 @@ var CpolarController = class {
|
|
|
12817
14124
|
async reset() {
|
|
12818
14125
|
if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
|
|
12819
14126
|
await this.enqueue(async () => {
|
|
12820
|
-
await this.stop();
|
|
12821
|
-
this.enabled = false;
|
|
12822
14127
|
await this.options.store.save({
|
|
12823
14128
|
version: 1,
|
|
12824
14129
|
enabled: false
|
|
12825
14130
|
});
|
|
14131
|
+
this.enabled = false;
|
|
14132
|
+
await this.stop();
|
|
12826
14133
|
this.publish({
|
|
12827
14134
|
enabled: false,
|
|
12828
14135
|
state: "off"
|
|
@@ -13147,20 +14454,19 @@ async function pathExists(path) {
|
|
|
13147
14454
|
/** Replace only this version's verified binary, retaining the previous directory until promotion succeeds. */
|
|
13148
14455
|
async function publishVerifiedCpolarExecutable(extracted, componentRoot, componentStorage, executableName, operations = {}) {
|
|
13149
14456
|
if (dirname(componentStorage) !== componentRoot || !inside(componentRoot, componentStorage) || basename(executableName) !== executableName || executableName === "." || executableName === "..") throw new Error("cpolar component storage escaped its private root");
|
|
13150
|
-
const move =
|
|
13151
|
-
|
|
13152
|
-
|
|
13153
|
-
|
|
13154
|
-
|
|
14457
|
+
const move = async (from, to) => {
|
|
14458
|
+
await assertManagedParents(componentRoot, from, "cpolar");
|
|
14459
|
+
await assertManagedParents(componentRoot, to, "cpolar");
|
|
14460
|
+
if (operations.move === void 0) await renameManagedPath(componentRoot, from, to, "cpolar");
|
|
14461
|
+
else await operations.move(from, to);
|
|
14462
|
+
};
|
|
14463
|
+
const remove = operations.remove ?? ((path) => removeManagedTree(componentRoot, path, "cpolar"));
|
|
13155
14464
|
const candidate = join(componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
13156
14465
|
const backup = join(componentRoot, `.previous-${randomBytes(12).toString("hex")}`);
|
|
13157
14466
|
let backupContainsPrevious = false;
|
|
13158
14467
|
let replacementInstalled = false;
|
|
13159
14468
|
try {
|
|
13160
|
-
await
|
|
13161
|
-
recursive: true,
|
|
13162
|
-
mode: 448
|
|
13163
|
-
});
|
|
14469
|
+
await ensureManagedDirectory(componentRoot, candidate, "cpolar");
|
|
13164
14470
|
await copyFile(extracted, join(candidate, executableName));
|
|
13165
14471
|
await chmod(join(candidate, executableName), 448);
|
|
13166
14472
|
if (await pathExists(componentStorage)) {
|
|
@@ -13194,10 +14500,7 @@ async function publishVerifiedCpolarExecutable(extracted, componentRoot, compone
|
|
|
13194
14500
|
}
|
|
13195
14501
|
}
|
|
13196
14502
|
/** Remove only this install's staging directory without replacing a preceding failure. */
|
|
13197
|
-
async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) =>
|
|
13198
|
-
recursive: true,
|
|
13199
|
-
force: true
|
|
13200
|
-
})) {
|
|
14503
|
+
async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) => removeManagedTree(dirname(path), path, "cpolar")) {
|
|
13201
14504
|
try {
|
|
13202
14505
|
await remove(staging);
|
|
13203
14506
|
} catch (error) {
|
|
@@ -13291,6 +14594,7 @@ var CpolarComponentManager = class {
|
|
|
13291
14594
|
stateRoot;
|
|
13292
14595
|
logRoot;
|
|
13293
14596
|
stagingRoot;
|
|
14597
|
+
stateDirectory;
|
|
13294
14598
|
platform;
|
|
13295
14599
|
arch;
|
|
13296
14600
|
release;
|
|
@@ -13301,8 +14605,9 @@ var CpolarComponentManager = class {
|
|
|
13301
14605
|
errorCode;
|
|
13302
14606
|
queue = Promise.resolve();
|
|
13303
14607
|
constructor(options) {
|
|
14608
|
+
if (!isAbsolute(options.stateDirectory)) throw new Error("cpolar state directory must be absolute");
|
|
13304
14609
|
const stateDirectory = resolve(options.stateDirectory);
|
|
13305
|
-
|
|
14610
|
+
this.stateDirectory = stateDirectory;
|
|
13306
14611
|
this.platform = options.platform ?? process.platform;
|
|
13307
14612
|
this.arch = options.arch ?? process.arch;
|
|
13308
14613
|
this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
|
|
@@ -13330,13 +14635,24 @@ var CpolarComponentManager = class {
|
|
|
13330
14635
|
/** Inspect the managed binary and configuration without using global cpolar state. */
|
|
13331
14636
|
async initialize() {
|
|
13332
14637
|
const release = this.release;
|
|
13333
|
-
this.installed =
|
|
13334
|
-
|
|
14638
|
+
this.installed = false;
|
|
14639
|
+
this.configured = false;
|
|
14640
|
+
this.errorCode = void 0;
|
|
14641
|
+
try {
|
|
14642
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
|
|
14643
|
+
await assertManagedParents(this.stateDirectory, this.configFile, "cpolar");
|
|
14644
|
+
this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
|
|
14645
|
+
if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
|
|
14646
|
+
this.installed = false;
|
|
14647
|
+
this.errorCode = "cpolar_component_invalid";
|
|
14648
|
+
}
|
|
14649
|
+
this.configured = await regularFile(this.configFile);
|
|
14650
|
+
if (this.configured) await restrictPrivateFile(this.configFile);
|
|
14651
|
+
} catch (_error) {
|
|
13335
14652
|
this.installed = false;
|
|
14653
|
+
this.configured = false;
|
|
13336
14654
|
this.errorCode = "cpolar_component_invalid";
|
|
13337
14655
|
}
|
|
13338
|
-
this.configured = await regularFile(this.configFile);
|
|
13339
|
-
if (this.configured) await restrictPrivateFile(this.configFile);
|
|
13340
14656
|
}
|
|
13341
14657
|
/** Return a safe status that never includes the account token. */
|
|
13342
14658
|
status() {
|
|
@@ -13362,11 +14678,9 @@ var CpolarComponentManager = class {
|
|
|
13362
14678
|
return this.enqueue(async () => {
|
|
13363
14679
|
const release = this.release;
|
|
13364
14680
|
if (release === void 0) throw new Error("cpolar_component_unsupported");
|
|
14681
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
|
|
13365
14682
|
const staging = await installStep("cpolar_storage_failed", async () => {
|
|
13366
|
-
await
|
|
13367
|
-
recursive: true,
|
|
13368
|
-
mode: 448
|
|
13369
|
-
});
|
|
14683
|
+
await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cpolar");
|
|
13370
14684
|
return mkdtemp(join(this.stagingRoot, "install-"));
|
|
13371
14685
|
});
|
|
13372
14686
|
let installFailed = false;
|
|
@@ -13394,19 +14708,24 @@ var CpolarComponentManager = class {
|
|
|
13394
14708
|
await installStep("cpolar_extract_failed", () => this.extractArtifact(archive, staging));
|
|
13395
14709
|
const extracted = join(staging, release.executableName);
|
|
13396
14710
|
if (!await installStep("cpolar_extract_failed", async () => await regularFile(extracted, release.executableBytes) && await sha256(extracted) === release.executableSha256)) throw new Error("cpolar_executable_hash_mismatch");
|
|
13397
|
-
await installStep("cpolar_storage_failed", () =>
|
|
14711
|
+
await installStep("cpolar_storage_failed", async () => {
|
|
14712
|
+
await ensureManagedDirectory(this.stateDirectory, this.componentRoot, "cpolar");
|
|
14713
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
|
|
14714
|
+
await publishVerifiedCpolarExecutable(extracted, this.componentRoot, this.componentStorage, release.executableName);
|
|
14715
|
+
});
|
|
13398
14716
|
this.installed = true;
|
|
13399
14717
|
this.errorCode = void 0;
|
|
13400
14718
|
} catch (error) {
|
|
13401
14719
|
installFailed = true;
|
|
13402
14720
|
try {
|
|
14721
|
+
await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
|
|
13403
14722
|
this.installed = await regularFile(this.executable, release.executableBytes) && await sha256(this.executable) === release.executableSha256;
|
|
13404
14723
|
} catch (_inspectionError) {
|
|
13405
14724
|
this.installed = false;
|
|
13406
14725
|
}
|
|
13407
14726
|
throw error;
|
|
13408
14727
|
} finally {
|
|
13409
|
-
await cleanupCpolarInstallStaging(staging, installFailed);
|
|
14728
|
+
await cleanupCpolarInstallStaging(staging, installFailed, (path) => removeManagedTree(this.stateDirectory, path, "cpolar"));
|
|
13410
14729
|
}
|
|
13411
14730
|
});
|
|
13412
14731
|
}
|
|
@@ -13414,24 +14733,8 @@ var CpolarComponentManager = class {
|
|
|
13414
14733
|
configure(authtoken) {
|
|
13415
14734
|
return this.enqueue(async () => {
|
|
13416
14735
|
const token = validateCpolarAuthtoken(authtoken);
|
|
13417
|
-
await mkdir(this.stateRoot, {
|
|
13418
|
-
recursive: true,
|
|
13419
|
-
mode: 448
|
|
13420
|
-
});
|
|
13421
|
-
const temporary = join(this.stateRoot, `.cpolar.${randomBytes(12).toString("hex")}.tmp`);
|
|
13422
14736
|
const body = `authtoken: ${JSON.stringify(token)}\nconsole_ui: false\nupdate: false\ninspect_db_size: -1\n`;
|
|
13423
|
-
|
|
13424
|
-
await writeFile(temporary, body, {
|
|
13425
|
-
encoding: "utf8",
|
|
13426
|
-
flag: "wx",
|
|
13427
|
-
mode: 384
|
|
13428
|
-
});
|
|
13429
|
-
await rename(temporary, this.configFile);
|
|
13430
|
-
await restrictPrivateFile(this.configFile);
|
|
13431
|
-
} catch (error) {
|
|
13432
|
-
await rm(temporary, { force: true });
|
|
13433
|
-
throw error;
|
|
13434
|
-
}
|
|
14737
|
+
await writeManagedPrivateFile(this.stateDirectory, this.configFile, body, "cpolar");
|
|
13435
14738
|
this.configured = true;
|
|
13436
14739
|
this.errorCode = void 0;
|
|
13437
14740
|
});
|
|
@@ -13439,24 +14742,12 @@ var CpolarComponentManager = class {
|
|
|
13439
14742
|
/** Remove every cpolar file owned by DSH Mobile without touching global state. */
|
|
13440
14743
|
purge() {
|
|
13441
14744
|
return this.enqueue(async () => {
|
|
13442
|
-
await
|
|
13443
|
-
|
|
13444
|
-
|
|
13445
|
-
|
|
13446
|
-
|
|
13447
|
-
|
|
13448
|
-
recursive: true,
|
|
13449
|
-
force: true
|
|
13450
|
-
}),
|
|
13451
|
-
rm(this.logRoot, {
|
|
13452
|
-
recursive: true,
|
|
13453
|
-
force: true
|
|
13454
|
-
}),
|
|
13455
|
-
rm(this.stagingRoot, {
|
|
13456
|
-
recursive: true,
|
|
13457
|
-
force: true
|
|
13458
|
-
})
|
|
13459
|
-
]);
|
|
14745
|
+
await removeManagedPaths(this.stateDirectory, [
|
|
14746
|
+
this.componentRoot,
|
|
14747
|
+
this.stateRoot,
|
|
14748
|
+
this.logRoot,
|
|
14749
|
+
this.stagingRoot
|
|
14750
|
+
], "cpolar");
|
|
13460
14751
|
this.installed = false;
|
|
13461
14752
|
this.configured = false;
|
|
13462
14753
|
this.errorCode = void 0;
|
|
@@ -13470,82 +14761,25 @@ var CpolarComponentManager = class {
|
|
|
13470
14761
|
};
|
|
13471
14762
|
//#endregion
|
|
13472
14763
|
//#region src/caddy-files.ts
|
|
13473
|
-
/** Refuse link-shaped ancestors within
|
|
14764
|
+
/** Refuse link-shaped ancestors within Caddy's owned state root. */
|
|
13474
14765
|
async function assertCaddyParents(root, target) {
|
|
13475
|
-
|
|
13476
|
-
|
|
13477
|
-
|
|
13478
|
-
|
|
13479
|
-
|
|
13480
|
-
let cursor = parent;
|
|
13481
|
-
for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
|
|
13482
|
-
cursor = join(cursor, segment);
|
|
13483
|
-
paths.push(cursor);
|
|
13484
|
-
}
|
|
13485
|
-
for (const path of paths) {
|
|
13486
|
-
let entry;
|
|
13487
|
-
try {
|
|
13488
|
-
entry = await lstat(path);
|
|
13489
|
-
} catch (error) {
|
|
13490
|
-
if (error.code === "ENOENT") continue;
|
|
13491
|
-
throw error;
|
|
13492
|
-
}
|
|
13493
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
13494
|
-
}
|
|
14766
|
+
await assertManagedParents(root, target, "caddy");
|
|
14767
|
+
}
|
|
14768
|
+
/** Move a Caddy-owned path, preserving bounded Windows retries and ownership checks. */
|
|
14769
|
+
async function renameCaddyPath(root, source, destination) {
|
|
14770
|
+
await renameManagedPath(root, source, destination, "caddy");
|
|
13495
14771
|
}
|
|
13496
|
-
/** Create a
|
|
14772
|
+
/** Create a real Caddy directory, securing fresh directories and preserving existing ACLs. */
|
|
13497
14773
|
async function ensureCaddyDirectory(root, directory) {
|
|
13498
|
-
await
|
|
13499
|
-
await mkdir(directory, {
|
|
13500
|
-
recursive: true,
|
|
13501
|
-
mode: 448
|
|
13502
|
-
});
|
|
13503
|
-
const entry = await lstat(directory);
|
|
13504
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
13505
|
-
await restrictPrivateFile(directory, 448);
|
|
14774
|
+
await ensureManagedDirectory(root, directory, "caddy");
|
|
13506
14775
|
}
|
|
13507
|
-
/** Atomically replace one private configuration file
|
|
14776
|
+
/** Atomically replace one private Caddy configuration file. */
|
|
13508
14777
|
async function writeCaddyPrivateFile(root, file, body) {
|
|
13509
|
-
await
|
|
13510
|
-
try {
|
|
13511
|
-
const entry = await lstat(file);
|
|
13512
|
-
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("caddy_config_target_invalid");
|
|
13513
|
-
} catch (error) {
|
|
13514
|
-
if (error.code !== "ENOENT") throw error;
|
|
13515
|
-
}
|
|
13516
|
-
const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
13517
|
-
try {
|
|
13518
|
-
await writeFile(temporary, body, {
|
|
13519
|
-
encoding: "utf8",
|
|
13520
|
-
flag: "wx",
|
|
13521
|
-
mode: 384
|
|
13522
|
-
});
|
|
13523
|
-
await restrictPrivateFile(temporary);
|
|
13524
|
-
await rename(temporary, file);
|
|
13525
|
-
} finally {
|
|
13526
|
-
await rm(temporary, { force: true });
|
|
13527
|
-
}
|
|
14778
|
+
await writeManagedPrivateFile(root, file, body, "caddy");
|
|
13528
14779
|
}
|
|
13529
|
-
/** Delete only owned
|
|
14780
|
+
/** Delete only Caddy-owned children; unlink junctions and symlinks themselves. */
|
|
13530
14781
|
async function removeCaddyTree(root, target) {
|
|
13531
|
-
await
|
|
13532
|
-
let entry;
|
|
13533
|
-
try {
|
|
13534
|
-
entry = await lstat(target);
|
|
13535
|
-
} catch (error) {
|
|
13536
|
-
if (error.code === "ENOENT") return;
|
|
13537
|
-
throw error;
|
|
13538
|
-
}
|
|
13539
|
-
if (entry.isSymbolicLink()) {
|
|
13540
|
-
await unlink(target);
|
|
13541
|
-
return;
|
|
13542
|
-
}
|
|
13543
|
-
if (!entry.isDirectory()) {
|
|
13544
|
-
await unlink(target);
|
|
13545
|
-
return;
|
|
13546
|
-
}
|
|
13547
|
-
for (const name of await readdir(target)) await removeCaddyTree(root, join(target, name));
|
|
13548
|
-
await rmdir(target);
|
|
14782
|
+
await removeManagedTree(root, target, "caddy");
|
|
13549
14783
|
}
|
|
13550
14784
|
//#endregion
|
|
13551
14785
|
//#region src/caddy-component.ts
|
|
@@ -13622,7 +14856,7 @@ var CaddyComponentManager = class {
|
|
|
13622
14856
|
errorPrefix: "caddy"
|
|
13623
14857
|
}));
|
|
13624
14858
|
this.inspectExecutable = options.inspectExecutable ?? inspect;
|
|
13625
|
-
this.promote = options.promoteDirectory ??
|
|
14859
|
+
this.promote = options.promoteDirectory ?? ((source, destination) => renameCaddyPath(this.stateDirectory, source, destination));
|
|
13626
14860
|
}
|
|
13627
14861
|
/** Recheck a pinned executable before launching; no unverified local binary can run. */
|
|
13628
14862
|
async ensureExecutable() {
|
|
@@ -13673,6 +14907,7 @@ var CaddyComponentManager = class {
|
|
|
13673
14907
|
const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
|
|
13674
14908
|
let previous = false;
|
|
13675
14909
|
let promoted = false;
|
|
14910
|
+
let failed = false;
|
|
13676
14911
|
try {
|
|
13677
14912
|
const controller = new AbortController();
|
|
13678
14913
|
const timeout = setTimeout(() => {
|
|
@@ -13699,7 +14934,7 @@ var CaddyComponentManager = class {
|
|
|
13699
14934
|
try {
|
|
13700
14935
|
const entry = await lstat(this.componentStorage);
|
|
13701
14936
|
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
13702
|
-
await
|
|
14937
|
+
await renameCaddyPath(this.stateDirectory, this.componentStorage, backup);
|
|
13703
14938
|
previous = true;
|
|
13704
14939
|
} catch (error) {
|
|
13705
14940
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -13708,18 +14943,33 @@ var CaddyComponentManager = class {
|
|
|
13708
14943
|
await this.promote(candidate, this.componentStorage);
|
|
13709
14944
|
promoted = true;
|
|
13710
14945
|
} catch (error) {
|
|
13711
|
-
if (previous) {
|
|
13712
|
-
await
|
|
14946
|
+
if (previous) try {
|
|
14947
|
+
await renameCaddyPath(this.stateDirectory, backup, this.componentStorage);
|
|
13713
14948
|
previous = false;
|
|
14949
|
+
} catch (rollbackError) {
|
|
14950
|
+
this.installed = false;
|
|
14951
|
+
this.errorCode = "caddy_component_invalid";
|
|
14952
|
+
throw new AggregateError([error, rollbackError], "caddy_component_rollback_failed", { cause: error });
|
|
13714
14953
|
}
|
|
13715
14954
|
throw error;
|
|
13716
14955
|
}
|
|
13717
14956
|
this.installed = true;
|
|
13718
14957
|
this.errorCode = void 0;
|
|
14958
|
+
} catch (error) {
|
|
14959
|
+
failed = true;
|
|
14960
|
+
throw error;
|
|
13719
14961
|
} finally {
|
|
13720
|
-
|
|
13721
|
-
|
|
13722
|
-
|
|
14962
|
+
let cleanupError;
|
|
14963
|
+
for (const path of [
|
|
14964
|
+
staging,
|
|
14965
|
+
candidate,
|
|
14966
|
+
...previous && promoted ? [backup] : []
|
|
14967
|
+
]) try {
|
|
14968
|
+
await removeCaddyTree(this.stateDirectory, path);
|
|
14969
|
+
} catch (error) {
|
|
14970
|
+
cleanupError ??= error;
|
|
14971
|
+
}
|
|
14972
|
+
if (!failed && cleanupError !== void 0) throw cleanupError;
|
|
13723
14973
|
}
|
|
13724
14974
|
});
|
|
13725
14975
|
}
|
|
@@ -14321,12 +15571,12 @@ var ManagedOriginController = class {
|
|
|
14321
15571
|
gateway() {
|
|
14322
15572
|
return this.enabled ? this.controller().gateway() : void 0;
|
|
14323
15573
|
}
|
|
14324
|
-
initialize() {
|
|
15574
|
+
initialize(initialState) {
|
|
14325
15575
|
return this.enqueue(async () => {
|
|
14326
15576
|
if (this.initialized) return;
|
|
14327
15577
|
if (this.disposed) throw new Error("origin_controller_unavailable");
|
|
14328
15578
|
this.selected = (await this.options.modeStore.load()).mode;
|
|
14329
|
-
this.enabled = (await this.options.store.load()).enabled;
|
|
15579
|
+
this.enabled = (initialState ?? await this.options.store.load()).enabled;
|
|
14330
15580
|
await this.options.external.initialize();
|
|
14331
15581
|
await this.options.managed.initialize();
|
|
14332
15582
|
await this.options.external.setEnabled(false);
|
|
@@ -14365,11 +15615,11 @@ var ManagedOriginController = class {
|
|
|
14365
15615
|
if (!enabled) this.options.managed.cancelPendingStart?.();
|
|
14366
15616
|
return this.enqueue(async () => {
|
|
14367
15617
|
this.assertAvailable();
|
|
14368
|
-
if (!enabled) await this.stopBoth();
|
|
14369
15618
|
await this.options.store.save({
|
|
14370
15619
|
version: 1,
|
|
14371
15620
|
enabled
|
|
14372
15621
|
});
|
|
15622
|
+
if (!enabled) await this.stopBoth();
|
|
14373
15623
|
this.enabled = enabled;
|
|
14374
15624
|
if (enabled && !this.disposed) try {
|
|
14375
15625
|
await this.controller().setEnabled(true);
|
|
@@ -14383,11 +15633,11 @@ var ManagedOriginController = class {
|
|
|
14383
15633
|
this.options.managed.cancelPendingStart?.();
|
|
14384
15634
|
return this.enqueue(async () => {
|
|
14385
15635
|
this.assertAvailable();
|
|
14386
|
-
await this.stopBoth();
|
|
14387
15636
|
await this.options.store.save({
|
|
14388
15637
|
version: 1,
|
|
14389
15638
|
enabled: true
|
|
14390
15639
|
});
|
|
15640
|
+
await this.stopBoth();
|
|
14391
15641
|
this.enabled = true;
|
|
14392
15642
|
if (!this.disposed) try {
|
|
14393
15643
|
await this.controller().reconnect();
|
|
@@ -14832,24 +16082,69 @@ var PluginReleaseManager = class {
|
|
|
14832
16082
|
//#endregion
|
|
14833
16083
|
//#region src/file-logger.ts
|
|
14834
16084
|
const MAX_LOG_BYTES = 5242880;
|
|
14835
|
-
|
|
14836
|
-
|
|
16085
|
+
const MAX_QUEUED_BYTES = 262144;
|
|
16086
|
+
const PATH_PREFIX = "mobile_log";
|
|
16087
|
+
async function regularLogFile(file) {
|
|
16088
|
+
try {
|
|
16089
|
+
const entry = await lstat(file);
|
|
16090
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("mobile_log_target_invalid");
|
|
16091
|
+
return entry;
|
|
16092
|
+
} catch (error) {
|
|
16093
|
+
if (error.code === "ENOENT") return void 0;
|
|
16094
|
+
throw error;
|
|
16095
|
+
}
|
|
16096
|
+
}
|
|
16097
|
+
/** Open a private append-only file before registering a safely disposable Cordis exporter. */
|
|
16098
|
+
async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
|
|
14837
16099
|
const directory = join(stateDirectory, "logs");
|
|
14838
16100
|
const file = join(directory, "dsh-mobile.log");
|
|
14839
16101
|
const previous = `${file}.1`;
|
|
14840
|
-
await
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
await
|
|
14847
|
-
await
|
|
16102
|
+
await ensureManagedDirectory(stateDirectory, directory, PATH_PREFIX);
|
|
16103
|
+
const entry = await regularLogFile(file);
|
|
16104
|
+
if (entry !== void 0 && entry.size >= MAX_LOG_BYTES) {
|
|
16105
|
+
await regularLogFile(previous);
|
|
16106
|
+
await removeManagedTree(stateDirectory, previous, PATH_PREFIX);
|
|
16107
|
+
await renameManagedPath(stateDirectory, file, previous, PATH_PREFIX);
|
|
16108
|
+
await regularLogFile(previous);
|
|
16109
|
+
await restrictPrivateFile(previous);
|
|
16110
|
+
}
|
|
16111
|
+
await assertManagedParents(stateDirectory, file, PATH_PREFIX);
|
|
16112
|
+
await regularLogFile(file);
|
|
16113
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0), 384);
|
|
16114
|
+
let stream;
|
|
16115
|
+
try {
|
|
16116
|
+
const opened = await handle.stat();
|
|
16117
|
+
const actual = await regularLogFile(file);
|
|
16118
|
+
if (!opened.isFile() || actual === void 0 || actual.dev !== opened.dev || actual.ino !== opened.ino) throw new Error("mobile_log_target_invalid");
|
|
16119
|
+
await assertManagedParents(stateDirectory, file, PATH_PREFIX);
|
|
16120
|
+
await handle.chmod(384);
|
|
16121
|
+
await restrictPrivateFile(file);
|
|
16122
|
+
stream = options.createStream?.(handle) ?? handle.createWriteStream({
|
|
16123
|
+
encoding: "utf8",
|
|
16124
|
+
autoClose: true
|
|
16125
|
+
});
|
|
16126
|
+
} catch (error) {
|
|
16127
|
+
await handle.close();
|
|
16128
|
+
throw error;
|
|
14848
16129
|
}
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
16130
|
+
let stopped = false;
|
|
16131
|
+
let removeExporter;
|
|
16132
|
+
const stopLogging = (error) => {
|
|
16133
|
+
if (stopped) return;
|
|
16134
|
+
stopped = true;
|
|
16135
|
+
try {
|
|
16136
|
+
Promise.resolve(removeExporter?.()).catch(() => void 0);
|
|
16137
|
+
} catch (disposeError) {}
|
|
16138
|
+
stream.destroy();
|
|
16139
|
+
const candidate = error;
|
|
16140
|
+
const code = candidate !== null && typeof candidate === "object" && typeof candidate.code === "string" ? candidate.code : "write_failed";
|
|
16141
|
+
try {
|
|
16142
|
+
ctx.logger("dsh-mobile").warn("file logging disabled (%s); other logging remains available", code);
|
|
16143
|
+
} catch (loggerError) {}
|
|
16144
|
+
};
|
|
16145
|
+
stream.on("error", stopLogging);
|
|
16146
|
+
const closed = finished$1(stream, { cleanup: true }).catch((error) => {
|
|
16147
|
+
stopLogging(error);
|
|
14853
16148
|
});
|
|
14854
16149
|
const exporter = {
|
|
14855
16150
|
colors: false,
|
|
@@ -14859,20 +16154,40 @@ async function installMobileFileLogger(ctx, stateDirectory) {
|
|
|
14859
16154
|
"dsh-mobile": 3
|
|
14860
16155
|
},
|
|
14861
16156
|
export(message) {
|
|
14862
|
-
if (message.name !== "dsh-mobile") return;
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
16157
|
+
if (stopped || message.name !== "dsh-mobile") return;
|
|
16158
|
+
try {
|
|
16159
|
+
const record = {
|
|
16160
|
+
timestamp: new Date(message.ts).toISOString(),
|
|
16161
|
+
level: message.type,
|
|
16162
|
+
logger: message.name,
|
|
16163
|
+
message: Logger.format(exporter, message)
|
|
16164
|
+
};
|
|
16165
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
16166
|
+
if (stream.writableLength + Buffer.byteLength(line) > MAX_QUEUED_BYTES) {
|
|
16167
|
+
stopLogging(Object.assign(/* @__PURE__ */ new Error("file log queue exhausted"), { code: "backpressure" }));
|
|
16168
|
+
return;
|
|
16169
|
+
}
|
|
16170
|
+
stream.write(line);
|
|
16171
|
+
} catch (error) {
|
|
16172
|
+
stopLogging(error);
|
|
16173
|
+
}
|
|
14870
16174
|
}
|
|
14871
16175
|
};
|
|
14872
|
-
|
|
14873
|
-
|
|
14874
|
-
|
|
14875
|
-
|
|
16176
|
+
try {
|
|
16177
|
+
removeExporter = ctx.logger.exporter(exporter);
|
|
16178
|
+
ctx.effect(() => async () => {
|
|
16179
|
+
stopped = true;
|
|
16180
|
+
await removeExporter?.();
|
|
16181
|
+
stream.end();
|
|
16182
|
+
await closed;
|
|
16183
|
+
}, "dsh-mobile file logger");
|
|
16184
|
+
} catch (error) {
|
|
16185
|
+
stopped = true;
|
|
16186
|
+
stream.destroy();
|
|
16187
|
+
await closed;
|
|
16188
|
+
await removeExporter?.();
|
|
16189
|
+
throw error;
|
|
16190
|
+
}
|
|
14876
16191
|
return file;
|
|
14877
16192
|
}
|
|
14878
16193
|
//#endregion
|
|
@@ -15034,6 +16349,7 @@ function installedDshVersion() {
|
|
|
15034
16349
|
}
|
|
15035
16350
|
function mapAdminError(error) {
|
|
15036
16351
|
if (error instanceof HttpError) return error;
|
|
16352
|
+
if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
|
|
15037
16353
|
const code = error.code;
|
|
15038
16354
|
if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
|
|
15039
16355
|
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
@@ -15210,10 +16526,14 @@ function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenP
|
|
|
15210
16526
|
* An unreadable self-signed CA fails explicitly: falling back to the system
|
|
15211
16527
|
* trust store would hide a broken pairing identity behind a start timeout.
|
|
15212
16528
|
*/
|
|
15213
|
-
async function readFrpIngressTrustAnchor(settings, stateFile) {
|
|
16529
|
+
async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirname(stateFile)) {
|
|
15214
16530
|
if (!isFrpSelfSignedIngress(settings)) return void 0;
|
|
15215
16531
|
try {
|
|
15216
|
-
|
|
16532
|
+
const file = frpIngressPaths(stateFile).caCertFile;
|
|
16533
|
+
await assertManagedParents(ownedRoot, file, "frp_ingress");
|
|
16534
|
+
const entry = await lstat(file);
|
|
16535
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
|
|
16536
|
+
return await readFile(file, "utf8");
|
|
15217
16537
|
} catch (error) {
|
|
15218
16538
|
throw new Error("frp_ingress_ca_invalid", { cause: error });
|
|
15219
16539
|
}
|
|
@@ -15306,15 +16626,15 @@ async function apply(ctx, config) {
|
|
|
15306
16626
|
const frpComponent = new FrpComponentManager({ stateDirectory });
|
|
15307
16627
|
await frpComponent.initialize();
|
|
15308
16628
|
const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
|
|
15309
|
-
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity));
|
|
16629
|
+
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
|
|
15310
16630
|
await frpConfig.initialize();
|
|
15311
|
-
const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
|
|
16631
|
+
const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
|
|
15312
16632
|
await originConfig.initialize();
|
|
15313
16633
|
const caddyComponent = new CaddyComponentManager({ stateDirectory });
|
|
15314
16634
|
await caddyComponent.initialize();
|
|
15315
16635
|
const caddyConfig = new CaddyConfigStore(stateDirectory);
|
|
15316
16636
|
await caddyConfig.initialize();
|
|
15317
|
-
const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
|
|
16637
|
+
const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"), stateDirectory);
|
|
15318
16638
|
await cloudflaredTunnel.initialize();
|
|
15319
16639
|
const unregisterBuiltin = mobileAccess.registerExtension({
|
|
15320
16640
|
schemaVersion: 1,
|
|
@@ -15433,7 +16753,7 @@ async function apply(ctx, config) {
|
|
|
15433
16753
|
*/
|
|
15434
16754
|
const createFrpGateway = async (publicOrigin, settings) => {
|
|
15435
16755
|
if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
|
|
15436
|
-
const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
|
|
16756
|
+
const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), void 0, stateDirectory);
|
|
15437
16757
|
const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
|
|
15438
16758
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
15439
16759
|
try {
|
|
@@ -15482,6 +16802,7 @@ async function apply(ctx, config) {
|
|
|
15482
16802
|
store: tailscaleStore,
|
|
15483
16803
|
executable: funnelExecutable(import.meta.url),
|
|
15484
16804
|
stateDirectory: join(remoteDirectory, "tailscale"),
|
|
16805
|
+
ownedRoot: stateDirectory,
|
|
15485
16806
|
hostname: `dsh-${instanceId.slice(0, 12)}`,
|
|
15486
16807
|
createGateway: createRemoteGateway
|
|
15487
16808
|
}),
|
|
@@ -15509,9 +16830,9 @@ async function apply(ctx, config) {
|
|
|
15509
16830
|
* Only this module knows where the ingress material lives, so the anchor is
|
|
15510
16831
|
* read here from the ingress directory instead of being guessed downstream.
|
|
15511
16832
|
*/
|
|
15512
|
-
resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
|
|
16833
|
+
resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile, stateDirectory),
|
|
15513
16834
|
maintainIngressCertificate: async (settings, gateway) => {
|
|
15514
|
-
await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
|
|
16835
|
+
await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId, stateDirectory);
|
|
15515
16836
|
await gateway.refreshProvidedTls();
|
|
15516
16837
|
}
|
|
15517
16838
|
}),
|
|
@@ -15590,7 +16911,7 @@ async function apply(ctx, config) {
|
|
|
15590
16911
|
const frpGateway = remoteControllers.frp.gateway();
|
|
15591
16912
|
const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
|
|
15592
16913
|
try {
|
|
15593
|
-
const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
|
|
16914
|
+
const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile, stateDirectory);
|
|
15594
16915
|
if (trustAnchorPem === void 0) return { state: "unreachable" };
|
|
15595
16916
|
const started = performance.now();
|
|
15596
16917
|
return await defaultProbeDiscovery({
|
|
@@ -15713,6 +17034,17 @@ async function apply(ctx, config) {
|
|
|
15713
17034
|
sendJson(response, 200, await releaseManager.status(), false);
|
|
15714
17035
|
return;
|
|
15715
17036
|
}
|
|
17037
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/extensions/hosts`) {
|
|
17038
|
+
sendJson(response, 200, mobileAccess.hostStatus(), false);
|
|
17039
|
+
return;
|
|
17040
|
+
}
|
|
17041
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/extensions/recover`) {
|
|
17042
|
+
const body = await readJsonObject(request, 4096);
|
|
17043
|
+
if (typeof body.id !== "string" || body.confirm !== true) throw new HttpError(400, "bad_request");
|
|
17044
|
+
await mobileAccess.recoverExtension(body.id);
|
|
17045
|
+
sendJson(response, 200, mobileAccess.hostStatus(), false);
|
|
17046
|
+
return;
|
|
17047
|
+
}
|
|
15716
17048
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/release/update`) {
|
|
15717
17049
|
await readJsonObject(request, 4096);
|
|
15718
17050
|
sendJson(response, 200, await releaseManager.update(), false);
|
|
@@ -15946,7 +17278,7 @@ async function apply(ctx, config) {
|
|
|
15946
17278
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
|
|
15947
17279
|
const settings = frpConfig.settings();
|
|
15948
17280
|
if (settings === void 0) throw new HttpError(409, "frp_config_missing");
|
|
15949
|
-
const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
|
|
17281
|
+
const check = await frpIngressSelfCheck(settings, remoteDeviceFile, stateDirectory);
|
|
15950
17282
|
const entry = resolveFrpEntryProbe(settings);
|
|
15951
17283
|
const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
|
|
15952
17284
|
sendJson(response, 200, {
|
|
@@ -16061,10 +17393,10 @@ async function apply(ctx, config) {
|
|
|
16061
17393
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
16062
17394
|
await remoteProviders.mutate(async () => {
|
|
16063
17395
|
await remoteControllers.frp.setEnabled(false);
|
|
16064
|
-
await
|
|
16065
|
-
frpComponent.purge(),
|
|
16066
|
-
frpConfig.purge(),
|
|
16067
|
-
purgeFrpIngressCertificates(remoteDeviceFile)
|
|
17396
|
+
await settleCleanupSteps([
|
|
17397
|
+
() => frpComponent.purge().then(() => void 0),
|
|
17398
|
+
() => frpConfig.purge().then(() => void 0),
|
|
17399
|
+
() => purgeFrpIngressCertificates(remoteDeviceFile, stateDirectory)
|
|
16068
17400
|
]);
|
|
16069
17401
|
});
|
|
16070
17402
|
sendJson(response, 200, remotePayload(), false);
|
|
@@ -16130,15 +17462,23 @@ async function apply(ctx, config) {
|
|
|
16130
17462
|
kind: "error",
|
|
16131
17463
|
text: "请带上需求,例如:/mobile 把手机端改成深色主题"
|
|
16132
17464
|
};
|
|
17465
|
+
const hostStates = new Map(mobileAccess.hostStatus().hosts.map((host) => [host.id, host]));
|
|
16133
17466
|
const guide = buildMobileGuide({
|
|
16134
17467
|
directory: stateDirectory,
|
|
16135
17468
|
hasCustomCss: await existsRegularFile(template.customCssFile),
|
|
16136
17469
|
hasCustomJs: await existsRegularFile(template.customScriptFile),
|
|
16137
|
-
extensions: mobileAccess.manifest().map((entry) =>
|
|
16138
|
-
|
|
16139
|
-
|
|
16140
|
-
|
|
16141
|
-
|
|
17470
|
+
extensions: mobileAccess.manifest().map((entry) => {
|
|
17471
|
+
const host = hostStates.get(entry.id);
|
|
17472
|
+
return {
|
|
17473
|
+
id: entry.id,
|
|
17474
|
+
name: entry.name,
|
|
17475
|
+
version: entry.version,
|
|
17476
|
+
...host === void 0 ? {} : {
|
|
17477
|
+
executionMode: host.mode,
|
|
17478
|
+
executionState: host.state
|
|
17479
|
+
}
|
|
17480
|
+
};
|
|
17481
|
+
}),
|
|
16142
17482
|
failedExtensionCount: mobileAccess.status().failed
|
|
16143
17483
|
});
|
|
16144
17484
|
agent.steer(createUserMessage({
|
|
@@ -16159,7 +17499,7 @@ async function apply(ctx, config) {
|
|
|
16159
17499
|
}
|
|
16160
17500
|
});
|
|
16161
17501
|
try {
|
|
16162
|
-
await mobileAccess.startLocal(template.extensionsDir, ctx);
|
|
17502
|
+
await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
|
|
16163
17503
|
await lanController.initialize();
|
|
16164
17504
|
const stores = {
|
|
16165
17505
|
tailscale: tailscaleStore,
|
|
@@ -16168,8 +17508,9 @@ async function apply(ctx, config) {
|
|
|
16168
17508
|
frp: frpStore,
|
|
16169
17509
|
origin: originStore
|
|
16170
17510
|
};
|
|
16171
|
-
|
|
16172
|
-
for (const provider of REMOTE_PROVIDERS) await remoteControllers[provider].initialize();
|
|
17511
|
+
const prepared = await prepareDisabledRemoteProviders(remoteProviders.selected, stores);
|
|
17512
|
+
for (const provider of REMOTE_PROVIDERS) if (provider === remoteProviders.selected) await remoteControllers[provider].initialize();
|
|
17513
|
+
else await remoteControllers[provider].initialize(prepared.get(provider));
|
|
16173
17514
|
const lanDiscovery = lanGateway?.discoveryStatus();
|
|
16174
17515
|
if (lanDiscovery !== void 0 && !lanDiscovery.broadcast) logger.warn("broadcast discovery is unavailable (%s); mDNS is still published, so pair manually or free the UDP port", lanDiscovery.errorCode ?? "unknown_cause");
|
|
16175
17516
|
if (lanDiscovery?.mobileAssetsErrorCode !== void 0) logger.warn("a bundled mobile asset is missing (%s); the phone frontend will serve without it", lanDiscovery.mobileAssetsErrorCode);
|
|
@@ -16211,6 +17552,6 @@ async function apply(ctx, config) {
|
|
|
16211
17552
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
16212
17553
|
}
|
|
16213
17554
|
//#endregion
|
|
16214
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
|
|
17555
|
+
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
|
|
16215
17556
|
|
|
16216
17557
|
//# sourceMappingURL=index.mjs.map
|