dsh-mobile 0.3.15 → 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 +30 -0
- package/README.en.md +62 -10
- package/README.md +72 -19
- 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 +653 -118
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js +89 -34
- package/lib/mobile-layout.js.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)]);
|
|
@@ -8708,7 +9078,6 @@ async function materializeManagedSetup(setup, table) {
|
|
|
8708
9078
|
return {
|
|
8709
9079
|
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
8710
9080
|
listenHost: network.address,
|
|
8711
|
-
upstreamOrigin: setup.upstreamOrigin,
|
|
8712
9081
|
allowedCidrs: [network.cidr],
|
|
8713
9082
|
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
8714
9083
|
pairingCaFile: setup.tls.caCertFile,
|
|
@@ -8720,6 +9089,79 @@ async function materializeManagedSetup(setup, table) {
|
|
|
8720
9089
|
};
|
|
8721
9090
|
}
|
|
8722
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
|
|
8723
9165
|
//#region src/plugin.ts
|
|
8724
9166
|
/** Stable Cordis plugin name. */
|
|
8725
9167
|
const name = "dsh-mobile";
|
|
@@ -8793,6 +9235,7 @@ function mapAdminError(error) {
|
|
|
8793
9235
|
if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
|
|
8794
9236
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
8795
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);
|
|
8796
9239
|
return new HttpError(500, "internal_error");
|
|
8797
9240
|
}
|
|
8798
9241
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -8832,8 +9275,9 @@ async function loadSetup(config) {
|
|
|
8832
9275
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
8833
9276
|
} catch (error) {
|
|
8834
9277
|
if (error.code === "ENOENT") return {
|
|
8835
|
-
kind: "
|
|
8836
|
-
config
|
|
9278
|
+
kind: "unconfigured",
|
|
9279
|
+
config,
|
|
9280
|
+
setupFile: resolve(config.setupFile)
|
|
8837
9281
|
};
|
|
8838
9282
|
throw error;
|
|
8839
9283
|
}
|
|
@@ -8852,6 +9296,7 @@ async function loadSetup(config) {
|
|
|
8852
9296
|
};
|
|
8853
9297
|
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");
|
|
8854
9298
|
const { version: _version, ...setup } = record;
|
|
9299
|
+
delete setup.upstreamOrigin;
|
|
8855
9300
|
return {
|
|
8856
9301
|
kind: "fixed",
|
|
8857
9302
|
config: {
|
|
@@ -8861,9 +9306,11 @@ async function loadSetup(config) {
|
|
|
8861
9306
|
};
|
|
8862
9307
|
}
|
|
8863
9308
|
function loopbackTemplate(loaded, webServerPort) {
|
|
9309
|
+
const base = withoutSetupKeys(loaded.config);
|
|
9310
|
+
const activeUpstreamOrigin = `http://127.0.0.1:${String(webServerPort)}`;
|
|
8864
9311
|
return parseGatewayConfig({
|
|
8865
|
-
...
|
|
8866
|
-
...loaded.kind === "managed" ? { upstreamOrigin:
|
|
9312
|
+
...base,
|
|
9313
|
+
...loaded.kind === "managed" ? { upstreamOrigin: activeUpstreamOrigin } : { upstreamOrigin: loaded.config.upstreamOrigin ?? activeUpstreamOrigin },
|
|
8867
9314
|
listenHost: "127.0.0.1",
|
|
8868
9315
|
listenPort: 0,
|
|
8869
9316
|
publicAuthorities: ["127.0.0.1"],
|
|
@@ -8937,6 +9384,13 @@ async function apply(ctx, config) {
|
|
|
8937
9384
|
const logFile = await installMobileFileLogger(ctx, stateDirectory);
|
|
8938
9385
|
const logger = ctx.logger("dsh-mobile");
|
|
8939
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
|
+
});
|
|
8940
9394
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
8941
9395
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
8942
9396
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -8982,17 +9436,24 @@ async function apply(ctx, config) {
|
|
|
8982
9436
|
await webSocketPaths.load();
|
|
8983
9437
|
const blockedUpgradePaths = new BlockedUpgradePathLog();
|
|
8984
9438
|
let lanGateway;
|
|
9439
|
+
let preparedLanSetup;
|
|
8985
9440
|
const startGateway = async (candidateConfig) => {
|
|
8986
|
-
const resolved = parseGatewayConfig(
|
|
9441
|
+
const resolved = parseGatewayConfig({
|
|
9442
|
+
...candidateConfig,
|
|
9443
|
+
upstreamOrigin: template.upstreamOrigin.origin
|
|
9444
|
+
});
|
|
8987
9445
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
|
|
8988
9446
|
await candidate.start();
|
|
8989
9447
|
lanGateway = candidate;
|
|
9448
|
+
const removeTaskSink = taskEventHub.add(candidate);
|
|
8990
9449
|
return { close: async () => {
|
|
8991
9450
|
if (lanGateway === candidate) lanGateway = void 0;
|
|
9451
|
+
removeTaskSink();
|
|
8992
9452
|
await candidate.close();
|
|
8993
9453
|
} };
|
|
8994
9454
|
};
|
|
8995
9455
|
const startRuntime = async () => {
|
|
9456
|
+
if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
8996
9457
|
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
8997
9458
|
const following = new FollowingMobileAccessRuntime(async () => {
|
|
8998
9459
|
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
@@ -9009,7 +9470,13 @@ async function apply(ctx, config) {
|
|
|
9009
9470
|
await following.initialize(2e3);
|
|
9010
9471
|
return following;
|
|
9011
9472
|
};
|
|
9012
|
-
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);
|
|
9013
9480
|
const remoteDeviceFile = join(remoteDirectory, "devices.json");
|
|
9014
9481
|
const legacyCpolarDeviceFile = join(remoteDirectory, "cpolar", "devices.json");
|
|
9015
9482
|
if (initialRemoteProvider === "cpolar") try {
|
|
@@ -9043,7 +9510,6 @@ async function apply(ctx, config) {
|
|
|
9043
9510
|
store: cpolarStore,
|
|
9044
9511
|
executable: cpolarComponent.executable,
|
|
9045
9512
|
configFile: cpolarComponent.configFile,
|
|
9046
|
-
region: "cn",
|
|
9047
9513
|
createGateway: createRemoteGateway
|
|
9048
9514
|
}),
|
|
9049
9515
|
frp: new FrpController({
|
|
@@ -9056,16 +9522,51 @@ async function apply(ctx, config) {
|
|
|
9056
9522
|
};
|
|
9057
9523
|
const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
|
|
9058
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
|
+
}
|
|
9059
9535
|
const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
|
|
9060
9536
|
tailscale: remoteControllers.tailscale.status(),
|
|
9061
9537
|
cpolar: remoteControllers.cpolar.status(),
|
|
9062
9538
|
frp: remoteControllers.frp.status()
|
|
9063
9539
|
}, cpolarComponent.status(), frpComponent.status(), frpConfig.status());
|
|
9064
9540
|
const lanPayload = () => ({
|
|
9541
|
+
...loaded.kind === "unconfigured" ? {
|
|
9542
|
+
configured: preparedLanSetup !== void 0,
|
|
9543
|
+
restartRequired: preparedLanSetup !== void 0
|
|
9544
|
+
} : {},
|
|
9065
9545
|
running: lanController.isRunning(),
|
|
9066
9546
|
origin: lanGateway?.address().origin,
|
|
9547
|
+
...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
|
|
9067
9548
|
...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
|
|
9068
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
|
+
};
|
|
9069
9570
|
const diagnosticsPayload = async () => {
|
|
9070
9571
|
let interfaceName;
|
|
9071
9572
|
let networkError;
|
|
@@ -9078,6 +9579,7 @@ async function apply(ctx, config) {
|
|
|
9078
9579
|
return collectConnectionDiagnostics({
|
|
9079
9580
|
dshVersion,
|
|
9080
9581
|
lan: {
|
|
9582
|
+
configured: loaded.kind !== "unconfigured" || preparedLanSetup !== void 0,
|
|
9081
9583
|
running: lanController.isRunning(),
|
|
9082
9584
|
...lanGateway === void 0 ? {} : {
|
|
9083
9585
|
origin: lanGateway.address().origin,
|
|
@@ -9112,6 +9614,10 @@ async function apply(ctx, config) {
|
|
|
9112
9614
|
sendJson(response, 200, lanPayload(), false);
|
|
9113
9615
|
return;
|
|
9114
9616
|
}
|
|
9617
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/setup`) {
|
|
9618
|
+
sendJson(response, 200, await lanSetupPayload(), false);
|
|
9619
|
+
return;
|
|
9620
|
+
}
|
|
9115
9621
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
|
|
9116
9622
|
sendJson(response, 200, await diagnosticsPayload(), false);
|
|
9117
9623
|
return;
|
|
@@ -9128,10 +9634,37 @@ async function apply(ctx, config) {
|
|
|
9128
9634
|
if (request.method === "POST" && lanControl) {
|
|
9129
9635
|
const body = await readJsonObject(request, 4096);
|
|
9130
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");
|
|
9131
9638
|
await lanController.setRunning(body.running);
|
|
9132
9639
|
sendJson(response, 200, lanPayload(), false);
|
|
9133
9640
|
return;
|
|
9134
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
|
+
}
|
|
9135
9668
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
|
|
9136
9669
|
sendJson(response, 200, remotePayload(), false);
|
|
9137
9670
|
return;
|
|
@@ -9398,6 +9931,7 @@ async function apply(ctx, config) {
|
|
|
9398
9931
|
await settleCleanupSteps([
|
|
9399
9932
|
unregister,
|
|
9400
9933
|
disposeMobileCommand,
|
|
9934
|
+
disposeTaskEvents,
|
|
9401
9935
|
async () => {
|
|
9402
9936
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9403
9937
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9415,6 +9949,7 @@ async function apply(ctx, config) {
|
|
|
9415
9949
|
await settleCleanupSteps([
|
|
9416
9950
|
unregister,
|
|
9417
9951
|
disposeMobileCommand,
|
|
9952
|
+
disposeTaskEvents,
|
|
9418
9953
|
async () => {
|
|
9419
9954
|
const failures = (await Promise.allSettled(Object.values(remoteControllers).map((controller) => controller.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
9420
9955
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
@@ -9427,6 +9962,6 @@ async function apply(ctx, config) {
|
|
|
9427
9962
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
9428
9963
|
}
|
|
9429
9964
|
//#endregion
|
|
9430
|
-
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 };
|
|
9431
9966
|
|
|
9432
9967
|
//# sourceMappingURL=index.mjs.map
|