@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/bin/README.md +27 -0
  5. package/cordis.patch.yml +37 -0
  6. package/docs/AC-0.md +60 -0
  7. package/docs/EVIDENCE.md +135 -0
  8. package/examples/config.downgrade.json +20 -0
  9. package/examples/config.example.json +22 -0
  10. package/examples/config.multi.downgrade.json +39 -0
  11. package/examples/config.multi.json +46 -0
  12. package/icon.svg +9 -0
  13. package/index.js +89 -0
  14. package/lib/binary.js +133 -0
  15. package/lib/config.js +401 -0
  16. package/lib/control/client.js +100 -0
  17. package/lib/control/grpc.js +121 -0
  18. package/lib/control/protobuf.js +143 -0
  19. package/lib/health.js +109 -0
  20. package/lib/log.js +20 -0
  21. package/lib/paths.js +65 -0
  22. package/lib/process.js +154 -0
  23. package/lib/route.js +299 -0
  24. package/lib/share-link.js +132 -0
  25. package/lib/supervisor.js +687 -0
  26. package/lib/tools.js +106 -0
  27. package/lib/xray-config.js +225 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +42 -4
  31. package/scripts/check-config.mjs +56 -0
  32. package/scripts/parse-share-link.mjs +23 -0
  33. package/scripts/probe-routing.mjs +66 -0
  34. package/scripts/release-check.mjs +104 -0
  35. package/test/binary.test.js +103 -0
  36. package/test/config.test.js +106 -0
  37. package/test/control.test.js +166 -0
  38. package/test/health.test.js +75 -0
  39. package/test/index.test.js +52 -0
  40. package/test/multi-node.test.js +111 -0
  41. package/test/route.test.js +158 -0
  42. package/test/share-link.test.js +113 -0
  43. package/test/supervisor.test.js +525 -0
  44. package/test/tools.test.js +88 -0
  45. package/test/xray-config.test.js +132 -0
