@twinsearth/dsh-2xray 0.0.0-stage → 0.1.1
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 +35 -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 +197 -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/process.test.js +49 -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,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"server": {
|
|
4
|
+
"address": "example.com",
|
|
5
|
+
"port": 443,
|
|
6
|
+
"uuid": "11111111-2222-4333-8444-555555555555",
|
|
7
|
+
"flow": "",
|
|
8
|
+
"serverName": "",
|
|
9
|
+
"network": "tcp",
|
|
10
|
+
"security": "none"
|
|
11
|
+
},
|
|
12
|
+
"inbound": {
|
|
13
|
+
"protocol": "http",
|
|
14
|
+
"host": "127.0.0.1",
|
|
15
|
+
"port": 20808
|
|
16
|
+
},
|
|
17
|
+
"functionalProbe": {
|
|
18
|
+
"enabled": false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"server": {
|
|
4
|
+
"address": "example.com",
|
|
5
|
+
"port": 443,
|
|
6
|
+
"uuid": "00000000-0000-0000-0000-000000000000",
|
|
7
|
+
"flow": "xtls-rprx-vision",
|
|
8
|
+
"serverName": "www.example.com",
|
|
9
|
+
"password": "REPLACE_WITH_43_CHAR_BASE64URL_X25519_PUBLIC_KEY",
|
|
10
|
+
"shortId": "",
|
|
11
|
+
"fingerprint": "chrome",
|
|
12
|
+
"spiderX": "/",
|
|
13
|
+
"network": "raw",
|
|
14
|
+
"security": "reality"
|
|
15
|
+
},
|
|
16
|
+
"inbound": {
|
|
17
|
+
"protocol": "http",
|
|
18
|
+
"host": "127.0.0.1",
|
|
19
|
+
"port": 20808
|
|
20
|
+
},
|
|
21
|
+
"takeover": "auto"
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"nodes": [
|
|
4
|
+
{
|
|
5
|
+
"id": "hk-01",
|
|
6
|
+
"address": "hk.example.com",
|
|
7
|
+
"port": 443,
|
|
8
|
+
"uuid": "11111111-2222-4333-8444-555555555555",
|
|
9
|
+
"flow": "",
|
|
10
|
+
"network": "tcp",
|
|
11
|
+
"security": "none"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "jp-01",
|
|
15
|
+
"address": "jp.example.com",
|
|
16
|
+
"port": 443,
|
|
17
|
+
"uuid": "11111111-2222-4333-8444-555555555555",
|
|
18
|
+
"flow": "",
|
|
19
|
+
"network": "tcp",
|
|
20
|
+
"security": "none"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"activeNode": "hk-01",
|
|
24
|
+
"inbound": {
|
|
25
|
+
"protocol": "http",
|
|
26
|
+
"host": "127.0.0.1",
|
|
27
|
+
"port": 20808
|
|
28
|
+
},
|
|
29
|
+
"control": {
|
|
30
|
+
"enabled": true,
|
|
31
|
+
"host": "127.0.0.1",
|
|
32
|
+
"port": 10085,
|
|
33
|
+
"stats": true
|
|
34
|
+
},
|
|
35
|
+
"functionalProbe": {
|
|
36
|
+
"enabled": false
|
|
37
|
+
},
|
|
38
|
+
"takeover": "off"
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"nodes": [
|
|
4
|
+
{
|
|
5
|
+
"id": "hk-01",
|
|
6
|
+
"address": "hk.example.com",
|
|
7
|
+
"port": 443,
|
|
8
|
+
"uuid": "11111111-2222-4333-8444-555555555555",
|
|
9
|
+
"flow": "xtls-rprx-vision",
|
|
10
|
+
"serverName": "www.example.com",
|
|
11
|
+
"password": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
12
|
+
"shortId": "ab12",
|
|
13
|
+
"fingerprint": "chrome",
|
|
14
|
+
"spiderX": "/",
|
|
15
|
+
"network": "raw",
|
|
16
|
+
"security": "reality"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "jp-01",
|
|
20
|
+
"address": "jp.example.com",
|
|
21
|
+
"port": 443,
|
|
22
|
+
"uuid": "11111111-2222-4333-8444-555555555555",
|
|
23
|
+
"flow": "xtls-rprx-vision",
|
|
24
|
+
"serverName": "www.example.com",
|
|
25
|
+
"password": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
26
|
+
"shortId": "ab12",
|
|
27
|
+
"fingerprint": "chrome",
|
|
28
|
+
"spiderX": "/",
|
|
29
|
+
"network": "raw",
|
|
30
|
+
"security": "reality"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"activeNode": "hk-01",
|
|
34
|
+
"inbound": {
|
|
35
|
+
"protocol": "http",
|
|
36
|
+
"host": "127.0.0.1",
|
|
37
|
+
"port": 20808
|
|
38
|
+
},
|
|
39
|
+
"control": {
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"host": "127.0.0.1",
|
|
42
|
+
"port": 10085,
|
|
43
|
+
"stats": true
|
|
44
|
+
},
|
|
45
|
+
"takeover": "auto"
|
|
46
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Xray Proxy">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
|
3
|
+
<circle cx="20" cy="32" r="7" fill="none" stroke="#38bdf8" stroke-width="3"/>
|
|
4
|
+
<circle cx="44" cy="32" r="7" fill="none" stroke="#a78bfa" stroke-width="3"/>
|
|
5
|
+
<path d="M27 32h10" stroke="#e2e8f0" stroke-width="3" stroke-linecap="round"/>
|
|
6
|
+
<path d="M43 32l-6-4v8l6-4z" fill="#e2e8f0"/>
|
|
7
|
+
<path d="M20 25v-7M20 46v-7" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
|
|
8
|
+
<path d="M44 25v-7M44 46v-7" stroke="#a78bfa" stroke-width="2.5" stroke-linecap="round"/>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { normalizeConfig } from './lib/config.js'
|
|
2
|
+
import { createLazyLauncher } from './lib/process.js'
|
|
3
|
+
import { RouteManager } from './lib/route.js'
|
|
4
|
+
import { Supervisor } from './lib/supervisor.js'
|
|
5
|
+
import { createLogger } from './lib/log.js'
|
|
6
|
+
import { resolveDataDir } from './lib/paths.js'
|
|
7
|
+
import { ControlClient } from './lib/control/client.js'
|
|
8
|
+
import { installTools } from './lib/tools.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-2xray'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Host half of dsh-2xray: it supervises one xray-core child process and
|
|
14
|
+
* points the harness' process-global proxy policy at the local inbound.
|
|
15
|
+
*
|
|
16
|
+
* The entry point never throws for a bad configuration, a missing binary, or
|
|
17
|
+
* an unreachable upstream: those become a `failed(...)` state with a reason
|
|
18
|
+
* chain in the log and `status.json`. That keeps the patch row active, so the
|
|
19
|
+
* user can fix the config instead of losing the plugin entirely.
|
|
20
|
+
*
|
|
21
|
+
* `apply` returns the cleanup registered through `ctx.effect`. Cordis has no
|
|
22
|
+
* `ctx.on('dispose')` event (checked against 0.2.1-rc.3): `ctx.effect` and the
|
|
23
|
+
* disposer returned from `apply` are the lifecycle seam.
|
|
24
|
+
*/
|
|
25
|
+
export function apply(ctx, rawConfig) {
|
|
26
|
+
const logger = createLogger(ctx)
|
|
27
|
+
const { config, errors, warnings } = normalizeConfig(rawConfig)
|
|
28
|
+
|
|
29
|
+
for (const warning of warnings) logger.warn(`配置警告:${warning}`)
|
|
30
|
+
|
|
31
|
+
let dataDir
|
|
32
|
+
try {
|
|
33
|
+
dataDir = resolveDataDir(config)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
errors.push({ field: 'binary.dataDir', message: `无法创建数据目录:${String(error)}` })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (config.enabled && !errors.length) {
|
|
39
|
+
logger.info(
|
|
40
|
+
`启动 xray:nodes=${config.nodes.length} active=${config.activeNode} ` +
|
|
41
|
+
`flow=${config.nodes[0].flow || '(none)'} inbound=${config.inbound.protocol}://${config.inbound.host}:${config.inbound.port} ` +
|
|
42
|
+
`takeover=${config.takeover} control=${config.control.enabled ? `127.0.0.1:${config.control.port}` : 'off'} ` +
|
|
43
|
+
`dataDir=${dataDir ?? '(unavailable)'}`,
|
|
44
|
+
)
|
|
45
|
+
if (config.inbound.protocol === 'socks') {
|
|
46
|
+
logger.warn('inbound.protocol=socks 时 DSH 的代理策略不覆盖 web_fetch;除非确有非 Node 客户端需要 SOCKS,建议改回 http')
|
|
47
|
+
}
|
|
48
|
+
if (!config.server.password && config.server.publicKey) {
|
|
49
|
+
logger.info('使用旧字段 publicKey 配置 REALITY;xray 会同时读取 password,两者都写可兼容新旧版本')
|
|
50
|
+
} }
|
|
51
|
+
|
|
52
|
+
const launcher = createLazyLauncher(ctx, { logger })
|
|
53
|
+
const route = new RouteManager({ logger })
|
|
54
|
+
const control =
|
|
55
|
+
config.control.enabled
|
|
56
|
+
? new ControlClient({
|
|
57
|
+
host: config.control.host,
|
|
58
|
+
port: config.control.port,
|
|
59
|
+
timeoutMs: config.control.timeoutMs,
|
|
60
|
+
balancerTag: config.control.balancerTag,
|
|
61
|
+
logger,
|
|
62
|
+
})
|
|
63
|
+
: undefined
|
|
64
|
+
const supervisor = new Supervisor({
|
|
65
|
+
config,
|
|
66
|
+
errors,
|
|
67
|
+
warnings,
|
|
68
|
+
launcher,
|
|
69
|
+
route,
|
|
70
|
+
control,
|
|
71
|
+
logger,
|
|
72
|
+
dataDir,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const dispose = ctx.effect(
|
|
76
|
+
() => async () => {
|
|
77
|
+
await supervisor.stop()
|
|
78
|
+
},
|
|
79
|
+
'dsh-2xray/xray-supervisor',
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Detached by design: activation must not block the harness boot on xray
|
|
83
|
+
// startup. Every rejection is caught — an unhandled one would dispose the
|
|
84
|
+
// whole application.
|
|
85
|
+
supervisor.start().catch((error) => logger.error(`xray 监督器异常:${String(error)}`))
|
|
86
|
+
installTools({ ctx, supervisor, logger }).catch((error) => logger.warn(`安装 xray_* 工具失败:${String(error)}`))
|
|
87
|
+
|
|
88
|
+
return dispose
|
|
89
|
+
}
|
package/lib/binary.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync } from 'node:fs'
|
|
2
|
+
import { delimiter, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/** Package root, so a bundle that ships its own `bin/xray(.exe)` is found first. */
|
|
6
|
+
export const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
|
|
7
|
+
|
|
8
|
+
function raceTimeout(promise, ms, timeoutValue) {
|
|
9
|
+
let timer
|
|
10
|
+
return Promise.race([
|
|
11
|
+
promise,
|
|
12
|
+
new Promise((resolve) => {
|
|
13
|
+
timer = setTimeout(() => resolve(timeoutValue), ms)
|
|
14
|
+
timer.unref?.()
|
|
15
|
+
}),
|
|
16
|
+
]).finally(() => clearTimeout(timer))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class BinaryError extends Error {
|
|
20
|
+
constructor(message, code = 'binary-missing', candidates = []) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'BinaryError'
|
|
23
|
+
this.code = code
|
|
24
|
+
this.candidates = candidates
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function executableName() {
|
|
29
|
+
return process.platform === 'win32' ? 'xray.exe' : 'xray'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isExecutable(file) {
|
|
33
|
+
try {
|
|
34
|
+
accessSync(file, constants.X_OK)
|
|
35
|
+
return true
|
|
36
|
+
} catch {
|
|
37
|
+
// Windows has no execute bit; X_OK approximates "readable and present".
|
|
38
|
+
return existsSync(file) && process.platform === 'win32'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the xray executable without downloading anything (v1 never fetches).
|
|
44
|
+
*
|
|
45
|
+
* Order: explicit `binary.path`, `DSH_XRAY_BIN`, the plugin's own `bin/` (a
|
|
46
|
+
* bundle may ship the binary), the managed data directory's `bin/`, then PATH
|
|
47
|
+
* (through `ctx.subprocess.resolveExecutable` when available).
|
|
48
|
+
*
|
|
49
|
+
* @returns {Promise<{ path: string, source: 'config'|'env'|'bundled'|'managed'|'path' }>}
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveBinary({
|
|
52
|
+
config,
|
|
53
|
+
dataDir,
|
|
54
|
+
launcher,
|
|
55
|
+
exists = isExecutable,
|
|
56
|
+
packageRoot = PACKAGE_ROOT,
|
|
57
|
+
}) {
|
|
58
|
+
const candidates = []
|
|
59
|
+
const explicit = config.binary.path?.trim()
|
|
60
|
+
if (explicit) candidates.push({ path: explicit, source: 'config' })
|
|
61
|
+
const fromEnv = process.env.DSH_XRAY_BIN?.trim()
|
|
62
|
+
if (fromEnv) candidates.push({ path: fromEnv, source: 'env' })
|
|
63
|
+
candidates.push({ path: join(packageRoot, 'bin', executableName()), source: 'bundled' })
|
|
64
|
+
candidates.push({ path: join(packageRoot, 'bin', 'xray'), source: 'bundled' })
|
|
65
|
+
candidates.push({ path: join(dataDir, 'bin', executableName()), source: 'managed' })
|
|
66
|
+
candidates.push({ path: join(dataDir, 'bin', 'xray'), source: 'managed' })
|
|
67
|
+
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
if (exists(candidate.path)) return { path: candidate.path, source: candidate.source }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// PATH lookup, honouring PATHEXT on Windows.
|
|
73
|
+
if (launcher?.resolveExecutable) {
|
|
74
|
+
try {
|
|
75
|
+
const resolved = await launcher.resolveExecutable('xray')
|
|
76
|
+
if (typeof resolved === 'string' && resolved) {
|
|
77
|
+
return { path: resolved, source: 'path' }
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// fall through to the manual scan below
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const names = process.platform === 'win32' ? ['xray.exe', 'xray'] : ['xray']
|
|
85
|
+
const pathEntries = (process.env.PATH ?? '').split(delimiter).filter(Boolean)
|
|
86
|
+
for (const entry of pathEntries) {
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const candidate = join(entry, name)
|
|
89
|
+
if (exists(candidate)) return { path: candidate, source: 'path' }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new BinaryError(
|
|
94
|
+
`找不到 xray-core 可执行文件。已尝试:${candidates.map((candidate) => candidate.path).join(', ') || '(无显式路径)'},以及 PATH 中的 ${pathEntries.join(delimiter)}。` +
|
|
95
|
+
'请把 xray 放进插件自己的 bin/、数据目录的 bin/,或设置 config.binary.path。',
|
|
96
|
+
'binary-missing',
|
|
97
|
+
candidates.map((candidate) => candidate.path),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run `xray version` and parse the version line. A failed probe (missing
|
|
103
|
+
* dynamic library, wrong architecture, antivirus block) is a startup failure,
|
|
104
|
+
* so it is reported rather than ignored; only an unparsable version is a
|
|
105
|
+
* warning.
|
|
106
|
+
*/
|
|
107
|
+
export async function probeVersion({ launcher, binaryPath, timeoutMs = 5000 }) {
|
|
108
|
+
const handle = launcher.spawn({
|
|
109
|
+
argv: [binaryPath, 'version'],
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
graceMs: Math.max(500, timeoutMs),
|
|
112
|
+
env: {},
|
|
113
|
+
})
|
|
114
|
+
const outcome = await raceTimeout(handle.done, timeoutMs, { timedOut: true })
|
|
115
|
+
if (outcome?.timedOut) {
|
|
116
|
+
await handle.terminate()
|
|
117
|
+
return { ok: false, version: undefined, detail: 'xray version 探测超时' }
|
|
118
|
+
}
|
|
119
|
+
if (outcome?.error) {
|
|
120
|
+
return { ok: false, version: undefined, detail: String(outcome.error) }
|
|
121
|
+
}
|
|
122
|
+
const text = `${handle.stdoutTail?.() ?? ''}\n${handle.stderrTail?.() ?? ''}`
|
|
123
|
+
if (outcome.exitCode !== 0) {
|
|
124
|
+
return { ok: false, version: undefined, detail: tail(text) || `xray version 退出码 ${outcome.exitCode}` }
|
|
125
|
+
}
|
|
126
|
+
const version = /Xray\s+([0-9][^\s(]*)/i.exec(text)?.[1]
|
|
127
|
+
return { ok: true, version, detail: version ? `xray ${version}` : tail(text) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function tail(text, limit = 1200) {
|
|
131
|
+
const trimmed = String(text ?? '').trim()
|
|
132
|
+
return trimmed.length > limit ? `…${trimmed.slice(-limit)}` : trimmed
|
|
133
|
+
}
|