dsh-mobile 0.5.5 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/CONTRIBUTORS.md +23 -6
- package/README.en.md +120 -39
- package/README.md +120 -41
- package/SECURITY.md +16 -5
- package/THIRD_PARTY_NOTICES.md +9 -1
- package/docs/ATTACH_EXISTING_FRPS.en.md +9 -8
- package/docs/ATTACH_EXISTING_FRPS.md +16 -13
- package/docs/CADDY_MANAGED.en.md +61 -0
- package/docs/CADDY_MANAGED.md +61 -0
- package/docs/CLIENT_MODULES.en.md +33 -0
- package/docs/CLIENT_MODULES.md +33 -0
- package/docs/CLOUDFLARE_TUNNEL.en.md +14 -8
- package/docs/CLOUDFLARE_TUNNEL.md +14 -8
- package/docs/README.md +30 -18
- package/docs/SELF_HOSTED_FRP.en.md +6 -6
- package/docs/SELF_HOSTED_FRP.md +6 -6
- package/docs/SELF_HOSTED_ORIGIN.en.md +67 -17
- package/docs/SELF_HOSTED_ORIGIN.md +1 -1
- package/docs/SLOW_CONNECTIONS.en.md +16 -0
- package/docs/SLOW_CONNECTIONS.md +16 -0
- package/docs/TRUSTED_NETWORKS.en.md +26 -0
- package/docs/TRUSTED_NETWORKS.md +26 -0
- package/lib/cli.js +33 -5
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1184 -24
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +112 -15
- package/lib/index.mjs +2173 -330
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-compat.js +1 -1
- package/lib/mobile-compat.js.map +1 -1
- package/lib/mobile-layout.js +57 -9
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +14 -3
package/lib/index.mjs
CHANGED
|
@@ -70,14 +70,14 @@ var BoundedRateLimiter = class {
|
|
|
70
70
|
function opaqueToken() {
|
|
71
71
|
return randomBytes(32).toString("base64url");
|
|
72
72
|
}
|
|
73
|
-
function digest(value) {
|
|
73
|
+
function digest$1(value) {
|
|
74
74
|
return createHash("sha256").update(value, "utf8").digest();
|
|
75
75
|
}
|
|
76
76
|
function digestHex(value) {
|
|
77
|
-
return digest(value).toString("hex");
|
|
77
|
+
return digest$1(value).toString("hex");
|
|
78
78
|
}
|
|
79
79
|
function matchesDigest(value, expected) {
|
|
80
|
-
return timingSafeEqual(digest(value), expected);
|
|
80
|
+
return timingSafeEqual(digest$1(value), expected);
|
|
81
81
|
}
|
|
82
82
|
function normalizeLabel(value) {
|
|
83
83
|
const label = (value ?? "Mobile device").normalize("NFC").trim();
|
|
@@ -175,7 +175,7 @@ var AccessController = class {
|
|
|
175
175
|
const record = Object.freeze({
|
|
176
176
|
key,
|
|
177
177
|
deviceId,
|
|
178
|
-
csrfDigest: digest(csrfToken),
|
|
178
|
+
csrfDigest: digest$1(csrfToken),
|
|
179
179
|
createdAt: now,
|
|
180
180
|
expiresAt: Math.min(now + this.options.sessionTtlMs, deviceExpiresAt)
|
|
181
181
|
});
|
|
@@ -196,7 +196,7 @@ var AccessController = class {
|
|
|
196
196
|
const token = opaqueToken();
|
|
197
197
|
const expiresAt = this.now() + ttl;
|
|
198
198
|
this.pairingWindow = Object.freeze({
|
|
199
|
-
digest: digest(token),
|
|
199
|
+
digest: digest$1(token),
|
|
200
200
|
expiresAt
|
|
201
201
|
});
|
|
202
202
|
return Object.freeze({
|
|
@@ -289,7 +289,7 @@ var AccessController = class {
|
|
|
289
289
|
}
|
|
290
290
|
requireDevice(deviceToken, now) {
|
|
291
291
|
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
292
|
-
const tokenDigest = digest(deviceToken);
|
|
292
|
+
const tokenDigest = digest$1(deviceToken);
|
|
293
293
|
const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
|
|
294
294
|
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
295
295
|
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
@@ -904,6 +904,7 @@ const Config = z.object({
|
|
|
904
904
|
maxWebSockets: z.natural(),
|
|
905
905
|
maxBodyBytes: z.natural(),
|
|
906
906
|
upstreamTimeoutMs: z.natural(),
|
|
907
|
+
upstreamApiTimeoutMs: z.natural(),
|
|
907
908
|
rateLimitWindowMs: z.natural(),
|
|
908
909
|
maxPairingAttempts: z.natural(),
|
|
909
910
|
maxRateLimitKeys: z.natural()
|
|
@@ -1052,6 +1053,7 @@ function parseGatewayConfig(raw) {
|
|
|
1052
1053
|
maxWebSockets: integer(value.maxWebSockets, "maxWebSockets", 16, 1, 256),
|
|
1053
1054
|
maxBodyBytes: integer(value.maxBodyBytes, "maxBodyBytes", 167772160, 1024, 268435456),
|
|
1054
1055
|
upstreamTimeoutMs: integer(value.upstreamTimeoutMs, "upstreamTimeoutMs", 3e4, 1e3, 3e5),
|
|
1056
|
+
upstreamApiTimeoutMs: integer(value.upstreamApiTimeoutMs, "upstreamApiTimeoutMs", 0, 0, 2147483647),
|
|
1055
1057
|
rateLimitWindowMs: integer(value.rateLimitWindowMs, "rateLimitWindowMs", 6e4, 1e3, 36e5),
|
|
1056
1058
|
maxPairingAttempts: integer(value.maxPairingAttempts, "maxPairingAttempts", 8, 1, 100),
|
|
1057
1059
|
maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
|
|
@@ -1133,6 +1135,180 @@ async function restrictPrivateFile(file, mode = 384) {
|
|
|
1133
1135
|
});
|
|
1134
1136
|
}
|
|
1135
1137
|
//#endregion
|
|
1138
|
+
//#region src/client-module-preferences.ts
|
|
1139
|
+
const MAX_RECORD_BYTES = 1048576;
|
|
1140
|
+
/** Validate untrusted JSON selections; graph membership is checked by the Gateway. */
|
|
1141
|
+
function parseExcludedClientModules(value) {
|
|
1142
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id || /[\u0000-\u001f\u007f]/u.test(id))) throw new Error("excluded_client_modules_invalid");
|
|
1143
|
+
const ids = value;
|
|
1144
|
+
if (new Set(ids).size !== ids.length) throw new Error("excluded_client_modules_invalid");
|
|
1145
|
+
return Object.freeze([...ids]);
|
|
1146
|
+
}
|
|
1147
|
+
function deviceKey(deviceId) {
|
|
1148
|
+
if (!/^[a-f\d]{32}$/u.test(deviceId)) throw new Error("client_module_device_invalid");
|
|
1149
|
+
}
|
|
1150
|
+
function parseRecord(value) {
|
|
1151
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("client_module_preferences_invalid");
|
|
1152
|
+
const record = value;
|
|
1153
|
+
if (record.version !== 1 || record.devices === null || typeof record.devices !== "object" || Array.isArray(record.devices) || Object.keys(record).some((key) => ![
|
|
1154
|
+
"version",
|
|
1155
|
+
"computer",
|
|
1156
|
+
"devices"
|
|
1157
|
+
].includes(key))) throw new Error("client_module_preferences_invalid");
|
|
1158
|
+
const devices = {};
|
|
1159
|
+
for (const [id, excluded] of Object.entries(record.devices)) {
|
|
1160
|
+
deviceKey(id);
|
|
1161
|
+
devices[id] = parseExcludedClientModules(excluded);
|
|
1162
|
+
}
|
|
1163
|
+
return Object.freeze({
|
|
1164
|
+
version: 1,
|
|
1165
|
+
devices: Object.freeze(devices),
|
|
1166
|
+
...record.computer === void 0 ? {} : { computer: parseExcludedClientModules(record.computer) }
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
/** Atomic, serialized preferences shared by the LAN and remote Gateway instances. */
|
|
1170
|
+
var ClientModulePreferenceStore = class {
|
|
1171
|
+
file;
|
|
1172
|
+
defaults;
|
|
1173
|
+
value = Object.freeze({
|
|
1174
|
+
version: 1,
|
|
1175
|
+
devices: Object.freeze({})
|
|
1176
|
+
});
|
|
1177
|
+
initialization;
|
|
1178
|
+
queue = Promise.resolve();
|
|
1179
|
+
constructor(file, defaults) {
|
|
1180
|
+
this.file = file;
|
|
1181
|
+
if (!isAbsolute(file)) throw new Error("client module preference path must be absolute");
|
|
1182
|
+
this.defaults = parseExcludedClientModules(defaults);
|
|
1183
|
+
}
|
|
1184
|
+
/** Read existing state once. Missing state is not written during initialization. */
|
|
1185
|
+
initialize() {
|
|
1186
|
+
this.initialization ??= this.load();
|
|
1187
|
+
return this.initialization;
|
|
1188
|
+
}
|
|
1189
|
+
async load() {
|
|
1190
|
+
let information;
|
|
1191
|
+
try {
|
|
1192
|
+
information = await lstat(this.file);
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
if (error.code === "ENOENT") return;
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
if (!information.isFile() || information.isSymbolicLink() || information.size > MAX_RECORD_BYTES) throw new Error("client module preferences must be a regular file no larger than 1 MiB");
|
|
1198
|
+
this.value = parseRecord(JSON.parse(await readFile(this.file, "utf8")));
|
|
1199
|
+
}
|
|
1200
|
+
selection(deviceId) {
|
|
1201
|
+
if (deviceId !== void 0) deviceKey(deviceId);
|
|
1202
|
+
const computer = this.value.computer ?? this.defaults;
|
|
1203
|
+
const device = deviceId === void 0 ? void 0 : this.value.devices[deviceId];
|
|
1204
|
+
return Object.freeze({
|
|
1205
|
+
excludedClientModules: device ?? computer,
|
|
1206
|
+
defaultExcludedClientModules: computer,
|
|
1207
|
+
source: device !== void 0 ? "device" : this.value.computer !== void 0 ? "computer" : "plugin"
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
exclusive(action) {
|
|
1211
|
+
const operation = this.queue.then(action);
|
|
1212
|
+
this.queue = operation.then(() => void 0, () => void 0);
|
|
1213
|
+
return operation;
|
|
1214
|
+
}
|
|
1215
|
+
/** Resolve one device override, otherwise the computer or plugin default. */
|
|
1216
|
+
async read(deviceId) {
|
|
1217
|
+
await this.initialize();
|
|
1218
|
+
return this.exclusive(async () => this.selection(deviceId));
|
|
1219
|
+
}
|
|
1220
|
+
/** Revalidate against the current boot manifest inside the serialized mutation. */
|
|
1221
|
+
async configure(excluded, deviceId, validate) {
|
|
1222
|
+
const ids = parseExcludedClientModules(excluded);
|
|
1223
|
+
if (deviceId !== void 0) deviceKey(deviceId);
|
|
1224
|
+
await this.initialize();
|
|
1225
|
+
return this.exclusive(async () => {
|
|
1226
|
+
await validate(ids);
|
|
1227
|
+
const next = deviceId === void 0 ? {
|
|
1228
|
+
...this.value,
|
|
1229
|
+
computer: ids
|
|
1230
|
+
} : {
|
|
1231
|
+
...this.value,
|
|
1232
|
+
devices: {
|
|
1233
|
+
...this.value.devices,
|
|
1234
|
+
[deviceId]: ids
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
await this.save(next);
|
|
1238
|
+
return this.selection(deviceId);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
/** Remove only the selected override; retain all other device selections. */
|
|
1242
|
+
async reset(deviceId, validate) {
|
|
1243
|
+
if (deviceId !== void 0) deviceKey(deviceId);
|
|
1244
|
+
await this.initialize();
|
|
1245
|
+
return this.exclusive(async () => {
|
|
1246
|
+
const devices = { ...this.value.devices };
|
|
1247
|
+
if (deviceId !== void 0) delete devices[deviceId];
|
|
1248
|
+
const next = {
|
|
1249
|
+
version: 1,
|
|
1250
|
+
devices,
|
|
1251
|
+
...deviceId === void 0 || this.value.computer === void 0 ? {} : { computer: this.value.computer }
|
|
1252
|
+
};
|
|
1253
|
+
await validate(deviceId === void 0 ? this.defaults : this.value.computer ?? this.defaults);
|
|
1254
|
+
await this.save(next);
|
|
1255
|
+
return this.selection(deviceId);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
/** Remove exactly the revoked device's record without evaluating its obsolete graph. */
|
|
1259
|
+
removeDevice(deviceId) {
|
|
1260
|
+
return this.removeDevices([deviceId]);
|
|
1261
|
+
}
|
|
1262
|
+
/** Remove an explicit set of revoked identities, not every record in the shared store. */
|
|
1263
|
+
async removeDevices(deviceIds) {
|
|
1264
|
+
for (const deviceId of deviceIds) deviceKey(deviceId);
|
|
1265
|
+
await this.initialize();
|
|
1266
|
+
await this.exclusive(async () => {
|
|
1267
|
+
if (!deviceIds.some((id) => this.value.devices[id] !== void 0)) return;
|
|
1268
|
+
const devices = { ...this.value.devices };
|
|
1269
|
+
for (const deviceId of deviceIds) delete devices[deviceId];
|
|
1270
|
+
await this.save({
|
|
1271
|
+
...this.value,
|
|
1272
|
+
devices
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
async save(record) {
|
|
1277
|
+
const valid = parseRecord(record);
|
|
1278
|
+
const body = `${JSON.stringify(valid)}\n`;
|
|
1279
|
+
if (Buffer.byteLength(body) > MAX_RECORD_BYTES) throw new Error("client_module_preferences_limit");
|
|
1280
|
+
const directory = dirname(this.file);
|
|
1281
|
+
if (await mkdir(directory, {
|
|
1282
|
+
recursive: true,
|
|
1283
|
+
mode: 448
|
|
1284
|
+
}) !== void 0) await restrictPrivateFile(directory, 448);
|
|
1285
|
+
try {
|
|
1286
|
+
const existing = await lstat(this.file);
|
|
1287
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw new Error("client module preference target must be a regular file");
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
if (error.code !== "ENOENT") throw error;
|
|
1290
|
+
}
|
|
1291
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
1292
|
+
try {
|
|
1293
|
+
await writeFile(temporary, body, {
|
|
1294
|
+
encoding: "utf8",
|
|
1295
|
+
flag: "wx",
|
|
1296
|
+
mode: 384
|
|
1297
|
+
});
|
|
1298
|
+
await restrictPrivateFile(temporary);
|
|
1299
|
+
await rename(temporary, this.file);
|
|
1300
|
+
this.value = valid;
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
try {
|
|
1303
|
+
await rm(temporary, { force: true });
|
|
1304
|
+
} catch (cleanupError) {
|
|
1305
|
+
throw new AggregateError([error, cleanupError], "client module preference write and temporary cleanup failed");
|
|
1306
|
+
}
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
//#endregion
|
|
1136
1312
|
//#region src/control.ts
|
|
1137
1313
|
/** Keeps one runtime aligned with a changing network selection. */
|
|
1138
1314
|
var FollowingMobileAccessRuntime = class {
|
|
@@ -1380,6 +1556,88 @@ var MobileAccessGatewayController = class {
|
|
|
1380
1556
|
return run;
|
|
1381
1557
|
}
|
|
1382
1558
|
};
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/client-module-recovery.ts
|
|
1561
|
+
const CLIENT_MODULE_RECOVERY_COPY = {
|
|
1562
|
+
zh: {
|
|
1563
|
+
title: "移动页面模块需要调整",
|
|
1564
|
+
explanation: "电脑上的插件或依赖关系已变化,之前保存的模块选择无法加载当前页面。",
|
|
1565
|
+
scope: "可为本设备加载全部模块并重新打开 DSH。不会修改电脑默认设置、其他设备或已安装的插件。",
|
|
1566
|
+
action: "为本设备加载全部模块并重试",
|
|
1567
|
+
working: "正在更新本设备的模块选择…",
|
|
1568
|
+
failed: "未能恢复。请确认电脑和远程通道仍在线,然后重试。"
|
|
1569
|
+
},
|
|
1570
|
+
en: {
|
|
1571
|
+
title: "Mobile page modules need attention",
|
|
1572
|
+
explanation: "The computer’s plugins or dependencies have changed. The saved module selection can no longer load this page.",
|
|
1573
|
+
scope: "Load all modules for this device and reopen DSH. Computer defaults, other devices, and installed plugins are unchanged.",
|
|
1574
|
+
action: "Load all modules for this device and retry",
|
|
1575
|
+
working: "Updating this device’s module selection…",
|
|
1576
|
+
failed: "Could not restore the page. Check that the computer and remote connection are online, then retry."
|
|
1577
|
+
},
|
|
1578
|
+
it: {
|
|
1579
|
+
title: "I moduli della pagina mobile richiedono attenzione",
|
|
1580
|
+
explanation: "I plugin o le dipendenze del computer sono cambiati. La selezione salvata non può più caricare questa pagina.",
|
|
1581
|
+
scope: "Carica tutti i moduli per questo dispositivo e riapri DSH. I predefiniti del computer, gli altri dispositivi e i plugin installati non cambiano.",
|
|
1582
|
+
action: "Carica tutti i moduli per questo dispositivo e riprova",
|
|
1583
|
+
working: "Aggiornamento dei moduli per questo dispositivo…",
|
|
1584
|
+
failed: "Ripristino non riuscito. Verifica che il computer e la connessione remota siano online, poi riprova."
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
/** Render an explicit device-only recovery action without interpolating graph diagnostics or credentials.
|
|
1588
|
+
* @param locale - Negotiated browser display language.
|
|
1589
|
+
* @returns An owned HTML page which changes no selection until the user activates its button.
|
|
1590
|
+
*/
|
|
1591
|
+
function renderClientModuleRecoveryPage(locale) {
|
|
1592
|
+
const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
|
|
1593
|
+
return `<!doctype html>
|
|
1594
|
+
<html lang="${locale === "zh" ? "zh-CN" : locale}">
|
|
1595
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
1596
|
+
<meta name="color-scheme" content="light dark"><title>${copy.title}</title>
|
|
1597
|
+
<style>
|
|
1598
|
+
body{margin:0;background:Canvas;color:CanvasText;font:16px/1.5 system-ui,sans-serif}
|
|
1599
|
+
main{box-sizing:border-box;max-width:640px;margin:32px auto;padding:24px max(24px,env(safe-area-inset-left)) 24px max(24px,env(safe-area-inset-right));overflow-wrap:anywhere}
|
|
1600
|
+
h1{font-size:24px;line-height:1.3}button{min-height:48px;max-width:100%;padding:12px 20px;border:1px solid ButtonText;border-radius:12px;background:ButtonFace;color:ButtonText;font:inherit;cursor:pointer}
|
|
1601
|
+
button:focus-visible{outline:2px solid Highlight;outline-offset:3px}button:disabled{opacity:.6;cursor:wait}#recovery-status{min-height:1.5em}
|
|
1602
|
+
</style></head>
|
|
1603
|
+
<body><main><h1>${copy.title}</h1><p>${copy.explanation}</p><p>${copy.scope}</p>
|
|
1604
|
+
<button id="load-all-modules" type="button">${copy.action}</button><p id="recovery-status" role="status" aria-live="polite"></p>
|
|
1605
|
+
</main><script>${renderClientModuleRecoveryScript(locale)}<\/script></body></html>
|
|
1606
|
+
`;
|
|
1607
|
+
}
|
|
1608
|
+
/** Render the fixed same-origin action; CSRF values are read from the active browser cookie only at user activation.
|
|
1609
|
+
* @param locale - Negotiated browser display language.
|
|
1610
|
+
* @returns JavaScript that preserves selections on failure and reloads only after an authorized save succeeds.
|
|
1611
|
+
*/
|
|
1612
|
+
function renderClientModuleRecoveryScript(locale) {
|
|
1613
|
+
const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
|
|
1614
|
+
return `(() => {
|
|
1615
|
+
const button = document.getElementById('load-all-modules')
|
|
1616
|
+
const status = document.getElementById('recovery-status')
|
|
1617
|
+
button.addEventListener('click', async () => {
|
|
1618
|
+
if (button.disabled) return
|
|
1619
|
+
button.disabled = true
|
|
1620
|
+
status.textContent = ${JSON.stringify(copy.working)}
|
|
1621
|
+
try {
|
|
1622
|
+
const values = document.cookie.split(';').map(value => value.trim())
|
|
1623
|
+
const host = values.filter(value => value.startsWith('__Host-dsh_ma_csrf='))
|
|
1624
|
+
const candidates = host.length === 0 ? values.filter(value => value.startsWith('dsh_ma_csrf=')) : host
|
|
1625
|
+
if (candidates.length !== 1) throw new Error('csrf_unavailable')
|
|
1626
|
+
const csrf = candidates[0].slice(candidates[0].indexOf('=') + 1)
|
|
1627
|
+
const response = await fetch('/mobile-access/client-modules', {
|
|
1628
|
+
method: 'POST', credentials: 'same-origin', redirect: 'error', cache: 'no-store',
|
|
1629
|
+
headers: { 'content-type': 'application/json', 'x-dsh-mobile-csrf': csrf },
|
|
1630
|
+
body: JSON.stringify({ excludedClientModules: [] }),
|
|
1631
|
+
})
|
|
1632
|
+
if (!response.ok) throw new Error('recovery_failed')
|
|
1633
|
+
location.reload()
|
|
1634
|
+
} catch {
|
|
1635
|
+
status.textContent = ${JSON.stringify(copy.failed)}
|
|
1636
|
+
button.disabled = false
|
|
1637
|
+
}
|
|
1638
|
+
})
|
|
1639
|
+
})()`;
|
|
1640
|
+
}
|
|
1383
1641
|
function unwrapBrackets(hostname) {
|
|
1384
1642
|
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1385
1643
|
}
|
|
@@ -1621,10 +1879,8 @@ function commentRanges(html) {
|
|
|
1621
1879
|
/**
|
|
1622
1880
|
* Load the compatibility bundle synchronously, before the first DSH script.
|
|
1623
1881
|
*
|
|
1624
|
-
* Returns the HTML unchanged when there is no script to anchor to.
|
|
1625
|
-
*
|
|
1626
|
-
* cannot place it in must still be served: throwing here took the entire dedicated
|
|
1627
|
-
* mobile frontend down with a 502.
|
|
1882
|
+
* Returns the HTML unchanged when there is no executable script to anchor to.
|
|
1883
|
+
* The bundle supplies missing browser APIs before the page's scripts execute.
|
|
1628
1884
|
*/
|
|
1629
1885
|
function ensureMobileCompatibility(html) {
|
|
1630
1886
|
const comments = commentRanges(html);
|
|
@@ -2233,6 +2489,12 @@ function normalizeRoutePath(value) {
|
|
|
2233
2489
|
if (normalizedInput.split("/").some((part) => part === ".." || part === ".")) throw new MobileExtensionError("invalid_route", "extension route path is invalid");
|
|
2234
2490
|
return normalizedInput === "/" ? "/" : normalizedInput.replace(/\/+$/u, "");
|
|
2235
2491
|
}
|
|
2492
|
+
/** Validate and snapshot an optional per-operation timeout override. */
|
|
2493
|
+
function operationTimeout(value, code) {
|
|
2494
|
+
if (value === void 0) return void 0;
|
|
2495
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 3e5) throw new MobileExtensionError(code, "operation timeoutMs is invalid");
|
|
2496
|
+
return value;
|
|
2497
|
+
}
|
|
2236
2498
|
function validateDefinition(definition) {
|
|
2237
2499
|
const manifest = parseExtensionManifest({
|
|
2238
2500
|
schemaVersion: definition.schemaVersion,
|
|
@@ -2242,9 +2504,17 @@ function validateDefinition(definition) {
|
|
|
2242
2504
|
...definition.description === void 0 ? {} : { description: definition.description }
|
|
2243
2505
|
});
|
|
2244
2506
|
const actionNames = /* @__PURE__ */ new Set();
|
|
2507
|
+
const actions = {};
|
|
2245
2508
|
for (const [name, action] of Object.entries(definition.actions ?? {})) {
|
|
2246
2509
|
if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== "object" || typeof action.run !== "function" || actionNames.has(name)) throw new MobileExtensionError("invalid_action", `invalid action ${name}`);
|
|
2247
2510
|
actionNames.add(name);
|
|
2511
|
+
const actionTimeout = operationTimeout(action.timeoutMs, "invalid_action");
|
|
2512
|
+
const actionInput = action.input;
|
|
2513
|
+
actions[name] = Object.freeze({
|
|
2514
|
+
...actionInput === void 0 ? {} : { input: actionInput },
|
|
2515
|
+
run: action.run.bind(action),
|
|
2516
|
+
...actionTimeout === void 0 ? {} : { timeoutMs: actionTimeout }
|
|
2517
|
+
});
|
|
2248
2518
|
}
|
|
2249
2519
|
const routeNames = /* @__PURE__ */ new Set();
|
|
2250
2520
|
const routes = (definition.routes ?? []).map((route) => {
|
|
@@ -2258,11 +2528,14 @@ function validateDefinition(definition) {
|
|
|
2258
2528
|
"PATCH",
|
|
2259
2529
|
"DELETE"
|
|
2260
2530
|
].includes(method)) throw new MobileExtensionError("invalid_route", "unsupported extension route method");
|
|
2261
|
-
const
|
|
2262
|
-
|
|
2531
|
+
const routeTimeout = operationTimeout(route.timeoutMs, "invalid_route");
|
|
2532
|
+
const normalized = Object.freeze({
|
|
2263
2533
|
method,
|
|
2264
|
-
path: normalizeRoutePath(route.path)
|
|
2265
|
-
|
|
2534
|
+
path: normalizeRoutePath(route.path),
|
|
2535
|
+
...route.kind === void 0 ? {} : { kind: route.kind },
|
|
2536
|
+
handle: route.handle.bind(route),
|
|
2537
|
+
...routeTimeout === void 0 ? {} : { timeoutMs: routeTimeout }
|
|
2538
|
+
});
|
|
2266
2539
|
const key = routeKey(normalized);
|
|
2267
2540
|
if (routeNames.has(key)) throw new MobileExtensionError("duplicate_route", `duplicate route ${key}`);
|
|
2268
2541
|
routeNames.add(key);
|
|
@@ -2270,7 +2543,7 @@ function validateDefinition(definition) {
|
|
|
2270
2543
|
});
|
|
2271
2544
|
return Object.freeze({
|
|
2272
2545
|
...manifest,
|
|
2273
|
-
...definition.actions === void 0 ? {} : { actions: Object.freeze(
|
|
2546
|
+
...definition.actions === void 0 ? {} : { actions: Object.freeze(actions) },
|
|
2274
2547
|
...routes.length === 0 ? {} : { routes: Object.freeze(routes) }
|
|
2275
2548
|
});
|
|
2276
2549
|
}
|
|
@@ -2315,6 +2588,8 @@ var MobileAccessService = class extends Service {
|
|
|
2315
2588
|
local = /* @__PURE__ */ new Map();
|
|
2316
2589
|
retired = /* @__PURE__ */ new Map();
|
|
2317
2590
|
failures = /* @__PURE__ */ new Map();
|
|
2591
|
+
/** Extensions with caller-detached, still-unresolved operations. */
|
|
2592
|
+
abandoned = /* @__PURE__ */ new Map();
|
|
2318
2593
|
contentListeners = /* @__PURE__ */ new Set();
|
|
2319
2594
|
contentHash = createHash("sha256").update("").digest("hex");
|
|
2320
2595
|
localRoot;
|
|
@@ -2434,30 +2709,65 @@ var MobileAccessService = class extends Service {
|
|
|
2434
2709
|
name: asset.name
|
|
2435
2710
|
};
|
|
2436
2711
|
}
|
|
2712
|
+
/** Reject new dispatches to an extension whose abandoned work is unresolved. */
|
|
2713
|
+
requireAdmission(id) {
|
|
2714
|
+
if ((this.abandoned.get(id) ?? 0) > 0) throw new MobileExtensionError("extension_busy", `extension ${id} has unresolved abandoned work`, 503);
|
|
2715
|
+
}
|
|
2716
|
+
/** Account for one operation until its underlying work actually settles. */
|
|
2717
|
+
trackOperation(id, operation) {
|
|
2718
|
+
operation.settled.then(() => {
|
|
2719
|
+
if (operation.detached()) {
|
|
2720
|
+
const remaining = (this.abandoned.get(id) ?? 1) - 1;
|
|
2721
|
+
if (remaining <= 0) this.abandoned.delete(id);
|
|
2722
|
+
else this.abandoned.set(id, remaining);
|
|
2723
|
+
}
|
|
2724
|
+
}).catch(() => void 0);
|
|
2725
|
+
}
|
|
2437
2726
|
/** Invoke one action after parsing its input and binding the request lifetime. */
|
|
2438
2727
|
async invoke(id, actionName, input, context, generation) {
|
|
2439
2728
|
const extension = this.extension(id, generation);
|
|
2440
2729
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
2441
2730
|
const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
|
|
2442
2731
|
if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
|
|
2443
|
-
|
|
2732
|
+
if ("host" in extension) extension.controller.signal.throwIfAborted();
|
|
2733
|
+
context.signal.throwIfAborted();
|
|
2734
|
+
this.requireAdmission(id);
|
|
2735
|
+
const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
|
|
2736
|
+
let operation;
|
|
2444
2737
|
try {
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2738
|
+
operation = startBoundedOperation((signal) => {
|
|
2739
|
+
let parsed;
|
|
2740
|
+
try {
|
|
2741
|
+
parsed = parseActionInput(action.input, input);
|
|
2742
|
+
} catch {
|
|
2743
|
+
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
2744
|
+
}
|
|
2745
|
+
return Promise.resolve(parsed).then((value) => {
|
|
2746
|
+
signal.throwIfAborted();
|
|
2747
|
+
return action.run({
|
|
2748
|
+
...context,
|
|
2749
|
+
signal
|
|
2750
|
+
}, value);
|
|
2751
|
+
}, () => {
|
|
2752
|
+
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
2753
|
+
});
|
|
2754
|
+
}, {
|
|
2755
|
+
external: external?.signal ?? context.signal,
|
|
2756
|
+
timeoutMs: action.timeoutMs ?? 3e4,
|
|
2757
|
+
timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
|
|
2758
|
+
onDetach: () => {
|
|
2759
|
+
this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
|
|
2760
|
+
}
|
|
2761
|
+
});
|
|
2762
|
+
this.trackOperation(id, operation);
|
|
2763
|
+
return await operation.caller;
|
|
2456
2764
|
} catch (error) {
|
|
2765
|
+
if (error instanceof OperationCancelled) throw error.reason;
|
|
2457
2766
|
if (error instanceof MobileExtensionError) throw error;
|
|
2458
2767
|
throw new MobileExtensionError("extension_failed", "extension action failed", 500);
|
|
2459
2768
|
} finally {
|
|
2460
|
-
|
|
2769
|
+
operation?.release();
|
|
2770
|
+
external?.cleanup();
|
|
2461
2771
|
}
|
|
2462
2772
|
}
|
|
2463
2773
|
/** Match one route and invoke it with a generation-bound abort signal. */
|
|
@@ -2469,25 +2779,47 @@ var MobileAccessService = class extends Service {
|
|
|
2469
2779
|
return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
|
|
2470
2780
|
});
|
|
2471
2781
|
if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
|
|
2472
|
-
|
|
2473
|
-
|
|
2782
|
+
if ("host" in extension) extension.controller.signal.throwIfAborted();
|
|
2783
|
+
request.signal.throwIfAborted();
|
|
2784
|
+
this.requireAdmission(id);
|
|
2785
|
+
const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
|
|
2786
|
+
let operation;
|
|
2787
|
+
let holdForStream = false;
|
|
2474
2788
|
try {
|
|
2475
|
-
|
|
2789
|
+
operation = startBoundedOperation((signal) => route.handle({
|
|
2476
2790
|
...request,
|
|
2477
|
-
signal
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2791
|
+
signal
|
|
2792
|
+
}), {
|
|
2793
|
+
external: external?.signal ?? request.signal,
|
|
2794
|
+
timeoutMs: route.timeoutMs ?? 3e4,
|
|
2795
|
+
timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
|
|
2796
|
+
onDetach: () => {
|
|
2797
|
+
this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
this.trackOperation(id, operation);
|
|
2801
|
+
operation.settled.then(() => {
|
|
2802
|
+
disposeLateExtensionResponse(operation?.lateValue());
|
|
2803
|
+
}).catch(() => void 0);
|
|
2804
|
+
const result = await operation.caller;
|
|
2480
2805
|
if (result === null || typeof result !== "object" || typeof result.body !== "string" && !(result.body instanceof Uint8Array) && !isReadable(result.body)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid response", 500);
|
|
2481
|
-
if (
|
|
2482
|
-
|
|
2483
|
-
releaseSignalLifetimeWhenStreamSettles(result.body,
|
|
2806
|
+
if (isReadable(result.body)) {
|
|
2807
|
+
holdForStream = true;
|
|
2808
|
+
releaseSignalLifetimeWhenStreamSettles(result.body, () => {
|
|
2809
|
+
operation?.release();
|
|
2810
|
+
external?.cleanup();
|
|
2811
|
+
});
|
|
2484
2812
|
}
|
|
2485
2813
|
return result;
|
|
2486
2814
|
} catch (error) {
|
|
2815
|
+
if (error instanceof OperationCancelled) throw error.reason;
|
|
2487
2816
|
if (error instanceof MobileExtensionError) throw error;
|
|
2488
2817
|
throw new MobileExtensionError("extension_failed", "extension route failed", 500);
|
|
2489
2818
|
} finally {
|
|
2490
|
-
if (
|
|
2819
|
+
if (!holdForStream) {
|
|
2820
|
+
operation?.release();
|
|
2821
|
+
external?.cleanup();
|
|
2822
|
+
}
|
|
2491
2823
|
}
|
|
2492
2824
|
}
|
|
2493
2825
|
/** Start the local directory watcher; an absent directory is intentionally inert. */
|
|
@@ -2658,6 +2990,16 @@ var MobileAccessService = class extends Service {
|
|
|
2658
2990
|
function isReadable(value) {
|
|
2659
2991
|
return value !== null && typeof value === "object" && typeof value.pipe === "function";
|
|
2660
2992
|
}
|
|
2993
|
+
/** Destroy a route response that arrived after its caller detached; must never throw or emit unhandled stream errors. */
|
|
2994
|
+
function disposeLateExtensionResponse(value) {
|
|
2995
|
+
try {
|
|
2996
|
+
if (value === null || typeof value !== "object") return;
|
|
2997
|
+
const body = value.body;
|
|
2998
|
+
if (!isReadable(body) || typeof body.once !== "function" || typeof body.destroy !== "function") return;
|
|
2999
|
+
body.once("error", () => void 0);
|
|
3000
|
+
body.destroy();
|
|
3001
|
+
} catch {}
|
|
3002
|
+
}
|
|
2661
3003
|
function releaseSignalLifetimeWhenStreamSettles(stream, cleanup) {
|
|
2662
3004
|
let stopObserving;
|
|
2663
3005
|
stopObserving = finished(stream, () => {
|
|
@@ -2680,6 +3022,77 @@ async function settleBounded(pending, timeoutMs) {
|
|
|
2680
3022
|
})]);
|
|
2681
3023
|
if (timer !== void 0) clearTimeout(timer);
|
|
2682
3024
|
}
|
|
3025
|
+
/** Sentinel distinguishing caller detachment (deadline or external cancel) from a host error. */
|
|
3026
|
+
var OperationCancelled = class {
|
|
3027
|
+
reason;
|
|
3028
|
+
constructor(reason) {
|
|
3029
|
+
this.reason = reason;
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
function startBoundedOperation(start, options) {
|
|
3033
|
+
const deadline = new AbortController();
|
|
3034
|
+
const combined = options.external === void 0 ? {
|
|
3035
|
+
signal: deadline.signal,
|
|
3036
|
+
cleanup: () => void 0
|
|
3037
|
+
} : combineSignalLifetime(options.external, deadline.signal);
|
|
3038
|
+
let detached = false;
|
|
3039
|
+
let workState;
|
|
3040
|
+
let lateFulfilled;
|
|
3041
|
+
let notifySettled;
|
|
3042
|
+
let callerTimer;
|
|
3043
|
+
let onExternal;
|
|
3044
|
+
const settled = new Promise((resolve) => {
|
|
3045
|
+
notifySettled = resolve;
|
|
3046
|
+
});
|
|
3047
|
+
const detach = (reject, reason) => {
|
|
3048
|
+
if (detached || workState !== void 0) return;
|
|
3049
|
+
detached = true;
|
|
3050
|
+
if (callerTimer !== void 0) clearTimeout(callerTimer);
|
|
3051
|
+
if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
|
|
3052
|
+
options.onDetach?.();
|
|
3053
|
+
deadline.abort(reason);
|
|
3054
|
+
reject(new OperationCancelled(reason));
|
|
3055
|
+
};
|
|
3056
|
+
const caller = new Promise((resolve, reject) => {
|
|
3057
|
+
callerTimer = setTimeout(() => detach(reject, options.timeoutError()), options.timeoutMs);
|
|
3058
|
+
onExternal = () => {
|
|
3059
|
+
detach(reject, combined.signal.reason);
|
|
3060
|
+
};
|
|
3061
|
+
options.external?.addEventListener("abort", onExternal, { once: true });
|
|
3062
|
+
(async () => {
|
|
3063
|
+
try {
|
|
3064
|
+
const value = await start(combined.signal);
|
|
3065
|
+
workState = "fulfilled";
|
|
3066
|
+
if (callerTimer !== void 0) clearTimeout(callerTimer);
|
|
3067
|
+
if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
|
|
3068
|
+
if (detached) combined.cleanup();
|
|
3069
|
+
if (detached) lateFulfilled = value;
|
|
3070
|
+
else resolve(value);
|
|
3071
|
+
notifySettled?.();
|
|
3072
|
+
} catch (error) {
|
|
3073
|
+
workState = "rejected";
|
|
3074
|
+
if (callerTimer !== void 0) clearTimeout(callerTimer);
|
|
3075
|
+
if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
|
|
3076
|
+
if (detached) combined.cleanup();
|
|
3077
|
+
if (!detached) reject(error);
|
|
3078
|
+
notifySettled?.();
|
|
3079
|
+
}
|
|
3080
|
+
})();
|
|
3081
|
+
});
|
|
3082
|
+
let released = false;
|
|
3083
|
+
return {
|
|
3084
|
+
caller,
|
|
3085
|
+
settled,
|
|
3086
|
+
detached: () => detached,
|
|
3087
|
+
lateValue: () => detached && workState === "fulfilled" ? lateFulfilled : void 0,
|
|
3088
|
+
release: () => {
|
|
3089
|
+
if (!released) {
|
|
3090
|
+
released = true;
|
|
3091
|
+
combined.cleanup();
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
2683
3096
|
async function abortAndDisposeLocal(entries) {
|
|
2684
3097
|
const pending = [];
|
|
2685
3098
|
for (const entry of entries) {
|
|
@@ -3161,32 +3574,60 @@ var ExcludedClientModulesError = class extends Error {
|
|
|
3161
3574
|
super(`excludedClientModules: ${message}`);
|
|
3162
3575
|
}
|
|
3163
3576
|
};
|
|
3164
|
-
/**
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3577
|
+
/** A graph-selection conflict with a bounded diagnostic safe for authenticated callers. */
|
|
3578
|
+
var ClientModuleConflictError = class extends HttpError {
|
|
3579
|
+
detail;
|
|
3580
|
+
constructor(detail) {
|
|
3581
|
+
super(409, "excluded_client_modules_invalid");
|
|
3582
|
+
this.detail = detail.replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/(?:https?:\/\/|[a-z]:[\\/])\S+/giu, "[redacted]").slice(0, 512);
|
|
3583
|
+
}
|
|
3584
|
+
};
|
|
3585
|
+
function requiredClientModule(entry, phase) {
|
|
3586
|
+
return MOBILE_BOOT_CORE_MODULES.has(entry.id) || entry.immediately === true || phase !== "application";
|
|
3587
|
+
}
|
|
3588
|
+
function clientModuleDependencies(entry) {
|
|
3589
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
3590
|
+
for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
|
|
3591
|
+
if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
|
|
3592
|
+
for (const dependency of edges ?? []) dependencies.add(dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency);
|
|
3593
|
+
}
|
|
3594
|
+
return Object.freeze([...dependencies]);
|
|
3595
|
+
}
|
|
3596
|
+
function clientModulePhases(entries, batches) {
|
|
3170
3597
|
const phases = /* @__PURE__ */ new Map();
|
|
3171
3598
|
for (const batch of batches) for (const id of batch.entries) {
|
|
3172
3599
|
if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
|
|
3173
3600
|
phases.set(id, batch.phase);
|
|
3174
3601
|
}
|
|
3175
3602
|
for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
|
|
3603
|
+
return phases;
|
|
3604
|
+
}
|
|
3605
|
+
/** Read only installed module ids and declared edges from the same boot parser used for serving. */
|
|
3606
|
+
function describeMobileClientModules(html) {
|
|
3607
|
+
const plan = parseMobileBootPlan(html);
|
|
3608
|
+
const ids = new Set(plan.entries.map((entry) => entry.id));
|
|
3609
|
+
const phases = plan.batches === void 0 ? /* @__PURE__ */ new Map() : clientModulePhases(plan.entries, plan.batches);
|
|
3610
|
+
return Object.freeze(plan.entries.map((entry) => Object.freeze({
|
|
3611
|
+
id: entry.id,
|
|
3612
|
+
required: requiredClientModule(entry, phases.get(entry.id)),
|
|
3613
|
+
dependencies: Object.freeze(clientModuleDependencies(entry).filter((id) => ids.has(id)))
|
|
3614
|
+
})));
|
|
3615
|
+
}
|
|
3616
|
+
/** Validate graph edges before removing application rows from both manifest views. */
|
|
3617
|
+
function excludeMobileBootEntries(entries, batches, excludedIds) {
|
|
3618
|
+
if (excludedIds.length === 0) return;
|
|
3619
|
+
const excluded = new Set(excludedIds);
|
|
3620
|
+
if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
|
|
3621
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
3622
|
+
const phases = clientModulePhases(entries, batches);
|
|
3176
3623
|
for (const id of excluded) {
|
|
3177
3624
|
const entry = byId.get(id);
|
|
3178
3625
|
if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
|
|
3179
|
-
if (
|
|
3626
|
+
if (requiredClientModule(entry, phases.get(id))) throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
|
|
3180
3627
|
}
|
|
3181
3628
|
for (const entry of entries) {
|
|
3182
3629
|
if (excluded.has(entry.id)) continue;
|
|
3183
|
-
for (const
|
|
3184
|
-
if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
|
|
3185
|
-
for (const dependency of edges ?? []) {
|
|
3186
|
-
const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
|
|
3187
|
-
if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
|
|
3188
|
-
}
|
|
3189
|
-
}
|
|
3630
|
+
for (const packageId of clientModuleDependencies(entry)) if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
|
|
3190
3631
|
}
|
|
3191
3632
|
entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
|
|
3192
3633
|
for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
|
|
@@ -3831,13 +4272,18 @@ var MobileAccessGateway = class {
|
|
|
3831
4272
|
removeExtensionContentListener;
|
|
3832
4273
|
renewLimiter;
|
|
3833
4274
|
probeLimiter;
|
|
3834
|
-
|
|
4275
|
+
clientModulePreferences;
|
|
4276
|
+
clientModuleOperations = /* @__PURE__ */ new Set();
|
|
4277
|
+
clientModuleTasks = /* @__PURE__ */ new Set();
|
|
4278
|
+
clientModuleRevocations = /* @__PURE__ */ new Set();
|
|
4279
|
+
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences) {
|
|
3835
4280
|
this.config = config;
|
|
3836
4281
|
this.extensions = extensions;
|
|
3837
4282
|
this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
|
|
3838
4283
|
this.extraWebSocketPaths = extraWebSocketPaths;
|
|
3839
4284
|
this.blockedUpgradeLog = blockedUpgradeLog;
|
|
3840
4285
|
this.onDiscoveryDegraded = onDiscoveryDegraded;
|
|
4286
|
+
this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
|
|
3841
4287
|
this.listenerTlsEnabled = config.tls.mode === "provided";
|
|
3842
4288
|
this.tlsEnabled = config.publicTls;
|
|
3843
4289
|
this.access = new AccessController(store, {
|
|
@@ -3854,6 +4300,11 @@ var MobileAccessGateway = class {
|
|
|
3854
4300
|
this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
3855
4301
|
this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
|
|
3856
4302
|
if (reason === "revoked") {
|
|
4303
|
+
const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
|
|
4304
|
+
process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
|
|
4305
|
+
});
|
|
4306
|
+
this.clientModuleRevocations.add(cleanup);
|
|
4307
|
+
cleanup.finally(() => this.clientModuleRevocations.delete(cleanup));
|
|
3857
4308
|
if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
|
|
3858
4309
|
this.pendingDeviceRevocations.add(authorization.deviceId);
|
|
3859
4310
|
this.broadcastDeviceRevoked(authorization.deviceId);
|
|
@@ -3874,6 +4325,7 @@ var MobileAccessGateway = class {
|
|
|
3874
4325
|
this.started = true;
|
|
3875
4326
|
await this.access.initialize();
|
|
3876
4327
|
try {
|
|
4328
|
+
await this.clientModulePreferences.initialize();
|
|
3877
4329
|
if (this.config.pairingCaFile !== void 0) {
|
|
3878
4330
|
const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
|
|
3879
4331
|
const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
@@ -4377,6 +4829,7 @@ var MobileAccessGateway = class {
|
|
|
4377
4829
|
}
|
|
4378
4830
|
const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
|
|
4379
4831
|
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
4832
|
+
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
4380
4833
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
4381
4834
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
4382
4835
|
const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
@@ -4396,7 +4849,7 @@ var MobileAccessGateway = class {
|
|
|
4396
4849
|
contentType: "text/javascript; charset=utf-8",
|
|
4397
4850
|
fallback: void 0
|
|
4398
4851
|
} : void 0 : void 0;
|
|
4399
|
-
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
4852
|
+
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
4400
4853
|
if (![
|
|
4401
4854
|
"GET",
|
|
4402
4855
|
"HEAD",
|
|
@@ -4425,6 +4878,44 @@ var MobileAccessGateway = class {
|
|
|
4425
4878
|
throw error;
|
|
4426
4879
|
}
|
|
4427
4880
|
if (isMutation) this.requireCsrf(request, authorization);
|
|
4881
|
+
if (clientModules) {
|
|
4882
|
+
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
4883
|
+
if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
4884
|
+
const operation = this.allocateRequest(authorization, response, {});
|
|
4885
|
+
const onClose = () => {
|
|
4886
|
+
operation.abort();
|
|
4887
|
+
operation.release();
|
|
4888
|
+
};
|
|
4889
|
+
response.once("close", onClose);
|
|
4890
|
+
try {
|
|
4891
|
+
let kind = "describe";
|
|
4892
|
+
let excluded;
|
|
4893
|
+
if (request.method === "POST") {
|
|
4894
|
+
const body = await readJsonObject(request, this.config.maxBodyBytes);
|
|
4895
|
+
const keys = Object.keys(body);
|
|
4896
|
+
if (keys.length !== 1) throw new HttpError(400, "bad_request");
|
|
4897
|
+
if (keys[0] === "reset" && body.reset === true) kind = "reset";
|
|
4898
|
+
else if (keys[0] === "excludedClientModules") {
|
|
4899
|
+
kind = "configure";
|
|
4900
|
+
excluded = body.excludedClientModules;
|
|
4901
|
+
} else throw new HttpError(400, "bad_request");
|
|
4902
|
+
}
|
|
4903
|
+
const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal);
|
|
4904
|
+
operation.signal.throwIfAborted();
|
|
4905
|
+
sendJson(response, 200, view, this.tlsEnabled);
|
|
4906
|
+
} catch (error) {
|
|
4907
|
+
if (operation.signal.aborted) return;
|
|
4908
|
+
if (error instanceof ClientModuleConflictError) sendJson(response, 409, {
|
|
4909
|
+
error: error.code,
|
|
4910
|
+
detail: error.detail
|
|
4911
|
+
}, this.tlsEnabled);
|
|
4912
|
+
else throw error;
|
|
4913
|
+
} finally {
|
|
4914
|
+
response.removeListener("close", onClose);
|
|
4915
|
+
operation.release();
|
|
4916
|
+
}
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4428
4919
|
const extension = requestedExtension;
|
|
4429
4920
|
if (extension !== void 0) {
|
|
4430
4921
|
await this.handleExtensionRequest(extension, target, request, response, authorization);
|
|
@@ -4595,6 +5086,7 @@ var MobileAccessGateway = class {
|
|
|
4595
5086
|
const operation = this.allocateRequest(authorization, response, {});
|
|
4596
5087
|
const abort = new AbortController();
|
|
4597
5088
|
response.once("close", () => {
|
|
5089
|
+
operation.release();
|
|
4598
5090
|
abort.abort();
|
|
4599
5091
|
});
|
|
4600
5092
|
const generationSignal = extensions.signal(targetInfo.id, generation);
|
|
@@ -4640,6 +5132,7 @@ var MobileAccessGateway = class {
|
|
|
4640
5132
|
const operation = this.allocateRequest(authorization, response, {});
|
|
4641
5133
|
const abort = new AbortController();
|
|
4642
5134
|
response.once("close", () => {
|
|
5135
|
+
operation.release();
|
|
4643
5136
|
abort.abort();
|
|
4644
5137
|
});
|
|
4645
5138
|
const generationSignal = extensions.signal(targetInfo.id, generation);
|
|
@@ -4743,7 +5236,7 @@ var MobileAccessGateway = class {
|
|
|
4743
5236
|
});
|
|
4744
5237
|
this.upstreamAuthRequest = upstreamRequest;
|
|
4745
5238
|
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
4746
|
-
upstreamRequest.destroy(
|
|
5239
|
+
upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
|
|
4747
5240
|
});
|
|
4748
5241
|
upstreamRequest.once("response", resolve);
|
|
4749
5242
|
upstreamRequest.once("error", reject);
|
|
@@ -4771,33 +5264,125 @@ var MobileAccessGateway = class {
|
|
|
4771
5264
|
this.upstreamAuthRequest = void 0;
|
|
4772
5265
|
}
|
|
4773
5266
|
}
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
5267
|
+
/** List installed modules and resolve the next-open selection.
|
|
5268
|
+
* @param deviceId - Authenticated device identity; omitted for computer defaults.
|
|
5269
|
+
* @param callerSignal - Cancels catalog fetching when the admin request closes.
|
|
5270
|
+
* @returns The installed catalog and effective module selection.
|
|
5271
|
+
*/
|
|
5272
|
+
describeClientModules(deviceId, callerSignal) {
|
|
5273
|
+
return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
|
|
5274
|
+
}
|
|
5275
|
+
/** Persist a validated device override or computer default.
|
|
5276
|
+
* @param excluded - Untrusted module IDs to validate against the current boot graph.
|
|
5277
|
+
* @param deviceId - Authenticated device identity; omitted for computer defaults.
|
|
5278
|
+
* @param callerSignal - Cancels fetching and validation, not an atomic write already started.
|
|
5279
|
+
* @returns The catalog and saved selection without reloading clients.
|
|
5280
|
+
*/
|
|
5281
|
+
configureClientModules(excluded, deviceId, callerSignal) {
|
|
5282
|
+
return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal);
|
|
5283
|
+
}
|
|
5284
|
+
/** Reset one override without reloading connected clients.
|
|
5285
|
+
* @param deviceId - Authenticated device identity; omitted to reset computer defaults.
|
|
5286
|
+
* @param callerSignal - Cancels fetching and validation, not an atomic write already started.
|
|
5287
|
+
* @returns The catalog and inherited selection.
|
|
5288
|
+
*/
|
|
5289
|
+
resetClientModules(deviceId, callerSignal) {
|
|
5290
|
+
return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
|
|
5291
|
+
}
|
|
5292
|
+
async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
|
|
5293
|
+
if (this.closing || !this.started) throw new HttpError(503, "unavailable");
|
|
5294
|
+
const controller = new AbortController();
|
|
5295
|
+
const abort = () => controller.abort();
|
|
5296
|
+
if (callerSignal?.aborted === true) controller.abort();
|
|
5297
|
+
callerSignal?.addEventListener("abort", abort, { once: true });
|
|
5298
|
+
this.clientModuleOperations.add(controller);
|
|
5299
|
+
const task = (async () => {
|
|
5300
|
+
let html = "";
|
|
5301
|
+
const validate = async (selection) => {
|
|
5302
|
+
html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
|
|
5303
|
+
try {
|
|
5304
|
+
parseMobileBootPlan(html, selection);
|
|
5305
|
+
} catch (error) {
|
|
5306
|
+
if (error instanceof ExcludedClientModulesError) throw new ClientModuleConflictError(error.message);
|
|
5307
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
5308
|
+
}
|
|
5309
|
+
controller.signal.throwIfAborted();
|
|
5310
|
+
};
|
|
5311
|
+
let selection;
|
|
5312
|
+
if (kind === "configure") {
|
|
5313
|
+
let ids;
|
|
5314
|
+
try {
|
|
5315
|
+
ids = parseExcludedClientModules(excluded);
|
|
5316
|
+
} catch {
|
|
5317
|
+
throw new HttpError(400, "excluded_client_modules_invalid");
|
|
5318
|
+
}
|
|
5319
|
+
selection = await this.clientModulePreferences.configure(ids, deviceId, validate);
|
|
5320
|
+
} else if (kind === "reset") selection = await this.clientModulePreferences.reset(deviceId, validate);
|
|
5321
|
+
else {
|
|
5322
|
+
html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
|
|
5323
|
+
selection = await this.clientModulePreferences.read(deviceId);
|
|
5324
|
+
}
|
|
5325
|
+
controller.signal.throwIfAborted();
|
|
5326
|
+
try {
|
|
5327
|
+
const plan = parseMobileBootPlan(html);
|
|
5328
|
+
return Object.freeze({
|
|
5329
|
+
entries: describeMobileClientModules(html),
|
|
5330
|
+
...selection,
|
|
5331
|
+
...plan.batches === void 0 ? {
|
|
5332
|
+
excludedClientModules: Object.freeze([]),
|
|
5333
|
+
defaultExcludedClientModules: Object.freeze([])
|
|
5334
|
+
} : {},
|
|
5335
|
+
reloadRequired: true
|
|
5336
|
+
});
|
|
5337
|
+
} catch (error) {
|
|
5338
|
+
if (error instanceof HttpError) throw error;
|
|
5339
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
5340
|
+
}
|
|
5341
|
+
})();
|
|
5342
|
+
this.clientModuleTasks.add(task);
|
|
4777
5343
|
try {
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
5344
|
+
return await task;
|
|
5345
|
+
} finally {
|
|
5346
|
+
callerSignal?.removeEventListener("abort", abort);
|
|
5347
|
+
this.clientModuleOperations.delete(controller);
|
|
5348
|
+
this.clientModuleTasks.delete(task);
|
|
5349
|
+
}
|
|
5350
|
+
}
|
|
5351
|
+
async fetchAuthenticatedUpstreamIndex(signal, incoming) {
|
|
5352
|
+
signal.throwIfAborted();
|
|
5353
|
+
let upstreamRequest;
|
|
5354
|
+
const abort = () => {
|
|
5355
|
+
upstreamRequest?.destroy(requestAbortedError());
|
|
5356
|
+
};
|
|
5357
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
5358
|
+
try {
|
|
5359
|
+
const headers = incoming === void 0 ? {
|
|
5360
|
+
host: this.config.upstreamOrigin.host,
|
|
5361
|
+
accept: "text/html",
|
|
5362
|
+
"accept-encoding": "identity"
|
|
5363
|
+
} : sanitizeRequestHeaders(incoming, this.config.upstreamOrigin);
|
|
5364
|
+
const upstreamCookie = await waitForRequestTask(this.upstreamCookieHeader(), signal);
|
|
5365
|
+
signal.throwIfAborted();
|
|
5366
|
+
if (upstreamCookie !== void 0) headers.cookie = upstreamCookie;
|
|
5367
|
+
headers["accept-encoding"] = "identity";
|
|
4782
5368
|
const proxied = await new Promise((resolve, reject) => {
|
|
4783
|
-
|
|
5369
|
+
upstreamRequest = request({
|
|
4784
5370
|
protocol: "http:",
|
|
4785
5371
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
4786
5372
|
port: Number(this.config.upstreamOrigin.port),
|
|
4787
5373
|
method: "GET",
|
|
4788
5374
|
path: "/",
|
|
4789
|
-
headers
|
|
5375
|
+
headers,
|
|
4790
5376
|
agent: false
|
|
4791
5377
|
});
|
|
4792
|
-
holder.request = upstreamRequest;
|
|
4793
5378
|
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
4794
|
-
upstreamRequest
|
|
5379
|
+
upstreamRequest?.destroy(new HttpError(504, "upstream_timeout"));
|
|
4795
5380
|
});
|
|
4796
5381
|
upstreamRequest.once("response", resolve);
|
|
4797
5382
|
upstreamRequest.once("error", reject);
|
|
4798
5383
|
upstreamRequest.end();
|
|
4799
5384
|
});
|
|
4800
|
-
if (
|
|
5385
|
+
if (proxied.statusCode !== 200) throw new HttpError(502, "upstream_unavailable");
|
|
4801
5386
|
const chunks = [];
|
|
4802
5387
|
let bytes = 0;
|
|
4803
5388
|
for await (const chunk of proxied) {
|
|
@@ -4806,15 +5391,40 @@ var MobileAccessGateway = class {
|
|
|
4806
5391
|
if (bytes > 4194304) throw new HttpError(502, "upstream_unavailable");
|
|
4807
5392
|
chunks.push(buffer);
|
|
4808
5393
|
}
|
|
5394
|
+
signal.throwIfAborted();
|
|
5395
|
+
return {
|
|
5396
|
+
html: Buffer.concat(chunks).toString("utf8"),
|
|
5397
|
+
headers: proxied.headers
|
|
5398
|
+
};
|
|
5399
|
+
} catch (error) {
|
|
5400
|
+
if (signal.aborted || error instanceof HttpError) throw error;
|
|
5401
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
5402
|
+
} finally {
|
|
5403
|
+
signal.removeEventListener("abort", abort);
|
|
5404
|
+
upstreamRequest?.destroy();
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
async proxyMobileIndex(request, response, authorization) {
|
|
5408
|
+
const holder = {};
|
|
5409
|
+
const operation = this.allocateRequest(authorization, response, holder);
|
|
5410
|
+
const onClose = () => {
|
|
5411
|
+
operation.abort();
|
|
5412
|
+
operation.release();
|
|
5413
|
+
};
|
|
5414
|
+
response.once("close", onClose);
|
|
5415
|
+
try {
|
|
5416
|
+
const index = await this.fetchAuthenticatedUpstreamIndex(operation.signal, request);
|
|
5417
|
+
const preference = await this.clientModulePreferences.read(authorization.deviceId);
|
|
5418
|
+
const excludedClientModules = preference.excludedClientModules;
|
|
4809
5419
|
let body;
|
|
4810
5420
|
try {
|
|
4811
|
-
const html =
|
|
4812
|
-
const plan = parseMobileBootPlan(html,
|
|
4813
|
-
const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules
|
|
4814
|
-
...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
|
|
4815
|
-
excludedClientModules
|
|
5421
|
+
const html = index.html;
|
|
5422
|
+
const plan = parseMobileBootPlan(html, excludedClientModules);
|
|
5423
|
+
const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
|
|
5424
|
+
...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
|
|
5425
|
+
excludedClientModules
|
|
4816
5426
|
};
|
|
4817
|
-
const cookies = browserAuthCookies(request
|
|
5427
|
+
const cookies = browserAuthCookies(request.headers.cookie);
|
|
4818
5428
|
const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
|
|
4819
5429
|
const rewritten = rewriteMobileIndexWithBatch(html, {
|
|
4820
5430
|
...options,
|
|
@@ -4829,6 +5439,18 @@ var MobileAccessGateway = class {
|
|
|
4829
5439
|
this.lastModuleExclusionError = detail;
|
|
4830
5440
|
process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
|
|
4831
5441
|
}
|
|
5442
|
+
if (preference.source === "device" || preference.source === "computer") {
|
|
5443
|
+
operation.signal.throwIfAborted();
|
|
5444
|
+
const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
|
|
5445
|
+
const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
|
|
5446
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
5447
|
+
response.writeHead(200, {
|
|
5448
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
5449
|
+
"Content-Length": recovery.byteLength
|
|
5450
|
+
});
|
|
5451
|
+
response.end(recovery);
|
|
5452
|
+
return;
|
|
5453
|
+
}
|
|
4832
5454
|
sendJson(response, 409, {
|
|
4833
5455
|
error: "excluded_client_modules_invalid",
|
|
4834
5456
|
detail
|
|
@@ -4837,7 +5459,8 @@ var MobileAccessGateway = class {
|
|
|
4837
5459
|
}
|
|
4838
5460
|
throw new HttpError(502, "upstream_unavailable");
|
|
4839
5461
|
}
|
|
4840
|
-
|
|
5462
|
+
operation.signal.throwIfAborted();
|
|
5463
|
+
const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
|
|
4841
5464
|
delete headers["content-length"];
|
|
4842
5465
|
delete headers["content-encoding"];
|
|
4843
5466
|
delete headers.etag;
|
|
@@ -4854,6 +5477,7 @@ var MobileAccessGateway = class {
|
|
|
4854
5477
|
if (response.headersSent) response.destroy();
|
|
4855
5478
|
else throw new HttpError(502, "upstream_unavailable");
|
|
4856
5479
|
} finally {
|
|
5480
|
+
response.removeListener("close", onClose);
|
|
4857
5481
|
operation.release();
|
|
4858
5482
|
}
|
|
4859
5483
|
}
|
|
@@ -4874,16 +5498,17 @@ var MobileAccessGateway = class {
|
|
|
4874
5498
|
* could not be probed, keeps its own upstream `/plugins` row: a merged batch
|
|
4875
5499
|
* cannot carry it, and an unknown bundle must not risk the whole batch.
|
|
4876
5500
|
*/
|
|
4877
|
-
async resolveMobileBootSizes(planEntries) {
|
|
5501
|
+
async resolveMobileBootSizes(planEntries, signal) {
|
|
4878
5502
|
const sizes = /* @__PURE__ */ new Map();
|
|
4879
5503
|
const passThrough = /* @__PURE__ */ new Set();
|
|
4880
5504
|
const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && upstreamPluginBundleUrl(entry.url, this.config.upstreamOrigin) !== void 0);
|
|
4881
5505
|
let cursor = 0;
|
|
4882
5506
|
const worker = async () => {
|
|
4883
5507
|
while (cursor < candidates.length) {
|
|
5508
|
+
signal?.throwIfAborted();
|
|
4884
5509
|
const index = cursor++;
|
|
4885
5510
|
const source = candidates[index].url;
|
|
4886
|
-
const size = await this.upstreamBundleSize(source);
|
|
5511
|
+
const size = await this.upstreamBundleSize(source, signal);
|
|
4887
5512
|
if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
|
|
4888
5513
|
else sizes.set(source, size);
|
|
4889
5514
|
}
|
|
@@ -4900,14 +5525,20 @@ var MobileAccessGateway = class {
|
|
|
4900
5525
|
* counts bytes; it aborts instantly past the per-entry cap. Measurements are
|
|
4901
5526
|
* cached for a short window.
|
|
4902
5527
|
*/
|
|
4903
|
-
async upstreamBundleSize(source) {
|
|
5528
|
+
async upstreamBundleSize(source, signal) {
|
|
5529
|
+
signal?.throwIfAborted();
|
|
4904
5530
|
const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
|
|
4905
5531
|
if (target === void 0) return void 0;
|
|
4906
5532
|
const cached = this.bootEntrySizeCache.get(source);
|
|
4907
5533
|
if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
|
|
4908
|
-
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4909
5534
|
let upstreamRequest;
|
|
5535
|
+
const abort = () => {
|
|
5536
|
+
upstreamRequest?.destroy(requestAbortedError());
|
|
5537
|
+
};
|
|
5538
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
4910
5539
|
try {
|
|
5540
|
+
const upstreamCookie = signal === void 0 ? await this.upstreamCookieHeader() : await waitForRequestTask(this.upstreamCookieHeader(), signal);
|
|
5541
|
+
signal?.throwIfAborted();
|
|
4911
5542
|
const proxied = await new Promise((resolve, reject) => {
|
|
4912
5543
|
upstreamRequest = request({
|
|
4913
5544
|
protocol: "http:",
|
|
@@ -4953,9 +5584,11 @@ var MobileAccessGateway = class {
|
|
|
4953
5584
|
at: Date.now()
|
|
4954
5585
|
});
|
|
4955
5586
|
return size;
|
|
4956
|
-
} catch {
|
|
5587
|
+
} catch (error) {
|
|
5588
|
+
if (signal?.aborted === true) throw error;
|
|
4957
5589
|
return;
|
|
4958
5590
|
} finally {
|
|
5591
|
+
signal?.removeEventListener("abort", abort);
|
|
4959
5592
|
upstreamRequest?.destroy();
|
|
4960
5593
|
}
|
|
4961
5594
|
}
|
|
@@ -5122,6 +5755,7 @@ var MobileAccessGateway = class {
|
|
|
5122
5755
|
}
|
|
5123
5756
|
}
|
|
5124
5757
|
allocateRequest(authorization, response, upstream) {
|
|
5758
|
+
if (this.closing || response.destroyed || response.writableEnded) throw requestAbortedError();
|
|
5125
5759
|
if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
|
|
5126
5760
|
const id = this.nextOperationId++;
|
|
5127
5761
|
const controller = new AbortController();
|
|
@@ -5149,72 +5783,88 @@ var MobileAccessGateway = class {
|
|
|
5149
5783
|
};
|
|
5150
5784
|
}
|
|
5151
5785
|
/**
|
|
5152
|
-
* Proxy one request
|
|
5153
|
-
*
|
|
5154
|
-
*
|
|
5155
|
-
*
|
|
5786
|
+
* Proxy one request, retrying transient failures for plugin and asset GETs.
|
|
5787
|
+
* Only bodyless GETs can be replayed, before response headers reach the client.
|
|
5788
|
+
* One request slot and cancellation signal cover attempts and backoff together.
|
|
5789
|
+
* API requests and requests with bodies are forwarded once.
|
|
5156
5790
|
*/
|
|
5157
5791
|
async proxyHttp(request, response, authorization) {
|
|
5158
|
-
|
|
5159
|
-
try {
|
|
5160
|
-
await this.proxyHttpOnce(request, response, authorization);
|
|
5161
|
-
} catch (error) {
|
|
5162
|
-
if (error instanceof HttpError) throw error;
|
|
5163
|
-
if (response.headersSent) response.destroy();
|
|
5164
|
-
else throw new HttpError(502, "upstream_unavailable");
|
|
5165
|
-
}
|
|
5166
|
-
return;
|
|
5167
|
-
}
|
|
5168
|
-
const delay = (attempt) => new Promise((resolve) => setTimeout(resolve, MOBILE_BOOT_RETRY_DELAY_MS * attempt));
|
|
5169
|
-
for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) try {
|
|
5170
|
-
await this.proxyHttpOnce(request, response, authorization);
|
|
5171
|
-
return;
|
|
5172
|
-
} catch (error) {
|
|
5173
|
-
if (response.headersSent) {
|
|
5174
|
-
response.destroy();
|
|
5175
|
-
return;
|
|
5176
|
-
}
|
|
5177
|
-
if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
|
|
5178
|
-
if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
|
|
5179
|
-
await delay(attempt);
|
|
5180
|
-
}
|
|
5181
|
-
throw new HttpError(502, "upstream_unavailable");
|
|
5182
|
-
}
|
|
5183
|
-
async proxyHttpOnce(request$3, response, authorization) {
|
|
5184
|
-
const declared = request$3.headers["content-length"];
|
|
5792
|
+
const declared = request.headers["content-length"];
|
|
5185
5793
|
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
5794
|
+
const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
|
|
5186
5795
|
const holder = {};
|
|
5187
5796
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
5188
|
-
|
|
5797
|
+
const onClose = () => {
|
|
5798
|
+
operation.release();
|
|
5799
|
+
operation.abort();
|
|
5800
|
+
};
|
|
5801
|
+
response.once("close", onClose);
|
|
5802
|
+
if (replayable) request.resume();
|
|
5189
5803
|
try {
|
|
5190
|
-
const
|
|
5191
|
-
|
|
5192
|
-
|
|
5804
|
+
const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
|
|
5805
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
5806
|
+
operation.signal.throwIfAborted();
|
|
5807
|
+
try {
|
|
5808
|
+
await this.proxyHttpOnce(request, response, holder, operation.signal, replayable);
|
|
5809
|
+
return;
|
|
5810
|
+
} catch (error) {
|
|
5811
|
+
if (operation.signal.aborted || response.destroyed) return;
|
|
5812
|
+
if (response.headersSent) {
|
|
5813
|
+
response.destroy();
|
|
5814
|
+
return;
|
|
5815
|
+
}
|
|
5816
|
+
if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
|
|
5817
|
+
if (attempt === attempts) throw new HttpError(502, "upstream_unavailable");
|
|
5818
|
+
await waitForAbortableDelay(MOBILE_BOOT_RETRY_DELAY_MS * attempt, operation.signal);
|
|
5819
|
+
}
|
|
5820
|
+
}
|
|
5821
|
+
} catch (error) {
|
|
5822
|
+
if (!operation.signal.aborted) throw error;
|
|
5823
|
+
} finally {
|
|
5824
|
+
response.removeListener("close", onClose);
|
|
5825
|
+
operation.release();
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5828
|
+
async proxyHttpOnce(request$2, response, holder, signal, replayable) {
|
|
5829
|
+
let bodyDone;
|
|
5830
|
+
try {
|
|
5831
|
+
const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
|
|
5832
|
+
if (request$2.headers["transfer-encoding"] !== void 0) upstreamHeaders["transfer-encoding"] = request$2.headers["transfer-encoding"];
|
|
5833
|
+
const upstreamCookie = await this.upstreamCookieHeader();
|
|
5834
|
+
signal.throwIfAborted();
|
|
5835
|
+
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
5193
5836
|
const proxied = await new Promise((resolve, reject) => {
|
|
5194
5837
|
const upstreamRequest = request({
|
|
5195
5838
|
protocol: "http:",
|
|
5196
5839
|
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
5197
5840
|
port: Number(this.config.upstreamOrigin.port),
|
|
5198
|
-
method: request$
|
|
5199
|
-
path: request$
|
|
5841
|
+
method: request$2.method,
|
|
5842
|
+
path: request$2.url,
|
|
5200
5843
|
headers: upstreamHeaders,
|
|
5201
5844
|
agent: false
|
|
5202
5845
|
});
|
|
5203
5846
|
holder.request = upstreamRequest;
|
|
5204
5847
|
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
5205
|
-
upstreamRequest.destroy(
|
|
5848
|
+
upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
|
|
5849
|
+
});
|
|
5850
|
+
const pathname = parseRequestTarget(request$2.url).decodedPathname;
|
|
5851
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) upstreamRequest.once("finish", () => {
|
|
5852
|
+
upstreamRequest.setTimeout(this.config.upstreamApiTimeoutMs);
|
|
5206
5853
|
});
|
|
5207
5854
|
upstreamRequest.once("response", resolve);
|
|
5208
5855
|
upstreamRequest.once("error", reject);
|
|
5209
|
-
|
|
5210
|
-
|
|
5856
|
+
if (replayable) upstreamRequest.end();
|
|
5857
|
+
else {
|
|
5858
|
+
bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
5859
|
+
bodyDone.catch(reject);
|
|
5860
|
+
}
|
|
5211
5861
|
});
|
|
5212
5862
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
5213
5863
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
5214
5864
|
const statusCode = proxied.statusCode ?? 502;
|
|
5215
|
-
const cacheControl = revisionedStaticCacheControl(request$
|
|
5865
|
+
const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
|
|
5216
5866
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
5217
|
-
const compressed = shouldCompressResponse(request$
|
|
5867
|
+
const compressed = shouldCompressResponse(request$2, proxied);
|
|
5218
5868
|
if (compressed) {
|
|
5219
5869
|
delete headers["accept-ranges"];
|
|
5220
5870
|
delete headers["content-length"];
|
|
@@ -5231,7 +5881,7 @@ var MobileAccessGateway = class {
|
|
|
5231
5881
|
if (response.headersSent) response.destroy();
|
|
5232
5882
|
throw error;
|
|
5233
5883
|
} finally {
|
|
5234
|
-
|
|
5884
|
+
delete holder.request;
|
|
5235
5885
|
}
|
|
5236
5886
|
}
|
|
5237
5887
|
abortSessionResources(sessionKey) {
|
|
@@ -5580,12 +6230,19 @@ var MobileAccessGateway = class {
|
|
|
5580
6230
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
5581
6231
|
if (typeof body.deviceId !== "string" || !/^[a-f\d]{32}$/u.test(body.deviceId)) throw new HttpError(400, "bad_request");
|
|
5582
6232
|
if (!await this.access.revokeDevice(body.deviceId)) throw new HttpError(404, "not_found");
|
|
6233
|
+
await this.clientModulePreferences.removeDevice(body.deviceId).catch(() => {
|
|
6234
|
+
process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
|
|
6235
|
+
});
|
|
5583
6236
|
sendJson(response, 200, { revoked: true }, false);
|
|
5584
6237
|
return;
|
|
5585
6238
|
}
|
|
5586
6239
|
if (request.method === "POST" && target.decodedPathname === `${prefix}/devices/reset`) {
|
|
5587
6240
|
if ((await readJsonObject(request, MAX_CONTROL_BODY_BYTES)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
6241
|
+
const removed = this.access.listDevices().map((device) => device.id);
|
|
5588
6242
|
await this.access.resetDevices();
|
|
6243
|
+
await this.clientModulePreferences.removeDevices(removed).catch(() => {
|
|
6244
|
+
process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
|
|
6245
|
+
});
|
|
5589
6246
|
sendJson(response, 200, { reset: true }, false);
|
|
5590
6247
|
return;
|
|
5591
6248
|
}
|
|
@@ -5606,6 +6263,7 @@ var MobileAccessGateway = class {
|
|
|
5606
6263
|
}
|
|
5607
6264
|
async performClose() {
|
|
5608
6265
|
this.closing = true;
|
|
6266
|
+
for (const controller of this.clientModuleOperations) controller.abort();
|
|
5609
6267
|
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
5610
6268
|
this.extensionChangeTimer = void 0;
|
|
5611
6269
|
this.removeExtensionContentListener();
|
|
@@ -5638,6 +6296,8 @@ var MobileAccessGateway = class {
|
|
|
5638
6296
|
});
|
|
5639
6297
|
}
|
|
5640
6298
|
await accessClose;
|
|
6299
|
+
await Promise.allSettled(this.clientModuleTasks);
|
|
6300
|
+
await Promise.allSettled(this.clientModuleRevocations);
|
|
5641
6301
|
this.activeRequests.clear();
|
|
5642
6302
|
this.activeWebSockets.clear();
|
|
5643
6303
|
this.connectedSockets.clear();
|
|
@@ -6346,6 +7006,14 @@ function resolveFrpVhostHttpPort(settings) {
|
|
|
6346
7006
|
function resolveFrpPublicPort(settings) {
|
|
6347
7007
|
return settings.publicPort ?? 33080;
|
|
6348
7008
|
}
|
|
7009
|
+
/** Public HTTPS endpoint the user opens, independent of the frps control address. */
|
|
7010
|
+
function resolveFrpEntryProbe(settings) {
|
|
7011
|
+
const origin = new URL(frpEntryOrigin(settings));
|
|
7012
|
+
return {
|
|
7013
|
+
host: origin.hostname,
|
|
7014
|
+
port: origin.port === "" ? 443 : Number(origin.port)
|
|
7015
|
+
};
|
|
7016
|
+
}
|
|
6349
7017
|
/** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
|
|
6350
7018
|
function frpEntryOrigin(settings) {
|
|
6351
7019
|
if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
|
|
@@ -7128,7 +7796,8 @@ function parseManagedSetup(value) {
|
|
|
7128
7796
|
"networkInterface",
|
|
7129
7797
|
"listenPort",
|
|
7130
7798
|
"upstreamOrigin",
|
|
7131
|
-
"tls"
|
|
7799
|
+
"tls",
|
|
7800
|
+
"extraAllowedCidrs"
|
|
7132
7801
|
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
7133
7802
|
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
7134
7803
|
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
@@ -7145,6 +7814,7 @@ function parseManagedSetup(value) {
|
|
|
7145
7814
|
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
7146
7815
|
listenPort: record.listenPort,
|
|
7147
7816
|
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
7817
|
+
...record.extraAllowedCidrs === void 0 ? {} : { extraAllowedCidrs: parseAdditionalTrustedNetworks(record.extraAllowedCidrs) },
|
|
7148
7818
|
tls: Object.freeze({
|
|
7149
7819
|
mode: "managed",
|
|
7150
7820
|
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
@@ -7154,6 +7824,43 @@ function parseManagedSetup(value) {
|
|
|
7154
7824
|
})
|
|
7155
7825
|
});
|
|
7156
7826
|
}
|
|
7827
|
+
/** Validate explicitly trusted networks without adding implicit VPN or private ranges.
|
|
7828
|
+
* @param value - CIDR strings submitted by the computer administrator.
|
|
7829
|
+
* @returns Deduplicated validated CIDRs in input order.
|
|
7830
|
+
*/
|
|
7831
|
+
function parseAdditionalTrustedNetworks(value) {
|
|
7832
|
+
if (!Array.isArray(value)) throw new Error("extraAllowedCidrs must be an array of CIDR strings");
|
|
7833
|
+
const sources = value.map((entry) => {
|
|
7834
|
+
if (typeof entry !== "string" || entry.trim() === "") throw new Error("extraAllowedCidrs must be an array of CIDR strings");
|
|
7835
|
+
return entry.trim();
|
|
7836
|
+
});
|
|
7837
|
+
const entries = [];
|
|
7838
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7839
|
+
for (const cidr of sources) {
|
|
7840
|
+
const parsed = parseCidr(cidr);
|
|
7841
|
+
const key = `${parsed.bits}:${parsed.network}:${parsed.prefix}`;
|
|
7842
|
+
if (seen.has(key)) continue;
|
|
7843
|
+
seen.add(key);
|
|
7844
|
+
entries.push(cidr);
|
|
7845
|
+
}
|
|
7846
|
+
return Object.freeze(entries);
|
|
7847
|
+
}
|
|
7848
|
+
/** Save extra trusted networks without replacing the interface or pairing identity.
|
|
7849
|
+
* @param setupFile - Existing managed setup file.
|
|
7850
|
+
* @param values - Explicitly selected additional networks.
|
|
7851
|
+
* @returns The saved managed setup.
|
|
7852
|
+
*/
|
|
7853
|
+
async function saveManagedTrustedNetworks(setupFile, values) {
|
|
7854
|
+
const extraAllowedCidrs = parseAdditionalTrustedNetworks(values);
|
|
7855
|
+
const setup = parseManagedSetup(JSON.parse(await readFile(setupFile, "utf8")));
|
|
7856
|
+
const next = Object.freeze({
|
|
7857
|
+
...setup,
|
|
7858
|
+
extraAllowedCidrs
|
|
7859
|
+
});
|
|
7860
|
+
await atomicWrite(setupFile, `${JSON.stringify(next, null, 2)}\n`);
|
|
7861
|
+
await restrictPrivateFile(setupFile);
|
|
7862
|
+
return next;
|
|
7863
|
+
}
|
|
7157
7864
|
function privateIpv4(value) {
|
|
7158
7865
|
const parts = value.split(".").map(Number);
|
|
7159
7866
|
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
@@ -7294,10 +8001,18 @@ async function atomicWrite(file, contents) {
|
|
|
7294
8001
|
recursive: true,
|
|
7295
8002
|
mode: 448
|
|
7296
8003
|
});
|
|
7297
|
-
const temporary = join(directory, `.${basename(file)}.${
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
8004
|
+
const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
8005
|
+
try {
|
|
8006
|
+
await writeFile(temporary, contents, {
|
|
8007
|
+
mode: 384,
|
|
8008
|
+
flag: "wx"
|
|
8009
|
+
});
|
|
8010
|
+
await restrictPrivateFile(temporary);
|
|
8011
|
+
await rename(temporary, file);
|
|
8012
|
+
await restrictPrivateFile(file);
|
|
8013
|
+
} finally {
|
|
8014
|
+
await rm(temporary, { force: true });
|
|
8015
|
+
}
|
|
7301
8016
|
}
|
|
7302
8017
|
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
7303
8018
|
async function ensureManagedCa(setup, legacy) {
|
|
@@ -7436,7 +8151,7 @@ async function materializeManagedSetup(setup, table) {
|
|
|
7436
8151
|
return {
|
|
7437
8152
|
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
7438
8153
|
listenHost: network.address,
|
|
7439
|
-
allowedCidrs: [network.cidr],
|
|
8154
|
+
allowedCidrs: parseAdditionalTrustedNetworks([network.cidr, ...setup.extraAllowedCidrs ?? []]),
|
|
7440
8155
|
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
7441
8156
|
pairingCaFile: setup.tls.caCertFile,
|
|
7442
8157
|
tls: {
|
|
@@ -7719,6 +8434,17 @@ var RemoteProviderCoordinator = class {
|
|
|
7719
8434
|
mutate(operation) {
|
|
7720
8435
|
return this.enqueue(() => operation(this.controller()));
|
|
7721
8436
|
}
|
|
8437
|
+
/** Apply one operation only while its intended provider remains selected.
|
|
8438
|
+
* @param provider - Provider the caller explicitly chose.
|
|
8439
|
+
* @param operation - Configuration and activation performed in the same queue item.
|
|
8440
|
+
* @returns The operation result, or a rejection when another selection won the queue.
|
|
8441
|
+
*/
|
|
8442
|
+
mutateSelected(provider, operation) {
|
|
8443
|
+
return this.enqueue(async () => {
|
|
8444
|
+
if (provider !== this.selectedValue) throw new Error("remote_provider_changed");
|
|
8445
|
+
return operation(this.controller());
|
|
8446
|
+
});
|
|
8447
|
+
}
|
|
7722
8448
|
/** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
|
|
7723
8449
|
select(provider) {
|
|
7724
8450
|
return this.enqueue(async () => {
|
|
@@ -9753,7 +10479,7 @@ var CloudflaredController = class {
|
|
|
9753
10479
|
};
|
|
9754
10480
|
//#endregion
|
|
9755
10481
|
//#region src/cloudflared-tunnel.ts
|
|
9756
|
-
const MAX_CONFIG_BYTES = 8192;
|
|
10482
|
+
const MAX_CONFIG_BYTES$1 = 8192;
|
|
9757
10483
|
const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
|
|
9758
10484
|
/** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
|
|
9759
10485
|
const TUNNEL_ROUTING_SUFFIX = ".cfargotunnel.com";
|
|
@@ -9912,7 +10638,7 @@ var CloudflaredTunnelStore = class {
|
|
|
9912
10638
|
if (error.code === "ENOENT") return;
|
|
9913
10639
|
throw error;
|
|
9914
10640
|
}
|
|
9915
|
-
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
|
|
10641
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES$1) {
|
|
9916
10642
|
this.errorCode = "cloudflared_tunnel_config_invalid";
|
|
9917
10643
|
return;
|
|
9918
10644
|
}
|
|
@@ -10550,6 +11276,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
10550
11276
|
- 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
|
|
10551
11277
|
- extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
|
|
10552
11278
|
- 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) 的适配器;两种形式都会在动作执行前校验并规范化输入。
|
|
11279
|
+
- 动作与路由的执行有时间预算:默认 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,直到该操作真正结束。
|
|
10553
11280
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
10554
11281
|
- mobile.css:手机端样式(可选)
|
|
10555
11282
|
- assets/:手机端静态资源(可选)
|
|
@@ -10558,6 +11285,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
10558
11285
|
|
|
10559
11286
|
安全约束:
|
|
10560
11287
|
- host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
|
|
11288
|
+
- host.mjs 的同步阻塞(如 while (true) {} 或长时间同步循环)会卡死整个电脑端网关进程,超时与取消机制都无法打断;绝不编写同步阻塞代码
|
|
10561
11289
|
- 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
|
|
10562
11290
|
|
|
10563
11291
|
完成前请自检:
|
|
@@ -12741,201 +13469,1160 @@ var CpolarComponentManager = class {
|
|
|
12741
13469
|
}
|
|
12742
13470
|
};
|
|
12743
13471
|
//#endregion
|
|
12744
|
-
//#region src/
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
const
|
|
12748
|
-
const
|
|
12749
|
-
const
|
|
12750
|
-
|
|
12751
|
-
const
|
|
12752
|
-
|
|
12753
|
-
const
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
const
|
|
12758
|
-
|
|
12759
|
-
|
|
12760
|
-
|
|
12761
|
-
|
|
12762
|
-
|
|
12763
|
-
|
|
12764
|
-
const core = [
|
|
12765
|
-
Number(match[1]),
|
|
12766
|
-
Number(match[2]),
|
|
12767
|
-
Number(match[3])
|
|
12768
|
-
];
|
|
12769
|
-
if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
|
|
12770
|
-
const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
|
|
12771
|
-
if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
|
|
12772
|
-
return Object.freeze({
|
|
12773
|
-
core,
|
|
12774
|
-
prerelease: Object.freeze(prerelease)
|
|
12775
|
-
});
|
|
12776
|
-
}
|
|
12777
|
-
/** Compare two strict SemVer strings, including prerelease precedence. */
|
|
12778
|
-
function comparePluginVersions(left, right) {
|
|
12779
|
-
const a = parseSemver(left);
|
|
12780
|
-
const b = parseSemver(right);
|
|
12781
|
-
if (a === void 0 || b === void 0) return void 0;
|
|
12782
|
-
for (let index = 0; index < a.core.length; index += 1) {
|
|
12783
|
-
const difference = a.core[index] - b.core[index];
|
|
12784
|
-
if (difference !== 0) return Math.sign(difference);
|
|
12785
|
-
}
|
|
12786
|
-
if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
|
|
12787
|
-
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
12788
|
-
for (let index = 0; index < length; index += 1) {
|
|
12789
|
-
const leftPart = a.prerelease[index];
|
|
12790
|
-
const rightPart = b.prerelease[index];
|
|
12791
|
-
if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
|
|
12792
|
-
if (leftPart === rightPart) continue;
|
|
12793
|
-
if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
|
|
12794
|
-
if (typeof leftPart === "number") return -1;
|
|
12795
|
-
if (typeof rightPart === "number") return 1;
|
|
12796
|
-
return leftPart < rightPart ? -1 : 1;
|
|
12797
|
-
}
|
|
12798
|
-
return 0;
|
|
12799
|
-
}
|
|
12800
|
-
function isComparatorSet(value) {
|
|
12801
|
-
if (HYPHEN_RANGE.test(value)) return true;
|
|
12802
|
-
const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
|
|
12803
|
-
return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
|
|
12804
|
-
}
|
|
12805
|
-
function isNpmVersionRange(value) {
|
|
12806
|
-
if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
|
|
12807
|
-
const alternatives = value.split(/ *\|\| */u);
|
|
12808
|
-
return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
|
|
12809
|
-
}
|
|
12810
|
-
/** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
|
|
12811
|
-
function isRegistryPluginSpec(value) {
|
|
12812
|
-
if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
|
|
12813
|
-
if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
|
|
12814
|
-
return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
|
|
12815
|
-
}
|
|
12816
|
-
/** Resolve the DSH profile named by the current launcher arguments. */
|
|
12817
|
-
function launchedProfileName(argv) {
|
|
12818
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
12819
|
-
if (argv[index] === "--profile") {
|
|
12820
|
-
const candidate = argv[index + 1];
|
|
12821
|
-
if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
|
|
13472
|
+
//#region src/caddy-files.ts
|
|
13473
|
+
/** Refuse link-shaped ancestors within the owned state root before filesystem operations. */
|
|
13474
|
+
async function assertCaddyParents(root, target) {
|
|
13475
|
+
const parent = resolve(root);
|
|
13476
|
+
const candidate = resolve(target);
|
|
13477
|
+
const offset = relative(parent, candidate);
|
|
13478
|
+
if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error("caddy_path_invalid");
|
|
13479
|
+
const paths = [parent];
|
|
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;
|
|
12822
13492
|
}
|
|
12823
|
-
|
|
12824
|
-
if (match?.[1] !== void 0) return match[1];
|
|
12825
|
-
}
|
|
12826
|
-
return "web";
|
|
12827
|
-
}
|
|
12828
|
-
/** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
|
|
12829
|
-
function releaseProfileDirectory(ctx, dshHome, argv) {
|
|
12830
|
-
const desktopProfiles = ctx.get("desktopProfiles");
|
|
12831
|
-
const desktopDirectory = desktopProfiles?.current?.dir;
|
|
12832
|
-
if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
|
|
12833
|
-
if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
|
|
12834
|
-
return join(dshHome, "profiles", launchedProfileName(argv));
|
|
12835
|
-
}
|
|
12836
|
-
async function profileDependencySpec(profileDirectory) {
|
|
12837
|
-
try {
|
|
12838
|
-
const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
|
|
12839
|
-
return typeof value === "string" ? value : void 0;
|
|
12840
|
-
} catch {
|
|
12841
|
-
return;
|
|
13493
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
12842
13494
|
}
|
|
12843
13495
|
}
|
|
12844
|
-
|
|
12845
|
-
|
|
12846
|
-
|
|
12847
|
-
|
|
12848
|
-
|
|
12849
|
-
|
|
12850
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
13496
|
+
/** Create a private real directory, never traversing a link owned by the component. */
|
|
13497
|
+
async function ensureCaddyDirectory(root, directory) {
|
|
13498
|
+
await assertCaddyParents(root, directory);
|
|
13499
|
+
await mkdir(directory, {
|
|
13500
|
+
recursive: true,
|
|
13501
|
+
mode: 448
|
|
12851
13502
|
});
|
|
12852
|
-
|
|
12853
|
-
|
|
12854
|
-
|
|
13503
|
+
const entry = await lstat(directory);
|
|
13504
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
13505
|
+
await restrictPrivateFile(directory, 448);
|
|
12855
13506
|
}
|
|
12856
|
-
|
|
12857
|
-
|
|
13507
|
+
/** Atomically replace one private configuration file under a real owned directory. */
|
|
13508
|
+
async function writeCaddyPrivateFile(root, file, body) {
|
|
13509
|
+
await ensureCaddyDirectory(root, dirname(file));
|
|
12858
13510
|
try {
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
|
|
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;
|
|
12862
13515
|
}
|
|
12863
|
-
|
|
12864
|
-
if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
|
|
12865
|
-
let version;
|
|
13516
|
+
const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
|
|
12866
13517
|
try {
|
|
12867
|
-
|
|
12868
|
-
|
|
12869
|
-
|
|
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 });
|
|
12870
13527
|
}
|
|
12871
|
-
return parseSemver(version) === void 0 ? void 0 : version;
|
|
12872
|
-
}
|
|
12873
|
-
function androidReleaseDownloadUrl(version) {
|
|
12874
|
-
if (version === void 0) return GITHUB_RELEASES_URL;
|
|
12875
|
-
const tag = `v${version}`;
|
|
12876
|
-
return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
|
|
12877
|
-
}
|
|
12878
|
-
async function fetchAndroidVersion(fetcher) {
|
|
12879
|
-
const response = await fetcher(GITHUB_LATEST_URL, {
|
|
12880
|
-
method: "GET",
|
|
12881
|
-
redirect: "manual",
|
|
12882
|
-
headers: {
|
|
12883
|
-
accept: "text/html",
|
|
12884
|
-
"user-agent": "dsh-mobile-release-check"
|
|
12885
|
-
},
|
|
12886
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
12887
|
-
});
|
|
12888
|
-
return githubReleaseVersion(response.headers.get("location"), response.url);
|
|
12889
|
-
}
|
|
12890
|
-
/** Best-effort body of the latest GitHub release, trimmed to a bounded size. */
|
|
12891
|
-
async function fetchReleaseNotes(fetcher) {
|
|
12892
|
-
const response = await fetcher(GITHUB_API_LATEST_URL, {
|
|
12893
|
-
headers: {
|
|
12894
|
-
accept: "application/vnd.github+json",
|
|
12895
|
-
"user-agent": "dsh-mobile-release-check"
|
|
12896
|
-
},
|
|
12897
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
12898
|
-
});
|
|
12899
|
-
if (!response.ok) return void 0;
|
|
12900
|
-
const payload = await response.json();
|
|
12901
|
-
if (typeof payload.body !== "string") return void 0;
|
|
12902
|
-
const notes = payload.body.trim();
|
|
12903
|
-
return notes === "" ? void 0 : notes.slice(0, RELEASE_NOTES_MAX_CHARS);
|
|
12904
13528
|
}
|
|
12905
|
-
|
|
13529
|
+
/** Delete only owned entries; junctions and symlinks are unlinked, never descended into. */
|
|
13530
|
+
async function removeCaddyTree(root, target) {
|
|
13531
|
+
await assertCaddyParents(root, target);
|
|
13532
|
+
let entry;
|
|
12906
13533
|
try {
|
|
12907
|
-
|
|
12908
|
-
|
|
12909
|
-
|
|
12910
|
-
|
|
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);
|
|
12911
13545
|
return;
|
|
12912
13546
|
}
|
|
13547
|
+
for (const name of await readdir(target)) await removeCaddyTree(root, join(target, name));
|
|
13548
|
+
await rmdir(target);
|
|
12913
13549
|
}
|
|
12914
|
-
|
|
12915
|
-
|
|
12916
|
-
|
|
12917
|
-
|
|
12918
|
-
|
|
12919
|
-
|
|
12920
|
-
|
|
12921
|
-
|
|
12922
|
-
|
|
12923
|
-
|
|
13550
|
+
//#endregion
|
|
13551
|
+
//#region src/caddy-component.ts
|
|
13552
|
+
const CADDY_VERSION = "2.11.6";
|
|
13553
|
+
const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
|
|
13554
|
+
const CADDY_DOWNLOAD_PAGE = "https://caddyserver.com/download";
|
|
13555
|
+
const CADDY_COMPONENT_RELEASES = Object.freeze({});
|
|
13556
|
+
Object.freeze({
|
|
13557
|
+
version: CADDY_VERSION,
|
|
13558
|
+
dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION
|
|
13559
|
+
});
|
|
13560
|
+
function isCaddyDnsProvider(value) {
|
|
13561
|
+
return value === "tencentcloud";
|
|
12924
13562
|
}
|
|
12925
|
-
function
|
|
12926
|
-
|
|
13563
|
+
async function digest(file) {
|
|
13564
|
+
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
13565
|
+
}
|
|
13566
|
+
/** Minimal process environment for version/module checks, without ambient API credentials or proxy overrides. */
|
|
13567
|
+
function caddyInspectionEnvironment(environment = process.env) {
|
|
13568
|
+
const result = {};
|
|
13569
|
+
for (const name of [
|
|
13570
|
+
"SystemRoot",
|
|
13571
|
+
"WINDIR",
|
|
13572
|
+
"TEMP",
|
|
13573
|
+
"TMP",
|
|
13574
|
+
"LANG",
|
|
13575
|
+
"LC_ALL"
|
|
13576
|
+
]) if (environment[name] !== void 0) result[name] = environment[name];
|
|
13577
|
+
return result;
|
|
13578
|
+
}
|
|
13579
|
+
async function inspect(executable) {
|
|
13580
|
+
const options = {
|
|
13581
|
+
env: caddyInspectionEnvironment(),
|
|
13582
|
+
timeout: 15e3,
|
|
13583
|
+
maxBuffer: 262144
|
|
13584
|
+
};
|
|
12927
13585
|
return {
|
|
12928
|
-
|
|
12929
|
-
|
|
12930
|
-
timer.unref();
|
|
12931
|
-
}),
|
|
12932
|
-
cancel: () => {
|
|
12933
|
-
if (timer !== void 0) clearTimeout(timer);
|
|
12934
|
-
timer = void 0;
|
|
12935
|
-
}
|
|
13586
|
+
version: (await execFileText(executable, ["version"], options)).stdout.trim(),
|
|
13587
|
+
modules: (await execFileText(executable, ["list-modules", "--versions"], options)).stdout
|
|
12936
13588
|
};
|
|
12937
13589
|
}
|
|
12938
|
-
|
|
13590
|
+
/** Owns only the optional pinned executable; runtime configuration belongs to CaddyConfigStore. */
|
|
13591
|
+
var CaddyComponentManager = class {
|
|
13592
|
+
executable;
|
|
13593
|
+
componentRoot;
|
|
13594
|
+
componentStorage;
|
|
13595
|
+
logRoot;
|
|
13596
|
+
stateDirectory;
|
|
13597
|
+
stagingRoot;
|
|
13598
|
+
artifact;
|
|
13599
|
+
fetchArtifact;
|
|
13600
|
+
inspectExecutable;
|
|
13601
|
+
promote;
|
|
13602
|
+
installed = false;
|
|
13603
|
+
errorCode;
|
|
13604
|
+
queue = Promise.resolve();
|
|
13605
|
+
constructor(options) {
|
|
13606
|
+
if (!isAbsolute(options.stateDirectory)) throw new Error("caddy state directory must be absolute");
|
|
13607
|
+
this.stateDirectory = resolve(options.stateDirectory);
|
|
13608
|
+
const platform = options.platform ?? process.platform;
|
|
13609
|
+
const arch = options.arch ?? process.arch;
|
|
13610
|
+
const artifact = options.artifact ?? CADDY_COMPONENT_RELEASES[`${platform}-${arch}`];
|
|
13611
|
+
if (artifact !== void 0 && (artifact.platform !== platform || artifact.arch !== arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("caddy_artifact_invalid");
|
|
13612
|
+
this.artifact = artifact;
|
|
13613
|
+
this.componentRoot = join(this.stateDirectory, "components", "caddy");
|
|
13614
|
+
this.componentStorage = join(this.componentRoot, artifact?.version ?? "2.11.6");
|
|
13615
|
+
this.executable = join(this.componentStorage, artifact?.executableName ?? (platform === "win32" ? "caddy.exe" : "caddy"));
|
|
13616
|
+
this.logRoot = join(this.stateDirectory, "caddy", "logs");
|
|
13617
|
+
this.stagingRoot = join(this.stateDirectory, "staging", "caddy");
|
|
13618
|
+
this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => downloadPinnedArtifact({
|
|
13619
|
+
url,
|
|
13620
|
+
signal,
|
|
13621
|
+
expectedBytes: artifact?.downloadBytes ?? 0,
|
|
13622
|
+
errorPrefix: "caddy"
|
|
13623
|
+
}));
|
|
13624
|
+
this.inspectExecutable = options.inspectExecutable ?? inspect;
|
|
13625
|
+
this.promote = options.promoteDirectory ?? rename;
|
|
13626
|
+
}
|
|
13627
|
+
/** Recheck a pinned executable before launching; no unverified local binary can run. */
|
|
13628
|
+
async ensureExecutable() {
|
|
13629
|
+
const artifact = this.artifact;
|
|
13630
|
+
if (artifact === void 0) throw new Error("caddy_component_unavailable");
|
|
13631
|
+
await assertCaddyParents(this.stateDirectory, this.executable);
|
|
13632
|
+
const entry = await lstat(this.executable);
|
|
13633
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes || await digest(this.executable) !== artifact.executableSha256) throw new Error("caddy_component_invalid");
|
|
13634
|
+
await this.verifyMetadata(this.executable, artifact);
|
|
13635
|
+
}
|
|
13636
|
+
async initialize() {
|
|
13637
|
+
this.installed = false;
|
|
13638
|
+
this.errorCode = void 0;
|
|
13639
|
+
if (this.artifact === void 0) {
|
|
13640
|
+
this.errorCode = "caddy_component_unavailable";
|
|
13641
|
+
return;
|
|
13642
|
+
}
|
|
13643
|
+
try {
|
|
13644
|
+
await this.ensureExecutable();
|
|
13645
|
+
this.installed = true;
|
|
13646
|
+
} catch (error) {
|
|
13647
|
+
if (error.code !== "ENOENT") this.errorCode = "caddy_component_invalid";
|
|
13648
|
+
}
|
|
13649
|
+
}
|
|
13650
|
+
status() {
|
|
13651
|
+
const artifact = this.artifact;
|
|
13652
|
+
return Object.freeze({
|
|
13653
|
+
supported: artifact !== void 0,
|
|
13654
|
+
installed: this.installed,
|
|
13655
|
+
version: artifact?.version ?? "2.11.6",
|
|
13656
|
+
downloadBytes: artifact?.downloadBytes ?? 0,
|
|
13657
|
+
installedBytes: this.installed ? artifact?.executableBytes ?? 0 : 0,
|
|
13658
|
+
sourceUrl: artifact?.downloadUrl ?? "https://caddyserver.com/download",
|
|
13659
|
+
downloadPage: CADDY_DOWNLOAD_PAGE,
|
|
13660
|
+
storagePath: this.componentRoot,
|
|
13661
|
+
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
13662
|
+
});
|
|
13663
|
+
}
|
|
13664
|
+
/** Explicit installation verifies bytes and reported modules before preserving-old promotion. */
|
|
13665
|
+
install() {
|
|
13666
|
+
return this.enqueue(async () => {
|
|
13667
|
+
const artifact = this.artifact;
|
|
13668
|
+
if (artifact === void 0) throw new Error("caddy_component_unavailable");
|
|
13669
|
+
await ensureCaddyDirectory(this.stateDirectory, this.stagingRoot);
|
|
13670
|
+
await ensureCaddyDirectory(this.stateDirectory, this.componentRoot);
|
|
13671
|
+
const staging = await mkdtemp(join(this.stagingRoot, "install-"));
|
|
13672
|
+
const candidate = join(this.componentRoot, ".install-" + randomBytes(12).toString("hex"));
|
|
13673
|
+
const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
|
|
13674
|
+
let previous = false;
|
|
13675
|
+
let promoted = false;
|
|
13676
|
+
try {
|
|
13677
|
+
const controller = new AbortController();
|
|
13678
|
+
const timeout = setTimeout(() => {
|
|
13679
|
+
controller.abort();
|
|
13680
|
+
}, 6e5);
|
|
13681
|
+
timeout.unref();
|
|
13682
|
+
let bytes;
|
|
13683
|
+
try {
|
|
13684
|
+
bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
|
|
13685
|
+
} finally {
|
|
13686
|
+
clearTimeout(timeout);
|
|
13687
|
+
}
|
|
13688
|
+
if (bytes.byteLength !== artifact.downloadBytes) throw new Error("caddy_download_size_mismatch");
|
|
13689
|
+
if (createHash("sha256").update(bytes).digest("hex") !== artifact.downloadSha256) throw new Error("caddy_download_hash_mismatch");
|
|
13690
|
+
await ensureCaddyDirectory(this.stateDirectory, candidate);
|
|
13691
|
+
const executable = join(candidate, artifact.executableName);
|
|
13692
|
+
await writeFile(executable, bytes, {
|
|
13693
|
+
flag: "wx",
|
|
13694
|
+
mode: 448
|
|
13695
|
+
});
|
|
13696
|
+
await chmod(executable, 448);
|
|
13697
|
+
if (bytes.byteLength !== artifact.executableBytes || await digest(executable) !== artifact.executableSha256) throw new Error("caddy_executable_hash_mismatch");
|
|
13698
|
+
await this.verifyMetadata(executable, artifact);
|
|
13699
|
+
try {
|
|
13700
|
+
const entry = await lstat(this.componentStorage);
|
|
13701
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
|
|
13702
|
+
await rename(this.componentStorage, backup);
|
|
13703
|
+
previous = true;
|
|
13704
|
+
} catch (error) {
|
|
13705
|
+
if (error.code !== "ENOENT") throw error;
|
|
13706
|
+
}
|
|
13707
|
+
try {
|
|
13708
|
+
await this.promote(candidate, this.componentStorage);
|
|
13709
|
+
promoted = true;
|
|
13710
|
+
} catch (error) {
|
|
13711
|
+
if (previous) {
|
|
13712
|
+
await rename(backup, this.componentStorage);
|
|
13713
|
+
previous = false;
|
|
13714
|
+
}
|
|
13715
|
+
throw error;
|
|
13716
|
+
}
|
|
13717
|
+
this.installed = true;
|
|
13718
|
+
this.errorCode = void 0;
|
|
13719
|
+
} finally {
|
|
13720
|
+
await removeCaddyTree(this.stateDirectory, staging);
|
|
13721
|
+
await removeCaddyTree(this.stateDirectory, candidate);
|
|
13722
|
+
if (previous && promoted) await removeCaddyTree(this.stateDirectory, backup);
|
|
13723
|
+
}
|
|
13724
|
+
});
|
|
13725
|
+
}
|
|
13726
|
+
/** Remove optional component files; callers stop the controller and purge runtime configuration separately. */
|
|
13727
|
+
purge() {
|
|
13728
|
+
return this.enqueue(async () => {
|
|
13729
|
+
await removeCaddyTree(this.stateDirectory, this.componentRoot);
|
|
13730
|
+
await removeCaddyTree(this.stateDirectory, this.stagingRoot);
|
|
13731
|
+
this.installed = false;
|
|
13732
|
+
this.errorCode = this.artifact === void 0 ? "caddy_component_unavailable" : void 0;
|
|
13733
|
+
});
|
|
13734
|
+
}
|
|
13735
|
+
async verifyMetadata(executable, artifact) {
|
|
13736
|
+
const info = await this.inspectExecutable(executable);
|
|
13737
|
+
if (!info.version.startsWith("v" + artifact.version + " ")) throw new Error("caddy_executable_version_mismatch");
|
|
13738
|
+
const nonstandard = info.modules.split(/\r?\n/u).filter((line) => line.startsWith("dns.providers."));
|
|
13739
|
+
if (nonstandard.length !== 1 || nonstandard[0] !== "dns.providers.tencentcloud " + artifact.dnsPluginVersion || !info.modules.includes("Non-standard modules: 1")) throw new Error("caddy_executable_modules_mismatch");
|
|
13740
|
+
}
|
|
13741
|
+
enqueue(operation) {
|
|
13742
|
+
const task = this.queue.then(operation, operation);
|
|
13743
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
13744
|
+
return task.then(() => this.status());
|
|
13745
|
+
}
|
|
13746
|
+
};
|
|
13747
|
+
const MAX_CONFIG_BYTES = 16384;
|
|
13748
|
+
/** Inject only the DNS credentials needed by the managed child. */
|
|
13749
|
+
function caddyCredentialEnvironment(provider, credentials) {
|
|
13750
|
+
if (!isCaddyDnsProvider(provider)) throw new Error("caddy_dns_provider_invalid");
|
|
13751
|
+
return {
|
|
13752
|
+
TENCENTCLOUD_SECRET_ID: credentials.secretId,
|
|
13753
|
+
TENCENTCLOUD_SECRET_KEY: credentials.secretKey
|
|
13754
|
+
};
|
|
13755
|
+
}
|
|
13756
|
+
/** Validate saved or administrative settings, including ports reserved by DSH. */
|
|
13757
|
+
function parseCaddySettings(value) {
|
|
13758
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_settings_invalid");
|
|
13759
|
+
const record = value;
|
|
13760
|
+
if (Reflect.ownKeys(record).some((key) => ![
|
|
13761
|
+
"version",
|
|
13762
|
+
"publicOrigin",
|
|
13763
|
+
"dnsProvider",
|
|
13764
|
+
"listenPort"
|
|
13765
|
+
].includes(String(key))) || record.version !== void 0 && record.version !== 1) throw new Error("caddy_settings_invalid");
|
|
13766
|
+
if (!isCaddyDnsProvider(record.dnsProvider)) throw new Error("caddy_dns_provider_invalid");
|
|
13767
|
+
const listenPort = record.listenPort ?? 8443;
|
|
13768
|
+
if (!Number.isSafeInteger(listenPort) || Number(listenPort) < 1024 || Number(listenPort) > 65535) throw new Error("caddy_listen_port_invalid");
|
|
13769
|
+
if ([
|
|
13770
|
+
3444,
|
|
13771
|
+
3443,
|
|
13772
|
+
3080
|
|
13773
|
+
].includes(Number(listenPort))) throw new Error("caddy_listen_port_reserved");
|
|
13774
|
+
const publicOrigin = validateOriginPublicOrigin(record.publicOrigin);
|
|
13775
|
+
if (isIP(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
|
|
13776
|
+
return Object.freeze({
|
|
13777
|
+
version: 1,
|
|
13778
|
+
publicOrigin,
|
|
13779
|
+
dnsProvider: record.dnsProvider,
|
|
13780
|
+
listenPort: Number(listenPort)
|
|
13781
|
+
});
|
|
13782
|
+
}
|
|
13783
|
+
function parseCredentials(value) {
|
|
13784
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_credentials_invalid");
|
|
13785
|
+
const record = value;
|
|
13786
|
+
if (Reflect.ownKeys(record).some((key) => key !== "secretId" && key !== "secretKey")) throw new Error("caddy_credentials_invalid");
|
|
13787
|
+
const { secretId, secretKey } = record;
|
|
13788
|
+
if (typeof secretId !== "string" || typeof secretKey !== "string" || secretId.length === 0 || secretKey.length === 0 || secretId.length > 512 || secretKey.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(secretId + secretKey)) throw new Error("caddy_credentials_invalid");
|
|
13789
|
+
return Object.freeze({
|
|
13790
|
+
secretId,
|
|
13791
|
+
secretKey
|
|
13792
|
+
});
|
|
13793
|
+
}
|
|
13794
|
+
/** Render a single TLS site, with no admin API, config autosave, or embedded DNS secrets. */
|
|
13795
|
+
function renderCaddyfile(settings, stateDirectory, backendPort = 3444) {
|
|
13796
|
+
const validated = parseCaddySettings(settings);
|
|
13797
|
+
if (!Number.isSafeInteger(backendPort) || backendPort < 1 || backendPort > 65535) throw new Error("caddy_backend_port_invalid");
|
|
13798
|
+
const root = resolve(stateDirectory, "caddy");
|
|
13799
|
+
const dataDirectory = join(root, "data").replaceAll("\\", "/");
|
|
13800
|
+
const hostname = new URL(validated.publicOrigin).hostname;
|
|
13801
|
+
return [
|
|
13802
|
+
"{",
|
|
13803
|
+
" admin off",
|
|
13804
|
+
" persist_config off",
|
|
13805
|
+
" auto_https disable_redirects",
|
|
13806
|
+
`\tstorage file_system ${JSON.stringify(dataDirectory)}`,
|
|
13807
|
+
" log {",
|
|
13808
|
+
" output discard",
|
|
13809
|
+
" }",
|
|
13810
|
+
"}",
|
|
13811
|
+
"",
|
|
13812
|
+
`https://${hostname}:${String(validated.listenPort)} {`,
|
|
13813
|
+
" tls {",
|
|
13814
|
+
" dns tencentcloud {",
|
|
13815
|
+
" secret_id {env.TENCENTCLOUD_SECRET_ID}",
|
|
13816
|
+
" secret_key {env.TENCENTCLOUD_SECRET_KEY}",
|
|
13817
|
+
" }",
|
|
13818
|
+
" }",
|
|
13819
|
+
`\treverse_proxy 127.0.0.1:${String(backendPort)} {`,
|
|
13820
|
+
" header_up Host {http.request.hostport}",
|
|
13821
|
+
" transport http {",
|
|
13822
|
+
" keepalive off",
|
|
13823
|
+
" dial_timeout 5s",
|
|
13824
|
+
" }",
|
|
13825
|
+
" }",
|
|
13826
|
+
"}",
|
|
13827
|
+
""
|
|
13828
|
+
].join("\n");
|
|
13829
|
+
}
|
|
13830
|
+
/** Owns one atomic configuration record and every Caddy-created private file. */
|
|
13831
|
+
var CaddyConfigStore = class {
|
|
13832
|
+
stateDirectory;
|
|
13833
|
+
rootDirectory;
|
|
13834
|
+
settingsFile;
|
|
13835
|
+
envFile;
|
|
13836
|
+
caddyfile;
|
|
13837
|
+
dataDirectory;
|
|
13838
|
+
configDirectory;
|
|
13839
|
+
logsDirectory;
|
|
13840
|
+
settingsValue;
|
|
13841
|
+
credentialsValue;
|
|
13842
|
+
errorCodeValue;
|
|
13843
|
+
queue = Promise.resolve();
|
|
13844
|
+
constructor(stateDirectory) {
|
|
13845
|
+
this.stateDirectory = stateDirectory;
|
|
13846
|
+
if (!isAbsolute(stateDirectory)) throw new Error("caddy config state directory must be absolute");
|
|
13847
|
+
this.rootDirectory = resolve(stateDirectory, "caddy");
|
|
13848
|
+
this.settingsFile = join(this.rootDirectory, "config.json");
|
|
13849
|
+
this.envFile = this.settingsFile;
|
|
13850
|
+
this.caddyfile = join(this.rootDirectory, "Caddyfile");
|
|
13851
|
+
this.dataDirectory = join(this.rootDirectory, "data");
|
|
13852
|
+
this.configDirectory = join(this.rootDirectory, "xdg-config");
|
|
13853
|
+
this.logsDirectory = join(this.rootDirectory, "logs");
|
|
13854
|
+
}
|
|
13855
|
+
settings() {
|
|
13856
|
+
return this.settingsValue;
|
|
13857
|
+
}
|
|
13858
|
+
credentials() {
|
|
13859
|
+
return this.credentialsValue;
|
|
13860
|
+
}
|
|
13861
|
+
errorCode() {
|
|
13862
|
+
return this.errorCodeValue;
|
|
13863
|
+
}
|
|
13864
|
+
status() {
|
|
13865
|
+
const settings = this.settingsValue;
|
|
13866
|
+
return Object.freeze({
|
|
13867
|
+
configured: settings !== void 0 && this.credentialsValue !== void 0,
|
|
13868
|
+
credentialsConfigured: this.credentialsValue !== void 0,
|
|
13869
|
+
storagePath: this.rootDirectory,
|
|
13870
|
+
...settings === void 0 ? {} : {
|
|
13871
|
+
publicOrigin: settings.publicOrigin,
|
|
13872
|
+
listenPort: settings.listenPort,
|
|
13873
|
+
dnsProvider: settings.dnsProvider
|
|
13874
|
+
},
|
|
13875
|
+
...this.errorCodeValue === void 0 ? {} : { errorCode: this.errorCodeValue }
|
|
13876
|
+
});
|
|
13877
|
+
}
|
|
13878
|
+
async initialize() {
|
|
13879
|
+
await this.enqueue(async () => {
|
|
13880
|
+
this.settingsValue = void 0;
|
|
13881
|
+
this.credentialsValue = void 0;
|
|
13882
|
+
this.errorCodeValue = void 0;
|
|
13883
|
+
await assertCaddyParents(this.stateDirectory, this.settingsFile);
|
|
13884
|
+
let entry;
|
|
13885
|
+
try {
|
|
13886
|
+
entry = await lstat(this.settingsFile);
|
|
13887
|
+
} catch (error) {
|
|
13888
|
+
if (error.code === "ENOENT") return;
|
|
13889
|
+
throw error;
|
|
13890
|
+
}
|
|
13891
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
|
|
13892
|
+
this.errorCodeValue = "caddy_config_invalid";
|
|
13893
|
+
return;
|
|
13894
|
+
}
|
|
13895
|
+
await restrictPrivateFile(this.settingsFile);
|
|
13896
|
+
try {
|
|
13897
|
+
const record = JSON.parse(await readFile(this.settingsFile, "utf8"));
|
|
13898
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) throw new Error("invalid");
|
|
13899
|
+
const values = record;
|
|
13900
|
+
if (Reflect.ownKeys(values).some((key) => key !== "settings" && key !== "credentials")) throw new Error("invalid");
|
|
13901
|
+
const settings = parseCaddySettings(values.settings);
|
|
13902
|
+
const credentials = parseCredentials(values.credentials);
|
|
13903
|
+
this.settingsValue = settings;
|
|
13904
|
+
this.credentialsValue = credentials;
|
|
13905
|
+
} catch {
|
|
13906
|
+
this.errorCodeValue = "caddy_config_invalid";
|
|
13907
|
+
}
|
|
13908
|
+
});
|
|
13909
|
+
}
|
|
13910
|
+
/** Atomically save validated settings and credentials; omitted or both-blank secrets retain saved ones. */
|
|
13911
|
+
configure(value, credentialInput) {
|
|
13912
|
+
return this.enqueue(async () => {
|
|
13913
|
+
const settings = parseCaddySettings(value);
|
|
13914
|
+
const blank = typeof credentialInput === "object" && credentialInput !== null && !Array.isArray(credentialInput) && credentialInput.secretId === "" && credentialInput.secretKey === "" && Reflect.ownKeys(credentialInput).every((key) => key === "secretId" || key === "secretKey");
|
|
13915
|
+
const credentials = credentialInput === void 0 || blank ? this.credentialsValue : parseCredentials(credentialInput);
|
|
13916
|
+
if (credentials === void 0) throw new Error("caddy_credentials_missing");
|
|
13917
|
+
await writeCaddyPrivateFile(this.stateDirectory, this.settingsFile, JSON.stringify({
|
|
13918
|
+
settings,
|
|
13919
|
+
credentials
|
|
13920
|
+
}) + "\n");
|
|
13921
|
+
this.settingsValue = settings;
|
|
13922
|
+
this.credentialsValue = credentials;
|
|
13923
|
+
this.errorCodeValue = void 0;
|
|
13924
|
+
});
|
|
13925
|
+
}
|
|
13926
|
+
/** Write derived runtime files after authoritative settings are available. */
|
|
13927
|
+
prepareCaddyfile(backendPort) {
|
|
13928
|
+
return this.enqueue(async () => {
|
|
13929
|
+
const settings = this.settingsValue;
|
|
13930
|
+
if (settings === void 0 || this.credentialsValue === void 0) throw new Error("caddy_config_missing");
|
|
13931
|
+
for (const directory of [
|
|
13932
|
+
this.dataDirectory,
|
|
13933
|
+
this.configDirectory,
|
|
13934
|
+
this.logsDirectory
|
|
13935
|
+
]) await ensureCaddyDirectory(this.stateDirectory, directory);
|
|
13936
|
+
await writeCaddyPrivateFile(this.stateDirectory, this.caddyfile, renderCaddyfile(settings, this.stateDirectory, backendPort));
|
|
13937
|
+
});
|
|
13938
|
+
}
|
|
13939
|
+
/** Remove credentials, certificates, private keys, configuration, logs, and runtime cache. */
|
|
13940
|
+
purge() {
|
|
13941
|
+
return this.enqueue(async () => {
|
|
13942
|
+
await removeCaddyTree(this.stateDirectory, this.rootDirectory);
|
|
13943
|
+
this.settingsValue = void 0;
|
|
13944
|
+
this.credentialsValue = void 0;
|
|
13945
|
+
this.errorCodeValue = void 0;
|
|
13946
|
+
});
|
|
13947
|
+
}
|
|
13948
|
+
enqueue(operation) {
|
|
13949
|
+
const task = this.queue.then(operation, operation);
|
|
13950
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
13951
|
+
return task;
|
|
13952
|
+
}
|
|
13953
|
+
};
|
|
13954
|
+
//#endregion
|
|
13955
|
+
//#region src/caddy.ts
|
|
13956
|
+
/** Private directories override Caddy's platform defaults; ambient API keys and admin/proxy overrides are omitted. */
|
|
13957
|
+
function caddyProcessEnvironment(config, settings, environment = process.env) {
|
|
13958
|
+
const credentials = config.credentials();
|
|
13959
|
+
if (credentials === void 0) throw new Error("caddy_credentials_missing");
|
|
13960
|
+
return {
|
|
13961
|
+
...caddyInspectionEnvironment(environment),
|
|
13962
|
+
...caddyCredentialEnvironment(settings.dnsProvider, credentials),
|
|
13963
|
+
HOME: config.rootDirectory,
|
|
13964
|
+
USERPROFILE: config.rootDirectory,
|
|
13965
|
+
APPDATA: config.configDirectory,
|
|
13966
|
+
LOCALAPPDATA: config.dataDirectory,
|
|
13967
|
+
XDG_DATA_HOME: config.dataDirectory,
|
|
13968
|
+
XDG_CONFIG_HOME: config.configDirectory
|
|
13969
|
+
};
|
|
13970
|
+
}
|
|
13971
|
+
function spawnCaddy(executable, args, environment) {
|
|
13972
|
+
return spawn(executable, [...args], {
|
|
13973
|
+
env: environment,
|
|
13974
|
+
shell: false,
|
|
13975
|
+
stdio: [
|
|
13976
|
+
"pipe",
|
|
13977
|
+
"pipe",
|
|
13978
|
+
"pipe"
|
|
13979
|
+
],
|
|
13980
|
+
windowsHide: true
|
|
13981
|
+
});
|
|
13982
|
+
}
|
|
13983
|
+
async function validate(executable, caddyfile, environment, signal) {
|
|
13984
|
+
try {
|
|
13985
|
+
await execFileText(executable, [
|
|
13986
|
+
"adapt",
|
|
13987
|
+
"--config",
|
|
13988
|
+
caddyfile,
|
|
13989
|
+
"--adapter",
|
|
13990
|
+
"caddyfile"
|
|
13991
|
+
], {
|
|
13992
|
+
env: environment,
|
|
13993
|
+
signal,
|
|
13994
|
+
timeout: 15e3,
|
|
13995
|
+
maxBuffer: 262144
|
|
13996
|
+
});
|
|
13997
|
+
} catch {
|
|
13998
|
+
throw new Error("caddy_config_validation_failed");
|
|
13999
|
+
}
|
|
14000
|
+
}
|
|
14001
|
+
/** Owns Caddy and a loopback Gateway until both have stopped; ready requires verified public TLS and instance identity. */
|
|
14002
|
+
var CaddyController = class {
|
|
14003
|
+
options;
|
|
14004
|
+
enabled = false;
|
|
14005
|
+
initialized = false;
|
|
14006
|
+
disposed = false;
|
|
14007
|
+
child;
|
|
14008
|
+
gatewayValue;
|
|
14009
|
+
generation = 0;
|
|
14010
|
+
startupAbort;
|
|
14011
|
+
latest = Object.freeze({
|
|
14012
|
+
enabled: false,
|
|
14013
|
+
state: "off"
|
|
14014
|
+
});
|
|
14015
|
+
queue = Promise.resolve();
|
|
14016
|
+
closeTask;
|
|
14017
|
+
constructor(options) {
|
|
14018
|
+
this.options = options;
|
|
14019
|
+
}
|
|
14020
|
+
initialize() {
|
|
14021
|
+
return this.enqueue(async () => {
|
|
14022
|
+
if (this.initialized) return;
|
|
14023
|
+
if (this.disposed) throw new Error("caddy_controller_unavailable");
|
|
14024
|
+
this.enabled = (await this.options.store.load()).enabled;
|
|
14025
|
+
this.initialized = true;
|
|
14026
|
+
if (this.enabled) await this.start();
|
|
14027
|
+
});
|
|
14028
|
+
}
|
|
14029
|
+
gateway() {
|
|
14030
|
+
return this.latest.state === "ready" ? this.gatewayValue : void 0;
|
|
14031
|
+
}
|
|
14032
|
+
status() {
|
|
14033
|
+
return this.latest;
|
|
14034
|
+
}
|
|
14035
|
+
/** Cancel a readiness attempt before its owning coordinator waits for queued teardown. */
|
|
14036
|
+
cancelPendingStart() {
|
|
14037
|
+
this.startupAbort?.abort();
|
|
14038
|
+
}
|
|
14039
|
+
setEnabled(enabled) {
|
|
14040
|
+
if (!enabled) this.startupAbort?.abort();
|
|
14041
|
+
return this.enqueue(async () => {
|
|
14042
|
+
this.assertAvailable();
|
|
14043
|
+
if (this.enabled === enabled && (!enabled || this.latest.state === "ready")) return;
|
|
14044
|
+
if (!enabled) await this.stop();
|
|
14045
|
+
await this.options.store.save({
|
|
14046
|
+
version: 1,
|
|
14047
|
+
enabled
|
|
14048
|
+
});
|
|
14049
|
+
this.enabled = enabled;
|
|
14050
|
+
if (enabled) await this.start();
|
|
14051
|
+
else this.publish({
|
|
14052
|
+
enabled: false,
|
|
14053
|
+
state: "off"
|
|
14054
|
+
});
|
|
14055
|
+
}).then(() => this.status());
|
|
14056
|
+
}
|
|
14057
|
+
reconnect() {
|
|
14058
|
+
this.startupAbort?.abort();
|
|
14059
|
+
return this.enqueue(async () => {
|
|
14060
|
+
this.assertAvailable();
|
|
14061
|
+
await this.stop();
|
|
14062
|
+
await this.options.store.save({
|
|
14063
|
+
version: 1,
|
|
14064
|
+
enabled: true
|
|
14065
|
+
});
|
|
14066
|
+
this.enabled = true;
|
|
14067
|
+
await this.start();
|
|
14068
|
+
}).then(() => this.status());
|
|
14069
|
+
}
|
|
14070
|
+
reset() {
|
|
14071
|
+
return this.setEnabled(false);
|
|
14072
|
+
}
|
|
14073
|
+
close() {
|
|
14074
|
+
this.disposed = true;
|
|
14075
|
+
this.startupAbort?.abort();
|
|
14076
|
+
this.closeTask ??= this.enqueue(async () => {
|
|
14077
|
+
await this.stop();
|
|
14078
|
+
this.publish({
|
|
14079
|
+
enabled: this.enabled,
|
|
14080
|
+
state: "off"
|
|
14081
|
+
});
|
|
14082
|
+
}).catch((error) => {
|
|
14083
|
+
this.closeTask = void 0;
|
|
14084
|
+
throw error;
|
|
14085
|
+
});
|
|
14086
|
+
return this.closeTask;
|
|
14087
|
+
}
|
|
14088
|
+
assertAvailable() {
|
|
14089
|
+
if (!this.initialized || this.disposed) throw new Error("caddy_controller_unavailable");
|
|
14090
|
+
}
|
|
14091
|
+
enqueue(operation) {
|
|
14092
|
+
const task = this.queue.then(operation, operation);
|
|
14093
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
14094
|
+
return task;
|
|
14095
|
+
}
|
|
14096
|
+
publish(status) {
|
|
14097
|
+
this.latest = Object.freeze(status);
|
|
14098
|
+
try {
|
|
14099
|
+
this.options.onStatus?.(this.latest);
|
|
14100
|
+
} catch {}
|
|
14101
|
+
}
|
|
14102
|
+
async start() {
|
|
14103
|
+
const generation = ++this.generation;
|
|
14104
|
+
const startup = new AbortController();
|
|
14105
|
+
this.startupAbort = startup;
|
|
14106
|
+
const settings = this.options.config.settings();
|
|
14107
|
+
if (settings === void 0 || this.options.config.credentials() === void 0) {
|
|
14108
|
+
this.publish({
|
|
14109
|
+
enabled: true,
|
|
14110
|
+
state: "unavailable",
|
|
14111
|
+
errorCode: "caddy_config_missing"
|
|
14112
|
+
});
|
|
14113
|
+
this.startupAbort = void 0;
|
|
14114
|
+
return;
|
|
14115
|
+
}
|
|
14116
|
+
if (!this.options.component.status().supported) {
|
|
14117
|
+
this.publish({
|
|
14118
|
+
enabled: true,
|
|
14119
|
+
state: "unavailable",
|
|
14120
|
+
errorCode: "caddy_component_unavailable"
|
|
14121
|
+
});
|
|
14122
|
+
this.startupAbort = void 0;
|
|
14123
|
+
return;
|
|
14124
|
+
}
|
|
14125
|
+
this.publish({
|
|
14126
|
+
enabled: true,
|
|
14127
|
+
state: "starting",
|
|
14128
|
+
origin: settings.publicOrigin
|
|
14129
|
+
});
|
|
14130
|
+
let childFailure;
|
|
14131
|
+
const deadline = setTimeout(() => {
|
|
14132
|
+
startup.abort();
|
|
14133
|
+
}, this.options.startupTimeoutMs ?? 12e4);
|
|
14134
|
+
deadline.unref();
|
|
14135
|
+
try {
|
|
14136
|
+
await this.options.component.ensureExecutable();
|
|
14137
|
+
if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
|
|
14138
|
+
const gateway = await this.options.createGateway(settings);
|
|
14139
|
+
this.gatewayValue = gateway;
|
|
14140
|
+
if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
|
|
14141
|
+
await this.options.config.prepareCaddyfile(gateway.address().port);
|
|
14142
|
+
const environment = caddyProcessEnvironment(this.options.config, settings);
|
|
14143
|
+
await (this.options.validateConfig ?? validate)(this.options.component.executable, this.options.config.caddyfile, environment, startup.signal);
|
|
14144
|
+
if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
|
|
14145
|
+
const child = (this.options.spawnProcess ?? spawnCaddy)(this.options.component.executable, [
|
|
14146
|
+
"run",
|
|
14147
|
+
"--config",
|
|
14148
|
+
this.options.config.caddyfile,
|
|
14149
|
+
"--adapter",
|
|
14150
|
+
"caddyfile"
|
|
14151
|
+
], environment);
|
|
14152
|
+
this.child = child;
|
|
14153
|
+
child.stdout.resume();
|
|
14154
|
+
child.stderr.resume();
|
|
14155
|
+
child.stdin.end();
|
|
14156
|
+
const failed = (code) => {
|
|
14157
|
+
if (generation !== this.generation || this.child !== child) return;
|
|
14158
|
+
childFailure = code;
|
|
14159
|
+
startup.abort();
|
|
14160
|
+
if (this.latest.state === "ready") this.enqueue(async () => {
|
|
14161
|
+
if (generation !== this.generation) return;
|
|
14162
|
+
await this.stop();
|
|
14163
|
+
if (this.enabled && !this.disposed) this.publish({
|
|
14164
|
+
enabled: true,
|
|
14165
|
+
state: "error",
|
|
14166
|
+
errorCode: code
|
|
14167
|
+
});
|
|
14168
|
+
}).catch(() => {
|
|
14169
|
+
this.publish({
|
|
14170
|
+
enabled: this.enabled,
|
|
14171
|
+
state: "error",
|
|
14172
|
+
errorCode: "caddy_cleanup_failed"
|
|
14173
|
+
});
|
|
14174
|
+
});
|
|
14175
|
+
};
|
|
14176
|
+
child.once("error", () => {
|
|
14177
|
+
failed("caddy_launch_failed");
|
|
14178
|
+
});
|
|
14179
|
+
child.once("close", () => {
|
|
14180
|
+
failed("caddy_exited");
|
|
14181
|
+
});
|
|
14182
|
+
const probe = this.options.probeDiscovery ?? ((origin, expected, signal) => defaultProbeDiscovery({ origin }, expected, signal));
|
|
14183
|
+
while (true) {
|
|
14184
|
+
if (startup.signal.aborted || this.disposed) throw new Error(childFailure ?? "caddy_start_timeout");
|
|
14185
|
+
let ready = false;
|
|
14186
|
+
try {
|
|
14187
|
+
ready = await probe(settings.publicOrigin, this.options.instanceId, startup.signal);
|
|
14188
|
+
} catch (error) {
|
|
14189
|
+
if (error instanceof Error && (error.message === "frp_discovery_mismatch" || error.message === "frp_discovery_invalid")) throw new Error("caddy_public_identity_invalid");
|
|
14190
|
+
}
|
|
14191
|
+
if (startup.signal.aborted || this.disposed || generation !== this.generation) throw new Error(childFailure ?? "caddy_start_timeout");
|
|
14192
|
+
if (ready) break;
|
|
14193
|
+
await this.waitForProbe(startup.signal);
|
|
14194
|
+
}
|
|
14195
|
+
this.publish({
|
|
14196
|
+
enabled: true,
|
|
14197
|
+
state: "ready",
|
|
14198
|
+
origin: settings.publicOrigin,
|
|
14199
|
+
backendOrigin: "http://127.0.0.1:" + String(gateway.address().port)
|
|
14200
|
+
});
|
|
14201
|
+
} catch (error) {
|
|
14202
|
+
++this.generation;
|
|
14203
|
+
await this.stopResources();
|
|
14204
|
+
if (!this.disposed) this.publish({
|
|
14205
|
+
enabled: this.enabled,
|
|
14206
|
+
state: "error",
|
|
14207
|
+
errorCode: childFailure ?? (error instanceof Error && error.message.startsWith("caddy_") ? error.message : error.code === "EADDRINUSE" ? "caddy_backend_port_in_use" : "caddy_start_failed")
|
|
14208
|
+
});
|
|
14209
|
+
} finally {
|
|
14210
|
+
clearTimeout(deadline);
|
|
14211
|
+
if (this.startupAbort === startup) this.startupAbort = void 0;
|
|
14212
|
+
}
|
|
14213
|
+
}
|
|
14214
|
+
waitForProbe(signal) {
|
|
14215
|
+
if (signal.aborted) return Promise.resolve();
|
|
14216
|
+
return new Promise((resolve) => {
|
|
14217
|
+
const done = () => {
|
|
14218
|
+
clearTimeout(timer);
|
|
14219
|
+
signal.removeEventListener("abort", done);
|
|
14220
|
+
resolve();
|
|
14221
|
+
};
|
|
14222
|
+
const timer = setTimeout(done, this.options.probeIntervalMs ?? 1e3);
|
|
14223
|
+
timer.unref();
|
|
14224
|
+
signal.addEventListener("abort", done, { once: true });
|
|
14225
|
+
});
|
|
14226
|
+
}
|
|
14227
|
+
async stop() {
|
|
14228
|
+
++this.generation;
|
|
14229
|
+
this.startupAbort?.abort();
|
|
14230
|
+
await this.stopResources();
|
|
14231
|
+
}
|
|
14232
|
+
async stopResources() {
|
|
14233
|
+
const child = this.child;
|
|
14234
|
+
const gateway = this.gatewayValue;
|
|
14235
|
+
await settleRemoteResources([async () => {
|
|
14236
|
+
if (child !== void 0) {
|
|
14237
|
+
await (this.options.terminateProcess ?? terminateRemoteProcess)(child);
|
|
14238
|
+
if (this.child === child) this.child = void 0;
|
|
14239
|
+
}
|
|
14240
|
+
}, async () => {
|
|
14241
|
+
if (gateway !== void 0) {
|
|
14242
|
+
await gateway.close();
|
|
14243
|
+
if (this.gatewayValue === gateway) this.gatewayValue = void 0;
|
|
14244
|
+
}
|
|
14245
|
+
}], "caddy cleanup failed");
|
|
14246
|
+
}
|
|
14247
|
+
};
|
|
14248
|
+
//#endregion
|
|
14249
|
+
//#region src/managed-origin.ts
|
|
14250
|
+
/** Children never independently restore a durable enabled switch. */
|
|
14251
|
+
function createVolatileRemoteControlStore() {
|
|
14252
|
+
let value = {
|
|
14253
|
+
version: 1,
|
|
14254
|
+
enabled: false
|
|
14255
|
+
};
|
|
14256
|
+
return {
|
|
14257
|
+
load: async () => value,
|
|
14258
|
+
save: async (state) => {
|
|
14259
|
+
value = Object.freeze({ ...state });
|
|
14260
|
+
}
|
|
14261
|
+
};
|
|
14262
|
+
}
|
|
14263
|
+
function parseMode(value) {
|
|
14264
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("origin_mode_invalid");
|
|
14265
|
+
const record = value;
|
|
14266
|
+
if (record.version !== 1 || record.mode !== "external" && record.mode !== "managed" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "mode")) throw new Error("origin_mode_invalid");
|
|
14267
|
+
return Object.freeze({
|
|
14268
|
+
version: 1,
|
|
14269
|
+
mode: record.mode
|
|
14270
|
+
});
|
|
14271
|
+
}
|
|
14272
|
+
/** Persist only origin's upstream mode, defaulting to the existing external-proxy behavior. */
|
|
14273
|
+
var JsonOriginModeStore = class {
|
|
14274
|
+
file;
|
|
14275
|
+
constructor(file) {
|
|
14276
|
+
this.file = file;
|
|
14277
|
+
if (!isAbsolute(file)) throw new Error("origin mode file must be absolute");
|
|
14278
|
+
}
|
|
14279
|
+
async load() {
|
|
14280
|
+
await assertCaddyParents(dirname(this.file), this.file);
|
|
14281
|
+
let entry;
|
|
14282
|
+
try {
|
|
14283
|
+
entry = await lstat(this.file);
|
|
14284
|
+
} catch (error) {
|
|
14285
|
+
if (error.code === "ENOENT") return {
|
|
14286
|
+
version: 1,
|
|
14287
|
+
mode: "external"
|
|
14288
|
+
};
|
|
14289
|
+
throw error;
|
|
14290
|
+
}
|
|
14291
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 1024) throw new Error("origin_mode_invalid");
|
|
14292
|
+
await restrictPrivateFile(this.file);
|
|
14293
|
+
return parseMode(JSON.parse(await readFile(this.file, "utf8")));
|
|
14294
|
+
}
|
|
14295
|
+
async save(value) {
|
|
14296
|
+
await writeCaddyPrivateFile(dirname(this.file), this.file, JSON.stringify(parseMode(value)) + "\n");
|
|
14297
|
+
}
|
|
14298
|
+
};
|
|
14299
|
+
/** One origin-provider owner selects exactly one upstream; external settings and shared device records remain untouched. */
|
|
14300
|
+
var ManagedOriginController = class {
|
|
14301
|
+
options;
|
|
14302
|
+
selected = "external";
|
|
14303
|
+
enabled = false;
|
|
14304
|
+
initialized = false;
|
|
14305
|
+
disposed = false;
|
|
14306
|
+
queue = Promise.resolve();
|
|
14307
|
+
closeTask;
|
|
14308
|
+
constructor(options) {
|
|
14309
|
+
this.options = options;
|
|
14310
|
+
}
|
|
14311
|
+
mode() {
|
|
14312
|
+
return this.selected;
|
|
14313
|
+
}
|
|
14314
|
+
status() {
|
|
14315
|
+
return Object.freeze({
|
|
14316
|
+
...this.controller().status(),
|
|
14317
|
+
enabled: this.enabled,
|
|
14318
|
+
mode: this.selected
|
|
14319
|
+
});
|
|
14320
|
+
}
|
|
14321
|
+
gateway() {
|
|
14322
|
+
return this.enabled ? this.controller().gateway() : void 0;
|
|
14323
|
+
}
|
|
14324
|
+
initialize() {
|
|
14325
|
+
return this.enqueue(async () => {
|
|
14326
|
+
if (this.initialized) return;
|
|
14327
|
+
if (this.disposed) throw new Error("origin_controller_unavailable");
|
|
14328
|
+
this.selected = (await this.options.modeStore.load()).mode;
|
|
14329
|
+
this.enabled = (await this.options.store.load()).enabled;
|
|
14330
|
+
await this.options.external.initialize();
|
|
14331
|
+
await this.options.managed.initialize();
|
|
14332
|
+
await this.options.external.setEnabled(false);
|
|
14333
|
+
await this.options.managed.setEnabled(false);
|
|
14334
|
+
this.initialized = true;
|
|
14335
|
+
if (this.enabled && !this.disposed) await this.controller().setEnabled(true);
|
|
14336
|
+
});
|
|
14337
|
+
}
|
|
14338
|
+
selectMode(mode) {
|
|
14339
|
+
if (mode !== this.selected) this.options.managed.cancelPendingStart?.();
|
|
14340
|
+
return this.enqueue(async () => {
|
|
14341
|
+
this.assertAvailable();
|
|
14342
|
+
if (mode !== "external" && mode !== "managed") throw new Error("origin_mode_invalid");
|
|
14343
|
+
if (mode === this.selected) return;
|
|
14344
|
+
const previous = this.controller();
|
|
14345
|
+
await previous.setEnabled(false);
|
|
14346
|
+
try {
|
|
14347
|
+
await this.options.modeStore.save({
|
|
14348
|
+
version: 1,
|
|
14349
|
+
mode
|
|
14350
|
+
});
|
|
14351
|
+
} catch (error) {
|
|
14352
|
+
if (this.enabled && !this.disposed) await previous.setEnabled(true);
|
|
14353
|
+
throw error;
|
|
14354
|
+
}
|
|
14355
|
+
this.selected = mode;
|
|
14356
|
+
if (this.enabled && !this.disposed) try {
|
|
14357
|
+
await this.controller().setEnabled(true);
|
|
14358
|
+
} catch (error) {
|
|
14359
|
+
await this.stopBoth();
|
|
14360
|
+
throw error;
|
|
14361
|
+
}
|
|
14362
|
+
}).then(() => this.status());
|
|
14363
|
+
}
|
|
14364
|
+
setEnabled(enabled) {
|
|
14365
|
+
if (!enabled) this.options.managed.cancelPendingStart?.();
|
|
14366
|
+
return this.enqueue(async () => {
|
|
14367
|
+
this.assertAvailable();
|
|
14368
|
+
if (!enabled) await this.stopBoth();
|
|
14369
|
+
await this.options.store.save({
|
|
14370
|
+
version: 1,
|
|
14371
|
+
enabled
|
|
14372
|
+
});
|
|
14373
|
+
this.enabled = enabled;
|
|
14374
|
+
if (enabled && !this.disposed) try {
|
|
14375
|
+
await this.controller().setEnabled(true);
|
|
14376
|
+
} catch (error) {
|
|
14377
|
+
await this.stopBoth();
|
|
14378
|
+
throw error;
|
|
14379
|
+
}
|
|
14380
|
+
}).then(() => this.status());
|
|
14381
|
+
}
|
|
14382
|
+
reconnect() {
|
|
14383
|
+
this.options.managed.cancelPendingStart?.();
|
|
14384
|
+
return this.enqueue(async () => {
|
|
14385
|
+
this.assertAvailable();
|
|
14386
|
+
await this.stopBoth();
|
|
14387
|
+
await this.options.store.save({
|
|
14388
|
+
version: 1,
|
|
14389
|
+
enabled: true
|
|
14390
|
+
});
|
|
14391
|
+
this.enabled = true;
|
|
14392
|
+
if (!this.disposed) try {
|
|
14393
|
+
await this.controller().reconnect();
|
|
14394
|
+
} catch (error) {
|
|
14395
|
+
await this.stopBoth();
|
|
14396
|
+
throw error;
|
|
14397
|
+
}
|
|
14398
|
+
}).then(() => this.status());
|
|
14399
|
+
}
|
|
14400
|
+
reset() {
|
|
14401
|
+
return this.setEnabled(false);
|
|
14402
|
+
}
|
|
14403
|
+
close() {
|
|
14404
|
+
this.disposed = true;
|
|
14405
|
+
this.closeTask ??= settleRemoteResources([() => this.options.external.close(), () => this.options.managed.close()]).then(() => this.queue).catch((error) => {
|
|
14406
|
+
this.closeTask = void 0;
|
|
14407
|
+
throw error;
|
|
14408
|
+
});
|
|
14409
|
+
return this.closeTask;
|
|
14410
|
+
}
|
|
14411
|
+
controller() {
|
|
14412
|
+
return this.options[this.selected];
|
|
14413
|
+
}
|
|
14414
|
+
assertAvailable() {
|
|
14415
|
+
if (!this.initialized || this.disposed) throw new Error("origin_controller_unavailable");
|
|
14416
|
+
}
|
|
14417
|
+
stopBoth() {
|
|
14418
|
+
return settleRemoteResources([async () => {
|
|
14419
|
+
await this.options.external.setEnabled(false);
|
|
14420
|
+
}, async () => {
|
|
14421
|
+
await this.options.managed.setEnabled(false);
|
|
14422
|
+
}]);
|
|
14423
|
+
}
|
|
14424
|
+
enqueue(operation) {
|
|
14425
|
+
const task = this.queue.then(operation, operation);
|
|
14426
|
+
this.queue = task.then(() => void 0, () => void 0);
|
|
14427
|
+
return task;
|
|
14428
|
+
}
|
|
14429
|
+
};
|
|
14430
|
+
//#endregion
|
|
14431
|
+
//#region src/release-update.ts
|
|
14432
|
+
const PACKAGE_NAME = "dsh-mobile";
|
|
14433
|
+
const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
|
|
14434
|
+
const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
|
|
14435
|
+
const GITHUB_API_LATEST_URL = "https://api.github.com/repos/saya-ch/dsh-mobile/releases/latest";
|
|
14436
|
+
const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
|
|
14437
|
+
const RELEASE_NOTES_MAX_CHARS = 4e3;
|
|
14438
|
+
const STATUS_CACHE_MS = 6e5;
|
|
14439
|
+
const REQUEST_TIMEOUT_MS = 8e3;
|
|
14440
|
+
const UPDATE_TIMEOUT_MS = 12e4;
|
|
14441
|
+
const UPDATE_TERMINATION_GRACE_MS = 1500;
|
|
14442
|
+
const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
|
|
14443
|
+
const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
|
|
14444
|
+
const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
|
|
14445
|
+
const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
|
|
14446
|
+
const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
|
|
14447
|
+
const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
|
|
14448
|
+
function parseSemver(value) {
|
|
14449
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
|
|
14450
|
+
if (match === null) return void 0;
|
|
14451
|
+
const core = [
|
|
14452
|
+
Number(match[1]),
|
|
14453
|
+
Number(match[2]),
|
|
14454
|
+
Number(match[3])
|
|
14455
|
+
];
|
|
14456
|
+
if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
|
|
14457
|
+
const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
|
|
14458
|
+
if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
|
|
14459
|
+
return Object.freeze({
|
|
14460
|
+
core,
|
|
14461
|
+
prerelease: Object.freeze(prerelease)
|
|
14462
|
+
});
|
|
14463
|
+
}
|
|
14464
|
+
/** Compare two strict SemVer strings, including prerelease precedence. */
|
|
14465
|
+
function comparePluginVersions(left, right) {
|
|
14466
|
+
const a = parseSemver(left);
|
|
14467
|
+
const b = parseSemver(right);
|
|
14468
|
+
if (a === void 0 || b === void 0) return void 0;
|
|
14469
|
+
for (let index = 0; index < a.core.length; index += 1) {
|
|
14470
|
+
const difference = a.core[index] - b.core[index];
|
|
14471
|
+
if (difference !== 0) return Math.sign(difference);
|
|
14472
|
+
}
|
|
14473
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
|
|
14474
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
14475
|
+
for (let index = 0; index < length; index += 1) {
|
|
14476
|
+
const leftPart = a.prerelease[index];
|
|
14477
|
+
const rightPart = b.prerelease[index];
|
|
14478
|
+
if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
|
|
14479
|
+
if (leftPart === rightPart) continue;
|
|
14480
|
+
if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
|
|
14481
|
+
if (typeof leftPart === "number") return -1;
|
|
14482
|
+
if (typeof rightPart === "number") return 1;
|
|
14483
|
+
return leftPart < rightPart ? -1 : 1;
|
|
14484
|
+
}
|
|
14485
|
+
return 0;
|
|
14486
|
+
}
|
|
14487
|
+
function isComparatorSet(value) {
|
|
14488
|
+
if (HYPHEN_RANGE.test(value)) return true;
|
|
14489
|
+
const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
|
|
14490
|
+
return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
|
|
14491
|
+
}
|
|
14492
|
+
function isNpmVersionRange(value) {
|
|
14493
|
+
if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
|
|
14494
|
+
const alternatives = value.split(/ *\|\| */u);
|
|
14495
|
+
return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
|
|
14496
|
+
}
|
|
14497
|
+
/** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
|
|
14498
|
+
function isRegistryPluginSpec(value) {
|
|
14499
|
+
if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
|
|
14500
|
+
if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
|
|
14501
|
+
return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
|
|
14502
|
+
}
|
|
14503
|
+
/** Resolve the DSH profile named by the current launcher arguments. */
|
|
14504
|
+
function launchedProfileName(argv) {
|
|
14505
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
14506
|
+
if (argv[index] === "--profile") {
|
|
14507
|
+
const candidate = argv[index + 1];
|
|
14508
|
+
if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
|
|
14509
|
+
}
|
|
14510
|
+
const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
|
|
14511
|
+
if (match?.[1] !== void 0) return match[1];
|
|
14512
|
+
}
|
|
14513
|
+
return "web";
|
|
14514
|
+
}
|
|
14515
|
+
/** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
|
|
14516
|
+
function releaseProfileDirectory(ctx, dshHome, argv) {
|
|
14517
|
+
const desktopProfiles = ctx.get("desktopProfiles");
|
|
14518
|
+
const desktopDirectory = desktopProfiles?.current?.dir;
|
|
14519
|
+
if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
|
|
14520
|
+
if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
|
|
14521
|
+
return join(dshHome, "profiles", launchedProfileName(argv));
|
|
14522
|
+
}
|
|
14523
|
+
async function profileDependencySpec(profileDirectory) {
|
|
14524
|
+
try {
|
|
14525
|
+
const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
|
|
14526
|
+
return typeof value === "string" ? value : void 0;
|
|
14527
|
+
} catch {
|
|
14528
|
+
return;
|
|
14529
|
+
}
|
|
14530
|
+
}
|
|
14531
|
+
async function fetchNpmVersion(fetcher) {
|
|
14532
|
+
const response = await fetcher(NPM_LATEST_URL, {
|
|
14533
|
+
headers: {
|
|
14534
|
+
accept: "application/json",
|
|
14535
|
+
"user-agent": "dsh-mobile-release-check"
|
|
14536
|
+
},
|
|
14537
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
14538
|
+
});
|
|
14539
|
+
if (!response.ok) return void 0;
|
|
14540
|
+
const payload = await response.json();
|
|
14541
|
+
return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
|
|
14542
|
+
}
|
|
14543
|
+
function githubReleaseVersion(location, responseUrl) {
|
|
14544
|
+
let url;
|
|
14545
|
+
try {
|
|
14546
|
+
url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
|
|
14547
|
+
} catch {
|
|
14548
|
+
return;
|
|
14549
|
+
}
|
|
14550
|
+
if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
|
|
14551
|
+
if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
|
|
14552
|
+
let version;
|
|
14553
|
+
try {
|
|
14554
|
+
version = decodeURIComponent(url.pathname.slice(34));
|
|
14555
|
+
} catch {
|
|
14556
|
+
return;
|
|
14557
|
+
}
|
|
14558
|
+
return parseSemver(version) === void 0 ? void 0 : version;
|
|
14559
|
+
}
|
|
14560
|
+
function androidReleaseDownloadUrl(version) {
|
|
14561
|
+
if (version === void 0) return GITHUB_RELEASES_URL;
|
|
14562
|
+
const tag = `v${version}`;
|
|
14563
|
+
return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
|
|
14564
|
+
}
|
|
14565
|
+
async function fetchAndroidVersion(fetcher) {
|
|
14566
|
+
const response = await fetcher(GITHUB_LATEST_URL, {
|
|
14567
|
+
method: "GET",
|
|
14568
|
+
redirect: "manual",
|
|
14569
|
+
headers: {
|
|
14570
|
+
accept: "text/html",
|
|
14571
|
+
"user-agent": "dsh-mobile-release-check"
|
|
14572
|
+
},
|
|
14573
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
14574
|
+
});
|
|
14575
|
+
return githubReleaseVersion(response.headers.get("location"), response.url);
|
|
14576
|
+
}
|
|
14577
|
+
/** Best-effort body of the latest GitHub release, trimmed to a bounded size. */
|
|
14578
|
+
async function fetchReleaseNotes(fetcher) {
|
|
14579
|
+
const response = await fetcher(GITHUB_API_LATEST_URL, {
|
|
14580
|
+
headers: {
|
|
14581
|
+
accept: "application/vnd.github+json",
|
|
14582
|
+
"user-agent": "dsh-mobile-release-check"
|
|
14583
|
+
},
|
|
14584
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
14585
|
+
});
|
|
14586
|
+
if (!response.ok) return void 0;
|
|
14587
|
+
const payload = await response.json();
|
|
14588
|
+
if (typeof payload.body !== "string") return void 0;
|
|
14589
|
+
const notes = payload.body.trim();
|
|
14590
|
+
return notes === "" ? void 0 : notes.slice(0, RELEASE_NOTES_MAX_CHARS);
|
|
14591
|
+
}
|
|
14592
|
+
async function readProfileInstalledVersion(profileDirectory) {
|
|
14593
|
+
try {
|
|
14594
|
+
const manifestPath = createRequire(join(profileDirectory, "package.json")).resolve(`${PACKAGE_NAME}/package.json`);
|
|
14595
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
14596
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
14597
|
+
} catch {
|
|
14598
|
+
return;
|
|
14599
|
+
}
|
|
14600
|
+
}
|
|
14601
|
+
function childCompletion(child) {
|
|
14602
|
+
return new Promise((resolveCompletion, rejectCompletion) => {
|
|
14603
|
+
child.once("error", rejectCompletion);
|
|
14604
|
+
child.once("close", (code, signal) => {
|
|
14605
|
+
resolveCompletion({
|
|
14606
|
+
code,
|
|
14607
|
+
signal
|
|
14608
|
+
});
|
|
14609
|
+
});
|
|
14610
|
+
});
|
|
14611
|
+
}
|
|
14612
|
+
function createDeadline(timeoutMs) {
|
|
14613
|
+
let timer;
|
|
14614
|
+
return {
|
|
14615
|
+
promise: new Promise((resolveTimeout) => {
|
|
14616
|
+
timer = setTimeout(resolveTimeout, timeoutMs);
|
|
14617
|
+
timer.unref();
|
|
14618
|
+
}),
|
|
14619
|
+
cancel: () => {
|
|
14620
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
14621
|
+
timer = void 0;
|
|
14622
|
+
}
|
|
14623
|
+
};
|
|
14624
|
+
}
|
|
14625
|
+
async function taskkillProcessTree(pid) {
|
|
12939
14626
|
if ((await childCompletion(spawn("taskkill.exe", [
|
|
12940
14627
|
"/PID",
|
|
12941
14628
|
String(pid),
|
|
@@ -13434,7 +15121,8 @@ async function loadSetup(config) {
|
|
|
13434
15121
|
if (record.version === 2) return {
|
|
13435
15122
|
kind: "managed",
|
|
13436
15123
|
config: withoutSetupKeys(config),
|
|
13437
|
-
setup: parseManagedSetup(record)
|
|
15124
|
+
setup: parseManagedSetup(record),
|
|
15125
|
+
setupFile: resolve(config.setupFile)
|
|
13438
15126
|
};
|
|
13439
15127
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
13440
15128
|
const { version: _version, ...setup } = record;
|
|
@@ -13542,7 +15230,7 @@ function originGatewayConfig(template, settings, stateFile, instanceId, listenPo
|
|
|
13542
15230
|
allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
|
|
13543
15231
|
});
|
|
13544
15232
|
}
|
|
13545
|
-
function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration) {
|
|
15233
|
+
function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus) {
|
|
13546
15234
|
return {
|
|
13547
15235
|
provider,
|
|
13548
15236
|
running: status.enabled,
|
|
@@ -13583,7 +15271,8 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
|
|
|
13583
15271
|
bundled: true,
|
|
13584
15272
|
running: providerStatuses.origin.enabled,
|
|
13585
15273
|
state: providerStatuses.origin.state,
|
|
13586
|
-
configuration: originConfiguration
|
|
15274
|
+
configuration: originConfiguration,
|
|
15275
|
+
component: caddyComponentStatus
|
|
13587
15276
|
}
|
|
13588
15277
|
}
|
|
13589
15278
|
};
|
|
@@ -13623,6 +15312,10 @@ async function apply(ctx, config) {
|
|
|
13623
15312
|
await frpConfig.initialize();
|
|
13624
15313
|
const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
|
|
13625
15314
|
await originConfig.initialize();
|
|
15315
|
+
const caddyComponent = new CaddyComponentManager({ stateDirectory });
|
|
15316
|
+
await caddyComponent.initialize();
|
|
15317
|
+
const caddyConfig = new CaddyConfigStore(stateDirectory);
|
|
15318
|
+
await caddyConfig.initialize();
|
|
13626
15319
|
const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
|
|
13627
15320
|
await cloudflaredTunnel.initialize();
|
|
13628
15321
|
const unregisterBuiltin = mobileAccess.registerExtension({
|
|
@@ -13658,8 +15351,13 @@ async function apply(ctx, config) {
|
|
|
13658
15351
|
const webSocketPaths = new WebSocketPathStore(join(stateDirectory, "websocket-paths.json"));
|
|
13659
15352
|
await webSocketPaths.load();
|
|
13660
15353
|
const blockedUpgradePaths = new BlockedUpgradePathLog();
|
|
15354
|
+
const clientModulePreferences = new ClientModulePreferenceStore(join(stateDirectory, "client-modules.json"), template.excludedClientModules);
|
|
15355
|
+
await clientModulePreferences.initialize();
|
|
13661
15356
|
let lanGateway;
|
|
13662
15357
|
let preparedLanSetup;
|
|
15358
|
+
let trustedNetworksRestartRequired = false;
|
|
15359
|
+
let trustedNetworksWrite = Promise.resolve();
|
|
15360
|
+
let adminClosing = false;
|
|
13663
15361
|
const startGateway = async (candidateConfig) => {
|
|
13664
15362
|
const resolved = parseGatewayConfig({
|
|
13665
15363
|
...candidateConfig,
|
|
@@ -13667,7 +15365,7 @@ async function apply(ctx, config) {
|
|
|
13667
15365
|
});
|
|
13668
15366
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
|
|
13669
15367
|
logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
|
|
13670
|
-
});
|
|
15368
|
+
}, clientModulePreferences);
|
|
13671
15369
|
await candidate.start();
|
|
13672
15370
|
lanGateway = candidate;
|
|
13673
15371
|
const removeTaskSink = taskEventHub.add(candidate);
|
|
@@ -13723,7 +15421,7 @@ async function apply(ctx, config) {
|
|
|
13723
15421
|
}
|
|
13724
15422
|
const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
|
|
13725
15423
|
const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
|
|
13726
|
-
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
15424
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
13727
15425
|
await candidate.start();
|
|
13728
15426
|
return candidate;
|
|
13729
15427
|
};
|
|
@@ -13739,7 +15437,7 @@ async function apply(ctx, config) {
|
|
|
13739
15437
|
if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
|
|
13740
15438
|
const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
|
|
13741
15439
|
const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
|
|
13742
|
-
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
15440
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
13743
15441
|
try {
|
|
13744
15442
|
await candidate.start();
|
|
13745
15443
|
} catch (error) {
|
|
@@ -13750,7 +15448,7 @@ async function apply(ctx, config) {
|
|
|
13750
15448
|
};
|
|
13751
15449
|
const createOriginGateway = async (settings) => {
|
|
13752
15450
|
const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
|
|
13753
|
-
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
15451
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
|
|
13754
15452
|
try {
|
|
13755
15453
|
await candidate.start();
|
|
13756
15454
|
} catch (error) {
|
|
@@ -13764,6 +15462,23 @@ async function apply(ctx, config) {
|
|
|
13764
15462
|
const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
|
|
13765
15463
|
const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
|
|
13766
15464
|
const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
|
|
15465
|
+
const caddyController = new CaddyController({
|
|
15466
|
+
store: createVolatileRemoteControlStore(),
|
|
15467
|
+
component: caddyComponent,
|
|
15468
|
+
config: caddyConfig,
|
|
15469
|
+
instanceId,
|
|
15470
|
+
createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
|
|
15471
|
+
});
|
|
15472
|
+
const originController = new ManagedOriginController({
|
|
15473
|
+
store: originStore,
|
|
15474
|
+
modeStore: new JsonOriginModeStore(join(remoteDirectory, "origin", "upstream.json")),
|
|
15475
|
+
external: new OriginController({
|
|
15476
|
+
store: createVolatileRemoteControlStore(),
|
|
15477
|
+
config: originConfig,
|
|
15478
|
+
createGateway: createOriginGateway
|
|
15479
|
+
}),
|
|
15480
|
+
managed: caddyController
|
|
15481
|
+
});
|
|
13767
15482
|
const remoteControllers = {
|
|
13768
15483
|
tailscale: new FunnelController({
|
|
13769
15484
|
store: tailscaleStore,
|
|
@@ -13802,11 +15517,7 @@ async function apply(ctx, config) {
|
|
|
13802
15517
|
await gateway.refreshProvidedTls();
|
|
13803
15518
|
}
|
|
13804
15519
|
}),
|
|
13805
|
-
origin:
|
|
13806
|
-
store: originStore,
|
|
13807
|
-
config: originConfig,
|
|
13808
|
-
createGateway: createOriginGateway
|
|
13809
|
-
})
|
|
15520
|
+
origin: originController
|
|
13810
15521
|
};
|
|
13811
15522
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
13812
15523
|
const remoteController = () => remoteProviders.controller();
|
|
@@ -13816,13 +15527,18 @@ async function apply(ctx, config) {
|
|
|
13816
15527
|
controller.gateway()?.broadcastTaskEvent(event);
|
|
13817
15528
|
} });
|
|
13818
15529
|
}
|
|
13819
|
-
const remotePayload = () =>
|
|
13820
|
-
|
|
13821
|
-
|
|
13822
|
-
|
|
13823
|
-
|
|
13824
|
-
|
|
13825
|
-
|
|
15530
|
+
const remotePayload = () => ({
|
|
15531
|
+
...remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
15532
|
+
tailscale: remoteControllers.tailscale.status(),
|
|
15533
|
+
cpolar: remoteControllers.cpolar.status(),
|
|
15534
|
+
cloudflared: remoteControllers.cloudflared.status(),
|
|
15535
|
+
frp: remoteControllers.frp.status(),
|
|
15536
|
+
origin: remoteControllers.origin.status()
|
|
15537
|
+
}, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status()),
|
|
15538
|
+
originMode: originController.mode(),
|
|
15539
|
+
caddyConfiguration: caddyConfig.status(),
|
|
15540
|
+
caddyState: caddyController.status()
|
|
15541
|
+
});
|
|
13826
15542
|
const lanPayload = () => ({
|
|
13827
15543
|
...loaded.kind === "unconfigured" ? {
|
|
13828
15544
|
configured: preparedLanSetup !== void 0,
|
|
@@ -13853,6 +15569,15 @@ async function apply(ctx, config) {
|
|
|
13853
15569
|
windowsFirewall: process.platform === "win32"
|
|
13854
15570
|
};
|
|
13855
15571
|
};
|
|
15572
|
+
const trustedNetworksPayload = async () => {
|
|
15573
|
+
if (loaded.kind !== "managed") return { supported: false };
|
|
15574
|
+
return {
|
|
15575
|
+
supported: true,
|
|
15576
|
+
extraAllowedCidrs: parseManagedSetup(JSON.parse(await readFile(loaded.setupFile, "utf8"))).extraAllowedCidrs ?? [],
|
|
15577
|
+
restartRequired: trustedNetworksRestartRequired,
|
|
15578
|
+
windowsFirewall: process.platform === "win32"
|
|
15579
|
+
};
|
|
15580
|
+
};
|
|
13856
15581
|
const diagnosticsPayload = async () => {
|
|
13857
15582
|
let interfaceName;
|
|
13858
15583
|
let networkError;
|
|
@@ -13918,6 +15643,7 @@ async function apply(ctx, config) {
|
|
|
13918
15643
|
try {
|
|
13919
15644
|
const target = parseRequestTarget(request.url);
|
|
13920
15645
|
assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
|
|
15646
|
+
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
13921
15647
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
13922
15648
|
const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
|
|
13923
15649
|
if (request.method === "GET" && lanControl) {
|
|
@@ -13928,6 +15654,59 @@ async function apply(ctx, config) {
|
|
|
13928
15654
|
sendJson(response, 200, await lanSetupPayload(), false);
|
|
13929
15655
|
return;
|
|
13930
15656
|
}
|
|
15657
|
+
if (target.decodedPathname === `/api/mobile-access/client-modules`) {
|
|
15658
|
+
const gateway = lanGateway ?? remoteController().gateway();
|
|
15659
|
+
if (gateway === void 0) throw new HttpError(409, "mobile_access_not_running");
|
|
15660
|
+
const operation = new AbortController();
|
|
15661
|
+
const abort = () => {
|
|
15662
|
+
operation.abort();
|
|
15663
|
+
};
|
|
15664
|
+
response.once("close", abort);
|
|
15665
|
+
try {
|
|
15666
|
+
if (request.method === "GET") {
|
|
15667
|
+
sendJson(response, 200, await gateway.describeClientModules(void 0, operation.signal), false);
|
|
15668
|
+
return;
|
|
15669
|
+
}
|
|
15670
|
+
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
15671
|
+
const body = await readJsonObject(request, template.maxBodyBytes);
|
|
15672
|
+
if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
|
|
15673
|
+
sendJson(response, 200, body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
|
|
15674
|
+
} catch (error) {
|
|
15675
|
+
if (!(error instanceof ClientModuleConflictError)) throw error;
|
|
15676
|
+
sendJson(response, 409, {
|
|
15677
|
+
error: "excluded_client_modules_invalid",
|
|
15678
|
+
detail: error.detail
|
|
15679
|
+
}, false);
|
|
15680
|
+
} finally {
|
|
15681
|
+
response.removeListener("close", abort);
|
|
15682
|
+
operation.abort();
|
|
15683
|
+
}
|
|
15684
|
+
return;
|
|
15685
|
+
}
|
|
15686
|
+
if (target.decodedPathname === `/api/mobile-access/lan/trusted-networks`) {
|
|
15687
|
+
if (request.method === "GET") {
|
|
15688
|
+
sendJson(response, 200, await trustedNetworksPayload(), false);
|
|
15689
|
+
return;
|
|
15690
|
+
}
|
|
15691
|
+
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
15692
|
+
if (loaded.kind !== "managed") throw new HttpError(409, "lan_trusted_networks_unavailable");
|
|
15693
|
+
const body = await readJsonObject(request, 4096);
|
|
15694
|
+
let values;
|
|
15695
|
+
try {
|
|
15696
|
+
values = parseAdditionalTrustedNetworks(body.extraAllowedCidrs);
|
|
15697
|
+
} catch {
|
|
15698
|
+
throw new HttpError(400, "lan_trusted_networks_invalid");
|
|
15699
|
+
}
|
|
15700
|
+
const write = trustedNetworksWrite.then(async () => {
|
|
15701
|
+
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
15702
|
+
await saveManagedTrustedNetworks(loaded.setupFile, values);
|
|
15703
|
+
trustedNetworksRestartRequired = true;
|
|
15704
|
+
});
|
|
15705
|
+
trustedNetworksWrite = write.catch(() => void 0);
|
|
15706
|
+
await write;
|
|
15707
|
+
sendJson(response, 200, await trustedNetworksPayload(), false);
|
|
15708
|
+
return;
|
|
15709
|
+
}
|
|
13931
15710
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
|
|
13932
15711
|
sendJson(response, 200, await diagnosticsPayload(), false);
|
|
13933
15712
|
return;
|
|
@@ -14055,6 +15834,63 @@ async function apply(ctx, config) {
|
|
|
14055
15834
|
sendJson(response, 200, remotePayload(), false);
|
|
14056
15835
|
return;
|
|
14057
15836
|
}
|
|
15837
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/install`) {
|
|
15838
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
15839
|
+
logger.info("caddy component install started");
|
|
15840
|
+
try {
|
|
15841
|
+
await remoteProviders.mutate(async () => caddyComponent.install());
|
|
15842
|
+
logger.info("caddy component install completed");
|
|
15843
|
+
} catch (error) {
|
|
15844
|
+
logger.error("caddy component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
|
|
15845
|
+
throw error;
|
|
15846
|
+
}
|
|
15847
|
+
sendJson(response, 200, remotePayload(), false);
|
|
15848
|
+
return;
|
|
15849
|
+
}
|
|
15850
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/settings`) {
|
|
15851
|
+
const body = await readJsonObject(request, 8192);
|
|
15852
|
+
if (body.connect !== void 0 && typeof body.connect !== "boolean") throw new HttpError(400, "bad_request");
|
|
15853
|
+
const configure = async () => {
|
|
15854
|
+
if (body.connect === true && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
|
|
15855
|
+
await caddyConfig.configure(body.settings, {
|
|
15856
|
+
secretId: body.secretId ?? "",
|
|
15857
|
+
secretKey: body.secretKey ?? ""
|
|
15858
|
+
});
|
|
15859
|
+
if (body.connect === true) {
|
|
15860
|
+
await originController.setEnabled(false);
|
|
15861
|
+
await originController.selectMode("managed");
|
|
15862
|
+
await originController.reconnect();
|
|
15863
|
+
} else if (originController.mode() === "managed" && originController.status().enabled) await originController.reconnect();
|
|
15864
|
+
};
|
|
15865
|
+
if (body.connect === true) await remoteProviders.mutateSelected("origin", configure);
|
|
15866
|
+
else await remoteProviders.mutate(configure);
|
|
15867
|
+
sendJson(response, 200, remotePayload(), false);
|
|
15868
|
+
return;
|
|
15869
|
+
}
|
|
15870
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/mode`) {
|
|
15871
|
+
const body = await readJsonObject(request, 4096);
|
|
15872
|
+
if (body.mode !== "external" && body.mode !== "managed") throw new HttpError(400, "bad_request");
|
|
15873
|
+
const mode = body.mode;
|
|
15874
|
+
if (mode === "managed" && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
|
|
15875
|
+
await remoteProviders.mutate(async () => {
|
|
15876
|
+
await originController.selectMode(mode);
|
|
15877
|
+
});
|
|
15878
|
+
sendJson(response, 200, remotePayload(), false);
|
|
15879
|
+
return;
|
|
15880
|
+
}
|
|
15881
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/purge`) {
|
|
15882
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
15883
|
+
await remoteProviders.mutate(async () => {
|
|
15884
|
+
if (originController.mode() === "managed") {
|
|
15885
|
+
await originController.setEnabled(false);
|
|
15886
|
+
await originController.selectMode("external");
|
|
15887
|
+
}
|
|
15888
|
+
await caddyComponent.purge();
|
|
15889
|
+
await caddyConfig.purge();
|
|
15890
|
+
});
|
|
15891
|
+
sendJson(response, 200, remotePayload(), false);
|
|
15892
|
+
return;
|
|
15893
|
+
}
|
|
14058
15894
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
|
|
14059
15895
|
const body = await readJsonObject(request, 8192);
|
|
14060
15896
|
await remoteProviders.mutate(async () => {
|
|
@@ -14113,7 +15949,8 @@ async function apply(ctx, config) {
|
|
|
14113
15949
|
const settings = frpConfig.settings();
|
|
14114
15950
|
if (settings === void 0) throw new HttpError(409, "frp_config_missing");
|
|
14115
15951
|
const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
|
|
14116
|
-
const
|
|
15952
|
+
const entry = resolveFrpEntryProbe(settings);
|
|
15953
|
+
const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
|
|
14117
15954
|
sendJson(response, 200, {
|
|
14118
15955
|
...remotePayload(),
|
|
14119
15956
|
frpSelfCheck: {
|
|
@@ -14281,6 +16118,10 @@ async function apply(ctx, config) {
|
|
|
14281
16118
|
};
|
|
14282
16119
|
await ctx.effect(async () => {
|
|
14283
16120
|
const unregister = ctx.webServer.register(adminRoute);
|
|
16121
|
+
const stopAdmin = () => {
|
|
16122
|
+
adminClosing = true;
|
|
16123
|
+
unregister();
|
|
16124
|
+
};
|
|
14284
16125
|
const disposeMobileCommand = ctx.commands.register({
|
|
14285
16126
|
name: "mobile",
|
|
14286
16127
|
description: "定制手机界面或添加电脑能力",
|
|
@@ -14337,7 +16178,8 @@ async function apply(ctx, config) {
|
|
|
14337
16178
|
} catch (error) {
|
|
14338
16179
|
try {
|
|
14339
16180
|
await settleCleanupSteps([
|
|
14340
|
-
|
|
16181
|
+
stopAdmin,
|
|
16182
|
+
() => trustedNetworksWrite,
|
|
14341
16183
|
disposeMobileCommand,
|
|
14342
16184
|
disposeTaskEvents,
|
|
14343
16185
|
async () => {
|
|
@@ -14355,7 +16197,8 @@ async function apply(ctx, config) {
|
|
|
14355
16197
|
}
|
|
14356
16198
|
return async () => {
|
|
14357
16199
|
await settleCleanupSteps([
|
|
14358
|
-
|
|
16200
|
+
stopAdmin,
|
|
16201
|
+
() => trustedNetworksWrite,
|
|
14359
16202
|
disposeMobileCommand,
|
|
14360
16203
|
disposeTaskEvents,
|
|
14361
16204
|
async () => {
|
|
@@ -14370,6 +16213,6 @@ async function apply(ctx, config) {
|
|
|
14370
16213
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
14371
16214
|
}
|
|
14372
16215
|
//#endregion
|
|
14373
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, 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 };
|
|
16216
|
+
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 };
|
|
14374
16217
|
|
|
14375
16218
|
//# sourceMappingURL=index.mjs.map
|