@twinsearth/dsh-2xray 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/index.js +18 -0
- package/lib/client.js +285 -75
- package/lib/supervisor.js +20 -1
- package/lib/xray-config.js +13 -1
- package/package.json +1 -1
- package/test/client.test.js +56 -11
- package/test/xray-config.test.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 设置页与导入
|
|
6
|
+
|
|
7
|
+
- **设置页新增「2Xray」标签页**(`settings.section`):VLESS 链接输入、本地二维码图片选择、屏幕扫码、启用/takeover/xray 路径/端口,保存后重启生效。
|
|
8
|
+
- 插件页卡片 `plugins.item` 的 id 改为 settings namespace(`xray-proxy`),并按页面真实契约使用 `{ state, mutate }`;写冲突(mutate=false)会显式报错,不再假装保存成功。
|
|
9
|
+
- 左侧「2Xray」面板保持三卡片(配置/实时信息/状态)。
|
|
10
|
+
|
|
11
|
+
### 兼容
|
|
12
|
+
|
|
13
|
+
- 路由直连规则从 `geoip:private` 改为显式私有网段(10/8、172.16/12、192.168/16、127/8、169.254/16、::1、fc00::/7、fe80::/10),不再依赖 xray 二进制旁的 `geoip.dat`——v2rayN 自带的 xray.exe 可 `-test` 通过。
|
|
14
|
+
|
|
15
|
+
## 0.2.3 — 2026-10-29
|
|
16
|
+
|
|
17
|
+
### 界面
|
|
18
|
+
|
|
19
|
+
- 主面板改为三卡片:①当前 VPN 配置(节点下拉、协议/安全/SNI、切换节点)②实时连接信息(运行时长、代理端口、Xray PID、入站类型,日志可折叠)③连接状态(状态点、延迟、出口 IP、节点名)。
|
|
20
|
+
- 新增 `POST /api/xray/switch`(鉴权围栏)驱动节点切换;切换依赖控制面与多节点,单节点会返回明确错误。
|
|
21
|
+
- 状态快照补充 `inbound`、`uptimeSec`、节点 `security/serverName/flow/network`。
|
|
22
|
+
|
|
3
23
|
## 0.2.2 — 2026-10-29
|
|
4
24
|
|
|
5
25
|
### 新增
|
package/index.js
CHANGED
|
@@ -158,6 +158,24 @@ export function apply(ctx, rawConfig) {
|
|
|
158
158
|
}),
|
|
159
159
|
'dsh-2xray: status api',
|
|
160
160
|
)
|
|
161
|
+
child.effect(
|
|
162
|
+
() =>
|
|
163
|
+
child.connection.fetch.register({
|
|
164
|
+
path: '/api/xray/switch',
|
|
165
|
+
methods: ['POST'],
|
|
166
|
+
requestBody: 'buffered',
|
|
167
|
+
fetch: async (request) => {
|
|
168
|
+
try {
|
|
169
|
+
const body = await request.json()
|
|
170
|
+
const activeNode = await supervisor.switchNode(String(body?.node ?? ''))
|
|
171
|
+
return Response.json({ ok: true, activeNode })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return Response.json({ ok: false, error: String(error?.message ?? error) }, { status: 400 })
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
'dsh-2xray: switch api',
|
|
178
|
+
)
|
|
161
179
|
} catch (error) {
|
|
162
180
|
logger.warn(`状态 API 注册失败:${String(error)}`)
|
|
163
181
|
}
|
package/lib/client.js
CHANGED
|
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
|
|
7
7
|
const PANEL_ID = 'xray'
|
|
8
8
|
const NS = 'xray-proxy'
|
|
9
|
-
const inject = ['slots'
|
|
9
|
+
const inject = ['slots']
|
|
10
10
|
|
|
11
11
|
const C = {
|
|
12
12
|
page: { maxWidth: 980, margin: '0 auto', padding: '20px 24px 16px', color: 'var(--dsw-alias-label-primary, #0f1115)' },
|
|
@@ -114,29 +114,100 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
return { status, logs, error, setLogs }
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function
|
|
117
|
+
function fmtUptime(seconds) {
|
|
118
|
+
const total = Math.max(0, Math.floor(seconds ?? 0))
|
|
119
|
+
const hh = Math.floor(total / 3600)
|
|
120
|
+
const mm = Math.floor((total % 3600) / 60)
|
|
121
|
+
const ss = total % 60
|
|
122
|
+
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ConfigCard({ status }) {
|
|
118
126
|
const nodes = status?.nodes ?? []
|
|
127
|
+
const active = nodes.find((node) => node.tag === status?.activeNode) ?? nodes[0]
|
|
128
|
+
const [switching, setSwitching] = React.useState(false)
|
|
129
|
+
const [message, setMessage] = React.useState('')
|
|
130
|
+
|
|
119
131
|
if (nodes.length === 0) {
|
|
120
|
-
return h('
|
|
132
|
+
return h('section', { style: C.card },
|
|
133
|
+
h('div', { style: C.cardHead }, '① 当前 VPN 配置'),
|
|
134
|
+
h('div', { style: { ...C.muted, fontSize: 12.5 } }, '还没有节点。到「设置 → 插件 → Xray 代理」粘贴 vless 链接并保存,重启 DSH 后这里会显示。'))
|
|
121
135
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
|
|
137
|
+
const switchTo = async (tag) => {
|
|
138
|
+
if (!tag || tag === status?.activeNode) return
|
|
139
|
+
setSwitching(true)
|
|
140
|
+
setMessage('')
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(new URL('api/xray/switch', document.baseURI), {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
credentials: 'same-origin',
|
|
145
|
+
headers: { 'content-type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ node: tag }),
|
|
147
|
+
})
|
|
148
|
+
const data = await response.json()
|
|
149
|
+
if (!data.ok) throw new Error(data.error || '切换失败')
|
|
150
|
+
setMessage(`已切换到 ${tag}`)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
setMessage(String(error.message || error))
|
|
153
|
+
} finally {
|
|
154
|
+
setSwitching(false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return h('section', { style: C.card },
|
|
159
|
+
h('div', { style: C.cardHead }, '① 当前 VPN 配置',
|
|
160
|
+
h('span', { style: { ...C.muted, fontWeight: 400, marginLeft: 'auto' } }, `${nodes.length} 个节点`)),
|
|
161
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
|
|
162
|
+
h('select', {
|
|
163
|
+
value: active?.tag ?? '',
|
|
164
|
+
disabled: switching,
|
|
165
|
+
onChange: (event) => switchTo(event.target.value),
|
|
166
|
+
style: { ...C.input, width: 'auto', minWidth: 180 },
|
|
167
|
+
}, nodes.map((node) => h('option', { key: node.tag, value: node.tag }, node.id ?? node.tag))),
|
|
168
|
+
h('span', { style: { ...C.mono, fontSize: 12.5 } }, active ? `VLESS · ${active.address}:${active.port}` : '—'),
|
|
169
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, switching ? '切换中…' : message)),
|
|
170
|
+
active
|
|
171
|
+
? h('div', { style: { ...C.muted, fontSize: 12.5, marginTop: 8 } },
|
|
172
|
+
`安全:${String(active.security || 'none').toUpperCase()}${active.serverName ? ` (${active.serverName})` : ''} · 传输:${active.network || 'raw'}${active.flow ? ` · flow:${active.flow}` : ''}`)
|
|
173
|
+
: null)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function RuntimeCard({ status, logs, setLogs }) {
|
|
177
|
+
const [showLogs, setShowLogs] = React.useState(false)
|
|
178
|
+
const inbound = status?.inbound
|
|
179
|
+
const inboundLabel = inbound
|
|
180
|
+
? `${String(inbound.protocol).toUpperCase()}:${inbound.port}${inbound.socks ? ` · SOCKS5:${inbound.socks.port}` : ''}`
|
|
181
|
+
: '—'
|
|
182
|
+
const metrics = [
|
|
183
|
+
['运行时长', fmtUptime(status?.uptimeSec)],
|
|
184
|
+
['代理端口', status ? `:${status.port}` : '—'],
|
|
185
|
+
['Xray PID', status?.process?.pid ?? '—'],
|
|
186
|
+
['入站类型', inboundLabel],
|
|
187
|
+
]
|
|
188
|
+
return h('section', { style: C.card },
|
|
189
|
+
h('div', { style: C.cardHead }, '② 实时连接信息',
|
|
190
|
+
h('button', { type: 'button', style: { ...C.btn, marginLeft: 'auto' }, onClick: () => setShowLogs((value) => !value) },
|
|
191
|
+
showLogs ? '收起日志' : '查看日志')),
|
|
192
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 } },
|
|
193
|
+
metrics.map(([label, value]) => h('div', { key: label },
|
|
194
|
+
h('div', { style: { ...C.muted, fontSize: 11.5 } }, label),
|
|
195
|
+
h('div', { style: { ...C.mono, fontSize: 14, fontWeight: 600, marginTop: 2 } }, String(value))))),
|
|
196
|
+
showLogs ? h('div', { style: { marginTop: 12 } }, h(LogList, { logs, setLogs })) : null)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function StatusCard({ status, latency, egress, phase }) {
|
|
200
|
+
return h('section', { style: C.card },
|
|
201
|
+
h('div', { style: C.cardHead }, '③ 连接状态'),
|
|
202
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
203
|
+
h('span', { style: { ...C.dot, background: phase.color } }),
|
|
204
|
+
h('b', { style: { fontSize: 14 } }, phase.label),
|
|
205
|
+
h('span', { style: { ...C.mono, fontSize: 13 } }, latency == null ? '延迟 —' : `延迟 ${latency} ms`),
|
|
206
|
+
h('span', { style: { ...C.mono, fontSize: 12.5, marginLeft: 'auto' } },
|
|
207
|
+
egress?.ip ? `出口 IP ${egress.ip}${egress.country ? ` (${egress.country})` : ''}` : '出口 IP —')),
|
|
208
|
+
h('div', { style: { ...C.muted, fontSize: 12.5, marginTop: 6 } },
|
|
209
|
+
`节点「${status?.vpnName ?? status?.activeNode ?? '—'}」` +
|
|
210
|
+
(status?.route?.installed ? ` · ${status.route.mode === 'harness' ? 'DSH 策略已接管' : '内置 dispatcher'}` : ' · 尚未接管')))
|
|
140
211
|
}
|
|
141
212
|
|
|
142
213
|
function LogList({ logs, setLogs }) {
|
|
@@ -180,96 +251,229 @@ window.__ModuleLoader__.load({
|
|
|
180
251
|
? h('div', { style: { ...C.card, fontSize: 12.5 } }, h('b', null, '原因:'), ` ${status.reason}`,
|
|
181
252
|
status.detail ? h('div', { style: { ...C.muted, marginTop: 4 } }, status.detail) : null)
|
|
182
253
|
: null,
|
|
183
|
-
h(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
h(NodeTable, { status })),
|
|
187
|
-
h('section', { style: C.card },
|
|
188
|
-
h('div', { style: C.cardHead }, '实时连接日志'),
|
|
189
|
-
h(LogList, { logs, setLogs })),
|
|
190
|
-
h('div', { style: C.statusBar },
|
|
191
|
-
h('span', { style: { ...C.dot, background: phase.color } }),
|
|
192
|
-
h('b', null, phase.label),
|
|
193
|
-
h('span', { style: { ...C.mono, fontWeight: 600 } }, latency == null ? '延迟 —' : `延迟 ${latency} ms`),
|
|
194
|
-
h('span', { style: { ...C.mono } }, status?.vpnName ? String(status.vpnName) : '—'),
|
|
195
|
-
h('span', { style: { ...C.mono } }, egress?.ip ? `出口 ${egress.ip}${egress.country ? ` (${egress.country})` : ''}` : '出口 IP —'),
|
|
196
|
-
h('span', { style: { ...C.muted, marginLeft: 'auto' } }, status?.updatedAt ? `更新于 ${fmtTime(status.updatedAt)}` : '等待状态')))
|
|
254
|
+
h(ConfigCard, { status }),
|
|
255
|
+
h(RuntimeCard, { status, logs, setLogs }),
|
|
256
|
+
h(StatusCard, { status, latency, egress, phase }))
|
|
197
257
|
}
|
|
198
258
|
|
|
199
259
|
function Field({ label, children }) {
|
|
200
260
|
return h('div', null, h('label', { style: C.label }, label), children)
|
|
201
261
|
}
|
|
202
262
|
|
|
203
|
-
function
|
|
263
|
+
function controllerValue(controller) {
|
|
204
264
|
try {
|
|
205
|
-
const snapshot =
|
|
206
|
-
|
|
207
|
-
return candidate && typeof candidate === 'object' ? candidate : {}
|
|
265
|
+
const snapshot = controller?.getSnapshot?.()
|
|
266
|
+
return snapshot?.value && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
208
267
|
} catch {
|
|
209
268
|
return {}
|
|
210
269
|
}
|
|
211
270
|
}
|
|
212
271
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
272
|
+
function pageValue(pageForm) {
|
|
273
|
+
const value = pageForm?.state?.value
|
|
274
|
+
return value && typeof value === 'object' ? value : {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function writeFields({ pageForm, controller }, fields) {
|
|
278
|
+
if (pageForm && typeof pageForm.mutate === 'function') {
|
|
279
|
+
const ops = fields.map(({ path, value }) => ({ op: 'set', path, value }))
|
|
280
|
+
const accepted = await pageForm.mutate(ops, pageForm.state?.revision)
|
|
281
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if (controller && typeof controller.mutate === 'function') {
|
|
285
|
+
for (const { path, value } of fields) {
|
|
286
|
+
if (path.length === 1 && typeof controller.set === 'function') {
|
|
287
|
+
const accepted = await controller.set(path[0], value)
|
|
288
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
289
|
+
} else {
|
|
290
|
+
const revision = controller.getSnapshot?.()?.revision
|
|
291
|
+
const accepted = await controller.mutate([{ op: 'set', path, value }], revision)
|
|
292
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return
|
|
219
296
|
}
|
|
220
297
|
throw new Error('设置表单不可写')
|
|
221
298
|
}
|
|
222
299
|
|
|
223
|
-
function
|
|
224
|
-
|
|
300
|
+
async function decodeQrFromCanvas(canvas) {
|
|
301
|
+
if (!('BarcodeDetector' in window)) return null
|
|
302
|
+
const detector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
|
303
|
+
const codes = await detector.detect(canvas)
|
|
304
|
+
return codes && codes.length > 0 ? codes[0].rawValue : null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function XrayConfigForm({ controller, pageForm }) {
|
|
308
|
+
const value = controller ? controllerValue(controller) : pageValue(pageForm)
|
|
225
309
|
const [shareLink, setShareLink] = React.useState(value.shareLink ?? '')
|
|
226
310
|
const [binaryPath, setBinaryPath] = React.useState(value.binary?.path ?? '')
|
|
227
311
|
const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
|
|
228
312
|
const [enabled, setEnabled] = React.useState(value.enabled === true)
|
|
229
313
|
const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
|
|
230
314
|
const [message, setMessage] = React.useState('')
|
|
315
|
+
const [kind, setKind] = React.useState('')
|
|
231
316
|
const [busy, setBusy] = React.useState(false)
|
|
317
|
+
const [scanning, setScanning] = React.useState(false)
|
|
318
|
+
const videoRef = React.useRef(null)
|
|
319
|
+
const streamRef = React.useRef(null)
|
|
320
|
+
const timerRef = React.useRef(null)
|
|
321
|
+
|
|
322
|
+
const say = (text, tone) => {
|
|
323
|
+
setMessage(text)
|
|
324
|
+
setKind(tone || '')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const applyDecoded = (text) => {
|
|
328
|
+
const trimmed = String(text || '').trim()
|
|
329
|
+
if (!trimmed) return
|
|
330
|
+
setShareLink(trimmed)
|
|
331
|
+
say('已识别二维码,确认后点保存。', 'ok')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const onFile = async (event) => {
|
|
335
|
+
const file = event.target.files && event.target.files[0]
|
|
336
|
+
if (!file) return
|
|
337
|
+
say('正在解析图片…')
|
|
338
|
+
try {
|
|
339
|
+
const bitmap = await createImageBitmap(file)
|
|
340
|
+
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
|
|
341
|
+
const canvas = document.createElement('canvas')
|
|
342
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
|
|
343
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
|
344
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
345
|
+
const decoded = await decodeQrFromCanvas(canvas)
|
|
346
|
+
if (!decoded) say('没有识别到二维码;当前浏览器不支持或图片不清晰,可改用插件目录 tools/scanner.html。', 'error')
|
|
347
|
+
else applyDecoded(decoded)
|
|
348
|
+
} catch (error) {
|
|
349
|
+
say('解析失败:' + (error.message || error), 'error')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const stopScan = () => {
|
|
354
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
355
|
+
timerRef.current = null
|
|
356
|
+
if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop()
|
|
357
|
+
streamRef.current = null
|
|
358
|
+
if (videoRef.current) videoRef.current.srcObject = null
|
|
359
|
+
setScanning(false)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const tick = async () => {
|
|
363
|
+
const video = videoRef.current
|
|
364
|
+
if (!video || !streamRef.current || video.readyState < 2 || !video.videoWidth) return
|
|
365
|
+
const canvas = document.createElement('canvas')
|
|
366
|
+
canvas.width = video.videoWidth
|
|
367
|
+
canvas.height = video.videoHeight
|
|
368
|
+
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
369
|
+
try {
|
|
370
|
+
const decoded = await decodeQrFromCanvas(canvas)
|
|
371
|
+
if (decoded) {
|
|
372
|
+
applyDecoded(decoded)
|
|
373
|
+
stopScan()
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
// a frame without a readable code is normal
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const startScan = async () => {
|
|
381
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
382
|
+
say('当前浏览器不支持屏幕捕获;请使用「二维码图片」。', 'error')
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (!('BarcodeDetector' in window)) {
|
|
386
|
+
say('当前浏览器没有 BarcodeDetector;请使用插件目录 tools/scanner.html(内置 jsQR)。', 'error')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 10 }, audio: false })
|
|
391
|
+
streamRef.current = stream
|
|
392
|
+
setScanning(true)
|
|
393
|
+
if (videoRef.current) {
|
|
394
|
+
videoRef.current.srcObject = stream
|
|
395
|
+
videoRef.current.style.display = 'block'
|
|
396
|
+
await videoRef.current.play()
|
|
397
|
+
}
|
|
398
|
+
stream.getVideoTracks()[0].addEventListener('ended', stopScan)
|
|
399
|
+
timerRef.current = setInterval(tick, 350)
|
|
400
|
+
say('请把二维码放到共享屏幕上…')
|
|
401
|
+
} catch (error) {
|
|
402
|
+
say('屏幕共享未开始:' + (error.message || error), 'error')
|
|
403
|
+
setScanning(false)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
React.useEffect(() => () => stopScan(), [])
|
|
232
408
|
|
|
233
409
|
const save = async () => {
|
|
234
410
|
setBusy(true)
|
|
235
411
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
412
|
+
const fields = [
|
|
413
|
+
{ path: ['shareLink'], value: shareLink.trim() },
|
|
414
|
+
{ path: ['enabled'], value: enabled },
|
|
415
|
+
{ path: ['takeover'], value: takeover },
|
|
416
|
+
]
|
|
417
|
+
if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
|
|
239
418
|
const numericPort = Number(port)
|
|
240
|
-
if (Number.isInteger(numericPort) && numericPort > 0)
|
|
241
|
-
|
|
242
|
-
|
|
419
|
+
if (Number.isInteger(numericPort) && numericPort > 0) fields.push({ path: ['inbound', 'port'], value: numericPort })
|
|
420
|
+
await writeFields({ controller, pageForm }, fields)
|
|
421
|
+
say('已保存。重启 DSH 后生效。', 'ok')
|
|
243
422
|
} catch (error) {
|
|
244
|
-
|
|
423
|
+
say('保存失败:' + (error.message || error), 'error')
|
|
245
424
|
} finally {
|
|
246
425
|
setBusy(false)
|
|
247
426
|
}
|
|
248
427
|
}
|
|
249
428
|
|
|
250
|
-
return h('div',
|
|
251
|
-
h(
|
|
252
|
-
h(Field, { label: 'VLESS 分享链接(粘贴或扫码)' },
|
|
429
|
+
return h('div', null,
|
|
430
|
+
h(Field, { label: '1. VLESS 分享链接(粘贴)' },
|
|
253
431
|
h('textarea', {
|
|
254
432
|
style: { ...C.input, minHeight: 64, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
255
433
|
value: shareLink,
|
|
256
434
|
placeholder: 'vless://uuid@host:443?security=reality&sni=...&pbk=...#备注',
|
|
257
435
|
onChange: (event) => setShareLink(event.target.value),
|
|
258
436
|
})),
|
|
437
|
+
h(Field, { label: '2. 本地二维码图片' },
|
|
438
|
+
h('input', { type: 'file', accept: 'image/*', onChange: onFile, disabled: scanning })),
|
|
439
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 10, flexWrap: 'wrap' } },
|
|
440
|
+
h('button', { type: 'button', style: C.btn, onClick: startScan, disabled: scanning }, scanning ? '扫描中…' : '扫描屏幕二维码'),
|
|
441
|
+
h('button', { type: 'button', style: C.btn, onClick: stopScan, disabled: !scanning }, '停止'),
|
|
442
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, '本地图片/屏幕扫码结果会填入上方链接')),
|
|
443
|
+
h('video', { ref: videoRef, playsInline: true, muted: true, style: { ...C.input, display: 'none', maxHeight: 240, background: '#000' } }),
|
|
259
444
|
h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
260
|
-
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop:
|
|
445
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 10 } },
|
|
261
446
|
h('input', { type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }), '启用代理'),
|
|
262
|
-
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop:
|
|
447
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 10 } }, 'takeover',
|
|
263
448
|
h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
|
|
264
449
|
h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
|
|
265
450
|
h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
|
|
266
|
-
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
451
|
+
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\...\\bin\\xray\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
267
452
|
h(Field, { label: '本地 HTTP 入站端口' },
|
|
268
453
|
h('input', { style: C.input, value: port, onChange: (event) => setPort(event.target.value) })),
|
|
269
454
|
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 12 } },
|
|
270
455
|
h('button', { type: 'button', style: C.primaryBtn, disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
|
|
271
|
-
h('span', { style: {
|
|
272
|
-
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '
|
|
456
|
+
h('span', { style: { fontSize: 12, color: kind === 'error' ? '#c81e1e' : kind === 'ok' ? '#15803d' : undefined } }, message)),
|
|
457
|
+
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '保存后重启 DSH;左侧「2Xray」面板显示节点、日志、延迟与出口 IP。'))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function XraySettingsCard({ view, form }) {
|
|
461
|
+
if (view === 'summary') {
|
|
462
|
+
return h('div', { style: { ...C.muted, fontSize: 12.5 } }, 'VLESS 链接 / 本地二维码导入 · 保存后重启 DSH 生效')
|
|
463
|
+
}
|
|
464
|
+
return h('div', { style: { ...C.card, marginTop: 8 } },
|
|
465
|
+
h('div', { style: C.cardHead }, h(GlobeIcon), '2Xray 连接'),
|
|
466
|
+
h(XrayConfigForm, { pageForm: form }))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function SettingsSection({ controller }) {
|
|
470
|
+
return h('div', { style: C.page },
|
|
471
|
+
h('div', { style: C.header },
|
|
472
|
+
h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
|
|
473
|
+
h('div', null,
|
|
474
|
+
h('h1', { style: C.title }, '2Xray'),
|
|
475
|
+
h('p', { style: C.sub }, '导入 VLESS 链接或本地二维码,保存后重启 DSH 生效。'))),
|
|
476
|
+
h('section', { style: C.card }, h(XrayConfigForm, { controller })))
|
|
273
477
|
}
|
|
274
478
|
|
|
275
479
|
function apply(ctx) {
|
|
@@ -290,26 +494,32 @@ window.__ModuleLoader__.load({
|
|
|
290
494
|
if (typeof ctx.inject === 'function') {
|
|
291
495
|
ctx.inject(['configForms'], (child) => {
|
|
292
496
|
try {
|
|
497
|
+
// A dedicated Settings tab. It owns the ConfigFormController, so the
|
|
498
|
+
// full editor works even when the plugins page passes a narrow form.
|
|
499
|
+
const controller = child.configForms.get(NS)
|
|
500
|
+
child.effect(
|
|
501
|
+
() =>
|
|
502
|
+
child.slots.inject('settings.section', () =>
|
|
503
|
+
child.slots.register(
|
|
504
|
+
{ name: 'settings.section', id: NS, order: 30, label: () => '2Xray' },
|
|
505
|
+
() => h(SettingsSection, { controller }),
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
'dsh-2xray: settings section',
|
|
509
|
+
)
|
|
510
|
+
// The plugins page card: its id MUST equal the settings namespace,
|
|
511
|
+
// otherwise the page's formFor(id) returns undefined.
|
|
293
512
|
child.effect(
|
|
294
513
|
() =>
|
|
295
514
|
child.configForms.whileServed([NS], () =>
|
|
296
515
|
child.slots.inject('plugins.item', () =>
|
|
297
|
-
child.slots.register(
|
|
298
|
-
{
|
|
299
|
-
name: 'plugins.item',
|
|
300
|
-
id: PANEL_ID,
|
|
301
|
-
order: 40,
|
|
302
|
-
label: () => '2Xray 连接',
|
|
303
|
-
inject: () => ({ form: child.configForms.get(NS) }),
|
|
304
|
-
},
|
|
305
|
-
XraySettingsCard,
|
|
306
|
-
),
|
|
516
|
+
child.slots.register({ name: 'plugins.item', id: NS, order: 40, label: () => '2Xray' }, XraySettingsCard),
|
|
307
517
|
),
|
|
308
518
|
),
|
|
309
|
-
'dsh-2xray:
|
|
519
|
+
'dsh-2xray: plugins card',
|
|
310
520
|
)
|
|
311
|
-
} catch {
|
|
312
|
-
|
|
521
|
+
} catch (error) {
|
|
522
|
+
console.error('[dsh-2xray] client registration failed', error)
|
|
313
523
|
}
|
|
314
524
|
})
|
|
315
525
|
}
|
package/lib/supervisor.js
CHANGED
|
@@ -694,13 +694,32 @@ export class Supervisor {
|
|
|
694
694
|
enabled: this.config.enabled,
|
|
695
695
|
takeover: this.config.takeover,
|
|
696
696
|
settingsSchema: this.#schemaLoaded,
|
|
697
|
-
nodes: this.config.nodes.map((node) => ({
|
|
697
|
+
nodes: this.config.nodes.map((node) => ({
|
|
698
|
+
id: node.id,
|
|
699
|
+
tag: node.tag,
|
|
700
|
+
address: node.address,
|
|
701
|
+
port: node.port,
|
|
702
|
+
protocol: 'vless',
|
|
703
|
+
security: node.security,
|
|
704
|
+
serverName: node.serverName,
|
|
705
|
+
flow: node.flow,
|
|
706
|
+
network: node.network,
|
|
707
|
+
})),
|
|
698
708
|
activeNode: this.#activeNode,
|
|
699
709
|
control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
|
|
700
710
|
generation: this.#generation,
|
|
701
711
|
restarts: this.#restarts,
|
|
702
712
|
port: this.config.inbound.port,
|
|
703
713
|
portSource: 'config',
|
|
714
|
+
inbound: {
|
|
715
|
+
protocol: this.config.inbound.protocol,
|
|
716
|
+
host: this.config.inbound.host,
|
|
717
|
+
port: this.config.inbound.port,
|
|
718
|
+
socks: this.config.inbound.socks.enabled
|
|
719
|
+
? { host: this.config.inbound.socks.host, port: this.config.inbound.socks.port }
|
|
720
|
+
: null,
|
|
721
|
+
},
|
|
722
|
+
uptimeSec: this.#startedAt ? Math.max(0, Math.floor((this.#now() - this.#startedAt) / 1000)) : 0,
|
|
704
723
|
binary: this.#binary
|
|
705
724
|
? { path: this.#binary.path, source: this.#binary.source, version: this.#version ?? null }
|
|
706
725
|
: null,
|
package/lib/xray-config.js
CHANGED
|
@@ -17,7 +17,19 @@
|
|
|
17
17
|
* `api` routing rule, and optionally the stats counters.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Explicit RFC1918/loopback/link-local ranges instead of geoip:private: the
|
|
21
|
+
// latter needs geoip.dat beside the binary, and v2rayN's xray.exe keeps it one
|
|
22
|
+
// directory up, which fails -test with "failed to open geoip.dat".
|
|
23
|
+
const PRIVATE_IP_RULES = [
|
|
24
|
+
'10.0.0.0/8',
|
|
25
|
+
'172.16.0.0/12',
|
|
26
|
+
'192.168.0.0/16',
|
|
27
|
+
'127.0.0.0/8',
|
|
28
|
+
'169.254.0.0/16',
|
|
29
|
+
'::1/128',
|
|
30
|
+
'fc00::/7',
|
|
31
|
+
'fe80::/10',
|
|
32
|
+
]
|
|
21
33
|
const API_TAG = 'api'
|
|
22
34
|
|
|
23
35
|
function escapeRegExp(value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "DSH-2Xray: supervises xray-core and routes DSH process-wide HTTP(S) traffic through its local inbound, with optional multi-node gRPC control plane",
|
|
6
6
|
"type": "module",
|
package/test/client.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
test('the client bundle registers
|
|
4
|
+
test('the client bundle registers sidebar, main panel and settings entries', async () => {
|
|
5
5
|
let captured
|
|
6
6
|
globalThis.window = { __ModuleLoader__: { load(module) { captured = module } } }
|
|
7
7
|
try {
|
|
@@ -24,10 +24,11 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
|
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
assert.equal(typeof mod.apply, 'function')
|
|
27
|
-
assert.deepEqual(mod.inject, ['slots'
|
|
27
|
+
assert.deepEqual(mod.inject, ['slots'])
|
|
28
28
|
assert.equal(mod.PANEL_ID, 'xray')
|
|
29
|
+
assert.equal(mod.NS, 'xray-proxy')
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
+
const base = []
|
|
31
32
|
const ctx = {
|
|
32
33
|
effect(fn) {
|
|
33
34
|
fn()
|
|
@@ -37,20 +38,64 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
|
|
|
37
38
|
callback()
|
|
38
39
|
},
|
|
39
40
|
register(definition, component) {
|
|
40
|
-
|
|
41
|
+
base.push({ definition, component })
|
|
41
42
|
},
|
|
42
43
|
},
|
|
43
44
|
}
|
|
44
45
|
mod.apply(ctx)
|
|
45
46
|
|
|
46
47
|
assert.deepEqual(
|
|
47
|
-
|
|
48
|
+
base.map((entry) => entry.definition.name),
|
|
48
49
|
['sidebar.panellist', 'main'],
|
|
49
50
|
)
|
|
50
|
-
assert.equal(
|
|
51
|
-
assert.equal(
|
|
52
|
-
assert.equal(
|
|
53
|
-
|
|
54
|
-
assert.equal(
|
|
55
|
-
|
|
51
|
+
assert.equal(base[0].definition.id, 'xray')
|
|
52
|
+
assert.equal(base[0].definition.order, 1)
|
|
53
|
+
assert.equal(base[0].definition.label(), '2Xray')
|
|
54
|
+
assert.equal(base[0].component().type, 'svg')
|
|
55
|
+
assert.equal(base[1].definition.key, 'xray')
|
|
56
|
+
|
|
57
|
+
const settings = []
|
|
58
|
+
const controller = {
|
|
59
|
+
getSnapshot: () => ({ status: 'ready', value: { shareLink: '' }, revision: 0, writable: true }),
|
|
60
|
+
set: async () => true,
|
|
61
|
+
mutate: async () => true,
|
|
62
|
+
}
|
|
63
|
+
const child = {
|
|
64
|
+
effect(fn) {
|
|
65
|
+
fn()
|
|
66
|
+
},
|
|
67
|
+
slots: {
|
|
68
|
+
inject(_name, callback) {
|
|
69
|
+
callback()
|
|
70
|
+
},
|
|
71
|
+
register(definition, component) {
|
|
72
|
+
settings.push({ definition, component })
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
configForms: {
|
|
76
|
+
get: () => controller,
|
|
77
|
+
whileServed(namespaces, register) {
|
|
78
|
+
return register(new Set(namespaces))
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
mod.apply({
|
|
83
|
+
effect(fn) {
|
|
84
|
+
fn()
|
|
85
|
+
},
|
|
86
|
+
slots: { inject() {}, register() {} },
|
|
87
|
+
inject(services, callback) {
|
|
88
|
+
assert.deepEqual(services, ['configForms'])
|
|
89
|
+
callback(child)
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
settings.map((entry) => entry.definition.name),
|
|
95
|
+
['settings.section', 'plugins.item'],
|
|
96
|
+
)
|
|
97
|
+
// The plugins page looks the form up by the settings namespace, not the panel id.
|
|
98
|
+
assert.equal(settings[0].definition.id, 'xray-proxy')
|
|
99
|
+
assert.equal(settings[1].definition.id, 'xray-proxy')
|
|
100
|
+
assert.equal(settings[1].definition.label(), '2Xray')
|
|
56
101
|
})
|
package/test/xray-config.test.js
CHANGED
|
@@ -82,7 +82,9 @@ test('direct and block outbounds exist and the catch-all goes to proxy', () => {
|
|
|
82
82
|
test('private addresses and internal suffixes are routed direct', () => {
|
|
83
83
|
const built = buildXrayConfig(config({ root: { routing: { bypassDomains: ['lan', 'corp'] } } }))
|
|
84
84
|
const ipRule = built.routing.rules.find((rule) => rule.ip)
|
|
85
|
-
assert.
|
|
85
|
+
assert.ok(ipRule.ip.includes('10.0.0.0/8'))
|
|
86
|
+
assert.ok(ipRule.ip.includes('192.168.0.0/16'))
|
|
87
|
+
assert.equal(ipRule.ip.includes('geoip:private'), false)
|
|
86
88
|
const domainRule = built.routing.rules.find((rule) => rule.domain && rule.domain.some((entry) => entry.startsWith('domain:lan')))
|
|
87
89
|
assert.deepEqual(domainRule.domain, ['domain:lan', 'domain:corp'])
|
|
88
90
|
})
|