@twinsearth/dsh-2xray 0.2.6 → 0.2.8
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 +23 -0
- package/README.md +18 -0
- package/lib/assets.js +42 -0
- package/lib/binary.js +6 -0
- package/lib/client.js +35 -1
- package/lib/config.js +44 -0
- package/lib/route.js +5 -1
- package/lib/schema.js +14 -0
- package/lib/supervisor.js +19 -2
- package/lib/xray-config.js +25 -2
- package/package.json +1 -1
- package/test/assets.test.js +35 -0
- package/test/install-core.test.js +22 -0
- package/test/route.test.js +8 -0
- package/test/routing.test.js +73 -0
- package/tools/install-xray-core.mjs +128 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.8 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- `cn-direct` 模式设置 `routing.domainStrategy: IPIfNonMatch`:未被 `geosite:cn` 命中的域名会解析为 IP 再匹配 `geoip:cn`,国内站点不再漏判。
|
|
8
|
+
- `XRAY_LOCATION_ASSET` 显式传入 `run` 与 `run -test` 的 spawn 环境:服务启动器会清理环境,之前 geodata 路径可能丢失,导致 `geoip:cn`/`geosite:cn` 静默失效。
|
|
9
|
+
- 启动与 `xray_validate_config` 前都先复制 `geoip.dat`/`geosite.dat` 到数据目录;缺失时日志明确告警。
|
|
10
|
+
|
|
11
|
+
## 0.2.7 — 2026-10-29
|
|
12
|
+
|
|
13
|
+
### 新增
|
|
14
|
+
|
|
15
|
+
- **路由模式**:`global` / `cn-direct`(国内 IP、域名直连)/ `whitelist`(仅白名单走 2Xray)。
|
|
16
|
+
- **黑白名单**:白名单默认 github.com、google.com、youtube.com、x.com、facebook.com;黑名单默认 deepseek.com、api.deepseek.com。黑名单优先,并并入 `noProxy`。
|
|
17
|
+
- **内核安装脚本** `tools/install-xray-core.mjs`:官方 release + `.dgst` SHA2-256 校验 + 解压到数据目录。
|
|
18
|
+
- 侧边栏「实时连接信息」默认展开日志。
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
|
|
22
|
+
- macOS 自动发现补充 `~/Library/Application Support/v2rayN`、`/Applications` 等路径。
|
|
23
|
+
- 路由验证 URL 改为保留域 `dsh-2xray-verify.invalid`,不再被 `noProxy: deepseek.com` 绕过(route-instance-mismatch 根因)。
|
|
24
|
+
- 非 Windows 下入站端口 <1024 自动回落到 20808(避免把远端 556 当本地端口导致 EACCES)。
|
|
25
|
+
|
|
3
26
|
## 0.2.6 — 2026-10-29
|
|
4
27
|
|
|
5
28
|
### 内置浏览器支持
|
package/README.md
CHANGED
|
@@ -328,6 +328,24 @@ v0.2.0 提供两个入口,互不替代:
|
|
|
328
328
|
| 状态一直 `degraded` + `route-install-failed` | 查日志里的模块解析诊断;必要时确认 DSH 安装目录结构 |
|
|
329
329
|
| `xray_switch_node` 报 `unknown service ... RoutingService` | xray 版本早于 2024-09;升级二进制,或关闭 `control.enabled` 只用 `activeNode` |
|
|
330
330
|
|
|
331
|
+
## 路由模式与黑白名单
|
|
332
|
+
|
|
333
|
+
- `global`(默认):全部流量走 2Xray。
|
|
334
|
+
- `cn-direct`:`geoip:cn` / `geosite:cn` 直连,其余走代理。插件会从 xray 二进制旁复制 `geoip.dat`/`geosite.dat` 到数据目录,并设置 `XRAY_LOCATION_ASSET`。
|
|
335
|
+
- `whitelist`:仅白名单域名走 2Xray,其余直连。
|
|
336
|
+
|
|
337
|
+
黑名单优先于白名单与分流,并会同时加入 DSH 的 `noProxy`(xray 重启时也不受影响)。默认白名单:github.com、google.com、youtube.com、x.com、facebook.com;默认黑名单:deepseek.com、api.deepseek.com。
|
|
338
|
+
|
|
339
|
+
## 内核安装(macOS / Linux)
|
|
340
|
+
|
|
341
|
+
npm 包不内嵌五平台内核。未找到内核时:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
node tools/install-xray-core.mjs 26.9.30
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
脚本下载官方 release 资产、校验 `.dgst` 的 SHA2-256、解压到 `~/.dsh/xray-proxy/bin/`。GitHub 不可达时设置 `DSH_XRAY_MIRROR` 指向镜像。macOS 也会自动搜索 `~/Library/Application Support/v2rayN/bin/xray/xray`。
|
|
348
|
+
|
|
331
349
|
## 内置浏览器(Chromium)
|
|
332
350
|
|
|
333
351
|
DSH 内置浏览器走 Chromium 网络栈,不读 undici dispatcher,插件无法在运行时改它的代理。支持方式只有启动参数:
|
package/lib/assets.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const FILES = ['geoip.dat', 'geosite.dat']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `geoip:cn` / `geosite:cn` need data files next to the binary or in
|
|
8
|
+
* XRAY_LOCATION_ASSET. A borrowed v2rayN binary keeps them one directory up,
|
|
9
|
+
* so copy them into the managed data dir and point xray there.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureXrayAssets({ binaryPath, dataDir, logger } = {}) {
|
|
12
|
+
if (!dataDir) return { dir: undefined, copied: [], missing: FILES }
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(dataDir, { recursive: true })
|
|
15
|
+
} catch (error) {
|
|
16
|
+
logger?.warn?.(`无法创建数据目录:${String(error)}`)
|
|
17
|
+
return { dir: dataDir, copied: [], missing: FILES }
|
|
18
|
+
}
|
|
19
|
+
const candidates = [dataDir, dirname(binaryPath ?? ''), dirname(dirname(binaryPath ?? ''))]
|
|
20
|
+
const copied = []
|
|
21
|
+
const missing = []
|
|
22
|
+
for (const name of FILES) {
|
|
23
|
+
const target = join(dataDir, name)
|
|
24
|
+
if (existsSync(target)) continue
|
|
25
|
+
const source = candidates.find((dir) => dir && existsSync(join(dir, name)))
|
|
26
|
+
if (!source) {
|
|
27
|
+
missing.push(name)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
copyFileSync(join(source, name), target)
|
|
32
|
+
copied.push(name)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
logger?.warn?.(`复制 ${name} 失败:${String(error)}`)
|
|
35
|
+
missing.push(name)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (missing.length > 0) {
|
|
39
|
+
logger?.warn?.(`缺少 xray 数据文件 ${missing.join(', ')};cn-direct / geosite 规则可能启动失败,可从 v2rayN 的 bin/ 目录复制到 ${dataDir}`)
|
|
40
|
+
}
|
|
41
|
+
return { dir: dataDir, copied, missing }
|
|
42
|
+
}
|
package/lib/binary.js
CHANGED
|
@@ -117,6 +117,12 @@ export function defaultDiscoveryRoots() {
|
|
|
117
117
|
local,
|
|
118
118
|
home ? join(home, 'Desktop') : '',
|
|
119
119
|
home ? join(home, 'Downloads') : '',
|
|
120
|
+
// macOS: v2rayN keeps its core under Application Support, not ~/bin.
|
|
121
|
+
home ? join(home, 'Library', 'Application Support') : '',
|
|
122
|
+
home ? join(home, '.local', 'bin') : '',
|
|
123
|
+
'/Applications',
|
|
124
|
+
'/opt/homebrew/bin',
|
|
125
|
+
'/usr/local/bin',
|
|
120
126
|
'C:\\Program Files',
|
|
121
127
|
'C:\\Program Files (x86)',
|
|
122
128
|
'D:\\',
|
package/lib/client.js
CHANGED
|
@@ -176,7 +176,7 @@ window.__ModuleLoader__.load({
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function RuntimeCard({ status, logs, setLogs }) {
|
|
179
|
-
const [showLogs, setShowLogs] = React.useState(
|
|
179
|
+
const [showLogs, setShowLogs] = React.useState(true)
|
|
180
180
|
const inbound = status?.inbound
|
|
181
181
|
const inboundLabel = inbound
|
|
182
182
|
? `${String(inbound.protocol).toUpperCase()}:${inbound.port}${inbound.socks ? ` · SOCKS5:${inbound.socks.port}` : ''}`
|
|
@@ -313,6 +313,9 @@ window.__ModuleLoader__.load({
|
|
|
313
313
|
const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
|
|
314
314
|
const [enabled, setEnabled] = React.useState(value.enabled === true)
|
|
315
315
|
const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
|
|
316
|
+
const [routingMode, setRoutingMode] = React.useState(value.routing?.mode ?? 'global')
|
|
317
|
+
const [whitelist, setWhitelist] = React.useState((value.routing?.whitelist ?? []).join('\n'))
|
|
318
|
+
const [blacklist, setBlacklist] = React.useState((value.routing?.blacklist ?? []).join('\n'))
|
|
316
319
|
const [message, setMessage] = React.useState('')
|
|
317
320
|
const [kind, setKind] = React.useState('')
|
|
318
321
|
const [busy, setBusy] = React.useState(false)
|
|
@@ -411,10 +414,18 @@ window.__ModuleLoader__.load({
|
|
|
411
414
|
const save = async () => {
|
|
412
415
|
setBusy(true)
|
|
413
416
|
try {
|
|
417
|
+
const splitList = (text) =>
|
|
418
|
+
String(text)
|
|
419
|
+
.split(/[\n,]+/)
|
|
420
|
+
.map((entry) => entry.trim())
|
|
421
|
+
.filter(Boolean)
|
|
414
422
|
const fields = [
|
|
415
423
|
{ path: ['shareLink'], value: shareLink.trim() },
|
|
416
424
|
{ path: ['enabled'], value: enabled },
|
|
417
425
|
{ path: ['takeover'], value: takeover },
|
|
426
|
+
{ path: ['routing', 'mode'], value: routingMode },
|
|
427
|
+
{ path: ['routing', 'whitelist'], value: splitList(whitelist) },
|
|
428
|
+
{ path: ['routing', 'blacklist'], value: splitList(blacklist) },
|
|
418
429
|
]
|
|
419
430
|
if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
|
|
420
431
|
const numericPort = Number(port)
|
|
@@ -449,6 +460,29 @@ window.__ModuleLoader__.load({
|
|
|
449
460
|
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 10 } }, 'takeover',
|
|
450
461
|
h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
|
|
451
462
|
h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
|
|
463
|
+
h(Field, { label: '路由模式' },
|
|
464
|
+
h('select', {
|
|
465
|
+
value: routingMode,
|
|
466
|
+
onChange: (event) => setRoutingMode(event.target.value),
|
|
467
|
+
style: { ...C.input, width: 'auto' },
|
|
468
|
+
},
|
|
469
|
+
h('option', { value: 'global' }, '全局:全部走 2Xray'),
|
|
470
|
+
h('option', { value: 'cn-direct' }, '国内直连:国内 IP/域名不走 VPN,国外走'),
|
|
471
|
+
h('option', { value: 'whitelist' }, '白名单:仅白名单走 2Xray'))),
|
|
472
|
+
h(Field, { label: '白名单(强制走 2Xray,每行一个域名)' },
|
|
473
|
+
h('textarea', {
|
|
474
|
+
style: { ...C.input, minHeight: 58, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
475
|
+
value: whitelist,
|
|
476
|
+
placeholder: 'github.com\ngoogle.com\nyoutube.com\nx.com\nfacebook.com',
|
|
477
|
+
onChange: (event) => setWhitelist(event.target.value),
|
|
478
|
+
})),
|
|
479
|
+
h(Field, { label: '黑名单(强制直连,每行一个域名,优先于白名单)' },
|
|
480
|
+
h('textarea', {
|
|
481
|
+
style: { ...C.input, minHeight: 58, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
482
|
+
value: blacklist,
|
|
483
|
+
placeholder: 'deepseek.com\napi.deepseek.com',
|
|
484
|
+
onChange: (event) => setBlacklist(event.target.value),
|
|
485
|
+
})),
|
|
452
486
|
h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
|
|
453
487
|
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\...\\bin\\xray\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
454
488
|
h(Field, { label: '本地 HTTP 入站端口' },
|
package/lib/config.js
CHANGED
|
@@ -51,6 +51,9 @@ export const DEFAULT_CONFIG = {
|
|
|
51
51
|
takeover: 'auto',
|
|
52
52
|
noProxy: '',
|
|
53
53
|
routing: {
|
|
54
|
+
mode: 'global',
|
|
55
|
+
whitelist: ['github.com', 'google.com', 'youtube.com', 'x.com', 'facebook.com'],
|
|
56
|
+
blacklist: ['deepseek.com', 'api.deepseek.com'],
|
|
54
57
|
bypassDomains: ['localhost', 'local', 'lan', 'internal', 'corp', 'home.arpa'],
|
|
55
58
|
blockAds: false,
|
|
56
59
|
geositeCn: false,
|
|
@@ -139,6 +142,20 @@ function asStringList(value, fallback) {
|
|
|
139
142
|
return clone(fallback)
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
/** Accept URLs as well as bare domains; routing rules only understand hosts. */
|
|
146
|
+
function normalizeDomainList(entries) {
|
|
147
|
+
const result = []
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
let text = String(entry ?? '').trim().toLowerCase()
|
|
150
|
+
if (!text) continue
|
|
151
|
+
text = text.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').split('/')[0].split(':')[0]
|
|
152
|
+
text = text.replace(/^\*\./, '').replace(/\.$/, '')
|
|
153
|
+
if (!/^[a-z0-9.-]+$/.test(text) || !text.includes('.')) continue
|
|
154
|
+
if (!result.includes(text)) result.push(text)
|
|
155
|
+
}
|
|
156
|
+
return result
|
|
157
|
+
}
|
|
158
|
+
|
|
142
159
|
export function sanitizeTag(value) {
|
|
143
160
|
return String(value ?? '')
|
|
144
161
|
.trim()
|
|
@@ -316,6 +333,22 @@ export function normalizeConfig(raw) {
|
|
|
316
333
|
merged.routing.bypassDomains = asStringList(merged.routing.bypassDomains, DEFAULT_CONFIG.routing.bypassDomains)
|
|
317
334
|
merged.routing.blockAds = asBool(merged.routing.blockAds, false)
|
|
318
335
|
merged.routing.geositeCn = asBool(merged.routing.geositeCn, false)
|
|
336
|
+
merged.routing.mode = asString(merged.routing.mode, 'global')
|
|
337
|
+
merged.routing.whitelist = normalizeDomainList(
|
|
338
|
+
asStringList(merged.routing.whitelist, DEFAULT_CONFIG.routing.whitelist),
|
|
339
|
+
)
|
|
340
|
+
merged.routing.blacklist = normalizeDomainList(
|
|
341
|
+
asStringList(merged.routing.blacklist, DEFAULT_CONFIG.routing.blacklist),
|
|
342
|
+
)
|
|
343
|
+
if (merged.routing.blacklist.length > 0) {
|
|
344
|
+
// Bypass the plugin at the dispatcher level too: a blacklisted host must
|
|
345
|
+
// work even when xray is restarting.
|
|
346
|
+
const existing = String(merged.noProxy ?? '')
|
|
347
|
+
.split(/[,\s]+/)
|
|
348
|
+
.map((entry) => entry.trim())
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
merged.noProxy = [...new Set([...existing, ...merged.routing.blacklist])].join(',')
|
|
351
|
+
}
|
|
319
352
|
|
|
320
353
|
const control = merged.control
|
|
321
354
|
control.enabled = asBool(control.enabled, false)
|
|
@@ -344,11 +377,22 @@ export function normalizeConfig(raw) {
|
|
|
344
377
|
if (inbound.protocol !== 'http' && inbound.protocol !== 'socks') {
|
|
345
378
|
errors.push({ field: 'inbound.protocol', message: 'inbound.protocol 只支持 http 或 socks' })
|
|
346
379
|
}
|
|
380
|
+
if (!['global', 'cn-direct', 'whitelist'].includes(merged.routing.mode)) {
|
|
381
|
+
errors.push({ field: 'routing.mode', message: 'routing.mode 只支持 global、cn-direct、whitelist' })
|
|
382
|
+
}
|
|
383
|
+
if (merged.routing.mode === 'whitelist' && merged.routing.whitelist.length === 0) {
|
|
384
|
+
warnings.push('routing.mode=whitelist 但白名单为空:所有流量都会直连')
|
|
385
|
+
}
|
|
347
386
|
if (!LOOPBACK_HOSTS.has(inbound.host)) {
|
|
348
387
|
errors.push({ field: 'inbound.host', message: '入站必须绑定回环地址,127.0.0.1 / ::1 / localhost' })
|
|
349
388
|
}
|
|
350
389
|
if (!Number.isInteger(inbound.port) || inbound.port < 1 || inbound.port > 65535) {
|
|
351
390
|
errors.push({ field: 'inbound.port', message: '入站端口必须是 1..65535 的整数' })
|
|
391
|
+
} else if (inbound.port < 1024 && process.platform !== 'win32') {
|
|
392
|
+
// A remote node port (e.g. 556) must never become the local listener:
|
|
393
|
+
// binding <1024 needs root on macOS/Linux and xray fails with EACCES.
|
|
394
|
+
warnings.push(`入站端口 ${inbound.port} 是特权端口,macOS/Linux 下无法绑定;已改用 ${DEFAULT_CONFIG.inbound.port}`)
|
|
395
|
+
inbound.port = DEFAULT_CONFIG.inbound.port
|
|
352
396
|
}
|
|
353
397
|
if (socks.enabled) {
|
|
354
398
|
if (!LOOPBACK_HOSTS.has(socks.host)) {
|
package/lib/route.js
CHANGED
|
@@ -3,7 +3,11 @@ import { dirname, join } from 'node:path'
|
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
|
|
5
5
|
const PROXY_PACKAGE = '@deepseek-ai/dsh-http-proxy'
|
|
6
|
-
|
|
6
|
+
// Route verification must not depend on the data plane it checks: a real
|
|
7
|
+
// target such as api.deepseek.com can legitimately be listed in noProxy, which
|
|
8
|
+
// would make a working install look unproxied. A reserved .invalid host is
|
|
9
|
+
// never bypassed; proxyRouteFor only resolves the route, it never fetches.
|
|
10
|
+
export const PROBE_URL = 'https://dsh-2xray-verify.invalid/'
|
|
7
11
|
const DISPATCHER_SYMBOLS = [Symbol.for('undici.globalDispatcher.2'), Symbol.for('undici.globalDispatcher.1')]
|
|
8
12
|
|
|
9
13
|
export class RouteError extends Error {
|
package/lib/schema.js
CHANGED
|
@@ -93,4 +93,18 @@ export const Config = Schema.object({
|
|
|
93
93
|
.description('启用 gRPC 控制面(多节点切换与流量统计,需 xray 2024-09+)。')
|
|
94
94
|
.volatile(),
|
|
95
95
|
}),
|
|
96
|
+
routing: Schema.object({
|
|
97
|
+
mode: Schema.string()
|
|
98
|
+
.default('global')
|
|
99
|
+
.description('global:全部走代理;cn-direct:国内 IP/域名直连;whitelist:仅白名单走代理。')
|
|
100
|
+
.volatile(),
|
|
101
|
+
whitelist: Schema.array(Schema.string())
|
|
102
|
+
.default(['github.com', 'google.com', 'youtube.com', 'x.com', 'facebook.com'])
|
|
103
|
+
.description('强制走 2Xray 的域名(匹配子域名)。')
|
|
104
|
+
.volatile(),
|
|
105
|
+
blacklist: Schema.array(Schema.string())
|
|
106
|
+
.default(['deepseek.com', 'api.deepseek.com'])
|
|
107
|
+
.description('强制直连的域名(优先于白名单与分流)。')
|
|
108
|
+
.volatile(),
|
|
109
|
+
}),
|
|
96
110
|
})
|
package/lib/supervisor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { rmSync } from 'node:fs'
|
|
3
|
+
import { ensureXrayAssets } from './assets.js'
|
|
3
4
|
import { buildXrayConfig } from './xray-config.js'
|
|
4
5
|
import { resolveBinary, probeVersion, tail } from './binary.js'
|
|
5
6
|
import { httpProxyProbe, portIsFree, waitForPort } from './health.js'
|
|
@@ -222,6 +223,15 @@ export class Supervisor {
|
|
|
222
223
|
this.#binary = binary
|
|
223
224
|
if (signal.aborted) return
|
|
224
225
|
|
|
226
|
+
if (this.dataDir) {
|
|
227
|
+
const routing = this.config.routing ?? {}
|
|
228
|
+
if (routing.mode === 'cn-direct' || routing.geositeCn || routing.blockAds) {
|
|
229
|
+
const assets = ensureXrayAssets({ binaryPath: binary.path, dataDir: this.dataDir, logger: this.#logger })
|
|
230
|
+
process.env.XRAY_LOCATION_ASSET = this.dataDir
|
|
231
|
+
if (assets.copied.length > 0) this.#logger?.info?.(`已复制 xray 数据文件:${assets.copied.join(', ')}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
225
235
|
const version = await probeVersion({
|
|
226
236
|
launcher: this.#launcher,
|
|
227
237
|
binaryPath: binary.path,
|
|
@@ -269,7 +279,9 @@ export class Supervisor {
|
|
|
269
279
|
argv: [binary.path, 'run', '-c', configPath],
|
|
270
280
|
cwd: this.dataDir,
|
|
271
281
|
graceMs: Math.max(500, lifecycle.stopGraceMs),
|
|
272
|
-
env
|
|
282
|
+
// geodata is copied next to the managed config; without this env the
|
|
283
|
+
// scrubbed service environment makes geoip:cn/geosite:cn fail silently.
|
|
284
|
+
env: this.dataDir ? { XRAY_LOCATION_ASSET: this.dataDir } : {},
|
|
273
285
|
})
|
|
274
286
|
} catch (error) {
|
|
275
287
|
this.#setState('failed', 'spawn-failed', String(error))
|
|
@@ -445,7 +457,7 @@ export class Supervisor {
|
|
|
445
457
|
argv: [binaryPath, 'run', '-test', '-c', configPath],
|
|
446
458
|
cwd: this.dataDir ?? process.cwd(),
|
|
447
459
|
graceMs: 2000,
|
|
448
|
-
env: {},
|
|
460
|
+
env: this.dataDir ? { XRAY_LOCATION_ASSET: this.dataDir } : {},
|
|
449
461
|
})
|
|
450
462
|
} catch (error) {
|
|
451
463
|
return { ok: false, detail: String(error) }
|
|
@@ -493,6 +505,11 @@ export class Supervisor {
|
|
|
493
505
|
}
|
|
494
506
|
}
|
|
495
507
|
|
|
508
|
+
const routing = this.config.routing ?? {}
|
|
509
|
+
if (routing.mode === 'cn-direct' || routing.geositeCn || routing.blockAds) {
|
|
510
|
+
ensureXrayAssets({ binaryPath: binary.path, dataDir: this.dataDir, logger: this.#logger })
|
|
511
|
+
}
|
|
512
|
+
|
|
496
513
|
const built = buildXrayConfig(this.config)
|
|
497
514
|
const text = JSON.stringify(built, null, 2)
|
|
498
515
|
const file = join(this.dataDir, 'conf', `validate-${Date.now()}-${process.pid}.json`)
|
package/lib/xray-config.js
CHANGED
|
@@ -156,6 +156,19 @@ export function buildXrayConfig(config) {
|
|
|
156
156
|
if (routing.blockAds) {
|
|
157
157
|
rules.push({ type: 'field', domain: ['geosite:category-ads-all'], outboundTag: 'block' })
|
|
158
158
|
}
|
|
159
|
+
const blacklist = dedupe(routing.blacklist ?? [])
|
|
160
|
+
if (blacklist.length > 0) {
|
|
161
|
+
// Highest priority: these hosts must stay direct even in whitelist mode.
|
|
162
|
+
rules.push({ type: 'field', domain: blacklist.map((entry) => `domain:${entry}`), outboundTag: 'direct' })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const whitelist = dedupe(routing.whitelist ?? []).filter((entry) => !blacklist.includes(entry))
|
|
166
|
+
const mode = routing.mode ?? 'global'
|
|
167
|
+
const proxyTarget = nodes.length > 1 ? { balancerTag: control.balancerTag } : { outboundTag: nodes[0].tag }
|
|
168
|
+
if (mode === 'whitelist' && whitelist.length > 0) {
|
|
169
|
+
rules.push({ type: 'field', domain: whitelist.map((entry) => `domain:${entry}`), ...proxyTarget })
|
|
170
|
+
}
|
|
171
|
+
|
|
159
172
|
rules.push({ type: 'field', ip: PRIVATE_IP_RULES, outboundTag: 'direct' })
|
|
160
173
|
|
|
161
174
|
const bypassDomains = dedupe(routing.bypassDomains)
|
|
@@ -166,9 +179,12 @@ export function buildXrayConfig(config) {
|
|
|
166
179
|
rules.push({ type: 'field', domain: bypassDomains.map((entry) => `domain:${entry}`), outboundTag: 'direct' })
|
|
167
180
|
}
|
|
168
181
|
|
|
169
|
-
if (routing.geositeCn) {
|
|
182
|
+
if (mode === 'cn-direct' || routing.geositeCn) {
|
|
170
183
|
rules.push({ type: 'field', domain: ['geosite:cn'], outboundTag: 'direct' })
|
|
171
184
|
}
|
|
185
|
+
if (mode === 'cn-direct') {
|
|
186
|
+
rules.push({ type: 'field', ip: ['geoip:cn'], outboundTag: 'direct' })
|
|
187
|
+
}
|
|
172
188
|
|
|
173
189
|
const balancers = []
|
|
174
190
|
if (nodes.length > 1) {
|
|
@@ -180,6 +196,11 @@ export function buildXrayConfig(config) {
|
|
|
180
196
|
selector: nodes.map((node) => `^${escapeRegExp(node.tag)}$`),
|
|
181
197
|
strategy: { type: 'random' },
|
|
182
198
|
})
|
|
199
|
+
}
|
|
200
|
+
if (mode === 'whitelist') {
|
|
201
|
+
// Only the whitelist above goes through xray; everything else is direct.
|
|
202
|
+
rules.push({ type: 'field', network: 'tcp,udp', outboundTag: 'direct' })
|
|
203
|
+
} else if (nodes.length > 1) {
|
|
183
204
|
rules.push({ type: 'field', network: 'tcp,udp', balancerTag: control.balancerTag })
|
|
184
205
|
} else {
|
|
185
206
|
rules.push({ type: 'field', network: 'tcp,udp', outboundTag: nodes[0].tag })
|
|
@@ -210,7 +231,9 @@ export function buildXrayConfig(config) {
|
|
|
210
231
|
inbounds,
|
|
211
232
|
outbounds,
|
|
212
233
|
routing: {
|
|
213
|
-
|
|
234
|
+
// cn-direct relies on resolving unmatched domains to match geoip:cn;
|
|
235
|
+
// global/whitelist stay AsIs so no extra DNS lookups are introduced.
|
|
236
|
+
domainStrategy: mode === 'cn-direct' ? 'IPIfNonMatch' : 'AsIs',
|
|
214
237
|
rules,
|
|
215
238
|
...(balancers.length > 0 ? { balancers } : {}),
|
|
216
239
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "DSH-2Xray: supervises xray-core and routes DSH process-wide HTTP(S) traffic through its local inbound, with optional multi-node gRPC control plane",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { ensureXrayAssets } from '../lib/assets.js'
|
|
7
|
+
|
|
8
|
+
test('geodata next to a v2rayN binary is copied into the managed data dir', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-xray-assets-'))
|
|
10
|
+
try {
|
|
11
|
+
const bin = join(root, 'v2rayN-windows-64', 'bin')
|
|
12
|
+
const xrayDir = join(bin, 'xray')
|
|
13
|
+
mkdirSync(xrayDir, { recursive: true })
|
|
14
|
+
writeFileSync(join(bin, 'geoip.dat'), 'geoip')
|
|
15
|
+
writeFileSync(join(bin, 'geosite.dat'), 'geosite')
|
|
16
|
+
const dataDir = join(root, 'data')
|
|
17
|
+
const result = ensureXrayAssets({ binaryPath: join(xrayDir, 'xray.exe'), dataDir })
|
|
18
|
+
assert.deepEqual(result.copied.sort(), ['geoip.dat', 'geosite.dat'])
|
|
19
|
+
assert.equal(existsSync(join(dataDir, 'geoip.dat')), true)
|
|
20
|
+
assert.equal(existsSync(join(dataDir, 'geosite.dat')), true)
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(root, { recursive: true, force: true })
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('missing geodata is reported rather than silently ignored', () => {
|
|
27
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-xray-assets-'))
|
|
28
|
+
try {
|
|
29
|
+
const result = ensureXrayAssets({ binaryPath: join(root, 'xray.exe'), dataDir: join(root, 'data') })
|
|
30
|
+
assert.deepEqual(result.copied, [])
|
|
31
|
+
assert.deepEqual(result.missing.sort(), ['geoip.dat', 'geosite.dat'])
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(root, { recursive: true, force: true })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { assetName, parseDigest, sha256, dataBinDir } from '../tools/install-xray-core.mjs'
|
|
4
|
+
|
|
5
|
+
test('asset names follow the official release naming per platform', () => {
|
|
6
|
+
assert.equal(assetName('win32', 'x64'), 'Xray-windows-64.zip')
|
|
7
|
+
assert.equal(assetName('darwin', 'arm64'), 'Xray-macos-arm64-v8a.zip')
|
|
8
|
+
assert.equal(assetName('darwin', 'x64'), 'Xray-macos-64.zip')
|
|
9
|
+
assert.equal(assetName('linux', 'x64'), 'Xray-linux-64.zip')
|
|
10
|
+
assert.throws(() => assetName('aix', 'ppc64'), /unsupported/)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('the .dgst parser reads the SHA2-256 line', () => {
|
|
14
|
+
const digest = 'SHA2-256: ' + 'a'.repeat(64) + '\n'
|
|
15
|
+
assert.equal(parseDigest(digest), 'a'.repeat(64))
|
|
16
|
+
assert.throws(() => parseDigest('SHA1: abc'), /SHA2-256/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('sha256 hashes the downloaded bytes', () => {
|
|
20
|
+
assert.equal(sha256(Buffer.from('abc')).length, 64)
|
|
21
|
+
assert.equal(dataBinDir().endsWith('bin'), true)
|
|
22
|
+
})
|
package/test/route.test.js
CHANGED
|
@@ -156,3 +156,11 @@ test('a socks inbound always takes the builtin path', async () => {
|
|
|
156
156
|
assert.equal(snapshot.mode, 'builtin')
|
|
157
157
|
await route.restore()
|
|
158
158
|
})
|
|
159
|
+
|
|
160
|
+
test('the policy verification host is reserved and cannot be in noProxy', async () => {
|
|
161
|
+
const { PROBE_URL } = await import('../lib/route.js')
|
|
162
|
+
const url = new URL(PROBE_URL)
|
|
163
|
+
assert.equal(url.hostname, 'dsh-2xray-verify.invalid')
|
|
164
|
+
assert.equal(url.hostname.endsWith('.invalid'), true)
|
|
165
|
+
assert.notEqual(url.hostname, 'api.deepseek.com')
|
|
166
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
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 base(root = {}) {
|
|
7
|
+
return normalizeConfig({
|
|
8
|
+
enabled: true,
|
|
9
|
+
server: {
|
|
10
|
+
address: 'example.com',
|
|
11
|
+
port: 443,
|
|
12
|
+
uuid: '11111111-2222-4333-8444-555555555555',
|
|
13
|
+
serverName: 'www.example.com',
|
|
14
|
+
password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
15
|
+
shortId: 'ab12',
|
|
16
|
+
},
|
|
17
|
+
...root,
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rulesOf(config) {
|
|
22
|
+
return buildXrayConfig(config).routing.rules
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('global mode keeps the proxy catch-all and private direct rule', () => {
|
|
26
|
+
const { config, errors } = base()
|
|
27
|
+
assert.deepEqual(errors, [])
|
|
28
|
+
const rules = rulesOf(config)
|
|
29
|
+
assert.equal(buildXrayConfig(config).routing.domainStrategy, 'AsIs')
|
|
30
|
+
assert.ok(rules.some((rule) => Array.isArray(rule.ip) && rule.ip.includes('10.0.0.0/8')))
|
|
31
|
+
const catchAll = rules[rules.length - 1]
|
|
32
|
+
assert.equal(catchAll.network, 'tcp,udp')
|
|
33
|
+
assert.equal(catchAll.outboundTag, 'proxy')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('cn-direct adds geosite:cn and geoip:cn direct rules', () => {
|
|
37
|
+
const { config } = base({ routing: { mode: 'cn-direct' } })
|
|
38
|
+
const rules = rulesOf(config)
|
|
39
|
+
assert.equal(buildXrayConfig(config).routing.domainStrategy, 'IPIfNonMatch')
|
|
40
|
+
assert.ok(rules.some((rule) => rule.domain?.includes('geosite:cn')))
|
|
41
|
+
assert.ok(rules.some((rule) => rule.ip?.includes('geoip:cn')))
|
|
42
|
+
assert.equal(rules[rules.length - 1].outboundTag, 'proxy')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('whitelist mode proxies only listed domains and defaults to direct', () => {
|
|
46
|
+
const { config } = base({ routing: { mode: 'whitelist', whitelist: ['github.com', 'youtube.com'] } })
|
|
47
|
+
const rules = rulesOf(config)
|
|
48
|
+
const proxyRule = rules.find((rule) => rule.domain?.includes('domain:github.com'))
|
|
49
|
+
assert.ok(proxyRule)
|
|
50
|
+
assert.equal(proxyRule.outboundTag, 'proxy')
|
|
51
|
+
const catchAll = rules[rules.length - 1]
|
|
52
|
+
assert.equal(catchAll.outboundTag, 'direct')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('blacklisted domains go direct and are added to noProxy', () => {
|
|
56
|
+
const { config } = base({ routing: { blacklist: ['https://api.deepseek.com/anthropic'] } })
|
|
57
|
+
const rules = rulesOf(config)
|
|
58
|
+
assert.equal(rules[0].outboundTag, 'direct')
|
|
59
|
+
assert.ok(rules[0].domain.includes('domain:api.deepseek.com'))
|
|
60
|
+
assert.match(config.noProxy, /(^|,)api\.deepseek\.com(,|$)/)
|
|
61
|
+
assert.equal(config.routing.blacklist.includes('api.deepseek.com'), true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('whitelist wins over bypassDomains but loses to the blacklist', () => {
|
|
65
|
+
const { config } = base({
|
|
66
|
+
routing: { mode: 'whitelist', whitelist: ['github.com'], blacklist: ['github.com'], bypassDomains: ['github.com'] },
|
|
67
|
+
})
|
|
68
|
+
const rules = rulesOf(config)
|
|
69
|
+
const black = rules.findIndex((rule) => rule.domain?.includes('domain:github.com') && rule.outboundTag === 'direct')
|
|
70
|
+
const white = rules.findIndex((rule) => rule.domain?.includes('domain:github.com') && rule.outboundTag === 'proxy')
|
|
71
|
+
assert.ok(black >= 0)
|
|
72
|
+
assert.equal(white, -1)
|
|
73
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Install an official Xray-core binary into the plugin data directory.
|
|
4
|
+
*
|
|
5
|
+
* The npm package does not embed five platform cores (that is ~150 MB). Instead
|
|
6
|
+
* this one-shot installer downloads the official release asset for the current
|
|
7
|
+
* platform, verifies the published SHA2-256 digest, extracts it, and places the
|
|
8
|
+
* binary where the plugin already looks: <dataDir>/bin/xray(.exe).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node tools/install-xray-core.mjs [version]
|
|
12
|
+
* XRAY_VERSION=26.9.30 node tools/install-xray-core.mjs
|
|
13
|
+
* DSH_XRAY_MIRROR=https://mirror.example/Xray-core/releases/download node tools/install-xray-core.mjs
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { homedir, tmpdir } from 'node:os'
|
|
18
|
+
import { basename, dirname, join } from 'node:path'
|
|
19
|
+
import { spawnSync } from 'node:child_process'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_VERSION = '26.9.30'
|
|
23
|
+
|
|
24
|
+
export function assetName(platform = process.platform, arch = process.arch) {
|
|
25
|
+
if (platform === 'win32' && arch === 'x64') return 'Xray-windows-64.zip'
|
|
26
|
+
if (platform === 'win32' && arch === 'arm64') return 'Xray-windows-arm64-v8a.zip'
|
|
27
|
+
if (platform === 'darwin' && arch === 'arm64') return 'Xray-macos-arm64-v8a.zip'
|
|
28
|
+
if (platform === 'darwin' && arch === 'x64') return 'Xray-macos-64.zip'
|
|
29
|
+
if (platform === 'linux' && arch === 'x64') return 'Xray-linux-64.zip'
|
|
30
|
+
if (platform === 'linux' && arch === 'arm64') return 'Xray-linux-arm64-v8a.zip'
|
|
31
|
+
throw new Error(`unsupported platform: ${platform}/${arch}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseDigest(text) {
|
|
35
|
+
const match = String(text).match(/SHA2-256:\s*([0-9a-f]{64})/i)
|
|
36
|
+
if (!match) throw new Error('release digest has no SHA2-256 value')
|
|
37
|
+
return match[1].toLowerCase()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sha256(buffer) {
|
|
41
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dataBinDir() {
|
|
45
|
+
const home = homedir()
|
|
46
|
+
return join(home, '.dsh', 'xray-proxy', 'bin')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function download(url) {
|
|
50
|
+
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180000) })
|
|
51
|
+
if (!response.ok) throw new Error(`${url} -> HTTP ${response.status}`)
|
|
52
|
+
return Buffer.from(await response.arrayBuffer())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extract(zip, destination) {
|
|
56
|
+
const attempts =
|
|
57
|
+
process.platform === 'win32'
|
|
58
|
+
? [['tar', ['-xf', zip, '-C', destination]], ['powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${zip}' -DestinationPath '${destination}' -Force`]]]
|
|
59
|
+
: [['unzip', ['-o', zip, '-d', destination]], ['tar', ['-xf', zip, '-C', destination]]]
|
|
60
|
+
let lastError
|
|
61
|
+
for (const [command, args] of attempts) {
|
|
62
|
+
const result = spawnSync(command, args, { stdio: 'ignore' })
|
|
63
|
+
if (result.status === 0) return
|
|
64
|
+
lastError = result.error ?? new Error(`${command} exited ${result.status}`)
|
|
65
|
+
}
|
|
66
|
+
throw lastError
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findBinary(dir, name) {
|
|
70
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
const full = join(dir, entry.name)
|
|
72
|
+
if (entry.isFile() && entry.name === name) return full
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
const nested = findBinary(full, name)
|
|
75
|
+
if (nested) return nested
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function main() {
|
|
82
|
+
const version = process.argv[2] || process.env.XRAY_VERSION || DEFAULT_VERSION
|
|
83
|
+
const asset = assetName()
|
|
84
|
+
const mirror = (process.env.DSH_XRAY_MIRROR || 'https://github.com/XTLS/Xray-core/releases/download').replace(/\/$/, '')
|
|
85
|
+
const url = `${mirror}/v${version.replace(/^v/, '')}/${asset}`
|
|
86
|
+
const targetDir = dataBinDir()
|
|
87
|
+
const targetName = process.platform === 'win32' ? 'xray.exe' : 'xray'
|
|
88
|
+
const target = join(targetDir, targetName)
|
|
89
|
+
|
|
90
|
+
if (existsSync(target)) {
|
|
91
|
+
console.log(`already present: ${target}`)
|
|
92
|
+
return target
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`downloading ${url}`)
|
|
96
|
+
const zip = await download(url)
|
|
97
|
+
console.log(`verifying ${asset}.dgst`)
|
|
98
|
+
const digestText = (await download(`${url}.dgst`)).toString('utf8')
|
|
99
|
+
const expected = parseDigest(digestText)
|
|
100
|
+
const actual = sha256(zip)
|
|
101
|
+
if (expected !== actual) throw new Error(`digest mismatch: expected ${expected}, got ${actual}`)
|
|
102
|
+
|
|
103
|
+
const workspace = mkdtempSync(join(tmpdir(), 'dsh-xray-core-'))
|
|
104
|
+
try {
|
|
105
|
+
const archive = join(workspace, asset)
|
|
106
|
+
writeFileSync(archive, zip)
|
|
107
|
+
const extracted = join(workspace, 'out')
|
|
108
|
+
mkdirSync(extracted, { recursive: true })
|
|
109
|
+
extract(archive, extracted)
|
|
110
|
+
const binary = findBinary(extracted, targetName)
|
|
111
|
+
if (!binary) throw new Error(`no ${targetName} inside ${asset}`)
|
|
112
|
+
mkdirSync(targetDir, { recursive: true })
|
|
113
|
+
copyFileSync(binary, target)
|
|
114
|
+
if (process.platform !== 'win32') chmodSync(target, 0o755)
|
|
115
|
+
console.log(`installed ${target} (${Math.round(statSync(target).size / 1048576)} MB, v${version})`)
|
|
116
|
+
return target
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(workspace, { recursive: true, force: true })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
123
|
+
main().catch((error) => {
|
|
124
|
+
console.error(`install failed: ${error.message ?? error}`)
|
|
125
|
+
console.error('If GitHub is unreachable, set DSH_XRAY_MIRROR to a mirror and retry.')
|
|
126
|
+
process.exitCode = 1
|
|
127
|
+
})
|
|
128
|
+
}
|