dsh-mobile 0.4.5 → 0.4.6

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. */
@@ -817,8 +937,13 @@ function execFileText(file, args, options = {}) {
817
937
  //#endregion
818
938
  //#region src/private-file.ts
819
939
  let userSidTask;
940
+ function windowsSystemTool(name) {
941
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
942
+ if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
943
+ return win32.join(systemRoot, "System32", name);
944
+ }
820
945
  async function currentWindowsUserSid() {
821
- userSidTask ??= execFileText("whoami.exe", [
946
+ userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
822
947
  "/user",
823
948
  "/fo",
824
949
  "csv",
@@ -841,11 +966,12 @@ async function currentWindowsUserSid() {
841
966
  async function restrictPrivateFile(file, mode = 384) {
842
967
  await chmod(file, mode);
843
968
  if (process.platform !== "win32") return;
844
- await execFileText("icacls.exe", [
969
+ const userSid = await currentWindowsUserSid();
970
+ await execFileText(windowsSystemTool("icacls.exe"), [
845
971
  file,
846
972
  "/inheritance:r",
847
973
  "/grant:r",
848
- `*${await currentWindowsUserSid()}:(F)`,
974
+ `*${userSid}:(F)`,
849
975
  "*S-1-5-18:(F)",
850
976
  "*S-1-5-32-544:(F)",
851
977
  "/remove:g",
@@ -2502,6 +2628,57 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2502
2628
  })]);
2503
2629
  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
2630
  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}})();`;
2631
+ /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
2632
+ const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
2633
+ const view=globalThis;
2634
+ const port=view.navigator;
2635
+ const nativeFetch=typeof view.fetch==='function'?view.fetch.bind(view):undefined;
2636
+ let reachable=true;
2637
+ let checking=false;
2638
+ let replaying=false;
2639
+ let disposed=false;
2640
+ let epoch=0;
2641
+ let retryTimer;
2642
+ let activeController;
2643
+ const clearRetry=()=>{if(retryTimer!==undefined){view.clearTimeout(retryTimer);retryTimer=undefined}};
2644
+ const scheduleRetry=()=>{if(!disposed&&!reachable&&retryTimer===undefined){retryTimer=view.setTimeout(()=>{retryTimer=undefined;check()},5000)}};
2645
+ const check=()=>{
2646
+ if(checking||disposed||nativeFetch===undefined)return;
2647
+ checking=true;
2648
+ const current=++epoch;
2649
+ const controller=new AbortController();
2650
+ activeController=controller;
2651
+ const timer=view.setTimeout(()=>controller.abort(),2000);
2652
+ void nativeFetch('/mobile-access/health',{cache:'no-store',credentials:'same-origin',signal:controller.signal})
2653
+ .then(response=>response.ok,()=>false)
2654
+ .then(ok=>{
2655
+ if(disposed||current!==epoch)return;
2656
+ if(ok){
2657
+ const wasOffline=!reachable;
2658
+ reachable=true;
2659
+ clearRetry();
2660
+ if(wasOffline)view.dispatchEvent(new Event('online'));
2661
+ return;
2662
+ }
2663
+ const wasOnline=reachable;
2664
+ reachable=false;
2665
+ if(wasOnline){
2666
+ replaying=true;
2667
+ try{view.dispatchEvent(new Event('offline'))}finally{replaying=false}
2668
+ }
2669
+ scheduleRetry();
2670
+ })
2671
+ .finally(()=>{view.clearTimeout(timer);activeController=undefined;checking=false});
2672
+ };
2673
+ if(port!==undefined&&typeof Object.defineProperty==='function'){
2674
+ try{Object.defineProperty(port,'onLine',{configurable:true,get:()=>reachable})}catch{/* A frozen navigator retains its native value. */}
2675
+ }
2676
+ if(typeof view.addEventListener==='function'){
2677
+ view.addEventListener('offline',event=>{if(replaying||nativeFetch===undefined)return;event.stopImmediatePropagation();check()},true);
2678
+ view.addEventListener('online',()=>{++epoch;reachable=true;clearRetry()},true);
2679
+ view.addEventListener('pagehide',()=>{disposed=true;++epoch;clearRetry();activeController?.abort()},true);
2680
+ }
2681
+ })();`;
2505
2682
  const gzipBuffer = promisify(gzip);
2506
2683
  function ensureMobileViewport(html) {
2507
2684
  const match = /<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])[^>]*>/iu.exec(html);
@@ -2678,7 +2855,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
2678
2855
  batches: plan.batches
2679
2856
  })).digest("hex").slice(0, 16);
2680
2857
  }
2681
- const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2858
+ 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)};`;
2682
2859
  return Object.freeze({
2683
2860
  html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2684
2861
  batches
@@ -3277,6 +3454,15 @@ var MobileAccessGateway = class {
3277
3454
  throw error;
3278
3455
  }
3279
3456
  }
3457
+ /** Install a newly signed provided leaf for future TLS handshakes without dropping sessions. */
3458
+ async refreshProvidedTls() {
3459
+ if (!this.listenerTlsEnabled || this.config.tls.mode !== "provided") throw new Error("gateway does not terminate provided TLS");
3460
+ const server = this.server;
3461
+ if (server === void 0 || !this.started || this.closing) throw new Error("gateway is not running");
3462
+ const options = await tlsOptions(this.config);
3463
+ if (this.server !== server || this.closing) throw new Error("gateway is not running");
3464
+ server.setSecureContext(options);
3465
+ }
3280
3466
  async startDiscovery(port) {
3281
3467
  const socket = createSocket("udp4");
3282
3468
  this.discoverySocket = socket;
@@ -4052,11 +4238,11 @@ var MobileAccessGateway = class {
4052
4238
  this.upstreamAuthRequest = void 0;
4053
4239
  }
4054
4240
  }
4055
- async proxyMobileIndex(request$1, response, authorization) {
4241
+ async proxyMobileIndex(request$2, response, authorization) {
4056
4242
  const holder = {};
4057
4243
  const operation = this.allocateRequest(authorization, response, holder);
4058
4244
  try {
4059
- const upstreamHeaders = sanitizeRequestHeaders(request$1, this.config.upstreamOrigin);
4245
+ const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
4060
4246
  const upstreamCookie = await this.upstreamCookieHeader();
4061
4247
  if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4062
4248
  upstreamHeaders["accept-encoding"] = "identity";
@@ -4440,15 +4626,15 @@ var MobileAccessGateway = class {
4440
4626
  }
4441
4627
  throw new HttpError(502, "upstream_unavailable");
4442
4628
  }
4443
- async proxyHttpOnce(request$2, response, authorization) {
4444
- const declared = request$2.headers["content-length"];
4629
+ async proxyHttpOnce(request$3, response, authorization) {
4630
+ const declared = request$3.headers["content-length"];
4445
4631
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
4446
4632
  const holder = {};
4447
4633
  const operation = this.allocateRequest(authorization, response, holder);
4448
4634
  let bodyDone;
4449
4635
  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);
4636
+ 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;
4637
+ const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
4452
4638
  const upstreamCookie = await this.upstreamCookieHeader();
4453
4639
  if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4454
4640
  if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
@@ -4457,8 +4643,8 @@ var MobileAccessGateway = class {
4457
4643
  protocol: "http:",
4458
4644
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4459
4645
  port: Number(this.config.upstreamOrigin.port),
4460
- method: request$2.method,
4461
- path: request$2.url,
4646
+ method: request$3.method,
4647
+ path: request$3.url,
4462
4648
  headers: upstreamHeaders,
4463
4649
  agent: false
4464
4650
  });
@@ -4468,7 +4654,7 @@ var MobileAccessGateway = class {
4468
4654
  });
4469
4655
  upstreamRequest.once("response", resolve);
4470
4656
  upstreamRequest.once("error", reject);
4471
- if (bufferedBody === void 0) bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4657
+ if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4472
4658
  else {
4473
4659
  upstreamRequest.end(bufferedBody);
4474
4660
  bodyDone = Promise.resolve();
@@ -4478,9 +4664,9 @@ var MobileAccessGateway = class {
4478
4664
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
4479
4665
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
4480
4666
  const statusCode = proxied.statusCode ?? 502;
4481
- const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
4667
+ const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
4482
4668
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
4483
- const compressed = shouldCompressResponse(request$2, proxied);
4669
+ const compressed = shouldCompressResponse(request$3, proxied);
4484
4670
  if (compressed) {
4485
4671
  delete headers["accept-ranges"];
4486
4672
  delete headers["content-length"];
@@ -4810,7 +4996,10 @@ var MobileAccessGateway = class {
4810
4996
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
4811
4997
  if (body.ttlMs !== void 0 && typeof body.ttlMs !== "number") throw new HttpError(400, "bad_request");
4812
4998
  const opened = await this.access.openPairing(body.ttlMs);
4813
- const pairUrl = `${this.address().origin}/mobile-access/pair#instance=${this.config.instanceId}&token=${opened.token}`;
4999
+ const requiresRemoteCa = this.config.pairingCaFile !== void 0 && !this.config.discovery;
5000
+ const appKey = `dsh${requiresRemoteCa ? "2" : "1"}.${this.config.instanceId}.${opened.token}`;
5001
+ const fragment = requiresRemoteCa ? `key=${appKey}` : `instance=${this.config.instanceId}&token=${opened.token}`;
5002
+ const pairUrl = `${this.address().origin}/mobile-access/pair#${fragment}`;
4814
5003
  const appPairUrl = pairUrl;
4815
5004
  let qrSvg = "";
