dsh-mobile 0.3.16 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.en.md +59 -9
- package/README.md +69 -18
- package/lib/cli.js +96 -81
- package/lib/cli.js.map +1 -1
- package/lib/client.js +736 -89
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +86 -2
- package/lib/index.mjs +644 -114
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -142,28 +142,28 @@ var AccessController = class {
|
|
|
142
142
|
devices: Object.freeze([...devices])
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
emitSessionEnded(session) {
|
|
145
|
+
emitSessionEnded(session, reason) {
|
|
146
146
|
const authorization = Object.freeze({
|
|
147
147
|
sessionKey: session.key,
|
|
148
148
|
deviceId: session.deviceId,
|
|
149
149
|
expiresAt: session.expiresAt
|
|
150
150
|
});
|
|
151
|
-
for (const listener of this.sessionEndedListeners) listener(authorization);
|
|
151
|
+
for (const listener of this.sessionEndedListeners) listener(authorization, reason);
|
|
152
152
|
}
|
|
153
|
-
removeSession(key) {
|
|
153
|
+
removeSession(key, reason) {
|
|
154
154
|
const session = this.sessions.get(key);
|
|
155
155
|
if (session === void 0) return;
|
|
156
156
|
this.sessions.delete(key);
|
|
157
|
-
this.emitSessionEnded(session);
|
|
157
|
+
this.emitSessionEnded(session, reason);
|
|
158
158
|
}
|
|
159
159
|
pruneSessions(now) {
|
|
160
|
-
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key);
|
|
160
|
+
for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key, "expired");
|
|
161
161
|
}
|
|
162
162
|
createSession(deviceId, now, deviceExpiresAt) {
|
|
163
163
|
this.pruneSessions(now);
|
|
164
164
|
if (this.sessions.size >= this.options.maxSessions) {
|
|
165
165
|
const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
|
|
166
|
-
if (oldest !== void 0) this.removeSession(oldest.key);
|
|
166
|
+
if (oldest !== void 0) this.removeSession(oldest.key, "evicted");
|
|
167
167
|
}
|
|
168
168
|
const sessionToken = opaqueToken();
|
|
169
169
|
const csrfToken = opaqueToken();
|
|
@@ -244,7 +244,9 @@ var AccessController = class {
|
|
|
244
244
|
const tokenDigest = digest(deviceToken);
|
|
245
245
|
const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
|
|
246
246
|
const device = this.devices[index];
|
|
247
|
-
if (device === void 0
|
|
247
|
+
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
248
|
+
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
249
|
+
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
248
250
|
const updated = Object.freeze({
|
|
249
251
|
...device,
|
|
250
252
|
lastSeenAt: now
|
|
@@ -256,6 +258,32 @@ var AccessController = class {
|
|
|
256
258
|
return this.createSession(device.id, now, device.expiresAt);
|
|
257
259
|
});
|
|
258
260
|
}
|
|
261
|
+
/** Validate a persistent device credential without consuming a Session slot. */
|
|
262
|
+
async probe(deviceToken) {
|
|
263
|
+
this.requireInitialized();
|
|
264
|
+
if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
|
|
265
|
+
return this.exclusive(async () => {
|
|
266
|
+
const now = this.now();
|
|
267
|
+
const tokenDigest = digest(deviceToken);
|
|
268
|
+
const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
|
|
269
|
+
const device = this.devices[index];
|
|
270
|
+
if (device === void 0) throw new AccessError(401, "authentication_failed");
|
|
271
|
+
if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
|
|
272
|
+
if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
|
|
273
|
+
const updated = Object.freeze({
|
|
274
|
+
...device,
|
|
275
|
+
lastSeenAt: now
|
|
276
|
+
});
|
|
277
|
+
const next = [...this.devices];
|
|
278
|
+
next[index] = updated;
|
|
279
|
+
await this.store.save(this.snapshot(next));
|
|
280
|
+
this.devices = next;
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
deviceId: device.id,
|
|
283
|
+
deviceExpiresAt: device.expiresAt
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
259
287
|
/** Resolve a short Session Cookie without revealing whether device or Session failed. */
|
|
260
288
|
authorizeSession(sessionToken) {
|
|
261
289
|
this.requireInitialized();
|
|
@@ -266,7 +294,7 @@ var AccessController = class {
|
|
|
266
294
|
const session = this.sessions.get(key);
|
|
267
295
|
const device = session === void 0 ? void 0 : this.devices.find((candidate) => candidate.id === session.deviceId);
|
|
268
296
|
if (session === void 0 || device === void 0 || device.revokedAt !== void 0 || device.expiresAt <= now) {
|
|
269
|
-
if (session !== void 0) this.removeSession(session.key);
|
|
297
|
+
if (session !== void 0) this.removeSession(session.key, "expired");
|
|
270
298
|
throw new AccessError(401, "authentication_failed");
|
|
271
299
|
}
|
|
272
300
|
return Object.freeze({
|
|
@@ -282,7 +310,7 @@ var AccessController = class {
|
|
|
282
310
|
}
|
|
283
311
|
/** End one short Session and notify the gateway to abort its attached work. */
|
|
284
312
|
logout(authorization) {
|
|
285
|
-
this.removeSession(authorization.sessionKey);
|
|
313
|
+
this.removeSession(authorization.sessionKey, "logout");
|
|
286
314
|
}
|
|
287
315
|
/** Persist revocation, then end every Session owned by that device. */
|
|
288
316
|
async revokeDevice(deviceId) {
|
|
@@ -298,7 +326,7 @@ var AccessController = class {
|
|
|
298
326
|
});
|
|
299
327
|
await this.store.save(this.snapshot(next));
|
|
300
328
|
this.devices = next;
|
|
301
|
-
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key);
|
|
329
|
+
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
|
|
302
330
|
return true;
|
|
303
331
|
});
|
|
304
332
|
}
|
|
@@ -308,7 +336,7 @@ var AccessController = class {
|
|
|
308
336
|
await this.exclusive(async () => {
|
|
309
337
|
await this.store.save(this.snapshot([]));
|
|
310
338
|
this.devices = [];
|
|
311
|
-
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
339
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "revoked");
|
|
312
340
|
this.pairingWindow = void 0;
|
|
313
341
|
});
|
|
314
342
|
}
|
|
@@ -347,7 +375,7 @@ var AccessController = class {
|
|
|
347
375
|
async finishClose() {
|
|
348
376
|
await this.mutation;
|
|
349
377
|
this.pairingWindow = void 0;
|
|
350
|
-
for (const key of [...this.sessions.keys()]) this.removeSession(key);
|
|
378
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "expired");
|
|
351
379
|
this.sessionEndedListeners.clear();
|
|
352
380
|
this.initialized = false;
|
|
353
381
|
}
|
|
@@ -2094,6 +2122,188 @@ function createMobileAccessService(ctx) {
|
|
|
2094
2122
|
return new MobileAccessService(ctx);
|
|
2095
2123
|
}
|
|
2096
2124
|
//#endregion
|
|
2125
|
+
//#region src/auth-pages.ts
|
|
2126
|
+
const COPY = {
|
|
2127
|
+
en: {
|
|
2128
|
+
lang: "en",
|
|
2129
|
+
pairTitle: "Pair DSH mobile access",
|
|
2130
|
+
pairHeading: "Pair this device",
|
|
2131
|
+
pairingCode: "Pairing code",
|
|
2132
|
+
deviceName: "Device name",
|
|
2133
|
+
pair: "Pair",
|
|
2134
|
+
pairing: "Pairing…",
|
|
2135
|
+
pairFailed: "Pairing failed",
|
|
2136
|
+
unavailable: "The computer is unavailable.",
|
|
2137
|
+
reconnectTitle: "Reconnect DSH mobile access",
|
|
2138
|
+
reconnectHeading: "Reconnect this device",
|
|
2139
|
+
restoring: "Restoring the secure Session…",
|
|
2140
|
+
noLongerPaired: "This device is no longer paired. Open pairing on the computer, then pair it again.",
|
|
2141
|
+
openPairing: "Open pairing"
|
|
2142
|
+
},
|
|
2143
|
+
zh: {
|
|
2144
|
+
lang: "zh-CN",
|
|
2145
|
+
pairTitle: "配对 DSH 移动访问",
|
|
2146
|
+
pairHeading: "配对此设备",
|
|
2147
|
+
pairingCode: "配对码",
|
|
2148
|
+
deviceName: "设备名称",
|
|
2149
|
+
pair: "配对",
|
|
2150
|
+
pairing: "正在配对…",
|
|
2151
|
+
pairFailed: "配对失败",
|
|
2152
|
+
unavailable: "电脑当前不可用。",
|
|
2153
|
+
reconnectTitle: "重新连接 DSH 移动访问",
|
|
2154
|
+
reconnectHeading: "重新连接此设备",
|
|
2155
|
+
restoring: "正在恢复安全会话…",
|
|
2156
|
+
noLongerPaired: "此设备已不在配对列表中。请在电脑端打开配对,然后重新配对。",
|
|
2157
|
+
openPairing: "打开配对页面"
|
|
2158
|
+
},
|
|
2159
|
+
it: {
|
|
2160
|
+
lang: "it-IT",
|
|
2161
|
+
pairTitle: "Abbina accesso mobile DSH",
|
|
2162
|
+
pairHeading: "Abbina questo dispositivo",
|
|
2163
|
+
pairingCode: "Codice di abbinamento",
|
|
2164
|
+
deviceName: "Nome dispositivo",
|
|
2165
|
+
pair: "Abbina",
|
|
2166
|
+
pairing: "Abbinamento…",
|
|
2167
|
+
pairFailed: "Abbinamento non riuscito",
|
|
2168
|
+
unavailable: "Il computer non è disponibile.",
|
|
2169
|
+
reconnectTitle: "Riconnetti accesso mobile DSH",
|
|
2170
|
+
reconnectHeading: "Riconnetti questo dispositivo",
|
|
2171
|
+
restoring: "Ripristino della sessione sicura…",
|
|
2172
|
+
noLongerPaired: "Questo dispositivo non è più abbinato. Apri l’abbinamento sul computer e ripeti la procedura.",
|
|
2173
|
+
openPairing: "Apri abbinamento"
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
/** Choose Chinese, Italian, or English from an HTTP Accept-Language header. */
|
|
2177
|
+
function resolveAuthPageLocale(header) {
|
|
2178
|
+
const values = header?.split(",").map((value, index) => {
|
|
2179
|
+
const [language, ...parameters] = value.split(";");
|
|
2180
|
+
const quality = parameters.find((parameter) => parameter.trim().toLowerCase().startsWith("q="));
|
|
2181
|
+
const parsedQuality = quality === void 0 ? 1 : Number(quality.trim().slice(2));
|
|
2182
|
+
return {
|
|
2183
|
+
language: language?.trim().toLowerCase() ?? "",
|
|
2184
|
+
quality: Number.isFinite(parsedQuality) ? Math.max(0, Math.min(1, parsedQuality)) : 0,
|
|
2185
|
+
index
|
|
2186
|
+
};
|
|
2187
|
+
}).filter((value) => value.quality > 0).sort((left, right) => right.quality - left.quality || left.index - right.index).map((value) => value.language) ?? [];
|
|
2188
|
+
for (const value of values) {
|
|
2189
|
+
if (value.startsWith("zh")) return "zh";
|
|
2190
|
+
if (value.startsWith("it")) return "it";
|
|
2191
|
+
if (value.startsWith("en")) return "en";
|
|
2192
|
+
}
|
|
2193
|
+
return "en";
|
|
2194
|
+
}
|
|
2195
|
+
/** Render the browser pairing form without reflecting request data into HTML. */
|
|
2196
|
+
function renderPairPage(locale) {
|
|
2197
|
+
const copy = COPY[locale];
|
|
2198
|
+
return `<!doctype html>
|
|
2199
|
+
<html lang="${copy.lang}">
|
|
2200
|
+
<meta charset="utf-8">
|
|
2201
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2202
|
+
<title>${copy.pairTitle}</title>
|
|
2203
|
+
<main>
|
|
2204
|
+
<h1>${copy.pairHeading}</h1>
|
|
2205
|
+
<form id="pair-form">
|
|
2206
|
+
<label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
2207
|
+
<label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
2208
|
+
<button type="submit">${copy.pair}</button>
|
|
2209
|
+
<output id="pair-status" aria-live="polite"></output>
|
|
2210
|
+
</form>
|
|
2211
|
+
</main>
|
|
2212
|
+
<script src="/mobile-access/pair.js" defer><\/script>
|
|
2213
|
+
</html>
|
|
2214
|
+
`;
|
|
2215
|
+
}
|
|
2216
|
+
/** Render the pairing form script with locale-owned status messages. */
|
|
2217
|
+
function renderPairScript(locale) {
|
|
2218
|
+
const copy = COPY[locale];
|
|
2219
|
+
return `(() => {
|
|
2220
|
+
const form = document.getElementById('pair-form')
|
|
2221
|
+
const token = document.getElementById('pair-token')
|
|
2222
|
+
const label = document.getElementById('device-label')
|
|
2223
|
+
const status = document.getElementById('pair-status')
|
|
2224
|
+
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
2225
|
+
const supplied = fragment.get('token')
|
|
2226
|
+
history.replaceState(null, '', location.pathname)
|
|
2227
|
+
if (supplied) token.value = supplied
|
|
2228
|
+
form.addEventListener('submit', async (event) => {
|
|
2229
|
+
event.preventDefault()
|
|
2230
|
+
status.value = ${JSON.stringify(copy.pairing)}
|
|
2231
|
+
try {
|
|
2232
|
+
const response = await fetch('/mobile-access/auth/pair', {
|
|
2233
|
+
method: 'POST',
|
|
2234
|
+
credentials: 'same-origin',
|
|
2235
|
+
headers: { 'content-type': 'application/json' },
|
|
2236
|
+
body: JSON.stringify({ token: token.value, label: label.value || undefined }),
|
|
2237
|
+
})
|
|
2238
|
+
if (!response.ok) {
|
|
2239
|
+
status.value = ${JSON.stringify(copy.pairFailed)}
|
|
2240
|
+
return
|
|
2241
|
+
}
|
|
2242
|
+
location.replace('/')
|
|
2243
|
+
} catch {
|
|
2244
|
+
status.value = ${JSON.stringify(copy.unavailable)}
|
|
2245
|
+
}
|
|
2246
|
+
})
|
|
2247
|
+
})()
|
|
2248
|
+
`;
|
|
2249
|
+
}
|
|
2250
|
+
/** Render the browser reauthentication page. */
|
|
2251
|
+
function renderLoginPage(locale) {
|
|
2252
|
+
const copy = COPY[locale];
|
|
2253
|
+
return `<!doctype html>
|
|
2254
|
+
<html lang="${copy.lang}">
|
|
2255
|
+
<meta charset="utf-8">
|
|
2256
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2257
|
+
<title>${copy.reconnectTitle}</title>
|
|
2258
|
+
<main>
|
|
2259
|
+
<h1>${copy.reconnectHeading}</h1>
|
|
2260
|
+
<p id="login-progress" aria-live="polite">${copy.restoring}</p>
|
|
2261
|
+
<section id="login-failed" hidden>
|
|
2262
|
+
<p>${copy.noLongerPaired}</p>
|
|
2263
|
+
<a href="/mobile-access/pair">${copy.openPairing}</a>
|
|
2264
|
+
</section>
|
|
2265
|
+
</main>
|
|
2266
|
+
<script src="/mobile-access/login.js" defer><\/script>
|
|
2267
|
+
</html>
|
|
2268
|
+
`;
|
|
2269
|
+
}
|
|
2270
|
+
/** Render the reauthentication script with locale-owned failure text. */
|
|
2271
|
+
function renderLoginScript(locale) {
|
|
2272
|
+
const copy = COPY[locale];
|
|
2273
|
+
return `(() => {
|
|
2274
|
+
const candidate = new URL(location.href).searchParams.get('return')
|
|
2275
|
+
let returnPath = '/'
|
|
2276
|
+
if (candidate && candidate.startsWith('/')) {
|
|
2277
|
+
try {
|
|
2278
|
+
const resolved = new URL(candidate, location.origin)
|
|
2279
|
+
const pathname = decodeURIComponent(resolved.pathname)
|
|
2280
|
+
if (resolved.origin === location.origin && pathname !== '/mobile-access'
|
|
2281
|
+
&& !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
|
|
2282
|
+
returnPath = resolved.pathname + resolved.search + resolved.hash
|
|
2283
|
+
}
|
|
2284
|
+
} catch {
|
|
2285
|
+
// Malformed untrusted return targets keep the safe root default.
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
fetch('/mobile-access/auth/renew', {
|
|
2289
|
+
method: 'POST',
|
|
2290
|
+
credentials: 'same-origin',
|
|
2291
|
+
headers: { 'content-type': 'application/json' },
|
|
2292
|
+
body: '{}',
|
|
2293
|
+
}).then((response) => {
|
|
2294
|
+
if (response.ok) {
|
|
2295
|
+
location.replace(returnPath)
|
|
2296
|
+
return
|
|
2297
|
+
}
|
|
2298
|
+
document.getElementById('login-progress').hidden = true
|
|
2299
|
+
document.getElementById('login-failed').hidden = false
|
|
2300
|
+
}).catch(() => {
|
|
2301
|
+
document.getElementById('login-progress').textContent = ${JSON.stringify(copy.unavailable)}
|
|
2302
|
+
})
|
|
2303
|
+
})()
|
|
2304
|
+
`;
|
|
2305
|
+
}
|
|
2306
|
+
//#endregion
|
|
2097
2307
|
//#region src/gateway.ts
|
|
2098
2308
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
2099
2309
|
const MAX_HEADER_BYTES = 16384;
|
|
@@ -2152,23 +2362,6 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
|
|
|
2152
2362
|
})]);
|
|
2153
2363
|
const MOBILE_CSRF_FETCH_BOOTSTRAP = `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${CSRF_COOKIE}=`)};const token=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith(prefix))?.slice(prefix.length);if(token!==undefined)headers.set(${JSON.stringify(CSRF_HEADER)},token)}return nativeFetch(input,{...init,headers})};})();`;
|
|
2154
2364
|
const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true}})();`;
|
|
2155
|
-
const PAIR_PAGE = `<!doctype html>
|
|
2156
|
-
<html lang="en">
|
|
2157
|
-
<meta charset="utf-8">
|
|
2158
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2159
|
-
<title>Pair DSH mobile access</title>
|
|
2160
|
-
<main>
|
|
2161
|
-
<h1>Pair this device</h1>
|
|
2162
|
-
<form id="pair-form">
|
|
2163
|
-
<label>Pairing code <input id="pair-token" autocomplete="one-time-code" required></label>
|
|
2164
|
-
<label>Device name <input id="device-label" maxlength="64" autocomplete="off"></label>
|
|
2165
|
-
<button type="submit">Pair</button>
|
|
2166
|
-
<output id="pair-status"></output>
|
|
2167
|
-
</form>
|
|
2168
|
-
</main>
|
|
2169
|
-
<script src="/mobile-access/pair.js" defer><\/script>
|
|
2170
|
-
</html>
|
|
2171
|
-
`;
|
|
2172
2365
|
const gzipBuffer = promisify(gzip);
|
|
2173
2366
|
function ensureMobileViewport(html) {
|
|
2174
2367
|
const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
|
|
@@ -2272,80 +2465,6 @@ function rewriteMobileIndexWithBatch(html) {
|
|
|
2272
2465
|
function rewriteMobileIndex(html) {
|
|
2273
2466
|
return rewriteMobileIndexWithBatch(html).html;
|
|
2274
2467
|
}
|
|
2275
|
-
const PAIR_SCRIPT = `(() => {
|
|
2276
|
-
const form = document.getElementById('pair-form')
|
|
2277
|
-
const token = document.getElementById('pair-token')
|
|
2278
|
-
const label = document.getElementById('device-label')
|
|
2279
|
-
const status = document.getElementById('pair-status')
|
|
2280
|
-
const fragment = new URLSearchParams(location.hash.slice(1))
|
|
2281
|
-
const supplied = fragment.get('token')
|
|
2282
|
-
history.replaceState(null, '', location.pathname)
|
|
2283
|
-
if (supplied) token.value = supplied
|
|
2284
|
-
form.addEventListener('submit', async (event) => {
|
|
2285
|
-
event.preventDefault()
|
|
2286
|
-
status.value = 'Pairing…'
|
|
2287
|
-
const response = await fetch('/mobile-access/auth/pair', {
|
|
2288
|
-
method: 'POST',
|
|
2289
|
-
credentials: 'same-origin',
|
|
2290
|
-
headers: { 'content-type': 'application/json' },
|
|
2291
|
-
body: JSON.stringify({ token: token.value, label: label.value || undefined }),
|
|
2292
|
-
})
|
|
2293
|
-
if (!response.ok) {
|
|
2294
|
-
status.value = 'Pairing failed'
|
|
2295
|
-
return
|
|
2296
|
-
}
|
|
2297
|
-
location.replace('/')
|
|
2298
|
-
})
|
|
2299
|
-
})()
|
|
2300
|
-
`;
|
|
2301
|
-
const LOGIN_PAGE = `<!doctype html>
|
|
2302
|
-
<html lang="en">
|
|
2303
|
-
<meta charset="utf-8">
|
|
2304
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
2305
|
-
<title>Reconnect DSH mobile access</title>
|
|
2306
|
-
<main>
|
|
2307
|
-
<h1>Reconnect this device</h1>
|
|
2308
|
-
<p id="login-progress">Restoring the secure Session…</p>
|
|
2309
|
-
<section id="login-failed" hidden>
|
|
2310
|
-
<p>This device is no longer paired. Open pairing on the computer, then pair it again.</p>
|
|
2311
|
-
<a href="/mobile-access/pair">Open pairing</a>
|
|
2312
|
-
</section>
|
|
2313
|
-
</main>
|
|
2314
|
-
<script src="/mobile-access/login.js" defer><\/script>
|
|
2315
|
-
</html>
|
|
2316
|
-
`;
|
|
2317
|
-
const LOGIN_SCRIPT = `(() => {
|
|
2318
|
-
const candidate = new URL(location.href).searchParams.get('return')
|
|
2319
|
-
let returnPath = '/'
|
|
2320
|
-
if (candidate && candidate.startsWith('/')) {
|
|
2321
|
-
try {
|
|
2322
|
-
const resolved = new URL(candidate, location.origin)
|
|
2323
|
-
const pathname = decodeURIComponent(resolved.pathname)
|
|
2324
|
-
if (resolved.origin === location.origin && pathname !== '/mobile-access'
|
|
2325
|
-
&& !pathname.startsWith('/mobile-access/') && !pathname.includes('\\\\')) {
|
|
2326
|
-
returnPath = resolved.pathname + resolved.search + resolved.hash
|
|
2327
|
-
}
|
|
2328
|
-
} catch {
|
|
2329
|
-
// Malformed untrusted return targets keep the safe root default.
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
fetch('/mobile-access/auth/renew', {
|
|
2333
|
-
method: 'POST',
|
|
2334
|
-
credentials: 'same-origin',
|
|
2335
|
-
headers: { 'content-type': 'application/json' },
|
|
2336
|
-
body: '{}',
|
|
2337
|
-
}).then((response) => {
|
|
2338
|
-
if (response.ok) {
|
|
2339
|
-
location.replace(returnPath)
|
|
2340
|
-
return
|
|
2341
|
-
}
|
|
2342
|
-
document.getElementById('login-progress').hidden = true
|
|
2343
|
-
document.getElementById('login-failed').hidden = false
|
|
2344
|
-
}).catch(() => {
|
|
2345
|
-
document.getElementById('login-progress').textContent = 'The computer is unavailable.'
|
|
2346
|
-
})
|
|
2347
|
-
})()
|
|
2348
|
-
`;
|
|
2349
2468
|
var ByteLimitTransform = class extends Transform {
|
|
2350
2469
|
maximum;
|
|
2351
2470
|
total = 0;
|
|
@@ -2772,6 +2891,9 @@ var MobileAccessGateway = class {
|
|
|
2772
2891
|
mobileBootBatches = /* @__PURE__ */ new Map();
|
|
2773
2892
|
extensionEventListeners = /* @__PURE__ */ new Set();
|
|
2774
2893
|
extensionEventRevision = 0;
|
|
2894
|
+
taskEventListeners = /* @__PURE__ */ new Set();
|
|
2895
|
+
deviceEventListeners = /* @__PURE__ */ new Map();
|
|
2896
|
+
pendingDeviceRevocations = /* @__PURE__ */ new Set();
|
|
2775
2897
|
extensionChangeTimer;
|
|
2776
2898
|
extensionChangeTask;
|
|
2777
2899
|
legacyCustomDigest = "";
|
|
@@ -2786,6 +2908,7 @@ var MobileAccessGateway = class {
|
|
|
2786
2908
|
removeSessionListener;
|
|
2787
2909
|
removeExtensionContentListener;
|
|
2788
2910
|
renewLimiter;
|
|
2911
|
+
probeLimiter;
|
|
2789
2912
|
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
|
|
2790
2913
|
this.config = config;
|
|
2791
2914
|
this.extensions = extensions;
|
|
@@ -2805,8 +2928,18 @@ var MobileAccessGateway = class {
|
|
|
2805
2928
|
maxRateLimitKeys: config.maxRateLimitKeys
|
|
2806
2929
|
});
|
|
2807
2930
|
this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
2808
|
-
this.
|
|
2809
|
-
|
|
2931
|
+
this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
2932
|
+
this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
|
|
2933
|
+
if (reason === "revoked") {
|
|
2934
|
+
if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
|
|
2935
|
+
this.pendingDeviceRevocations.add(authorization.deviceId);
|
|
2936
|
+
this.broadcastDeviceRevoked(authorization.deviceId);
|
|
2937
|
+
}
|
|
2938
|
+
setImmediate(() => {
|
|
2939
|
+
this.abortSessionResources(authorization.sessionKey);
|
|
2940
|
+
this.pendingDeviceRevocations.delete(authorization.deviceId);
|
|
2941
|
+
});
|
|
2942
|
+
} else this.abortSessionResources(authorization.sessionKey);
|
|
2810
2943
|
});
|
|
2811
2944
|
this.removeExtensionContentListener = this.extensions?.onContentChanged(() => {
|
|
2812
2945
|
this.broadcastExtensionChange();
|
|
@@ -3078,6 +3211,17 @@ var MobileAccessGateway = class {
|
|
|
3078
3211
|
sessionExpiresAt: result.sessionExpiresAt
|
|
3079
3212
|
}, this.tlsEnabled);
|
|
3080
3213
|
}
|
|
3214
|
+
async handleNativeProbe(request, response) {
|
|
3215
|
+
if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
3216
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
3217
|
+
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
3218
|
+
const result = await this.access.probe(body.deviceToken);
|
|
3219
|
+
sendJson(response, 200, {
|
|
3220
|
+
instanceId: this.config.instanceId,
|
|
3221
|
+
deviceId: result.deviceId,
|
|
3222
|
+
deviceExpiresAt: result.deviceExpiresAt
|
|
3223
|
+
}, this.tlsEnabled);
|
|
3224
|
+
}
|
|
3081
3225
|
async handleLogout(request, response) {
|
|
3082
3226
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
3083
3227
|
const authorization = this.authorize(request);
|
|
@@ -3140,8 +3284,11 @@ var MobileAccessGateway = class {
|
|
|
3140
3284
|
}
|
|
3141
3285
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
3142
3286
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
3287
|
+
const languageHeader = request.headers["accept-language"];
|
|
3288
|
+
const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
|
|
3143
3289
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3144
|
-
|
|
3290
|
+
response.setHeader("Vary", "Accept-Language");
|
|
3291
|
+
const body = target.decodedPathname.endsWith(".js") ? renderPairScript(locale) : renderPairPage(locale);
|
|
3145
3292
|
response.writeHead(200, {
|
|
3146
3293
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
3147
3294
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -3151,8 +3298,11 @@ var MobileAccessGateway = class {
|
|
|
3151
3298
|
}
|
|
3152
3299
|
if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
|
|
3153
3300
|
if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
|
|
3301
|
+
const languageHeader = request.headers["accept-language"];
|
|
3302
|
+
const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
|
|
3154
3303
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3155
|
-
|
|
3304
|
+
response.setHeader("Vary", "Accept-Language");
|
|
3305
|
+
const body = target.decodedPathname.endsWith(".js") ? renderLoginScript(locale) : renderLoginPage(locale);
|
|
3156
3306
|
response.writeHead(200, {
|
|
3157
3307
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
3158
3308
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -3176,6 +3326,10 @@ var MobileAccessGateway = class {
|
|
|
3176
3326
|
await this.handleNativeRenew(request, response);
|
|
3177
3327
|
return;
|
|
3178
3328
|
}
|
|
3329
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-probe`) {
|
|
3330
|
+
await this.handleNativeProbe(request, response);
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3179
3333
|
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
|
|
3180
3334
|
await this.handleLogout(request, response);
|
|
3181
3335
|
return;
|
|
@@ -3900,6 +4054,22 @@ var MobileAccessGateway = class {
|
|
|
3900
4054
|
this.extensionEventRevision += 1;
|
|
3901
4055
|
for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
|
|
3902
4056
|
}
|
|
4057
|
+
/** Fan a completed task to every phone holding this gateway's event stream. */
|
|
4058
|
+
broadcastTaskEvent(event) {
|
|
4059
|
+
if (this.closing) return;
|
|
4060
|
+
const payload = JSON.stringify({
|
|
4061
|
+
sessionId: String(event.sessionId),
|
|
4062
|
+
turn: Number(event.turn) || 0
|
|
4063
|
+
});
|
|
4064
|
+
for (const listener of this.taskEventListeners) listener(payload);
|
|
4065
|
+
}
|
|
4066
|
+
/** Notify only the device whose persistent credential was revoked by the Host. */
|
|
4067
|
+
broadcastDeviceRevoked(deviceId) {
|
|
4068
|
+
const listeners = this.deviceEventListeners.get(deviceId);
|
|
4069
|
+
if (listeners === void 0) return;
|
|
4070
|
+
const payload = JSON.stringify({ reason: "device_revoked" });
|
|
4071
|
+
for (const listener of [...listeners]) listener(payload);
|
|
4072
|
+
}
|
|
3903
4073
|
pollLegacyCustomChanges() {
|
|
3904
4074
|
if (this.extensionChangeTask !== void 0) return this.extensionChangeTask;
|
|
3905
4075
|
const digestFile = async (path, fallback) => {
|
|
@@ -3931,6 +4101,10 @@ var MobileAccessGateway = class {
|
|
|
3931
4101
|
closed = true;
|
|
3932
4102
|
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
3933
4103
|
this.extensionEventListeners.delete(send);
|
|
4104
|
+
this.taskEventListeners.delete(sendTask);
|
|
4105
|
+
const deviceListeners = this.deviceEventListeners.get(authorization.deviceId);
|
|
4106
|
+
deviceListeners?.delete(sendDevice);
|
|
4107
|
+
if (deviceListeners?.size === 0) this.deviceEventListeners.delete(authorization.deviceId);
|
|
3934
4108
|
request.removeListener("aborted", close);
|
|
3935
4109
|
response.removeListener("close", close);
|
|
3936
4110
|
operation.release();
|
|
@@ -3939,6 +4113,14 @@ var MobileAccessGateway = class {
|
|
|
3939
4113
|
if (closed || response.destroyed || response.writableEnded) return;
|
|
3940
4114
|
response.write(`id: ${String(revision)}\nevent: extensions-changed\ndata: {\"revision\":${String(revision)}}\n\n`);
|
|
3941
4115
|
};
|
|
4116
|
+
const sendTask = (payload) => {
|
|
4117
|
+
if (closed || response.destroyed || response.writableEnded) return;
|
|
4118
|
+
response.write(`event: task-notify\ndata: ${payload}\n\n`);
|
|
4119
|
+
};
|
|
4120
|
+
const sendDevice = (payload) => {
|
|
4121
|
+
if (closed || response.destroyed || response.writableEnded) return;
|
|
4122
|
+
response.write(`event: device-revoked\ndata: ${payload}\n\n`);
|
|
4123
|
+
};
|
|
3942
4124
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
3943
4125
|
response.writeHead(200, {
|
|
3944
4126
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
@@ -3948,6 +4130,10 @@ var MobileAccessGateway = class {
|
|
|
3948
4130
|
});
|
|
3949
4131
|
response.write("retry: 2000\n: ready\n\n");
|
|
3950
4132
|
this.extensionEventListeners.add(send);
|
|
4133
|
+
this.taskEventListeners.add(sendTask);
|
|
4134
|
+
const deviceListeners = this.deviceEventListeners.get(authorization.deviceId) ?? /* @__PURE__ */ new Set();
|
|
4135
|
+
deviceListeners.add(sendDevice);
|
|
4136
|
+
this.deviceEventListeners.set(authorization.deviceId, deviceListeners);
|
|
3951
4137
|
heartbeat = setInterval(() => {
|
|
3952
4138
|
if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
|
|
3953
4139
|
}, EXTENSION_EVENT_HEARTBEAT_MS);
|
|
@@ -4218,6 +4404,7 @@ var MobileAccessGateway = class {
|
|
|
4218
4404
|
this.upstreamAuthRequest?.destroy();
|
|
4219
4405
|
this.upstreamAuthRequest = void 0;
|
|
4220
4406
|
this.removeSessionListener();
|
|
4407
|
+
this.pendingDeviceRevocations.clear();
|
|
4221
4408
|
const accessClose = this.access.close();
|
|
4222
4409
|
for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
|
|
4223
4410
|
for (const request of this.activeRequests.values()) request.abort();
|
|
@@ -5766,6 +5953,84 @@ var FrpController = class {
|
|
|
5766
5953
|
}
|
|
5767
5954
|
};
|
|
5768
5955
|
//#endregion
|
|
5956
|
+
//#region src/task-events.ts
|
|
5957
|
+
const TASK_EVENT_DEBOUNCE_MS = 1e3;
|
|
5958
|
+
function turnNumber(value) {
|
|
5959
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
5960
|
+
}
|
|
5961
|
+
function turnData(value) {
|
|
5962
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return {
|
|
5963
|
+
turn: 0,
|
|
5964
|
+
completed: false
|
|
5965
|
+
};
|
|
5966
|
+
const record = value;
|
|
5967
|
+
const reason = record.reason;
|
|
5968
|
+
const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
|
|
5969
|
+
return {
|
|
5970
|
+
turn: turnNumber(record.turn),
|
|
5971
|
+
completed
|
|
5972
|
+
};
|
|
5973
|
+
}
|
|
5974
|
+
/**
|
|
5975
|
+
* Subscribe to completed root turns. Subagent turns are skipped so one task
|
|
5976
|
+
* announces once, and rapid turn boundaries collapse into a single event.
|
|
5977
|
+
* Returns a disposer that also drops pending debounces.
|
|
5978
|
+
*/
|
|
5979
|
+
function watchTaskCompletions(ctx, options) {
|
|
5980
|
+
const debounceMs = options.debounceMs ?? 1e3;
|
|
5981
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5982
|
+
const disposeListener = ctx.on("session/event", (session, event) => {
|
|
5983
|
+
if (event.type !== "turn/end") return;
|
|
5984
|
+
const { turn, completed } = turnData(event.data);
|
|
5985
|
+
if (!completed) return;
|
|
5986
|
+
if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
|
|
5987
|
+
const sessionId = String(session.id);
|
|
5988
|
+
options.log?.("task-completed-observed", {
|
|
5989
|
+
sessionId,
|
|
5990
|
+
turn
|
|
5991
|
+
});
|
|
5992
|
+
const previous = pending.get(sessionId);
|
|
5993
|
+
if (previous !== void 0) clearTimeout(previous);
|
|
5994
|
+
pending.set(sessionId, setTimeout(() => {
|
|
5995
|
+
pending.delete(sessionId);
|
|
5996
|
+
options.log?.("task-completed-announced", {
|
|
5997
|
+
sessionId,
|
|
5998
|
+
turn
|
|
5999
|
+
});
|
|
6000
|
+
options.onTaskCompleted(Object.freeze({
|
|
6001
|
+
sessionId,
|
|
6002
|
+
turn
|
|
6003
|
+
}));
|
|
6004
|
+
}, debounceMs));
|
|
6005
|
+
});
|
|
6006
|
+
return () => {
|
|
6007
|
+
disposeListener();
|
|
6008
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
6009
|
+
pending.clear();
|
|
6010
|
+
};
|
|
6011
|
+
}
|
|
6012
|
+
/** One subscription feeding every live gateway; gateways register on start. */
|
|
6013
|
+
var TaskEventHub = class {
|
|
6014
|
+
sinks = /* @__PURE__ */ new Set();
|
|
6015
|
+
/** Register a sink; returns its disposer. */
|
|
6016
|
+
add(sink) {
|
|
6017
|
+
this.sinks.add(sink);
|
|
6018
|
+
return () => {
|
|
6019
|
+
this.sinks.delete(sink);
|
|
6020
|
+
};
|
|
6021
|
+
}
|
|
6022
|
+
/** Fan out to a snapshot so a failing sink cannot break its siblings. */
|
|
6023
|
+
broadcast(event) {
|
|
6024
|
+
for (const sink of [...this.sinks]) try {
|
|
6025
|
+
sink.broadcastTaskEvent(event);
|
|
6026
|
+
} catch {}
|
|
6027
|
+
}
|
|
6028
|
+
/** Visible for tests. */
|
|
6029
|
+
get size() {
|
|
6030
|
+
return this.sinks.size;
|
|
6031
|
+
}
|
|
6032
|
+
};
|
|
6033
|
+
//#endregion
|
|
5769
6034
|
//#region src/diagnostics.ts
|
|
5770
6035
|
const REMOTE_ERROR_GUIDANCE = Object.freeze({
|
|
5771
6036
|
component_missing: "重新安装完整插件包。",
|
|
@@ -5921,7 +6186,8 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
5921
6186
|
const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
|
|
5922
6187
|
const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
|
|
5923
6188
|
checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
|
|
5924
|
-
if (snapshot.lan.
|
|
6189
|
+
if (snapshot.lan.configured === false) checks.push(check("network", "error", "lan-setup-required", "局域网配置", "尚未完成局域网初始化。", "返回局域网页选择网卡并完成配置。"));
|
|
6190
|
+
else if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
|
|
5925
6191
|
else if (snapshot.lan.configuredInterface !== void 0) {
|
|
5926
6192
|
const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
|
|
5927
6193
|
checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
|
|
@@ -7646,7 +7912,7 @@ var CpolarController = class {
|
|
|
7646
7912
|
const args = [
|
|
7647
7913
|
"http",
|
|
7648
7914
|
`-config=${resolve(this.options.configFile)}`,
|
|
7649
|
-
`-region=${this.options.region
|
|
7915
|
+
...this.options.region === void 0 ? [] : [`-region=${this.options.region}`],
|
|
7650
7916
|
"-inspect-addr=false",
|
|
7651
7917
|
"-redirect-https=true",
|
|
7652
7918
|
"-log=stdout",
|
|
@@ -8603,6 +8869,59 @@ function likelyVirtualInterface(name) {
|
|
|
8603
8869
|
const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
|
|
8604
8870
|
return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
|
|
8605
8871
|
}
|
|
8872
|
+
async function runRouteCommand(file, args) {
|
|
8873
|
+
return (await execFileText(file, [...args], {
|
|
8874
|
+
encoding: "utf8",
|
|
8875
|
+
windowsHide: true
|
|
8876
|
+
})).stdout;
|
|
8877
|
+
}
|
|
8878
|
+
function uniqueLines(output) {
|
|
8879
|
+
return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
|
|
8880
|
+
}
|
|
8881
|
+
/** Return operating-system default-route interfaces in routing preference order. */
|
|
8882
|
+
async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
|
|
8883
|
+
try {
|
|
8884
|
+
if (platform === "win32") return uniqueLines(await run("powershell.exe", [
|
|
8885
|
+
"-NoProfile",
|
|
8886
|
+
"-NonInteractive",
|
|
8887
|
+
"-Command",
|
|
8888
|
+
[
|
|
8889
|
+
"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
|
|
8890
|
+
"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
|
|
8891
|
+
" $route = $_",
|
|
8892
|
+
" $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
8893
|
+
" $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
|
|
8894
|
+
" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
|
|
8895
|
+
" [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
|
|
8896
|
+
" }",
|
|
8897
|
+
"}",
|
|
8898
|
+
"$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
|
|
8899
|
+
].join("; ")
|
|
8900
|
+
]));
|
|
8901
|
+
if (platform === "linux") {
|
|
8902
|
+
const routes = uniqueLines(await run("ip", [
|
|
8903
|
+
"-o",
|
|
8904
|
+
"-4",
|
|
8905
|
+
"route",
|
|
8906
|
+
"show",
|
|
8907
|
+
"default"
|
|
8908
|
+
])).map((line) => ({
|
|
8909
|
+
name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
|
|
8910
|
+
metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
|
|
8911
|
+
})).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
|
|
8912
|
+
return [...new Set(routes.map((route) => route.name))];
|
|
8913
|
+
}
|
|
8914
|
+
if (platform === "darwin") {
|
|
8915
|
+
const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
|
|
8916
|
+
"-n",
|
|
8917
|
+
"get",
|
|
8918
|
+
"default"
|
|
8919
|
+
]))?.[1];
|
|
8920
|
+
return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
|
|
8921
|
+
}
|
|
8922
|
+
} catch {}
|
|
8923
|
+
return [];
|
|
8924
|
+
}
|
|
8606
8925
|
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
8607
8926
|
function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
|
|
8608
8927
|
const candidates = availableLanNetworks(table);
|
|
@@ -8653,6 +8972,57 @@ async function atomicWrite(file, contents) {
|
|
|
8653
8972
|
await rename(temporary, file);
|
|
8654
8973
|
await restrictPrivateFile(file);
|
|
8655
8974
|
}
|
|
8975
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
8976
|
+
async function ensureManagedCa(setup, legacy) {
|
|
8977
|
+
let certPem;
|
|
8978
|
+
let keyPem;
|
|
8979
|
+
try {
|
|
8980
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
8981
|
+
} catch (error) {
|
|
8982
|
+
if (error.code !== "ENOENT") throw error;
|
|
8983
|
+
let migrated = false;
|
|
8984
|
+
if (legacy !== void 0) try {
|
|
8985
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
8986
|
+
assertMatchingCa(certPem, keyPem);
|
|
8987
|
+
migrated = true;
|
|
8988
|
+
} catch (legacyError) {
|
|
8989
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
8990
|
+
}
|
|
8991
|
+
if (!migrated) {
|
|
8992
|
+
const now = /* @__PURE__ */ new Date();
|
|
8993
|
+
const notAfter = new Date(now);
|
|
8994
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
8995
|
+
const generated = await generate([{
|
|
8996
|
+
name: "commonName",
|
|
8997
|
+
value: "DeepSeek Harness Mobile CA"
|
|
8998
|
+
}], {
|
|
8999
|
+
keyType: "ec",
|
|
9000
|
+
curve: "P-256",
|
|
9001
|
+
algorithm: "sha256",
|
|
9002
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
9003
|
+
notAfterDate: notAfter,
|
|
9004
|
+
extensions: [{
|
|
9005
|
+
name: "basicConstraints",
|
|
9006
|
+
cA: true,
|
|
9007
|
+
critical: true
|
|
9008
|
+
}, {
|
|
9009
|
+
name: "keyUsage",
|
|
9010
|
+
digitalSignature: true,
|
|
9011
|
+
keyCertSign: true,
|
|
9012
|
+
cRLSign: true,
|
|
9013
|
+
critical: true
|
|
9014
|
+
}]
|
|
9015
|
+
});
|
|
9016
|
+
certPem = generated.cert;
|
|
9017
|
+
keyPem = generated.private;
|
|
9018
|
+
}
|
|
9019
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
9020
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
9021
|
+
}
|
|
9022
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
9023
|
+
await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
|
|
9024
|
+
return assertMatchingCa(certPem, keyPem);
|
|
9025
|
+
}
|
|
8656
9026
|
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
8657
9027
|
async function refreshManagedServerCertificate(setup, address) {
|
|
8658
9028
|
await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
|
|
@@ -8719,6 +9089,79 @@ async function materializeManagedSetup(setup, table) {
|
|
|
8719
9089
|
};
|
|
8720
9090
|
}
|
|
8721
9091
|
//#endregion
|
|
9092
|
+
//#region src/lan-setup.ts
|
|
9093
|
+
const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
|
|
9094
|
+
const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
|
|
9095
|
+
async function runElevatedPowerShell(script) {
|
|
9096
|
+
await execFileText("powershell.exe", [
|
|
9097
|
+
"-NoProfile",
|
|
9098
|
+
"-NonInteractive",
|
|
9099
|
+
"-Command",
|
|
9100
|
+
[
|
|
9101
|
+
"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
|
|
9102
|
+
` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
|
|
9103
|
+
"; exit $process.ExitCode"
|
|
9104
|
+
].join(" ")
|
|
9105
|
+
], { windowsHide: true });
|
|
9106
|
+
}
|
|
9107
|
+
/** Install the two Windows inbound rules used by the managed LAN listener. */
|
|
9108
|
+
async function configureWindowsFirewall(port) {
|
|
9109
|
+
if (process.platform !== "win32") return;
|
|
9110
|
+
await runElevatedPowerShell([
|
|
9111
|
+
"$ErrorActionPreference = 'Stop'",
|
|
9112
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
9113
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
9114
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
9115
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
9116
|
+
].join("; "));
|
|
9117
|
+
}
|
|
9118
|
+
/** Generate private TLS material and persist a managed setup selected by the user. */
|
|
9119
|
+
async function prepareManagedLanSetup(options) {
|
|
9120
|
+
if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
|
|
9121
|
+
if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
|
|
9122
|
+
if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
|
|
9123
|
+
const directory = dirname(options.setupFile);
|
|
9124
|
+
const tlsDirectory = join(directory, "tls");
|
|
9125
|
+
await mkdir(tlsDirectory, {
|
|
9126
|
+
recursive: true,
|
|
9127
|
+
mode: 448
|
|
9128
|
+
});
|
|
9129
|
+
const managedTls = {
|
|
9130
|
+
mode: "managed",
|
|
9131
|
+
caCertFile: join(tlsDirectory, "ca.pem"),
|
|
9132
|
+
caKeyFile: join(tlsDirectory, "ca-key.pem"),
|
|
9133
|
+
certFile: join(tlsDirectory, "server-cert.pem"),
|
|
9134
|
+
keyFile: join(tlsDirectory, "server-key.pem")
|
|
9135
|
+
};
|
|
9136
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
9137
|
+
certFile: join(tlsDirectory, "cert.pem"),
|
|
9138
|
+
keyFile: join(tlsDirectory, "key.pem")
|
|
9139
|
+
});
|
|
9140
|
+
const setup = {
|
|
9141
|
+
version: 2,
|
|
9142
|
+
networkInterface: options.network.name,
|
|
9143
|
+
listenPort: options.listenPort,
|
|
9144
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
9145
|
+
tls: managedTls
|
|
9146
|
+
};
|
|
9147
|
+
await refreshManagedServerCertificate(setup, options.network.address);
|
|
9148
|
+
const androidCertificate = join(tlsDirectory, "dsh-mobile-ca.cer");
|
|
9149
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
9150
|
+
await Promise.all([...Object.values(managedTls).filter((value) => value !== "managed").map((file) => restrictPrivateFile(file)), restrictPrivateFile(androidCertificate)]);
|
|
9151
|
+
if (options.configureFirewall) await configureWindowsFirewall(options.listenPort);
|
|
9152
|
+
await Promise.all([writeFile(options.setupFile, `${JSON.stringify({
|
|
9153
|
+
...setup,
|
|
9154
|
+
tls: Object.fromEntries(Object.entries(setup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
9155
|
+
}, null, 2)}\n`, { mode: 384 }), writeFile(options.controlFile, "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
9156
|
+
await Promise.all([restrictPrivateFile(options.setupFile), restrictPrivateFile(options.controlFile)]);
|
|
9157
|
+
return Object.freeze({
|
|
9158
|
+
setup: Object.freeze(setup),
|
|
9159
|
+
network: Object.freeze({ ...options.network }),
|
|
9160
|
+
origin: `https://${options.network.address}:${String(options.listenPort)}`,
|
|
9161
|
+
androidCertificate
|
|
9162
|
+
});
|
|
9163
|
+
}
|
|
9164
|
+
//#endregion
|
|
8722
9165
|
//#region src/plugin.ts
|
|
8723
9166
|
/** Stable Cordis plugin name. */
|
|
8724
9167
|
const name = "dsh-mobile";
|
|
@@ -8792,6 +9235,7 @@ function mapAdminError(error) {
|
|
|
8792
9235
|
if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
|
|
8793
9236
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
8794
9237
|
if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
|
|
9238
|
+
if (error instanceof Error && error.message.startsWith("lan_setup_")) return new HttpError(409, error.message);
|
|
8795
9239
|
return new HttpError(500, "internal_error");
|
|
8796
9240
|
}
|
|
8797
9241
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -8831,8 +9275,9 @@ async function loadSetup(config) {
|
|
|
8831
9275
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
8832
9276
|
} catch (error) {
|
|
8833
9277
|
if (error.code === "ENOENT") return {
|
|
8834
|
-
kind: "
|
|
8835
|
-
config
|
|
9278
|
+
kind: "unconfigured",
|
|
9279
|
+
config,
|
|
9280
|
+
setupFile: resolve(config.setupFile)
|
|
8836
9281
|
};
|
|
8837
9282
|
throw error;
|
|
8838
9283
|
}
|
|
@@ -8939,6 +9384,13 @@ async function apply(ctx, config) {
|
|
|
8939
9384
|
const logFile = await installMobileFileLogger(ctx, stateDirectory);
|
|
8940
9385
|
const logger = ctx.logger("dsh-mobile");
|
|
8941
9386
|
logger.info("logging initialized file=%s", logFile);
|
|
9387
|
+
const taskEventHub = new TaskEventHub();
|
|
9388
|
+
const disposeTaskEvents = watchTaskCompletions(ctx, {
|
|
9389
|
+
onTaskCompleted: (event) => taskEventHub.broadcast(event),
|
|
9390
|
+
log(event, fields) {
|
|
9391
|
+
logger.info("task event=%s fields=%o", event, fields);
|
|
9392
|
+
}
|
|
9393
|
+
});
|
|
8942
9394
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
8943
9395
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
8944
9396
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -8984,6 +9436,7 @@ async function apply(ctx, config) {
|
|
|
8984
9436
|
await webSocketPaths.load();
|
|
8985
9437
|
const blockedUpgradePaths = new BlockedUpgradePathLog();
|
|
8986
9438
|
let lanGateway;
|
|
9439
|
+
let preparedLanSetup;
|
|
8987
9440
|
const startGateway = async (candidateConfig) => {
|
|
8988
9441
|
const resolved = parseGatewayConfig({
|
|
8989
9442
|
...candidateConfig,
|
|
@@ -8992,12 +9445,15 @@ async function apply(ctx, config) {
|
|
|
8992
9445
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
8993
9446
|
await candidate.start();
|
|
8994
9447
|
lanGateway = candidate;
|
|
9448
|
+
const removeTaskSink = taskEventHub.add(candidate);
|
|
8995
9449
|
return { close: async () => {
|
|
8996
9450
|
if (lanGateway === candidate) lanGateway = void 0;
|
|
9451
|
+
removeTaskSink();
|
|
8997
9452
|
await candidate.close();
|
|
8998
9453
|
} };
|
|
8999
9454
|
};
|
|
9000
9455
|
const startRuntime = async () => {
|
|
9456
|
+
if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
9001
9457
|
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
9002
9458
|
const following = new FollowingMobileAccessRuntime(async () => {
|
|
9003
9459
|
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
@@ -9014,7 +9470,13 @@ async function apply(ctx, config) {
|
|
|
9014
9470
|
await following.initialize(2e3);
|
|
9015
9471
|
return following;
|
|
9016
9472
|
};
|
|
9017
|
-
const
|
|
9473
|
+
const lanControlFile = parseControlFile(config.controlFile);
|
|
9474
|
+
const lanControlStore = new JsonMobileAccessControlStore(lanControlFile, config.initiallyEnabled);
|
|
9475
|
+
if (loaded.kind === "unconfigured" && (await lanControlStore.load()).enabled) await lanControlStore.save({
|
|
9476
|
+
version: 1,
|
|
9477
|
+
enabled: false
|
|
9478
|
+
});
|
|
9479
|
+
const lanController = new MobileAccessGatewayController(lanControlStore, startRuntime);
|
|
9018
9480
|
const remoteDeviceFile = join(remoteDirectory, "devices.json");
|
|
9019
9481
|
const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
|
|
9020
9482
|
if (initialRemoteProvider === "cpolar") try {
|
|
@@ -9048,7 +9510,6 @@ async function apply(ctx, config) {
|
|
|
9048
9510
|
store: cpolarStore,
|
|
9049
9511
|
executable: cpolarComponent.executable,
|
|
9050
9512
|
configFile: cpolarComponent.configFile,
|
|
9051
|
-
region: "cn",
|
|
9052
9513
|
createGateway: createRemoteGateway
|
|
9053
9514
|
}),
|
|
9054
9515
|
frp: new FrpController({
|
|
@@ -9061,16 +9522,51 @@ async function apply(ctx, config) {
|
|
|
9061
9522
|
};
|
|
9062
9523
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
9063
9524
|
const remoteController = () => remoteProviders.controller();
|
|
9525
|
+
for (const provider of [
|
|
9526
|
+
"tailscale",
|
|
9527
|
+
"cpolar",
|
|
9528
|
+
"frp"
|
|
9529
|
+
]) {
|
|
9530
|
+
const controller = remoteControllers[provider];
|
|
9531
|
+
taskEventHub.add({ broadcastTaskEvent: (event) => {
|
|
9532
|
+
controller.gateway()?.broadcastTaskEvent(event);
|
|
9533
|
+
} });
|
|
9534
|
+
}
|
|
9064
9535
|
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
9065
9536
|
tailscale: remoteControllers.tailscale.status(),
|
|
9066
9537
|
cpolar: remoteControllers.cpolar.status(),
|
|
9067
9538
|
frp: remoteControllers.frp.status()
|
|
9068
9539
|
}, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
|
|
9069
9540
|
const lanPayload = () => ({
|
|
9541
|
+
...loaded.kind === "unconfigured" ? {
|
|
9542
|
+
configured: preparedLanSetup !== void 0,
|
|
9543
|
+
restartRequired: preparedLanSetup !== void 0
|
|
9544
|
+
} : {},
|
|
9070
9545
|
running: lanController.isRunning(),
|
|
9071
9546
|
origin: lanGateway?.address().origin,
|
|
9547
|
+
...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
|
|
9072
9548
|
...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
|
|
9073
9549
|
});
|
|
9550
|
+
const lanSetupPayload = async () => {
|
|
9551
|
+
if (loaded.kind !== "unconfigured" || preparedLanSetup !== void 0) return lanPayload();
|
|
9552
|
+
const networks = availableLanNetworks();
|
|
9553
|
+
const preferred = await preferredLanInterfaceNames();
|
|
9554
|
+
let recommendedAddress;
|
|
9555
|
+
try {
|
|
9556
|
+
recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
|
|
9557
|
+
} catch {}
|
|
9558
|
+
return {
|
|
9559
|
+
...lanPayload(),
|
|
9560
|
+
networks: networks.map((network) => ({
|
|
9561
|
+
name: network.name,
|
|
9562
|
+
address: network.address,
|
|
9563
|
+
cidr: network.cidr,
|
|
9564
|
+
recommended: network.address === recommendedAddress
|
|
9565
|
+
})),
|
|
9566
|
+
listenPort: 3443,
|
|
9567
|
+
windowsFirewall: process.platform === "win32"
|
|
9568
|
+
};
|
|
9569
|
+
};
|
|
9074
9570
|
const diagnosticsPayload = async () => {
|
|
9075
9571
|
let interfaceName;
|
|
9076
9572
|
let networkError;
|
|
@@ -9083,6 +9579,7 @@ async function apply(ctx, config) {
|
|
|
9083
9579
|
return collectConnectionDiagnostics({
|
|
9084
9580
|
dshVersion,
|
|
9085
9581
|
lan: {
|
|
9582
|
+
configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
|
|
9086
9583
|
running: lanController.isRunning(),
|
|
9087
9584
|
...lanGateway === void 0 ? {} : {
|
|
9088
9585
|
origin: lanGateway.address().origin,
|
|
@@ -9117,6 +9614,10 @@ async function apply(ctx, config) {
|
|
|
9117
9614
|
sendJson(response, 200, lanPayload(), false);
|
|
9118
9615
|
return;
|
|
9119
9616
|
}
|
|
9617
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
|
|
9618
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9619
|
+
return;
|
|
9620
|
+
}
|
|
9120
9621
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
|
|
9121
9622
|
sendJson(response, 200, await diagnosticsPayload(), false);
|
|
9122
9623
|
return;
|
|
@@ -9133,10 +9634,37 @@ async function apply(ctx, config) {
|
|
|
9133
9634
|
if (request.method === "POST" && lanControl) {
|
|
9134
9635
|
const body = await readJsonObject(request, 4096);
|
|
9135
9636
|
if (typeof body.running !== "boolean") throw new HttpError(400, "bad_request");
|
|
9637
|
+
if (body.running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
9136
9638
|
await lanController.setRunning(body.running);
|
|
9137
9639
|
sendJson(response, 200, lanPayload(), false);
|
|
9138
9640
|
return;
|
|
9139
9641
|
}
|
|
9642
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
|
|
9643
|
+
const body = await readJsonObject(request, 4096);
|
|
9644
|
+
if (loaded.kind !== "unconfigured") throw new HttpError(409, "lan_setup_already_configured");
|
|
9645
|
+
if (preparedLanSetup !== void 0) {
|
|
9646
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9647
|
+
return;
|
|
9648
|
+
}
|
|
9649
|
+
if (body.confirm !== true || typeof body.address !== "string") throw new HttpError(400, "bad_request");
|
|
9650
|
+
const network = availableLanNetworks().find((candidate) => candidate.address === body.address);
|
|
9651
|
+
if (network === void 0) throw new HttpError(409, "lan_setup_network_unavailable");
|
|
9652
|
+
try {
|
|
9653
|
+
preparedLanSetup = await prepareManagedLanSetup({
|
|
9654
|
+
setupFile: loaded.setupFile,
|
|
9655
|
+
controlFile: lanControlFile,
|
|
9656
|
+
network,
|
|
9657
|
+
listenPort: 3443,
|
|
9658
|
+
dshPort: ctx.webServer.port,
|
|
9659
|
+
configureFirewall: body.configureFirewall !== false
|
|
9660
|
+
});
|
|
9661
|
+
} catch (error) {
|
|
9662
|
+
logger.error("managed LAN setup failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
|
|
9663
|
+
throw new HttpError(409, "lan_setup_failed");
|
|
9664
|
+
}
|
|
9665
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9666
|
+
return;
|
|
9667
|
+
}
|
|
9140
9668
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
|
|
9141
9669
|
sendJson(response, 200, remotePayload(), false);
|
|
9142
9670
|
return;
|
|
@@ -9403,6 +9931,7 @@ async function apply(ctx, config) {
|
|
|
9403
9931
|
await settleCleanupSteps([
|
|
9404
9932
|
unregister,
|
|
9405
9933
|
disposeMobileCommand,
|
|
9934
|
+
disposeTaskEvents,
|
|
9406
9935
|
async () => {
|
|
9407
9936
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9408
9937
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9420,6 +9949,7 @@ async function apply(ctx, config) {
|
|
|
9420
9949
|
await settleCleanupSteps([
|
|
9421
9950
|
unregister,
|
|
9422
9951
|
disposeMobileCommand,
|
|
9952
|
+
disposeTaskEvents,
|
|
9423
9953
|
async () => {
|
|
9424
9954
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9425
9955
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9432,6 +9962,6 @@ async function apply(ctx, config) {
|
|
|
9432
9962
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
9433
9963
|
}
|
|
9434
9964
|
//#endregion
|
|
9435
|
-
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath };
|
|
9965
|
+
export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateWebSocketPath, watchTaskCompletions };
|
|
9436
9966
|
|
|
9437
9967
|
//# sourceMappingURL=index.mjs.map
|