@twinsearth/dsh-2xray 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/index.js +88 -1
- package/lib/client.js +308 -197
- package/lib/config.js +4 -2
- package/lib/logbuf.js +27 -0
- package/lib/monitor.js +150 -0
- package/lib/supervisor.js +70 -7
- package/package.json +1 -1
- package/test/logbuf.test.js +32 -0
- package/test/supervisor.test.js +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 界面
|
|
6
|
+
|
|
7
|
+
- 主面板改为三卡片:①当前 VPN 配置(节点下拉、协议/安全/SNI、切换节点)②实时连接信息(运行时长、代理端口、Xray PID、入站类型,日志可折叠)③连接状态(状态点、延迟、出口 IP、节点名)。
|
|
8
|
+
- 新增 `POST /api/xray/switch`(鉴权围栏)驱动节点切换;切换依赖控制面与多节点,单节点会返回明确错误。
|
|
9
|
+
- 状态快照补充 `inbound`、`uptimeSec`、节点 `security/serverName/flow/network`。
|
|
10
|
+
|
|
11
|
+
## 0.2.2 — 2026-10-29
|
|
12
|
+
|
|
13
|
+
### 新增
|
|
14
|
+
|
|
15
|
+
- **界面三段式重做**:左侧「2Xray」面板改为「当前 VPN 配置 / 实时连接日志 / 状态栏(延迟 · VPN 名称 · 出口 IP)」。
|
|
16
|
+
- **设置页卡片**:客户端注册 `plugins.item` 卡片(分享链接、启用、takeover、xray 路径、端口),解决 DSH 不会自动渲染表单的问题;侧边栏面板与设置卡片共用同一配置。
|
|
17
|
+
- **Host 状态通道**:`/api/xray/status`(经 `ctx.connection.fetch` 鉴权围栏)返回快照 + 日志环 + 延迟/出口 IP;日志 1000 条环缓冲、按 cursor 增量拉取。
|
|
18
|
+
- **延迟/出口探测**:经本地入站 absolute-form 请求,仅接管后运行,30s/10min TTL,单飞。
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
|
|
22
|
+
- 功能探测失败不再杀进程:改为 `degraded(probe-failed)`,健康循环重试并自动接管。
|
|
23
|
+
- 默认探测目标从 gstatic 改为 `cp.cloudflare.com/generate_204`,避免大陆网络误判。
|
|
24
|
+
- `xray_status` 增加 `settingsSchema`,可远程确认宿主是否加载了设置表单 schema。
|
|
25
|
+
|
|
3
26
|
## 0.2.1 — 2026-10-29
|
|
4
27
|
|
|
5
28
|
### 修复
|
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}`)
|
|
@@ -95,6 +113,75 @@ export function apply(ctx, rawConfig) {
|
|
|
95
113
|
dataDir,
|
|
96
114
|
})
|
|
97
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
|
+
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
|
+
)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.warn(`状态 API 注册失败:${String(error)}`)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
98
185
|
const dispose = ctx.effect(
|
|
99
186
|
() => async () => {
|
|
100
187
|
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,227 +53,286 @@ 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
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
return { status, logs, error, setLogs }
|
|
115
|
+
}
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 }) {
|
|
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
|
+
|
|
131
|
+
if (nodes.length === 0) {
|
|
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 后这里会显示。'))
|
|
98
135
|
}
|
|
99
136
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
137
|
+
const switchTo = async (tag) => {
|
|
138
|
+
if (!tag || tag === status?.activeNode) return
|
|
139
|
+
setSwitching(true)
|
|
140
|
+
setMessage('')
|
|
104
141
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
113
|
-
|
|
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}`)
|
|
114
151
|
} catch (error) {
|
|
115
|
-
|
|
152
|
+
setMessage(String(error.message || error))
|
|
153
|
+
} finally {
|
|
154
|
+
setSwitching(false)
|
|
116
155
|
}
|
|
117
156
|
}
|
|
118
157
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
175
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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'}` : ' · 尚未接管')))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function LogList({ logs, setLogs }) {
|
|
214
|
+
const box = React.useRef(null)
|
|
215
|
+
const [follow, setFollow] = React.useState(true)
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (follow && box.current) box.current.scrollTop = box.current.scrollHeight
|
|
218
|
+
}, [logs, follow])
|
|
219
|
+
return h('div', null,
|
|
220
|
+
h('div', { style: { display: 'flex', gap: 8, marginBottom: 6 } },
|
|
221
|
+
h('button', { type: 'button', style: C.btn, onClick: () => setFollow((v) => !v) }, follow ? '自动滚动:开' : '自动滚动:关'),
|
|
222
|
+
h('button', { type: 'button', style: C.btn, onClick: () => setLogs([]) }, '清空视图')),
|
|
223
|
+
h('div', { ref: box, style: C.logBox },
|
|
224
|
+
logs.length === 0
|
|
225
|
+
? h('div', { style: { ...C.muted, fontSize: 12 } }, '暂无连接日志')
|
|
226
|
+
: logs.map((line) => h('div', { key: line.seq, style: C.logLine },
|
|
227
|
+
h('span', { style: { ...C.muted, flex: '0 0 66px' } }, fmtTime(line.ts)),
|
|
228
|
+
h('span', { style: { flex: '0 0 40px', color: line.level === 'error' ? '#c81e1e' : line.level === 'warn' ? '#b45309' : 'inherit' } }, line.level.toUpperCase()),
|
|
229
|
+
h('span', { style: { flex: 1, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, line.text)))))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function XrayPage() {
|
|
233
|
+
const { status, logs, error, setLogs } = useStatusFeed()
|
|
234
|
+
const phase = phaseOf(status)
|
|
235
|
+
const probe = status?.probe ?? {}
|
|
236
|
+
const latency = probe.latency?.ms
|
|
237
|
+
const egress = probe.egress ?? {}
|
|
238
|
+
const localEndpoint = status ? `127.0.0.1:${status.port}` : '—'
|
|
239
|
+
|
|
240
|
+
return h('div', { style: C.page },
|
|
241
|
+
h('div', { style: C.header },
|
|
242
|
+
h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
|
|
243
|
+
h('div', null,
|
|
244
|
+
h('h1', { style: C.title }, '2Xray'),
|
|
245
|
+
h('p', { style: C.sub }, `Xray 全局代理 · 本地 ${localEndpoint}`)),
|
|
246
|
+
h(StatusPill, { status })),
|
|
247
|
+
error
|
|
248
|
+
? h('div', { style: { ...C.card, ...C.error, fontSize: 12.5 } }, `状态通道不可用:${error}(代理本身可能仍在工作)`)
|
|
249
|
+
: null,
|
|
250
|
+
status?.reason && status.state !== 'ready'
|
|
251
|
+
? h('div', { style: { ...C.card, fontSize: 12.5 } }, h('b', null, '原因:'), ` ${status.reason}`,
|
|
252
|
+
status.detail ? h('div', { style: { ...C.muted, marginTop: 4 } }, status.detail) : null)
|
|
253
|
+
: null,
|
|
254
|
+
h(ConfigCard, { status }),
|
|
255
|
+
h(RuntimeCard, { status, logs, setLogs }),
|
|
256
|
+
h(StatusCard, { status, latency, egress, phase }))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function Field({ label, children }) {
|
|
260
|
+
return h('div', null, h('label', { style: C.label }, label), children)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readFormValue(form) {
|
|
264
|
+
try {
|
|
265
|
+
const snapshot = form?.getSnapshot?.()
|
|
266
|
+
const candidate = snapshot?.value ?? snapshot?.state?.view?.value ?? snapshot?.view?.value
|
|
267
|
+
return candidate && typeof candidate === 'object' ? candidate : {}
|
|
268
|
+
} catch {
|
|
269
|
+
return {}
|
|
150
270
|
}
|
|
271
|
+
}
|
|
151
272
|
|
|
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
|
-
}
|
|
273
|
+
async function setField(form, field, value) {
|
|
274
|
+
if (typeof form?.set === 'function' && typeof field === 'string') return form.set(field, value)
|
|
275
|
+
const snapshot = form?.getSnapshot?.()
|
|
276
|
+
const revision = snapshot?.revision ?? snapshot?.state?.view?.revision
|
|
277
|
+
if (typeof form?.mutate === 'function') {
|
|
278
|
+
return form.mutate([{ op: 'set', path: Array.isArray(field) ? field : [field], value }], revision)
|
|
177
279
|
}
|
|
280
|
+
throw new Error('设置表单不可写')
|
|
281
|
+
}
|
|
178
282
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
283
|
+
function XraySettingsCard({ form }) {
|
|
284
|
+
const value = readFormValue(form)
|
|
285
|
+
const [shareLink, setShareLink] = React.useState(value.shareLink ?? '')
|
|
286
|
+
const [binaryPath, setBinaryPath] = React.useState(value.binary?.path ?? '')
|
|
287
|
+
const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
|
|
288
|
+
const [enabled, setEnabled] = React.useState(value.enabled === true)
|
|
289
|
+
const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
|
|
290
|
+
const [message, setMessage] = React.useState('')
|
|
291
|
+
const [busy, setBusy] = React.useState(false)
|
|
183
292
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
say('还没有链接可复制。', 'error')
|
|
187
|
-
return
|
|
188
|
-
}
|
|
293
|
+
const save = async () => {
|
|
294
|
+
setBusy(true)
|
|
189
295
|
try {
|
|
190
|
-
await
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
296
|
+
await setField(form, 'shareLink', shareLink.trim())
|
|
297
|
+
await setField(form, 'enabled', enabled)
|
|
298
|
+
await setField(form, 'takeover', takeover)
|
|
299
|
+
const numericPort = Number(port)
|
|
300
|
+
if (Number.isInteger(numericPort) && numericPort > 0) await setField(form, ['inbound', 'port'], numericPort)
|
|
301
|
+
if (binaryPath.trim()) await setField(form, ['binary', 'path'], binaryPath.trim())
|
|
302
|
+
setMessage('已保存,重启 DSH 后生效。')
|
|
303
|
+
} catch (error) {
|
|
304
|
+
setMessage('保存失败:' + (error.message || error))
|
|
305
|
+
} finally {
|
|
306
|
+
setBusy(false)
|
|
194
307
|
}
|
|
195
308
|
}
|
|
196
309
|
|
|
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. 分享链接'),
|
|
310
|
+
return h('div', { style: { ...C.card, marginTop: 8 } },
|
|
311
|
+
h('div', { style: C.cardHead }, h(GlobeIcon), '2Xray 连接'),
|
|
312
|
+
h(Field, { label: 'VLESS 分享链接(粘贴或扫码)' },
|
|
225
313
|
h('textarea', {
|
|
226
|
-
style:
|
|
227
|
-
value:
|
|
314
|
+
style: { ...C.input, minHeight: 64, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
315
|
+
value: shareLink,
|
|
228
316
|
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
|
-
)
|
|
317
|
+
onChange: (event) => setShareLink(event.target.value),
|
|
318
|
+
})),
|
|
319
|
+
h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
320
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 } },
|
|
321
|
+
h('input', { type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }), '启用代理'),
|
|
322
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 } }, 'takeover',
|
|
323
|
+
h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
|
|
324
|
+
h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
|
|
325
|
+
h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
|
|
326
|
+
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
327
|
+
h(Field, { label: '本地 HTTP 入站端口' },
|
|
328
|
+
h('input', { style: C.input, value: port, onChange: (event) => setPort(event.target.value) })),
|
|
329
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 12 } },
|
|
330
|
+
h('button', { type: 'button', style: C.primaryBtn, disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
|
|
331
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, message)),
|
|
332
|
+
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '打开左侧「2Xray」面板可扫码导入、查看节点/日志/延迟/出口 IP。'))
|
|
246
333
|
}
|
|
247
334
|
|
|
248
335
|
function apply(ctx) {
|
|
249
|
-
// Register the sidebar entry first: a failure in the main-panel
|
|
250
|
-
// registration must not hide the entry.
|
|
251
336
|
ctx.effect(
|
|
252
337
|
() =>
|
|
253
338
|
ctx.slots.inject('sidebar.panellist', () =>
|
|
@@ -262,8 +347,34 @@ window.__ModuleLoader__.load({
|
|
|
262
347
|
),
|
|
263
348
|
'dsh-2xray: main panel',
|
|
264
349
|
)
|
|
350
|
+
if (typeof ctx.inject === 'function') {
|
|
351
|
+
ctx.inject(['configForms'], (child) => {
|
|
352
|
+
try {
|
|
353
|
+
child.effect(
|
|
354
|
+
() =>
|
|
355
|
+
child.configForms.whileServed([NS], () =>
|
|
356
|
+
child.slots.inject('plugins.item', () =>
|
|
357
|
+
child.slots.register(
|
|
358
|
+
{
|
|
359
|
+
name: 'plugins.item',
|
|
360
|
+
id: PANEL_ID,
|
|
361
|
+
order: 40,
|
|
362
|
+
label: () => '2Xray 连接',
|
|
363
|
+
inject: () => ({ form: child.configForms.get(NS) }),
|
|
364
|
+
},
|
|
365
|
+
XraySettingsCard,
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
'dsh-2xray: settings card',
|
|
370
|
+
)
|
|
371
|
+
} catch {
|
|
372
|
+
// a client without the settings service keeps the sidebar panel
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
}
|
|
265
376
|
}
|
|
266
377
|
|
|
267
|
-
return { apply, inject, PANEL_ID }
|
|
378
|
+
return { apply, inject, PANEL_ID, NS }
|
|
268
379
|
},
|
|
269
380
|
})
|
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/supervisor.js
CHANGED
|
@@ -320,14 +320,16 @@ export class Supervisor {
|
|
|
320
320
|
this.#functionalProbe = probe.ok ? 'pass' : `fail:${probe.status ?? probe.error ?? 'unknown'}`
|
|
321
321
|
if (!probe.ok) {
|
|
322
322
|
this.#lastStdio = safeTail(handle) || this.#lastStdio
|
|
323
|
-
|
|
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.
|
|
324
326
|
this.#setState(
|
|
325
|
-
'
|
|
326
|
-
'
|
|
327
|
+
'degraded',
|
|
328
|
+
'probe-failed',
|
|
327
329
|
`经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
|
|
328
|
-
'
|
|
329
|
-
'确认链路正常后可设置 functionalProbe.enabled: false 只做 TCP 就绪检查。',
|
|
330
|
+
'进程保留、未接管流量;健康循环会继续重试。常见原因:uuid/flow/SNI/shortId/REALITY 公钥不一致,或探测目标不可达。',
|
|
330
331
|
)
|
|
332
|
+
this.#startHealthLoop(generation)
|
|
331
333
|
return
|
|
332
334
|
}
|
|
333
335
|
} else {
|
|
@@ -564,6 +566,44 @@ export class Supervisor {
|
|
|
564
566
|
this.#restartTimer.unref?.()
|
|
565
567
|
}
|
|
566
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
|
+
|
|
567
607
|
#startHealthLoop(generation) {
|
|
568
608
|
const interval = this.config.lifecycle.healthIntervalMs
|
|
569
609
|
if (!(interval > 0)) return
|
|
@@ -581,7 +621,11 @@ export class Supervisor {
|
|
|
581
621
|
if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
|
|
582
622
|
if (ok) {
|
|
583
623
|
this.#unhealthy = 0
|
|
584
|
-
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
|
+
}
|
|
585
629
|
} else {
|
|
586
630
|
this.#unhealthy++
|
|
587
631
|
if (this.#unhealthy >= this.config.lifecycle.unhealthyThreshold) {
|
|
@@ -650,13 +694,32 @@ export class Supervisor {
|
|
|
650
694
|
enabled: this.config.enabled,
|
|
651
695
|
takeover: this.config.takeover,
|
|
652
696
|
settingsSchema: this.#schemaLoaded,
|
|
653
|
-
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
|
+
})),
|
|
654
708
|
activeNode: this.#activeNode,
|
|
655
709
|
control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
|
|
656
710
|
generation: this.#generation,
|
|
657
711
|
restarts: this.#restarts,
|
|
658
712
|
port: this.config.inbound.port,
|
|
659
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,
|
|
660
723
|
binary: this.#binary
|
|
661
724
|
? { path: this.#binary.path, source: this.#binary.source, version: this.#version ?? null }
|
|
662
725
|
: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -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
|
+
|