4816
5005
  try {
@@ -4821,7 +5010,7 @@ var MobileAccessGateway = class {
4821
5010
  } catch {}
4822
5011
  sendJson(response, 201, {
4823
5012
  ...opened,
4824
- appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
5013
+ appKey,
4825
5014
  pairUrl,
4826
5015
  appPairUrl,
4827
5016
  qrSvg
@@ -5486,8 +5675,6 @@ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
5486
5675
  const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
5487
5676
  /** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
5488
5677
  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
5678
  function publicIpv4Address(value) {
5492
5679
  const parts = value.split(".");
5493
5680
  return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
@@ -5505,8 +5692,31 @@ function parsePublicOrigin(publicOrigin) {
5505
5692
  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
5693
  return url.hostname;
5507
5694
  }
5508
- /** Build the Caddy site for one public host (without markers or import wiring). */
5509
- function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
5695
+ function resolveVhostHttpPort(options) {
5696
+ const port = options.vhostHttpPort ?? 7080;
5697
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_template_input_invalid");
5698
+ return port;
5699
+ }
5700
+ /**
5701
+ * A self-signed entry deliberately never reaches Caddy: the tunnel is a raw TCP
5702
+ * passthrough and the DSH gateway is the TLS endpoint. Refuse loudly instead of
5703
+ * emitting a Caddy site that would need the gateway's private CA on the VPS.
5704
+ */
5705
+ function rejectSelfSignedCaddySite(options) {
5706
+ if (options.entryTls === "self-signed") throw new Error("frp_entry_tls_invalid");
5707
+ }
5708
+ /**
5709
+ * Build the Caddy site for one public host (without markers or import wiring).
5710
+ *
5711
+ * The second parameter accepts either a bare certificate directory (the legacy
5712
+ * signature) or a full {@link CaddySiteOptions} object, so existing callers keep
5713
+ * working unchanged.
5714
+ */
5715
+ function createCaddySite(publicHost, options = {}) {
5716
+ const resolved = typeof options === "string" ? { certDir: options } : options;
5717
+ rejectSelfSignedCaddySite(resolved);
5718
+ const certDir = resolved.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
5719
+ const vhostHttpPort = resolveVhostHttpPort(resolved);
5510
5720
  if (publicIpv4Address(publicHost)) return [
5511
5721
  "{",
5512
5722
  ` default_sni ${publicHost}`,
@@ -5518,14 +5728,14 @@ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
5518
5728
  "",
5519
5729
  `https://${publicHost} {`,
5520
5730
  ` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
5521
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
5731
+ ` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
5522
5732
  "}",
5523
5733
  ""
5524
5734
  ].join("\n");
5525
5735
  if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
5526
5736
  return [
5527
5737
  `${publicHost} {`,
5528
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
5738
+ ` reverse_proxy 127.0.0.1:${String(vhostHttpPort)}`,
5529
5739
  "}",
5530
5740
  ""
5531
5741
  ].join("\n");
@@ -5548,15 +5758,24 @@ function manualIpCertificateGuide(publicHost) {
5548
5758
  "#"
5549
5759
  ].join("\n");
5550
5760
  }
5551
- /** Build the only supported frps config and Caddy snippet from validated user inputs. */
5552
- function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5761
+ /**
5762
+ * Build the only supported frps config and Caddy snippet from validated user inputs.
5763
+ *
5764
+ * Default `options` reproduce the upstream artefact byte for byte; `attach` mode
5765
+ * passes the user's real vhost port, and `self-signed` is rejected because that
5766
+ * mode publishes a raw TCP proxy instead of an HTTP vhost.
5767
+ */
5768
+ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin, options = {}) {
5553
5769
  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");
5770
+ rejectSelfSignedCaddySite(options);
5771
+ const vhostHttpPort = resolveVhostHttpPort(options);
5772
+ const certDir = options.certDir ?? "/var/lib/caddy/dsh-mobile-certs";
5554
5773
  const publicHost = parsePublicOrigin(publicOrigin);
5555
5774
  const lines = [
5556
5775
  "# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
5557
5776
  `bindPort = ${String(serverPort)}`,
5558
5777
  "proxyBindAddr = \"127.0.0.1\"",
5559
- `vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
5778
+ `vhostHTTPPort = ${String(vhostHttpPort)}`,
5560
5779
  "auth.method = \"token\"",
5561
5780
  `auth.token = ${JSON.stringify(token)}`,
5562
5781
  "",
@@ -5567,7 +5786,10 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5567
5786
  "# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
5568
5787
  "# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
5569
5788
  `${FRP_CADDY_SNIPPET_MARKER}`,
5570
- createCaddySite(publicHost).trimEnd(),
5789
+ createCaddySite(publicHost, {
5790
+ certDir,
5791
+ vhostHttpPort
5792
+ }).trimEnd(),
5571
5793
  ""
5572
5794
  ];
5573
5795
  if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
@@ -5576,6 +5798,26 @@ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
5576
5798
  //#endregion
5577
5799
  //#region src/frp-config.ts
5578
5800
  const MAX_SETTINGS_BYTES$1 = 8192;
5801
+ /** Public entry port used by the self-signed TCP passthrough (never 443 by requirement). */
5802
+ const FRP_DEFAULT_PUBLIC_PORT = 33080;
5803
+ /** Ports owned by other DSH Mobile listeners; the public entry may never reuse them. */
5804
+ const FRP_RESERVED_PORTS = Object.freeze([
5805
+ 3080,
5806
+ 3443,
5807
+ 3444
5808
+ ]);
5809
+ /** Every key `settings.json` may contain. Unknown keys stay rejected. */
5810
+ const FRP_SETTINGS_KEYS = /* @__PURE__ */ new Set([
5811
+ "version",
5812
+ "serverAddress",
5813
+ "serverPort",
5814
+ "token",
5815
+ "publicOrigin",
5816
+ "mode",
5817
+ "entryTls",
5818
+ "vhostHttpPort",
5819
+ "publicPort"
5820
+ ]);
5579
5821
  function hostname$2(value) {
5580
5822
  if (value.length > 253 || !value.includes(".")) return false;
5581
5823
  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 +5853,90 @@ function validateFrpPublicOrigin(value) {
5611
5853
  if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
5612
5854
  return url.origin;
5613
5855
  }
5614
- /** Parse FRP settings at the loopback request and filesystem boundaries. */
5856
+ /** Validate the provisioning mode. */
5857
+ function validateFrpMode(value) {
5858
+ if (value !== "deploy" && value !== "attach") throw new Error("frp_settings_invalid");
5859
+ return value;
5860
+ }
5861
+ /** Validate the entry TLS mode. */
5862
+ function validateFrpEntryTls(value) {
5863
+ if (value !== "public-ip-cert" && value !== "self-signed") throw new Error("frp_entry_tls_invalid");
5864
+ return value;
5865
+ }
5866
+ function validatePort(value, rejectReserved) {
5867
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("frp_settings_invalid");
5868
+ const port = Number(value);
5869
+ if (rejectReserved && FRP_RESERVED_PORTS.includes(port)) throw new Error("frp_settings_invalid");
5870
+ return port;
5871
+ }
5872
+ /** Validate the loopback vhost port of the target frps. */
5873
+ function validateFrpVhostHttpPort(value) {
5874
+ return validatePort(value, false);
5875
+ }
5876
+ /** Validate the public entry port of the self-signed TCP proxy. */
5877
+ function validateFrpPublicPort(value) {
5878
+ return validatePort(value, true);
5879
+ }
5880
+ /** Effective provisioning mode; an absent field keeps the upstream behaviour. */
5881
+ function resolveFrpMode(settings) {
5882
+ return settings.mode ?? "deploy";
5883
+ }
5884
+ /** Effective entry TLS mode; an absent field keeps the upstream behaviour. */
5885
+ function resolveFrpEntryTls(settings) {
5886
+ return settings.entryTls ?? "public-ip-cert";
5887
+ }
5888
+ /** Effective loopback vhost port; an absent field keeps the upstream behaviour. */
5889
+ function resolveFrpVhostHttpPort(settings) {
5890
+ return settings.vhostHttpPort ?? 7080;
5891
+ }
5892
+ /** Effective public entry port of the self-signed passthrough. */
5893
+ function resolveFrpPublicPort(settings) {
5894
+ return settings.publicPort ?? 33080;
5895
+ }
5896
+ /** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
5897
+ function frpEntryOrigin(settings) {
5898
+ if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
5899
+ return `https://${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
5900
+ }
5901
+ /** True when the gateway terminates TLS itself behind a raw frps TCP proxy. */
5902
+ function isFrpSelfSignedIngress(settings) {
5903
+ return resolveFrpEntryTls(settings) === "self-signed";
5904
+ }
5905
+ /**
5906
+ * Parse FRP settings at the loopback request and filesystem boundaries.
5907
+ *
5908
+ * Optional keys are kept only when supplied, so a legacy `settings.json` still
5909
+ * round-trips byte for byte and every default stays the upstream behaviour.
5910
+ * Two cross-field rules are enforced here because neither can be recovered later:
5911
+ * the self-signed entry is a TCP passthrough and therefore attach-only, and
5912
+ * attach mode with a public-CA entry must state the user's real vhost port —
5913
+ * silently assuming 7080 would disable the plaintext-exposure gate.
5914
+ */
5615
5915
  function parseFrpSettings(value) {
5616
5916
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_settings_invalid");
5617
5917
  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");
5918
+ if (Reflect.ownKeys(record).some((key) => !FRP_SETTINGS_KEYS.has(String(key)))) throw new Error("frp_settings_invalid");
5625
5919
  if (record.version !== void 0 && record.version !== 1) throw new Error("frp_settings_invalid");
5920
+ const mode = record.mode === void 0 ? void 0 : validateFrpMode(record.mode);
5921
+ const entryTls = record.entryTls === void 0 ? void 0 : validateFrpEntryTls(record.entryTls);
5922
+ const vhostHttpPort = record.vhostHttpPort === void 0 ? void 0 : validateFrpVhostHttpPort(record.vhostHttpPort);
5923
+ const publicPort = record.publicPort === void 0 ? void 0 : validateFrpPublicPort(record.publicPort);
5924
+ const resolvedMode = mode ?? "deploy";
5925
+ const resolvedEntryTls = entryTls ?? "public-ip-cert";
5926
+ if (resolvedEntryTls === "self-signed" && resolvedMode !== "attach") throw new Error("frp_entry_tls_invalid");
5927
+ if (resolvedMode === "attach" && resolvedEntryTls === "public-ip-cert" && vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
5928
+ const publicOrigin = validateFrpPublicOrigin(record.publicOrigin);
5929
+ if (resolvedEntryTls === "self-signed" && isIP(new URL(publicOrigin).hostname) !== 4) throw new Error("frp_self_signed_requires_public_ipv4");
5626
5930
  return Object.freeze({
5627
5931
  version: 1,
5628
5932
  serverAddress: validateFrpServerAddress(record.serverAddress),
5629
5933
  serverPort: validateFrpServerPort(record.serverPort),
5630
5934
  token: validateFrpToken(record.token),
5631
- publicOrigin: validateFrpPublicOrigin(record.publicOrigin)
5935
+ publicOrigin,
5936
+ ...mode === void 0 ? {} : { mode },
5937
+ ...entryTls === void 0 ? {} : { entryTls },
5938
+ ...vhostHttpPort === void 0 ? {} : { vhostHttpPort },
5939
+ ...publicPort === void 0 ? {} : { publicPort }
5632
5940
  });
5633
5941
  }
5634
5942
  /**
@@ -5638,11 +5946,24 @@ function parseFrpSettings(value) {
5638
5946
  * missing configuration instead of silently deploying blanks.
5639
5947
  */
5640
5948
  function mergeSavedFrpSettings(partial, saved) {
5949
+ const pick = (key) => {
5950
+ const supplied = partial[key];
5951
+ if (supplied === "" || supplied === void 0) return saved?.[key];
5952
+ return supplied;
5953
+ };
5954
+ const mode = pick("mode");
5955
+ const entryTls = pick("entryTls");
5956
+ const vhostHttpPort = pick("vhostHttpPort");
5957
+ const publicPort = pick("publicPort");
5641
5958
  const merged = {
5642
5959
  serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
5643
5960
  serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
5644
5961
  token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
5645
- publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin
5962
+ publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin,
5963
+ ...mode === "" || mode === void 0 ? {} : { mode },
5964
+ ...entryTls === "" || entryTls === void 0 ? {} : { entryTls },
5965
+ ...vhostHttpPort === "" || vhostHttpPort === void 0 ? {} : { vhostHttpPort },
5966
+ ...publicPort === "" || publicPort === void 0 ? {} : { publicPort }
5646
5967
  };
5647
5968
  if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
5648
5969
  return parseFrpSettings(merged);
@@ -5660,11 +5981,16 @@ function mergeSavedFrpTarget(partial, saved) {
5660
5981
  function tomlString(value) {
5661
5982
  return JSON.stringify(value);
5662
5983
  }
5663
- /** Build the single-purpose frpc configuration for the current loopback gateway. */
5984
+ /**
5985
+ * Build the single-purpose frpc configuration for the current loopback gateway.
5986
+ *
5987
+ * The default (public-CA) entry is an HTTP vhost behind Caddy and is emitted byte
5988
+ * for byte as before. The self-signed entry cannot use a vhost at all: frps only
5989
+ * forwards raw TCP, and the gateway terminates TLS on that connection.
5990
+ */
5664
5991
  function createFrpcToml(settings, localPort) {
5665
5992
  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 [
5993
+ const header = [
5668
5994
  `serverAddr = ${tomlString(settings.serverAddress)}`,
5669
5995
  `serverPort = ${String(settings.serverPort)}`,
5670
5996
  "auth.method = \"token\"",
@@ -5672,7 +5998,21 @@ function createFrpcToml(settings, localPort) {
5672
5998
  "transport.tls.enable = true",
5673
5999
  "",
5674
6000
  "[[proxies]]",
5675
- "name = \"dsh-mobile\"",
6001
+ "name = \"dsh-mobile\""
6002
+ ];
6003
+ if (isFrpSelfSignedIngress(settings)) return [
6004
+ ...header,
6005
+ "type = \"tcp\"",
6006
+ "localIP = \"127.0.0.1\"",
6007
+ `localPort = ${String(localPort)}`,
6008
+ `remotePort = ${String(resolveFrpPublicPort(settings))}`,
6009
+ "transport.useEncryption = true",
6010
+ "transport.useCompression = true",
6011
+ ""
6012
+ ].join("\n");
6013
+ const hostnameValue = new URL(settings.publicOrigin).hostname;
6014
+ return [
6015
+ ...header,
5676
6016
  "type = \"http\"",
5677
6017
  "localIP = \"127.0.0.1\"",
5678
6018
  `localPort = ${String(localPort)}`,
@@ -5684,7 +6024,10 @@ function createFrpcToml(settings, localPort) {
5684
6024
  }
5685
6025
  /** Build the matching restricted frps and Caddy templates for one VPS. */
5686
6026
  function createFrpServerTemplate(settings) {
5687
- return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin);
6027
+ return createRestrictedFrpServerTemplate(settings.serverPort, settings.token, settings.publicOrigin, {
6028
+ vhostHttpPort: resolveFrpVhostHttpPort(settings),
6029
+ entryTls: resolveFrpEntryTls(settings)
6030
+ });
5688
6031
  }
5689
6032
  async function atomicPrivateWrite$2(file, body) {
5690
6033
  const directory = dirname(file);
@@ -5747,9 +6090,17 @@ var FrpConfigStore = class {
5747
6090
  this.errorCode = "frp_config_invalid";
5748
6091
  }
5749
6092
  }
5750
- /** Return configuration metadata without exposing the FRP token. */
6093
+ /**
6094
+ * Return configuration metadata without exposing the FRP token.
6095
+ *
6096
+ * The optional keys appear only when the saved configuration departs from the
6097
+ * upstream defaults, so the pre-existing field set is unchanged for every
6098
+ * legacy configuration.
6099
+ */
5751
6100
  status() {
5752
6101
  const settings = this.settingsValue;
6102
+ const mode = settings === void 0 ? void 0 : resolveFrpMode(settings);
6103
+ const entryTls = settings === void 0 ? void 0 : resolveFrpEntryTls(settings);
5753
6104
  return Object.freeze({
5754
6105
  configured: settings !== void 0,
5755
6106
  ...settings === void 0 ? {} : {
@@ -5757,9 +6108,12 @@ var FrpConfigStore = class {
5757
6108
  serverPort: settings.serverPort,
5758
6109
  publicOrigin: settings.publicOrigin
5759
6110
  },
5760
- vhostHttpPort: FRP_VHOST_HTTP_PORT,
6111
+ vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
5761
6112
  storagePath: this.stateRoot,
5762
- ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6113
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode },
6114
+ ...mode === void 0 || mode === "deploy" ? {} : { mode },
6115
+ ...entryTls === void 0 || entryTls === "public-ip-cert" ? {} : { entryTls },
6116
+ ...settings?.publicPort === void 0 ? {} : { publicPort: settings.publicPort }
5763
6117
  });
5764
6118
  }
5765
6119
  /** Return private settings only to the provider lifecycle. */
@@ -5794,133 +6148,1159 @@ var FrpConfigStore = class {
5794
6148
  }
5795
6149
  };
5796
6150
  //#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);
6151
+ //#region src/frp-attach.ts
6152
+ /** Default proof path used to confirm the public entry answered from this computer. */
6153
+ const FRP_ATTACH_DISCOVERY_PATH = "/mobile-access/discovery";
6154
+ /** Declaration printed above the VPS half of an attach template. */
6155
+ const FRP_ATTACH_VPS_DECLARATION = "# 本模板不安装、不改动、不重启你的 frps;只新增 Caddy 片段与证书。frps 侧需你自行确认 vhostHTTPPort=<N> 与 proxyBindAddr=\"127.0.0.1\"。";
6156
+ /** Declaration printed above the local half of an attach template. */
6157
+ const FRP_ATTACH_LOCAL_DECLARATION = "# 本机只写入 frpc.toml 并启动 frpc:不安装、不改动本机 frps,也不改动 DSH 自身配置。";
6158
+ /** Declaration for the self-signed passthrough, which keeps its CA on this computer. */
6159
+ const FRP_ATTACH_SELF_SIGNED_DECLARATION = "# 自签穿透档不使用 Caddy 或公开证书:frps 只做 TCP 透传,由本机 DSH 网关终止 TLS;服务端证书会在运行时续签,CA 到期需重新配对。";
6160
+ /**
6161
+ * One-line notice printed above the masked frpc.toml preview.
6162
+ *
6163
+ * It states both facts a reader cannot verify from the text itself: the real
6164
+ * file is written with 0600 permissions, and an unmasked copy would survive in
6165
+ * the system clipboard.
6166
+ */
6167
+ function frpAttachTokenMaskNotice(configFile) {
6168
+ return `# 预览已打码:真正的 frpc.toml 已由插件以 0600 权限写入 ${configFile};明文 token 不打码会随复制进入系统剪贴板,故此处只显示占位符。`;
5809
6169
  }
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;
6170
+ /** One-line notice printed above an explicitly revealed frpc.toml preview. */
6171
+ const FRP_ATTACH_TOKEN_REVEAL_NOTICE = "# 明文 token 已按你的显式操作显示:复制后即进入系统剪贴板并长期留存,请勿粘贴到公开位置。";
6172
+ /** Matches the single `auth.token` assignment of a generated frpc.toml. */
6173
+ const FRPC_TOKEN_ASSIGNMENT = /^auth\.token\s*=\s*"(?:[^"\\]|\\.)*"/mu;
6174
+ /**
6175
+ * Replace the `auth.token` value of a rendered frpc.toml with a fixed placeholder.
6176
+ *
6177
+ * Only the preview and clipboard paths call this: the file written to disk keeps
6178
+ * the real token, because frpc has to read it. The mapping never fails open — a
6179
+ * token line that cannot be masked raises instead of leaking the secret.
6180
+ */
6181
+ function redactFrpcTomlToken(frpcToml) {
6182
+ if (!/(^|\n)auth\.token/u.test(frpcToml)) return frpcToml;
6183
+ const masked = frpcToml.replace(FRPC_TOKEN_ASSIGNMENT, `auth.token = "***"`);
6184
+ if (!masked.includes(`auth.token = "***"`)) throw new Error("frp_token_mask_failed");
6185
+ return masked;
5814
6186
  }
5815
6187
  /**
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.
6188
+ * The frpc.toml exactly as the panel displays or copies it.
6189
+ *
6190
+ * `options.revealToken` is the only way to obtain the plaintext variant; the
6191
+ * default is the masked text. `createFrpcToml` itself is untouched, so the file
6192
+ * the provider writes stays byte-for-byte identical to the upstream output.
5818
6193
  */
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;
5828
- }
5829
- /** Return the durable provider currently selected by the desktop UI. */
5830
- get selected() {
5831
- return this.selectedValue;
5832
- }
5833
- /** Return the controller selected when this method is called. */
5834
- controller() {
5835
- return this.controllers[this.selectedValue];
5836
- }
5837
- /** Run a provider-owned mutation after all earlier provider work settles. */
5838
- mutate(operation) {
5839
- return this.enqueue(() => operation(this.controller()));
5840
- }
5841
- /** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
5842
- select(provider) {
5843
- return this.enqueue(async () => {
5844
- if (provider === this.selectedValue) return;
5845
- const previous = this.controllers[this.selectedValue];
5846
- const restore = previous.status().enabled;
5847
- if (restore) await previous.setEnabled(false);
5848
- try {
5849
- await this.store.save({
5850
- version: 1,
5851
- provider
5852
- });
5853
- this.selectedValue = provider;
5854
- } catch (error) {
5855
- if (restore) try {
5856
- await previous.setEnabled(true);
5857
- } catch (restoreError) {
5858
- throw new AggregateError([error, restoreError], "remote provider selection rollback failed");
5859
- }
5860
- throw error;
5861
- }
5862
- });
5863
- }
5864
- enqueue(operation) {
5865
- const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
5866
- this.queue = task.then(() => void 0, () => void 0);
5867
- return task;
5868
- }
5869
- async runAndEnforce(operation) {
5870
- let value;
5871
- let operationError;
5872
- try {
5873
- value = await operation();
5874
- } catch (error) {
5875
- operationError = error;
5876
- }
5877
- const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== this.selectedValue).map((provider) => this.controllers[provider].setEnabled(false)));
5878
- const failure = aggregateErrors([...operationError === void 0 ? [] : [operationError], ...results.filter((result) => result.status === "rejected").map((result) => result.reason)], "remote provider operation failed");
5879
- if (failure !== void 0) throw failure;
5880
- return value;
6194
+ function createFrpAttachFrpcToml(settings, options = {}) {
6195
+ const frpcToml = createFrpcToml(settings, attachLocalPort(options));
6196
+ return options.revealToken === true ? frpcToml : redactFrpcTomlToken(frpcToml);
6197
+ }
6198
+ const DEFAULT_ATTACH_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
6199
+ function attachConfigFile(options) {
6200
+ const file = options.configFile;
6201
+ if (file === void 0 || file === "") return DEFAULT_ATTACH_CONFIG_FILE;
6202
+ return file;
6203
+ }
6204
+ function attachLocalPort(options) {
6205
+ const port = options.localPort ?? 3443;
6206
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("frp_local_port_invalid");
6207
+ return port;
6208
+ }
6209
+ /**
6210
+ * Reject every attach request that could not be carried out safely before any
6211
+ * artefact is produced. Attach never installs frps, so it must know the user's
6212
+ * real vhost port (unless the self-signed passthrough removes the vhost entirely).
6213
+ */
6214
+ function validateAttachSettings(settings, options = {}) {
6215
+ if (resolveFrpMode(settings) !== "attach") throw new Error("frp_settings_invalid");
6216
+ if (isFrpSelfSignedIngress(settings)) {
6217
+ if (settings.mode !== "attach") throw new Error("frp_entry_tls_invalid");
6218
+ resolveFrpPublicPort(settings);
6219
+ return;
5881
6220
  }
5882
- };
5883
- /** Stop an owned provider process and do not report completion before its close event. */
5884
- async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTimeoutMs = 1500) {
5885
- if (child.exitCode !== null || child.signalCode !== null) return;
5886
- await new Promise((resolveClose, rejectClose) => {
5887
- let gracefulTimer;
5888
- let forcedTimer;
5889
- let settled = false;
5890
- const finish = (error) => {
5891
- if (settled) return;
5892
- settled = true;
5893
- if (gracefulTimer !== void 0) clearTimeout(gracefulTimer);
5894
- if (forcedTimer !== void 0) clearTimeout(forcedTimer);
5895
- child.off("close", onClose);
5896
- if (error === void 0) resolveClose();
5897
- else rejectClose(error);
5898
- };
5899
- const onClose = () => {
5900
- finish();
5901
- };
5902
- child.once("close", onClose);
5903
- try {
5904
- child.kill("SIGTERM");
5905
- } catch (error) {
5906
- finish(error instanceof Error ? error : new Error(String(error)));
5907
- return;
5908
- }
5909
- if (settled) return;
5910
- gracefulTimer = setTimeout(() => {
5911
- try {
5912
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
5913
- } catch (error) {
5914
- finish(error instanceof Error ? error : new Error(String(error)));
5915
- return;
5916
- }
5917
- if (settled) return;
5918
- forcedTimer = setTimeout(() => {
5919
- finish(/* @__PURE__ */ new Error("remote_process_stop_timeout"));
5920
- }, forcedTimeoutMs);
5921
- forcedTimer.unref();
5922
- }, gracefulTimeoutMs);
5923
- gracefulTimer.unref();
6221
+ const vhostHttpPort = options.vhostHttpPort ?? settings.vhostHttpPort;
6222
+ if (vhostHttpPort === void 0) throw new Error("frp_attach_mode_requires_vhost_port");
6223
+ if (!Number.isSafeInteger(vhostHttpPort) || vhostHttpPort < 1 || vhostHttpPort > 65535) throw new Error("frp_settings_invalid");
6224
+ }
6225
+ /** Strip the leading `# ` of a guide block so it can be pasted as runnable shell. */
6226
+ function guideCommands(guide) {
6227
+ return guide.split("\n").map((line) => line.replace(/^#\s?/u, "").trimEnd()).filter((line) => line !== "");
6228
+ }
6229
+ /**
6230
+ * Derive every VPS-side value once so the copied runbook and the panel plan can
6231
+ * never drift apart.
6232
+ */
6233
+ function frpAttachVpsParts(settings, options = {}) {
6234
+ validateAttachSettings(settings, options);
6235
+ const publicHost = new URL(settings.publicOrigin).hostname;
6236
+ if (isFrpSelfSignedIngress(settings)) return Object.freeze({
6237
+ publicHost,
6238
+ selfSigned: true,
6239
+ publicPort: resolveFrpPublicPort(settings),
6240
+ snippet: "",
6241
+ certGuide: Object.freeze([])
6242
+ });
6243
+ const vhostHttpPort = options.vhostHttpPort ?? resolveFrpVhostHttpPort(settings);
6244
+ const certDir = options.certDir;
6245
+ const site = createCaddySite(publicHost, {
6246
+ vhostHttpPort,
6247
+ ...certDir === void 0 ? {} : { certDir }
6248
+ });
6249
+ return Object.freeze({
6250
+ publicHost,
6251
+ selfSigned: false,
6252
+ vhostHttpPort,
6253
+ snippet: `${FRP_CADDY_SNIPPET_MARKER}\n${site.trimEnd()}\n`,
6254
+ certGuide: Object.freeze(isIP(publicHost) === 4 ? guideCommands(manualIpCertificateGuide(publicHost)) : [])
6255
+ });
6256
+ }
6257
+ /**
6258
+ * Build the attach artefact for an existing frps.
6259
+ *
6260
+ * The result is deliberately split in two: the VPS half contains only what the
6261
+ * user's own server needs (Caddy snippet + import + certificate guidance, or a
6262
+ * single firewall rule for the self-signed passthrough), and the local half
6263
+ * contains the generated frpc.toml plus its `frpc verify` self-check. Neither
6264
+ * half installs, rewrites, or restarts the user's frps.
6265
+ */
6266
+ function createFrpAttachTemplateParts(settings, options = {}) {
6267
+ const parts = frpAttachVpsParts(settings, options);
6268
+ const configFile = attachConfigFile(options);
6269
+ const revealed = options.revealToken === true;
6270
+ const frpcToml = createFrpAttachFrpcToml(settings, options);
6271
+ const local = [
6272
+ "# ---- (b) 本机侧(在这台电脑上执行)----",
6273
+ FRP_ATTACH_LOCAL_DECLARATION,
6274
+ revealed ? FRP_ATTACH_TOKEN_REVEAL_NOTICE : frpAttachTokenMaskNotice(configFile),
6275
+ `# frpc.toml 全文(插件同样会以 0600 权限写入 ${configFile})`,
6276
+ frpcToml.trimEnd(),
6277
+ "",
6278
+ "# 启动前自检:",
6279
+ `frpc verify -c "${configFile}"`,
6280
+ ""
6281
+ ].join("\n");
6282
+ if (parts.selfSigned) {
6283
+ const publicPort = parts.publicPort ?? resolveFrpPublicPort(settings);
6284
+ const vps = [
6285
+ "# ---- (a) VPS 侧(在你的服务器上执行,共 3 项)----",
6286
+ FRP_ATTACH_SELF_SIGNED_DECLARATION,
6287
+ "#",
6288
+ "# 1) 放行公网入口端口(就是这台电脑网关的 HTTPS 入口,TCP 透传不解密):",
6289
+ `ufw allow ${String(publicPort)}/tcp`,
6290
+ `ufw status | grep ${String(publicPort)}`,
6291
+ "# 若使用 firewalld / 云厂商安全组,请放行同样的 TCP 端口。",
6292
+ "# 前提:现有 frps 的 TCP 代理已监听公网地址;若 proxyBindAddr=127.0.0.1,放行防火墙仍无法从公网访问。",
6293
+ "# 插件不会修改你现有的 frps;不满足此前提时请改选受信任证书入口。",
6294
+ "#",
6295
+ "# 2) 确认你既有的 frps 已就绪(无需修改它的配置):",
6296
+ "systemctl is-active frps || true",
6297
+ `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`,
6298
+ "#",
6299
+ "# 3) frpc 启动后,用 -k 检查连通性;此命令不验证身份,App 配对后会固定网关 CA:",
6300
+ `curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`,
6301
+ "# 期望输出 200。",
6302
+ ""
6303
+ ].join("\n");
6304
+ return Object.freeze({
6305
+ mode: "attach",
6306
+ vps,
6307
+ local,
6308
+ text: `${vps}${local}`
6309
+ });
6310
+ }
6311
+ const vps = [
6312
+ "# ---- (a) VPS 侧(在你的服务器上执行)----",
6313
+ FRP_ATTACH_VPS_DECLARATION,
6314
+ "#",
6315
+ `# 1) 保存 Caddy 片段到 ${FRP_CADDY_SNIPPET_PATH}(只新增这一个文件):`,
6316
+ `cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
6317
+ parts.snippet.trimEnd(),
6318
+ "DSH_MOBILE_CADDY_SNIPPET",
6319
+ "#",
6320
+ "# 2) 确认 Caddyfile 顶部含下面这一行(全局块必须在前;没有则加到文件最上方):",
6321
+ `# ${FRP_CADDY_IMPORT_LINE}`,
6322
+ "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
6323
+ "#",
6324
+ ...parts.certGuide.length === 0 ? ["# 3) 域名 HTTPS:确认 DNS 指向 VPS,开放 80/443;Caddy 会自动申请和续期证书。"] : [
6325
+ "# 3) 公网 IP 的 HTTPS 证书(Caddy 不能为 IP 自动签发,故用 certbot 签发):",
6326
+ ...parts.certGuide.map((line) => `# ${line}`),
6327
+ "#",
6328
+ "# 4) 确认证书续期任务,并在续期后将新证书安装到 Caddy:",
6329
+ "systemctl list-timers certbot.timer --all || true"
6330
+ ],
6331
+ "#",
6332
+ "# 端到端验证(公网入口为 443,与 frps 的 vhost 端口无关):",
6333
+ `curl -sS -o /dev/null -w '%{http_code}\\n' https://${parts.publicHost}${FRP_ATTACH_DISCOVERY_PATH}`,
6334
+ "# 期望输出 200。",
6335
+ ""
6336
+ ].join("\n");
6337
+ return Object.freeze({
6338
+ mode: "attach",
6339
+ vps,
6340
+ local,
6341
+ text: `${vps}${local}`
6342
+ });
6343
+ }
6344
+ /** Combined attach artefact, ready for the clipboard. */
6345
+ function createFrpAttachTemplate(settings, options = {}) {
6346
+ return createFrpAttachTemplateParts(settings, options).text;
6347
+ }
6348
+ //#endregion
6349
+ //#region src/frp-attach-plan.ts
6350
+ const DEFAULT_PLAN_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
6351
+ function planConfigFile(options) {
6352
+ const file = options.configFile;
6353
+ return file === void 0 || file === "" ? DEFAULT_PLAN_CONFIG_FILE : file;
6354
+ }
6355
+ /**
6356
+ * Build the copy-only attach plan. It performs no network access, no SSH, and no
6357
+ * filesystem write: the caller renders or copies the steps and the local frpc
6358
+ * configuration is written by the ordinary provider lifecycle.
6359
+ */
6360
+ function createFrpAttachPlan(settings, options = {}) {
6361
+ const parts = frpAttachVpsParts(settings, options);
6362
+ const configFile = planConfigFile(options);
6363
+ const localPort = options.localPort ?? 3443;
6364
+ const frpcToml = createFrpAttachFrpcToml(settings, {
6365
+ ...options,
6366
+ localPort
6367
+ });
6368
+ const tokenMasked = options.revealToken !== true;
6369
+ const verifyCommand = `frpc verify -c "${configFile}"`;
6370
+ const commonWarnings = ["本机探测 ≠ 公网验证:请在外部(手机流量或另一台机器)再执行一次 VPS 侧清单里的 curl 验证。", "插件不会安装、不会改动、不会重启你既有的 frps,也不会改动 Caddyfile 里的其他内容。"];
6371
+ if (isFrpSelfSignedIngress(settings)) {
6372
+ const publicPort = parts.publicPort ?? 0;
6373
+ const publicHost = parts.publicHost;
6374
+ return Object.freeze({
6375
+ mode: "attach",
6376
+ entryTls: resolveFrpEntryTls(settings),
6377
+ publicPort,
6378
+ local: Object.freeze({
6379
+ frpcToml,
6380
+ tokenMasked,
6381
+ verifyCommand,
6382
+ configFile,
6383
+ declaration: FRP_ATTACH_LOCAL_DECLARATION
6384
+ }),
6385
+ vps: Object.freeze([
6386
+ Object.freeze({
6387
+ id: "open-public-port",
6388
+ title: "frpAttachStepOpenPort",
6389
+ label: `放行公网入口端口 ${String(publicPort)}/tcp`,
6390
+ commands: Object.freeze([`ufw allow ${String(publicPort)}/tcp`, `ufw status | grep ${String(publicPort)}`]),
6391
+ optional: false,
6392
+ verifyHint: `ufw status 中应出现 ${String(publicPort)}/tcp ALLOW;云厂商安全组也需放行同一 TCP 端口。`
6393
+ }),
6394
+ Object.freeze({
6395
+ id: "verify-frps",
6396
+ title: "frpAttachStepVerifyFrps",
6397
+ label: "确认既有 frps 已就绪(不改动它的配置)",
6398
+ commands: Object.freeze(["systemctl is-active frps || true", `ss -lnt | grep -E ':(${String(settings.serverPort)}|${String(publicPort)})\\b' || true`]),
6399
+ optional: false,
6400
+ verifyHint: `frps 控制端口 ${String(settings.serverPort)} 应监听;入口 ${String(publicPort)} 的 Local Address 不应是 127.0.0.1。若仅回环监听,防火墙放行也无法公网访问。`
6401
+ }),
6402
+ Object.freeze({
6403
+ id: "verify-entry",
6404
+ title: "frpAttachStepVerifyEntry",
6405
+ label: "frpc 启动后端到端验证(自签证书需 -k)",
6406
+ commands: Object.freeze([`curl -k -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}:${String(publicPort)}${FRP_ATTACH_DISCOVERY_PATH}`]),
6407
+ optional: false,
6408
+ verifyHint: "期望输出 200;curl -k 仅验证连通性,不验证服务器身份;App 配对后会固定网关 CA。返回 000 时检查端口与转发。"
6409
+ })
6410
+ ]),
6411
+ warnings: Object.freeze([
6412
+ ...commonWarnings,
6413
+ FRP_ATTACH_SELF_SIGNED_DECLARATION,
6414
+ "现有 frps 的 TCP 代理必须已监听公网地址;若 proxyBindAddr=127.0.0.1,单靠开放防火墙端口无法连通,请改选公网证书 + Caddy 模式。插件不会修改你的 frps。",
6415
+ "手机浏览器访问自签入口会提示证书不受信任 —— 请用 App 扫码配对;App 已固定网关 CA,不需要公开证书。",
6416
+ "切勿把网关 listenHost 改成 0.0.0.0:公网入口由 frps 的 TCP 代理提供,网关只监听 127.0.0.1。"
6417
+ ])
6418
+ });
6419
+ }
6420
+ const vhostHttpPort = parts.vhostHttpPort ?? 0;
6421
+ const publicHost = parts.publicHost;
6422
+ const certGuide = parts.certGuide;
6423
+ const certificateSteps = certGuide.length === 0 ? [] : [Object.freeze({
6424
+ id: "issue-ip-cert",
6425
+ title: "frpAttachStepIssueCert",
6426
+ label: "为公网 IP 签发受信任证书(certbot)",
6427
+ commands: Object.freeze([...certGuide]),
6428
+ optional: false,
6429
+ verifyHint: `证书应落到 /etc/letsencrypt/live/${publicHost}/ 并安装到 Caddy 证书目录。`
6430
+ }), Object.freeze({
6431
+ id: "enable-cert-timer",
6432
+ title: "frpAttachStepCertTimer",
6433
+ label: "确认 certbot 续期定时器并更新 Caddy 证书文件",
6434
+ commands: Object.freeze(["systemctl list-timers certbot.timer --all || true"]),
6435
+ optional: false,
6436
+ verifyHint: "公网 IP 证书有效期较短;续期后还须将新证书安装到 Caddy 并重载。"
6437
+ })];
6438
+ return Object.freeze({
6439
+ mode: "attach",
6440
+ entryTls: resolveFrpEntryTls(settings),
6441
+ vhostHttpPort,
6442
+ local: Object.freeze({
6443
+ frpcToml,
6444
+ tokenMasked,
6445
+ verifyCommand,
6446
+ configFile,
6447
+ declaration: FRP_ATTACH_LOCAL_DECLARATION
6448
+ }),
6449
+ vps: Object.freeze([
6450
+ Object.freeze({
6451
+ id: "write-snippet",
6452
+ title: "frpAttachStepWriteSnippet",
6453
+ label: `新增 Caddy 片段 ${FRP_CADDY_SNIPPET_PATH}`,
6454
+ commands: Object.freeze([
6455
+ `cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'`,
6456
+ parts.snippet.trimEnd(),
6457
+ "DSH_MOBILE_CADDY_SNIPPET"
6458
+ ]),
6459
+ optional: false,
6460
+ verifyHint: `片段内容里的反代目标是 127.0.0.1:${String(vhostHttpPort)},即你 frps 的 vhostHTTPPort。`
6461
+ }),
6462
+ Object.freeze({
6463
+ id: "add-import",
6464
+ title: "frpAttachStepAddImport",
6465
+ label: "在 Caddyfile 顶部加入 import 行并重载",
6466
+ commands: Object.freeze([`# 确认 /etc/caddy/Caddyfile 顶部包含:${FRP_CADDY_IMPORT_LINE}`, "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy"]),
6467
+ optional: false,
6468
+ verifyHint: "caddy validate 通过且 reload 无报错;Caddyfile 中你自己原有的站点内容保持不变。"
6469
+ }),
6470
+ ...certificateSteps,
6471
+ Object.freeze({
6472
+ id: "verify-https",
6473
+ title: "frpAttachStepVerifyHttps",
6474
+ label: "端到端验证公网 HTTPS 入口",
6475
+ commands: Object.freeze([`curl -sS -o /dev/null -w '%{http_code}\\n' https://${publicHost}${FRP_ATTACH_DISCOVERY_PATH}`]),
6476
+ optional: true,
6477
+ verifyHint: certGuide.length === 0 ? "期望输出 200;证书错误时检查 DNS、80/443 端口和 Caddy 日志。" : "期望输出 200;证书错误时检查 certbot 证书是否安装并重载 Caddy。"
6478
+ })
6479
+ ]),
6480
+ warnings: Object.freeze([
6481
+ ...commonWarnings,
6482
+ FRP_ATTACH_VPS_DECLARATION,
6483
+ `你 frps 的明文 vhost 必须只监听 127.0.0.1(proxyBindAddr)且 vhostHTTPPort=${String(vhostHttpPort)};插件启动前会探测该端口,若公网可达将直接拒绝启动。`
6484
+ ])
6485
+ });
6486
+ }
6487
+ //#endregion
6488
+ //#region src/cert-renewal.ts
6489
+ /** Days left below which the entry certificate is reported as expiring. */
6490
+ const CERT_EXPIRING_DAYS = 2;
6491
+ const DAY_MS = 864e5;
6492
+ /** Evaluate one `notAfter` timestamp against the current clock. */
6493
+ function evaluateCertificateLifetime(notAfter, now = Date.now()) {
6494
+ if (!Number.isFinite(notAfter) || !Number.isFinite(now)) return Object.freeze({
6495
+ state: "unknown",
6496
+ errorCode: "frp_attach_cert_unknown"
6497
+ });
6498
+ const daysRemaining = Math.floor((notAfter - now) / DAY_MS);
6499
+ if (notAfter <= now) return Object.freeze({
6500
+ state: "expired",
6501
+ notAfter,
6502
+ daysRemaining
6503
+ });
6504
+ if (daysRemaining <= 2) return Object.freeze({
6505
+ state: "expiring",
6506
+ notAfter,
6507
+ daysRemaining
6508
+ });
6509
+ return Object.freeze({
6510
+ state: "ok",
6511
+ notAfter,
6512
+ daysRemaining
6513
+ });
6514
+ }
6515
+ function statusFromCertificate(certificate, now) {
6516
+ const lifetime = evaluateCertificateLifetime(Date.parse(certificate.validTo), now);
6517
+ return Object.freeze({
6518
+ ...lifetime,
6519
+ subject: certificate.subject,
6520
+ issuer: certificate.issuer
6521
+ });
6522
+ }
6523
+ /**
6524
+ * Read one PEM certificate from disk and report only its lifetime.
6525
+ *
6526
+ * A missing or unreadable file yields `unknown` with the stable
6527
+ * `frp_attach_cert_unknown` code so the panel can explain how to re-issue it.
6528
+ */
6529
+ async function readCertificateRenewal(file, now = Date.now()) {
6530
+ try {
6531
+ return statusFromCertificate(new X509Certificate(await readFile(file)), now);
6532
+ } catch {
6533
+ return Object.freeze({
6534
+ state: "unknown",
6535
+ errorCode: "frp_attach_cert_unknown"
6536
+ });
6537
+ }
6538
+ }
6539
+ /**
6540
+ * Ask a live TLS endpoint for its leaf certificate and report its lifetime.
6541
+ *
6542
+ * Verification is intentionally skipped: this is a read-only lifetime probe for
6543
+ * an endpoint whose trust anchor is already pinned elsewhere, and a self-signed
6544
+ * leaf is the expected answer for the passthrough entry.
6545
+ */
6546
+ async function probeOriginCertificate(host, port, timeoutMs = 5e3) {
6547
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return Object.freeze({
6548
+ state: "unknown",
6549
+ errorCode: "frp_attach_cert_unknown"
6550
+ });
6551
+ return new Promise((resolveProbe) => {
6552
+ let finished = false;
6553
+ const socket = connect$1({
6554
+ host,
6555
+ port,
6556
+ rejectUnauthorized: false,
6557
+ servername: void 0
6558
+ });
6559
+ const finish = (status) => {
6560
+ if (finished) return;
6561
+ finished = true;
6562
+ clearTimeout(timer);
6563
+ socket.destroy();
6564
+ resolveProbe(status);
6565
+ };
6566
+ const timer = setTimeout(() => {
6567
+ finish(Object.freeze({
6568
+ state: "unknown",
6569
+ errorCode: "frp_attach_cert_unknown"
6570
+ }));
6571
+ }, timeoutMs);
6572
+ timer.unref();
6573
+ socket.once("secureConnect", () => {
6574
+ const certificate = socket.getPeerX509Certificate();
6575
+ finish(certificate === void 0 ? Object.freeze({
6576
+ state: "unknown",
6577
+ errorCode: "frp_attach_cert_unknown"
6578
+ }) : statusFromCertificate(certificate, Date.now()));
6579
+ });
6580
+ socket.once("error", () => {
6581
+ finish(Object.freeze({
6582
+ state: "unknown",
6583
+ errorCode: "frp_attach_cert_unknown"
6584
+ }));
6585
+ });
6586
+ socket.once("close", () => {
6587
+ finish(Object.freeze({
6588
+ state: "unknown",
6589
+ errorCode: "frp_attach_cert_unknown"
6590
+ }));
6591
+ });
6592
+ });
6593
+ }
6594
+ //#endregion
6595
+ //#region src/managed-setup.ts
6596
+ const VIRTUAL_INTERFACE_MARKERS = [
6597
+ "bridge",
6598
+ "docker",
6599
+ "hyper-v",
6600
+ "mihomo",
6601
+ "radmin",
6602
+ "tailscale",
6603
+ "tap",
6604
+ "tun",
6605
+ "utun",
6606
+ "vbox",
6607
+ "veth",
6608
+ "virtual",
6609
+ "vmware",
6610
+ "vpn",
6611
+ "vethernet",
6612
+ "wsl",
6613
+ "zerotier"
6614
+ ];
6615
+ /** Route inspection is advisory; never let a system command hold setup open indefinitely. */
6616
+ const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
6617
+ function requiredString(value, name) {
6618
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
6619
+ return value;
6620
+ }
6621
+ /** Validate the durable managed setup before it controls network and filesystem operations. */
6622
+ function parseManagedSetup(value) {
6623
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
6624
+ const record = value;
6625
+ if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
6626
+ "version",
6627
+ "networkInterface",
6628
+ "listenPort",
6629
+ "upstreamOrigin",
6630
+ "tls"
6631
+ ].includes(key))) throw new Error("mobile setup file has an unsupported format");
6632
+ if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
6633
+ if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
6634
+ const tls = record.tls;
6635
+ if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
6636
+ "mode",
6637
+ "caCertFile",
6638
+ "caKeyFile",
6639
+ "certFile",
6640
+ "keyFile"
6641
+ ].includes(key))) throw new Error("mobile setup tls has an unsupported format");
6642
+ return Object.freeze({
6643
+ version: 2,
6644
+ networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
6645
+ listenPort: record.listenPort,
6646
+ upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
6647
+ tls: Object.freeze({
6648
+ mode: "managed",
6649
+ caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
6650
+ caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
6651
+ certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
6652
+ keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
6653
+ })
6654
+ });
6655
+ }
6656
+ function privateIpv4(value) {
6657
+ const parts = value.split(".").map(Number);
6658
+ 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);
6659
+ }
6660
+ function networkCidr(address, cidr) {
6661
+ const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
6662
+ const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
6663
+ return `${[
6664
+ 24,
6665
+ 16,
6666
+ 8,
6667
+ 0
6668
+ ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
6669
+ }
6670
+ /** List current private IPv4 candidates with their interface identity. */
6671
+ function availableLanNetworks(table = networkInterfaces()) {
6672
+ const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
6673
+ name,
6674
+ address: entry.address,
6675
+ cidr: networkCidr(entry.address, entry.cidr)
6676
+ })));
6677
+ return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
6678
+ }
6679
+ function likelyVirtualInterface(name) {
6680
+ const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
6681
+ return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
6682
+ }
6683
+ async function runRouteCommand(file, args) {
6684
+ return (await execFileText(file, [...args], {
6685
+ encoding: "utf8",
6686
+ timeout: ROUTE_COMMAND_TIMEOUT_MS
6687
+ })).stdout;
6688
+ }
6689
+ function uniqueLines(output) {
6690
+ return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
6691
+ }
6692
+ /** Return operating-system default-route interfaces in routing preference order. */
6693
+ async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
6694
+ try {
6695
+ if (platform === "win32") return uniqueLines(await run("powershell.exe", [
6696
+ "-NoProfile",
6697
+ "-NonInteractive",
6698
+ "-Command",
6699
+ [
6700
+ "$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
6701
+ "$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
6702
+ " $route = $_",
6703
+ " $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
6704
+ " $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
6705
+ " if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
6706
+ " [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
6707
+ " }",
6708
+ "}",
6709
+ "$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
6710
+ ].join("; ")
6711
+ ]));
6712
+ if (platform === "linux") {
6713
+ const routes = uniqueLines(await run("ip", [
6714
+ "-o",
6715
+ "-4",
6716
+ "route",
6717
+ "show",
6718
+ "default"
6719
+ ])).map((line) => ({
6720
+ name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
6721
+ metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
6722
+ })).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
6723
+ return [...new Set(routes.map((route) => route.name))];
6724
+ }
6725
+ if (platform === "darwin") {
6726
+ const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
6727
+ "-n",
6728
+ "get",
6729
+ "default"
6730
+ ]))?.[1];
6731
+ return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
6732
+ }
6733
+ } catch {}
6734
+ return [];
6735
+ }
6736
+ /** Select an active LAN, optionally by address or by a previously saved interface name. */
6737
+ function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
6738
+ const candidates = availableLanNetworks(table);
6739
+ if (requestedAddress !== void 0) {
6740
+ const match = candidates.find((candidate) => candidate.address === requestedAddress);
6741
+ if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
6742
+ return match;
6743
+ }
6744
+ if (requestedInterface !== void 0) {
6745
+ const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
6746
+ if (matches.length === 1) return matches[0];
6747
+ if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
6748
+ throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
6749
+ }
6750
+ if (candidates.length === 1) return candidates[0];
6751
+ if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
6752
+ for (const name of preferredInterfaces) {
6753
+ const matches = candidates.filter((candidate) => candidate.name === name);
6754
+ if (matches.length === 1) return matches[0];
6755
+ }
6756
+ const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
6757
+ if (physicalCandidates.length === 1) return physicalCandidates[0];
6758
+ throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
6759
+ }
6760
+ /**
6761
+ * True when the failure came from LAN selection (the configured network is
6762
+ * gone, nothing is up, or several are) rather than from config/TLS code.
6763
+ * Callers use it to degrade (warn + stay down) instead of failing boot.
6764
+ * Pinned to the selectLanNetwork message contract — keep in sync.
6765
+ */
6766
+ function isNetworkSelectionError(error) {
6767
+ const message = error instanceof Error ? error.message : String(error ?? "");
6768
+ 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");
6769
+ }
6770
+ function assertMatchingCa(certPem, keyPem) {
6771
+ const certificate = new X509Certificate(certPem);
6772
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
6773
+ const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
6774
+ format: "der",
6775
+ type: "spki"
6776
+ });
6777
+ const certificatePublic = certificate.publicKey.export({
6778
+ format: "der",
6779
+ type: "spki"
6780
+ });
6781
+ if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
6782
+ if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
6783
+ return certificate;
6784
+ }
6785
+ /** Read an existing managed CA without ever generating a replacement identity. */
6786
+ async function readManagedCa(setup) {
6787
+ const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
6788
+ return assertMatchingCa(certPem, keyPem);
6789
+ }
6790
+ async function atomicWrite(file, contents) {
6791
+ const directory = dirname(file);
6792
+ await mkdir(directory, {
6793
+ recursive: true,
6794
+ mode: 448
6795
+ });
6796
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
6797
+ await writeFile(temporary, contents, { mode: 384 });
6798
+ await rename(temporary, file);
6799
+ await restrictPrivateFile(file);
6800
+ }
6801
+ /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
6802
+ async function ensureManagedCa(setup, legacy) {
6803
+ let certPem;
6804
+ let keyPem;
6805
+ try {
6806
+ [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
6807
+ } catch (error) {
6808
+ if (error.code !== "ENOENT") throw error;
6809
+ let migrated = false;
6810
+ if (legacy !== void 0) try {
6811
+ [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
6812
+ assertMatchingCa(certPem, keyPem);
6813
+ migrated = true;
6814
+ } catch (legacyError) {
6815
+ if (legacyError.code !== "ENOENT") throw legacyError;
6816
+ }
6817
+ if (!migrated) {
6818
+ const now = /* @__PURE__ */ new Date();
6819
+ const notAfter = new Date(now);
6820
+ notAfter.setFullYear(notAfter.getFullYear() + 5);
6821
+ const generated = await generate([{
6822
+ name: "commonName",
6823
+ value: "DeepSeek Harness Mobile CA"
6824
+ }], {
6825
+ keyType: "ec",
6826
+ curve: "P-256",
6827
+ algorithm: "sha256",
6828
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
6829
+ notAfterDate: notAfter,
6830
+ extensions: [{
6831
+ name: "basicConstraints",
6832
+ cA: true,
6833
+ critical: true
6834
+ }, {
6835
+ name: "keyUsage",
6836
+ digitalSignature: true,
6837
+ keyCertSign: true,
6838
+ cRLSign: true,
6839
+ critical: true
6840
+ }]
6841
+ });
6842
+ certPem = generated.cert;
6843
+ keyPem = generated.private;
6844
+ }
6845
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
6846
+ await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
6847
+ }
6848
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
6849
+ await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)]);
6850
+ return assertMatchingCa(certPem, keyPem);
6851
+ }
6852
+ function subjectAltNames(target) {
6853
+ const names = [];
6854
+ for (const ip of target.ipAddresses ?? []) names.push({
6855
+ type: 7,
6856
+ ip
6857
+ });
6858
+ for (const dns of target.dnsNames ?? []) names.push({
6859
+ type: 2,
6860
+ value: dns
6861
+ });
6862
+ if (names.length === 0) throw new Error("managed TLS leaf needs at least one server name");
6863
+ return names;
6864
+ }
6865
+ /**
6866
+ * Sign one server leaf with an existing self-signed CA and install it privately.
6867
+ *
6868
+ * Both the LAN listener and the self-signed FRP ingress use this one path, so a
6869
+ * certificate can never be produced by two divergent code paths. IP entries are
6870
+ * required for console addresses (`type: 7`), DNS entries (`type: 2`) cover named
6871
+ * hosts; the Android client accepts either through its pinned CA.
6872
+ */
6873
+ async function issueServerCertificate(authority, target, output, lifetimeDays = 397) {
6874
+ const altNames = subjectAltNames(target);
6875
+ await Promise.all([restrictPrivateFile(authority.caCertFile), restrictPrivateFile(authority.caKeyFile)]);
6876
+ const [caCert, caKey] = await Promise.all([readFile(authority.caCertFile, "utf8"), readFile(authority.caKeyFile, "utf8")]);
6877
+ assertMatchingCa(caCert, caKey);
6878
+ const now = /* @__PURE__ */ new Date();
6879
+ const notAfter = new Date(now);
6880
+ notAfter.setDate(notAfter.getDate() + lifetimeDays);
6881
+ const server = await generate([{
6882
+ name: "commonName",
6883
+ value: target.commonName
6884
+ }], {
6885
+ keyType: "ec",
6886
+ curve: "P-256",
6887
+ algorithm: "sha256",
6888
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
6889
+ notAfterDate: notAfter,
6890
+ ca: {
6891
+ cert: caCert,
6892
+ key: caKey
6893
+ },
6894
+ extensions: [
6895
+ {
6896
+ name: "basicConstraints",
6897
+ cA: false,
6898
+ critical: true
6899
+ },
6900
+ {
6901
+ name: "keyUsage",
6902
+ digitalSignature: true,
6903
+ critical: true
6904
+ },
6905
+ {
6906
+ name: "extKeyUsage",
6907
+ serverAuth: true
6908
+ },
6909
+ {
6910
+ name: "subjectAltName",
6911
+ altNames
6912
+ }
6913
+ ]
6914
+ });
6915
+ await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
6916
+ }
6917
+ /** Sign and atomically install a server leaf for the interface's current address. */
6918
+ async function refreshManagedServerCertificate(setup, address) {
6919
+ await issueServerCertificate({
6920
+ caCertFile: setup.tls.caCertFile,
6921
+ caKeyFile: setup.tls.caKeyFile
6922
+ }, {
6923
+ commonName: "DeepSeek Harness Mobile",
6924
+ ipAddresses: [address]
6925
+ }, {
6926
+ certFile: setup.tls.certFile,
6927
+ keyFile: setup.tls.keyFile
6928
+ });
6929
+ }
6930
+ /** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
6931
+ async function materializeManagedSetup(setup, table) {
6932
+ const network = selectLanNetwork(void 0, setup.networkInterface, table);
6933
+ await refreshManagedServerCertificate(setup, network.address);
6934
+ const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
6935
+ return {
6936
+ publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
6937
+ listenHost: network.address,
6938
+ allowedCidrs: [network.cidr],
6939
+ instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
6940
+ pairingCaFile: setup.tls.caCertFile,
6941
+ tls: {
6942
+ mode: "provided",
6943
+ certFile: setup.tls.certFile,
6944
+ keyFile: setup.tls.keyFile
6945
+ }
6946
+ };
6947
+ }
6948
+ //#endregion
6949
+ //#region src/frp-ingress.ts
6950
+ /** Private ingress directory, always a sibling of the remote device file. */
6951
+ function frpIngressPaths(stateFile) {
6952
+ const directory = join(dirname(stateFile), "ingress");
6953
+ return Object.freeze({
6954
+ directory,
6955
+ caCertFile: join(directory, "ca.pem"),
6956
+ caKeyFile: join(directory, "ca-key.pem"),
6957
+ certFile: join(directory, "server.pem"),
6958
+ keyFile: join(directory, "server-key.pem"),
6959
+ statusFile: join(dirname(stateFile), "ingress-identity.json")
6960
+ });
6961
+ }
6962
+ async function regularFileExists(file) {
6963
+ try {
6964
+ const entry = await lstat(file);
6965
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
6966
+ return true;
6967
+ } catch (error) {
6968
+ if (error.code === "ENOENT") return false;
6969
+ throw error;
6970
+ }
6971
+ }
6972
+ async function readIngressIdentity(file) {
6973
+ if (!await regularFileExists(file)) return void 0;
6974
+ try {
6975
+ const parsed = JSON.parse(await readFile(file, "utf8"));
6976
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid identity");
6977
+ const record = parsed;
6978
+ if (record.version !== 1 || typeof record.caFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.caFingerprint)) throw new Error("invalid identity");
6979
+ return record.caFingerprint;
6980
+ } catch (error) {
6981
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
6982
+ }
6983
+ }
6984
+ async function writeIngressIdentity(file, fingerprint) {
6985
+ const temporary = join(dirname(file), `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
6986
+ try {
6987
+ await writeFile(temporary, `${JSON.stringify({
6988
+ version: 1,
6989
+ caFingerprint: fingerprint
6990
+ })}\n`, {
6991
+ flag: "wx",
6992
+ mode: 384
6993
+ });
6994
+ await rename(temporary, file);
6995
+ await restrictPrivateFile(file);
6996
+ } finally {
6997
+ try {
6998
+ await unlink(temporary);
6999
+ } catch (error) {
7000
+ if (error.code !== "ENOENT") throw error;
7001
+ }
7002
+ }
7003
+ }
7004
+ function coversPublicIp(certificate, address) {
7005
+ return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
7006
+ }
7007
+ /**
7008
+ * Materialize the CA and leaf the gateway terminates TLS with.
7009
+ *
7010
+ * The CA remains stable for paired phones; an expired CA must be re-paired, not
7011
+ * replaced silently. The leaf is re-signed when expiring or no longer valid for
7012
+ * the public IPv4, and the running gateway reloads it after a renewal check.
7013
+ */
7014
+ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
7015
+ if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
7016
+ const paths = frpIngressPaths(stateFile);
7017
+ try {
7018
+ const directory = await lstat(paths.directory);
7019
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
7020
+ } catch (error) {
7021
+ if (error.code !== "ENOENT") throw error;
7022
+ }
7023
+ const previousFingerprint = await readIngressIdentity(paths.statusFile);
7024
+ const [caCertExists, caKeyExists, leafCertExists, leafKeyExists] = await Promise.all([
7025
+ regularFileExists(paths.caCertFile),
7026
+ regularFileExists(paths.caKeyFile),
7027
+ regularFileExists(paths.certFile),
7028
+ regularFileExists(paths.keyFile)
7029
+ ]);
7030
+ if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
7031
+ if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
7032
+ if (previousFingerprint === void 0) {
7033
+ await mkdir(dirname(paths.statusFile), {
7034
+ recursive: true,
7035
+ mode: 448
7036
+ });
7037
+ try {
7038
+ await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
7039
+ flag: "wx",
7040
+ mode: 384
7041
+ });
7042
+ await restrictPrivateFile(paths.statusFile);
7043
+ } catch (error) {
7044
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
7045
+ }
7046
+ }
7047
+ const caFiles = {
7048
+ mode: "managed",
7049
+ caCertFile: paths.caCertFile,
7050
+ caKeyFile: paths.caKeyFile,
7051
+ certFile: paths.certFile,
7052
+ keyFile: paths.keyFile
7053
+ };
7054
+ let ca;
7055
+ try {
7056
+ ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
7057
+ } catch (error) {
7058
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
7059
+ }
7060
+ const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
7061
+ if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
7062
+ if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
7063
+ if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
7064
+ const publicHost = new URL(settings.publicOrigin).hostname;
7065
+ let leaf;
7066
+ try {
7067
+ leaf = new X509Certificate(await readFile(paths.certFile));
7068
+ } catch {
7069
+ leaf = void 0;
7070
+ }
7071
+ if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
7072
+ await issueServerCertificate({
7073
+ caCertFile: paths.caCertFile,
7074
+ caKeyFile: paths.caKeyFile
7075
+ }, {
7076
+ commonName: "DSH Mobile FRP ingress",
7077
+ ipAddresses: [publicHost]
7078
+ }, {
7079
+ certFile: paths.certFile,
7080
+ keyFile: paths.keyFile
7081
+ });
7082
+ leaf = new X509Certificate(await readFile(paths.certFile));
7083
+ }
7084
+ const signed = leaf ?? (() => {
7085
+ throw new Error("frp_attach_cert_unknown");
7086
+ })();
7087
+ return Object.freeze({
7088
+ paths,
7089
+ ca,
7090
+ leaf: signed,
7091
+ caFingerprint,
7092
+ status: await readCertificateRenewal(paths.certFile, now)
7093
+ });
7094
+ }
7095
+ /**
7096
+ * Build the read-only self-check payload shown in the desktop panel.
7097
+ *
7098
+ * No secret ever leaves this function: the CA is reported by fingerprint only,
7099
+ * and the token is never read.
7100
+ */
7101
+ async function frpIngressSelfCheck(settings, stateFile) {
7102
+ const paths = frpIngressPaths(stateFile);
7103
+ const base = {
7104
+ mode: resolveFrpMode(settings),
7105
+ entryTls: resolveFrpEntryTls(settings),
7106
+ vhostHttpPort: resolveFrpVhostHttpPort(settings),
7107
+ publicPort: resolveFrpPublicPort(settings),
7108
+ publicOrigin: settings.publicOrigin,
7109
+ entryOrigin: frpEntryOrigin(settings),
7110
+ serverAddress: settings.serverAddress,
7111
+ serverPort: settings.serverPort,
7112
+ inbound: {
7113
+ listenHost: "127.0.0.1",
7114
+ allowedCidrs: Object.freeze(["127.0.0.0/8"])
7115
+ }
7116
+ };
7117
+ if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
7118
+ let caFingerprint;
7119
+ try {
7120
+ caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
7121
+ } catch {
7122
+ caFingerprint = void 0;
7123
+ }
7124
+ return Object.freeze({
7125
+ ...base,
7126
+ ...caFingerprint === void 0 ? {} : { caFingerprint },
7127
+ certificate: await readCertificateRenewal(paths.certFile),
7128
+ caCertificate: await readCertificateRenewal(paths.caCertFile)
7129
+ });
7130
+ }
7131
+ /** Remove only the five files owned by the local self-signed FRP entry. */
7132
+ async function purgeFrpIngressCertificates(stateFile) {
7133
+ const paths = frpIngressPaths(stateFile);
7134
+ try {
7135
+ const marker = await lstat(paths.statusFile);
7136
+ if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
7137
+ await unlink(paths.statusFile);
7138
+ } catch (error) {
7139
+ if (error.code !== "ENOENT") throw error;
7140
+ }
7141
+ let directory;
7142
+ try {
7143
+ directory = await lstat(paths.directory);
7144
+ } catch (error) {
7145
+ if (error.code === "ENOENT") return;
7146
+ throw error;
7147
+ }
7148
+ if (directory.isSymbolicLink()) {
7149
+ await unlink(paths.directory);
7150
+ return;
7151
+ }
7152
+ if (!directory.isDirectory()) throw new Error("frp_ingress_directory_invalid");
7153
+ for (const file of [
7154
+ paths.caCertFile,
7155
+ paths.caKeyFile,
7156
+ paths.certFile,
7157
+ paths.keyFile
7158
+ ]) {
7159
+ let entry;
7160
+ try {
7161
+ entry = await lstat(file);
7162
+ } catch (error) {
7163
+ if (error.code === "ENOENT") continue;
7164
+ throw error;
7165
+ }
7166
+ if (!entry.isFile() && !entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
7167
+ await unlink(file);
7168
+ }
7169
+ try {
7170
+ await rmdir(paths.directory);
7171
+ } catch (error) {
7172
+ if (error.code === "ENOENT") return;
7173
+ throw error;
7174
+ }
7175
+ }
7176
+ //#endregion
7177
+ //#region src/remote.ts
7178
+ const REMOTE_PROVIDERS = Object.freeze([
7179
+ "tailscale",
7180
+ "cpolar",
7181
+ "cloudflared",
7182
+ "frp",
7183
+ "origin"
7184
+ ]);
7185
+ function aggregateErrors(errors, message) {
7186
+ if (errors.length === 0) return void 0;
7187
+ if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
7188
+ return new AggregateError(errors, message);
7189
+ }
7190
+ /** Settle independent remote cleanup work before reporting any collected failure. */
7191
+ async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
7192
+ const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
7193
+ if (failure !== void 0) throw failure;
7194
+ }
7195
+ /**
7196
+ * Serialize all provider mutations and preserve the single-provider invariant.
7197
+ * Operations read the selected controller only after reaching the front of the queue.
7198
+ */
7199
+ var RemoteProviderCoordinator = class {
7200
+ controllers;
7201
+ store;
7202
+ selectedValue;
7203
+ queue = Promise.resolve();
7204
+ constructor(selected, controllers, store) {
7205
+ this.controllers = controllers;
7206
+ this.store = store;
7207
+ this.selectedValue = selected;
7208
+ }
7209
+ /** Return the durable provider currently selected by the desktop UI. */
7210
+ get selected() {
7211
+ return this.selectedValue;
7212
+ }
7213
+ /** Return the controller selected when this method is called. */
7214
+ controller() {
7215
+ return this.controllers[this.selectedValue];
7216
+ }
7217
+ /** Run a provider-owned mutation after all earlier provider work settles. */
7218
+ mutate(operation) {
7219
+ return this.enqueue(() => operation(this.controller()));
7220
+ }
7221
+ /** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
7222
+ select(provider) {
7223
+ return this.enqueue(async () => {
7224
+ if (provider === this.selectedValue) return;
7225
+ const previous = this.controllers[this.selectedValue];
7226
+ const restore = previous.status().enabled;
7227
+ if (restore) await previous.setEnabled(false);
7228
+ try {
7229
+ await this.store.save({
7230
+ version: 1,
7231
+ provider
7232
+ });
7233
+ this.selectedValue = provider;
7234
+ } catch (error) {
7235
+ if (restore) try {
7236
+ await previous.setEnabled(true);
7237
+ } catch (restoreError) {
7238
+ throw new AggregateError([error, restoreError], "remote provider selection rollback failed");
7239
+ }
7240
+ throw error;
7241
+ }
7242
+ });
7243
+ }
7244
+ enqueue(operation) {
7245
+ const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
7246
+ this.queue = task.then(() => void 0, () => void 0);
7247
+ return task;
7248
+ }
7249
+ async runAndEnforce(operation) {
7250
+ let value;
7251
+ let operationError;
7252
+ try {
7253
+ value = await operation();
7254
+ } catch (error) {
7255
+ operationError = error;
7256
+ }
7257
+ const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== this.selectedValue).map((provider) => this.controllers[provider].setEnabled(false)));
7258
+ const failure = aggregateErrors([...operationError === void 0 ? [] : [operationError], ...results.filter((result) => result.status === "rejected").map((result) => result.reason)], "remote provider operation failed");
7259
+ if (failure !== void 0) throw failure;
7260
+ return value;
7261
+ }
7262
+ };
7263
+ /** Stop an owned provider process and do not report completion before its close event. */
7264
+ async function terminateRemoteProcess(child, gracefulTimeoutMs = 1500, forcedTimeoutMs = 1500) {
7265
+ if (child.exitCode !== null || child.signalCode !== null) return;
7266
+ await new Promise((resolveClose, rejectClose) => {
7267
+ let gracefulTimer;
7268
+ let forcedTimer;
7269
+ let settled = false;
7270
+ const finish = (error) => {
7271
+ if (settled) return;
7272
+ settled = true;
7273
+ if (gracefulTimer !== void 0) clearTimeout(gracefulTimer);
7274
+ if (forcedTimer !== void 0) clearTimeout(forcedTimer);
7275
+ child.off("close", onClose);
7276
+ if (error === void 0) resolveClose();
7277
+ else rejectClose(error);
7278
+ };
7279
+ const onClose = () => {
7280
+ finish();
7281
+ };
7282
+ child.once("close", onClose);
7283
+ try {
7284
+ child.kill("SIGTERM");
7285
+ } catch (error) {
7286
+ finish(error instanceof Error ? error : new Error(String(error)));
7287
+ return;
7288
+ }
7289
+ if (settled) return;
7290
+ gracefulTimer = setTimeout(() => {
7291
+ try {
7292
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
7293
+ } catch (error) {
7294
+ finish(error instanceof Error ? error : new Error(String(error)));
7295
+ return;
7296
+ }
7297
+ if (settled) return;
7298
+ forcedTimer = setTimeout(() => {
7299
+ finish(/* @__PURE__ */ new Error("remote_process_stop_timeout"));
7300
+ }, forcedTimeoutMs);
7301
+ forcedTimer.unref();
7302
+ }, gracefulTimeoutMs);
7303
+ gracefulTimer.unref();
5924
7304
  });
5925
7305
  }
5926
7306
  /** Validate the provider selection loaded across the filesystem boundary. */
@@ -6003,6 +7383,7 @@ const DISCOVERY_REQUEST_TIMEOUT_MS = 5e3;
6003
7383
  const DISCOVERY_RETRY_MS = 1e3;
6004
7384
  const MAX_DISCOVERY_BYTES = 16384;
6005
7385
  const VHOST_PROBE_TIMEOUT_MS = 1500;
7386
+ const INGRESS_CERT_CHECK_MS = 432e5;
6006
7387
  function publicStatus$3(status) {
6007
7388
  return Object.freeze({
6008
7389
  enabled: status.enabled,
@@ -6097,38 +7478,108 @@ async function boundedResponseBytes(response) {
6097
7478
  }
6098
7479
  return bytes;
6099
7480
  }
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`, {
7481
+ /** Web view over an HTTPS response so the byte cap applies to both paths alike. */
7482
+ function discoveryResponse(message) {
7483
+ const headers = new Headers();
7484
+ const declaredLength = message.headers["content-length"];
7485
+ if (declaredLength !== void 0) headers.set("content-length", declaredLength);
7486
+ return new Response(Readable.toWeb(message), {
7487
+ status: message.statusCode ?? 200,
7488
+ headers
7489
+ });
7490
+ }
7491
+ /**
7492
+ * Perform one discovery request against the public entry.
7493
+ *
7494
+ * `node:https` carries this request instead of the global `fetch` for a single
7495
+ * reason: only here can the trust anchor be stated. The self-signed entry
7496
+ * presents a leaf the plugin's own ingress CA signed, so the default chain
7497
+ * always fails there (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`) even though the
7498
+ * transport itself is healthy. The anchor is *added* to the trust store — never
7499
+ * `rejectUnauthorized: false` — so the peer must still prove its identity, and
7500
+ * the verified name stays the host of the origin, which is the name the app
7501
+ * dials as well (Node matches IP literals against `IP Address:` SANs).
7502
+ *
7503
+ * A non-200 answer resolves `undefined` for retry; discovery requires a body.
7504
+ * transport, TLS, and timeout failures reject so the caller keeps retrying.
7505
+ * The request stays cancellable until the bounded response body has completed.
7506
+ */
7507
+ function requestDiscovery(target, signal) {
7508
+ if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_aborted"));
7509
+ const url = new URL(`${target.origin}/mobile-access/discovery`);
7510
+ if (url.protocol !== "https:" || url.hostname === "") return Promise.reject(/* @__PURE__ */ new Error("frp_discovery_invalid"));
7511
+ return new Promise((resolveRequest, rejectRequest) => {
7512
+ let settled = false;
7513
+ let request;
7514
+ const release = () => {
7515
+ clearTimeout(timeout);
7516
+ signal.removeEventListener("abort", onAbort);
7517
+ };
7518
+ const fail = (error) => {
7519
+ if (settled) return;
7520
+ settled = true;
7521
+ release();
7522
+ request?.destroy();
7523
+ rejectRequest(error);
7524
+ };
7525
+ const succeed = (value) => {
7526
+ if (settled) return;
7527
+ settled = true;
7528
+ release();
7529
+ resolveRequest(value);
7530
+ };
7531
+ const onAbort = () => {
7532
+ fail(/* @__PURE__ */ new Error("frp_discovery_aborted"));
7533
+ };
7534
+ const timeout = setTimeout(() => {
7535
+ fail(/* @__PURE__ */ new Error("frp_discovery_timeout"));
7536
+ }, DISCOVERY_REQUEST_TIMEOUT_MS);
7537
+ timeout.unref();
7538
+ signal.addEventListener("abort", onAbort, { once: true });
7539
+ request = request$1({
7540
+ host: url.hostname,
7541
+ port: url.port === "" ? 443 : Number(url.port),
7542
+ path: url.pathname,
6110
7543
  method: "GET",
6111
- redirect: "error",
6112
- cache: "no-store",
6113
- signal: requestController.signal,
6114
- headers: { accept: "application/json" }
7544
+ headers: { accept: "application/json" },
7545
+ ...target.trustAnchorPem === void 0 ? {} : { ca: target.trustAnchorPem },
7546
+ agent: false
7547
+ }, (message) => {
7548
+ if ((message.statusCode ?? 0) !== 200) {
7549
+ succeed(void 0);
7550
+ message.destroy();
7551
+ return;
7552
+ }
7553
+ boundedResponseBytes(discoveryResponse(message)).then(succeed, fail);
6115
7554
  });
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);
7555
+ request.once("error", (error) => {
7556
+ fail(error instanceof Error ? error : /* @__PURE__ */ new Error("frp_discovery_request_failed"));
7557
+ });
7558
+ request.end();
7559
+ });
7560
+ }
7561
+ /**
7562
+ * Read the public discovery advertisement and compare it with the expected
7563
+ * identity.
7564
+ *
7565
+ * `frp_discovery_invalid` (malformed body) and `frp_discovery_mismatch` (the
7566
+ * entry advertises another identity) stay immediate failures; transport, TLS,
7567
+ * and timeout problems reject so the caller retries until its deadline.
7568
+ */
7569
+ async function defaultProbeDiscovery(target, expectedInstanceId, signal) {
7570
+ const bytes = await requestDiscovery(target, signal);
7571
+ if (bytes === void 0) return false;
7572
+ let value;
7573
+ try {
7574
+ value = JSON.parse(new TextDecoder().decode(bytes));
7575
+ } catch {
7576
+ throw new Error("frp_discovery_invalid");
6131
7577
  }
7578
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("frp_discovery_invalid");
7579
+ const actual = value.instanceId;
7580
+ if (typeof actual !== "string") throw new Error("frp_discovery_invalid");
7581
+ if (actual !== expectedInstanceId) throw new Error("frp_discovery_mismatch");
7582
+ return true;
6132
7583
  }
6133
7584
  /** Owns frpc, its generation-specific configuration, and the remote gateway. */
6134
7585
  var FrpController = class {
@@ -6145,10 +7596,12 @@ var FrpController = class {
6145
7596
  });
6146
7597
  queue = Promise.resolve();
6147
7598
  startupAbort;
7599
+ ingressCertificateTimer;
6148
7600
  constructor(options) {
6149
7601
  this.options = options;
6150
7602
  if (!isAbsolute(options.executable)) throw new Error("frpc executable path must be absolute");
6151
7603
  if (!/^[a-f0-9]{64}$/u.test(options.instanceId)) throw new Error("FRP instance ID is invalid");
7604
+ if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
6152
7605
  }
6153
7606
  /** Restore the remembered FRP switch without changing LAN or other providers. */
6154
7607
  async initialize() {
@@ -6268,41 +7721,45 @@ var FrpController = class {
6268
7721
  });
6269
7722
  return;
6270
7723
  }
