dsh-mobile 0.1.0-alpha.4 → 0.1.0-alpha.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/README.md CHANGED
@@ -61,12 +61,12 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
61
61
  pnpm dsh --profile web
62
62
  ```
63
63
 
64
- Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, and stores plugin data under `$DSH_HOME/mobile-access/`; run `dsh-mobile setup --help` through the same `plugin exec` command for manual network options. When DHCP changes that interface's IP, the plugin follows the new address and signs a matching server certificate from the same CA on the next DSH start.
64
+ Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval for LAN-only TCP/UDP firewall rules. Use `--no-firewall` only when those rules are managed separately. When DHCP changes that interface's IP, the plugin follows the new address and signs a matching server certificate from the same CA on the next DSH start.
65
65
 
66
66
  1. Copy the generated `dsh-mobile-ca.cer` to Android and install it once as a user CA certificate.
67
67
  2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
68
68
  3. Start access and select **Create pairing key**. The key is copied to the clipboard; the same card also shows a complete browser link.
69
- 4. Open the Android app; it finds DSH on the LAN automatically. Paste the key and connect. Alternatively, open the browser link directly on the phone.
69
+ 4. Open the Android app, select **Scan**, then select the discovered DSH. Paste the key and connect. Alternatively, open the browser link directly on the phone.
70
70
 
71
71
  The key and browser link expire after two minutes and work once. After pairing, Android stores a revocable device credential encrypted by Android Keystore. The app can rediscover the same DSH installation on the default port after its LAN address changes and renew its short Web session without another key. Browser profiles still pair separately.
72
72
 
@@ -79,7 +79,7 @@ The two access modes are equivalent:
79
79
  - Android app: a thin system WebView shell without browser address and tab bars.
80
80
  - Mobile browser: open the setup origin directly; no app installation is required.
81
81
 
82
- Android discovery scans only the current Wi-Fi `/24` on the default HTTPS port `3443`. It returns a stable, non-secret installation identifier; credentials never appear in discovery responses. A custom port or unusual subnet remains available through manual address entry.
82
+ Android first sends a UDP discovery query on the default port `3443`; the plugin replies with only its HTTPS origin and stable, non-secret installation identifier. It falls back to HTTPS scanning of visible private Wi-Fi and hotspot `/24` networks. Credentials never appear in discovery responses. The first screen contains only Scan and the discovered DSH list; the pairing-key field appears after selecting one result.
83
83
 
84
84
  ## Customize the mobile Web UI
85
85
 
package/README.zh.md CHANGED
@@ -61,12 +61,12 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
61
61
  pnpm dsh --profile web
62
62
  ```
63
63
 
64
- 两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
64
+ 两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows 会请求管理员批准两条仅限所选局域网的 TCP/UDP 防火墙规则;只有自行管理规则时才使用 `--no-firewall`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
65
65
 
66
66
  1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
67
67
  2. 在电脑 DSH 左下角打开“移动端”卡片。
68
68
  3. 确认服务已开启,点击“生成配对密钥”。密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
69
- 4. 打开 Android App,它会自动在局域网中找到 DSH。粘贴密钥并连接;也可直接在手机浏览器打开卡片中的链接。
69
+ 4. 打开 Android App,点击“扫描”并选择发现的 DSH,再粘贴密钥连接;也可直接在手机浏览器打开卡片中的链接。
70
70
 
71
71
  密钥和浏览器链接两分钟内有效且只能使用一次。配对后,Android 会用 Android Keystore 加密保存可随时撤销的设备凭据。局域网 IP 变化后,App 能在默认端口重新找到同一个 DSH,并自动续期短期 Web 会话,不需要新密钥。浏览器仍需单独配对。
72
72
 
@@ -79,7 +79,7 @@ pnpm dsh --profile web
79
79
  - Android App:使用系统 WebView 打开同一 DSH 页面,没有浏览器地址栏和标签栏。
80
80
  - 手机浏览器:直接访问向导显示的 `https://局域网地址:端口`,无需安装 App。
81
81
 
82
- Android 自动发现只扫描当前 Wi-Fi 的 `/24` 网段和默认 HTTPS 端口 `3443`。发现响应只包含稳定的非机密安装标识,不包含任何凭据。使用自定义端口或特殊子网时,仍可手动输入地址。
82
+ Android 会先在默认端口 `3443` 发送 UDP 发现请求,插件只回复 HTTPS 地址和稳定的非机密安装标识;失败时才回退扫描手机可见的私有 Wi-Fi 和热点 `/24` 网段。发现响应不包含任何凭据。首页只有扫描按钮和 DSH 列表,点击其中一项后才会显示配对密钥输入框。
83
83
 
