@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
package/lib/config.js ADDED
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Config normalization and validation for dsh-2xray.
3
+ *
4
+ * The plugin deliberately exports no `Config` schema: a schema failure at load
5
+ * time would deactivate the row and hide the diagnostics from the user. Instead
6
+ * normalization returns `{ config, errors, warnings }`; the supervisor turns a
7
+ * non-empty `errors` array into a `failed(config-invalid)` state and keeps the
8
+ * plugin loaded so the reason stays readable in the log and `status.json`.
9
+ *
10
+ * Two node shapes are accepted:
11
+ * - `server.*` legacy single node (still the shortest config);
12
+ * - `nodes[]` one entry per outbound, switched at runtime through a
13
+ * balancer override on the Xray control plane;
14
+ * - `shareLink` / `shareLinks` standard `vless://` links (the same text a
15
+ * QR code carries), parsed into nodes.
16
+ */
17
+
18
+ import { parseShareLinks } from './share-link.js'
19
+
20
+ export const DEFAULT_CONFIG = {
21
+ enabled: false,
22
+ server: {
23
+ address: '',
24
+ port: 443,
25
+ uuid: '',
26
+ flow: 'xtls-rprx-vision',
27
+ serverName: '',
28
+ // REALITY `password` is the current field name; `publicKey` is the legacy
29
+ // one. Both are written to the xray config when provided so one config
30
+ // works across the versions that renamed the field.
31
+ password: '',
32
+ publicKey: '',
33
+ shortId: '',
34
+ fingerprint: 'chrome',
35
+ spiderX: '/',
36
+ network: 'raw',
37
+ security: 'reality',
38
+ },
39
+ nodes: [],
40
+ /** Paste one standard `vless://` share link (same text a QR code carries). */
41
+ shareLink: '',
42
+ /** Or paste several; each becomes a node with its fragment name as the id. */
43
+ shareLinks: [],
44
+ activeNode: '',
45
+ inbound: {
46
+ protocol: 'http',
47
+ host: '127.0.0.1',
48
+ port: 20808,
49
+ socks: { enabled: false, host: '127.0.0.1', port: 20809, udp: false },
50
+ },
51
+ takeover: 'auto',
52
+ noProxy: '',
53
+ routing: {
54
+ bypassDomains: ['localhost', 'local', 'lan', 'internal', 'corp', 'home.arpa'],
55
+ blockAds: false,
56
+ geositeCn: false,
57
+ },
58
+ control: {
59
+ // Off by default: a single-node config needs no API listener, and enabling
60
+ // it opens one more loopback port. Multi-node switching requires it.
61
+ enabled: false,
62
+ host: '127.0.0.1',
63
+ port: 10085,
64
+ services: ['HandlerService', 'StatsService', 'RoutingService'],
65
+ stats: true,
66
+ balancerTag: 'active',
67
+ timeoutMs: 3000,
68
+ },
69
+ binary: { path: '', dataDir: '' },
70
+ lifecycle: {
71
+ startTimeoutMs: 15000,
72
+ stopGraceMs: 3000,
73
+ maxRestarts: 5,
74
+ backoffMs: 500,
75
+ backoffMaxMs: 30000,
76
+ healthIntervalMs: 30000,
77
+ unhealthyThreshold: 3,
78
+ probeTimeoutMs: 3000,
79
+ },
80
+ functionalProbe: {
81
+ enabled: true,
82
+ url: 'http://www.gstatic.com/generate_204',
83
+ timeoutMs: 5000,
84
+ },
85
+ log: { level: 'warning' },
86
+ }
87
+
88
+ const FLOWS = new Set(['', 'xtls-rprx-vision', 'xtls-rprx-vision-udp443'])
89
+ const NETWORKS = new Set(['raw', 'tcp'])
90
+ const SECURITIES = new Set(['reality', 'tls', 'none'])
91
+ const TAKEOVERS = new Set(['auto', 'force', 'off'])
92
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
93
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
94
+ const BASE64URL_32_RE = /^[A-Za-z0-9_-]{43}$/
95
+ const SHORT_ID_RE = /^(?:[0-9a-fA-F]{2})*$/
96
+
97
+ function isObject(value) {
98
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
99
+ }
100
+
101
+ function clone(value) {
102
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
103
+ }
104
+
105
+ function merge(base, patch) {
106
+ if (!isObject(patch)) return base
107
+ const out = isObject(base) ? { ...base } : {}
108
+ for (const [key, value] of Object.entries(patch)) {
109
+ if (isObject(value) && isObject(out[key])) out[key] = merge(out[key], value)
110
+ else out[key] = clone(value)
111
+ }
112
+ return out
113
+ }
114
+
115
+ function asString(value, fallback) {
116
+ return typeof value === 'string' ? value.trim() : fallback
117
+ }
118
+
119
+ function asInt(value, fallback) {
120
+ if (value === undefined || value === null || value === '') return fallback
121
+ const n = typeof value === 'number' ? value : Number(String(value).trim())
122
+ return Number.isFinite(n) ? Math.trunc(n) : Number.NaN
123
+ }
124
+
125
+ function asBool(value, fallback) {
126
+ if (typeof value === 'boolean') return value
127
+ if (typeof value === 'string') {
128
+ if (/^(1|true|yes|on)$/i.test(value.trim())) return true
129
+ if (/^(0|false|no|off)$/i.test(value.trim())) return false
130
+ }
131
+ return fallback
132
+ }
133
+
134
+ function asStringList(value, fallback) {
135
+ if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean)
136
+ if (typeof value === 'string') return value.split(',').map((entry) => entry.trim()).filter(Boolean)
137
+ return clone(fallback)
138
+ }
139
+
140
+ export function sanitizeTag(value) {
141
+ return String(value ?? '')
142
+ .trim()
143
+ .replace(/[^A-Za-z0-9_.-]/g, '_')
144
+ }
145
+
146
+ function normalizeEndpoint(raw, index) {
147
+ const node = merge(clone(DEFAULT_CONFIG.server), raw)
148
+ const id = asString(raw?.id, '') || (index === undefined ? 'default' : `node-${index + 1}`)
149
+ const network = asString(node.network, 'raw')
150
+ return {
151
+ id,
152
+ tag: asString(raw?.tag, '') || (index === undefined ? 'proxy' : `proxy-${sanitizeTag(id)}`),
153
+ address: asString(node.address, ''),
154
+ port: asInt(node.port, 443),
155
+ uuid: asString(node.uuid, ''),
156
+ flow: asString(node.flow, DEFAULT_CONFIG.server.flow),
157
+ serverName: asString(node.serverName, ''),
158
+ password: asString(node.password, ''),
159
+ publicKey: asString(node.publicKey, ''),
160
+ shortId: asString(node.shortId, ''),
161
+ fingerprint: asString(node.fingerprint, 'chrome'),
162
+ spiderX: asString(node.spiderX, '/'),
163
+ network: network === 'tcp' ? 'raw' : network,
164
+ security: asString(node.security, 'reality'),
165
+ }
166
+ }
167
+
168
+ function validateEndpoint(endpoint, prefix, errors, warnings) {
169
+ if (!endpoint.address) errors.push({ field: `${prefix}.address`, message: '服务端地址不能为空' })
170
+ if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) {
171
+ errors.push({ field: `${prefix}.port`, message: '服务端端口必须是 1..65535 的整数' })
172
+ }
173
+ if (!endpoint.uuid) errors.push({ field: `${prefix}.uuid`, message: 'VLESS uuid 不能为空' })
174
+ else if (!UUID_RE.test(endpoint.uuid) && endpoint.uuid.length > 30) {
175
+ errors.push({ field: `${prefix}.uuid`, message: 'uuid 既不是标准 UUID,也超过 30 字节(VLESS 会映射为 UUIDv5)' })
176
+ } else if (!UUID_RE.test(endpoint.uuid)) {
177
+ warnings.push(`${prefix}.uuid 不是标准 UUID,将按 VLESS 字符串 id 处理`)
178
+ }
179
+
180
+ if (!NETWORKS.has(endpoint.network)) {
181
+ errors.push({ field: `${prefix}.network`, message: `network 只支持 raw/tcp,收到 ${endpoint.network}` })
182
+ }
183
+ if (!SECURITIES.has(endpoint.security)) {
184
+ errors.push({ field: `${prefix}.security`, message: `security 只支持 reality/tls/none,收到 ${endpoint.security}` })
185
+ }
186
+ if (!FLOWS.has(endpoint.flow)) {
187
+ errors.push({ field: `${prefix}.flow`, message: `flow 只支持 ""/xtls-rprx-vision/xtls-rprx-vision-udp443,收到 ${endpoint.flow}` })
188
+ }
189
+ if (endpoint.flow !== '' && (!NETWORKS.has(endpoint.network) || !['reality', 'tls'].includes(endpoint.security))) {
190
+ // `-test` does not catch this; the first request fails with
191
+ // "XTLS only supports TLS and REALITY directly for now."
192
+ errors.push({ field: `${prefix}.flow`, message: 'flow 非空时 network 必须是 raw/tcp 且 security 必须是 reality/tls' })
193
+ }
194
+
195
+ if (endpoint.security === 'reality') {
196
+ if (!endpoint.serverName) errors.push({ field: `${prefix}.serverName`, message: 'REALITY 必须提供 serverName' })
197
+ if (!endpoint.password && !endpoint.publicKey) {
198
+ errors.push({ field: `${prefix}.password`, message: 'REALITY 必须提供 password(或旧字段 publicKey)' })
199
+ }
200
+ for (const candidate of [endpoint.password, endpoint.publicKey]) {
201
+ if (candidate && !BASE64URL_32_RE.test(candidate)) {
202
+ errors.push({ field: `${prefix}.password`, message: 'REALITY 公钥必须是 43 字符 base64url(32 字节 X25519),请用 xray x25519 生成' })
203
+ break
204
+ }
205
+ }
206
+ if (endpoint.shortId && (!SHORT_ID_RE.test(endpoint.shortId) || endpoint.shortId.length > 16)) {
207
+ errors.push({ field: `${prefix}.shortId`, message: 'shortId 必须是偶数长度的 hex,且不超过 16 个字符' })
208
+ }
209
+ if (!endpoint.fingerprint) errors.push({ field: `${prefix}.fingerprint`, message: 'REALITY 必须提供 uTLS 指纹,例如 chrome' })
210
+ if (!endpoint.spiderX.startsWith('/')) errors.push({ field: `${prefix}.spiderX`, message: 'spiderX 必须以 / 开头' })
211
+ } else if (endpoint.security === 'tls') {
212
+ if (!endpoint.serverName) errors.push({ field: `${prefix}.serverName`, message: 'TLS 必须提供 serverName(SNI)' })
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Normalize a raw `config:` block from `cordis.patch.yml` into the shape the
218
+ * rest of the plugin consumes.
219
+ *
220
+ * @param raw - the row config, possibly `undefined`.
221
+ * @returns normalized config plus field-level validation results.
222
+ */
223
+ export function normalizeConfig(raw) {
224
+ const errors = []
225
+ const warnings = []
226
+ const merged = merge(clone(DEFAULT_CONFIG), raw)
227
+
228
+ merged.enabled = asBool(merged.enabled, DEFAULT_CONFIG.enabled)
229
+ merged.takeover = asString(merged.takeover, 'auto')
230
+ merged.noProxy = asString(merged.noProxy, '')
231
+ merged.activeNode = asString(merged.activeNode, '')
232
+
233
+ const server = normalizeEndpoint(merged.server, undefined)
234
+ merged.server = { ...server }
235
+
236
+ const rawNodes = Array.isArray(merged.nodes) ? merged.nodes : []
237
+ const entries = rawNodes.map((node, index) => ({
238
+ node: normalizeEndpoint(node, index),
239
+ prefix: `nodes[${index}]`,
240
+ }))
241
+
242
+ // Share links are the paste/scan import path: the same URL text a QR code
243
+ // carries. `shareLinks` wins over the singular `shareLink` when both are set.
244
+ const linkValues = []
245
+ if (Array.isArray(merged.shareLinks)) linkValues.push(...merged.shareLinks)
246
+ else if (typeof merged.shareLinks === 'string' && merged.shareLinks.trim() !== '') linkValues.push(merged.shareLinks)
247
+ if (linkValues.length === 0 && typeof merged.shareLink === 'string' && merged.shareLink.trim() !== '') {
248
+ linkValues.push(merged.shareLink)
249
+ }
250
+ const parsedLinks = parseShareLinks(linkValues)
251
+ for (const error of parsedLinks.errors) errors.push(error)
252
+ for (const warning of parsedLinks.warnings) warnings.push(warning)
253
+ parsedLinks.nodes.forEach((node, index) => {
254
+ entries.push({
255
+ node: normalizeEndpoint(node, rawNodes.length + index),
256
+ prefix: `shareLinks[${index}]`,
257
+ })
258
+ })
259
+
260
+ const nodes = entries.map((entry) => entry.node)
261
+ if (nodes.length > 0) {
262
+ for (const entry of entries) {
263
+ validateEndpoint(entry.node, entry.prefix, errors, warnings)
264
+ }
265
+ const seenTags = new Set()
266
+ for (const entry of entries) {
267
+ entry.node.tag = sanitizeTag(entry.node.tag) || `proxy-${sanitizeTag(entry.node.id)}`
268
+ if (seenTags.has(entry.node.tag)) {
269
+ errors.push({ field: `${entry.prefix}.tag`, message: `outbound tag 重复:${entry.node.tag}` })
270
+ }
271
+ seenTags.add(entry.node.tag)
272
+ }
273
+ const match = merged.activeNode
274
+ ? nodes.find((node) => node.id === merged.activeNode || node.tag === merged.activeNode)
275
+ : undefined
276
+ if (merged.activeNode && !match) {
277
+ warnings.push(`activeNode ${merged.activeNode} 不在节点中,回退到第一个节点`)
278
+ }
279
+ merged.activeNode = (match ?? nodes[0]).tag
280
+ } else {
281
+ validateEndpoint(merged.server, 'server', errors, warnings)
282
+ merged.activeNode = 'proxy'
283
+ nodes.push({ ...merged.server, id: 'default', tag: 'proxy' })
284
+ }
285
+ merged.nodes = nodes
286
+
287
+ const inbound = merged.inbound
288
+ inbound.protocol = asString(inbound.protocol, 'http')
289
+ inbound.host = asString(inbound.host, '127.0.0.1')
290
+ inbound.port = asInt(inbound.port, DEFAULT_CONFIG.inbound.port)
291
+ const socks = inbound.socks
292
+ socks.enabled = asBool(socks.enabled, false)
293
+ socks.host = asString(socks.host, '127.0.0.1')
294
+ socks.port = asInt(socks.port, DEFAULT_CONFIG.inbound.socks.port)
295
+ socks.udp = asBool(socks.udp, false)
296
+
297
+ merged.routing.bypassDomains = asStringList(merged.routing.bypassDomains, DEFAULT_CONFIG.routing.bypassDomains)
298
+ merged.routing.blockAds = asBool(merged.routing.blockAds, false)
299
+ merged.routing.geositeCn = asBool(merged.routing.geositeCn, false)
300
+
301
+ const control = merged.control
302
+ control.enabled = asBool(control.enabled, false)
303
+ control.host = asString(control.host, '127.0.0.1')
304
+ control.port = asInt(control.port, DEFAULT_CONFIG.control.port)
305
+ control.services = asStringList(control.services, DEFAULT_CONFIG.control.services)
306
+ control.stats = asBool(control.stats, true)
307
+ control.balancerTag = sanitizeTag(asString(control.balancerTag, 'active')) || 'active'
308
+ control.timeoutMs = asInt(control.timeoutMs, DEFAULT_CONFIG.control.timeoutMs)
309
+
310
+ merged.binary.path = asString(merged.binary.path, '')
311
+ merged.binary.dataDir = asString(merged.binary.dataDir, '')
312
+
313
+ const life = merged.lifecycle
314
+ for (const key of Object.keys(DEFAULT_CONFIG.lifecycle)) {
315
+ life[key] = asInt(life[key], DEFAULT_CONFIG.lifecycle[key])
316
+ }
317
+
318
+ merged.functionalProbe.enabled = asBool(merged.functionalProbe.enabled, true)
319
+ merged.functionalProbe.url = asString(merged.functionalProbe.url, DEFAULT_CONFIG.functionalProbe.url)
320
+ merged.functionalProbe.timeoutMs = asInt(merged.functionalProbe.timeoutMs, DEFAULT_CONFIG.functionalProbe.timeoutMs)
321
+
322
+ merged.log.level = asString(merged.log.level, 'warning')
323
+
324
+ // ---- semantic validation -------------------------------------------------
325
+ if (inbound.protocol !== 'http' && inbound.protocol !== 'socks') {
326
+ errors.push({ field: 'inbound.protocol', message: 'inbound.protocol 只支持 http 或 socks' })
327
+ }
328
+ if (!LOOPBACK_HOSTS.has(inbound.host)) {
329
+ errors.push({ field: 'inbound.host', message: '入站必须绑定回环地址,127.0.0.1 / ::1 / localhost' })
330
+ }
331
+ if (!Number.isInteger(inbound.port) || inbound.port < 1 || inbound.port > 65535) {
332
+ errors.push({ field: 'inbound.port', message: '入站端口必须是 1..65535 的整数' })
333
+ }
334
+ if (socks.enabled) {
335
+ if (!LOOPBACK_HOSTS.has(socks.host)) {
336
+ errors.push({ field: 'inbound.socks.host', message: 'SOCKS 入站必须绑定回环地址' })
337
+ }
338
+ if (!Number.isInteger(socks.port) || socks.port < 1 || socks.port > 65535) {
339
+ errors.push({ field: 'inbound.socks.port', message: 'SOCKS 入站端口必须是 1..65535 的整数' })
340
+ }
341
+ if (socks.port === inbound.port) {
342
+ errors.push({ field: 'inbound.socks.port', message: 'SOCKS 入站端口不能与 HTTP 入站端口相同' })
343
+ }
344
+ }
345
+ if (!TAKEOVERS.has(merged.takeover)) {
346
+ errors.push({ field: 'takeover', message: `takeover 只支持 auto/force/off,收到 ${merged.takeover}` })
347
+ }
348
+ for (const [key, value] of Object.entries(life)) {
349
+ if (!Number.isInteger(value) || value < 0) {
350
+ errors.push({ field: `lifecycle.${key}`, message: `${key} 必须是非负整数` })
351
+ }
352
+ }
353
+ if (!['debug', 'info', 'warning', 'error', 'none'].includes(merged.log.level)) {
354
+ warnings.push(`log.level ${merged.log.level} 不是标准值,将原样传给 xray`)
355
+ }
356
+ if (merged.functionalProbe.enabled) {
357
+ try {
358
+ const probeUrl = new URL(merged.functionalProbe.url)
359
+ if (probeUrl.protocol !== 'http:') {
360
+ warnings.push('functionalProbe.url 建议使用 http:// 的 204 地址;https 会额外做一次 TLS 建连')
361
+ }
362
+ } catch {
363
+ errors.push({ field: 'functionalProbe.url', message: 'functionalProbe.url 不是合法 URL' })
364
+ }
365
+ }
366
+
367
+ if (control.enabled) {
368
+ if (!LOOPBACK_HOSTS.has(control.host)) {
369
+ errors.push({ field: 'control.host', message: 'gRPC API 必须绑定回环地址' })
370
+ }
371
+ if (!Number.isInteger(control.port) || control.port < 1 || control.port > 65535) {
372
+ errors.push({ field: 'control.port', message: 'gRPC API 端口必须是 1..65535 的整数' })
373
+ }
374
+ if (control.port === inbound.port || (socks.enabled && control.port === socks.port)) {
375
+ errors.push({ field: 'control.port', message: 'gRPC API 端口不能与任何入站端口相同' })
376
+ }
377
+ if (control.services.length === 0) {
378
+ errors.push({ field: 'control.services', message: 'control.services 不能为空' })
379
+ }
380
+ for (const service of control.services) {
381
+ if (!['HandlerService', 'StatsService', 'RoutingService', 'LoggerService'].includes(service)) {
382
+ warnings.push(`control.services 含未知服务 ${service},xray 可能拒绝启动`)
383
+ }
384
+ }
385
+ if (!Number.isInteger(control.timeoutMs) || control.timeoutMs <= 0) {
386
+ errors.push({ field: 'control.timeoutMs', message: 'control.timeoutMs 必须是正整数' })
387
+ }
388
+ }
389
+ if (nodes.length > 1 && !control.enabled) {
390
+ warnings.push('配置了多个 nodes 但 control.enabled=false,运行时切换节点不可用;将使用第一个节点')
391
+ }
392
+
393
+ return { config: merged, errors, warnings }
394
+ }
395
+
396
+ export function isLoopbackHost(host) {
397
+ const normalized = String(host ?? '').replace(/^\[|\]$/g, '').toLowerCase()
398
+ if (normalized === 'localhost' || normalized.endsWith('.localhost')) return true
399
+ if (normalized === '::1' || normalized === '::' || normalized === '0.0.0.0') return true
400
+ return /^127\./.test(normalized)
401
+ }
@@ -0,0 +1,100 @@
1
+ import { grpcUnary } from './grpc.js'
2
+ import {
3
+ decodeBalancerInfo,
4
+ decodeQueryStats,
5
+ encodeGetBalancerInfo,
6
+ encodeOverrideBalancer,
7
+ encodeQueryStats,
8
+ } from './protobuf.js'
9
+
10
+ const ROUTING_SERVICE = '/xray.app.router.command.RoutingService'
11
+ const STATS_SERVICE = '/xray.app.stats.command.StatsService'
12
+
13
+ /**
14
+ * Client for the Xray control plane exposed by the `api` inbound.
15
+ *
16
+ * The active node is switched by pinning the balancer to one outbound tag:
17
+ * every node is already an outbound in the static config, so no handler is
18
+ * added or removed and no protobuf-encoded outbound config is needed.
19
+ */
20
+ export class ControlClient {
21
+ #host
22
+ #port
23
+ #timeoutMs
24
+ #balancerTag
25
+ #logger
26
+ #state = { reachable: undefined, lastError: undefined, activeTarget: undefined, checkedAt: undefined }
27
+
28
+ constructor({ host = '127.0.0.1', port, timeoutMs = 3000, balancerTag = 'active', logger } = {}) {
29
+ this.#host = host
30
+ this.#port = port
31
+ this.#timeoutMs = timeoutMs
32
+ this.#balancerTag = balancerTag
33
+ this.#logger = logger
34
+ }
35
+
36
+ get balancerTag() {
37
+ return this.#balancerTag
38
+ }
39
+
40
+ async #call(path, message) {
41
+ try {
42
+ const response = await grpcUnary({
43
+ host: this.#host,
44
+ port: this.#port,
45
+ path,
46
+ message,
47
+ timeoutMs: this.#timeoutMs,
48
+ })
49
+ this.#state.reachable = true
50
+ this.#state.lastError = undefined
51
+ this.#state.checkedAt = Date.now()
52
+ return response
53
+ } catch (error) {
54
+ this.#state.reachable = false
55
+ this.#state.lastError = `${error.code ?? 'ERROR'}: ${error.message}`
56
+ this.#state.checkedAt = Date.now()
57
+ throw error
58
+ }
59
+ }
60
+
61
+ /** Read the balancer state; also serves as the reachability check. */
62
+ async getBalancerInfo() {
63
+ const response = await this.#call(
64
+ `${ROUTING_SERVICE}/GetBalancerInfo`,
65
+ encodeGetBalancerInfo({ tag: this.#balancerTag }),
66
+ )
67
+ const info = decodeBalancerInfo(response)
68
+ if (info.overrideTarget !== undefined) this.#state.activeTarget = info.overrideTarget
69
+ return info
70
+ }
71
+
72
+ /** Pin the balancer to one outbound tag (the runtime node switch). */
73
+ async overrideBalancerTarget(target) {
74
+ await this.#call(
75
+ `${ROUTING_SERVICE}/OverrideBalancerTarget`,
76
+ encodeOverrideBalancer({ balancerTag: this.#balancerTag, target }),
77
+ )
78
+ this.#state.activeTarget = target
79
+ this.#logger?.info?.(`已切换 Xray 出口:${target}`)
80
+ return target
81
+ }
82
+
83
+ /** Query traffic counters. Names look like `outbound>>>tag>>>traffic>>>uplink`. */
84
+ async queryStats({ patterns = ['outbound>>>'], reset = false } = {}) {
85
+ const response = await this.#call(`${STATS_SERVICE}/QueryStats`, encodeQueryStats({ patterns, reset }))
86
+ return decodeQueryStats(response)
87
+ }
88
+
89
+ snapshot() {
90
+ return {
91
+ host: this.#host,
92
+ port: this.#port,
93
+ balancerTag: this.#balancerTag,
94
+ reachable: this.#state.reachable ?? null,
95
+ activeTarget: this.#state.activeTarget ?? null,
96
+ lastError: this.#state.lastError ?? null,
97
+ checkedAt: this.#state.checkedAt ?? null,
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,121 @@
1
+ import http2 from 'node:http2'
2
+
3
+ export class GrpcError extends Error {
4
+ constructor(code, message) {
5
+ super(message)
6
+ this.name = 'GrpcError'
7
+ this.code = String(code)
8
+ }
9
+ }
10
+
11
+ /** gRPC frames every message with a one-byte compression flag and a uint32 length. */
12
+ function frame(message) {
13
+ const prefix = Buffer.alloc(5)
14
+ prefix.writeUInt8(0, 0)
15
+ prefix.writeUInt32BE(message.length, 1)
16
+ return Buffer.concat([prefix, message])
17
+ }
18
+
19
+ function unframe(buffer) {
20
+ if (buffer.length < 5) throw new GrpcError('INTERNAL', 'gRPC response body shorter than its 5-byte frame header')
21
+ const length = buffer.readUInt32BE(1)
22
+ const end = 5 + length
23
+ if (end > buffer.length) throw new GrpcError('INTERNAL', 'gRPC response frame length exceeds the body')
24
+ return buffer.subarray(5, end)
25
+ }
26
+
27
+ function decodeGrpcMessage(raw) {
28
+ if (raw === undefined) return ''
29
+ try {
30
+ return decodeURIComponent(String(raw))
31
+ } catch {
32
+ return String(raw)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * One unary gRPC call over plaintext HTTP/2 (h2c), which is what Xray's API
38
+ * inbound speaks. No TLS, no compression, no streaming.
39
+ *
40
+ * @param {object} options
41
+ * @param {string} options.host
42
+ * @param {number} options.port
43
+ * @param {string} options.path - e.g. `/xray.app.stats.command.StatsService/QueryStats`
44
+ * @param {Buffer} options.message - protobuf request body
45
+ * @param {number} [options.timeoutMs]
46
+ * @returns {Promise<Buffer>} protobuf response body
47
+ */
48
+ export function grpcUnary({ host = '127.0.0.1', port, path, message, timeoutMs = 3000 }) {
49
+ return new Promise((resolve, reject) => {
50
+ let settled = false
51
+ let client
52
+ let timer
53
+
54
+ const finish = (error, value) => {
55
+ if (settled) return
56
+ settled = true
57
+ if (timer) clearTimeout(timer)
58
+ try {
59
+ client?.close()
60
+ } catch {
61
+ // already closed
62
+ }
63
+ if (error) reject(error)
64
+ else resolve(value)
65
+ }
66
+
67
+ timer = setTimeout(() => {
68
+ finish(new GrpcError('DEADLINE_EXCEEDED', `gRPC ${path} timed out after ${timeoutMs}ms`))
69
+ }, timeoutMs)
70
+
71
+ try {
72
+ client = http2.connect(`http://${host}:${port}`)
73
+ } catch (error) {
74
+ finish(error)
75
+ return
76
+ }
77
+ client.on('error', (error) => finish(new GrpcError(error.code ?? 'UNAVAILABLE', error.message)))
78
+
79
+ let request
80
+ try {
81
+ request = client.request({
82
+ ':method': 'POST',
83
+ ':path': path,
84
+ 'content-type': 'application/grpc',
85
+ te: 'trailers',
86
+ 'grpc-accept-encoding': 'identity',
87
+ })
88
+ } catch (error) {
89
+ finish(error)
90
+ return
91
+ }
92
+
93
+ let grpcStatus
94
+ let grpcMessage
95
+ const chunks = []
96
+
97
+ request.on('response', (headers) => {
98
+ if (headers['grpc-status'] !== undefined) grpcStatus = headers['grpc-status']
99
+ if (headers['grpc-message'] !== undefined) grpcMessage = headers['grpc-message']
100
+ })
101
+ request.on('trailers', (trailers) => {
102
+ if (trailers['grpc-status'] !== undefined) grpcStatus = trailers['grpc-status']
103
+ if (trailers['grpc-message'] !== undefined) grpcMessage = trailers['grpc-message']
104
+ })
105
+ request.on('data', (chunk) => chunks.push(chunk))
106
+ request.on('error', (error) => finish(new GrpcError(error.code ?? 'UNAVAILABLE', error.message)))
107
+ request.on('end', () => {
108
+ if (grpcStatus !== undefined && Number(grpcStatus) !== 0) {
109
+ finish(new GrpcError(grpcStatus, decodeGrpcMessage(grpcMessage) || `gRPC status ${grpcStatus}`))
110
+ return
111
+ }
112
+ try {
113
+ finish(undefined, unframe(Buffer.concat(chunks)))
114
+ } catch (error) {
115
+ finish(error)
116
+ }
117
+ })
118
+
119
+ request.end(frame(message))
120
+ })
121
+ }