dsh-mobile 0.4.5 → 0.4.7

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
@@ -1,26 +1,27 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
3
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { connect, createServer, isIP } from "node:net";
7
- import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { connect, createServer } from "node:net";
7
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { createSocket } from "node:dgram";
10
10
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
11
11
  import { createServer as createServer$1, request } from "node:http";
12
- import { createServer as createServer$2 } from "node:https";
13
- import { Transform, finished } from "node:stream";
12
+ import { createServer as createServer$2, request as request$1 } from "node:https";
13
+ import { Readable, Transform, finished } from "node:stream";
14
14
  import { pipeline } from "node:stream/promises";
15
15
  import { promisify } from "node:util";
16
16
  import { createGzip, gzip } from "node:zlib";
17
17
  import Bonjour from "bonjour-service";
18
18
  import * as QRCode from "qrcode";
19
19
  import { Logger, Service } from "@deepseek-ai/cordis";
20
+ import { connect as connect$1 } from "node:tls";
21
+ import { generate } from "selfsigned";
20
22
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
21
23
  import { lookup } from "node:dns/promises";
22
24
  import { createWriteStream } from "node:fs";
23
- import { generate } from "selfsigned";
24
25
  //#region src/access.ts
25
26
  /** Stable error categories converted to deliberately terse HTTP responses. */
26
27
  var AccessError = class extends Error {
@@ -384,6 +385,88 @@ var AccessController = class {
384
385
  }
385
386
  };
386
387
  //#endregion
388
+ //#region src/ip.ts
389
+ /**
390
+ * Browser-safe IP literal classification.
391
+ *
392
+ * `node:net` cannot be imported from the browser Client face, so the pure
393
+ * predicate lives here instead. Semantics are deliberately identical to
394
+ * `net.isIP`: strict dotted-quad IPv4 (no leading zeros, no signs, no
395
+ * whitespace), IPv6 with at most one `::`, at most four hex digits per group,
396
+ * an optional dotted-quad tail in the final position, and an optional
397
+ * non-empty zone id after a single `%`.
398
+ *
399
+ * This module is dependency-free and side-effect-free on purpose: it is the
400
+ * single classification source shared by every face of the plugin — the Node
401
+ * side (`config.ts`, `cloudflared-tunnel.ts`, `origin-proxy-config.ts`,
402
+ * `vps-deploy.ts`, `gateway.ts`, `network.ts`, `frp-config.ts`) and the browser
403
+ * Client face that cannot reach `node:net` — so both classify a given literal
404
+ * identically. Callers rely on the strict reading above and never post-process
405
+ * the literal afterwards, so changing the accept/reject boundary here changes
406
+ * it everywhere, which is the intended contract.
407
+ */
408
+ /** Strict dotted-quad IPv4: POSIX `inet_pton` rules, so `01.1.1.1` is rejected. */
409
+ function isStrictIpv4(value) {
410
+ const parts = value.split(".");
411
+ if (parts.length !== 4) return false;
412
+ for (const part of parts) {
413
+ if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(part)) return false;
414
+ if (Number(part) > 255) return false;
415
+ }
416
+ return true;
417
+ }
418
+ /**
419
+ * Count the 16-bit groups one side of an IPv6 address occupies.
420
+ *
421
+ * A dotted-quad tail counts as two groups and is accepted only where the caller
422
+ * allows it — that is, only in the final label of the whole address.
423
+ *
424
+ * @returns the group count, or `-1` when any label is malformed.
425
+ */
426
+ function countIpv6Groups(labels, allowDottedQuad) {
427
+ let groups = 0;
428
+ for (let index = 0; index < labels.length; index += 1) {
429
+ const label = labels[index];
430
+ if (label.includes(".")) {
431
+ if (index !== labels.length - 1 || !allowDottedQuad || !isStrictIpv4(label)) return -1;
432
+ groups += 2;
433
+ continue;
434
+ }
435
+ if (!/^[\da-f]{1,4}$/iu.test(label)) return -1;
436
+ groups += 1;
437
+ }
438
+ return groups;
439
+ }
440
+ /**
441
+ * Classify an IP literal.
442
+ *
443
+ * @param value - candidate address; non-strings are never an IP.
444
+ * @returns `4` for IPv4, `6` for IPv6, `0` when the value is neither.
445
+ */
446
+ function isIP(value) {
447
+ if (typeof value !== "string") return 0;
448
+ if (isStrictIpv4(value)) return 4;
449
+ let text = value;
450
+ const zone = text.indexOf("%");
451
+ if (zone !== -1) {
452
+ const suffix = text.slice(zone + 1);
453
+ if (suffix.length === 0 || suffix.includes("%")) return 0;
454
+ text = text.slice(0, zone);
455
+ }
456
+ if (text.length === 0) return 0;
457
+ const halves = text.split("::");
458
+ if (halves.length > 2) return 0;
459
+ if (halves.length === 1) return countIpv6Groups(text.split(":"), true) === 8 ? 6 : 0;
460
+ const leftText = halves[0];
461
+ const rightText = halves[1];
462
+ const left = leftText === "" ? [] : leftText.split(":");
463
+ const right = rightText === "" ? [] : rightText.split(":");
464
+ const leftGroups = countIpv6Groups(left, false);
465
+ const rightGroups = countIpv6Groups(right, right.length > 0);
466
+ if (leftGroups < 0 || rightGroups < 0) return 0;
467
+ return leftGroups + rightGroups >= 8 ? 0 : 6;
468
+ }
469
+ //#endregion
387
470
  //#region src/network.ts
388
471
  function parseIpv4$1(address) {
389
472
  const parts = address.split(".");
@@ -624,6 +707,43 @@ var RequestTrustPolicy = class {
624
707
  return normalized;
625
708
  }
626
709
  };
710
+ /**
711
+ * Open a plain TCP connection only to learn whether something is listening.
712
+ *
713
+ * Used by the on-demand self-check so the panel can tell "frps is up" from
714
+ * "frps is not listening". A transparent proxy can acknowledge any connect, so
715
+ * this result is advisory and never authorises a security decision on its own.
716
+ */
717
+ async function probeTcpReachable(host, port, timeoutMs = 1500) {
718
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return false;
719
+ return new Promise((resolveProbe) => {
720
+ let finished = false;
721
+ const socket = connect({
722
+ host,
723
+ port
724
+ });
725
+ const finish = (reachable) => {
726
+ if (finished) return;
727
+ finished = true;
728
+ clearTimeout(timer);
729
+ socket.destroy();
730
+ resolveProbe(reachable);
731
+ };
732
+ const timer = setTimeout(() => {
733
+ finish(false);
734
+ }, timeoutMs);
735
+ timer.unref();
736
+ socket.once("connect", () => {
737
+ finish(true);
738
+ });
739
+ socket.once("error", () => {
740
+ finish(false);
741
+ });
742
+ socket.once("close", () => {
743
+ finish(false);
744
+ });
745
+ });
746
+ }
627
747
  //#endregion
628
748
  //#region src/config.ts
629
749
  /** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
@@ -641,6 +761,7 @@ const Config = z.object({
641
761
  customScriptFile: z.string().hidden(),
642
762
  mobileLayoutFile: z.string().hidden(),
643
763
  mobileCompatibilityFile: z.string().hidden(),
764
+ excludedClientModules: z.array(String).default([]),
644
765
  instanceId: z.string().hidden(),
645
766
  pairingCaFile: z.string().hidden(),
646
767
  initiallyEnabled: z.boolean().hidden().required(),
@@ -677,6 +798,13 @@ function absoluteFile(value, name) {
677
798
  if (typeof value !== "string" || value.length === 0 || !isAbsolute(value)) throw new Error(`${name} must be an absolute file path`);
678
799
  return resolve(value);
679
800
  }
801
+ function excludedClientModules(value) {
802
+ if (value === void 0) return Object.freeze([]);
803
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id)) throw new Error("excludedClientModules must be an array of exact, non-empty client module ids");
804
+ const ids = value;
805
+ if (new Set(ids).size !== ids.length) throw new Error("excludedClientModules must not contain duplicate ids");
806
+ return Object.freeze([...ids]);
807
+ }
680
808
  /** Resolve the hidden runtime-control file independently from gateway configuration. */
681
809
  function parseControlFile(value) {
682
810
  return absoluteFile(value, "controlFile");
@@ -767,6 +895,7 @@ function parseGatewayConfig(raw) {
767
895
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
768
896
  mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
769
897
  mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
898
+ excludedClientModules: excludedClientModules(value.excludedClientModules),
770
899
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
771
900
  throw new Error("instanceId must be a lowercase SHA-256 value");
772
901
  })(),
@@ -817,8 +946,13 @@ function execFileText(file, args, options = {}) {
817
946
  //#endregion
818
947
  //#region src/private-file.ts
819
948
  let userSidTask;
949
+ function windowsSystemTool(name) {
950
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
951
+ if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
952
+ return win32.join(systemRoot, "System32", name);
953
+ }
820
954
  async function currentWindowsUserSid() {
821
- userSidTask ??= execFileText("whoami.exe", [
955
+ userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
822
956
  "/user",
823
957
  "/fo",
824
958
  "csv",
@@ -841,11 +975,12 @@ async function currentWindowsUserSid() {
841
975
  async function restrictPrivateFile(file, mode = 384) {
842
976
  await chmod(file, mode);
843
977
  if (process.platform !== "win32") return;
844
- await execFileText("icacls.exe", [
978
+ const userSid = await currentWindowsUserSid();
979
+ await execFileText(windowsSystemTool("icacls.exe"), [
845
980
  file,
846
981
  "/inheritance:r",
847
982
  "/grant:r",
848
- `*${await currentWindowsUserSid()}:(F)`,
983
+ `*${userSid}:(F)`,
849
984
  "*S-1-5-18:(F)",
850
985
  "*S-1-5-32-544:(F)",
851
986
  "/remove:g",
@@ -2488,6 +2623,17 @@ const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
2488
2623
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
2489
2624
  const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
2490
2625
  const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
2626
+ const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
2627
+ MOBILE_CLIENT_MODULE,
2628
+ MOBILE_LAYOUT_MODULE,
2629
+ CONNECTION_MODULE,
2630
+ RUNTIME_MODULE,
2631
+ RENDERER_MODULE,
2632
+ SIDEBAR_MODULE,
2633
+ SETTINGS_MODULE,
2634
+ API_GATEWAY_MODULE,
2635
+ API_REMOTES_MODULE
2636
+ ]);
2491
2637
  const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2492
2638
  slots: RUNTIME_MODULE,
2493
2639
  dependencies: Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"])
@@ -2502,6 +2648,57 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2502
2648
  })]);
2503
2649
  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})};})();`;
2504
2650
  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}})();`;
