dsh-mobile 0.3.16 → 0.4.1
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 +36 -4
- package/README.en.md +82 -14
- package/README.md +84 -22
- package/SECURITY.md +2 -0
- package/docs/SELF_HOSTED_FRP.en.md +75 -0
- package/docs/SELF_HOSTED_FRP.md +3 -1
- package/lib/cli.js +99 -82
- package/lib/cli.js.map +1 -1
- package/lib/client.js +808 -103
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +89 -3
- package/lib/index.mjs +727 -131
- package/lib/index.mjs.map +1 -1
- package/package.json +18 -9
package/lib/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
3
2
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
5
|
import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
7
|
import { generate } from "selfsigned";
|
|
8
8
|
import "@deepseek-ai/cordis";
|
|
9
9
|
import "@deepseek-ai/schemastery";
|
|
@@ -96,6 +96,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
|
|
|
96
96
|
"wsl",
|
|
97
97
|
"zerotier"
|
|
98
98
|
];
|
|
99
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
100
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
99
101
|
function privateIpv4(value) {
|
|
100
102
|
const parts = value.split(".").map(Number);
|
|
101
103
|
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
@@ -126,7 +128,7 @@ function likelyVirtualInterface(name) {
|
|
|
126
128
|
async function runRouteCommand(file, args) {
|
|
127
129
|
return (await execFileText(file, [...args], {
|
|
128
130
|
encoding: "utf8",
|
|
129
|
-
|
|
131
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
130
132
|
})).stdout;
|
|
131
133
|
}
|
|
132
134
|
function uniqueLines(output) {
|
|
@@ -324,6 +326,88 @@ async function refreshManagedServerCertificate(setup, address) {
|
|
|
324
326
|
});
|
|
325
327
|
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
326
328
|
}
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/lan-setup.ts
|
|
331
|
+
const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
|
|
332
|
+
const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
|
|
333
|
+
async function runElevatedPowerShell(script) {
|
|
334
|
+
await execFileText("powershell.exe", [
|
|
335
|
+
"-NoProfile",
|
|
336
|
+
"-NonInteractive",
|
|
337
|
+
"-Command",
|
|
338
|
+
[
|
|
339
|
+
"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
|
|
340
|
+
` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
|
|
341
|
+
"; exit $process.ExitCode"
|
|
342
|
+
].join(" ")
|
|
343
|
+
], { windowsHide: true });
|
|
344
|
+
}
|
|
345
|
+
/** Install the two Windows inbound rules used by the managed LAN listener. */
|
|
346
|
+
async function configureWindowsFirewall(port) {
|
|
347
|
+
if (process.platform !== "win32") return;
|
|
348
|
+
await runElevatedPowerShell([
|
|
349
|
+
"$ErrorActionPreference = 'Stop'",
|
|
350
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
351
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
352
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
353
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
354
|
+
].join("; "));
|
|
355
|
+
}
|
|
356
|
+
/** Remove only the two Windows firewall rules owned by DSH Mobile. */
|
|
357
|
+
async function removeWindowsFirewall() {
|
|
358
|
+
if (process.platform !== "win32") return;
|
|
359
|
+
await runElevatedPowerShell([
|
|
360
|
+
"$ErrorActionPreference = 'Stop'",
|
|
361
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
362
|
+
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`
|
|
363
|
+
].join("; "));
|
|
364
|
+
}
|
|
365
|
+
/** Generate private TLS material and persist a managed setup selected by the user. */
|
|
366
|
+
async function prepareManagedLanSetup(options) {
|
|
367
|
+
if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
|
|
368
|
+
if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
|
|
369
|
+
if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
|
|
370
|
+
const directory = dirname(options.setupFile);
|
|
371
|
+
const tlsDirectory = join(directory, "tls");
|
|
372
|
+
await mkdir(tlsDirectory, {
|
|
373
|
+
recursive: true,
|
|
374
|
+
mode: 448
|
|
375
|
+
});
|
|
376
|
+
const managedTls = {
|
|
377
|
+
mode: "managed",
|
|
378
|
+
caCertFile: join(tlsDirectory, "ca.pem"),
|
|
379
|
+
caKeyFile: join(tlsDirectory, "ca-key.pem"),
|
|
380
|
+
certFile: join(tlsDirectory, "server-cert.pem"),
|
|
381
|
+
keyFile: join(tlsDirectory, "server-key.pem")
|
|
382
|
+
};
|
|
383
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
384
|
+
certFile: join(tlsDirectory, "cert.pem"),
|
|
385
|
+
keyFile: join(tlsDirectory, "key.pem")
|
|
386
|
+
});
|
|
387
|
+
const setup = {
|
|
388
|
+
version: 2,
|
|
389
|
+
networkInterface: options.network.name,
|
|
390
|
+
listenPort: options.listenPort,
|
|
391
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
392
|
+
tls: managedTls
|
|
393
|
+
};
|
|
394
|
+
await refreshManagedServerCertificate(setup, options.network.address);
|
|
395
|
+
const androidCertificate = join(tlsDirectory, "dsh-mobile-ca.cer");
|
|
396
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
397
|
+
await Promise.all([...Object.values(managedTls).filter((value) => value !== "managed").map((file) => restrictPrivateFile(file)), restrictPrivateFile(androidCertificate)]);
|
|
398
|
+
if (options.configureFirewall) await configureWindowsFirewall(options.listenPort);
|
|
399
|
+
await Promise.all([writeFile(options.setupFile, `${JSON.stringify({
|
|
400
|
+
...setup,
|
|
401
|
+
tls: Object.fromEntries(Object.entries(setup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
402
|
+
}, null, 2)}\n`, { mode: 384 }), writeFile(options.controlFile, "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
403
|
+
await Promise.all([restrictPrivateFile(options.setupFile), restrictPrivateFile(options.controlFile)]);
|
|
404
|
+
return Object.freeze({
|
|
405
|
+
setup: Object.freeze(setup),
|
|
406
|
+
network: Object.freeze({ ...options.network }),
|
|
407
|
+
origin: `https://${options.network.address}:${String(options.listenPort)}`,
|
|
408
|
+
androidCertificate
|
|
409
|
+
});
|
|
410
|
+
}
|
|
327
411
|
Object.freeze({
|
|
328
412
|
manifest: 65536,
|
|
329
413
|
script: 1048576,
|
|
@@ -351,8 +435,6 @@ function assertExtensionId(value) {
|
|
|
351
435
|
}
|
|
352
436
|
//#endregion
|
|
353
437
|
//#region src/cli.ts
|
|
354
|
-
const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
|
|
355
|
-
const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
|
|
356
438
|
function parseOptions(args) {
|
|
357
439
|
let address;
|
|
358
440
|
let port = 3443;
|
|
@@ -394,84 +476,16 @@ function parseOptions(args) {
|
|
|
394
476
|
function dshHome() {
|
|
395
477
|
return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
|
|
396
478
|
}
|
|
397
|
-
async function runElevatedPowerShell(script) {
|
|
398
|
-
await execFileText("powershell.exe", [
|
|
399
|
-
"-NoProfile",
|
|
400
|
-
"-NonInteractive",
|
|
401
|
-
"-Command",
|
|
402
|
-
[
|
|
403
|
-
"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
|
|
404
|
-
` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
|
|
405
|
-
"; exit $process.ExitCode"
|
|
406
|
-
].join(" ")
|
|
407
|
-
], { windowsHide: true });
|
|
408
|
-
}
|
|
409
|
-
async function configureWindowsFirewall(port) {
|
|
410
|
-
if (process.platform !== "win32") return;
|
|
411
|
-
const script = [
|
|
412
|
-
"$ErrorActionPreference = 'Stop'",
|
|
413
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
414
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
415
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
416
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
417
|
-
].join("; ");
|
|
418
|
-
console.log("Windows will request administrator approval for two LAN-only firewall rules.");
|
|
419
|
-
await runElevatedPowerShell(script);
|
|
420
|
-
}
|
|
421
|
-
async function removeWindowsFirewall() {
|
|
422
|
-
if (process.platform !== "win32") return;
|
|
423
|
-
await runElevatedPowerShell([
|
|
424
|
-
"$ErrorActionPreference = 'Stop'",
|
|
425
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
426
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`
|
|
427
|
-
].join("; "));
|
|
428
|
-
}
|
|
429
479
|
async function setup(args) {
|
|
430
480
|
const options = parseOptions(args);
|
|
431
481
|
const preferredInterfaces = options.address === void 0 ? await preferredLanInterfaceNames() : [];
|
|
432
482
|
const network = selectLanNetwork(options.address, void 0, void 0, preferredInterfaces);
|
|
433
483
|
const home = dshHome();
|
|
434
484
|
const directory = join(home, "mobile-access");
|
|
435
|
-
|
|
436
|
-
await mkdir(tls, {
|
|
485
|
+
await mkdir(directory, {
|
|
437
486
|
recursive: true,
|
|
438
487
|
mode: 448
|
|
439
488
|
});
|
|
440
|
-
const legacyCertFile = join(tls, "cert.pem");
|
|
441
|
-
const legacyKeyFile = join(tls, "key.pem");
|
|
442
|
-
const certFile = join(tls, "server-cert.pem");
|
|
443
|
-
const keyFile = join(tls, "server-key.pem");
|
|
444
|
-
const caCertFile = join(tls, "ca.pem");
|
|
445
|
-
const caKeyFile = join(tls, "ca-key.pem");
|
|
446
|
-
const androidCertificate = join(tls, "dsh-mobile-ca.cer");
|
|
447
|
-
const managedTls = {
|
|
448
|
-
mode: "managed",
|
|
449
|
-
caCertFile,
|
|
450
|
-
caKeyFile,
|
|
451
|
-
certFile,
|
|
452
|
-
keyFile
|
|
453
|
-
};
|
|
454
|
-
const ca = await ensureManagedCa(managedTls, {
|
|
455
|
-
certFile: legacyCertFile,
|
|
456
|
-
keyFile: legacyKeyFile
|
|
457
|
-
});
|
|
458
|
-
const managedSetup = {
|
|
459
|
-
version: 2,
|
|
460
|
-
networkInterface: network.name,
|
|
461
|
-
listenPort: options.port,
|
|
462
|
-
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
463
|
-
tls: managedTls
|
|
464
|
-
};
|
|
465
|
-
await refreshManagedServerCertificate(managedSetup, network.address);
|
|
466
|
-
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
467
|
-
await Promise.all([
|
|
468
|
-
restrictPrivateFile(caCertFile),
|
|
469
|
-
restrictPrivateFile(caKeyFile),
|
|
470
|
-
restrictPrivateFile(certFile),
|
|
471
|
-
restrictPrivateFile(keyFile),
|
|
472
|
-
restrictPrivateFile(androidCertificate)
|
|
473
|
-
]);
|
|
474
|
-
if (options.configureFirewall) await configureWindowsFirewall(options.port);
|
|
475
489
|
const customCss = join(directory, "mobile.css");
|
|
476
490
|
try {
|
|
477
491
|
await readFile(customCss);
|
|
@@ -507,14 +521,17 @@ async function setup(args) {
|
|
|
507
521
|
mode: 448
|
|
508
522
|
});
|
|
509
523
|
await createExtensionScaffold(extensions, "custom", "自定义移动扩展", false);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
524
|
+
if (options.configureFirewall && process.platform === "win32") console.log("Windows will request administrator approval for two LAN-only firewall rules.");
|
|
525
|
+
const result = await prepareManagedLanSetup({
|
|
526
|
+
setupFile: join(directory, "setup.json"),
|
|
527
|
+
controlFile: join(directory, "control.json"),
|
|
528
|
+
network,
|
|
529
|
+
listenPort: options.port,
|
|
530
|
+
dshPort: options.dshPort,
|
|
531
|
+
configureFirewall: options.configureFirewall
|
|
532
|
+
});
|
|
533
|
+
console.log(`DSH Mobile follows ${network.name} and is currently configured for ${result.origin}`);
|
|
534
|
+
console.log(`Install this CA certificate on Android once: ${result.androidCertificate}`);
|
|
518
535
|
console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
|
|
519
536
|
console.log(`Additional extensions live in: ${extensions}`);
|
|
520
537
|
console.log("Start DSH with: dsh --profile web");
|