dsh-mobile 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTORS.md +16 -4
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/apps/mobile/README.md +2 -0
- package/apps/mobile/README.zh-CN.md +2 -0
- package/docs/CLIENT_MODULES.en.md +1 -1
- package/docs/CLIENT_MODULES.md +1 -1
- package/docs/CONNECTION_DIAGNOSTICS.en.md +2 -0
- package/docs/CONNECTION_DIAGNOSTICS.md +2 -0
- package/docs/IROH.en.md +1 -0
- package/docs/IROH.md +1 -0
- package/docs/README.md +1 -1
- package/docs/SLOW_CONNECTIONS.en.md +8 -0
- package/docs/SLOW_CONNECTIONS.md +8 -0
- package/docs/WEB_PUSH.en.md +20 -4
- package/docs/WEB_PUSH.md +20 -4
- package/lib/cli.js +156 -24
- package/lib/cli.js.map +1 -1
- package/lib/client.js +267 -18
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +59 -1
- package/lib/index.mjs +1134 -64
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/packages/question-fixes/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir, networkInterfaces } from "node:os";
|
|
2
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
|
5
5
|
import { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
@@ -9,6 +9,39 @@ import { performance } from "node:perf_hooks";
|
|
|
9
9
|
import "@deepseek-ai/cordis";
|
|
10
10
|
import "@deepseek-ai/schemastery";
|
|
11
11
|
// src/exec-file.ts
|
|
12
|
+
/**
|
|
13
|
+
* Longest stream text attached to a failed subprocess result.
|
|
14
|
+
*
|
|
15
|
+
* The text exists for diagnostics only: a caller decides which part is safe to
|
|
16
|
+
* show. It is bounded here so a chatty child cannot turn one failure into an
|
|
17
|
+
* unbounded buffer in every future consumer of that rejection.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_SUBPROCESS_ERROR_STREAM_CHARS = 8192;
|
|
20
|
+
function boundedStream(value) {
|
|
21
|
+
if (typeof value !== "string" || value === "") return void 0;
|
|
22
|
+
return value.length <= 8192 ? value : value.slice(0, MAX_SUBPROCESS_ERROR_STREAM_CHARS);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Attach the captured streams to a subprocess failure, best effort.
|
|
26
|
+
*
|
|
27
|
+
* `execFile` reports a non-zero exit as an Error whose only text is the command
|
|
28
|
+
* line with stderr merged into `message`; the raw stream strings are handed to
|
|
29
|
+
* the callback and otherwise discarded, so a caller cannot read them from the
|
|
30
|
+
* rejection. They are copied onto it when it accepts properties. A frozen,
|
|
31
|
+
* exotic or primitive rejection value must keep `message` as its diagnostic
|
|
32
|
+
* rather than turn one failure into a different error, so every write is
|
|
33
|
+
* guarded and an existing property is never overwritten.
|
|
34
|
+
*/
|
|
35
|
+
function attachCapturedStreams(error, stdout, stderr) {
|
|
36
|
+
if (typeof error !== "object" || error === null) return;
|
|
37
|
+
const target = error;
|
|
38
|
+
try {
|
|
39
|
+
const capturedStderr = boundedStream(stderr);
|
|
40
|
+
const capturedStdout = boundedStream(stdout);
|
|
41
|
+
if (target.stderr === void 0 && capturedStderr !== void 0) target.stderr = capturedStderr;
|
|
42
|
+
if (target.stdout === void 0 && capturedStdout !== void 0) target.stdout = capturedStdout;
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
12
45
|
/** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */
|
|
13
46
|
function execFileText(file, args, options = {}) {
|
|
14
47
|
return new Promise((resolve, reject) => {
|
|
@@ -18,6 +51,7 @@ function execFileText(file, args, options = {}) {
|
|
|
18
51
|
encoding: "utf8"
|
|
19
52
|
}, (error, stdout, stderr) => {
|
|
20
53
|
if (error !== null) {
|
|
54
|
+
attachCapturedStreams(error, stdout, stderr);
|
|
21
55
|
reject(error);
|
|
22
56
|
return;
|
|
23
57
|
}
|
|
@@ -176,7 +210,7 @@ const VIRTUAL_INTERFACE_MARKERS = [
|
|
|
176
210
|
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
177
211
|
function privateIpv4(value) {
|
|
178
212
|
const parts = value.split(".").map(Number);
|
|
179
|
-
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);
|
|
213
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
180
214
|
}
|
|
181
215
|
function networkCidr(address, cidr) {
|
|
182
216
|
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
@@ -512,28 +546,126 @@ async function refreshManagedServerCertificate(setup, address) {
|
|
|
512
546
|
// src/lan-setup.ts
|
|
513
547
|
const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
|
|
514
548
|
const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
|
|
549
|
+
const MIN_MANAGED_PORT = 1024;
|
|
550
|
+
const MAX_MANAGED_PORT = 65535;
|
|
551
|
+
function assertManagedPort(port, name) {
|
|
552
|
+
if (!Number.isSafeInteger(port) || port < MIN_MANAGED_PORT || port > MAX_MANAGED_PORT) throw new Error(`${name} must be from ${String(MIN_MANAGED_PORT)} through ${String(MAX_MANAGED_PORT)}`);
|
|
553
|
+
}
|
|
554
|
+
/** Longest failure detail carried in the thrown message. */
|
|
555
|
+
const MAX_FAILURE_DETAIL_CHARS = 512;
|
|
556
|
+
/**
|
|
557
|
+
* Strip the elevated invocation and filesystem locations from PowerShell text.
|
|
558
|
+
*
|
|
559
|
+
* The launcher's stderr quotes the command it ran, including the base64
|
|
560
|
+
* `-EncodedCommand` payload, and the elevated script quotes its own scratch
|
|
561
|
+
* path. Neither helps an operator, and both can push the one useful line past
|
|
562
|
+
* the length bound, so they are replaced before the detail is bounded.
|
|
563
|
+
*/
|
|
564
|
+
function redactPowerShellDetail(value) {
|
|
565
|
+
return value.replace(/EncodedCommand\s+\S+/giu, "EncodedCommand <redacted>").replace(/\b[A-Za-z0-9+/]{64,}={0,2}\b/gu, "<redacted>").replace(/(?<![A-Za-z])[A-Za-z]:[\\/][^\s'"]*/gu, "<path>");
|
|
566
|
+
}
|
|
567
|
+
/** Bounded, redacted detail for a failed elevated Windows operation. */
|
|
568
|
+
function powerShellFailureDetail(error, innerError = "") {
|
|
569
|
+
if (!(error instanceof Error)) return "unknown failure";
|
|
570
|
+
const code = "code" in error && (typeof error.code === "string" || typeof error.code === "number") ? ` (${String(error.code)})` : "";
|
|
571
|
+
const detail = redactPowerShellDetail(innerError.trim() === "" && "stderr" in error && typeof error.stderr === "string" ? error.stderr : innerError).trim().replace(/\s+/gu, " ").slice(0, MAX_FAILURE_DETAIL_CHARS);
|
|
572
|
+
return `${code}${detail === "" ? "" : `: ${detail}`}`;
|
|
573
|
+
}
|
|
574
|
+
/** Default sink for a scratch directory that could not be removed. */
|
|
575
|
+
function warnFirewallCleanupFailure(detail) {
|
|
576
|
+
process.emitWarning(`Windows firewall temporary cleanup failed (${detail})`, { code: "DSH_MOBILE_FIREWALL_CLEANUP_FAILED" });
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Remove the elevated helper's scratch directory without touching the run's outcome.
|
|
580
|
+
*
|
|
581
|
+
* The directory is created before elevation and removed in a `finally`, so a
|
|
582
|
+
* cleanup failure must not replace the firewall error that explains the
|
|
583
|
+
* failure, and must not fail a run that already succeeded. It stays observable
|
|
584
|
+
* instead: the removal error's code is reported to the warning sink, whose own
|
|
585
|
+
* failure is ignored for the same reason.
|
|
586
|
+
* @param directory - scratch path created for this run.
|
|
587
|
+
* @param options - injectable removal and warning sink for tests.
|
|
588
|
+
*/
|
|
589
|
+
async function discardFirewallScratch(directory, options = {}) {
|
|
590
|
+
const remove = options.remove ?? ((path) => rm(path, {
|
|
591
|
+
recursive: true,
|
|
592
|
+
force: true
|
|
593
|
+
}));
|
|
594
|
+
try {
|
|
595
|
+
await remove(directory);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
const code = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
|
|
598
|
+
const warn = options.warn ?? warnFirewallCleanupFailure;
|
|
599
|
+
try {
|
|
600
|
+
warn(code);
|
|
601
|
+
} catch {}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
515
604
|
async function runElevatedPowerShell(script) {
|
|
516
|
-
await
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
"
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
605
|
+
const temporary = await mkdtemp(join(tmpdir(), "dsh-mobile-firewall-"));
|
|
606
|
+
const errorFile = join(temporary, "error.log");
|
|
607
|
+
const wrapped = [
|
|
608
|
+
`$__dshMobileErrorFile = '${errorFile.replaceAll("'", "''")}'`,
|
|
609
|
+
"try {",
|
|
610
|
+
script,
|
|
611
|
+
"} catch {",
|
|
612
|
+
" try { [IO.File]::WriteAllText($__dshMobileErrorFile, ($_ | Out-String)) } catch {}",
|
|
613
|
+
" throw",
|
|
614
|
+
"}"
|
|
615
|
+
].join("\n");
|
|
616
|
+
const launch = [
|
|
617
|
+
"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
|
|
618
|
+
` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(wrapped, "utf16le").toString("base64")}')`,
|
|
619
|
+
"; exit $process.ExitCode"
|
|
620
|
+
].join(" ");
|
|
621
|
+
try {
|
|
622
|
+
await execFileText("powershell.exe", [
|
|
623
|
+
"-NoProfile",
|
|
624
|
+
"-NonInteractive",
|
|
625
|
+
"-Command",
|
|
626
|
+
launch
|
|
627
|
+
], { windowsHide: true });
|
|
628
|
+
} catch (error) {
|
|
629
|
+
let innerError = "";
|
|
630
|
+
try {
|
|
631
|
+
innerError = (await readFile(errorFile, "utf8")).replace(/^\uFEFF/u, "");
|
|
632
|
+
} catch {}
|
|
633
|
+
throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error, innerError)}`, { cause: error });
|
|
634
|
+
} finally {
|
|
635
|
+
await discardFirewallScratch(temporary);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function firewallRuleScript(name, protocol, port) {
|
|
639
|
+
return [
|
|
640
|
+
`$rules = @(Get-NetFirewallRule -DisplayName '${name}' -ErrorAction SilentlyContinue)`,
|
|
641
|
+
"if ($rules.Count -eq 0) {",
|
|
642
|
+
` New-NetFirewallRule -DisplayName '${name}' -Direction Inbound -Action Allow -Protocol ${protocol} -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
643
|
+
"} else {",
|
|
644
|
+
" foreach ($rule in $rules) {",
|
|
645
|
+
" $rule | Set-NetFirewallRule -Enabled True -Direction Inbound -Action Allow -Profile Any",
|
|
646
|
+
` $rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter -Protocol ${protocol} -LocalPort ${String(port)}`,
|
|
647
|
+
" $rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -RemoteAddress LocalSubnet",
|
|
648
|
+
" }",
|
|
649
|
+
"}",
|
|
650
|
+
`$verified = @(Get-NetFirewallRule -DisplayName '${name}' -ErrorAction Stop | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' -and $_.Profile -eq 'Any' })`,
|
|
651
|
+
`$matchingPort = @($verified | Get-NetFirewallPortFilter | Where-Object { $_.Protocol -eq '${protocol}' -and $_.LocalPort -contains '${String(port)}' })`,
|
|
652
|
+
`$matchingAddress = @($verified | Get-NetFirewallAddressFilter | Where-Object { $_.RemoteAddress -contains 'LocalSubnet' })`,
|
|
653
|
+
`if ($verified.Count -eq 0 -or $matchingPort.Count -eq 0 -or $matchingAddress.Count -eq 0) { throw "DSH Mobile firewall rule '${name}' is not active with the expected scope" }`
|
|
654
|
+
];
|
|
655
|
+
}
|
|
656
|
+
/** Build the elevated Windows firewall script without removing a working rule first. */
|
|
657
|
+
function windowsFirewallSetupScript(port) {
|
|
658
|
+
assertManagedPort(port, "Windows firewall port");
|
|
659
|
+
return [
|
|
660
|
+
"$ErrorActionPreference = 'Stop'",
|
|
661
|
+
...firewallRuleScript(FIREWALL_TCP_RULE, "TCP", port),
|
|
662
|
+
...firewallRuleScript(FIREWALL_UDP_RULE, "UDP", port)
|
|
663
|
+
].join("\n");
|
|
664
|
+
}
|
|
665
|
+
/** Install or update the two Windows inbound rules used by the managed LAN listener. */
|
|
528
666
|
async function configureWindowsFirewall(port) {
|
|
529
667
|
if (process.platform !== "win32") return;
|
|
530
|
-
await runElevatedPowerShell(
|
|
531
|
-
"$ErrorActionPreference = 'Stop'",
|
|
532
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
533
|
-
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
534
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
535
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
536
|
-
].join("; "));
|
|
668
|
+
await runElevatedPowerShell(windowsFirewallSetupScript(port));
|
|
537
669
|
}
|
|
538
670
|
/** Remove only the two Windows firewall rules owned by DSH Mobile. */
|
|
539
671
|
async function removeWindowsFirewall() {
|
|
@@ -547,8 +679,8 @@ async function removeWindowsFirewall() {
|
|
|
547
679
|
/** Generate private TLS material and persist a managed setup selected by the user. */
|
|
548
680
|
async function prepareManagedLanSetup(options) {
|
|
549
681
|
if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
|
|
550
|
-
|
|
551
|
-
|
|
682
|
+
assertManagedPort(options.listenPort, "managed LAN listenPort");
|
|
683
|
+
assertManagedPort(options.dshPort, "managed LAN dshPort");
|
|
552
684
|
const directory = dirname(options.setupFile);
|
|
553
685
|
const tlsDirectory = join(directory, "tls");
|
|
554
686
|
await mkdir(tlsDirectory, {
|