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/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
- windowsHide: true
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
- const tls = join(directory, "tls");
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
- const origin = `https://${network.address}:${String(options.port)}`;
511
- await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
512
- ...managedSetup,
513
- tls: Object.fromEntries(Object.entries(managedSetup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
514
- }, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
515
- await Promise.all([restrictPrivateFile(join(directory, "setup.json")), restrictPrivateFile(join(directory, "control.json"))]);
516
- console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`);
517
- console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
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");