dsh-mobile 0.3.7 → 0.3.9

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
@@ -7,7 +7,7 @@ import { connect, createServer, isIP } from "node:net";
7
7
  import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { createSocket } from "node:dgram";
10
- import { homedir, hostname, networkInterfaces } from "node:os";
10
+ import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
11
11
  import { createServer as createServer$1, request } from "node:http";
12
12
  import { createServer as createServer$2 } from "node:https";
13
13
  import { Transform, finished } from "node:stream";
@@ -16,9 +16,10 @@ 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
- import { Service } from "@deepseek-ai/cordis";
19
+ import { Logger, Service } from "@deepseek-ai/cordis";
20
20
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
21
21
  import { lookup } from "node:dns/promises";
22
+ import { createWriteStream } from "node:fs";
22
23
  import { generate } from "selfsigned";
23
24
  //#region src/access.ts
24
25
  /** Stable error categories converted to deliberately terse HTTP responses. */
@@ -463,6 +464,59 @@ function isLoopbackAddress(address) {
463
464
  return false;
464
465
  }
465
466
  }
467
+ /**
468
+ * IPv4 ranges that are never a public VPS endpoint (IANA special-purpose,
469
+ * private, shared, loopback, link-local, documentation, benchmark, multicast,
470
+ * and reserved space). Kept in sync with Android `RemoteHostPolicy`.
471
+ */
472
+ const NON_ROUTABLE_IPV4_RANGES = Object.freeze([
473
+ [0n, 8],
474
+ [167772160n, 8],
475
+ [1681915904n, 10],
476
+ [2130706432n, 8],
477
+ [2851995648n, 16],
478
+ [2886729728n, 12],
479
+ [3221225472n, 24],
480
+ [3221225984n, 24],
481
+ [3223307264n, 24],
482
+ [3224682752n, 24],
483
+ [3227017984n, 24],
484
+ [3232235520n, 16],
485
+ [3232706560n, 24],
486
+ [3323068416n, 15],
487
+ [3325256704n, 24],
488
+ [3405803776n, 24],
489
+ [3758096384n, 4],
490
+ [4026531840n, 4]
491
+ ]);
492
+ function parseStrictIpv4Octets(address) {
493
+ const parts = address.split(".");
494
+ if (parts.length !== 4) return void 0;
495
+ const octets = [];
496
+ for (const part of parts) {
497
+ if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(part)) return void 0;
498
+ const octet = Number(part);
499
+ if (octet > 255) return void 0;
500
+ octets.push(octet);
501
+ }
502
+ return octets;
503
+ }
504
+ /**
505
+ * Whether a dotted-quad IPv4 literal is globally routable and therefore usable
506
+ * as a public VPS / remote HTTPS endpoint. Rejects documentation addresses such
507
+ * as 203.0.113.10 alongside private, shared, and reserved space.
508
+ */
509
+ function isGloballyRoutableIpv4(address) {
510
+ const octets = parseStrictIpv4Octets(address);
511
+ if (octets === void 0) return false;
512
+ const value = BigInt(octets[0]) << 24n | BigInt(octets[1]) << 16n | BigInt(octets[2]) << 8n | BigInt(octets[3]);
513
+ return !NON_ROUTABLE_IPV4_RANGES.some(([network, prefix]) => {
514
+ if (prefix === 0) return true;
515
+ const hostBits = BigInt(32 - prefix);
516
+ const mask = (1n << 32n) - 1n ^ (1n << hostBits) - 1n;
517
+ return (value & mask) === network;
518
+ });
519
+ }
466
520
  /** Parse a bare host or host:port authority without accepting URL components. */
