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/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 execFileText("powershell.exe", [
517
- "-NoProfile",
518
- "-NonInteractive",
519
- "-Command",
520
- [
521
- "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
522
- ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
523
- "; exit $process.ExitCode"
524
- ].join(" ")
525
- ], { windowsHide: true });
526
- }
527
- /** Install the two Windows inbound rules used by the managed LAN listener. */
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
- if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
551
- if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
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, {