dsh-vpn-panel 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +104 -2
- package/cordis.patch.yml +3 -0
- package/lib/client.js +598 -0
- package/package.json +53 -6
- package/src/index.js +580 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hanhan9999999999999999999
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-vpn-panel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DSH 的**设置页**里加一个 `VPN` 栏目:一键开关本机代理内核、切换节点、测速、刷新订阅。
|
|
4
|
+
|
|
5
|
+
它本身不是代理,也不带节点。它做的是把「启动 / 停止本机 mihomo 内核 + 改写 Windows 系统代理」
|
|
6
|
+
这件事搬进 DSH 的面板里,省掉去桌面上点快捷方式。
|
|
7
|
+
|
|
8
|
+
配套的脚本与配置生成器在 [vpn-toolkit](https://github.com/hanhan9999999999999999999/vpn-toolkit)。
|
|
9
|
+
|
|
10
|
+
## 声明
|
|
11
|
+
|
|
12
|
+
本插件是**本地进程管理与界面接入的技术示例,仅供学习与研究使用**。
|
|
13
|
+
|
|
14
|
+
- 不提供、不内置、也不推荐任何节点、订阅源或可用服务;
|
|
15
|
+
- 不含代理内核,内核与订阅由使用者自行获取;
|
|
16
|
+
- 请遵守所在国家或地区的法律法规,使用后果由使用者自行承担。
|
|
17
|
+
|
|
18
|
+
## Notice
|
|
19
|
+
|
|
20
|
+
A technical example of local process management and host-UI integration, provided
|
|
21
|
+
**for study and research only**. No nodes, subscriptions, services or proxy core
|
|
22
|
+
are bundled or endorsed. You are responsible for how you use it and for the laws
|
|
23
|
+
that apply to you.
|
|
24
|
+
|
|
25
|
+
## 装
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
# 从 npm(发布后)
|
|
29
|
+
dsh plugin --profile desktop add dsh-vpn-panel
|
|
30
|
+
|
|
31
|
+
# 或者直接从仓库
|
|
32
|
+
dsh plugin --profile desktop add https://github.com/hanhan9999999999999999999/dsh-vpn-panel
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
装完重启 DSH,**设置 → VPN** 就在左侧栏目里。
|
|
36
|
+
|
|
37
|
+
## 面板上有什么
|
|
38
|
+
|
|
39
|
+
- **总开关** — 打开 / 关闭(等价于执行 `vpn-on.ps1` / `vpn-off.ps1`)
|
|
40
|
+
- **状态卡** — 内核是否在跑、系统代理是否指向本机端口、当前节点、节点数、上次测速
|
|
41
|
+
- **节点列表** — 带搜索框,点一行即切换(写 mihomo 的 `手动切换` 组)
|
|
42
|
+
- **四个按钮** — 测下载速度 / 测节点延迟 / 更新订阅节点 / 刷新状态
|
|
43
|
+
- **设置** — 代理端口、控制端口、脚本目录、测速地址、开机自启
|
|
44
|
+
- **日志** — 可折叠,出错时看得到 PowerShell 的真实输出
|
|
45
|
+
|
|
46
|
+
## 它依赖什么
|
|
47
|
+
|
|
48
|
+
面板假设 `scriptsDir`(默认 `D:\document\vpn`)下有:
|
|
49
|
+
|
|
50
|
+
| 文件 | 作用 |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `vpn-on.ps1` | 启动内核并打开系统代理 |
|
|
53
|
+
| `vpn-off.ps1` | 停止内核并还原系统代理 |
|
|
54
|
+
| `vpn-refresh.ps1` | 重新抓订阅、重建配置、重启内核 |
|
|
55
|
+
|
|
56
|
+
这些脚本在 vpn-toolkit 仓库里;目录不同就在面板设置里改 `scriptsDir`。
|
|
57
|
+
另外 `controllerPort`(默认 9090)要对上 mihomo 的 `external-controller`。
|
|
58
|
+
|
|
59
|
+
## 本机路由(只接受回环访问)
|
|
60
|
+
|
|
61
|
+
| 方法 | 路径 | 作用 |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| GET | `/dsh-vpn/status.json` | 内核进程、系统代理、当前节点、节点数、日志尾巴 |
|
|
64
|
+
| POST | `/dsh-vpn/power.json` | `{"on": true}` 开,`{"on": false}` 关 |
|
|
65
|
+
| GET | `/dsh-vpn/nodes.json` | 节点列表(读 `127.0.0.1:9090/proxies`) |
|
|
66
|
+
| POST | `/dsh-vpn/select.json` | `{name}` 切换节点 |
|
|
67
|
+
| GET | `/dsh-vpn/delay.json` | 批量测延迟 |
|
|
68
|
+
| GET | `/dsh-vpn/speed.json` | 经本机代理实测下载速度 |
|
|
69
|
+
| POST | `/dsh-vpn/refresh.json` | 重建配置并重启内核 |
|
|
70
|
+
| GET | `/dsh-vpn/settings.json` | 读设置 |
|
|
71
|
+
| POST | `/dsh-vpn/settings/save.json` | 写设置 |
|
|
72
|
+
|
|
73
|
+
每个响应都是 JSON;跨站请求与非回环地址直接 403,方法不对 405。
|
|
74
|
+
|
|
75
|
+
## 设置文件
|
|
76
|
+
|
|
77
|
+
`%USERPROFILE%\.dsh\vpn-panel.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"proxyPort": 7891,
|
|
82
|
+
"controllerPort": 9090,
|
|
83
|
+
"scriptsDir": "D:\\document\\vpn",
|
|
84
|
+
"testUrl": "http://speed.cloudflare.com/__down?bytes=5000000",
|
|
85
|
+
"autoStart": false
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`testUrl` 必须是 `http://`——测速走的是普通 HTTP GET 穿代理,不解 CONNECT。
|
|
90
|
+
`autoStart` 打开后会在 `HKCU\...\Run` 写一个 `DSHVpn` 值,开机自动执行 `vpn-on.ps1`。
|
|
91
|
+
|
|
92
|
+
## 开发
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
# 宿主端:假 cordis 上下文,起一个端口把路由跑起来
|
|
96
|
+
node test/host-harness.mjs ./src/index.js 51999
|
|
97
|
+
|
|
98
|
+
# 客户端:假 __ModuleLoader__ + 假 React,验证槽位注册与渲染
|
|
99
|
+
node test/client-sim.cjs
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`lib/client.js` 是手写的模块包(`window.__ModuleLoader__.load(...)`),只依赖宿主提供的 `react`,
|
|
103
|
+
没有构建步骤;改完直接重启 DSH 就生效。
|
|
104
|
+
|
|
105
|
+
MIT.
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vpn-panel — client half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a "VPN" entry inside Settings (slot `settings.section`) that talks
|
|
5
|
+
* to the host routes served by ./src/index.js under /dsh-vpn/*.
|
|
6
|
+
*
|
|
7
|
+
* Hand-written bundle: window.__ModuleLoader__ supplies only `react`.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: 'dsh-vpn-panel',
|
|
11
|
+
factory: (require) => {
|
|
12
|
+
const module = { exports: {} }
|
|
13
|
+
const React = require('react')
|
|
14
|
+
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
const { useState, useEffect, useRef, useMemo, useCallback } = React
|
|
17
|
+
|
|
18
|
+
/* ── styling helpers ───────────────────────────────────────────── */
|
|
19
|
+
|
|
20
|
+
const C = {
|
|
21
|
+
text: 'var(--dsh-text-primary, #e8e8ea)',
|
|
22
|
+
dim: 'var(--dsh-text-secondary, #9a9aa2)',
|
|
23
|
+
faint: 'var(--dsh-text-tertiary, #6f6f78)',
|
|
24
|
+
line: 'var(--dsh-border, rgba(255,255,255,0.10))',
|
|
25
|
+
card: 'var(--dsh-surface-raised, rgba(255,255,255,0.04))',
|
|
26
|
+
field: 'var(--dsh-surface-sunken, rgba(0,0,0,0.22))',
|
|
27
|
+
accent: 'var(--dsh-accent, #5b9dff)',
|
|
28
|
+
ok: '#43c584',
|
|
29
|
+
warn: '#e8b44a',
|
|
30
|
+
bad: '#e5646b',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const s = {
|
|
34
|
+
root: { display: 'flex', flexDirection: 'column', gap: 14, padding: '4px 2px 28px', color: C.text, fontSize: 13, lineHeight: 1.55 },
|
|
35
|
+
card: { border: '1px solid ' + C.line, borderRadius: 12, background: C.card, padding: 14 },
|
|
36
|
+
row: { display: 'flex', alignItems: 'center', gap: 10 },
|
|
37
|
+
spread: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 },
|
|
38
|
+
label: { color: C.dim, fontSize: 12 },
|
|
39
|
+
value: { color: C.text, fontSize: 12, fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace' },
|
|
40
|
+
h1: { fontSize: 16, fontWeight: 650, margin: 0, letterSpacing: 0.2 },
|
|
41
|
+
h2: { fontSize: 12, fontWeight: 650, margin: 0, color: C.dim, letterSpacing: 0.4, textTransform: 'uppercase' },
|
|
42
|
+
btn: {
|
|
43
|
+
appearance: 'none',
|
|
44
|
+
border: '1px solid ' + C.line,
|
|
45
|
+
background: 'rgba(255,255,255,0.06)',
|
|
46
|
+
color: C.text,
|
|
47
|
+
borderRadius: 8,
|
|
48
|
+
padding: '6px 12px',
|
|
49
|
+
fontSize: 12,
|
|
50
|
+
cursor: 'pointer',
|
|
51
|
+
transition: 'background .15s ease, border-color .15s ease',
|
|
52
|
+
fontFamily: 'inherit',
|
|
53
|
+
},
|
|
54
|
+
btnGhost: { appearance: 'none', border: '1px solid transparent', background: 'transparent', color: C.dim, borderRadius: 8, padding: '5px 8px', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit' },
|
|
55
|
+
input: {
|
|
56
|
+
width: '100%',
|
|
57
|
+
boxSizing: 'border-box',
|
|
58
|
+
border: '1px solid ' + C.line,
|
|
59
|
+
background: C.field,
|
|
60
|
+
color: C.text,
|
|
61
|
+
borderRadius: 8,
|
|
62
|
+
padding: '6px 9px',
|
|
63
|
+
fontSize: 12,
|
|
64
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
65
|
+
outline: 'none',
|
|
66
|
+
},
|
|
67
|
+
pill: (color) => ({
|
|
68
|
+
display: 'inline-flex',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
gap: 6,
|
|
71
|
+
padding: '2px 9px',
|
|
72
|
+
borderRadius: 999,
|
|
73
|
+
border: '1px solid ' + color + '55',
|
|
74
|
+
background: color + '18',
|
|
75
|
+
color,
|
|
76
|
+
fontSize: 11,
|
|
77
|
+
fontWeight: 600,
|
|
78
|
+
}),
|
|
79
|
+
dot: (color) => ({ width: 7, height: 7, borderRadius: 999, background: color, boxShadow: '0 0 8px ' + color + '99' }),
|
|
80
|
+
log: {
|
|
81
|
+
margin: 0,
|
|
82
|
+
maxHeight: 220,
|
|
83
|
+
overflow: 'auto',
|
|
84
|
+
padding: 10,
|
|
85
|
+
borderRadius: 8,
|
|
86
|
+
background: C.field,
|
|
87
|
+
border: '1px solid ' + C.line,
|
|
88
|
+
fontSize: 11,
|
|
89
|
+
lineHeight: 1.5,
|
|
90
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
91
|
+
whiteSpace: 'pre-wrap',
|
|
92
|
+
wordBreak: 'break-all',
|
|
93
|
+
color: C.dim,
|
|
94
|
+
},
|
|
95
|
+
list: { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 260, overflow: 'auto', marginTop: 8 },
|
|
96
|
+
item: (active) => ({
|
|
97
|
+
display: 'flex',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'space-between',
|
|
100
|
+
gap: 8,
|
|
101
|
+
padding: '6px 9px',
|
|
102
|
+
borderRadius: 7,
|
|
103
|
+
cursor: 'pointer',
|
|
104
|
+
background: active ? 'rgba(91,157,255,0.14)' : 'transparent',
|
|
105
|
+
border: '1px solid ' + (active ? 'rgba(91,157,255,0.35)' : 'transparent'),
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── api ───────────────────────────────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
async function api(path, method, body) {
|
|
112
|
+
const init = { method: method || 'GET', headers: {} }
|
|
113
|
+
if (body !== undefined) {
|
|
114
|
+
init.headers['content-type'] = 'application/json'
|
|
115
|
+
init.body = JSON.stringify(body)
|
|
116
|
+
}
|
|
117
|
+
const res = await fetch(path, init)
|
|
118
|
+
let json = null
|
|
119
|
+
try {
|
|
120
|
+
json = await res.json()
|
|
121
|
+
} catch {
|
|
122
|
+
json = null
|
|
123
|
+
}
|
|
124
|
+
if (!json) throw new Error('HTTP ' + res.status)
|
|
125
|
+
return json
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fmtKbps = (n) => (typeof n === 'number' ? n >= 1024 ? (n / 1024).toFixed(2) + ' MB/s' : n + ' KB/s' : '—')
|
|
129
|
+
|
|
130
|
+
/* ── small components ──────────────────────────────────────────── */
|
|
131
|
+
|
|
132
|
+
function Switch(props) {
|
|
133
|
+
const { on, disabled, onChange, labels } = props
|
|
134
|
+
return h(
|
|
135
|
+
'button',
|
|
136
|
+
{
|
|
137
|
+
type: 'button',
|
|
138
|
+
disabled,
|
|
139
|
+
onClick: onChange,
|
|
140
|
+
title: on ? labels[1] : labels[0],
|
|
141
|
+
style: {
|
|
142
|
+
appearance: 'none',
|
|
143
|
+
border: '1px solid ' + (on ? C.ok + '88' : C.line),
|
|
144
|
+
background: on ? C.ok + '22' : 'rgba(255,255,255,0.05)',
|
|
145
|
+
borderRadius: 999,
|
|
146
|
+
width: 54,
|
|
147
|
+
height: 28,
|
|
148
|
+
padding: 2,
|
|
149
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
150
|
+
opacity: disabled ? 0.55 : 1,
|
|
151
|
+
transition: 'background .18s ease, border-color .18s ease',
|
|
152
|
+
display: 'flex',
|
|
153
|
+
alignItems: 'center',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
h('span', {
|
|
157
|
+
style: {
|
|
158
|
+
display: 'block',
|
|
159
|
+
width: 22,
|
|
160
|
+
height: 22,
|
|
161
|
+
borderRadius: 999,
|
|
162
|
+
background: on ? C.ok : '#7a7a84',
|
|
163
|
+
transform: on ? 'translateX(26px)' : 'translateX(0px)',
|
|
164
|
+
transition: 'transform .18s cubic-bezier(.4,.9,.3,1), background .18s ease',
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function Row(props) {
|
|
171
|
+
return h('div', { style: s.spread }, h('span', { style: s.label }, props.k), h('span', { style: s.value }, props.v))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── main panel ────────────────────────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
function VpnPanel() {
|
|
177
|
+
const [status, setStatus] = useState(null)
|
|
178
|
+
const [nodes, setNodes] = useState(null)
|
|
179
|
+
const [settings, setSettings] = useState(null)
|
|
180
|
+
const [busy, setBusy] = useState('')
|
|
181
|
+
const [notice, setNotice] = useState(null)
|
|
182
|
+
const [speed, setSpeed] = useState(null)
|
|
183
|
+
const [delays, setDelays] = useState({})
|
|
184
|
+
const [filter, setFilter] = useState('')
|
|
185
|
+
const [showLog, setShowLog] = useState(false)
|
|
186
|
+
const [draft, setDraft] = useState(null)
|
|
187
|
+
const alive = useRef(true)
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
alive.current = true
|
|
191
|
+
return () => {
|
|
192
|
+
alive.current = false
|
|
193
|
+
}
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const pullStatus = useCallback(async () => {
|
|
197
|
+
try {
|
|
198
|
+
const st = await api('/dsh-vpn/status.json')
|
|
199
|
+
if (alive.current) setStatus(st)
|
|
200
|
+
} catch {}
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
203
|
+
const pullNodes = useCallback(async () => {
|
|
204
|
+
try {
|
|
205
|
+
const nd = await api('/dsh-vpn/nodes.json')
|
|
206
|
+
if (alive.current) setNodes(nd.ok ? nd : null)
|
|
207
|
+
} catch {
|
|
208
|
+
if (alive.current) setNodes(null)
|
|
209
|
+
}
|
|
210
|
+
}, [])
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
pullStatus()
|
|
214
|
+
api('/dsh-vpn/settings.json')
|
|
215
|
+
.then((r) => {
|
|
216
|
+
if (!alive.current) return
|
|
217
|
+
setSettings(r.settings)
|
|
218
|
+
setDraft(r.settings)
|
|
219
|
+
})
|
|
220
|
+
.catch(() => {})
|
|
221
|
+
}, [pullStatus])
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const timer = setInterval(() => {
|
|
225
|
+
if (!alive.current) return
|
|
226
|
+
if (!busy) pullStatus()
|
|
227
|
+
}, 5000)
|
|
228
|
+
return () => clearInterval(timer)
|
|
229
|
+
}, [busy, pullStatus])
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (status && status.running) pullNodes()
|
|
233
|
+
else setNodes(null)
|
|
234
|
+
}, [status && status.running, pullNodes])
|
|
235
|
+
|
|
236
|
+
const toast = (kind, text) => {
|
|
237
|
+
setNotice({ kind, text })
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
if (alive.current) setNotice((n) => (n && n.text === text ? null : n))
|
|
240
|
+
}, 6000)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const toggle = async () => {
|
|
244
|
+
const want = !(status && status.running)
|
|
245
|
+
setBusy(want ? '开启中…' : '关闭中…')
|
|
246
|
+
setNotice(null)
|
|
247
|
+
try {
|
|
248
|
+
const res = await api('/dsh-vpn/power.json', 'POST', { on: want })
|
|
249
|
+
if (alive.current) {
|
|
250
|
+
if (res.status) setStatus(res.status)
|
|
251
|
+
if (res.ok) toast('ok', want ? 'VPN 已开启,系统代理已接管' : 'VPN 已关闭,系统代理已还原')
|
|
252
|
+
else toast('bad', (res.error || res.log || '操作失败').slice(0, 300))
|
|
253
|
+
}
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
256
|
+
} finally {
|
|
257
|
+
if (alive.current) setBusy('')
|
|
258
|
+
pullStatus()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const selectNode = async (nodeName) => {
|
|
263
|
+
setBusy('切换节点…')
|
|
264
|
+
try {
|
|
265
|
+
const res = await api('/dsh-vpn/select.json', 'POST', { name: nodeName })
|
|
266
|
+
if (alive.current) {
|
|
267
|
+
if (res.ok) toast('ok', '已切换到 ' + nodeName)
|
|
268
|
+
else toast('bad', res.error || '切换失败')
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
272
|
+
} finally {
|
|
273
|
+
if (alive.current) setBusy('')
|
|
274
|
+
pullStatus()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const testDelay = async () => {
|
|
279
|
+
const list = (nodes && nodes.list) || []
|
|
280
|
+
if (!list.length) return
|
|
281
|
+
setBusy('测延迟…')
|
|
282
|
+
try {
|
|
283
|
+
const sample = list.slice(0, 40)
|
|
284
|
+
const res = await api('/dsh-vpn/delay.json?names=' + encodeURIComponent(sample.join(',')))
|
|
285
|
+
if (res.ok && alive.current) {
|
|
286
|
+
const map = {}
|
|
287
|
+
for (const r of res.results) map[r.name] = r.delay
|
|
288
|
+
setDelays((d) => Object.assign({}, d, map))
|
|
289
|
+
const aliveCount = res.results.filter((r) => r.delay !== null).length
|
|
290
|
+
toast('ok', `测速完成:${aliveCount}/${res.results.length} 个节点可用`)
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
294
|
+
} finally {
|
|
295
|
+
if (alive.current) setBusy('')
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const testSpeed = async () => {
|
|
300
|
+
setBusy('测速中…')
|
|
301
|
+
setSpeed(null)
|
|
302
|
+
try {
|
|
303
|
+
const res = await api('/dsh-vpn/speed.json')
|
|
304
|
+
if (alive.current) {
|
|
305
|
+
setSpeed(res)
|
|
306
|
+
if (res.ok) toast('ok', '下载速度 ' + fmtKbps(res.kbps))
|
|
307
|
+
else toast('bad', res.error || '测速失败')
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
311
|
+
} finally {
|
|
312
|
+
if (alive.current) setBusy('')
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const refreshSubs = async () => {
|
|
317
|
+
setBusy('更新订阅…')
|
|
318
|
+
try {
|
|
319
|
+
const res = await api('/dsh-vpn/refresh.json', 'POST', {})
|
|
320
|
+
if (alive.current) {
|
|
321
|
+
if (res.status) setStatus(res.status)
|
|
322
|
+
toast(res.ok ? 'ok' : 'bad', res.ok ? '订阅已更新,节点列表已重建' : (res.error || '更新失败'))
|
|
323
|
+
if (res.log) setShowLog(true)
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
327
|
+
} finally {
|
|
328
|
+
if (alive.current) setBusy('')
|
|
329
|
+
pullNodes()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const saveSettings = async () => {
|
|
334
|
+
if (!draft) return
|
|
335
|
+
setBusy('保存设置…')
|
|
336
|
+
try {
|
|
337
|
+
const res = await api('/dsh-vpn/settings/save.json', 'POST', draft)
|
|
338
|
+
if (alive.current) {
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
setSettings(res.settings)
|
|
341
|
+
setDraft(res.settings)
|
|
342
|
+
if (res.status) setStatus(res.status)
|
|
343
|
+
toast('ok', '设置已保存')
|
|
344
|
+
} else toast('bad', res.error || '保存失败')
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (alive.current) toast('bad', String(err.message || err))
|
|
348
|
+
} finally {
|
|
349
|
+
if (alive.current) setBusy('')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const running = !!(status && status.running)
|
|
354
|
+
const badge = busy
|
|
355
|
+
? { color: C.warn, text: busy }
|
|
356
|
+
: running
|
|
357
|
+
? { color: C.ok, text: status && status.systemProxy ? '已开启 · 全局代理' : '已开启 · 未改系统代理' }
|
|
358
|
+
: { color: C.faint, text: '未开启' }
|
|
359
|
+
|
|
360
|
+
const visibleList = useMemo(() => {
|
|
361
|
+
const list = (nodes && nodes.list) || []
|
|
362
|
+
const q = filter.trim().toLowerCase()
|
|
363
|
+
if (!q) return list
|
|
364
|
+
return list.filter((n) => String(n).toLowerCase().includes(q))
|
|
365
|
+
}, [nodes, filter])
|
|
366
|
+
|
|
367
|
+
return h(
|
|
368
|
+
'div',
|
|
369
|
+
{ style: s.root },
|
|
370
|
+
|
|
371
|
+
/* header */
|
|
372
|
+
h(
|
|
373
|
+
'div',
|
|
374
|
+
{ style: s.spread },
|
|
375
|
+
h(
|
|
376
|
+
'div',
|
|
377
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 3 } },
|
|
378
|
+
h('h1', { style: s.h1 }, 'VPN'),
|
|
379
|
+
h('span', { style: { ...s.label, fontSize: 11 } }, '免费节点 · Mihomo 内核 · 本地代理'),
|
|
380
|
+
),
|
|
381
|
+
h('div', { style: s.row }, h('span', { style: s.pill(badge.color) }, h('span', { style: s.dot(badge.color) }), badge.text)),
|
|
382
|
+
),
|
|
383
|
+
|
|
384
|
+
/* master switch */
|
|
385
|
+
h(
|
|
386
|
+
'div',
|
|
387
|
+
{ style: { ...s.card, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14 } },
|
|
388
|
+
h(
|
|
389
|
+
'div',
|
|
390
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
391
|
+
h('span', { style: { fontWeight: 600 } }, running ? '代理正在运行' : '代理处于关闭状态'),
|
|
392
|
+
h(
|
|
393
|
+
'span',
|
|
394
|
+
{ style: { ...s.label, fontSize: 11 } },
|
|
395
|
+
running
|
|
396
|
+
? 'HTTP/SOCKS 入口 127.0.0.1:' + (status.proxyPort || '—') + ' · 规则分流:国内直连,国外走代理'
|
|
397
|
+
: '打开后自动选择最快节点,并接管系统代理',
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
h(Switch, { on: running, disabled: !!busy, onChange: toggle, labels: ['打开 VPN', '关闭 VPN'] }),
|
|
401
|
+
),
|
|
402
|
+
|
|
403
|
+
/* notice */
|
|
404
|
+
notice &&
|
|
405
|
+
h(
|
|
406
|
+
'div',
|
|
407
|
+
{
|
|
408
|
+
style: {
|
|
409
|
+
border: '1px solid ' + (notice.kind === 'ok' ? C.ok : C.bad) + '55',
|
|
410
|
+
background: (notice.kind === 'ok' ? C.ok : C.bad) + '14',
|
|
411
|
+
color: notice.kind === 'ok' ? C.ok : C.bad,
|
|
412
|
+
borderRadius: 9,
|
|
413
|
+
padding: '8px 11px',
|
|
414
|
+
fontSize: 12,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
notice.text,
|
|
418
|
+
),
|
|
419
|
+
|
|
420
|
+
/* status */
|
|
421
|
+
h(
|
|
422
|
+
'div',
|
|
423
|
+
{ style: s.card },
|
|
424
|
+
h('h2', { style: s.h2 }, '状态'),
|
|
425
|
+
h('div', { style: { marginTop: 8, display: 'flex', flexDirection: 'column', gap: 5 } },
|
|
426
|
+
h(Row, { k: '内核进程', v: running ? '运行中 (127.0.0.1:' + status.proxyPort + ')' : '未运行' }),
|
|
427
|
+
h(Row, { k: '系统代理', v: status && status.systemProxy ? (status.proxyServer || '已开启') : '未接管' }),
|
|
428
|
+
h(Row, { k: '当前节点', v: (status && status.node) || '—' }),
|
|
429
|
+
h(Row, { k: '可选节点', v: status && status.nodeCount ? status.nodeCount + ' 个' : '—' }),
|
|
430
|
+
speed && h(Row, { k: '下载测速', v: speed.ok ? fmtKbps(speed.kbps) + ' · ' + speed.seconds + 's' : '失败:' + (speed.error || '') }),
|
|
431
|
+
),
|
|
432
|
+
h(
|
|
433
|
+
'div',
|
|
434
|
+
{ style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 } },
|
|
435
|
+
h('button', { type: 'button', style: s.btn, disabled: !!busy || !running, onClick: testSpeed }, '测下载速度'),
|
|
436
|
+
h('button', { type: 'button', style: s.btn, disabled: !!busy || !running, onClick: testDelay }, '测节点延迟'),
|
|
437
|
+
h('button', { type: 'button', style: s.btn, disabled: !!busy, onClick: refreshSubs }, '更新订阅节点'),
|
|
438
|
+
h('button', { type: 'button', style: s.btn, disabled: !!busy, onClick: () => pullStatus() }, '刷新状态'),
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
|
|
442
|
+
/* nodes */
|
|
443
|
+
running &&
|
|
444
|
+
h(
|
|
445
|
+
'div',
|
|
446
|
+
{ style: s.card },
|
|
447
|
+
h(
|
|
448
|
+
'div',
|
|
449
|
+
{ style: s.spread },
|
|
450
|
+
h('h2', { style: s.h2 }, '节点'),
|
|
451
|
+
h('span', { style: { ...s.label, fontSize: 11 } }, ((nodes && nodes.list) || []).length + ' 个可选'),
|
|
452
|
+
),
|
|
453
|
+
h('input', {
|
|
454
|
+
style: { ...s.input, marginTop: 10, fontFamily: 'inherit' },
|
|
455
|
+
placeholder: '筛选节点名称…',
|
|
456
|
+
value: filter,
|
|
457
|
+
onChange: (e) => setFilter(e.target.value),
|
|
458
|
+
}),
|
|
459
|
+
h(
|
|
460
|
+
'div',
|
|
461
|
+
{ style: s.list },
|
|
462
|
+
visibleList.slice(0, 200).map((nodeName) =>
|
|
463
|
+
h(
|
|
464
|
+
'div',
|
|
465
|
+
{
|
|
466
|
+
key: nodeName,
|
|
467
|
+
style: s.item(nodes && nodes.now === nodeName),
|
|
468
|
+
onClick: () => !busy && selectNode(nodeName),
|
|
469
|
+
title: '点击设为当前节点',
|
|
470
|
+
},
|
|
471
|
+
h('span', { style: { fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, nodeName),
|
|
472
|
+
h(
|
|
473
|
+
'span',
|
|
474
|
+
{ style: { ...s.label, fontSize: 11, flex: 'none' } },
|
|
475
|
+
delays[nodeName] === null ? '超时' : delays[nodeName] ? delays[nodeName] + ' ms' : nodes && nodes.now === nodeName ? '使用中' : '',
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
),
|
|
479
|
+
!visibleList.length && h('div', { style: { ...s.label, padding: 10 } }, nodes ? '没有匹配的节点' : '节点列表加载中…'),
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
|
|
483
|
+
/* settings */
|
|
484
|
+
draft &&
|
|
485
|
+
h(
|
|
486
|
+
'div',
|
|
487
|
+
{ style: s.card },
|
|
488
|
+
h('h2', { style: s.h2 }, '设置'),
|
|
489
|
+
h(
|
|
490
|
+
'div',
|
|
491
|
+
{ style: { marginTop: 10, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 } },
|
|
492
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5 } },
|
|
493
|
+
h('span', { style: s.label }, '本地代理端口'),
|
|
494
|
+
h('input', {
|
|
495
|
+
style: s.input,
|
|
496
|
+
type: 'number',
|
|
497
|
+
value: draft.proxyPort,
|
|
498
|
+
onChange: (e) => setDraft({ ...draft, proxyPort: Number(e.target.value) || 0 }),
|
|
499
|
+
}),
|
|
500
|
+
),
|
|
501
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5 } },
|
|
502
|
+
h('span', { style: s.label }, '控制器端口'),
|
|
503
|
+
h('input', {
|
|
504
|
+
style: s.input,
|
|
505
|
+
type: 'number',
|
|
506
|
+
value: draft.controllerPort,
|
|
507
|
+
onChange: (e) => setDraft({ ...draft, controllerPort: Number(e.target.value) || 0 }),
|
|
508
|
+
}),
|
|
509
|
+
),
|
|
510
|
+
),
|
|
511
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 10 } },
|
|
512
|
+
h('span', { style: s.label }, '脚本目录'),
|
|
513
|
+
h('input', { style: s.input, value: draft.scriptsDir, onChange: (e) => setDraft({ ...draft, scriptsDir: e.target.value }) }),
|
|
514
|
+
),
|
|
515
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 10 } },
|
|
516
|
+
h('span', { style: s.label }, '测速地址 (http)'),
|
|
517
|
+
h('input', { style: s.input, value: draft.testUrl, onChange: (e) => setDraft({ ...draft, testUrl: e.target.value }) }),
|
|
518
|
+
),
|
|
519
|
+
h(
|
|
520
|
+
'label',
|
|
521
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, cursor: 'pointer' } },
|
|
522
|
+
h('input', {
|
|
523
|
+
type: 'checkbox',
|
|
524
|
+
checked: !!draft.autoStart,
|
|
525
|
+
onChange: (e) => setDraft({ ...draft, autoStart: e.target.checked }),
|
|
526
|
+
}),
|
|
527
|
+
h('span', { style: { fontSize: 12 } }, '开机自动开启 VPN'),
|
|
528
|
+
),
|
|
529
|
+
h(
|
|
530
|
+
'div',
|
|
531
|
+
{ style: { display: 'flex', gap: 8, marginTop: 12 } },
|
|
532
|
+
h('button', { type: 'button', style: { ...s.btn, borderColor: C.accent + '66', background: C.accent + '22' }, disabled: !!busy, onClick: saveSettings }, '保存设置'),
|
|
533
|
+
h('button', { type: 'button', style: s.btnGhost, onClick: () => setDraft(settings) }, '还原'),
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
|
|
537
|
+
/* log */
|
|
538
|
+
status &&
|
|
539
|
+
(status.lastLog || status.lastError) &&
|
|
540
|
+
h(
|
|
541
|
+
'div',
|
|
542
|
+
{ style: s.card },
|
|
543
|
+
h(
|
|
544
|
+
'div',
|
|
545
|
+
{ style: s.spread },
|
|
546
|
+
h('h2', { style: s.h2 }, '最近日志'),
|
|
547
|
+
h('button', { type: 'button', style: s.btnGhost, onClick: () => setShowLog(!showLog) }, showLog ? '收起' : '展开'),
|
|
548
|
+
),
|
|
549
|
+
showLog && h('pre', { style: { ...s.log, marginTop: 10 } }, (status.lastLog || '') + (status.lastError ? '\n[error]\n' + status.lastError : '')),
|
|
550
|
+
),
|
|
551
|
+
|
|
552
|
+
h(
|
|
553
|
+
'div',
|
|
554
|
+
{ style: { ...s.label, fontSize: 11, display: 'flex', justifyContent: 'space-between' } },
|
|
555
|
+
h('span', null, '内核:Mihomo · 仅供学习研究,请遵守当地法律法规'),
|
|
556
|
+
status && status.lastActionAt ? h('span', null, '上次操作 ' + new Date(status.lastActionAt).toLocaleTimeString()) : null,
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* ── plugin contract ───────────────────────────────────────────── */
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
name: 'vpn-panel',
|
|
565
|
+
inject: ['slots'],
|
|
566
|
+
apply(ctx) {
|
|
567
|
+
ctx.slots.inject('settings.section', () =>
|
|
568
|
+
ctx.slots.register(
|
|
569
|
+
{
|
|
570
|
+
name: 'settings.section',
|
|
571
|
+
id: 'vpn',
|
|
572
|
+
order: 60,
|
|
573
|
+
label: () => 'VPN',
|
|
574
|
+
},
|
|
575
|
+
VpnPanel,
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
ctx.slots.inject('settings.section.icon', () =>
|
|
579
|
+
ctx.slots.register(
|
|
580
|
+
{
|
|
581
|
+
name: 'settings.section.icon',
|
|
582
|
+
id: 'vpn',
|
|
583
|
+
},
|
|
584
|
+
() =>
|
|
585
|
+
h(
|
|
586
|
+
'svg',
|
|
587
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
588
|
+
h('path', { d: 'M12 3l7 3v6c0 4.2-2.9 7.7-7 9-4.1-1.3-7-4.8-7-9V6l7-3z' }),
|
|
589
|
+
h('path', { d: 'M9.2 12.2l2 2 3.6-3.9' }),
|
|
590
|
+
),
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return module.exports
|
|
597
|
+
},
|
|
598
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-vpn-panel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Settings section that controls a local proxy core: start/stop, node selection, real download measurement and config rebuild. Ships no nodes or proxy core.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"lib",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"immediately": true,
|
|
26
|
+
"inject": [
|
|
27
|
+
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dsh-plugin",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"vpn",
|
|
35
|
+
"proxy",
|
|
36
|
+
"mihomo"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": "^22.19.0 || \u003e=24.0.0"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/hanhan9999999999999999999/dsh-vpn-panel.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/hanhan9999999999999999999/dsh-vpn-panel#readme",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/hanhan9999999999999999999/dsh-vpn-panel/issues"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vpn-panel — host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves a tiny JSON API under /dsh-vpn/* that drives the local mihomo based
|
|
5
|
+
* free-VPN stack (D:\document\vpn by default) and is consumed by the client
|
|
6
|
+
* bundle's Settings section:
|
|
7
|
+
*
|
|
8
|
+
* GET /dsh-vpn/status.json runtime state (port listening, system proxy, current node)
|
|
9
|
+
* POST /dsh-vpn/power.json { on: true|false } → vpn-on.ps1 / vpn-off.ps1
|
|
10
|
+
* GET /dsh-vpn/nodes.json groups + node list from the mihomo controller
|
|
11
|
+
* POST /dsh-vpn/select.json { name } → pin a node in the select group
|
|
12
|
+
* GET /dsh-vpn/delay.json latency probe for a node subset
|
|
13
|
+
* GET /dsh-vpn/speed.json real download throughput through the proxy
|
|
14
|
+
* POST /dsh-vpn/refresh.json pull subscriptions + rebuild config + restart
|
|
15
|
+
* GET /dsh-vpn/settings.json panel settings (ports, scripts dir, autostart)
|
|
16
|
+
* POST /dsh-vpn/settings.json write panel settings
|
|
17
|
+
*
|
|
18
|
+
* Everything is loopback-only: the routes exist for the desktop/web client that
|
|
19
|
+
* runs on this machine, and writes are refused for cross-site or foreign hosts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import os from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import net from 'node:net'
|
|
26
|
+
import http from 'node:http'
|
|
27
|
+
import { spawn } from 'node:child_process'
|
|
28
|
+
|
|
29
|
+
export const name = 'vpn-panel'
|
|
30
|
+
export const inject = ['webServer']
|
|
31
|
+
|
|
32
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
33
|
+
const SETTINGS_FILE = path.join(DSH_HOME, 'vpn-panel.json')
|
|
34
|
+
|
|
35
|
+
const DEFAULTS = {
|
|
36
|
+
scriptsDir: 'D:\\document\\vpn',
|
|
37
|
+
proxyPort: 7891,
|
|
38
|
+
controllerPort: 9090,
|
|
39
|
+
autoStart: false,
|
|
40
|
+
testUrl: 'http://speed.cloudflare.com/__down?bytes=5000000',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readSettings() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
|
|
46
|
+
return { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) }
|
|
47
|
+
} catch {
|
|
48
|
+
return { ...DEFAULTS }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeSettings(patch) {
|
|
53
|
+
const next = { ...readSettings(), ...(patch || {}) }
|
|
54
|
+
// Only known keys, and only sane types — the file is read on every request.
|
|
55
|
+
const clean = {
|
|
56
|
+
scriptsDir: String(next.scriptsDir || DEFAULTS.scriptsDir),
|
|
57
|
+
proxyPort: Number(next.proxyPort) || DEFAULTS.proxyPort,
|
|
58
|
+
controllerPort: Number(next.controllerPort) || DEFAULTS.controllerPort,
|
|
59
|
+
autoStart: next.autoStart === true,
|
|
60
|
+
testUrl: String(next.testUrl || DEFAULTS.testUrl),
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true })
|
|
64
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(clean, null, 2), 'utf8')
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { ok: false, error: String((err && err.message) || err) }
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, settings: clean }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isLoopbackHostname(input) {
|
|
72
|
+
const h = String(input || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
|
|
73
|
+
if (!h) return false
|
|
74
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true
|
|
75
|
+
if (h === '::1') return true
|
|
76
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
|
77
|
+
return !!m && Number(m[1]) === 127 && [m[2], m[3], m[4]].every((x) => Number(x) <= 255)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function requestRejection(req) {
|
|
81
|
+
try {
|
|
82
|
+
const headers = (req && req.headers) || {}
|
|
83
|
+
let host = null
|
|
84
|
+
try {
|
|
85
|
+
host = new URL('http://' + String(headers.host || ''))
|
|
86
|
+
} catch {
|
|
87
|
+
return 403
|
|
88
|
+
}
|
|
89
|
+
if (!isLoopbackHostname(host.hostname)) return 403
|
|
90
|
+
const site = String(headers['sec-fetch-site'] || '').toLowerCase()
|
|
91
|
+
if (site === 'cross-site') return 403
|
|
92
|
+
const origin = headers.origin
|
|
93
|
+
if (typeof origin === 'string' && origin && origin !== 'null') {
|
|
94
|
+
try {
|
|
95
|
+
if (new URL(origin).host.toLowerCase() !== host.host.toLowerCase()) return 403
|
|
96
|
+
} catch {
|
|
97
|
+
return 403
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
} catch {
|
|
102
|
+
return 403
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sendJson(res, code, body) {
|
|
107
|
+
try {
|
|
108
|
+
const text = JSON.stringify(body)
|
|
109
|
+
res.statusCode = code
|
|
110
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
111
|
+
res.setHeader('cache-control', 'no-store')
|
|
112
|
+
res.end(text)
|
|
113
|
+
} catch {
|
|
114
|
+
try {
|
|
115
|
+
res.statusCode = 500
|
|
116
|
+
res.end()
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readBody(req, limit = 64 * 1024) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
let size = 0
|
|
124
|
+
const chunks = []
|
|
125
|
+
req.on('data', (c) => {
|
|
126
|
+
size += c.length
|
|
127
|
+
if (size > limit) {
|
|
128
|
+
req.destroy()
|
|
129
|
+
resolve(null)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
chunks.push(c)
|
|
133
|
+
})
|
|
134
|
+
req.on('end', () => {
|
|
135
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
136
|
+
if (!text) return resolve({})
|
|
137
|
+
try {
|
|
138
|
+
resolve(JSON.parse(text))
|
|
139
|
+
} catch {
|
|
140
|
+
resolve(null)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
req.on('error', () => resolve(null))
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function portOpen(port, host = '127.0.0.1', timeout = 700) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const socket = new net.Socket()
|
|
150
|
+
let done = false
|
|
151
|
+
const finish = (ok) => {
|
|
152
|
+
if (done) return
|
|
153
|
+
done = true
|
|
154
|
+
try {
|
|
155
|
+
socket.destroy()
|
|
156
|
+
} catch {}
|
|
157
|
+
resolve(ok)
|
|
158
|
+
}
|
|
159
|
+
socket.setTimeout(timeout)
|
|
160
|
+
socket.once('connect', () => finish(true))
|
|
161
|
+
socket.once('timeout', () => finish(false))
|
|
162
|
+
socket.once('error', () => finish(false))
|
|
163
|
+
try {
|
|
164
|
+
socket.connect(port, host)
|
|
165
|
+
} catch {
|
|
166
|
+
finish(false)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function controllerRequest(conf, method, apiPath, body, timeout = 4000) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), 'utf8')
|
|
174
|
+
const req = http.request(
|
|
175
|
+
{
|
|
176
|
+
host: '127.0.0.1',
|
|
177
|
+
port: conf.controllerPort,
|
|
178
|
+
method,
|
|
179
|
+
path: apiPath,
|
|
180
|
+
headers: payload
|
|
181
|
+
? { 'content-type': 'application/json', 'content-length': payload.length }
|
|
182
|
+
: undefined,
|
|
183
|
+
timeout,
|
|
184
|
+
},
|
|
185
|
+
(res) => {
|
|
186
|
+
const chunks = []
|
|
187
|
+
res.on('data', (c) => chunks.push(c))
|
|
188
|
+
res.on('end', () => {
|
|
189
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
190
|
+
let json = null
|
|
191
|
+
try {
|
|
192
|
+
json = text ? JSON.parse(text) : null
|
|
193
|
+
} catch {
|
|
194
|
+
json = null
|
|
195
|
+
}
|
|
196
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, code: res.statusCode, json, text })
|
|
197
|
+
})
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
req.on('timeout', () => {
|
|
201
|
+
try {
|
|
202
|
+
req.destroy()
|
|
203
|
+
} catch {}
|
|
204
|
+
resolve({ ok: false, code: 0, json: null, text: 'timeout' })
|
|
205
|
+
})
|
|
206
|
+
req.on('error', (err) => resolve({ ok: false, code: 0, json: null, text: String(err.message || err) }))
|
|
207
|
+
if (payload) req.write(payload)
|
|
208
|
+
req.end()
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const state = {
|
|
213
|
+
busy: null, // 'on' | 'off' | 'refresh'
|
|
214
|
+
lastLog: '',
|
|
215
|
+
lastError: '',
|
|
216
|
+
lastActionAt: 0,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runPowerShell(scriptPath, args = [], timeoutMs = 60000) {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const exists = (() => {
|
|
222
|
+
try {
|
|
223
|
+
return fs.existsSync(scriptPath)
|
|
224
|
+
} catch {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
})()
|
|
228
|
+
if (!exists) return resolve({ ok: false, code: -1, out: '', err: `script not found: ${scriptPath}` })
|
|
229
|
+
const child = spawn(
|
|
230
|
+
'powershell.exe',
|
|
231
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args],
|
|
232
|
+
{ windowsHide: true },
|
|
233
|
+
)
|
|
234
|
+
let out = ''
|
|
235
|
+
let err = ''
|
|
236
|
+
const timer = setTimeout(() => {
|
|
237
|
+
try {
|
|
238
|
+
child.kill()
|
|
239
|
+
} catch {}
|
|
240
|
+
}, timeoutMs)
|
|
241
|
+
child.stdout.on('data', (d) => {
|
|
242
|
+
out += d.toString()
|
|
243
|
+
})
|
|
244
|
+
child.stderr.on('data', (d) => {
|
|
245
|
+
err += d.toString()
|
|
246
|
+
})
|
|
247
|
+
child.on('error', (e) => {
|
|
248
|
+
clearTimeout(timer)
|
|
249
|
+
resolve({ ok: false, code: -1, out, err: String(e.message || e) })
|
|
250
|
+
})
|
|
251
|
+
child.on('close', (code) => {
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
resolve({ ok: code === 0, code, out, err })
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readRegValue(keyPath, valueName) {
|
|
259
|
+
return new Promise((resolve) => {
|
|
260
|
+
const child = spawn('reg.exe', ['query', keyPath, '/v', valueName], { windowsHide: true })
|
|
261
|
+
let out = ''
|
|
262
|
+
child.stdout.on('data', (d) => {
|
|
263
|
+
out += d.toString()
|
|
264
|
+
})
|
|
265
|
+
child.on('error', () => resolve(null))
|
|
266
|
+
child.on('close', () => {
|
|
267
|
+
const m = new RegExp(valueName + '\\s+(REG_\\w+)\\s+(.+)', 'i').exec(out)
|
|
268
|
+
if (!m) return resolve(null)
|
|
269
|
+
let value = m[2].trim()
|
|
270
|
+
// reg.exe prints DWORDs as 0x1 — normalise so callers can compare numbers.
|
|
271
|
+
if (/^0x[0-9a-f]+$/i.test(value)) value = String(parseInt(value, 16))
|
|
272
|
+
resolve(value)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function collectStatus(conf) {
|
|
278
|
+
const listening = await portOpen(conf.proxyPort)
|
|
279
|
+
const proxyEnable = await readRegValue(
|
|
280
|
+
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
|
281
|
+
'ProxyEnable',
|
|
282
|
+
)
|
|
283
|
+
const proxyServer = await readRegValue(
|
|
284
|
+
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
|
285
|
+
'ProxyServer',
|
|
286
|
+
)
|
|
287
|
+
let group = null
|
|
288
|
+
let node = null
|
|
289
|
+
let nodeCount = 0
|
|
290
|
+
if (listening) {
|
|
291
|
+
const res = await controllerRequest(conf, 'GET', '/proxies')
|
|
292
|
+
if (res.ok && res.json && res.json.proxies) {
|
|
293
|
+
const proxies = res.json.proxies
|
|
294
|
+
const select = proxies['手动切换']
|
|
295
|
+
const auto = proxies['自动选择']
|
|
296
|
+
const target = select || auto
|
|
297
|
+
if (target) {
|
|
298
|
+
group = select ? '手动切换' : '自动选择'
|
|
299
|
+
node = target.now || null
|
|
300
|
+
nodeCount = Array.isArray(target.all) ? target.all.length : 0
|
|
301
|
+
} else {
|
|
302
|
+
nodeCount = Object.keys(proxies).length
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
running: listening,
|
|
308
|
+
proxyPort: conf.proxyPort,
|
|
309
|
+
controllerPort: conf.controllerPort,
|
|
310
|
+
scriptsDir: conf.scriptsDir,
|
|
311
|
+
systemProxy: String(proxyEnable) === '1',
|
|
312
|
+
proxyServer: proxyServer || '',
|
|
313
|
+
group,
|
|
314
|
+
node,
|
|
315
|
+
nodeCount,
|
|
316
|
+
busy: state.busy,
|
|
317
|
+
lastLog: state.lastLog.slice(-4000),
|
|
318
|
+
lastError: state.lastError,
|
|
319
|
+
lastActionAt: state.lastActionAt,
|
|
320
|
+
settings: conf,
|
|
321
|
+
time: Date.now(),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Real throughput through the local mixed proxy: plain HTTP GET (no CONNECT),
|
|
327
|
+
* 6 seconds of streaming after the first byte, hard stop at 20 s overall.
|
|
328
|
+
* Throughput is measured from the first byte, so slow DNS/connect does not
|
|
329
|
+
* masquerade as a slow link.
|
|
330
|
+
*/
|
|
331
|
+
function speedThroughProxy(conf, url) {
|
|
332
|
+
return new Promise((resolve) => {
|
|
333
|
+
let target
|
|
334
|
+
try {
|
|
335
|
+
target = new URL(url)
|
|
336
|
+
} catch {
|
|
337
|
+
return resolve({ ok: false, error: 'bad testUrl' })
|
|
338
|
+
}
|
|
339
|
+
if (target.protocol !== 'http:') return resolve({ ok: false, error: 'testUrl must be http://' })
|
|
340
|
+
const started = Date.now()
|
|
341
|
+
let firstByteAt = null
|
|
342
|
+
let bytes = 0
|
|
343
|
+
let done = false
|
|
344
|
+
let guard = null
|
|
345
|
+
const finish = (extra) => {
|
|
346
|
+
if (done) return
|
|
347
|
+
done = true
|
|
348
|
+
if (guard) clearTimeout(guard)
|
|
349
|
+
const span = firstByteAt ? (Date.now() - firstByteAt) / 1000 : (Date.now() - started) / 1000
|
|
350
|
+
const seconds = Math.max(0.05, span)
|
|
351
|
+
resolve({
|
|
352
|
+
ok: bytes > 0,
|
|
353
|
+
bytes,
|
|
354
|
+
seconds: Number(seconds.toFixed(2)),
|
|
355
|
+
kbps: Math.round(bytes / 1024 / seconds),
|
|
356
|
+
...extra,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
const req = http.request(
|
|
360
|
+
{
|
|
361
|
+
host: '127.0.0.1',
|
|
362
|
+
port: conf.proxyPort,
|
|
363
|
+
method: 'GET',
|
|
364
|
+
path: url,
|
|
365
|
+
headers: { host: target.host, 'user-agent': 'dsh-vpn-panel', connection: 'close' },
|
|
366
|
+
},
|
|
367
|
+
(res) => {
|
|
368
|
+
if (res.statusCode !== 200) {
|
|
369
|
+
try {
|
|
370
|
+
res.destroy()
|
|
371
|
+
} catch {}
|
|
372
|
+
return finish({ ok: false, error: 'http ' + res.statusCode })
|
|
373
|
+
}
|
|
374
|
+
res.on('data', (chunk) => {
|
|
375
|
+
if (firstByteAt === null) firstByteAt = Date.now()
|
|
376
|
+
bytes += chunk.length
|
|
377
|
+
if (Date.now() - firstByteAt > 6000) {
|
|
378
|
+
try {
|
|
379
|
+
req.destroy()
|
|
380
|
+
} catch {}
|
|
381
|
+
finish({})
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
res.on('end', () => finish({}))
|
|
385
|
+
res.on('error', () => finish({ partial: true }))
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
guard = setTimeout(() => {
|
|
389
|
+
try {
|
|
390
|
+
req.destroy()
|
|
391
|
+
} catch {}
|
|
392
|
+
finish({ ok: false, error: 'timeout' })
|
|
393
|
+
}, 20000)
|
|
394
|
+
req.on('error', (err) => {
|
|
395
|
+
if (bytes > 0) finish({ partial: true })
|
|
396
|
+
else finish({ ok: false, error: String(err.message || err) })
|
|
397
|
+
})
|
|
398
|
+
req.end()
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function apply(ctx) {
|
|
403
|
+
const disposers = []
|
|
404
|
+
let conf = readSettings()
|
|
405
|
+
|
|
406
|
+
const route = (apiPath, method, handler) =>
|
|
407
|
+
disposers.push(
|
|
408
|
+
ctx.webServer.register({
|
|
409
|
+
kind: 'exact',
|
|
410
|
+
path: apiPath,
|
|
411
|
+
handler: async (req, res) => {
|
|
412
|
+
const deny = requestRejection(req)
|
|
413
|
+
if (deny !== null) return sendJson(res, deny, { ok: false, error: 'forbidden' })
|
|
414
|
+
const wanted = String(req.method || 'GET').toUpperCase()
|
|
415
|
+
if (wanted !== method) return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
416
|
+
try {
|
|
417
|
+
const body = method === 'POST' ? await readBody(req) : null
|
|
418
|
+
if (method === 'POST' && body === null) return sendJson(res, 400, { ok: false, error: 'bad json' })
|
|
419
|
+
const result = await handler(req, body)
|
|
420
|
+
sendJson(res, result && result.status ? result.status : 200, result && result.body ? result.body : result)
|
|
421
|
+
} catch (err) {
|
|
422
|
+
state.lastError = String((err && err.stack) || err)
|
|
423
|
+
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
route('/dsh-vpn/status.json', 'GET', async () => collectStatus(conf))
|
|
430
|
+
|
|
431
|
+
route('/dsh-vpn/power.json', 'POST', async (req, body) => {
|
|
432
|
+
const on = body && body.on === true
|
|
433
|
+
if (state.busy) return { ok: false, error: `busy: ${state.busy}`, status: collectStatus(conf) }
|
|
434
|
+
state.busy = on ? 'on' : 'off'
|
|
435
|
+
state.lastError = ''
|
|
436
|
+
try {
|
|
437
|
+
const script = path.join(conf.scriptsDir, on ? 'vpn-on.ps1' : 'vpn-off.ps1')
|
|
438
|
+
const res = await runPowerShell(script, [], 90000)
|
|
439
|
+
state.lastLog = (res.out || '') + (res.err ? '\n[stderr]\n' + res.err : '')
|
|
440
|
+
state.lastActionAt = Date.now()
|
|
441
|
+
if (!res.ok) state.lastError = (res.err || res.out || `exit ${res.code}`).slice(-2000)
|
|
442
|
+
const status = await collectStatus(conf)
|
|
443
|
+
return { ok: res.ok, action: on ? 'on' : 'off', status, log: state.lastLog.slice(-3000) }
|
|
444
|
+
} finally {
|
|
445
|
+
state.busy = null
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
route('/dsh-vpn/nodes.json', 'GET', async () => {
|
|
450
|
+
if (!(await portOpen(conf.proxyPort))) return { ok: false, error: 'proxy not running' }
|
|
451
|
+
const res = await controllerRequest(conf, 'GET', '/proxies')
|
|
452
|
+
if (!res.ok || !res.json) return { ok: false, error: res.text || 'controller error' }
|
|
453
|
+
const proxies = res.json.proxies || {}
|
|
454
|
+
const groups = []
|
|
455
|
+
const nodes = []
|
|
456
|
+
for (const [key, value] of Object.entries(proxies)) {
|
|
457
|
+
if (!value || typeof value !== 'object') continue
|
|
458
|
+
if (value.type === 'Selector' || value.type === 'URLTest' || value.type === 'Fallback') {
|
|
459
|
+
groups.push({ name: key, type: value.type, now: value.now || null, all: value.all || [] })
|
|
460
|
+
} else if (value.type && !['Direct', 'Reject', 'Pass', 'Compatible'].includes(value.type)) {
|
|
461
|
+
const history = Array.isArray(value.history) ? value.history : []
|
|
462
|
+
const last = history.length ? history[history.length - 1] : null
|
|
463
|
+
nodes.push({ name: key, type: value.type, delay: last ? last.delay : null })
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const preferred = groups.find((g) => g.name === '手动切换') || groups.find((g) => g.name === '自动选择') || groups[0]
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
groups,
|
|
470
|
+
nodes,
|
|
471
|
+
group: preferred ? preferred.name : null,
|
|
472
|
+
now: preferred ? preferred.now : null,
|
|
473
|
+
list: preferred ? preferred.all : nodes.map((n) => n.name),
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
route('/dsh-vpn/select.json', 'POST', async (req, body) => {
|
|
478
|
+
const nodeName = body && typeof body.name === 'string' ? body.name : ''
|
|
479
|
+
if (!nodeName) return { ok: false, error: 'name required' }
|
|
480
|
+
if (!(await portOpen(conf.proxyPort))) return { ok: false, error: 'proxy not running' }
|
|
481
|
+
const res = await controllerRequest(conf, 'GET', '/proxies')
|
|
482
|
+
const proxies = (res.json && res.json.proxies) || {}
|
|
483
|
+
const groupName = proxies['手动切换'] ? '手动切换' : proxies['自动选择'] ? '自动选择' : null
|
|
484
|
+
if (!groupName) return { ok: false, error: 'no selectable group' }
|
|
485
|
+
const put = await controllerRequest(
|
|
486
|
+
conf,
|
|
487
|
+
'PUT',
|
|
488
|
+
'/proxies/' + encodeURIComponent(groupName),
|
|
489
|
+
{ name: nodeName },
|
|
490
|
+
)
|
|
491
|
+
return { ok: put.ok, code: put.code, group: groupName, name: nodeName, error: put.ok ? undefined : put.text }
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
route('/dsh-vpn/delay.json', 'GET', async (req) => {
|
|
495
|
+
if (!(await portOpen(conf.proxyPort))) return { ok: false, error: 'proxy not running' }
|
|
496
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
497
|
+
const wanted = String(url.searchParams.get('names') || '')
|
|
498
|
+
.split(',')
|
|
499
|
+
.map((s) => s.trim())
|
|
500
|
+
.filter(Boolean)
|
|
501
|
+
.slice(0, 60)
|
|
502
|
+
if (!wanted.length) return { ok: false, error: 'names required' }
|
|
503
|
+
const probeUrl = 'http://www.gstatic.com/generate_204'
|
|
504
|
+
const results = []
|
|
505
|
+
const queue = [...wanted]
|
|
506
|
+
const worker = async () => {
|
|
507
|
+
while (queue.length) {
|
|
508
|
+
const nodeName = queue.shift()
|
|
509
|
+
const started = Date.now()
|
|
510
|
+
const res = await controllerRequest(
|
|
511
|
+
conf,
|
|
512
|
+
'GET',
|
|
513
|
+
`/proxies/${encodeURIComponent(nodeName)}/delay?url=${encodeURIComponent(probeUrl)}&timeout=3000`,
|
|
514
|
+
undefined,
|
|
515
|
+
5000,
|
|
516
|
+
)
|
|
517
|
+
const ms = res.ok && res.json && typeof res.json.delay === 'number' ? res.json.delay : null
|
|
518
|
+
results.push({ name: nodeName, delay: ms, elapsed: Date.now() - started })
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
await Promise.all(Array.from({ length: 8 }, worker))
|
|
522
|
+
results.sort((a, b) => (a.delay === null ? 99999 : a.delay) - (b.delay === null ? 99999 : b.delay))
|
|
523
|
+
return { ok: true, results }
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
route('/dsh-vpn/speed.json', 'GET', async () => {
|
|
527
|
+
if (!(await portOpen(conf.proxyPort))) return { ok: false, error: 'proxy not running' }
|
|
528
|
+
const result = await speedThroughProxy(conf, conf.testUrl)
|
|
529
|
+
return result
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
route('/dsh-vpn/refresh.json', 'POST', async () => {
|
|
533
|
+
if (state.busy) return { ok: false, error: `busy: ${state.busy}` }
|
|
534
|
+
state.busy = 'refresh'
|
|
535
|
+
state.lastError = ''
|
|
536
|
+
try {
|
|
537
|
+
const res = await runPowerShell(path.join(conf.scriptsDir, 'vpn-refresh.ps1'), [], 20 * 60 * 1000)
|
|
538
|
+
state.lastLog = (res.out || '') + (res.err ? '\n[stderr]\n' + res.err : '')
|
|
539
|
+
state.lastActionAt = Date.now()
|
|
540
|
+
if (!res.ok) state.lastError = (res.err || res.out || `exit ${res.code}`).slice(-2000)
|
|
541
|
+
return { ok: res.ok, log: state.lastLog.slice(-6000), status: await collectStatus(conf) }
|
|
542
|
+
} finally {
|
|
543
|
+
state.busy = null
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
route('/dsh-vpn/settings.json', 'GET', async () => ({ ok: true, settings: conf }))
|
|
548
|
+
|
|
549
|
+
// A separate path on purpose: two handler registrations on one exact path are
|
|
550
|
+
// order-dependent in the host router, so GET /settings.json must stay unique.
|
|
551
|
+
route('/dsh-vpn/settings/save.json', 'POST', async (req, body) => {
|
|
552
|
+
const saved = writeSettings(body || {})
|
|
553
|
+
if (!saved.ok) return { ok: false, error: saved.error }
|
|
554
|
+
conf = readSettings()
|
|
555
|
+
// Autostart rides the per-user Run key, the same way the desktop shortcut does.
|
|
556
|
+
if (body && typeof body.autoStart === 'boolean') {
|
|
557
|
+
const runKey = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
|
558
|
+
const command = `powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${path.join(conf.scriptsDir, 'vpn-on.ps1')}"`
|
|
559
|
+
await new Promise((resolve) => {
|
|
560
|
+
const child = body.autoStart
|
|
561
|
+
? spawn('reg.exe', ['add', runKey, '/v', 'DSHVpn', '/t', 'REG_SZ', '/d', command, '/f'], { windowsHide: true })
|
|
562
|
+
: spawn('reg.exe', ['delete', runKey, '/v', 'DSHVpn', '/f'], { windowsHide: true })
|
|
563
|
+
child.on('close', () => resolve())
|
|
564
|
+
child.on('error', () => resolve())
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
return { ok: true, settings: conf, status: await collectStatus(conf) }
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const disposeAll = () => {
|
|
571
|
+
for (const dispose of disposers) {
|
|
572
|
+
try {
|
|
573
|
+
dispose()
|
|
574
|
+
} catch {}
|
|
575
|
+
}
|
|
576
|
+
disposers.length = 0
|
|
577
|
+
}
|
|
578
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => disposeAll)
|
|
579
|
+
else if (typeof ctx.on === 'function') ctx.on('dispose', disposeAll)
|
|
580
|
+
}
|