@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
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
|
+
}
|