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