467
521
  function parseAuthority(source) {
468
522
  if (source.trim() !== source || source.length === 0 || /[/?#@\\]/u.test(source)) throw new Error(`invalid public authority ${JSON.stringify(source)}`);
@@ -529,14 +583,21 @@ var RequestTrustPolicy = class {
529
583
  canonicalOrigin(header) {
530
584
  if (header === void 0) return void 0;
531
585
  let normalized;
532
- try {
533
- const parsed = new URL(header);
534
- if (parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "" || parsed.username !== "" || parsed.password !== "") return;
535
- normalized = parsed.origin.toLowerCase();
536
- } catch {
537
- return;
586
+ for (const part of header.split(",")) {
587
+ const trimmed = part.trim();
588
+ if (trimmed === "undefined") continue;
589
+ let candidate;
590
+ try {
591
+ const parsed = new URL(trimmed);
592
+ if (parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "" || parsed.username !== "" || parsed.password !== "") return;
593
+ candidate = parsed.origin.toLowerCase();
594
+ } catch {
595
+ return;
596
+ }
597
+ if (!this.origins.has(candidate) || normalized !== void 0) return void 0;
598
+ normalized = candidate;
538
599
  }
539
- return this.origins.has(normalized) ? normalized : void 0;
600
+ return normalized;
540
601
  }
541
602
  };
542
603
  //#endregion
@@ -607,7 +668,7 @@ function parseUpstream(value) {
607
668
  if (url.protocol !== "http:" || !isLoopbackAddress(url.hostname) || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.port === "") throw new Error("upstreamOrigin must be an HTTP loopback origin with an explicit port and no path or credentials");
608
669
  return url;
609
670
  }
610
- function parsePublicOrigin(value) {
671
+ function parsePublicOrigin$1(value) {
611
672
  if (value === void 0) return void 0;
612
673
  if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error("publicOrigin must be an HTTPS origin");
613
674
  let url;
@@ -640,7 +701,7 @@ function parseTls(value, listenHost) {
640
701
  function parseGatewayConfig(raw) {
641
702
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("mobile-access config must be an object");
642
703
  const value = raw;
643
- const publicOrigin = parsePublicOrigin(value.publicOrigin);
704
+ const publicOrigin = parsePublicOrigin$1(value.publicOrigin);
644
705
  if (publicOrigin !== void 0 && value.listenPort !== void 0) throw new Error("publicOrigin cannot be combined with listenPort");
645
706
  if (publicOrigin !== void 0 && value.publicAuthorities !== void 0) throw new Error("publicOrigin cannot be combined with publicAuthorities");
646
707
  const listenHost = value.listenHost ?? (publicOrigin === void 0 ? "127.0.0.1" : "0.0.0.0");
@@ -1128,8 +1189,8 @@ function assertExternalTrust(request, policy, requireOrigin) {
1128
1189
  const origin = request.headers.origin;
1129
1190
  if (origin !== void 0 && !policy.acceptsOrigin(origin)) throw new HttpError(403, "forbidden");
1130
1191
  const site = request.headers["sec-fetch-site"];
1131
- if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
1132
- if (requireOrigin && (!policy.acceptsOrigin(origin) || site !== "same-origin")) throw new HttpError(403, "forbidden");
1192
+ if (site !== void 0 && site !== "same-origin" && site !== "same-site" && site !== "cross-site" && site !== "none") throw new HttpError(403, "forbidden");
1193
+ if (requireOrigin && !policy.acceptsOrigin(origin)) throw new HttpError(403, "forbidden");
1133
1194
  }
1134
1195
  function localAuthority(header) {
1135
1196
  if (header === void 0 || /[/?#@\\]/u.test(header)) return void 0;
@@ -4195,7 +4256,7 @@ var MemoryDeviceStore = class {
4195
4256
  };
4196
4257
  //#endregion
4197
4258
  //#region src/frp-component.ts
4198
- const FRP_VERSION = "0.70.1";
4259
+ const FRP_VERSION$1 = "0.70.1";
4199
4260
  const MAX_ARCHIVE_ENTRIES = 128;
4200
4261
  const MAX_ARCHIVE_LIST_BYTES = 262144;
4201
4262
  /** Pinned official FRP release metadata for supported desktop targets. */
@@ -4207,7 +4268,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4207
4268
  executableName: "frpc.exe",
4208
4269
  downloadBytes: 13924309,
4209
4270
  downloadSha256: "531f3cd3cc41c0b4f077b54fe6b7dd83c0ff727e7f0bf412a4c78fa279165de5",
4210
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_windows_amd64.zip`
4271
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_windows_amd64.zip`
4211
4272
  },
4212
4273
  {
4213
4274
  platform: "win32",
@@ -4216,7 +4277,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4216
4277
  executableName: "frpc.exe",
4217
4278
  downloadBytes: 12204751,
4218
4279
  downloadSha256: "74d3acaf0f03ee190dd0462f9b49861dca50b0559c5488af4b36572fc951fcca",
4219
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_windows_arm64.zip`
4280
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_windows_arm64.zip`
4220
4281
  },
4221
4282
  {
4222
4283
  platform: "linux",
@@ -4225,7 +4286,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4225
4286
  executableName: "frpc",
4226
4287
  downloadBytes: 13924042,
4227
4288
  downloadSha256: "333da23d1b9009d7c01638e9ba38cf4600f7d37d393f854e96ee1396adefa9a6",
4228
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_amd64.tar.gz`
4289
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_linux_amd64.tar.gz`
4229
4290
  },
4230
4291
  {
4231
4292
  platform: "linux",
@@ -4234,7 +4295,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4234
4295
  executableName: "frpc",
4235
4296
  downloadBytes: 12371290,
4236
4297
  downloadSha256: "3990f396a9a490ee7f0e5f355287750ed41520064ed999eab443b5e9a78d773d",
4237
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_arm64.tar.gz`
4298
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_linux_arm64.tar.gz`
4238
4299
  },
4239
4300
  {
4240
4301
  platform: "darwin",
@@ -4243,7 +4304,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4243
4304
  executableName: "frpc",
4244
4305
  downloadBytes: 13951979,
4245
4306
  downloadSha256: "cbf69cf26e5553e914e97d37f5d4367fa30f5f531d073a889465af4719281e25",
4246
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_darwin_amd64.tar.gz`
4307
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_darwin_amd64.tar.gz`
4247
4308
  },
4248
4309
  {
4249
4310
  platform: "darwin",
@@ -4252,7 +4313,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
4252
4313
  executableName: "frpc",
4253
4314
  downloadBytes: 12670664,
4254
4315
  downloadSha256: "cfa733b5a261c1647edee3c1fc4133d2542989b28f5602e81d47fc821d25c55f",
4255
- downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_darwin_arm64.tar.gz`
4316
+ downloadUrl: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION$1}/frp_${FRP_VERSION$1}_darwin_arm64.tar.gz`
4256
4317
  }
4257
4318
  ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
4258
4319
  function inside$1(parent, child) {
@@ -4414,7 +4475,7 @@ var FrpComponentManager = class {
4414
4475
  const arch = options.arch ?? process.arch;
4415
4476
  this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
4416
4477
  this.componentRoot = join(stateDirectory, "components", "frp");
4417
- this.componentStorage = join(this.componentRoot, FRP_VERSION);
4478
+ this.componentStorage = join(this.componentRoot, FRP_VERSION$1);
4418
4479
  this.executable = join(this.componentStorage, platform === "win32" ? "frpc.exe" : "frpc");
4419
4480
  this.logRoot = join(stateDirectory, "logs", "frp");
4420
4481
  this.stagingRoot = join(stateDirectory, "staging", "frp");
@@ -4433,7 +4494,7 @@ var FrpComponentManager = class {
4433
4494
  this.installed = await regularFile$1(this.executable);
4434
4495
  this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
4435
4496
  if (this.installed) try {
4436
- if (await this.inspectExecutable(this.executable) !== FRP_VERSION) throw new Error("frp_component_version_mismatch");
4497
+ if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
4437
4498
  this.errorCode = void 0;
4438
4499
  } catch {
4439
4500
  this.installed = false;
@@ -4445,11 +4506,11 @@ var FrpComponentManager = class {
4445
4506
  return Object.freeze({
4446
4507
  supported: this.artifact !== void 0,
4447
4508
  installed: this.installed,
4448
- version: FRP_VERSION,
4509
+ version: FRP_VERSION$1,
4449
4510
  downloadBytes: this.artifact?.downloadBytes ?? 0,
4450
4511
  installedBytes: this.installedBytes,
4451
4512
  sourceUrl: this.artifact?.downloadUrl ?? "https://github.com/fatedier/frp/releases",
4452
- releasePage: `https://github.com/fatedier/frp/releases/tag/v${FRP_VERSION}`,
4513
+ releasePage: `https://github.com/fatedier/frp/releases/tag/v${FRP_VERSION$1}`,
4453
4514
  storagePath: this.componentRoot,
4454
4515
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
4455
4516
  });
@@ -4487,7 +4548,7 @@ var FrpComponentManager = class {
4487
4548
  const extracted = join(staging, artifact.executableName);
4488
4549
  if (!await regularFile$1(extracted)) throw new Error("frp_executable_missing");
4489
4550
  await chmod(extracted, 448);
4490
- if (await this.inspectExecutable(extracted) !== FRP_VERSION) throw new Error("frp_component_version_mismatch");
4551
+ if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
4491
4552
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
4492
4553
  await mkdir(candidate, {
4493
4554
  recursive: true,
@@ -4540,33 +4601,98 @@ var FrpComponentManager = class {
4540
4601
  //#region src/frp-template.ts
4541
4602
  /** Loopback-only HTTP vhost port used between Caddy and frps. */
4542
4603
  const FRP_VHOST_HTTP_PORT = 7080;
4604
+ /** Caddy snippet owned entirely by DSH Mobile; the main Caddyfile only imports it. */
4605
+ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
4606
+ /** First line of the owned snippet; also the legacy whole-file marker. */
4607
+ const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
4608
+ /** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
4609
+ const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
4610
+ /** Directory holding the public-IPv4 certificates installed by certbot. */
4611
+ const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
4612
+ function publicIpv4Address(value) {
4613
+ const parts = value.split(".");
4614
+ return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
4615
+ }
4543
4616
  function publicDnsHostname(value) {
4544
4617
  return value.length <= 253 && value.includes(".") && !/^[0-9.]+$/u.test(value) && !value.includes(":") && value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
4545
4618
  }
4546
- /** Build the only supported frps and Caddy configuration from validated user inputs. */
4547
- function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
4548
- 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");
4619
+ function parsePublicOrigin(publicOrigin) {
4549
4620
  let url;
4550
4621
  try {
4551
4622
  url = new URL(publicOrigin);
4552
4623
  } catch {
4553
4624
  throw new Error("frp_template_input_invalid");
4554
4625
  }
4555
- if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
4626
+ 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");
4627
+ return url.hostname;
4628
+ }
4629
+ /** Build the Caddy site for one public host (without markers or import wiring). */
4630
+ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
4631
+ if (publicIpv4Address(publicHost)) return [
4632
+ "{",
4633
+ ` default_sni ${publicHost}`,
4634
+ "}",
4635
+ "",
4636
+ `http://${publicHost} {`,
4637
+ ` redir https://${publicHost}{uri} permanent`,
4638
+ "}",
4639
+ "",
4640
+ `https://${publicHost} {`,
4641
+ ` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
4642
+ ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
4643
+ "}",
4644
+ ""
4645
+ ].join("\n");
4646
+ if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
4556
4647
  return [
4557
- "# frps.toml",
4648
+ `${publicHost} {`,
4649
+ ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
4650
+ "}",
4651
+ ""
4652
+ ].join("\n");
4653
+ }
4654
+ /** Manual certbot steps for a public-IPv4 origin (Caddy cannot issue IP certificates itself). */
4655
+ function manualIpCertificateGuide(publicHost) {
4656
+ return [
4657
+ "# Public-IPv4 manual HTTPS: Caddy cannot issue IP certificates by itself.",
4658
+ "# On the VPS (Ubuntu/Debian, port 80 reachable from the internet), run once as root:",
4659
+ "# apt-get install -y python3-venv",
4660
+ "# python3 -m venv /opt/dsh-mobile/certbot-venv",
4661
+ "# /opt/dsh-mobile/certbot-venv/bin/pip install 'certbot==5.8.0'",
4662
+ "# systemctl stop caddy || true",
4663
+ `# /opt/dsh-mobile/certbot-venv/bin/certbot certonly --standalone --preferred-profile shortlived --ip-address ${publicHost} --agree-tos --register-unsafely-without-email --non-interactive --keep-until-expiring`,
4664
+ "# install -d -m 0750 -o caddy -g caddy /var/lib/caddy/dsh-mobile-certs",
4665
+ `# install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/fullchain.pem /var/lib/caddy/dsh-mobile-certs/fullchain.pem`,
4666
+ `# install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/privkey.pem /var/lib/caddy/dsh-mobile-certs/privkey.pem`,
4667
+ "# systemctl start caddy",
4668
+ "# The site below already references those paths. Certificates last about 6 days: re-run certonly before expiry.",
4669
+ "#"
4670
+ ].join("\n");
4671
+ }
4672
+ /** Build the only supported frps config and Caddy snippet from validated user inputs. */
4673
+ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
4674
+ 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");
4675
+ const publicHost = parsePublicOrigin(publicOrigin);
4676
+ const lines = [
4677
+ "# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
4558
4678
  `bindPort = ${String(serverPort)}`,
4559
4679
  "proxyBindAddr = \"127.0.0.1\"",
4560
4680
  `vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
4561
4681
  "auth.method = \"token\"",
4562
4682
  `auth.token = ${JSON.stringify(token)}`,
4563
4683
  "",
4564
- "# Caddyfile",
4565
- `${url.hostname} {`,
4566
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
4567
- "}",
4684
+ `# Caddy — save the site below as ${FRP_CADDY_SNIPPET_PATH},`,
4685
+ "# then make sure your Caddyfile contains exactly this line at the TOP of the file",
4686
+ "# (create the file with just this line if needed; globals must precede sites):",
4687
+ `# ${FRP_CADDY_IMPORT_LINE}`,
4688
+ "# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
4689
+ "# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
4690
+ `${FRP_CADDY_SNIPPET_MARKER}`,
4691
+ createCaddySite(publicHost).trimEnd(),
4568
4692
  ""
4569
- ].join("\n");
4693
+ ];
4694
+ if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
4695
+ return lines.join("\n");
4570
4696
  }
4571
4697
  //#endregion
4572
4698
  //#region src/frp-config.ts
@@ -4601,7 +4727,9 @@ function validateFrpPublicOrigin(value) {
4601
4727
  } catch {
4602
4728
  throw new Error("frp_public_origin_invalid");
4603
4729
  }
4604
- if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(url.hostname) !== 0 || !hostname$1(url.hostname)) throw new Error("frp_public_origin_invalid");
4730
+ const publicHost = url.hostname;
4731
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || isIP(publicHost) !== 4 && !hostname$1(publicHost)) throw new Error("frp_public_origin_invalid");
4732
+ if (isIP(publicHost) === 4 && !isGloballyRoutableIpv4(publicHost)) throw new Error("frp_public_origin_invalid");
4605
4733
  return url.origin;
4606
4734
  }
4607
4735
  /** Parse FRP settings at the loopback request and filesystem boundaries. */
@@ -4624,6 +4752,32 @@ function parseFrpSettings(value) {
4624
4752
  publicOrigin: validateFrpPublicOrigin(record.publicOrigin)
4625
4753
  });
4626
4754
  }
4755
+ /**
4756
+ * Merge a partial VPS request body with the saved configuration so a blank
4757
+ * field keeps its saved value ("已保存时可留空"). Every merged field is still
4758
+ * validated; with nothing saved and nothing supplied the result reports a
4759
+ * missing configuration instead of silently deploying blanks.
4760
+ */
4761
+ function mergeSavedFrpSettings(partial, saved) {
4762
+ const merged = {
4763
+ serverAddress: partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress,
4764
+ serverPort: typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort,
4765
+ token: partial.token === "" || partial.token === void 0 ? saved?.token : partial.token,
4766
+ publicOrigin: partial.publicOrigin === "" || partial.publicOrigin === void 0 ? saved?.publicOrigin : partial.publicOrigin
4767
+ };
4768
+ if (merged.serverAddress === void 0 && merged.serverPort === void 0 && merged.token === void 0 && merged.publicOrigin === void 0) throw new Error("frp_config_missing");
4769
+ return parseFrpSettings(merged);
4770
+ }
4771
+ /** Merge a VPS target (address and control port) with the saved configuration. */
4772
+ function mergeSavedFrpTarget(partial, saved) {
4773
+ const serverAddress = partial.serverAddress === "" || partial.serverAddress === void 0 ? saved?.serverAddress : partial.serverAddress;
4774
+ const serverPort = typeof partial.serverPort === "number" && Number.isSafeInteger(partial.serverPort) && partial.serverPort >= 1 ? partial.serverPort : saved?.serverPort;
4775
+ if (serverAddress === void 0 || serverPort === void 0) throw new Error("frp_config_missing");
4776
+ return Object.freeze({
4777
+ serverAddress: validateFrpServerAddress(serverAddress),
4778
+ serverPort: validateFrpServerPort(serverPort)
4779
+ });
4780
+ }
4627
4781
  function tomlString(value) {
4628
4782
  return JSON.stringify(value);
4629
4783
  }
@@ -5010,6 +5164,7 @@ async function defaultProbeVhostExposure(serverAddress, port) {
5010
5164
  port
5011
5165
  });
5012
5166
  let finished = false;
5167
+ let received = "";
5013
5168
  const finish = (exposed) => {
5014
5169
  if (finished) return;
5015
5170
  finished = true;
@@ -5022,7 +5177,14 @@ async function defaultProbeVhostExposure(serverAddress, port) {
5022
5177
  }, VHOST_PROBE_TIMEOUT_MS);
5023
5178
  timer.unref();
5024
5179
  socket.once("connect", () => {
5025
- finish(true);
5180
+ socket.write("GET /dsh-mobile-exposure-probe HTTP/1.1\r\nHost: invalid.example\r\nConnection: close\r\n\r\n");
5181
+ });
5182
+ socket.on("data", (chunk) => {
5183
+ received = `${received}${chunk.toString("latin1")}`.slice(0, 32);
5184
+ if (/^HTTP\/1\.[01] [1-5][0-9]{2}/u.test(received)) finish(true);
5185
+ });
5186
+ socket.once("close", () => {
5187
+ finish(false);
5026
5188
  });
5027
5189
  socket.once("error", () => {
5028
5190
  finish(false);
@@ -5588,12 +5750,28 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
5588
5750
  }
5589
5751
  //#endregion
5590
5752
  //#region src/mobile-guide.ts
5753
+ /** Compose the guide with the current customization state injected. */
5754
+ function buildMobileGuide(state) {
5755
+ const styleLine = state.hasCustomCss ? "存在(当前生效的自定义样式)" : "不存在(使用内置默认样式)";
5756
+ const scriptLine = state.hasCustomJs ? "存在(当前生效的自定义脚本)" : "不存在(无自定义脚本)";
5757
+ const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})`).join("\n");
5758
+ const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
5759
+ return `${`## 手机端当前状态(改名前必读,避免覆盖已有定制)
5760
+
5761
+ - 定制目录:${state.directory}(所有改动只允许在这里进行)
5762
+ - mobile.css:${styleLine}
5763
+ - mobile.js:${scriptLine}
5764
+ - 已安装扩展:
5765
+ ${extensionLines}
5766
+ ${failureLine}
5767
+
5768
+ “恢复默认”操作说明:当用户要求恢复默认 / 还原初始外观时,删除 mobile.css 与 mobile.js 两个文件(删除后手机端自动回到内置默认外观,无需创建占位文件),并按需删除 extensions/ 下的扩展目录。\n\n`}${MOBILE_CUSTOMIZATION_GUIDE_BODY}`;
5769
+ }
5591
5770
  /**
5592
- * Instructions handed to the DSH agent when the user runs `/mobile <task>`.
5593
- * The agent edits files under the DSH home; this text is what tells it the
5594
- * layout of the mobile-access customization surface so it does not guess.
5771
+ * Static body of the customization guide. Kept separate from the injected
5772
+ * state snapshot so the two concerns stay easy to edit independently.
5595
5773
  */
5596
- const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机端。DSH Mobile 是一个把电脑上的 DeepSeek Harness 带到手机浏览器的插件,手机端界面和能力都来自本机文件。
5774
+ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手机端。DSH Mobile 是一个把电脑上的 DeepSeek Harness 带到手机浏览器的插件,手机端界面和能力都来自本机文件。
5597
5775
 
5598
5776
  所有改动只允许在 $DSH_HOME/mobile-access/ 目录内进行,绝不修改 DeepSeek Harness 的源码或其他目录。$DSH_HOME 是 DeepSeek Harness 的配置目录(通常为 ~/.dsh),先确认它的实际路径再操作。
5599
5777
 
@@ -5618,7 +5796,994 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
5618
5796
  - host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
5619
5797
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
5620
5798
 
5621
- 请执行用户需求:外观或交互类改 mobile.css / mobile.js;需要电脑能力的创建或修改扩展。完成后简要说明改了什么、手机端会有什么变化。`;
5799
+ 完成前请自检:
5800
+ - 改动涉及 mobile.js 或扩展的 mobile.js / host.mjs 时,先做语法检查再保存(如 node --check <file>),确保没有语法错误
5801
+ - 创建或修改扩展后,确认 extension.json 的 schemaVersion 为 1、id 与目录名一致、且 id 只含小写字母数字和连字符
5802
+ - 扩展的 host.mjs 若在完成前无法激活,先修正而不是留下损坏的扩展
5803
+ - 完成后检查自己实际写入了哪些文件,向用户简要说明改了什么、手机端会有什么变化`;
5804
+ //#endregion
5805
+ //#region src/vps-deploy.ts
5806
+ const FRP_VERSION = "0.70.1";
5807
+ const SSH_TIMEOUT_MS = 3e5;
5808
+ const LINUX_ARTIFACTS = Object.freeze({
5809
+ x64: Object.freeze({
5810
+ directory: `frp_${FRP_VERSION}_linux_amd64`,
5811
+ url: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_amd64.tar.gz`,
5812
+ sha256: "333da23d1b9009d7c01638e9ba38cf4600f7d37d393f854e96ee1396adefa9a6"
5813
+ }),
5814
+ arm64: Object.freeze({
5815
+ directory: `frp_${FRP_VERSION}_linux_arm64`,
5816
+ url: `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_arm64.tar.gz`,
5817
+ sha256: "3990f396a9a490ee7f0e5f355287750ed41520064ed999eab443b5e9a78d773d"
5818
+ })
5819
+ });
5820
+ var VpsSshError = class extends Error {
5821
+ stdout;
5822
+ stderr;
5823
+ constructor(message, stdout, stderr, options) {
5824
+ super(message, options);
5825
+ this.stdout = stdout;
5826
+ this.stderr = stderr;
5827
+ }
5828
+ };
5829
+ function shellQuote(value) {
5830
+ return `'${value.replaceAll("'", "'\\''")}'`;
5831
+ }
5832
+ /**
5833
+ * Reject loopback, private, and other non-routable IPv4 literals as VPS SSH
5834
+ * targets. A self-hosted deployment always addresses a public server; the
5835
+ * shared frpc settings stay permissive so local loopback test rigs keep working.
5836
+ */
5837
+ function assertPublicSshTarget(serverAddress) {
5838
+ if (isIP(serverAddress) === 4 && !isGloballyRoutableIpv4(serverAddress)) throw new Error("vps_server_not_public");
5839
+ }
5840
+ /**
5841
+ * Validate a VPS address for every operation that opens a network connection
5842
+ * to it (scan, deploy, cleanup). IPv6 is unsupported by the SSH flow and
5843
+ * loopback/private targets are never valid VPS endpoints.
5844
+ */
5845
+ function validateVpsServerTarget(serverAddress) {
5846
+ const address = validateFrpServerAddress(serverAddress);
5847
+ if (isIP(address) !== 0 && address.includes(":")) throw new Error("vps_ipv6_ssh_not_supported");
5848
+ assertPublicSshTarget(address);
5849
+ return address;
5850
+ }
5851
+ function validSshUser(value) {
5852
+ if (typeof value !== "string" || value.length < 1 || value.length > 64 || !/^[a-z_][a-z0-9_.-]*[$]?$/iu.test(value)) throw new Error("vps_ssh_user_invalid");
5853
+ return value;
5854
+ }
5855
+ function validSshPort(value) {
5856
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error("vps_ssh_port_invalid");
5857
+ return Number(value);
5858
+ }
5859
+ function validSshKeyPath(value) {
5860
+ if (value === void 0 || value === "") return void 0;
5861
+ if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/u.test(value)) throw new Error("vps_ssh_key_invalid");
5862
+ return resolve(value);
5863
+ }
5864
+ function parseVpsDeploymentInput(value) {
5865
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("vps_deploy_input_invalid");
5866
+ const record = value;
5867
+ if (Reflect.ownKeys(record).some((key) => ![
5868
+ "sshUser",
5869
+ "sshPort",
5870
+ "sshKeyPath",
5871
+ "hostFingerprints"
5872
+ ].includes(String(key)))) throw new Error("vps_deploy_input_invalid");
5873
+ const sshKeyPath = validSshKeyPath(record.sshKeyPath);
5874
+ return Object.freeze({
5875
+ sshUser: validSshUser(record.sshUser),
5876
+ sshPort: validSshPort(record.sshPort),
5877
+ ...sshKeyPath === void 0 ? {} : { sshKeyPath },
5878
+ hostFingerprints: Object.freeze(parseVpsHostFingerprints(record.hostFingerprints))
5879
+ });
5880
+ }
5881
+ /** Validate user-confirmed SHA256 host-key fingerprints (`SHA256:…`). */
5882
+ function parseVpsHostFingerprints(value) {
5883
+ if (!Array.isArray(value) || value.length < 1 || value.length > 8) throw new Error("vps_host_key_unconfirmed");
5884
+ const fingerprints = [];
5885
+ for (const entry of value) {
5886
+ if (typeof entry !== "string" || !/^SHA256:[A-Za-z0-9+/]{40,60}={0,2}$/u.test(entry) || entry.length > 96) throw new Error("vps_host_key_unconfirmed");
5887
+ fingerprints.push(entry);
5888
+ }
5889
+ return [...new Set(fingerprints)];
5890
+ }
5891
+ const SUPPORTED_HOST_KEY_TYPES = /* @__PURE__ */ new Set([
5892
+ "ssh-rsa",
5893
+ "ecdsa-sha2-nistp256",
5894
+ "ecdsa-sha2-nistp384",
5895
+ "ecdsa-sha2-nistp521",
5896
+ "ssh-ed25519"
5897
+ ]);
5898
+ /** Format a raw host public key the way OpenSSH displays it (`SHA256:…` without padding). */
5899
+ function fingerprintHostPublicKey(keyType, base64Key) {
5900
+ if (!SUPPORTED_HOST_KEY_TYPES.has(keyType)) throw new Error("vps_host_key_unsupported_type");
5901
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(base64Key) || base64Key.length < 24 || base64Key.length > 1024) throw new Error("vps_host_key_invalid");
5902
+ const raw = Buffer.from(base64Key, "base64");
5903
+ if (raw.length < 16 || raw.length > 768) throw new Error("vps_host_key_invalid");
5904
+ return `SHA256:${createHash("sha256").update(raw).digest("base64").replace(/=+$/u, "")}`;
5905
+ }
5906
+ function parseKeyscanOutput(output) {
5907
+ const keys = [];
5908
+ for (const line of output.split(/\r?\n/u)) {
5909
+ const trimmed = line.trim();
5910
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
5911
+ const match = /^(?:\S+\s+)?(ssh-rsa|ecdsa-sha2-nistp\d+|ssh-ed25519)\s+([A-Za-z0-9+/]+={0,2})(\s|$)/u.exec(trimmed);
5912
+ if (match?.[1] === void 0 || match[2] === void 0) throw new Error("vps_host_key_invalid");
5913
+ keys.push({
5914
+ keyType: match[1],
5915
+ base64Key: match[2]
5916
+ });
5917
+ }
5918
+ return keys;
5919
+ }
5920
+ /** Base SSH options shared by long deployment sessions. Keepalives survive NAT
5921
+ * middleboxes during minute-long apt/pip phases; host identity stays pinned. */
5922
+ function sshSessionOptions(knownHostsFile) {
5923
+ return [
5924
+ "-o",
5925
+ "BatchMode=yes",
5926
+ "-o",
5927
+ "ConnectTimeout=15",
5928
+ "-o",
5929
+ "ServerAliveInterval=15",
5930
+ "-o",
5931
+ "ServerAliveCountMax=8",
5932
+ "-o",
5933
+ "StrictHostKeyChecking=yes",
5934
+ `-o UserKnownHostsFile=${knownHostsFile}`
5935
+ ];
5936
+ }
5937
+ /** Lenient scan probe: garbage fails the whole fetch, comment-only output falls back. */
5938
+ function tryParseKeyscanOutput(output) {
5939
+ try {
5940
+ return parseKeyscanOutput(output);
5941
+ } catch {
5942
+ return;
5943
+ }
5944
+ }
5945
+ async function gitBundledKeyscan() {
5946
+ if (process.platform !== "win32") return void 0;
5947
+ const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
5948
+ const candidate = join(programFiles, "Git", "usr", "bin", "ssh-keyscan.exe");
5949
+ try {
5950
+ if (!(await lstat(candidate)).isFile()) return void 0;
5951
+ } catch {
5952
+ return;
5953
+ }
5954
+ return candidate;
5955
+ }
5956
+ async function defaultRunKeyscan(input, serverAddress) {
5957
+ const keyscan = process.platform === "win32" ? "ssh-keyscan.exe" : "ssh-keyscan";
5958
+ const args = [
5959
+ "-T",
5960
+ "10",
5961
+ "-p",
5962
+ String(input.sshPort),
5963
+ "-t",
5964
+ "rsa,ecdsa,ed25519",
5965
+ serverAddress
5966
+ ];
5967
+ const first = await runProcess(keyscan, args, void 0, 3e4).catch(() => void 0);
5968
+ if (first !== void 0 && tryParseKeyscanOutput(first.stdout)?.length) return first.stdout;
5969
+ const bundled = await gitBundledKeyscan();
5970
+ if (bundled !== void 0) {
5971
+ const second = await runProcess(bundled, args, void 0, 3e4).catch(() => void 0);
5972
+ if (second !== void 0 && tryParseKeyscanOutput(second.stdout)?.length) return second.stdout;
5973
+ }
5974
+ return first?.stdout ?? "";
5975
+ }
5976
+ /**
5977
+ * Read the server's public host keys over an authenticated connection with a
5978
+ * throwaway known_hosts file. Fallback for keyscan binaries that cannot
5979
+ * negotiate with modern servers; output feeds the same confirm-and-pin pipeline.
5980
+ */
5981
+ async function defaultRunSshFetch(input, serverAddress) {
5982
+ const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
5983
+ const sshUser = validSshUser(input.sshUser);
5984
+ const sshPort = validSshPort(input.sshPort);
5985
+ const sshKeyPath = validSshKeyPath(input.sshKeyPath);
5986
+ const { stdout } = await runProcess(ssh, [
5987
+ "-o",
5988
+ "BatchMode=yes",
5989
+ "-o",
5990
+ "ConnectTimeout=15",
5991
+ "-o",
5992
+ "StrictHostKeyChecking=no",
5993
+ `-o UserKnownHostsFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
5994
+ ...sshKeyPath === void 0 ? [] : ["-i", sshKeyPath],
5995
+ "-p",
5996
+ String(sshPort),
5997
+ `${sshUser}@${serverAddress}`,
5998
+ "cat /etc/ssh/ssh_host_*_key.pub"
5999
+ ], void 0, 3e4).catch((error) => {
6000
+ throw new VpsSshError("vps_host_key_unavailable", "", error instanceof Error ? error.message : String(error));
6001
+ });
6002
+ const lines = [];
6003
+ for (const line of stdout.split(/\r?\n/u)) {
6004
+ const match = /^(ssh-rsa|ecdsa-sha2-nistp\d+|ssh-ed25519)\s+([A-Za-z0-9+/]+={0,2})(\s|$)/u.exec(line.trim());
6005
+ if (match?.[1] !== void 0 && match[2] !== void 0) lines.push(`${serverAddress} ${match[1]} ${match[2]}`);
6006
+ }
6007
+ return lines.length === 0 ? stdout : `${lines.join("\n")}\n`;
6008
+ }
6009
+ /**
6010
+ * Scan host keys with keyscan first, then fall back to an authenticated read
6011
+ * when the local keyscan binary cannot negotiate with the server. Both paths
6012
+ * feed the same confirm-and-pin pipeline, so a fallback never weakens the
6013
+ * user-confirmation gate.
6014
+ */
6015
+ async function scanHostKeys(input, serverAddress, options) {
6016
+ const scanned = await (options.runKeyscan ?? defaultRunKeyscan)({
6017
+ sshUser: input.sshUser,
6018
+ sshPort: input.sshPort
6019
+ }, serverAddress).catch(() => "");
6020
+ if (tryParseKeyscanOutput(scanned)?.length) return scanned;
6021
+ options.log?.("host-keys-keyscan-empty", { serverAddress });
6022
+ const fetched = await (options.runSshFetch ?? defaultRunSshFetch)(input, serverAddress);
6023
+ if (tryParseKeyscanOutput(fetched)?.length) {
6024
+ options.log?.("host-keys-ssh-fallback", { serverAddress });
6025
+ return fetched;
6026
+ }
6027
+ throw new Error("vps_host_key_unavailable");
6028
+ }
6029
+ /**
6030
+ * Fetch the server's current host keys and return them with OpenSSH-style
6031
+ * fingerprints for the user to confirm out of band (for example against the
6032
+ * VPS console) before any destructive or authenticated deployment step.
6033
+ */
6034
+ async function fetchVpsHostKeys(serverAddress, input, options = {}) {
6035
+ const address = validateVpsServerTarget(serverAddress);
6036
+ const keys = parseKeyscanOutput(await scanHostKeys({
6037
+ sshUser: validSshUser(input.sshUser),
6038
+ sshPort: validSshPort(input.sshPort),
6039
+ sshKeyPath: input.sshKeyPath
6040
+ }, address, options));
6041
+ if (keys.length === 0) throw new Error("vps_host_key_unavailable");
6042
+ const seen = /* @__PURE__ */ new Set();
6043
+ const hostKeys = [];
6044
+ for (const key of keys) {
6045
+ const fingerprint = fingerprintHostPublicKey(key.keyType, key.base64Key);
6046
+ if (seen.has(fingerprint)) continue;
6047
+ seen.add(fingerprint);
6048
+ hostKeys.push(Object.freeze({
6049
+ keyType: key.keyType,
6050
+ fingerprint
6051
+ }));
6052
+ }
6053
+ options.log?.("host-keys-fetched", {
6054
+ serverAddress: address,
6055
+ keyTypes: hostKeys.length
6056
+ });
6057
+ return Object.freeze(hostKeys);
6058
+ }
6059
+ /**
6060
+ * Verify that every host key the server currently presents was confirmed by the
6061
+ * user, then return a pinned known_hosts body. Fails closed on rotation,
6062
+ * replacement, or unexpected extra keys.
6063
+ */
6064
+ function buildPinnedKnownHosts(serverAddress, sshPort, keyscanOutput, confirmedFingerprints) {
6065
+ const address = validateFrpServerAddress(serverAddress);
6066
+ const port = validSshPort(sshPort);
6067
+ const confirmed = new Set(parseVpsHostFingerprints([...confirmedFingerprints]));
6068
+ const keys = parseKeyscanOutput(keyscanOutput);
6069
+ if (keys.length === 0) throw new Error("vps_host_key_unavailable");
6070
+ const host = port === 22 ? address : `[${address}]:${port}`;
6071
+ const lines = [];
6072
+ for (const key of keys) {
6073
+ const fingerprint = fingerprintHostPublicKey(key.keyType, key.base64Key);
6074
+ if (!confirmed.has(fingerprint)) throw new Error("vps_host_key_mismatch");
6075
+ lines.push(`${host} ${key.keyType} ${key.base64Key}`);
6076
+ }
6077
+ return `${lines.join("\n")}\n`;
6078
+ }
6079
+ function safeOutput(value, token) {
6080
+ return (token === "" ? value : value.replaceAll(token, "<redacted>")).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").slice(0, 8192).trim();
6081
+ }
6082
+ function parseChecks(stdout, stderr, token) {
6083
+ const checks = [];
6084
+ for (const line of stdout.split(/\r?\n/u)) {
6085
+ const match = /^DSH_MOBILE_CHECK\s+([a-z0-9_-]+)\s+(ok|warning|error)\s+(.+)$/iu.exec(line);
6086
+ if (match !== null) checks.push(Object.freeze({
6087
+ id: match[1],
6088
+ status: match[2].toLowerCase(),
6089
+ detail: safeOutput(match[3], token)
6090
+ }));
6091
+ }
6092
+ if (checks.length === 0 && stderr.trim() !== "") checks.push(Object.freeze({
6093
+ id: "remote-command",
6094
+ status: "error",
6095
+ detail: safeOutput(stderr, token) || "VPS 返回了未分类错误。"
6096
+ }));
6097
+ return Object.freeze(checks);
6098
+ }
6099
+ /**
6100
+ * One-line failure detail for transport-level failures: prefer the failed
6101
+ * remote check, otherwise use the last stderr line (a `set -eu` abort has no
6102
+ * check line) instead of dumping the whole transcript into the UI.
6103
+ */
6104
+ function failureDetail(stdout, stderr, token) {
6105
+ const failed = parseChecks(`${stdout}\n${stderr}`, "", token).find((check) => check.status === "error");
6106
+ if (failed !== void 0) return failed.detail;
6107
+ for (const stream of [stderr, stdout]) {
6108
+ const lines = stream.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line !== "");
6109
+ const last = lines[lines.length - 1];
6110
+ if (last !== void 0) return safeOutput(last, token);
6111
+ }
6112
+ return "";
6113
+ }
6114
+ function deploymentScript(settings) {
6115
+ const amd64 = LINUX_ARTIFACTS.x64;
6116
+ const arm64 = LINUX_ARTIFACTS.arm64;
6117
+ const config = [
6118
+ "bindAddr = \"0.0.0.0\"",
6119
+ `bindPort = ${String(settings.serverPort)}`,
6120
+ "proxyBindAddr = \"127.0.0.1\"",
6121
+ "vhostHTTPPort = 7080",
6122
+ "auth.method = \"token\"",
6123
+ `auth.token = ${JSON.stringify(settings.token)}`,
6124
+ ""
6125
+ ].join("\n");
6126
+ const publicHost = new URL(settings.publicOrigin).hostname;
6127
+ const publicIp = isGloballyRoutableIpv4(publicHost);
6128
+ const caddySite = createCaddySite(publicHost);
6129
+ const caddySnippet = `${FRP_CADDY_SNIPPET_MARKER}\n${caddySite.trimEnd()}\n`;
6130
+ const ipCertificateSetup = publicIp ? `
6131
+ export DEBIAN_FRONTEND=noninteractive
6132
+ apt-get install -y python3-venv
6133
+ if [ ! -x /opt/dsh-mobile/certbot-venv/bin/certbot ]; then
6134
+ python3 -m venv /opt/dsh-mobile/certbot-venv
6135
+ /opt/dsh-mobile/certbot-venv/bin/pip install --disable-pip-version-check 'certbot==5.8.0'
6136
+ fi
6137
+ systemctl stop caddy.service || true
6138
+ if ! /opt/dsh-mobile/certbot-venv/bin/certbot certonly --standalone --preferred-profile shortlived --ip-address ${publicHost} --agree-tos --register-unsafely-without-email --non-interactive --keep-until-expiring; then
6139
+ systemctl start caddy.service || true
6140
+ fail "公网 IP HTTPS 证书申请失败;请确认 80/tcp 可从公网访问。"
6141
+ fi
6142
+ install -d -m 0750 -o caddy -g caddy /var/lib/caddy/dsh-mobile-certs
6143
+ install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/fullchain.pem /var/lib/caddy/dsh-mobile-certs/fullchain.pem
6144
+ install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/privkey.pem /var/lib/caddy/dsh-mobile-certs/privkey.pem
6145
+ cat > /usr/local/sbin/dsh-mobile-cert-renew <<'DSH_MOBILE_CERT_RENEW'
6146
+ #!/bin/sh
6147
+ set -eu
6148
+ systemctl stop caddy.service
6149
+ trap 'systemctl start caddy.service' EXIT
6150
+ /opt/dsh-mobile/certbot-venv/bin/certbot renew --cert-name ${publicHost} --preferred-profile shortlived --non-interactive
6151
+ install -d -m 0750 -o caddy -g caddy /var/lib/caddy/dsh-mobile-certs
6152
+ install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/fullchain.pem /var/lib/caddy/dsh-mobile-certs/fullchain.pem
6153
+ install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/privkey.pem /var/lib/caddy/dsh-mobile-certs/privkey.pem
6154
+ DSH_MOBILE_CERT_RENEW
6155
+ chmod 0755 /usr/local/sbin/dsh-mobile-cert-renew
6156
+ cat > /etc/systemd/system/dsh-mobile-cert-renew.service <<'DSH_MOBILE_CERT_SERVICE'
6157
+ [Unit]
6158
+ Description=Renew DSH Mobile public IP TLS certificate
6159
+ After=network-online.target
6160
+ Wants=network-online.target
6161
+
6162
+ [Service]
6163
+ Type=oneshot
6164
+ ExecStart=/usr/local/sbin/dsh-mobile-cert-renew
6165
+ DSH_MOBILE_CERT_SERVICE
6166
+ cat > /etc/systemd/system/dsh-mobile-cert-renew.timer <<'DSH_MOBILE_CERT_TIMER'
6167
+ [Unit]
6168
+ Description=Daily DSH Mobile public IP TLS certificate renewal check
6169
+
6170
+ [Timer]
6171
+ OnCalendar=daily
6172
+ RandomizedDelaySec=2h
6173
+ Persistent=true
6174
+ Unit=dsh-mobile-cert-renew.service
6175
+
6176
+ [Install]
6177
+ WantedBy=timers.target
6178
+ DSH_MOBILE_CERT_TIMER
6179
+ check certificate ok "Let's Encrypt 公网 IP 证书已安装并启用每日自动续期。"
6180
+ ` : "";
6181
+ return `#!/bin/sh
6182
+ set -eu
6183
+ umask 077
6184
+
6185
+ fail() { echo "DSH_MOBILE_CHECK remote-command error $1" >&2; exit 1; }
6186
+ check() { echo "DSH_MOBILE_CHECK $1 $2 $3"; }
6187
+
6188
+ # Serialize concurrent deploys: two writers racing sed -i on the Caddyfile
6189
+ # can duplicate the import line and break validation. Uninstall takes the
6190
+ # same lock, so deploy and cleanup also exclude each other.
6191
+ if command -v flock >/dev/null 2>&1; then
6192
+ exec 9>/tmp/dsh-mobile-deploy.lock
6193
+ flock -n 9 || fail "已有部署或清理正在进行,请稍后再试。"
6194
+ fi
6195
+
6196
+ [ "$(id -u)" = "0" ] || fail "请使用 root SSH 账号。"
6197
+ command -v systemctl >/dev/null 2>&1 || fail "VPS 不支持 systemd。"
6198
+ command -v tar >/dev/null 2>&1 || fail "VPS 缺少 tar。"
6199
+ command -v curl >/dev/null 2>&1 || fail "VPS 缺少 curl。"
6200
+ command -v sha256sum >/dev/null 2>&1 || fail "VPS 缺少 sha256sum。"
6201
+ command -v useradd >/dev/null 2>&1 || fail "VPS 缺少 useradd。"
6202
+
6203
+ if [ -r /etc/os-release ]; then . /etc/os-release; else fail "无法识别 VPS 系统。"; fi
6204
+ case "\${ID:-}" in
6205
+ debian|ubuntu) ;;
6206
+ *) fail "首版 VPS 部署只支持 Debian/Ubuntu。" ;;
6207
+ esac
6208
+ check os ok "\${PRETTY_NAME:-Debian/Ubuntu}"
6209
+
6210
+ if command -v ss >/dev/null 2>&1 && ss -ltnH | awk '{print $4}' | grep -Eq '(^|:)${String(settings.serverPort)}$'; then
6211
+ systemctl is-active --quiet dsh-mobile-frps.service || fail "端口 ${String(settings.serverPort)} 已被占用。"
6212
+ fi
6213
+
6214
+ if ! command -v caddy >/dev/null 2>&1; then
6215
+ export DEBIAN_FRONTEND=noninteractive
6216
+ # A previous interrupted run may have left these files unreadable because
6217
+ # the deployment uses umask 077. APT reads repositories as the _apt user.
6218
+ chmod 0644 /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null || true
6219
+ chmod 0644 /etc/apt/sources.list.d/caddy-stable.list 2>/dev/null || true
6220
+ apt-get update
6221
+ apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
6222
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
6223
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list
6224
+ chmod 0644 /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
6225
+ apt-get update
6226
+ apt-get install -y caddy
6227
+ fi
6228
+ check caddy ok "Caddy 已安装。"
6229
+
6230
+ # The site lives in our own snippet file; the main Caddyfile only gains one
6231
+ # import line, so existing user content is never rewritten or merged.
6232
+ caddy_import='${FRP_CADDY_IMPORT_LINE}'
6233
+ install -d -m 0755 /etc/caddy
6234
+ caddyfile_ready=false
6235
+ if [ ! -e /etc/caddy/Caddyfile ]; then
6236
+ printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
6237
+ chmod 0644 /etc/caddy/Caddyfile
6238
+ caddyfile_ready=true
6239
+ elif grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile; then
6240
+ # The snippet may carry global options (IP mode default_sni), which must
6241
+ # precede all site blocks after import inlining: rebuild the file with a
6242
+ # single import on top. Removal uses grep -v with the exact gate pattern
6243
+ # above (not sed -i, whose in-place delete proved unreliable here), and the
6244
+ # result is verified to carry exactly one import before replacing the file.
6245
+ {
6246
+ printf '%s\n' "$caddy_import"
6247
+ grep -Ev '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile || true
6248
+ } > /etc/caddy/Caddyfile.dsh-new
6249
+ [ "$(grep -Ec '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile.dsh-new)" = 1 ] \
6250
+ || fail "Caddyfile import 整理失败,未做任何修改。"
6251
+ cat /etc/caddy/Caddyfile.dsh-new > /etc/caddy/Caddyfile
6252
+ rm -f /etc/caddy/Caddyfile.dsh-new
6253
+ chmod 0644 /etc/caddy/Caddyfile
6254
+ caddyfile_ready=true
6255
+ elif [ ! -s /etc/caddy/Caddyfile ]; then
6256
+ printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
6257
+ chmod 0644 /etc/caddy/Caddyfile
6258
+ caddyfile_ready=true
6259
+ elif grep -q '^# DSH Mobile removed its site' /etc/caddy/Caddyfile; then
6260
+ # Leftover placeholder from our own uninstall: drop only that line, then
6261
+ # ensure the import exists exactly once (same grep -v + count discipline).
6262
+ grep -Ev '^# DSH Mobile removed its site.*$' /etc/caddy/Caddyfile > /etc/caddy/Caddyfile.dsh-new || true
6263
+ grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile.dsh-new \
6264
+ || printf '%s\n' "$caddy_import" >> /etc/caddy/Caddyfile.dsh-new
6265
+ [ "$(grep -Ec '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile.dsh-new)" = 1 ] \
6266
+ || fail "Caddyfile import 整理失败,未做任何修改。"
6267
+ cat /etc/caddy/Caddyfile.dsh-new > /etc/caddy/Caddyfile
6268
+ rm -f /etc/caddy/Caddyfile.dsh-new
6269
+ chmod 0644 /etc/caddy/Caddyfile
6270
+ caddyfile_ready=true
6271
+ else
6272
+ caddy_hash="$(sha256sum /etc/caddy/Caddyfile | awk '{print $1}')"
6273
+ if [ "$caddy_hash" = '66177d46fa761acb07208065db9b0274cb1b12c02ac43b9bfc9857b698b1ccfe' ]; then
6274
+ printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
6275
+ chmod 0644 /etc/caddy/Caddyfile
6276
+ caddyfile_ready=true
6277
+ elif grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
6278
+ # Legacy whole-file layout: the entire file is ours by construction.
6279
+ printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
6280
+ chmod 0644 /etc/caddy/Caddyfile
6281
+ caddyfile_ready=true
6282
+ elif grep -q '^:80[[:space:]]*{' /etc/caddy/Caddyfile \
6283
+ && grep -q 'root [*] /usr/share/caddy' /etc/caddy/Caddyfile \
6284
+ && grep -q '^[[:space:]]*file_server[[:space:]]*$' /etc/caddy/Caddyfile; then
6285
+ printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
6286
+ chmod 0644 /etc/caddy/Caddyfile
6287
+ caddyfile_ready=true
6288
+ fi
6289
+ fi
6290
+ if [ "$caddyfile_ready" != true ]; then
6291
+ fail "已有 Caddyfile,请先备份,然后加一行 ${FRP_CADDY_IMPORT_LINE},或手动合并站点。"
6292
+ fi
6293
+
6294
+ ${ipCertificateSetup}
6295
+
6296
+ arch="$(uname -m)"
6297
+ case "$arch" in
6298
+ x86_64|amd64) url=${shellQuote(amd64.url)}; expected=${shellQuote(amd64.sha256)}; directory=${shellQuote(amd64.directory)} ;;
6299
+ aarch64|arm64) url=${shellQuote(arm64.url)}; expected=${shellQuote(arm64.sha256)}; directory=${shellQuote(arm64.directory)} ;;
6300
+ *) fail "只支持 Linux x86_64 和 arm64。" ;;
6301
+ esac
6302
+
6303
+ tmp="$(mktemp -d /tmp/dsh-mobile-frp.XXXXXX)"
6304
+ cleanup() { rm -rf "$tmp"; [ -z "\${DSH_MOBILE_FRP_ARCHIVE:-}" ] || rm -f "$DSH_MOBILE_FRP_ARCHIVE"; }
6305
+ trap cleanup EXIT HUP INT TERM
6306
+ archive="$tmp/frp.tar.gz"
6307
+ if [ -n "\${DSH_MOBILE_FRP_ARCHIVE:-}" ]; then
6308
+ [ -f "$DSH_MOBILE_FRP_ARCHIVE" ] || fail "上传的 frps 安装包不存在。"
6309
+ cp "$DSH_MOBILE_FRP_ARCHIVE" "$archive"
6310
+ else
6311
+ curl --fail --location --proto '=https' --tlsv1.2 --output "$archive" "$url"
6312
+ fi
6313
+ actual="$(sha256sum "$archive" | awk '{print $1}')"
6314
+ [ "$actual" = "$expected" ] || fail "frps 下载校验失败。"
6315
+ tar -xzf "$archive" -C "$tmp" "$directory/frps"
6316
+
6317
+ install -d -m 0755 /usr/local/libexec/dsh-mobile/frp/${FRP_VERSION}
6318
+ install -m 0755 "$tmp/$directory/frps" /usr/local/libexec/dsh-mobile/frp/${FRP_VERSION}/frps
6319
+ # The account must exist before anything references its group below.
6320
+ dsh_mobile_created=false
6321
+ if ! id -u dsh-mobile >/dev/null 2>&1; then
6322
+ useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin --no-create-home dsh-mobile
6323
+ dsh_mobile_created=true
6324
+ fi
6325
+ install -d -m 0750 -o root -g dsh-mobile /etc/dsh-mobile
6326
+ if [ "$dsh_mobile_created" = true ]; then
6327
+ # Ownership record: uninstall removes the account only when this deployment created it.
6328
+ touch /etc/dsh-mobile/.owns-account
6329
+ check account ok "已创建 dsh-mobile 系统用户。"
6330
+ else
6331
+ check account ok "复用已有的 dsh-mobile 系统用户(卸载时将保留)。"
6332
+ fi
6333
+ cat > /etc/dsh-mobile/frps.toml <<'DSH_MOBILE_FRPS_CONFIG'
6334
+ ${config}DSH_MOBILE_FRPS_CONFIG
6335
+ chown root:dsh-mobile /etc/dsh-mobile/frps.toml
6336
+ chmod 0640 /etc/dsh-mobile/frps.toml
6337
+
6338
+ cat > /etc/systemd/system/dsh-mobile-frps.service <<'DSH_MOBILE_FRPS_UNIT'
6339
+ [Unit]
6340
+ Description=DSH Mobile self-hosted FRP server
6341
+ After=network-online.target
6342
+ Wants=network-online.target
6343
+
6344
+ [Service]
6345
+ Type=simple
6346
+ User=dsh-mobile
6347
+ Group=dsh-mobile
6348
+ ExecStart=/usr/local/libexec/dsh-mobile/frp/${FRP_VERSION}/frps -c /etc/dsh-mobile/frps.toml
6349
+ Restart=on-failure
6350
+ RestartSec=5s
6351
+ NoNewPrivileges=true
6352
+ PrivateTmp=true
6353
+ ProtectHome=true
6354
+ ProtectSystem=strict
6355
+
6356
+ [Install]
6357
+ WantedBy=multi-user.target
6358
+ DSH_MOBILE_FRPS_UNIT
6359
+
6360
+ cat > ${FRP_CADDY_SNIPPET_PATH} <<'DSH_MOBILE_CADDY_SNIPPET'
6361
+ ${caddySnippet}DSH_MOBILE_CADDY_SNIPPET
6362
+ chmod 0644 ${FRP_CADDY_SNIPPET_PATH}
6363
+ caddy validate --config /etc/caddy/Caddyfile
6364
+ systemctl daemon-reload
6365
+ # Restart (not just start) so a redeploy over a running previous generation
6366
+ # actually picks up the new frps token and config instead of keeping the old
6367
+ # process alive with stale credentials.
6368
+ systemctl enable dsh-mobile-frps.service
6369
+ systemctl restart dsh-mobile-frps.service
6370
+ systemctl enable --now caddy.service
6371
+ ${publicIp ? "systemctl enable --now dsh-mobile-cert-renew.timer" : ""}
6372
+ systemctl reload caddy.service || systemctl restart caddy.service
6373
+
6374
+ if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then
6375
+ ufw allow ${String(settings.serverPort)}/tcp comment 'DSH Mobile FRP control' >/dev/null
6376
+ ufw allow 80/tcp comment 'DSH Mobile HTTPS redirect' >/dev/null
6377
+ ufw allow 443/tcp comment 'DSH Mobile HTTPS' >/dev/null
6378
+ check firewall ok "UFW 已放行 FRP 控制端口和 HTTPS。"
6379
+ else
6380
+ check firewall warning "未修改系统防火墙;请确认 ${String(settings.serverPort)}/tcp、80/tcp、443/tcp 已放行。"
6381
+ fi
6382
+
6383
+ systemctl is-active --quiet dsh-mobile-frps.service || fail "frps 服务启动失败。"
6384
+ systemctl is-active --quiet caddy.service || fail "Caddy 服务启动失败。"
6385
+ check frps ok "frps ${FRP_VERSION} 已启动,7080 仅绑定回环地址。"
6386
+ check caddy ok "Caddy 已加载 ${publicHost}。"
6387
+ echo DSH_MOBILE_DEPLOYMENT_OK
6388
+ `;
6389
+ }
6390
+ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
6391
+ return new Promise((resolveRun, rejectRun) => {
6392
+ const child = spawn(command, args, {
6393
+ windowsHide: true,
6394
+ stdio: [
6395
+ "pipe",
6396
+ "pipe",
6397
+ "pipe"
6398
+ ]
6399
+ });
6400
+ let stdout = "";
6401
+ let stderr = "";
6402
+ const append = (current, chunk) => `${current}${chunk.toString("utf8")}`.slice(-98304);
6403
+ const timer = setTimeout(() => {
6404
+ child.kill();
6405
+ rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr));
6406
+ }, timeoutMs);
6407
+ timer.unref();
6408
+ child.stdout.on("data", (chunk) => {
6409
+ stdout = append(stdout, Buffer.from(chunk));
6410
+ });
6411
+ child.stderr.on("data", (chunk) => {
6412
+ stderr = append(stderr, Buffer.from(chunk));
6413
+ });
6414
+ child.once("error", (error) => {
6415
+ clearTimeout(timer);
6416
+ rejectRun(new VpsSshError("vps_ssh_unavailable", stdout, stderr, { cause: error }));
6417
+ });
6418
+ child.once("close", (code) => {
6419
+ clearTimeout(timer);
6420
+ if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr));
6421
+ else resolveRun({
6422
+ stdout,
6423
+ stderr
6424
+ });
6425
+ });
6426
+ child.stdin.end(stdin, "utf8");
6427
+ });
6428
+ }
6429
+ async function downloadArtifact(artifact, file) {
6430
+ await runProcess(process.platform === "win32" ? "curl.exe" : "curl", [
6431
+ ...process.platform === "win32" ? ["--ipv4"] : [],
6432
+ "--fail",
6433
+ "--location",
6434
+ "--silent",
6435
+ "--show-error",
6436
+ "--connect-timeout",
6437
+ "15",
6438
+ "--max-time",
6439
+ "180",
6440
+ "--proto",
6441
+ "=https",
6442
+ "--tlsv1.2",
6443
+ "--output",
6444
+ file,
6445
+ artifact.url
6446
+ ], void 0, 2e5);
6447
+ const bytes = await readFile(file);
6448
+ if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) throw new Error("vps_download_hash_mismatch");
6449
+ return bytes.byteLength;
6450
+ }
6451
+ async function defaultRunSsh(input, serverAddress, script, knownHostsFile, log) {
6452
+ const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
6453
+ const scp = process.platform === "win32" ? "scp.exe" : "scp";
6454
+ const common = sshSessionOptions(knownHostsFile);
6455
+ if (input.sshKeyPath !== void 0) common.push("-i", input.sshKeyPath);
6456
+ const target = `${input.sshUser}@${serverAddress}`;
6457
+ const architecture = (await runProcess(ssh, [
6458
+ ...common,
6459
+ "-p",
6460
+ String(input.sshPort),
6461
+ target,
6462
+ "uname -m"
6463
+ ])).stdout.trim();
6464
+ const artifact = architecture === "x86_64" || architecture === "amd64" ? LINUX_ARTIFACTS.x64 : architecture === "aarch64" || architecture === "arm64" ? LINUX_ARTIFACTS.arm64 : void 0;
6465
+ if (artifact === void 0) throw new Error("vps_arch_unsupported");
6466
+ log?.("architecture", { architecture });
6467
+ const localDirectory = await mkdtemp(join(tmpdir(), "dsh-mobile-frp-"));
6468
+ const localArchive = join(localDirectory, "frp.tar.gz");
6469
+ const remoteArchive = `/tmp/dsh-mobile-frp-${randomBytes(12).toString("hex")}.tar.gz`;
6470
+ try {
6471
+ log?.("download-start", {
6472
+ source: "local",
6473
+ architecture
6474
+ });
6475
+ const bytes = await downloadArtifact(artifact, localArchive);
6476
+ log?.("download-complete", {
6477
+ source: "local",
6478
+ bytes
6479
+ });
6480
+ const scpResult = await runProcess(scp, [
6481
+ ...common,
6482
+ "-P",
6483
+ String(input.sshPort),
6484
+ localArchive,
6485
+ `${target}:${remoteArchive}`
6486
+ ]);
6487
+ log?.("upload-complete", {
6488
+ bytes,
6489
+ stderrBytes: Buffer.byteLength(scpResult.stderr)
6490
+ });
6491
+ const remoteCommand = input.sshUser === "root" ? `env DSH_MOBILE_FRP_ARCHIVE=${shellQuote(remoteArchive)} sh -s` : `sudo -n env DSH_MOBILE_FRP_ARCHIVE=${shellQuote(remoteArchive)} sh -s`;
6492
+ return await runProcess(ssh, [
6493
+ ...common,
6494
+ "-p",
6495
+ String(input.sshPort),
6496
+ target,
6497
+ remoteCommand
6498
+ ], script);
6499
+ } finally {
6500
+ await rm(localDirectory, {
6501
+ recursive: true,
6502
+ force: true
6503
+ });
6504
+ }
6505
+ }
6506
+ async function deployVps(settings, input, options = {}) {
6507
+ const serverPort = validateFrpServerPort(settings.serverPort);
6508
+ const token = validateFrpToken(settings.token);
6509
+ const publicOrigin = validateFrpPublicOrigin(settings.publicOrigin);
6510
+ const serverAddress = validateVpsServerTarget(settings.serverAddress);
6511
+ const parsedInput = parseVpsDeploymentInput(input);
6512
+ if (parsedInput.sshKeyPath !== void 0) {
6513
+ const entry = await lstat(parsedInput.sshKeyPath).catch(() => void 0);
6514
+ if (entry === void 0 || !entry.isFile() || entry.isSymbolicLink()) throw new Error("vps_ssh_key_invalid");
6515
+ }
6516
+ const keyscanOutput = await scanHostKeys({
6517
+ sshUser: parsedInput.sshUser,
6518
+ sshPort: parsedInput.sshPort,
6519
+ sshKeyPath: parsedInput.sshKeyPath
6520
+ }, serverAddress, options);
6521
+ const knownHostsBody = buildPinnedKnownHosts(serverAddress, parsedInput.sshPort, keyscanOutput, parsedInput.hostFingerprints);
6522
+ options.log?.("host-keys-verified", { serverAddress });
6523
+ const runSsh = options.runSsh ?? (async (sshInput, host, scriptBody) => {
6524
+ const workDirectory = await mkdtemp(join(tmpdir(), "dsh-mobile-known-hosts-"));
6525
+ try {
6526
+ const knownHostsFile = join(workDirectory, "known_hosts");
6527
+ await writeFile(knownHostsFile, knownHostsBody, {
6528
+ encoding: "utf8",
6529
+ mode: 384
6530
+ });
6531
+ return await defaultRunSsh(sshInput, host, scriptBody, knownHostsFile, options.log);
6532
+ } finally {
6533
+ await rm(workDirectory, {
6534
+ recursive: true,
6535
+ force: true
6536
+ });
6537
+ }
6538
+ });
6539
+ options.log?.("validated", {
6540
+ serverAddress,
6541
+ serverPort,
6542
+ publicOrigin,
6543
+ sshUser: parsedInput.sshUser,
6544
+ sshPort: parsedInput.sshPort,
6545
+ keyProvided: parsedInput.sshKeyPath !== void 0
6546
+ });
6547
+ let result;
6548
+ try {
6549
+ options.log?.("ssh-start", {
6550
+ serverAddress,
6551
+ sshPort: parsedInput.sshPort
6552
+ });
6553
+ result = await runSsh(parsedInput, serverAddress, deploymentScript({
6554
+ ...settings,
6555
+ serverAddress,
6556
+ serverPort,
6557
+ token,
6558
+ publicOrigin
6559
+ }));
6560
+ options.log?.("ssh-complete", {
6561
+ stdoutBytes: Buffer.byteLength(result.stdout),
6562
+ stderrBytes: Buffer.byteLength(result.stderr)
6563
+ });
6564
+ } catch (error) {
6565
+ if (error instanceof VpsSshError) {
6566
+ const detail = failureDetail(error.stdout, error.stderr, token);
6567
+ options.log?.("ssh-failed", {
6568
+ code: error.message,
6569
+ detail: detail || "no remote output"
6570
+ });
6571
+ throw new Error(detail === "" ? error.message : `${error.message}:${detail}`, { cause: error });
6572
+ }
6573
+ options.log?.("ssh-failed", { code: error instanceof Error ? error.message : "unknown" });
6574
+ throw error;
6575
+ }
6576
+ const checks = parseChecks(result.stdout, result.stderr, token);
6577
+ for (const check of checks) options.log?.("remote-check", {
6578
+ id: check.id,
6579
+ status: check.status,
6580
+ detail: check.detail
6581
+ });
6582
+ if (!result.stdout.includes("DSH_MOBILE_DEPLOYMENT_OK")) {
6583
+ if (checks.length === 0) throw new Error("vps_deploy_failed");
6584
+ throw new Error(`vps_deploy_failed:${checks.map((check) => check.detail).join(" ")}`);
6585
+ }
6586
+ return Object.freeze({
6587
+ version: 1,
6588
+ deployed: true,
6589
+ serverAddress,
6590
+ publicOrigin,
6591
+ checks
6592
+ });
6593
+ }
6594
+ function validCertName(value) {
6595
+ if (value === void 0 || value === "") return void 0;
6596
+ if (typeof value !== "string" || value.length > 253 || !/^[a-z0-9.-]+$/u.test(value)) throw new Error("vps_cert_name_invalid");
6597
+ return value.toLowerCase();
6598
+ }
6599
+ /**
6600
+ * Build a reviewable uninstall script that removes only DSH Mobile-owned
6601
+ * server artifacts: its systemd units, config, binaries, venv, renew helper,
6602
+ * managed Caddy site, owned UFW rules, and optionally its IP certificate.
6603
+ * Existing non-DSH-Mobile Caddy content and firewall rules are never touched.
6604
+ */
6605
+ function createVpsUninstallScript(input) {
6606
+ const serverPort = validateFrpServerPort(input.serverPort);
6607
+ const certName = validCertName(input.certName);
6608
+ return `#!/bin/sh
6609
+ # DSH Mobile VPS uninstall. Review before running: only files, services, and
6610
+ # firewall rules created by the DSH Mobile deployment are removed.
6611
+ set -eu
6612
+ umask 077
6613
+
6614
+ fail() { echo "DSH_MOBILE_CHECK remote-command error $1" >&2; exit 1; }
6615
+ check() { echo "DSH_MOBILE_CHECK $1 $2 $3"; }
6616
+
6617
+ # Same lock as the deploy script: cleanup and deployment exclude each other
6618
+ # so their Caddyfile surgeries never interleave.
6619
+ if command -v flock >/dev/null 2>&1; then
6620
+ exec 9>/tmp/dsh-mobile-deploy.lock
6621
+ flock -n 9 || fail "已有部署或清理正在进行,请稍后再试。"
6622
+ fi
6623
+
6624
+ [ "$(id -u)" = "0" ] || fail "请使用 root SSH 账号。"
6625
+ command -v systemctl >/dev/null 2>&1 || fail "VPS 不支持 systemd。"
6626
+
6627
+ # Ownership is decided before deleting anything: only an account created by a
6628
+ # DSH Mobile deployment (marker written at useradd time) may be removed below.
6629
+ owns_account=false
6630
+ if [ -f /etc/dsh-mobile/.owns-account ]; then owns_account=true; fi
6631
+
6632
+ systemctl disable --now dsh-mobile-cert-renew.timer >/dev/null 2>&1 || true
6633
+ systemctl disable --now dsh-mobile-frps.service >/dev/null 2>&1 || true
6634
+ rm -f /etc/systemd/system/dsh-mobile-frps.service
6635
+ rm -f /etc/systemd/system/dsh-mobile-cert-renew.service
6636
+ rm -f /etc/systemd/system/dsh-mobile-cert-renew.timer
6637
+ systemctl daemon-reload
6638
+ check services ok "已停止并删除 dsh-mobile-frps 服务与证书续期定时器。"
6639
+ ${certName === void 0 ? "" : `
6640
+ if [ -x /opt/dsh-mobile/certbot-venv/bin/certbot ]; then
6641
+ /opt/dsh-mobile/certbot-venv/bin/certbot delete --cert-name ${shellQuote(certName)} --non-interactive || true
6642
+ fi
6643
+ `}
6644
+ rm -rf /etc/dsh-mobile
6645
+ rm -rf /usr/local/libexec/dsh-mobile
6646
+ rm -rf /opt/dsh-mobile/certbot-venv
6647
+ rm -f /usr/local/sbin/dsh-mobile-cert-renew
6648
+ rm -rf /var/lib/caddy/dsh-mobile-certs
6649
+ rm -f ${FRP_CADDY_SNIPPET_PATH}
6650
+ check files ok "已删除 DSH Mobile 配置、二进制与证书文件。"
6651
+
6652
+ if grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile 2>/dev/null; then
6653
+ # Rebuild without our import line (grep -v with the gate pattern, verified
6654
+ # to remove every copy), keeping all user content byte-identical otherwise.
6655
+ grep -Ev '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile > /etc/caddy/Caddyfile.dsh-new || true
6656
+ if grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:space:]]|$)' /etc/caddy/Caddyfile.dsh-new; then
6657
+ rm -f /etc/caddy/Caddyfile.dsh-new
6658
+ fail "Caddyfile import 移除失败,未做任何修改。"
6659
+ fi
6660
+ cat /etc/caddy/Caddyfile.dsh-new > /etc/caddy/Caddyfile
6661
+ rm -f /etc/caddy/Caddyfile.dsh-new
6662
+ if [ ! -s /etc/caddy/Caddyfile ]; then
6663
+ printf '# DSH Mobile removed its site; the remaining Caddyfile was empty.\n' > /etc/caddy/Caddyfile
6664
+ chmod 0644 /etc/caddy/Caddyfile
6665
+ fi
6666
+ if command -v caddy >/dev/null 2>&1; then
6667
+ caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.service || systemctl restart caddy.service || true
6668
+ fi
6669
+ check caddy ok "已移除 DSH Mobile 站点引入;其余 Caddy 配置保持原样。"
6670
+ elif [ -f /etc/caddy/Caddyfile ] && grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
6671
+ # Legacy whole-file layout (pre-snippet releases): the entire file is ours.
6672
+ printf '# DSH Mobile removed its site. Restore your own Caddyfile or reinstall the Caddy defaults.\\n' > /etc/caddy/Caddyfile
6673
+ chmod 0644 /etc/caddy/Caddyfile
6674
+ if command -v caddy >/dev/null 2>&1; then
6675
+ caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.service || systemctl restart caddy.service || true
6676
+ fi
6677
+ check caddy ok "已清空旧版 DSH Mobile 管理的 Caddy 站点;请按需恢复自己的配置。"
6678
+ else
6679
+ check caddy ok "Caddyfile 非 DSH Mobile 管理,保持原样。"
6680
+ fi
6681
+
6682
+ if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
6683
+ for rule in $(ufw status numbered 2>/dev/null | grep 'DSH Mobile' | sed -E 's/^\\[ *([0-9]+)\\].*/\\1/' | sort -rn); do
6684
+ yes | ufw delete "$rule" >/dev/null 2>&1 || true
6685
+ done
6686
+ check firewall ok "已删除带 DSH Mobile 标记的 UFW 规则(FRP 控制端口 ${String(serverPort)}、80、443)。"
6687
+ else
6688
+ check firewall ok "UFW 未启用或无需调整。"
6689
+ fi
6690
+
6691
+ if [ "$owns_account" = true ]; then
6692
+ if id dsh-mobile >/dev/null 2>&1; then
6693
+ if pgrep -u dsh-mobile >/dev/null 2>&1; then
6694
+ fail "dsh-mobile 用户仍有运行中的进程,已保留该用户;请先停止相关进程后重试。"
6695
+ fi
6696
+ userdel dsh-mobile || fail "删除 dsh-mobile 系统用户失败。"
6697
+ check account ok "已删除本次部署创建的 dsh-mobile 系统用户。"
6698
+ else
6699
+ check account ok "dsh-mobile 系统用户已不存在,无需删除。"
6700
+ fi
6701
+ else
6702
+ check account ok "dsh-mobile 系统用户非本次部署创建,已保留。"
6703
+ fi
6704
+
6705
+ echo DSH_MOBILE_UNINSTALL_OK
6706
+ `;
6707
+ }
6708
+ async function runRemoteScript(input, serverAddress, script, environment, knownHostsFile, log) {
6709
+ const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
6710
+ const parsedInput = parseVpsDeploymentInput(input);
6711
+ const common = sshSessionOptions(knownHostsFile);
6712
+ if (parsedInput.sshKeyPath !== void 0) common.push("-i", parsedInput.sshKeyPath);
6713
+ const target = `${parsedInput.sshUser}@${serverAddress}`;
6714
+ const remoteCommand = parsedInput.sshUser === "root" ? "sh -s" : "sudo -n sh -s";
6715
+ const envPrefix = Object.entries(environment).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
6716
+ log?.("uninstall-ssh-start", { serverAddress });
6717
+ return await runProcess(ssh, [
6718
+ ...common,
6719
+ "-p",
6720
+ String(parsedInput.sshPort),
6721
+ target,
6722
+ `${envPrefix} ${remoteCommand}`.trim()
6723
+ ], script);
6724
+ }
6725
+ /** Remove DSH Mobile-owned server artifacts over a pinned SSH connection. */
6726
+ async function uninstallVps(serverAddress, uninstall, input, options = {}) {
6727
+ const address = validateVpsServerTarget(serverAddress);
6728
+ const parsedInput = parseVpsDeploymentInput(input);
6729
+ const keyscanOutput = await scanHostKeys({
6730
+ sshUser: parsedInput.sshUser,
6731
+ sshPort: parsedInput.sshPort,
6732
+ sshKeyPath: parsedInput.sshKeyPath
6733
+ }, address, options);
6734
+ const knownHostsBody = buildPinnedKnownHosts(address, parsedInput.sshPort, keyscanOutput, parsedInput.hostFingerprints);
6735
+ options.log?.("host-keys-verified", { serverAddress: address });
6736
+ const script = createVpsUninstallScript(uninstall);
6737
+ options.log?.("uninstall-start", { serverAddress: address });
6738
+ const runRemote = options.runRemoteScript ?? (async (sshInput, host, scriptBody) => {
6739
+ const workDirectory = await mkdtemp(join(tmpdir(), "dsh-mobile-known-hosts-"));
6740
+ try {
6741
+ const knownHostsFile = join(workDirectory, "known_hosts");
6742
+ await writeFile(knownHostsFile, knownHostsBody, {
6743
+ encoding: "utf8",
6744
+ mode: 384
6745
+ });
6746
+ return await runRemoteScript(sshInput, host, scriptBody, {}, knownHostsFile, options.log);
6747
+ } finally {
6748
+ await rm(workDirectory, {
6749
+ recursive: true,
6750
+ force: true
6751
+ });
6752
+ }
6753
+ });
6754
+ let result;
6755
+ try {
6756
+ result = await runRemote(parsedInput, address, script);
6757
+ } catch (error) {
6758
+ if (error instanceof VpsSshError) {
6759
+ const detail = failureDetail(error.stdout, error.stderr, "");
6760
+ const code = error.message === "vps_deploy_failed" ? "vps_uninstall_failed" : error.message;
6761
+ options.log?.("uninstall-failed", {
6762
+ code,
6763
+ detail: detail || "no remote output"
6764
+ });
6765
+ throw new Error(detail === "" ? code : `${code}:${detail}`, { cause: error });
6766
+ }
6767
+ options.log?.("uninstall-failed", { code: error instanceof Error ? error.message : "unknown" });
6768
+ throw error;
6769
+ }
6770
+ const checks = parseChecks(result.stdout, result.stderr, "");
6771
+ for (const check of checks) options.log?.("remote-check", {
6772
+ id: check.id,
6773
+ status: check.status,
6774
+ detail: check.detail
6775
+ });
6776
+ if (!result.stdout.includes("DSH_MOBILE_UNINSTALL_OK")) {
6777
+ if (checks.length === 0) throw new Error("vps_uninstall_failed");
6778
+ throw new Error(`vps_uninstall_failed:${checks.map((check) => check.detail).join(" ")}`);
6779
+ }
6780
+ return Object.freeze({
6781
+ version: 1,
6782
+ removed: true,
6783
+ serverAddress: address,
6784
+ checks
6785
+ });
6786
+ }
5622
6787
  //#endregion
5623
6788
  //#region src/funnel.ts
5624
6789
  const MAX_PROTOCOL_LINE_BYTES = 16384;
@@ -7008,6 +8173,52 @@ var PluginReleaseManager = class {
7008
8173
  }
7009
8174
  };
7010
8175
  //#endregion
8176
+ //#region src/file-logger.ts
8177
+ const MAX_LOG_BYTES = 5242880;
8178
+ /** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
8179
+ async function installMobileFileLogger(ctx, stateDirectory) {
8180
+ const directory = join(stateDirectory, "logs");
8181
+ const file = join(directory, "dsh-mobile.log");
8182
+ const previous = `${file}.1`;
8183
+ await mkdir(directory, {
8184
+ recursive: true,
8185
+ mode: 448
8186
+ });
8187
+ const entry = await lstat(file).catch(() => void 0);
8188
+ if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
8189
+ await rm(previous, { force: true });
8190
+ await rename(file, previous);
8191
+ }
8192
+ const stream = createWriteStream(file, {
8193
+ flags: "a",
8194
+ encoding: "utf8",
8195
+ mode: 384
8196
+ });
8197
+ const exporter = {
8198
+ colors: false,
8199
+ maxLength: 16384,
8200
+ levels: {
8201
+ default: -1,
8202
+ "dsh-mobile": 3
8203
+ },
8204
+ export(message) {
8205
+ if (message.name !== "dsh-mobile") return;
8206
+ const record = {
8207
+ timestamp: new Date(message.ts).toISOString(),
8208
+ level: message.type,
8209
+ logger: message.name,
8210
+ message: Logger.format(exporter, message)
8211
+ };
8212
+ stream.write(`${JSON.stringify(record)}\n`);
8213
+ }
8214
+ };
8215
+ ctx.logger.exporter(exporter);
8216
+ ctx.effect(() => () => {
8217
+ stream.end();
8218
+ }, "dsh-mobile file logger");
8219
+ return file;
8220
+ }
8221
+ //#endregion
7011
8222
  //#region src/managed-setup.ts
7012
8223
  const VIRTUAL_INTERFACE_MARKERS = [
7013
8224
  "bridge",
@@ -7248,6 +8459,7 @@ function installedDshVersion() {
7248
8459
  function mapAdminError(error) {
7249
8460
  if (error instanceof HttpError) return error;
7250
8461
  const code = error.code;
8462
+ if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
7251
8463
  if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
7252
8464
  if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
7253
8465
  if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
@@ -7261,6 +8473,7 @@ function mapAdminError(error) {
7261
8473
  "frp_settings_invalid"
7262
8474
  ].includes(error.message)) return new HttpError(400, error.message);
7263
8475
  if (error instanceof Error && error.message.startsWith("frp_")) return new HttpError(409, error.message);
8476
+ if (error instanceof Error && error.message.startsWith("vps_")) return new HttpError(409, error.message);
7264
8477
  if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
7265
8478
  if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
7266
8479
  return new HttpError(500, "internal_error");
@@ -7277,6 +8490,15 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
7277
8490
  "pairingCaFile",
7278
8491
  "tls"
7279
8492
  ]);
8493
+ /** True when path names a regular file (not a directory or symlink). */
8494
+ async function existsRegularFile(path) {
8495
+ try {
8496
+ const info = await lstat(path);
8497
+ return info.isFile() && !info.isSymbolicLink();
8498
+ } catch {
8499
+ return false;
8500
+ }
8501
+ }
7280
8502
  function withoutSetupKeys(config) {
7281
8503
  const merged = { ...config };
7282
8504
  for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
@@ -7321,10 +8543,10 @@ async function loadSetup(config) {
7321
8543
  }
7322
8544
  };
7323
8545
  }
7324
- function loopbackTemplate(loaded) {
8546
+ function loopbackTemplate(loaded, webServerPort) {
7325
8547
  return parseGatewayConfig({
7326
8548
  ...withoutSetupKeys(loaded.config),
7327
- ...loaded.kind === "managed" ? { upstreamOrigin: loaded.setup.upstreamOrigin } : loaded.config.upstreamOrigin === void 0 ? {} : { upstreamOrigin: loaded.config.upstreamOrigin },
8549
+ ...loaded.kind === "managed" ? { upstreamOrigin: loaded.setup.upstreamOrigin } : { upstreamOrigin: loaded.config.upstreamOrigin ?? `http://127.0.0.1:${String(webServerPort)}` },
7328
8550
  listenHost: "127.0.0.1",
7329
8551
  listenPort: 0,
7330
8552
  publicAuthorities: ["127.0.0.1"],
@@ -7391,10 +8613,13 @@ async function apply(ctx, config) {
7391
8613
  const dshVersion = installedDshVersion();
7392
8614
  const loaded = await loadSetup(config);
7393
8615
  const mobileAccess = createMobileAccessService(ctx);
7394
- const template = loopbackTemplate(loaded);
8616
+ const template = loopbackTemplate(loaded, ctx.webServer.port);
7395
8617
  const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, template.upstreamOrigin);
7396
8618
  const instanceId = await stableInstanceId(loaded, template);
7397
8619
  const stateDirectory = dirname(template.stateFile);
8620
+ const logFile = await installMobileFileLogger(ctx, stateDirectory);
8621
+ const logger = ctx.logger("dsh-mobile");
8622
+ logger.info("logging initialized file=%s", logFile);
7398
8623
  const remoteDirectory = join(stateDirectory, "remote");
7399
8624
  const configuredDshHome = process.env.DSH_HOME?.trim();
7400
8625
  const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
@@ -7621,7 +8846,14 @@ async function apply(ctx, config) {
7621
8846
  }
7622
8847
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
7623
8848
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
7624
- await remoteProviders.mutate(async () => frpComponent.install());
8849
+ logger.info("frpc component install started");
8850
+ try {
8851
+ await remoteProviders.mutate(async () => frpComponent.install());
8852
+ logger.info("frpc component install completed");
8853
+ } catch (error) {
8854
+ logger.error("frpc component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
8855
+ throw error;
8856
+ }
7625
8857
  sendJson(response, 200, remotePayload(), false);
7626
8858
  return;
7627
8859
  }
@@ -7634,6 +8866,86 @@ async function apply(ctx, config) {
7634
8866
  sendJson(response, 200, remotePayload(), false);
7635
8867
  return;
7636
8868
  }
8869
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/vps/host-keys`) {
8870
+ const body = await readJsonObject(request, 8192);
8871
+ const serverAddress = typeof body.serverAddress === "string" ? body.serverAddress : "";
8872
+ logger.info("vps host keys requested host=%s sshUser=%s sshPort=%d", serverAddress, String(body.sshUser), Number(body.sshPort));
8873
+ const hostKeys = await fetchVpsHostKeys(serverAddress, {
8874
+ sshUser: body.sshUser,
8875
+ sshPort: body.sshPort,
8876
+ ...body.sshKeyPath === void 0 || body.sshKeyPath === "" ? {} : { sshKeyPath: body.sshKeyPath }
8877
+ }, { log(event, fields) {
8878
+ logger.info("vps host keys event=%s fields=%o", event, fields);
8879
+ } });
8880
+ for (const key of hostKeys) logger.info("vps host key host=%s type=%s fingerprint=%s", serverAddress, key.keyType, key.fingerprint);
8881
+ sendJson(response, 200, {
8882
+ ...remotePayload(),
8883
+ vpsHostKeys: hostKeys
8884
+ }, false);
8885
+ return;
8886
+ }
8887
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/vps/deploy`) {
8888
+ const body = await readJsonObject(request, 8192);
8889
+ if (body.confirm !== true) throw new HttpError(400, "bad_request");
8890
+ logger.info("vps deploy requested host=%s port=%d sshUser=%s sshPort=%d keyProvided=%s fingerprints=%s", String(body.serverAddress), Number(body.serverPort), String(body.sshUser), Number(body.sshPort), body.sshKeyPath === void 0 ? "false" : "true", Array.isArray(body.hostFingerprints) ? String(body.hostFingerprints.length) : "none");
8891
+ const deployment = await remoteProviders.mutate(async () => {
8892
+ const settings = mergeSavedFrpSettings(body, frpConfig.settings());
8893
+ const result = await deployVps(settings, parseVpsDeploymentInput({
8894
+ sshUser: body.sshUser,
8895
+ sshPort: body.sshPort,
8896
+ ...body.sshKeyPath === void 0 ? {} : { sshKeyPath: body.sshKeyPath },
8897
+ hostFingerprints: body.hostFingerprints
8898
+ }), { log(event, fields) {
8899
+ logger.info("vps deploy event=%s fields=%o", event, fields);
8900
+ } });
8901
+ await frpConfig.configure(settings);
8902
+ logger.info("vps deploy completed host=%s origin=%s checks=%d", settings.serverAddress, settings.publicOrigin, result.checks.length);
8903
+ return result;
8904
+ });
8905
+ sendJson(response, 200, {
8906
+ ...remotePayload(),
8907
+ vpsDeployment: deployment
8908
+ }, false);
8909
+ return;
8910
+ }
8911
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/vps/uninstall-script`) {
8912
+ const body = await readJsonObject(request, 4096);
8913
+ const script = createVpsUninstallScript({
8914
+ serverPort: mergeSavedFrpTarget(body, frpConfig.settings()).serverPort,
8915
+ ...body.certName === void 0 || body.certName === "" ? {} : { certName: body.certName }
8916
+ });
8917
+ sendJson(response, 200, {
8918
+ ...remotePayload(),
8919
+ vpsUninstallScript: script
8920
+ }, false);
8921
+ return;
8922
+ }
8923
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/vps/uninstall`) {
8924
+ const body = await readJsonObject(request, 8192);
8925
+ if (body.confirm !== true) throw new HttpError(400, "bad_request");
8926
+ logger.info("vps uninstall requested host=%s sshUser=%s sshPort=%d", String(body.serverAddress), String(body.sshUser), Number(body.sshPort));
8927
+ const removal = await remoteProviders.mutate(async () => {
8928
+ const savedTarget = mergeSavedFrpTarget(body, frpConfig.settings());
8929
+ const result = await uninstallVps(savedTarget.serverAddress, {
8930
+ serverPort: savedTarget.serverPort,
8931
+ ...body.certName === void 0 || body.certName === "" ? {} : { certName: body.certName }
8932
+ }, parseVpsDeploymentInput({
8933
+ sshUser: body.sshUser,
8934
+ sshPort: body.sshPort,
8935
+ ...body.sshKeyPath === void 0 ? {} : { sshKeyPath: body.sshKeyPath },
8936
+ hostFingerprints: body.hostFingerprints
8937
+ }), { log(event, fields) {
8938
+ logger.info("vps uninstall event=%s fields=%o", event, fields);
8939
+ } });
8940
+ logger.info("vps uninstall completed host=%s checks=%d", result.serverAddress, result.checks.length);
8941
+ return result;
8942
+ });
8943
+ sendJson(response, 200, {
8944
+ ...remotePayload(),
8945
+ vpsUninstall: removal
8946
+ }, false);
8947
+ return;
8948
+ }
7637
8949
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/purge`) {
7638
8950
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
7639
8951
  await remoteProviders.mutate(async () => {
@@ -7693,16 +9005,27 @@ async function apply(ctx, config) {
7693
9005
  name: "mobile",
7694
9006
  description: "按需求修改 DSH Mobile 的手机端界面或添加电脑端能力",
7695
9007
  input: { hint: "<要做什么>" },
7696
- handler: ({ agent, rawInput }) => {
9008
+ handler: async ({ agent, rawInput }) => {
7697
9009
  const task = rawInput.trim();
7698
9010
  if (task === "") return {
7699
9011
  kind: "error",
7700
9012
  text: "请带上需求,例如:/mobile 把手机端改成深色主题"
7701
9013
  };
9014
+ const guide = buildMobileGuide({
9015
+ directory: stateDirectory,
9016
+ hasCustomCss: await existsRegularFile(template.customCssFile),
9017
+ hasCustomJs: await existsRegularFile(template.customScriptFile),
9018
+ extensions: mobileAccess.manifest().map((entry) => ({
9019
+ id: entry.id,
9020
+ name: entry.name,
9021
+ version: entry.version
9022
+ })),
9023
+ failedExtensionCount: mobileAccess.status().failed
9024
+ });
7702
9025
  agent.steer(createUserMessage({
7703
9026
  content: [{
7704
9027
  type: "text",
7705
- text: `${MOBILE_CUSTOMIZATION_GUIDE}\n\n用户需求:${task}`
9028
+ text: `${guide}\n\n用户需求:${task}`
7706
9029
  }],
7707
9030
  source: {
7708
9031
  kind: "plugin",
@@ -7768,6 +9091,6 @@ async function apply(ctx, config) {
7768
9091
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
7769
9092
  }
7770
9093
  //#endregion
7771
- export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken };
9094
+ export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, inject, isGloballyRoutableIpv4, isLoopbackAddress, mergeSavedFrpSettings, mergeSavedFrpTarget, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseRemoteProviderState, resolveAuthority, rewriteMobileIndex, validateFrpPublicOrigin, validateFrpServerAddress, validateFrpServerPort, validateFrpToken };
7772
9095
 
7773
9096
  //# sourceMappingURL=index.mjs.map