dsh-vpn 0.1.7 → 0.1.9
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 +13 -5
- package/assets/vpn-ops.md +3 -2
- package/lib/client.js +204 -21
- package/lib/index.js +296 -180
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-vpn
|
|
2
2
|
|
|
3
|
-
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:在**你自己的 VPS** 上一键部署加密的 VPN 服务,保护你的网络连接安全。在 dsh 里填好 VPS 的 IP
|
|
3
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:在**你自己的 VPS** 上一键部署加密的 VPN 服务,保护你的网络连接安全。在 dsh 里填好 VPS 的 IP、端口、用户名和密码(或用密钥登录),一键部署 sing-box,然后扫二维码导入手机或电脑上的客户端。
|
|
4
4
|
|
|
5
5
|
## 用途:保护你的网络安全
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
## 特性
|
|
15
15
|
|
|
16
|
-
- **不依赖本机的 ssh 命令**:内置纯 JS 的 SSH 客户端([ssh2](https://github.com/mscdex/ssh2)
|
|
16
|
+
- **不依赖本机的 ssh 命令**:内置纯 JS 的 SSH 客户端([ssh2](https://github.com/mscdex/ssh2)),支持密码登录和密钥登录。
|
|
17
17
|
- **两种用法**:在设置页点按钮,或者直接对 agent 说“帮我装个节点”。
|
|
18
18
|
- **五种协议可选**:VLESS Reality、Hysteria2(默认)、TUIC v5、AnyTLS、Shadowsocks 2022。
|
|
19
19
|
- **免域名的正规证书**:可选 Let's Encrypt 为服务器 IP 签发的证书,所有客户端都能正常校验证书。
|
|
@@ -33,12 +33,19 @@ dsh plugin --profile web add dsh-vpn
|
|
|
33
33
|
|
|
34
34
|
在 VPN 设置里:
|
|
35
35
|
|
|
36
|
-
1. 填写服务器 IP、SSH
|
|
36
|
+
1. 填写服务器 IP、SSH 端口、用户名、密码,点「保存」和「测试连接」。服务器只允许密钥登录的,见下面的「密钥登录」。
|
|
37
37
|
2. 勾选要安装的协议,选择证书类型(推荐「IP 证书」),保存后点「安装 / 更新」。
|
|
38
38
|
3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。
|
|
39
39
|
4. 服务器防火墙的端口由插件自动放行,不用手动操作;安装完成后提示框会列出放行了哪些端口。
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
**密钥登录**:服务器只允许密钥登录(或者你不想保存密码)时,把「登录方式」选成「密钥」。插件会生成一把专用钥匙(纯 JS 生成,不需要本机的 ssh-keygen),页面上显示它的公钥,任选一种办法放到服务器上:
|
|
42
|
+
|
|
43
|
+
- A. 粘到服务商后台的「SSH 密钥」里,重装系统时勾选它(新机器推荐);
|
|
44
|
+
- B. 已经能登录服务器的(网页控制台或别的终端),复制页面上的一行命令粘贴执行。
|
|
45
|
+
|
|
46
|
+
放好后点「保存」和「测试连接」。已经有自己的私钥、服务器上也放好了对应公钥的,展开「改用我自己已有的私钥」粘贴进去即可(支持带密码短语的私钥)。用非 root 用户时,「sudo 密码」一栏填该用户的 sudo 密码;root 或免密 sudo 不用填。
|
|
47
|
+
|
|
48
|
+
**找回已有节点**:重装过 dsh 或 dsh-vpn 后,本地保存的设置会丢失,但 VPS 上的节点还在运行。在 VPN 设置里填好服务器 IP、端口、用户名和密码(或选密钥登录),**不用先保存**,直接点登录方式下面的「检查已有安装」:插件登录服务器读取仍在运行的节点,显示原来的二维码(链接和以前完全一样,已经导入手机或电脑客户端的配置继续可用),按服务器上的实际情况勾选协议和证书类型,并自动保存设置。登录失败时什么都不保存,填的内容也不会被清掉。
|
|
42
49
|
|
|
43
50
|
页面第一行显示插件版本,有新版本时旁边会提示,点「更新」即可升级插件,重启 dsh 后生效(需要 dsh 0.2 及以上;更早的版本请在「设置 → 插件」中更新)。为了供应链安全,发布不足 24 小时的新版本会等满 24 小时再提供更新。
|
|
44
51
|
|
|
@@ -84,7 +91,8 @@ IP 证书由 sing-box 1.14 内置的 ACME 向 Let's Encrypt 申请([2026 年
|
|
|
84
91
|
|
|
85
92
|
| 方面 | 做法 |
|
|
86
93
|
|---|---|
|
|
87
|
-
|
|
|
94
|
+
| 密码和私钥 | 存在 dsh 凭据库(`$DSH_HOME/.credentials.yaml`,权限 0600),不写进配置文件;浏览器只能写入,读不回来(专用钥匙只向页面提供公钥) |
|
|
95
|
+
| 登录方式 | 只用你选定的一种:选了密钥就只用密钥,不会退回密码;不使用键盘交互登录或 ssh-agent |
|
|
88
96
|
| 接口鉴权 | 设置页的接口挂在 dsh 自己的 `/api` 下,复用 dsh 的登录 cookie 和防跨站、防 DNS rebinding 检查 |
|
|
89
97
|
| 主机指纹 | 首次连接时记录 SSH 主机指纹,之后指纹变化就拒绝连接,防止中间人截获密码 |
|
|
90
98
|
| sudo | 非 root 用户用 `sudo -S`:密码只经 stdin 传入,不会出现在命令行,也不会传给脚本 |
|
package/assets/vpn-ops.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vpn-ops
|
|
2
2
|
|
|
3
|
-
Deploy and operate a personal, encrypted VPN (sing-box) on the user's own VPS. The plugin logs in over SSH with the password saved in dsh Settings (bottom-left gear) → VPN 设置. No local ssh client is needed.
|
|
3
|
+
Deploy and operate a personal, encrypted VPN (sing-box) on the user's own VPS. The plugin logs in over SSH with the password or key saved in dsh Settings (bottom-left gear) → VPN 设置. No local ssh client is needed.
|
|
4
4
|
|
|
5
5
|
## Purpose
|
|
6
6
|
|
|
@@ -33,7 +33,8 @@ The settings page offers **self-signed** (default) or **IP certificate** (recomm
|
|
|
33
33
|
|
|
34
34
|
| Error / symptom | Likely cause | What to do |
|
|
35
35
|
|---|---|---|
|
|
36
|
-
| `auth_failed` | Wrong username or password | User fixes it on the settings page |
|
|
36
|
+
| `auth_failed` | Wrong username or password, or the server only allows key login | User fixes it on the settings page (Login method → Key) |
|
|
37
|
+
| `key_rejected` | The server did not accept the key | User puts the public key shown on the settings page into authorized_keys |
|
|
37
38
|
| `host_key_mismatch` | Server reinstalled or replaced, or a man-in-the-middle | Ask whether the server was reinstalled. Only then may the user click 重置指纹 (Reset fingerprint) |
|
|
38
39
|
| `connect_failed` / `timeout` | Wrong IP or port, SSH not reachable, server down | Check the IP and SSH port on the settings page |
|
|
39
40
|
| `sudo_failed` | User is not root and lacks sudo, or the sudo password differs | Use root, or grant sudo |
|
package/lib/client.js
CHANGED
|
@@ -50,12 +50,14 @@ async function post(path, body = {}) {
|
|
|
50
50
|
var vpnApi = {
|
|
51
51
|
getSettings: () => post("/settings/get"),
|
|
52
52
|
saveSettings: (form) => post("/settings/save", form),
|
|
53
|
+
/** Public half of the plugin's dedicated key and the line that installs it. */
|
|
54
|
+
sshKey: () => post("/ssh-key"),
|
|
53
55
|
forgetHostKey: () => post("/host-key/forget"),
|
|
54
56
|
test: () => post("/test"),
|
|
55
57
|
status: () => post("/status"),
|
|
56
58
|
links: () => post("/links"),
|
|
57
59
|
/** Find an earlier installation on the server and adopt its protocols and certificate mode. */
|
|
58
|
-
detect: () => post("/detect"),
|
|
60
|
+
detect: (form) => post("/detect", form ?? {}),
|
|
59
61
|
install: (regenerate) => post("/install", { regenerate }),
|
|
60
62
|
uninstall: () => post("/uninstall"),
|
|
61
63
|
/** Installed version; with `check`, also the newest release on npm. */
|
|
@@ -89,6 +91,26 @@ var zh = {
|
|
|
89
91
|
port: "SSH \u7AEF\u53E3",
|
|
90
92
|
username: "\u7528\u6237\u540D",
|
|
91
93
|
usernameHint: "\u5EFA\u8BAE\u7528 root\uFF1B\u5176\u4ED6\u7528\u6237\u9700\u8981\u6709 sudo \u6743\u9650\u3002",
|
|
94
|
+
authMethod: "\u767B\u5F55\u65B9\u5F0F",
|
|
95
|
+
authPassword: "\u5BC6\u7801",
|
|
96
|
+
authKey: "\u5BC6\u94A5\uFF08\u670D\u52A1\u5668\u53EA\u5141\u8BB8\u5BC6\u94A5\u767B\u5F55\u65F6\u9009\u8FD9\u4E2A\uFF09",
|
|
97
|
+
sudoPassword: "sudo \u5BC6\u7801\uFF08\u9009\u586B\uFF09",
|
|
98
|
+
sudoPasswordEmpty: "\u7528 root \u6216\u514D\u5BC6 sudo \u65F6\u4E0D\u7528\u586B",
|
|
99
|
+
keyIntro: "\u7528\u63D2\u4EF6\u7684\u4E13\u7528\u94A5\u5319\u767B\u5F55\u3002\u628A\u4E0B\u9762\u7684\u516C\u94A5\u653E\u5230\u670D\u52A1\u5668\u4E0A\uFF0C\u4EFB\u9009\u4E00\u79CD\u529E\u6CD5\uFF1A",
|
|
100
|
+
keyWayConsole: "A. \u670D\u52A1\u5546\u540E\u53F0\u6DFB\u52A0\uFF08\u65B0\u673A\u5668\u63A8\u8350\uFF09",
|
|
101
|
+
keyWayConsoleHint: "\u7C98\u5230\u670D\u52A1\u5546\u7684\u300CSSH \u5BC6\u94A5\u300D\u91CC\uFF0C\u91CD\u88C5\u7CFB\u7EDF\u65F6\u52FE\u9009\u5B83\u3002",
|
|
102
|
+
keyWayCommand: "B. \u5DF2\u7ECF\u80FD\u767B\u5F55\uFF08\u7F51\u9875\u63A7\u5236\u53F0\u6216\u522B\u7684\u7EC8\u7AEF\uFF09",
|
|
103
|
+
keyWayCommandHint: "\u4EE5 {user} \u8EAB\u4EFD\u767B\u5F55\u670D\u52A1\u5668\uFF0C\u7C98\u8D34\u6267\u884C\u8FD9\u4E00\u884C\u3002",
|
|
104
|
+
keyThen: "\u653E\u597D\u540E\u70B9\u300C\u4FDD\u5B58\u300D\uFF0C\u518D\u70B9\u300C\u6D4B\u8BD5\u8FDE\u63A5\u300D\u3002",
|
|
105
|
+
copyPublicKey: "\u590D\u5236\u516C\u94A5",
|
|
106
|
+
copyCommand: "\u590D\u5236\u547D\u4EE4",
|
|
107
|
+
ownKeyToggle: "\u6539\u7528\u6211\u81EA\u5DF1\u5DF2\u6709\u7684\u79C1\u94A5",
|
|
108
|
+
ownKeyBack: "\u6539\u56DE\u63D2\u4EF6\u4E13\u7528\u94A5\u5319",
|
|
109
|
+
ownKey: "\u6211\u81EA\u5DF1\u7684\u79C1\u94A5",
|
|
110
|
+
ownKeyHint: "\u7C98\u8D34 OpenSSH \u6216 PEM \u683C\u5F0F\u7684\u79C1\u94A5\uFF08\u670D\u52A1\u5668\u4E0A\u5DF2\u7ECF\u653E\u4E86\u5B83\u5BF9\u5E94\u7684\u516C\u94A5\uFF09\u3002\u53EA\u4FDD\u5B58\u5728\u672C\u673A dsh \u7684\u51ED\u636E\u5E93\u91CC\uFF0C\u4E0D\u4F1A\u663E\u793A\u56DE\u9875\u9762\u3002",
|
|
111
|
+
ownKeySaved: "\u5DF2\u4FDD\u5B58\uFF08\u7559\u7A7A\u5219\u4E0D\u4FEE\u6539\uFF09",
|
|
112
|
+
passphrase: "\u79C1\u94A5\u5BC6\u7801\u77ED\u8BED\uFF08\u9009\u586B\uFF09",
|
|
113
|
+
passphraseHint: "\u79C1\u94A5\u6CA1\u6709\u52A0\u5BC6\u5C31\u4E0D\u7528\u586B",
|
|
92
114
|
password: "\u5BC6\u7801",
|
|
93
115
|
passwordSaved: "\u5DF2\u4FDD\u5B58\uFF08\u7559\u7A7A\u5219\u4E0D\u4FEE\u6539\uFF09",
|
|
94
116
|
passwordEmpty: "\u767B\u5F55 VPS \u7684\u5BC6\u7801",
|
|
@@ -135,6 +157,8 @@ var zh = {
|
|
|
135
157
|
qrSection: "\u8282\u70B9\u4E8C\u7EF4\u7801",
|
|
136
158
|
qrHint: "\u7528 v2rayN\u3001NekoBox\u3001Shadowrocket\u3001sing-box\u3001Clash Meta \u7B49\u5BA2\u6237\u7AEF\u626B\u63CF\u5BFC\u5165\u3002\u4E8C\u7EF4\u7801\u7B49\u540C\u4E8E\u5BC6\u7801\uFF0C\u8BF7\u52FF\u5916\u4F20\u3002",
|
|
137
159
|
detect: "\u68C0\u67E5\u5DF2\u6709\u5B89\u88C5",
|
|
160
|
+
detecting: "\u6B63\u5728\u8BFB\u53D6\u670D\u52A1\u5668\u2026",
|
|
161
|
+
detectHint: "\u91CD\u88C5\u8FC7 dsh \u6216\u672C\u63D2\u4EF6\uFF1F\u586B\u597D\u4E0A\u9762\u7684\u767B\u5F55\u4FE1\u606F\u76F4\u63A5\u70B9\u8FD9\u91CC\uFF08\u4E0D\u7528\u5148\u4FDD\u5B58\uFF09\uFF0C\u4F1A\u767B\u5F55\u670D\u52A1\u5668\u8BFB\u53D6\u4ECD\u5728\u8FD0\u884C\u7684\u8282\u70B9\uFF0C\u663E\u793A\u539F\u6765\u7684\u4E8C\u7EF4\u7801\uFF0C\u5E76\u81EA\u52A8\u4FDD\u5B58\u8BBE\u7F6E\u3002",
|
|
138
162
|
detectFound: "\u5DF2\u627E\u5230\u4E4B\u524D\u7684\u5B89\u88C5\uFF1Asing-box {version}\uFF0C\u534F\u8BAE\uFF1A{protocols}\uFF08{cert}\uFF09\u3002\u8282\u70B9\u4E8C\u7EF4\u7801\u5DF2\u663E\u793A\u5728\u4E0B\u65B9\uFF0C\u539F\u6709\u7684\u5BA2\u6237\u7AEF\u914D\u7F6E\u53EF\u4EE5\u7EE7\u7EED\u4F7F\u7528\u3002",
|
|
139
163
|
detectSynced: "\u5DF2\u6309\u670D\u52A1\u5668\u4E0A\u7684\u5B9E\u9645\u60C5\u51B5\u66F4\u65B0\u4E86\u534F\u8BAE\u52FE\u9009\u548C\u8BC1\u4E66\u7C7B\u578B\u3002",
|
|
140
164
|
detectNone: "\u8FD9\u53F0\u670D\u52A1\u5668\u4E0A\u6CA1\u6709\u627E\u5230\u4E4B\u524D\u7684\u5B89\u88C5\uFF0C\u53EF\u4EE5\u76F4\u63A5\u70B9\u300C\u5B89\u88C5 / \u66F4\u65B0\u300D\u3002",
|
|
@@ -152,7 +176,8 @@ var zh = {
|
|
|
152
176
|
fwStatus: "\u670D\u52A1\u5668\u9632\u706B\u5899\uFF08{backend}\uFF09\u5DF2\u653E\u884C {count} \u4E2A\u7AEF\u53E3\uFF0C\u91CD\u542F\u540E\u81EA\u52A8\u6062\u590D\u3002",
|
|
153
177
|
cloudHint: "\u5982\u679C\u4E91\u670D\u52A1\u5546\u6709\u5B89\u5168\u7EC4\uFF08\u963F\u91CC\u4E91\u3001\u817E\u8BAF\u4E91\u3001AWS \u7B49\uFF09\uFF0C\u8FD9\u4E9B\u7AEF\u53E3\u8FD8\u9700\u5728\u5176\u63A7\u5236\u53F0\u653E\u884C\u3002",
|
|
154
178
|
"err.not_configured": "\u8BF7\u5148\u586B\u5199\u5E76\u4FDD\u5B58\u670D\u52A1\u5668\u4FE1\u606F\u3002",
|
|
155
|
-
"err.auth_failed": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF\u3002",
|
|
179
|
+
"err.auth_failed": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF\u3002\u5982\u679C\u670D\u52A1\u5668\u53EA\u5141\u8BB8\u5BC6\u94A5\u767B\u5F55\uFF0C\u8BF7\u628A\u300C\u767B\u5F55\u65B9\u5F0F\u300D\u6539\u6210\u300C\u5BC6\u94A5\u300D\u3002",
|
|
180
|
+
"err.key_rejected": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u670D\u52A1\u5668\u6CA1\u6709\u63A5\u53D7\u8FD9\u628A\u94A5\u5319\u3002\u8BF7\u786E\u8BA4\u516C\u94A5\u5DF2\u653E\u5230\u8BE5\u7528\u6237\u7684 ~/.ssh/authorized_keys \u91CC\uFF0C\u7528\u6237\u540D\u586B\u5199\u6B63\u786E\u3002",
|
|
156
181
|
"err.host_key_mismatch": "\u670D\u52A1\u5668\u6307\u7EB9\u4E0E\u4E0A\u6B21\u4E0D\u540C\uFF0C\u5DF2\u62D2\u7EDD\u8FDE\u63A5\u3002\u5982\u679C\u4F60\u91CD\u88C5\u4E86\u7CFB\u7EDF\u6216\u6362\u4E86\u673A\u5668\uFF0C\u8BF7\u70B9\u300C\u91CD\u7F6E\u6307\u7EB9\u300D\u540E\u91CD\u8BD5\u3002",
|
|
157
182
|
"err.connect_failed": "\u65E0\u6CD5\u8FDE\u63A5\u670D\u52A1\u5668\uFF1A{message}",
|
|
158
183
|
"err.timeout": "\u64CD\u4F5C\u8D85\u65F6\u3002",
|
|
@@ -170,6 +195,8 @@ var zh = {
|
|
|
170
195
|
"field.port": "SSH \u7AEF\u53E3\u9700\u5728 1\u201365535 \u4E4B\u95F4\u3002",
|
|
171
196
|
"field.username": "\u7528\u6237\u540D\u683C\u5F0F\u4E0D\u6B63\u786E\u3002",
|
|
172
197
|
"field.password": "\u8BF7\u586B\u5199\u5BC6\u7801\u3002",
|
|
198
|
+
"field.auth": "\u767B\u5F55\u65B9\u5F0F\u4E0D\u6B63\u786E\u3002",
|
|
199
|
+
"field.privateKey": "\u79C1\u94A5\u65E0\u6CD5\u4F7F\u7528\uFF1A\u683C\u5F0F\u4E0D\u5BF9\uFF0C\u6216\u8005\u5BC6\u7801\u77ED\u8BED\u4E0D\u6B63\u786E\u3002",
|
|
173
200
|
"field.protocols": "\u8BF7\u81F3\u5C11\u52FE\u9009\u4E00\u4E2A\u534F\u8BAE\u3002",
|
|
174
201
|
"field.linkAddress": "\u8282\u70B9\u5730\u5740\u683C\u5F0F\u4E0D\u6B63\u786E\u3002",
|
|
175
202
|
"field.certMode": "IP \u8BC1\u4E66\u9700\u8981\u7528 IP \u5730\u5740\u4F5C\u4E3A\u8282\u70B9\u5730\u5740\uFF1A\u8BF7\u628A\u300C\u8282\u70B9\u5730\u5740\u300D\u7559\u7A7A\uFF08\u4F7F\u7528\u670D\u52A1\u5668 IP\uFF09\u6216\u586B\u5199 IP\uFF0C\u6216\u6539\u7528\u81EA\u7B7E\u8BC1\u4E66\u3002"
|
|
@@ -195,6 +222,26 @@ var en = {
|
|
|
195
222
|
port: "SSH port",
|
|
196
223
|
username: "Username",
|
|
197
224
|
usernameHint: "root is recommended; other users need sudo.",
|
|
225
|
+
authMethod: "Login method",
|
|
226
|
+
authPassword: "Password",
|
|
227
|
+
authKey: "Key (choose this if the server only allows key login)",
|
|
228
|
+
sudoPassword: "sudo password (optional)",
|
|
229
|
+
sudoPasswordEmpty: "Not needed for root or passwordless sudo",
|
|
230
|
+
keyIntro: "Log in with the plugin's dedicated key. Put the public key below on the server, either way:",
|
|
231
|
+
keyWayConsole: "A. Add it in your provider's console (recommended for new machines)",
|
|
232
|
+
keyWayConsoleHint: `Paste it into your provider's "SSH keys" and select it when reinstalling the OS.`,
|
|
233
|
+
keyWayCommand: "B. If you can already log in (web console or another terminal)",
|
|
234
|
+
keyWayCommandHint: "Log in to the server as {user} and paste and run this line.",
|
|
235
|
+
keyThen: "Then click Save, then Test connection.",
|
|
236
|
+
copyPublicKey: "Copy public key",
|
|
237
|
+
copyCommand: "Copy command",
|
|
238
|
+
ownKeyToggle: "Use my own existing private key instead",
|
|
239
|
+
ownKeyBack: "Back to the plugin's dedicated key",
|
|
240
|
+
ownKey: "My own private key",
|
|
241
|
+
ownKeyHint: "Paste an OpenSSH or PEM private key whose public key is already on the server. It is kept only in dsh's local credential store and never shown again.",
|
|
242
|
+
ownKeySaved: "Saved (leave blank to keep)",
|
|
243
|
+
passphrase: "Key passphrase (optional)",
|
|
244
|
+
passphraseHint: "Leave empty if the key is not encrypted",
|
|
198
245
|
password: "Password",
|
|
199
246
|
passwordSaved: "Saved (leave blank to keep)",
|
|
200
247
|
passwordEmpty: "Password for the VPS",
|
|
@@ -241,6 +288,8 @@ var en = {
|
|
|
241
288
|
qrSection: "Node QR codes",
|
|
242
289
|
qrHint: "Scan with v2rayN, NekoBox, Shadowrocket, sing-box, Clash Meta and similar apps. A QR code is as secret as a password.",
|
|
243
290
|
detect: "Find existing install",
|
|
291
|
+
detecting: "Reading the server\u2026",
|
|
292
|
+
detectHint: "Reinstalled dsh or this plugin? Fill in the login details above and click here (no need to save first): it logs in, reads the node that is still running, shows its QR codes and saves the settings.",
|
|
244
293
|
detectFound: "Found an earlier installation: sing-box {version}, protocols: {protocols} ({cert}). Its QR codes are shown below; clients you already set up keep working.",
|
|
245
294
|
detectSynced: "The protocol selection and certificate type now match the server.",
|
|
246
295
|
detectNone: "No earlier installation was found on this server. Click Install / Update to set one up.",
|
|
@@ -258,7 +307,8 @@ var en = {
|
|
|
258
307
|
fwStatus: "Server firewall ({backend}) has {count} ports opened; re-applied after reboots.",
|
|
259
308
|
cloudHint: "If your cloud provider has a security group (Aliyun, Tencent Cloud, AWS\u2026), also open these ports in its console.",
|
|
260
309
|
"err.not_configured": "Fill in and save the server details first.",
|
|
261
|
-
"err.auth_failed": "SSH login failed: wrong username or password.",
|
|
310
|
+
"err.auth_failed": "SSH login failed: wrong username or password. If the server only allows key login, set Login method to Key.",
|
|
311
|
+
"err.key_rejected": "SSH login failed: the server did not accept the key. Check that the public key is in that user's ~/.ssh/authorized_keys and the username is right.",
|
|
262
312
|
"err.host_key_mismatch": 'The server fingerprint changed, so the connection was refused. If you reinstalled or replaced the server, click "Reset fingerprint" and retry.',
|
|
263
313
|
"err.connect_failed": "Cannot connect to the server: {message}",
|
|
264
314
|
"err.timeout": "The operation timed out.",
|
|
@@ -276,6 +326,8 @@ var en = {
|
|
|
276
326
|
"field.port": "The SSH port must be 1\u201365535.",
|
|
277
327
|
"field.username": "The username is not valid.",
|
|
278
328
|
"field.password": "Enter the password.",
|
|
329
|
+
"field.auth": "Unknown login method.",
|
|
330
|
+
"field.privateKey": "The private key cannot be used: wrong format or wrong passphrase.",
|
|
279
331
|
"field.protocols": "Select at least one protocol.",
|
|
280
332
|
"field.linkAddress": "The node address is not valid.",
|
|
281
333
|
"field.certMode": 'An IP certificate needs an IP address as the node address: leave "Node address" empty (uses the server IP) or enter an IP, or use self-signed.'
|
|
@@ -317,6 +369,22 @@ var STYLES = `
|
|
|
317
369
|
.dshvpn-qr { background: #fff; padding: 6px; border-radius: 6px; line-height: 0; align-self: center; }
|
|
318
370
|
.dshvpn-qr svg { width: 180px; height: 180px; }
|
|
319
371
|
.dshvpn-link { display: block; max-width: 100%; max-height: 3.2em; overflow: hidden; color: var(--dsw-alias-label-secondary, #666); }
|
|
372
|
+
.dshvpn-auth { display: flex; flex-direction: column; gap: 6px; }
|
|
373
|
+
.dshvpn-auth-options { display: flex; flex-wrap: wrap; gap: 18px; }
|
|
374
|
+
.dshvpn-grid .dshvpn-auth-options label.dshvpn-radio { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }
|
|
375
|
+
.dshvpn-auth-options .dshvpn-radio input { margin: 0; }
|
|
376
|
+
.dshvpn-keypanel { display: flex; flex-direction: column; gap: 10px; }
|
|
377
|
+
.dshvpn-keypanel p { margin: 0; }
|
|
378
|
+
.dshvpn-keycard { display: flex; flex-direction: column; gap: 6px; padding: 12px; border-radius: 10px;
|
|
379
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
|
|
380
|
+
.dshvpn-copyable { display: flex; align-items: flex-start; gap: 8px; }
|
|
381
|
+
.dshvpn-copyable code { flex: 1; min-width: 0; font-size: 12px; word-break: break-all; white-space: pre-wrap; padding: 6px 8px; border-radius: 6px;
|
|
382
|
+
background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12)); }
|
|
383
|
+
.dshvpn-disclosure { align-self: flex-start; border: none; background: transparent; color: inherit; padding: 0; cursor: pointer; font-size: 12.5px; opacity: .75; }
|
|
384
|
+
.dshvpn-textarea { width: 100%; box-sizing: border-box; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 8px; border-radius: 8px; resize: vertical;
|
|
385
|
+
color: inherit; background: transparent; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
|
|
386
|
+
.dshvpn-detect { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 4px; }
|
|
387
|
+
.dshvpn-detect small { flex: 1; min-width: 220px; }
|
|
320
388
|
.dshvpn-section { display: flex; flex-direction: column; gap: 16px; }
|
|
321
389
|
.dshvpn-section-head h2 { margin: 0; font-size: 18px; font-weight: 600; }
|
|
322
390
|
.dshvpn-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 4px; }
|
|
@@ -1044,6 +1112,7 @@ var import_jsx_runtime = require("react/jsx-runtime");
|
|
|
1044
1112
|
var ERROR_KEYS = /* @__PURE__ */ new Set([
|
|
1045
1113
|
"not_configured",
|
|
1046
1114
|
"auth_failed",
|
|
1115
|
+
"key_rejected",
|
|
1047
1116
|
"host_key_mismatch",
|
|
1048
1117
|
"connect_failed",
|
|
1049
1118
|
"timeout",
|
|
@@ -1057,16 +1126,28 @@ var ERROR_KEYS = /* @__PURE__ */ new Set([
|
|
|
1057
1126
|
"port80_in_use",
|
|
1058
1127
|
"cert_failed"
|
|
1059
1128
|
]);
|
|
1060
|
-
var FIELD_KEYS = /* @__PURE__ */ new Set(["host", "port", "username", "password", "protocols", "linkAddress", "certMode"]);
|
|
1129
|
+
var FIELD_KEYS = /* @__PURE__ */ new Set(["host", "port", "username", "password", "auth", "privateKey", "protocols", "linkAddress", "certMode"]);
|
|
1061
1130
|
function errorText(t, f) {
|
|
1062
1131
|
if (f.error === "invalid" && f.field !== void 0 && FIELD_KEYS.has(f.field)) return t(`field.${f.field}`);
|
|
1063
1132
|
const key = ERROR_KEYS.has(f.error) ? `err.${f.error}` : "err.other";
|
|
1064
1133
|
return t(key, { message: f.message ?? f.error });
|
|
1065
1134
|
}
|
|
1066
1135
|
function formOf(s) {
|
|
1067
|
-
return {
|
|
1136
|
+
return {
|
|
1137
|
+
host: s.host,
|
|
1138
|
+
port: s.port,
|
|
1139
|
+
username: s.username,
|
|
1140
|
+
password: "",
|
|
1141
|
+
auth: s.auth,
|
|
1142
|
+
privateKey: "",
|
|
1143
|
+
passphrase: "",
|
|
1144
|
+
clearPrivateKey: false,
|
|
1145
|
+
protocols: [...s.protocols],
|
|
1146
|
+
certMode: s.certMode,
|
|
1147
|
+
linkAddress: s.linkAddress
|
|
1148
|
+
};
|
|
1068
1149
|
}
|
|
1069
|
-
var sameForm = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username && a.password === b.password && a.linkAddress === b.linkAddress && a.certMode === b.certMode && a.protocols.join() === [...b.protocols].join();
|
|
1150
|
+
var sameForm = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username && a.password === b.password && a.auth === b.auth && a.privateKey === b.privateKey && a.passphrase === b.passphrase && a.clearPrivateKey === b.clearPrivateKey && a.linkAddress === b.linkAddress && a.certMode === b.certMode && a.protocols.join() === [...b.protocols].join();
|
|
1070
1151
|
function Checkbox({ label, checked, disabled, onChange }) {
|
|
1071
1152
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-checkbox", children: [
|
|
1072
1153
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked, disabled, onChange: (e) => {
|
|
@@ -1075,6 +1156,93 @@ function Checkbox({ label, checked, disabled, onChange }) {
|
|
|
1075
1156
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: label })
|
|
1076
1157
|
] });
|
|
1077
1158
|
}
|
|
1159
|
+
function Copyable({ text, label, t }) {
|
|
1160
|
+
const [copied, setCopied] = (0, import_react.useState)(false);
|
|
1161
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-copyable", children: [
|
|
1162
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: text }),
|
|
1163
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
|
|
1164
|
+
void (0, import_dsh_client_ui_primitives.writeClipboard)(text).then(() => {
|
|
1165
|
+
setCopied(true);
|
|
1166
|
+
setTimeout(() => {
|
|
1167
|
+
setCopied(false);
|
|
1168
|
+
}, 1500);
|
|
1169
|
+
});
|
|
1170
|
+
}, children: copied ? t("copied") : label })
|
|
1171
|
+
] });
|
|
1172
|
+
}
|
|
1173
|
+
function KeyPanel({ t, api, form, saved, set, setForm }) {
|
|
1174
|
+
const [key, setKey] = (0, import_react.useState)(null);
|
|
1175
|
+
const ownStored = saved.hasPrivateKey && !form.clearPrivateKey;
|
|
1176
|
+
const [own, setOwn] = (0, import_react.useState)(ownStored);
|
|
1177
|
+
(0, import_react.useEffect)(() => {
|
|
1178
|
+
let live = true;
|
|
1179
|
+
void api.sshKey().then((r) => {
|
|
1180
|
+
if (live && r.ok) setKey(r);
|
|
1181
|
+
});
|
|
1182
|
+
return () => {
|
|
1183
|
+
live = false;
|
|
1184
|
+
};
|
|
1185
|
+
}, [api]);
|
|
1186
|
+
const preparing = t("working");
|
|
1187
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keypanel dshvpn-wide", children: [
|
|
1188
|
+
!own && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
1189
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dshvpn-muted", children: t("keyIntro") }),
|
|
1190
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
|
|
1191
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keyWayConsole") }),
|
|
1192
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyWayConsoleHint") }),
|
|
1193
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Copyable, { t, text: key?.publicKey ?? preparing, label: t("copyPublicKey") })
|
|
1194
|
+
] }),
|
|
1195
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
|
|
1196
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keyWayCommand") }),
|
|
1197
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyWayCommandHint", { user: form.username || "root" }) }),
|
|
1198
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Copyable, { t, text: key?.command ?? preparing, label: t("copyCommand") })
|
|
1199
|
+
] }),
|
|
1200
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyThen") })
|
|
1201
|
+
] }),
|
|
1202
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dshvpn-disclosure", onClick: () => {
|
|
1203
|
+
if (own) {
|
|
1204
|
+
setOwn(false);
|
|
1205
|
+
setForm({ ...form, privateKey: "", passphrase: "", clearPrivateKey: saved.hasPrivateKey });
|
|
1206
|
+
} else {
|
|
1207
|
+
setOwn(true);
|
|
1208
|
+
setForm({ ...form, clearPrivateKey: false });
|
|
1209
|
+
}
|
|
1210
|
+
}, children: own ? `\u25BE ${t("ownKeyBack")}` : `\u25B8 ${t("ownKeyToggle")}` }),
|
|
1211
|
+
own && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
|
|
1212
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("ownKey") }),
|
|
1213
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("ownKeyHint") }),
|
|
1214
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1215
|
+
"textarea",
|
|
1216
|
+
{
|
|
1217
|
+
className: "dshvpn-textarea",
|
|
1218
|
+
rows: 5,
|
|
1219
|
+
spellCheck: false,
|
|
1220
|
+
autoComplete: "off",
|
|
1221
|
+
value: form.privateKey,
|
|
1222
|
+
placeholder: ownStored ? t("ownKeySaved") : "-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
1223
|
+
onChange: (e) => {
|
|
1224
|
+
set("privateKey", e.target.value);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
),
|
|
1228
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
|
|
1229
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("passphrase") }),
|
|
1230
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1231
|
+
import_dsh_client_ui_primitives.Input,
|
|
1232
|
+
{
|
|
1233
|
+
type: "password",
|
|
1234
|
+
value: form.passphrase,
|
|
1235
|
+
autoComplete: "new-password",
|
|
1236
|
+
placeholder: t("passphraseHint"),
|
|
1237
|
+
onChange: (e) => {
|
|
1238
|
+
set("passphrase", e.target.value);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
)
|
|
1242
|
+
] })
|
|
1243
|
+
] })
|
|
1244
|
+
] });
|
|
1245
|
+
}
|
|
1078
1246
|
function QrCard({ item, t }) {
|
|
1079
1247
|
const [copied, setCopied] = (0, import_react.useState)(false);
|
|
1080
1248
|
const info = PROTOCOLS.find((p) => p.id === item.id);
|
|
@@ -1143,12 +1311,12 @@ function VpnSettingsPage({ view, t, api }) {
|
|
|
1143
1311
|
setBusy(null);
|
|
1144
1312
|
}
|
|
1145
1313
|
}
|
|
1146
|
-
const detect = async () => {
|
|
1147
|
-
await op("detect", api.detect, (
|
|
1148
|
-
const res =
|
|
1314
|
+
const detect = async (fromForm = true) => {
|
|
1315
|
+
const r = await op("detect", () => api.detect(fromForm ? form : void 0), (r2) => {
|
|
1316
|
+
const res = r2.result ?? {};
|
|
1149
1317
|
if (res.installed !== true) return { tone: "info", text: t("detectNone") };
|
|
1150
|
-
if (
|
|
1151
|
-
setLinks(
|
|
1318
|
+
if (r2.links) {
|
|
1319
|
+
setLinks(r2.links);
|
|
1152
1320
|
setRefreshedAt(/* @__PURE__ */ new Date());
|
|
1153
1321
|
}
|
|
1154
1322
|
const protocols = (res.protocols ?? []).map((id) => PROTOCOLS.find((p) => p.id === id)?.name ?? id).join("\u3001");
|
|
@@ -1157,10 +1325,12 @@ function VpnSettingsPage({ view, t, api }) {
|
|
|
1157
1325
|
return { tone: "success", text: res.synced === true ? `${text}
|
|
1158
1326
|
${t("detectSynced")}` : text };
|
|
1159
1327
|
});
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1328
|
+
if (r.ok || r.links) {
|
|
1329
|
+
const s = await api.getSettings();
|
|
1330
|
+
if (s.ok) {
|
|
1331
|
+
setSaved(s.settings);
|
|
1332
|
+
setForm(formOf(s.settings));
|
|
1333
|
+
}
|
|
1164
1334
|
}
|
|
1165
1335
|
};
|
|
1166
1336
|
const save = async () => {
|
|
@@ -1170,7 +1340,7 @@ ${t("detectSynced")}` : text };
|
|
|
1170
1340
|
setForm(formOf(r2.settings));
|
|
1171
1341
|
return { tone: "success", text: t("saved") };
|
|
1172
1342
|
});
|
|
1173
|
-
if (r.ok && first) await detect();
|
|
1343
|
+
if (r.ok && first) await detect(false);
|
|
1174
1344
|
};
|
|
1175
1345
|
const refreshSettings = async () => {
|
|
1176
1346
|
const r = await api.getSettings();
|
|
@@ -1232,20 +1402,36 @@ ${t("cloudHint")}`;
|
|
|
1232
1402
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("usernameHint") })
|
|
1233
1403
|
] }),
|
|
1234
1404
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
|
|
1235
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("password") }),
|
|
1405
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: form.auth === "key" ? t("sudoPassword") : t("password") }),
|
|
1236
1406
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1237
1407
|
import_dsh_client_ui_primitives.Input,
|
|
1238
1408
|
{
|
|
1239
1409
|
type: "password",
|
|
1240
1410
|
value: form.password,
|
|
1241
1411
|
autoComplete: "new-password",
|
|
1242
|
-
placeholder: saved.hasPassword ? t("passwordSaved") : t("passwordEmpty"),
|
|
1412
|
+
placeholder: saved.hasPassword ? t("passwordSaved") : form.auth === "key" ? t("sudoPasswordEmpty") : t("passwordEmpty"),
|
|
1243
1413
|
onChange: (e) => {
|
|
1244
1414
|
set("password", e.target.value);
|
|
1245
1415
|
}
|
|
1246
1416
|
}
|
|
1247
1417
|
)
|
|
1248
1418
|
] }),
|
|
1419
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-wide dshvpn-auth", children: [
|
|
1420
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("authMethod") }),
|
|
1421
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-auth-options", children: ["password", "key"].map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-radio", children: [
|
|
1422
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "radio", name: "dshvpn-auth", checked: form.auth === m, disabled, onChange: () => {
|
|
1423
|
+
set("auth", m);
|
|
1424
|
+
} }),
|
|
1425
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: m === "key" ? t("authKey") : t("authPassword") })
|
|
1426
|
+
] }, m)) })
|
|
1427
|
+
] }),
|
|
1428
|
+
form.auth === "key" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyPanel, { t, api, form, saved, set, setForm }),
|
|
1429
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-wide dshvpn-detect", children: [
|
|
1430
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "primary", disabled: disabled || form.host.trim() === "", onClick: () => {
|
|
1431
|
+
void detect();
|
|
1432
|
+
}, children: busy === "detect" ? t("detecting") : t("detect") }),
|
|
1433
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("detectHint") })
|
|
1434
|
+
] }),
|
|
1249
1435
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-wide", children: [
|
|
1250
1436
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("linkAddress") }),
|
|
1251
1437
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Input, { value: form.linkAddress, placeholder: form.host, onChange: (e) => {
|
|
@@ -1334,9 +1520,6 @@ ${t("cloudHint")}`;
|
|
|
1334
1520
|
children: t("test")
|
|
1335
1521
|
}
|
|
1336
1522
|
),
|
|
1337
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "outline", disabled: disabled || needsSave, onClick: () => {
|
|
1338
|
-
void detect();
|
|
1339
|
-
}, children: busy === "detect" ? t("working") : t("detect") }),
|
|
1340
1523
|
dirty && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-warn", children: t("unsaved") })
|
|
1341
1524
|
] }),
|
|
1342
1525
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { children: [
|
package/lib/index.js
CHANGED
|
@@ -20164,6 +20164,150 @@ var require_lib2 = __commonJS({
|
|
|
20164
20164
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
20165
20165
|
import Schema from "@deepseek-ai/schemastery";
|
|
20166
20166
|
|
|
20167
|
+
// src/ssh.ts
|
|
20168
|
+
var import_ssh2 = __toESM(require_lib2(), 1);
|
|
20169
|
+
import { createHash } from "node:crypto";
|
|
20170
|
+
var SshError = class extends Error {
|
|
20171
|
+
constructor(code, message, details = {}) {
|
|
20172
|
+
super(message);
|
|
20173
|
+
this.code = code;
|
|
20174
|
+
this.details = details;
|
|
20175
|
+
this.name = "SshError";
|
|
20176
|
+
}
|
|
20177
|
+
code;
|
|
20178
|
+
details;
|
|
20179
|
+
};
|
|
20180
|
+
function privateKeyProblem(key, passphrase) {
|
|
20181
|
+
const parsed = import_ssh2.utils.parseKey(key, passphrase || void 0);
|
|
20182
|
+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
20183
|
+
if (first instanceof Error) return first.message;
|
|
20184
|
+
if (!first || !first.isPrivateKey()) return "not a private key";
|
|
20185
|
+
return void 0;
|
|
20186
|
+
}
|
|
20187
|
+
function generateKeyPair(comment) {
|
|
20188
|
+
for (let i = 0; i < 20; i++) {
|
|
20189
|
+
const k = import_ssh2.utils.generateKeyPairSync("ed25519", { comment });
|
|
20190
|
+
try {
|
|
20191
|
+
if (privateKeyProblem(k.private) === void 0) return { privateKey: k.private, publicKey: k.public.trim() };
|
|
20192
|
+
} catch {
|
|
20193
|
+
}
|
|
20194
|
+
}
|
|
20195
|
+
throw new Error("could not generate a usable SSH key");
|
|
20196
|
+
}
|
|
20197
|
+
function fingerprint(key) {
|
|
20198
|
+
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
20199
|
+
}
|
|
20200
|
+
var STDERR_TAIL = 8e3;
|
|
20201
|
+
var SshSession = class _SshSession {
|
|
20202
|
+
constructor(client, hostKey) {
|
|
20203
|
+
this.client = client;
|
|
20204
|
+
this.hostKey = hostKey;
|
|
20205
|
+
}
|
|
20206
|
+
client;
|
|
20207
|
+
hostKey;
|
|
20208
|
+
static async open(target, signal, timeoutMs = 2e4) {
|
|
20209
|
+
signal.throwIfAborted();
|
|
20210
|
+
const client = new import_ssh2.Client();
|
|
20211
|
+
let seen;
|
|
20212
|
+
const config = {
|
|
20213
|
+
host: target.host,
|
|
20214
|
+
port: target.port,
|
|
20215
|
+
username: target.username,
|
|
20216
|
+
// Exactly one method: the key, or the password. Never keyboard-interactive or agents.
|
|
20217
|
+
...target.privateKey ? { privateKey: target.privateKey, ...target.passphrase ? { passphrase: target.passphrase } : {} } : { password: target.password },
|
|
20218
|
+
readyTimeout: timeoutMs,
|
|
20219
|
+
tryKeyboard: false,
|
|
20220
|
+
hostVerifier: (key) => {
|
|
20221
|
+
seen = fingerprint(key);
|
|
20222
|
+
return target.hostKey === void 0 || target.hostKey === seen;
|
|
20223
|
+
}
|
|
20224
|
+
};
|
|
20225
|
+
return await new Promise((resolve, reject) => {
|
|
20226
|
+
const onAbort = () => {
|
|
20227
|
+
client.end();
|
|
20228
|
+
reject(new SshError("cancelled", "cancelled"));
|
|
20229
|
+
};
|
|
20230
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
20231
|
+
client.once("ready", () => {
|
|
20232
|
+
signal.removeEventListener("abort", onAbort);
|
|
20233
|
+
resolve(new _SshSession(client, seen ?? ""));
|
|
20234
|
+
});
|
|
20235
|
+
client.once("error", (err) => {
|
|
20236
|
+
signal.removeEventListener("abort", onAbort);
|
|
20237
|
+
client.end();
|
|
20238
|
+
if (target.hostKey !== void 0 && seen !== void 0 && seen !== target.hostKey) {
|
|
20239
|
+
reject(new SshError(
|
|
20240
|
+
"host_key_mismatch",
|
|
20241
|
+
`host key changed: expected ${target.hostKey}, got ${seen}`,
|
|
20242
|
+
{ expected: target.hostKey, actual: seen }
|
|
20243
|
+
));
|
|
20244
|
+
} else if (err.level === "client-authentication") {
|
|
20245
|
+
reject(target.privateKey ? new SshError("key_rejected", `SSH login failed: ${target.username}@${target.host} did not accept the key`) : new SshError("auth_failed", "SSH login failed: wrong username or password"));
|
|
20246
|
+
} else if (err.level === "client-timeout") {
|
|
20247
|
+
reject(new SshError("timeout", `timed out connecting to ${target.host}:${target.port}`));
|
|
20248
|
+
} else {
|
|
20249
|
+
reject(new SshError("connect_failed", `cannot connect to ${target.host}:${target.port}: ${err.message}`));
|
|
20250
|
+
}
|
|
20251
|
+
});
|
|
20252
|
+
client.connect(config);
|
|
20253
|
+
});
|
|
20254
|
+
}
|
|
20255
|
+
/** Run one command; `stdin` is written then closed. Never uses a pty. */
|
|
20256
|
+
async exec(command, stdin, signal) {
|
|
20257
|
+
signal.throwIfAborted();
|
|
20258
|
+
const stream = await new Promise((resolve, reject) => {
|
|
20259
|
+
this.client.exec(command, (err, s) => {
|
|
20260
|
+
if (err) reject(err);
|
|
20261
|
+
else resolve(s);
|
|
20262
|
+
});
|
|
20263
|
+
});
|
|
20264
|
+
let stdout = "";
|
|
20265
|
+
let stderr = "";
|
|
20266
|
+
stream.setEncoding("utf8");
|
|
20267
|
+
stream.on("data", (chunk) => {
|
|
20268
|
+
stdout += chunk;
|
|
20269
|
+
});
|
|
20270
|
+
stream.stderr.setEncoding("utf8");
|
|
20271
|
+
stream.stderr.on("data", (chunk) => {
|
|
20272
|
+
stderr = (stderr + chunk).slice(-STDERR_TAIL);
|
|
20273
|
+
});
|
|
20274
|
+
let cleanup = () => {
|
|
20275
|
+
};
|
|
20276
|
+
try {
|
|
20277
|
+
const code = await new Promise((resolve, reject) => {
|
|
20278
|
+
const onAbort = () => {
|
|
20279
|
+
reject(signal.reason ?? new SshError("cancelled", "cancelled"));
|
|
20280
|
+
stream.close();
|
|
20281
|
+
this.client.end();
|
|
20282
|
+
};
|
|
20283
|
+
const onGone = () => {
|
|
20284
|
+
reject(new SshError("connect_failed", "connection closed during command"));
|
|
20285
|
+
};
|
|
20286
|
+
cleanup = () => {
|
|
20287
|
+
signal.removeEventListener("abort", onAbort);
|
|
20288
|
+
this.client.off("close", onGone);
|
|
20289
|
+
};
|
|
20290
|
+
if (signal.aborted) {
|
|
20291
|
+
onAbort();
|
|
20292
|
+
return;
|
|
20293
|
+
}
|
|
20294
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
20295
|
+
this.client.once("close", onGone);
|
|
20296
|
+
stream.once("close", (c) => {
|
|
20297
|
+
resolve(c ?? null);
|
|
20298
|
+
});
|
|
20299
|
+
stream.end(stdin);
|
|
20300
|
+
});
|
|
20301
|
+
return { code, stdout, stderr };
|
|
20302
|
+
} finally {
|
|
20303
|
+
cleanup();
|
|
20304
|
+
}
|
|
20305
|
+
}
|
|
20306
|
+
close() {
|
|
20307
|
+
this.client.end();
|
|
20308
|
+
}
|
|
20309
|
+
};
|
|
20310
|
+
|
|
20167
20311
|
// src/protocols.ts
|
|
20168
20312
|
var PROTOCOLS = [
|
|
20169
20313
|
{ id: "reality", name: "VLESS Reality", transport: "TCP", insecureLink: false, defaultSelected: true },
|
|
@@ -20203,6 +20347,8 @@ var EMPTY_VIEW = {
|
|
|
20203
20347
|
port: 22,
|
|
20204
20348
|
username: "root",
|
|
20205
20349
|
hasPassword: false,
|
|
20350
|
+
auth: "password",
|
|
20351
|
+
hasPrivateKey: false,
|
|
20206
20352
|
protocols: [...DEFAULT_PROTOCOLS],
|
|
20207
20353
|
certMode: "self",
|
|
20208
20354
|
linkAddress: "",
|
|
@@ -20216,6 +20362,8 @@ function publicView(s) {
|
|
|
20216
20362
|
port: s.port,
|
|
20217
20363
|
username: s.username,
|
|
20218
20364
|
hasPassword: s.password !== "",
|
|
20365
|
+
auth: s.auth,
|
|
20366
|
+
hasPrivateKey: s.privateKey !== void 0,
|
|
20219
20367
|
protocols: s.protocols,
|
|
20220
20368
|
certMode: s.certMode,
|
|
20221
20369
|
linkAddress: s.linkAddress ?? "",
|
|
@@ -20229,8 +20377,23 @@ function applyInput(current, input) {
|
|
|
20229
20377
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SettingsError("port", "port must be 1-65535");
|
|
20230
20378
|
const username = String(input.username ?? "").trim();
|
|
20231
20379
|
if (!USERNAME.test(username)) throw new SettingsError("username", `invalid username: ${username}`);
|
|
20380
|
+
const auth = input.auth ?? current?.auth ?? "password";
|
|
20381
|
+
if (auth !== "password" && auth !== "key") throw new SettingsError("auth", `unknown login method: ${auth}`);
|
|
20232
20382
|
const password = input.password ? String(input.password) : current?.password ?? "";
|
|
20233
|
-
if (password === "") throw new SettingsError("password", "password is required");
|
|
20383
|
+
if (auth === "password" && password === "") throw new SettingsError("password", "password is required");
|
|
20384
|
+
let privateKey = input.clearPrivateKey ? void 0 : current?.privateKey;
|
|
20385
|
+
let passphrase = input.clearPrivateKey ? void 0 : current?.passphrase;
|
|
20386
|
+
if (input.privateKey && String(input.privateKey).trim() !== "") {
|
|
20387
|
+
privateKey = `${String(input.privateKey).trim()}
|
|
20388
|
+
`;
|
|
20389
|
+
passphrase = input.passphrase ? String(input.passphrase) : void 0;
|
|
20390
|
+
} else if (input.passphrase && privateKey) {
|
|
20391
|
+
passphrase = String(input.passphrase);
|
|
20392
|
+
}
|
|
20393
|
+
if (privateKey !== void 0) {
|
|
20394
|
+
const problem = privateKeyProblem(privateKey, passphrase);
|
|
20395
|
+
if (problem) throw new SettingsError("privateKey", `unusable private key: ${problem}`);
|
|
20396
|
+
}
|
|
20234
20397
|
if (!Array.isArray(input.protocols)) throw new SettingsError("protocols", "protocols must be a list");
|
|
20235
20398
|
const protocols = [...new Set(input.protocols)];
|
|
20236
20399
|
if (protocols.length === 0) throw new SettingsError("protocols", "select at least one protocol");
|
|
@@ -20249,12 +20412,29 @@ function applyInput(current, input) {
|
|
|
20249
20412
|
port,
|
|
20250
20413
|
username,
|
|
20251
20414
|
password,
|
|
20415
|
+
auth,
|
|
20416
|
+
privateKey,
|
|
20417
|
+
passphrase,
|
|
20252
20418
|
protocols,
|
|
20253
20419
|
certMode,
|
|
20254
20420
|
linkAddress: linkAddress || void 0,
|
|
20255
20421
|
hostKey: sameMachine ? current.hostKey : void 0
|
|
20256
20422
|
};
|
|
20257
20423
|
}
|
|
20424
|
+
var MemoryKeyStore = class {
|
|
20425
|
+
constructor(make) {
|
|
20426
|
+
this.make = make;
|
|
20427
|
+
}
|
|
20428
|
+
make;
|
|
20429
|
+
pair;
|
|
20430
|
+
get() {
|
|
20431
|
+
return Promise.resolve(this.pair ??= this.make());
|
|
20432
|
+
}
|
|
20433
|
+
};
|
|
20434
|
+
function authorizedKeysCommand(publicKey) {
|
|
20435
|
+
const safe = publicKey.trim().replace(/'/g, "'\\''");
|
|
20436
|
+
return `mkdir -p ~/.ssh && chmod 700 ~/.ssh && { grep -qxF '${safe}' ~/.ssh/authorized_keys 2>/dev/null || echo '${safe}' >> ~/.ssh/authorized_keys; } && chmod 600 ~/.ssh/authorized_keys`;
|
|
20437
|
+
}
|
|
20258
20438
|
|
|
20259
20439
|
// src/api.ts
|
|
20260
20440
|
var API_BASE = "/api/dsh-vpn";
|
|
@@ -20291,6 +20471,11 @@ function apiHandlers(service, uiLang, onUiLang, updater) {
|
|
|
20291
20471
|
});
|
|
20292
20472
|
return { ok: true, settings: publicView(saved) };
|
|
20293
20473
|
},
|
|
20474
|
+
// The dedicated key's public half and the line that installs it; never the private key.
|
|
20475
|
+
"/ssh-key": async () => {
|
|
20476
|
+
const { publicKey } = await service.keys.get();
|
|
20477
|
+
return { ok: true, publicKey, command: authorizedKeysCommand(publicKey) };
|
|
20478
|
+
},
|
|
20294
20479
|
"/host-key/forget": async () => {
|
|
20295
20480
|
await service.store.update((cur) => cur && { ...cur, hostKey: void 0 });
|
|
20296
20481
|
return { ok: true, settings: publicView(await service.store.get()) };
|
|
@@ -20298,7 +20483,12 @@ function apiHandlers(service, uiLang, onUiLang, updater) {
|
|
|
20298
20483
|
"/test": (_body, signal) => service.test(signal),
|
|
20299
20484
|
"/status": (_body, signal) => service.status(signal),
|
|
20300
20485
|
"/links": (_body, signal) => service.links(signal),
|
|
20301
|
-
|
|
20486
|
+
// With form fields: try them as they are (no save needed first); they are
|
|
20487
|
+
// stored only once the login works.
|
|
20488
|
+
"/detect": async (body, signal) => {
|
|
20489
|
+
if (typeof body.host !== "string") return service.detect(signal);
|
|
20490
|
+
return service.detect(signal, applyInput(await service.store.get(), body));
|
|
20491
|
+
},
|
|
20302
20492
|
"/install": (body, signal) => service.install({ regenerate: body.regenerate === true }, signal),
|
|
20303
20493
|
"/uninstall": (_body, signal) => service.uninstall(signal)
|
|
20304
20494
|
};
|
|
@@ -20335,135 +20525,6 @@ function registerApi(ctx, service, uiLang, onUiLang, updater) {
|
|
|
20335
20525
|
|
|
20336
20526
|
// src/remote.ts
|
|
20337
20527
|
import { readFile } from "node:fs/promises";
|
|
20338
|
-
|
|
20339
|
-
// src/ssh.ts
|
|
20340
|
-
var import_ssh2 = __toESM(require_lib2(), 1);
|
|
20341
|
-
import { createHash } from "node:crypto";
|
|
20342
|
-
var SshError = class extends Error {
|
|
20343
|
-
constructor(code, message, details = {}) {
|
|
20344
|
-
super(message);
|
|
20345
|
-
this.code = code;
|
|
20346
|
-
this.details = details;
|
|
20347
|
-
this.name = "SshError";
|
|
20348
|
-
}
|
|
20349
|
-
code;
|
|
20350
|
-
details;
|
|
20351
|
-
};
|
|
20352
|
-
function fingerprint(key) {
|
|
20353
|
-
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
20354
|
-
}
|
|
20355
|
-
var STDERR_TAIL = 8e3;
|
|
20356
|
-
var SshSession = class _SshSession {
|
|
20357
|
-
constructor(client, hostKey) {
|
|
20358
|
-
this.client = client;
|
|
20359
|
-
this.hostKey = hostKey;
|
|
20360
|
-
}
|
|
20361
|
-
client;
|
|
20362
|
-
hostKey;
|
|
20363
|
-
static async open(target, signal, timeoutMs = 2e4) {
|
|
20364
|
-
signal.throwIfAborted();
|
|
20365
|
-
const client = new import_ssh2.Client();
|
|
20366
|
-
let seen;
|
|
20367
|
-
const config = {
|
|
20368
|
-
host: target.host,
|
|
20369
|
-
port: target.port,
|
|
20370
|
-
username: target.username,
|
|
20371
|
-
password: target.password,
|
|
20372
|
-
readyTimeout: timeoutMs,
|
|
20373
|
-
// Password only: never fall back to keyboard-interactive prompts or agents.
|
|
20374
|
-
tryKeyboard: false,
|
|
20375
|
-
hostVerifier: (key) => {
|
|
20376
|
-
seen = fingerprint(key);
|
|
20377
|
-
return target.hostKey === void 0 || target.hostKey === seen;
|
|
20378
|
-
}
|
|
20379
|
-
};
|
|
20380
|
-
return await new Promise((resolve, reject) => {
|
|
20381
|
-
const onAbort = () => {
|
|
20382
|
-
client.end();
|
|
20383
|
-
reject(new SshError("cancelled", "cancelled"));
|
|
20384
|
-
};
|
|
20385
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
20386
|
-
client.once("ready", () => {
|
|
20387
|
-
signal.removeEventListener("abort", onAbort);
|
|
20388
|
-
resolve(new _SshSession(client, seen ?? ""));
|
|
20389
|
-
});
|
|
20390
|
-
client.once("error", (err) => {
|
|
20391
|
-
signal.removeEventListener("abort", onAbort);
|
|
20392
|
-
client.end();
|
|
20393
|
-
if (target.hostKey !== void 0 && seen !== void 0 && seen !== target.hostKey) {
|
|
20394
|
-
reject(new SshError(
|
|
20395
|
-
"host_key_mismatch",
|
|
20396
|
-
`host key changed: expected ${target.hostKey}, got ${seen}`,
|
|
20397
|
-
{ expected: target.hostKey, actual: seen }
|
|
20398
|
-
));
|
|
20399
|
-
} else if (err.level === "client-authentication") {
|
|
20400
|
-
reject(new SshError("auth_failed", "SSH login failed: wrong username or password"));
|
|
20401
|
-
} else if (err.level === "client-timeout") {
|
|
20402
|
-
reject(new SshError("timeout", `timed out connecting to ${target.host}:${target.port}`));
|
|
20403
|
-
} else {
|
|
20404
|
-
reject(new SshError("connect_failed", `cannot connect to ${target.host}:${target.port}: ${err.message}`));
|
|
20405
|
-
}
|
|
20406
|
-
});
|
|
20407
|
-
client.connect(config);
|
|
20408
|
-
});
|
|
20409
|
-
}
|
|
20410
|
-
/** Run one command; `stdin` is written then closed. Never uses a pty. */
|
|
20411
|
-
async exec(command, stdin, signal) {
|
|
20412
|
-
signal.throwIfAborted();
|
|
20413
|
-
const stream = await new Promise((resolve, reject) => {
|
|
20414
|
-
this.client.exec(command, (err, s) => {
|
|
20415
|
-
if (err) reject(err);
|
|
20416
|
-
else resolve(s);
|
|
20417
|
-
});
|
|
20418
|
-
});
|
|
20419
|
-
let stdout = "";
|
|
20420
|
-
let stderr = "";
|
|
20421
|
-
stream.setEncoding("utf8");
|
|
20422
|
-
stream.on("data", (chunk) => {
|
|
20423
|
-
stdout += chunk;
|
|
20424
|
-
});
|
|
20425
|
-
stream.stderr.setEncoding("utf8");
|
|
20426
|
-
stream.stderr.on("data", (chunk) => {
|
|
20427
|
-
stderr = (stderr + chunk).slice(-STDERR_TAIL);
|
|
20428
|
-
});
|
|
20429
|
-
let cleanup = () => {
|
|
20430
|
-
};
|
|
20431
|
-
try {
|
|
20432
|
-
const code = await new Promise((resolve, reject) => {
|
|
20433
|
-
const onAbort = () => {
|
|
20434
|
-
reject(signal.reason ?? new SshError("cancelled", "cancelled"));
|
|
20435
|
-
stream.close();
|
|
20436
|
-
this.client.end();
|
|
20437
|
-
};
|
|
20438
|
-
const onGone = () => {
|
|
20439
|
-
reject(new SshError("connect_failed", "connection closed during command"));
|
|
20440
|
-
};
|
|
20441
|
-
cleanup = () => {
|
|
20442
|
-
signal.removeEventListener("abort", onAbort);
|
|
20443
|
-
this.client.off("close", onGone);
|
|
20444
|
-
};
|
|
20445
|
-
if (signal.aborted) {
|
|
20446
|
-
onAbort();
|
|
20447
|
-
return;
|
|
20448
|
-
}
|
|
20449
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
20450
|
-
this.client.once("close", onGone);
|
|
20451
|
-
stream.once("close", (c) => {
|
|
20452
|
-
resolve(c ?? null);
|
|
20453
|
-
});
|
|
20454
|
-
stream.end(stdin);
|
|
20455
|
-
});
|
|
20456
|
-
return { code, stdout, stderr };
|
|
20457
|
-
} finally {
|
|
20458
|
-
cleanup();
|
|
20459
|
-
}
|
|
20460
|
-
}
|
|
20461
|
-
close() {
|
|
20462
|
-
this.client.end();
|
|
20463
|
-
}
|
|
20464
|
-
};
|
|
20465
|
-
|
|
20466
|
-
// src/remote.ts
|
|
20467
20528
|
var SCRIPT_URL = new URL("../scripts/vpn.sh", import.meta.url);
|
|
20468
20529
|
var RESULT_PREFIX = "DSH_VPN_RESULT=";
|
|
20469
20530
|
var LOG_TAIL = 4e3;
|
|
@@ -20527,8 +20588,11 @@ var NotConfiguredError = class extends Error {
|
|
|
20527
20588
|
this.name = "NotConfiguredError";
|
|
20528
20589
|
}
|
|
20529
20590
|
};
|
|
20530
|
-
function targetOf(s) {
|
|
20531
|
-
|
|
20591
|
+
async function targetOf(s, keys) {
|
|
20592
|
+
const base = { host: s.host, port: s.port, username: s.username, password: s.password, hostKey: s.hostKey };
|
|
20593
|
+
if (s.auth !== "key") return base;
|
|
20594
|
+
if (s.privateKey !== void 0) return { ...base, privateKey: s.privateKey, passphrase: s.passphrase };
|
|
20595
|
+
return { ...base, privateKey: (await keys.get()).privateKey };
|
|
20532
20596
|
}
|
|
20533
20597
|
function resolveProtocols(requested, fallback) {
|
|
20534
20598
|
const list = requested && requested.length > 0 ? [...new Set(requested)] : [...fallback];
|
|
@@ -20543,12 +20607,14 @@ function failure(err) {
|
|
|
20543
20607
|
return { ok: false, error: "internal", message: err instanceof Error ? err.message : String(err) };
|
|
20544
20608
|
}
|
|
20545
20609
|
var VpnService = class {
|
|
20546
|
-
constructor(store, config) {
|
|
20610
|
+
constructor(store, config, keys = new MemoryKeyStore(() => generateKeyPair("dsh-vpn"))) {
|
|
20547
20611
|
this.store = store;
|
|
20548
20612
|
this.config = config;
|
|
20613
|
+
this.keys = keys;
|
|
20549
20614
|
}
|
|
20550
20615
|
store;
|
|
20551
20616
|
config;
|
|
20617
|
+
keys;
|
|
20552
20618
|
queue = Promise.resolve();
|
|
20553
20619
|
async settings() {
|
|
20554
20620
|
const s = await this.store.get();
|
|
@@ -20569,31 +20635,42 @@ var VpnService = class {
|
|
|
20569
20635
|
return this.exclusive(async () => {
|
|
20570
20636
|
try {
|
|
20571
20637
|
const s = await this.settings();
|
|
20572
|
-
const
|
|
20573
|
-
await this.pin(s,
|
|
20574
|
-
|
|
20575
|
-
const sudo = /incorrect password|a password is required|not in the sudoers|may not run sudo/i.test(res.log);
|
|
20576
|
-
return {
|
|
20577
|
-
ok: false,
|
|
20578
|
-
error: sudo ? "sudo_failed" : "no_result",
|
|
20579
|
-
message: sudo ? `sudo refused for ${s.username}: ${res.log}` : `script did not report a result (exit ${res.exitCode}): ${res.log}`,
|
|
20580
|
-
log: res.log
|
|
20581
|
-
};
|
|
20582
|
-
}
|
|
20583
|
-
const r = res.result;
|
|
20584
|
-
return {
|
|
20585
|
-
ok: r.ok === true,
|
|
20586
|
-
...typeof r.error === "string" ? { error: r.error } : {},
|
|
20587
|
-
...typeof r.message === "string" ? { message: r.message } : {},
|
|
20588
|
-
result: r,
|
|
20589
|
-
...r.links ? { links: r.links } : {},
|
|
20590
|
-
log: res.log
|
|
20591
|
-
};
|
|
20638
|
+
const { op, hostKey } = await this.runOn(s, action, args(s), signal);
|
|
20639
|
+
await this.pin(s, hostKey);
|
|
20640
|
+
return op;
|
|
20592
20641
|
} catch (err) {
|
|
20593
20642
|
return failure(err);
|
|
20594
20643
|
}
|
|
20595
20644
|
});
|
|
20596
20645
|
}
|
|
20646
|
+
/** Run the script against `s` (stored or not); throws SSH and config errors. */
|
|
20647
|
+
async runOn(s, action, args, signal) {
|
|
20648
|
+
const res = await runVpnScript(action, args, await targetOf(s, this.keys), signal, this.config.timeoutMs);
|
|
20649
|
+
if (!res.result) {
|
|
20650
|
+
const sudo = /incorrect password|a password is required|not in the sudoers|may not run sudo/i.test(res.log);
|
|
20651
|
+
return {
|
|
20652
|
+
hostKey: res.hostKey,
|
|
20653
|
+
op: {
|
|
20654
|
+
ok: false,
|
|
20655
|
+
error: sudo ? "sudo_failed" : "no_result",
|
|
20656
|
+
message: sudo ? `sudo refused for ${s.username}: ${res.log}` : `script did not report a result (exit ${res.exitCode}): ${res.log}`,
|
|
20657
|
+
log: res.log
|
|
20658
|
+
}
|
|
20659
|
+
};
|
|
20660
|
+
}
|
|
20661
|
+
const r = res.result;
|
|
20662
|
+
return {
|
|
20663
|
+
hostKey: res.hostKey,
|
|
20664
|
+
op: {
|
|
20665
|
+
ok: r.ok === true,
|
|
20666
|
+
...typeof r.error === "string" ? { error: r.error } : {},
|
|
20667
|
+
...typeof r.message === "string" ? { message: r.message } : {},
|
|
20668
|
+
result: r,
|
|
20669
|
+
...r.links ? { links: r.links } : {},
|
|
20670
|
+
log: res.log
|
|
20671
|
+
}
|
|
20672
|
+
};
|
|
20673
|
+
}
|
|
20597
20674
|
install(opts, signal) {
|
|
20598
20675
|
return this.run("install", (s) => {
|
|
20599
20676
|
const protocols = resolveProtocols(opts.protocols, s.protocols);
|
|
@@ -20617,32 +20694,44 @@ var VpnService = class {
|
|
|
20617
20694
|
return this.run("links", (s) => ["--server", s.linkAddress ?? s.host], signal);
|
|
20618
20695
|
}
|
|
20619
20696
|
/**
|
|
20620
|
-
* Find an existing installation (e.g.
|
|
20621
|
-
* settings
|
|
20622
|
-
*
|
|
20623
|
-
*
|
|
20697
|
+
* Find an existing installation (e.g. dsh or dsh-vpn was reinstalled and the
|
|
20698
|
+
* local settings are gone, but the node still runs). `candidate` is what the
|
|
20699
|
+
* settings page holds, saved or not: it is stored only once login works, with
|
|
20700
|
+
* the server's protocols and certificate mode adopted so that a later
|
|
20701
|
+
* 安装 / 更新 keeps the node, its keys and its links as they are.
|
|
20624
20702
|
*/
|
|
20625
|
-
|
|
20626
|
-
|
|
20627
|
-
|
|
20628
|
-
|
|
20629
|
-
|
|
20630
|
-
|
|
20631
|
-
|
|
20632
|
-
|
|
20633
|
-
|
|
20634
|
-
|
|
20635
|
-
|
|
20636
|
-
|
|
20637
|
-
|
|
20703
|
+
detect(signal, candidate) {
|
|
20704
|
+
return this.exclusive(async () => {
|
|
20705
|
+
try {
|
|
20706
|
+
const s = candidate ?? await this.settings();
|
|
20707
|
+
const status = await this.runOn(s, "status", [], signal);
|
|
20708
|
+
if (!status.op.ok) return status.op;
|
|
20709
|
+
const r = status.op.result ?? {};
|
|
20710
|
+
const installed = r.installed === true;
|
|
20711
|
+
const protocols = installed ? String(r.protocols ?? "").split(",").filter(isProtocolId) : [];
|
|
20712
|
+
const mode = r.cert?.mode;
|
|
20713
|
+
const certMode = installed && (mode === "ip" || mode === "self") ? mode : void 0;
|
|
20714
|
+
const before = candidate ?? s;
|
|
20715
|
+
const next = {
|
|
20716
|
+
...before,
|
|
20717
|
+
...protocols.length > 0 ? { protocols } : {},
|
|
20718
|
+
...certMode ? { certMode } : {},
|
|
20719
|
+
hostKey: before.hostKey ?? (status.hostKey || void 0)
|
|
20720
|
+
};
|
|
20721
|
+
const synced = next.certMode !== before.certMode || next.protocols.join() !== before.protocols.join();
|
|
20722
|
+
await this.store.update((cur) => candidate || cur ? next : void 0);
|
|
20723
|
+
if (!installed) return { ok: true, result: { installed: false } };
|
|
20724
|
+
const links = await this.runOn(next, "links", ["--server", next.linkAddress ?? next.host], signal);
|
|
20725
|
+
return {
|
|
20726
|
+
ok: links.op.ok,
|
|
20727
|
+
...links.op.error ? { error: links.op.error, message: links.op.message } : {},
|
|
20728
|
+
result: { installed: true, active: r.active, version: r.version, protocols, certMode, synced },
|
|
20729
|
+
...links.op.links ? { links: links.op.links } : {}
|
|
20730
|
+
};
|
|
20731
|
+
} catch (err) {
|
|
20732
|
+
return failure(err);
|
|
20733
|
+
}
|
|
20638
20734
|
});
|
|
20639
|
-
const links = await this.links(signal);
|
|
20640
|
-
return {
|
|
20641
|
-
ok: links.ok,
|
|
20642
|
-
...links.error ? { error: links.error, message: links.message } : {},
|
|
20643
|
-
result: { installed: true, active: r.active, version: r.version, protocols, certMode, synced },
|
|
20644
|
-
...links.links ? { links: links.links } : {}
|
|
20645
|
-
};
|
|
20646
20735
|
}
|
|
20647
20736
|
uninstall(signal) {
|
|
20648
20737
|
return this.run("uninstall", () => [], signal);
|
|
@@ -20656,7 +20745,7 @@ var VpnService = class {
|
|
|
20656
20745
|
let session;
|
|
20657
20746
|
try {
|
|
20658
20747
|
const s = await this.settings();
|
|
20659
|
-
session = await SshSession.open(targetOf(s), signal);
|
|
20748
|
+
session = await SshSession.open(await targetOf(s, this.keys), signal);
|
|
20660
20749
|
const asRoot = s.username === "root";
|
|
20661
20750
|
const cmd = `echo "uid=$(${asRoot ? "" : `sudo -S -p '' `}id -u)"; echo "sys=$(uname -srm)"`;
|
|
20662
20751
|
const res = await session.exec(cmd, asRoot ? "" : `${s.password}
|
|
@@ -20694,6 +20783,9 @@ function parse(payload) {
|
|
|
20694
20783
|
port: p.port,
|
|
20695
20784
|
username: p.username,
|
|
20696
20785
|
password: p.password,
|
|
20786
|
+
auth: p.auth === "key" ? "key" : "password",
|
|
20787
|
+
privateKey: typeof p.privateKey === "string" ? p.privateKey : void 0,
|
|
20788
|
+
passphrase: typeof p.passphrase === "string" ? p.passphrase : void 0,
|
|
20697
20789
|
protocols: protocols.length > 0 ? protocols : ["reality", "hy2"],
|
|
20698
20790
|
certMode: p.certMode === "ip" ? "ip" : "self",
|
|
20699
20791
|
linkAddress: typeof p.linkAddress === "string" ? p.linkAddress : void 0,
|
|
@@ -20717,6 +20809,26 @@ var CredentialSettingsStore = class {
|
|
|
20717
20809
|
});
|
|
20718
20810
|
}
|
|
20719
20811
|
};
|
|
20812
|
+
var SSH_KEY = credentialKey("dsh-vpn", "ssh-key");
|
|
20813
|
+
var CredentialKeyStore = class {
|
|
20814
|
+
constructor(ctx) {
|
|
20815
|
+
this.ctx = ctx;
|
|
20816
|
+
}
|
|
20817
|
+
ctx;
|
|
20818
|
+
async get() {
|
|
20819
|
+
let pair;
|
|
20820
|
+
await this.ctx.credentials.modifyRecord(SSH_KEY, async (record) => {
|
|
20821
|
+
const p = record?.kind === "grant" ? record.payload : null;
|
|
20822
|
+
if (typeof p?.privateKey === "string" && typeof p.publicKey === "string") {
|
|
20823
|
+
pair = { privateKey: p.privateKey, publicKey: p.publicKey };
|
|
20824
|
+
return void 0;
|
|
20825
|
+
}
|
|
20826
|
+
pair = generateKeyPair("dsh-vpn");
|
|
20827
|
+
return { kind: "grant", payload: { ...pair } };
|
|
20828
|
+
});
|
|
20829
|
+
return pair;
|
|
20830
|
+
}
|
|
20831
|
+
};
|
|
20720
20832
|
var UI_LANG_KEY = credentialKey("dsh-vpn", "ui-locale");
|
|
20721
20833
|
async function readUiLang(ctx) {
|
|
20722
20834
|
const record = await ctx.credentials.readRecord(UI_LANG_KEY);
|
|
@@ -20942,7 +21054,11 @@ function toToolValue(r, uiLang) {
|
|
|
20942
21054
|
};
|
|
20943
21055
|
}
|
|
20944
21056
|
function apply(ctx, config) {
|
|
20945
|
-
const service = new VpnService(
|
|
21057
|
+
const service = new VpnService(
|
|
21058
|
+
new CredentialSettingsStore(ctx),
|
|
21059
|
+
{ timeoutMs: config.timeoutMs, acmeProvider: config.acmeProvider },
|
|
21060
|
+
new CredentialKeyStore(ctx)
|
|
21061
|
+
);
|
|
20946
21062
|
const uiLang = new UiLang();
|
|
20947
21063
|
readUiLang(ctx).then((lang) => {
|
|
20948
21064
|
if (lang && !uiLang.reported) uiLang.set(lang);
|
package/package.json
CHANGED