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 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
- # Temporary Holding Version
1
+ # dsh-vpn-panel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: vpn-panel
3
+ name: dsh-vpn-panel
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
- "name": "dsh-vpn-panel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }