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 +3 -3
- package/README.zh.md +3 -3
- package/SECURITY.md +2 -1
- package/lib/cli.js +49 -3
- package/lib/index.d.mts +2 -0
- package/lib/index.mjs +34 -0
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|