2651
+ /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
2652
+ const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
2653
+ const view=globalThis;
2654
+ const port=view.navigator;
2655
+ const nativeFetch=typeof view.fetch==='function'?view.fetch.bind(view):undefined;
2656
+ let reachable=true;
2657
+ let checking=false;
2658
+ let replaying=false;
2659
+ let disposed=false;
2660
+ let epoch=0;
2661
+ let retryTimer;
2662
+ let activeController;
2663
+ const clearRetry=()=>{if(retryTimer!==undefined){view.clearTimeout(retryTimer);retryTimer=undefined}};
2664
+ const scheduleRetry=()=>{if(!disposed&&!reachable&&retryTimer===undefined){retryTimer=view.setTimeout(()=>{retryTimer=undefined;check()},5000)}};
2665
+ const check=()=>{
2666
+ if(checking||disposed||nativeFetch===undefined)return;
2667
+ checking=true;
2668
+ const current=++epoch;
2669
+ const controller=new AbortController();
2670
+ activeController=controller;
2671
+ const timer=view.setTimeout(()=>controller.abort(),2000);
2672
+ void nativeFetch('/mobile-access/health',{cache:'no-store',credentials:'same-origin',signal:controller.signal})
2673
+ .then(response=>response.ok,()=>false)
2674
+ .then(ok=>{
2675
+ if(disposed||current!==epoch)return;
2676
+ if(ok){
2677
+ const wasOffline=!reachable;
2678
+ reachable=true;
2679
+ clearRetry();
2680
+ if(wasOffline)view.dispatchEvent(new Event('online'));
2681
+ return;
2682
+ }
2683
+ const wasOnline=reachable;
2684
+ reachable=false;
2685
+ if(wasOnline){
2686
+ replaying=true;
2687
+ try{view.dispatchEvent(new Event('offline'))}finally{replaying=false}
2688
+ }
2689
+ scheduleRetry();
2690
+ })
2691
+ .finally(()=>{view.clearTimeout(timer);activeController=undefined;checking=false});
2692
+ };
2693
+ if(port!==undefined&&typeof Object.defineProperty==='function'){
2694
+ try{Object.defineProperty(port,'onLine',{configurable:true,get:()=>reachable})}catch{/* A frozen navigator retains its native value. */}
2695
+ }
2696
+ if(typeof view.addEventListener==='function'){
2697
+ view.addEventListener('offline',event=>{if(replaying||nativeFetch===undefined)return;event.stopImmediatePropagation();check()},true);
2698
+ view.addEventListener('online',()=>{++epoch;reachable=true;clearRetry()},true);
2699
+ view.addEventListener('pagehide',()=>{disposed=true;++epoch;clearRetry();activeController?.abort()},true);
2700
+ }
2701
+ })();`;
2505
2702
  const gzipBuffer = promisify(gzip);
2506
2703
  function ensureMobileViewport(html) {
2507
2704
  const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
@@ -2542,13 +2739,49 @@ function revisionedMobileBatchPath(entries) {
2542
2739
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2543
2740
  };
2544
2741
  }
2742
+ var ExcludedClientModulesError = class extends Error {
2743
+ constructor(message) {
2744
+ super(`excludedClientModules: ${message}`);
2745
+ }
2746
+ };
2747
+ /** Validate graph edges before removing application rows from both manifest views. */
2748
+ function excludeMobileBootEntries(entries, batches, excludedIds) {
2749
+ if (excludedIds.length === 0) return;
2750
+ const excluded = new Set(excludedIds);
2751
+ if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
2752
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
2753
+ const phases = /* @__PURE__ */ new Map();
2754
+ for (const batch of batches) for (const id of batch.entries) {
2755
+ if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
2756
+ phases.set(id, batch.phase);
2757
+ }
2758
+ for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
2759
+ for (const id of excluded) {
2760
+ const entry = byId.get(id);
2761
+ if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
2762
+ if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
2763
+ }
2764
+ for (const entry of entries) {
2765
+ if (excluded.has(entry.id)) continue;
2766
+ for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
2767
+ if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
2768
+ for (const dependency of edges ?? []) {
2769
+ const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
2770
+ if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
2771
+ }
2772
+ }
2773
+ }
2774
+ entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
2775
+ for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
2776
+ batches.splice(0, batches.length, ...batches.filter((batch) => batch.entries.length > 0));
2777
+ }
2545
2778
  /**
2546
2779
  * Parse one upstream boot manifest and rewire the layout entry onto the
2547
- * gateway-owned layout module. The layout entry's URL and revision become the
2548
- * local `mobile-layout.js` path; the dedicated frontend is authenticated in the
2549
- * gateway, so the requested layout needs no upstream address.
2780
+ * gateway-owned layout module. Optional exclusions remove application entries
2781
+ * and identify every affected batch for reassembly. The dedicated frontend is
2782
+ * authenticated in the gateway, so the layout needs no upstream address.
2550
2783
  */
2551
- function parseMobileBootPlan(html) {
2784
+ function parseMobileBootPlan(html, excludedClientModules = []) {
2552
2785
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2553
2786
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2554
2787
  const replaceStart = assignment.index;
@@ -2567,14 +2800,17 @@ function parseMobileBootPlan(html) {
2567
2800
  layout[0].url = MOBILE_LAYOUT_PATH;
2568
2801
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2569
2802
  const parsedBatches = parsed.batches;
2570
- if (parsedBatches === void 0) return Object.freeze({
2571
- parsed,
2572
- entries,
2573
- slotProvider: dependencyProfile.slots,
2574
- assignment: assignment[0],
2575
- replaceStart,
2576
- replaceEnd: scriptEnd
2577
- });
2803
+ if (parsedBatches === void 0) {
2804
+ if (excludedClientModules.length > 0) throw new ExcludedClientModulesError("this DSH version has no batched boot manifest; disable exclusions or update DSH");
2805
+ return Object.freeze({
2806
+ parsed,
2807
+ entries,
2808
+ slotProvider: dependencyProfile.slots,
2809
+ assignment: assignment[0],
2810
+ replaceStart,
2811
+ replaceEnd: scriptEnd
2812
+ });
2813
+ }
2578
2814
  if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
2579
2815
  const batches = parsedBatches;
2580
2816
  const entryById = new Map(entries.map((entry) => [entry.id, entry]));
@@ -2586,15 +2822,26 @@ function parseMobileBootPlan(html) {
2586
2822
  }
2587
2823
  if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2588
2824
  const layoutBatch = layoutBatches[0];
2589
- const planEntries = layoutBatch.entries.map((id) => {
2590
- const entry = entryById.get(id);
2591
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2592
- return Object.freeze({
2593
- id,
2594
- url: entry.url,
2595
- rev: entry.rev
2596
- });
2597
- });
2825
+ const originalApplicationBatches = batches.filter((batch) => batch.phase === "application").map((batch) => ({
2826
+ ...batch,
2827
+ entries: [...batch.entries]
2828
+ }));
2829
+ excludeMobileBootEntries(entries, batches, excludedClientModules);
2830
+ const affectedBatchUrls = new Set(originalApplicationBatches.filter((batch) => batch.entries.some((id) => excludedClientModules.includes(id))).map((batch) => batch.url));
2831
+ const rewrittenBatchEntries = batches.filter((batch) => batch === layoutBatch || batch.phase === "application" && affectedBatchUrls.has(batch.url)).map((batch) => ({
2832
+ batch,
2833
+ entries: [...batch.entries].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).map((id) => {
2834
+ const entry = entryById.get(id);
2835
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2836
+ return Object.freeze({
2837
+ id,
2838
+ url: entry.url,
2839
+ rev: entry.rev
2840
+ });
2841
+ })
2842
+ }));
2843
+ const planEntries = rewrittenBatchEntries.find((candidate) => candidate.batch === layoutBatch)?.entries;
2844
+ if (planEntries === void 0) throw new Error("upstream DSH boot manifest lost the application layout batch");
2598
2845
  return Object.freeze({
2599
2846
  parsed,
2600
2847
  entries,
@@ -2604,7 +2851,9 @@ function parseMobileBootPlan(html) {
2604
2851
  replaceStart,
2605
2852
  replaceEnd: scriptEnd,
2606
2853
  layoutBatch,
2607
- planEntries
2854
+ planEntries,
2855
+ originalApplicationBatches,
2856
+ rewrittenBatchEntries
2608
2857
  });
2609
2858
  }
2610
2859
  /**
@@ -2663,30 +2912,62 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
2663
2912
  rows
2664
2913
  };
2665
2914
  }
2915
+ function escapeMobilePreloadAttribute(value) {
2916
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2917
+ }
2918
+ function mobileApplicationPreload(url) {
2919
+ return `<link rel="preload" as="script" href="${escapeMobilePreloadAttribute(url)}">`;
2920
+ }
2921
+ function preloadAttribute(tag, name) {
2922
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "iu").exec(tag);
2923
+ return match?.[1] ?? match?.[2];
2924
+ }
2925
+ /** Replace only DSH's application-script preloads; bootstrap and unrelated links are untouched. */
2926
+ function rewriteMobileApplicationPreloads(html, replacements) {
2927
+ if (replacements.size === 0) return html;
2928
+ const escaped = new Map([...replacements].map(([url, markup]) => [escapeMobilePreloadAttribute(url), markup]));
2929
+ return html.replace(/<link\b[^>]*>/giu, (tag) => {
2930
+ if (!preloadAttribute(tag, "rel")?.split(/\s+/u).includes("preload") || preloadAttribute(tag, "as") !== "script") return tag;
2931
+ return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
2932
+ });
2933
+ }
2666
2934
  function rewriteMobileIndexWithBatch(html, options = {}) {
2667
- const plan = parseMobileBootPlan(html);
2935
+ const plan = parseMobileBootPlan(html, options.excludedClientModules);
2668
2936
  const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
2669
2937
  let batches = [];
2670
- if (plan.batches !== void 0 && plan.layoutBatch !== void 0 && plan.planEntries !== void 0) {
2671
- const split = splitMobileBootBatch(plan.planEntries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set());
2672
- const at = plan.batches.indexOf(plan.layoutBatch);
2673
- if (at < 0) throw new Error("upstream DSH boot manifest has no unique application layout batch");
2674
- plan.batches.splice(at, 1, ...split.rows);
2675
- batches = Object.freeze(split.plans);
2938
+ let preloadReplacements = /* @__PURE__ */ new Map();
2939
+ if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
2940
+ const rewrites = plan.rewrittenBatchEntries.map(({ batch, entries }) => ({
2941
+ batch,
2942
+ split: splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())
2943
+ }));
2944
+ if (rewrites.reduce((count, item) => count + item.split.plans.length, 0) > MAX_MOBILE_BOOT_BATCHES) throw new Error(`upstream DSH mobile boot requires more than ${String(MAX_MOBILE_BOOT_BATCHES)} merged batches`);
2945
+ for (const { batch, split } of rewrites) {
2946
+ const at = plan.batches.indexOf(batch);
2947
+ if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
2948
+ plan.batches.splice(at, 1, ...split.rows);
2949
+ batches = [...batches, ...split.plans];
2950
+ }
2951
+ for (const original of plan.originalApplicationBatches ?? []) {
2952
+ const replacement = rewrites.find((item) => item.batch.url === original.url);
2953
+ preloadReplacements.set(original.url, replacement === void 0 ? plan.batches.some((batch) => batch.url === original.url) ? mobileApplicationPreload(original.url) : "" : replacement.split.rows.map((row) => mobileApplicationPreload(row.url)).join(""));
2954
+ }
2955
+ batches = Object.freeze(batches);
2676
2956
  plan.parsed.rev = createHash("sha256").update(JSON.stringify({
2677
2957
  entries: plan.entries,
2678
2958
  batches: plan.batches
2679
2959
  })).digest("hex").slice(0, 16);
2680
2960
  }
2681
- const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2961
+ const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2962
+ const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
2682
2963
  return Object.freeze({
2683
- html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2964
+ html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
2684
2965
  batches
2685
2966
  });
2686
2967
  }
2687
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
2688
- function rewriteMobileIndex(html) {
2689
- return rewriteMobileIndexWithBatch(html).html;
2968
+ /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
2969
+ function rewriteMobileIndex(html, excludedClientModules = []) {
2970
+ return rewriteMobileIndexWithBatch(html, { excludedClientModules }).html;
2690
2971
  }
2691
2972
  var ByteLimitTransform = class extends Transform {
2692
2973
  maximum;
@@ -3130,6 +3411,7 @@ var MobileAccessGateway = class {
3130
3411
  mdnsError;
3131
3412
  /** Names of the bundled mobile assets that could not be read, if any. */
3132
3413
  mobileAssetError;
3414
+ lastModuleExclusionError;
3133
3415
  bonjour;
3134
3416
  pairingCaCertificate;
3135
3417
  listenerPort;
@@ -3277,6 +3559,15 @@ var MobileAccessGateway = class {
3277
3559
  throw error;
3278
3560
  }
3279
3561
  }
3562
+ /** Install a newly signed provided leaf for future TLS handshakes without dropping sessions. */
3563
+ async refreshProvidedTls() {
3564
+ if (!this.listenerTlsEnabled || this.config.tls.mode !== "provided") throw new Error("gateway does not terminate provided TLS");
3565
+ const server = this.server;
3566
+ if (server === void 0 || !this.started || this.closing) throw new Error("gateway is not running");
3567
+ const options = await tlsOptions(this.config);
3568
+ if (this.server !== server || this.closing) throw new Error("gateway is not running");
3569
+ server.setSecureContext(options);
3570
+ }
3280
3571
  async startDiscovery(port) {
3281
3572
  const socket = createSocket("udp4");
3282
3573
  this.discoverySocket = socket;
@@ -4052,11 +4343,11 @@ var MobileAccessGateway = class {
4052
4343
  this.upstreamAuthRequest = void 0;
4053
4344
  }
4054
4345
  }
4055
- async proxyMobileIndex(request$1, response, authorization) {
4346
+ async proxyMobileIndex(request$2, response, authorization) {
4056
4347
  const holder = {};
4057
4348
  const operation = this.allocateRequest(authorization, response, holder);
4058
4349
  try {
4059
- const upstreamHeaders = sanitizeRequestHeaders(request$1, this.config.upstreamOrigin);
4350
+ const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
4060
4351
  const upstreamCookie = await this.upstreamCookieHeader();
4061
4352
  if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4062
4353
  upstreamHeaders["accept-encoding"] = "identity";
@@ -4090,11 +4381,26 @@ var MobileAccessGateway = class {
4090
4381
  let body;
4091
4382
  try {
4092
4383
  const html = Buffer.concat(chunks).toString("utf8");
4093
- const plan = parseMobileBootPlan(html);
4094
- const rewritten = rewriteMobileIndexWithBatch(html, plan.planEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.planEntries));
4384
+ const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4385
+ const rewritten = rewriteMobileIndexWithBatch(html, plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4386
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4387
+ excludedClientModules: this.config.excludedClientModules
4388
+ });
4095
4389
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
4096
4390
  body = Buffer.from(rewritten.html);
4097
- } catch {
4391
+ } catch (error) {
4392
+ if (error instanceof ExcludedClientModulesError) {
4393
+ const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
4394
+ if (detail !== this.lastModuleExclusionError) {
4395
+ this.lastModuleExclusionError = detail;
4396
+ process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
4397
+ }
4398
+ sendJson(response, 409, {
4399
+ error: "excluded_client_modules_invalid",
4400
+ detail
4401
+ }, this.tlsEnabled);
4402
+ return;
4403
+ }
4098
4404
  throw new HttpError(502, "upstream_unavailable");
4099
4405
  }
4100
4406
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
@@ -4440,15 +4746,15 @@ var MobileAccessGateway = class {
4440
4746
  }
4441
4747
  throw new HttpError(502, "upstream_unavailable");
4442
4748
  }
4443
- async proxyHttpOnce(request$2, response, authorization) {
4444
- const declared = request$2.headers["content-length"];
4749
+ async proxyHttpOnce(request$3, response, authorization) {
4750
+ const declared = request$3.headers["content-length"];
4445
4751
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
4446
4752
  const holder = {};
4447
4753
  const operation = this.allocateRequest(authorization, response, holder);
4448
4754
  let bodyDone;
4449
4755
  try {
4450
- const bufferedBody = request$2.method === "POST" && request$2.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$2, await readBoundedBody(request$2, this.config.maxBodyBytes)) : void 0;
4451
- const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
4756
+ const bufferedBody = request$3.method === "POST" && request$3.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$3, await readBoundedBody(request$3, this.config.maxBodyBytes)) : void 0;
4757
+ const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
4452
4758
  const upstreamCookie = await this.upstreamCookieHeader();
4453
4759
  if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4454
4760
  if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
@@ -4457,8 +4763,8 @@ var MobileAccessGateway = class {
4457
4763
  protocol: "http:",
4458
4764
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4459
4765
  port: Number(this.config.upstreamOrigin.port),
4460
- method: request$2.method,
4461
- path: request$2.url,
4766
+ method: request$3.method,
4767
+ path: request$3.url,
4462
4768
  headers: upstreamHeaders,
4463
4769
  agent: false
4464
4770
  });
@@ -4468,7 +4774,7 @@ var MobileAccessGateway = class {
4468
4774
  });
4469
4775
  upstreamRequest.once("response", resolve);
4470
4776
  upstreamRequest.once("error", reject);
4471
- if (bufferedBody === void 0) bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4777
+ if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4472
4778
  else {
4473
4779
  upstreamRequest.end(bufferedBody);
4474
4780
  bodyDone = Promise.resolve();
@@ -4478,9 +4784,9 @@ var MobileAccessGateway = class {
4478
4784
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
4479
4785
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
4480
4786
  const statusCode = proxied.statusCode ?? 502;
4481
- const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
4787
+ const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
4482
4788
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
4483
- const compressed = shouldCompressResponse(request$2, proxied);
4789
+ const compressed = shouldCompressResponse(request$3, proxied);
4484
4790
  if (compressed) {
4485
4791
  delete headers["accept-ranges"];
4486
4792
  delete headers["content-length"];
@@ -4810,7 +5116,10 @@ var MobileAccessGateway = class {
4810
5116
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
4811
5117
  if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
4812
5118
  const opened = await this.access.openPairing(body.ttlMs);
4813
- const pairUrl = `${this.address().origin}/mobile-access/pair#instance=${this.config.instanceId}&token=${opened.token}`;
5119
+ const requiresRemoteCa = this.config.pairingCaFile !== void 0 && !this.config.discovery;
5120
+ const appKey = `dsh${requiresRemoteCa ? "2" : "1"}.${this.config.instanceId}.${opened.token}`;
5121
+ const fragment = requiresRemoteCa ? `key=${appKey}` : `instance=${this.config.instanceId}&token=${opened.token}`;
5122
+ const pairUrl = `${this.address().origin}/mobile-access/pair#${fragment}`;
4814
5123
  const appPairUrl = pairUrl;
4815
5124
  let qrSvg = "";
