@twinsearth/dsh-2xray 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/bin/README.md +27 -0
- package/cordis.patch.yml +37 -0
- package/docs/AC-0.md +60 -0
- package/docs/EVIDENCE.md +135 -0
- package/examples/config.downgrade.json +20 -0
- package/examples/config.example.json +22 -0
- package/examples/config.multi.downgrade.json +39 -0
- package/examples/config.multi.json +46 -0
- package/icon.svg +9 -0
- package/index.js +89 -0
- package/lib/binary.js +133 -0
- package/lib/config.js +401 -0
- package/lib/control/client.js +100 -0
- package/lib/control/grpc.js +121 -0
- package/lib/control/protobuf.js +143 -0
- package/lib/health.js +109 -0
- package/lib/log.js +20 -0
- package/lib/paths.js +65 -0
- package/lib/process.js +154 -0
- package/lib/route.js +299 -0
- package/lib/share-link.js +132 -0
- package/lib/supervisor.js +687 -0
- package/lib/tools.js +106 -0
- package/lib/xray-config.js +225 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
- package/scripts/check-config.mjs +56 -0
- package/scripts/parse-share-link.mjs +23 -0
- package/scripts/probe-routing.mjs +66 -0
- package/scripts/release-check.mjs +104 -0
- package/test/binary.test.js +103 -0
- package/test/config.test.js +106 -0
- package/test/control.test.js +166 -0
- package/test/health.test.js +75 -0
- package/test/index.test.js +52 -0
- package/test/multi-node.test.js +111 -0
- package/test/route.test.js +158 -0
- package/test/share-link.test.js +113 -0
- package/test/supervisor.test.js +525 -0
- package/test/tools.test.js +88 -0
- package/test/xray-config.test.js +132 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { rmSync } from 'node:fs'
|
|
3
|
+
import { buildXrayConfig } from './xray-config.js'
|
|
4
|
+
import { resolveBinary, probeVersion, tail } from './binary.js'
|
|
5
|
+
import { httpProxyProbe, portIsFree, waitForPort } from './health.js'
|
|
6
|
+
import { atomicWriteJson, sha256 } from './paths.js'
|
|
7
|
+
|
|
8
|
+
const CONFIG_REJECTED = 23
|
|
9
|
+
|
|
10
|
+
function safeTail(handle) {
|
|
11
|
+
try {
|
|
12
|
+
return handle?.stderrTail?.() ?? ''
|
|
13
|
+
} catch {
|
|
14
|
+
return ''
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatHost(host) {
|
|
19
|
+
return host.includes(':') ? `[${host}]` : host
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withTimeout(promise, ms) {
|
|
23
|
+
return raceTimeout(
|
|
24
|
+
Promise.resolve(promise)
|
|
25
|
+
.then(() => true)
|
|
26
|
+
.catch(() => false),
|
|
27
|
+
ms,
|
|
28
|
+
false,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Race a promise against a timer that is always cleared, so tests exit fast. */
|
|
33
|
+
function raceTimeout(promise, ms, timeoutValue) {
|
|
34
|
+
let timer
|
|
35
|
+
return Promise.race([
|
|
36
|
+
promise,
|
|
37
|
+
new Promise((resolve) => {
|
|
38
|
+
timer = setTimeout(() => resolve(timeoutValue), ms)
|
|
39
|
+
timer.unref?.()
|
|
40
|
+
}),
|
|
41
|
+
]).finally(() => clearTimeout(timer))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Owns the xray child lifecycle and the route decision for one plugin load.
|
|
46
|
+
*
|
|
47
|
+
* Invariants:
|
|
48
|
+
* A. The dispatcher is installed only after the inbound answered a TCP connect
|
|
49
|
+
* and (when enabled) a real proxied request; it is never pointed at a port
|
|
50
|
+
* that is not proven alive.
|
|
51
|
+
* B. Restore happens before terminate, so no window exists where the process
|
|
52
|
+
* is gone but the dispatcher still points at it.
|
|
53
|
+
* C. `stop()` waits for an in-flight `start()`, so a spawn cannot land after
|
|
54
|
+
* teardown and leak a process that owns the port.
|
|
55
|
+
* D. Every self-owned promise chain is caught; an unhandled rejection would
|
|
56
|
+
* dispose the whole harness process.
|
|
57
|
+
*/
|
|
58
|
+
export class Supervisor {
|
|
59
|
+
#state = 'idle'
|
|
60
|
+
#reason
|
|
61
|
+
#detail
|
|
62
|
+
#generation = 0
|
|
63
|
+
#restarts = 0
|
|
64
|
+
#handle
|
|
65
|
+
#startTask
|
|
66
|
+
#stopRequested = false
|
|
67
|
+
#stopped = false
|
|
68
|
+
#abort = new AbortController()
|
|
69
|
+
#restartTimer
|
|
70
|
+
#healthTimer
|
|
71
|
+
#unhealthy = 0
|
|
72
|
+
#binary
|
|
73
|
+
#version
|
|
74
|
+
#configHash
|
|
75
|
+
#functionalProbe = 'pending'
|
|
76
|
+
#lastExit
|
|
77
|
+
#lastStdio = ''
|
|
78
|
+
#startedAt
|
|
79
|
+
#updatedAt = Date.now()
|
|
80
|
+
#snapshotCache
|
|
81
|
+
#publishChain = Promise.resolve()
|
|
82
|
+
#logger
|
|
83
|
+
#route
|
|
84
|
+
#launcher
|
|
85
|
+
#probes
|
|
86
|
+
#resolveBinaryFn
|
|
87
|
+
#control
|
|
88
|
+
#activeNode
|
|
89
|
+
#now
|
|
90
|
+
|
|
91
|
+
constructor({
|
|
92
|
+
config,
|
|
93
|
+
errors = [],
|
|
94
|
+
warnings = [],
|
|
95
|
+
launcher,
|
|
96
|
+
route,
|
|
97
|
+
logger,
|
|
98
|
+
dataDir,
|
|
99
|
+
statusPath,
|
|
100
|
+
probes = {},
|
|
101
|
+
resolveBinaryFn = resolveBinary,
|
|
102
|
+
control,
|
|
103
|
+
now = () => Date.now(),
|
|
104
|
+
}) {
|
|
105
|
+
this.config = config
|
|
106
|
+
this.errors = errors
|
|
107
|
+
this.warnings = warnings
|
|
108
|
+
this.#launcher = launcher
|
|
109
|
+
this.#route = route
|
|
110
|
+
this.#control = control
|
|
111
|
+
this.#activeNode = config.activeNode
|
|
112
|
+
this.#logger = logger
|
|
113
|
+
this.dataDir = dataDir
|
|
114
|
+
this.statusPath = statusPath ?? (dataDir ? join(dataDir, 'status.json') : undefined)
|
|
115
|
+
this.#probes = {
|
|
116
|
+
waitForPort,
|
|
117
|
+
httpProxyProbe,
|
|
118
|
+
portIsFree,
|
|
119
|
+
...probes,
|
|
120
|
+
}
|
|
121
|
+
this.#resolveBinaryFn = resolveBinaryFn
|
|
122
|
+
this.#now = now
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
snapshot() {
|
|
126
|
+
if (!this.#snapshotCache) this.#snapshotCache = this.#buildSnapshot()
|
|
127
|
+
return this.#snapshotCache
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Wait until a predicate over the latest snapshot holds, for tests and tools. */
|
|
131
|
+
waitFor(predicate, { timeoutMs = 10_000, intervalMs = 20 } = {}) {
|
|
132
|
+
const immediate = predicate(this.snapshot())
|
|
133
|
+
if (immediate) return Promise.resolve(this.snapshot())
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const started = this.#now()
|
|
136
|
+
const timer = setInterval(() => {
|
|
137
|
+
const snap = this.snapshot()
|
|
138
|
+
if (predicate(snap)) {
|
|
139
|
+
clearInterval(timer)
|
|
140
|
+
resolve(snap)
|
|
141
|
+
} else if (this.#now() - started > timeoutMs) {
|
|
142
|
+
clearInterval(timer)
|
|
143
|
+
reject(new Error(`waitFor timeout; state=${snap.state} reason=${snap.reason ?? 'none'}`))
|
|
144
|
+
}
|
|
145
|
+
}, intervalMs)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async start() {
|
|
150
|
+
if (this.#stopped) return this.snapshot()
|
|
151
|
+
if (this.#startTask) return this.#startTask
|
|
152
|
+
this.#startedAt ??= this.#now()
|
|
153
|
+
this.#startTask = this.#run()
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
this.#setState('failed', 'internal-error', String(error))
|
|
156
|
+
})
|
|
157
|
+
.finally(() => {
|
|
158
|
+
this.#startTask = undefined
|
|
159
|
+
})
|
|
160
|
+
return this.#startTask
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async stop() {
|
|
164
|
+
if (this.#stopped) return
|
|
165
|
+
this.#stopped = true
|
|
166
|
+
this.#stopRequested = true
|
|
167
|
+
this.#abort.abort()
|
|
168
|
+
this.#stopHealthLoop()
|
|
169
|
+
if (this.#restartTimer) {
|
|
170
|
+
clearTimeout(this.#restartTimer)
|
|
171
|
+
this.#restartTimer = undefined
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await this.#startTask
|
|
175
|
+
} catch {
|
|
176
|
+
// start() never rejects; belt and braces
|
|
177
|
+
}
|
|
178
|
+
// Invariant B: restore the dispatcher before the process disappears.
|
|
179
|
+
await this.#route.restore().catch((error) => this.#logger?.warn?.(`恢复路由失败:${String(error)}`))
|
|
180
|
+
const handle = this.#handle
|
|
181
|
+
this.#handle = undefined
|
|
182
|
+
if (handle) {
|
|
183
|
+
await handle.terminate().catch(() => {})
|
|
184
|
+
const exited = await withTimeout(handle.waitForExit(), Math.max(1000, this.config.lifecycle.stopGraceMs + 1000))
|
|
185
|
+
if (!exited) {
|
|
186
|
+
this.#logger?.warn?.('xray 子进程未在 stopGraceMs 内退出,交由服务/Job Object 兜底')
|
|
187
|
+
this.#lastStdio = safeTail(handle) || this.#lastStdio
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.#setState('stopped', 'disposed')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async #run() {
|
|
194
|
+
const generation = ++this.#generation
|
|
195
|
+
const signal = this.#abort.signal
|
|
196
|
+
const lifecycle = this.config.lifecycle
|
|
197
|
+
|
|
198
|
+
if (!this.config.enabled) {
|
|
199
|
+
this.#setState('idle', 'disabled')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (this.errors.length > 0) {
|
|
203
|
+
this.#setState('failed', 'config-invalid', this.errors.map((error) => `${error.field}: ${error.message}`).join('; '))
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.#setState('resolving')
|
|
208
|
+
let binary
|
|
209
|
+
try {
|
|
210
|
+
binary = await this.#resolveBinaryFn({
|
|
211
|
+
config: this.config,
|
|
212
|
+
dataDir: this.dataDir,
|
|
213
|
+
launcher: this.#launcher,
|
|
214
|
+
})
|
|
215
|
+
} catch (error) {
|
|
216
|
+
this.#setState('failed', error.code ?? 'binary-missing', error.message)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
this.#binary = binary
|
|
220
|
+
if (signal.aborted) return
|
|
221
|
+
|
|
222
|
+
const version = await probeVersion({
|
|
223
|
+
launcher: this.#launcher,
|
|
224
|
+
binaryPath: binary.path,
|
|
225
|
+
timeoutMs: 5000,
|
|
226
|
+
}).catch((error) => ({ ok: false, detail: String(error) }))
|
|
227
|
+
if (!version.ok) {
|
|
228
|
+
this.#setState('failed', 'binary-unusable', version.detail)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
this.#version = version.version
|
|
232
|
+
if (signal.aborted) return
|
|
233
|
+
|
|
234
|
+
const xrayConfig = buildXrayConfig(this.config)
|
|
235
|
+
const text = JSON.stringify(xrayConfig, null, 2)
|
|
236
|
+
this.#configHash = sha256(text)
|
|
237
|
+
const configPath = join(this.dataDir, 'conf', 'xray-config.json')
|
|
238
|
+
try {
|
|
239
|
+
atomicWriteJson(configPath, xrayConfig, 0o600)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.#setState('failed', 'config-write-failed', String(error))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const host = this.config.inbound.host
|
|
246
|
+
const port = this.config.inbound.port
|
|
247
|
+
|
|
248
|
+
if (!(await this.#probes.portIsFree(host, port))) {
|
|
249
|
+
this.#setState('failed', 'port-busy', `127.0.0.1:${port} 已被占用;不会静默改端口,请释放端口或修改 inbound.port`)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (signal.aborted) return
|
|
253
|
+
|
|
254
|
+
const tested = await this.#runConfigTest(binary.path, configPath, generation)
|
|
255
|
+
if (tested.aborted) return
|
|
256
|
+
if (!tested.ok) {
|
|
257
|
+
this.#setState('failed', 'config-rejected', tail(tested.detail))
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (signal.aborted) return
|
|
261
|
+
|
|
262
|
+
this.#setState('starting')
|
|
263
|
+
let handle
|
|
264
|
+
try {
|
|
265
|
+
handle = this.#launcher.spawn({
|
|
266
|
+
argv: [binary.path, 'run', '-c', configPath],
|
|
267
|
+
cwd: this.dataDir,
|
|
268
|
+
graceMs: Math.max(500, lifecycle.stopGraceMs),
|
|
269
|
+
env: {},
|
|
270
|
+
})
|
|
271
|
+
} catch (error) {
|
|
272
|
+
this.#setState('failed', 'spawn-failed', String(error))
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
this.#handle = handle
|
|
276
|
+
this.#watch(handle, generation)
|
|
277
|
+
|
|
278
|
+
const readyOutcome = await Promise.race([
|
|
279
|
+
this.#probes
|
|
280
|
+
.waitForPort({
|
|
281
|
+
host,
|
|
282
|
+
port,
|
|
283
|
+
timeoutMs: lifecycle.startTimeoutMs,
|
|
284
|
+
signal,
|
|
285
|
+
})
|
|
286
|
+
.then((ok) => ({ ready: ok })),
|
|
287
|
+
// A process that exits before it ever listens must be classified by the
|
|
288
|
+
// exit watcher, not by a 15s port-poll timeout that would overwrite the
|
|
289
|
+
// real reason and swallow the retry.
|
|
290
|
+
handle.done.then(() => ({ exited: true })),
|
|
291
|
+
])
|
|
292
|
+
if (signal.aborted) {
|
|
293
|
+
await this.#killHandle(handle)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (readyOutcome.exited) return
|
|
297
|
+
if (!readyOutcome.ready) {
|
|
298
|
+
this.#lastStdio = safeTail(handle)
|
|
299
|
+
await this.#killHandle(handle)
|
|
300
|
+
if (generation !== this.#generation) return
|
|
301
|
+
this.#setState('failed', 'start-timeout', `端口 ${port} 在 ${lifecycle.startTimeoutMs}ms 内未监听;stderr: ${tail(this.#lastStdio)}`)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Functional probe: proves the upstream path, not just the listener.
|
|
306
|
+
if (this.config.functionalProbe.enabled) {
|
|
307
|
+
const probe = await this.#probes.httpProxyProbe({
|
|
308
|
+
host,
|
|
309
|
+
port,
|
|
310
|
+
url: this.config.functionalProbe.url,
|
|
311
|
+
timeoutMs: this.config.functionalProbe.timeoutMs,
|
|
312
|
+
})
|
|
313
|
+
if (signal.aborted) {
|
|
314
|
+
await this.#killHandle(handle)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
this.#functionalProbe = probe.ok ? 'pass' : `fail:${probe.status ?? probe.error ?? 'unknown'}`
|
|
318
|
+
if (!probe.ok) {
|
|
319
|
+
this.#lastStdio = safeTail(handle) || this.#lastStdio
|
|
320
|
+
await this.#killHandle(handle)
|
|
321
|
+
this.#setState(
|
|
322
|
+
'failed',
|
|
323
|
+
'functional-probe-failed',
|
|
324
|
+
`经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
|
|
325
|
+
'常见原因:uuid/flow/SNI/shortId/REALITY 公钥与服务端不一致,或远程不可达。' +
|
|
326
|
+
'确认链路正常后可设置 functionalProbe.enabled: false 只做 TCP 就绪检查。',
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
this.#functionalProbe = 'skipped'
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (this.config.takeover === 'off') {
|
|
335
|
+
this.#setState('ready', 'takeover-off', 'xray 已就绪,但 takeover=off,未接管全局流量')
|
|
336
|
+
} else {
|
|
337
|
+
try {
|
|
338
|
+
const scheme = this.config.inbound.protocol === 'socks' ? 'socks5' : 'http'
|
|
339
|
+
const result = await this.#route.install({
|
|
340
|
+
proxyUrl: `${scheme}://${formatHost(host)}:${port}`,
|
|
341
|
+
noProxy: this.config.noProxy,
|
|
342
|
+
force: this.config.takeover === 'force',
|
|
343
|
+
})
|
|
344
|
+
if (result.reason === 'external-proxy-active') {
|
|
345
|
+
this.#setState('degraded', 'external-proxy-active', '启动器已有代理策略;未接管。设置 takeover: force 可强制覆盖')
|
|
346
|
+
} else {
|
|
347
|
+
this.#setState('ready')
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// The process is healthy; only routing failed. Keep it running so a
|
|
351
|
+
// fixed config or a later retry can recover without a spawn storm.
|
|
352
|
+
this.#setState('degraded', error.code ?? 'route-install-failed', error.message)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await this.#applyActiveNode()
|
|
357
|
+
|
|
358
|
+
if (signal.aborted) {
|
|
359
|
+
await this.#killHandle(handle)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
this.#startHealthLoop(generation)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Pin the balancer to the configured node after (re)start. The override lives
|
|
367
|
+
* in the xray process, so a restart drops it and this re-applies it.
|
|
368
|
+
*/
|
|
369
|
+
async #applyActiveNode() {
|
|
370
|
+
if (!this.config.control.enabled || this.config.nodes.length <= 1 || !this.#control) return
|
|
371
|
+
try {
|
|
372
|
+
await this.#control.getBalancerInfo()
|
|
373
|
+
await this.#control.overrideBalancerTarget(this.#activeNode)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const hint =
|
|
376
|
+
String(error.code) === '12'
|
|
377
|
+
? '当前 xray 版本没有 RoutingService(本机实测 1.4.5 返回 UNIMPLEMENTED);请升级到 2024-09 之后的版本(v1.8.24+/v24.9+)'
|
|
378
|
+
: '出口暂由 balancer 的随机策略选择,切换工具不可用'
|
|
379
|
+
this.#logger?.warn?.(`控制面不可用(${error.code ?? error.message}):${hint}`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Runtime node switch through the control plane. The process, the local
|
|
385
|
+
* inbound and the installed dispatcher all stay untouched.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} nodeRef - node id or outbound tag.
|
|
388
|
+
* @returns {Promise<string>} the outbound tag now pinned.
|
|
389
|
+
*/
|
|
390
|
+
async switchNode(nodeRef) {
|
|
391
|
+
if (!this.config.control.enabled || !this.#control) {
|
|
392
|
+
throw new Error('control.enabled=false,无法运行时切换节点')
|
|
393
|
+
}
|
|
394
|
+
if (this.config.nodes.length <= 1) {
|
|
395
|
+
throw new Error('只有一个节点,没有可切换的 balancer')
|
|
396
|
+
}
|
|
397
|
+
const node = this.config.nodes.find((entry) => entry.id === nodeRef || entry.tag === nodeRef)
|
|
398
|
+
if (!node) {
|
|
399
|
+
throw new Error(`未知节点:${nodeRef};可用:${this.config.nodes.map((entry) => entry.tag).join(', ')}`)
|
|
400
|
+
}
|
|
401
|
+
await this.#control.overrideBalancerTarget(node.tag)
|
|
402
|
+
this.#activeNode = node.tag
|
|
403
|
+
this.#publish()
|
|
404
|
+
return node.tag
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Read xray's cumulative outbound counters and fold them per node tag.
|
|
409
|
+
* StatsService counters are cumulative since process start, not a rate.
|
|
410
|
+
*/
|
|
411
|
+
async queryStats({ reset = false } = {}) {
|
|
412
|
+
if (!this.#control) throw new Error('control.enabled=false,无统计可用')
|
|
413
|
+
const stats = await this.#control.queryStats({ patterns: ['outbound>>>'], reset })
|
|
414
|
+
const byTag = {}
|
|
415
|
+
for (const { name, value } of stats) {
|
|
416
|
+
const match = /^outbound>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(name)
|
|
417
|
+
if (!match) continue
|
|
418
|
+
byTag[match[1]] ??= { uplink: 0, downlink: 0 }
|
|
419
|
+
byTag[match[1]][match[2]] += value
|
|
420
|
+
}
|
|
421
|
+
return byTag
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async #runConfigTest(binaryPath, configPath, generation) {
|
|
425
|
+
if (this.#abort.signal.aborted || generation !== this.#generation) return { aborted: true }
|
|
426
|
+
const outcome = await this.#runTestOnce(binaryPath, configPath)
|
|
427
|
+
if (this.#abort.signal.aborted || generation !== this.#generation) return { aborted: true }
|
|
428
|
+
return outcome
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Spawn `xray run -test -c <file>` once and report its exit facts. Never
|
|
433
|
+
* binds a port: this is the safe pre-flight both startup and the
|
|
434
|
+
* `xray_validate_config` tool use.
|
|
435
|
+
*/
|
|
436
|
+
async #runTestOnce(binaryPath, configPath) {
|
|
437
|
+
let handle
|
|
438
|
+
try {
|
|
439
|
+
handle = this.#launcher.spawn({
|
|
440
|
+
argv: [binaryPath, 'run', '-test', '-c', configPath],
|
|
441
|
+
cwd: this.dataDir ?? process.cwd(),
|
|
442
|
+
graceMs: 2000,
|
|
443
|
+
env: {},
|
|
444
|
+
})
|
|
445
|
+
} catch (error) {
|
|
446
|
+
return { ok: false, detail: String(error) }
|
|
447
|
+
}
|
|
448
|
+
const outcome = await raceTimeout(handle.done, 10_000, { timedOut: true })
|
|
449
|
+
if (outcome?.timedOut) {
|
|
450
|
+
await handle.terminate()
|
|
451
|
+
return { ok: false, detail: 'xray run -test 超时' }
|
|
452
|
+
}
|
|
453
|
+
return { ok: outcome.exitCode === 0, detail: `${handle.stdoutTail?.() ?? ''}\n${handle.stderrTail?.() ?? ''}` }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Validate the config built from the *current* settings without starting or
|
|
458
|
+
* restarting the proxy. Safe to call while `ready`.
|
|
459
|
+
*
|
|
460
|
+
* @returns {Promise<{ ok: boolean, detail: string, configHash: string }>}
|
|
461
|
+
*/
|
|
462
|
+
async validateConfig() {
|
|
463
|
+
// Field-level problems are known before spawning anything; report them in
|
|
464
|
+
// the same shape so the model gets one contract for every failure.
|
|
465
|
+
if (this.errors?.length > 0) {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: 'config-invalid',
|
|
469
|
+
detail: this.errors.map((error) => `${error.field}: ${error.message}`).join('; '),
|
|
470
|
+
configHash: null,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!this.dataDir) {
|
|
474
|
+
return { ok: false, reason: 'data-dir-unavailable', detail: '数据目录不可用,无法写入待校验的配置', configHash: null }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let binary
|
|
478
|
+
try {
|
|
479
|
+
binary =
|
|
480
|
+
this.#binary ??
|
|
481
|
+
(await this.#resolveBinaryFn({ config: this.config, dataDir: this.dataDir, launcher: this.#launcher }))
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: error.code ?? 'binary-missing',
|
|
486
|
+
detail: error.message,
|
|
487
|
+
configHash: null,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const built = buildXrayConfig(this.config)
|
|
492
|
+
const text = JSON.stringify(built, null, 2)
|
|
493
|
+
const file = join(this.dataDir, 'conf', `validate-${Date.now()}-${process.pid}.json`)
|
|
494
|
+
atomicWriteJson(file, built, 0o600)
|
|
495
|
+
try {
|
|
496
|
+
const outcome = await this.#runTestOnce(binary.path, file)
|
|
497
|
+
return { ok: outcome.ok, reason: outcome.ok ? null : 'config-rejected', detail: tail(outcome.detail), configHash: sha256(text) }
|
|
498
|
+
} finally {
|
|
499
|
+
try {
|
|
500
|
+
rmSync(file, { force: true })
|
|
501
|
+
} catch {
|
|
502
|
+
// best effort
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#watch(handle, generation) {
|
|
508
|
+
handle.done
|
|
509
|
+
.then((outcome) => this.#onExit(handle, generation, outcome))
|
|
510
|
+
.catch((error) => this.#onExit(handle, generation, { error }))
|
|
511
|
+
// The watcher itself must never produce an unhandled rejection: that
|
|
512
|
+
// would dispose the whole harness process.
|
|
513
|
+
.catch((error) => this.#logger?.error?.(`处理 xray 退出时异常:${String(error)}`))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async #onExit(handle, generation, outcome) {
|
|
517
|
+
if (this.#stopRequested) return
|
|
518
|
+
if (generation !== this.#generation) return
|
|
519
|
+
this.#generation++
|
|
520
|
+
this.#stopHealthLoop()
|
|
521
|
+
if (this.#handle === handle) this.#handle = undefined
|
|
522
|
+
const stderr = safeTail(handle) || this.#lastStdio
|
|
523
|
+
this.#lastStdio = stderr
|
|
524
|
+
this.#lastExit = { exitCode: outcome?.exitCode ?? null, signal: outcome?.signal ?? null, at: this.#now() }
|
|
525
|
+
await this.#route.restore().catch(() => {})
|
|
526
|
+
|
|
527
|
+
if (outcome?.exitCode === CONFIG_REJECTED) {
|
|
528
|
+
this.#setState('failed', 'config-rejected', tail(stderr) || 'xray 以退出码 23 退出(配置错误),不会自动重启')
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
if (/bind|address already in use|only one usage of each socket address/i.test(stderr)) {
|
|
532
|
+
this.#setState('failed', 'port-busy', tail(stderr))
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
this.#scheduleRestart('crashed', stderr)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#scheduleRestart(reason, detail) {
|
|
539
|
+
if (this.#stopRequested || this.#stopped) return
|
|
540
|
+
const lifecycle = this.config.lifecycle
|
|
541
|
+
if (this.#restarts >= lifecycle.maxRestarts) {
|
|
542
|
+
this.#setState('failed', 'restart-exhausted', `连续重启 ${this.#restarts} 次仍未恢复;最后一次:${tail(detail)}`)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
this.#restarts++
|
|
546
|
+
const base = Math.min(lifecycle.backoffMs * 2 ** (this.#restarts - 1), lifecycle.backoffMaxMs)
|
|
547
|
+
const delayMs = Math.round(base * (0.5 + Math.random() * 0.5))
|
|
548
|
+
this.#setState('backoff', reason, `第 ${this.#restarts}/${lifecycle.maxRestarts} 次重启,约 ${delayMs}ms 后`)
|
|
549
|
+
this.#restartTimer = setTimeout(() => {
|
|
550
|
+
this.#restartTimer = undefined
|
|
551
|
+
if (this.#stopRequested || this.#stopped) return
|
|
552
|
+
const startAgain = () => {
|
|
553
|
+
if (this.#stopRequested || this.#stopped || this.#startTask) return
|
|
554
|
+
this.start().catch((error) => this.#logger?.error?.(`重启 xray 失败:${String(error)}`))
|
|
555
|
+
}
|
|
556
|
+
// The failed run may still be unwinding (for example a start-timeout
|
|
557
|
+
// poll); wait for its task to settle before spawning a second process.
|
|
558
|
+
if (this.#startTask) this.#startTask.then(startAgain, startAgain)
|
|
559
|
+
else startAgain()
|
|
560
|
+
}, delayMs)
|
|
561
|
+
this.#restartTimer.unref?.()
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
#startHealthLoop(generation) {
|
|
565
|
+
const interval = this.config.lifecycle.healthIntervalMs
|
|
566
|
+
if (!(interval > 0)) return
|
|
567
|
+
this.#stopHealthLoop()
|
|
568
|
+
const tick = async () => {
|
|
569
|
+
this.#healthTimer = undefined
|
|
570
|
+
if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
|
|
571
|
+
try {
|
|
572
|
+
const ok = await this.#probes.waitForPort({
|
|
573
|
+
host: this.config.inbound.host,
|
|
574
|
+
port: this.config.inbound.port,
|
|
575
|
+
timeoutMs: Math.max(200, this.config.lifecycle.probeTimeoutMs),
|
|
576
|
+
signal: this.#abort.signal,
|
|
577
|
+
})
|
|
578
|
+
if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
|
|
579
|
+
if (ok) {
|
|
580
|
+
this.#unhealthy = 0
|
|
581
|
+
if (this.#state === 'degraded' && this.#reason === 'unhealthy') this.#setState('ready', 'health-recovered')
|
|
582
|
+
} else {
|
|
583
|
+
this.#unhealthy++
|
|
584
|
+
if (this.#unhealthy >= this.config.lifecycle.unhealthyThreshold) {
|
|
585
|
+
await this.#recycle('unhealthy')
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
this.#logger?.warn?.(`xray 健康探测失败 ${this.#unhealthy}/${this.config.lifecycle.unhealthyThreshold}`)
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
this.#logger?.warn?.(`健康探测异常:${String(error)}`)
|
|
592
|
+
}
|
|
593
|
+
if (this.#stopRequested || this.#stopped) return
|
|
594
|
+
this.#healthTimer = setTimeout(tick, interval)
|
|
595
|
+
this.#healthTimer.unref?.()
|
|
596
|
+
}
|
|
597
|
+
this.#healthTimer = setTimeout(tick, interval)
|
|
598
|
+
this.#healthTimer.unref?.()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#stopHealthLoop() {
|
|
602
|
+
if (this.#healthTimer) {
|
|
603
|
+
clearTimeout(this.#healthTimer)
|
|
604
|
+
this.#healthTimer = undefined
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async #recycle(reason) {
|
|
609
|
+
const generation = ++this.#generation
|
|
610
|
+
this.#stopHealthLoop()
|
|
611
|
+
await this.#route.restore().catch(() => {})
|
|
612
|
+
const handle = this.#handle
|
|
613
|
+
this.#handle = undefined
|
|
614
|
+
this.#lastStdio = safeTail(handle) || this.#lastStdio
|
|
615
|
+
if (handle) await this.#killHandle(handle)
|
|
616
|
+
if (this.#stopRequested) return
|
|
617
|
+
this.#logger?.warn?.(`xray 连续健康探测失败,回收进程(generation=${generation})`)
|
|
618
|
+
this.#scheduleRestart(reason, this.#lastStdio)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async #killHandle(handle) {
|
|
622
|
+
if (!handle) return
|
|
623
|
+
await handle.terminate().catch(() => {})
|
|
624
|
+
await withTimeout(handle.waitForExit(), Math.max(1000, this.config.lifecycle.stopGraceMs))
|
|
625
|
+
if (this.#handle === handle) this.#handle = undefined
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
#setState(state, reason, detail) {
|
|
629
|
+
this.#state = state
|
|
630
|
+
this.#reason = reason
|
|
631
|
+
this.#detail = detail
|
|
632
|
+
this.#updatedAt = this.#now()
|
|
633
|
+
const line = `state=${state}${reason ? ` reason=${reason}` : ''}${detail ? ` detail=${detail}` : ''}`
|
|
634
|
+
if (state === 'failed') this.#logger?.error?.(line)
|
|
635
|
+
else if (state === 'degraded' || state === 'backoff') this.#logger?.warn?.(line)
|
|
636
|
+
else this.#logger?.info?.(line)
|
|
637
|
+
this.#publish()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#buildSnapshot() {
|
|
641
|
+
const handle = this.#handle
|
|
642
|
+
return {
|
|
643
|
+
plugin: 'dsh-2xray',
|
|
644
|
+
state: this.#state,
|
|
645
|
+
reason: this.#reason ?? null,
|
|
646
|
+
detail: this.#detail ?? null,
|
|
647
|
+
enabled: this.config.enabled,
|
|
648
|
+
takeover: this.config.takeover,
|
|
649
|
+
nodes: this.config.nodes.map((node) => ({ id: node.id, tag: node.tag, address: node.address, port: node.port })),
|
|
650
|
+
activeNode: this.#activeNode,
|
|
651
|
+
control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
|
|
652
|
+
generation: this.#generation,
|
|
653
|
+
restarts: this.#restarts,
|
|
654
|
+
port: this.config.inbound.port,
|
|
655
|
+
portSource: 'config',
|
|
656
|
+
binary: this.#binary
|
|
657
|
+
? { path: this.#binary.path, source: this.#binary.source, version: this.#version ?? null }
|
|
658
|
+
: null,
|
|
659
|
+
process: {
|
|
660
|
+
pid: handle?.pid ?? null,
|
|
661
|
+
alive: Boolean(handle),
|
|
662
|
+
exitCode: this.#lastExit?.exitCode ?? null,
|
|
663
|
+
stderrTail: tail(this.#lastStdio, 2000),
|
|
664
|
+
},
|
|
665
|
+
route: this.#route?.snapshot?.() ?? { installed: false, mode: 'none' },
|
|
666
|
+
functionalProbe: this.#functionalProbe,
|
|
667
|
+
configHash: this.#configHash ?? null,
|
|
668
|
+
startedAt: this.#startedAt ?? null,
|
|
669
|
+
updatedAt: this.#updatedAt,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
#publish() {
|
|
674
|
+
this.#snapshotCache = this.#buildSnapshot()
|
|
675
|
+
const snapshot = this.#snapshotCache
|
|
676
|
+
if (!this.statusPath) return
|
|
677
|
+
this.#publishChain = this.#publishChain
|
|
678
|
+
.then(() => {
|
|
679
|
+
try {
|
|
680
|
+
atomicWriteJson(this.statusPath, snapshot, 0o600)
|
|
681
|
+
} catch (error) {
|
|
682
|
+
this.#logger?.warn?.(`写入 status.json 失败:${String(error)}`)
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
.catch(() => {})
|
|
686
|
+
}
|
|
687
|
+
}
|