7724
+ const entryOrigin = frpEntryOrigin(settings);
6271
7725
  this.publish({
6272
7726
  enabled: true,
6273
7727
  state: "starting",
6274
- origin: settings.publicOrigin
7728
+ origin: entryOrigin
6275
7729
  });
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;
7730
+ if (!isFrpSelfSignedIngress(settings)) {
7731
+ let exposed;
7732
+ try {
7733
+ exposed = await (this.options.probeVhostExposure ?? defaultProbeVhostExposure)(settings.serverAddress, resolveFrpVhostHttpPort(settings));
7734
+ } catch {
7735
+ this.publish({
7736
+ enabled: true,
7737
+ state: "error",
7738
+ origin: entryOrigin,
7739
+ errorCode: "frp_vhost_probe_failed"
7740
+ });
7741
+ return;
7742
+ }
7743
+ if (exposed) {
7744
+ this.publish({
7745
+ enabled: true,
7746
+ state: "error",
7747
+ origin: entryOrigin,
7748
+ errorCode: "frp_vhost_publicly_reachable"
7749
+ });
7750
+ return;
7751
+ }
6296
7752
  }
6297
7753
  let gateway;
6298
7754
  try {
6299
- gateway = await this.options.createGateway(settings.publicOrigin);
6300
- } catch {
7755
+ gateway = await this.options.createGateway(settings.publicOrigin, settings);
7756
+ } catch (error) {
7757
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "gateway_start_failed";
6301
7758
  this.publish({
6302
7759
  enabled: true,
6303
7760
  state: "error",
6304
- origin: settings.publicOrigin,
6305
- errorCode: "gateway_start_failed"
7761
+ origin: entryOrigin,
7762
+ errorCode: code
6306
7763
  });
6307
7764
  return;
6308
7765
  }
