dsh-vpn 0.1.1 → 0.1.3
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 +30 -10
- package/assets/vpn-ops.md +5 -5
- package/lib/client.js +94 -67
- package/lib/index.js +3 -10
- package/package.json +2 -3
- package/scripts/vpn.sh +148 -13
package/README.md
CHANGED
|
@@ -18,17 +18,31 @@ npx @deepseek-ai/dsh plugin --profile web add dsh-vpn
|
|
|
18
18
|
dsh plugin --profile web add dsh-vpn
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
装好后重启 dsh
|
|
21
|
+
装好后重启 dsh,点左下角「设置」,左侧菜单里的「**VPN 设置**」就是本插件的设置页(dsh 0.1.5 和 0.1.7 相同)。
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
- dsh 0.1.7 及以后:侧边栏「插件」→ dsh-vpn
|
|
25
|
-
|
|
26
|
-
在设置页里:
|
|
23
|
+
在 VPN 设置里:
|
|
27
24
|
|
|
28
25
|
1. 填写服务器 IP、SSH 端口、用户名、密码,点「保存」和「测试连接」。
|
|
29
26
|
2. 勾选要安装的协议,选择证书类型(推荐「IP 证书」),保存后点「安装 / 更新」。
|
|
30
|
-
3.
|
|
31
|
-
4.
|
|
27
|
+
3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。
|
|
28
|
+
4. 服务器防火墙的端口由插件自动放行,不用手动操作;安装完成后提示框会列出放行了哪些端口。
|
|
29
|
+
|
|
30
|
+
## 防火墙
|
|
31
|
+
|
|
32
|
+
安装时插件会检测 VPS 上启用的防火墙,自动放行所选协议的端口(使用 IP 证书时还有 TCP 80):
|
|
33
|
+
|
|
34
|
+
| 防火墙 | 做法 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| ufw | `ufw allow` |
|
|
37
|
+
| firewalld | 放行端口(永久规则和运行时规则都加) |
|
|
38
|
+
| iptables(如 Oracle Cloud 镜像自带的 REJECT 规则) | 在 INPUT 链最前面插入放行规则,带 `dsh-vpn` 标签 |
|
|
39
|
+
| nftables(原生规则集) | 在各个 input 链插入放行规则,带 `dsh-vpn` 标签 |
|
|
40
|
+
| 没有启用防火墙 | 不做任何改动 |
|
|
41
|
+
|
|
42
|
+
- 规则写在 `/etc/dsh-vpn/firewall.sh` 里,服务每次启动都会重新应用,所以 iptables/nftables 规则重启后也还在。
|
|
43
|
+
- 重新安装时,先关闭上次放行的端口,再放行新的,所以取消勾选的协议端口会被关掉。卸载时会删除插件添加的所有规则。
|
|
44
|
+
- 插件只添加和删除自己的规则,从不启用防火墙、修改默认策略或改动 SSH 端口。
|
|
45
|
+
- **云服务商的安全组**(阿里云、腾讯云、AWS 等)在服务器外部,只能在服务商控制台修改,插件改不了;如果有安全组,仍需在控制台放行同样的端口。
|
|
32
46
|
|
|
33
47
|
## 证书
|
|
34
48
|
|
|
@@ -64,7 +78,7 @@ IP 证书由 sing-box 1.14 内置的 ACME 向 Let's Encrypt 申请([2026 年
|
|
|
64
78
|
| `src/settings.ts` / `src/credential-store.ts` | 服务器设置的校验和存储 |
|
|
65
79
|
| `src/api.ts` | 设置页调用的 `/api/dsh-vpn/*` 接口 |
|
|
66
80
|
| `src/index.ts` | agent 工具 `vpn_install` / `vpn_status` / `vpn_links` / `vpn_uninstall`,以及执行前的审批 |
|
|
67
|
-
| `src/client/` |
|
|
81
|
+
| `src/client/` | 浏览器端:设置弹窗里的「VPN 设置」一栏、二维码 |
|
|
68
82
|
| `src/skill.ts` + `assets/vpn-ops.md` | `vpn-ops` 技能:操作流程和排障 |
|
|
69
83
|
|
|
70
84
|
## 远端落盘位置
|
|
@@ -89,7 +103,7 @@ npm 上的 `@deepseek-ai/*` 包版本过旧,所以 `build` 不做类型检查
|
|
|
89
103
|
|
|
90
104
|
## 发布
|
|
91
105
|
|
|
92
|
-
推送与 `package.json` 版本号一致的标签,GitHub Actions 会先跑测试,再发布到 npm
|
|
106
|
+
推送与 `package.json` 版本号一致的标签,GitHub Actions 会先跑测试,再发布到 npm。需要在仓库 Secrets 里配置 `NPM_TOKEN`。仓库是私有的,npm 不支持为私有仓库生成来源证明(provenance),所以发布时不带来源证明:
|
|
93
107
|
|
|
94
108
|
```bash
|
|
95
109
|
git tag v0.1.0 && git push origin v0.1.0
|
|
@@ -103,6 +117,12 @@ limactl start --name=vps --vm-type=vz --network=vzNAT --set '.mounts=[]' templat
|
|
|
103
117
|
DSH_VPN_E2E_HOST=192.168.64.2 DSH_VPN_E2E_USER=root DSH_VPN_E2E_PASSWORD=… pnpm test:e2e
|
|
104
118
|
```
|
|
105
119
|
|
|
120
|
+
防火墙测试会依次启用 ufw、firewalld、iptables、nftables,**只能在测试虚拟机上运行**:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
DSH_VPN_E2E_FIREWALL=1 DSH_VPN_E2E_HOST=… DSH_VPN_E2E_USER=root DSH_VPN_E2E_PASSWORD=… pnpm test:e2e
|
|
124
|
+
```
|
|
125
|
+
|
|
106
126
|
IP 证书的测试需要一个本地 ACME 服务器,用 Let's Encrypt 官方的 [Pebble](https://github.com/letsencrypt/pebble) 模拟。**只能在测试虚拟机上运行**,因为它会把 Pebble 的根证书加入系统信任:
|
|
107
127
|
|
|
108
128
|
```bash
|
|
@@ -119,6 +139,6 @@ DSH_VPN_E2E_ACME=https://localhost:14000/dir DSH_VPN_E2E_HOST=… pnpm test:e2e
|
|
|
119
139
|
- [ ] 可自定义 Reality 的 SNI、协议端口
|
|
120
140
|
- [ ] 证书使用自己的域名(DNS 验证:Cloudflare / 阿里云)
|
|
121
141
|
- [ ] IPv6 的 IP 证书(目前只在 IPv4 上验证过)
|
|
122
|
-
- [ ] 支持 OpenRC(Alpine
|
|
142
|
+
- [ ] 支持 OpenRC(Alpine)
|
|
123
143
|
- [ ] 支持 GitHub 下载镜像
|
|
124
144
|
- [ ] 在真实 VPS 上跑端到端测试(Debian 12 / Ubuntu 24.04 / Rocky 9)
|
package/assets/vpn-ops.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# vpn-ops
|
|
2
2
|
|
|
3
|
-
Deploy and operate a personal sing-box server on the user's VPS. The plugin logs in over SSH with the password saved
|
|
3
|
+
Deploy and operate a personal sing-box server on the user's VPS. The plugin logs in over SSH with the password saved in dsh Settings (bottom-left gear) → VPN 设置. No local ssh client is needed.
|
|
4
4
|
|
|
5
5
|
## Workflow
|
|
6
6
|
|
|
7
7
|
1. **Check first.** Call `vpn_status`.
|
|
8
|
-
- `error: not_configured` → tell the user to open dsh
|
|
8
|
+
- `error: not_configured` → tell the user to open dsh 设置 (Settings, bottom left) → VPN 设置, fill in server IP, SSH port, username and password, then click 保存 and 测试连接. Never ask for the password in chat.
|
|
9
9
|
- Installed and active → offer `vpn_links` instead of reinstalling.
|
|
10
10
|
2. **Install.** Call `vpn_install`. Omit `protocols` to use the selection saved in settings. Only pass `regenerate: true` when the user explicitly wants new keys, because every old link stops working.
|
|
11
|
-
3. **Deliver.** Say the links are ready and that
|
|
12
|
-
4. **
|
|
11
|
+
3. **Deliver.** Say the links are ready and that 设置 → VPN 设置 shows scannable QR codes (刷新二维码 button). Client apps: v2rayN, NekoBox, Shadowrocket, sing-box, Clash Meta / mihomo.
|
|
12
|
+
4. **Firewall.** Install opens the ports in the VPS's own firewall automatically (ufw, firewalld, iptables or nftables; see `result.firewall`) and re-applies them after reboots. Don't ask the user to do that. Only mention that a cloud security group (Aliyun, Tencent Cloud, AWS…), which sits outside the server, may still need the same ports opened in the provider console.
|
|
13
13
|
|
|
14
14
|
## Certificates
|
|
15
15
|
|
|
@@ -38,7 +38,7 @@ The settings page offers **self-signed** (default) or **IP certificate** (recomm
|
|
|
38
38
|
| `cert_failed` | IP certificate not issued (usually port 80 closed). The node fell back to self-signed and still works | Open TCP 80 in the security group, then install again |
|
|
39
39
|
| `port80_in_use` | Another program (nginx, …) holds port 80 | Stop it, or use self-signed |
|
|
40
40
|
| Status says the certificate expires within 24 h or has expired | Renewal failing | Open TCP 80; an expired certificate breaks every TLS protocol |
|
|
41
|
-
| Installed and active, but the client times out | Cloud security group
|
|
41
|
+
| Installed and active, but the client times out | Cloud security group (the VPS firewall is handled automatically) | Open the ports from `result.firewall.opened` in the cloud console |
|
|
42
42
|
| Reality fails, others work | SNI site blocked | Reinstall with another SNI (future option) |
|
|
43
43
|
| UDP protocols fail, TCP works | ISP blocks or throttles UDP | Use Reality or AnyTLS |
|
|
44
44
|
| Everything stopped after working | IP blocked | `vpn_install` with `regenerate: true` for new ports; if it is still blocked, the IP must change |
|
package/lib/client.js
CHANGED
|
@@ -62,6 +62,7 @@ var vpnApi = {
|
|
|
62
62
|
var NS = "dsh-vpn";
|
|
63
63
|
var zh = {
|
|
64
64
|
title: "VPN \u8282\u70B9",
|
|
65
|
+
nav: "VPN \u8BBE\u7F6E",
|
|
65
66
|
description: "\u5728\u4F60\u7684 VPS \u4E0A\u4E00\u952E\u90E8\u7F72 sing-box\uFF0C\u5E76\u7528\u4E8C\u7EF4\u7801\u5BFC\u5165\u624B\u673A\u6216\u7535\u8111\u4E0A\u7684\u4EE3\u7406\u5BA2\u6237\u7AEF\u3002",
|
|
66
67
|
serverSection: "\u670D\u52A1\u5668",
|
|
67
68
|
host: "\u670D\u52A1\u5668 IP \u6216\u57DF\u540D",
|
|
@@ -114,12 +115,19 @@ var zh = {
|
|
|
114
115
|
working: "\u6267\u884C\u4E2D\u2026",
|
|
115
116
|
qrSection: "\u8282\u70B9\u4E8C\u7EF4\u7801",
|
|
116
117
|
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",
|
|
117
|
-
refresh: "\u5237\u65B0",
|
|
118
|
-
|
|
118
|
+
refresh: "\u5237\u65B0\u4E8C\u7EF4\u7801",
|
|
119
|
+
refreshing: "\u6B63\u5728\u5237\u65B0\u2026",
|
|
120
|
+
refreshedAt: "\u4E0A\u6B21\u5237\u65B0 {time}",
|
|
121
|
+
generate: "\u751F\u6210\u8282\u70B9\u4E8C\u7EF4\u7801",
|
|
122
|
+
qrEmpty: "\u8FD8\u6CA1\u6709\u4E8C\u7EF4\u7801\u3002\u5B89\u88C5\u5B8C\u6210\u540E\uFF0C\u70B9\u4E0B\u9762\u7684\u6309\u94AE\u4ECE\u670D\u52A1\u5668\u8BFB\u53D6\u8282\u70B9\u5E76\u751F\u6210\u4E8C\u7EF4\u7801\u3002",
|
|
123
|
+
qrNeedsSave: "\u8BF7\u5148\u586B\u5199\u5E76\u4FDD\u5B58\u670D\u52A1\u5668\u4FE1\u606F\u3002",
|
|
119
124
|
copy: "\u590D\u5236\u94FE\u63A5",
|
|
120
125
|
copied: "\u5DF2\u590D\u5236",
|
|
121
126
|
portLabel: "\u7AEF\u53E3 {port}",
|
|
122
|
-
|
|
127
|
+
fwOpened: "\u5DF2\u81EA\u52A8\u5728\u670D\u52A1\u5668\u9632\u706B\u5899\uFF08{backend}\uFF09\u653E\u884C\u7AEF\u53E3\uFF1A{ports}\u3002",
|
|
128
|
+
fwNone: "\u670D\u52A1\u5668\u6CA1\u6709\u542F\u7528\u9632\u706B\u5899\uFF0C\u7AEF\u53E3\u65E0\u9700\u653E\u884C\u3002",
|
|
129
|
+
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",
|
|
130
|
+
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",
|
|
123
131
|
"err.not_configured": "\u8BF7\u5148\u586B\u5199\u5E76\u4FDD\u5B58\u670D\u52A1\u5668\u4FE1\u606F\u3002",
|
|
124
132
|
"err.auth_failed": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF\u3002",
|
|
125
133
|
"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",
|
|
@@ -145,6 +153,7 @@ var zh = {
|
|
|
145
153
|
};
|
|
146
154
|
var en = {
|
|
147
155
|
title: "VPN node",
|
|
156
|
+
nav: "VPN",
|
|
148
157
|
description: "Deploy sing-box to your VPS in one click and import it into proxy apps by scanning QR codes.",
|
|
149
158
|
serverSection: "Server",
|
|
150
159
|
host: "Server IP or domain",
|
|
@@ -197,12 +206,19 @@ var en = {
|
|
|
197
206
|
working: "Working\u2026",
|
|
198
207
|
qrSection: "Node QR codes",
|
|
199
208
|
qrHint: "Scan with v2rayN, NekoBox, Shadowrocket, sing-box, Clash Meta and similar apps. A QR code is as secret as a password.",
|
|
200
|
-
refresh: "Refresh",
|
|
201
|
-
|
|
209
|
+
refresh: "Refresh QR codes",
|
|
210
|
+
refreshing: "Refreshing\u2026",
|
|
211
|
+
refreshedAt: "Last refreshed {time}",
|
|
212
|
+
generate: "Generate node QR codes",
|
|
213
|
+
qrEmpty: "No QR codes yet. After installing, click below to read the nodes from the server and generate QR codes.",
|
|
214
|
+
qrNeedsSave: "Fill in and save the server details first.",
|
|
202
215
|
copy: "Copy link",
|
|
203
216
|
copied: "Copied",
|
|
204
217
|
portLabel: "Port {port}",
|
|
205
|
-
|
|
218
|
+
fwOpened: "Opened in the server firewall ({backend}): {ports}.",
|
|
219
|
+
fwNone: "The server has no firewall enabled; nothing to open.",
|
|
220
|
+
fwStatus: "Server firewall ({backend}) has {count} ports opened; re-applied after reboots.",
|
|
221
|
+
cloudHint: "If your cloud provider has a security group (Aliyun, Tencent Cloud, AWS\u2026), also open these ports in its console.",
|
|
206
222
|
"err.not_configured": "Fill in and save the server details first.",
|
|
207
223
|
"err.auth_failed": "SSH login failed: wrong username or password.",
|
|
208
224
|
"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,16 +279,21 @@ var STYLES = `
|
|
|
263
279
|
.dshvpn-qr { background: #fff; padding: 6px; border-radius: 6px; line-height: 0; align-self: center; }
|
|
264
280
|
.dshvpn-qr svg { width: 180px; height: 180px; }
|
|
265
281
|
.dshvpn-link { display: block; max-width: 100%; max-height: 3.2em; overflow: hidden; color: var(--dsw-alias-label-secondary, #666); }
|
|
266
|
-
.dshvpn-
|
|
267
|
-
.dshvpn-
|
|
268
|
-
.dshvpn-
|
|
269
|
-
.dshvpn-
|
|
270
|
-
.dshvpn-
|
|
282
|
+
.dshvpn-section { display: flex; flex-direction: column; gap: 16px; }
|
|
283
|
+
.dshvpn-section-head h2 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
|
284
|
+
.dshvpn-section-head p { margin: 0; }
|
|
285
|
+
.dshvpn-qr-section { padding: 14px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
|
|
286
|
+
.dshvpn-qr-section h3 { font-size: 15px; }
|
|
287
|
+
.dshvpn-refresh { gap: 6px; font-weight: 600; }
|
|
288
|
+
.dshvpn-refresh-lg { padding: 10px 22px !important; font-size: 14px !important; }
|
|
289
|
+
.dshvpn-spin { display: inline-block; animation: dshvpn-spin 1s linear infinite; }
|
|
290
|
+
@keyframes dshvpn-spin { to { transform: rotate(360deg); } }
|
|
291
|
+
.dshvpn-qr-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 28px 16px; text-align: center;
|
|
292
|
+
border: 2px dashed var(--dsw-alias-border-l3, rgba(127,127,127,.35)); border-radius: 12px; }
|
|
293
|
+
.dshvpn-qr-empty p { margin: 0; font-size: 13px; color: var(--dsw-alias-label-secondary, #666); max-width: 420px; }
|
|
294
|
+
.dshvpn-qr-empty-icon { font-size: 36px; line-height: 1; color: var(--dsw-alias-label-tertiary, #888); }
|
|
271
295
|
`;
|
|
272
296
|
|
|
273
|
-
// src/client/VpnSettingsCard.tsx
|
|
274
|
-
var import_react2 = require("react");
|
|
275
|
-
|
|
276
297
|
// src/client/VpnSettingsPage.tsx
|
|
277
298
|
var import_react = require("react");
|
|
278
299
|
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
@@ -1000,10 +1021,6 @@ function formOf(s) {
|
|
|
1000
1021
|
return { host: s.host, port: s.port, username: s.username, password: "", protocols: [...s.protocols], certMode: s.certMode, linkAddress: s.linkAddress };
|
|
1001
1022
|
}
|
|
1002
1023
|
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();
|
|
1003
|
-
function transportsFor(id) {
|
|
1004
|
-
const t = PROTOCOLS.find((p) => p.id === id)?.transport ?? "TCP";
|
|
1005
|
-
return t === "TCP+UDP" ? ["tcp", "udp"] : [t.toLowerCase()];
|
|
1006
|
-
}
|
|
1007
1024
|
function Checkbox({ label, checked, disabled, onChange }) {
|
|
1008
1025
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-checkbox", children: [
|
|
1009
1026
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked, disabled, onChange: (e) => {
|
|
@@ -1044,6 +1061,7 @@ function VpnSettingsPage({ view, t, api }) {
|
|
|
1044
1061
|
const [busy, setBusy] = (0, import_react.useState)(null);
|
|
1045
1062
|
const [notice, setNotice] = (0, import_react.useState)(null);
|
|
1046
1063
|
const [links, setLinks] = (0, import_react.useState)(null);
|
|
1064
|
+
const [refreshedAt, setRefreshedAt] = (0, import_react.useState)(null);
|
|
1047
1065
|
const [regenerate, setRegenerate] = (0, import_react.useState)(false);
|
|
1048
1066
|
const [confirming, setConfirming] = (0, import_react.useState)(null);
|
|
1049
1067
|
(0, import_react.useEffect)(() => {
|
|
@@ -1091,21 +1109,32 @@ function VpnSettingsPage({ view, t, api }) {
|
|
|
1091
1109
|
setForm((f) => f && { ...f });
|
|
1092
1110
|
}
|
|
1093
1111
|
};
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1112
|
+
const firewallNotice = (result) => {
|
|
1113
|
+
const fw = result?.firewall;
|
|
1114
|
+
if (!fw?.backend || fw.backend === "unknown") return "";
|
|
1115
|
+
const ports = (fw.opened ?? []).map((p) => p.toUpperCase()).join(", ");
|
|
1116
|
+
const line = fw.backend === "none" ? t("fwNone") : t("fwOpened", { backend: fw.backend, ports });
|
|
1117
|
+
return `
|
|
1118
|
+
${line}
|
|
1119
|
+
${t("cloudHint")}`;
|
|
1120
|
+
};
|
|
1100
1121
|
const install = async () => {
|
|
1101
1122
|
const r = await op("install", () => api.install(regenerate), (v) => {
|
|
1102
|
-
if (v.links)
|
|
1103
|
-
|
|
1123
|
+
if (v.links) {
|
|
1124
|
+
setLinks(v.links);
|
|
1125
|
+
setRefreshedAt(/* @__PURE__ */ new Date());
|
|
1126
|
+
}
|
|
1127
|
+
return { tone: "success", text: `${t("installed")}${firewallNotice(v.result)}` };
|
|
1104
1128
|
});
|
|
1105
1129
|
if (!r.ok && r.links) setLinks(r.links);
|
|
1106
1130
|
setRegenerate(false);
|
|
1107
1131
|
void refreshSettings();
|
|
1108
1132
|
};
|
|
1133
|
+
const refreshLinks = () => op("links", api.links, (r) => {
|
|
1134
|
+
if (r.links) setLinks(r.links);
|
|
1135
|
+
setRefreshedAt(/* @__PURE__ */ new Date());
|
|
1136
|
+
return null;
|
|
1137
|
+
});
|
|
1109
1138
|
const disabled = busy !== null;
|
|
1110
1139
|
const needsSave = dirty || !saved.configured;
|
|
1111
1140
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn", children: [
|
|
@@ -1280,8 +1309,11 @@ ${t("certNotIssued")}` : hours < 0 ? `
|
|
|
1280
1309
|
${t("certExpired")}` : hours < 24 ? `
|
|
1281
1310
|
${t("certExpiring", { hours })}` : `
|
|
1282
1311
|
${t("certExpires", { date: new Date(cert.expires ?? "").toLocaleString(), hours })}`;
|
|
1312
|
+
const fw = res.firewall;
|
|
1313
|
+
const fwLine = !fw?.backend || fw.backend === "unknown" ? "" : `
|
|
1314
|
+
${fw.backend === "none" ? t("fwNone") : t("fwStatus", { backend: fw.backend, count: fw.opened?.length ?? 0 })}`;
|
|
1283
1315
|
const healthy = res.active === true && !certProblem;
|
|
1284
|
-
return { tone: healthy ? "success" : "error", text: `${head} ${t("listening")}: ${listening}${certLine}` };
|
|
1316
|
+
return { tone: healthy ? "success" : "error", text: `${head} ${t("listening")}: ${listening}${certLine}${fwLine}` };
|
|
1285
1317
|
});
|
|
1286
1318
|
},
|
|
1287
1319
|
children: t("status")
|
|
@@ -1312,53 +1344,51 @@ ${t("certExpires", { date: new Date(cert.expires ?? "").toLocaleString(), hours
|
|
|
1312
1344
|
] })
|
|
1313
1345
|
] }),
|
|
1314
1346
|
(busy !== null || notice) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `dshvpn-notice dshvpn-${busy !== null ? "info" : notice?.tone}`, role: "status", children: busy !== null ? t("working") : notice?.text }),
|
|
1315
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { children: [
|
|
1347
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "dshvpn-qr-section", children: [
|
|
1316
1348
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-row", children: [
|
|
1317
1349
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { children: t("qrSection") }),
|
|
1318
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
1319
|
-
|
|
1320
|
-
{
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
return null;
|
|
1328
|
-
});
|
|
1329
|
-
},
|
|
1330
|
-
children: t("refresh")
|
|
1331
|
-
}
|
|
1332
|
-
)
|
|
1350
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dshvpn-actions", children: [
|
|
1351
|
+
refreshedAt && busy !== "links" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("refreshedAt", { time: refreshedAt.toLocaleTimeString() }) }),
|
|
1352
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dsh_client_ui_primitives.Button, { variant: "primary", className: "dshvpn-refresh", disabled: disabled || needsSave, onClick: () => {
|
|
1353
|
+
void refreshLinks();
|
|
1354
|
+
}, children: [
|
|
1355
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: busy === "links" ? "dshvpn-spin" : void 0, "aria-hidden": "true", children: "\u21BB" }),
|
|
1356
|
+
busy === "links" ? t("refreshing") : t("refresh")
|
|
1357
|
+
] })
|
|
1358
|
+
] })
|
|
1333
1359
|
] }),
|
|
1334
1360
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("qrHint") }),
|
|
1335
|
-
links ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-qr-grid", children: links.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(QrCard, { item, t }, item.id)) }) : /* @__PURE__ */ (0, import_jsx_runtime.
|
|
1361
|
+
links ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-qr-grid", children: links.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(QrCard, { item, t }, item.id)) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-qr-empty", children: [
|
|
1362
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshvpn-qr-empty-icon", "aria-hidden": "true", children: "\u25A6" }),
|
|
1363
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: needsSave ? t("qrNeedsSave") : t("qrEmpty") }),
|
|
1364
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dsh_client_ui_primitives.Button, { variant: "primary", className: "dshvpn-refresh dshvpn-refresh-lg", disabled: disabled || needsSave, onClick: () => {
|
|
1365
|
+
void refreshLinks();
|
|
1366
|
+
}, children: [
|
|
1367
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: busy === "links" ? "dshvpn-spin" : void 0, "aria-hidden": "true", children: "\u21BB" }),
|
|
1368
|
+
busy === "links" ? t("refreshing") : t("generate")
|
|
1369
|
+
] })
|
|
1370
|
+
] })
|
|
1336
1371
|
] })
|
|
1337
1372
|
] });
|
|
1338
1373
|
}
|
|
1339
1374
|
|
|
1340
|
-
// src/client/
|
|
1375
|
+
// src/client/VpnSettingsSection.tsx
|
|
1341
1376
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1342
|
-
function
|
|
1377
|
+
function VpnSettingsSection(props) {
|
|
1343
1378
|
const { t } = props;
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
}, children: [
|
|
1349
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "dshvpn-card-title", children: [
|
|
1350
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("strong", { children: t("title") }),
|
|
1351
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("small", { className: "dshvpn-muted", children: t("description") })
|
|
1352
|
-
] }),
|
|
1353
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "aria-hidden": "true", className: "dshvpn-card-chevron", children: open ? "\u25BE" : "\u25B8" })
|
|
1379
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dshvpn-section", children: [
|
|
1380
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("header", { className: "dshvpn-section-head", children: [
|
|
1381
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: t("nav") }),
|
|
1382
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dshvpn-muted", children: t("description") })
|
|
1354
1383
|
] }),
|
|
1355
|
-
|
|
1384
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(VpnSettingsPage, { t, api: props.api, view: "page" })
|
|
1356
1385
|
] });
|
|
1357
1386
|
}
|
|
1358
1387
|
|
|
1359
1388
|
// src/client/index.ts
|
|
1360
1389
|
var PACKAGE_NAME = "dsh-vpn";
|
|
1361
1390
|
var inject = ["slots", "locale"];
|
|
1391
|
+
var SECTION_ORDER = 25;
|
|
1362
1392
|
function apply(ctx) {
|
|
1363
1393
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-vpn: dictionaries");
|
|
1364
1394
|
ctx.effect(() => {
|
|
@@ -1370,18 +1400,15 @@ function apply(ctx) {
|
|
|
1370
1400
|
style.remove();
|
|
1371
1401
|
};
|
|
1372
1402
|
}, "dsh-vpn: styles");
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
1380
|
-
name: "settings.plugin.item",
|
|
1381
|
-
key: PACKAGE_NAME,
|
|
1403
|
+
const t = ctx.locale.bind(NS);
|
|
1404
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1405
|
+
name: "settings.section",
|
|
1406
|
+
id: PACKAGE_NAME,
|
|
1407
|
+
order: SECTION_ORDER,
|
|
1408
|
+
label: () => t("nav"),
|
|
1382
1409
|
locale: NS,
|
|
1383
1410
|
inject: () => ({ api: vpnApi })
|
|
1384
|
-
},
|
|
1411
|
+
}, VpnSettingsSection));
|
|
1385
1412
|
}
|
|
1386
1413
|
|
|
1387
1414
|
return module.exports;
|
package/lib/index.js
CHANGED
|
@@ -20706,7 +20706,7 @@ function uninstallPrompt(lang, target) {
|
|
|
20706
20706
|
return lang === "zh" ? `\u5C06\u505C\u6B62\u5E76\u5220\u9664 ${target} \u4E0A\u7684 sing-box \u670D\u52A1\u3001\u914D\u7F6E\u548C\u5BC6\u94A5\u3002\u73B0\u6709\u8282\u70B9\u94FE\u63A5\u5C06\u5168\u90E8\u5931\u6548\u3002` : `Stop and remove the sing-box service, its config and keys from ${target}. All existing node links stop working.`;
|
|
20707
20707
|
}
|
|
20708
20708
|
function notConfiguredMessage(lang) {
|
|
20709
|
-
return lang === "zh" ? "\u5C1A\u672A\u914D\u7F6E\u670D\u52A1\u5668\u3002\u8BF7\
|
|
20709
|
+
return lang === "zh" ? "\u5C1A\u672A\u914D\u7F6E\u670D\u52A1\u5668\u3002\u8BF7\u70B9 dsh \u5DE6\u4E0B\u89D2\u300C\u8BBE\u7F6E\u300D\u2192\u300CVPN \u8BBE\u7F6E\u300D\uFF0C\u586B\u5199\u670D\u52A1\u5668 IP\u3001\u7AEF\u53E3\u3001\u7528\u6237\u540D\u548C\u5BC6\u7801\u3002" : "No server configured. Open dsh Settings (bottom left) \u2192 VPN and fill in the server IP, port, username and password first.";
|
|
20710
20710
|
}
|
|
20711
20711
|
|
|
20712
20712
|
// src/index.ts
|
|
@@ -20751,7 +20751,7 @@ function apply(ctx, config) {
|
|
|
20751
20751
|
const service = new VpnService(new CredentialSettingsStore(ctx), { timeoutMs: config.timeoutMs, acmeProvider: config.acmeProvider });
|
|
20752
20752
|
ctx.tools.register(defineTool({
|
|
20753
20753
|
name: "vpn_install",
|
|
20754
|
-
description: `Install or reconfigure sing-box on the VPS configured in
|
|
20754
|
+
description: `Install or reconfigure sing-box on the VPS configured in dsh Settings \u2192 VPN, then return share links. Opens the ports in the VPS firewall automatically (result.firewall). Idempotent: rerunning keeps existing keys and ports unless \`regenerate\` is set. Protocols: ${PROTOCOL_IDS.join(", ")}; omit to use the ones selected in settings.`,
|
|
20755
20755
|
parameters: {
|
|
20756
20756
|
protocols: {
|
|
20757
20757
|
type: "array",
|
|
@@ -20774,7 +20774,7 @@ function apply(ctx, config) {
|
|
|
20774
20774
|
}));
|
|
20775
20775
|
ctx.tools.register(defineTool({
|
|
20776
20776
|
name: "vpn_links",
|
|
20777
|
-
description: "Return the share links (vless://, hysteria2://, \u2026) of the installed server. Tell the user they can scan QR codes
|
|
20777
|
+
description: "Return the share links (vless://, hysteria2://, \u2026) of the installed server. Tell the user they can scan QR codes in dsh Settings \u2192 VPN (\u5237\u65B0\u4E8C\u7EF4\u7801).",
|
|
20778
20778
|
parameters: {},
|
|
20779
20779
|
output: OUTPUT,
|
|
20780
20780
|
execute: async (_args, exec) => toToolValue(await service.links(exec.signal))
|
|
@@ -20811,13 +20811,6 @@ function apply(ctx, config) {
|
|
|
20811
20811
|
ctx.inject(["connection"], (webCtx) => {
|
|
20812
20812
|
registerApi(webCtx, service);
|
|
20813
20813
|
});
|
|
20814
|
-
ctx.inject(["settings"], (settingsCtx) => {
|
|
20815
|
-
const settings = settingsCtx.settings;
|
|
20816
|
-
try {
|
|
20817
|
-
settings?.register?.("dsh-vpn", Schema.object({}), {});
|
|
20818
|
-
} catch {
|
|
20819
|
-
}
|
|
20820
|
-
});
|
|
20821
20814
|
}
|
|
20822
20815
|
export {
|
|
20823
20816
|
Config,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vpn",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: one-click sing-box deployment to a VPS over SSH, with a settings page and QR codes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -53,8 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"packageManager": "pnpm@11.9.0",
|
|
55
55
|
"publishConfig": {
|
|
56
|
-
"access": "public"
|
|
57
|
-
"provenance": true
|
|
56
|
+
"access": "public"
|
|
58
57
|
},
|
|
59
58
|
"scripts": {
|
|
60
59
|
"build": "node scripts/build.mjs",
|
package/scripts/vpn.sh
CHANGED
|
@@ -400,6 +400,7 @@ After=network-online.target
|
|
|
400
400
|
Wants=network-online.target
|
|
401
401
|
|
|
402
402
|
[Service]
|
|
403
|
+
ExecStartPre=-/bin/bash $FW_FILE open
|
|
403
404
|
ExecStart=$BIN run -c $CONFIG_FILE
|
|
404
405
|
Restart=on-failure
|
|
405
406
|
RestartSec=3
|
|
@@ -429,22 +430,149 @@ protocol_transports() {
|
|
|
429
430
|
|
|
430
431
|
protocol_port() { local var; var="$(echo "$1" | tr '[:lower:]' '[:upper:]')_PORT"; echo "${!var}"; }
|
|
431
432
|
|
|
432
|
-
|
|
433
|
+
# Host firewall. The rules live in a generated $FW_FILE so that the service
|
|
434
|
+
# can re-apply them on every start (raw iptables/nftables rules do not survive
|
|
435
|
+
# a reboot by themselves) and so that a later install or uninstall can close
|
|
436
|
+
# exactly the ports this one opened. Cloud security groups sit outside the VPS
|
|
437
|
+
# and cannot be changed from here.
|
|
438
|
+
FW_FILE=$STATE_DIR/firewall.sh
|
|
439
|
+
|
|
440
|
+
write_fw_script() {
|
|
441
|
+
{
|
|
442
|
+
printf '#!/usr/bin/env bash\n# Generated by dsh-vpn. Usage: firewall.sh open|close|backend\n'
|
|
443
|
+
printf 'RULES="%s"\n' "$1"
|
|
444
|
+
cat <<'FWEOF'
|
|
445
|
+
TAG=dsh-vpn
|
|
446
|
+
set -u
|
|
447
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
448
|
+
ufw_active() { have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; }
|
|
449
|
+
firewalld_active() { have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; }
|
|
450
|
+
# iptables (legacy or iptables-nft) whose INPUT drops or rejects something.
|
|
451
|
+
ipt_blocks() { have "$1" && "$1" -S INPUT 2>/dev/null | grep -qE '^-P INPUT (DROP|REJECT)|-j (DROP|REJECT)'; }
|
|
452
|
+
# Native nftables base chains on the input hook (iptables-nft's own tables excluded).
|
|
453
|
+
nft_input_chains() {
|
|
454
|
+
have nft || return 0
|
|
455
|
+
local fam tbl ch
|
|
456
|
+
nft list chains 2>/dev/null | awk '/^table /{f=$2;t=$3} /^[[:space:]]*chain /{c=$2} /hook input/{print f, t, c}' |
|
|
457
|
+
while read -r fam tbl ch; do
|
|
458
|
+
nft list table "$fam" "$tbl" 2>/dev/null | grep -q 'managed by iptables-nft' || echo "$fam $tbl $ch"
|
|
459
|
+
done
|
|
460
|
+
}
|
|
461
|
+
nft_blocks() {
|
|
462
|
+
local fam tbl ch
|
|
463
|
+
while read -r fam tbl ch; do
|
|
464
|
+
[ -n "$fam" ] && nft list chain "$fam" "$tbl" "$ch" 2>/dev/null | grep -qE 'policy drop|[[:space:]](drop|reject)' && return 0
|
|
465
|
+
done < <(nft_input_chains)
|
|
466
|
+
return 1
|
|
467
|
+
}
|
|
468
|
+
backend() {
|
|
469
|
+
if ufw_active; then echo ufw; return; fi
|
|
470
|
+
if firewalld_active; then echo firewalld; return; fi
|
|
471
|
+
local b=""
|
|
472
|
+
{ ipt_blocks iptables || ipt_blocks ip6tables; } && b=iptables
|
|
473
|
+
nft_blocks && b="${b:+$b+}nftables"
|
|
474
|
+
echo "${b:-none}"
|
|
475
|
+
}
|
|
476
|
+
ipt_close() {
|
|
477
|
+
local t rule
|
|
478
|
+
for t in iptables ip6tables; do
|
|
479
|
+
have "$t" || continue
|
|
480
|
+
"$t" -S INPUT 2>/dev/null | grep -- "--comment $TAG" | sed 's/^-A /-D /' | while read -r rule; do
|
|
481
|
+
eval "$t $rule" 2>/dev/null || true
|
|
482
|
+
done
|
|
483
|
+
done
|
|
484
|
+
}
|
|
485
|
+
nft_close() {
|
|
486
|
+
local fam tbl ch h
|
|
487
|
+
while read -r fam tbl ch; do
|
|
488
|
+
[ -n "$fam" ] || continue
|
|
489
|
+
nft -a list chain "$fam" "$tbl" "$ch" 2>/dev/null | grep "comment \"$TAG\"" | sed -n 's/.*# handle \([0-9]*\).*/\1/p' |
|
|
490
|
+
while read -r h; do nft delete rule "$fam" "$tbl" "$ch" handle "$h" 2>/dev/null || true; done
|
|
491
|
+
done < <(nft_input_chains)
|
|
492
|
+
}
|
|
493
|
+
close_all() {
|
|
494
|
+
local r
|
|
495
|
+
if have ufw; then for r in $RULES; do ufw delete allow "$r" >/dev/null 2>&1 || true; done; fi
|
|
496
|
+
if firewalld_active; then
|
|
497
|
+
for r in $RULES; do
|
|
498
|
+
firewall-cmd -q --permanent --remove-port="$r" 2>/dev/null || true
|
|
499
|
+
firewall-cmd -q --remove-port="$r" 2>/dev/null || true
|
|
500
|
+
done
|
|
501
|
+
fi
|
|
502
|
+
ipt_close
|
|
503
|
+
nft_close
|
|
504
|
+
}
|
|
505
|
+
open_all() {
|
|
506
|
+
local b r t port proto fam tbl ch
|
|
507
|
+
b=$(backend)
|
|
508
|
+
case "$b" in
|
|
509
|
+
ufw) for r in $RULES; do ufw allow "$r" comment "$TAG" >/dev/null 2>&1 || ufw allow "$r" >/dev/null; done ;;
|
|
510
|
+
firewalld)
|
|
511
|
+
for r in $RULES; do
|
|
512
|
+
firewall-cmd -q --permanent --add-port="$r" || true
|
|
513
|
+
firewall-cmd -q --add-port="$r" || true
|
|
514
|
+
done ;;
|
|
515
|
+
none) ;;
|
|
516
|
+
*)
|
|
517
|
+
ipt_close; nft_close # re-add cleanly (idempotent across service restarts)
|
|
518
|
+
for r in $RULES; do
|
|
519
|
+
port=${r%/*}; proto=${r#*/}
|
|
520
|
+
if [[ $b == *iptables* ]]; then
|
|
521
|
+
for t in iptables ip6tables; do
|
|
522
|
+
have "$t" && "$t" -I INPUT 1 -p "$proto" --dport "$port" -m comment --comment "$TAG" -j ACCEPT 2>/dev/null || true
|
|
523
|
+
done
|
|
524
|
+
fi
|
|
525
|
+
if [[ $b == *nftables* ]]; then
|
|
526
|
+
while read -r fam tbl ch; do
|
|
527
|
+
[ -n "$fam" ] && nft insert rule "$fam" "$tbl" "$ch" "$proto" dport "$port" accept comment "\"$TAG\"" 2>/dev/null || true
|
|
528
|
+
done < <(nft_input_chains)
|
|
529
|
+
fi
|
|
530
|
+
done ;;
|
|
531
|
+
esac
|
|
532
|
+
echo "backend=$b"
|
|
533
|
+
}
|
|
534
|
+
case "${1:-}" in
|
|
535
|
+
open) open_all ;;
|
|
536
|
+
close) close_all; echo "backend=$(backend)" ;;
|
|
537
|
+
backend) echo "backend=$(backend)" ;;
|
|
538
|
+
*) echo "usage: firewall.sh open|close|backend" >&2; exit 2 ;;
|
|
539
|
+
esac
|
|
540
|
+
FWEOF
|
|
541
|
+
} >"$FW_FILE"
|
|
542
|
+
chmod 700 "$FW_FILE"
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
# Rules for the current selection, e.g. "52816/tcp 37689/udp 80/tcp".
|
|
546
|
+
fw_rules() {
|
|
433
547
|
local p t rules=()
|
|
434
548
|
for p in $ALL_PROTOCOLS; do
|
|
435
549
|
has_protocol "$p" || continue
|
|
436
550
|
for t in $(protocol_transports "$p"); do rules+=("$(protocol_port "$p")/$t"); done
|
|
437
551
|
done
|
|
438
552
|
[ "$CERT_MODE" = ip ] && uses_cert && rules+=("80/tcp")
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
fi
|
|
447
|
-
|
|
553
|
+
echo "${rules[*]}"
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
open_firewall() {
|
|
557
|
+
local rules out
|
|
558
|
+
rules=$(fw_rules)
|
|
559
|
+
# Close what the previous install opened (unticked protocols, old ports).
|
|
560
|
+
if [ -f "$FW_FILE" ]; then bash "$FW_FILE" close >/dev/null 2>&1 || true; fi
|
|
561
|
+
write_fw_script "$rules"
|
|
562
|
+
out=$(bash "$FW_FILE" open 2>/dev/null) || true
|
|
563
|
+
FW_BACKEND=$(printf '%s\n' "$out" | sed -n 's/^backend=//p')
|
|
564
|
+
FW_BACKEND=${FW_BACKEND:-unknown}
|
|
565
|
+
FW_OPENED=$rules
|
|
566
|
+
log "firewall ($FW_BACKEND): ${rules:-nothing to open}"
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
firewall_json() {
|
|
570
|
+
# Split on spaces explicitly: callers such as cmd_status have IFS=",".
|
|
571
|
+
local r items=() rules=()
|
|
572
|
+
IFS=' ' read -r -a rules <<<"${FW_OPENED:-}"
|
|
573
|
+
for r in "${rules[@]}"; do items+=("\"$r\""); done
|
|
574
|
+
local IFS=,
|
|
575
|
+
printf '{"backend":"%s","opened":[%s]}' "${FW_BACKEND:-unknown}" "${items[*]}"
|
|
448
576
|
}
|
|
449
577
|
|
|
450
578
|
enable_bbr() {
|
|
@@ -578,13 +706,13 @@ cmd_install() {
|
|
|
578
706
|
write_config
|
|
579
707
|
write_unit
|
|
580
708
|
open_firewall
|
|
581
|
-
if [ "$CERT_MODE" = ip ] && uses_cert && ! wait_for_cert; then cert_fallback; fi
|
|
709
|
+
if [ "$CERT_MODE" = ip ] && uses_cert && ! wait_for_cert; then cert_fallback; open_firewall; fi
|
|
582
710
|
enable_bbr
|
|
583
711
|
resolve_ip
|
|
584
712
|
log "done"
|
|
585
713
|
local head='"ok":true'
|
|
586
714
|
[ -z "$CERT_FAILURE" ] || head="\"ok\":false,\"error\":\"cert_failed\",\"message\":\"$(json_escape "$CERT_FAILURE")\""
|
|
587
|
-
result "{$head,\"action\":\"install\",\"protocols\":\"$PROTOCOLS\",\"version\":\"$("$BIN" version | awk 'NR==1{print $3}')\",\"cert\":$(cert_status_json),\"links\":$(links_json)}"
|
|
715
|
+
result "{$head,\"action\":\"install\",\"protocols\":\"$PROTOCOLS\",\"version\":\"$("$BIN" version | awk 'NR==1{print $3}')\",\"cert\":$(cert_status_json),\"firewall\":$(firewall_json),\"links\":$(links_json)}"
|
|
588
716
|
}
|
|
589
717
|
|
|
590
718
|
cmd_status() {
|
|
@@ -604,7 +732,13 @@ cmd_status() {
|
|
|
604
732
|
done
|
|
605
733
|
local IFS=,
|
|
606
734
|
local cert; cert=$(cert_status_json)
|
|
607
|
-
|
|
735
|
+
FW_OPENED=""; FW_BACKEND=unknown
|
|
736
|
+
if [ -f "$FW_FILE" ]; then
|
|
737
|
+
FW_OPENED=$(sed -n 's/^RULES="\(.*\)"$/\1/p' "$FW_FILE")
|
|
738
|
+
FW_BACKEND=$(bash "$FW_FILE" backend 2>/dev/null | sed -n 's/^backend=//p')
|
|
739
|
+
fi
|
|
740
|
+
local fw; fw=$(firewall_json)
|
|
741
|
+
result "{\"ok\":true,\"action\":\"status\",\"installed\":true,\"active\":$active,\"version\":\"$version\",\"protocols\":\"$PROTOCOLS\",\"cert\":$cert,\"firewall\":$fw,\"listening\":{${listening[*]}}}"
|
|
608
742
|
}
|
|
609
743
|
|
|
610
744
|
cmd_links() {
|
|
@@ -623,6 +757,7 @@ cmd_links() {
|
|
|
623
757
|
cmd_uninstall() {
|
|
624
758
|
require_root
|
|
625
759
|
systemctl disable --now "$UNIT" >/dev/null 2>&1 || true
|
|
760
|
+
if [ -f "$FW_FILE" ]; then bash "$FW_FILE" close >/dev/null 2>&1 || true; fi
|
|
626
761
|
rm -f "$UNIT_FILE" /etc/sysctl.d/99-dsh-vpn.conf
|
|
627
762
|
systemctl daemon-reload 2>/dev/null || true
|
|
628
763
|
rm -rf "$STATE_DIR" "$BIN"
|