dsh-mobile 0.1.0-alpha.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/LICENSE +201 -0
- package/README.md +207 -0
- package/README.zh.md +213 -0
- package/SECURITY.md +29 -0
- package/assets/brand/app-icon-master.png +0 -0
- package/assets/brand/repository-hero.png +0 -0
- package/cordis.patch.yml +18 -0
- package/lib/cli.js +202 -0
- package/lib/client.js +212 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +409 -0
- package/lib/index.mjs +2118 -0
- package/package.json +117 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,2118 @@
|
|
|
1
|
+
import { X509Certificate, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { connect, isIP } from "node:net";
|
|
5
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createServer, request } from "node:http";
|
|
7
|
+
import { createServer as createServer$1 } from "node:https";
|
|
8
|
+
import { Transform } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
//#region src/access.ts
|
|
11
|
+
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
12
|
+
var AccessError = class extends Error {
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
constructor(status, code) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = "AccessError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** Fixed-window limiter whose attacker-controlled key table is itself bounded. */
|
|
23
|
+
var BoundedRateLimiter = class {
|
|
24
|
+
limit;
|
|
25
|
+
windowMs;
|
|
26
|
+
maximumKeys;
|
|
27
|
+
buckets = /* @__PURE__ */ new Map();
|
|
28
|
+
constructor(limit, windowMs, maximumKeys) {
|
|
29
|
+
this.limit = limit;
|
|
30
|
+
this.windowMs = windowMs;
|
|
31
|
+
this.maximumKeys = maximumKeys;
|
|
32
|
+
}
|
|
33
|
+
/** Consume one attempt; unknown keys fail closed when the bounded table is full. */
|
|
34
|
+
take(key, now) {
|
|
35
|
+
for (const [candidate, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(candidate);
|
|
36
|
+
const current = this.buckets.get(key);
|
|
37
|
+
if (current === void 0) {
|
|
38
|
+
if (this.buckets.size >= this.maximumKeys) return false;
|
|
39
|
+
this.buckets.set(key, {
|
|
40
|
+
count: 1,
|
|
41
|
+
resetAt: now + this.windowMs
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (current.count >= this.limit) return false;
|
|
46
|
+
current.count += 1;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/** Current table size, exposed for bounded-state assertions. */
|
|
50
|
+
get size() {
|
|
51
|
+
return this.buckets.size;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
function opaqueToken() {
|
|
55
|
+
return randomBytes(32).toString("base64url");
|
|
56
|
+
}
|
|
57
|
+
function digest(value) {
|
|
58
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
59
|
+
}
|
|
60
|
+
function digestHex(value) {
|
|
61
|
+
return digest(value).toString("hex");
|
|
62
|
+
}
|
|
63
|
+
function matchesDigest(value, expected) {
|
|
64
|
+
return timingSafeEqual(digest(value), expected);
|
|
65
|
+
}
|
|
66
|
+
function normalizeLabel(value) {
|
|
67
|
+
const label = (value ?? "Mobile device").normalize("NFC").trim();
|
|
68
|
+
if (label.length < 1 || label.length > 64 || /[\u0000-\u001f\u007f]/u.test(label)) throw new AccessError(400, "invalid_request");
|
|
69
|
+
return label;
|
|
70
|
+
}
|
|
71
|
+
function publicDevice(device) {
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
id: device.id,
|
|
74
|
+
label: device.label,
|
|
75
|
+
createdAt: device.createdAt,
|
|
76
|
+
expiresAt: device.expiresAt,
|
|
77
|
+
lastSeenAt: device.lastSeenAt,
|
|
78
|
+
...device.revokedAt === void 0 ? {} : { revokedAt: device.revokedAt }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Pairing, persistent-device, short-Session, revocation, and CSRF state machine. */
|
|
82
|
+
var AccessController = class {
|
|
83
|
+
store;
|
|
84
|
+
options;
|
|
85
|
+
now;
|
|
86
|
+
pairLimiter;
|
|
87
|
+
devices = [];
|
|
88
|
+
pairingWindow;
|
|
89
|
+
sessions = /* @__PURE__ */ new Map();
|
|
90
|
+
sessionEndedListeners = /* @__PURE__ */ new Set();
|
|
91
|
+
mutation = Promise.resolve();
|
|
92
|
+
initialized = false;
|
|
93
|
+
closing = false;
|
|
94
|
+
closeTask;
|
|
95
|
+
constructor(store, options) {
|
|
96
|
+
this.store = store;
|
|
97
|
+
this.options = options;
|
|
98
|
+
this.now = options.now ?? Date.now;
|
|
99
|
+
this.pairLimiter = new BoundedRateLimiter(options.maxPairingAttempts, options.rateLimitWindowMs, options.maxRateLimitKeys);
|
|
100
|
+
}
|
|
101
|
+
/** Load and validate digest-only durable state before accepting traffic. */
|
|
102
|
+
async initialize() {
|
|
103
|
+
if (this.initialized || this.closing) throw new Error("access controller cannot be initialized again");
|
|
104
|
+
const snapshot = await this.store.load();
|
|
105
|
+
if (snapshot.devices.length > this.options.maxDevices) throw new Error("device state exceeds configured maxDevices");
|
|
106
|
+
this.devices = [...snapshot.devices];
|
|
107
|
+
this.initialized = true;
|
|
108
|
+
}
|
|
109
|
+
requireInitialized() {
|
|
110
|
+
if (!this.initialized || this.closing) throw new Error("access controller is not available");
|
|
111
|
+
}
|
|
112
|
+
async exclusive(operation) {
|
|
113
|
+
const prior = this.mutation;
|
|
114
|
+
let release;
|
|
115
|
+
this.mutation = new Promise((resolve) => {
|
|
116
|
+
release = resolve;
|
|
117
|
+
});
|
|
118
|
+
await prior;
|
|
119
|
+
try {
|
|
120
|
+
return await operation();
|
|
121
|
+
} finally {
|
|
122
|
+
release();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
snapshot(devices) {
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
version: 1,
|
|
128
|
+
devices: Object.freeze([...devices])
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
emitSessionEnded(session) {
|
|
132
|
+
const authorization = Object.freeze({
|
|
133
|
+
sessionKey: session.key,
|
|
134
|
+
deviceId: session.deviceId,
|
|
135
|
+
expiresAt: session.expiresAt
|
|
136
|
+
});
|
|
137
|
+
for (const listener of this.sessionEndedListeners) listener(authorization);
|
|
138
|
+
}
|
|
139
|
+
removeSession(key) {
|
|
140
|
+
const session = this.sessions.get(key);
|
|
141
|
+
if (session === void 0) return;
|
|
142
|
+
this.sessions.delete(key);
|
|
143
|
+
this.emitSessionEnded(session);
|
|
144
|
+
}
|
|
145
|
+
pruneSessions(now) {
|
|
146
|
+
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key);
|
|
147
|
+
}
|
|
148
|
+
createSession(deviceId, now, deviceExpiresAt) {
|
|
149
|
+
this.pruneSessions(now);
|
|
150
|
+
if (this.sessions.size >= this.options.maxSessions) {
|
|
151
|
+
const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
|
|
152
|
+
if (oldest !== void 0) this.removeSession(oldest.key);
|
|
153
|
+
}
|
|
154
|
+
const sessionToken = opaqueToken();
|
|
155
|
+
const csrfToken = opaqueToken();
|
|
156
|
+
const key = digestHex(sessionToken);
|
|
157
|
+
const record = Object.freeze({
|
|
158
|
+
key,
|
|
159
|
+
deviceId,
|
|
160
|
+
csrfDigest: digest(csrfToken),
|
|
161
|
+
createdAt: now,
|
|
162
|
+
expiresAt: Math.min(now + this.options.sessionTtlMs, deviceExpiresAt)
|
|
163
|
+
});
|
|
164
|
+
this.sessions.set(key, record);
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
deviceId,
|
|
167
|
+
sessionToken,
|
|
168
|
+
csrfToken,
|
|
169
|
+
sessionExpiresAt: record.expiresAt
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Open one short pairing window and return its one-time secret to a loopback caller only. */
|
|
173
|
+
async openPairing(requestedTtlMs) {
|
|
174
|
+
this.requireInitialized();
|
|
175
|
+
return this.exclusive(async () => {
|
|
176
|
+
const ttl = requestedTtlMs ?? this.options.pairingTtlMs;
|
|
177
|
+
if (!Number.isSafeInteger(ttl) || ttl < 1e4 || ttl > this.options.pairingTtlMs) throw new AccessError(400, "invalid_request");
|
|
178
|
+
const token = opaqueToken();
|
|
179
|
+
const expiresAt = this.now() + ttl;
|
|
180
|
+
this.pairingWindow = Object.freeze({
|
|
181
|
+
digest: digest(token),
|
|
182
|
+
expiresAt
|
|
183
|
+
});
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
token,
|
|
186
|
+
expiresAt
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** Consume the pairing window exactly once and persist only the device-token digest. */
|
|
191
|
+
async pair(sourceKey, token, label) {
|
|
192
|
+
this.requireInitialized();
|
|
193
|
+
const now = this.now();
|
|
194
|
+
if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
|
|
195
|
+
if (token.length > 512) throw new AccessError(401, "authentication_failed");
|
|
196
|
+
return this.exclusive(async () => {
|
|
197
|
+
const window = this.pairingWindow;
|
|
198
|
+
if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
|
|
199
|
+
if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
|
|
200
|
+
throw new AccessError(401, "authentication_failed");
|
|
201
|
+
}
|
|
202
|
+
this.pairingWindow = void 0;
|
|
203
|
+
if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
|
|
204
|
+
const deviceToken = opaqueToken();
|
|
205
|
+
const device = Object.freeze({
|
|
206
|
+
id: randomBytes(16).toString("hex"),
|
|
207
|
+
label: normalizeLabel(label),
|
|
208
|
+
tokenDigest: digestHex(deviceToken),
|
|
209
|
+
createdAt: now,
|
|
210
|
+
expiresAt: now + this.options.deviceTtlMs,
|
|
211
|
+
lastSeenAt: now
|
|
212
|
+
});
|
|
213
|
+
const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
|
|
214
|
+
await this.store.save(this.snapshot(next));
|
|
215
|
+
this.devices = next;
|
|
216
|
+
const session = this.createSession(device.id, now, device.expiresAt);
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
...session,
|
|
219
|
+
deviceToken,
|
|
220
|
+
deviceExpiresAt: device.expiresAt
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** Exchange a valid persistent device credential for a new short Session. */
|
|
225
|
+
async renew(deviceToken) {
|
|
226
|
+
this.requireInitialized();
|
|
227
|
+
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
228
|
+
return this.exclusive(async () => {
|
|
229
|
+
const now = this.now();
|
|
230
|
+
const tokenDigest = digest(deviceToken);
|
|
231
|
+
const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
|
|
232
|
+
const device = this.devices[index];
|
|
233
|
+
if (device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) throw new AccessError(401, "authentication_failed");
|
|
234
|
+
const updated = Object.freeze({
|
|
235
|
+
...device,
|
|
236
|
+
lastSeenAt: now
|
|
237
|
+
});
|
|
238
|
+
const next = [...this.devices];
|
|
239
|
+
next[index] = updated;
|
|
240
|
+
await this.store.save(this.snapshot(next));
|
|
241
|
+
this.devices = next;
|
|
242
|
+
return this.createSession(device.id, now, device.expiresAt);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Resolve a short Session Cookie without revealing whether device or Session failed. */
|
|
246
|
+
authorizeSession(sessionToken) {
|
|
247
|
+
this.requireInitialized();
|
|
248
|
+
if (sessionToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
249
|
+
const now = this.now();
|
|
250
|
+
this.pruneSessions(now);
|
|
251
|
+
const key = digestHex(sessionToken);
|
|
252
|
+
const session = this.sessions.get(key);
|
|
253
|
+
const device = session === void 0 ? void 0 : this.devices.find((candidate) => candidate.id === session.deviceId);
|
|
254
|
+
if (session === void 0 || device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) {
|
|
255
|
+
if (session !== void 0) this.removeSession(session.key);
|
|
256
|
+
throw new AccessError(401, "authentication_failed");
|
|
257
|
+
}
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
sessionKey: key,
|
|
260
|
+
deviceId: session.deviceId,
|
|
261
|
+
expiresAt: session.expiresAt
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** Require the Session-bound anti-CSRF value for an authenticated mutation. */
|
|
265
|
+
assertCsrf(authorization, csrfToken) {
|
|
266
|
+
const session = this.sessions.get(authorization.sessionKey);
|
|
267
|
+
if (session === void 0 || csrfToken === void 0 || csrfToken.length > 512 || !matchesDigest(csrfToken, session.csrfDigest)) throw new AccessError(403, "forbidden");
|
|
268
|
+
}
|
|
269
|
+
/** End one short Session and notify the gateway to abort its attached work. */
|
|
270
|
+
logout(authorization) {
|
|
271
|
+
this.removeSession(authorization.sessionKey);
|
|
272
|
+
}
|
|
273
|
+
/** Persist revocation, then end every Session owned by that device. */
|
|
274
|
+
async revokeDevice(deviceId) {
|
|
275
|
+
this.requireInitialized();
|
|
276
|
+
return this.exclusive(async () => {
|
|
277
|
+
const index = this.devices.findIndex((device) => device.id === deviceId);
|
|
278
|
+
const device = this.devices[index];
|
|
279
|
+
if (device === void 0 || device.revokedAt !== void 0) return false;
|
|
280
|
+
const next = [...this.devices];
|
|
281
|
+
next[index] = Object.freeze({
|
|
282
|
+
...device,
|
|
283
|
+
revokedAt: this.now()
|
|
284
|
+
});
|
|
285
|
+
await this.store.save(this.snapshot(next));
|
|
286
|
+
this.devices = next;
|
|
287
|
+
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key);
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Remove every persistent credential and terminate every active Session. */
|
|
292
|
+
async resetDevices() {
|
|
293
|
+
this.requireInitialized();
|
|
294
|
+
await this.exclusive(async () => {
|
|
295
|
+
await this.store.save(this.snapshot([]));
|
|
296
|
+
this.devices = [];
|
|
297
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
298
|
+
this.pairingWindow = void 0;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** Safe metadata for the loopback administration surface. */
|
|
302
|
+
listDevices() {
|
|
303
|
+
this.requireInitialized();
|
|
304
|
+
return Object.freeze(this.devices.map(publicDevice));
|
|
305
|
+
}
|
|
306
|
+
/** Pairing status without exposing the one-time secret. */
|
|
307
|
+
pairingStatus() {
|
|
308
|
+
this.requireInitialized();
|
|
309
|
+
const window = this.pairingWindow;
|
|
310
|
+
if (window === void 0 || window.expiresAt <= this.now()) {
|
|
311
|
+
this.pairingWindow = void 0;
|
|
312
|
+
return Object.freeze({ open: false });
|
|
313
|
+
}
|
|
314
|
+
return Object.freeze({
|
|
315
|
+
open: true,
|
|
316
|
+
expiresAt: window.expiresAt
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Subscribe gateway resources to Session logout, expiry, eviction, and device revocation. */
|
|
320
|
+
onSessionEnded(listener) {
|
|
321
|
+
this.sessionEndedListeners.add(listener);
|
|
322
|
+
return () => {
|
|
323
|
+
this.sessionEndedListeners.delete(listener);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Stop new operations, drain durable mutations, then clear volatile credentials. */
|
|
327
|
+
close() {
|
|
328
|
+
if (this.closeTask !== void 0) return this.closeTask;
|
|
329
|
+
this.closing = true;
|
|
330
|
+
this.closeTask = this.finishClose();
|
|
331
|
+
return this.closeTask;
|
|
332
|
+
}
|
|
333
|
+
async finishClose() {
|
|
334
|
+
await this.mutation;
|
|
335
|
+
this.pairingWindow = void 0;
|
|
336
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
337
|
+
this.sessionEndedListeners.clear();
|
|
338
|
+
this.initialized = false;
|
|
339
|
+
}
|
|
340
|
+
/** Bounded volatile-state metrics for tests and local status. */
|
|
341
|
+
metrics() {
|
|
342
|
+
return Object.freeze({
|
|
343
|
+
sessions: this.sessions.size,
|
|
344
|
+
rateLimitKeys: this.pairLimiter.size
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/network.ts
|
|
350
|
+
function parseIpv4(address) {
|
|
351
|
+
const parts = address.split(".");
|
|
352
|
+
if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
353
|
+
let value = 0n;
|
|
354
|
+
for (const part of parts) {
|
|
355
|
+
if (!/^\d{1,3}$/u.test(part)) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
356
|
+
const octet = Number(part);
|
|
357
|
+
if (octet > 255) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
358
|
+
value = value << 8n | BigInt(octet);
|
|
359
|
+
}
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
function parseIpv6Part(part, address) {
|
|
363
|
+
if (part.includes(".")) {
|
|
364
|
+
const ipv4 = parseIpv4(part);
|
|
365
|
+
return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
|
|
366
|
+
}
|
|
367
|
+
if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
368
|
+
return [Number.parseInt(part, 16)];
|
|
369
|
+
}
|
|
370
|
+
function parseIpv6(address) {
|
|
371
|
+
const withoutZone = address.split("%", 1)[0] ?? address;
|
|
372
|
+
if (withoutZone.split("::").length > 2) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
373
|
+
const [leftText, rightText] = withoutZone.split("::");
|
|
374
|
+
const left = leftText === "" ? [] : leftText.split(":").flatMap((part) => parseIpv6Part(part, address));
|
|
375
|
+
const right = rightText === void 0 || rightText === "" ? [] : rightText.split(":").flatMap((part) => parseIpv6Part(part, address));
|
|
376
|
+
const omitted = 8 - left.length - right.length;
|
|
377
|
+
if (rightText === void 0 ? omitted !== 0 : omitted < 1) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
378
|
+
const groups = [
|
|
379
|
+
...left,
|
|
380
|
+
...Array.from({ length: omitted }, () => 0),
|
|
381
|
+
...right
|
|
382
|
+
];
|
|
383
|
+
if (groups.length !== 8) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
384
|
+
return groups.reduce((value, group) => value << 16n | BigInt(group), 0n);
|
|
385
|
+
}
|
|
386
|
+
function mappedIpv4(address) {
|
|
387
|
+
return /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/iu.exec(address)?.[1];
|
|
388
|
+
}
|
|
389
|
+
function parseIp(address) {
|
|
390
|
+
const unwrapped = address.startsWith("[") && address.endsWith("]") ? address.slice(1, -1) : address;
|
|
391
|
+
const mapped = mappedIpv4(unwrapped);
|
|
392
|
+
if (mapped !== void 0) return {
|
|
393
|
+
bits: 32,
|
|
394
|
+
value: parseIpv4(mapped)
|
|
395
|
+
};
|
|
396
|
+
const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
|
|
397
|
+
if (version === 4) return {
|
|
398
|
+
bits: 32,
|
|
399
|
+
value: parseIpv4(unwrapped)
|
|
400
|
+
};
|
|
401
|
+
if (version === 6) return {
|
|
402
|
+
bits: 128,
|
|
403
|
+
value: parseIpv6(unwrapped)
|
|
404
|
+
};
|
|
405
|
+
throw new Error(`invalid IP address ${JSON.stringify(address)}`);
|
|
406
|
+
}
|
|
407
|
+
/** Parse and canonicalize one IPv4 or IPv6 CIDR. */
|
|
408
|
+
function parseCidr(source) {
|
|
409
|
+
const slash = source.lastIndexOf("/");
|
|
410
|
+
if (slash <= 0 || slash === source.length - 1) throw new Error(`invalid CIDR ${JSON.stringify(source)}`);
|
|
411
|
+
const parsed = parseIp(source.slice(0, slash));
|
|
412
|
+
const prefixText = source.slice(slash + 1);
|
|
413
|
+
if (!/^\d{1,3}$/u.test(prefixText)) throw new Error(`invalid CIDR ${JSON.stringify(source)}`);
|
|
414
|
+
const prefix = Number(prefixText);
|
|
415
|
+
if (prefix > parsed.bits) throw new Error(`invalid CIDR ${JSON.stringify(source)}`);
|
|
416
|
+
const hostBits = BigInt(parsed.bits - prefix);
|
|
417
|
+
const mask = hostBits === BigInt(parsed.bits) ? 0n : (1n << BigInt(parsed.bits)) - 1n ^ (1n << hostBits) - 1n;
|
|
418
|
+
const network = parsed.value & mask;
|
|
419
|
+
if (network !== parsed.value) throw new Error(`CIDR ${JSON.stringify(source)} has host bits set`);
|
|
420
|
+
return Object.freeze({
|
|
421
|
+
bits: parsed.bits,
|
|
422
|
+
network,
|
|
423
|
+
prefix,
|
|
424
|
+
source
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/** Whether a directly connected socket address belongs to at least one allowed CIDR. */
|
|
428
|
+
function addressAllowed(address, cidrs) {
|
|
429
|
+
if (address === void 0) return false;
|
|
430
|
+
let parsed;
|
|
431
|
+
try {
|
|
432
|
+
parsed = parseIp(address);
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
return cidrs.some((cidr) => {
|
|
437
|
+
if (cidr.bits !== parsed.bits) return false;
|
|
438
|
+
const hostBits = BigInt(cidr.bits - cidr.prefix);
|
|
439
|
+
const mask = hostBits === BigInt(cidr.bits) ? 0n : (1n << BigInt(cidr.bits)) - 1n ^ (1n << hostBits) - 1n;
|
|
440
|
+
return (parsed.value & mask) === cidr.network;
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/** Whether an IP literal is loopback and therefore eligible for HTTP-only development. */
|
|
444
|
+
function isLoopbackAddress(address) {
|
|
445
|
+
try {
|
|
446
|
+
const parsed = parseIp(address);
|
|
447
|
+
if (parsed.bits === 32) return parsed.value >> 24n === 127n;
|
|
448
|
+
return parsed.value === 1n;
|
|
449
|
+
} catch {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Parse a bare host or host:port authority without accepting URL components. */
|
|
454
|
+
function parseAuthority(source) {
|
|
455
|
+
if (source.trim() !== source || source.length === 0 || /[/?#@\\]/u.test(source)) throw new Error(`invalid public authority ${JSON.stringify(source)}`);
|
|
456
|
+
let url;
|
|
457
|
+
try {
|
|
458
|
+
url = new URL(`https://${source}`);
|
|
459
|
+
} catch {
|
|
460
|
+
throw new Error(`invalid public authority ${JSON.stringify(source)}`);
|
|
461
|
+
}
|
|
462
|
+
if (url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error(`invalid public authority ${JSON.stringify(source)}`);
|
|
463
|
+
const explicitPort = /\]:\d+$/u.test(source) || !source.startsWith("[") && /:\d+$/u.test(source);
|
|
464
|
+
const hostname = url.hostname.toLowerCase();
|
|
465
|
+
const port = explicitPort ? Number(url.port === "" ? 443 : url.port) : void 0;
|
|
466
|
+
if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error(`invalid public authority ${JSON.stringify(source)}`);
|
|
467
|
+
return port === void 0 ? Object.freeze({ hostname }) : Object.freeze({
|
|
468
|
+
hostname,
|
|
469
|
+
port
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
function formatHostname(hostname) {
|
|
473
|
+
return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
474
|
+
}
|
|
475
|
+
/** Resolve an authority against the actual listener port. */
|
|
476
|
+
function resolveAuthority(spec, listenerPort) {
|
|
477
|
+
return `${formatHostname(spec.hostname)}:${String(spec.port ?? listenerPort)}`;
|
|
478
|
+
}
|
|
479
|
+
/** Exact Host/Origin/CIDR policy for the directly exposed listener. */
|
|
480
|
+
var RequestTrustPolicy = class {
|
|
481
|
+
cidrs;
|
|
482
|
+
authorities;
|
|
483
|
+
origins;
|
|
484
|
+
scheme;
|
|
485
|
+
constructor(specs, listenerPort, cidrs, tls) {
|
|
486
|
+
this.cidrs = cidrs;
|
|
487
|
+
this.scheme = tls ? "https" : "http";
|
|
488
|
+
this.authorities = new Set(specs.map((spec) => resolveAuthority(spec, listenerPort).toLowerCase()));
|
|
489
|
+
this.origins = new Set([...this.authorities].map((authority) => new URL(`${this.scheme}://${authority}`).origin.toLowerCase()));
|
|
490
|
+
}
|
|
491
|
+
/** Validate the exact Host header after WHATWG authority normalization. */
|
|
492
|
+
acceptsHost(header) {
|
|
493
|
+
return this.canonicalHost(header) !== void 0;
|
|
494
|
+
}
|
|
495
|
+
/** Return the canonical accepted Host authority, otherwise undefined. */
|
|
496
|
+
canonicalHost(header) {
|
|
497
|
+
if (header === void 0 || /[/?#@\\]/u.test(header)) return void 0;
|
|
498
|
+
let normalized;
|
|
499
|
+
try {
|
|
500
|
+
const parsed = new URL(`${this.scheme}://${header}`);
|
|
501
|
+
if (parsed.pathname !== "/" || parsed.username !== "" || parsed.password !== "") return void 0;
|
|
502
|
+
normalized = resolveAuthority({
|
|
503
|
+
hostname: parsed.hostname,
|
|
504
|
+
port: Number(parsed.port || (this.scheme === "https" ? "443" : "80"))
|
|
505
|
+
}, 80).toLowerCase();
|
|
506
|
+
} catch {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
return this.authorities.has(normalized) ? normalized : void 0;
|
|
510
|
+
}
|
|
511
|
+
/** Validate an exact same-scheme browser Origin. */
|
|
512
|
+
acceptsOrigin(header) {
|
|
513
|
+
return this.canonicalOrigin(header) !== void 0;
|
|
514
|
+
}
|
|
515
|
+
/** Return the canonical accepted Origin, otherwise undefined. */
|
|
516
|
+
canonicalOrigin(header) {
|
|
517
|
+
if (header === void 0) return void 0;
|
|
518
|
+
let normalized;
|
|
519
|
+
try {
|
|
520
|
+
const parsed = new URL(header);
|
|
521
|
+
if (parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "" || parsed.username !== "" || parsed.password !== "") return;
|
|
522
|
+
normalized = parsed.origin.toLowerCase();
|
|
523
|
+
} catch {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
return this.origins.has(normalized) ? normalized : void 0;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/config.ts
|
|
531
|
+
/** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
|
|
532
|
+
const Config = z.object({
|
|
533
|
+
setupFile: z.string().hidden(),
|
|
534
|
+
publicOrigin: z.string(),
|
|
535
|
+
listenHost: z.string(),
|
|
536
|
+
listenPort: z.natural().max(65535),
|
|
537
|
+
upstreamOrigin: z.string(),
|
|
538
|
+
publicAuthorities: z.array(String).default(void 0),
|
|
539
|
+
allowedCidrs: z.array(String).default(void 0),
|
|
540
|
+
stateFile: String,
|
|
541
|
+
controlFile: z.string().hidden().required(),
|
|
542
|
+
customCssFile: z.string().hidden(),
|
|
543
|
+
initiallyEnabled: z.boolean().hidden().required(),
|
|
544
|
+
tls: z.object({
|
|
545
|
+
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
546
|
+
certFile: z.string(),
|
|
547
|
+
keyFile: z.string(),
|
|
548
|
+
caFile: z.string()
|
|
549
|
+
}),
|
|
550
|
+
pairingTtlMs: z.natural(),
|
|
551
|
+
deviceTtlMs: z.natural(),
|
|
552
|
+
sessionTtlMs: z.natural(),
|
|
553
|
+
maxDevices: z.natural(),
|
|
554
|
+
maxSessions: z.natural(),
|
|
555
|
+
maxConnections: z.natural(),
|
|
556
|
+
maxActiveRequests: z.natural(),
|
|
557
|
+
maxWebSockets: z.natural(),
|
|
558
|
+
maxBodyBytes: z.natural(),
|
|
559
|
+
upstreamTimeoutMs: z.natural(),
|
|
560
|
+
rateLimitWindowMs: z.natural(),
|
|
561
|
+
maxPairingAttempts: z.natural(),
|
|
562
|
+
maxRateLimitKeys: z.natural()
|
|
563
|
+
});
|
|
564
|
+
function integer(value, name, fallback, minimum, maximum) {
|
|
565
|
+
const resolved = value ?? fallback;
|
|
566
|
+
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)}`);
|
|
567
|
+
return resolved;
|
|
568
|
+
}
|
|
569
|
+
function stringArray(value, name) {
|
|
570
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw new Error(`${name} must be a non-empty string array`);
|
|
571
|
+
return value;
|
|
572
|
+
}
|
|
573
|
+
function absoluteFile(value, name) {
|
|
574
|
+
if (typeof value !== "string" || value.length === 0 || !isAbsolute(value)) throw new Error(`${name} must be an absolute file path`);
|
|
575
|
+
return resolve(value);
|
|
576
|
+
}
|
|
577
|
+
/** Resolve the hidden runtime-control file independently from gateway configuration. */
|
|
578
|
+
function parseControlFile(value) {
|
|
579
|
+
return absoluteFile(value, "controlFile");
|
|
580
|
+
}
|
|
581
|
+
function parseUpstream(value) {
|
|
582
|
+
const source = value ?? "http://127.0.0.1:3080";
|
|
583
|
+
if (typeof source !== "string") throw new Error("upstreamOrigin must be a string");
|
|
584
|
+
let url;
|
|
585
|
+
try {
|
|
586
|
+
url = new URL(source);
|
|
587
|
+
} catch {
|
|
588
|
+
throw new Error("upstreamOrigin must be an HTTP loopback origin");
|
|
589
|
+
}
|
|
590
|
+
if (url.protocol !== "http:" || !isLoopbackAddress(url.hostname) || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.port === "") throw new Error("upstreamOrigin must be an HTTP loopback origin with an explicit port and no path or credentials");
|
|
591
|
+
return url;
|
|
592
|
+
}
|
|
593
|
+
function parsePublicOrigin(value) {
|
|
594
|
+
if (value === void 0) return void 0;
|
|
595
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error("publicOrigin must be an HTTPS origin");
|
|
596
|
+
let url;
|
|
597
|
+
try {
|
|
598
|
+
url = new URL(value);
|
|
599
|
+
} catch {
|
|
600
|
+
throw new Error("publicOrigin must be an HTTPS origin");
|
|
601
|
+
}
|
|
602
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error("publicOrigin must be an HTTPS origin with no path or credentials");
|
|
603
|
+
if (url.hostname === "0.0.0.0" || url.hostname === "[::]") throw new Error("publicOrigin must name a reachable host");
|
|
604
|
+
return Object.freeze({
|
|
605
|
+
authority: parseAuthority(url.host),
|
|
606
|
+
port: Number(url.port || "443")
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function parseTls(value, listenHost) {
|
|
610
|
+
const mode = value?.mode ?? "provided";
|
|
611
|
+
if (mode === "disabled") {
|
|
612
|
+
if (!isLoopbackAddress(listenHost)) throw new Error("TLS may be disabled only on an IP loopback listener");
|
|
613
|
+
return Object.freeze({ mode });
|
|
614
|
+
}
|
|
615
|
+
return Object.freeze({
|
|
616
|
+
mode,
|
|
617
|
+
certFile: absoluteFile(value?.certFile, "tls.certFile"),
|
|
618
|
+
keyFile: absoluteFile(value?.keyFile, "tls.keyFile"),
|
|
619
|
+
...value?.caFile === void 0 ? {} : { caFile: absoluteFile(value.caFile, "tls.caFile") }
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
/** Parse configuration and reject unsafe topology, credential, and resource combinations. */
|
|
623
|
+
function parseGatewayConfig(raw) {
|
|
624
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("mobile-access config must be an object");
|
|
625
|
+
const value = raw;
|
|
626
|
+
const publicOrigin = parsePublicOrigin(value.publicOrigin);
|
|
627
|
+
if (publicOrigin !== void 0 && value.listenPort !== void 0) throw new Error("publicOrigin cannot be combined with listenPort");
|
|
628
|
+
if (publicOrigin !== void 0 && value.publicAuthorities !== void 0) throw new Error("publicOrigin cannot be combined with publicAuthorities");
|
|
629
|
+
const listenHost = value.listenHost ?? (publicOrigin === void 0 ? "127.0.0.1" : "0.0.0.0");
|
|
630
|
+
if (isIP(listenHost) === 0) throw new Error("listenHost must be an IP literal");
|
|
631
|
+
const listenPort = publicOrigin?.port ?? integer(value.listenPort, "listenPort", 3443, 0, 65535);
|
|
632
|
+
const upstreamOrigin = parseUpstream(value.upstreamOrigin);
|
|
633
|
+
const tls = parseTls(value.tls, listenHost);
|
|
634
|
+
if (publicOrigin !== void 0 && tls.mode !== "provided") throw new Error("publicOrigin requires TLS");
|
|
635
|
+
let authorities;
|
|
636
|
+
if (publicOrigin !== void 0) authorities = [publicOrigin.authority];
|
|
637
|
+
else {
|
|
638
|
+
let authoritySources = value.publicAuthorities;
|
|
639
|
+
if (authoritySources === void 0 || authoritySources.length === 0) {
|
|
640
|
+
if (!isLoopbackAddress(listenHost)) throw new Error("publicAuthorities is required for a non-loopback listener");
|
|
641
|
+
authoritySources = [listenHost];
|
|
642
|
+
}
|
|
643
|
+
authorities = authoritySources.map(parseAuthority);
|
|
644
|
+
}
|
|
645
|
+
for (const authority of authorities) {
|
|
646
|
+
if (listenPort === 0 && authority.port !== void 0) throw new Error("explicit public authority ports require a non-zero listenPort");
|
|
647
|
+
if (authority.port !== void 0 && listenPort !== 0 && authority.port !== listenPort) throw new Error("every explicit public authority port must equal listenPort");
|
|
648
|
+
}
|
|
649
|
+
if (new Set(authorities.map((entry) => `${entry.hostname}:${String(entry.port ?? listenPort)}`)).size !== authorities.length) throw new Error("publicAuthorities must not contain duplicates");
|
|
650
|
+
const allowedCidrs = stringArray(value.allowedCidrs ?? (isLoopbackAddress(listenHost) ? ["127.0.0.0/8", "::1/128"] : void 0), "allowedCidrs").map(parseCidr);
|
|
651
|
+
if (new Set(allowedCidrs.map((entry) => `${String(entry.bits)}:${entry.network.toString(16)}:${String(entry.prefix)}`)).size !== allowedCidrs.length) throw new Error("allowedCidrs must not contain duplicates");
|
|
652
|
+
const deviceTtlMs = integer(value.deviceTtlMs, "deviceTtlMs", 7776e6, 6e4, 316224e5);
|
|
653
|
+
const sessionTtlMs = integer(value.sessionTtlMs, "sessionTtlMs", 288e5, 3e4, 864e5);
|
|
654
|
+
if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
|
|
655
|
+
return Object.freeze({
|
|
656
|
+
listenHost,
|
|
657
|
+
listenPort,
|
|
658
|
+
upstreamOrigin,
|
|
659
|
+
authorities: Object.freeze(authorities),
|
|
660
|
+
allowedCidrs: Object.freeze(allowedCidrs),
|
|
661
|
+
stateFile: absoluteFile(value.stateFile, "stateFile"),
|
|
662
|
+
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
663
|
+
tls,
|
|
664
|
+
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
665
|
+
deviceTtlMs,
|
|
666
|
+
sessionTtlMs,
|
|
667
|
+
maxDevices: integer(value.maxDevices, "maxDevices", 32, 1, 256),
|
|
668
|
+
maxSessions: integer(value.maxSessions, "maxSessions", 64, 1, 1024),
|
|
669
|
+
maxConnections: integer(value.maxConnections, "maxConnections", 64, 1, 1024),
|
|
670
|
+
maxActiveRequests: integer(value.maxActiveRequests, "maxActiveRequests", 32, 1, 1024),
|
|
671
|
+
maxWebSockets: integer(value.maxWebSockets, "maxWebSockets", 16, 1, 256),
|
|
672
|
+
maxBodyBytes: integer(value.maxBodyBytes, "maxBodyBytes", 167772160, 1024, 268435456),
|
|
673
|
+
upstreamTimeoutMs: integer(value.upstreamTimeoutMs, "upstreamTimeoutMs", 3e4, 1e3, 3e5),
|
|
674
|
+
rateLimitWindowMs: integer(value.rateLimitWindowMs, "rateLimitWindowMs", 6e4, 1e3, 36e5),
|
|
675
|
+
maxPairingAttempts: integer(value.maxPairingAttempts, "maxPairingAttempts", 8, 1, 100),
|
|
676
|
+
maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/control.ts
|
|
681
|
+
/** Validate control state loaded across the filesystem boundary. */
|
|
682
|
+
function parseMobileAccessControlState(value) {
|
|
683
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
|
|
684
|
+
const record = value;
|
|
685
|
+
if (record.version !== 1 || typeof record.enabled !== "boolean" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "enabled")) throw new Error("mobile-access control state has an unsupported format");
|
|
686
|
+
return Object.freeze({
|
|
687
|
+
version: 1,
|
|
688
|
+
enabled: record.enabled
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/** Atomic JSON store whose absent-file state comes from the installation-time default. */
|
|
692
|
+
var JsonMobileAccessControlStore = class {
|
|
693
|
+
file;
|
|
694
|
+
initiallyEnabled;
|
|
695
|
+
constructor(file, initiallyEnabled) {
|
|
696
|
+
this.file = file;
|
|
697
|
+
this.initiallyEnabled = initiallyEnabled;
|
|
698
|
+
}
|
|
699
|
+
async load() {
|
|
700
|
+
let stat;
|
|
701
|
+
try {
|
|
702
|
+
stat = await lstat(this.file);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error.code === "ENOENT") return Object.freeze({
|
|
705
|
+
version: 1,
|
|
706
|
+
enabled: this.initiallyEnabled
|
|
707
|
+
});
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("mobile-access control state must be a regular file no larger than 4 KiB");
|
|
711
|
+
let parsed;
|
|
712
|
+
try {
|
|
713
|
+
parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
714
|
+
} catch (error) {
|
|
715
|
+
throw new Error("mobile-access control state is not valid JSON", { cause: error });
|
|
716
|
+
}
|
|
717
|
+
return parseMobileAccessControlState(parsed);
|
|
718
|
+
}
|
|
719
|
+
async save(state) {
|
|
720
|
+
const validated = parseMobileAccessControlState(state);
|
|
721
|
+
const directory = dirname(this.file);
|
|
722
|
+
await mkdir(directory, {
|
|
723
|
+
recursive: true,
|
|
724
|
+
mode: 448
|
|
725
|
+
});
|
|
726
|
+
try {
|
|
727
|
+
const current = await lstat(this.file);
|
|
728
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("mobile-access control state target must remain a regular file");
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (error.code !== "ENOENT") throw error;
|
|
731
|
+
}
|
|
732
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
733
|
+
try {
|
|
734
|
+
await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
|
|
735
|
+
encoding: "utf8",
|
|
736
|
+
flag: "wx",
|
|
737
|
+
mode: 384
|
|
738
|
+
});
|
|
739
|
+
await chmod(temporary, 384);
|
|
740
|
+
await rename(temporary, this.file);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
try {
|
|
743
|
+
await rm(temporary, { force: true });
|
|
744
|
+
} catch (cleanupError) {
|
|
745
|
+
throw new AggregateError([error, cleanupError], "control state write and temporary cleanup both failed");
|
|
746
|
+
}
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
/** Serialized persistent lifecycle for the gateway behind the always-loaded Cordis entry. */
|
|
752
|
+
var MobileAccessGatewayController = class {
|
|
753
|
+
store;
|
|
754
|
+
startRuntime;
|
|
755
|
+
runtime;
|
|
756
|
+
initialized = false;
|
|
757
|
+
closing = false;
|
|
758
|
+
queue = Promise.resolve();
|
|
759
|
+
closeTask;
|
|
760
|
+
constructor(store, startRuntime) {
|
|
761
|
+
this.store = store;
|
|
762
|
+
this.startRuntime = startRuntime;
|
|
763
|
+
}
|
|
764
|
+
/** Load the durable preference and start the first runtime when enabled. */
|
|
765
|
+
initialize() {
|
|
766
|
+
return this.enqueue(async () => {
|
|
767
|
+
if (this.initialized) throw new Error("mobile-access control is already initialized");
|
|
768
|
+
if (this.closing) throw new Error("mobile-access control is closing");
|
|
769
|
+
if ((await this.store.load()).enabled) this.runtime = await this.startRuntime();
|
|
770
|
+
this.initialized = true;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/** Return the committed in-process runtime state. */
|
|
774
|
+
isRunning() {
|
|
775
|
+
return this.runtime !== void 0;
|
|
776
|
+
}
|
|
777
|
+
/** Start or stop the runtime and persist only a successfully committed transition. */
|
|
778
|
+
setRunning(running) {
|
|
779
|
+
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("mobile-access control is closing"));
|
|
780
|
+
return this.enqueue(async () => {
|
|
781
|
+
if (!this.initialized) throw new Error("mobile-access control is not initialized");
|
|
782
|
+
if (this.isRunning() === running) return;
|
|
783
|
+
if (running) await this.enable();
|
|
784
|
+
else await this.disable();
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
/** Stop the runtime after earlier transitions without changing the restart preference. */
|
|
788
|
+
close() {
|
|
789
|
+
if (this.closeTask !== void 0) return this.closeTask;
|
|
790
|
+
this.closing = true;
|
|
791
|
+
this.closeTask = this.enqueue(async () => {
|
|
792
|
+
const runtime = this.runtime;
|
|
793
|
+
if (runtime === void 0) return;
|
|
794
|
+
await runtime.close();
|
|
795
|
+
this.runtime = void 0;
|
|
796
|
+
});
|
|
797
|
+
return this.closeTask;
|
|
798
|
+
}
|
|
799
|
+
async enable() {
|
|
800
|
+
const candidate = await this.startRuntime();
|
|
801
|
+
try {
|
|
802
|
+
await this.store.save({
|
|
803
|
+
version: 1,
|
|
804
|
+
enabled: true
|
|
805
|
+
});
|
|
806
|
+
} catch (error) {
|
|
807
|
+
try {
|
|
808
|
+
await candidate.close();
|
|
809
|
+
} catch (rollbackError) {
|
|
810
|
+
throw new AggregateError([error, rollbackError], "enabling mobile access failed and runtime rollback also failed");
|
|
811
|
+
}
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
this.runtime = candidate;
|
|
815
|
+
}
|
|
816
|
+
async disable() {
|
|
817
|
+
const previous = this.runtime;
|
|
818
|
+
if (previous === void 0) return;
|
|
819
|
+
await previous.close();
|
|
820
|
+
try {
|
|
821
|
+
await this.store.save({
|
|
822
|
+
version: 1,
|
|
823
|
+
enabled: false
|
|
824
|
+
});
|
|
825
|
+
} catch (error) {
|
|
826
|
+
try {
|
|
827
|
+
this.runtime = await this.startRuntime();
|
|
828
|
+
} catch (rollbackError) {
|
|
829
|
+
this.runtime = void 0;
|
|
830
|
+
throw new AggregateError([error, rollbackError], "disabling mobile access failed and runtime rollback also failed");
|
|
831
|
+
}
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
this.runtime = void 0;
|
|
835
|
+
}
|
|
836
|
+
enqueue(operation) {
|
|
837
|
+
const run = this.queue.then(operation, operation);
|
|
838
|
+
this.queue = run.then(() => {}, () => {});
|
|
839
|
+
return run;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/http-security.ts
|
|
844
|
+
const DEVICE_COOKIE = "dsh_ma_device";
|
|
845
|
+
const SESSION_COOKIE = "dsh_ma_session";
|
|
846
|
+
const CSRF_COOKIE = "dsh_ma_csrf";
|
|
847
|
+
const CSRF_HEADER = "x-dsh-mobile-csrf";
|
|
848
|
+
const LOCAL_ADMIN_PREFIX = "/api/mobile-access";
|
|
849
|
+
const AUTH_PREFIX = "/mobile-access";
|
|
850
|
+
const WS_PATHS = /* @__PURE__ */ new Set(["/api/events.mux", "/api/events.host"]);
|
|
851
|
+
/** Terse request failure safe to expose without internal diagnostics. */
|
|
852
|
+
var HttpError = class extends Error {
|
|
853
|
+
status;
|
|
854
|
+
code;
|
|
855
|
+
constructor(status, code) {
|
|
856
|
+
super(code);
|
|
857
|
+
this.status = status;
|
|
858
|
+
this.code = code;
|
|
859
|
+
this.name = "HttpError";
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
/** Parse only origin-form request targets and reject ambiguous slash encodings. */
|
|
863
|
+
function parseRequestTarget(raw) {
|
|
864
|
+
if (raw === void 0 || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\") || /[\u0000-\u001f\u007f]/u.test(raw)) throw new HttpError(400, "bad_request");
|
|
865
|
+
let parsed;
|
|
866
|
+
let decodedPathname;
|
|
867
|
+
try {
|
|
868
|
+
parsed = new URL(raw, "http://gateway.invalid");
|
|
869
|
+
decodedPathname = decodeURIComponent(parsed.pathname);
|
|
870
|
+
} catch {
|
|
871
|
+
throw new HttpError(400, "bad_request");
|
|
872
|
+
}
|
|
873
|
+
if (decodedPathname.includes("\\") || decodedPathname.startsWith("//") || /[\u0000-\u001f\u007f]/u.test(decodedPathname)) throw new HttpError(400, "bad_request");
|
|
874
|
+
return Object.freeze({
|
|
875
|
+
raw,
|
|
876
|
+
pathname: parsed.pathname,
|
|
877
|
+
decodedPathname,
|
|
878
|
+
search: parsed.search
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
/** Set the gateway-owned browser protections and non-cacheability. */
|
|
882
|
+
function setSecurityHeaders(response, tls) {
|
|
883
|
+
response.setHeader("Cache-Control", "no-store");
|
|
884
|
+
response.setHeader("Content-Security-Policy", [
|
|
885
|
+
"default-src 'self'",
|
|
886
|
+
"base-uri 'none'",
|
|
887
|
+
"object-src 'none'",
|
|
888
|
+
"frame-ancestors 'none'",
|
|
889
|
+
"form-action 'self'",
|
|
890
|
+
"script-src 'self' 'unsafe-inline'",
|
|
891
|
+
"style-src 'self' 'unsafe-inline'",
|
|
892
|
+
"img-src 'self' data: blob:",
|
|
893
|
+
"font-src 'self' data:",
|
|
894
|
+
"connect-src 'self'",
|
|
895
|
+
"worker-src 'self' blob:"
|
|
896
|
+
].join("; "));
|
|
897
|
+
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
898
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
899
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
900
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
901
|
+
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
902
|
+
if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
|
|
903
|
+
}
|
|
904
|
+
/** Send a bounded JSON response without reflecting request or upstream data. */
|
|
905
|
+
function sendJson(response, status, value, tls) {
|
|
906
|
+
if (response.headersSent || response.destroyed) return;
|
|
907
|
+
setSecurityHeaders(response, tls);
|
|
908
|
+
const body = `${JSON.stringify(value)}\n`;
|
|
909
|
+
response.writeHead(status, {
|
|
910
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
911
|
+
"Content-Length": Buffer.byteLength(body)
|
|
912
|
+
});
|
|
913
|
+
response.end(body);
|
|
914
|
+
}
|
|
915
|
+
/** Send a generic failure containing only a stable category. */
|
|
916
|
+
function sendFailure(response, status, code, tls) {
|
|
917
|
+
sendJson(response, status, { error: code }, tls);
|
|
918
|
+
}
|
|
919
|
+
/** Read and parse one bounded JSON object. */
|
|
920
|
+
async function readJsonObject(request, maximumBytes) {
|
|
921
|
+
if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") throw new HttpError(415, "unsupported_media_type");
|
|
922
|
+
const declared = request.headers["content-length"];
|
|
923
|
+
if (declared !== void 0) {
|
|
924
|
+
if (!/^\d+$/u.test(declared) || Number(declared) > maximumBytes) throw new HttpError(413, "payload_too_large");
|
|
925
|
+
}
|
|
926
|
+
const chunks = [];
|
|
927
|
+
let total = 0;
|
|
928
|
+
for await (const chunk of request) {
|
|
929
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
930
|
+
total += buffer.length;
|
|
931
|
+
if (total > maximumBytes) throw new HttpError(413, "payload_too_large");
|
|
932
|
+
chunks.push(buffer);
|
|
933
|
+
}
|
|
934
|
+
let parsed;
|
|
935
|
+
try {
|
|
936
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
937
|
+
} catch {
|
|
938
|
+
throw new HttpError(400, "bad_request");
|
|
939
|
+
}
|
|
940
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "bad_request");
|
|
941
|
+
return parsed;
|
|
942
|
+
}
|
|
943
|
+
/** Strict cookie parser: malformed or duplicate names invalidate the whole header. */
|
|
944
|
+
function parseCookies(header) {
|
|
945
|
+
if (header === void 0) return /* @__PURE__ */ new Map();
|
|
946
|
+
if (header.length > 8192) return void 0;
|
|
947
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
948
|
+
for (const part of header.split(";")) {
|
|
949
|
+
const equals = part.indexOf("=");
|
|
950
|
+
if (equals <= 0) return void 0;
|
|
951
|
+
const name = part.slice(0, equals).trim();
|
|
952
|
+
const value = part.slice(equals + 1).trim();
|
|
953
|
+
if (!/^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u.test(name) || !/^[\w\-.~+/=]*$/u.test(value) || cookies.has(name)) return;
|
|
954
|
+
cookies.set(name, value);
|
|
955
|
+
}
|
|
956
|
+
return cookies;
|
|
957
|
+
}
|
|
958
|
+
/** Serialize a host-only Cookie with no Domain attribute. */
|
|
959
|
+
function cookie(name, value, options) {
|
|
960
|
+
const parts = [
|
|
961
|
+
`${name}=${value}`,
|
|
962
|
+
`Path=${options.path}`,
|
|
963
|
+
`Max-Age=${String(Math.max(0, Math.floor(options.maxAgeSeconds)))}`,
|
|
964
|
+
"SameSite=Strict",
|
|
965
|
+
"Priority=High"
|
|
966
|
+
];
|
|
967
|
+
if (options.tls) parts.push("Secure");
|
|
968
|
+
if (options.httpOnly) parts.push("HttpOnly");
|
|
969
|
+
return parts.join("; ");
|
|
970
|
+
}
|
|
971
|
+
/** Enforce direct CIDR, exact Host, and browser same-origin facts. */
|
|
972
|
+
function assertExternalTrust(request, policy, requireOrigin) {
|
|
973
|
+
if (!addressAllowed(request.socket.remoteAddress, policy.cidrs) || !policy.acceptsHost(request.headers.host)) throw new HttpError(403, "forbidden");
|
|
974
|
+
const origin = request.headers.origin;
|
|
975
|
+
if (origin !== void 0 && !policy.acceptsOrigin(origin)) throw new HttpError(403, "forbidden");
|
|
976
|
+
const site = request.headers["sec-fetch-site"];
|
|
977
|
+
if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
|
|
978
|
+
if (requireOrigin && (!policy.acceptsOrigin(origin) || site !== "same-origin")) throw new HttpError(403, "forbidden");
|
|
979
|
+
}
|
|
980
|
+
function localAuthority(header) {
|
|
981
|
+
if (header === void 0 || /[/?#@\\]/u.test(header)) return void 0;
|
|
982
|
+
try {
|
|
983
|
+
const url = new URL(`http://${header}`);
|
|
984
|
+
if (url.pathname !== "/" || url.username !== "" || url.password !== "") return void 0;
|
|
985
|
+
return {
|
|
986
|
+
hostname: url.hostname,
|
|
987
|
+
authority: url.host.toLowerCase()
|
|
988
|
+
};
|
|
989
|
+
} catch {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/** Protect the inner management route from non-loopback and DNS-rebinding callers. */
|
|
994
|
+
function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
995
|
+
if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
|
|
996
|
+
const host = localAuthority(request.headers.host);
|
|
997
|
+
if (host === void 0 || host.hostname !== "localhost" && !isLoopbackAddress(host.hostname)) throw new HttpError(403, "forbidden");
|
|
998
|
+
const site = request.headers["sec-fetch-site"];
|
|
999
|
+
if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
|
|
1000
|
+
const origin = request.headers.origin;
|
|
1001
|
+
if (origin !== void 0) try {
|
|
1002
|
+
const parsed = new URL(origin);
|
|
1003
|
+
if (parsed.host.toLowerCase() !== host.authority || parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new HttpError(403, "forbidden");
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (error instanceof HttpError) throw error;
|
|
1006
|
+
throw new HttpError(403, "forbidden");
|
|
1007
|
+
}
|
|
1008
|
+
if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/gateway.ts
|
|
1012
|
+
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
1013
|
+
const MAX_HEADER_BYTES = 16384;
|
|
1014
|
+
const PAIR_PAGE = `<!doctype html>
|
|
1015
|
+
<html lang="en">
|
|
1016
|
+
<meta charset="utf-8">
|
|
1017
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
1018
|
+
<title>Pair DSH mobile access</title>
|
|
1019
|
+
<main>
|
|
1020
|
+
<h1>Pair this device</h1>
|
|
1021
|
+
<form id="pair-form">
|
|
1022
|
+
<label>Pairing code <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
1023
|
+
<label>Device name <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
1024
|
+
<button type="submit">Pair</button>
|
|
1025
|
+
<output id="pair-status"></output>
|
|
1026
|
+
</form>
|
|
1027
|
+
</main>
|
|
1028
|
+
<script src="/mobile-access/pair.js" defer><\/script>
|
|
1029
|
+
</html>
|
|
1030
|
+
`;
|
|
1031
|
+
const PAIR_SCRIPT = `(() => {
|
|
1032
|
+
const form = document.getElementById('pair-form')
|
|
1033
|
+
const token = document.getElementById('pair-token')
|
|
1034
|
+
const label = document.getElementById('device-label')
|
|
1035
|
+
const status = document.getElementById('pair-status')
|
|
1036
|
+
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
1037
|
+
const supplied = fragment.get('token')
|
|
1038
|
+
history.replaceState(null, '', location.pathname)
|
|
1039
|
+
if (supplied) token.value = supplied
|
|
1040
|
+
form.addEventListener('submit', async (event) => {
|
|
1041
|
+
event.preventDefault()
|
|
1042
|
+
status.value = 'Pairing…'
|
|
1043
|
+
const response = await fetch('/mobile-access/auth/pair', {
|
|
1044
|
+
method: 'POST',
|
|
1045
|
+
credentials: 'same-origin',
|
|
1046
|
+
headers: { 'content-type': 'application/json' },
|
|
1047
|
+
body: JSON.stringify({ token: token.value, label: label.value || undefined }),
|
|
1048
|
+
})
|
|
1049
|
+
if (!response.ok) {
|
|
1050
|
+
status.value = 'Pairing failed'
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
location.replace('/')
|
|
1054
|
+
})
|
|
1055
|
+
})()
|
|
1056
|
+
`;
|
|
1057
|
+
const LOGIN_PAGE = `<!doctype html>
|
|
1058
|
+
<html lang="en">
|
|
1059
|
+
<meta charset="utf-8">
|
|
1060
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
1061
|
+
<title>Reconnect DSH mobile access</title>
|
|
1062
|
+
<main>
|
|
1063
|
+
<h1>Reconnect this device</h1>
|
|
1064
|
+
<p id="login-progress">Restoring the secure Session…</p>
|
|
1065
|
+
<section id="login-failed" hidden>
|
|
1066
|
+
<p>This device is no longer paired. Open pairing on the computer, then pair it again.</p>
|
|
1067
|
+
<a href="/mobile-access/pair">Open pairing</a>
|
|
1068
|
+
</section>
|
|
1069
|
+
</main>
|
|
1070
|
+
<script src="/mobile-access/login.js" defer><\/script>
|
|
1071
|
+
</html>
|
|
1072
|
+
`;
|
|
1073
|
+
const LOGIN_SCRIPT = `(() => {
|
|
1074
|
+
const candidate = new URL(location.href).searchParams.get('return')
|
|
1075
|
+
let returnPath = '/'
|
|
1076
|
+
if (candidate && candidate.startsWith('/')) {
|
|
1077
|
+
try {
|
|
1078
|
+
const resolved = new URL(candidate, location.origin)
|
|
1079
|
+
const pathname = decodeURIComponent(resolved.pathname)
|
|
1080
|
+
if (resolved.origin === location.origin && pathname !== '/mobile-access'
|
|
1081
|
+
&& !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
|
|
1082
|
+
returnPath = resolved.pathname + resolved.search + resolved.hash
|
|
1083
|
+
}
|
|
1084
|
+
} catch {
|
|
1085
|
+
// Malformed untrusted return targets keep the safe root default.
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
fetch('/mobile-access/auth/renew', {
|
|
1089
|
+
method: 'POST',
|
|
1090
|
+
credentials: 'same-origin',
|
|
1091
|
+
headers: { 'content-type': 'application/json' },
|
|
1092
|
+
body: '{}',
|
|
1093
|
+
}).then((response) => {
|
|
1094
|
+
if (response.ok) {
|
|
1095
|
+
location.replace(returnPath)
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
document.getElementById('login-progress').hidden = true
|
|
1099
|
+
document.getElementById('login-failed').hidden = false
|
|
1100
|
+
}).catch(() => {
|
|
1101
|
+
document.getElementById('login-progress').textContent = 'The computer is unavailable.'
|
|
1102
|
+
})
|
|
1103
|
+
})()
|
|
1104
|
+
`;
|
|
1105
|
+
var ByteLimitTransform = class extends Transform {
|
|
1106
|
+
maximum;
|
|
1107
|
+
total = 0;
|
|
1108
|
+
constructor(maximum) {
|
|
1109
|
+
super();
|
|
1110
|
+
this.maximum = maximum;
|
|
1111
|
+
}
|
|
1112
|
+
_transform(chunk, encoding, callback) {
|
|
1113
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
1114
|
+
this.total += buffer.length;
|
|
1115
|
+
if (this.total > this.maximum) {
|
|
1116
|
+
callback(new HttpError(413, "payload_too_large"));
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
callback(null, buffer);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
function stripIpv6Brackets(hostname) {
|
|
1123
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1124
|
+
}
|
|
1125
|
+
function parsePemCertificates(contents, source) {
|
|
1126
|
+
const text = contents.toString("utf8");
|
|
1127
|
+
const pattern = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/gu;
|
|
1128
|
+
const blocks = text.match(pattern) ?? [];
|
|
1129
|
+
if (blocks.length === 0 || text.replace(pattern, "").trim() !== "") throw new Error(`${source} must contain only PEM certificates`);
|
|
1130
|
+
return blocks.map((pem) => {
|
|
1131
|
+
let certificate;
|
|
1132
|
+
try {
|
|
1133
|
+
certificate = new X509Certificate(pem);
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
throw new Error(`${source} contains an invalid certificate`, { cause: error });
|
|
1136
|
+
}
|
|
1137
|
+
return Object.freeze({
|
|
1138
|
+
pem: `${pem}\n`,
|
|
1139
|
+
certificate
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function validateServerChain(chain) {
|
|
1144
|
+
const now = Date.now();
|
|
1145
|
+
for (const [index, entry] of chain.entries()) {
|
|
1146
|
+
if (Date.parse(entry.certificate.validFrom) > now || Date.parse(entry.certificate.validTo) <= now) throw new Error("TLS certificate chain contains a certificate that is not currently valid");
|
|
1147
|
+
if (index === 0) continue;
|
|
1148
|
+
if (entry.certificate.subject === entry.certificate.issuer && entry.certificate.verify(entry.certificate.publicKey)) throw new Error("TLS server certificate chain must not include a self-signed root");
|
|
1149
|
+
const child = chain[index - 1].certificate;
|
|
1150
|
+
if (!entry.certificate.ca || !child.checkIssued(entry.certificate) || !child.verify(entry.certificate.publicKey)) throw new Error("TLS server certificate chain is not an ordered leaf-to-intermediate chain");
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function tlsOptions(config) {
|
|
1154
|
+
if (config.tls.mode === "disabled") throw new Error("TLS options requested for a disabled listener");
|
|
1155
|
+
const [certFile, key, additionalChainFile] = await Promise.all([
|
|
1156
|
+
readFile(config.tls.certFile),
|
|
1157
|
+
readFile(config.tls.keyFile),
|
|
1158
|
+
config.tls.caFile === void 0 ? Promise.resolve(void 0) : readFile(config.tls.caFile)
|
|
1159
|
+
]);
|
|
1160
|
+
const chain = [...parsePemCertificates(certFile, "tls.certFile"), ...additionalChainFile === void 0 ? [] : parsePemCertificates(additionalChainFile, "tls.caFile")];
|
|
1161
|
+
validateServerChain(chain);
|
|
1162
|
+
const leaf = chain[0].certificate;
|
|
1163
|
+
for (const authority of config.authorities) {
|
|
1164
|
+
const hostname = stripIpv6Brackets(authority.hostname);
|
|
1165
|
+
if ((isIP(hostname) === 0 ? leaf.checkHost(hostname) : leaf.checkIP(hostname)) === void 0) throw new Error(`TLS certificate does not cover configured authority ${hostname}`);
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
cert: chain.map((entry) => entry.pem).join(""),
|
|
1169
|
+
key,
|
|
1170
|
+
requestCert: false,
|
|
1171
|
+
minVersion: "TLSv1.2",
|
|
1172
|
+
maxHeaderSize: MAX_HEADER_BYTES
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function websocketAccept(key) {
|
|
1176
|
+
return createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, "ascii").digest("base64");
|
|
1177
|
+
}
|
|
1178
|
+
function headerValue(headers, name) {
|
|
1179
|
+
const value = headers[name];
|
|
1180
|
+
return Array.isArray(value) ? void 0 : value;
|
|
1181
|
+
}
|
|
1182
|
+
function hasToken(header, token) {
|
|
1183
|
+
return header?.split(",").some((value) => value.trim().toLowerCase() === token) ?? false;
|
|
1184
|
+
}
|
|
1185
|
+
function rejectUpgrade(socket, status, code) {
|
|
1186
|
+
if (socket.destroyed) return;
|
|
1187
|
+
const body = `${JSON.stringify({ error: code })}\n`;
|
|
1188
|
+
socket.end([
|
|
1189
|
+
`HTTP/1.1 ${String(status)} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}`,
|
|
1190
|
+
"Connection: close",
|
|
1191
|
+
"Cache-Control: no-store",
|
|
1192
|
+
"Content-Type: application/json; charset=utf-8",
|
|
1193
|
+
"Referrer-Policy: no-referrer",
|
|
1194
|
+
"X-Content-Type-Options: nosniff",
|
|
1195
|
+
`Content-Length: ${String(Buffer.byteLength(body))}`,
|
|
1196
|
+
"",
|
|
1197
|
+
body
|
|
1198
|
+
].join("\r\n"));
|
|
1199
|
+
}
|
|
1200
|
+
function sanitizeRequestHeaders(request, upstream) {
|
|
1201
|
+
const headers = { host: upstream.host };
|
|
1202
|
+
if (request.headers.origin !== void 0) headers.origin = upstream.origin;
|
|
1203
|
+
if (request.headers["sec-fetch-site"] !== void 0) headers["sec-fetch-site"] = "same-origin";
|
|
1204
|
+
for (const name of [
|
|
1205
|
+
"accept",
|
|
1206
|
+
"accept-encoding",
|
|
1207
|
+
"accept-language",
|
|
1208
|
+
"content-encoding",
|
|
1209
|
+
"content-length",
|
|
1210
|
+
"content-type",
|
|
1211
|
+
"if-match",
|
|
1212
|
+
"if-modified-since",
|
|
1213
|
+
"if-none-match",
|
|
1214
|
+
"if-unmodified-since",
|
|
1215
|
+
"range",
|
|
1216
|
+
"user-agent"
|
|
1217
|
+
]) {
|
|
1218
|
+
const value = request.headers[name];
|
|
1219
|
+
if (value !== void 0) headers[name] = value;
|
|
1220
|
+
}
|
|
1221
|
+
return headers;
|
|
1222
|
+
}
|
|
1223
|
+
const BLOCKED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
1224
|
+
"alt-svc",
|
|
1225
|
+
"cache-control",
|
|
1226
|
+
"connection",
|
|
1227
|
+
"content-security-policy",
|
|
1228
|
+
"content-security-policy-report-only",
|
|
1229
|
+
"cross-origin-embedder-policy",
|
|
1230
|
+
"cross-origin-opener-policy",
|
|
1231
|
+
"cross-origin-resource-policy",
|
|
1232
|
+
"expires",
|
|
1233
|
+
"keep-alive",
|
|
1234
|
+
"nel",
|
|
1235
|
+
"permissions-policy",
|
|
1236
|
+
"pragma",
|
|
1237
|
+
"proxy-authenticate",
|
|
1238
|
+
"referrer-policy",
|
|
1239
|
+
"report-to",
|
|
1240
|
+
"reporting-endpoints",
|
|
1241
|
+
"server",
|
|
1242
|
+
"set-cookie",
|
|
1243
|
+
"strict-transport-security",
|
|
1244
|
+
"trailer",
|
|
1245
|
+
"transfer-encoding",
|
|
1246
|
+
"upgrade",
|
|
1247
|
+
"via",
|
|
1248
|
+
"x-content-type-options",
|
|
1249
|
+
"x-frame-options",
|
|
1250
|
+
"x-powered-by"
|
|
1251
|
+
]);
|
|
1252
|
+
function sanitizeResponseHeaders(headers, upstream) {
|
|
1253
|
+
const clean = {};
|
|
1254
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1255
|
+
const lower = name.toLowerCase();
|
|
1256
|
+
if (value === void 0 || BLOCKED_RESPONSE_HEADERS.has(lower) || lower.startsWith("access-control-")) continue;
|
|
1257
|
+
if (lower === "location" && typeof value === "string") {
|
|
1258
|
+
try {
|
|
1259
|
+
const location = new URL(value, upstream);
|
|
1260
|
+
clean.location = location.origin === upstream.origin ? `${location.pathname}${location.search}${location.hash}` : value;
|
|
1261
|
+
} catch {
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
clean[lower] = value;
|
|
1267
|
+
}
|
|
1268
|
+
return clean;
|
|
1269
|
+
}
|
|
1270
|
+
function requestCookies(request) {
|
|
1271
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
1272
|
+
if (cookies === void 0) throw new HttpError(401, "authentication_failed");
|
|
1273
|
+
return cookies;
|
|
1274
|
+
}
|
|
1275
|
+
function mapError(error) {
|
|
1276
|
+
if (error instanceof HttpError) return error;
|
|
1277
|
+
if (error instanceof AccessError) return new HttpError(error.status, error.code);
|
|
1278
|
+
return new HttpError(500, "internal_error");
|
|
1279
|
+
}
|
|
1280
|
+
/** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
|
|
1281
|
+
var MobileAccessGateway = class {
|
|
1282
|
+
config;
|
|
1283
|
+
access;
|
|
1284
|
+
tlsEnabled;
|
|
1285
|
+
policy;
|
|
1286
|
+
server;
|
|
1287
|
+
listenerPort;
|
|
1288
|
+
connectedSockets = /* @__PURE__ */ new Set();
|
|
1289
|
+
activeRequests = /* @__PURE__ */ new Map();
|
|
1290
|
+
activeWebSockets = /* @__PURE__ */ new Map();
|
|
1291
|
+
nextOperationId = 1;
|
|
1292
|
+
closing = false;
|
|
1293
|
+
started = false;
|
|
1294
|
+
closeTask;
|
|
1295
|
+
removeSessionListener;
|
|
1296
|
+
renewLimiter;
|
|
1297
|
+
constructor(config, store) {
|
|
1298
|
+
this.config = config;
|
|
1299
|
+
this.tlsEnabled = config.tls.mode === "provided";
|
|
1300
|
+
this.access = new AccessController(store, {
|
|
1301
|
+
pairingTtlMs: config.pairingTtlMs,
|
|
1302
|
+
deviceTtlMs: config.deviceTtlMs,
|
|
1303
|
+
sessionTtlMs: config.sessionTtlMs,
|
|
1304
|
+
maxDevices: config.maxDevices,
|
|
1305
|
+
maxSessions: config.maxSessions,
|
|
1306
|
+
rateLimitWindowMs: config.rateLimitWindowMs,
|
|
1307
|
+
maxPairingAttempts: config.maxPairingAttempts,
|
|
1308
|
+
maxRateLimitKeys: config.maxRateLimitKeys
|
|
1309
|
+
});
|
|
1310
|
+
this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
1311
|
+
this.removeSessionListener = this.access.onSessionEnded((authorization) => {
|
|
1312
|
+
this.abortSessionResources(authorization.sessionKey);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
/** Initialize durable state, validate TLS, and bind the externally reachable listener. */
|
|
1316
|
+
async start() {
|
|
1317
|
+
if (this.started || this.server !== void 0) throw new Error("mobile-access gateway cannot be started twice");
|
|
1318
|
+
this.started = true;
|
|
1319
|
+
await this.access.initialize();
|
|
1320
|
+
try {
|
|
1321
|
+
const handler = (request, response) => {
|
|
1322
|
+
this.handleExternalRequest(request, response).catch((error) => {
|
|
1323
|
+
const mapped = mapError(error);
|
|
1324
|
+
if (response.headersSent) response.destroy();
|
|
1325
|
+
else sendFailure(response, mapped.status, mapped.code, this.tlsEnabled);
|
|
1326
|
+
});
|
|
1327
|
+
};
|
|
1328
|
+
const server = this.tlsEnabled ? createServer$1(await tlsOptions(this.config), handler) : createServer({ maxHeaderSize: MAX_HEADER_BYTES }, handler);
|
|
1329
|
+
this.server = server;
|
|
1330
|
+
server.maxHeadersCount = 64;
|
|
1331
|
+
server.maxConnections = this.config.maxConnections;
|
|
1332
|
+
server.headersTimeout = 1e4;
|
|
1333
|
+
server.requestTimeout = this.config.upstreamTimeoutMs;
|
|
1334
|
+
server.keepAliveTimeout = 5e3;
|
|
1335
|
+
server.on("connection", (socket) => {
|
|
1336
|
+
if (this.connectedSockets.size >= this.config.maxConnections) {
|
|
1337
|
+
socket.destroy();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
this.connectedSockets.add(socket);
|
|
1341
|
+
socket.once("close", () => {
|
|
1342
|
+
this.connectedSockets.delete(socket);
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
server.on("connect", (_request, socket) => {
|
|
1346
|
+
socket.destroy();
|
|
1347
|
+
});
|
|
1348
|
+
server.on("upgrade", (request, socket, head) => {
|
|
1349
|
+
this.handleUpgrade(request, socket, head).catch((error) => {
|
|
1350
|
+
const mapped = mapError(error);
|
|
1351
|
+
rejectUpgrade(socket, mapped.status, mapped.code);
|
|
1352
|
+
});
|
|
1353
|
+
});
|
|
1354
|
+
server.on("clientError", (_error, socket) => {
|
|
1355
|
+
rejectUpgrade(socket, 400, "bad_request");
|
|
1356
|
+
});
|
|
1357
|
+
await new Promise((resolve, reject) => {
|
|
1358
|
+
const failed = (error) => {
|
|
1359
|
+
reject(error);
|
|
1360
|
+
};
|
|
1361
|
+
server.once("error", failed);
|
|
1362
|
+
server.listen(this.config.listenPort, this.config.listenHost, () => {
|
|
1363
|
+
server.off("error", failed);
|
|
1364
|
+
resolve();
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
const address = server.address();
|
|
1368
|
+
if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
|
|
1369
|
+
this.listenerPort = address.port;
|
|
1370
|
+
this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
await this.closeFailedStart();
|
|
1373
|
+
throw error;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
async closeFailedStart() {
|
|
1377
|
+
for (const socket of this.connectedSockets) socket.destroy();
|
|
1378
|
+
const server = this.server;
|
|
1379
|
+
this.server = void 0;
|
|
1380
|
+
if (server?.listening === true) await new Promise((resolve) => {
|
|
1381
|
+
server.close(() => resolve());
|
|
1382
|
+
});
|
|
1383
|
+
await this.access.close();
|
|
1384
|
+
}
|
|
1385
|
+
/** Actual bound address, available after start and safe for loopback status output. */
|
|
1386
|
+
address() {
|
|
1387
|
+
if (this.listenerPort === void 0 || this.policy === void 0) throw new Error("gateway is not listening");
|
|
1388
|
+
const origin = this.policy.origins.values().next().value;
|
|
1389
|
+
if (origin === void 0) throw new Error("gateway has no public authority");
|
|
1390
|
+
return Object.freeze({
|
|
1391
|
+
host: this.config.listenHost,
|
|
1392
|
+
port: this.listenerPort,
|
|
1393
|
+
origin
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
requirePolicy() {
|
|
1397
|
+
if (this.policy === void 0 || this.closing) throw new HttpError(503, "unavailable");
|
|
1398
|
+
return this.policy;
|
|
1399
|
+
}
|
|
1400
|
+
authorize(request) {
|
|
1401
|
+
const sessionToken = requestCookies(request).get(SESSION_COOKIE);
|
|
1402
|
+
if (sessionToken === void 0) throw new HttpError(401, "authentication_failed");
|
|
1403
|
+
return this.access.authorizeSession(sessionToken);
|
|
1404
|
+
}
|
|
1405
|
+
requireCsrf(request, authorization) {
|
|
1406
|
+
const value = headerValue(request.headers, CSRF_HEADER);
|
|
1407
|
+
this.access.assertCsrf(authorization, value);
|
|
1408
|
+
}
|
|
1409
|
+
setSessionCookies(response, result, now) {
|
|
1410
|
+
const maxAge = (result.sessionExpiresAt - now) / 1e3;
|
|
1411
|
+
response.setHeader("Set-Cookie", [cookie(SESSION_COOKIE, result.sessionToken, {
|
|
1412
|
+
tls: this.tlsEnabled,
|
|
1413
|
+
httpOnly: true,
|
|
1414
|
+
path: "/",
|
|
1415
|
+
maxAgeSeconds: maxAge
|
|
1416
|
+
}), cookie(CSRF_COOKIE, result.csrfToken, {
|
|
1417
|
+
tls: this.tlsEnabled,
|
|
1418
|
+
httpOnly: false,
|
|
1419
|
+
path: "/",
|
|
1420
|
+
maxAgeSeconds: maxAge
|
|
1421
|
+
})]);
|
|
1422
|
+
}
|
|
1423
|
+
async handlePair(request, response) {
|
|
1424
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1425
|
+
if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
|
|
1426
|
+
const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
|
|
1427
|
+
const now = Date.now();
|
|
1428
|
+
this.setSessionCookies(response, result, now);
|
|
1429
|
+
const sessionCookies = response.getHeader("Set-Cookie");
|
|
1430
|
+
response.setHeader("Set-Cookie", [...sessionCookies, cookie(DEVICE_COOKIE, result.deviceToken, {
|
|
1431
|
+
tls: this.tlsEnabled,
|
|
1432
|
+
httpOnly: true,
|
|
1433
|
+
path: "/mobile-access/auth/renew",
|
|
1434
|
+
maxAgeSeconds: (result.deviceExpiresAt - now) / 1e3
|
|
1435
|
+
})]);
|
|
1436
|
+
sendJson(response, 201, {
|
|
1437
|
+
paired: true,
|
|
1438
|
+
deviceId: result.deviceId,
|
|
1439
|
+
csrfToken: result.csrfToken,
|
|
1440
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1441
|
+
}, this.tlsEnabled);
|
|
1442
|
+
}
|
|
1443
|
+
async handleRenew(request, response) {
|
|
1444
|
+
if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
1445
|
+
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1446
|
+
const deviceToken = requestCookies(request).get(DEVICE_COOKIE);
|
|
1447
|
+
if (deviceToken === void 0) throw new HttpError(401, "authentication_failed");
|
|
1448
|
+
let result;
|
|
1449
|
+
try {
|
|
1450
|
+
result = await this.access.renew(deviceToken);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (error instanceof AccessError && error.status === 401) response.setHeader("Set-Cookie", cookie(DEVICE_COOKIE, "", {
|
|
1453
|
+
tls: this.tlsEnabled,
|
|
1454
|
+
httpOnly: true,
|
|
1455
|
+
path: "/mobile-access/auth/renew",
|
|
1456
|
+
maxAgeSeconds: 0
|
|
1457
|
+
}));
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
this.setSessionCookies(response, result, Date.now());
|
|
1461
|
+
sendJson(response, 200, {
|
|
1462
|
+
renewed: true,
|
|
1463
|
+
deviceId: result.deviceId,
|
|
1464
|
+
csrfToken: result.csrfToken,
|
|
1465
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1466
|
+
}, this.tlsEnabled);
|
|
1467
|
+
}
|
|
1468
|
+
async handleLogout(request, response) {
|
|
1469
|
+
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1470
|
+
const authorization = this.authorize(request);
|
|
1471
|
+
this.requireCsrf(request, authorization);
|
|
1472
|
+
this.access.logout(authorization);
|
|
1473
|
+
response.setHeader("Set-Cookie", [cookie(SESSION_COOKIE, "", {
|
|
1474
|
+
tls: this.tlsEnabled,
|
|
1475
|
+
httpOnly: true,
|
|
1476
|
+
path: "/",
|
|
1477
|
+
maxAgeSeconds: 0
|
|
1478
|
+
}), cookie(CSRF_COOKIE, "", {
|
|
1479
|
+
tls: this.tlsEnabled,
|
|
1480
|
+
httpOnly: false,
|
|
1481
|
+
path: "/",
|
|
1482
|
+
maxAgeSeconds: 0
|
|
1483
|
+
})]);
|
|
1484
|
+
sendJson(response, 200, { loggedOut: true }, this.tlsEnabled);
|
|
1485
|
+
}
|
|
1486
|
+
async handleExternalRequest(request, response) {
|
|
1487
|
+
const target = parseRequestTarget(request.url);
|
|
1488
|
+
assertExternalTrust(request, this.requirePolicy(), request.method !== "GET" && request.method !== "HEAD");
|
|
1489
|
+
if (target.decodedPathname === "/api/mobile-access" || target.decodedPathname.startsWith(`/api/mobile-access/`)) throw new HttpError(404, "not_found");
|
|
1490
|
+
if (request.method === "TRACE" || request.method === "CONNECT") throw new HttpError(405, "method_not_allowed");
|
|
1491
|
+
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/health`) {
|
|
1492
|
+
sendJson(response, 200, { ok: true }, this.tlsEnabled);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
1496
|
+
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
1497
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1498
|
+
const body = target.decodedPathname.endsWith(".js") ? PAIR_SCRIPT : PAIR_PAGE;
|
|
1499
|
+
response.writeHead(200, {
|
|
1500
|
+
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
1501
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1502
|
+
});
|
|
1503
|
+
response.end(body);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
|
|
1507
|
+
if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
|
|
1508
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1509
|
+
const body = target.decodedPathname.endsWith(".js") ? LOGIN_SCRIPT : LOGIN_PAGE;
|
|
1510
|
+
response.writeHead(200, {
|
|
1511
|
+
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
1512
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1513
|
+
});
|
|
1514
|
+
response.end(body);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/pair`) {
|
|
1518
|
+
await this.handlePair(request, response);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/renew`) {
|
|
1522
|
+
await this.handleRenew(request, response);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
|
|
1526
|
+
await this.handleLogout(request, response);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const customCss = target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/custom.css`;
|
|
1530
|
+
if (!customCss && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
1531
|
+
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
1532
|
+
if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/")) throw new HttpError(405, "method_not_allowed");
|
|
1533
|
+
let authorization;
|
|
1534
|
+
try {
|
|
1535
|
+
authorization = this.authorize(request);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
const mapped = mapError(error);
|
|
1538
|
+
const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
|
|
1539
|
+
const topLevel = request.method === "GET" && acceptsHtml && (request.headers["sec-fetch-dest"] === void 0 || request.headers["sec-fetch-dest"] === "document") && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/");
|
|
1540
|
+
if (mapped.status === 401 && topLevel) {
|
|
1541
|
+
const returnPath = target.raw.length <= 2048 ? target.raw : "/";
|
|
1542
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1543
|
+
response.writeHead(302, {
|
|
1544
|
+
Location: `${AUTH_PREFIX}/login?return=${encodeURIComponent(returnPath)}`,
|
|
1545
|
+
"Content-Length": 0
|
|
1546
|
+
});
|
|
1547
|
+
response.end();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
throw error;
|
|
1551
|
+
}
|
|
1552
|
+
if (customCss) {
|
|
1553
|
+
let body;
|
|
1554
|
+
try {
|
|
1555
|
+
body = await readFile(this.config.customCssFile);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
if (error.code !== "ENOENT") throw error;
|
|
1558
|
+
body = Buffer.from("/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n");
|
|
1559
|
+
}
|
|
1560
|
+
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
1561
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1562
|
+
response.writeHead(200, {
|
|
1563
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
1564
|
+
"Content-Length": body.byteLength
|
|
1565
|
+
});
|
|
1566
|
+
response.end(body);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
await this.proxyHttp(request, response, authorization);
|
|
1570
|
+
}
|
|
1571
|
+
allocateRequest(authorization, response, upstream) {
|
|
1572
|
+
if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
|
|
1573
|
+
const id = this.nextOperationId++;
|
|
1574
|
+
const abort = () => {
|
|
1575
|
+
upstream.request?.destroy();
|
|
1576
|
+
if (!response.destroyed) response.destroy();
|
|
1577
|
+
};
|
|
1578
|
+
const timer = setTimeout(abort, Math.max(1, authorization.expiresAt - Date.now()));
|
|
1579
|
+
timer.unref();
|
|
1580
|
+
this.activeRequests.set(id, Object.freeze({
|
|
1581
|
+
...authorization,
|
|
1582
|
+
abort,
|
|
1583
|
+
timer
|
|
1584
|
+
}));
|
|
1585
|
+
return {
|
|
1586
|
+
id,
|
|
1587
|
+
release: () => {
|
|
1588
|
+
const entry = this.activeRequests.get(id);
|
|
1589
|
+
if (entry !== void 0) clearTimeout(entry.timer);
|
|
1590
|
+
this.activeRequests.delete(id);
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
async proxyHttp(request$1, response, authorization) {
|
|
1595
|
+
const declared = request$1.headers["content-length"];
|
|
1596
|
+
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
|
|
1597
|
+
const holder = {};
|
|
1598
|
+
const operation = this.allocateRequest(authorization, response, holder);
|
|
1599
|
+
let bodyDone;
|
|
1600
|
+
try {
|
|
1601
|
+
const proxied = await new Promise((resolve, reject) => {
|
|
1602
|
+
const upstreamRequest = request({
|
|
1603
|
+
protocol: "http:",
|
|
1604
|
+
hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
1605
|
+
port: Number(this.config.upstreamOrigin.port),
|
|
1606
|
+
method: request$1.method,
|
|
1607
|
+
path: request$1.url,
|
|
1608
|
+
headers: sanitizeRequestHeaders(request$1, this.config.upstreamOrigin),
|
|
1609
|
+
agent: false
|
|
1610
|
+
});
|
|
1611
|
+
holder.request = upstreamRequest;
|
|
1612
|
+
upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
|
|
1613
|
+
upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
|
|
1614
|
+
});
|
|
1615
|
+
upstreamRequest.once("response", resolve);
|
|
1616
|
+
upstreamRequest.once("error", reject);
|
|
1617
|
+
bodyDone = pipeline(request$1, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
|
|
1618
|
+
bodyDone.catch(reject);
|
|
1619
|
+
});
|
|
1620
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1621
|
+
response.writeHead(proxied.statusCode ?? 502, sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin));
|
|
1622
|
+
await Promise.all([bodyDone, pipeline(proxied, response)]);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
holder.request?.destroy();
|
|
1625
|
+
await bodyDone?.catch(() => void 0);
|
|
1626
|
+
if (error instanceof HttpError) throw error;
|
|
1627
|
+
if (response.headersSent) response.destroy();
|
|
1628
|
+
else throw new HttpError(502, "upstream_unavailable");
|
|
1629
|
+
} finally {
|
|
1630
|
+
operation.release();
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
abortSessionResources(sessionKey) {
|
|
1634
|
+
for (const request of this.activeRequests.values()) if (request.sessionKey === sessionKey) request.abort();
|
|
1635
|
+
for (const socket of this.activeWebSockets.values()) if (socket.sessionKey === sessionKey) {
|
|
1636
|
+
socket.client.destroy();
|
|
1637
|
+
socket.upstream.destroy();
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
async readUpgradeResponse(upstream, expectedAccept) {
|
|
1641
|
+
return new Promise((resolve, reject) => {
|
|
1642
|
+
let buffer = Buffer.alloc(0);
|
|
1643
|
+
const failed = (error) => {
|
|
1644
|
+
cleanup();
|
|
1645
|
+
reject(error);
|
|
1646
|
+
};
|
|
1647
|
+
const closed = () => {
|
|
1648
|
+
cleanup();
|
|
1649
|
+
reject(/* @__PURE__ */ new Error("upstream closed during WebSocket handshake"));
|
|
1650
|
+
};
|
|
1651
|
+
const data = (chunk) => {
|
|
1652
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
1653
|
+
if (buffer.length > MAX_HEADER_BYTES) {
|
|
1654
|
+
failed(/* @__PURE__ */ new Error("upstream WebSocket headers are too large"));
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const end = buffer.indexOf("\r\n\r\n");
|
|
1658
|
+
if (end < 0) return;
|
|
1659
|
+
cleanup();
|
|
1660
|
+
const lines = buffer.subarray(0, end).toString("latin1").split("\r\n");
|
|
1661
|
+
if (lines.shift() !== "HTTP/1.1 101 Switching Protocols") {
|
|
1662
|
+
reject(/* @__PURE__ */ new Error("upstream refused WebSocket upgrade"));
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const selected = /* @__PURE__ */ new Map();
|
|
1666
|
+
for (const line of lines) {
|
|
1667
|
+
const colon = line.indexOf(":");
|
|
1668
|
+
if (colon <= 0) {
|
|
1669
|
+
reject(/* @__PURE__ */ new Error("upstream returned malformed WebSocket headers"));
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
1673
|
+
const value = line.slice(colon + 1).trim();
|
|
1674
|
+
if (selected.has(name)) {
|
|
1675
|
+
reject(/* @__PURE__ */ new Error("upstream returned duplicate WebSocket headers"));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
selected.set(name, value);
|
|
1679
|
+
}
|
|
1680
|
+
if (selected.get("upgrade")?.toLowerCase() !== "websocket" || !hasToken(selected.get("connection"), "upgrade") || selected.get("sec-websocket-accept") !== expectedAccept) {
|
|
1681
|
+
reject(/* @__PURE__ */ new Error("upstream returned an invalid WebSocket handshake"));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const output = [
|
|
1685
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
1686
|
+
"Upgrade: websocket",
|
|
1687
|
+
"Connection: Upgrade",
|
|
1688
|
+
`Sec-WebSocket-Accept: ${expectedAccept}`
|
|
1689
|
+
];
|
|
1690
|
+
const protocol = selected.get("sec-websocket-protocol");
|
|
1691
|
+
const extensions = selected.get("sec-websocket-extensions");
|
|
1692
|
+
if (protocol !== void 0) output.push(`Sec-WebSocket-Protocol: ${protocol}`);
|
|
1693
|
+
if (extensions !== void 0) output.push(`Sec-WebSocket-Extensions: ${extensions}`);
|
|
1694
|
+
output.push("Referrer-Policy: no-referrer", "X-Content-Type-Options: nosniff", "", "");
|
|
1695
|
+
resolve({
|
|
1696
|
+
header: output.join("\r\n"),
|
|
1697
|
+
remainder: buffer.subarray(end + 4)
|
|
1698
|
+
});
|
|
1699
|
+
};
|
|
1700
|
+
const cleanup = () => {
|
|
1701
|
+
upstream.off("data", data);
|
|
1702
|
+
upstream.off("error", failed);
|
|
1703
|
+
upstream.off("close", closed);
|
|
1704
|
+
};
|
|
1705
|
+
upstream.on("data", data);
|
|
1706
|
+
upstream.once("error", failed);
|
|
1707
|
+
upstream.once("close", closed);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
async handleUpgrade(request, client, head) {
|
|
1711
|
+
const target = parseRequestTarget(request.url);
|
|
1712
|
+
assertExternalTrust(request, this.requirePolicy(), true);
|
|
1713
|
+
if (target.search !== "" || !WS_PATHS.has(target.decodedPathname)) throw new HttpError(404, "not_found");
|
|
1714
|
+
if (request.method !== "GET" || headerValue(request.headers, "upgrade")?.toLowerCase() !== "websocket" || !hasToken(headerValue(request.headers, "connection"), "upgrade")) throw new HttpError(400, "bad_request");
|
|
1715
|
+
const key = headerValue(request.headers, "sec-websocket-key");
|
|
1716
|
+
if (key === void 0 || headerValue(request.headers, "sec-websocket-version") !== "13") throw new HttpError(400, "bad_request");
|
|
1717
|
+
let decodedKey;
|
|
1718
|
+
try {
|
|
1719
|
+
decodedKey = Buffer.from(key, "base64");
|
|
1720
|
+
} catch {
|
|
1721
|
+
throw new HttpError(400, "bad_request");
|
|
1722
|
+
}
|
|
1723
|
+
if (decodedKey.length !== 16 || decodedKey.toString("base64") !== key) throw new HttpError(400, "bad_request");
|
|
1724
|
+
const authorization = this.authorize(request);
|
|
1725
|
+
if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
|
|
1726
|
+
const upstream = connect({
|
|
1727
|
+
host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
|
|
1728
|
+
port: Number(this.config.upstreamOrigin.port)
|
|
1729
|
+
});
|
|
1730
|
+
client.pause();
|
|
1731
|
+
const id = this.nextOperationId++;
|
|
1732
|
+
const closeBoth = () => {
|
|
1733
|
+
client.destroy();
|
|
1734
|
+
upstream.destroy();
|
|
1735
|
+
};
|
|
1736
|
+
const timer = setTimeout(closeBoth, Math.max(1, authorization.expiresAt - Date.now()));
|
|
1737
|
+
timer.unref();
|
|
1738
|
+
const record = Object.freeze({
|
|
1739
|
+
...authorization,
|
|
1740
|
+
client,
|
|
1741
|
+
upstream,
|
|
1742
|
+
timer
|
|
1743
|
+
});
|
|
1744
|
+
this.activeWebSockets.set(id, record);
|
|
1745
|
+
const cleanup = () => {
|
|
1746
|
+
const active = this.activeWebSockets.get(id);
|
|
1747
|
+
if (active !== void 0) clearTimeout(active.timer);
|
|
1748
|
+
this.activeWebSockets.delete(id);
|
|
1749
|
+
};
|
|
1750
|
+
client.once("close", () => {
|
|
1751
|
+
upstream.destroy();
|
|
1752
|
+
cleanup();
|
|
1753
|
+
});
|
|
1754
|
+
upstream.once("close", () => {
|
|
1755
|
+
client.destroy();
|
|
1756
|
+
cleanup();
|
|
1757
|
+
});
|
|
1758
|
+
upstream.setTimeout(this.config.upstreamTimeoutMs, closeBoth);
|
|
1759
|
+
try {
|
|
1760
|
+
await new Promise((resolve, reject) => {
|
|
1761
|
+
upstream.once("connect", resolve);
|
|
1762
|
+
upstream.once("error", reject);
|
|
1763
|
+
});
|
|
1764
|
+
const requestLines = [
|
|
1765
|
+
`GET ${target.raw} HTTP/1.1`,
|
|
1766
|
+
`Host: ${this.config.upstreamOrigin.host}`,
|
|
1767
|
+
"Upgrade: websocket",
|
|
1768
|
+
"Connection: Upgrade",
|
|
1769
|
+
`Origin: ${this.config.upstreamOrigin.origin}`,
|
|
1770
|
+
"Sec-Fetch-Site: same-origin",
|
|
1771
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
1772
|
+
"Sec-WebSocket-Version: 13"
|
|
1773
|
+
];
|
|
1774
|
+
const protocol = headerValue(request.headers, "sec-websocket-protocol");
|
|
1775
|
+
const extensions = headerValue(request.headers, "sec-websocket-extensions");
|
|
1776
|
+
if (protocol !== void 0) requestLines.push(`Sec-WebSocket-Protocol: ${protocol}`);
|
|
1777
|
+
if (extensions !== void 0) requestLines.push(`Sec-WebSocket-Extensions: ${extensions}`);
|
|
1778
|
+
requestLines.push("", "");
|
|
1779
|
+
upstream.write(requestLines.join("\r\n"));
|
|
1780
|
+
if (head.length > 0) upstream.write(head);
|
|
1781
|
+
const handshake = await this.readUpgradeResponse(upstream, websocketAccept(key));
|
|
1782
|
+
upstream.setTimeout(0);
|
|
1783
|
+
client.write(handshake.header);
|
|
1784
|
+
if (handshake.remainder.length > 0) client.write(handshake.remainder);
|
|
1785
|
+
upstream.pipe(client);
|
|
1786
|
+
client.pipe(upstream);
|
|
1787
|
+
client.resume();
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
closeBoth();
|
|
1790
|
+
if (error instanceof HttpError) throw error;
|
|
1791
|
+
throw new HttpError(502, "upstream_unavailable");
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/** Loopback-only DSH WebServer route for opening pairing and managing devices. */
|
|
1795
|
+
localAdminRoute() {
|
|
1796
|
+
return {
|
|
1797
|
+
kind: "prefix",
|
|
1798
|
+
path: LOCAL_ADMIN_PREFIX,
|
|
1799
|
+
handler: async (request, response) => {
|
|
1800
|
+
try {
|
|
1801
|
+
const target = parseRequestTarget(request.url);
|
|
1802
|
+
assertLocalAdminTrust(request, request.method === "POST");
|
|
1803
|
+
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
1804
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/status`) {
|
|
1805
|
+
sendJson(response, 200, {
|
|
1806
|
+
gateway: this.address(),
|
|
1807
|
+
pairing: this.access.pairingStatus(),
|
|
1808
|
+
deviceCount: this.access.listDevices().length,
|
|
1809
|
+
resources: {
|
|
1810
|
+
connections: this.connectedSockets.size,
|
|
1811
|
+
activeRequests: this.activeRequests.size,
|
|
1812
|
+
webSockets: this.activeWebSockets.size
|
|
1813
|
+
}
|
|
1814
|
+
}, false);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/devices`) {
|
|
1818
|
+
sendJson(response, 200, { devices: this.access.listDevices() }, false);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/pairing/open`) {
|
|
1822
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1823
|
+
if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
|
|
1824
|
+
const opened = await this.access.openPairing(body.ttlMs);
|
|
1825
|
+
sendJson(response, 201, {
|
|
1826
|
+
...opened,
|
|
1827
|
+
pairUrl: `${this.address().origin}/mobile-access/pair#token=${opened.token}`
|
|
1828
|
+
}, false);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/devices/revoke`) {
|
|
1832
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1833
|
+
if (typeof body.deviceId !== "string" || !/^[a-f\d]{32}$/u.test(body.deviceId)) throw new HttpError(400, "bad_request");
|
|
1834
|
+
if (!await this.access.revokeDevice(body.deviceId)) throw new HttpError(404, "not_found");
|
|
1835
|
+
sendJson(response, 200, { revoked: true }, false);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/devices/reset`) {
|
|
1839
|
+
if ((await readJsonObject(request, MAX_CONTROL_BODY_BYTES)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
1840
|
+
await this.access.resetDevices();
|
|
1841
|
+
sendJson(response, 200, { reset: true }, false);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
throw new HttpError(404, "not_found");
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
const mapped = mapError(error);
|
|
1847
|
+
if (response.headersSent) response.destroy();
|
|
1848
|
+
else sendFailure(response, mapped.status, mapped.code, false);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
/** Close listeners and abort all accepted work before resolving teardown. */
|
|
1854
|
+
async close() {
|
|
1855
|
+
if (this.closeTask !== void 0) return this.closeTask;
|
|
1856
|
+
this.closeTask = this.performClose();
|
|
1857
|
+
return this.closeTask;
|
|
1858
|
+
}
|
|
1859
|
+
async performClose() {
|
|
1860
|
+
this.closing = true;
|
|
1861
|
+
this.removeSessionListener();
|
|
1862
|
+
const accessClose = this.access.close();
|
|
1863
|
+
for (const request of this.activeRequests.values()) request.abort();
|
|
1864
|
+
for (const websocket of this.activeWebSockets.values()) {
|
|
1865
|
+
websocket.client.destroy();
|
|
1866
|
+
websocket.upstream.destroy();
|
|
1867
|
+
}
|
|
1868
|
+
for (const socket of this.connectedSockets) socket.destroy();
|
|
1869
|
+
const server = this.server;
|
|
1870
|
+
this.server = void 0;
|
|
1871
|
+
if (server !== void 0 && server.listening) {
|
|
1872
|
+
server.closeAllConnections();
|
|
1873
|
+
await new Promise((resolve) => {
|
|
1874
|
+
server.close(() => resolve());
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
await accessClose;
|
|
1878
|
+
this.activeRequests.clear();
|
|
1879
|
+
this.activeWebSockets.clear();
|
|
1880
|
+
this.connectedSockets.clear();
|
|
1881
|
+
this.policy = void 0;
|
|
1882
|
+
this.listenerPort = void 0;
|
|
1883
|
+
}
|
|
1884
|
+
/** Safe metadata helper for direct loopback integrations. */
|
|
1885
|
+
devices() {
|
|
1886
|
+
return this.access.listDevices();
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
//#endregion
|
|
1890
|
+
//#region src/storage.ts
|
|
1891
|
+
function assertInteger(value, name) {
|
|
1892
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`device state ${name} must be a non-negative integer`);
|
|
1893
|
+
}
|
|
1894
|
+
function parseDevice(value) {
|
|
1895
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("device state contains an invalid device");
|
|
1896
|
+
const record = value;
|
|
1897
|
+
if (typeof record.id !== "string" || !/^[a-f\d]{32}$/u.test(record.id)) throw new Error("device state contains an invalid id");
|
|
1898
|
+
if (typeof record.label !== "string" || record.label.length < 1 || record.label.length > 64 || /[\u0000-\u001f\u007f]/u.test(record.label)) throw new Error("device state contains an invalid label");
|
|
1899
|
+
if (typeof record.tokenDigest !== "string" || !/^[a-f\d]{64}$/u.test(record.tokenDigest)) throw new Error("device state contains an invalid credential digest");
|
|
1900
|
+
assertInteger(record.createdAt, "createdAt");
|
|
1901
|
+
assertInteger(record.expiresAt, "expiresAt");
|
|
1902
|
+
assertInteger(record.lastSeenAt, "lastSeenAt");
|
|
1903
|
+
if (record.revokedAt !== void 0) assertInteger(record.revokedAt, "revokedAt");
|
|
1904
|
+
if (record.expiresAt <= record.createdAt || record.lastSeenAt < record.createdAt) throw new Error("device state contains inconsistent timestamps");
|
|
1905
|
+
return Object.freeze({
|
|
1906
|
+
id: record.id,
|
|
1907
|
+
label: record.label,
|
|
1908
|
+
tokenDigest: record.tokenDigest,
|
|
1909
|
+
createdAt: record.createdAt,
|
|
1910
|
+
expiresAt: record.expiresAt,
|
|
1911
|
+
lastSeenAt: record.lastSeenAt,
|
|
1912
|
+
...record.revokedAt === void 0 ? {} : { revokedAt: record.revokedAt }
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
/** Validate durable data before it can authorize a device. */
|
|
1916
|
+
function parseDeviceSnapshot(value, maximumDevices = 256) {
|
|
1917
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("device state must be an object");
|
|
1918
|
+
const snapshot = value;
|
|
1919
|
+
if (snapshot.version !== 1 || !Array.isArray(snapshot.devices) || snapshot.devices.length > maximumDevices) throw new Error("device state has an unsupported version or device count");
|
|
1920
|
+
const devices = snapshot.devices.map(parseDevice);
|
|
1921
|
+
if (new Set(devices.map((device) => device.id)).size !== devices.length || new Set(devices.map((device) => device.tokenDigest)).size !== devices.length) throw new Error("device state contains duplicate device identities");
|
|
1922
|
+
return Object.freeze({
|
|
1923
|
+
version: 1,
|
|
1924
|
+
devices: Object.freeze(devices)
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
/** Atomic JSON implementation with symlink refusal and owner-only file creation. */
|
|
1928
|
+
var JsonDeviceStore = class {
|
|
1929
|
+
file;
|
|
1930
|
+
maximumDevices;
|
|
1931
|
+
constructor(file, maximumDevices = 256) {
|
|
1932
|
+
this.file = file;
|
|
1933
|
+
this.maximumDevices = maximumDevices;
|
|
1934
|
+
}
|
|
1935
|
+
async load() {
|
|
1936
|
+
let stat;
|
|
1937
|
+
try {
|
|
1938
|
+
stat = await lstat(this.file);
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
if (error.code === "ENOENT") return Object.freeze({
|
|
1941
|
+
version: 1,
|
|
1942
|
+
devices: Object.freeze([])
|
|
1943
|
+
});
|
|
1944
|
+
throw error;
|
|
1945
|
+
}
|
|
1946
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) throw new Error("device state must be a regular file no larger than 1 MiB");
|
|
1947
|
+
let parsed;
|
|
1948
|
+
try {
|
|
1949
|
+
parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
throw new Error("device state is not valid JSON", { cause: error });
|
|
1952
|
+
}
|
|
1953
|
+
return parseDeviceSnapshot(parsed, this.maximumDevices);
|
|
1954
|
+
}
|
|
1955
|
+
async save(snapshot) {
|
|
1956
|
+
const validated = parseDeviceSnapshot(snapshot, this.maximumDevices);
|
|
1957
|
+
const directory = dirname(this.file);
|
|
1958
|
+
await mkdir(directory, {
|
|
1959
|
+
recursive: true,
|
|
1960
|
+
mode: 448
|
|
1961
|
+
});
|
|
1962
|
+
try {
|
|
1963
|
+
const current = await lstat(this.file);
|
|
1964
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("device state target must remain a regular file");
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
if (error.code !== "ENOENT") throw error;
|
|
1967
|
+
}
|
|
1968
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
1969
|
+
try {
|
|
1970
|
+
await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
|
|
1971
|
+
encoding: "utf8",
|
|
1972
|
+
flag: "wx",
|
|
1973
|
+
mode: 384
|
|
1974
|
+
});
|
|
1975
|
+
await rename(temporary, this.file);
|
|
1976
|
+
await chmod(this.file, 384);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
try {
|
|
1979
|
+
await rm(temporary, { force: true });
|
|
1980
|
+
} catch (cleanupError) {
|
|
1981
|
+
throw new AggregateError([error, cleanupError], "device state write and temporary cleanup both failed");
|
|
1982
|
+
}
|
|
1983
|
+
throw error;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
/** In-memory store useful for embedding and deterministic tests. */
|
|
1988
|
+
var MemoryDeviceStore = class {
|
|
1989
|
+
snapshot;
|
|
1990
|
+
constructor(initial = {
|
|
1991
|
+
version: 1,
|
|
1992
|
+
devices: []
|
|
1993
|
+
}) {
|
|
1994
|
+
this.snapshot = parseDeviceSnapshot(initial);
|
|
1995
|
+
}
|
|
1996
|
+
async load() {
|
|
1997
|
+
return structuredClone(this.snapshot);
|
|
1998
|
+
}
|
|
1999
|
+
async save(snapshot) {
|
|
2000
|
+
this.snapshot = structuredClone(parseDeviceSnapshot(snapshot));
|
|
2001
|
+
}
|
|
2002
|
+
/** Return a defensive copy for assertions or administrative export. */
|
|
2003
|
+
inspect() {
|
|
2004
|
+
return structuredClone(this.snapshot);
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
//#endregion
|
|
2008
|
+
//#region src/plugin.ts
|
|
2009
|
+
/** Stable Cordis plugin name. */
|
|
2010
|
+
const name = "dsh-mobile";
|
|
2011
|
+
/** The stock WebServer is the only DSH Host service this plugin requires. */
|
|
2012
|
+
const inject = ["webServer"];
|
|
2013
|
+
function mapAdminError(error) {
|
|
2014
|
+
return error instanceof HttpError ? error : new HttpError(500, "internal_error");
|
|
2015
|
+
}
|
|
2016
|
+
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
2017
|
+
"version",
|
|
2018
|
+
"publicOrigin",
|
|
2019
|
+
"listenHost",
|
|
2020
|
+
"listenPort",
|
|
2021
|
+
"upstreamOrigin",
|
|
2022
|
+
"publicAuthorities",
|
|
2023
|
+
"allowedCidrs",
|
|
2024
|
+
"tls"
|
|
2025
|
+
]);
|
|
2026
|
+
async function loadSetup(config) {
|
|
2027
|
+
if (config.setupFile === void 0) return config;
|
|
2028
|
+
if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
|
|
2029
|
+
let source;
|
|
2030
|
+
try {
|
|
2031
|
+
source = await readFile(resolve(config.setupFile), "utf8");
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
if (error.code === "ENOENT") return config;
|
|
2034
|
+
throw error;
|
|
2035
|
+
}
|
|
2036
|
+
let parsed;
|
|
2037
|
+
try {
|
|
2038
|
+
parsed = JSON.parse(source);
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
throw new Error("mobile setup file is not valid JSON", { cause: error });
|
|
2041
|
+
}
|
|
2042
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2043
|
+
const record = parsed;
|
|
2044
|
+
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2045
|
+
const { version: _version, ...setup } = record;
|
|
2046
|
+
const merged = { ...config };
|
|
2047
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2048
|
+
return {
|
|
2049
|
+
...merged,
|
|
2050
|
+
...setup
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/** Mount the resident control route and its optional authenticated LAN gateway. */
|
|
2054
|
+
async function apply(ctx, config) {
|
|
2055
|
+
const resolved = parseGatewayConfig(await loadSetup(config));
|
|
2056
|
+
let gateway;
|
|
2057
|
+
const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), async () => {
|
|
2058
|
+
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
|
|
2059
|
+
await candidate.start();
|
|
2060
|
+
gateway = candidate;
|
|
2061
|
+
return { close: async () => {
|
|
2062
|
+
if (gateway === candidate) gateway = void 0;
|
|
2063
|
+
await candidate.close();
|
|
2064
|
+
} };
|
|
2065
|
+
});
|
|
2066
|
+
const adminRoute = {
|
|
2067
|
+
kind: "prefix",
|
|
2068
|
+
path: LOCAL_ADMIN_PREFIX,
|
|
2069
|
+
handler: async (request, response) => {
|
|
2070
|
+
try {
|
|
2071
|
+
const target = parseRequestTarget(request.url);
|
|
2072
|
+
assertLocalAdminTrust(request, request.method === "POST");
|
|
2073
|
+
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
2074
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/control`) {
|
|
2075
|
+
sendJson(response, 200, {
|
|
2076
|
+
running: controller.isRunning(),
|
|
2077
|
+
origin: gateway?.address().origin
|
|
2078
|
+
}, false);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/control`) {
|
|
2082
|
+
const body = await readJsonObject(request, 4096);
|
|
2083
|
+
if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
|
|
2084
|
+
await controller.setRunning(body.running);
|
|
2085
|
+
sendJson(response, 200, {
|
|
2086
|
+
running: controller.isRunning(),
|
|
2087
|
+
origin: gateway?.address().origin
|
|
2088
|
+
}, false);
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const active = gateway;
|
|
2092
|
+
if (active === void 0) throw new HttpError(409, "gateway_stopped");
|
|
2093
|
+
await active.localAdminRoute().handler(request, response);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
const mapped = mapAdminError(error);
|
|
2096
|
+
if (response.headersSent) response.destroy();
|
|
2097
|
+
else sendFailure(response, mapped.status, mapped.code, false);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
await ctx.effect(async () => {
|
|
2102
|
+
const unregister = ctx.webServer.register(adminRoute);
|
|
2103
|
+
try {
|
|
2104
|
+
await controller.initialize();
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
unregister();
|
|
2107
|
+
throw error;
|
|
2108
|
+
}
|
|
2109
|
+
return async () => {
|
|
2110
|
+
unregister();
|
|
2111
|
+
await controller.close();
|
|
2112
|
+
};
|
|
2113
|
+
}, "dsh-mobile: local control and authenticated LAN gateway");
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, addressAllowed, apply, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority };
|
|
2117
|
+
|
|
2118
|
+
//# sourceMappingURL=index.mjs.map
|