dsh-mobile 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTORS.md +10 -6
- package/README.en.md +22 -6
- package/README.md +22 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/cordis.patch.yml +6 -0
- package/docs/README.md +1 -0
- package/docs/SLOW_CONNECTIONS.en.md +77 -0
- package/docs/SLOW_CONNECTIONS.md +77 -0
- package/lib/client.js +129 -7
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +147 -122
- package/lib/index.mjs +529 -215
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js +134 -2
- package/lib/mobile-layout.js.map +1 -1
- package/node_modules/dsh-mobile-question-fixes/lib/client.js +78 -0
- package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +1 -0
- package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +14 -0
- package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +1 -0
- package/node_modules/dsh-mobile-question-fixes/package.json +25 -0
- package/package.json +19 -4
- package/packages/question-fixes/lib/client.js +78 -0
- package/packages/question-fixes/lib/client.js.map +1 -0
- package/packages/question-fixes/lib/index.mjs +14 -0
- package/packages/question-fixes/lib/index.mjs.map +1 -0
- package/packages/question-fixes/package.json +25 -0
package/lib/index.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { promisify } from "node:util";
|
|
|
16
16
|
import { createGzip, gzip } from "node:zlib";
|
|
17
17
|
import Bonjour from "bonjour-service";
|
|
18
18
|
import * as QRCode from "qrcode";
|
|
19
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
19
20
|
import { Logger, Service } from "@deepseek-ai/cordis";
|
|
20
21
|
import { connect as connect$1 } from "node:tls";
|
|
21
22
|
import { generate } from "selfsigned";
|
|
@@ -241,15 +242,10 @@ var AccessController = class {
|
|
|
241
242
|
/** Exchange a valid persistent device credential for a new short Session. */
|
|
242
243
|
async renew(deviceToken) {
|
|
243
244
|
this.requireInitialized();
|
|
244
|
-
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
245
245
|
return this.exclusive(async () => {
|
|
246
246
|
const now = this.now();
|
|
247
|
-
const
|
|
248
|
-
const index = this.devices.
|
|
249
|
-
const device = this.devices[index];
|
|
250
|
-
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
251
|
-
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
252
|
-
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
247
|
+
const device = this.requireDevice(deviceToken, now);
|
|
248
|
+
const index = this.devices.indexOf(device);
|
|
253
249
|
const updated = Object.freeze({
|
|
254
250
|
...device,
|
|
255
251
|
lastSeenAt: now
|
|
@@ -264,15 +260,10 @@ var AccessController = class {
|
|
|
264
260
|
/** Validate a persistent device credential without consuming a Session slot. */
|
|
265
261
|
async probe(deviceToken) {
|
|
266
262
|
this.requireInitialized();
|
|
267
|
-
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
268
263
|
return this.exclusive(async () => {
|
|
269
264
|
const now = this.now();
|
|
270
|
-
const
|
|
271
|
-
const index = this.devices.
|
|
272
|
-
const device = this.devices[index];
|
|
273
|
-
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
274
|
-
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
275
|
-
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
265
|
+
const device = this.requireDevice(deviceToken, now);
|
|
266
|
+
const index = this.devices.indexOf(device);
|
|
276
267
|
const updated = Object.freeze({
|
|
277
268
|
...device,
|
|
278
269
|
lastSeenAt: now
|
|
@@ -287,6 +278,24 @@ var AccessController = class {
|
|
|
287
278
|
});
|
|
288
279
|
});
|
|
289
280
|
}
|
|
281
|
+
/** Validate a device credential without writing state or opening a Session. */
|
|
282
|
+
authorizeDevice(deviceToken) {
|
|
283
|
+
this.requireInitialized();
|
|
284
|
+
const device = this.requireDevice(deviceToken, this.now());
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
deviceId: device.id,
|
|
287
|
+
deviceExpiresAt: device.expiresAt
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
requireDevice(deviceToken, now) {
|
|
291
|
+
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
292
|
+
const tokenDigest = digest(deviceToken);
|
|
293
|
+
const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
|
|
294
|
+
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
295
|
+
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
296
|
+
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
297
|
+
return device;
|
|
298
|
+
}
|
|
290
299
|
/** Resolve a short Session Cookie without revealing whether device or Session failed. */
|
|
291
300
|
authorizeSession(sessionToken) {
|
|
292
301
|
this.requireInitialized();
|
|
@@ -745,6 +754,112 @@ async function probeTcpReachable(host, port, timeoutMs = 1500) {
|
|
|
745
754
|
});
|
|
746
755
|
}
|
|
747
756
|
//#endregion
|
|
757
|
+
//#region src/websocket-paths.ts
|
|
758
|
+
/** Cap the admin-approved extra WebSocket upgrade paths (exact pathnames). */
|
|
759
|
+
const MAX_EXTRA_WEBSOCKET_PATHS = 16;
|
|
760
|
+
const MAX_WEBSOCKET_PATH_LENGTH = 256;
|
|
761
|
+
function fail(code) {
|
|
762
|
+
throw new Error(code);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Validate one exact pathname for proxying. Query strings are matched at
|
|
766
|
+
* upgrade time, so only the pathname is stored. Rejects anything the
|
|
767
|
+
* gateway cannot match exactly.
|
|
768
|
+
*/
|
|
769
|
+
function validateWebSocketPath(value) {
|
|
770
|
+
if (typeof value !== "string") fail("websocket_path_invalid");
|
|
771
|
+
const path = value;
|
|
772
|
+
if (path.length === 0 || path.length > 256) fail("websocket_path_invalid");
|
|
773
|
+
if (!path.startsWith("/")) fail("websocket_path_invalid");
|
|
774
|
+
if (/[\s\u0000-\u001f\u007f#?]/u.test(path)) fail("websocket_path_invalid");
|
|
775
|
+
if (path.includes("..")) fail("websocket_path_invalid");
|
|
776
|
+
return path;
|
|
777
|
+
}
|
|
778
|
+
/** Validate a whole replacement list all-or-nothing; duplicates collapse. */
|
|
779
|
+
function normalizeWebSocketPaths(value) {
|
|
780
|
+
if (!Array.isArray(value)) fail("websocket_paths_invalid");
|
|
781
|
+
if (value.length > 16) fail("websocket_paths_invalid");
|
|
782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
783
|
+
for (const entry of value) {
|
|
784
|
+
const path = validateWebSocketPath(entry);
|
|
785
|
+
seen.add(path);
|
|
786
|
+
}
|
|
787
|
+
return [...seen];
|
|
788
|
+
}
|
|
789
|
+
/** Cap remembered rejected upgrade paths offered for one-click approval. */
|
|
790
|
+
const MAX_BLOCKED_UPGRADE_PATHS = 32;
|
|
791
|
+
/**
|
|
792
|
+
* In-memory log of rejected third-party upgrade paths, shared by every
|
|
793
|
+
* gateway instance so the approval UI sees attempts on any listener.
|
|
794
|
+
* Bounded and newest-first; a restart clears it (attempts reappear on
|
|
795
|
+
* the next blocked handshake).
|
|
796
|
+
*/
|
|
797
|
+
var BlockedUpgradePathLog = class {
|
|
798
|
+
entries = /* @__PURE__ */ new Map();
|
|
799
|
+
record(pathname) {
|
|
800
|
+
const now = Date.now();
|
|
801
|
+
const stats = this.entries.get(pathname);
|
|
802
|
+
if (stats === void 0) {
|
|
803
|
+
if (this.entries.size >= 32) {
|
|
804
|
+
let oldest;
|
|
805
|
+
for (const [path, entry] of this.entries) if (oldest === void 0 || entry.lastSeen < (this.entries.get(oldest)?.lastSeen ?? 0)) oldest = path;
|
|
806
|
+
if (oldest !== void 0) this.entries.delete(oldest);
|
|
807
|
+
}
|
|
808
|
+
this.entries.set(pathname, {
|
|
809
|
+
attempts: 1,
|
|
810
|
+
firstSeen: now,
|
|
811
|
+
lastSeen: now
|
|
812
|
+
});
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
stats.attempts += 1;
|
|
816
|
+
stats.lastSeen = now;
|
|
817
|
+
}
|
|
818
|
+
report() {
|
|
819
|
+
return [...this.entries].map(([path, stats]) => ({
|
|
820
|
+
path,
|
|
821
|
+
...stats
|
|
822
|
+
})).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
/** File-backed store shared by every gateway instance (LAN and remote). */
|
|
826
|
+
var WebSocketPathStore = class {
|
|
827
|
+
file;
|
|
828
|
+
paths = /* @__PURE__ */ new Set();
|
|
829
|
+
loaded = false;
|
|
830
|
+
constructor(file) {
|
|
831
|
+
this.file = file;
|
|
832
|
+
}
|
|
833
|
+
/** Snapshot for the upgrade check. */
|
|
834
|
+
has(pathname) {
|
|
835
|
+
return this.paths.has(pathname);
|
|
836
|
+
}
|
|
837
|
+
list() {
|
|
838
|
+
return [...this.paths];
|
|
839
|
+
}
|
|
840
|
+
async load() {
|
|
841
|
+
if (!this.loaded) {
|
|
842
|
+
try {
|
|
843
|
+
const raw = JSON.parse(await readFile(this.file, "utf8"));
|
|
844
|
+
this.paths = new Set(normalizeWebSocketPaths(raw.paths ?? []));
|
|
845
|
+
} catch {
|
|
846
|
+
this.paths = /* @__PURE__ */ new Set();
|
|
847
|
+
}
|
|
848
|
+
this.loaded = true;
|
|
849
|
+
}
|
|
850
|
+
return this.list();
|
|
851
|
+
}
|
|
852
|
+
/** Replace the whole list after validating; persists atomically. */
|
|
853
|
+
async replace(paths) {
|
|
854
|
+
const next = normalizeWebSocketPaths([...paths]);
|
|
855
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
856
|
+
await writeFile(this.file, `${JSON.stringify({ paths: next })}\n`, "utf8");
|
|
857
|
+
this.paths = new Set(next);
|
|
858
|
+
this.loaded = true;
|
|
859
|
+
return this.list();
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
//#endregion
|
|
748
863
|
//#region src/config.ts
|
|
749
864
|
/** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
|
|
750
865
|
const Config = z.object({
|
|
@@ -762,6 +877,14 @@ const Config = z.object({
|
|
|
762
877
|
mobileLayoutFile: z.string().hidden(),
|
|
763
878
|
mobileCompatibilityFile: z.string().hidden(),
|
|
764
879
|
excludedClientModules: z.array(String).default([]),
|
|
880
|
+
websocketCompression: z.object({
|
|
881
|
+
paths: z.array(String).default([]),
|
|
882
|
+
maxMessageBytes: z.natural(),
|
|
883
|
+
maxQueuedBytes: z.natural(),
|
|
884
|
+
thresholdBytes: z.natural(),
|
|
885
|
+
concurrencyLimit: z.natural(),
|
|
886
|
+
level: z.natural().max(9)
|
|
887
|
+
}),
|
|
765
888
|
instanceId: z.string().hidden(),
|
|
766
889
|
pairingCaFile: z.string().hidden(),
|
|
767
890
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
@@ -790,6 +913,21 @@ function integer(value, name, fallback, minimum, maximum) {
|
|
|
790
913
|
if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`${name} must be an integer from ${String(minimum)} through ${String(maximum)}`);
|
|
791
914
|
return resolved;
|
|
792
915
|
}
|
|
916
|
+
function parseWebSocketCompression(raw) {
|
|
917
|
+
if (raw !== void 0 && (typeof raw !== "object" || raw === null || Array.isArray(raw))) throw new Error("websocketCompression must be an object");
|
|
918
|
+
const value = raw ?? {};
|
|
919
|
+
const maxMessageBytes = integer(value.maxMessageBytes, "websocketCompression.maxMessageBytes", 33554432, 1024, 268435456);
|
|
920
|
+
const maxQueuedBytes = integer(value.maxQueuedBytes, "websocketCompression.maxQueuedBytes", 67108864, 1024, 268435456);
|
|
921
|
+
if (maxQueuedBytes < maxMessageBytes + 14) throw new Error("websocketCompression.maxQueuedBytes must cover maxMessageBytes and the frame header");
|
|
922
|
+
return Object.freeze({
|
|
923
|
+
paths: Object.freeze(normalizeWebSocketPaths(value.paths ?? [])),
|
|
924
|
+
maxMessageBytes,
|
|
925
|
+
maxQueuedBytes,
|
|
926
|
+
thresholdBytes: integer(value.thresholdBytes, "websocketCompression.thresholdBytes", 1024, 0, maxMessageBytes),
|
|
927
|
+
concurrencyLimit: integer(value.concurrencyLimit, "websocketCompression.concurrencyLimit", 4, 1, 32),
|
|
928
|
+
level: integer(value.level, "websocketCompression.level", 3, 0, 9)
|
|
929
|
+
});
|
|
930
|
+
}
|
|
793
931
|
function stringArray(value, name) {
|
|
794
932
|
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw new Error(`${name} must be a non-empty string array`);
|
|
795
933
|
return value;
|
|
@@ -896,6 +1034,7 @@ function parseGatewayConfig(raw) {
|
|
|
896
1034
|
mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
|
|
897
1035
|
mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
|
|
898
1036
|
excludedClientModules: excludedClientModules(value.excludedClientModules),
|
|
1037
|
+
websocketCompression: parseWebSocketCompression(value.websocketCompression),
|
|
899
1038
|
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
900
1039
|
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
901
1040
|
})(),
|
|
@@ -1402,21 +1541,6 @@ async function readJsonObject(request, maximumBytes) {
|
|
|
1402
1541
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "bad_request");
|
|
1403
1542
|
return parsed;
|
|
1404
1543
|
}
|
|
1405
|
-
/** Strict cookie parser: malformed or duplicate names invalidate the whole header. */
|
|
1406
|
-
function parseCookies(header) {
|
|
1407
|
-
if (header === void 0) return /* @__PURE__ */ new Map();
|
|
1408
|
-
if (header.length > 8192) return void 0;
|
|
1409
|
-
const cookies = /* @__PURE__ */ new Map();
|
|
1410
|
-
for (const part of header.split(";")) {
|
|
1411
|
-
const equals = part.indexOf("=");
|
|
1412
|
-
if (equals <= 0) return void 0;
|
|
1413
|
-
const name = part.slice(0, equals).trim();
|
|
1414
|
-
const value = part.slice(equals + 1).trim();
|
|
1415
|
-
if (!/^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u.test(name) || !/^[\w\-.~+/=]*$/u.test(value) || cookies.has(name)) return;
|
|
1416
|
-
cookies.set(name, value);
|
|
1417
|
-
}
|
|
1418
|
-
return cookies;
|
|
1419
|
-
}
|
|
1420
1544
|
/** Serialize a host-only Cookie with no Domain attribute. */
|
|
1421
1545
|
function cookie(name, value, options) {
|
|
1422
1546
|
const parts = [
|
|
@@ -1516,6 +1640,280 @@ function ensureMobileCompatibility(html) {
|
|
|
1516
1640
|
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
1517
1641
|
}
|
|
1518
1642
|
//#endregion
|
|
1643
|
+
//#region src/browser-auth-cookies.ts
|
|
1644
|
+
const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
|
|
1645
|
+
const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
|
|
1646
|
+
/** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
|
|
1647
|
+
const HOST_DEVICE_COOKIE = "__Host-dsh_ma_device_id";
|
|
1648
|
+
const AUTH_COOKIE_NAMES = /* @__PURE__ */ new Set([
|
|
1649
|
+
SESSION_COOKIE,
|
|
1650
|
+
DEVICE_COOKIE,
|
|
1651
|
+
CSRF_COOKIE,
|
|
1652
|
+
HOST_SESSION_COOKIE,
|
|
1653
|
+
HOST_CSRF_COOKIE,
|
|
1654
|
+
HOST_DEVICE_COOKIE
|
|
1655
|
+
]);
|
|
1656
|
+
/** Read only gateway authentication cookies, retaining distinct values from different scopes. */
|
|
1657
|
+
function browserAuthCookies(header) {
|
|
1658
|
+
if (header === void 0) return /* @__PURE__ */ new Map();
|
|
1659
|
+
if (header.length > 8192) throw new HttpError(401, "authentication_failed");
|
|
1660
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
1661
|
+
for (const part of header.split(";")) {
|
|
1662
|
+
const equals = part.indexOf("=");
|
|
1663
|
+
if (equals <= 0) continue;
|
|
1664
|
+
const name = part.slice(0, equals).trim();
|
|
1665
|
+
if (!AUTH_COOKIE_NAMES.has(name)) continue;
|
|
1666
|
+
const values = cookies.get(name) ?? [];
|
|
1667
|
+
cookies.set(name, values);
|
|
1668
|
+
const value = part.slice(equals + 1).trim();
|
|
1669
|
+
if (!/^[\w\-.~+/=]{1,512}$/u.test(value) || values.includes(value)) continue;
|
|
1670
|
+
values.push(value);
|
|
1671
|
+
}
|
|
1672
|
+
return cookies;
|
|
1673
|
+
}
|
|
1674
|
+
/** Select exactly one authenticated value; never let cookie order choose between valid identities. */
|
|
1675
|
+
function authenticatedCookie(values, authenticate, accepts = () => true) {
|
|
1676
|
+
let selected;
|
|
1677
|
+
for (const value of values) {
|
|
1678
|
+
let result;
|
|
1679
|
+
try {
|
|
1680
|
+
result = authenticate(value);
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
if (error instanceof AccessError && error.status === 401) continue;
|
|
1683
|
+
throw error;
|
|
1684
|
+
}
|
|
1685
|
+
if (!accepts(result)) continue;
|
|
1686
|
+
if (selected !== void 0) throw new HttpError(401, "authentication_failed");
|
|
1687
|
+
selected = {
|
|
1688
|
+
value,
|
|
1689
|
+
result
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
if (selected === void 0) throw new HttpError(401, "authentication_failed");
|
|
1693
|
+
return selected;
|
|
1694
|
+
}
|
|
1695
|
+
//#endregion
|
|
1696
|
+
//#region src/websocket-compression.ts
|
|
1697
|
+
function payloadBytes(data) {
|
|
1698
|
+
if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
|
|
1699
|
+
return data.byteLength;
|
|
1700
|
+
}
|
|
1701
|
+
function forwardableClose(code) {
|
|
1702
|
+
return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999;
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Bridge an authorized HTTP upgrade, negotiating compression only with the client.
|
|
1706
|
+
* Errors after acceptance close both legs. Socket destruction by the gateway also
|
|
1707
|
+
* releases the ws wrappers, pending compression, and handshake/close timers.
|
|
1708
|
+
* @param request - validated browser upgrade request.
|
|
1709
|
+
* @param client - gateway-owned client socket, paused during authorization.
|
|
1710
|
+
* @param upstream - gateway-owned loopback socket, connecting or connected.
|
|
1711
|
+
* @param head - bytes the HTTP parser read after the client's upgrade headers.
|
|
1712
|
+
* @param target - validated upstream URL, including the original query.
|
|
1713
|
+
* @param upstreamCookie - gateway's signed upstream cookie; browser cookies are discarded.
|
|
1714
|
+
* @param options - resolved message, queue, and compression limits.
|
|
1715
|
+
* @param handshakeTimeoutMs - maximum handshake and close-handshake duration.
|
|
1716
|
+
* @returns fulfillment after both HTTP upgrades are accepted, or rejection before acceptance.
|
|
1717
|
+
*/
|
|
1718
|
+
function bridgeCompressedWebSocket(request, client, upstream, head, target, upstreamCookie, options, handshakeTimeoutMs) {
|
|
1719
|
+
return new Promise((resolve, reject) => {
|
|
1720
|
+
let incoming;
|
|
1721
|
+
let outgoing;
|
|
1722
|
+
let server;
|
|
1723
|
+
let accepted = false;
|
|
1724
|
+
let stopped = false;
|
|
1725
|
+
let closing = false;
|
|
1726
|
+
let closeTimer;
|
|
1727
|
+
const handshakeTimer = setTimeout(() => {
|
|
1728
|
+
stop(/* @__PURE__ */ new Error("WebSocket bridge handshake timed out"));
|
|
1729
|
+
}, handshakeTimeoutMs);
|
|
1730
|
+
handshakeTimer.unref();
|
|
1731
|
+
const stop = (error) => {
|
|
1732
|
+
if (stopped) return;
|
|
1733
|
+
stopped = true;
|
|
1734
|
+
clearTimeout(handshakeTimer);
|
|
1735
|
+
clearTimeout(closeTimer);
|
|
1736
|
+
if (!accepted) reject(error);
|
|
1737
|
+
incoming?.terminate();
|
|
1738
|
+
outgoing?.terminate();
|
|
1739
|
+
client.destroy();
|
|
1740
|
+
upstream.destroy();
|
|
1741
|
+
server?.close();
|
|
1742
|
+
};
|
|
1743
|
+
const finish = () => {
|
|
1744
|
+
if (incoming?.readyState === WebSocket.CLOSED && outgoing?.readyState === WebSocket.CLOSED) stop(/* @__PURE__ */ new Error("WebSocket bridge closed"));
|
|
1745
|
+
};
|
|
1746
|
+
const beginClose = (code, reason) => {
|
|
1747
|
+
if (!accepted) {
|
|
1748
|
+
stop(new Error(reason));
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
if (stopped || closing) return;
|
|
1752
|
+
closing = true;
|
|
1753
|
+
incoming?.resume();
|
|
1754
|
+
outgoing?.resume();
|
|
1755
|
+
for (const socket of [incoming, outgoing]) if (socket?.readyState === WebSocket.OPEN) socket.close(code, reason);
|
|
1756
|
+
closeTimer = setTimeout(() => {
|
|
1757
|
+
stop(/* @__PURE__ */ new Error("WebSocket bridge close timed out"));
|
|
1758
|
+
}, handshakeTimeoutMs);
|
|
1759
|
+
closeTimer.unref();
|
|
1760
|
+
};
|
|
1761
|
+
const socketError = (error) => {
|
|
1762
|
+
stop(error);
|
|
1763
|
+
};
|
|
1764
|
+
const socketClosed = () => {
|
|
1765
|
+
if (!accepted) stop(/* @__PURE__ */ new Error("WebSocket socket closed before acceptance"));
|
|
1766
|
+
};
|
|
1767
|
+
client.on("error", socketError);
|
|
1768
|
+
upstream.on("error", socketError);
|
|
1769
|
+
client.once("close", socketClosed);
|
|
1770
|
+
upstream.once("close", socketClosed);
|
|
1771
|
+
const wsError = (error) => {
|
|
1772
|
+
if (!accepted) {
|
|
1773
|
+
stop(error);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
beginClose(error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? 1009 : 1011, error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? "Message too large" : "WebSocket bridge failed");
|
|
1777
|
+
};
|
|
1778
|
+
const attachRelay = (source, destination) => {
|
|
1779
|
+
let queuedBytes = 0;
|
|
1780
|
+
const send = (bytes, operation) => {
|
|
1781
|
+
if (stopped || closing) return;
|
|
1782
|
+
if (destination.readyState !== WebSocket.OPEN) {
|
|
1783
|
+
beginClose(1011, "WebSocket peer unavailable");
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const queued = bytes + 14;
|
|
1787
|
+
if (queued > options.maxQueuedBytes - queuedBytes) {
|
|
1788
|
+
beginClose(1009, "WebSocket queue limit");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
queuedBytes += queued;
|
|
1792
|
+
source.pause();
|
|
1793
|
+
const complete = (error) => {
|
|
1794
|
+
queuedBytes -= queued;
|
|
1795
|
+
if (error != null) {
|
|
1796
|
+
wsError(error);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (!stopped && !closing && queuedBytes === 0 && source.readyState === WebSocket.OPEN) source.resume();
|
|
1800
|
+
};
|
|
1801
|
+
try {
|
|
1802
|
+
operation(complete);
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
complete(error instanceof Error ? error : /* @__PURE__ */ new Error("WebSocket forwarding failed"));
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
source.on("message", (data, binary) => {
|
|
1808
|
+
const bytes = payloadBytes(data);
|
|
1809
|
+
if (bytes > options.maxMessageBytes) {
|
|
1810
|
+
beginClose(1009, "Message too large");
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
send(bytes, (callback) => {
|
|
1814
|
+
destination.send(data, {
|
|
1815
|
+
binary,
|
|
1816
|
+
compress: destination === incoming
|
|
1817
|
+
}, callback);
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
source.on("ping", (data) => {
|
|
1821
|
+
send(data.byteLength, (callback) => {
|
|
1822
|
+
destination.ping(data, void 0, callback);
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
source.on("pong", (data) => {
|
|
1826
|
+
send(data.byteLength, (callback) => {
|
|
1827
|
+
destination.pong(data, void 0, callback);
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
source.on("close", (code, reason) => {
|
|
1831
|
+
if (stopped) return;
|
|
1832
|
+
if (code === 1006 || !forwardableClose(code) && code !== 1005) {
|
|
1833
|
+
stop(/* @__PURE__ */ new Error("WebSocket peer closed without a valid close frame"));
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
closing = true;
|
|
1837
|
+
destination.resume();
|
|
1838
|
+
if (destination.readyState === WebSocket.OPEN) {
|
|
1839
|
+
if (code === 1005) destination.close();
|
|
1840
|
+
else destination.close(code, reason);
|
|
1841
|
+
}
|
|
1842
|
+
closeTimer ??= setTimeout(() => {
|
|
1843
|
+
stop(/* @__PURE__ */ new Error("WebSocket bridge close timed out"));
|
|
1844
|
+
}, handshakeTimeoutMs);
|
|
1845
|
+
closeTimer.unref();
|
|
1846
|
+
finish();
|
|
1847
|
+
});
|
|
1848
|
+
};
|
|
1849
|
+
try {
|
|
1850
|
+
if (client.destroyed || upstream.destroyed) throw new Error("WebSocket socket is already closed");
|
|
1851
|
+
const protocolHeader = request.headers["sec-websocket-protocol"];
|
|
1852
|
+
const protocols = typeof protocolHeader === "string" ? protocolHeader.split(",").map((protocol) => protocol.trim()) : [];
|
|
1853
|
+
const url = new URL(target);
|
|
1854
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
1855
|
+
outgoing = new WebSocket(url, protocols, {
|
|
1856
|
+
createConnection: () => upstream,
|
|
1857
|
+
handshakeTimeout: handshakeTimeoutMs,
|
|
1858
|
+
perMessageDeflate: false,
|
|
1859
|
+
autoPong: false,
|
|
1860
|
+
followRedirects: false,
|
|
1861
|
+
maxPayload: options.maxMessageBytes,
|
|
1862
|
+
headers: {
|
|
1863
|
+
host: target.host,
|
|
1864
|
+
origin: target.origin,
|
|
1865
|
+
"sec-fetch-site": "same-origin",
|
|
1866
|
+
...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
outgoing.on("error", wsError);
|
|
1870
|
+
outgoing.once("close", () => {
|
|
1871
|
+
if (!accepted) stop(/* @__PURE__ */ new Error("Upstream WebSocket closed before acceptance"));
|
|
1872
|
+
else finish();
|
|
1873
|
+
});
|
|
1874
|
+
outgoing.once("open", () => {
|
|
1875
|
+
if (stopped || client.destroyed) {
|
|
1876
|
+
stop(/* @__PURE__ */ new Error("Client closed before WebSocket acceptance"));
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
upstream.setTimeout(0);
|
|
1880
|
+
server = new WebSocketServer({
|
|
1881
|
+
noServer: true,
|
|
1882
|
+
clientTracking: false,
|
|
1883
|
+
autoPong: false,
|
|
1884
|
+
maxPayload: options.maxMessageBytes,
|
|
1885
|
+
handleProtocols: () => outgoing?.protocol || false,
|
|
1886
|
+
perMessageDeflate: {
|
|
1887
|
+
clientNoContextTakeover: true,
|
|
1888
|
+
serverNoContextTakeover: true,
|
|
1889
|
+
threshold: options.thresholdBytes,
|
|
1890
|
+
concurrencyLimit: options.concurrencyLimit,
|
|
1891
|
+
zlibDeflateOptions: { level: options.level }
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
server.on("error", wsError);
|
|
1895
|
+
try {
|
|
1896
|
+
server.handleUpgrade(request, client, head, (socket) => {
|
|
1897
|
+
incoming = socket;
|
|
1898
|
+
incoming.on("error", wsError);
|
|
1899
|
+
incoming.once("close", finish);
|
|
1900
|
+
attachRelay(incoming, outgoing);
|
|
1901
|
+
attachRelay(outgoing, incoming);
|
|
1902
|
+
accepted = true;
|
|
1903
|
+
clearTimeout(handshakeTimer);
|
|
1904
|
+
incoming.resume();
|
|
1905
|
+
resolve();
|
|
1906
|
+
});
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
stop(error instanceof Error ? error : /* @__PURE__ */ new Error("Client WebSocket upgrade failed"));
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
stop(error instanceof Error ? error : /* @__PURE__ */ new Error("WebSocket bridge setup failed"));
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
//#endregion
|
|
1519
1917
|
//#region src/version.ts
|
|
1520
1918
|
const manifest = createRequire(import.meta.url)("../package.json");
|
|
1521
1919
|
/** Version of the installed DSH Mobile plugin package. */
|
|
@@ -2577,8 +2975,6 @@ function renderLoginScript(locale) {
|
|
|
2577
2975
|
//#region src/gateway.ts
|
|
2578
2976
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
2579
2977
|
const MAX_HEADER_BYTES = 16384;
|
|
2580
|
-
const MOBILE_HISTORY_PAGE_MESSAGES = 10;
|
|
2581
|
-
const SESSION_HISTORY_PATH = "/api/session.history";
|
|
2582
2978
|
const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
|
|
2583
2979
|
const DISCOVERY_PROTOCOL = 1;
|
|
2584
2980
|
const DISCOVERY_INTERVAL_MS = 3e3;
|
|
@@ -2664,7 +3060,9 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2664
3060
|
"@deepseek-ai/dsh-client-ui-theme"
|
|
2665
3061
|
])
|
|
2666
3062
|
})]);
|
|
2667
|
-
|
|
3063
|
+
function mobileCsrfFetchBootstrap(cookieName) {
|
|
3064
|
+
return `(()=>{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(`${cookieName}=`)};const values=[...new Set(document.cookie.split(';').map(value=>value.trim()).filter(value=>value.startsWith(prefix)).map(value=>value.slice(prefix.length)))];if(values.length===1)headers.set(${JSON.stringify(CSRF_HEADER)},values[0])}return nativeFetch(input,{...init,headers})};})();`;
|
|
3065
|
+
}
|
|
2668
3066
|
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},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
|
|
2669
3067
|
/** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
|
|
2670
3068
|
const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
|
|
@@ -2976,7 +3374,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
2976
3374
|
batches: plan.batches
|
|
2977
3375
|
})).digest("hex").slice(0, 16);
|
|
2978
3376
|
}
|
|
2979
|
-
const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${
|
|
3377
|
+
const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
2980
3378
|
const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
|
|
2981
3379
|
return Object.freeze({
|
|
2982
3380
|
html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
|
|
@@ -3177,7 +3575,7 @@ function isCompressibleContentType(value) {
|
|
|
3177
3575
|
}
|
|
3178
3576
|
function shouldCompressResponse(request, response) {
|
|
3179
3577
|
const pathname = request.url?.split("?", 1)[0] ?? "";
|
|
3180
|
-
return
|
|
3578
|
+
return request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
|
|
3181
3579
|
}
|
|
3182
3580
|
function revisionedStaticCacheControl(request, statusCode) {
|
|
3183
3581
|
if (request.method !== "GET" && request.method !== "HEAD") return void 0;
|
|
@@ -3194,39 +3592,12 @@ function revisionedStaticCacheControl(request, statusCode) {
|
|
|
3194
3592
|
if (!(target.pathname.startsWith("/plugins/") && hasRevision) && !(target.pathname.startsWith("/assets/") && (hasRevision || hashedAsset))) return void 0;
|
|
3195
3593
|
return "private, max-age=31536000, immutable";
|
|
3196
3594
|
}
|
|
3197
|
-
function isJsonRecord(value) {
|
|
3198
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3199
|
-
}
|
|
3200
|
-
function mobileHistoryRequestBody(request, body) {
|
|
3201
|
-
if (request.method !== "POST" || request.url?.split("?", 1)[0] !== SESSION_HISTORY_PATH) return body;
|
|
3202
|
-
let parsed;
|
|
3203
|
-
try {
|
|
3204
|
-
parsed = JSON.parse(body.toString("utf8"));
|
|
3205
|
-
} catch {
|
|
3206
|
-
return body;
|
|
3207
|
-
}
|
|
3208
|
-
if (!isJsonRecord(parsed) || parsed.method !== "session.history" || !isJsonRecord(parsed.payload)) return body;
|
|
3209
|
-
const requested = parsed.payload.maxMessages;
|
|
3210
|
-
if (typeof requested === "number" && Number.isInteger(requested) && requested > 0 && requested <= MOBILE_HISTORY_PAGE_MESSAGES) return body;
|
|
3211
|
-
return Buffer.from(JSON.stringify({
|
|
3212
|
-
...parsed,
|
|
3213
|
-
payload: {
|
|
3214
|
-
...parsed.payload,
|
|
3215
|
-
maxMessages: MOBILE_HISTORY_PAGE_MESSAGES
|
|
3216
|
-
}
|
|
3217
|
-
}));
|
|
3218
|
-
}
|
|
3219
3595
|
function addVaryAcceptEncoding(headers) {
|
|
3220
3596
|
const existing = headers.vary;
|
|
3221
3597
|
const values = (Array.isArray(existing) ? existing.map((value) => String(value)) : existing === void 0 ? [] : [String(existing)]).flatMap((value) => value.split(",").map((part) => part.trim()).filter(Boolean));
|
|
3222
3598
|
if (!values.some((value) => value.toLowerCase() === "accept-encoding")) values.push("Accept-Encoding");
|
|
3223
3599
|
headers.vary = values.join(", ");
|
|
3224
3600
|
}
|
|
3225
|
-
function requestCookies(request) {
|
|
3226
|
-
const cookies = parseCookies(request.headers.cookie);
|
|
3227
|
-
if (cookies === void 0) throw new HttpError(401, "authentication_failed");
|
|
3228
|
-
return cookies;
|
|
3229
|
-
}
|
|
3230
3601
|
function mapError(error) {
|
|
3231
3602
|
if (error instanceof HttpError) return error;
|
|
3232
3603
|
if (error instanceof AccessError) return new HttpError(error.status, error.code);
|
|
@@ -3755,9 +4126,9 @@ var MobileAccessGateway = class {
|
|
|
3755
4126
|
return this.policy;
|
|
3756
4127
|
}
|
|
3757
4128
|
authorize(request) {
|
|
3758
|
-
const
|
|
3759
|
-
|
|
3760
|
-
return this.access.authorizeSession(
|
|
4129
|
+
const cookies = browserAuthCookies(request.headers.cookie);
|
|
4130
|
+
const name = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_SESSION_COOKIE : SESSION_COOKIE;
|
|
4131
|
+
return authenticatedCookie(cookies.get(name) ?? [], (token) => this.access.authorizeSession(token)).result;
|
|
3761
4132
|
}
|
|
3762
4133
|
requireCsrf(request, authorization) {
|
|
3763
4134
|
const value = headerValue(request.headers, CSRF_HEADER);
|
|
@@ -3765,16 +4136,40 @@ var MobileAccessGateway = class {
|
|
|
3765
4136
|
}
|
|
3766
4137
|
setSessionCookies(response, result, now) {
|
|
3767
4138
|
const maxAge = (result.sessionExpiresAt - now) / 1e3;
|
|
3768
|
-
response.setHeader("Set-Cookie", [
|
|
3769
|
-
|
|
4139
|
+
response.setHeader("Set-Cookie", [
|
|
4140
|
+
cookie(SESSION_COOKIE, result.sessionToken, {
|
|
4141
|
+
tls: this.tlsEnabled,
|
|
4142
|
+
httpOnly: true,
|
|
4143
|
+
path: "/",
|
|
4144
|
+
maxAgeSeconds: maxAge
|
|
4145
|
+
}),
|
|
4146
|
+
cookie(CSRF_COOKIE, result.csrfToken, {
|
|
4147
|
+
tls: this.tlsEnabled,
|
|
4148
|
+
httpOnly: false,
|
|
4149
|
+
path: "/",
|
|
4150
|
+
maxAgeSeconds: maxAge
|
|
4151
|
+
}),
|
|
4152
|
+
...this.tlsEnabled ? [cookie(HOST_SESSION_COOKIE, result.sessionToken, {
|
|
4153
|
+
tls: true,
|
|
4154
|
+
httpOnly: true,
|
|
4155
|
+
path: "/",
|
|
4156
|
+
maxAgeSeconds: maxAge
|
|
4157
|
+
}), cookie(HOST_CSRF_COOKIE, result.csrfToken, {
|
|
4158
|
+
tls: true,
|
|
4159
|
+
httpOnly: false,
|
|
4160
|
+
path: "/",
|
|
4161
|
+
maxAgeSeconds: maxAge
|
|
4162
|
+
})] : []
|
|
4163
|
+
]);
|
|
4164
|
+
}
|
|
4165
|
+
setBrowserDeviceBinding(response, deviceId, deviceExpiresAt) {
|
|
4166
|
+
if (!this.tlsEnabled) return;
|
|
4167
|
+
const existing = response.getHeader("Set-Cookie");
|
|
4168
|
+
response.setHeader("Set-Cookie", [...existing, cookie(HOST_DEVICE_COOKIE, deviceId, {
|
|
4169
|
+
tls: true,
|
|
3770
4170
|
httpOnly: true,
|
|
3771
4171
|
path: "/",
|
|
3772
|
-
maxAgeSeconds:
|
|
3773
|
-
}), cookie(CSRF_COOKIE, result.csrfToken, {
|
|
3774
|
-
tls: this.tlsEnabled,
|
|
3775
|
-
httpOnly: false,
|
|
3776
|
-
path: "/",
|
|
3777
|
-
maxAgeSeconds: maxAge
|
|
4172
|
+
maxAgeSeconds: (deviceExpiresAt - Date.now()) / 1e3
|
|
3778
4173
|
})]);
|
|
3779
4174
|
}
|
|
3780
4175
|
async handlePair(request, response) {
|
|
@@ -3790,6 +4185,7 @@ var MobileAccessGateway = class {
|
|
|
3790
4185
|
path: "/mobile-access/auth/renew",
|
|
3791
4186
|
maxAgeSeconds: (result.deviceExpiresAt - now) / 1e3
|
|
3792
4187
|
})]);
|
|
4188
|
+
this.setBrowserDeviceBinding(response, result.deviceId, result.deviceExpiresAt);
|
|
3793
4189
|
sendJson(response, 201, {
|
|
3794
4190
|
paired: true,
|
|
3795
4191
|
deviceId: result.deviceId,
|
|
@@ -3800,21 +4196,13 @@ var MobileAccessGateway = class {
|
|
|
3800
4196
|
async handleRenew(request, response) {
|
|
3801
4197
|
if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
3802
4198
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
3803
|
-
const
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
} catch (error) {
|
|
3809
|
-
if (error instanceof AccessError && error.status === 401) response.setHeader("Set-Cookie", cookie(DEVICE_COOKIE, "", {
|
|
3810
|
-
tls: this.tlsEnabled,
|
|
3811
|
-
httpOnly: true,
|
|
3812
|
-
path: "/mobile-access/auth/renew",
|
|
3813
|
-
maxAgeSeconds: 0
|
|
3814
|
-
}));
|
|
3815
|
-
throw error;
|
|
3816
|
-
}
|
|
4199
|
+
const cookies = browserAuthCookies(request.headers.cookie);
|
|
4200
|
+
const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
|
|
4201
|
+
if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
|
|
4202
|
+
const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
|
|
4203
|
+
const result = await this.access.renew(device.value);
|
|
3817
4204
|
this.setSessionCookies(response, result, Date.now());
|
|
4205
|
+
this.setBrowserDeviceBinding(response, result.deviceId, device.result.deviceExpiresAt);
|
|
3818
4206
|
sendJson(response, 200, {
|
|
3819
4207
|
renewed: true,
|
|
3820
4208
|
deviceId: result.deviceId,
|
|
@@ -3865,17 +4253,31 @@ var MobileAccessGateway = class {
|
|
|
3865
4253
|
const authorization = this.authorize(request);
|
|
3866
4254
|
this.requireCsrf(request, authorization);
|
|
3867
4255
|
this.access.logout(authorization);
|
|
3868
|
-
response.setHeader("Set-Cookie", [
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
4256
|
+
response.setHeader("Set-Cookie", [
|
|
4257
|
+
cookie(SESSION_COOKIE, "", {
|
|
4258
|
+
tls: this.tlsEnabled,
|
|
4259
|
+
httpOnly: true,
|
|
4260
|
+
path: "/",
|
|
4261
|
+
maxAgeSeconds: 0
|
|
4262
|
+
}),
|
|
4263
|
+
cookie(CSRF_COOKIE, "", {
|
|
4264
|
+
tls: this.tlsEnabled,
|
|
4265
|
+
httpOnly: false,
|
|
4266
|
+
path: "/",
|
|
4267
|
+
maxAgeSeconds: 0
|
|
4268
|
+
}),
|
|
4269
|
+
...this.tlsEnabled ? [cookie(HOST_SESSION_COOKIE, "", {
|
|
4270
|
+
tls: true,
|
|
4271
|
+
httpOnly: true,
|
|
4272
|
+
path: "/",
|
|
4273
|
+
maxAgeSeconds: 0
|
|
4274
|
+
}), cookie(HOST_CSRF_COOKIE, "", {
|
|
4275
|
+
tls: true,
|
|
4276
|
+
httpOnly: false,
|
|
4277
|
+
path: "/",
|
|
4278
|
+
maxAgeSeconds: 0
|
|
4279
|
+
})] : []
|
|
4280
|
+
]);
|
|
3879
4281
|
sendJson(response, 200, { loggedOut: true }, this.tlsEnabled);
|
|
3880
4282
|
}
|
|
3881
4283
|
async handleExternalRequest(request, response) {
|
|
@@ -3994,7 +4396,14 @@ var MobileAccessGateway = class {
|
|
|
3994
4396
|
fallback: void 0
|
|
3995
4397
|
} : void 0 : void 0;
|
|
3996
4398
|
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");
|
|
3997
|
-
if (
|
|
4399
|
+
if (![
|
|
4400
|
+
"GET",
|
|
4401
|
+
"HEAD",
|
|
4402
|
+
"POST",
|
|
4403
|
+
"PUT",
|
|
4404
|
+
"PATCH",
|
|
4405
|
+
"DELETE"
|
|
4406
|
+
].includes(request.method ?? "") && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
|
|
3998
4407
|
let authorization;
|
|
3999
4408
|
try {
|
|
4000
4409
|
authorization = this.authorize(request);
|
|
@@ -4400,9 +4809,15 @@ var MobileAccessGateway = class {
|
|
|
4400
4809
|
try {
|
|
4401
4810
|
const html = Buffer.concat(chunks).toString("utf8");
|
|
4402
4811
|
const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
|
|
4403
|
-
const
|
|
4812
|
+
const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
|
|
4404
4813
|
...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
|
|
4405
4814
|
excludedClientModules: this.config.excludedClientModules
|
|
4815
|
+
};
|
|
4816
|
+
const cookies = browserAuthCookies(request$2.headers.cookie);
|
|
4817
|
+
const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
|
|
4818
|
+
const rewritten = rewriteMobileIndexWithBatch(html, {
|
|
4819
|
+
...options,
|
|
4820
|
+
csrfCookie
|
|
4406
4821
|
});
|
|
4407
4822
|
for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
|
|
4408
4823
|
body = Buffer.from(rewritten.html);
|
|
@@ -4771,11 +5186,9 @@ var MobileAccessGateway = class {
|
|
|
4771
5186
|
const operation = this.allocateRequest(authorization, response, holder);
|
|
4772
5187
|
let bodyDone;
|
|
4773
5188
|
try {
|
|
4774
|
-
const bufferedBody = request$3.method === "POST" && request$3.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$3, await readBoundedBody(request$3, this.config.maxBodyBytes)) : void 0;
|
|
4775
5189
|
const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
|
|
4776
5190
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
4777
5191
|
if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
|
|
4778
|
-
if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
|
|
4779
5192
|
const proxied = await new Promise((resolve, reject) => {
|
|
4780
5193
|
const upstreamRequest = request({
|
|
4781
5194
|
protocol: "http:",
|
|
@@ -4792,11 +5205,7 @@ var MobileAccessGateway = class {
|
|
|
4792
5205
|
});
|
|
4793
5206
|
upstreamRequest.once("response", resolve);
|
|
4794
5207
|
upstreamRequest.once("error", reject);
|
|
4795
|
-
|
|
4796
|
-
else {
|
|
4797
|
-
upstreamRequest.end(bufferedBody);
|
|
4798
|
-
bodyDone = Promise.resolve();
|
|
4799
|
-
}
|
|
5208
|
+
bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
4800
5209
|
bodyDone.catch(reject);
|
|
4801
5210
|
});
|
|
4802
5211
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
@@ -5019,10 +5428,13 @@ var MobileAccessGateway = class {
|
|
|
5019
5428
|
throw new HttpError(400, "bad_request");
|
|
5020
5429
|
}
|
|
5021
5430
|
if (decodedKey.length !== 16 || decodedKey.toString("base64") !== key) throw new HttpError(400, "bad_request");
|
|
5022
|
-
|
|
5431
|
+
let authorization = this.authorize(request);
|
|
5023
5432
|
if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
|
|
5024
5433
|
const upstreamCookie = await this.upstreamCookieHeader();
|
|
5025
5434
|
if (client.destroyed) return;
|
|
5435
|
+
authorization = this.authorize(request);
|
|
5436
|
+
if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
|
|
5437
|
+
const compressed = this.config.websocketCompression.paths.includes(target.decodedPathname);
|
|
5026
5438
|
const upstream = connect({
|
|
5027
5439
|
host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
5028
5440
|
port: Number(this.config.upstreamOrigin.port)
|
|
@@ -5045,20 +5457,28 @@ var MobileAccessGateway = class {
|
|
|
5045
5457
|
});
|
|
5046
5458
|
this.activeWebSockets.set(id, record);
|
|
5047
5459
|
const cleanup = () => {
|
|
5460
|
+
if (compressed && (!client.destroyed || !upstream.destroyed)) return;
|
|
5048
5461
|
const active = this.activeWebSockets.get(id);
|
|
5049
5462
|
if (active !== void 0) clearTimeout(active.timer);
|
|
5050
5463
|
this.activeWebSockets.delete(id);
|
|
5051
5464
|
};
|
|
5052
5465
|
client.once("close", () => {
|
|
5053
|
-
upstream.destroy();
|
|
5466
|
+
if (!compressed) upstream.destroy();
|
|
5054
5467
|
cleanup();
|
|
5055
5468
|
});
|
|
5056
5469
|
upstream.once("close", () => {
|
|
5057
|
-
client.destroy();
|
|
5470
|
+
if (!compressed) client.destroy();
|
|
5058
5471
|
cleanup();
|
|
5059
5472
|
});
|
|
5060
5473
|
upstream.setTimeout(this.config.upstreamTimeoutMs, closeBoth);
|
|
5061
5474
|
try {
|
|
5475
|
+
if (compressed) {
|
|
5476
|
+
const compressionTarget = new URL(this.config.upstreamOrigin.href);
|
|
5477
|
+
compressionTarget.pathname = target.pathname;
|
|
5478
|
+
compressionTarget.search = target.search;
|
|
5479
|
+
await bridgeCompressedWebSocket(request, client, upstream, head, compressionTarget, upstreamCookie, this.config.websocketCompression, this.config.upstreamTimeoutMs);
|
|
5480
|
+
return;
|
|
5481
|
+
}
|
|
5062
5482
|
await new Promise((resolve, reject) => {
|
|
5063
5483
|
const connected = () => {
|
|
5064
5484
|
upstream.off("error", failed);
|
|
@@ -5698,112 +6118,6 @@ var FrpComponentManager = class {
|
|
|
5698
6118
|
}
|
|
5699
6119
|
};
|
|
5700
6120
|
//#endregion
|
|
5701
|
-
//#region src/websocket-paths.ts
|
|
5702
|
-
/** Cap the admin-approved extra WebSocket upgrade paths (exact pathnames). */
|
|
5703
|
-
const MAX_EXTRA_WEBSOCKET_PATHS = 16;
|
|
5704
|
-
const MAX_WEBSOCKET_PATH_LENGTH = 256;
|
|
5705
|
-
function fail(code) {
|
|
5706
|
-
throw new Error(code);
|
|
5707
|
-
}
|
|
5708
|
-
/**
|
|
5709
|
-
* Validate one exact pathname for proxying. Query strings are matched at
|
|
5710
|
-
* upgrade time, so only the pathname is stored. Rejects anything the
|
|
5711
|
-
* gateway cannot match exactly.
|
|
5712
|
-
*/
|
|
5713
|
-
function validateWebSocketPath(value) {
|
|
5714
|
-
if (typeof value !== "string") fail("websocket_path_invalid");
|
|
5715
|
-
const path = value;
|
|
5716
|
-
if (path.length === 0 || path.length > 256) fail("websocket_path_invalid");
|
|
5717
|
-
if (!path.startsWith("/")) fail("websocket_path_invalid");
|
|
5718
|
-
if (/[\s\u0000-\u001f\u007f#?]/u.test(path)) fail("websocket_path_invalid");
|
|
5719
|
-
if (path.includes("..")) fail("websocket_path_invalid");
|
|
5720
|
-
return path;
|
|
5721
|
-
}
|
|
5722
|
-
/** Validate a whole replacement list all-or-nothing; duplicates collapse. */
|
|
5723
|
-
function normalizeWebSocketPaths(value) {
|
|
5724
|
-
if (!Array.isArray(value)) fail("websocket_paths_invalid");
|
|
5725
|
-
if (value.length > 16) fail("websocket_paths_invalid");
|
|
5726
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5727
|
-
for (const entry of value) {
|
|
5728
|
-
const path = validateWebSocketPath(entry);
|
|
5729
|
-
seen.add(path);
|
|
5730
|
-
}
|
|
5731
|
-
return [...seen];
|
|
5732
|
-
}
|
|
5733
|
-
/** Cap remembered rejected upgrade paths offered for one-click approval. */
|
|
5734
|
-
const MAX_BLOCKED_UPGRADE_PATHS = 32;
|
|
5735
|
-
/**
|
|
5736
|
-
* In-memory log of rejected third-party upgrade paths, shared by every
|
|
5737
|
-
* gateway instance so the approval UI sees attempts on any listener.
|
|
5738
|
-
* Bounded and newest-first; a restart clears it (attempts reappear on
|
|
5739
|
-
* the next blocked handshake).
|
|
5740
|
-
*/
|
|
5741
|
-
var BlockedUpgradePathLog = class {
|
|
5742
|
-
entries = /* @__PURE__ */ new Map();
|
|
5743
|
-
record(pathname) {
|
|
5744
|
-
const now = Date.now();
|
|
5745
|
-
const stats = this.entries.get(pathname);
|
|
5746
|
-
if (stats === void 0) {
|
|
5747
|
-
if (this.entries.size >= 32) {
|
|
5748
|
-
let oldest;
|
|
5749
|
-
for (const [path, entry] of this.entries) if (oldest === void 0 || entry.lastSeen < (this.entries.get(oldest)?.lastSeen ?? 0)) oldest = path;
|
|
5750
|
-
if (oldest !== void 0) this.entries.delete(oldest);
|
|
5751
|
-
}
|
|
5752
|
-
this.entries.set(pathname, {
|
|
5753
|
-
attempts: 1,
|
|
5754
|
-
firstSeen: now,
|
|
5755
|
-
lastSeen: now
|
|
5756
|
-
});
|
|
5757
|
-
return;
|
|
5758
|
-
}
|
|
5759
|
-
stats.attempts += 1;
|
|
5760
|
-
stats.lastSeen = now;
|
|
5761
|
-
}
|
|
5762
|
-
report() {
|
|
5763
|
-
return [...this.entries].map(([path, stats]) => ({
|
|
5764
|
-
path,
|
|
5765
|
-
...stats
|
|
5766
|
-
})).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
5767
|
-
}
|
|
5768
|
-
};
|
|
5769
|
-
/** File-backed store shared by every gateway instance (LAN and remote). */
|
|
5770
|
-
var WebSocketPathStore = class {
|
|
5771
|
-
file;
|
|
5772
|
-
paths = /* @__PURE__ */ new Set();
|
|
5773
|
-
loaded = false;
|
|
5774
|
-
constructor(file) {
|
|
5775
|
-
this.file = file;
|
|
5776
|
-
}
|
|
5777
|
-
/** Snapshot for the upgrade check. */
|
|
5778
|
-
has(pathname) {
|
|
5779
|
-
return this.paths.has(pathname);
|
|
5780
|
-
}
|
|
5781
|
-
list() {
|
|
5782
|
-
return [...this.paths];
|
|
5783
|
-
}
|
|
5784
|
-
async load() {
|
|
5785
|
-
if (!this.loaded) {
|
|
5786
|
-
try {
|
|
5787
|
-
const raw = JSON.parse(await readFile(this.file, "utf8"));
|
|
5788
|
-
this.paths = new Set(normalizeWebSocketPaths(raw.paths ?? []));
|
|
5789
|
-
} catch {
|
|
5790
|
-
this.paths = /* @__PURE__ */ new Set();
|
|
5791
|
-
}
|
|
5792
|
-
this.loaded = true;
|
|
5793
|
-
}
|
|
5794
|
-
return this.list();
|
|
5795
|
-
}
|
|
5796
|
-
/** Replace the whole list after validating; persists atomically. */
|
|
5797
|
-
async replace(paths) {
|
|
5798
|
-
const next = normalizeWebSocketPaths([...paths]);
|
|
5799
|
-
await mkdir(dirname(this.file), { recursive: true });
|
|
5800
|
-
await writeFile(this.file, `${JSON.stringify({ paths: next })}\n`, "utf8");
|
|
5801
|
-
this.paths = new Set(next);
|
|
5802
|
-
this.loaded = true;
|
|
5803
|
-
return this.list();
|
|
5804
|
-
}
|
|
5805
|
-
};
|
|
5806
|
-
//#endregion
|
|
5807
6121
|
//#region src/frp-template.ts
|
|
5808
6122
|
/** Loopback-only HTTP vhost port used between Caddy and frps. */
|
|
5809
6123
|
const FRP_VHOST_HTTP_PORT = 7080;
|