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/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 || device.revokedAt !== void 0 || device.expiresAt <= now) throw new AccessError(401, "authentication_failed");
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.removeSessionListener = this.access.onSessionEnded((authorization) => {
2809
- this.abortSessionResources(authorization.sessionKey);
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
- const body = target.decodedPathname.endsWith(".js") ? PAIR_SCRIPT : PAIR_PAGE;
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
- const body = target.decodedPathname.endsWith(".js") ? LOGIN_SCRIPT : LOGIN_PAGE;
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.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
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 ?? "cn"}`,
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: "fixed",
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
- ...withoutSetupKeys(loaded.config),
8866
- ...loaded.kind === "managed" ? { upstreamOrigin: loaded.setup.upstreamOrigin } : { upstreamOrigin: loaded.config.upstreamOrigin ?? `http://127.0.0.1:${String(webServerPort)}` },
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(candidateConfig);
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 lanController = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
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