dsh-mobile 0.3.16 → 0.4.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 +36 -4
- package/README.en.md +82 -14
- package/README.md +84 -22
- package/SECURITY.md +2 -0
- package/docs/SELF_HOSTED_FRP.en.md +75 -0
- package/docs/SELF_HOSTED_FRP.md +3 -1
- package/lib/cli.js +99 -82
- package/lib/cli.js.map +1 -1
- package/lib/client.js +808 -103
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +89 -3
- package/lib/index.mjs +727 -131
- package/lib/index.mjs.map +1 -1
- package/package.json +18 -9
package/lib/index.mjs
CHANGED
|
@@ -142,28 +142,28 @@ var AccessController = class {
|
|
|
142
142
|
devices: Object.freeze([...devices])
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
emitSessionEnded(session) {
|
|
145
|
+
emitSessionEnded(session, reason) {
|
|
146
146
|
const authorization = Object.freeze({
|
|
147
147
|
sessionKey: session.key,
|
|
148
148
|
deviceId: session.deviceId,
|
|
149
149
|
expiresAt: session.expiresAt
|
|
150
150
|
});
|
|
151
|
-
for (const listener of this.sessionEndedListeners) listener(authorization);
|
|
151
|
+
for (const listener of this.sessionEndedListeners) listener(authorization, reason);
|
|
152
152
|
}
|
|
153
|
-
removeSession(key) {
|
|
153
|
+
removeSession(key, reason) {
|
|
154
154
|
const session = this.sessions.get(key);
|
|
155
155
|
if (session === void 0) return;
|
|
156
156
|
this.sessions.delete(key);
|
|
157
|
-
this.emitSessionEnded(session);
|
|
157
|
+
this.emitSessionEnded(session, reason);
|
|
158
158
|
}
|
|
159
159
|
pruneSessions(now) {
|
|
160
|
-
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key);
|
|
160
|
+
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key, "expired");
|
|
161
161
|
}
|
|
162
162
|
createSession(deviceId, now, deviceExpiresAt) {
|
|
163
163
|
this.pruneSessions(now);
|
|
164
164
|
if (this.sessions.size >= this.options.maxSessions) {
|
|
165
165
|
const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
|
|
166
|
-
if (oldest !== void 0) this.removeSession(oldest.key);
|
|
166
|
+
if (oldest !== void 0) this.removeSession(oldest.key, "evicted");
|
|
167
167
|
}
|
|
168
168
|
const sessionToken = opaqueToken();
|
|
169
169
|
const csrfToken = opaqueToken();
|
|
@@ -244,7 +244,9 @@ var AccessController = class {
|
|
|
244
244
|
const tokenDigest = digest(deviceToken);
|
|
245
245
|
const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
|
|
246
246
|
const device = this.devices[index];
|
|
247
|
-
if (device === void 0
|
|
247
|
+
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
248
|
+
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
249
|
+
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
248
250
|
const updated = Object.freeze({
|
|
249
251
|
...device,
|
|
250
252
|
lastSeenAt: now
|
|
@@ -256,6 +258,32 @@ var AccessController = class {
|
|
|
256
258
|
return this.createSession(device.id, now, device.expiresAt);
|
|
257
259
|
});
|
|
258
260
|
}
|
|
261
|
+
/** Validate a persistent device credential without consuming a Session slot. */
|
|
262
|
+
async probe(deviceToken) {
|
|
263
|
+
this.requireInitialized();
|
|
264
|
+
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
265
|
+
return this.exclusive(async () => {
|
|
266
|
+
const now = this.now();
|
|
267
|
+
const tokenDigest = digest(deviceToken);
|
|
268
|
+
const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
|
|
269
|
+
const device = this.devices[index];
|
|
270
|
+
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
271
|
+
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
272
|
+
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
273
|
+
const updated = Object.freeze({
|
|
274
|
+
...device,
|
|
275
|
+
lastSeenAt: now
|
|
276
|
+
});
|
|
277
|
+
const next = [...this.devices];
|
|
278
|
+
next[index] = updated;
|
|
279
|
+
await this.store.save(this.snapshot(next));
|
|
280
|
+
this.devices = next;
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
deviceId: device.id,
|
|
283
|
+
deviceExpiresAt: device.expiresAt
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
259
287
|
/** Resolve a short Session Cookie without revealing whether device or Session failed. */
|
|
260
288
|
authorizeSession(sessionToken) {
|
|
261
289
|
this.requireInitialized();
|
|
@@ -266,7 +294,7 @@ var AccessController = class {
|
|
|
266
294
|
const session = this.sessions.get(key);
|
|
267
295
|
const device = session === void 0 ? void 0 : this.devices.find((candidate) => candidate.id === session.deviceId);
|
|
268
296
|
if (session === void 0 || device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) {
|
|
269
|
-
if (session !== void 0) this.removeSession(session.key);
|
|
297
|
+
if (session !== void 0) this.removeSession(session.key, "expired");
|
|
270
298
|
throw new AccessError(401, "authentication_failed");
|
|
271
299
|
}
|
|
272
300
|
return Object.freeze({
|
|
@@ -282,7 +310,7 @@ var AccessController = class {
|
|
|
282
310
|
}
|
|
283
311
|
/** End one short Session and notify the gateway to abort its attached work. */
|
|
284
312
|
logout(authorization) {
|
|
285
|
-
this.removeSession(authorization.sessionKey);
|
|
313
|
+
this.removeSession(authorization.sessionKey, "logout");
|
|
286
314
|
}
|
|
287
315
|
/** Persist revocation, then end every Session owned by that device. */
|
|
288
316
|
async revokeDevice(deviceId) {
|
|
@@ -298,7 +326,7 @@ var AccessController = class {
|
|
|
298
326
|
});
|
|
299
327
|
await this.store.save(this.snapshot(next));
|
|
300
328
|
this.devices = next;
|
|
301
|
-
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key);
|
|
329
|
+
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
|
|
302
330
|
return true;
|
|
303
331
|
});
|
|
304
332
|
}
|
|
@@ -308,7 +336,7 @@ var AccessController = class {
|
|
|
308
336
|
await this.exclusive(async () => {
|
|
309
337
|
await this.store.save(this.snapshot([]));
|
|
310
338
|
this.devices = [];
|
|
311
|
-
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
339
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "revoked");
|
|
312
340
|
this.pairingWindow = void 0;
|
|
313
341
|
});
|
|
314
342
|
}
|
|
@@ -347,7 +375,7 @@ var AccessController = class {
|
|
|
347
375
|
async finishClose() {
|
|
348
376
|
await this.mutation;
|
|
349
377
|
this.pairingWindow = void 0;
|
|
350
|
-
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
378
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "expired");
|
|
351
379
|
this.sessionEndedListeners.clear();
|
|
352
380
|
this.initialized = false;
|
|
353
381
|
}
|
|
@@ -361,7 +389,7 @@ var AccessController = class {
|
|
|
361
389
|
};
|
|
362
390
|
//#endregion
|
|
363
391
|
//#region src/network.ts
|
|
364
|
-
function parseIpv4(address) {
|
|
392
|
+
function parseIpv4$1(address) {
|
|
365
393
|
const parts = address.split(".");
|
|
366
394
|
if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
367
395
|
let value = 0n;
|
|
@@ -375,7 +403,7 @@ function parseIpv4(address) {
|
|
|
375
403
|
}
|
|
376
404
|
function parseIpv6Part(part, address) {
|
|
377
405
|
if (part.includes(".")) {
|
|
378
|
-
const ipv4 = parseIpv4(part);
|
|
406
|
+
const ipv4 = parseIpv4$1(part);
|
|
379
407
|
return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
|
|
380
408
|
}
|
|
381
409
|
if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
@@ -405,12 +433,12 @@ function parseIp(address) {
|
|
|
405
433
|
const mapped = mappedIpv4(unwrapped);
|
|
406
434
|
if (mapped !== void 0) return {
|
|
407
435
|
bits: 32,
|
|
408
|
-
value: parseIpv4(mapped)
|
|
436
|
+
value: parseIpv4$1(mapped)
|
|
409
437
|
};
|
|
410
438
|
const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
|
|
411
439
|
if (version === 4) return {
|
|
412
440
|
bits: 32,
|
|
413
|
-
value: parseIpv4(unwrapped)
|
|
441
|
+
value: parseIpv4$1(unwrapped)
|
|
414
442
|
};
|
|
415
443
|
if (version === 6) return {
|
|
416
444
|
bits: 128,
|
|
@@ -1051,6 +1079,46 @@ var MobileAccessGatewayController = class {
|
|
|
1051
1079
|
}
|
|
1052
1080
|
};
|
|
1053
1081
|
//#endregion
|
|
1082
|
+
//#region src/local-admin-host.ts
|
|
1083
|
+
/** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
|
|
1084
|
+
function unwrapBrackets(hostname) {
|
|
1085
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1086
|
+
}
|
|
1087
|
+
function parseIpv4(hostname) {
|
|
1088
|
+
const parts = hostname.split(".");
|
|
1089
|
+
if (parts.length !== 4) return void 0;
|
|
1090
|
+
const octets = [];
|
|
1091
|
+
for (const part of parts) {
|
|
1092
|
+
if (!/^(?:0|[1-9]\d{0,2})$/u.test(part)) return void 0;
|
|
1093
|
+
const value = Number(part);
|
|
1094
|
+
if (value > 255) return void 0;
|
|
1095
|
+
octets.push(value);
|
|
1096
|
+
}
|
|
1097
|
+
return octets;
|
|
1098
|
+
}
|
|
1099
|
+
function isLoopbackIpv4(octets) {
|
|
1100
|
+
return octets[0] === 127;
|
|
1101
|
+
}
|
|
1102
|
+
function isPrivateIpv4(octets) {
|
|
1103
|
+
return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
|
|
1104
|
+
}
|
|
1105
|
+
function isLinkLocalIpv4(octets) {
|
|
1106
|
+
return octets[0] === 169 && octets[1] === 254;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Hostnames that may appear on the DSH desktop admin surface.
|
|
1110
|
+
* Loopback, RFC1918, and IPv4 link-local are accepted. Public IPs,
|
|
1111
|
+
* CGNAT, and arbitrary DNS names stay rejected so DNS rebinding
|
|
1112
|
+
* cannot reach `/api/mobile-access`.
|
|
1113
|
+
*/
|
|
1114
|
+
function isLocalAdminHostname(hostname) {
|
|
1115
|
+
const host = unwrapBrackets(hostname).trim().toLowerCase();
|
|
1116
|
+
if (host === "localhost" || host === "::1") return true;
|
|
1117
|
+
const octets = parseIpv4(host);
|
|
1118
|
+
if (octets === void 0) return false;
|
|
1119
|
+
return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1054
1122
|
//#region src/http-security.ts
|
|
1055
1123
|
const DEVICE_COOKIE = "dsh_ma_device";
|
|
1056
1124
|
const SESSION_COOKIE = "dsh_ma_session";
|
|
@@ -1094,26 +1162,43 @@ function parseRequestTarget(raw) {
|
|
|
1094
1162
|
search: parsed.search
|
|
1095
1163
|
});
|
|
1096
1164
|
}
|
|
1097
|
-
/**
|
|
1098
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* The frame sources a proxied GUI document may embed: its own routes, blob:
|
|
1167
|
+
* (the PDF viewer's object URL) and external http(s) pages (the sidebar
|
|
1168
|
+
* browser tab). The framed document runs in the tab's existing sandbox
|
|
1169
|
+
* (opaque origin, no same-origin privileges), so this does not hand the
|
|
1170
|
+
* embedded page anything it did not already have.
|
|
1171
|
+
*/
|
|
1172
|
+
const PROXIED_FRAME_SRC = "frame-src 'self' blob: https: http:";
|
|
1173
|
+
/**
|
|
1174
|
+
* Set the gateway browser protections and non-cacheability.
|
|
1175
|
+
* @param response - the response to decorate.
|
|
1176
|
+
* @param tls - whether the connection is TLS (adds HSTS).
|
|
1177
|
+
* @param framing - the framing policy; defaults to the strict gateway one, so
|
|
1178
|
+
* every existing call site keeps refusing to be framed. Only the two PROXIED
|
|
1179
|
+
* forwarders pass `'proxied'`.
|
|
1180
|
+
*/
|
|
1181
|
+
function setSecurityHeaders(response, tls, framing = "gateway") {
|
|
1182
|
+
const proxied = framing === "proxied";
|
|
1099
1183
|
response.setHeader("Cache-Control", "no-store");
|
|
1100
1184
|
response.setHeader("Content-Security-Policy", [
|
|
1101
1185
|
"default-src 'self'",
|
|
1102
1186
|
"base-uri 'none'",
|
|
1103
1187
|
"object-src 'none'",
|
|
1104
|
-
"frame-ancestors 'none'",
|
|
1188
|
+
proxied ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
|
|
1105
1189
|
"form-action 'self'",
|
|
1106
1190
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1107
1191
|
"style-src 'self' 'unsafe-inline'",
|
|
1108
1192
|
"img-src 'self' data: blob:",
|
|
1109
1193
|
"font-src 'self' data:",
|
|
1110
1194
|
"connect-src 'self'",
|
|
1111
|
-
"worker-src 'self' blob:"
|
|
1195
|
+
"worker-src 'self' blob:",
|
|
1196
|
+
...proxied ? [PROXIED_FRAME_SRC] : []
|
|
1112
1197
|
].join("; "));
|
|
1113
1198
|
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
1114
1199
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1115
1200
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
1116
|
-
response.setHeader("X-Frame-Options", "DENY");
|
|
1201
|
+
response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
|
|
1117
1202
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1118
1203
|
if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
|
|
1119
1204
|
}
|
|
@@ -1210,7 +1295,7 @@ function localAuthority(header) {
|
|
|
1210
1295
|
function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
1211
1296
|
if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
|
|
1212
1297
|
const host = localAuthority(request.headers.host);
|
|
1213
|
-
if (host === void 0 ||
|
|
1298
|
+
if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
|
|
1214
1299
|
const site = request.headers["sec-fetch-site"];
|
|
1215
1300
|
if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
|
|
1216
1301
|
const origin = request.headers.origin;
|
|
@@ -1221,7 +1306,8 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1221
1306
|
if (error instanceof HttpError) throw error;
|
|
1222
1307
|
throw new HttpError(403, "forbidden");
|
|
1223
1308
|
}
|
|
1224
|
-
if (requireBrowserOrigin &&
|
|
1309
|
+
if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
|
|
1310
|
+
if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
|
|
1225
1311
|
}
|
|
1226
1312
|
//#endregion
|
|
1227
1313
|
//#region src/version.ts
|
|
@@ -1613,6 +1699,12 @@ function combineSignalLifetime(first, second) {
|
|
|
1613
1699
|
cleanup
|
|
1614
1700
|
};
|
|
1615
1701
|
}
|
|
1702
|
+
/** Validate and normalize an action input through either supported schema form. */
|
|
1703
|
+
function parseActionInput(schema, input) {
|
|
1704
|
+
if (schema === void 0) return input;
|
|
1705
|
+
if (typeof schema === "function") return schema(input);
|
|
1706
|
+
return schema.parse(input) ?? input;
|
|
1707
|
+
}
|
|
1616
1708
|
/** Host registry and service consumed by both npm plugins and local extensions. */
|
|
1617
1709
|
var MobileAccessService = class extends Service {
|
|
1618
1710
|
registered = /* @__PURE__ */ new Map();
|
|
@@ -1744,9 +1836,9 @@ var MobileAccessService = class extends Service {
|
|
|
1744
1836
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
1745
1837
|
const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
|
|
1746
1838
|
if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
|
|
1747
|
-
let parsed
|
|
1839
|
+
let parsed;
|
|
1748
1840
|
try {
|
|
1749
|
-
parsed = action.input
|
|
1841
|
+
parsed = parseActionInput(action.input, input);
|
|
1750
1842
|
} catch {
|
|
1751
1843
|
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
1752
1844
|
}
|
|
@@ -2094,6 +2186,188 @@ function createMobileAccessService(ctx) {
|
|
|
2094
2186
|
return new MobileAccessService(ctx);
|
|
2095
2187
|
}
|
|
2096
2188
|
//#endregion
|
|
2189
|
+
//#region src/auth-pages.ts
|
|
2190
|
+
const COPY = {
|
|
2191
|
+
en: {
|
|
2192
|
+
lang: "en",
|
|
2193
|
+
pairTitle: "Pair DSH mobile access",
|
|
2194
|
+
pairHeading: "Pair this device",
|
|
2195
|
+
pairingCode: "Pairing code",
|
|
2196
|
+
deviceName: "Device name",
|
|
2197
|
+
pair: "Pair",
|
|
2198
|
+
pairing: "Pairing…",
|
|
2199
|
+
pairFailed: "Pairing failed",
|
|
2200
|
+
unavailable: "The computer is unavailable.",
|
|
2201
|
+
reconnectTitle: "Reconnect DSH mobile access",
|
|
2202
|
+
reconnectHeading: "Reconnect this device",
|
|
2203
|
+
restoring: "Restoring the secure Session…",
|
|
2204
|
+
noLongerPaired: "This device is no longer paired. Open pairing on the computer, then pair it again.",
|
|
2205
|
+
openPairing: "Open pairing"
|
|
2206
|
+
},
|
|
2207
|
+
zh: {
|
|
2208
|
+
lang: "zh-CN",
|
|
2209
|
+
pairTitle: "配对 DSH 移动访问",
|
|
2210
|
+
pairHeading: "配对此设备",
|
|
2211
|
+
pairingCode: "配对码",
|
|
2212
|
+
deviceName: "设备名称",
|
|
2213
|
+
pair: "配对",
|
|
2214
|
+
pairing: "正在配对…",
|
|
2215
|
+
pairFailed: "配对失败",
|
|
2216
|
+
unavailable: "电脑当前不可用。",
|
|
2217
|
+
reconnectTitle: "重新连接 DSH 移动访问",
|
|
2218
|
+
reconnectHeading: "重新连接此设备",
|
|
2219
|
+
restoring: "正在恢复安全会话…",
|
|
2220
|
+
noLongerPaired: "此设备已不在配对列表中。请在电脑端打开配对,然后重新配对。",
|
|
2221
|
+
openPairing: "打开配对页面"
|
|
2222
|
+
},
|
|
2223
|
+
it: {
|
|
2224
|
+
lang: "it-IT",
|
|
2225
|
+
pairTitle: "Abbina accesso mobile DSH",
|
|
2226
|
+
pairHeading: "Abbina questo dispositivo",
|
|
2227
|
+
pairingCode: "Codice di abbinamento",
|
|
2228
|
+
deviceName: "Nome dispositivo",
|
|
2229
|
+
pair: "Abbina",
|
|
2230
|
+
pairing: "Abbinamento…",
|
|
2231
|
+
pairFailed: "Abbinamento non riuscito",
|
|
2232
|
+
unavailable: "Il computer non è disponibile.",
|
|
2233
|
+
reconnectTitle: "Riconnetti accesso mobile DSH",
|
|
2234
|
+
reconnectHeading: "Riconnetti questo dispositivo",
|
|
2235
|
+
restoring: "Ripristino della sessione sicura…",
|
|
2236
|
+
noLongerPaired: "Questo dispositivo non è più abbinato. Apri l’abbinamento sul computer e ripeti la procedura.",
|
|
2237
|
+
openPairing: "Apri abbinamento"
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
/** Choose Chinese, Italian, or English from an HTTP Accept-Language header. */
|
|
2241
|
+
function resolveAuthPageLocale(header) {
|
|
2242
|
+
const values = header?.split(",").map((value, index) => {
|
|
2243
|
+
const [language, ...parameters] = value.split(";");
|
|
2244
|
+
const quality = parameters.find((parameter) => parameter.trim().toLowerCase().startsWith("q="));
|
|
2245
|
+
const parsedQuality = quality === void 0 ? 1 : Number(quality.trim().slice(2));
|
|
2246
|
+
return {
|
|
2247
|
+
language: language?.trim().toLowerCase() ?? "",
|
|
2248
|
+
quality: Number.isFinite(parsedQuality) ? Math.max(0, Math.min(1, parsedQuality)) : 0,
|
|
2249
|
+
index
|
|
2250
|
+
};
|
|
2251
|
+
}).filter((value) => value.quality > 0).sort((left, right) => right.quality - left.quality || left.index - right.index).map((value) => value.language) ?? [];
|
|
2252
|
+
for (const value of values) {
|
|
2253
|
+
if (value.startsWith("zh")) return "zh";
|
|
2254
|
+
if (value.startsWith("it")) return "it";
|
|
2255
|
+
if (value.startsWith("en")) return "en";
|
|
2256
|
+
}
|
|
2257
|
+
return "en";
|
|
2258
|
+
}
|
|
2259
|
+
/** Render the browser pairing form without reflecting request data into HTML. */
|
|
2260
|
+
function renderPairPage(locale) {
|
|
2261
|
+
const copy = COPY[locale];
|
|
2262
|
+
return `<!doctype html>
|
|
2263
|
+
<html lang="${copy.lang}">
|
|
2264
|
+
<meta charset="utf-8">
|
|
2265
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2266
|
+
<title>${copy.pairTitle}</title>
|
|
2267
|
+
<main>
|
|
2268
|
+
<h1>${copy.pairHeading}</h1>
|
|
2269
|
+
<form id="pair-form">
|
|
2270
|
+
<label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
2271
|
+
<label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
2272
|
+
<button type="submit">${copy.pair}</button>
|
|
2273
|
+
<output id="pair-status" aria-live="polite"></output>
|
|
2274
|
+
</form>
|
|
2275
|
+
</main>
|
|
2276
|
+
<script src="/mobile-access/pair.js" defer><\/script>
|
|
2277
|
+
</html>
|
|
2278
|
+
`;
|
|
2279
|
+
}
|
|
2280
|
+
/** Render the pairing form script with locale-owned status messages. */
|
|
2281
|
+
function renderPairScript(locale) {
|
|
2282
|
+
const copy = COPY[locale];
|
|
2283
|
+
return `(() => {
|
|
2284
|
+
const form = document.getElementById('pair-form')
|
|
2285
|
+
const token = document.getElementById('pair-token')
|
|
2286
|
+
const label = document.getElementById('device-label')
|
|
2287
|
+
const status = document.getElementById('pair-status')
|
|
2288
|
+
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
2289
|
+
const supplied = fragment.get('token')
|
|
2290
|
+
history.replaceState(null, '', location.pathname)
|
|
2291
|
+
if (supplied) token.value = supplied
|
|
2292
|
+
form.addEventListener('submit', async (event) => {
|
|
2293
|
+
event.preventDefault()
|
|
2294
|
+
status.value = ${JSON.stringify(copy.pairing)}
|
|
2295
|
+
try {
|
|
2296
|
+
const response = await fetch('/mobile-access/auth/pair', {
|
|
2297
|
+
method: 'POST',
|
|
2298
|
+
credentials: 'same-origin',
|
|
2299
|
+
headers: { 'content-type': 'application/json' },
|
|
2300
|
+
body: JSON.stringify({ token: token.value, label: label.value || undefined }),
|
|
2301
|
+
})
|
|
2302
|
+
if (!response.ok) {
|
|
2303
|
+
status.value = ${JSON.stringify(copy.pairFailed)}
|
|
2304
|
+
return
|
|
2305
|
+
}
|
|
2306
|
+
location.replace('/')
|
|
2307
|
+
} catch {
|
|
2308
|
+
status.value = ${JSON.stringify(copy.unavailable)}
|
|
2309
|
+
}
|
|
2310
|
+
})
|
|
2311
|
+
})()
|
|
2312
|
+
`;
|
|
2313
|
+
}
|
|
2314
|
+
/** Render the browser reauthentication page. */
|
|
2315
|
+
function renderLoginPage(locale) {
|
|
2316
|
+
const copy = COPY[locale];
|
|
2317
|
+
return `<!doctype html>
|
|
2318
|
+
<html lang="${copy.lang}">
|
|
2319
|
+
<meta charset="utf-8">
|
|
2320
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2321
|
+
<title>${copy.reconnectTitle}</title>
|
|
2322
|
+
<main>
|
|
2323
|
+
<h1>${copy.reconnectHeading}</h1>
|
|
2324
|
+
<p id="login-progress" aria-live="polite">${copy.restoring}</p>
|
|
2325
|
+
<section id="login-failed" hidden>
|
|
2326
|
+
<p>${copy.noLongerPaired}</p>
|
|
2327
|
+
<a href="/mobile-access/pair">${copy.openPairing}</a>
|
|
2328
|
+
</section>
|
|
2329
|
+
</main>
|
|
2330
|
+
<script src="/mobile-access/login.js" defer><\/script>
|
|
2331
|
+
</html>
|
|
2332
|
+
`;
|
|
2333
|
+
}
|
|
2334
|
+
/** Render the reauthentication script with locale-owned failure text. */
|
|
2335
|
+
function renderLoginScript(locale) {
|
|
2336
|
+
const copy = COPY[locale];
|
|
2337
|
+
return `(() => {
|
|
2338
|
+
const candidate = new URL(location.href).searchParams.get('return')
|
|
2339
|
+
let returnPath = '/'
|
|
2340
|
+
if (candidate && candidate.startsWith('/')) {
|
|
2341
|
+
try {
|
|
2342
|
+
const resolved = new URL(candidate, location.origin)
|
|
2343
|
+
const pathname = decodeURIComponent(resolved.pathname)
|
|
2344
|
+
if (resolved.origin === location.origin && pathname !== '/mobile-access'
|
|
2345
|
+
&& !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
|
|
2346
|
+
returnPath = resolved.pathname + resolved.search + resolved.hash
|
|
2347
|
+
}
|
|
2348
|
+
} catch {
|
|
2349
|
+
// Malformed untrusted return targets keep the safe root default.
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
fetch('/mobile-access/auth/renew', {
|
|
2353
|
+
method: 'POST',
|
|
2354
|
+
credentials: 'same-origin',
|
|
2355
|
+
headers: { 'content-type': 'application/json' },
|
|
2356
|
+
body: '{}',
|
|
2357
|
+
}).then((response) => {
|
|
2358
|
+
if (response.ok) {
|
|
2359
|
+
location.replace(returnPath)
|
|
2360
|
+
return
|
|
2361
|
+
}
|
|
2362
|
+
document.getElementById('login-progress').hidden = true
|
|
2363
|
+
document.getElementById('login-failed').hidden = false
|
|
2364
|
+
}).catch(() => {
|
|
2365
|
+
document.getElementById('login-progress').textContent = ${JSON.stringify(copy.unavailable)}
|
|
2366
|
+
})
|
|
2367
|
+
})()
|
|
2368
|
+
`;
|
|
2369
|
+
}
|
|
2370
|
+
//#endregion
|
|
2097
2371
|
//#region src/gateway.ts
|
|
2098
2372
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
2099
2373
|
const MAX_HEADER_BYTES = 16384;
|
|
@@ -2152,23 +2426,6 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2152
2426
|
})]);
|
|
2153
2427
|
const MOBILE_CSRF_FETCH_BOOTSTRAP = `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${CSRF_COOKIE}=`)};const token=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith(prefix))?.slice(prefix.length);if(token!==undefined)headers.set(${JSON.stringify(CSRF_HEADER)},token)}return nativeFetch(input,{...init,headers})};})();`;
|
|
2154
2428
|
const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true}})();`;
|
|
2155
|
-
const PAIR_PAGE = `<!doctype html>
|
|
2156
|
-
<html lang="en">
|
|
2157
|
-
<meta charset="utf-8">
|
|
2158
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2159
|
-
<title>Pair DSH mobile access</title>
|
|
2160
|
-
<main>
|
|
2161
|
-
<h1>Pair this device</h1>
|
|
2162
|
-
<form id="pair-form">
|
|
2163
|
-
<label>Pairing code <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
2164
|
-
<label>Device name <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
2165
|
-
<button type="submit">Pair</button>
|
|
2166
|
-
<output id="pair-status"></output>
|
|
2167
|
-
</form>
|
|
2168
|
-
</main>
|
|
2169
|
-
<script src="/mobile-access/pair.js" defer><\/script>
|
|
2170
|
-
</html>
|
|
2171
|
-
`;
|
|
2172
2429
|
const gzipBuffer = promisify(gzip);
|
|
2173
2430
|
function ensureMobileViewport(html) {
|
|
2174
2431
|
const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
|
|
@@ -2272,80 +2529,6 @@ function rewriteMobileIndexWithBatch(html) {
|
|
|
2272
2529
|
function rewriteMobileIndex(html) {
|
|
2273
2530
|
return rewriteMobileIndexWithBatch(html).html;
|
|
2274
2531
|
}
|
|
2275
|
-
const PAIR_SCRIPT = `(() => {
|
|
2276
|
-
const form = document.getElementById('pair-form')
|
|
2277
|
-
const token = document.getElementById('pair-token')
|
|
2278
|
-
const label = document.getElementById('device-label')
|
|
2279
|
-
const status = document.getElementById('pair-status')
|
|
2280
|
-
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
2281
|
-
const supplied = fragment.get('token')
|
|
2282
|
-
history.replaceState(null, '', location.pathname)
|
|
2283
|
-
if (supplied) token.value = supplied
|
|
2284
|
-
form.addEventListener('submit', async (event) => {
|
|
2285
|
-
event.preventDefault()
|
|
2286
|
-
status.value = 'Pairing…'
|
|
2287
|
-
const response = await fetch('/mobile-access/auth/pair', {
|
|
2288
|
-
method: 'POST',
|
|
2289
|
-
credentials: 'same-origin',
|
|
2290
|
-
headers: { 'content-type': 'application/json' },
|
|
2291
|
-
body: JSON.stringify({ token: token.value, label: label.value || undefined }),
|
|
2292
|
-
})
|
|
2293
|
-
if (!response.ok) {
|
|
2294
|
-
status.value = 'Pairing failed'
|
|
2295
|
-
return
|
|
2296
|
-
}
|
|
2297
|
-
location.replace('/')
|
|
2298
|
-
})
|
|
2299
|
-
})()
|
|
2300
|
-
`;
|
|
2301
|
-
const LOGIN_PAGE = `<!doctype html>
|
|
2302
|
-
<html lang="en">
|
|
2303
|
-
<meta charset="utf-8">
|
|
2304
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2305
|
-
<title>Reconnect DSH mobile access</title>
|
|
2306
|
-
<main>
|
|
2307
|
-
<h1>Reconnect this device</h1>
|
|
2308
|
-
<p id="login-progress">Restoring the secure Session…</p>
|
|
2309
|
-
<section id="login-failed" hidden>
|
|
2310
|
-
<p>This device is no longer paired. Open pairing on the computer, then pair it again.</p>
|
|
2311
|
-
<a href="/mobile-access/pair">Open pairing</a>
|
|
2312
|
-
</section>
|
|
2313
|
-
</main>
|
|
2314
|
-
<script src="/mobile-access/login.js" defer><\/script>
|
|
2315
|
-
</html>
|
|
2316
|
-
`;
|
|
2317
|
-
const LOGIN_SCRIPT = `(() => {
|
|
2318
|
-
const candidate = new URL(location.href).searchParams.get('return')
|
|
2319
|
-
let returnPath = '/'
|
|
2320
|
-
if (candidate && candidate.startsWith('/')) {
|
|
2321
|
-
try {
|
|
2322
|
-
const resolved = new URL(candidate, location.origin)
|
|
2323
|
-
const pathname = decodeURIComponent(resolved.pathname)
|
|
2324
|
-
if (resolved.origin === location.origin && pathname !== '/mobile-access'
|
|
2325
|
-
&& !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
|
|
2326
|
-
returnPath = resolved.pathname + resolved.search + resolved.hash
|
|
2327
|
-
}
|
|
2328
|
-
} catch {
|
|
2329
|
-
// Malformed untrusted return targets keep the safe root default.
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
fetch('/mobile-access/auth/renew', {
|
|
2333
|
-
method: 'POST',
|
|
2334
|
-
credentials: 'same-origin',
|
|
2335
|
-
headers: { 'content-type': 'application/json' },
|
|
2336
|
-
body: '{}',
|
|
2337
|
-
}).then((response) => {
|
|
2338
|
-
if (response.ok) {
|
|
2339
|
-
location.replace(returnPath)
|
|
2340
|
-
return
|
|
2341
|
-
}
|
|
2342
|
-
document.getElementById('login-progress').hidden = true
|
|
2343
|
-
document.getElementById('login-failed').hidden = false
|
|
2344
|
-
}).catch(() => {
|
|
2345
|
-
document.getElementById('login-progress').textContent = 'The computer is unavailable.'
|
|
2346
|
-
})
|
|
2347
|
-
})()
|
|
2348
|
-
`;
|
|
2349
2532
|
var ByteLimitTransform = class extends Transform {
|
|
2350
2533
|
maximum;
|
|
2351
2534
|
total = 0;
|
|
@@ -2772,6 +2955,9 @@ var MobileAccessGateway = class {
|
|
|
2772
2955
|
mobileBootBatches = /* @__PURE__ */ new Map();
|
|
2773
2956
|
extensionEventListeners = /* @__PURE__ */ new Set();
|
|
2774
2957
|
extensionEventRevision = 0;
|
|
2958
|
+
taskEventListeners = /* @__PURE__ */ new Set();
|
|
2959
|
+
deviceEventListeners = /* @__PURE__ */ new Map();
|
|
2960
|
+
pendingDeviceRevocations = /* @__PURE__ */ new Set();
|
|
2775
2961
|
extensionChangeTimer;
|
|
2776
2962
|
extensionChangeTask;
|
|
2777
2963
|
legacyCustomDigest = "";
|
|
@@ -2786,6 +2972,7 @@ var MobileAccessGateway = class {
|
|
|
2786
2972
|
removeSessionListener;
|
|
2787
2973
|
removeExtensionContentListener;
|
|
2788
2974
|
renewLimiter;
|
|
2975
|
+
probeLimiter;
|
|
2789
2976
|
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
|
|
2790
2977
|
this.config = config;
|
|
2791
2978
|
this.extensions = extensions;
|
|
@@ -2805,8 +2992,18 @@ var MobileAccessGateway = class {
|
|
|
2805
2992
|
maxRateLimitKeys: config.maxRateLimitKeys
|
|
2806
2993
|
});
|
|
2807
2994
|
this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
2808
|
-
this.
|
|
2809
|
-
|
|
2995
|
+
this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
2996
|
+
this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
|
|
2997
|
+
if (reason === "revoked") {
|
|
2998
|
+
if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
|
|
2999
|
+
this.pendingDeviceRevocations.add(authorization.deviceId);
|
|
3000
|
+
this.broadcastDeviceRevoked(authorization.deviceId);
|
|
3001
|
+
}
|
|
3002
|
+
setImmediate(() => {
|
|
3003
|
+
this.abortSessionResources(authorization.sessionKey);
|
|
3004
|
+
this.pendingDeviceRevocations.delete(authorization.deviceId);
|
|
3005
|
+
});
|
|
3006
|
+
} else this.abortSessionResources(authorization.sessionKey);
|
|
2810
3007
|
});
|
|
2811
3008
|
this.removeExtensionContentListener = this.extensions?.onContentChanged(() => {
|
|
2812
3009
|
this.broadcastExtensionChange();
|
|
@@ -3078,6 +3275,17 @@ var MobileAccessGateway = class {
|
|
|
3078
3275
|
sessionExpiresAt: result.sessionExpiresAt
|
|
3079
3276
|
}, this.tlsEnabled);
|
|
3080
3277
|
}
|
|
3278
|
+
async handleNativeProbe(request, response) {
|
|
3279
|
+
if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
3280
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
3281
|
+
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
3282
|
+
const result = await this.access.probe(body.deviceToken);
|
|
3283
|
+
sendJson(response, 200, {
|
|
3284
|
+
instanceId: this.config.instanceId,
|
|
3285
|
+
deviceId: result.deviceId,
|
|
3286
|
+
deviceExpiresAt: result.deviceExpiresAt
|
|
3287
|
+
}, this.tlsEnabled);
|
|
3288
|
+
}
|
|
3081
3289
|
async handleLogout(request, response) {
|
|
3082
3290
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
3083
3291
|
const authorization = this.authorize(request);
|
|
@@ -3140,8 +3348,11 @@ var MobileAccessGateway = class {
|
|
|
3140
3348
|
}
|
|
3141
3349
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
3142
3350
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
3351
|
+
const languageHeader = request.headers["accept-language"];
|
|
3352
|
+
const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
|
|
3143
3353
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3144
|
-
|
|
3354
|
+
response.setHeader("Vary", "Accept-Language");
|
|
3355
|
+
const body = target.decodedPathname.endsWith(".js") ? renderPairScript(locale) : renderPairPage(locale);
|
|
3145
3356
|
response.writeHead(200, {
|
|
3146
3357
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
3147
3358
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -3151,8 +3362,11 @@ var MobileAccessGateway = class {
|
|
|
3151
3362
|
}
|
|
3152
3363
|
if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
|
|
3153
3364
|
if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
|
|
3365
|
+
const languageHeader = request.headers["accept-language"];
|
|
3366
|
+
const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
|
|
3154
3367
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3155
|
-
|
|
3368
|
+
response.setHeader("Vary", "Accept-Language");
|
|
3369
|
+
const body = target.decodedPathname.endsWith(".js") ? renderLoginScript(locale) : renderLoginPage(locale);
|
|
3156
3370
|
response.writeHead(200, {
|
|
3157
3371
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
3158
3372
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -3176,6 +3390,10 @@ var MobileAccessGateway = class {
|
|
|
3176
3390
|
await this.handleNativeRenew(request, response);
|
|
3177
3391
|
return;
|
|
3178
3392
|
}
|
|
3393
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-probe`) {
|
|
3394
|
+
await this.handleNativeProbe(request, response);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3179
3397
|
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
|
|
3180
3398
|
await this.handleLogout(request, response);
|
|
3181
3399
|
return;
|
|
@@ -3612,7 +3830,7 @@ var MobileAccessGateway = class {
|
|
|
3612
3830
|
delete headers["content-length"];
|
|
3613
3831
|
delete headers["content-encoding"];
|
|
3614
3832
|
delete headers.etag;
|
|
3615
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
3833
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
3616
3834
|
response.writeHead(200, {
|
|
3617
3835
|
...headers,
|
|
3618
3836
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -3864,7 +4082,7 @@ var MobileAccessGateway = class {
|
|
|
3864
4082
|
}
|
|
3865
4083
|
bodyDone.catch(reject);
|
|
3866
4084
|
});
|
|
3867
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
4085
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
3868
4086
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
3869
4087
|
const cacheControl = revisionedStaticCacheControl(request$2);
|
|
3870
4088
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
@@ -3900,6 +4118,22 @@ var MobileAccessGateway = class {
|
|
|
3900
4118
|
this.extensionEventRevision += 1;
|
|
3901
4119
|
for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
|
|
3902
4120
|
}
|
|
4121
|
+
/** Fan a completed task to every phone holding this gateway's event stream. */
|
|
4122
|
+
broadcastTaskEvent(event) {
|
|
4123
|
+
if (this.closing) return;
|
|
4124
|
+
const payload = JSON.stringify({
|
|
4125
|
+
sessionId: String(event.sessionId),
|
|
4126
|
+
turn: Number(event.turn) || 0
|
|
4127
|
+
});
|
|
4128
|
+
for (const listener of this.taskEventListeners) listener(payload);
|
|
4129
|
+
}
|
|
4130
|
+
/** Notify only the device whose persistent credential was revoked by the Host. */
|
|
4131
|
+
broadcastDeviceRevoked(deviceId) {
|
|
4132
|
+
const listeners = this.deviceEventListeners.get(deviceId);
|
|
4133
|
+
if (listeners === void 0) return;
|
|
4134
|
+
const payload = JSON.stringify({ reason: "device_revoked" });
|
|
4135
|
+
for (const listener of [...listeners]) listener(payload);
|
|
4136
|
+
}
|
|
3903
4137
|
pollLegacyCustomChanges() {
|
|
3904
4138
|
if (this.extensionChangeTask !== void 0) return this.extensionChangeTask;
|
|
3905
4139
|
const digestFile = async (path, fallback) => {
|
|
@@ -3931,6 +4165,10 @@ var MobileAccessGateway = class {
|
|
|
3931
4165
|
closed = true;
|
|
3932
4166
|
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
3933
4167
|
this.extensionEventListeners.delete(send);
|
|
4168
|
+
this.taskEventListeners.delete(sendTask);
|
|
4169
|
+
const deviceListeners = this.deviceEventListeners.get(authorization.deviceId);
|
|
4170
|
+
deviceListeners?.delete(sendDevice);
|
|
4171
|
+
if (deviceListeners?.size === 0) this.deviceEventListeners.delete(authorization.deviceId);
|
|
3934
4172
|
request.removeListener("aborted", close);
|
|
3935
4173
|
response.removeListener("close", close);
|
|
3936
4174
|
operation.release();
|
|
@@ -3939,6 +4177,14 @@ var MobileAccessGateway = class {
|
|
|
3939
4177
|
if (closed || response.destroyed || response.writableEnded) return;
|
|
3940
4178
|
response.write(`id: ${String(revision)}\nevent: extensions-changed\ndata: {\"revision\":${String(revision)}}\n\n`);
|
|
3941
4179
|
};
|
|
4180
|
+
const sendTask = (payload) => {
|
|
4181
|
+
if (closed || response.destroyed || response.writableEnded) return;
|
|
4182
|
+
response.write(`event: task-notify\ndata: ${payload}\n\n`);
|
|
4183
|
+
};
|
|
4184
|
+
const sendDevice = (payload) => {
|
|
4185
|
+
if (closed || response.destroyed || response.writableEnded) return;
|
|
4186
|
+
response.write(`event: device-revoked\ndata: ${payload}\n\n`);
|
|
4187
|
+
};
|
|
3942
4188
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3943
4189
|
response.writeHead(200, {
|
|
3944
4190
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
@@ -3948,6 +4194,10 @@ var MobileAccessGateway = class {
|
|
|
3948
4194
|
});
|
|
3949
4195
|
response.write("retry: 2000\n: ready\n\n");
|
|
3950
4196
|
this.extensionEventListeners.add(send);
|
|
4197
|
+
this.taskEventListeners.add(sendTask);
|
|
4198
|
+
const deviceListeners = this.deviceEventListeners.get(authorization.deviceId) ?? /* @__PURE__ */ new Set();
|
|
4199
|
+
deviceListeners.add(sendDevice);
|
|
4200
|
+
this.deviceEventListeners.set(authorization.deviceId, deviceListeners);
|
|
3951
4201
|
heartbeat = setInterval(() => {
|
|
3952
4202
|
if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
|
|
3953
4203
|
}, EXTENSION_EVENT_HEARTBEAT_MS);
|
|
@@ -4218,6 +4468,7 @@ var MobileAccessGateway = class {
|
|
|
4218
4468
|
this.upstreamAuthRequest?.destroy();
|
|
4219
4469
|
this.upstreamAuthRequest = void 0;
|
|
4220
4470
|
this.removeSessionListener();
|
|
4471
|
+
this.pendingDeviceRevocations.clear();
|
|
4221
4472
|
const accessClose = this.access.close();
|
|
4222
4473
|
for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
|
|
4223
4474
|
for (const request of this.activeRequests.values()) request.abort();
|
|
@@ -5766,6 +6017,84 @@ var FrpController = class {
|
|
|
5766
6017
|
}
|
|
5767
6018
|
};
|
|
5768
6019
|
//#endregion
|
|
6020
|
+
//#region src/task-events.ts
|
|
6021
|
+
const TASK_EVENT_DEBOUNCE_MS = 1e3;
|
|
6022
|
+
function turnNumber(value) {
|
|
6023
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
6024
|
+
}
|
|
6025
|
+
function turnData(value) {
|
|
6026
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return {
|
|
6027
|
+
turn: 0,
|
|
6028
|
+
completed: false
|
|
6029
|
+
};
|
|
6030
|
+
const record = value;
|
|
6031
|
+
const reason = record.reason;
|
|
6032
|
+
const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
|
|
6033
|
+
return {
|
|
6034
|
+
turn: turnNumber(record.turn),
|
|
6035
|
+
completed
|
|
6036
|
+
};
|
|
6037
|
+
}
|
|
6038
|
+
/**
|
|
6039
|
+
* Subscribe to completed root turns. Subagent turns are skipped so one task
|
|
6040
|
+
* announces once, and rapid turn boundaries collapse into a single event.
|
|
6041
|
+
* Returns a disposer that also drops pending debounces.
|
|
6042
|
+
*/
|
|
6043
|
+
function watchTaskCompletions(ctx, options) {
|
|
6044
|
+
const debounceMs = options.debounceMs ?? 1e3;
|
|
6045
|
+
const pending = /* @__PURE__ */ new Map();
|
|
6046
|
+
const disposeListener = ctx.on("session/event", (session, event) => {
|
|
6047
|
+
if (event.type !== "turn/end") return;
|
|
6048
|
+
const { turn, completed } = turnData(event.data);
|
|
6049
|
+
if (!completed) return;
|
|
6050
|
+
if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
|
|
6051
|
+
const sessionId = String(session.id);
|
|
6052
|
+
options.log?.("task-completed-observed", {
|
|
6053
|
+
sessionId,
|
|
6054
|
+
turn
|
|
6055
|
+
});
|
|
6056
|
+
const previous = pending.get(sessionId);
|
|
6057
|
+
if (previous !== void 0) clearTimeout(previous);
|
|
6058
|
+
pending.set(sessionId, setTimeout(() => {
|
|
6059
|
+
pending.delete(sessionId);
|
|
6060
|
+
options.log?.("task-completed-announced", {
|
|
6061
|
+
sessionId,
|
|
6062
|
+
turn
|
|
6063
|
+
});
|
|
6064
|
+
options.onTaskCompleted(Object.freeze({
|
|
6065
|
+
sessionId,
|
|
6066
|
+
turn
|
|
6067
|
+
}));
|
|
6068
|
+
}, debounceMs));
|
|
6069
|
+
});
|
|
6070
|
+
return () => {
|
|
6071
|
+
disposeListener();
|
|
6072
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
6073
|
+
pending.clear();
|
|
6074
|
+
};
|
|
6075
|
+
}
|
|
6076
|
+
/** One subscription feeding every live gateway; gateways register on start. */
|
|
6077
|
+
var TaskEventHub = class {
|
|
6078
|
+
sinks = /* @__PURE__ */ new Set();
|
|
6079
|
+
/** Register a sink; returns its disposer. */
|
|
6080
|
+
add(sink) {
|
|
6081
|
+
this.sinks.add(sink);
|
|
6082
|
+
return () => {
|
|
6083
|
+
this.sinks.delete(sink);
|
|
6084
|
+
};
|
|
6085
|
+
}
|
|
6086
|
+
/** Fan out to a snapshot so a failing sink cannot break its siblings. */
|
|
6087
|
+
broadcast(event) {
|
|
6088
|
+
for (const sink of [...this.sinks]) try {
|
|
6089
|
+
sink.broadcastTaskEvent(event);
|
|
6090
|
+
} catch {}
|
|
6091
|
+
}
|
|
6092
|
+
/** Visible for tests. */
|
|
6093
|
+
get size() {
|
|
6094
|
+
return this.sinks.size;
|
|
6095
|
+
}
|
|
6096
|
+
};
|
|
6097
|
+
//#endregion
|
|
5769
6098
|
//#region src/diagnostics.ts
|
|
5770
6099
|
const REMOTE_ERROR_GUIDANCE = Object.freeze({
|
|
5771
6100
|
component_missing: "重新安装完整插件包。",
|
|
@@ -5921,7 +6250,8 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
5921
6250
|
const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
|
|
5922
6251
|
const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
|
|
5923
6252
|
checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
|
|
5924
|
-
if (snapshot.lan.
|
|
6253
|
+
if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
|
|
6254
|
+
else if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
|
|
5925
6255
|
else if (snapshot.lan.configuredInterface !== void 0) {
|
|
5926
6256
|
const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
|
|
5927
6257
|
checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
|
|
@@ -6017,11 +6347,11 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
6017
6347
|
2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
|
|
6018
6348
|
- 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
|
|
6019
6349
|
- extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
|
|
6020
|
-
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn)
|
|
6350
|
+
- 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) 的适配器;两种形式都会在动作执行前校验并规范化输入。
|
|
6021
6351
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
6022
6352
|
- mobile.css:手机端样式(可选)
|
|
6023
6353
|
- assets/:手机端静态资源(可选)
|
|
6024
|
-
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action
|
|
6354
|
+
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
|
|
6025
6355
|
- 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
|
|
6026
6356
|
|
|
6027
6357
|
安全约束:
|
|
@@ -7646,7 +7976,7 @@ var CpolarController = class {
|
|
|
7646
7976
|
const args = [
|
|
7647
7977
|
"http",
|
|
7648
7978
|
`-config=${resolve(this.options.configFile)}`,
|
|
7649
|
-
`-region=${this.options.region
|
|
7979
|
+
...this.options.region === void 0 ? [] : [`-region=${this.options.region}`],
|
|
7650
7980
|
"-inspect-addr=false",
|
|
7651
7981
|
"-redirect-https=true",
|
|
7652
7982
|
"-log=stdout",
|
|
@@ -8537,6 +8867,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
|
|
|
8537
8867
|
"wsl",
|
|
8538
8868
|
"zerotier"
|
|
8539
8869
|
];
|
|
8870
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
8871
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
8540
8872
|
function requiredString(value, name) {
|
|
8541
8873
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
8542
8874
|
return value;
|
|
@@ -8603,6 +8935,59 @@ function likelyVirtualInterface(name) {
|
|
|
8603
8935
|
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
8604
8936
|
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
8605
8937
|
}
|
|
8938
|
+
async function runRouteCommand(file, args) {
|
|
8939
|
+
return (await execFileText(file, [...args], {
|
|
8940
|
+
encoding: "utf8",
|
|
8941
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
8942
|
+
})).stdout;
|
|
8943
|
+
}
|
|
8944
|
+
function uniqueLines(output) {
|
|
8945
|
+
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
8946
|
+
}
|
|
8947
|
+
/** Return operating-system default-route interfaces in routing preference order. */
|
|
8948
|
+
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
8949
|
+
try {
|
|
8950
|
+
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
8951
|
+
"-NoProfile",
|
|
8952
|
+
"-NonInteractive",
|
|
8953
|
+
"-Command",
|
|
8954
|
+
[
|
|
8955
|
+
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
8956
|
+
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
8957
|
+
" $route = $_",
|
|
8958
|
+
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
8959
|
+
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
8960
|
+
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
8961
|
+
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
8962
|
+
" }",
|
|
8963
|
+
"}",
|
|
8964
|
+
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
8965
|
+
].join("; ")
|
|
8966
|
+
]));
|
|
8967
|
+
if (platform === "linux") {
|
|
8968
|
+
const routes = uniqueLines(await run("ip", [
|
|
8969
|
+
"-o",
|
|
8970
|
+
"-4",
|
|
8971
|
+
"route",
|
|
8972
|
+
"show",
|
|
8973
|
+
"default"
|
|
8974
|
+
])).map((line) => ({
|
|
8975
|
+
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
8976
|
+
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
8977
|
+
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
8978
|
+
return [...new Set(routes.map((route) => route.name))];
|
|
8979
|
+
}
|
|
8980
|
+
if (platform === "darwin") {
|
|
8981
|
+
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
8982
|
+
"-n",
|
|
8983
|
+
"get",
|
|
8984
|
+
"default"
|
|
8985
|
+
]))?.[1];
|
|
8986
|
+
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
8987
|
+
}
|
|
8988
|
+
} catch {}
|
|
8989
|
+
return [];
|
|
8990
|
+
}
|
|
8606
8991
|
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
8607
8992
|
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
8608
8993
|
const candidates = availableLanNetworks(table);
|
|
@@ -8653,6 +9038,57 @@ async function atomicWrite(file, contents) {
|
|
|
8653
9038
|
await rename(temporary, file);
|
|
8654
9039
|
await restrictPrivateFile(file);
|
|
8655
9040
|
}
|
|
9041
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
9042
|
+
async function ensureManagedCa(setup, legacy) {
|
|
9043
|
+
let certPem;
|
|
9044
|
+
let keyPem;
|
|
9045
|
+
try {
|
|
9046
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
9047
|
+
} catch (error) {
|
|
9048
|
+
if (error.code !== "ENOENT") throw error;
|
|
9049
|
+
let migrated = false;
|
|
9050
|
+
if (legacy !== void 0) try {
|
|
9051
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
9052
|
+
assertMatchingCa(certPem, keyPem);
|
|
9053
|
+
migrated = true;
|
|
9054
|
+
} catch (legacyError) {
|
|
9055
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
9056
|
+
}
|
|
9057
|
+
if (!migrated) {
|
|
9058
|
+
const now = /* @__PURE__ */ new Date();
|
|
9059
|
+
const notAfter = new Date(now);
|
|
9060
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
9061
|
+
const generated = await generate([{
|
|
9062
|
+
name: "commonName",
|
|
9063
|
+
value: "DeepSeek Harness Mobile CA"
|
|
9064
|
+
}], {
|
|
9065
|
+
keyType: "ec",
|
|
9066
|
+
curve: "P-256",
|
|
9067
|
+
algorithm: "sha256",
|
|
9068
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
9069
|
+
notAfterDate: notAfter,
|
|
9070
|
+
extensions: [{
|
|
9071
|
+
name: "basicConstraints",
|
|
9072
|
+
cA: true,
|
|
9073
|
+
critical: true
|
|
9074
|
+
}, {
|
|
9075
|
+
name: "keyUsage",
|
|
9076
|
+
digitalSignature: true,
|
|
9077
|
+
keyCertSign: true,
|
|
9078
|
+
cRLSign: true,
|
|
9079
|
+
critical: true
|
|
9080
|
+
}]
|
|
9081
|
+
});
|
|
9082
|
+
certPem = generated.cert;
|
|
9083
|
+
keyPem = generated.private;
|
|
9084
|
+
}
|
|
9085
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
9086
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
9087
|
+
}
|
|
9088
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
9089
|
+
await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
|
|
9090
|
+
return assertMatchingCa(certPem, keyPem);
|
|
9091
|
+
}
|
|
8656
9092
|
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
8657
9093
|
async function refreshManagedServerCertificate(setup, address) {
|
|
8658
9094
|
await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
|
|
@@ -8719,6 +9155,79 @@ async function materializeManagedSetup(setup, table) {
|
|
|
8719
9155
|
};
|
|
8720
9156
|
}
|
|
8721
9157
|
//#endregion
|
|
9158
|
+
//#region src/lan-setup.ts
|
|
9159
|
+
const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
|
|
9160
|
+
const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
|
|
9161
|
+
async function runElevatedPowerShell(script) {
|
|
9162
|
+
await execFileText("powershell.exe", [
|
|
9163
|
+
"-NoProfile",
|
|
9164
|
+
"-NonInteractive",
|
|
9165
|
+
"-Command",
|
|
9166
|
+
[
|
|
9167
|
+
"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
|
|
9168
|
+
` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
|
|
9169
|
+
"; exit $process.ExitCode"
|
|
9170
|
+
].join(" ")
|
|
9171
|
+
], { windowsHide: true });
|
|
9172
|
+
}
|
|
9173
|
+
/** Install the two Windows inbound rules used by the managed LAN listener. */
|
|
9174
|
+
async function configureWindowsFirewall(port) {
|
|
9175
|
+
if (process.platform !== "win32") return;
|
|
9176
|
+
await runElevatedPowerShell([
|
|
9177
|
+
"$ErrorActionPreference = 'Stop'",
|
|
9178
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
9179
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
9180
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
9181
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
9182
|
+
].join("; "));
|
|
9183
|
+
}
|
|
9184
|
+
/** Generate private TLS material and persist a managed setup selected by the user. */
|
|
9185
|
+
async function prepareManagedLanSetup(options) {
|
|
9186
|
+
if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
|
|
9187
|
+
if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
|
|
9188
|
+
if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
|
|
9189
|
+
const directory = dirname(options.setupFile);
|
|
9190
|
+
const tlsDirectory = join(directory, "tls");
|
|
9191
|
+
await mkdir(tlsDirectory, {
|
|
9192
|
+
recursive: true,
|
|
9193
|
+
mode: 448
|
|
9194
|
+
});
|
|
9195
|
+
const managedTls = {
|
|
9196
|
+
mode: "managed",
|
|
9197
|
+
caCertFile: join(tlsDirectory, "ca.pem"),
|
|
9198
|
+
caKeyFile: join(tlsDirectory, "ca-key.pem"),
|
|
9199
|
+
certFile: join(tlsDirectory, "server-cert.pem"),
|
|
9200
|
+
keyFile: join(tlsDirectory, "server-key.pem")
|
|
9201
|
+
};
|
|
9202
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
9203
|
+
certFile: join(tlsDirectory, "cert.pem"),
|
|
9204
|
+
keyFile: join(tlsDirectory, "key.pem")
|
|
9205
|
+
});
|
|
9206
|
+
const setup = {
|
|
9207
|
+
version: 2,
|
|
9208
|
+
networkInterface: options.network.name,
|
|
9209
|
+
listenPort: options.listenPort,
|
|
9210
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
9211
|
+
tls: managedTls
|
|
9212
|
+
};
|
|
9213
|
+
await refreshManagedServerCertificate(setup, options.network.address);
|
|
9214
|
+
const androidCertificate = join(tlsDirectory, "dsh-mobile-ca.cer");
|
|
9215
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
9216
|
+
await Promise.all([...Object.values(managedTls).filter((value) => value !== "managed").map((file) => restrictPrivateFile(file)), restrictPrivateFile(androidCertificate)]);
|
|
9217
|
+
if (options.configureFirewall) await configureWindowsFirewall(options.listenPort);
|
|
9218
|
+
await Promise.all([writeFile(options.setupFile, `${JSON.stringify({
|
|
9219
|
+
...setup,
|
|
9220
|
+
tls: Object.fromEntries(Object.entries(setup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
9221
|
+
}, null, 2)}\n`, { mode: 384 }), writeFile(options.controlFile, "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
9222
|
+
await Promise.all([restrictPrivateFile(options.setupFile), restrictPrivateFile(options.controlFile)]);
|
|
9223
|
+
return Object.freeze({
|
|
9224
|
+
setup: Object.freeze(setup),
|
|
9225
|
+
network: Object.freeze({ ...options.network }),
|
|
9226
|
+
origin: `https://${options.network.address}:${String(options.listenPort)}`,
|
|
9227
|
+
androidCertificate
|
|
9228
|
+
});
|
|
9229
|
+
}
|
|
9230
|
+
//#endregion
|
|
8722
9231
|
//#region src/plugin.ts
|
|
8723
9232
|
/** Stable Cordis plugin name. */
|
|
8724
9233
|
const name = "dsh-mobile";
|
|
@@ -8792,6 +9301,7 @@ function mapAdminError(error) {
|
|
|
8792
9301
|
if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
|
|
8793
9302
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
8794
9303
|
if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
|
|
9304
|
+
if (error instanceof Error && error.message.startsWith("lan_setup_")) return new HttpError(409, error.message);
|
|
8795
9305
|
return new HttpError(500, "internal_error");
|
|
8796
9306
|
}
|
|
8797
9307
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -8831,8 +9341,9 @@ async function loadSetup(config) {
|
|
|
8831
9341
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
8832
9342
|
} catch (error) {
|
|
8833
9343
|
if (error.code === "ENOENT") return {
|
|
8834
|
-
kind: "
|
|
8835
|
-
config
|
|
9344
|
+
kind: "unconfigured",
|
|
9345
|
+
config,
|
|
9346
|
+
setupFile: resolve(config.setupFile)
|
|
8836
9347
|
};
|
|
8837
9348
|
throw error;
|
|
8838
9349
|
}
|
|
@@ -8939,6 +9450,13 @@ async function apply(ctx, config) {
|
|
|
8939
9450
|
const logFile = await installMobileFileLogger(ctx, stateDirectory);
|
|
8940
9451
|
const logger = ctx.logger("dsh-mobile");
|
|
8941
9452
|
logger.info("logging initialized file=%s", logFile);
|
|
9453
|
+
const taskEventHub = new TaskEventHub();
|
|
9454
|
+
const disposeTaskEvents = watchTaskCompletions(ctx, {
|
|
9455
|
+
onTaskCompleted: (event) => taskEventHub.broadcast(event),
|
|
9456
|
+
log(event, fields) {
|
|
9457
|
+
logger.info("task event=%s fields=%o", event, fields);
|
|
9458
|
+
}
|
|
9459
|
+
});
|
|
8942
9460
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
8943
9461
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
8944
9462
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -8984,6 +9502,7 @@ async function apply(ctx, config) {
|
|
|
8984
9502
|
await webSocketPaths.load();
|
|
8985
9503
|
const blockedUpgradePaths = new BlockedUpgradePathLog();
|
|
8986
9504
|
let lanGateway;
|
|
9505
|
+
let preparedLanSetup;
|
|
8987
9506
|
const startGateway = async (candidateConfig) => {
|
|
8988
9507
|
const resolved = parseGatewayConfig({
|
|
8989
9508
|
...candidateConfig,
|
|
@@ -8992,12 +9511,15 @@ async function apply(ctx, config) {
|
|
|
8992
9511
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
8993
9512
|
await candidate.start();
|
|
8994
9513
|
lanGateway = candidate;
|
|
9514
|
+
const removeTaskSink = taskEventHub.add(candidate);
|
|
8995
9515
|
return { close: async () => {
|
|
8996
9516
|
if (lanGateway === candidate) lanGateway = void 0;
|
|
9517
|
+
removeTaskSink();
|
|
8997
9518
|
await candidate.close();
|
|
8998
9519
|
} };
|
|
8999
9520
|
};
|
|
9000
9521
|
const startRuntime = async () => {
|
|
9522
|
+
if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
9001
9523
|
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
9002
9524
|
const following = new FollowingMobileAccessRuntime(async () => {
|
|
9003
9525
|
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
@@ -9014,7 +9536,13 @@ async function apply(ctx, config) {
|
|
|
9014
9536
|
await following.initialize(2e3);
|
|
9015
9537
|
return following;
|
|
9016
9538
|
};
|
|
9017
|
-
const
|
|
9539
|
+
const lanControlFile = parseControlFile(config.controlFile);
|
|
9540
|
+
const lanControlStore = new JsonMobileAccessControlStore(lanControlFile, config.initiallyEnabled);
|
|
9541
|
+
if (loaded.kind === "unconfigured" && (await lanControlStore.load()).enabled) await lanControlStore.save({
|
|
9542
|
+
version: 1,
|
|
9543
|
+
enabled: false
|
|
9544
|
+
});
|
|
9545
|
+
const lanController = new MobileAccessGatewayController(lanControlStore, startRuntime);
|
|
9018
9546
|
const remoteDeviceFile = join(remoteDirectory, "devices.json");
|
|
9019
9547
|
const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
|
|
9020
9548
|
if (initialRemoteProvider === "cpolar") try {
|
|
@@ -9048,7 +9576,6 @@ async function apply(ctx, config) {
|
|
|
9048
9576
|
store: cpolarStore,
|
|
9049
9577
|
executable: cpolarComponent.executable,
|
|
9050
9578
|
configFile: cpolarComponent.configFile,
|
|
9051
|
-
region: "cn",
|
|
9052
9579
|
createGateway: createRemoteGateway
|
|
9053
9580
|
}),
|
|
9054
9581
|
frp: new FrpController({
|
|
@@ -9061,16 +9588,51 @@ async function apply(ctx, config) {
|
|
|
9061
9588
|
};
|
|
9062
9589
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
9063
9590
|
const remoteController = () => remoteProviders.controller();
|
|
9591
|
+
for (const provider of [
|
|
9592
|
+
"tailscale",
|
|
9593
|
+
"cpolar",
|
|
9594
|
+
"frp"
|
|
9595
|
+
]) {
|
|
9596
|
+
const controller = remoteControllers[provider];
|
|
9597
|
+
taskEventHub.add({ broadcastTaskEvent: (event) => {
|
|
9598
|
+
controller.gateway()?.broadcastTaskEvent(event);
|
|
9599
|
+
} });
|
|
9600
|
+
}
|
|
9064
9601
|
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
9065
9602
|
tailscale: remoteControllers.tailscale.status(),
|
|
9066
9603
|
cpolar: remoteControllers.cpolar.status(),
|
|
9067
9604
|
frp: remoteControllers.frp.status()
|
|
9068
9605
|
}, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
|
|
9069
9606
|
const lanPayload = () => ({
|
|
9607
|
+
...loaded.kind === "unconfigured" ? {
|
|
9608
|
+
configured: preparedLanSetup !== void 0,
|
|
9609
|
+
restartRequired: preparedLanSetup !== void 0
|
|
9610
|
+
} : {},
|
|
9070
9611
|
running: lanController.isRunning(),
|
|
9071
9612
|
origin: lanGateway?.address().origin,
|
|
9613
|
+
...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
|
|
9072
9614
|
...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
|
|
9073
9615
|
});
|
|
9616
|
+
const lanSetupPayload = async () => {
|
|
9617
|
+
if (loaded.kind !== "unconfigured" || preparedLanSetup !== void 0) return lanPayload();
|
|
9618
|
+
const networks = availableLanNetworks();
|
|
9619
|
+
const preferred = await preferredLanInterfaceNames();
|
|
9620
|
+
let recommendedAddress;
|
|
9621
|
+
try {
|
|
9622
|
+
recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
|
|
9623
|
+
} catch {}
|
|
9624
|
+
return {
|
|
9625
|
+
...lanPayload(),
|
|
9626
|
+
networks: networks.map((network) => ({
|
|
9627
|
+
name: network.name,
|
|
9628
|
+
address: network.address,
|
|
9629
|
+
cidr: network.cidr,
|
|
9630
|
+
recommended: network.address === recommendedAddress
|
|
9631
|
+
})),
|
|
9632
|
+
listenPort: 3443,
|
|
9633
|
+
windowsFirewall: process.platform === "win32"
|
|
9634
|
+
};
|
|
9635
|
+
};
|
|
9074
9636
|
const diagnosticsPayload = async () => {
|
|
9075
9637
|
let interfaceName;
|
|
9076
9638
|
let networkError;
|
|
@@ -9083,6 +9645,7 @@ async function apply(ctx, config) {
|
|
|
9083
9645
|
return collectConnectionDiagnostics({
|
|
9084
9646
|
dshVersion,
|
|
9085
9647
|
lan: {
|
|
9648
|
+
configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
|
|
9086
9649
|
running: lanController.isRunning(),
|
|
9087
9650
|
...lanGateway === void 0 ? {} : {
|
|
9088
9651
|
origin: lanGateway.address().origin,
|
|
@@ -9117,6 +9680,10 @@ async function apply(ctx, config) {
|
|
|
9117
9680
|
sendJson(response, 200, lanPayload(), false);
|
|
9118
9681
|
return;
|
|
9119
9682
|
}
|
|
9683
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
|
|
9684
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9685
|
+
return;
|
|
9686
|
+
}
|
|
9120
9687
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
|
|
9121
9688
|
sendJson(response, 200, await diagnosticsPayload(), false);
|
|
9122
9689
|
return;
|
|
@@ -9133,10 +9700,37 @@ async function apply(ctx, config) {
|
|
|
9133
9700
|
if (request.method === "POST" && lanControl) {
|
|
9134
9701
|
const body = await readJsonObject(request, 4096);
|
|
9135
9702
|
if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
|
|
9703
|
+
if (body.running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
9136
9704
|
await lanController.setRunning(body.running);
|
|
9137
9705
|
sendJson(response, 200, lanPayload(), false);
|
|
9138
9706
|
return;
|
|
9139
9707
|
}
|
|
9708
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
|
|
9709
|
+
const body = await readJsonObject(request, 4096);
|
|
9710
|
+
if (loaded.kind !== "unconfigured") throw new HttpError(409, "lan_setup_already_configured");
|
|
9711
|
+
if (preparedLanSetup !== void 0) {
|
|
9712
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9713
|
+
return;
|
|
9714
|
+
}
|
|
9715
|
+
if (body.confirm !== true || typeof body.address !== "string") throw new HttpError(400, "bad_request");
|
|
9716
|
+
const network = availableLanNetworks().find((candidate) => candidate.address === body.address);
|
|
9717
|
+
if (network === void 0) throw new HttpError(409, "lan_setup_network_unavailable");
|
|
9718
|
+
try {
|
|
9719
|
+
preparedLanSetup = await prepareManagedLanSetup({
|
|
9720
|
+
setupFile: loaded.setupFile,
|
|
9721
|
+
controlFile: lanControlFile,
|
|
9722
|
+
network,
|
|
9723
|
+
listenPort: 3443,
|
|
9724
|
+
dshPort: ctx.webServer.port,
|
|
9725
|
+
configureFirewall: body.configureFirewall !== false
|
|
9726
|
+
});
|
|
9727
|
+
} catch (error) {
|
|
9728
|
+
logger.error("managed LAN setup failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
|
|
9729
|
+
throw new HttpError(409, "lan_setup_failed");
|
|
9730
|
+
}
|
|
9731
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9732
|
+
return;
|
|
9733
|
+
}
|
|
9140
9734
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
|
|
9141
9735
|
sendJson(response, 200, remotePayload(), false);
|
|
9142
9736
|
return;
|
|
@@ -9403,6 +9997,7 @@ async function apply(ctx, config) {
|
|
|
9403
9997
|
await settleCleanupSteps([
|
|
9404
9998
|
unregister,
|
|
9405
9999
|
disposeMobileCommand,
|
|
10000
|
+
disposeTaskEvents,
|
|
9406
10001
|
async () => {
|
|
9407
10002
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9408
10003
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9420,6 +10015,7 @@ async function apply(ctx, config) {
|
|
|
9420
10015
|
await settleCleanupSteps([
|
|
9421
10016
|
unregister,
|
|
9422
10017
|
disposeMobileCommand,
|
|
10018
|
+
disposeTaskEvents,
|
|
9423
10019
|
async () => {
|
|
9424
10020
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9425
10021
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9432,6 +10028,6 @@ async function apply(ctx, config) {
|
|
|
9432
10028
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
9433
10029
|
}
|
|
9434
10030
|
//#endregion
|
|
9435
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, 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, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath };
|
|
10031
|
+
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, 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, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath, watchTaskCompletions };
|
|
9436
10032
|
|
|
9437
10033
|
//# sourceMappingURL=index.mjs.map
|