@@ -6341,26 +7798,66 @@ var FrpController = class {
6341
7798
  this.publish({
6342
7799
  enabled: true,
6343
7800
  state: "connecting",
6344
- origin: settings.publicOrigin
7801
+ origin: entryOrigin
6345
7802
  });
7803
+ let check;
7804
+ try {
7805
+ check = await this.discoveryCheck(settings);
7806
+ } catch (error) {
7807
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_ca_invalid";
7808
+ await this.failGeneration(generation, code);
7809
+ return;
7810
+ }
7811
+ if (generation !== this.generation || !this.enabled) return;
6346
7812
  const controller = new AbortController();
6347
7813
  this.startupAbort = controller;
6348
- this.waitForDiscovery(generation, settings.publicOrigin, controller.signal);
7814
+ this.waitForDiscovery(generation, check, gateway.config.instanceId, settings, gateway, controller.signal);
7815
+ }
7816
+ /**
7817
+ * Derive the self-check target and trust anchor for the effective entry.
7818
+ *
7819
+ * The public-CA entry is reached on the saved origin itself: Caddy terminates
7820
+ * TLS on 443 with a publicly trusted certificate, so the probe must keep the
7821
+ * system trust store and the origin URL untouched. The self-signed entry is a
7822
+ * raw TCP passthrough on `publicPort` whose leaf chains to the ingress CA the
7823
+ * app pins from `pairingCaFile`; probing `publicOrigin` there would knock on
7824
+ * 443 — where nothing listens — with a chain the system cannot verify. Both
7825
+ * facts used to be implicit, and together they kept the channel out of `ready`
7826
+ * until `frp_start_timeout`.
7827
+ */
7828
+ async discoveryCheck(settings) {
7829
+ if (!isFrpSelfSignedIngress(settings)) return {
7830
+ publicOrigin: settings.publicOrigin,
7831
+ targetOrigin: settings.publicOrigin
7832
+ };
7833
+ const targetOrigin = frpEntryOrigin(settings);
7834
+ const trustAnchorPem = await this.options.resolveDiscoveryTrustAnchor?.(settings);
7835
+ if (this.options.resolveDiscoveryTrustAnchor !== void 0 && trustAnchorPem === void 0) throw new Error("frp_ingress_ca_invalid");
7836
+ return Object.freeze({
7837
+ publicOrigin: targetOrigin,
7838
+ targetOrigin,
7839
+ ...trustAnchorPem === void 0 ? {} : { trustAnchorPem }
7840
+ });
6349
7841
  }
