@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,525 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { normalizeConfig } from '../lib/config.js'
|
|
7
|
+
import { Supervisor } from '../lib/supervisor.js'
|
|
8
|
+
import { BinaryError } from '../lib/binary.js'
|
|
9
|
+
|
|
10
|
+
const silent = { info() {}, warn() {}, error() {}, debug() {} }
|
|
11
|
+
|
|
12
|
+
function makeConfig(overrides = {}) {
|
|
13
|
+
return normalizeConfig({
|
|
14
|
+
enabled: true,
|
|
15
|
+
server: {
|
|
16
|
+
address: 'example.com',
|
|
17
|
+
port: 443,
|
|
18
|
+
uuid: '11111111-2222-4333-8444-555555555555',
|
|
19
|
+
serverName: 'www.example.com',
|
|
20
|
+
password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
21
|
+
shortId: 'ab12',
|
|
22
|
+
},
|
|
23
|
+
inbound: { host: '127.0.0.1', port: 20808 },
|
|
24
|
+
functionalProbe: { enabled: false },
|
|
25
|
+
lifecycle: {
|
|
26
|
+
startTimeoutMs: 2000,
|
|
27
|
+
stopGraceMs: 500,
|
|
28
|
+
maxRestarts: 2,
|
|
29
|
+
backoffMs: 25,
|
|
30
|
+
backoffMaxMs: 50,
|
|
31
|
+
healthIntervalMs: 0,
|
|
32
|
+
unhealthyThreshold: 3,
|
|
33
|
+
probeTimeoutMs: 300,
|
|
34
|
+
},
|
|
35
|
+
...overrides,
|
|
36
|
+
}).config
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FakeProc {
|
|
40
|
+
constructor(log) {
|
|
41
|
+
this.log = log
|
|
42
|
+
this.stderr = ''
|
|
43
|
+
this.stdout = ''
|
|
44
|
+
this.settled = false
|
|
45
|
+
this.done = new Promise((resolve) => {
|
|
46
|
+
this._resolve = resolve
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
stdoutTail() {
|
|
50
|
+
return this.stdout
|
|
51
|
+
}
|
|
52
|
+
stderrTail() {
|
|
53
|
+
return this.stderr
|
|
54
|
+
}
|
|
55
|
+
terminate() {
|
|
56
|
+
this.log.push('terminate')
|
|
57
|
+
this.exit(0)
|
|
58
|
+
return Promise.resolve()
|
|
59
|
+
}
|
|
60
|
+
waitForExit() {
|
|
61
|
+
return this.done.then(() => {})
|
|
62
|
+
}
|
|
63
|
+
exit(code, stderr = '') {
|
|
64
|
+
if (this.settled) return
|
|
65
|
+
this.settled = true
|
|
66
|
+
this.stderr = stderr
|
|
67
|
+
this._resolve({ exitCode: code, signal: null })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeLauncher({ log = [], testExit = 0, runBehaviour } = {}) {
|
|
72
|
+
const runs = []
|
|
73
|
+
return {
|
|
74
|
+
kind: 'fake',
|
|
75
|
+
runs,
|
|
76
|
+
async resolveExecutable() {
|
|
77
|
+
return '/fake/xray'
|
|
78
|
+
},
|
|
79
|
+
spawn({ argv }) {
|
|
80
|
+
const proc = new FakeProc(log)
|
|
81
|
+
const args = argv.slice(1)
|
|
82
|
+
if (args[0] === 'version') {
|
|
83
|
+
proc.stdout = 'Xray 26.10.21 (go1.24.0)'
|
|
84
|
+
queueMicrotask(() => proc.exit(0))
|
|
85
|
+
return proc
|
|
86
|
+
}
|
|
87
|
+
if (args[0] === 'run' && args.includes('-test')) {
|
|
88
|
+
queueMicrotask(() => proc.exit(testExit, testExit ? 'failed to load config' : 'Configuration OK.'))
|
|
89
|
+
return proc
|
|
90
|
+
}
|
|
91
|
+
runs.push(proc)
|
|
92
|
+
log.push('spawn:run')
|
|
93
|
+
runBehaviour?.(proc, runs.length)
|
|
94
|
+
return proc
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeRoute(log = []) {
|
|
100
|
+
let installed = false
|
|
101
|
+
return {
|
|
102
|
+
installCalls: 0,
|
|
103
|
+
restoreCalls: 0,
|
|
104
|
+
lastProxyUrl: null,
|
|
105
|
+
async install({ proxyUrl } = {}) {
|
|
106
|
+
this.installCalls++
|
|
107
|
+
this.lastProxyUrl = proxyUrl
|
|
108
|
+
installed = true
|
|
109
|
+
log.push('route:install')
|
|
110
|
+
return { installed: true, mode: 'harness', proxy: proxyUrl, webFetchCovered: true, sentinel: 'test' }
|
|
111
|
+
},
|
|
112
|
+
async restore() {
|
|
113
|
+
if (!installed) return
|
|
114
|
+
installed = false
|
|
115
|
+
this.restoreCalls++
|
|
116
|
+
log.push('route:restore')
|
|
117
|
+
},
|
|
118
|
+
snapshot() {
|
|
119
|
+
return { installed, mode: installed ? 'harness' : 'none' }
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readyProbes(log = []) {
|
|
125
|
+
return {
|
|
126
|
+
async portIsFree() {
|
|
127
|
+
return true
|
|
128
|
+
},
|
|
129
|
+
waitForPort({ signal }) {
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
if (signal?.aborted) return resolve(false)
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
log.push('port:ready')
|
|
134
|
+
resolve(true)
|
|
135
|
+
}, 5)
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
async httpProxyProbe() {
|
|
139
|
+
return { ok: true, status: 204 }
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function abortableNeverReady({ signal }) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
if (signal?.aborted) return resolve(false)
|
|
147
|
+
signal?.addEventListener('abort', () => resolve(false), { once: true })
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function workdir() {
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-xray-test-'))
|
|
153
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function makeSupervisor({ config = makeConfig(), log = [], ...rest } = {}) {
|
|
157
|
+
const route = makeRoute(log)
|
|
158
|
+
const launcher = rest.launcher ?? makeLauncher({ log })
|
|
159
|
+
const supervisor = new Supervisor({
|
|
160
|
+
config,
|
|
161
|
+
errors: rest.errors ?? [],
|
|
162
|
+
launcher,
|
|
163
|
+
route,
|
|
164
|
+
control: rest.control,
|
|
165
|
+
logger: silent,
|
|
166
|
+
dataDir: rest.dataDir,
|
|
167
|
+
probes: rest.probes ?? readyProbes(log),
|
|
168
|
+
resolveBinaryFn: rest.resolveBinaryFn ?? (async () => ({ path: '/fake/xray', source: 'config' })),
|
|
169
|
+
})
|
|
170
|
+
return { supervisor, route, launcher }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test('start resolves binary, spawns, waits for the port, then installs the route', async () => {
|
|
174
|
+
const { dir, cleanup } = workdir()
|
|
175
|
+
try {
|
|
176
|
+
const log = []
|
|
177
|
+
const { supervisor, route } = makeSupervisor({ log, dataDir: dir })
|
|
178
|
+
await supervisor.start()
|
|
179
|
+
const snap = await supervisor.waitFor((s) => s.state === 'ready')
|
|
180
|
+
assert.equal(snap.route.installed, true)
|
|
181
|
+
assert.equal(route.installCalls, 1)
|
|
182
|
+
assert.equal(snap.binary.version, '26.10.21')
|
|
183
|
+
assert.ok(log.indexOf('port:ready') !== -1 && log.indexOf('port:ready') < log.indexOf('route:install'))
|
|
184
|
+
assert.equal(snap.configHash.length, 64)
|
|
185
|
+
await supervisor.stop()
|
|
186
|
+
assert.equal(supervisor.snapshot().state, 'stopped')
|
|
187
|
+
} finally {
|
|
188
|
+
cleanup()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('a missing binary fails with binary-missing and never installs a route', async () => {
|
|
193
|
+
const { dir, cleanup } = workdir()
|
|
194
|
+
try {
|
|
195
|
+
const { supervisor, route } = makeSupervisor({
|
|
196
|
+
dataDir: dir,
|
|
197
|
+
resolveBinaryFn: async () => {
|
|
198
|
+
throw new BinaryError('no xray', 'binary-missing')
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
await supervisor.start()
|
|
202
|
+
const snap = await supervisor.waitFor((s) => s.state === 'failed')
|
|
203
|
+
assert.equal(snap.reason, 'binary-missing')
|
|
204
|
+
assert.equal(route.installCalls, 0)
|
|
205
|
+
} finally {
|
|
206
|
+
cleanup()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('a config-error exit (23) is terminal and does not restart', async () => {
|
|
211
|
+
const { dir, cleanup } = workdir()
|
|
212
|
+
try {
|
|
213
|
+
const log = []
|
|
214
|
+
const launcher = makeLauncher({
|
|
215
|
+
log,
|
|
216
|
+
runBehaviour(proc) {
|
|
217
|
+
queueMicrotask(() => proc.exit(23, 'failed to load config files'))
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, launcher, log })
|
|
221
|
+
await supervisor.start()
|
|
222
|
+
const snap = await supervisor.waitFor((s) => s.state === 'failed')
|
|
223
|
+
assert.equal(snap.reason, 'config-rejected')
|
|
224
|
+
assert.equal(supervisor.snapshot().restarts, 0)
|
|
225
|
+
assert.equal(launcher.runs.length, 1)
|
|
226
|
+
} finally {
|
|
227
|
+
cleanup()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('a crash after ready restores the route and restarts with backoff', async () => {
|
|
232
|
+
const { dir, cleanup } = workdir()
|
|
233
|
+
try {
|
|
234
|
+
const log = []
|
|
235
|
+
const launcher = makeLauncher({ log })
|
|
236
|
+
const { supervisor, route } = makeSupervisor({ dataDir: dir, launcher, log })
|
|
237
|
+
await supervisor.start()
|
|
238
|
+
await supervisor.waitFor((s) => s.state === 'ready')
|
|
239
|
+
launcher.runs[0].exit(1, 'boom')
|
|
240
|
+
await supervisor.waitFor((s) => s.state === 'ready' && s.restarts === 1, { timeoutMs: 5000 })
|
|
241
|
+
assert.equal(launcher.runs.length, 2)
|
|
242
|
+
assert.equal(route.restoreCalls, 1)
|
|
243
|
+
assert.equal(route.installCalls, 2)
|
|
244
|
+
await supervisor.stop()
|
|
245
|
+
} finally {
|
|
246
|
+
cleanup()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('a socks primary inbound is handed to the route as socks5://', async () => { const { dir, cleanup } = workdir()
|
|
251
|
+
try {
|
|
252
|
+
const log = []
|
|
253
|
+
const config = makeConfig({ inbound: { protocol: 'socks', host: '127.0.0.1', port: 20809 } })
|
|
254
|
+
const { supervisor, route } = makeSupervisor({ dataDir: dir, log, config })
|
|
255
|
+
await supervisor.start()
|
|
256
|
+
await supervisor.waitFor((s) => s.state === 'ready')
|
|
257
|
+
assert.equal(route.lastProxyUrl, 'socks5://127.0.0.1:20809')
|
|
258
|
+
await supervisor.stop()
|
|
259
|
+
} finally {
|
|
260
|
+
cleanup()
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('stop restores the route before terminating the process', async () => {
|
|
265
|
+
const { dir, cleanup } = workdir()
|
|
266
|
+
try {
|
|
267
|
+
const log = []
|
|
268
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, log })
|
|
269
|
+
await supervisor.start()
|
|
270
|
+
await supervisor.waitFor((s) => s.state === 'ready')
|
|
271
|
+
log.length = 0
|
|
272
|
+
await supervisor.stop()
|
|
273
|
+
assert.deepEqual(log, ['route:restore', 'terminate'])
|
|
274
|
+
} finally {
|
|
275
|
+
cleanup()
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('stop during startup aborts the wait and leaves no live handle', async () => {
|
|
280
|
+
const { dir, cleanup } = workdir()
|
|
281
|
+
try {
|
|
282
|
+
const log = []
|
|
283
|
+
const { supervisor } = makeSupervisor({
|
|
284
|
+
dataDir: dir,
|
|
285
|
+
log,
|
|
286
|
+
probes: {
|
|
287
|
+
async portIsFree() {
|
|
288
|
+
return true
|
|
289
|
+
},
|
|
290
|
+
waitForPort: abortableNeverReady,
|
|
291
|
+
async httpProxyProbe() {
|
|
292
|
+
return { ok: true }
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
const started = supervisor.start()
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
298
|
+
await supervisor.stop()
|
|
299
|
+
await started
|
|
300
|
+
const snap = supervisor.snapshot()
|
|
301
|
+
assert.equal(snap.state, 'stopped')
|
|
302
|
+
assert.equal(snap.process.alive, false)
|
|
303
|
+
assert.ok(log.includes('terminate'))
|
|
304
|
+
} finally {
|
|
305
|
+
cleanup()
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('a busy port is a terminal failure with a readable reason', async () => {
|
|
310
|
+
const { dir, cleanup } = workdir()
|
|
311
|
+
try {
|
|
312
|
+
const { supervisor, route } = makeSupervisor({
|
|
313
|
+
dataDir: dir,
|
|
314
|
+
probes: {
|
|
315
|
+
async portIsFree() {
|
|
316
|
+
return false
|
|
317
|
+
},
|
|
318
|
+
waitForPort: abortableNeverReady,
|
|
319
|
+
async httpProxyProbe() {
|
|
320
|
+
return { ok: true }
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
await supervisor.start()
|
|
325
|
+
const snap = await supervisor.waitFor((s) => s.state === 'failed')
|
|
326
|
+
assert.equal(snap.reason, 'port-busy')
|
|
327
|
+
assert.equal(route.installCalls, 0)
|
|
328
|
+
} finally {
|
|
329
|
+
cleanup()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('restart is capped by maxRestarts', async () => {
|
|
334
|
+
const { dir, cleanup } = workdir()
|
|
335
|
+
try {
|
|
336
|
+
const log = []
|
|
337
|
+
const launcher = makeLauncher({
|
|
338
|
+
log,
|
|
339
|
+
runBehaviour(proc) {
|
|
340
|
+
// crash every instance shortly after it starts
|
|
341
|
+
setTimeout(() => proc.exit(1, 'boom'), 5)
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, launcher, log })
|
|
345
|
+
await supervisor.start()
|
|
346
|
+
const snap = await supervisor.waitFor((s) => s.state === 'failed' && s.reason === 'restart-exhausted', {
|
|
347
|
+
timeoutMs: 5000,
|
|
348
|
+
})
|
|
349
|
+
assert.equal(snap.restarts, 2)
|
|
350
|
+
} finally {
|
|
351
|
+
cleanup()
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
function fakeControl() {
|
|
356
|
+
return {
|
|
357
|
+
calls: [],
|
|
358
|
+
async getBalancerInfo() {
|
|
359
|
+
return { overrideTarget: undefined, principleTargets: [] }
|
|
360
|
+
},
|
|
361
|
+
async overrideBalancerTarget(tag) {
|
|
362
|
+
this.calls.push(tag)
|
|
363
|
+
return tag
|
|
364
|
+
},
|
|
365
|
+
snapshot() {
|
|
366
|
+
return { reachable: true, activeTarget: this.calls.at(-1) ?? null, lastError: null }
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function multiConfig() {
|
|
372
|
+
const nodes = ['hk-01', 'jp-01'].map((id) => ({
|
|
373
|
+
id,
|
|
374
|
+
address: `${id}.example.com`,
|
|
375
|
+
port: 443,
|
|
376
|
+
uuid: '11111111-2222-4333-8444-555555555555',
|
|
377
|
+
serverName: 'www.example.com',
|
|
378
|
+
password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
379
|
+
shortId: 'ab12',
|
|
380
|
+
}))
|
|
381
|
+
return normalizeConfig({
|
|
382
|
+
enabled: true,
|
|
383
|
+
nodes,
|
|
384
|
+
control: { enabled: true, port: 10085 },
|
|
385
|
+
inbound: { host: '127.0.0.1', port: 20808 },
|
|
386
|
+
functionalProbe: { enabled: false },
|
|
387
|
+
lifecycle: {
|
|
388
|
+
startTimeoutMs: 2000,
|
|
389
|
+
stopGraceMs: 500,
|
|
390
|
+
maxRestarts: 1,
|
|
391
|
+
backoffMs: 25,
|
|
392
|
+
backoffMaxMs: 50,
|
|
393
|
+
healthIntervalMs: 0,
|
|
394
|
+
unhealthyThreshold: 3,
|
|
395
|
+
probeTimeoutMs: 300,
|
|
396
|
+
},
|
|
397
|
+
}).config
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test('startup pins the balancer to the configured active node', async () => {
|
|
401
|
+
const { dir, cleanup } = workdir()
|
|
402
|
+
try {
|
|
403
|
+
const control = fakeControl()
|
|
404
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, config: multiConfig(), control })
|
|
405
|
+
await supervisor.start()
|
|
406
|
+
const snap = await supervisor.waitFor((s) => s.state === 'ready')
|
|
407
|
+
assert.equal(snap.activeNode, 'proxy-hk-01')
|
|
408
|
+
assert.deepEqual(control.calls, ['proxy-hk-01'])
|
|
409
|
+
await supervisor.stop()
|
|
410
|
+
} finally {
|
|
411
|
+
cleanup()
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('switchNode pins another outbound without restarting the process', async () => {
|
|
416
|
+
const { dir, cleanup } = workdir()
|
|
417
|
+
try {
|
|
418
|
+
const control = fakeControl()
|
|
419
|
+
const { supervisor, launcher } = makeSupervisor({ dataDir: dir, config: multiConfig(), control })
|
|
420
|
+
await supervisor.start()
|
|
421
|
+
await supervisor.waitFor((s) => s.state === 'ready')
|
|
422
|
+
const runsBefore = launcher.runs.length
|
|
423
|
+
|
|
424
|
+
assert.equal(await supervisor.switchNode('jp-01'), 'proxy-jp-01')
|
|
425
|
+
assert.equal(supervisor.snapshot().activeNode, 'proxy-jp-01')
|
|
426
|
+
assert.deepEqual(control.calls, ['proxy-hk-01', 'proxy-jp-01'])
|
|
427
|
+
assert.equal(launcher.runs.length, runsBefore)
|
|
428
|
+
await supervisor.stop()
|
|
429
|
+
} finally {
|
|
430
|
+
cleanup()
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('switchNode rejects unknown nodes and a disabled control plane', async () => {
|
|
435
|
+
const { dir, cleanup } = workdir()
|
|
436
|
+
try {
|
|
437
|
+
const control = fakeControl()
|
|
438
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, config: multiConfig(), control })
|
|
439
|
+
await supervisor.start()
|
|
440
|
+
await supervisor.waitFor((s) => s.state === 'ready')
|
|
441
|
+
await assert.rejects(() => supervisor.switchNode('nope'), /未知节点/)
|
|
442
|
+
await supervisor.stop()
|
|
443
|
+
} finally {
|
|
444
|
+
cleanup()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const disabled = makeSupervisor({ dataDir: undefined, config: makeConfig() }).supervisor
|
|
448
|
+
await assert.rejects(() => disabled.switchNode('proxy'), /control\.enabled=false/)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('a single-node config has no balancer to switch', async () => {
|
|
452
|
+
const config = makeConfig({ control: { enabled: true } })
|
|
453
|
+
const { supervisor } = makeSupervisor({ config, control: fakeControl() })
|
|
454
|
+
await assert.rejects(() => supervisor.switchNode('proxy'), /只有一个节点/)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('validateConfig runs xray -test without touching the running process', async () => {
|
|
458
|
+
const { dir, cleanup } = workdir()
|
|
459
|
+
try {
|
|
460
|
+
const launcher = makeLauncher({ testExit: 0 })
|
|
461
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, launcher })
|
|
462
|
+
const result = await supervisor.validateConfig()
|
|
463
|
+
assert.equal(result.ok, true)
|
|
464
|
+
assert.match(result.detail, /Configuration OK/)
|
|
465
|
+
assert.equal(result.configHash.length, 64)
|
|
466
|
+
assert.equal(supervisor.snapshot().state, 'idle')
|
|
467
|
+
assert.equal(launcher.runs.length, 0)
|
|
468
|
+
const { readdirSync } = await import('node:fs')
|
|
469
|
+
assert.equal(readdirSync(join(dir, 'conf')).some((name) => name.startsWith('validate-')), false)
|
|
470
|
+
} finally {
|
|
471
|
+
cleanup()
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('validateConfig reports a rejected config', async () => {
|
|
476
|
+
const { dir, cleanup } = workdir()
|
|
477
|
+
try {
|
|
478
|
+
const launcher = makeLauncher({ testExit: 23 })
|
|
479
|
+
const { supervisor } = makeSupervisor({ dataDir: dir, launcher })
|
|
480
|
+
const result = await supervisor.validateConfig()
|
|
481
|
+
assert.equal(result.ok, false)
|
|
482
|
+
assert.equal(result.reason, 'config-rejected')
|
|
483
|
+
assert.match(result.detail, /failed to load config/)
|
|
484
|
+
} finally {
|
|
485
|
+
cleanup()
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('validateConfig reports field-level errors without spawning', async () => {
|
|
490
|
+
const { dir, cleanup } = workdir()
|
|
491
|
+
try {
|
|
492
|
+
const launcher = makeLauncher()
|
|
493
|
+
const { supervisor } = makeSupervisor({
|
|
494
|
+
dataDir: dir,
|
|
495
|
+
launcher,
|
|
496
|
+
errors: [{ field: 'server.address', message: '服务端地址不能为空' }],
|
|
497
|
+
})
|
|
498
|
+
const result = await supervisor.validateConfig()
|
|
499
|
+
assert.equal(result.ok, false)
|
|
500
|
+
assert.equal(result.reason, 'config-invalid')
|
|
501
|
+
assert.match(result.detail, /server\.address/)
|
|
502
|
+
assert.equal(launcher.runs.length, 0)
|
|
503
|
+
} finally {
|
|
504
|
+
cleanup()
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('validateConfig reports a missing binary instead of throwing', async () => {
|
|
509
|
+
const { dir, cleanup } = workdir()
|
|
510
|
+
try {
|
|
511
|
+
const { supervisor } = makeSupervisor({
|
|
512
|
+
dataDir: dir,
|
|
513
|
+
resolveBinaryFn: async () => {
|
|
514
|
+
throw new BinaryError('no xray', 'binary-missing')
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
const result = await supervisor.validateConfig()
|
|
518
|
+
assert.equal(result.ok, false)
|
|
519
|
+
assert.equal(result.reason, 'binary-missing')
|
|
520
|
+
assert.match(result.detail, /no xray/)
|
|
521
|
+
} finally {
|
|
522
|
+
cleanup()
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { installTools } from '../lib/tools.js'
|
|
4
|
+
|
|
5
|
+
const silent = { info() {}, warn() {}, error() {}, debug() {} }
|
|
6
|
+
const defineTool = (options) => options
|
|
7
|
+
|
|
8
|
+
function fakeCtx(registered) {
|
|
9
|
+
return {
|
|
10
|
+
inject(_services, callback) {
|
|
11
|
+
callback({ tools: { register: (tool) => registered.push(tool) } })
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const supervisor = {
|
|
17
|
+
snapshot: () => ({ state: 'ready', activeNode: 'proxy-hk-01' }),
|
|
18
|
+
switchNode: async (node) => `proxy-${node}`,
|
|
19
|
+
queryStats: async ({ reset } = {}) => ({ 'proxy-hk-01': { uplink: reset ? 0 : 10, downlink: 20 } }),
|
|
20
|
+
validateConfig: async () => ({ ok: true, detail: 'Configuration OK.', configHash: 'abc' }),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('four tools are registered when the tool module resolves', async () => {
|
|
24
|
+
const registered = []
|
|
25
|
+
const ok = await installTools({
|
|
26
|
+
ctx: fakeCtx(registered),
|
|
27
|
+
supervisor,
|
|
28
|
+
logger: silent,
|
|
29
|
+
loadToolsModule: async () => ({ module: { defineTool } }),
|
|
30
|
+
})
|
|
31
|
+
assert.equal(ok, true)
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
registered.map((tool) => tool.name),
|
|
34
|
+
['xray_status', 'xray_validate_config', 'xray_switch_node', 'xray_stats'],
|
|
35
|
+
)
|
|
36
|
+
for (const tool of registered) {
|
|
37
|
+
assert.equal(typeof tool.execute, 'function')
|
|
38
|
+
assert.equal(typeof tool.output.render, 'function')
|
|
39
|
+
assert.deepEqual(tool.output.render({}, 'x'), [{ type: 'text', text: 'x' }])
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('the tools call the supervisor and return JSON strings', async () => {
|
|
44
|
+
const registered = []
|
|
45
|
+
await installTools({
|
|
46
|
+
ctx: fakeCtx(registered),
|
|
47
|
+
supervisor,
|
|
48
|
+
logger: silent,
|
|
49
|
+
loadToolsModule: async () => ({ module: { defineTool } }),
|
|
50
|
+
})
|
|
51
|
+
const [status, validate, switchNode, stats] = registered
|
|
52
|
+
|
|
53
|
+
const statusValue = await status.execute({})
|
|
54
|
+
assert.equal(JSON.parse(statusValue).state, 'ready')
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(JSON.parse(await validate.execute({})), {
|
|
57
|
+
ok: true,
|
|
58
|
+
detail: 'Configuration OK.',
|
|
59
|
+
configHash: 'abc',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
assert.equal(await switchNode.execute({ node: 'jp-01' }), JSON.stringify({ activeNode: 'proxy-jp-01' }))
|
|
63
|
+
|
|
64
|
+
const statsValue = JSON.parse(await stats.execute({ reset: true }))
|
|
65
|
+
assert.deepEqual(statsValue, { 'proxy-hk-01': { uplink: 0, downlink: 20 } })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a missing tool module only disables the tools', async () => {
|
|
69
|
+
const registered = []
|
|
70
|
+
const ok = await installTools({
|
|
71
|
+
ctx: fakeCtx(registered),
|
|
72
|
+
supervisor,
|
|
73
|
+
logger: silent,
|
|
74
|
+
loadToolsModule: async () => undefined,
|
|
75
|
+
})
|
|
76
|
+
assert.equal(ok, false)
|
|
77
|
+
assert.equal(registered.length, 0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('a context without inject skips tool registration', async () => {
|
|
81
|
+
const ok = await installTools({
|
|
82
|
+
ctx: {},
|
|
83
|
+
supervisor,
|
|
84
|
+
logger: silent,
|
|
85
|
+
loadToolsModule: async () => ({ module: { defineTool } }),
|
|
86
|
+
})
|
|
87
|
+
assert.equal(ok, false)
|
|
88
|
+
})
|