@@ -0,0 +1,103 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { join } from 'node:path'
4
+ import { BinaryError, resolveBinary } from '../lib/binary.js'
5
+ import { normalizeConfig } from '../lib/config.js'
6
+
7
+ const packageRoot = join('C:', 'plugins', 'dsh-2xray')
8
+ const dataDir = join('C:', 'users', 'me', '.dsh', 'xray-proxy')
9
+ const exe = process.platform === 'win32' ? 'xray.exe' : 'xray'
10
+ const bundled = join(packageRoot, 'bin', exe)
11
+ const managed = join(dataDir, 'bin', exe)
12
+
13
+ function config(overrides = {}) {
14
+ return normalizeConfig({ enabled: true, ...overrides }).config
15
+ }
16
+
17
+ function only(...paths) {
18
+ return (candidate) => paths.includes(candidate)
19
+ }
20
+
21
+ test('an explicit binary.path wins over everything', async () => {
22
+ const explicit = join('C:', 'tools', exe)
23
+ const found = await resolveBinary({
24
+ config: config({ binary: { path: explicit } }),
25
+ dataDir,
26
+ packageRoot,
27
+ exists: only(explicit, bundled),
28
+ })
29
+ assert.deepEqual(found, { path: explicit, source: 'config' })
30
+ })
31
+
32
+ test('a plugin-shipped bin/xray is preferred over the managed directory', async () => {
33
+ const found = await resolveBinary({
34
+ config: config(),
35
+ dataDir,
36
+ packageRoot,
37
+ exists: only(bundled, managed),
38
+ })
39
+ assert.deepEqual(found, { path: bundled, source: 'bundled' })
40
+ })
41
+
42
+ test('the managed data directory is used when the bundle ships nothing', async () => {
43
+ const found = await resolveBinary({
44
+ config: config(),
45
+ dataDir,
46
+ packageRoot,
47
+ exists: only(managed),
48
+ })
49
+ assert.deepEqual(found, { path: managed, source: 'managed' })
50
+ })
51
+
52
+ test('DSH_XRAY_BIN is used when no explicit path is configured', async () => {
53
+ const previous = process.env.DSH_XRAY_BIN
54
+ process.env.DSH_XRAY_BIN = join('C:', 'portable', exe)
55
+ try {
56
+ const found = await resolveBinary({
57
+ config: config(),
58
+ dataDir,
59
+ packageRoot,
60
+ exists: only(process.env.DSH_XRAY_BIN, bundled),
61
+ })
62
+ assert.equal(found.source, 'env')
63
+ } finally {
64
+ if (previous === undefined) delete process.env.DSH_XRAY_BIN
65
+ else process.env.DSH_XRAY_BIN = previous
66
+ }
67
+ })
68
+
69
+ test('PATH is the last resort and uses resolveExecutable when available', async () => {
70
+ const found = await resolveBinary({
71
+ config: config(),
72
+ dataDir,
73
+ packageRoot,
74
+ exists: () => false,
75
+ launcher: { resolveExecutable: async () => '/usr/local/bin/xray' },
76
+ })
77
+ assert.deepEqual(found, { path: '/usr/local/bin/xray', source: 'path' })
78
+ })
79
+
80
+ test('a missing binary fails with every candidate listed', async () => {
81
+ const previousPath = process.env.PATH
82
+ process.env.PATH = ''
83
+ try {
84
+ await assert.rejects(
85
+ () =>
86
+ resolveBinary({
87
+ config: config(),
88
+ dataDir,
89
+ packageRoot,
90
+ exists: () => false,
91
+ launcher: {},
92
+ }),
93
+ (error) => {
94
+ assert.ok(error instanceof BinaryError)
95
+ assert.equal(error.code, 'binary-missing')
96
+ assert.ok(error.candidates.includes(bundled))
97
+ return true
98
+ },
99
+ )
100
+ } finally {
101
+ process.env.PATH = previousPath
102
+ }
103
+ })
@@ -0,0 +1,106 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizeConfig } from '../lib/config.js'
4
+
5
+ function base(overrides = {}) {
6
+ return normalizeConfig({
7
+ enabled: true,
8
+ server: {
9
+ address: 'example.com',
10
+ port: 443,
11
+ uuid: '11111111-2222-4333-8444-555555555555',
12
+ serverName: 'www.example.com',
13
+ password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
14
+ shortId: 'ab12',
15
+ ...overrides.server,
16
+ },
17
+ ...overrides.root,
18
+ })
19
+ }
20
+
21
+ test('a complete REALITY config validates', () => {
22
+ const { errors } = base()
23
+ assert.deepEqual(errors, [])
24
+ })
25
+
26
+ test('missing server address / uuid are field-level errors, not throws', () => {
27
+ const { config, errors } = normalizeConfig({ enabled: true })
28
+ assert.equal(config.server.port, 443)
29
+ assert.ok(errors.some((error) => error.field === 'server.address'))
30
+ assert.ok(errors.some((error) => error.field === 'server.uuid'))
31
+ })
32
+
33
+ test('flow requires raw/tcp plus reality/tls', () => {
34
+ const { errors } = base({ server: { security: 'none', flow: 'xtls-rprx-vision' } })
35
+ assert.ok(errors.some((error) => error.field === 'server.flow'))
36
+ })
37
+
38
+ test('an unknown flow value is rejected', () => {
39
+ const { errors } = base({ server: { flow: 'xtls-rprx-direct' } })
40
+ assert.ok(errors.some((error) => error.field === 'server.flow'))
41
+ })
42
+
43
+ test('odd-length shortId is rejected', () => {
44
+ const { errors } = base({ server: { shortId: 'abc' } })
45
+ assert.ok(errors.some((error) => error.field === 'server.shortId'))
46
+ })
47
+
48
+ test('a non-base64url REALITY key is rejected', () => {
49
+ const { errors } = base({ server: { password: 'not-a-key' } })
50
+ assert.ok(errors.some((error) => error.field === 'server.password'))
51
+ })
52
+
53
+ test('legacy publicKey alone is accepted and normalized', () => {
54
+ const { config, errors } = base({
55
+ server: {
56
+ password: '',
57
+ publicKey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
58
+ },
59
+ })
60
+ assert.deepEqual(errors, [])
61
+ assert.equal(config.server.password, '')
62
+ assert.equal(config.server.publicKey, 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
63
+ })
64
+
65
+ test('a non-loopback inbound host is rejected', () => {
66
+ const { errors } = normalizeConfig({ enabled: true, inbound: { host: '0.0.0.0' } })
67
+ assert.ok(errors.some((error) => error.field === 'inbound.host'))
68
+ })
69
+
70
+ test('socks inbound may not reuse the http port', () => {
71
+ const { errors } = base({ root: { inbound: { port: 20808, socks: { enabled: true, port: 20808 } } } })
72
+ assert.ok(errors.some((error) => error.field === 'inbound.socks.port'))
73
+ })
74
+
75
+ test('takeover accepts only auto/force/off', () => {
76
+ const { errors } = base({ root: { takeover: 'always' } })
77
+ assert.ok(errors.some((error) => error.field === 'takeover'))
78
+ })
79
+
80
+ test('numbers given as strings are coerced, invalid numbers are errors', () => {
81
+ const ok = normalizeConfig({ enabled: true, server: { port: '8443' }, inbound: { port: '20999' } })
82
+ assert.equal(ok.config.server.port, 8443)
83
+ assert.equal(ok.config.inbound.port, 20999)
84
+
85
+ const bad = normalizeConfig({ enabled: true, server: { port: 'abc' } })
86
+ assert.ok(bad.errors.some((error) => error.field === 'server.port'))
87
+ })
88
+
89
+ test('disabled rows still normalize', () => {
90
+ const { config, errors } = normalizeConfig({ enabled: false })
91
+ assert.equal(config.enabled, false)
92
+ assert.equal(config.server.flow, 'xtls-rprx-vision')
93
+ assert.ok(errors.length > 0) // server fields are empty; supervisor skips them when disabled
94
+ })
95
+
96
+ test('bypass domains accept a comma string and an array', () => {
97
+ const fromString = normalizeConfig({ routing: { bypassDomains: 'a.test, b.test' } })
98
+ assert.deepEqual(fromString.config.routing.bypassDomains, ['a.test', 'b.test'])
99
+ const fromArray = normalizeConfig({ routing: { bypassDomains: ['c.test'] } })
100
+ assert.deepEqual(fromArray.config.routing.bypassDomains, ['c.test'])
101
+ })
102
+
103
+ test('network tcp is normalized to raw', () => {
104
+ const { config } = base({ server: { network: 'tcp' } })
105
+ assert.equal(config.server.network, 'raw')
106
+ })
@@ -0,0 +1,166 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import http2 from 'node:http2'
4
+ import {
5
+ decodeBalancerInfo,
6
+ decodeQueryStats,
7
+ encodeGetBalancerInfo,
8
+ encodeMessageField,
9
+ encodeOverrideBalancer,
10
+ encodeQueryStats,
11
+ encodeStringField,
12
+ encodeVarint,
13
+ } from '../lib/control/protobuf.js'
14
+ import { GrpcError, grpcUnary } from '../lib/control/grpc.js'
15
+ import { ControlClient } from '../lib/control/client.js'
16
+
17
+ function frame(payload) {
18
+ const prefix = Buffer.alloc(5)
19
+ prefix.writeUInt8(0, 0)
20
+ prefix.writeUInt32BE(payload.length, 1)
21
+ return Buffer.concat([prefix, payload])
22
+ }
23
+
24
+ function statBytes(name, value) {
25
+ return Buffer.concat([encodeStringField(1, name), Buffer.concat([encodeVarint((2 << 3) | 0), encodeVarint(value)])])
26
+ }
27
+
28
+ function listen(server) {
29
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
30
+ }
31
+
32
+ test('varint and string fields use the expected wire bytes', () => {
33
+ assert.deepEqual([...encodeVarint(0)], [0])
34
+ assert.deepEqual([...encodeVarint(300)], [0xac, 0x02])
35
+ const bytes = encodeOverrideBalancer({ balancerTag: 'active', target: 'proxy-hk' })
36
+ assert.deepEqual([...bytes], [
37
+ 0x0a, 0x06, ...Buffer.from('active'),
38
+ 0x12, 0x08, ...Buffer.from('proxy-hk'),
39
+ ])
40
+ })
41
+
42
+ test('QueryStats request carries repeated patterns and reset', () => {
43
+ const bytes = encodeQueryStats({ patterns: ['outbound>>>', 'inbound>>>'], reset: true })
44
+ const expected = Buffer.concat([
45
+ encodeStringField(1, 'outbound>>>'),
46
+ encodeStringField(1, 'inbound>>>'),
47
+ Buffer.from([0x10, 0x01]),
48
+ ])
49
+ assert.deepEqual([...bytes], [...expected])
50
+ })
51
+
52
+ test('QueryStats response decodes name/value pairs', () => {
53
+ const statA = statBytes('outbound>>>proxy-hk>>>traffic>>>uplink', 1234)
54
+ const statB = statBytes('outbound>>>proxy-hk>>>traffic>>>downlink', 99)
55
+ const response = Buffer.concat([encodeMessageField(1, statA), encodeMessageField(1, statB)])
56
+ assert.deepEqual(decodeQueryStats(response), [
57
+ { name: 'outbound>>>proxy-hk>>>traffic>>>uplink', value: 1234 },
58
+ { name: 'outbound>>>proxy-hk>>>traffic>>>downlink', value: 99 },
59
+ ])
60
+ })
61
+
62
+ test('balancer info decodes the pinned override target', () => {
63
+ const overrideInfo = Buffer.concat([encodeStringField(2, 'proxy-hk')])
64
+ const balancer = encodeMessageField(5, overrideInfo)
65
+ const response = encodeMessageField(1, balancer)
66
+ assert.deepEqual(decodeBalancerInfo(response), { overrideTarget: 'proxy-hk', principleTargets: [] })
67
+ })
68
+
69
+ test('GetBalancerInfo request is a single tag field', () => {
70
+ assert.deepEqual([...encodeGetBalancerInfo({ tag: 'active' })], [0x0a, 0x06, ...Buffer.from('active')])
71
+ })
72
+
73
+ async function withMockGrpc(handler, run) {
74
+ const server = http2.createServer()
75
+ const requests = []
76
+ server.on('stream', (stream, headers) => {
77
+ const chunks = []
78
+ stream.on('data', (chunk) => chunks.push(chunk))
79
+ stream.on('end', () => {
80
+ const body = Buffer.concat(chunks)
81
+ requests.push({ path: headers[':path'], contentType: headers['content-type'], body })
82
+ handler(stream, headers, body)
83
+ })
84
+ })
85
+ const port = await listen(server)
86
+ try {
87
+ await run({ port, requests })
88
+ } finally {
89
+ server.close()
90
+ }
91
+ }
92
+
93
+ test('grpcUnary speaks unary gRPC over h2c', async () => {
94
+ const payload = Buffer.concat([encodeStringField(2, 'proxy-hk')])
95
+ const wrapper = encodeMessageField(1, encodeMessageField(5, payload))
96
+ await withMockGrpc(
97
+ (stream) => {
98
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '0' })
99
+ stream.end(frame(wrapper))
100
+ },
101
+ async ({ port, requests }) => {
102
+ const request = Buffer.from([0x0a, 0x06, ...Buffer.from('active')])
103
+ const response = await grpcUnary({
104
+ port,
105
+ path: '/xray.app.router.command.RoutingService/GetBalancerInfo',
106
+ message: request,
107
+ timeoutMs: 2000,
108
+ })
109
+ assert.deepEqual([...response], [...wrapper])
110
+ assert.equal(requests.length, 1)
111
+ assert.equal(requests[0].contentType, 'application/grpc')
112
+ assert.equal(requests[0].body.readUInt32BE(1), request.length)
113
+ },
114
+ )
115
+ })
116
+
117
+ test('grpcUnary surfaces a non-zero grpc-status as GrpcError', async () => {
118
+ await withMockGrpc(
119
+ (stream) => {
120
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '5', 'grpc-message': 'boom' })
121
+ stream.end()
122
+ },
123
+ async ({ port }) => {
124
+ await assert.rejects(
125
+ () => grpcUnary({ port, path: '/x/y', message: Buffer.alloc(0), timeoutMs: 2000 }),
126
+ (error) => error instanceof GrpcError && error.code === '5' && /boom/.test(error.message),
127
+ )
128
+ },
129
+ )
130
+ })
131
+
132
+ test('a connection refusal becomes a readable control error', async () => {
133
+ const client = new ControlClient({ port: 1, timeoutMs: 300 })
134
+ await assert.rejects(() => client.getBalancerInfo())
135
+ const snapshot = client.snapshot()
136
+ assert.equal(snapshot.reachable, false)
137
+ assert.match(snapshot.lastError, /ECONNREFUSED|UNAVAILABLE|ECONNRESET/)
138
+ })
139
+
140
+ test('ControlClient switches the balancer and reads stats', async () => {
141
+ const statsBody = Buffer.concat([
142
+ encodeMessageField(1, statBytes('outbound>>>proxy-hk>>>traffic>>>uplink', 42)),
143
+ ])
144
+ await withMockGrpc(
145
+ (stream, headers) => {
146
+ if (headers[':path'].endsWith('/OverrideBalancerTarget')) {
147
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '0' })
148
+ stream.end(frame(Buffer.alloc(0)))
149
+ } else if (headers[':path'].endsWith('/QueryStats')) {
150
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '0' })
151
+ stream.end(frame(statsBody))
152
+ } else {
153
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '12' })
154
+ stream.end()
155
+ }
156
+ },
157
+ async ({ port }) => {
158
+ const client = new ControlClient({ port, timeoutMs: 2000, balancerTag: 'active' })
159
+ assert.equal(await client.overrideBalancerTarget('proxy-hk'), 'proxy-hk')
160
+ assert.equal(client.snapshot().activeTarget, 'proxy-hk')
161
+ assert.equal(client.snapshot().reachable, true)
162
+ const stats = await client.queryStats({ patterns: ['outbound>>>'] })
163
+ assert.deepEqual(stats, [{ name: 'outbound>>>proxy-hk>>>traffic>>>uplink', value: 42 }])
164
+ },
165
+ )
166
+ })
@@ -0,0 +1,75 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import http from 'node:http'
4
+ import { tcpConnect, waitForPort, portIsFree, httpProxyProbe, cancellableDelay } from '../lib/health.js'
5
+
6
+ function listen(server) {
7
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
8
+ }
9
+
10
+ test('tcpConnect sees a live listener and rejects a dead port', async () => {
11
+ const server = http.createServer((_req, res) => res.end())
12
+ const port = await listen(server)
13
+ try {
14
+ assert.equal(await tcpConnect('127.0.0.1', port, 500), true)
15
+ assert.equal(await tcpConnect('127.0.0.1', port + 1, 300), false)
16
+ } finally {
17
+ server.close()
18
+ }
19
+ })
20
+
21
+ test('waitForPort polls until a late listener appears', async () => {
22
+ const port = 21000 + Math.floor(Math.random() * 500)
23
+ const succeeded = waitForPort({ host: '127.0.0.1', port, timeoutMs: 2000, intervalMs: 25 })
24
+ let server
25
+ setTimeout(() => {
26
+ server = http.createServer((_req, res) => res.end())
27
+ server.listen(port, '127.0.0.1')
28
+ }, 150)
29
+ assert.equal(await succeeded, true)
30
+ server.close()
31
+ })
32
+
33
+ test('waitForPort gives up inside the budget', async () => {
34
+ const port = 21500 + Math.floor(Math.random() * 500)
35
+ const started = Date.now()
36
+ assert.equal(await waitForPort({ host: '127.0.0.1', port, timeoutMs: 300, intervalMs: 25 }), false)
37
+ assert.ok(Date.now() - started < 1500)
38
+ })
39
+
40
+ test('portIsFree reflects an occupied port', async () => {
41
+ const server = http.createServer((_req, res) => res.end())
42
+ const port = await listen(server)
43
+ try {
44
+ assert.equal(await portIsFree('127.0.0.1', port), false)
45
+ } finally {
46
+ server.close()
47
+ await new Promise((resolve) => server.once('close', resolve))
48
+ }
49
+ assert.equal(await portIsFree('127.0.0.1', port), true)
50
+ })
51
+
52
+ test('the functional probe needs a 2xx/3xx answer', async () => {
53
+ const server = http.createServer((req, res) => {
54
+ res.statusCode = req.url?.includes('ok') ? 204 : 500
55
+ res.end()
56
+ })
57
+ const port = await listen(server)
58
+ try {
59
+ const good = await httpProxyProbe({ host: '127.0.0.1', port, url: 'http://probe.test/ok', timeoutMs: 1000 })
60
+ assert.deepEqual(good, { ok: true, status: 204 })
61
+ const bad = await httpProxyProbe({ host: '127.0.0.1', port, url: 'http://probe.test/fail', timeoutMs: 1000 })
62
+ assert.equal(bad.ok, false)
63
+ assert.equal(bad.status, 500)
64
+ } finally {
65
+ server.close()
66
+ }
67
+ })
68
+
69
+ test('cancellableDelay returns early on abort', async () => {
70
+ const controller = new AbortController()
71
+ const started = Date.now()
72
+ setTimeout(() => controller.abort(), 20)
73
+ await cancellableDelay(5000, controller.signal)
74
+ assert.ok(Date.now() - started < 1000)
75
+ })
@@ -0,0 +1,52 @@
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 { apply, name } from '../index.js'
7
+
8
+ const silent = { info() {}, warn() {}, error() {}, debug() {} }
9
+
10
+ function fakeCtx() {
11
+ const effects = []
12
+ return {
13
+ logger: silent,
14
+ effects,
15
+ effect(callback, label) {
16
+ const disposer = callback()
17
+ effects.push({ label, disposer })
18
+ return () => disposer?.()
19
+ },
20
+ }
21
+ }
22
+
23
+ test('the plugin exports the documented entry shape', () => {
24
+ assert.equal(name, 'dsh-2xray')
25
+ assert.equal(typeof apply, 'function')
26
+ })
27
+
28
+ test('a disabled row registers cleanup and never spawns', async () => {
29
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-xray-index-'))
30
+ try {
31
+ const ctx = fakeCtx()
32
+ const dispose = apply(ctx, { enabled: false, binary: { dataDir: dir } })
33
+ assert.equal(typeof dispose, 'function')
34
+ assert.equal(ctx.effects.length, 1)
35
+ await new Promise((resolve) => setTimeout(resolve, 30))
36
+ await dispose()
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true })
39
+ }
40
+ })
41
+
42
+ test('an incomplete config does not throw from apply', async () => {
43
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-xray-index-'))
44
+ try {
45
+ const ctx = fakeCtx()
46
+ const dispose = apply(ctx, { enabled: true, binary: { dataDir: dir } })
47
+ await new Promise((resolve) => setTimeout(resolve, 50))
48
+ await dispose()
49
+ } finally {
50
+ rmSync(dir, { recursive: true, force: true })
51
+ }
52
+ })
@@ -0,0 +1,111 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizeConfig } from '../lib/config.js'
4
+ import { buildXrayConfig } from '../lib/xray-config.js'
5
+
6
+ function node(id, overrides = {}) {
7
+ return {
8
+ id,
9
+ address: `${id}.example.com`,
10
+ port: 443,
11
+ uuid: '11111111-2222-4333-8444-555555555555',
12
+ serverName: 'www.example.com',
13
+ password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
14
+ shortId: 'ab12',
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ function multi(overrides = {}) {
20
+ return normalizeConfig({
21
+ enabled: true,
22
+ nodes: [node('hk-01'), node('jp-01')],
23
+ control: { enabled: true, port: 10085 },
24
+ ...overrides,
25
+ })
26
+ }
27
+
28
+ test('multiple nodes get distinct outbound tags and default to the first', () => {
29
+ const { config, errors } = multi()
30
+ assert.deepEqual(errors, [])
31
+ assert.deepEqual(config.nodes.map((entry) => entry.tag), ['proxy-hk-01', 'proxy-jp-01'])
32
+ assert.equal(config.activeNode, 'proxy-hk-01')
33
+ })
34
+
35
+ test('activeNode may name an id or a tag', () => {
36
+ assert.equal(multi({ activeNode: 'jp-01' }).config.activeNode, 'proxy-jp-01')
37
+ assert.equal(multi({ activeNode: 'proxy-jp-01' }).config.activeNode, 'proxy-jp-01')
38
+ })
39
+
40
+ test('an unknown activeNode falls back to the first node with a warning', () => {
41
+ const { config, warnings } = multi({ activeNode: 'nope' })
42
+ assert.equal(config.activeNode, 'proxy-hk-01')
43
+ assert.ok(warnings.some((warning) => warning.includes('activeNode')))
44
+ })
45
+
46
+ test('duplicate outbound tags are rejected', () => {
47
+ const { errors } = multi({ nodes: [node('a', { tag: 'same' }), node('b', { tag: 'same' })] })
48
+ assert.ok(errors.some((error) => /tag 重复/.test(error.message)))
49
+ })
50
+
51
+ test('the control port may not collide with an inbound port', () => {
52
+ const { errors } = multi({ control: { enabled: true, port: 20808 } })
53
+ assert.ok(errors.some((error) => error.field === 'control.port'))
54
+ })
55
+
56
+ test('control host must be loopback', () => {
57
+ const { errors } = multi({ control: { enabled: true, host: '0.0.0.0' } })
58
+ assert.ok(errors.some((error) => error.field === 'control.host'))
59
+ })
60
+
61
+ test('multiple nodes without the control plane warn loudly', () => {
62
+ const { warnings } = multi({ control: { enabled: false } })
63
+ assert.ok(warnings.some((warning) => warning.includes('control.enabled')))
64
+ })
65
+
66
+ test('multi-node config preloads every outbound plus one balancer', () => {
67
+ const { config } = multi()
68
+ const built = buildXrayConfig(config)
69
+ assert.deepEqual(
70
+ built.outbounds.map((outbound) => outbound.tag),
71
+ ['proxy-hk-01', 'proxy-jp-01', 'direct', 'block'],
72
+ )
73
+ assert.deepEqual(built.routing.balancers, [
74
+ {
75
+ tag: 'active',
76
+ selector: ['^proxy-hk-01$', '^proxy-jp-01$'],
77
+ strategy: { type: 'random' },
78
+ },
79
+ ])
80
+ const catchAll = built.routing.rules.at(-1)
81
+ assert.equal(catchAll.balancerTag, 'active')
82
+ assert.equal(catchAll.outboundTag, undefined)
83
+ })
84
+
85
+ test('the control plane adds the api inbound, service list, stats and routing rule', () => {
86
+ const { config } = multi()
87
+ const built = buildXrayConfig(config)
88
+ const apiInbound = built.inbounds.find((inbound) => inbound.tag === 'api')
89
+ assert.equal(apiInbound.protocol, 'dokodemo-door')
90
+ assert.equal(apiInbound.listen, '127.0.0.1')
91
+ assert.equal(apiInbound.port, 10085)
92
+ assert.deepEqual(built.api, {
93
+ tag: 'api',
94
+ services: ['HandlerService', 'StatsService', 'RoutingService'],
95
+ })
96
+ assert.deepEqual(built.stats, {})
97
+ assert.equal(built.policy.system.statsOutboundUplink, true)
98
+ assert.deepEqual(built.routing.rules[0], { type: 'field', inboundTag: ['api'], outboundTag: 'api' })
99
+ })
100
+
101
+ test('a single node keeps the legacy config shape (no balancer, api or stats)', () => {
102
+ const { config } = normalizeConfig({
103
+ enabled: true,
104
+ server: node('solo'),
105
+ })
106
+ const built = buildXrayConfig(config)
107
+ assert.equal(built.routing.balancers, undefined)
108
+ assert.equal(built.api, undefined)
109
+ assert.equal(built.stats, undefined)
110
+ assert.equal(built.routing.rules.at(-1).outboundTag, 'proxy')
111
+ })