@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 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 logger = createLogger(ctx)
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
- function GlobeIcon() {
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: 20,
15
- height: 20,
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
- const styles = {
31
- page: { maxWidth: 760, margin: '0 auto', padding: '28px 20px 48px', color: 'var(--dsh-text, #e2e8f0)' },
32
- title: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
33
- hint: { color: 'var(--dsh-text-secondary, #94a3b8)', fontSize: 13, margin: '0 0 20px' },
34
- card: { border: '1px solid var(--dsh-border, #334155)', borderRadius: 12, padding: 16, marginBottom: 16 },
35
- h2: { fontSize: 15, margin: '0 0 10px' },
36
- textarea: {
37
- width: '100%',
38
- boxSizing: 'border-box',
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 XrayPage() {
65
- const [text, setText] = React.useState('')
66
- const [status, setStatus] = React.useState('')
67
- const [statusKind, setStatusKind] = React.useState('')
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
- const say = (message, kind) => {
75
- setStatus(message)
76
- setStatusKind(kind || '')
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
- const detector = () => {
80
- if (detectorRef.current !== null) return detectorRef.current
81
- detectorRef.current = 'BarcodeDetector' in window ? new window.BarcodeDetector({ formats: ['qr_code'] }) : false
82
- return detectorRef.current
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
- const decodeCanvas = async (canvas) => {
86
- const det = detector()
87
- if (!det) return null
88
- const codes = await det.detect(canvas)
89
- return codes && codes.length > 0 ? codes[0].rawValue : null
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
- const accept = (value) => {
93
- const trimmed = String(value || '').trim()
94
- if (!trimmed) return false
95
- setText(trimmed)
96
- say('已识别:' + trimmed.slice(0, 64) + (trimmed.length > 64 ? '…' : ''), 'ok')
97
- return true
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 onFile = async (event) => {
101
- const file = event.target.files && event.target.files[0]
102
- if (!file) return
103
- say('正在解析图片…')
137
+ const switchTo = async (tag) => {
138
+ if (!tag || tag === status?.activeNode) return
139
+ setSwitching(true)
140
+ setMessage('')
104
141
  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)
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
- say('解析失败:' + error.message, 'error')
152
+ setMessage(String(error.message || error))
153
+ } finally {
154
+ setSwitching(false)
116
155
  }
117
156
  }
118
157
 
119
- const stop = async () => {
120
- if (timerRef.current) {
121
- clearInterval(timerRef.current)
122
- timerRef.current = null
123
- }
124
- if (streamRef.current) {
125
- for (const track of streamRef.current.getTracks()) track.stop()
126
- streamRef.current = null
127
- }
128
- if (videoRef.current) videoRef.current.srcObject = null
129
- setScanning(false)
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
- const tick = async () => {
133
- const video = videoRef.current
134
- if (!video || !streamRef.current) return
135
- if (video.readyState < 2 || !video.videoWidth) return
136
- const canvas = document.createElement('canvas')
137
- canvas.width = video.videoWidth
138
- canvas.height = video.videoHeight
139
- canvas.getContext('2d').drawImage(video, 0, 0)
140
- try {
141
- const value = await decodeCanvas(canvas)
142
- if (value) {
143
- accept(value)
144
- await stop()
145
- say('已识别并停止扫描。', 'ok')
146
- }
147
- } catch {
148
- // a failed frame is normal while the code is out of view
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
- const startScreen = async () => {
153
- if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
154
- say('当前浏览器不支持屏幕捕获;请改用「二维码图片」或 tools/scanner.html。', 'error')
155
- return
156
- }
157
- if (!detector()) {
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
- React.useEffect(() => () => {
180
- if (timerRef.current) clearInterval(timerRef.current)
181
- if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop()
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 copy = async () => {
185
- if (!text.trim()) {
186
- say('还没有链接可复制。', 'error')
187
- return
188
- }
293
+ const save = async () => {
294
+ setBusy(true)
189
295
  try {
190
- await navigator.clipboard.writeText(text.trim())
191
- say('已复制。粘贴到「插件 → Xray 代理 → 分享链接」后保存,并重启 DSH。', 'ok')
192
- } catch {
193
- say('复制失败,请手动选中文本复制。', 'error')
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
- { style: styles.page },
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: styles.textarea,
227
- value: text,
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) => setText(event.target.value),
230
- }),
231
- h(
232
- 'div',
233
- { style: styles.row },
234
- h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: copy }, '复制链接'),
235
- h('span', { style: { ...styles.status, marginTop: 0, color: statusKind === 'error' ? '#f87171' : statusKind === 'ok' ? '#4ade80' : undefined } }, status),
236
- ),
237
- h(
238
- 'ol',
239
- { style: styles.steps },
240
- h('li', null, '复制上面的 vless:// 链接;'),
241
- h('li', null, '打开 设置 → 插件 → Xray 代理,粘贴到「分享链接」并保存;'),
242
- h('li', null, '重启 DSH,状态变为 ready 后即生效。'),
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
- url: 'http://www.gstatic.com/generate_204',
83
- timeoutMs: 5000,
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
- await this.#killHandle(handle)
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
- 'failed',
326
- 'functional-probe-failed',
327
+ 'degraded',
328
+ 'probe-failed',
327
329
  `经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
328
- '常见原因:uuid/flow/SNI/shortId/REALITY 公钥与服务端不一致,或远程不可达。' +
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 === 'unhealthy') this.#setState('ready', 'health-recovered')
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) => ({ id: node.id, tag: node.tag, address: node.address, port: node.port })),
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.1",
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
+ })
@@ -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
+