84
84
  ## 自定义移动端 Web UI
85
85
 
package/SECURITY.md CHANGED
@@ -15,7 +15,8 @@ The maintainer will acknowledge a complete report within seven days. Publication
15
15
  ## Deployment requirements
16
16
 
17
17
  - Keep the ordinary DSH Web listener on loopback.
18
- - Expose only the plugin-owned HTTPS listener to the LAN.
18
+ - Expose only the plugin-owned HTTPS listener to the LAN.
19
+ - UDP discovery on the same port accepts only the fixed discovery query from the configured LAN CIDR and returns only the public HTTPS origin and non-secret installation identifier. It never returns credentials or configuration.
19
20
  - Use a certificate trusted by every client platform; never instruct a WebView to ignore TLS errors.
20
21
  - Keep pairing closed except during a short local onboarding action.
21
22
  - Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
package/lib/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
2
3
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { homedir, networkInterfaces } from "node:os";
4
5
  import { basename, dirname, join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
5
7
  import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
6
8
  import { generate } from "selfsigned";
7
9
  //#region src/managed-setup.ts
@@ -170,10 +172,14 @@ async function refreshManagedServerCertificate(setup, address) {
170
172
  }
171
173
  //#endregion
172
174
  //#region src/cli.ts
175
+ const execFile$1 = promisify(execFile);
176
+ const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
177
+ const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
173
178
  function parseOptions(args) {
174
179
  let address;
175
180
  let port = 3443;
176
181
  let dshPort = 3080;
182
+ let configureFirewall = true;
177
183
  for (let index = 0; index < args.length; index += 1) {
178
184
  const name = args[index];
179
185
  const value = args[index + 1];
@@ -192,6 +198,10 @@ function parseOptions(args) {
192
198
  index += 1;
193
199
  continue;
194
200
  }
201
+ if (name === "--no-firewall") {
202
+ configureFirewall = false;
203
+ continue;
204
+ }
195
205
  throw new Error(`unknown setup option: ${name ?? ""}`);
196
206
  }
197
207
  if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
@@ -199,12 +209,46 @@ function parseOptions(args) {
199
209
  return {
200
210
  ...address === void 0 ? {} : { address },
201
211
  port,
202
- dshPort
212
+ dshPort,
213
+ configureFirewall
203
214
  };
204
215
  }
205
216
  function dshHome() {
206
217
  return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
207
218
  }
219
+ async function runElevatedPowerShell(script) {
220
+ const launch = [
221
+ "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
222
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
223
+ "; exit $process.ExitCode"
224
+ ].join(" ");
225
+ await execFile$1("powershell.exe", [
226
+ "-NoProfile",
227
+ "-NonInteractive",
228
+ "-Command",
229
+ launch
230
+ ], { windowsHide: true });
231
+ }
232
+ async function configureWindowsFirewall(port, cidr) {
233
+ if (process.platform !== "win32") return;
234
+ const script = [
235
+ "$ErrorActionPreference = 'Stop'",
236
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
237
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
238
+ `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress '${cidr}' -Profile Any | Out-Null`,
239
+ `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress '${cidr}' -Profile Any | Out-Null`
240
+ ].join("; ");
241
+ console.log("Windows will request administrator approval for two LAN-only firewall rules.");
242
+ await runElevatedPowerShell(script);
243
+ }
244
+ async function removeWindowsFirewall() {
245
+ if (process.platform !== "win32") return;
246
+ await runElevatedPowerShell([
247
+ "$ErrorActionPreference = 'Stop'",
248
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
249
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`
250
+ ].join("; "));
251
+ }
208
252
  async function setup(args) {
209
253
  const options = parseOptions(args);
210
254
  const network = selectLanNetwork(options.address);
@@ -249,6 +293,7 @@ async function setup(args) {
249
293
  chmod(keyFile, 384),
250
294
  chmod(androidCertificate, 384)
251
295
  ]);
296
+ if (options.configureFirewall) await configureWindowsFirewall(options.port, network.cidr);
252
297
  const customCss = join(directory, "mobile.css");
253
298
  try {
254
299
  await readFile(customCss);
@@ -287,7 +332,7 @@ async function setup(args) {
287
332
  console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
288
333
  console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
289
334
  console.log("Start DSH with: dsh --profile web");
290
- console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
335
+ console.log("Then open the Mobile card in the lower-left corner and create a pairing key.");
291
336
  }
292
337
  async function purge(args) {
293
338
  if (args.length !== 1 || args[0] !== "--yes") throw new Error("purge requires --yes");
@@ -296,11 +341,12 @@ async function purge(args) {
296
341
  recursive: true,
297
342
  force: true
298
343
  });
344
+ await removeWindowsFirewall();
299
345
  console.log("Removed DSH Mobile certificates, devices, preferences, and custom Web files.");
300
346
  }
301
347
  function help() {
302
348
  console.log([
303
- "dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080]",
349
+ "dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080] [--no-firewall]",
304
350
  "dsh-mobile purge --yes",
305
351
  "",
306
352
  "Run through the DSH profile:",
package/lib/index.d.mts CHANGED
@@ -352,6 +352,7 @@ declare class MobileAccessGateway {
352
352
  private readonly tlsEnabled;
353
353
  private policy;
354
354
  private server;
355
+ private discoverySocket;
355
356
  private listenerPort;
356
357
  private readonly connectedSockets;
357
358
  private readonly activeRequests;
@@ -365,6 +366,7 @@ declare class MobileAccessGateway {
365
366
  constructor(config: ResolvedGatewayConfig, store: DeviceStore);
366
367
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
367
368
  start(): Promise<void>;
369
+ private startDiscovery;
368
370
  private closeFailedStart;
369
371
  /** Actual bound address, available after start and safe for loopback status output. */
370
372
  address(): {
package/lib/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { connect, isIP } from "node:net";
5
5
  import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { createSocket } from "node:dgram";
6
7
  import { createServer, request } from "node:http";
7
8
  import { createServer as createServer$1 } from "node:https";
8
9
  import { Transform } from "node:stream";
@@ -1019,6 +1020,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1019
1020
  //#region src/gateway.ts
1020
1021
  const MAX_CONTROL_BODY_BYTES = 16384;
1021
1022
  const MAX_HEADER_BYTES = 16384;
1023
+ const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
1022
1024
  const PAIR_PAGE = `<!doctype html>
1023
1025
  <html lang="en">
1024
1026
  <meta charset="utf-8">
@@ -1292,6 +1294,7 @@ var MobileAccessGateway = class {
1292
1294
  tlsEnabled;
1293
1295
  policy;
1294
1296
  server;
1297
+ discoverySocket;
1295
1298
  listenerPort;
1296
1299
  connectedSockets = /* @__PURE__ */ new Set();
1297
1300
  activeRequests = /* @__PURE__ */ new Map();
@@ -1376,12 +1379,38 @@ var MobileAccessGateway = class {
1376
1379
  if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
1377
1380
  this.listenerPort = address.port;
1378
1381
  this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
1382
+ await this.startDiscovery(address.port);
1379
1383
  } catch (error) {
1380
1384
  await this.closeFailedStart();
1381
1385
  throw error;
1382
1386
  }
1383
1387
  }
1388
+ async startDiscovery(port) {
1389
+ const socket = createSocket("udp4");
1390
+ this.discoverySocket = socket;
1391
+ socket.on("message", (message, remote) => {
1392
+ if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
1393
+ const response = Buffer.from(JSON.stringify({
1394
+ protocol: 1,
1395
+ instanceId: this.config.instanceId,
1396
+ origin: this.address().origin
1397
+ }), "utf8");
1398
+ socket.send(response, remote.port, remote.address, () => void 0);
1399
+ });
1400
+ await new Promise((resolve, reject) => {
1401
+ const failed = (error) => {
1402
+ reject(error);
1403
+ };
1404
+ socket.once("error", failed);
1405
+ socket.bind(port, "0.0.0.0", () => {
1406
+ socket.off("error", failed);
1407
+ resolve();
1408
+ });
1409
+ });
1410
+ }
1384
1411
  async closeFailedStart() {
1412
+ this.discoverySocket?.close();
1413
+ this.discoverySocket = void 0;
1385
1414
  for (const socket of this.connectedSockets) socket.destroy();
1386
1415
  const server = this.server;
1387
1416
  this.server = void 0;
@@ -1926,6 +1955,11 @@ var MobileAccessGateway = class {
1926
1955
  websocket.upstream.destroy();
1927
1956
  }
1928
1957
  for (const socket of this.connectedSockets) socket.destroy();
1958
+ const discoverySocket = this.discoverySocket;
1959
+ this.discoverySocket = void 0;
1960
+ if (discoverySocket !== void 0) await new Promise((resolve) => {
1961
+ discoverySocket.close(() => resolve());
1962
+ });
1929
1963
  const server = this.server;
1930
1964
  this.server = void 0;
1931
1965
  if (server !== void 0 && server.listening) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "private": false,
5
5
  "description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
6
6
  "type": "module",