6350
- async waitForDiscovery(generation, origin, signal) {
7842
+ async waitForDiscovery(generation, check, advertisedInstanceId, settings, gateway, signal) {
6351
7843
  const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS$2);
6352
7844
  const probe = this.options.probeDiscovery ?? defaultProbeDiscovery;
7845
+ const target = Object.freeze({
7846
+ origin: check.targetOrigin,
7847
+ ...check.trustAnchorPem === void 0 ? {} : { trustAnchorPem: check.trustAnchorPem }
7848
+ });
6353
7849
  while (!signal.aborted && Date.now() < deadline) {
6354
7850
  try {
6355
- if (await probe(origin, this.options.instanceId, signal)) {
7851
+ if (await probe(target, advertisedInstanceId, signal)) {
6356
7852
  await this.enqueue(async () => {
6357
7853
  if (generation !== this.generation || signal.aborted || !this.enabled) return;
6358
7854
  this.startupAbort = void 0;
6359
7855
  this.publish({
6360
7856
  enabled: true,
6361
7857
  state: "ready",
6362
- origin
7858
+ origin: check.publicOrigin
6363
7859
  });
7860
+ this.scheduleIngressCertificateCheck(generation, settings, gateway);
6364
7861
  });
6365
7862
  return;
6366
7863
  }
@@ -6387,6 +7884,33 @@ var FrpController = class {
6387
7884
  }
6388
7885
  if (!signal.aborted) await this.enqueue(() => this.failGeneration(generation, "frp_start_timeout"));
6389
7886
  }
7887
+ scheduleIngressCertificateCheck(generation, settings, gateway) {
7888
+ const maintain = this.options.maintainIngressCertificate;
7889
+ if (!isFrpSelfSignedIngress(settings) || maintain === void 0) return;
7890
+ const timer = setTimeout(() => {
7891
+ if (this.ingressCertificateTimer !== timer) return;
7892
+ this.ingressCertificateTimer = void 0;
7893
+ this.enqueue(async () => {
7894
+ if (generation !== this.generation || !this.enabled || this.disposed || this.gatewayValue !== gateway) return;
7895
+ try {
7896
+ await maintain(settings, gateway);
7897
+ } catch (error) {
7898
+ const code = error instanceof Error && error.message.startsWith("frp_") ? error.message : "frp_ingress_renewal_failed";
7899
+ await this.failGeneration(generation, code);
7900
+ return;
7901
+ }
7902
+ if (generation === this.generation && this.enabled && !this.disposed && this.gatewayValue === gateway) this.scheduleIngressCertificateCheck(generation, settings, gateway);
7903
+ }).catch(() => {
7904
+ if (generation === this.generation && this.enabled && !this.disposed) this.publish({
7905
+ enabled: true,
7906
+ state: "error",
7907
+ errorCode: "frp_ingress_renewal_failed"
7908
+ });
7909
+ });
7910
+ }, this.options.ingressCertificateCheckMs ?? INGRESS_CERT_CHECK_MS);
7911
+ timer.unref();
7912
+ this.ingressCertificateTimer = timer;
7913
+ }
6390
7914
  async failGeneration(generation, code) {
6391
7915
  if (generation !== this.generation) return;
6392
7916
  await this.stopProcessAndGateway();
@@ -6401,6 +7925,8 @@ var FrpController = class {
6401
7925
  await this.stopProcessAndGateway();
6402
7926
  }
6403
7927
  async stopProcessAndGateway() {
7928
+ clearTimeout(this.ingressCertificateTimer);
7929
+ this.ingressCertificateTimer = void 0;
6404
7930
  this.startupAbort?.abort();
6405
7931
  this.startupAbort = void 0;
6406
7932
  const child = this.child;
@@ -7981,21 +9507,212 @@ function defaultFirewallProbe(platform = process.platform) {
7981
9507
  }
7982
9508
  };
7983
9509
  }
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;
9510
+ /** Reserve one six-second window per egress so the desktop's 15-second request can finish. */
9511
+ function remoteDiagnosticTimeoutMs(_origin) {
9512
+ return 6e3;
9513
+ }
9514
+ const TUNNEL_HEADER_LIMIT_BYTES = 65536;
9515
+ function proxyExcludedByNoProxy(target, raw) {
9516
+ const text = raw.trim().toLowerCase();
9517
+ if (text === "") return false;
9518
+ const hostname = target.hostname.toLowerCase();
9519
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
9520
+ const targetPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
9521
+ for (const entry of text.split(/[\s,]+/)) {
9522
+ let value = entry;
9523
+ if (value === "") continue;
9524
+ if (value === "*") return true;
9525
+ if (value.includes("/")) continue;
9526
+ let port;
9527
+ if (value.startsWith("[")) {
9528
+ const bracket = value.indexOf("]");
9529
+ if (bracket < 0) continue;
9530
+ const tail = value.slice(bracket + 1);
9531
+ if (tail !== "") {
9532
+ if (!/^:\d+$/u.test(tail)) continue;
9533
+ port = Number(tail.slice(1));
9534
+ }
9535
+ value = value.slice(1, bracket);
9536
+ } else if ((value.match(/:/gu) ?? []).length === 1) {
9537
+ const separator = value.lastIndexOf(":");
9538
+ const suffix = value.slice(separator + 1);
9539
+ if (!/^\d+$/u.test(suffix)) continue;
9540
+ port = Number(suffix);
9541
+ value = value.slice(0, separator);
9542
+ }
9543
+ if (port !== void 0 && port !== targetPort) continue;
9544
+ if (value.startsWith("*.")) value = value.slice(2);
9545
+ else if (value.startsWith(".")) value = value.slice(1);
9546
+ if (value === "") continue;
9547
+ if (host === value || host.endsWith(`.${value}`)) return true;
9548
+ }
9549
+ return false;
9550
+ }
9551
+ /**
9552
+ * Resolve the relay egress configured in the environment for `origin`.
9553
+ *
9554
+ * Honors `HTTPS_PROXY` (then `ALL_PROXY`, then the generic `HTTP_PROXY`) for an
9555
+ * HTTPS target, and `http_proxy`/`HTTP_PROXY`/`ALL_PROXY` for a plain one, unless
9556
+ * `NO_PROXY`/`no_proxy` covers the host. Only a plain `http://<host>:<port>`
9557
+ * relay is usable: SOCKS or a TLS-to-proxy configuration would need a different
9558
+ * tunnel, so absent that the probe keeps its direct semantics.
9559
+ */
9560
+ function configuredProxyFor(origin, env = process.env) {
9561
+ let target;
9562
+ try {
9563
+ target = new URL(origin);
9564
+ } catch {
9565
+ return;
9566
+ }
9567
+ if (proxyExcludedByNoProxy(target, env.NO_PROXY ?? env.no_proxy ?? "")) return void 0;
9568
+ const candidates = target.protocol === "https:" ? [
9569
+ env.https_proxy ?? env.HTTPS_PROXY,
9570
+ env.all_proxy ?? env.ALL_PROXY,
9571
+ env.http_proxy ?? env.HTTP_PROXY
9572
+ ] : [env.http_proxy ?? env.HTTP_PROXY, env.all_proxy ?? env.ALL_PROXY];
9573
+ for (const candidate of candidates) {
9574
+ if (typeof candidate !== "string" || candidate.trim() === "") continue;
9575
+ try {
9576
+ const proxy = new URL(candidate.trim());
9577
+ if (proxy.protocol !== "http:" || proxy.hostname === "") continue;
9578
+ return proxy;
9579
+ } catch {
9580
+ continue;
9581
+ }
9582
+ }
9583
+ }
9584
+ /** One reachability probe through an HTTP relay: CONNECT, TLS, then a status line. */
9585
+ async function relayProbeThroughProxy(target, proxy, budgetMs = remoteDiagnosticTimeoutMs(target.origin)) {
9586
+ const started = performance.now();
9587
+ return new Promise((resolve, reject) => {
9588
+ let settled = false;
9589
+ let hopRequest;
9590
+ let rawSocket;
9591
+ let secureSocket;
9592
+ let timer;
9593
+ const finish = (outcome) => {
9594
+ if (settled) return;
9595
+ settled = true;
9596
+ clearTimeout(timer);
9597
+ secureSocket?.destroy();
9598
+ rawSocket?.end();
9599
+ rawSocket?.destroy();
9600
+ hopRequest?.destroy();
9601
+ if (outcome.error !== void 0) reject(outcome.error);
9602
+ else resolve(outcome.value ?? {
9603
+ status: 0,
9604
+ latencyMs: 0
9605
+ });
9606
+ };
9607
+ timer = setTimeout(() => {
9608
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_timeout") });
9609
+ }, Math.max(1, budgetMs));
9610
+ timer.unref();
9611
+ const hopPort = target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port);
9612
+ const hopPath = `${target.hostname}:${hopPort}`;
9613
+ const headers = {
9614
+ host: hopPath,
9615
+ "proxy-connection": "keep-alive"
9616
+ };
9617
+ if (proxy.username !== "") headers["proxy-authorization"] = `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`, "utf8").toString("base64")}`;
9618
+ hopRequest = request({
9619
+ host: proxy.hostname,
9620
+ port: proxy.port === "" ? 80 : Number(proxy.port),
9621
+ method: "CONNECT",
9622
+ path: hopPath,
9623
+ headers,
9624
+ agent: false
9625
+ });
9626
+ hopRequest.once("connect", (response, socket) => {
9627
+ if (settled) {
9628
+ socket.destroy();
9629
+ return;
9630
+ }
9631
+ rawSocket = socket;
9632
+ if (response.statusCode !== 200) {
9633
+ finish({ error: /* @__PURE__ */ new Error(`proxy_tunnel_rejected_${response.statusCode ?? "unknown"}`) });
9634
+ return;
9635
+ }
9636
+ const secure = connect$1({
9637
+ socket,
9638
+ servername: target.hostname,
9639
+ rejectUnauthorized: true
9640
+ });
9641
+ secureSocket = secure;
9642
+ secure.once("secureConnect", () => {
9643
+ if (settled) return;
9644
+ secure.write([
9645
+ `GET ${target.pathname}${target.search} HTTP/1.1`,
9646
+ `Host: ${target.host}`,
9647
+ "User-Agent: dsh-mobile-diagnostics",
9648
+ "Accept-Encoding: identity",
9649
+ "Connection: close",
9650
+ "",
9651
+ ""
9652
+ ].join("\r\n"));
9653
+ });
9654
+ let buffered = Buffer.alloc(0);
9655
+ secure.on("data", (chunk) => {
9656
+ buffered = Buffer.concat([buffered, chunk]);
9657
+ if (buffered.byteLength > TUNNEL_HEADER_LIMIT_BYTES) {
9658
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_oversized_response") });
9659
+ return;
9660
+ }
9661
+ const end = buffered.indexOf("\r\n\r\n");
9662
+ if (end < 0) return;
9663
+ const statusLine = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/u.exec(buffered.subarray(0, end).toString("latin1"))?.[1];
9664
+ const status = Number.parseInt(statusLine ?? "", 10);
9665
+ if (!Number.isSafeInteger(status) || status < 100) {
9666
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_invalid_response") });
9667
+ return;
9668
+ }
9669
+ finish({ value: {
9670
+ status,
9671
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
9672
+ } });
9673
+ });
9674
+ secure.once("error", (error) => {
9675
+ finish({ error });
9676
+ });
9677
+ secure.once("close", () => {
9678
+ finish({ error: /* @__PURE__ */ new Error("proxy_tunnel_closed") });
9679
+ });
9680
+ });
9681
+ hopRequest.once("error", (error) => {
9682
+ finish({ error });
9683
+ });
9684
+ hopRequest.end();
9685
+ });
9686
+ }
9687
+ /** Bounded supplementary DNS context for a failed direct attempt. */
9688
+ async function unreachableWithDnsContext(hostname, budgetMs) {
9689
+ let fakeIp = false;
9690
+ let timer;
9691
+ try {
9692
+ fakeIp = (await Promise.race([lookup(hostname, { all: true }), new Promise((_resolve, reject) => {
9693
+ timer = setTimeout(() => {
9694
+ reject(/* @__PURE__ */ new Error("dns_context_timeout"));
9695
+ }, Math.max(1, budgetMs));
9696
+ timer.unref();
9697
+ })])).some(({ address }) => {
9698
+ const [first, second] = address.split(".").map(Number);
9699
+ return first === 198 && (second === 18 || second === 19);
9700
+ });
9701
+ } catch {} finally {
9702
+ clearTimeout(timer);
9703
+ }
9704
+ return {
9705
+ state: "unreachable",
9706
+ ...fakeIp ? { fakeIp: true } : {}
9707
+ };
7989
9708
  }
7990
- async function defaultRemoteProbe(origin) {
7991
- if (origin === void 0) return { state: "not-applicable" };
7992
- const hostname = new URL(origin).hostname;
9709
+ async function fetchRemoteHealth(target, budgetMs) {
7993
9710
  const started = performance.now();
7994
9711
  try {
7995
- const response = await fetch(new URL("/mobile-access/health", origin), {
9712
+ const response = await fetch(target, {
7996
9713
  cache: "no-store",
7997
9714
  redirect: "error",
7998
- signal: AbortSignal.timeout(remoteDiagnosticTimeoutMs(origin))
9715
+ signal: AbortSignal.timeout(Math.max(1, budgetMs - 500))
7999
9716
  });
8000
9717
  const latencyMs = Math.max(0, Math.round(performance.now() - started));
8001
9718
  if (response.status === 429) return {
@@ -8010,19 +9727,53 @@ async function defaultRemoteProbe(origin) {
8010
9727
  latencyMs
8011
9728
  };
8012
9729
  } 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 } : {}
9730
+ return unreachableWithDnsContext(target.hostname, Math.max(1, budgetMs - (performance.now() - started)));
9731
+ }
9732
+ }
9733
+ /**
9734
+ * Answer "is the public endpoint reachable" from the machine's own egress.
9735
+ *
9736
+ * A direct attempt stays authoritative first. When it fails and the environment
9737
+ * configures an HTTP relay that `NO_PROXY` does not exclude, one tunnelled
9738
+ * attempt runs: a self-managed Funnel origin otherwise resolves to this
9739
+ * machine's tailnet address, where nothing serves the funnel port locally, so
9740
+ * the direct measurement reports an outage while the relay's egress — the same
9741
+ * public path a phone takes — still succeeds. When both fail, the original
9742
+ * direct observation (including its Fake-IP context) stands unchanged.
9743
+ */
9744
+ async function probeRemoteHealth(origin, options = {}) {
9745
+ if (origin === void 0) return { state: "not-applicable" };
9746
+ let target;
9747
+ try {
9748
+ target = new URL("/mobile-access/health", origin);
9749
+ } catch {
9750
+ return { state: "not-applicable" };
9751
+ }
9752
+ const budgetMs = remoteDiagnosticTimeoutMs(origin);
9753
+ const observation = await (options.direct ?? fetchRemoteHealth)(target, budgetMs);
9754
+ if (observation.state !== "unreachable") return observation;
9755
+ const relay = configuredProxyFor(origin, options.env ?? process.env);
9756
+ if (relay === void 0) return observation;
9757
+ const tunnel = options.tunnel ?? relayProbeThroughProxy;
9758
+ try {
9759
+ const { status, latencyMs } = await tunnel(target, relay, budgetMs);
9760
+ if (status === 429) return {
9761
+ state: "rate-limited",
9762
+ latencyMs,
9763
+ viaProxy: true
8023
9764
  };
9765
+ return status >= 200 && status < 300 ? {
9766
+ state: "ready",
9767
+ latencyMs,
9768
+ viaProxy: true
9769
+ } : observation;
9770
+ } catch {
9771
+ return observation;
8024
9772
  }
8025
9773
  }
9774
+ async function defaultRemoteProbe(origin) {
9775
+ return probeRemoteHealth(origin);
9776
+ }
8026
9777
  function reportLine(entry) {
8027
9778
  return `[${entry.status.toUpperCase()}] ${entry.label}: ${entry.detail}${entry.action === void 0 ? "" : ` ${entry.action}`}`;
8028
9779
  }
@@ -8052,10 +9803,11 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
8052
9803
  const facts = {
8053
9804
  provider: snapshot.remote.provider,
8054
9805
  endpointSuffix,
8055
- ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
9806
+ ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs },
9807
+ ...remoteObservation.viaProxy === true ? { viaProxy: true } : {}
8056
9808
  };
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));
9809
+ 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));
9810
+ else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", remoteObservation.viaProxy === true ? "电脑经代理访问时观察到限流,尚未验证手机能否连接。" : "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
8059
9811
  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
9812
  else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
8061
9813
  } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
@@ -10618,394 +12370,76 @@ var PluginReleaseManager = class {
10618
12370
  return status;
10619
12371
  }
10620
12372
  /** 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`);
12373
+ async update() {
12374
+ if (this.activeUpdate !== void 0) return this.activeUpdate;
12375
+ this.activeUpdate = this.updateOnce();
12376
+ try {
12377
+ return await this.activeUpdate;
12378
+ } finally {
12379
+ this.activeUpdate = void 0;
12380
+ }
10847
12381
  }
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];
12382
+ async updateOnce() {
12383
+ const profileDirectory = this.profileDirectory;
12384
+ if (profileDirectory === void 0) throw new Error("plugin_update_unsupported");
12385
+ const status = await this.status(true);
12386
+ if (!status.updateSupported) throw new Error("plugin_update_unsupported");
12387
+ if (!status.updateAvailable || status.latestVersion === void 0) throw new Error("plugin_update_unavailable");
12388
+ await this.runner(profileDirectory, status.latestVersion);
12389
+ const installed = await this.installedVersionReader(profileDirectory);
12390
+ if (installed !== status.latestVersion) throw new Error("plugin_update_failed");
12391
+ this.cache = void 0;
12392
+ return Object.freeze({
12393
+ installedVersion: installed,
12394
+ restartRequired: true
12395
+ });
10853
12396
  }
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);
12397
+ };
12398
+ //#endregion
12399
+ //#region src/file-logger.ts
12400
+ const MAX_LOG_BYTES = 5242880;
12401
+ /** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
12402
+ async function installMobileFileLogger(ctx, stateDirectory) {
12403
+ const directory = join(stateDirectory, "logs");
12404
+ const file = join(directory, "dsh-mobile.log");
12405
+ const previous = `${file}.1`;
10885
12406
  await mkdir(directory, {
10886
12407
  recursive: true,
10887
12408
  mode: 448
10888
12409
  });
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)]);
12410
+ const entry = await lstat(file).catch(() => void 0);
12411
+ if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
12412
+ await rm(previous, { force: true });
12413
+ await rename(file, previous);
10940
12414
  }
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
- ]
12415
+ const stream = createWriteStream(file, {
12416
+ flags: "a",
12417
+ encoding: "utf8",
12418
+ mode: 384
10989
12419
  });
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
12420
+ const exporter = {
12421
+ colors: false,
12422
+ maxLength: 16384,
12423
+ levels: {
12424
+ default: -1,
12425
+ "dsh-mobile": 3
12426
+ },
12427
+ export(message) {
12428
+ if (message.name !== "dsh-mobile") return;
12429
+ const record = {
12430
+ timestamp: new Date(message.ts).toISOString(),
12431
+ level: message.type,
12432
+ logger: message.name,
12433
+ message: Logger.format(exporter, message)
12434
+ };
12435
+ stream.write(`${JSON.stringify(record)}\n`);
11007
12436
  }
11008
12437
  };
12438
+ ctx.logger.exporter(exporter);
12439
+ ctx.effect(() => () => {
12440
+ stream.end();
12441
+ }, "dsh-mobile file logger");
12442
+ return file;
11009
12443
  }
11010
12444
  //#endregion
11011
12445
  //#region src/lan-setup.ts
@@ -11269,6 +12703,56 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
11269
12703
  discovery: false
11270
12704
  });
11271
12705
  }
12706
+ /**
12707
+ * Gateway config for the self-signed FRP ingress.
12708
+ *
12709
+ * Unlike {@link remoteGatewayConfig}, the gateway itself is the TLS endpoint:
12710
+ * frps only forwards raw TCP, so the listener speaks HTTPS with the leaf that
12711
+ * `pairingCaFile` signed, and the pairing CA is deliberately retained so the
12712
+ * gateway can serve `GET /mobile-access/ca.cer` for the app to pin. The listener
12713
+ * stays on loopback because the public entry is the frps TCP proxy, never a
12714
+ * direct bind.
12715
+ */
12716
+ function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenPort = 0) {
12717
+ if (!isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
12718
+ const publicAuthority = `${new URL(settings.publicOrigin).hostname}:${String(resolveFrpPublicPort(settings))}`;
12719
+ const { pairingCaFile: _templateCaFile, ...shared } = template;
12720
+ return Object.freeze({
12721
+ ...shared,
12722
+ listenHost: "127.0.0.1",
12723
+ listenPort,
12724
+ authorities: Object.freeze([parseAuthority(publicAuthority)]),
12725
+ allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
12726
+ stateFile,
12727
+ instanceId: ingress.caFingerprint,
12728
+ pairingCaFile: ingress.paths.caCertFile,
12729
+ tls: Object.freeze({
12730
+ mode: "provided",
12731
+ certFile: ingress.paths.certFile,
12732
+ keyFile: ingress.paths.keyFile
12733
+ }),
12734
+ publicTls: true,
12735
+ discovery: false
12736
+ });
12737
+ }
12738
+ /**
12739
+ * CA the FRP start-up self-check must pin, read from the ingress directory.
12740
+ *
12741
+ * The self-signed entry presents a leaf signed by this CA, which is absent from
12742
+ * the system trust store, so the probe must anchor it exactly as the app does
12743
+ * when it pins `pairingCaFile`. The public-CA entry answers with a publicly
12744
+ * trusted certificate and therefore returns `undefined` (system trust store).
12745
+ * An unreadable self-signed CA fails explicitly: falling back to the system
12746
+ * trust store would hide a broken pairing identity behind a start timeout.
12747
+ */
12748
+ async function readFrpIngressTrustAnchor(settings, stateFile) {
12749
+ if (!isFrpSelfSignedIngress(settings)) return void 0;
12750
+ try {
12751
+ return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
12752
+ } catch (error) {
12753
+ throw new Error("frp_ingress_ca_invalid", { cause: error });
12754
+ }
12755
+ }
11272
12756
  /** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
11273
12757
  function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
11274
12758
  const validated = parseOriginSettings(settings);
@@ -11463,6 +12947,27 @@ async function apply(ctx, config) {
11463
12947
  await candidate.start();
11464
12948
  return candidate;
11465
12949
  };
12950
+ /**
12951
+ * Gateway factory for the FRP provider.
12952
+ *
12953
+ * The public-CA entry keeps the ordinary Caddy-fronted remote gateway. The
12954
+ * self-signed entry terminates TLS inside the gateway, so it first materializes
12955
+ * a leaf the pairing CA signed for the public IPv4 and then binds the ingress
12956
+ * configuration that keeps that CA pinned.
12957
+ */
12958
+ const createFrpGateway = async (publicOrigin, settings) => {
12959
+ if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
12960
+ const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
12961
+ const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
12962
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
12963
+ try {
12964
+ await candidate.start();
12965
+ } catch (error) {
12966
+ await candidate.close();
12967
+ throw error;
12968
+ }
12969
+ return candidate;
12970
+ };
11466
12971
  const createOriginGateway = async (settings) => {
11467
12972
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
11468
12973
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
@@ -11504,7 +13009,18 @@ async function apply(ctx, config) {
11504
13009
  executable: frpComponent.executable,
11505
13010
  config: frpConfig,
11506
13011
  instanceId,
11507
- createGateway: createRemoteGateway
13012
+ createGateway: createFrpGateway,
13013
+ /**
13014
+ * Hand the start-up self-check the CA the app itself pins.
13015
+ *
13016
+ * Only this module knows where the ingress material lives, so the anchor is
13017
+ * read here from the ingress directory instead of being guessed downstream.
13018
+ */
13019
+ resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
13020
+ maintainIngressCertificate: async (settings, gateway) => {
13021
+ await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
13022
+ await gateway.refreshProvidedTls();
13023
+ }
11508
13024
  }),
11509
13025
  origin: new OriginController({
11510
13026
  store: originStore,
@@ -11566,6 +13082,25 @@ async function apply(ctx, config) {
11566
13082
  networkError = "network_interface_unavailable";
11567
13083
  }
11568
13084
  const remote = remoteController().status();
13085
+ const frpOrigin = remote.origin;
13086
+ const frpSettings = remoteProviders.selected === "frp" ? frpConfig.settings() : void 0;
13087
+ const frpGateway = remoteControllers.frp.gateway();
13088
+ const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
13089
+ try {
13090
+ const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
13091
+ if (trustAnchorPem === void 0) return { state: "unreachable" };
13092
+ const started = performance.now();
13093
+ return await defaultProbeDiscovery({
13094
+ origin: frpOrigin,
13095
+ trustAnchorPem
13096
+ }, frpGateway.config.instanceId, AbortSignal.timeout(5500)) ? {
13097
+ state: "ready",
13098
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
13099
+ } : { state: "unreachable" };
13100
+ } catch {
13101
+ return { state: "unreachable" };
13102
+ }
13103
+ } : void 0;
11569
13104
  return collectConnectionDiagnostics({
11570
13105
  dshVersion,
11571
13106
  lan: {
@@ -11589,7 +13124,7 @@ async function apply(ctx, config) {
11589
13124
  ...remote.origin === void 0 ? {} : { origin: remote.origin },
11590
13125
  ...remote.errorCode === void 0 ? {} : { errorCode: remote.errorCode }
11591
13126
  }
11592
- });
13127
+ }, pinnedFrpProbe === void 0 ? {} : { remote: pinnedFrpProbe });
11593
13128
  };
