@twinsearth/dsh-2xray 0.2.0 → 0.2.2
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 +22 -0
- package/index.js +71 -1
- package/lib/client.js +258 -205
- package/lib/config.js +4 -2
- package/lib/logbuf.js +27 -0
- package/lib/monitor.js +150 -0
- package/lib/schema.js +21 -10
- package/lib/supervisor.js +54 -6
- package/package.json +1 -1
- package/test/client.test.js +6 -6
- package/test/logbuf.test.js +32 -0
- package/test/supervisor.test.js +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 新增
|
|
6
|
+
|
|
7
|
+
- **界面三段式重做**:左侧「2Xray」面板改为「当前 VPN 配置 / 实时连接日志 / 状态栏(延迟 · VPN 名称 · 出口 IP)」。
|
|
8
|
+
- **设置页卡片**:客户端注册 `plugins.item` 卡片(分享链接、启用、takeover、xray 路径、端口),解决 DSH 不会自动渲染表单的问题;侧边栏面板与设置卡片共用同一配置。
|
|
9
|
+
- **Host 状态通道**:`/api/xray/status`(经 `ctx.connection.fetch` 鉴权围栏)返回快照 + 日志环 + 延迟/出口 IP;日志 1000 条环缓冲、按 cursor 增量拉取。
|
|
10
|
+
- **延迟/出口探测**:经本地入站 absolute-form 请求,仅接管后运行,30s/10min TTL,单飞。
|
|
11
|
+
|
|
12
|
+
### 修复
|
|
13
|
+
|
|
14
|
+
- 功能探测失败不再杀进程:改为 `degraded(probe-failed)`,健康循环重试并自动接管。
|
|
15
|
+
- 默认探测目标从 gstatic 改为 `cp.cloudflare.com/generate_204`,避免大陆网络误判。
|
|
16
|
+
- `xray_status` 增加 `settingsSchema`,可远程确认宿主是否加载了设置表单 schema。
|
|
17
|
+
|
|
18
|
+
## 0.2.1 — 2026-10-29
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
|
|
22
|
+
- **设置页仍不显示**:Schemastery 改为优先通过 `createRequire(import.meta.url)` / profile 锚点解析文件后动态 import,绕开宿主模块加载器对裸 specifier 的受限解析;加载结果写入 `xray_status.settingsSchema`,可远程确认表单是否具备渲染条件。
|
|
23
|
+
- **侧边栏入口优先注册**:先注册 `sidebar.panellist`,再注册主面板;主面板失败不再连带隐藏入口。
|
|
24
|
+
|
|
3
25
|
## 0.2.0 — 2026-10-29
|
|
4
26
|
|
|
5
27
|
### 新增
|
package/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { createLogger } from './lib/log.js'
|
|
|
6
6
|
import { resolveDataDir } from './lib/paths.js'
|
|
7
7
|
import { ControlClient } from './lib/control/client.js'
|
|
8
8
|
import { installTools } from './lib/tools.js'
|
|
9
|
+
import { LogBuffer } from './lib/logbuf.js'
|
|
10
|
+
import { Monitor } from './lib/monitor.js'
|
|
9
11
|
|
|
10
12
|
export const name = 'dsh-2xray'
|
|
11
13
|
|
|
@@ -36,7 +38,23 @@ export { Config }
|
|
|
36
38
|
* disposer returned from `apply` are the lifecycle seam.
|
|
37
39
|
*/
|
|
38
40
|
export function apply(ctx, rawConfig) {
|
|
39
|
-
const
|
|
41
|
+
const ring = new LogBuffer(1000)
|
|
42
|
+
const baseLogger = createLogger(ctx)
|
|
43
|
+
const logger = {
|
|
44
|
+
info: (...args) => {
|
|
45
|
+
ring.append('info', args.join(' '))
|
|
46
|
+
baseLogger.info(...args)
|
|
47
|
+
},
|
|
48
|
+
warn: (...args) => {
|
|
49
|
+
ring.append('warn', args.join(' '))
|
|
50
|
+
baseLogger.warn(...args)
|
|
51
|
+
},
|
|
52
|
+
error: (...args) => {
|
|
53
|
+
ring.append('error', args.join(' '))
|
|
54
|
+
baseLogger.error(...args)
|
|
55
|
+
},
|
|
56
|
+
debug: (...args) => baseLogger.debug(...args),
|
|
57
|
+
}
|
|
40
58
|
const { config, errors, warnings } = normalizeConfig(rawConfig)
|
|
41
59
|
|
|
42
60
|
for (const warning of warnings) logger.warn(`配置警告:${warning}`)
|
|
@@ -90,10 +108,62 @@ export function apply(ctx, rawConfig) {
|
|
|
90
108
|
launcher,
|
|
91
109
|
route,
|
|
92
110
|
control,
|
|
111
|
+
schemaLoaded: Boolean(Config),
|
|
93
112
|
logger,
|
|
94
113
|
dataDir,
|
|
95
114
|
})
|
|
96
115
|
|
|
116
|
+
const monitor = new Monitor({
|
|
117
|
+
host: config.inbound.host,
|
|
118
|
+
port: config.inbound.port,
|
|
119
|
+
latencyUrl: config.functionalProbe.url,
|
|
120
|
+
logger,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Authenticated status/log feed for the browser panel. Optional service: a
|
|
124
|
+
// profile without `connection` keeps the proxy and the tools, it only loses
|
|
125
|
+
// the live panel data.
|
|
126
|
+
if (typeof ctx.inject === 'function') {
|
|
127
|
+
ctx.inject(['connection'], (child) => {
|
|
128
|
+
try {
|
|
129
|
+
child.effect(
|
|
130
|
+
() =>
|
|
131
|
+
child.connection.fetch.register({
|
|
132
|
+
path: '/api/xray/status',
|
|
133
|
+
methods: ['GET'],
|
|
134
|
+
requestBody: 'buffered',
|
|
135
|
+
fetch: (request) => {
|
|
136
|
+
const url = new URL(request.url)
|
|
137
|
+
const after = Number(url.searchParams.get('after') ?? 0) || 0
|
|
138
|
+
const snapshot = supervisor.snapshot()
|
|
139
|
+
const proxied = snapshot.state === 'ready' && snapshot.route?.installed === true
|
|
140
|
+
monitor.maybeRefresh({ ready: proxied })
|
|
141
|
+
const node = snapshot.nodes?.find((entry) => entry.tag === snapshot.activeNode)
|
|
142
|
+
const page = ring.since(after)
|
|
143
|
+
const body = JSON.stringify({
|
|
144
|
+
ok: true,
|
|
145
|
+
status: {
|
|
146
|
+
...snapshot,
|
|
147
|
+
proxied,
|
|
148
|
+
vpnName: node?.id ?? snapshot.activeNode ?? null,
|
|
149
|
+
probe: monitor.snapshot(),
|
|
150
|
+
},
|
|
151
|
+
logs: page.items,
|
|
152
|
+
cursor: page.cursor,
|
|
153
|
+
})
|
|
154
|
+
return new Response(body, {
|
|
155
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
'dsh-2xray: status api',
|
|
160
|
+
)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
logger.warn(`状态 API 注册失败:${String(error)}`)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
97
167
|
const dispose = ctx.effect(
|
|
98
168
|
() => async () => {
|
|
99
169
|
await supervisor.stop()
|
package/lib/client.js
CHANGED
|
@@ -5,14 +5,40 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
const h = React.createElement
|
|
6
6
|
|
|
7
7
|
const PANEL_ID = 'xray'
|
|
8
|
+
const NS = 'xray-proxy'
|
|
8
9
|
const inject = ['slots', 'locale']
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const C = {
|
|
12
|
+
page: { maxWidth: 980, margin: '0 auto', padding: '20px 24px 16px', color: 'var(--dsw-alias-label-primary, #0f1115)' },
|
|
13
|
+
header: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 },
|
|
14
|
+
globe: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
|
|
15
|
+
title: { fontSize: 18, fontWeight: 600, margin: 0 },
|
|
16
|
+
sub: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #61666b)', margin: '2px 0 0' },
|
|
17
|
+
pill: { marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 500, padding: '4px 10px', borderRadius: 999, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))' },
|
|
18
|
+
dot: { width: 8, height: 8, borderRadius: 999, background: '#9ca3af' },
|
|
19
|
+
card: { border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 12, padding: 12, marginBottom: 12, background: 'var(--dsw-alias-bg-layer-1, transparent)' },
|
|
20
|
+
cardHead: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, fontSize: 13, fontWeight: 600 },
|
|
21
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: 12.5 },
|
|
22
|
+
th: { textAlign: 'left', fontWeight: 500, color: 'var(--dsw-alias-label-secondary, #61666b)', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))' },
|
|
23
|
+
td: { padding: '7px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06))', verticalAlign: 'middle' },
|
|
24
|
+
mono: { fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
25
|
+
logBox: { height: 220, overflow: 'auto', border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 8, padding: '6px 8px', background: 'var(--dsw-alias-markdown-code-block, #f9fafb)' },
|
|
26
|
+
logLine: { display: 'flex', gap: 8, fontSize: 11.5, lineHeight: '18px', fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
27
|
+
statusBar: { display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 12, padding: '10px 14px', fontSize: 12.5 },
|
|
28
|
+
btn: { padding: '5px 10px', borderRadius: 7, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.16))', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 12 },
|
|
29
|
+
primaryBtn: { padding: '6px 12px', borderRadius: 7, border: '1px solid #2563eb', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: 12.5 },
|
|
30
|
+
input: { width: '100%', boxSizing: 'border-box', padding: '7px 9px', borderRadius: 7, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.16))', background: 'transparent', color: 'inherit', fontSize: 12.5 },
|
|
31
|
+
label: { display: 'block', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #61666b)', margin: '8px 0 4px' },
|
|
32
|
+
muted: { color: 'var(--dsw-alias-label-secondary, #61666b)' },
|
|
33
|
+
error: { color: '#c81e1e' },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function GlobeIcon({ size = 20 } = {}) {
|
|
11
37
|
return h(
|
|
12
38
|
'svg',
|
|
13
39
|
{
|
|
14
|
-
width:
|
|
15
|
-
height:
|
|
40
|
+
width: size,
|
|
41
|
+
height: size,
|
|
16
42
|
viewBox: '0 0 24 24',
|
|
17
43
|
fill: 'none',
|
|
18
44
|
stroke: 'currentColor',
|
|
@@ -27,241 +53,268 @@ window.__ModuleLoader__.load({
|
|
|
27
53
|
)
|
|
28
54
|
}
|
|
29
55
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
minHeight: 84,
|
|
40
|
-
padding: 10,
|
|
41
|
-
borderRadius: 8,
|
|
42
|
-
border: '1px solid var(--dsh-border, #334155)',
|
|
43
|
-
background: 'var(--dsh-surface, #0b1220)',
|
|
44
|
-
color: 'inherit',
|
|
45
|
-
font: '13px/1.5 ui-monospace, Consolas, monospace',
|
|
46
|
-
resize: 'vertical',
|
|
47
|
-
},
|
|
48
|
-
row: { display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 },
|
|
49
|
-
button: {
|
|
50
|
-
padding: '8px 14px',
|
|
51
|
-
borderRadius: 8,
|
|
52
|
-
border: '1px solid var(--dsh-border, #334155)',
|
|
53
|
-
background: 'var(--dsh-surface-raised, #334155)',
|
|
54
|
-
color: 'inherit',
|
|
55
|
-
cursor: 'pointer',
|
|
56
|
-
fontSize: 14,
|
|
57
|
-
},
|
|
58
|
-
primary: { background: '#2563eb', borderColor: '#2563eb', color: '#fff' },
|
|
59
|
-
status: { fontSize: 13, marginTop: 10, minHeight: 20, color: 'var(--dsh-text-secondary, #94a3b8)' },
|
|
60
|
-
video: { width: '100%', maxHeight: 320, borderRadius: 8, background: '#000', marginTop: 12, display: 'none' },
|
|
61
|
-
steps: { margin: '10px 0 0', paddingLeft: 20, fontSize: 13, color: 'var(--dsh-text-secondary, #94a3b8)' },
|
|
56
|
+
function phaseOf(status) {
|
|
57
|
+
if (!status) return { label: '状态未知', color: '#9ca3af' }
|
|
58
|
+
if (status.proxied) return { label: '已连接', color: '#15803d' }
|
|
59
|
+
if (status.state === 'ready') return { label: '已就绪 · 未接管', color: '#b45309' }
|
|
60
|
+
if (status.state === 'degraded') return { label: '连接降级', color: '#b45309' }
|
|
61
|
+
if (status.state === 'starting' || status.state === 'resolving' || status.state === 'backoff') return { label: '正在连接', color: '#2f5fbf' }
|
|
62
|
+
if (status.state === 'failed') return { label: '连接失败', color: '#c81e1e' }
|
|
63
|
+
if (status.state === 'stopped') return { label: '已停止', color: '#9ca3af' }
|
|
64
|
+
return { label: '未启用', color: '#9ca3af' }
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
function
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const [scanning, setScanning] = React.useState(false)
|
|
69
|
-
const videoRef = React.useRef(null)
|
|
70
|
-
const streamRef = React.useRef(null)
|
|
71
|
-
const timerRef = React.useRef(null)
|
|
72
|
-
const detectorRef = React.useRef(null)
|
|
67
|
+
function fmtTime(ts) {
|
|
68
|
+
const d = new Date(ts)
|
|
69
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
|
70
|
+
}
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
function StatusPill({ status }) {
|
|
73
|
+
const phase = phaseOf(status)
|
|
74
|
+
return h('span', { style: C.pill }, h('span', { style: { ...C.dot, background: phase.color } }), phase.label)
|
|
75
|
+
}
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
function useStatusFeed() {
|
|
78
|
+
const [status, setStatus] = React.useState(null)
|
|
79
|
+
const [logs, setLogs] = React.useState([])
|
|
80
|
+
const [error, setError] = React.useState(null)
|
|
81
|
+
const cursor = React.useRef(0)
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
let cancelled = false
|
|
85
|
+
let timer
|
|
86
|
+
const tick = async () => {
|
|
87
|
+
try {
|
|
88
|
+
const url = new URL(`api/xray/status?after=${cursor.current}`, document.baseURI)
|
|
89
|
+
const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
90
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
91
|
+
const data = await response.json()
|
|
92
|
+
if (cancelled) return
|
|
93
|
+
setStatus(data.status)
|
|
94
|
+
if (Array.isArray(data.logs) && data.logs.length > 0) {
|
|
95
|
+
setLogs((previous) => {
|
|
96
|
+
const merged = previous.concat(data.logs)
|
|
97
|
+
return merged.length > 500 ? merged.slice(merged.length - 500) : merged
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
cursor.current = data.cursor ?? cursor.current
|
|
101
|
+
setError(null)
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
if (!cancelled) setError(cause.message || String(cause))
|
|
104
|
+
}
|
|
105
|
+
if (!cancelled) timer = setTimeout(tick, 2000)
|
|
106
|
+
}
|
|
107
|
+
tick()
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
}
|
|
112
|
+
}, [])
|
|
91
113
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!trimmed) return false
|
|
95
|
-
setText(trimmed)
|
|
96
|
-
say('已识别:' + trimmed.slice(0, 64) + (trimmed.length > 64 ? '…' : ''), 'ok')
|
|
97
|
-
return true
|
|
98
|
-
}
|
|
114
|
+
return { status, logs, error, setLogs }
|
|
115
|
+
}
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const bitmap = await createImageBitmap(file)
|
|
106
|
-
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
|
|
107
|
-
const canvas = document.createElement('canvas')
|
|
108
|
-
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
|
|
109
|
-
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
|
110
|
-
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
111
|
-
const value = await decodeCanvas(canvas)
|
|
112
|
-
if (!value) say('图片里没有识别到二维码;换一张更清晰/更大的图,或使用插件目录里的 tools/scanner.html(带 jsQR 兜底)。', 'error')
|
|
113
|
-
else accept(value)
|
|
114
|
-
} catch (error) {
|
|
115
|
-
say('解析失败:' + error.message, 'error')
|
|
116
|
-
}
|
|
117
|
+
function NodeTable({ status }) {
|
|
118
|
+
const nodes = status?.nodes ?? []
|
|
119
|
+
if (nodes.length === 0) {
|
|
120
|
+
return h('div', { style: { ...C.muted, padding: '10px 0', fontSize: 12.5 } }, '还没有节点。到「设置 → 插件 → Xray 代理」粘贴 vless 链接并保存,重启 DSH 后这里会显示。')
|
|
117
121
|
}
|
|
122
|
+
return h(
|
|
123
|
+
'table',
|
|
124
|
+
{ style: C.table },
|
|
125
|
+
h('thead', null, h('tr', null,
|
|
126
|
+
h('th', { style: C.th }), h('th', { style: C.th }, '节点'), h('th', { style: C.th }, '地址'),
|
|
127
|
+
h('th', { style: C.th }, '端口'), h('th', { style: C.th }, '状态'))),
|
|
128
|
+
h('tbody', null, nodes.map((node) => {
|
|
129
|
+
const active = node.tag === status.activeNode
|
|
130
|
+
return h('tr', { key: node.tag },
|
|
131
|
+
h('td', { style: C.td }, active
|
|
132
|
+
? h('span', { style: { ...C.dot, background: '#15803d', display: 'inline-block' } })
|
|
133
|
+
: h('span', { style: { ...C.dot, border: '1px solid currentColor', background: 'transparent', display: 'inline-block' } })),
|
|
134
|
+
h('td', { style: C.td }, node.id ?? node.tag),
|
|
135
|
+
h('td', { style: { ...C.td, ...C.mono } }, node.address || '—'),
|
|
136
|
+
h('td', { style: { ...C.td, ...C.mono } }, node.port ?? '—'),
|
|
137
|
+
h('td', { style: C.td }, active ? '当前' : '—'))
|
|
138
|
+
})),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
118
141
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
function LogList({ logs, setLogs }) {
|
|
143
|
+
const box = React.useRef(null)
|
|
144
|
+
const [follow, setFollow] = React.useState(true)
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
if (follow && box.current) box.current.scrollTop = box.current.scrollHeight
|
|
147
|
+
}, [logs, follow])
|
|
148
|
+
return h('div', null,
|
|
149
|
+
h('div', { style: { display: 'flex', gap: 8, marginBottom: 6 } },
|
|
150
|
+
h('button', { type: 'button', style: C.btn, onClick: () => setFollow((v) => !v) }, follow ? '自动滚动:开' : '自动滚动:关'),
|
|
151
|
+
h('button', { type: 'button', style: C.btn, onClick: () => setLogs([]) }, '清空视图')),
|
|
152
|
+
h('div', { ref: box, style: C.logBox },
|
|
153
|
+
logs.length === 0
|
|
154
|
+
? h('div', { style: { ...C.muted, fontSize: 12 } }, '暂无连接日志')
|
|
155
|
+
: logs.map((line) => h('div', { key: line.seq, style: C.logLine },
|
|
156
|
+
h('span', { style: { ...C.muted, flex: '0 0 66px' } }, fmtTime(line.ts)),
|
|
157
|
+
h('span', { style: { flex: '0 0 40px', color: line.level === 'error' ? '#c81e1e' : line.level === 'warn' ? '#b45309' : 'inherit' } }, line.level.toUpperCase()),
|
|
158
|
+
h('span', { style: { flex: 1, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, line.text)))))
|
|
159
|
+
}
|
|
131
160
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
function XrayPage() {
|
|
162
|
+
const { status, logs, error, setLogs } = useStatusFeed()
|
|
163
|
+
const phase = phaseOf(status)
|
|
164
|
+
const probe = status?.probe ?? {}
|
|
165
|
+
const latency = probe.latency?.ms
|
|
166
|
+
const egress = probe.egress ?? {}
|
|
167
|
+
const localEndpoint = status ? `127.0.0.1:${status.port}` : '—'
|
|
168
|
+
|
|
169
|
+
return h('div', { style: C.page },
|
|
170
|
+
h('div', { style: C.header },
|
|
171
|
+
h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
|
|
172
|
+
h('div', null,
|
|
173
|
+
h('h1', { style: C.title }, '2Xray'),
|
|
174
|
+
h('p', { style: C.sub }, `Xray 全局代理 · 本地 ${localEndpoint}`)),
|
|
175
|
+
h(StatusPill, { status })),
|
|
176
|
+
error
|
|
177
|
+
? h('div', { style: { ...C.card, ...C.error, fontSize: 12.5 } }, `状态通道不可用:${error}(代理本身可能仍在工作)`)
|
|
178
|
+
: null,
|
|
179
|
+
status?.reason && status.state !== 'ready'
|
|
180
|
+
? h('div', { style: { ...C.card, fontSize: 12.5 } }, h('b', null, '原因:'), ` ${status.reason}`,
|
|
181
|
+
status.detail ? h('div', { style: { ...C.muted, marginTop: 4 } }, status.detail) : null)
|
|
182
|
+
: null,
|
|
183
|
+
h('section', { style: C.card },
|
|
184
|
+
h('div', { style: C.cardHead }, '当前 VPN 配置',
|
|
185
|
+
h('span', { style: { ...C.muted, fontWeight: 400, marginLeft: 'auto' } }, `${status?.nodes?.length ?? 0} 个节点`)),
|
|
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)}` : '等待状态')))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function Field({ label, children }) {
|
|
200
|
+
return h('div', null, h('label', { style: C.label }, label), children)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readFormValue(form) {
|
|
204
|
+
try {
|
|
205
|
+
const snapshot = form?.getSnapshot?.()
|
|
206
|
+
const candidate = snapshot?.value ?? snapshot?.state?.view?.value ?? snapshot?.view?.value
|
|
207
|
+
return candidate && typeof candidate === 'object' ? candidate : {}
|
|
208
|
+
} catch {
|
|
209
|
+
return {}
|
|
150
210
|
}
|
|
211
|
+
}
|
|
151
212
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
say('当前浏览器没有 BarcodeDetector;请使用插件目录里的 tools/scanner.html(内置 jsQR 兜底)。', 'error')
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
try {
|
|
162
|
-
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 10 }, audio: false })
|
|
163
|
-
streamRef.current = stream
|
|
164
|
-
setScanning(true)
|
|
165
|
-
if (videoRef.current) {
|
|
166
|
-
videoRef.current.srcObject = stream
|
|
167
|
-
videoRef.current.style.display = 'block'
|
|
168
|
-
await videoRef.current.play()
|
|
169
|
-
}
|
|
170
|
-
stream.getVideoTracks()[0].addEventListener('ended', stop)
|
|
171
|
-
say('请把二维码放到共享屏幕上,正在识别…')
|
|
172
|
-
timerRef.current = setInterval(tick, 350)
|
|
173
|
-
} catch (error) {
|
|
174
|
-
say('屏幕共享未开始:' + error.message, 'error')
|
|
175
|
-
setScanning(false)
|
|
176
|
-
}
|
|
213
|
+
async function setField(form, field, value) {
|
|
214
|
+
if (typeof form?.set === 'function' && typeof field === 'string') return form.set(field, value)
|
|
215
|
+
const snapshot = form?.getSnapshot?.()
|
|
216
|
+
const revision = snapshot?.revision ?? snapshot?.state?.view?.revision
|
|
217
|
+
if (typeof form?.mutate === 'function') {
|
|
218
|
+
return form.mutate([{ op: 'set', path: Array.isArray(field) ? field : [field], value }], revision)
|
|
177
219
|
}
|
|
220
|
+
throw new Error('设置表单不可写')
|
|
221
|
+
}
|
|
178
222
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
223
|
+
function XraySettingsCard({ form }) {
|
|
224
|
+
const value = readFormValue(form)
|
|
225
|
+
const [shareLink, setShareLink] = React.useState(value.shareLink ?? '')
|
|
226
|
+
const [binaryPath, setBinaryPath] = React.useState(value.binary?.path ?? '')
|
|
227
|
+
const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
|
|
228
|
+
const [enabled, setEnabled] = React.useState(value.enabled === true)
|
|
229
|
+
const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
|
|
230
|
+
const [message, setMessage] = React.useState('')
|
|
231
|
+
const [busy, setBusy] = React.useState(false)
|
|
183
232
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
say('还没有链接可复制。', 'error')
|
|
187
|
-
return
|
|
188
|
-
}
|
|
233
|
+
const save = async () => {
|
|
234
|
+
setBusy(true)
|
|
189
235
|
try {
|
|
190
|
-
await
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
236
|
+
await setField(form, 'shareLink', shareLink.trim())
|
|
237
|
+
await setField(form, 'enabled', enabled)
|
|
238
|
+
await setField(form, 'takeover', takeover)
|
|
239
|
+
const numericPort = Number(port)
|
|
240
|
+
if (Number.isInteger(numericPort) && numericPort > 0) await setField(form, ['inbound', 'port'], numericPort)
|
|
241
|
+
if (binaryPath.trim()) await setField(form, ['binary', 'path'], binaryPath.trim())
|
|
242
|
+
setMessage('已保存,重启 DSH 后生效。')
|
|
243
|
+
} catch (error) {
|
|
244
|
+
setMessage('保存失败:' + (error.message || error))
|
|
245
|
+
} finally {
|
|
246
|
+
setBusy(false)
|
|
194
247
|
}
|
|
195
248
|
}
|
|
196
249
|
|
|
197
|
-
return h(
|
|
198
|
-
'div',
|
|
199
|
-
{
|
|
200
|
-
h('h1', { style: styles.title }, '2Xray'),
|
|
201
|
-
h('p', { style: styles.hint }, '扫描或粘贴 vless 分享链接,然后到「插件 → Xray 代理」里保存。'),
|
|
202
|
-
h(
|
|
203
|
-
'section',
|
|
204
|
-
{ style: styles.card },
|
|
205
|
-
h('h2', { style: styles.h2 }, '1. 二维码图片'),
|
|
206
|
-
h('input', { type: 'file', accept: 'image/*', onChange: onFile, disabled: scanning }),
|
|
207
|
-
h('div', { style: styles.status, 'aria-live': 'polite' }, status),
|
|
208
|
-
),
|
|
209
|
-
h(
|
|
210
|
-
'section',
|
|
211
|
-
{ style: styles.card },
|
|
212
|
-
h('h2', { style: styles.h2 }, '2. 扫描屏幕二维码'),
|
|
213
|
-
h(
|
|
214
|
-
'div',
|
|
215
|
-
{ style: styles.row },
|
|
216
|
-
h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: startScreen, disabled: scanning }, scanning ? '扫描中…' : '开始扫描屏幕'),
|
|
217
|
-
h('button', { type: 'button', style: styles.button, onClick: stop, disabled: !scanning }, '停止'),
|
|
218
|
-
),
|
|
219
|
-
h('video', { ref: videoRef, playsInline: true, muted: true, style: styles.video }),
|
|
220
|
-
),
|
|
221
|
-
h(
|
|
222
|
-
'section',
|
|
223
|
-
{ style: styles.card },
|
|
224
|
-
h('h2', { style: styles.h2 }, '3. 分享链接'),
|
|
250
|
+
return h('div', { style: { ...C.card, marginTop: 8 } },
|
|
251
|
+
h('div', { style: C.cardHead }, h(GlobeIcon), '2Xray 连接'),
|
|
252
|
+
h(Field, { label: 'VLESS 分享链接(粘贴或扫码)' },
|
|
225
253
|
h('textarea', {
|
|
226
|
-
style:
|
|
227
|
-
value:
|
|
254
|
+
style: { ...C.input, minHeight: 64, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
255
|
+
value: shareLink,
|
|
228
256
|
placeholder: 'vless://uuid@host:443?security=reality&sni=...&pbk=...#备注',
|
|
229
|
-
onChange: (event) =>
|
|
230
|
-
}),
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
{
|
|
234
|
-
|
|
235
|
-
h('
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
),
|
|
244
|
-
)
|
|
245
|
-
)
|
|
257
|
+
onChange: (event) => setShareLink(event.target.value),
|
|
258
|
+
})),
|
|
259
|
+
h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
260
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 } },
|
|
261
|
+
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: 8 } }, 'takeover',
|
|
263
|
+
h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
|
|
264
|
+
h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
|
|
265
|
+
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) })),
|
|
267
|
+
h(Field, { label: '本地 HTTP 入站端口' },
|
|
268
|
+
h('input', { style: C.input, value: port, onChange: (event) => setPort(event.target.value) })),
|
|
269
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 12 } },
|
|
270
|
+
h('button', { type: 'button', style: C.primaryBtn, disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
|
|
271
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, message)),
|
|
272
|
+
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '打开左侧「2Xray」面板可扫码导入、查看节点/日志/延迟/出口 IP。'))
|
|
246
273
|
}
|
|
247
274
|
|
|
248
275
|
function apply(ctx) {
|
|
249
276
|
ctx.effect(
|
|
250
277
|
() =>
|
|
251
|
-
ctx.slots.inject('
|
|
252
|
-
ctx.slots.register({ name: '
|
|
278
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
279
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 1, label: () => '2Xray' }, GlobeIcon),
|
|
253
280
|
),
|
|
254
|
-
'dsh-2xray:
|
|
281
|
+
'dsh-2xray: sidebar entry',
|
|
255
282
|
)
|
|
256
283
|
ctx.effect(
|
|
257
284
|
() =>
|
|
258
|
-
ctx.slots.inject('
|
|
259
|
-
ctx.slots.register({ name: '
|
|
285
|
+
ctx.slots.inject('main', () =>
|
|
286
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID }, XrayPage),
|
|
260
287
|
),
|
|
261
|
-
'dsh-2xray:
|
|
288
|
+
'dsh-2xray: main panel',
|
|
262
289
|
)
|
|
290
|
+
if (typeof ctx.inject === 'function') {
|
|
291
|
+
ctx.inject(['configForms'], (child) => {
|
|
292
|
+
try {
|
|
293
|
+
child.effect(
|
|
294
|
+
() =>
|
|
295
|
+
child.configForms.whileServed([NS], () =>
|
|
296
|
+
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
|
+
),
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
'dsh-2xray: settings card',
|
|
310
|
+
)
|
|
311
|
+
} catch {
|
|
312
|
+
// a client without the settings service keeps the sidebar panel
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
263
316
|
}
|
|
264
317
|
|
|
265
|
-
return { apply, inject, PANEL_ID }
|
|
318
|
+
return { apply, inject, PANEL_ID, NS }
|
|
266
319
|
},
|
|
267
320
|
})
|
package/lib/config.js
CHANGED
|
@@ -79,8 +79,10 @@ export const DEFAULT_CONFIG = {
|
|
|
79
79
|
},
|
|
80
80
|
functionalProbe: {
|
|
81
81
|
enabled: true,
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Reachable from mainland networks through most nodes; gstatic is often
|
|
83
|
+
// blocked and would misreport a working tunnel as failed.
|
|
84
|
+
url: 'http://cp.cloudflare.com/generate_204',
|
|
85
|
+
timeoutMs: 8000,
|
|
84
86
|
},
|
|
85
87
|
log: { level: 'warning' },
|
|
86
88
|
}
|
package/lib/logbuf.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Bounded in-memory log ring served to the panel over the authenticated API. */
|
|
2
|
+
export class LogBuffer {
|
|
3
|
+
#items = []
|
|
4
|
+
#capacity
|
|
5
|
+
#seq = 0
|
|
6
|
+
|
|
7
|
+
constructor(capacity = 1000) {
|
|
8
|
+
this.#capacity = capacity
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
append(level, text) {
|
|
12
|
+
const entry = {
|
|
13
|
+
seq: ++this.#seq,
|
|
14
|
+
ts: Date.now(),
|
|
15
|
+
level: ['debug', 'info', 'warn', 'error'].includes(level) ? level : 'info',
|
|
16
|
+
text: String(text ?? '').slice(0, 2000),
|
|
17
|
+
}
|
|
18
|
+
this.#items.push(entry)
|
|
19
|
+
if (this.#items.length > this.#capacity) this.#items.splice(0, this.#items.length - this.#capacity)
|
|
20
|
+
return entry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
since(after = 0, limit = 200) {
|
|
24
|
+
const items = this.#items.filter((entry) => entry.seq > after).slice(0, limit)
|
|
25
|
+
return { items, cursor: this.#seq, dropped: this.#seq - this.#capacity > after }
|
|
26
|
+
}
|
|
27
|
+
}
|
package/lib/monitor.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Latency and egress-IP probes. Both go through the local HTTP inbound with an
|
|
5
|
+
* absolute-form request line, so they exercise the tunnel instead of the
|
|
6
|
+
* direct network. Plain http only: no CONNECT/TLS work is needed here.
|
|
7
|
+
*/
|
|
8
|
+
export class Monitor {
|
|
9
|
+
#host
|
|
10
|
+
#port
|
|
11
|
+
#latencyUrl
|
|
12
|
+
#egressUrl
|
|
13
|
+
#latencyTimeoutMs
|
|
14
|
+
#egressTimeoutMs
|
|
15
|
+
#latencyTtlMs
|
|
16
|
+
#egressTtlMs
|
|
17
|
+
#logger
|
|
18
|
+
#state = {
|
|
19
|
+
latency: { ms: null, at: null, state: 'idle' },
|
|
20
|
+
egress: { ip: null, country: null, at: null, state: 'idle' },
|
|
21
|
+
}
|
|
22
|
+
#latencyFlight = null
|
|
23
|
+
#egressFlight = null
|
|
24
|
+
|
|
25
|
+
constructor({
|
|
26
|
+
host = '127.0.0.1',
|
|
27
|
+
port = 20808,
|
|
28
|
+
latencyUrl = 'http://cp.cloudflare.com/generate_204',
|
|
29
|
+
egressUrl = 'http://ip-api.com/json/?fields=status,country,countryCode,query',
|
|
30
|
+
latencyTimeoutMs = 4000,
|
|
31
|
+
egressTimeoutMs = 5000,
|
|
32
|
+
latencyTtlMs = 30_000,
|
|
33
|
+
egressTtlMs = 600_000,
|
|
34
|
+
logger,
|
|
35
|
+
} = {}) {
|
|
36
|
+
this.#host = host
|
|
37
|
+
this.#port = port
|
|
38
|
+
this.#latencyUrl = latencyUrl
|
|
39
|
+
this.#egressUrl = egressUrl
|
|
40
|
+
this.#latencyTimeoutMs = latencyTimeoutMs
|
|
41
|
+
this.#egressTimeoutMs = egressTimeoutMs
|
|
42
|
+
this.#latencyTtlMs = latencyTtlMs
|
|
43
|
+
this.#egressTtlMs = egressTtlMs
|
|
44
|
+
this.#logger = logger
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
snapshot() {
|
|
48
|
+
const now = Date.now()
|
|
49
|
+
const latencyFresh = this.#state.latency.at !== null && now - this.#state.latency.at < this.#latencyTtlMs
|
|
50
|
+
const egressFresh = this.#state.egress.at !== null && now - this.#state.egress.at < this.#egressTtlMs
|
|
51
|
+
return {
|
|
52
|
+
latency: { ...this.#state.latency, stale: !latencyFresh },
|
|
53
|
+
egress: { ...this.#state.egress, stale: !egressFresh },
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Kick a refresh when data is stale; never throws, never blocks the caller. */
|
|
58
|
+
maybeRefresh({ ready }) {
|
|
59
|
+
if (!ready) {
|
|
60
|
+
this.#state.latency = { ...this.#state.latency, state: 'skipped' }
|
|
61
|
+
this.#state.egress = { ...this.#state.egress, state: 'skipped' }
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const now = Date.now()
|
|
65
|
+
if (this.#state.latency.at === null || now - this.#state.latency.at >= this.#latencyTtlMs) {
|
|
66
|
+
if (this.#latencyFlight === null) {
|
|
67
|
+
this.#latencyFlight = this.#probeLatency()
|
|
68
|
+
.catch(() => {})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
this.#latencyFlight = null
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (this.#state.egress.at === null || now - this.#state.egress.at >= this.#egressTtlMs) {
|
|
75
|
+
if (this.#egressFlight === null) {
|
|
76
|
+
this.#egressFlight = this.#probeEgress()
|
|
77
|
+
.catch(() => {})
|
|
78
|
+
.finally(() => {
|
|
79
|
+
this.#egressFlight = null
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async #probeLatency() {
|
|
86
|
+
const started = Date.now()
|
|
87
|
+
const result = await this.#getThroughProxy(this.#latencyUrl, this.#latencyTimeoutMs)
|
|
88
|
+
const ms = Date.now() - started
|
|
89
|
+
if (result.status >= 200 && result.status < 400) {
|
|
90
|
+
this.#state.latency = { ms, at: Date.now(), state: 'ok' }
|
|
91
|
+
this.#logger?.info?.(`延迟探测:${ms}ms(${this.#latencyUrl})`)
|
|
92
|
+
} else {
|
|
93
|
+
this.#state.latency = { ms: null, at: Date.now(), state: 'error' }
|
|
94
|
+
this.#logger?.warn?.(`延迟探测失败:${result.error ?? result.status}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async #probeEgress() {
|
|
99
|
+
const result = await this.#getThroughProxy(this.#egressUrl, this.#egressTimeoutMs)
|
|
100
|
+
if (result.status >= 200 && result.status < 300) {
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(result.body)
|
|
103
|
+
if (data && data.query) {
|
|
104
|
+
this.#state.egress = { ip: data.query, country: data.countryCode ?? data.country ?? null, at: Date.now(), state: 'ok' }
|
|
105
|
+
this.#logger?.info?.(`出口 IP:${data.query}${data.countryCode ? ` (${data.countryCode})` : ''}`)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// fall through to error state
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.#state.egress = { ip: null, country: null, at: Date.now(), state: 'error' }
|
|
113
|
+
this.#logger?.warn?.(`出口 IP 探测失败:${result.error ?? result.status}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#getThroughProxy(url, timeoutMs) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
let target
|
|
119
|
+
try {
|
|
120
|
+
target = new URL(url)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
resolve({ status: 0, body: '', error: String(error) })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const request = http.request(
|
|
126
|
+
{
|
|
127
|
+
host: this.#host,
|
|
128
|
+
port: this.#port,
|
|
129
|
+
method: 'GET',
|
|
130
|
+
path: target.href,
|
|
131
|
+
headers: { host: target.host, connection: 'close' },
|
|
132
|
+
timeout: timeoutMs,
|
|
133
|
+
},
|
|
134
|
+
(response) => {
|
|
135
|
+
const chunks = []
|
|
136
|
+
response.on('data', (chunk) => chunks.push(chunk))
|
|
137
|
+
response.on('end', () =>
|
|
138
|
+
resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
request.on('timeout', () => {
|
|
143
|
+
request.destroy(new Error('timeout'))
|
|
144
|
+
resolve({ status: 0, body: '', error: 'timeout' })
|
|
145
|
+
})
|
|
146
|
+
request.on('error', (error) => resolve({ status: 0, body: '', error: error.code ?? error.message }))
|
|
147
|
+
request.end()
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
package/lib/schema.js
CHANGED
|
@@ -19,11 +19,6 @@ import { join } from 'node:path'
|
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
20
20
|
|
|
21
21
|
async function loadSchema() {
|
|
22
|
-
try {
|
|
23
|
-
return (await import('@deepseek-ai/schemastery')).default
|
|
24
|
-
} catch {
|
|
25
|
-
// local checkout / test: borrow the profile's copy
|
|
26
|
-
}
|
|
27
22
|
const home = process.env.USERPROFILE ?? process.env.HOME
|
|
28
23
|
const anchors = home
|
|
29
24
|
? [
|
|
@@ -31,13 +26,29 @@ async function loadSchema() {
|
|
|
31
26
|
join(home, '.dsh', 'profiles', 'web', 'package.json'),
|
|
32
27
|
]
|
|
33
28
|
: []
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
|
|
30
|
+
// Prefer Node's own resolver: a bare `import()` inside a plugin may be served
|
|
31
|
+
// by the harness module loader, whose table does not necessarily contain a
|
|
32
|
+
// peer package such as Schemastery even though it sits in node_modules.
|
|
33
|
+
const attempts = [
|
|
34
|
+
() => {
|
|
35
|
+
const file = createRequire(import.meta.url).resolve('@deepseek-ai/schemastery')
|
|
36
|
+
return import(pathToFileURL(file).href)
|
|
37
|
+
},
|
|
38
|
+
...anchors.map((anchor) => () => {
|
|
36
39
|
const file = createRequire(anchor).resolve('@deepseek-ai/schemastery')
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
return import(pathToFileURL(file).href)
|
|
41
|
+
}),
|
|
42
|
+
() => import('@deepseek-ai/schemastery'),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (const attempt of attempts) {
|
|
46
|
+
try {
|
|
47
|
+
const module = await attempt()
|
|
48
|
+
const candidate = module?.default ?? module
|
|
49
|
+
if (typeof candidate === 'function' && typeof candidate.object === 'function') return candidate
|
|
39
50
|
} catch {
|
|
40
|
-
// try the next
|
|
51
|
+
// try the next resolution route
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
throw new Error('@deepseek-ai/schemastery is not resolvable')
|
package/lib/supervisor.js
CHANGED
|
@@ -86,6 +86,7 @@ export class Supervisor {
|
|
|
86
86
|
#resolveBinaryFn
|
|
87
87
|
#control
|
|
88
88
|
#activeNode
|
|
89
|
+
#schemaLoaded = false
|
|
89
90
|
#now
|
|
90
91
|
|
|
91
92
|
constructor({
|
|
@@ -100,6 +101,7 @@ export class Supervisor {
|
|
|
100
101
|
probes = {},
|
|
101
102
|
resolveBinaryFn = resolveBinary,
|
|
102
103
|
control,
|
|
104
|
+
schemaLoaded = false,
|
|
103
105
|
now = () => Date.now(),
|
|
104
106
|
}) {
|
|
105
107
|
this.config = config
|
|
@@ -108,6 +110,7 @@ export class Supervisor {
|
|
|
108
110
|
this.#launcher = launcher
|
|
109
111
|
this.#route = route
|
|
110
112
|
this.#control = control
|
|
113
|
+
this.#schemaLoaded = schemaLoaded
|
|
111
114
|
this.#activeNode = config.activeNode
|
|
112
115
|
this.#logger = logger
|
|
113
116
|
this.dataDir = dataDir
|
|
@@ -317,14 +320,16 @@ export class Supervisor {
|
|
|
317
320
|
this.#functionalProbe = probe.ok ? 'pass' : `fail:${probe.status ?? probe.error ?? 'unknown'}`
|
|
318
321
|
if (!probe.ok) {
|
|
319
322
|
this.#lastStdio = safeTail(handle) || this.#lastStdio
|
|
320
|
-
|
|
323
|
+
// Keep the process: a node whose egress blocks the probe target must
|
|
324
|
+
// not tear down an otherwise healthy tunnel. The health loop retries
|
|
325
|
+
// the probe and installs the route once it passes.
|
|
321
326
|
this.#setState(
|
|
322
|
-
'
|
|
323
|
-
'
|
|
327
|
+
'degraded',
|
|
328
|
+
'probe-failed',
|
|
324
329
|
`经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
|
|
325
|
-
'
|
|
326
|
-
'确认链路正常后可设置 functionalProbe.enabled: false 只做 TCP 就绪检查。',
|
|
330
|
+
'进程保留、未接管流量;健康循环会继续重试。常见原因:uuid/flow/SNI/shortId/REALITY 公钥不一致,或探测目标不可达。',
|
|
327
331
|
)
|
|
332
|
+
this.#startHealthLoop(generation)
|
|
328
333
|
return
|
|
329
334
|
}
|
|
330
335
|
} else {
|
|
@@ -561,6 +566,44 @@ export class Supervisor {
|
|
|
561
566
|
this.#restartTimer.unref?.()
|
|
562
567
|
}
|
|
563
568
|
|
|
569
|
+
/** Retry the functional probe after a `probe-failed` degradation. */
|
|
570
|
+
async #retryProbeAndInstall() {
|
|
571
|
+
if (!this.config.functionalProbe.enabled) return
|
|
572
|
+
const host = this.config.inbound.host
|
|
573
|
+
const port = this.config.inbound.port
|
|
574
|
+
const probe = await this.#probes.httpProxyProbe({
|
|
575
|
+
host,
|
|
576
|
+
port,
|
|
577
|
+
url: this.config.functionalProbe.url,
|
|
578
|
+
timeoutMs: this.config.functionalProbe.timeoutMs,
|
|
579
|
+
})
|
|
580
|
+
if (!probe.ok) {
|
|
581
|
+
this.#logger?.warn?.(`功能探测仍未通过:${probe.error ?? probe.status ?? 'unknown'}`)
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
this.#functionalProbe = 'pass'
|
|
585
|
+
if (this.config.takeover === 'off') {
|
|
586
|
+
this.#setState('ready', 'takeover-off', 'xray 已就绪,但 takeover=off,未接管全局流量')
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const scheme = this.config.inbound.protocol === 'socks' ? 'socks5' : 'http'
|
|
591
|
+
const result = await this.#route.install({
|
|
592
|
+
proxyUrl: `${scheme}://${formatHost(host)}:${port}`,
|
|
593
|
+
noProxy: this.config.noProxy,
|
|
594
|
+
force: this.config.takeover === 'force',
|
|
595
|
+
})
|
|
596
|
+
if (result.reason === 'external-proxy-active') {
|
|
597
|
+
this.#setState('degraded', 'external-proxy-active', '启动器已有代理策略;未接管。设置 takeover: force 可强制覆盖')
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
await this.#applyActiveNode()
|
|
601
|
+
this.#setState('ready')
|
|
602
|
+
} catch (error) {
|
|
603
|
+
this.#setState('degraded', error.code ?? 'route-install-failed', error.message)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
564
607
|
#startHealthLoop(generation) {
|
|
565
608
|
const interval = this.config.lifecycle.healthIntervalMs
|
|
566
609
|
if (!(interval > 0)) return
|
|
@@ -578,7 +621,11 @@ export class Supervisor {
|
|
|
578
621
|
if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
|
|
579
622
|
if (ok) {
|
|
580
623
|
this.#unhealthy = 0
|
|
581
|
-
if (this.#state === 'degraded' && this.#reason === '
|
|
624
|
+
if (this.#state === 'degraded' && this.#reason === 'probe-failed') {
|
|
625
|
+
await this.#retryProbeAndInstall()
|
|
626
|
+
} else if (this.#state === 'degraded' && this.#reason === 'unhealthy') {
|
|
627
|
+
this.#setState('ready', 'health-recovered')
|
|
628
|
+
}
|
|
582
629
|
} else {
|
|
583
630
|
this.#unhealthy++
|
|
584
631
|
if (this.#unhealthy >= this.config.lifecycle.unhealthyThreshold) {
|
|
@@ -646,6 +693,7 @@ export class Supervisor {
|
|
|
646
693
|
detail: this.#detail ?? null,
|
|
647
694
|
enabled: this.config.enabled,
|
|
648
695
|
takeover: this.config.takeover,
|
|
696
|
+
settingsSchema: this.#schemaLoaded,
|
|
649
697
|
nodes: this.config.nodes.map((node) => ({ id: node.id, tag: node.tag, address: node.address, port: node.port })),
|
|
650
698
|
activeNode: this.#activeNode,
|
|
651
699
|
control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
@@ -45,12 +45,12 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
|
|
|
45
45
|
|
|
46
46
|
assert.deepEqual(
|
|
47
47
|
registrations.map((entry) => entry.definition.name),
|
|
48
|
-
['
|
|
48
|
+
['sidebar.panellist', 'main'],
|
|
49
49
|
)
|
|
50
|
-
assert.equal(registrations[0].definition.
|
|
51
|
-
assert.equal(registrations[
|
|
52
|
-
assert.equal(registrations[
|
|
53
|
-
|
|
54
|
-
const icon = registrations[1].component()
|
|
50
|
+
assert.equal(registrations[0].definition.id, 'xray')
|
|
51
|
+
assert.equal(registrations[0].definition.order, 1)
|
|
52
|
+
assert.equal(registrations[0].definition.label(), '2Xray')
|
|
53
|
+
const icon = registrations[0].component()
|
|
55
54
|
assert.equal(icon.type, 'svg')
|
|
55
|
+
assert.equal(registrations[1].definition.key, 'xray')
|
|
56
56
|
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { LogBuffer } from '../lib/logbuf.js'
|
|
4
|
+
|
|
5
|
+
test('the log ring pages by cursor and reports the head', () => {
|
|
6
|
+
const ring = new LogBuffer(10)
|
|
7
|
+
ring.append('info', 'one')
|
|
8
|
+
ring.append('warn', 'two')
|
|
9
|
+
const first = ring.since(0)
|
|
10
|
+
assert.equal(first.items.length, 2)
|
|
11
|
+
assert.equal(first.cursor, 2)
|
|
12
|
+
const second = ring.since(first.cursor)
|
|
13
|
+
assert.equal(second.items.length, 0)
|
|
14
|
+
ring.append('error', 'three')
|
|
15
|
+
const third = ring.since(first.cursor)
|
|
16
|
+
assert.deepEqual(third.items.map((entry) => entry.text), ['three'])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('the ring drops the oldest entries at capacity', () => {
|
|
20
|
+
const ring = new LogBuffer(3)
|
|
21
|
+
for (let index = 1; index <= 5; index++) ring.append('info', `line-${index}`)
|
|
22
|
+
const page = ring.since(0)
|
|
23
|
+
assert.deepEqual(page.items.map((entry) => entry.text), ['line-3', 'line-4', 'line-5'])
|
|
24
|
+
assert.equal(page.cursor, 5)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('levels outside the known set fall back to info and text is bounded', () => {
|
|
28
|
+
const ring = new LogBuffer()
|
|
29
|
+
const entry = ring.append('verbose', 'x'.repeat(5000))
|
|
30
|
+
assert.equal(entry.level, 'info')
|
|
31
|
+
assert.equal(entry.text.length, 2000)
|
|
32
|
+
})
|
package/test/supervisor.test.js
CHANGED
|
@@ -523,3 +523,35 @@ test('validateConfig reports a missing binary instead of throwing', async () =>
|
|
|
523
523
|
}
|
|
524
524
|
})
|
|
525
525
|
|
|
526
|
+
test('a failing functional probe degrades but keeps the process alive', async () => {
|
|
527
|
+
const { dir, cleanup } = workdir()
|
|
528
|
+
try {
|
|
529
|
+
const config = makeConfig({ functionalProbe: { enabled: true, url: 'http://probe.invalid/', timeoutMs: 1000 } })
|
|
530
|
+
const log = []
|
|
531
|
+
const ready = readyProbes(log)
|
|
532
|
+
const { supervisor, route, launcher } = makeSupervisor({
|
|
533
|
+
dataDir: dir,
|
|
534
|
+
config,
|
|
535
|
+
log,
|
|
536
|
+
probes: {
|
|
537
|
+
async portIsFree() {
|
|
538
|
+
return true
|
|
539
|
+
},
|
|
540
|
+
waitForPort: ready.waitForPort,
|
|
541
|
+
async httpProxyProbe() {
|
|
542
|
+
return { ok: false, status: 502 }
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
})
|
|
546
|
+
await supervisor.start()
|
|
547
|
+
const snap = await supervisor.waitFor((s) => s.state === 'degraded')
|
|
548
|
+
assert.equal(snap.reason, 'probe-failed')
|
|
549
|
+
assert.equal(snap.process.alive, true)
|
|
550
|
+
assert.equal(route.installCalls, 0)
|
|
551
|
+
assert.equal(launcher.runs.length, 1)
|
|
552
|
+
await supervisor.stop()
|
|
553
|
+
} finally {
|
|
554
|
+
cleanup()
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|