4816
5125
  try {
@@ -4821,7 +5130,7 @@ var MobileAccessGateway = class {
4821
5130
  } catch {}
4822
5131
  sendJson(response, 201, {
4823
5132
  ...opened,
4824
- appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
5133
+ appKey,
4825
5134
  pairUrl,
4826
5135
  appPairUrl,
4827
5136
  qrSvg
@@ -5486,8 +5795,6 @@ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
5486
5795
  const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
5487
5796
  /** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
5488
5797
  const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
5489
- /** Directory holding the public-IPv4 certificates installed by certbot. */
5490
- const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
5491
5798
  function publicIpv4Address(value) {
5492
5799
  const parts = value.split(".");
5493
5800
  return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
@@ -5505,8 +5812,31 @@ function parsePublicOrigin(publicOrigin) {
5505
5812
  if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicIpv4Address(url.hostname) && !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
5506
5813
  return url.hostname;
5507
5814
  }
5508
- /** Build the Caddy site for one public host (without markers or import wiring). */
5509
- function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
5815
+ function resolveVhostHttpPort(options) {
5816
+ const port = options.vhostHttpPort ?? 7080;
5817
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_template_input_invalid");
5818
+ return port;
5819
+ }
5820
+ /**
5821
+ * A self-signed entry deliberately never reaches Caddy: the tunnel is a raw TCP
5822
+ * passthrough and the DSH gateway is the TLS endpoint. Refuse loudly instead of
5823
+ * emitting a Caddy site that would need the gateway's private CA on the VPS.
5824
+ */
5825
+ function rejectSelfSignedCaddySite(options) {
5826
+ if (options.entryTls === "self-signed") throw new Error("frp_entry_tls_invalid");
5827
+ }
5828
+ /**
5829
+ * Build the Caddy site for one public host (without markers or import wiring).
5830
+ *
5831
+ * The second parameter accepts either a bare certificate directory (the legacy
5832
+ * signature) or a full {@link CaddySiteOptions} object, so existing callers keep
5833
+ * working unchanged.
5834
+ */
5835
+ function createCaddySite(publicHost, options = {}) {
5836
+ const resolved = typeof options === "string" ? { certDir: options } : options;
5837
+ rejectSelfSignedCaddySite(resolved);
5838
+ const certDir = resolved.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
5839
+ const vhostHttpPort = resolveVhostHttpPort(resolved);
5510
5840
  if (publicIpv4Address(publicHost)) return [
5511
5841
  "{",
5512
5842
  ` default_sni ${publicHost}`,
@@ -5518,14 +5848,14 @@ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
5518
5848
  "",
5519
5849
  `https://${publicHost} {`,
5520
5850
  ` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
5521
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
5851
+ ` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
5522
5852
  "}",
5523
5853
  ""
5524
5854
  ].join("\n");
5525
5855
  if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
5526
5856
  return [
5527
5857
  `${publicHost} {`,
5528
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
5858
+ ` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
5529
5859
  "}",
5530
5860
  ""
5531
5861
  ].join("\n");
@@ -5548,15 +5878,24 @@ function manualIpCertificateGuide(publicHost) {
5548
5878
  "#"
5549
5879
  ].join("\n");
5550
5880
  }
5551
- /** Build the only supported frps config and Caddy snippet from validated user inputs. */
5552
- function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5881
+ /**
5882
+ * Build the only supported frps config and Caddy snippet from validated user inputs.
5883
+ *
5884
+ * Default `options` reproduce the upstream artefact byte for byte; `attach` mode
5885
+ * passes the user's real vhost port, and `self-signed` is rejected because that
5886
+ * mode publishes a raw TCP proxy instead of an HTTP vhost.
5887
+ */
5888
+ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, options = {}) {
5553
5889
  if (!Number.isSafeInteger(serverPort) || serverPort < 1 || serverPort > 65535 || token.length < 16 || token.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(token)) throw new Error("frp_template_input_invalid");
5890
+ rejectSelfSignedCaddySite(options);
5891
+ const vhostHttpPort = resolveVhostHttpPort(options);
5892
+ const certDir = options.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
5554
5893
  const publicHost = parsePublicOrigin(publicOrigin);
5555
5894
  const lines = [
5556
5895
  "# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
5557
5896
  `bindPort = ${String(serverPort)}`,
5558
5897
  "proxyBindAddr = \"127.0.0.1\"",
5559
- `vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
5898
+ `vhostHTTPPort = ${String(vhostHttpPort)}`,
5560
5899
  "auth.method = \"token\"",
5561
5900
  `auth.token = ${JSON.stringify(token)}`,
5562
5901
  "",
@@ -5567,7 +5906,10 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5567
5906
  "# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
5568
5907
  "# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
5569
5908
  `${FRP_CADDY_SNIPPET_MARKER}`,
5570
- createCaddySite(publicHost).trimEnd(),
5909
+ createCaddySite(publicHost, {
5910
+ certDir,
5911
+ vhostHttpPort
5912
+ }).trimEnd(),
5571
5913
  ""
5572
5914
  ];
5573
5915
  if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
@@ -5576,6 +5918,26 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5576
5918
  //#endregion
5577
5919
  //#region src/frp-config.ts
5578
5920
  const MAX_SETTINGS_BYTES$1 = 8192;
5921
+ /** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
5922
+ const FRP_DEFAULT_PUBLIC_PORT = 33080;
5923
+ /** Ports owned by other DSH Mobile listeners; the public entry may never reuse them. */
5924
+ const FRP_RESERVED_PORTS = Object.freeze([
5925
+ 3080,
5926
+ 3443,
5927
+ 3444
5928
+ ]);
5929
+ /** Every key `settings.json` may contain. Unknown keys stay rejected. */
5930
+ const FRP_SETTINGS_KEYS = /* @__PURE__ */ new Set([
5931
+ "version",
5932
+ "serverAddress",
5933
+ "serverPort",
5934
+ "token",
5935
+ "publicOrigin",
5936
+ "mode",
5937
+ "entryTls",
5938
+ "vhostHttpPort",
5939
+ "publicPort"
5940
+ ]);
5579
5941
  function hostname$2(value) {
5580
5942
  if (value.length > 253 || !value.includes(".")) return false;
5581
5943
  return value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
@@ -5611,24 +5973,90 @@ function validateFrpPublicOrigin(value) {
5611
5973
  if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
5612
5974
  return url.origin;
5613
5975
  }
5614
- /** Parse FRP settings at the loopback request and filesystem boundaries. */
5976
+ /** Validate the provisioning mode. */
5977
+ function validateFrpMode(value) {
5978
+ if (value !== "deploy" && value !== "attach") throw new Error("frp_settings_invalid");
5979
+ return value;
5980
+ }
5981
+ /** Validate the entry TLS mode. */
5982
+ function validateFrpEntryTls(value) {
5983
+ if (value !== "public-ip-cert" && value !== "self-signed") throw new Error("frp_entry_tls_invalid");
5984
+ return value;
5985
+ }
5986
+ function validatePort(value, rejectReserved) {
5987
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("frp_settings_invalid");
5988
+ const port = Number(value);
5989
+ if (rejectReserved && FRP_RESERVED_PORTS.includes(port)) throw new Error("frp_settings_invalid");
5990
+ return port;
5991
+ }
5992
+ /** Validate the loopback vhost port of the target frps. */
5993
+ function validateFrpVhostHttpPort(value) {
5994
+ return validatePort(value, false);
5995
+ }
5996
+ /** Validate the public entry port of the self-signed TCP proxy. */
5997
+ function validateFrpPublicPort(value) {
5998
+ return validatePort(value, true);
5999
+ }
6000
+ /** Effective provisioning mode; an absent field keeps the upstream behaviour. */
6001
+ function resolveFrpMode(settings) {
6002
+ return settings.mode ?? "deploy";
6003
+ }
6004
+ /** Effective entry TLS mode; an absent field keeps the upstream behaviour. */
6005
+ function resolveFrpEntryTls(settings) {
6006
+ return settings.entryTls ?? "public-ip-cert";
6007
+ }
6008
+ /** Effective loopback vhost port; an absent field keeps the upstream behaviour. */
6009
+ function resolveFrpVhostHttpPort(settings) {
6010
+ return settings.vhostHttpPort ?? 7080;
6011
+ }
6012
+ /** Effective public entry port of the self-signed passthrough. */
6013
+ function resolveFrpPublicPort(settings) {
6014
+ return settings.publicPort ?? 33080;
6015
+ }
6016
+ /** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
6017
+ function frpEntryOrigin(settings) {
6018
+ if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
6019
+ return `https://${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
6020
+ }
6021
+ /** True when the gateway terminates TLS itself behind a raw frps TCP proxy. */
6022
+ function isFrpSelfSignedIngress(settings) {
6023
+ return resolveFrpEntryTls(settings) === "self-signed";
6024
+ }
6025
+ /**
6026
+ * Parse FRP settings at the loopback request and filesystem boundaries.
6027
+ *
6028
+ * Optional keys are kept only when supplied, so a legacy `settings.json` still
6029
+ * round-trips byte for byte and every default stays the upstream behaviour.
6030
+ * Two cross-field rules are enforced here because neither can be recovered later:
6031
+ * the self-signed entry is a TCP passthrough and therefore attach-only, and
6032
+ * attach mode with a public-CA entry must state the user's real vhost port —
6033
+ * silently assuming 7080 would disable the plaintext-exposure gate.
6034
+ */
5615
6035
  function parseFrpSettings(value) {
5616
6036
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
5617
6037
  const record = value;
5618
- if (Reflect.ownKeys(record).some((key) => ![
5619
- "version",
5620
- "serverAddress",
5621
- "serverPort",
5622
- "token",
5623
- "publicOrigin"
5624
- ].includes(String(key)))) throw new Error("frp_settings_invalid");
6038
+ if (Reflect.ownKeys(record).some((key) => !FRP_SETTINGS_KEYS.has(String(key)))) throw new Error("frp_settings_invalid");
5625
6039
  if (record.version !== void 0 && record.version !== 1) throw new Error("frp_settings_invalid");
6040
+ const mode = record.mode === void 0 ? void 0 : validateFrpMode(record.mode);
6041
+ const entryTls = record.entryTls === void 0 ? void 0 : validateFrpEntryTls(record.entryTls);
6042
+ const vhostHttpPort = record.vhostHttpPort === void 0 ? void 0 : validateFrpVhostHttpPort(record.vhostHttpPort);
6043
+ const publicPort = record.publicPort === void 0 ? void 0 : validateFrpPublicPort(record.publicPort);
6044
+ const resolvedMode = mode ?? "deploy";
6045
+ const resolvedEntryTls = entryTls ?? "public-ip-cert";
6046
+ if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
6047
+ if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
6048
+ const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
6049
+ if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
5626
6050
  return Object.freeze({
5627
6051
  version: 1,
5628
6052
  serverAddress: validateFrpServerAddress(record.serverAddress),
5629
6053
  serverPort: validateFrpServerPort(record.serverPort),
5630
6054
  token: validateFrpToken(record.token),
5631
- publicOrigin: validateFrpPublicOrigin(record.publicOrigin)
6055
+ publicOrigin,
6056
+ ...mode === void 0 ? {} : { mode },
6057
+ ...entryTls === void 0 ? {} : { entryTls },
6058
+ ...vhostHttpPort === void 0 ? {} : { vhostHttpPort },
6059
+ ...publicPort === void 0 ? {} : { publicPort }
5632
6060
  });
5633
6061
  }
5634
6062
  /**
@@ -5638,11 +6066,24 @@ function parseFrpSettings(value) {
5638
6066
  * missing configuration instead of silently deploying blanks.
5639
6067
  */
5640
6068
  function mergeSavedFrpSettings(partial, saved) {
6069
+ const pick = (key) => {
6070
+ const supplied = partial[key];
6071
+ if (supplied === "" || supplied === void 0) return saved?.[key];
6072
+ return supplied;
6073
+ };
6074
+ const mode = pick("mode");
6075
+ const entryTls = pick("entryTls");
6076
+ const vhostHttpPort = pick("vhostHttpPort");
6077
+ const publicPort = pick("publicPort");
5641
6078
  const merged = {
5642
6079
  serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
5643
6080
  serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
5644
6081
  token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
5645
- publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin
6082
+ publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin,
6083
+ ...mode === "" || mode === void 0 ? {} : { mode },
6084
+ ...entryTls === "" || entryTls === void 0 ? {} : { entryTls },
6085
+ ...vhostHttpPort === "" || vhostHttpPort === void 0 ? {} : { vhostHttpPort },
6086
+ ...publicPort === "" || publicPort === void 0 ? {} : { publicPort }
5646
6087
  };
5647
6088
  if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
5648
6089
  return parseFrpSettings(merged);
@@ -5660,11 +6101,16 @@ function mergeSavedFrpTarget(partial, saved) {
5660
6101
  function tomlString(value) {
5661
6102
  return JSON.stringify(value);
5662
6103
  }
5663
- /** Build the single-purpose frpc configuration for the current loopback gateway. */
6104
+ /**
6105
+ * Build the single-purpose frpc configuration for the current loopback gateway.
6106
+ *
6107
+ * The default (public-CA) entry is an HTTP vhost behind Caddy and is emitted byte
6108
+ * for byte as before. The self-signed entry cannot use a vhost at all: frps only
6109
+ * forwards raw TCP, and the gateway terminates TLS on that connection.
6110
+ */
5664
6111
  function createFrpcToml(settings, localPort) {
5665
6112
  if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
5666
- const hostnameValue = new URL(settings.publicOrigin).hostname;
5667
- return [
6113
+ const header = [
5668
6114
  `serverAddr = ${tomlString(settings.serverAddress)}`,
5669
6115
  `serverPort = ${String(settings.serverPort)}`,
5670
6116
  "auth.method = \"token\"",
@@ -5672,7 +6118,21 @@ function createFrpcToml(settings, localPort) {
5672
6118
  "transport.tls.enable = true",
5673
6119
  "",
5674
6120
  "[[proxies]]",
5675
- "name = \"dsh-mobile\"",
6121
+ "name = \"dsh-mobile\""
6122
+ ];
6123
+ if (isFrpSelfSignedIngress(settings)) return [
6124
+ ...header,
6125
+ "type = \"tcp\"",
6126
+ "localIP = \"127.0.0.1\"",
6127
+ `localPort = ${String(localPort)}`,
6128
+ `remotePort = ${String(resolveFrpPublicPort(settings))}`,
6129
+ "transport.useEncryption = true",
6130
+ "transport.useCompression = true",
6131
+ ""
6132
+ ].join("\n");
6133
+ const hostnameValue = new URL(settings.publicOrigin).hostname;
6134
+ return [
6135
+ ...header,
5676
6136
  "type = \"http\"",
5677
6137
  "localIP = \"127.0.0.1\"",
5678
6138
  `localPort = ${String(localPort)}`,
@@ -5684,7 +6144,10 @@ function createFrpcToml(settings, localPort) {
5684
6144
  }
5685
6145
  /** Build the matching restricted frps and Caddy templates for one VPS. */
5686
6146
  function createFrpServerTemplate(settings) {
5687
- return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
6147
+ return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin, {
6148
+ vhostHttpPort: resolveFrpVhostHttpPort(settings),
6149
+ entryTls: resolveFrpEntryTls(settings)
6150
+ });
5688
6151
  }
5689
6152
  async function atomicPrivateWrite$2(file, body) {
5690
6153
  const directory = dirname(file);
@@ -5747,9 +6210,17 @@ var FrpConfigStore = class {
5747
6210
  this.errorCode = "frp_config_invalid";
5748
6211
  }
5749
6212
  }
5750
- /** Return configuration metadata without exposing the FRP token. */
6213
+ /**
6214
+ * Return configuration metadata without exposing the FRP token.
6215
+ *
6216
+ * The optional keys appear only when the saved configuration departs from the
6217
+ * upstream defaults, so the pre-existing field set is unchanged for every
6218
+ * legacy configuration.
6219
+ */
5751
6220
  status() {
5752
6221
  const settings = this.settingsValue;
6222
+ const mode = settings === void 0 ? void 0 : resolveFrpMode(settings);
6223
+ const entryTls = settings === void 0 ? void 0 : resolveFrpEntryTls(settings);
5753
6224
  return Object.freeze({
5754
6225
  configured: settings !== void 0,
5755
6226
  ...settings === void 0 ? {} : {
@@ -5757,9 +6228,12 @@ var FrpConfigStore = class {
5757
6228
  serverPort: settings.serverPort,
5758
6229
  publicOrigin: settings.publicOrigin
5759
6230
  },
5760
- vhostHttpPort: FRP_VHOST_HTTP_PORT,
6231
+ vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
5761
6232
  storagePath: this.stateRoot,
5762
- ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6233
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode },
6234
+ ...mode === void 0 || mode === "deploy" ? {} : { mode },
6235
+ ...entryTls === void 0 || entryTls === "public-ip-cert" ? {} : { entryTls },
6236
+ ...settings?.publicPort === void 0 ? {} : { publicPort: settings.publicPort }
5763
6237
  });
5764
6238
  }
5765
6239
  /** Return private settings only to the provider lifecycle. */
@@ -5794,41 +6268,1067 @@ var FrpConfigStore = class {
5794
6268
  }
5795
6269
  };
5796
6270
  //#endregion
5797
- //#region src/remote.ts
5798
- const REMOTE_PROVIDERS = Object.freeze([
5799
- "tailscale",
5800
- "cpolar",
5801
- "cloudflared",
5802
- "frp",
5803
- "origin"
5804
- ]);
5805
- function aggregateErrors(errors, message) {
5806
- if (errors.length === 0) return void 0;
5807
- if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
5808
- return new AggregateError(errors, message);
6271
+ //#region src/frp-attach.ts
6272
+ /** Default proof path used to confirm the public entry answered from this computer. */
6273
+ const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
6274
+ /** Declaration printed above the VPS half of an attach template. */
6275
+ const FRP_ATTACH_VPS_DECLARATION = "# 本模板不安装、不改动、不重启你的 frps;只新增 Caddy 片段与证书。frps 侧需你自行确认 vhostHTTPPort=<N> 与 proxyBindAddr=\"127.0.0.1\"。";
6276
+ /** Declaration printed above the local half of an attach template. */
6277
+ const FRP_ATTACH_LOCAL_DECLARATION = "# 本机只写入 frpc.toml 并启动 frpc:不安装、不改动本机 frps,也不改动 DSH 自身配置。";
6278
+ /** Declaration for the self-signed passthrough, which keeps its CA on this computer. */
6279
+ const FRP_ATTACH_SELF_SIGNED_DECLARATION = "# 自签穿透档不使用 Caddy 或公开证书:frps 只做 TCP 透传,由本机 DSH 网关终止 TLS;服务端证书会在运行时续签,CA 到期需重新配对。";
6280
+ /**
6281
+ * One-line notice printed above the masked frpc.toml preview.
6282
+ *
6283
+ * It states both facts a reader cannot verify from the text itself: the real
6284
+ * file is written with 0600 permissions, and an unmasked copy would survive in
6285
+ * the system clipboard.
6286
+ */
6287
+ function frpAttachTokenMaskNotice(configFile) {
6288
+ return `# 预览已打码:真正的 frpc.toml 已由插件以 0600 权限写入 ${configFile};明文 token 不打码会随复制进入系统剪贴板,故此处只显示占位符。`;
5809
6289
  }
5810
- /** Settle independent remote cleanup work before reporting any collected failure. */
5811
- async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
5812
- const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
5813
- if (failure !== void 0) throw failure;
6290
+ /** One-line notice printed above an explicitly revealed frpc.toml preview. */
6291
+ const FRP_ATTACH_TOKEN_REVEAL_NOTICE = "# 明文 token 已按你的显式操作显示:复制后即进入系统剪贴板并长期留存,请勿粘贴到公开位置。";
6292
+ /** Matches the single `auth.token` assignment of a generated frpc.toml. */
6293
+ const FRPC_TOKEN_ASSIGNMENT = /^auth\.token\s*=\s*"(?:[^"\\]|\\.)*"/mu;
6294
+ /**
6295
+ * Replace the `auth.token` value of a rendered frpc.toml with a fixed placeholder.
6296
+ *
6297
+ * Only the preview and clipboard paths call this: the file written to disk keeps
6298
+ * the real token, because frpc has to read it. The mapping never fails open — a
6299
+ * token line that cannot be masked raises instead of leaking the secret.
6300
+ */
6301
+ function redactFrpcTomlToken(frpcToml) {
6302
+ if (!/(^|\n)auth\.token/u.test(frpcToml)) return frpcToml;
6303
+ const masked = frpcToml.replace(FRPC_TOKEN_ASSIGNMENT, `auth.token = "***"`);
6304
+ if (!masked.includes(`auth.token = "***"`)) throw new Error("frp_token_mask_failed");
6305
+ return masked;
5814
6306
  }
5815
6307
  /**
5816
- * Serialize all provider mutations and preserve the single-provider invariant.
5817
- * Operations read the selected controller only after reaching the front of the queue.
6308
+ * The frpc.toml exactly as the panel displays or copies it.
6309
+ *
6310
+ * `options.revealToken` is the only way to obtain the plaintext variant; the
6311
+ * default is the masked text. `createFrpcToml` itself is untouched, so the file
6312
+ * the provider writes stays byte-for-byte identical to the upstream output.
5818
6313
  */
5819
- var RemoteProviderCoordinator = class {
5820
- controllers;
5821
- store;
5822
- selectedValue;
5823
- queue = Promise.resolve();
5824
- constructor(selected, controllers, store) {
5825
- this.controllers = controllers;
5826
- this.store = store;
5827
- this.selectedValue = selected;
6314
+ function createFrpAttachFrpcToml(settings, options = {}) {
6315
+ const frpcToml = createFrpcToml(settings, attachLocalPort(options));
6316
+ return options.revealToken === true ? frpcToml : redactFrpcTomlToken(frpcToml);
6317
+ }
6318
+ const DEFAULT_ATTACH_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
6319
+ function attachConfigFile(options) {
6320
+ const file = options.configFile;
6321
+ if (file === void 0 || file === "") return DEFAULT_ATTACH_CONFIG_FILE;
6322
+ return file;
6323
+ }
6324
+ function attachLocalPort(options) {
6325
+ const port = options.localPort ?? 3443;
6326
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_local_port_invalid");
6327
+ return port;
6328
+ }
6329
+ /**
6330
+ * Reject every attach request that could not be carried out safely before any
6331
+ * artefact is produced. Attach never installs frps, so it must know the user's
6332
+ * real vhost port (unless the self-signed passthrough removes the vhost entirely).
6333
+ */
6334
+ function validateAttachSettings(settings, options = {}) {
6335
+ if (resolveFrpMode(settings) !== "attach") throw new Error("frp_settings_invalid");
6336
+ if (isFrpSelfSignedIngress(settings)) {
6337
+ if (settings.mode !== "attach") throw new Error("frp_entry_tls_invalid");
6338
+ resolveFrpPublicPort(settings);
6339
+ return;
5828
6340
  }
5829
- /** Return the durable provider currently selected by the desktop UI. */
5830
- get selected() {
5831
- return this.selectedValue;
6341
+ const vhostHttpPort = options.vhostHttpPort ?? settings.vhostHttpPort;
6342
+ if (vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
6343
+ if (!Number.isSafeInteger(vhostHttpPort) || vhostHttpPort < 1 || vhostHttpPort > 65535) throw new Error("frp_settings_invalid");
6344
+ }
6345
+ /** Strip the leading `# ` of a guide block so it can be pasted as runnable shell. */
6346
+ function guideCommands(guide) {
6347
+ return guide.split("\n").map((line) => line.replace(/^#\s?/u, "").trimEnd()).filter((line) => line !== "");
6348
+ }
6349
+ /**
6350
+ * Derive every VPS-side value once so the copied runbook and the panel plan can
6351
+ * never drift apart.
6352
+ */
6353
+ function frpAttachVpsParts(settings, options = {}) {
6354
+ validateAttachSettings(settings, options);
6355
+ const publicHost = new URL(settings.publicOrigin).hostname;
6356
+ if (isFrpSelfSignedIngress(settings)) return Object.freeze({
6357
+ publicHost,
6358
+ selfSigned: true,
6359
+ publicPort: resolveFrpPublicPort(settings),
6360
+ snippet: "",
6361
+ certGuide: Object.freeze([])
6362
+ });
6363
+ const vhostHttpPort = options.vhostHttpPort ?? resolveFrpVhostHttpPort(settings);
6364
+ const certDir = options.certDir;
6365
+ const site = createCaddySite(publicHost, {
6366
+ vhostHttpPort,
6367
+ ...certDir === void 0 ? {} : { certDir }
6368
+ });
6369
+ return Object.freeze({
6370
+ publicHost,
6371
+ selfSigned: false,
6372
+ vhostHttpPort,
6373
+ snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
6374
+ certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
6375
+ });
6376
+ }
6377
+ /**
6378
+ * Build the attach artefact for an existing frps.
6379
+ *
6380
+ * The result is deliberately split in two: the VPS half contains only what the
6381
+ * user's own server needs (Caddy snippet + import + certificate guidance, or a
6382
+ * single firewall rule for the self-signed passthrough), and the local half
6383
+ * contains the generated frpc.toml plus its `frpc verify` self-check. Neither
6384
+ * half installs, rewrites, or restarts the user's frps.
6385
+ */
6386
+ function createFrpAttachTemplateParts(settings, options = {}) {
6387
+ const parts = frpAttachVpsParts(settings, options);
6388
+ const configFile = attachConfigFile(options);
6389
+ const revealed = options.revealToken === true;
6390
+ const frpcToml = createFrpAttachFrpcToml(settings, options);
6391
+ const local = [
6392
+ "# ---- (b) 本机侧(在这台电脑上执行)----",
6393
+ FRP_ATTACH_LOCAL_DECLARATION,
6394
+ revealed ? FRP_ATTACH_TOKEN_REVEAL_NOTICE : frpAttachTokenMaskNotice(configFile),
6395
+ `# frpc.toml 全文(插件同样会以 0600 权限写入 ${configFile})`,
6396
+ frpcToml.trimEnd(),
6397
+ "",
6398
+ "# 启动前自检:",
6399
+ `frpc verify -c "${configFile}"`,
6400
+ ""
6401
+ ].join("\n");
6402
+ if (parts.selfSigned) {
6403
+ const publicPort = parts.publicPort ?? resolveFrpPublicPort(settings);
6404
+ const vps = [
6405
+ "# ---- (a) VPS 侧(在你的服务器上执行,共 3 项)----",
6406
+ FRP_ATTACH_SELF_SIGNED_DECLARATION,
6407
+ "#",
6408
+ "# 1) 放行公网入口端口(就是这台电脑网关的 HTTPS 入口,TCP 透传不解密):",
6409
+ `ufw allow ${String(publicPort)}/tcp`,
6410
+ `ufw status | grep ${String(publicPort)}`,
6411
+ "# 若使用 firewalld / 云厂商安全组,请放行同样的 TCP 端口。",
6412
+ "# 前提:现有 frps 的 TCP 代理已监听公网地址;若 proxyBindAddr=127.0.0.1,放行防火墙仍无法从公网访问。",
6413
+ "# 插件不会修改你现有的 frps;不满足此前提时请改选受信任证书入口。",
6414
+ "#",
6415
+ "# 2) 确认你既有的 frps 已就绪(无需修改它的配置):",
6416
+ "systemctl is-active frps || true",
6417
+ `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`,
6418
+ "#",
6419
+ "# 3) frpc 启动后,用 -k 检查连通性;此命令不验证身份,App 配对后会固定网关 CA:",
6420
+ `curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`,
6421
+ "# 期望输出 200。",
6422
+ ""
6423
+ ].join("\n");
6424
+ return Object.freeze({
6425
+ mode: "attach",
6426
+ vps,
6427
+ local,
6428
+ text: `${vps}${local}`
6429
+ });
6430
+ }
6431
+ const vps = [
6432
+ "# ---- (a) VPS 侧(在你的服务器上执行)----",
6433
+ FRP_ATTACH_VPS_DECLARATION,
6434
+ "#",
6435
+ `# 1) 保存 Caddy 片段到 ${FRP_CADDY_SNIPPET_PATH}(只新增这一个文件):`,
6436
+ `cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
6437
+ parts.snippet.trimEnd(),
6438
+ "DSH_MOBILE_CADDY_SNIPPET",
6439
+ "#",
6440
+ "# 2) 确认 Caddyfile 顶部含下面这一行(全局块必须在前;没有则加到文件最上方):",
6441
+ `# ${FRP_CADDY_IMPORT_LINE}`,
6442
+ "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
6443
+ "#",
6444
+ ...parts.certGuide.length === 0 ? ["# 3) 域名 HTTPS:确认 DNS 指向 VPS,开放 80/443;Caddy 会自动申请和续期证书。"] : [
6445
+ "# 3) 公网 IP 的 HTTPS 证书(Caddy 不能为 IP 自动签发,故用 certbot 签发):",
6446
+ ...parts.certGuide.map((line) => `# ${line}`),
6447
+ "#",
6448
+ "# 4) 确认证书续期任务,并在续期后将新证书安装到 Caddy:",
6449
+ "systemctl list-timers certbot.timer --all || true"
6450
+ ],
6451
+ "#",
6452
+ "# 端到端验证(公网入口为 443,与 frps 的 vhost 端口无关):",
6453
+ `curl -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}${FRP_ATTACH_DISCOVERY_PATH}`,
6454
+ "# 期望输出 200。",
6455
+ ""
6456
+ ].join("\n");
6457
+ return Object.freeze({
6458
+ mode: "attach",
6459
+ vps,
6460
+ local,
6461
+ text: `${vps}${local}`
6462
+ });
6463
+ }
6464
+ /** Combined attach artefact, ready for the clipboard. */
6465
+ function createFrpAttachTemplate(settings, options = {}) {
6466
+ return createFrpAttachTemplateParts(settings, options).text;
6467
+ }
6468
+ //#endregion
6469
+ //#region src/frp-attach-plan.ts
6470
+ const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
6471
+ function planConfigFile(options) {
6472
+ const file = options.configFile;
6473
+ return file === void 0 || file === "" ? DEFAULT_PLAN_CONFIG_FILE : file;
6474
+ }
6475
+ /**
6476
+ * Build the copy-only attach plan. It performs no network access, no SSH, and no
6477
+ * filesystem write: the caller renders or copies the steps and the local frpc
6478
+ * configuration is written by the ordinary provider lifecycle.
6479
+ */
6480
+ function createFrpAttachPlan(settings, options = {}) {
6481
+ const parts = frpAttachVpsParts(settings, options);
6482
+ const configFile = planConfigFile(options);
6483
+ const localPort = options.localPort ?? 3443;
6484
+ const frpcToml = createFrpAttachFrpcToml(settings, {
6485
+ ...options,
6486
+ localPort
6487
+ });
6488
+ const tokenMasked = options.revealToken !== true;
6489
+ const verifyCommand = `frpc verify -c "${configFile}"`;
6490
+ const commonWarnings = ["本机探测 ≠ 公网验证:请在外部(手机流量或另一台机器)再执行一次 VPS 侧清单里的 curl 验证。", "插件不会安装、不会改动、不会重启你既有的 frps,也不会改动 Caddyfile 里的其他内容。"];
6491
+ if (isFrpSelfSignedIngress(settings)) {
6492
+ const publicPort = parts.publicPort ?? 0;
6493
+ const publicHost = parts.publicHost;
6494
+ return Object.freeze({
6495
+ mode: "attach",
6496
+ entryTls: resolveFrpEntryTls(settings),
6497
+ publicPort,
6498
+ local: Object.freeze({
6499
+ frpcToml,
6500
+ tokenMasked,
6501
+ verifyCommand,
6502
+ configFile,
6503
+ declaration: FRP_ATTACH_LOCAL_DECLARATION
6504
+ }),
6505
+ vps: Object.freeze([
6506
+ Object.freeze({
6507
+ id: "open-public-port",
6508
+ title: "frpAttachStepOpenPort",
6509
+ label: `放行公网入口端口 ${String(publicPort)}/tcp`,
6510
+ commands: Object.freeze([`ufw allow ${String(publicPort)}/tcp`, `ufw status | grep ${String(publicPort)}`]),
6511
+ optional: false,
6512
+ verifyHint: `ufw status 中应出现 ${String(publicPort)}/tcp ALLOW;云厂商安全组也需放行同一 TCP 端口。`
6513
+ }),
6514
+ Object.freeze({
6515
+ id: "verify-frps",
6516
+ title: "frpAttachStepVerifyFrps",
6517
+ label: "确认既有 frps 已就绪(不改动它的配置)",
6518
+ commands: Object.freeze(["systemctl is-active frps || true", `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`]),
6519
+ optional: false,
6520
+ verifyHint: `frps 控制端口 ${String(settings.serverPort)} 应监听;入口 ${String(publicPort)} 的 Local Address 不应是 127.0.0.1。若仅回环监听,防火墙放行也无法公网访问。`
6521
+ }),
6522
+ Object.freeze({
6523
+ id: "verify-entry",
6524
+ title: "frpAttachStepVerifyEntry",
6525
+ label: "frpc 启动后端到端验证(自签证书需 -k)",
6526
+ commands: Object.freeze([`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`]),
6527
+ optional: false,
6528
+ verifyHint: "期望输出 200;curl -k 仅验证连通性,不验证服务器身份;App 配对后会固定网关 CA。返回 000 时检查端口与转发。"
6529
+ })
6530
+ ]),
6531
+ warnings: Object.freeze([
6532
+ ...commonWarnings,
6533
+ FRP_ATTACH_SELF_SIGNED_DECLARATION,
6534
+ "现有 frps 的 TCP 代理必须已监听公网地址;若 proxyBindAddr=127.0.0.1,单靠开放防火墙端口无法连通,请改选公网证书 + Caddy 模式。插件不会修改你的 frps。",
6535
+ "手机浏览器访问自签入口会提示证书不受信任 —— 请用 App 扫码配对;App 已固定网关 CA,不需要公开证书。",
6536
+ "切勿把网关 listenHost 改成 0.0.0.0:公网入口由 frps 的 TCP 代理提供,网关只监听 127.0.0.1。"
6537
+ ])
6538
+ });
6539
+ }
6540
+ const vhostHttpPort = parts.vhostHttpPort ?? 0;
6541
+ const publicHost = parts.publicHost;
6542
+ const certGuide = parts.certGuide;
6543
+ const certificateSteps = certGuide.length === 0 ? [] : [Object.freeze({
6544
+ id: "issue-ip-cert",
6545
+ title: "frpAttachStepIssueCert",
6546
+ label: "为公网 IP 签发受信任证书(certbot)",
6547
+ commands: Object.freeze([...certGuide]),
6548
+ optional: false,
6549
+ verifyHint: `证书应落到 /etc/letsencrypt/live/${publicHost}/ 并安装到 Caddy 证书目录。`
6550
+ }), Object.freeze({
6551
+ id: "enable-cert-timer",
6552
+ title: "frpAttachStepCertTimer",
6553
+ label: "确认 certbot 续期定时器并更新 Caddy 证书文件",
6554
+ commands: Object.freeze(["systemctl list-timers certbot.timer --all || true"]),
6555
+ optional: false,
6556
+ verifyHint: "公网 IP 证书有效期较短;续期后还须将新证书安装到 Caddy 并重载。"
6557
+ })];
6558
+ return Object.freeze({
6559
+ mode: "attach",
6560
+ entryTls: resolveFrpEntryTls(settings),
6561
+ vhostHttpPort,
6562
+ local: Object.freeze({
6563
+ frpcToml,
6564
+ tokenMasked,
6565
+ verifyCommand,
6566
+ configFile,
6567
+ declaration: FRP_ATTACH_LOCAL_DECLARATION
6568
+ }),
6569
+ vps: Object.freeze([
6570
+ Object.freeze({
6571
+ id: "write-snippet",
6572
+ title: "frpAttachStepWriteSnippet",
6573
+ label: `新增 Caddy 片段 ${FRP_CADDY_SNIPPET_PATH}`,
6574
+ commands: Object.freeze([
6575
+ `cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
6576
+ parts.snippet.trimEnd(),
6577
+ "DSH_MOBILE_CADDY_SNIPPET"
6578
+ ]),
6579
+ optional: false,
6580
+ verifyHint: `片段内容里的反代目标是 127.0.0.1:${String(vhostHttpPort)},即你 frps 的 vhostHTTPPort。`
6581
+ }),
6582
+ Object.freeze({
6583
+ id: "add-import",
6584
+ title: "frpAttachStepAddImport",
6585
+ label: "在 Caddyfile 顶部加入 import 行并重载",
6586
+ commands: Object.freeze([`# 确认 /etc/caddy/Caddyfile 顶部包含:${FRP_CADDY_IMPORT_LINE}`, "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy"]),
6587
+ optional: false,
6588
+ verifyHint: "caddy validate 通过且 reload 无报错;Caddyfile 中你自己原有的站点内容保持不变。"
6589
+ }),
6590
+ ...certificateSteps,
6591
+ Object.freeze({
6592
+ id: "verify-https",
6593
+ title: "frpAttachStepVerifyHttps",
6594
+ label: "端到端验证公网 HTTPS 入口",
6595
+ commands: Object.freeze([`curl -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}${FRP_ATTACH_DISCOVERY_PATH}`]),
6596
+ optional: true,
6597
+ verifyHint: certGuide.length === 0 ? "期望输出 200;证书错误时检查 DNS、80/443 端口和 Caddy 日志。" : "期望输出 200;证书错误时检查 certbot 证书是否安装并重载 Caddy。"
6598
+ })
6599
+ ]),
6600
+ warnings: Object.freeze([
6601
+ ...commonWarnings,
6602
+ FRP_ATTACH_VPS_DECLARATION,
6603
+ `你 frps 的明文 vhost 必须只监听 127.0.0.1(proxyBindAddr)且 vhostHTTPPort=${String(vhostHttpPort)};插件启动前会探测该端口,若公网可达将直接拒绝启动。`
6604
+ ])
6605
+ });
6606
+ }
6607
+ //#endregion
6608
+ //#region src/cert-renewal.ts
6609
+ /** Days left below which the entry certificate is reported as expiring. */
6610
+ const CERT_EXPIRING_DAYS = 2;
6611
+ const DAY_MS = 864e5;
6612
+ /** Evaluate one `notAfter` timestamp against the current clock. */
6613
+ function evaluateCertificateLifetime(notAfter, now = Date.now()) {
6614
+ if (!Number.isFinite(notAfter) || !Number.isFinite(now)) return Object.freeze({
6615
+ state: "unknown",
6616
+ errorCode: "frp_attach_cert_unknown"
6617
+ });
6618
+ const daysRemaining = Math.floor((notAfter - now) / DAY_MS);
6619
+ if (notAfter <= now) return Object.freeze({
6620
+ state: "expired",
6621
+ notAfter,
6622
+ daysRemaining
6623
+ });
6624
+ if (daysRemaining <= 2) return Object.freeze({
6625
+ state: "expiring",
6626
+ notAfter,
6627
+ daysRemaining
6628
+ });
6629
+ return Object.freeze({
6630
+ state: "ok",
6631
+ notAfter,
6632
+ daysRemaining
6633
+ });
6634
+ }
6635
+ function statusFromCertificate(certificate, now) {
6636
+ const lifetime = evaluateCertificateLifetime(Date.parse(certificate.validTo), now);
6637
+ return Object.freeze({
6638
+ ...lifetime,
6639
+ subject: certificate.subject,
6640
+ issuer: certificate.issuer
6641
+ });
6642
+ }
6643
+ /**
6644
+ * Read one PEM certificate from disk and report only its lifetime.
6645
+ *
6646
+ * A missing or unreadable file yields `unknown` with the stable
6647
+ * `frp_attach_cert_unknown` code so the panel can explain how to re-issue it.
6648
+ */
6649
+ async function readCertificateRenewal(file, now = Date.now()) {
6650
+ try {
6651
+ return statusFromCertificate(new X509Certificate(await readFile(file)), now);
6652
+ } catch {
6653
+ return Object.freeze({
6654
+ state: "unknown",
6655
+ errorCode: "frp_attach_cert_unknown"
6656
+ });
6657
+ }
6658
+ }
6659
+ /**
6660
+ * Ask a live TLS endpoint for its leaf certificate and report its lifetime.
6661
+ *
6662
+ * Verification is intentionally skipped: this is a read-only lifetime probe for
6663
+ * an endpoint whose trust anchor is already pinned elsewhere, and a self-signed
6664
+ * leaf is the expected answer for the passthrough entry.
6665
+ */
6666
+ async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
6667
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return Object.freeze({
6668
+ state: "unknown",
6669
+ errorCode: "frp_attach_cert_unknown"
6670
+ });
6671
+ return new Promise((resolveProbe) => {
6672
+ let finished = false;
6673
+ const socket = connect$1({
6674
+ host,
6675
+ port,
6676
+ rejectUnauthorized: false,
6677
+ servername: void 0
6678
+ });
6679
+ const finish = (status) => {
6680
+ if (finished) return;
6681
+ finished = true;
6682
+ clearTimeout(timer);
6683
+ socket.destroy();
6684
+ resolveProbe(status);
6685
+ };
6686
+ const timer = setTimeout(() => {
6687
+ finish(Object.freeze({
6688
+ state: "unknown",
6689
+ errorCode: "frp_attach_cert_unknown"
6690
+ }));
6691
+ }, timeoutMs);
6692
+ timer.unref();
6693
+ socket.once("secureConnect", () => {
6694
+ const certificate = socket.getPeerX509Certificate();
6695
+ finish(certificate === void 0 ? Object.freeze({
6696
+ state: "unknown",
6697
+ errorCode: "frp_attach_cert_unknown"
6698
+ }) : statusFromCertificate(certificate, Date.now()));
6699
+ });
6700
+ socket.once("error", () => {
6701
+ finish(Object.freeze({
6702
+ state: "unknown",
6703
+ errorCode: "frp_attach_cert_unknown"
6704
+ }));
6705
+ });
6706
+ socket.once("close", () => {
6707
+ finish(Object.freeze({
6708
+ state: "unknown",
6709
+ errorCode: "frp_attach_cert_unknown"
6710
+ }));
6711
+ });
6712
+ });
6713
+ }
6714
+ //#endregion
6715
+ //#region src/managed-setup.ts
6716
+ const VIRTUAL_INTERFACE_MARKERS = [
6717
+ "bridge",
6718
+ "docker",
6719
+ "hyper-v",
6720
+ "mihomo",
6721
+ "radmin",
6722
+ "tailscale",
6723
+ "tap",
6724
+ "tun",
6725
+ "utun",
6726
+ "vbox",
6727
+ "veth",
6728
+ "virtual",
6729
+ "vmware",
6730
+ "vpn",
6731
+ "vethernet",
6732
+ "wsl",
6733
+ "zerotier"
6734
+ ];
6735
+ /** Route inspection is advisory; never let a system command hold setup open indefinitely. */
6736
+ const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
6737
+ function requiredString(value, name) {
6738
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
6739
+ return value;
6740
+ }
6741
+ /** Validate the durable managed setup before it controls network and filesystem operations. */
6742
+ function parseManagedSetup(value) {
6743
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
6744
+ const record = value;
6745
+ if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
6746
+ "version",
6747
+ "networkInterface",
6748
+ "listenPort",
6749
+ "upstreamOrigin",
6750
+ "tls"
6751
+ ].includes(key))) throw new Error("mobile setup file has an unsupported format");
6752
+ if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
6753
+ if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
6754
+ const tls = record.tls;
6755
+ if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
6756
+ "mode",
6757
+ "caCertFile",
6758
+ "caKeyFile",
6759
+ "certFile",
6760
+ "keyFile"
6761
+ ].includes(key))) throw new Error("mobile setup tls has an unsupported format");
6762
+ return Object.freeze({
6763
+ version: 2,
6764
+ networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
6765
+ listenPort: record.listenPort,
6766
+ upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
6767
+ tls: Object.freeze({
6768
+ mode: "managed",
6769
+ caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
6770
+ caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
6771
+ certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
6772
+ keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
6773
+ })
6774
+ });
6775
+ }
6776
+ function privateIpv4(value) {
6777
+ const parts = value.split(".").map(Number);
6778
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
6779
+ }
6780
+ function networkCidr(address, cidr) {
6781
+ const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
6782
+ const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
6783
+ return `${[
6784
+ 24,
6785
+ 16,
6786
+ 8,
6787
+ 0
6788
+ ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
6789
+ }
6790
+ /** List current private IPv4 candidates with their interface identity. */
6791
+ function availableLanNetworks(table = networkInterfaces()) {
6792
+ const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
6793
+ name,
6794
+ address: entry.address,
6795
+ cidr: networkCidr(entry.address, entry.cidr)
6796
+ })));
6797
+ return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
6798
+ }
6799
+ function likelyVirtualInterface(name) {
6800
+ const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
6801
+ return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
6802
+ }
6803
+ async function runRouteCommand(file, args) {
6804
+ return (await execFileText(file, [...args], {
6805
+ encoding: "utf8",
6806
+ timeout: ROUTE_COMMAND_TIMEOUT_MS
6807
+ })).stdout;
6808
+ }
6809
+ function uniqueLines(output) {
6810
+ return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
6811
+ }
6812
+ /** Return operating-system default-route interfaces in routing preference order. */
6813
+ async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
6814
+ try {
6815
+ if (platform === "win32") return uniqueLines(await run("powershell.exe", [
6816
+ "-NoProfile",
6817
+ "-NonInteractive",
6818
+ "-Command",
6819
+ [
6820
+ "$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
6821
+ "$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
6822
+ " $route = $_",
6823
+ " $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
6824
+ " $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
6825
+ " if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
6826
+ " [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
6827
+ " }",
6828
+ "}",
6829
+ "$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
6830
+ ].join("; ")
6831
+ ]));
6832
+ if (platform === "linux") {
6833
+ const routes = uniqueLines(await run("ip", [
6834
+ "-o",
6835
+ "-4",
6836
+ "route",
6837
+ "show",
6838
+ "default"
6839
+ ])).map((line) => ({
6840
+ name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
6841
+ metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
6842
+ })).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
6843
+ return [...new Set(routes.map((route) => route.name))];
6844
+ }
6845
+ if (platform === "darwin") {
6846
+ const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
6847
+ "-n",
6848
+ "get",
6849
+ "default"
6850
+ ]))?.[1];
6851
+ return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
6852
+ }
6853
+ } catch {}
6854
+ return [];
6855
+ }
6856
+ /** Select an active LAN, optionally by address or by a previously saved interface name. */
6857
+ function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
6858
+ const candidates = availableLanNetworks(table);
6859
+ if (requestedAddress !== void 0) {
6860
+ const match = candidates.find((candidate) => candidate.address === requestedAddress);
6861
+ if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
6862
+ return match;
6863
+ }
6864
+ if (requestedInterface !== void 0) {
6865
+ const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
6866
+ if (matches.length === 1) return matches[0];
6867
+ if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
6868
+ throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
6869
+ }
6870
+ if (candidates.length === 1) return candidates[0];
6871
+ if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
6872
+ for (const name of preferredInterfaces) {
6873
+ const matches = candidates.filter((candidate) => candidate.name === name);
6874
+ if (matches.length === 1) return matches[0];
6875
+ }
6876
+ const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
6877
+ if (physicalCandidates.length === 1) return physicalCandidates[0];
6878
+ throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
6879
+ }
6880
+ /**
6881
+ * True when the failure came from LAN selection (the configured network is
6882
+ * gone, nothing is up, or several are) rather than from config/TLS code.
6883
+ * Callers use it to degrade (warn + stay down) instead of failing boot.
6884
+ * Pinned to the selectLanNetwork message contract — keep in sync.
6885
+ */
6886
+ function isNetworkSelectionError(error) {
6887
+ const message = error instanceof Error ? error.message : String(error ?? "");
6888
+ return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
6889
+ }
6890
+ function assertMatchingCa(certPem, keyPem) {
6891
+ const certificate = new X509Certificate(certPem);
6892
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
6893
+ const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
6894
+ format: "der",
6895
+ type: "spki"
6896
+ });
6897
+ const certificatePublic = certificate.publicKey.export({
6898
+ format: "der",
6899
+ type: "spki"
6900
+ });
6901
+ if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
6902
+ if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
6903
+ return certificate;
6904
+ }
6905
+ /** Read an existing managed CA without ever generating a replacement identity. */
6906
+ async function readManagedCa(setup) {
6907
+ const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
6908
+ return assertMatchingCa(certPem, keyPem);
6909
+ }
6910
+ async function atomicWrite(file, contents) {
6911
+ const directory = dirname(file);
6912
+ await mkdir(directory, {
6913
+ recursive: true,
6914
+ mode: 448
6915
+ });
6916
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
6917
+ await writeFile(temporary, contents, { mode: 384 });
6918
+ await rename(temporary, file);
6919
+ await restrictPrivateFile(file);
6920
+ }
6921
+ /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
6922
+ async function ensureManagedCa(setup, legacy) {
6923
+ let certPem;
6924
+ let keyPem;
6925
+ try {
6926
+ [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
6927
+ } catch (error) {
6928
+ if (error.code !== "ENOENT") throw error;
6929
+ let migrated = false;
6930
+ if (legacy !== void 0) try {
6931
+ [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
6932
+ assertMatchingCa(certPem, keyPem);
6933
+ migrated = true;
6934
+ } catch (legacyError) {
6935
+ if (legacyError.code !== "ENOENT") throw legacyError;
6936
+ }
6937
+ if (!migrated) {
6938
+ const now = /* @__PURE__ */ new Date();
6939
+ const notAfter = new Date(now);
6940
+ notAfter.setFullYear(notAfter.getFullYear() + 5);
6941
+ const generated = await generate([{
6942
+ name: "commonName",
6943
+ value: "DeepSeek Harness Mobile CA"
6944
+ }], {
6945
+ keyType: "ec",
6946
+ curve: "P-256",
6947
+ algorithm: "sha256",
6948
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
6949
+ notAfterDate: notAfter,
6950
+ extensions: [{
6951
+ name: "basicConstraints",
6952
+ cA: true,
6953
+ critical: true
6954
+ }, {
6955
+ name: "keyUsage",
6956
+ digitalSignature: true,
6957
+ keyCertSign: true,
6958
+ cRLSign: true,
6959
+ critical: true
6960
+ }]
6961
+ });
6962
+ certPem = generated.cert;
6963
+ keyPem = generated.private;
6964
+ }
6965
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
6966
+ await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
6967
+ }
6968
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
6969
+ await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
6970
+ return assertMatchingCa(certPem, keyPem);
6971
+ }
6972
+ function subjectAltNames(target) {
6973
+ const names = [];
6974
+ for (const ip of target.ipAddresses ?? []) names.push({
6975
+ type: 7,
6976
+ ip
6977
+ });
6978
+ for (const dns of target.dnsNames ?? []) names.push({
6979
+ type: 2,
6980
+ value: dns
6981
+ });
6982
+ if (names.length === 0) throw new Error("managed TLS leaf needs at least one server name");
6983
+ return names;
6984
+ }
6985
+ /**
6986
+ * Sign one server leaf with an existing self-signed CA and install it privately.
6987
+ *
6988
+ * Both the LAN listener and the self-signed FRP ingress use this one path, so a
6989
+ * certificate can never be produced by two divergent code paths. IP entries are
6990
+ * required for console addresses (`type: 7`), DNS entries (`type: 2`) cover named
6991
+ * hosts; the Android client accepts either through its pinned CA.
6992
+ */
6993
+ async function issueServerCertificate(authority, target, output, lifetimeDays = 397) {
6994
+ const altNames = subjectAltNames(target);
6995
+ await Promise.all([restrictPrivateFile(authority.caCertFile), restrictPrivateFile(authority.caKeyFile)]);
6996
+ const [caCert, caKey] = await Promise.all([readFile(authority.caCertFile, "utf8"), readFile(authority.caKeyFile, "utf8")]);
6997
+ assertMatchingCa(caCert, caKey);
6998
+ const now = /* @__PURE__ */ new Date();
6999
+ const notAfter = new Date(now);
7000
+ notAfter.setDate(notAfter.getDate() + lifetimeDays);
7001
+ const server = await generate([{
7002
+ name: "commonName",
7003
+ value: target.commonName
7004
+ }], {
7005
+ keyType: "ec",
7006
+ curve: "P-256",
7007
+ algorithm: "sha256",
7008
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
7009
+ notAfterDate: notAfter,
7010
+ ca: {
7011
+ cert: caCert,
7012
+ key: caKey
7013
+ },
7014
+ extensions: [
7015
+ {
7016
+ name: "basicConstraints",
7017
+ cA: false,
7018
+ critical: true
7019
+ },
7020
+ {
7021
+ name: "keyUsage",
7022
+ digitalSignature: true,
7023
+ critical: true
7024
+ },
7025
+ {
7026
+ name: "extKeyUsage",
7027
+ serverAuth: true
7028
+ },
7029
+ {
7030
+ name: "subjectAltName",
7031
+ altNames
7032
+ }
7033
+ ]
7034
+ });
7035
+ await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
7036
+ }
7037
+ /** Sign and atomically install a server leaf for the interface's current address. */
7038
+ async function refreshManagedServerCertificate(setup, address) {
7039
+ await issueServerCertificate({
7040
+ caCertFile: setup.tls.caCertFile,
7041
+ caKeyFile: setup.tls.caKeyFile
7042
+ }, {
7043
+ commonName: "DeepSeek Harness Mobile",
7044
+ ipAddresses: [address]
7045
+ }, {
7046
+ certFile: setup.tls.certFile,
7047
+ keyFile: setup.tls.keyFile
7048
+ });
7049
+ }
7050
+ /** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
7051
+ async function materializeManagedSetup(setup, table) {
7052
+ const network = selectLanNetwork(void 0, setup.networkInterface, table);
7053
+ await refreshManagedServerCertificate(setup, network.address);
7054
+ const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
7055
+ return {
7056
+ publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
7057
+ listenHost: network.address,
7058
+ allowedCidrs: [network.cidr],
7059
+ instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
7060
+ pairingCaFile: setup.tls.caCertFile,
7061
+ tls: {
7062
+ mode: "provided",
7063
+ certFile: setup.tls.certFile,
7064
+ keyFile: setup.tls.keyFile
7065
+ }
7066
+ };
7067
+ }
7068
+ //#endregion
7069
+ //#region src/frp-ingress.ts
7070
+ /** Private ingress directory, always a sibling of the remote device file. */
7071
+ function frpIngressPaths(stateFile) {
7072
+ const directory = join(dirname(stateFile), "ingress");
7073
+ return Object.freeze({
7074
+ directory,
7075
+ caCertFile: join(directory, "ca.pem"),
7076
+ caKeyFile: join(directory, "ca-key.pem"),
7077
+ certFile: join(directory, "server.pem"),
7078
+ keyFile: join(directory, "server-key.pem"),
7079
+ statusFile: join(dirname(stateFile), "ingress-identity.json")
7080
+ });
7081
+ }
7082
+ async function regularFileExists(file) {
7083
+ try {
7084
+ const entry = await lstat(file);
7085
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
7086
+ return true;
7087
+ } catch (error) {
7088
+ if (error.code === "ENOENT") return false;
7089
+ throw error;
7090
+ }
7091
+ }
7092
+ async function readIngressIdentity(file) {
7093
+ if (!await regularFileExists(file)) return void 0;
7094
+ try {
7095
+ const parsed = JSON.parse(await readFile(file, "utf8"));
7096
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
7097
+ const record = parsed;
7098
+ if (record.version !== 1 || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
7099
+ return record.caFingerprint;
7100
+ } catch (error) {
7101
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
7102
+ }
7103
+ }
7104
+ async function writeIngressIdentity(file, fingerprint) {
7105
+ const temporary = join(dirname(file), `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
7106
+ try {
7107
+ await writeFile(temporary, `${JSON.stringify({
7108
+ version: 1,
7109
+ caFingerprint: fingerprint
7110
+ })}\n`, {
7111
+ flag: "wx",
7112
+ mode: 384
7113
+ });
7114
+ await rename(temporary, file);
7115
+ await restrictPrivateFile(file);
7116
+ } finally {
7117
+ try {
7118
+ await unlink(temporary);
7119
+ } catch (error) {
7120
+ if (error.code !== "ENOENT") throw error;
7121
+ }
7122
+ }
7123
+ }
7124
+ function coversPublicIp(certificate, address) {
7125
+ return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
7126
+ }
7127
+ /**
7128
+ * Materialize the CA and leaf the gateway terminates TLS with.
7129
+ *
7130
+ * The CA remains stable for paired phones; an expired CA must be re-paired, not
7131
+ * replaced silently. The leaf is re-signed when expiring or no longer valid for
7132
+ * the public IPv4, and the running gateway reloads it after a renewal check.
7133
+ */
7134
+ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
7135
+ if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
7136
+ const paths = frpIngressPaths(stateFile);
7137
+ try {
7138
+ const directory = await lstat(paths.directory);
7139
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
7140
+ } catch (error) {
7141
+ if (error.code !== "ENOENT") throw error;
7142
+ }
7143
+ const previousFingerprint = await readIngressIdentity(paths.statusFile);
7144
+ const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
7145
+ regularFileExists(paths.caCertFile),
7146
+ regularFileExists(paths.caKeyFile),
7147
+ regularFileExists(paths.certFile),
7148
+ regularFileExists(paths.keyFile)
7149
+ ]);
7150
+ if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
7151
+ if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
7152
+ if (previousFingerprint === void 0) {
7153
+ await mkdir(dirname(paths.statusFile), {
7154
+ recursive: true,
7155
+ mode: 448
7156
+ });
7157
+ try {
7158
+ await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
7159
+ flag: "wx",
7160
+ mode: 384
7161
+ });
7162
+ await restrictPrivateFile(paths.statusFile);
7163
+ } catch (error) {
7164
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
7165
+ }
7166
+ }
7167
+ const caFiles = {
7168
+ mode: "managed",
7169
+ caCertFile: paths.caCertFile,
7170
+ caKeyFile: paths.caKeyFile,
7171
+ certFile: paths.certFile,
7172
+ keyFile: paths.keyFile
7173
+ };
7174
+ let ca;
7175
+ try {
7176
+ ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
7177
+ } catch (error) {
7178
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
7179
+ }
7180
+ const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
7181
+ if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
7182
+ if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
7183
+ if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
7184
+ const publicHost = new URL(settings.publicOrigin).hostname;
7185
+ let leaf;
7186
+ try {
7187
+ leaf = new X509Certificate(await readFile(paths.certFile));
7188
+ } catch {
7189
+ leaf = void 0;
7190
+ }
7191
+ if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
7192
+ await issueServerCertificate({
7193
+ caCertFile: paths.caCertFile,
7194
+ caKeyFile: paths.caKeyFile
7195
+ }, {
7196
+ commonName: "DSH Mobile FRP ingress",
7197
+ ipAddresses: [publicHost]
7198
+ }, {
7199
+ certFile: paths.certFile,
7200
+ keyFile: paths.keyFile
7201
+ });
7202
+ leaf = new X509Certificate(await readFile(paths.certFile));
7203
+ }
7204
+ const signed = leaf ?? (() => {
7205
+ throw new Error("frp_attach_cert_unknown");
7206
+ })();
7207
+ return Object.freeze({
7208
+ paths,
7209
+ ca,
7210
+ leaf: signed,
7211
+ caFingerprint,
7212
+ status: await readCertificateRenewal(paths.certFile, now)
7213
+ });
7214
+ }
7215
+ /**
7216
+ * Build the read-only self-check payload shown in the desktop panel.
7217
+ *
7218
+ * No secret ever leaves this function: the CA is reported by fingerprint only,
7219
+ * and the token is never read.
7220
+ */
7221
+ async function frpIngressSelfCheck(settings, stateFile) {
7222
+ const paths = frpIngressPaths(stateFile);
7223
+ const base = {
7224
+ mode: resolveFrpMode(settings),
7225
+ entryTls: resolveFrpEntryTls(settings),
7226
+ vhostHttpPort: resolveFrpVhostHttpPort(settings),
7227
+ publicPort: resolveFrpPublicPort(settings),
7228
+ publicOrigin: settings.publicOrigin,
7229
+ entryOrigin: frpEntryOrigin(settings),
7230
+ serverAddress: settings.serverAddress,
7231
+ serverPort: settings.serverPort,
7232
+ inbound: {
7233
+ listenHost: "127.0.0.1",
7234
+ allowedCidrs: Object.freeze(["127.0.0.0/8"])
7235
+ }
7236
+ };
7237
+ if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
7238
+ let caFingerprint;
7239
+ try {
7240
+ caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
7241
+ } catch {
7242
+ caFingerprint = void 0;
7243
+ }
7244
+ return Object.freeze({
7245
+ ...base,
7246
+ ...caFingerprint === void 0 ? {} : { caFingerprint },
7247
+ certificate: await readCertificateRenewal(paths.certFile),
7248
+ caCertificate: await readCertificateRenewal(paths.caCertFile)
7249
+ });
7250
+ }
7251
+ /** Remove only the five files owned by the local self-signed FRP entry. */
7252
+ async function purgeFrpIngressCertificates(stateFile) {
7253
+ const paths = frpIngressPaths(stateFile);
7254
+ try {
7255
+ const marker = await lstat(paths.statusFile);
7256
+ if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
7257
+ await unlink(paths.statusFile);
7258
+ } catch (error) {
7259
+ if (error.code !== "ENOENT") throw error;
7260
+ }
7261
+ let directory;
7262
+ try {
7263
+ directory = await lstat(paths.directory);
7264
+ } catch (error) {
7265
+ if (error.code === "ENOENT") return;
7266
+ throw error;
7267
+ }
7268
+ if (directory.isSymbolicLink()) {
7269
+ await unlink(paths.directory);
7270
+ return;
7271
+ }
7272
+ if (!directory.isDirectory()) throw new Error("frp_ingress_directory_invalid");
7273
+ for (const file of [
7274
+ paths.caCertFile,
7275
+ paths.caKeyFile,
7276
+ paths.certFile,
7277
+ paths.keyFile
7278
+ ]) {
7279
+ let entry;
7280
+ try {
7281
+ entry = await lstat(file);
7282
+ } catch (error) {
7283
+ if (error.code === "ENOENT") continue;
7284
+ throw error;
7285
+ }
7286
+ if (!entry.isFile() && !entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
7287
+ await unlink(file);
7288
+ }
7289
+ try {
7290
+ await rmdir(paths.directory);
7291
+ } catch (error) {
7292
+ if (error.code === "ENOENT") return;
7293
+ throw error;
7294
+ }
7295
+ }
7296
+ //#endregion
7297
+ //#region src/remote.ts
7298
+ const REMOTE_PROVIDERS = Object.freeze([
7299
+ "tailscale",
7300
+ "cpolar",
7301
+ "cloudflared",
7302
+ "frp",
7303
+ "origin"
7304
+ ]);
7305
+ function aggregateErrors(errors, message) {
7306
+ if (errors.length === 0) return void 0;
7307
+ if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
7308
+ return new AggregateError(errors, message);
7309
+ }
7310
+ /** Settle independent remote cleanup work before reporting any collected failure. */
7311
+ async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
7312
+ const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
7313
+ if (failure !== void 0) throw failure;
7314
+ }
7315
+ /**
7316
+ * Serialize all provider mutations and preserve the single-provider invariant.
7317
+ * Operations read the selected controller only after reaching the front of the queue.
7318
+ */
7319
+ var RemoteProviderCoordinator = class {
7320
+ controllers;
7321
+ store;
7322
+ selectedValue;
7323
+ queue = Promise.resolve();
7324
+ constructor(selected, controllers, store) {
7325
+ this.controllers = controllers;
7326
+ this.store = store;
7327
+ this.selectedValue = selected;
7328
+ }
7329
+ /** Return the durable provider currently selected by the desktop UI. */
7330
+ get selected() {
7331
+ return this.selectedValue;
5832
7332
  }
5833
7333
  /** Return the controller selected when this method is called. */
5834
7334
  controller() {
@@ -6003,6 +7503,7 @@ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
6003
7503
  const DISCOVERY_RETRY_MS = 1e3;
6004
7504
  const MAX_DISCOVERY_BYTES = 16384;
6005
7505
  const VHOST_PROBE_TIMEOUT_MS = 1500;
7506
+ const INGRESS_CERT_CHECK_MS = 432e5;
6006
7507
  function publicStatus$3(status) {
6007
7508
  return Object.freeze({
6008
7509
  enabled: status.enabled,
@@ -6097,38 +7598,108 @@ async function boundedResponseBytes(response) {
6097
7598
  }
6098
7599
  return bytes;
6099
7600
  }
6100
- async function defaultProbeDiscovery(origin, expectedInstanceId, signal) {
6101
- const requestController = new AbortController();
6102
- const abort = () => {
6103
- requestController.abort();
6104
- };
6105
- signal.addEventListener("abort", abort, { once: true });
6106
- const timeout = setTimeout(abort, DISCOVERY_REQUEST_TIMEOUT_MS);
6107
- timeout.unref();
6108
- try {
6109
- const response = await fetch(`${origin}/mobile-access/discovery`, {
7601
+ /** Web view over an HTTPS response so the byte cap applies to both paths alike. */
7602
+ function discoveryResponse(message) {
7603
+ const headers = new Headers();
7604
+ const declaredLength = message.headers["content-length"];
7605
+ if (declaredLength !== void 0) headers.set("content-length", declaredLength);
7606
+ return new Response(Readable.toWeb(message), {
7607
+ status: message.statusCode ?? 200,
7608
+ headers
7609
+ });
7610
+ }
7611
+ /**
7612
+ * Perform one discovery request against the public entry.
7613
+ *
7614
+ * `node:https` carries this request instead of the global `fetch` for a single
7615
+ * reason: only here can the trust anchor be stated. The self-signed entry
7616
+ * presents a leaf the plugin's own ingress CA signed, so the default chain
7617
+ * always fails there (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`) even though the
7618
+ * transport itself is healthy. The anchor is *added* to the trust store — never
7619
+ * `rejectUnauthorized: false` — so the peer must still prove its identity, and
7620
+ * the verified name stays the host of the origin, which is the name the app
7621
+ * dials as well (Node matches IP literals against `IP Address:` SANs).
7622
+ *
7623
+ * A non-200 answer resolves `undefined` for retry; discovery requires a body.
7624
+ * transport, TLS, and timeout failures reject so the caller keeps retrying.
7625
+ * The request stays cancellable until the bounded response body has completed.
7626
+ */
7627
+ function requestDiscovery(target, signal) {
7628
+ if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_aborted"));
7629
+ const url = new URL(`${target.origin}/mobile-access/discovery`);
7630
+ if (url.protocol !== "https:" || url.hostname === "") return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_invalid"));
7631
+ return new Promise((resolveRequest, rejectRequest) => {
7632
+ let settled = false;
7633
+ let request;
7634
+ const release = () => {
7635
+ clearTimeout(timeout);
7636
+ signal.removeEventListener("abort", onAbort);
7637
+ };
7638
+ const fail = (error) => {
7639
+ if (settled) return;
7640
+ settled = true;
7641
+ release();
7642
+ request?.destroy();
7643
+ rejectRequest(error);
7644
+ };
7645
+ const succeed = (value) => {
7646
+ if (settled) return;
7647
+ settled = true;
7648
+ release();
7649
+ resolveRequest(value);
7650
+ };
7651
+ const onAbort = () => {
7652
+ fail(/* @__PURE__ */ new Error("frp_discovery_aborted"));
7653
+ };
7654
+ const timeout = setTimeout(() => {
7655
+ fail(/* @__PURE__ */ new Error("frp_discovery_timeout"));
7656
+ }, DISCOVERY_REQUEST_TIMEOUT_MS);
7657
+ timeout.unref();
7658
+ signal.addEventListener("abort", onAbort, { once: true });
7659
+ request = request$1({
7660
+ host: url.hostname,
7661
+ port: url.port === "" ? 443 : Number(url.port),
7662
+ path: url.pathname,
6110
7663
  method: "GET",
6111
- redirect: "error",
6112
- cache: "no-store",
6113
- signal: requestController.signal,
6114
- headers: { accept: "application/json" }
7664
+ headers: { accept: "application/json" },
7665
+ ...target.trustAnchorPem === void 0 ? {} : { ca: target.trustAnchorPem },
7666
+ agent: false
7667
+ }, (message) => {
7668
+ if ((message.statusCode ?? 0) !== 200) {
7669
+ succeed(void 0);
7670
+ message.destroy();
7671
+ return;
7672
+ }
7673
+ boundedResponseBytes(discoveryResponse(message)).then(succeed, fail);
6115
7674
  });
6116
- if (!response.ok) return false;
6117
- let value;
6118
- try {
6119
- value = JSON.parse(new TextDecoder().decode(await boundedResponseBytes(response)));
6120
- } catch {
6121
- throw new Error("frp_discovery_invalid");
6122
- }
6123
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
6124
- const actual = value.instanceId;
6125
- if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
6126
- if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
6127
- return true;
6128
- } finally {
6129
- clearTimeout(timeout);
6130
- signal.removeEventListener("abort", abort);
7675
+ request.once("error", (error) => {
7676
+ fail(error instanceof Error ? error : /* @__PURE__ */ new Error("frp_discovery_request_failed"));
7677
+ });
7678
+ request.end();
7679
+ });
7680
+ }
7681
+ /**
7682
+ * Read the public discovery advertisement and compare it with the expected
7683
+ * identity.
7684
+ *
7685
+ * `frp_discovery_invalid` (malformed body) and `frp_discovery_mismatch` (the
7686
+ * entry advertises another identity) stay immediate failures; transport, TLS,
7687
+ * and timeout problems reject so the caller retries until its deadline.
7688
+ */
7689
+ async function defaultProbeDiscovery(target, expectedInstanceId, signal) {
7690
+ const bytes = await requestDiscovery(target, signal);
7691
+ if (bytes === void 0) return false;
7692
+ let value;
7693
+ try {
7694
+ value = JSON.parse(new TextDecoder().decode(bytes));
7695
+ } catch {
7696
+ throw new Error("frp_discovery_invalid");
6131
7697
  }
7698
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
7699
+ const actual = value.instanceId;
7700
+ if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
7701
+ if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
7702
+ return true;
6132
7703
  }
6133
7704
  /** Owns frpc, its generation-specific configuration, and the remote gateway. */
6134
7705
  var FrpController = class {
@@ -6145,10 +7716,12 @@ var FrpController = class {
6145
7716
  });
6146
7717
  queue = Promise.resolve();
6147
7718
  startupAbort;
7719
+ ingressCertificateTimer;
6148
7720
  constructor(options) {
6149
7721
  this.options = options;
6150
7722
  if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
6151
7723
  if (!/^[a-f0-9]{64}$/u.test(options.instanceId)) throw new Error("FRP instance ID is invalid");
7724
+ if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
6152
7725
  }
6153
7726
  /** Restore the remembered FRP switch without changing LAN or other providers. */
6154
7727
  async initialize() {
@@ -6268,41 +7841,45 @@ var FrpController = class {
6268
7841
  });
6269
7842
  return;
6270
7843
  }
7844
+ const entryOrigin = frpEntryOrigin(settings);
6271
7845
  this.publish({
6272
7846
  enabled: true,
6273
7847
  state: "starting",
6274
- origin: settings.publicOrigin
7848
+ origin: entryOrigin
6275
7849
  });
6276
- let exposed;
6277
- try {
6278
- exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, FRP_VHOST_HTTP_PORT);
6279
- } catch {
6280
- this.publish({
6281
- enabled: true,
6282
- state: "error",
6283
- origin: settings.publicOrigin,
6284
- errorCode: "frp_vhost_probe_failed"
6285
- });
6286
- return;
6287
- }
6288
- if (exposed) {
6289
- this.publish({
6290
- enabled: true,
6291
- state: "error",
6292
- origin: settings.publicOrigin,
6293
- errorCode: "frp_vhost_publicly_reachable"
6294
- });
6295
- return;
7850
+ if (!isFrpSelfSignedIngress(settings)) {
7851
+ let exposed;
7852
+ try {
7853
+ exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, resolveFrpVhostHttpPort(settings));
7854
+ } catch {
7855
+ this.publish({
7856
+ enabled: true,
7857
+ state: "error",
7858
+ origin: entryOrigin,
7859
+ errorCode: "frp_vhost_probe_failed"
7860
+ });
7861
+ return;
7862
+ }
7863
+ if (exposed) {
7864
+ this.publish({
7865
+ enabled: true,
7866
+ state: "error",
7867
+ origin: entryOrigin,
7868
+ errorCode: "frp_vhost_publicly_reachable"
7869
+ });
7870
+ return;
7871
+ }
6296
7872
  }
6297
7873
  let gateway;
6298
7874
  try {
6299
- gateway = await this.options.createGateway(settings.publicOrigin);
6300
- } catch {
7875
+ gateway = await this.options.createGateway(settings.publicOrigin, settings);
7876
+ } catch (error) {
7877
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "gateway_start_failed";
6301
7878
  this.publish({
6302
7879
  enabled: true,
6303
7880
  state: "error",
6304
- origin: settings.publicOrigin,
6305
- errorCode: "gateway_start_failed"
7881
+ origin: entryOrigin,
7882
+ errorCode: code
6306
7883
  });
6307
7884
  return;
6308
7885
  }
@@ -6341,26 +7918,66 @@ var FrpController = class {
6341
7918
  this.publish({
6342
7919
  enabled: true,
6343
7920
  state: "connecting",
6344
- origin: settings.publicOrigin
7921
+ origin: entryOrigin
6345
7922
  });
7923
+ let check;
7924
+ try {
7925
+ check = await this.discoveryCheck(settings);
7926
+ } catch (error) {
7927
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_ca_invalid";
7928
+ await this.failGeneration(generation, code);
7929
+ return;
7930
+ }
7931
+ if (generation !== this.generation || !this.enabled) return;
6346
7932
  const controller = new AbortController();
6347
7933
  this.startupAbort = controller;
6348
- this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
7934
+ this.waitForDiscovery(generation, check, gateway.config.instanceId, settings, gateway, controller.signal);
7935
+ }
7936
+ /**
7937
+ * Derive the self-check target and trust anchor for the effective entry.
7938
+ *
7939
+ * The public-CA entry is reached on the saved origin itself: Caddy terminates
7940
+ * TLS on 443 with a publicly trusted certificate, so the probe must keep the
7941
+ * system trust store and the origin URL untouched. The self-signed entry is a
7942
+ * raw TCP passthrough on `publicPort` whose leaf chains to the ingress CA the
7943
+ * app pins from `pairingCaFile`; probing `publicOrigin` there would knock on
7944
+ * 443 — where nothing listens — with a chain the system cannot verify. Both
7945
+ * facts used to be implicit, and together they kept the channel out of `ready`
7946
+ * until `frp_start_timeout`.
7947
+ */
7948
+ async discoveryCheck(settings) {
7949
+ if (!isFrpSelfSignedIngress(settings)) return {
7950
+ publicOrigin: settings.publicOrigin,
7951
+ targetOrigin: settings.publicOrigin
7952
+ };
7953
+ const targetOrigin = frpEntryOrigin(settings);
7954
+ const trustAnchorPem = await this.options.resolveDiscoveryTrustAnchor?.(settings);
7955
+ if (this.options.resolveDiscoveryTrustAnchor !== void 0 && trustAnchorPem === void 0) throw new Error("frp_ingress_ca_invalid");
7956
+ return Object.freeze({
7957
+ publicOrigin: targetOrigin,
7958
+ targetOrigin,
7959
+ ...trustAnchorPem === void 0 ? {} : { trustAnchorPem }
7960
+ });
6349
7961
  }
6350
- async waitForDiscovery(generation, origin, signal) {
7962
+ async waitForDiscovery(generation, check, advertisedInstanceId, settings, gateway, signal) {
6351
7963
  const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
6352
7964
  const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
7965
+ const target = Object.freeze({
7966
+ origin: check.targetOrigin,
7967
+ ...check.trustAnchorPem === void 0 ? {} : { trustAnchorPem: check.trustAnchorPem }
7968
+ });
6353
7969
  while (!signal.aborted && Date.now() < deadline) {
6354
7970
  try {
6355
- if (await probe(origin, this.options.instanceId, signal)) {
7971
+ if (await probe(target, advertisedInstanceId, signal)) {
6356
7972
  await this.enqueue(async () => {
6357
7973
  if (generation !== this.generation || signal.aborted || !this.enabled) return;
6358
7974
  this.startupAbort = void 0;
6359
7975
  this.publish({
6360
7976
  enabled: true,
6361
7977
  state: "ready",
6362
- origin
7978
+ origin: check.publicOrigin
6363
7979
  });
7980
+ this.scheduleIngressCertificateCheck(generation, settings, gateway);
6364
7981
  });
6365
7982
  return;
6366
7983
  }
@@ -6387,6 +8004,33 @@ var FrpController = class {
6387
8004
  }
6388
8005
  if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
6389
8006
  }
8007
+ scheduleIngressCertificateCheck(generation, settings, gateway) {
8008
+ const maintain = this.options.maintainIngressCertificate;
8009
+ if (!isFrpSelfSignedIngress(settings) || maintain === void 0) return;
8010
+ const timer = setTimeout(() => {
8011
+ if (this.ingressCertificateTimer !== timer) return;
8012
+ this.ingressCertificateTimer = void 0;
8013
+ this.enqueue(async () => {
8014
+ if (generation !== this.generation || !this.enabled || this.disposed || this.gatewayValue !== gateway) return;
8015
+ try {
8016
+ await maintain(settings, gateway);
8017
+ } catch (error) {
8018
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_renewal_failed";
8019
+ await this.failGeneration(generation, code);
8020
+ return;
8021
+ }
8022
+ if (generation === this.generation && this.enabled && !this.disposed && this.gatewayValue === gateway) this.scheduleIngressCertificateCheck(generation, settings, gateway);
8023
+ }).catch(() => {
8024
+ if (generation === this.generation && this.enabled && !this.disposed) this.publish({
8025
+ enabled: true,
8026
+ state: "error",
8027
+ errorCode: "frp_ingress_renewal_failed"
8028
+ });
8029
+ });
8030
+ }, this.options.ingressCertificateCheckMs ?? INGRESS_CERT_CHECK_MS);
8031
+ timer.unref();
8032
+ this.ingressCertificateTimer = timer;
8033
+ }
6390
8034
  async failGeneration(generation, code) {
6391
8035
  if (generation !== this.generation) return;
6392
8036
  await this.stopProcessAndGateway();
@@ -6401,6 +8045,8 @@ var FrpController = class {
6401
8045
  await this.stopProcessAndGateway();
6402
8046
  }
6403
8047
  async stopProcessAndGateway() {
8048
+ clearTimeout(this.ingressCertificateTimer);
8049
+ this.ingressCertificateTimer = void 0;
6404
8050
  this.startupAbort?.abort();
6405
8051
  this.startupAbort = void 0;
6406
8052
  const child = this.child;
@@ -7981,21 +9627,212 @@ function defaultFirewallProbe(platform = process.platform) {
7981
9627
  }
7982
9628
  };
7983
9629
  }
7984
- /** Allow remote relays enough time to answer without making diagnostics unbounded. */
7985
- function remoteDiagnosticTimeoutMs(origin) {
7986
- const hostname = new URL(origin).hostname.toLowerCase();
7987
- if (hostname.endsWith(".ts.net") || hostname.includes(".cpolar.")) return 1e4;
7988
- return 1e4;
9630
+ /** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
9631
+ function remoteDiagnosticTimeoutMs(_origin) {
9632
+ return 6e3;
9633
+ }
9634
+ const TUNNEL_HEADER_LIMIT_BYTES = 65536;
9635
+ function proxyExcludedByNoProxy(target, raw) {
9636
+ const text = raw.trim().toLowerCase();
9637
+ if (text === "") return false;
9638
+ const hostname = target.hostname.toLowerCase();
9639
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
9640
+ const targetPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
9641
+ for (const entry of text.split(/[\s,]+/)) {
9642
+ let value = entry;
9643
+ if (value === "") continue;
9644
+ if (value === "*") return true;
9645
+ if (value.includes("/")) continue;
9646
+ let port;
9647
+ if (value.startsWith("[")) {
9648
+ const bracket = value.indexOf("]");
9649
+ if (bracket < 0) continue;
9650
+ const tail = value.slice(bracket + 1);
9651
+ if (tail !== "") {
9652
+ if (!/^:\d+$/u.test(tail)) continue;
9653
+ port = Number(tail.slice(1));
9654
+ }
9655
+ value = value.slice(1, bracket);
9656
+ } else if ((value.match(/:/gu) ?? []).length === 1) {
9657
+ const separator = value.lastIndexOf(":");
9658
+ const suffix = value.slice(separator + 1);
9659
+ if (!/^\d+$/u.test(suffix)) continue;
9660
+ port = Number(suffix);
9661
+ value = value.slice(0, separator);
9662
+ }
9663
+ if (port !== void 0 && port !== targetPort) continue;
9664
+ if (value.startsWith("*.")) value = value.slice(2);
9665
+ else if (value.startsWith(".")) value = value.slice(1);
9666
+ if (value === "") continue;
9667
+ if (host === value || host.endsWith(`.${value}`)) return true;
9668
+ }
9669
+ return false;
9670
+ }
9671
+ /**
9672
+ * Resolve the relay egress configured in the environment for `origin`.
9673
+ *
9674
+ * Honors `HTTPS_PROXY` (then `ALL_PROXY`, then the generic `HTTP_PROXY`) for an
9675
+ * HTTPS target, and `http_proxy`/`HTTP_PROXY`/`ALL_PROXY` for a plain one, unless
9676
+ * `NO_PROXY`/`no_proxy` covers the host. Only a plain `http://<host>:<port>`
9677
+ * relay is usable: SOCKS or a TLS-to-proxy configuration would need a different
9678
+ * tunnel, so absent that the probe keeps its direct semantics.
9679
+ */
9680
+ function configuredProxyFor(origin, env = process.env) {
9681
+ let target;
9682
+ try {
9683
+ target = new URL(origin);
9684
+ } catch {
9685
+ return;
9686
+ }
9687
+ if (proxyExcludedByNoProxy(target, env.NO_PROXY ?? env.no_proxy ?? "")) return void 0;
9688
+ const candidates = target.protocol === "https:" ? [
9689
+ env.https_proxy ?? env.HTTPS_PROXY,
9690
+ env.all_proxy ?? env.ALL_PROXY,
9691
+ env.http_proxy ?? env.HTTP_PROXY
9692
+ ] : [env.http_proxy ?? env.HTTP_PROXY, env.all_proxy ?? env.ALL_PROXY];
9693
+ for (const candidate of candidates) {
9694
+ if (typeof candidate !== "string" || candidate.trim() === "") continue;
9695
+ try {
9696
+ const proxy = new URL(candidate.trim());
9697
+ if (proxy.protocol !== "http:" || proxy.hostname === "") continue;
9698
+ return proxy;
9699
+ } catch {
9700
+ continue;
9701
+ }
9702
+ }
9703
+ }
9704
+ /** One reachability probe through an HTTP relay: CONNECT, TLS, then a status line. */
9705
+ async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnosticTimeoutMs(target.origin)) {
9706
+ const started = performance.now();
9707
+ return new Promise((resolve, reject) => {
9708
+ let settled = false;
9709
+ let hopRequest;
9710
+ let rawSocket;
9711
+ let secureSocket;
9712
+ let timer;
9713
+ const finish = (outcome) => {
9714
+ if (settled) return;
9715
+ settled = true;
9716
+ clearTimeout(timer);
9717
+ secureSocket?.destroy();
9718
+ rawSocket?.end();
9719
+ rawSocket?.destroy();
9720
+ hopRequest?.destroy();
9721
+ if (outcome.error !== void 0) reject(outcome.error);
9722
+ else resolve(outcome.value ?? {
9723
+ status: 0,
9724
+ latencyMs: 0
9725
+ });
9726
+ };
9727
+ timer = setTimeout(() => {
9728
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_timeout") });
9729
+ }, Math.max(1, budgetMs));
9730
+ timer.unref();
9731
+ const hopPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
9732
+ const hopPath = `${target.hostname}:${hopPort}`;
9733
+ const headers = {
9734
+ host: hopPath,
9735
+ "proxy-connection": "keep-alive"
9736
+ };
9737
+ if (proxy.username !== "") headers["proxy-authorization"] = `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`, "utf8").toString("base64")}`;
9738
+ hopRequest = request({
9739
+ host: proxy.hostname,
9740
+ port: proxy.port === "" ? 80 : Number(proxy.port),
9741
+ method: "CONNECT",
9742
+ path: hopPath,
9743
+ headers,
9744
+ agent: false
9745
+ });
9746
+ hopRequest.once("connect", (response, socket) => {
9747
+ if (settled) {
9748
+ socket.destroy();
9749
+ return;
9750
+ }
9751
+ rawSocket = socket;
9752
+ if (response.statusCode !== 200) {
9753
+ finish({ error: /* @__PURE__ */ new Error(`proxy_tunnel_rejected_${response.statusCode ?? "unknown"}`) });
9754
+ return;
9755
+ }
9756
+ const secure = connect$1({
9757
+ socket,
9758
+ servername: target.hostname,
9759
+ rejectUnauthorized: true
9760
+ });
9761
+ secureSocket = secure;
9762
+ secure.once("secureConnect", () => {
9763
+ if (settled) return;
9764
+ secure.write([
9765
+ `GET ${target.pathname}${target.search} HTTP/1.1`,
9766
+ `Host: ${target.host}`,
9767
+ "User-Agent: dsh-mobile-diagnostics",
9768
+ "Accept-Encoding: identity",
9769
+ "Connection: close",
9770
+ "",
9771
+ ""
9772
+ ].join("\r\n"));
9773
+ });
9774
+ let buffered = Buffer.alloc(0);
9775
+ secure.on("data", (chunk) => {
9776
+ buffered = Buffer.concat([buffered, chunk]);
9777
+ if (buffered.byteLength > TUNNEL_HEADER_LIMIT_BYTES) {
9778
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_oversized_response") });
9779
+ return;
9780
+ }
9781
+ const end = buffered.indexOf("\r\n\r\n");
9782
+ if (end < 0) return;
9783
+ const statusLine = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/u.exec(buffered.subarray(0, end).toString("latin1"))?.[1];
9784
+ const status = Number.parseInt(statusLine ?? "", 10);
9785
+ if (!Number.isSafeInteger(status) || status < 100) {
9786
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_invalid_response") });
9787
+ return;
9788
+ }
9789
+ finish({ value: {
9790
+ status,
9791
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
9792
+ } });
9793
+ });
9794
+ secure.once("error", (error) => {
9795
+ finish({ error });
9796
+ });
9797
+ secure.once("close", () => {
9798
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_closed") });
9799
+ });
9800
+ });
9801
+ hopRequest.once("error", (error) => {
9802
+ finish({ error });
9803
+ });
9804
+ hopRequest.end();
9805
+ });
9806
+ }
9807
+ /** Bounded supplementary DNS context for a failed direct attempt. */
9808
+ async function unreachableWithDnsContext(hostname, budgetMs) {
9809
+ let fakeIp = false;
9810
+ let timer;
9811
+ try {
9812
+ fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
9813
+ timer = setTimeout(() => {
9814
+ reject(/* @__PURE__ */ new Error("dns_context_timeout"));
9815
+ }, Math.max(1, budgetMs));
9816
+ timer.unref();
9817
+ })])).some(({ address }) => {
9818
+ const [first, second] = address.split(".").map(Number);
9819
+ return first === 198 && (second === 18 || second === 19);
9820
+ });
9821
+ } catch {} finally {
9822
+ clearTimeout(timer);
9823
+ }
9824
+ return {
9825
+ state: "unreachable",
9826
+ ...fakeIp ? { fakeIp: true } : {}
9827
+ };
7989
9828
  }
7990
- async function defaultRemoteProbe(origin) {
7991
- if (origin === void 0) return { state: "not-applicable" };
7992
- const hostname = new URL(origin).hostname;
9829
+ async function fetchRemoteHealth(target, budgetMs) {
7993
9830
  const started = performance.now();
7994
9831
  try {
7995
- const response = await fetch(new URL("/mobile-access/health", origin), {
9832
+ const response = await fetch(target, {
7996
9833
  cache: "no-store",
7997
9834
  redirect: "error",
7998
- signal: AbortSignal.timeout(remoteDiagnosticTimeoutMs(origin))
9835
+ signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
7999
9836
  });
8000
9837
  const latencyMs = Math.max(0, Math.round(performance.now() - started));
8001
9838
  if (response.status === 429) return {
@@ -8010,19 +9847,53 @@ async function defaultRemoteProbe(origin) {
8010
9847
  latencyMs
8011
9848
  };
8012
9849
  } catch {
8013
- let fakeIp = false;
8014
- try {
8015
- fakeIp = (await lookup(hostname, { all: true })).some(({ address }) => {
8016
- const [first, second] = address.split(".").map(Number);
8017
- return first === 198 && (second === 18 || second === 19);
8018
- });
8019
- } catch {}
8020
- return {
8021
- state: "unreachable",
8022
- ...fakeIp ? { fakeIp: true } : {}
9850
+ return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
9851
+ }
9852
+ }
9853
+ /**
9854
+ * Answer "is the public endpoint reachable" from the machine's own egress.
9855
+ *
9856
+ * A direct attempt stays authoritative first. When it fails and the environment
9857
+ * configures an HTTP relay that `NO_PROXY` does not exclude, one tunnelled
9858
+ * attempt runs: a self-managed Funnel origin otherwise resolves to this
9859
+ * machine's tailnet address, where nothing serves the funnel port locally, so
9860
+ * the direct measurement reports an outage while the relay's egress — the same
9861
+ * public path a phone takes — still succeeds. When both fail, the original
9862
+ * direct observation (including its Fake-IP context) stands unchanged.
9863
+ */
9864
+ async function probeRemoteHealth(origin, options = {}) {
9865
+ if (origin === void 0) return { state: "not-applicable" };
9866
+ let target;
9867
+ try {
9868
+ target = new URL("/mobile-access/health", origin);
9869
+ } catch {
9870
+ return { state: "not-applicable" };
9871
+ }
9872
+ const budgetMs = remoteDiagnosticTimeoutMs(origin);
9873
+ const observation = await (options.direct ?? fetchRemoteHealth)(target, budgetMs);
9874
+ if (observation.state !== "unreachable") return observation;
9875
+ const relay = configuredProxyFor(origin, options.env ?? process.env);
9876
+ if (relay === void 0) return observation;
9877
+ const tunnel = options.tunnel ?? relayProbeThroughProxy;
9878
+ try {
9879
+ const { status, latencyMs } = await tunnel(target, relay, budgetMs);
9880
+ if (status === 429) return {
9881
+ state: "rate-limited",
9882
+ latencyMs,
9883
+ viaProxy: true
8023
9884
  };
9885
+ return status >= 200 && status < 300 ? {
9886
+ state: "ready",
9887
+ latencyMs,
9888
+ viaProxy: true
9889
+ } : observation;
9890
+ } catch {
9891
+ return observation;
8024
9892
  }
8025
9893
  }
9894
+ async function defaultRemoteProbe(origin) {
9895
+ return probeRemoteHealth(origin);
9896
+ }
8026
9897
  function reportLine(entry) {
8027
9898
  return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
8028
9899
  }
@@ -8052,10 +9923,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
8052
9923
  const facts = {
8053
9924
  provider: snapshot.remote.provider,
8054
9925
  endpointSuffix,
8055
- ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
9926
+ ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
9927
+ ...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
8056
9928
  };
8057
- if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
8058
- else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
9929
+ if (remoteObservation.state === "ready") checks.push(check("remote", remoteObservation.viaProxy === true ? "warning" : "ok", "remote-ready", "远程通道", remoteObservation.viaProxy === true ? `电脑经代理可访问 ${endpointSuffix},但尚未验证手机能否连接。` : `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, remoteObservation.viaProxy === true ? "请在手机上测试远程连接。" : void 0, facts));
9930
+ else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
8059
9931
  else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
8060
9932
  else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
8061
9933
  } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
@@ -10618,394 +12490,76 @@ var PluginReleaseManager = class {
10618
12490
  return status;
10619
12491
  }
10620
12492
  /** Install the latest npm release into the active profile, then require a DSH restart. */
10621
- async update() {
10622
- if (this.activeUpdate !== void 0) return this.activeUpdate;
10623
- this.activeUpdate = this.updateOnce();
10624
- try {
10625
- return await this.activeUpdate;
10626
- } finally {
10627
- this.activeUpdate = void 0;
10628
- }
10629
- }
10630
- async updateOnce() {
10631
- const profileDirectory = this.profileDirectory;
10632
- if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
10633
- const status = await this.status(true);
10634
- if (!status.updateSupported) throw new Error("plugin_update_unsupported");
10635
- if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
10636
- await this.runner(profileDirectory, status.latestVersion);
10637
- const installed = await this.installedVersionReader(profileDirectory);
10638
- if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
10639
- this.cache = void 0;
10640
- return Object.freeze({
10641
- installedVersion: installed,
10642
- restartRequired: true
10643
- });
10644
- }
10645
- };
10646
- //#endregion
10647
- //#region src/file-logger.ts
10648
- const MAX_LOG_BYTES = 5242880;
10649
- /** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
10650
- async function installMobileFileLogger(ctx, stateDirectory) {
10651
- const directory = join(stateDirectory, "logs");
10652
- const file = join(directory, "dsh-mobile.log");
10653
- const previous = `${file}.1`;
10654
- await mkdir(directory, {
10655
- recursive: true,
10656
- mode: 448
10657
- });
10658
- const entry = await lstat(file).catch(() => void 0);
10659
- if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
10660
- await rm(previous, { force: true });
10661
- await rename(file, previous);
10662
- }
10663
- const stream = createWriteStream(file, {
10664
- flags: "a",
10665
- encoding: "utf8",
10666
- mode: 384
10667
- });
10668
- const exporter = {
10669
- colors: false,
10670
- maxLength: 16384,
10671
- levels: {
10672
- default: -1,
10673
- "dsh-mobile": 3
10674
- },
10675
- export(message) {
10676
- if (message.name !== "dsh-mobile") return;
10677
- const record = {
10678
- timestamp: new Date(message.ts).toISOString(),
10679
- level: message.type,
10680
- logger: message.name,
10681
- message: Logger.format(exporter, message)
10682
- };
10683
- stream.write(`${JSON.stringify(record)}\n`);
10684
- }
10685
- };
10686
- ctx.logger.exporter(exporter);
10687
- ctx.effect(() => () => {
10688
- stream.end();
10689
- }, "dsh-mobile file logger");
10690
- return file;
10691
- }
10692
- //#endregion
10693
- //#region src/managed-setup.ts
10694
- const VIRTUAL_INTERFACE_MARKERS = [
10695
- "bridge",
10696
- "docker",
10697
- "hyper-v",
10698
- "mihomo",
10699
- "radmin",
10700
- "tailscale",
10701
- "tap",
10702
- "tun",
10703
- "utun",
10704
- "vbox",
10705
- "veth",
10706
- "virtual",
10707
- "vmware",
10708
- "vpn",
10709
- "vethernet",
10710
- "wsl",
10711
- "zerotier"
10712
- ];
10713
- /** Route inspection is advisory; never let a system command hold setup open indefinitely. */
10714
- const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
10715
- function requiredString(value, name) {
10716
- if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
10717
- return value;
10718
- }
10719
- /** Validate the durable managed setup before it controls network and filesystem operations. */
10720
- function parseManagedSetup(value) {
10721
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
10722
- const record = value;
10723
- if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
10724
- "version",
10725
- "networkInterface",
10726
- "listenPort",
10727
- "upstreamOrigin",
10728
- "tls"
10729
- ].includes(key))) throw new Error("mobile setup file has an unsupported format");
10730
- if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
10731
- if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
10732
- const tls = record.tls;
10733
- if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
10734
- "mode",
10735
- "caCertFile",
10736
- "caKeyFile",
10737
- "certFile",
10738
- "keyFile"
10739
- ].includes(key))) throw new Error("mobile setup tls has an unsupported format");
10740
- return Object.freeze({
10741
- version: 2,
10742
- networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
10743
- listenPort: record.listenPort,
10744
- upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
10745
- tls: Object.freeze({
10746
- mode: "managed",
10747
- caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
10748
- caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
10749
- certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
10750
- keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
10751
- })
10752
- });
10753
- }
10754
- function privateIpv4(value) {
10755
- const parts = value.split(".").map(Number);
10756
- return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
10757
- }
10758
- function networkCidr(address, cidr) {
10759
- const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
10760
- const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
10761
- return `${[
10762
- 24,
10763
- 16,
10764
- 8,
10765
- 0
10766
- ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
10767
- }
10768
- /** List current private IPv4 candidates with their interface identity. */
10769
- function availableLanNetworks(table = networkInterfaces()) {
10770
- const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
10771
- name,
10772
- address: entry.address,
10773
- cidr: networkCidr(entry.address, entry.cidr)
10774
- })));
10775
- return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
10776
- }
10777
- function likelyVirtualInterface(name) {
10778
- const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
10779
- return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
10780
- }
10781
- async function runRouteCommand(file, args) {
10782
- return (await execFileText(file, [...args], {
10783
- encoding: "utf8",
10784
- timeout: ROUTE_COMMAND_TIMEOUT_MS
10785
- })).stdout;
10786
- }
10787
- function uniqueLines(output) {
10788
- return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
10789
- }
10790
- /** Return operating-system default-route interfaces in routing preference order. */
10791
- async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
10792
- try {
10793
- if (platform === "win32") return uniqueLines(await run("powershell.exe", [
10794
- "-NoProfile",
10795
- "-NonInteractive",
10796
- "-Command",
10797
- [
10798
- "$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
10799
- "$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
10800
- " $route = $_",
10801
- " $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
10802
- " $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
10803
- " if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
10804
- " [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
10805
- " }",
10806
- "}",
10807
- "$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
10808
- ].join("; ")
10809
- ]));
10810
- if (platform === "linux") {
10811
- const routes = uniqueLines(await run("ip", [
10812
- "-o",
10813
- "-4",
10814
- "route",
10815
- "show",
10816
- "default"
10817
- ])).map((line) => ({
10818
- name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
10819
- metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
10820
- })).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
10821
- return [...new Set(routes.map((route) => route.name))];
10822
- }
10823
- if (platform === "darwin") {
10824
- const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
10825
- "-n",
10826
- "get",
10827
- "default"
10828
- ]))?.[1];
10829
- return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
10830
- }
10831
- } catch {}
10832
- return [];
10833
- }
10834
- /** Select an active LAN, optionally by address or by a previously saved interface name. */
10835
- function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
10836
- const candidates = availableLanNetworks(table);
10837
- if (requestedAddress !== void 0) {
10838
- const match = candidates.find((candidate) => candidate.address === requestedAddress);
10839
- if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
10840
- return match;
10841
- }
10842
- if (requestedInterface !== void 0) {
10843
- const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
10844
- if (matches.length === 1) return matches[0];
10845
- if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
10846
- throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
12493
+ async update() {
12494
+ if (this.activeUpdate !== void 0) return this.activeUpdate;
12495
+ this.activeUpdate = this.updateOnce();
12496
+ try {
12497
+ return await this.activeUpdate;
12498
+ } finally {
12499
+ this.activeUpdate = void 0;
12500
+ }
10847
12501
  }
10848
- if (candidates.length === 1) return candidates[0];
10849
- if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
10850
- for (const name of preferredInterfaces) {
10851
- const matches = candidates.filter((candidate) => candidate.name === name);
10852
- if (matches.length === 1) return matches[0];
12502
+ async updateOnce() {
12503
+ const profileDirectory = this.profileDirectory;
12504
+ if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
12505
+ const status = await this.status(true);
12506
+ if (!status.updateSupported) throw new Error("plugin_update_unsupported");
12507
+ if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
12508
+ await this.runner(profileDirectory, status.latestVersion);
12509
+ const installed = await this.installedVersionReader(profileDirectory);
12510
+ if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
12511
+ this.cache = void 0;
12512
+ return Object.freeze({
12513
+ installedVersion: installed,
12514
+ restartRequired: true
12515
+ });
10853
12516
  }
10854
- const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
10855
- if (physicalCandidates.length === 1) return physicalCandidates[0];
10856
- throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
10857
- }
10858
- /**
10859
- * True when the failure came from LAN selection (the configured network is
10860
- * gone, nothing is up, or several are) rather than from config/TLS code.
10861
- * Callers use it to degrade (warn + stay down) instead of failing boot.
10862
- * Pinned to the selectLanNetwork message contract — keep in sync.
10863
- */
10864
- function isNetworkSelectionError(error) {
10865
- const message = error instanceof Error ? error.message : String(error ?? "");
10866
- return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
10867
- }
10868
- function assertMatchingCa(certPem, keyPem) {
10869
- const certificate = new X509Certificate(certPem);
10870
- if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
10871
- const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
10872
- format: "der",
10873
- type: "spki"
10874
- });
10875
- const certificatePublic = certificate.publicKey.export({
10876
- format: "der",
10877
- type: "spki"
10878
- });
10879
- if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
10880
- if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
10881
- return certificate;
10882
- }
10883
- async function atomicWrite(file, contents) {
10884
- const directory = dirname(file);
12517
+ };
12518
+ //#endregion
12519
+ //#region src/file-logger.ts
12520
+ const MAX_LOG_BYTES = 5242880;
12521
+ /** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
12522
+ async function installMobileFileLogger(ctx, stateDirectory) {
12523
+ const directory = join(stateDirectory, "logs");
12524
+ const file = join(directory, "dsh-mobile.log");
12525
+ const previous = `${file}.1`;
10885
12526
  await mkdir(directory, {
10886
12527
  recursive: true,
10887
12528
  mode: 448
10888
12529
  });
10889
- const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
10890
- await writeFile(temporary, contents, { mode: 384 });
10891
- await rename(temporary, file);
10892
- await restrictPrivateFile(file);
10893
- }
10894
- /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
10895
- async function ensureManagedCa(setup, legacy) {
10896
- let certPem;
10897
- let keyPem;
10898
- try {
10899
- [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
10900
- } catch (error) {
10901
- if (error.code !== "ENOENT") throw error;
10902
- let migrated = false;
10903
- if (legacy !== void 0) try {
10904
- [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
10905
- assertMatchingCa(certPem, keyPem);
10906
- migrated = true;
10907
- } catch (legacyError) {
10908
- if (legacyError.code !== "ENOENT") throw legacyError;
10909
- }
10910
- if (!migrated) {
10911
- const now = /* @__PURE__ */ new Date();
10912
- const notAfter = new Date(now);
10913
- notAfter.setFullYear(notAfter.getFullYear() + 5);
10914
- const generated = await generate([{
10915
- name: "commonName",
10916
- value: "DeepSeek Harness Mobile CA"
10917
- }], {
10918
- keyType: "ec",
10919
- curve: "P-256",
10920
- algorithm: "sha256",
10921
- notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
10922
- notAfterDate: notAfter,
10923
- extensions: [{
10924
- name: "basicConstraints",
10925
- cA: true,
10926
- critical: true
10927
- }, {
10928
- name: "keyUsage",
10929
- digitalSignature: true,
10930
- keyCertSign: true,
10931
- cRLSign: true,
10932
- critical: true
10933
- }]
10934
- });
10935
- certPem = generated.cert;
10936
- keyPem = generated.private;
10937
- }
10938
- if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
10939
- await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
12530
+ const entry = await lstat(file).catch(() => void 0);
12531
+ if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
12532
+ await rm(previous, { force: true });
12533
+ await rename(file, previous);
10940
12534
  }
10941
- if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
10942
- await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
10943
- return assertMatchingCa(certPem, keyPem);
10944
- }
10945
- /** Sign and atomically install a server leaf for the interface's current address. */
10946
- async function refreshManagedServerCertificate(setup, address) {
10947
- await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)]);
10948
- const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
10949
- assertMatchingCa(caCert, caKey);
10950
- const now = /* @__PURE__ */ new Date();
10951
- const notAfter = new Date(now);
10952
- notAfter.setDate(notAfter.getDate() + 397);
10953
- const server = await generate([{
10954
- name: "commonName",
10955
- value: "DeepSeek Harness Mobile"
10956
- }], {
10957
- keyType: "ec",
10958
- curve: "P-256",
10959
- algorithm: "sha256",
10960
- notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
10961
- notAfterDate: notAfter,
10962
- ca: {
10963
- cert: caCert,
10964
- key: caKey
10965
- },
10966
- extensions: [
10967
- {
10968
- name: "basicConstraints",
10969
- cA: false,
10970
- critical: true
10971
- },
10972
- {
10973
- name: "keyUsage",
10974
- digitalSignature: true,
10975
- critical: true
10976
- },
10977
- {
10978
- name: "extKeyUsage",
10979
- serverAuth: true
10980
- },
10981
- {
10982
- name: "subjectAltName",
10983
- altNames: [{
10984
- type: 7,
10985
- ip: address
10986
- }]
10987
- }
10988
- ]
12535
+ const stream = createWriteStream(file, {
12536
+ flags: "a",
12537
+ encoding: "utf8",
12538
+ mode: 384
10989
12539
  });
10990
- await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
10991
- }
10992
- /** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
10993
- async function materializeManagedSetup(setup, table) {
10994
- const network = selectLanNetwork(void 0, setup.networkInterface, table);
10995
- await refreshManagedServerCertificate(setup, network.address);
10996
- const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
10997
- return {
10998
- publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
10999
- listenHost: network.address,
11000
- allowedCidrs: [network.cidr],
11001
- instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
11002
- pairingCaFile: setup.tls.caCertFile,
11003
- tls: {
11004
- mode: "provided",
11005
- certFile: setup.tls.certFile,
11006
- keyFile: setup.tls.keyFile
12540
+ const exporter = {
12541
+ colors: false,
12542
+ maxLength: 16384,
12543
+ levels: {
12544
+ default: -1,
12545
+ "dsh-mobile": 3
12546
+ },
12547
+ export(message) {
12548
+ if (message.name !== "dsh-mobile") return;
12549
+ const record = {
12550
+ timestamp: new Date(message.ts).toISOString(),
12551
+ level: message.type,
12552
+ logger: message.name,
12553
+ message: Logger.format(exporter, message)
12554
+ };
12555
+ stream.write(`${JSON.stringify(record)}\n`);
11007
12556
  }
11008
12557
  };
12558
+ ctx.logger.exporter(exporter);
12559
+ ctx.effect(() => () => {
12560
+ stream.end();
12561
+ }, "dsh-mobile file logger");
12562
+ return file;
11009
12563
  }
11010
12564
  //#endregion
11011
12565
  //#region src/lan-setup.ts
@@ -11269,6 +12823,56 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
11269
12823
  discovery: false
11270
12824
  });
11271
12825
  }
12826
+ /**
12827
+ * Gateway config for the self-signed FRP ingress.
12828
+ *
12829
+ * Unlike {@link remoteGatewayConfig}, the gateway itself is the TLS endpoint:
12830
+ * frps only forwards raw TCP, so the listener speaks HTTPS with the leaf that
12831
+ * `pairingCaFile` signed, and the pairing CA is deliberately retained so the
12832
+ * gateway can serve `GET /mobile-access/ca.cer` for the app to pin. The listener
12833
+ * stays on loopback because the public entry is the frps TCP proxy, never a
12834
+ * direct bind.
12835
+ */
12836
+ function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenPort = 0) {
12837
+ if (!isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
12838
+ const publicAuthority = `${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
12839
+ const { pairingCaFile: _templateCaFile, ...shared } = template;
12840
+ return Object.freeze({
12841
+ ...shared,
12842
+ listenHost: "127.0.0.1",
12843
+ listenPort,
12844
+ authorities: Object.freeze([parseAuthority(publicAuthority)]),
12845
+ allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
12846
+ stateFile,
12847
+ instanceId: ingress.caFingerprint,
12848
+ pairingCaFile: ingress.paths.caCertFile,
12849
+ tls: Object.freeze({
12850
+ mode: "provided",
12851
+ certFile: ingress.paths.certFile,
12852
+ keyFile: ingress.paths.keyFile
12853
+ }),
12854
+ publicTls: true,
12855
+ discovery: false
12856
+ });
12857
+ }
12858
+ /**
12859
+ * CA the FRP start-up self-check must pin, read from the ingress directory.
12860
+ *
12861
+ * The self-signed entry presents a leaf signed by this CA, which is absent from
12862
+ * the system trust store, so the probe must anchor it exactly as the app does
12863
+ * when it pins `pairingCaFile`. The public-CA entry answers with a publicly
12864
+ * trusted certificate and therefore returns `undefined` (system trust store).
12865
+ * An unreadable self-signed CA fails explicitly: falling back to the system
12866
+ * trust store would hide a broken pairing identity behind a start timeout.
12867
+ */
12868
+ async function readFrpIngressTrustAnchor(settings, stateFile) {
12869
+ if (!isFrpSelfSignedIngress(settings)) return void 0;
12870
+ try {
12871
+ return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
12872
+ } catch (error) {
12873
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
12874
+ }
12875
+ }
11272
12876
  /** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
11273
12877
  function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
11274
12878
  const validated = parseOriginSettings(settings);
@@ -11463,6 +13067,27 @@ async function apply(ctx, config) {
11463
13067
  await candidate.start();
11464
13068
  return candidate;
11465
13069
  };
13070
+ /**
13071
+ * Gateway factory for the FRP provider.
13072
+ *
13073
+ * The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
13074
+ * self-signed entry terminates TLS inside the gateway, so it first materializes
13075
+ * a leaf the pairing CA signed for the public IPv4 and then binds the ingress
13076
+ * configuration that keeps that CA pinned.
13077
+ */
13078
+ const createFrpGateway = async (publicOrigin, settings) => {
13079
+ if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
13080
+ const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
13081
+ const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
13082
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
13083
+ try {
13084
+ await candidate.start();
13085
+ } catch (error) {
13086
+ await candidate.close();
13087
+ throw error;
13088
+ }
13089
+ return candidate;
13090
+ };
11466
13091
  const createOriginGateway = async (settings) => {
11467
13092
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
11468
13093
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
@@ -11504,7 +13129,18 @@ async function apply(ctx, config) {
11504
13129
  executable: frpComponent.executable,
11505
13130
  config: frpConfig,
11506
13131
  instanceId,
11507
- createGateway: createRemoteGateway
13132
+ createGateway: createFrpGateway,
13133
+ /**
13134
+ * Hand the start-up self-check the CA the app itself pins.
13135
+ *
13136
+ * Only this module knows where the ingress material lives, so the anchor is
13137
+ * read here from the ingress directory instead of being guessed downstream.
13138
+ */
13139
+ resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
13140
+ maintainIngressCertificate: async (settings, gateway) => {
13141
+ await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
13142
+ await gateway.refreshProvidedTls();
13143
+ }
11508
13144
  }),
11509
13145
  origin: new OriginController({
11510
13146
  store: originStore,
@@ -11566,6 +13202,25 @@ async function apply(ctx, config) {
11566
13202
  networkError = "network_interface_unavailable";
11567
13203
  }
11568
13204
  const remote = remoteController().status();
13205
+ const frpOrigin = remote.origin;
13206
+ const frpSettings = remoteProviders.selected === "frp" ? frpConfig.settings() : void 0;
13207
+ const frpGateway = remoteControllers.frp.gateway();
13208
+ const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
13209
+ try {
13210
+ const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
13211
+ if (trustAnchorPem === void 0) return { state: "unreachable" };
13212
+ const started = performance.now();
13213
+ return await defaultProbeDiscovery({
13214
+ origin: frpOrigin,
13215
+ trustAnchorPem
13216
+ }, frpGateway.config.instanceId, AbortSignal.timeout(5500)) ? {
13217
+ state: "ready",
13218
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
13219
+ } : { state: "unreachable" };
13220
+ } catch {
13221
+ return { state: "unreachable" };
13222
+ }
13223
+ } : void 0;
11569
13224
  return collectConnectionDiagnostics({
11570
13225
  dshVersion,
11571
13226
  lan: {
@@ -11589,7 +13244,7 @@ async function apply(ctx, config) {
11589
13244
  ...remote.origin === void 0 ? {} : { origin: remote.origin },
11590
13245
  ...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
11591
13246
  }
11592
- });
13247
+ }, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
11593
13248
  };
11594
13249
  const adminRoute = {
11595
13250
  kind: "prefix",
@@ -11767,6 +13422,34 @@ async function apply(ctx, config) {
11767
13422
  sendJson(response, 200, remotePayload(), false);
11768
13423
  return;
11769
13424
  }
13425
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
13426
+ const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
13427
+ const options = { configFile: frpConfig.runtimeConfigFile };
13428
+ logger.info("frp attach plan requested mode=%s entryTls=%s vhostHttpPort=%d", settings.mode ?? "deploy", settings.entryTls ?? "public-ip-cert", resolveFrpVhostHttpPort(settings));
13429
+ sendJson(response, 200, {
13430
+ ...remotePayload(),
13431
+ frpAttachPlan: createFrpAttachPlan(settings, options),
13432
+ frpAttachTemplate: createFrpAttachTemplate(settings, options)
13433
+ }, false);
13434
+ return;
13435
+ }
13436
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
13437
+ const settings = frpConfig.settings();
13438
+ if (settings === void 0) throw new HttpError(409, "frp_config_missing");
13439
+ const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
13440
+ const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
13441
+ sendJson(response, 200, {
13442
+ ...remotePayload(),
13443
+ frpSelfCheck: {
13444
+ ...check,
13445
+ frpsReachable,
13446
+ entryReachable,
13447
+ frpc: remoteController().status(),
13448
+ component: frpComponent.status()
13449
+ }
13450
+ }, false);
13451
+ return;
13452
+ }
11770
13453
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/websocket-paths`) {
11771
13454
  sendJson(response, 200, { paths: webSocketPaths.list() }, false);
11772
13455
  return;
@@ -11867,7 +13550,11 @@ async function apply(ctx, config) {
11867
13550
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11868
13551
  await remoteProviders.mutate(async () => {
11869
13552
  await remoteControllers.frp.setEnabled(false);
11870
- await Promise.all([frpComponent.purge(), frpConfig.purge()]);
13553
+ await Promise.all([
13554
+ frpComponent.purge(),
13555
+ frpConfig.purge(),
13556
+ purgeFrpIngressCertificates(remoteDeviceFile)
13557
+ ]);
11871
13558
  });
11872
13559
  sendJson(response, 200, remotePayload(), false);
11873
13560
  return;
@@ -12010,6 +13697,6 @@ async function apply(ctx, config) {
12010
13697
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
12011
13698
  }
12012
13699
  //#endregion
12013
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, 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, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isCloudflaredRegistration, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
13700
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, 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, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
12014
13701
 
12015
13702
  //# sourceMappingURL=index.mjs.map