11594
13129
  const adminRoute = {
11595
13130
  kind: "prefix",
@@ -11767,6 +13302,34 @@ async function apply(ctx, config) {
11767
13302
  sendJson(response, 200, remotePayload(), false);
11768
13303
  return;
11769
13304
  }
13305
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
13306
+ const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
13307
+ const options = { configFile: frpConfig.runtimeConfigFile };
13308
+ logger.info("frp attach plan requested mode=%s entryTls=%s vhostHttpPort=%d", settings.mode ?? "deploy", settings.entryTls ?? "public-ip-cert", resolveFrpVhostHttpPort(settings));
13309
+ sendJson(response, 200, {
13310
+ ...remotePayload(),
13311
+ frpAttachPlan: createFrpAttachPlan(settings, options),
13312
+ frpAttachTemplate: createFrpAttachTemplate(settings, options)
13313
+ }, false);
13314
+ return;
13315
+ }
13316
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
13317
+ const settings = frpConfig.settings();
13318
+ if (settings === void 0) throw new HttpError(409, "frp_config_missing");
13319
+ const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
13320
+ const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
13321
+ sendJson(response, 200, {
13322
+ ...remotePayload(),
13323
+ frpSelfCheck: {
13324
+ ...check,
13325
+ frpsReachable,
13326
+ entryReachable,
13327
+ frpc: remoteController().status(),
13328
+ component: frpComponent.status()
13329
+ }
13330
+ }, false);
13331
+ return;
13332
+ }
11770
13333
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/websocket-paths`) {
11771
13334
  sendJson(response, 200, { paths: webSocketPaths.list() }, false);
11772
13335
  return;
@@ -11867,7 +13430,11 @@ async function apply(ctx, config) {
11867
13430
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
11868
13431
  await remoteProviders.mutate(async () => {
11869
13432
  await remoteControllers.frp.setEnabled(false);
11870
- await Promise.all([frpComponent.purge(), frpConfig.purge()]);
13433
+ await Promise.all([
13434
+ frpComponent.purge(),
13435
+ frpConfig.purge(),
13436
+ purgeFrpIngressCertificates(remoteDeviceFile)
13437
+ ]);
11871
13438
  });
11872
13439
  sendJson(response, 200, remotePayload(), false);
11873
13440
  return;
@@ -12010,6 +13577,6 @@ async function apply(ctx, config) {
12010
13577
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
12011
13578
  }
12012
13579
  //#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 };
13580
+ 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
13581
 
12015
13582
  //# sourceMappingURL=index.mjs.map