dsh-mobile 0.3.8 → 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/CHANGELOG.md +8 -0
- package/README.en.md +21 -27
- package/README.md +23 -29
- package/SECURITY.md +2 -1
- package/docs/SELF_HOSTED_FRP.md +75 -0
- package/lib/client.js +544 -44
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +28 -2
- package/lib/index.mjs +1353 -37
- package/lib/index.mjs.map +1 -1
- package/package.json +6 -5
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)}`);
|
|
@@ -614,7 +668,7 @@ function parseUpstream(value) {
|
|
|
614
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");
|
|
615
669
|
return url;
|
|
616
670
|
}
|
|
617
|
-
function parsePublicOrigin(value) {
|
|
671
|
+
function parsePublicOrigin$1(value) {
|
|
618
672
|
if (value === void 0) return void 0;
|
|
619
673
|
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error("publicOrigin must be an HTTPS origin");
|
|
620
674
|
let url;
|
|
@@ -647,7 +701,7 @@ function parseTls(value, listenHost) {
|
|
|
647
701
|
function parseGatewayConfig(raw) {
|
|
648
702
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("mobile-access config must be an object");
|
|
649
703
|
const value = raw;
|
|
650
|
-
const publicOrigin = parsePublicOrigin(value.publicOrigin);
|
|
704
|
+
const publicOrigin = parsePublicOrigin$1(value.publicOrigin);
|
|
651
705
|
if (publicOrigin !== void 0 && value.listenPort !== void 0) throw new Error("publicOrigin cannot be combined with listenPort");
|
|
652
706
|
if (publicOrigin !== void 0 && value.publicAuthorities !== void 0) throw new Error("publicOrigin cannot be combined with publicAuthorities");
|
|
653
707
|
const listenHost = value.listenHost ?? (publicOrigin === void 0 ? "127.0.0.1" : "0.0.0.0");
|
|
@@ -4202,7 +4256,7 @@ var MemoryDeviceStore = class {
|
|
|
4202
4256
|
};
|
|
4203
4257
|
//#endregion
|
|
4204
4258
|
//#region src/frp-component.ts
|
|
4205
|
-
const FRP_VERSION = "0.70.1";
|
|
4259
|
+
const FRP_VERSION$1 = "0.70.1";
|
|
4206
4260
|
const MAX_ARCHIVE_ENTRIES = 128;
|
|
4207
4261
|
const MAX_ARCHIVE_LIST_BYTES = 262144;
|
|
4208
4262
|
/** Pinned official FRP release metadata for supported desktop targets. */
|
|
@@ -4214,7 +4268,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4214
4268
|
executableName: "frpc.exe",
|
|
4215
4269
|
downloadBytes: 13924309,
|
|
4216
4270
|
downloadSha256: "531f3cd3cc41c0b4f077b54fe6b7dd83c0ff727e7f0bf412a4c78fa279165de5",
|
|
4217
|
-
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`
|
|
4218
4272
|
},
|
|
4219
4273
|
{
|
|
4220
4274
|
platform: "win32",
|
|
@@ -4223,7 +4277,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4223
4277
|
executableName: "frpc.exe",
|
|
4224
4278
|
downloadBytes: 12204751,
|
|
4225
4279
|
downloadSha256: "74d3acaf0f03ee190dd0462f9b49861dca50b0559c5488af4b36572fc951fcca",
|
|
4226
|
-
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`
|
|
4227
4281
|
},
|
|
4228
4282
|
{
|
|
4229
4283
|
platform: "linux",
|
|
@@ -4232,7 +4286,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4232
4286
|
executableName: "frpc",
|
|
4233
4287
|
downloadBytes: 13924042,
|
|
4234
4288
|
downloadSha256: "333da23d1b9009d7c01638e9ba38cf4600f7d37d393f854e96ee1396adefa9a6",
|
|
4235
|
-
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`
|
|
4236
4290
|
},
|
|
4237
4291
|
{
|
|
4238
4292
|
platform: "linux",
|
|
@@ -4241,7 +4295,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4241
4295
|
executableName: "frpc",
|
|
4242
4296
|
downloadBytes: 12371290,
|
|
4243
4297
|
downloadSha256: "3990f396a9a490ee7f0e5f355287750ed41520064ed999eab443b5e9a78d773d",
|
|
4244
|
-
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`
|
|
4245
4299
|
},
|
|
4246
4300
|
{
|
|
4247
4301
|
platform: "darwin",
|
|
@@ -4250,7 +4304,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4250
4304
|
executableName: "frpc",
|
|
4251
4305
|
downloadBytes: 13951979,
|
|
4252
4306
|
downloadSha256: "cbf69cf26e5553e914e97d37f5d4367fa30f5f531d073a889465af4719281e25",
|
|
4253
|
-
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`
|
|
4254
4308
|
},
|
|
4255
4309
|
{
|
|
4256
4310
|
platform: "darwin",
|
|
@@ -4259,7 +4313,7 @@ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
|
|
|
4259
4313
|
executableName: "frpc",
|
|
4260
4314
|
downloadBytes: 12670664,
|
|
4261
4315
|
downloadSha256: "cfa733b5a261c1647edee3c1fc4133d2542989b28f5602e81d47fc821d25c55f",
|
|
4262
|
-
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`
|
|
4263
4317
|
}
|
|
4264
4318
|
].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
|
|
4265
4319
|
function inside$1(parent, child) {
|
|
@@ -4421,7 +4475,7 @@ var FrpComponentManager = class {
|
|
|
4421
4475
|
const arch = options.arch ?? process.arch;
|
|
4422
4476
|
this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
|
|
4423
4477
|
this.componentRoot = join(stateDirectory, "components", "frp");
|
|
4424
|
-
this.componentStorage = join(this.componentRoot, FRP_VERSION);
|
|
4478
|
+
this.componentStorage = join(this.componentRoot, FRP_VERSION$1);
|
|
4425
4479
|
this.executable = join(this.componentStorage, platform === "win32" ? "frpc.exe" : "frpc");
|
|
4426
4480
|
this.logRoot = join(stateDirectory, "logs", "frp");
|
|
4427
4481
|
this.stagingRoot = join(stateDirectory, "staging", "frp");
|
|
@@ -4440,7 +4494,7 @@ var FrpComponentManager = class {
|
|
|
4440
4494
|
this.installed = await regularFile$1(this.executable);
|
|
4441
4495
|
this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
|
|
4442
4496
|
if (this.installed) try {
|
|
4443
|
-
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");
|
|
4444
4498
|
this.errorCode = void 0;
|
|
4445
4499
|
} catch {
|
|
4446
4500
|
this.installed = false;
|
|
@@ -4452,11 +4506,11 @@ var FrpComponentManager = class {
|
|
|
4452
4506
|
return Object.freeze({
|
|
4453
4507
|
supported: this.artifact !== void 0,
|
|
4454
4508
|
installed: this.installed,
|
|
4455
|
-
version: FRP_VERSION,
|
|
4509
|
+
version: FRP_VERSION$1,
|
|
4456
4510
|
downloadBytes: this.artifact?.downloadBytes ?? 0,
|
|
4457
4511
|
installedBytes: this.installedBytes,
|
|
4458
4512
|
sourceUrl: this.artifact?.downloadUrl ?? "https://github.com/fatedier/frp/releases",
|
|
4459
|
-
releasePage: `https://github.com/fatedier/frp/releases/tag/v${FRP_VERSION}`,
|
|
4513
|
+
releasePage: `https://github.com/fatedier/frp/releases/tag/v${FRP_VERSION$1}`,
|
|
4460
4514
|
storagePath: this.componentRoot,
|
|
4461
4515
|
...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
|
|
4462
4516
|
});
|
|
@@ -4494,7 +4548,7 @@ var FrpComponentManager = class {
|
|
|
4494
4548
|
const extracted = join(staging, artifact.executableName);
|
|
4495
4549
|
if (!await regularFile$1(extracted)) throw new Error("frp_executable_missing");
|
|
4496
4550
|
await chmod(extracted, 448);
|
|
4497
|
-
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");
|
|
4498
4552
|
const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
|
|
4499
4553
|
await mkdir(candidate, {
|
|
4500
4554
|
recursive: true,
|
|
@@ -4547,33 +4601,98 @@ var FrpComponentManager = class {
|
|
|
4547
4601
|
//#region src/frp-template.ts
|
|
4548
4602
|
/** Loopback-only HTTP vhost port used between Caddy and frps. */
|
|
4549
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
|
+
}
|
|
4550
4616
|
function publicDnsHostname(value) {
|
|
4551
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));
|
|
4552
4618
|
}
|
|
4553
|
-
|
|
4554
|
-
function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
|
|
4555
|
-
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) {
|
|
4556
4620
|
let url;
|
|
4557
4621
|
try {
|
|
4558
4622
|
url = new URL(publicOrigin);
|
|
4559
4623
|
} catch {
|
|
4560
4624
|
throw new Error("frp_template_input_invalid");
|
|
4561
4625
|
}
|
|
4562
|
-
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");
|
|
4563
4647
|
return [
|
|
4564
|
-
|
|
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.",
|
|
4565
4678
|
`bindPort = ${String(serverPort)}`,
|
|
4566
4679
|
"proxyBindAddr = \"127.0.0.1\"",
|
|
4567
4680
|
`vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
|
|
4568
4681
|
"auth.method = \"token\"",
|
|
4569
4682
|
`auth.token = ${JSON.stringify(token)}`,
|
|
4570
4683
|
"",
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
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(),
|
|
4575
4692
|
""
|
|
4576
|
-
]
|
|
4693
|
+
];
|
|
4694
|
+
if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
|
|
4695
|
+
return lines.join("\n");
|
|
4577
4696
|
}
|
|
4578
4697
|
//#endregion
|
|
4579
4698
|
//#region src/frp-config.ts
|
|
@@ -4608,7 +4727,9 @@ function validateFrpPublicOrigin(value) {
|
|
|
4608
4727
|
} catch {
|
|
4609
4728
|
throw new Error("frp_public_origin_invalid");
|
|
4610
4729
|
}
|
|
4611
|
-
|
|
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");
|
|
4612
4733
|
return url.origin;
|
|
4613
4734
|
}
|
|
4614
4735
|
/** Parse FRP settings at the loopback request and filesystem boundaries. */
|
|
@@ -4631,6 +4752,32 @@ function parseFrpSettings(value) {
|
|
|
4631
4752
|
publicOrigin: validateFrpPublicOrigin(record.publicOrigin)
|
|
4632
4753
|
});
|
|
4633
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
|
+
}
|
|
4634
4781
|
function tomlString(value) {
|
|
4635
4782
|
return JSON.stringify(value);
|
|
4636
4783
|
}
|
|
@@ -5017,6 +5164,7 @@ async function defaultProbeVhostExposure(serverAddress, port) {
|
|
|
5017
5164
|
port
|
|
5018
5165
|
});
|
|
5019
5166
|
let finished = false;
|
|
5167
|
+
let received = "";
|
|
5020
5168
|
const finish = (exposed) => {
|
|
5021
5169
|
if (finished) return;
|
|
5022
5170
|
finished = true;
|
|
@@ -5029,7 +5177,14 @@ async function defaultProbeVhostExposure(serverAddress, port) {
|
|
|
5029
5177
|
}, VHOST_PROBE_TIMEOUT_MS);
|
|
5030
5178
|
timer.unref();
|
|
5031
5179
|
socket.once("connect", () => {
|
|
5032
|
-
|
|
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);
|
|
5033
5188
|
});
|
|
5034
5189
|
socket.once("error", () => {
|
|
5035
5190
|
finish(false);
|
|
@@ -5595,12 +5750,28 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
|
|
|
5595
5750
|
}
|
|
5596
5751
|
//#endregion
|
|
5597
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
|
+
}
|
|
5598
5770
|
/**
|
|
5599
|
-
*
|
|
5600
|
-
*
|
|
5601
|
-
* 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.
|
|
5602
5773
|
*/
|
|
5603
|
-
const
|
|
5774
|
+
const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手机端。DSH Mobile 是一个把电脑上的 DeepSeek Harness 带到手机浏览器的插件,手机端界面和能力都来自本机文件。
|
|
5604
5775
|
|
|
5605
5776
|
所有改动只允许在 $DSH_HOME/mobile-access/ 目录内进行,绝不修改 DeepSeek Harness 的源码或其他目录。$DSH_HOME 是 DeepSeek Harness 的配置目录(通常为 ~/.dsh),先确认它的实际路径再操作。
|
|
5606
5777
|
|
|
@@ -5625,7 +5796,994 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
|
|
|
5625
5796
|
- host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
|
|
5626
5797
|
- 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
|
|
5627
5798
|
|
|
5628
|
-
|
|
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
|
+
}
|
|
5629
6787
|
//#endregion
|
|
5630
6788
|
//#region src/funnel.ts
|
|
5631
6789
|
const MAX_PROTOCOL_LINE_BYTES = 16384;
|
|
@@ -7015,6 +8173,52 @@ var PluginReleaseManager = class {
|
|
|
7015
8173
|
}
|
|
7016
8174
|
};
|
|
7017
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
|
|
7018
8222
|
//#region src/managed-setup.ts
|
|
7019
8223
|
const VIRTUAL_INTERFACE_MARKERS = [
|
|
7020
8224
|
"bridge",
|
|
@@ -7255,6 +8459,7 @@ function installedDshVersion() {
|
|
|
7255
8459
|
function mapAdminError(error) {
|
|
7256
8460
|
if (error instanceof HttpError) return error;
|
|
7257
8461
|
const code = error.code;
|
|
8462
|
+
if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
|
|
7258
8463
|
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
7259
8464
|
if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
|
|
7260
8465
|
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
@@ -7268,6 +8473,7 @@ function mapAdminError(error) {
|
|
|
7268
8473
|
"frp_settings_invalid"
|
|
7269
8474
|
].includes(error.message)) return new HttpError(400, error.message);
|
|
7270
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);
|
|
7271
8477
|
if (error instanceof Error && error.message === "plugin_update_failed") return new HttpError(500, error.message);
|
|
7272
8478
|
if (error instanceof Error && error.message.startsWith("plugin_update_")) return new HttpError(409, error.message);
|
|
7273
8479
|
return new HttpError(500, "internal_error");
|
|
@@ -7284,6 +8490,15 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
7284
8490
|
"pairingCaFile",
|
|
7285
8491
|
"tls"
|
|
7286
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
|
+
}
|
|
7287
8502
|
function withoutSetupKeys(config) {
|
|
7288
8503
|
const merged = { ...config };
|
|
7289
8504
|
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
@@ -7402,6 +8617,9 @@ async function apply(ctx, config) {
|
|
|
7402
8617
|
const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, template.upstreamOrigin);
|
|
7403
8618
|
const instanceId = await stableInstanceId(loaded, template);
|
|
7404
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);
|
|
7405
8623
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
7406
8624
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
7407
8625
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -7628,7 +8846,14 @@ async function apply(ctx, config) {
|
|
|
7628
8846
|
}
|
|
7629
8847
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/install`) {
|
|
7630
8848
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
7631
|
-
|
|
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
|
+
}
|
|
7632
8857
|
sendJson(response, 200, remotePayload(), false);
|
|
7633
8858
|
return;
|
|
7634
8859
|
}
|
|
@@ -7641,6 +8866,86 @@ async function apply(ctx, config) {
|
|
|
7641
8866
|
sendJson(response, 200, remotePayload(), false);
|
|
7642
8867
|
return;
|
|
7643
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
|
+
}
|
|
7644
8949
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/component/purge`) {
|
|
7645
8950
|
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
7646
8951
|
await remoteProviders.mutate(async () => {
|
|
@@ -7700,16 +9005,27 @@ async function apply(ctx, config) {
|
|
|
7700
9005
|
name: "mobile",
|
|
7701
9006
|
description: "按需求修改 DSH Mobile 的手机端界面或添加电脑端能力",
|
|
7702
9007
|
input: { hint: "<要做什么>" },
|
|
7703
|
-
handler: ({ agent, rawInput }) => {
|
|
9008
|
+
handler: async ({ agent, rawInput }) => {
|
|
7704
9009
|
const task = rawInput.trim();
|
|
7705
9010
|
if (task === "") return {
|
|
7706
9011
|
kind: "error",
|
|
7707
9012
|
text: "请带上需求,例如:/mobile 把手机端改成深色主题"
|
|
7708
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
|
+
});
|
|
7709
9025
|
agent.steer(createUserMessage({
|
|
7710
9026
|
content: [{
|
|
7711
9027
|
type: "text",
|
|
7712
|
-
text: `${
|
|
9028
|
+
text: `${guide}\n\n用户需求:${task}`
|
|
7713
9029
|
}],
|
|
7714
9030
|
source: {
|
|
7715
9031
|
kind: "plugin",
|
|
@@ -7775,6 +9091,6 @@ async function apply(ctx, config) {
|
|
|
7775
9091
|
}, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
|
|
7776
9092
|
}
|
|
7777
9093
|
//#endregion
|
|
7778
|
-
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 };
|
|
7779
9095
|
|
|
7780
9096
|
//# sourceMappingURL=index.mjs.map
|