dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.4
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 +9 -7
- package/README.zh.md +9 -7
- package/assets/brand/app-icon-master.png +0 -0
- package/lib/cli.js +185 -78
- package/lib/client.js +18 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +5 -0
- package/lib/index.mjs +238 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://unpkg.com/dsh-mobile@
|
|
2
|
+
<img src="https://unpkg.com/dsh-mobile@latest/assets/brand/repository-hero.png" alt="A lively whale girl using a phone beside a desktop running DSH" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">DSH Mobile</h1>
|
|
@@ -61,14 +61,14 @@ 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
|
|
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.
|
|
65
65
|
|
|
66
|
-
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it as a user CA certificate.
|
|
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
|
-
3. Start access and
|
|
69
|
-
4.
|
|
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.
|
|
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
|
|
|
73
73
|
## Everyday control
|
|
74
74
|
|
|
@@ -79,6 +79,8 @@ 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.
|
|
83
|
+
|
|
82
84
|
## Customize the mobile Web UI
|
|
83
85
|
|
|
84
86
|
This is a same-origin Web extension surface, not a fixed theme editor. DSH can edit two files on the computer:
|
|
@@ -162,7 +164,7 @@ flowchart LR
|
|
|
162
164
|
## Security model
|
|
163
165
|
|
|
164
166
|
- The gateway accepts only the selected private LAN CIDR, exact Host, and same-origin browser requests.
|
|
165
|
-
- The Android app never bypasses TLS validation. The certificate
|
|
167
|
+
- The Android app never bypasses TLS validation. The stable local CA signs a server certificate for the selected interface's current LAN address at each DSH start.
|
|
166
168
|
- Pairing creates an HttpOnly session; devices and active sessions can be revoked from the computer.
|
|
167
169
|
- Control and device-management routes are loopback-only.
|
|
168
170
|
- Authentication happens before any request reaches the stock DSH loopback server. DSH itself is never rebound to `0.0.0.0`.
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://unpkg.com/dsh-mobile@
|
|
2
|
+
<img src="https://unpkg.com/dsh-mobile@latest/assets/brand/repository-hero.png" alt="鲸鱼娘拿着手机连接桌面上的 DSH" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">DSH Mobile</h1>
|
|
@@ -61,14 +61,14 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
|
|
65
65
|
|
|
66
|
-
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android
|
|
66
|
+
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
|
|
67
67
|
2. 在电脑 DSH 左下角打开“移动端”卡片。
|
|
68
|
-
3.
|
|
69
|
-
4.
|
|
68
|
+
3. 确认服务已开启,点击“生成配对密钥”。密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
|
|
69
|
+
4. 打开 Android App,它会自动在局域网中找到 DSH。粘贴密钥并连接;也可直接在手机浏览器打开卡片中的链接。
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
密钥和浏览器链接两分钟内有效且只能使用一次。配对后,Android 会用 Android Keystore 加密保存可随时撤销的设备凭据。局域网 IP 变化后,App 能在默认端口重新找到同一个 DSH,并自动续期短期 Web 会话,不需要新密钥。浏览器仍需单独配对。
|
|
72
72
|
|
|
73
73
|
## 日常使用
|
|
74
74
|
|
|
@@ -79,6 +79,8 @@ pnpm dsh --profile web
|
|
|
79
79
|
- Android App:使用系统 WebView 打开同一 DSH 页面,没有浏览器地址栏和标签栏。
|
|
80
80
|
- 手机浏览器:直接访问向导显示的 `https://局域网地址:端口`,无需安装 App。
|
|
81
81
|
|
|
82
|
+
Android 自动发现只扫描当前 Wi-Fi 的 `/24` 网段和默认 HTTPS 端口 `3443`。发现响应只包含稳定的非机密安装标识,不包含任何凭据。使用自定义端口或特殊子网时,仍可手动输入地址。
|
|
83
|
+
|
|
82
84
|
## 自定义移动端 Web UI
|
|
83
85
|
|
|
84
86
|
这不是一个只能换颜色的主题编辑器,而是与 DSH 页面同源的 Web 扩展面。电脑上的 DSH 可以编辑两个文件:
|
|
@@ -167,7 +169,7 @@ flowchart LR
|
|
|
167
169
|
## 安全模型
|
|
168
170
|
|
|
169
171
|
- 网关只接受设置向导选定的私有局域网 CIDR、精确 Host 和同源浏览器请求。
|
|
170
|
-
- Android App 不跳过 TLS
|
|
172
|
+
- Android App 不跳过 TLS 校验;稳定的本地 CA 会在每次 DSH 启动时为所选网卡的当前局域网地址签发服务器证书。
|
|
171
173
|
- 配对设备获得短期 HttpOnly Session,设备和会话都可在电脑端撤销。
|
|
172
174
|
- 管理接口只允许从电脑回环地址访问,移动端无法打开、关闭网关或管理其他设备。
|
|
173
175
|
- 外层网关在认证后才代理到原生 DSH 的回环端口,不会把 DSH 自身绑定到 `0.0.0.0`。
|
|
Binary file
|
package/lib/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
|
|
6
6
|
import { generate } from "selfsigned";
|
|
7
|
-
//#region src/
|
|
7
|
+
//#region src/managed-setup.ts
|
|
8
8
|
function privateIpv4(value) {
|
|
9
9
|
const parts = value.split(".").map(Number);
|
|
10
10
|
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);
|
|
@@ -19,13 +19,157 @@ function networkCidr(address, cidr) {
|
|
|
19
19
|
0
|
|
20
20
|
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
23
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
24
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
25
|
+
name,
|
|
24
26
|
address: entry.address,
|
|
25
27
|
cidr: networkCidr(entry.address, entry.cidr)
|
|
26
|
-
}));
|
|
27
|
-
return [...new Map(candidates.map((entry) => [entry.address
|
|
28
|
+
})));
|
|
29
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
30
|
+
}
|
|
31
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
32
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
33
|
+
const candidates = availableLanNetworks(table);
|
|
34
|
+
if (requestedAddress !== void 0) {
|
|
35
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
36
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
37
|
+
return match;
|
|
38
|
+
}
|
|
39
|
+
if (requestedInterface !== void 0) {
|
|
40
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
41
|
+
if (matches.length === 1) return matches[0];
|
|
42
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
43
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
44
|
+
}
|
|
45
|
+
if (candidates.length === 1) return candidates[0];
|
|
46
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
47
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
50
|
+
const certificate = new X509Certificate(certPem);
|
|
51
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
52
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
53
|
+
format: "der",
|
|
54
|
+
type: "spki"
|
|
55
|
+
});
|
|
56
|
+
const certificatePublic = certificate.publicKey.export({
|
|
57
|
+
format: "der",
|
|
58
|
+
type: "spki"
|
|
59
|
+
});
|
|
60
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
61
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
62
|
+
return certificate;
|
|
63
|
+
}
|
|
64
|
+
async function atomicWrite(file, contents) {
|
|
65
|
+
const directory = dirname(file);
|
|
66
|
+
await mkdir(directory, {
|
|
67
|
+
recursive: true,
|
|
68
|
+
mode: 448
|
|
69
|
+
});
|
|
70
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
71
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
72
|
+
await chmod(temporary, 384);
|
|
73
|
+
await rename(temporary, file);
|
|
74
|
+
}
|
|
75
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
76
|
+
async function ensureManagedCa(setup, legacy) {
|
|
77
|
+
let certPem;
|
|
78
|
+
let keyPem;
|
|
79
|
+
try {
|
|
80
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== "ENOENT") throw error;
|
|
83
|
+
let migrated = false;
|
|
84
|
+
if (legacy !== void 0) try {
|
|
85
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
86
|
+
assertMatchingCa(certPem, keyPem);
|
|
87
|
+
migrated = true;
|
|
88
|
+
} catch (legacyError) {
|
|
89
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
90
|
+
}
|
|
91
|
+
if (!migrated) {
|
|
92
|
+
const now = /* @__PURE__ */ new Date();
|
|
93
|
+
const notAfter = new Date(now);
|
|
94
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
95
|
+
const generated = await generate([{
|
|
96
|
+
name: "commonName",
|
|
97
|
+
value: "DeepSeek Harness Mobile CA"
|
|
98
|
+
}], {
|
|
99
|
+
keyType: "ec",
|
|
100
|
+
curve: "P-256",
|
|
101
|
+
algorithm: "sha256",
|
|
102
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
103
|
+
notAfterDate: notAfter,
|
|
104
|
+
extensions: [{
|
|
105
|
+
name: "basicConstraints",
|
|
106
|
+
cA: true,
|
|
107
|
+
critical: true
|
|
108
|
+
}, {
|
|
109
|
+
name: "keyUsage",
|
|
110
|
+
digitalSignature: true,
|
|
111
|
+
keyCertSign: true,
|
|
112
|
+
cRLSign: true,
|
|
113
|
+
critical: true
|
|
114
|
+
}]
|
|
115
|
+
});
|
|
116
|
+
certPem = generated.cert;
|
|
117
|
+
keyPem = generated.private;
|
|
118
|
+
}
|
|
119
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
120
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
121
|
+
}
|
|
122
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
123
|
+
return assertMatchingCa(certPem, keyPem);
|
|
124
|
+
}
|
|
125
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
126
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
127
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
128
|
+
assertMatchingCa(caCert, caKey);
|
|
129
|
+
const now = /* @__PURE__ */ new Date();
|
|
130
|
+
const notAfter = new Date(now);
|
|
131
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
132
|
+
const server = await generate([{
|
|
133
|
+
name: "commonName",
|
|
134
|
+
value: "DeepSeek Harness Mobile"
|
|
135
|
+
}], {
|
|
136
|
+
keyType: "ec",
|
|
137
|
+
curve: "P-256",
|
|
138
|
+
algorithm: "sha256",
|
|
139
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
140
|
+
notAfterDate: notAfter,
|
|
141
|
+
ca: {
|
|
142
|
+
cert: caCert,
|
|
143
|
+
key: caKey
|
|
144
|
+
},
|
|
145
|
+
extensions: [
|
|
146
|
+
{
|
|
147
|
+
name: "basicConstraints",
|
|
148
|
+
cA: false,
|
|
149
|
+
critical: true
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "keyUsage",
|
|
153
|
+
digitalSignature: true,
|
|
154
|
+
critical: true
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "extKeyUsage",
|
|
158
|
+
serverAuth: true
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "subjectAltName",
|
|
162
|
+
altNames: [{
|
|
163
|
+
type: 7,
|
|
164
|
+
ip: address
|
|
165
|
+
}]
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
28
170
|
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/cli.ts
|
|
29
173
|
function parseOptions(args) {
|
|
30
174
|
let address;
|
|
31
175
|
let port = 3443;
|
|
@@ -52,30 +196,18 @@ function parseOptions(args) {
|
|
|
52
196
|
}
|
|
53
197
|
if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
|
|
54
198
|
if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
|
|
55
|
-
if (address !== void 0 && !privateIpv4(address)) throw new Error("--address must be a private IPv4 address");
|
|
56
199
|
return {
|
|
57
200
|
...address === void 0 ? {} : { address },
|
|
58
201
|
port,
|
|
59
202
|
dshPort
|
|
60
203
|
};
|
|
61
204
|
}
|
|
62
|
-
function selectNetwork(requested) {
|
|
63
|
-
const candidates = availableAddresses();
|
|
64
|
-
if (requested !== void 0) {
|
|
65
|
-
const match = candidates.find((candidate) => candidate.address === requested);
|
|
66
|
-
if (match === void 0) throw new Error(`--address ${requested} is not an active private LAN address`);
|
|
67
|
-
return match;
|
|
68
|
-
}
|
|
69
|
-
if (candidates.length === 1) return candidates[0];
|
|
70
|
-
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
71
|
-
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
72
|
-
}
|
|
73
205
|
function dshHome() {
|
|
74
206
|
return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
|
|
75
207
|
}
|
|
76
208
|
async function setup(args) {
|
|
77
209
|
const options = parseOptions(args);
|
|
78
|
-
const network =
|
|
210
|
+
const network = selectLanNetwork(options.address);
|
|
79
211
|
const home = dshHome();
|
|
80
212
|
const directory = join(home, "mobile-access");
|
|
81
213
|
const tls = join(directory, "tls");
|
|
@@ -83,53 +215,36 @@ async function setup(args) {
|
|
|
83
215
|
recursive: true,
|
|
84
216
|
mode: 448
|
|
85
217
|
});
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}], {
|
|
93
|
-
keyType: "ec",
|
|
94
|
-
curve: "P-256",
|
|
95
|
-
algorithm: "sha256",
|
|
96
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
97
|
-
notAfterDate: notAfter,
|
|
98
|
-
extensions: [
|
|
99
|
-
{
|
|
100
|
-
name: "basicConstraints",
|
|
101
|
-
cA: true,
|
|
102
|
-
critical: true
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: "keyUsage",
|
|
106
|
-
digitalSignature: true,
|
|
107
|
-
keyCertSign: true,
|
|
108
|
-
cRLSign: true,
|
|
109
|
-
critical: true
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
name: "extKeyUsage",
|
|
113
|
-
serverAuth: true
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
name: "subjectAltName",
|
|
117
|
-
altNames: [{
|
|
118
|
-
type: 7,
|
|
119
|
-
ip: network.address
|
|
120
|
-
}]
|
|
121
|
-
}
|
|
122
|
-
]
|
|
123
|
-
});
|
|
124
|
-
const certFile = join(tls, "cert.pem");
|
|
125
|
-
const keyFile = join(tls, "key.pem");
|
|
218
|
+
const legacyCertFile = join(tls, "cert.pem");
|
|
219
|
+
const legacyKeyFile = join(tls, "key.pem");
|
|
220
|
+
const certFile = join(tls, "server-cert.pem");
|
|
221
|
+
const keyFile = join(tls, "server-key.pem");
|
|
222
|
+
const caCertFile = join(tls, "ca.pem");
|
|
223
|
+
const caKeyFile = join(tls, "ca-key.pem");
|
|
126
224
|
const androidCertificate = join(tls, "dsh-mobile-ca.cer");
|
|
225
|
+
const managedTls = {
|
|
226
|
+
mode: "managed",
|
|
227
|
+
caCertFile,
|
|
228
|
+
caKeyFile,
|
|
229
|
+
certFile,
|
|
230
|
+
keyFile
|
|
231
|
+
};
|
|
232
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
233
|
+
certFile: legacyCertFile,
|
|
234
|
+
keyFile: legacyKeyFile
|
|
235
|
+
});
|
|
236
|
+
const managedSetup = {
|
|
237
|
+
version: 2,
|
|
238
|
+
networkInterface: network.name,
|
|
239
|
+
listenPort: options.port,
|
|
240
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
241
|
+
tls: managedTls
|
|
242
|
+
};
|
|
243
|
+
await refreshManagedServerCertificate(managedSetup, network.address);
|
|
244
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
127
245
|
await Promise.all([
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
writeFile(androidCertificate, new X509Certificate(certificate.cert).raw, { mode: 384 })
|
|
131
|
-
]);
|
|
132
|
-
await Promise.all([
|
|
246
|
+
chmod(caCertFile, 384),
|
|
247
|
+
chmod(caKeyFile, 384),
|
|
133
248
|
chmod(certFile, 384),
|
|
134
249
|
chmod(keyFile, 384),
|
|
135
250
|
chmod(androidCertificate, 384)
|
|
@@ -165,19 +280,11 @@ async function setup(args) {
|
|
|
165
280
|
}
|
|
166
281
|
const origin = `https://${network.address}:${String(options.port)}`;
|
|
167
282
|
await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
listenHost: network.address,
|
|
171
|
-
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
172
|
-
allowedCidrs: [network.cidr],
|
|
173
|
-
tls: {
|
|
174
|
-
mode: "provided",
|
|
175
|
-
certFile: certFile.replaceAll("\\", "/"),
|
|
176
|
-
keyFile: keyFile.replaceAll("\\", "/")
|
|
177
|
-
}
|
|
283
|
+
...managedSetup,
|
|
284
|
+
tls: Object.fromEntries(Object.entries(managedSetup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
178
285
|
}, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
179
|
-
console.log(`DSH Mobile is configured for ${origin}`);
|
|
180
|
-
console.log(`Install this certificate on Android
|
|
286
|
+
console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`);
|
|
287
|
+
console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
|
|
181
288
|
console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
|
|
182
289
|
console.log("Start DSH with: dsh --profile web");
|
|
183
290
|
console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
|
package/lib/client.js
CHANGED
|
@@ -92,9 +92,21 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
94
|
const body = await response.json();
|
|
95
|
-
if (!response.ok)
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const code = typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`;
|
|
97
|
+
throw new Error(controlErrorMessage(code));
|
|
98
|
+
}
|
|
96
99
|
return body;
|
|
97
100
|
}
|
|
101
|
+
function controlErrorMessage(code) {
|
|
102
|
+
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
103
|
+
const message = {
|
|
104
|
+
network_address_changed: ["局域网地址已变化,请重启 DSH 后重试。", "The LAN address changed. Restart DSH and try again."],
|
|
105
|
+
network_interface_unavailable: ["保存的局域网网卡未连接,请连接网络后重启 DSH。", "The saved LAN interface is disconnected. Connect it and restart DSH."],
|
|
106
|
+
listen_port_in_use: ["移动访问端口已被占用。", "The mobile-access port is already in use."]
|
|
107
|
+
}[code];
|
|
108
|
+
return message === void 0 ? code : message[zh ? 0 : 1];
|
|
109
|
+
}
|
|
98
110
|
function mobileExtensionRequest(path, init = {}) {
|
|
99
111
|
const target = new URL(path, location.href);
|
|
100
112
|
if (target.origin !== location.origin) throw new TypeError("mobile extension requests must stay on the DSH origin");
|
|
@@ -125,7 +137,7 @@ window.__ModuleLoader__.load({
|
|
|
125
137
|
const toggle = element("button");
|
|
126
138
|
toggle.dataset.primary = "true";
|
|
127
139
|
const pair = element("button");
|
|
128
|
-
pair.textContent = zh ? "
|
|
140
|
+
pair.textContent = zh ? "生成配对密钥" : "Create pairing key";
|
|
129
141
|
actions.append(toggle, pair);
|
|
130
142
|
panel.append(title, status, actions);
|
|
131
143
|
root.append(panel);
|
|
@@ -163,8 +175,10 @@ window.__ModuleLoader__.load({
|
|
|
163
175
|
body: "{}"
|
|
164
176
|
}).then(async (data) => {
|
|
165
177
|
const url = typeof data.pairUrl === "string" ? data.pairUrl : "";
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
const token = typeof data.token === "string" ? data.token : "";
|
|
179
|
+
const copied = token !== "" ? token : url;
|
|
180
|
+
if (copied !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(copied);
|
|
181
|
+
status.textContent = copied === "" ? zh ? "无法生成密钥" : "Could not create key" : zh ? `配对密钥已复制:${copied}${url === "" ? "" : `\n手机浏览器链接:${url}`}` : `Pairing key copied: ${copied}${url === "" ? "" : `\nMobile browser link: ${url}`}`;
|
|
168
182
|
}, (error) => {
|
|
169
183
|
status.textContent = error instanceof Error ? error.message : String(error);
|
|
170
184
|
}).finally(() => {
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`)\n return body\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) {\n const code = typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`\n throw new Error(controlErrorMessage(code))\n }\n return body\n}\n\nfunction controlErrorMessage(code: string): string {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const messages: Record<string, readonly [string, string]> = {\n network_address_changed: ['局域网地址已变化,请重启 DSH 后重试。', 'The LAN address changed. Restart DSH and try again.'],\n network_interface_unavailable: ['保存的局域网网卡未连接,请连接网络后重启 DSH。', 'The saved LAN interface is disconnected. Connect it and restart DSH.'],\n listen_port_in_use: ['移动访问端口已被占用。', 'The mobile-access port is already in use.'],\n }\n const message = messages[code]\n return message === undefined ? code : message[zh ? 0 : 1]\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对密钥' : 'Create pairing key'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n const token = typeof data.token === 'string' ? data.token : ''\n const copied = token !== '' ? token : url\n if (copied !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(copied)\n status.textContent = copied === '' ? (zh ? '无法生成密钥' : 'Could not create key')\n : (zh\n ? `配对密钥已复制:${copied}${url === '' ? '' : `\\n手机浏览器链接:${url}`}`\n : `Pairing key copied: ${copied}${url === '' ? '' : `\\nMobile browser link: ${url}`}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
package/lib/index.d.mts
CHANGED
|
@@ -239,6 +239,8 @@ interface PluginConfig {
|
|
|
239
239
|
customCssFile?: string;
|
|
240
240
|
/** Optional user script that mounts authenticated mobile-only Web features. */
|
|
241
241
|
customScriptFile?: string;
|
|
242
|
+
/** Stable public discovery identifier; it is not an authentication secret. */
|
|
243
|
+
instanceId?: string;
|
|
242
244
|
/** First-run state used only while the control file does not exist. */
|
|
243
245
|
initiallyEnabled: boolean;
|
|
244
246
|
tls?: {
|
|
@@ -271,6 +273,7 @@ interface ResolvedGatewayConfig {
|
|
|
271
273
|
readonly stateFile: string;
|
|
272
274
|
readonly customCssFile: string;
|
|
273
275
|
readonly customScriptFile: string;
|
|
276
|
+
readonly instanceId: string;
|
|
274
277
|
readonly tls: TlsConfig;
|
|
275
278
|
readonly pairingTtlMs: number;
|
|
276
279
|
readonly deviceTtlMs: number;
|
|
@@ -375,6 +378,8 @@ declare class MobileAccessGateway {
|
|
|
375
378
|
private setSessionCookies;
|
|
376
379
|
private handlePair;
|
|
377
380
|
private handleRenew;
|
|
381
|
+
private handleNativePair;
|
|
382
|
+
private handleNativeRenew;
|
|
378
383
|
private handleLogout;
|
|
379
384
|
private handleExternalRequest;
|
|
380
385
|
private allocateRequest;
|
package/lib/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
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";
|
|
@@ -7,6 +7,8 @@ import { createServer, request } from "node:http";
|
|
|
7
7
|
import { createServer as createServer$1 } from "node:https";
|
|
8
8
|
import { Transform } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { networkInterfaces } from "node:os";
|
|
11
|
+
import { generate } from "selfsigned";
|
|
10
12
|
//#region src/access.ts
|
|
11
13
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
12
14
|
var AccessError = class extends Error {
|
|
@@ -541,6 +543,7 @@ const Config = z.object({
|
|
|
541
543
|
controlFile: z.string().hidden().required(),
|
|
542
544
|
customCssFile: z.string().hidden(),
|
|
543
545
|
customScriptFile: z.string().hidden(),
|
|
546
|
+
instanceId: z.string().hidden(),
|
|
544
547
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
545
548
|
tls: z.object({
|
|
546
549
|
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
@@ -662,6 +665,9 @@ function parseGatewayConfig(raw) {
|
|
|
662
665
|
stateFile: absoluteFile(value.stateFile, "stateFile"),
|
|
663
666
|
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
664
667
|
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
668
|
+
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
669
|
+
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
670
|
+
})(),
|
|
665
671
|
tls,
|
|
666
672
|
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
667
673
|
deviceTtlMs,
|
|
@@ -1467,6 +1473,33 @@ var MobileAccessGateway = class {
|
|
|
1467
1473
|
sessionExpiresAt: result.sessionExpiresAt
|
|
1468
1474
|
}, this.tlsEnabled);
|
|
1469
1475
|
}
|
|
1476
|
+
async handleNativePair(request, response) {
|
|
1477
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1478
|
+
if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
|
|
1479
|
+
const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
|
|
1480
|
+
sendJson(response, 201, {
|
|
1481
|
+
instanceId: this.config.instanceId,
|
|
1482
|
+
deviceId: result.deviceId,
|
|
1483
|
+
deviceToken: result.deviceToken,
|
|
1484
|
+
deviceExpiresAt: result.deviceExpiresAt,
|
|
1485
|
+
sessionToken: result.sessionToken,
|
|
1486
|
+
csrfToken: result.csrfToken,
|
|
1487
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1488
|
+
}, this.tlsEnabled);
|
|
1489
|
+
}
|
|
1490
|
+
async handleNativeRenew(request, response) {
|
|
1491
|
+
if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
|
|
1492
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1493
|
+
if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
|
|
1494
|
+
const result = await this.access.renew(body.deviceToken);
|
|
1495
|
+
sendJson(response, 200, {
|
|
1496
|
+
instanceId: this.config.instanceId,
|
|
1497
|
+
deviceId: result.deviceId,
|
|
1498
|
+
sessionToken: result.sessionToken,
|
|
1499
|
+
csrfToken: result.csrfToken,
|
|
1500
|
+
sessionExpiresAt: result.sessionExpiresAt
|
|
1501
|
+
}, this.tlsEnabled);
|
|
1502
|
+
}
|
|
1470
1503
|
async handleLogout(request, response) {
|
|
1471
1504
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
1472
1505
|
const authorization = this.authorize(request);
|
|
@@ -1494,6 +1527,14 @@ var MobileAccessGateway = class {
|
|
|
1494
1527
|
sendJson(response, 200, { ok: true }, this.tlsEnabled);
|
|
1495
1528
|
return;
|
|
1496
1529
|
}
|
|
1530
|
+
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
|
|
1531
|
+
sendJson(response, 200, {
|
|
1532
|
+
protocol: 1,
|
|
1533
|
+
instanceId: this.config.instanceId,
|
|
1534
|
+
port: this.address().port
|
|
1535
|
+
}, this.tlsEnabled);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1497
1538
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
1498
1539
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
1499
1540
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
@@ -1524,6 +1565,14 @@ var MobileAccessGateway = class {
|
|
|
1524
1565
|
await this.handleRenew(request, response);
|
|
1525
1566
|
return;
|
|
1526
1567
|
}
|
|
1568
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-pair`) {
|
|
1569
|
+
await this.handleNativePair(request, response);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-renew`) {
|
|
1573
|
+
await this.handleNativeRenew(request, response);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1527
1576
|
if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
|
|
1528
1577
|
await this.handleLogout(request, response);
|
|
1529
1578
|
return;
|
|
@@ -2016,13 +2065,190 @@ var MemoryDeviceStore = class {
|
|
|
2016
2065
|
}
|
|
2017
2066
|
};
|
|
2018
2067
|
//#endregion
|
|
2068
|
+
//#region src/managed-setup.ts
|
|
2069
|
+
function requiredString(value, name) {
|
|
2070
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
2071
|
+
return value;
|
|
2072
|
+
}
|
|
2073
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
2074
|
+
function parseManagedSetup(value) {
|
|
2075
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
2076
|
+
const record = value;
|
|
2077
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
2078
|
+
"version",
|
|
2079
|
+
"networkInterface",
|
|
2080
|
+
"listenPort",
|
|
2081
|
+
"upstreamOrigin",
|
|
2082
|
+
"tls"
|
|
2083
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2084
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
2085
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
2086
|
+
const tls = record.tls;
|
|
2087
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
2088
|
+
"mode",
|
|
2089
|
+
"caCertFile",
|
|
2090
|
+
"caKeyFile",
|
|
2091
|
+
"certFile",
|
|
2092
|
+
"keyFile"
|
|
2093
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
2094
|
+
return Object.freeze({
|
|
2095
|
+
version: 2,
|
|
2096
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
2097
|
+
listenPort: record.listenPort,
|
|
2098
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
2099
|
+
tls: Object.freeze({
|
|
2100
|
+
mode: "managed",
|
|
2101
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
2102
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
2103
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
2104
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
2105
|
+
})
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
function privateIpv4(value) {
|
|
2109
|
+
const parts = value.split(".").map(Number);
|
|
2110
|
+
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);
|
|
2111
|
+
}
|
|
2112
|
+
function networkCidr(address, cidr) {
|
|
2113
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
2114
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
2115
|
+
return `${[
|
|
2116
|
+
24,
|
|
2117
|
+
16,
|
|
2118
|
+
8,
|
|
2119
|
+
0
|
|
2120
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
2121
|
+
}
|
|
2122
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
2123
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
2124
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
2125
|
+
name,
|
|
2126
|
+
address: entry.address,
|
|
2127
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
2128
|
+
})));
|
|
2129
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
2130
|
+
}
|
|
2131
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
2132
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
2133
|
+
const candidates = availableLanNetworks(table);
|
|
2134
|
+
if (requestedAddress !== void 0) {
|
|
2135
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
2136
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
2137
|
+
return match;
|
|
2138
|
+
}
|
|
2139
|
+
if (requestedInterface !== void 0) {
|
|
2140
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
2141
|
+
if (matches.length === 1) return matches[0];
|
|
2142
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
2143
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
2144
|
+
}
|
|
2145
|
+
if (candidates.length === 1) return candidates[0];
|
|
2146
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
2147
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
2148
|
+
}
|
|
2149
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
2150
|
+
const certificate = new X509Certificate(certPem);
|
|
2151
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
2152
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
2153
|
+
format: "der",
|
|
2154
|
+
type: "spki"
|
|
2155
|
+
});
|
|
2156
|
+
const certificatePublic = certificate.publicKey.export({
|
|
2157
|
+
format: "der",
|
|
2158
|
+
type: "spki"
|
|
2159
|
+
});
|
|
2160
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
2161
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
2162
|
+
return certificate;
|
|
2163
|
+
}
|
|
2164
|
+
async function atomicWrite(file, contents) {
|
|
2165
|
+
const directory = dirname(file);
|
|
2166
|
+
await mkdir(directory, {
|
|
2167
|
+
recursive: true,
|
|
2168
|
+
mode: 448
|
|
2169
|
+
});
|
|
2170
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
2171
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
2172
|
+
await chmod(temporary, 384);
|
|
2173
|
+
await rename(temporary, file);
|
|
2174
|
+
}
|
|
2175
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
2176
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
2177
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
2178
|
+
assertMatchingCa(caCert, caKey);
|
|
2179
|
+
const now = /* @__PURE__ */ new Date();
|
|
2180
|
+
const notAfter = new Date(now);
|
|
2181
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
2182
|
+
const server = await generate([{
|
|
2183
|
+
name: "commonName",
|
|
2184
|
+
value: "DeepSeek Harness Mobile"
|
|
2185
|
+
}], {
|
|
2186
|
+
keyType: "ec",
|
|
2187
|
+
curve: "P-256",
|
|
2188
|
+
algorithm: "sha256",
|
|
2189
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
2190
|
+
notAfterDate: notAfter,
|
|
2191
|
+
ca: {
|
|
2192
|
+
cert: caCert,
|
|
2193
|
+
key: caKey
|
|
2194
|
+
},
|
|
2195
|
+
extensions: [
|
|
2196
|
+
{
|
|
2197
|
+
name: "basicConstraints",
|
|
2198
|
+
cA: false,
|
|
2199
|
+
critical: true
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
name: "keyUsage",
|
|
2203
|
+
digitalSignature: true,
|
|
2204
|
+
critical: true
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "extKeyUsage",
|
|
2208
|
+
serverAuth: true
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
name: "subjectAltName",
|
|
2212
|
+
altNames: [{
|
|
2213
|
+
type: 7,
|
|
2214
|
+
ip: address
|
|
2215
|
+
}]
|
|
2216
|
+
}
|
|
2217
|
+
]
|
|
2218
|
+
});
|
|
2219
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
2220
|
+
}
|
|
2221
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
2222
|
+
async function materializeManagedSetup(setup, table) {
|
|
2223
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
2224
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
2225
|
+
const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
|
|
2226
|
+
return {
|
|
2227
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
2228
|
+
listenHost: network.address,
|
|
2229
|
+
upstreamOrigin: setup.upstreamOrigin,
|
|
2230
|
+
allowedCidrs: [network.cidr],
|
|
2231
|
+
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
2232
|
+
tls: {
|
|
2233
|
+
mode: "provided",
|
|
2234
|
+
certFile: setup.tls.certFile,
|
|
2235
|
+
keyFile: setup.tls.keyFile
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
//#endregion
|
|
2019
2240
|
//#region src/plugin.ts
|
|
2020
2241
|
/** Stable Cordis plugin name. */
|
|
2021
2242
|
const name = "dsh-mobile";
|
|
2022
2243
|
/** The stock WebServer is the only DSH Host service this plugin requires. */
|
|
2023
2244
|
const inject = ["webServer"];
|
|
2024
2245
|
function mapAdminError(error) {
|
|
2025
|
-
|
|
2246
|
+
if (error instanceof HttpError) return error;
|
|
2247
|
+
const code = error.code;
|
|
2248
|
+
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
2249
|
+
if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
|
|
2250
|
+
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
2251
|
+
return new HttpError(500, "internal_error");
|
|
2026
2252
|
}
|
|
2027
2253
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
2028
2254
|
"version",
|
|
@@ -2032,6 +2258,7 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
2032
2258
|
"upstreamOrigin",
|
|
2033
2259
|
"publicAuthorities",
|
|
2034
2260
|
"allowedCidrs",
|
|
2261
|
+
"instanceId",
|
|
2035
2262
|
"tls"
|
|
2036
2263
|
]);
|
|
2037
2264
|
async function loadSetup(config) {
|
|
@@ -2052,6 +2279,15 @@ async function loadSetup(config) {
|
|
|
2052
2279
|
}
|
|
2053
2280
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2054
2281
|
const record = parsed;
|
|
2282
|
+
if (record.version === 2) {
|
|
2283
|
+
const setup = await materializeManagedSetup(parseManagedSetup(record));
|
|
2284
|
+
const merged = { ...config };
|
|
2285
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2286
|
+
return {
|
|
2287
|
+
...merged,
|
|
2288
|
+
...setup
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2055
2291
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2056
2292
|
const { version: _version, ...setup } = record;
|
|
2057
2293
|
const merged = { ...config };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"dsh-mobile": "lib/cli.js"
|
|
8
|
+
"dsh-mobile": "lib/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"main": "./lib/index.mjs",
|
|
11
11
|
"types": "./lib/index.d.mts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"lib/**/*.mjs",
|
|
24
|
-
"lib/cli.js",
|
|
23
|
+
"lib/**/*.mjs",
|
|
24
|
+
"lib/cli.js",
|
|
25
25
|
"lib/**/*.d.mts",
|
|
26
26
|
"lib/client.js",
|
|
27
27
|
"lib/client.js.map",
|