@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 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(false)
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
- const PROBE_URL = 'https://api.deepseek.com/'
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`)
@@ -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
- domainStrategy: 'AsIs',
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.6",
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
+ })
@@ -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
+ }