@twinsearth/dsh-2xray 0.3.0 → 0.3.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — 2026-10-29
4
+
5
+ ### 修复
6
+
7
+ - **入站端口护栏覆盖所有平台**:`inbound.port < 1024` 或与节点远端端口相同(如 556)时自动回落到 20808 并告警,避免把远端端口当本地监听。
8
+ - **geodata 发现扩大**:当内核来自平台包、旁边没有 `geoip.dat`/`geosite.dat` 时,会扫描本机 v2rayN 安装目录并复制到数据目录,再设置 `XRAY_LOCATION_ASSET`。
9
+ - **缺 geodata 不再 config-rejected**:cn-direct / geosite 规则缺数据文件时临时降级为 global,代理仍可用;状态新增 `routingFallback` 说明原因。
10
+
3
11
  ## 0.3.0 — 2026-10-29
4
12
 
5
13
  ### 更新中心
package/lib/assets.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { defaultDiscoveryRoots, discoverXray } from './binary.js'
3
4
 
4
5
  const FILES = ['geoip.dat', 'geosite.dat']
5
6
 
@@ -8,7 +9,7 @@ const FILES = ['geoip.dat', 'geosite.dat']
8
9
  * XRAY_LOCATION_ASSET. A borrowed v2rayN binary keeps them one directory up,
9
10
  * so copy them into the managed data dir and point xray there.
10
11
  */
11
- export function ensureXrayAssets({ binaryPath, dataDir, logger } = {}) {
12
+ export function ensureXrayAssets({ binaryPath, dataDir, logger, discoveryRoots } = {}) {
12
13
  if (!dataDir) return { dir: undefined, copied: [], missing: FILES }
13
14
  try {
14
15
  mkdirSync(dataDir, { recursive: true })
@@ -17,6 +18,18 @@ export function ensureXrayAssets({ binaryPath, dataDir, logger } = {}) {
17
18
  return { dir: dataDir, copied: [], missing: FILES }
18
19
  }
19
20
  const candidates = [dataDir, dirname(binaryPath ?? ''), dirname(dirname(binaryPath ?? ''))]
21
+ const missingBefore = FILES.some((name) => !existsSync(join(dataDir, name)))
22
+ if (missingBefore) {
23
+ // The resolved core may come from a package with no geodata. A local
24
+ // v2rayN install usually has geoip.dat/geosite.dat next to its binaries.
25
+ try {
26
+ for (const discovered of discoverXray({ roots: discoveryRoots ?? defaultDiscoveryRoots(), timeBudgetMs: 1500, max: 4 })) {
27
+ candidates.push(dirname(discovered), dirname(dirname(discovered)))
28
+ }
29
+ } catch {
30
+ // discovery is best-effort; the managed dir may already have the files
31
+ }
32
+ }
20
33
  const copied = []
21
34
  const missing = []
22
35
  for (const name of FILES) {
package/lib/config.js CHANGED
@@ -392,11 +392,18 @@ export function normalizeConfig(raw) {
392
392
  }
393
393
  if (!Number.isInteger(inbound.port) || inbound.port < 1 || inbound.port > 65535) {
394
394
  errors.push({ field: 'inbound.port', message: '入站端口必须是 1..65535 的整数' })
395
- } else if (inbound.port < 1024 && process.platform !== 'win32') {
395
+ } else {
396
396
  // A remote node port (e.g. 556) must never become the local listener:
397
- // binding <1024 needs root on macOS/Linux and xray fails with EACCES.
398
- warnings.push(`入站端口 ${inbound.port} 是特权端口,macOS/Linux 下无法绑定;已改用 ${DEFAULT_CONFIG.inbound.port}`)
399
- inbound.port = DEFAULT_CONFIG.inbound.port
397
+ // <1024 needs root on macOS/Linux, and mirroring the server port makes the
398
+ // local proxy look like a second server. Always fall back to 20808.
399
+ if (inbound.port < 1024) {
400
+ warnings.push(`入站端口 ${inbound.port} 是特权端口;已改用 ${DEFAULT_CONFIG.inbound.port}`)
401
+ inbound.port = DEFAULT_CONFIG.inbound.port
402
+ }
403
+ if (nodes.some((node) => Number(node.port) === inbound.port)) {
404
+ warnings.push(`入站端口 ${inbound.port} 与节点远端端口相同;已改用 ${DEFAULT_CONFIG.inbound.port}`)
405
+ inbound.port = DEFAULT_CONFIG.inbound.port
406
+ }
400
407
  }
401
408
  if (socks.enabled) {
402
409
  if (!LOOPBACK_HOSTS.has(socks.host)) {
package/lib/supervisor.js CHANGED
@@ -88,6 +88,7 @@ export class Supervisor {
88
88
  #control
89
89
  #activeNode
90
90
  #schemaLoaded = false
91
+ #routingFallback = null
91
92
  #now
92
93
 
93
94
  constructor({
@@ -223,12 +224,24 @@ export class Supervisor {
223
224
  this.#binary = binary
224
225
  if (signal.aborted) return
225
226
 
227
+ let effectiveConfig = this.config
228
+ this.#routingFallback = null
226
229
  if (this.dataDir) {
227
230
  const routing = this.config.routing ?? {}
228
231
  if (routing.mode === 'cn-direct' || routing.geositeCn || routing.blockAds) {
229
232
  const assets = ensureXrayAssets({ binaryPath: binary.path, dataDir: this.dataDir, logger: this.#logger })
230
233
  process.env.XRAY_LOCATION_ASSET = this.dataDir
231
234
  if (assets.copied.length > 0) this.#logger?.info?.(`已复制 xray 数据文件:${assets.copied.join(', ')}`)
235
+ if (assets.missing.length > 0) {
236
+ // Never let missing geodata turn into config-rejected: keep the
237
+ // tunnel usable with global routing and surface the degradation.
238
+ effectiveConfig = {
239
+ ...this.config,
240
+ routing: { ...routing, mode: 'global', geositeCn: false, blockAds: false },
241
+ }
242
+ this.#routingFallback = `缺少 ${assets.missing.join(', ')},已临时降级为 global`
243
+ this.#logger?.warn?.(this.#routingFallback)
244
+ }
232
245
  }
233
246
  }
234
247
 
@@ -244,7 +257,7 @@ export class Supervisor {
244
257
  this.#version = version.version
245
258
  if (signal.aborted) return
246
259
 
247
- const xrayConfig = buildXrayConfig(this.config)
260
+ const xrayConfig = buildXrayConfig(effectiveConfig)
248
261
  const text = JSON.stringify(xrayConfig, null, 2)
249
262
  this.#configHash = sha256(text)
250
263
  const configPath = join(this.dataDir, 'conf', 'xray-config.json')
@@ -505,12 +518,19 @@ export class Supervisor {
505
518
  }
506
519
  }
507
520
 
521
+ let effectiveConfig = this.config
508
522
  const routing = this.config.routing ?? {}
509
523
  if (routing.mode === 'cn-direct' || routing.geositeCn || routing.blockAds) {
510
- ensureXrayAssets({ binaryPath: binary.path, dataDir: this.dataDir, logger: this.#logger })
524
+ const assets = ensureXrayAssets({ binaryPath: binary.path, dataDir: this.dataDir, logger: this.#logger })
525
+ if (assets.missing.length > 0) {
526
+ effectiveConfig = {
527
+ ...this.config,
528
+ routing: { ...routing, mode: 'global', geositeCn: false, blockAds: false },
529
+ }
530
+ }
511
531
  }
512
532
 
513
- const built = buildXrayConfig(this.config)
533
+ const built = buildXrayConfig(effectiveConfig)
514
534
  const text = JSON.stringify(built, null, 2)
515
535
  const file = join(this.dataDir, 'conf', `validate-${Date.now()}-${process.pid}.json`)
516
536
  atomicWriteJson(file, built, 0o600)
@@ -711,6 +731,7 @@ export class Supervisor {
711
731
  enabled: this.config.enabled,
712
732
  takeover: this.config.takeover,
713
733
  settingsSchema: this.#schemaLoaded,
734
+ routingFallback: this.#routingFallback,
714
735
  nodes: this.config.nodes.map((node) => ({
715
736
  id: node.id,
716
737
  tag: node.tag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-2xray",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -26,7 +26,7 @@ test('geodata next to a v2rayN binary is copied into the managed data dir', () =
26
26
  test('missing geodata is reported rather than silently ignored', () => {
27
27
  const root = mkdtempSync(join(tmpdir(), 'dsh-xray-assets-'))
28
28
  try {
29
- const result = ensureXrayAssets({ binaryPath: join(root, 'xray.exe'), dataDir: join(root, 'data') })
29
+ const result = ensureXrayAssets({ binaryPath: join(root, 'xray.exe'), dataDir: join(root, 'data'), discoveryRoots: [] })
30
30
  assert.deepEqual(result.copied, [])
31
31
  assert.deepEqual(result.missing.sort(), ['geoip.dat', 'geosite.dat'])
32
32
  } finally {
@@ -0,0 +1,28 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizeConfig } from '../lib/config.js'
4
+
5
+ const base = (overrides = {}) => ({
6
+ enabled: true,
7
+ server: {
8
+ address: 'example.com',
9
+ port: 443,
10
+ uuid: '11111111-2222-4333-8444-555555555555',
11
+ serverName: 'www.example.com',
12
+ password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
13
+ shortId: 'ab12',
14
+ },
15
+ ...overrides,
16
+ })
17
+
18
+ test('a remote node port is never mirrored as the local inbound port', () => {
19
+ const { config, warnings } = normalizeConfig(base({ server: { address: 'example.com', port: 556, uuid: '11111111-2222-4333-8444-555555555555' }, inbound: { port: 556 } }))
20
+ assert.equal(config.inbound.port, 20808)
21
+ assert.ok(warnings.some((warning) => warning.includes('privileged') || warning.includes('特权')))
22
+ })
23
+
24
+ test('an inbound port equal to the node port falls back to 20808', () => {
25
+ const { config, warnings } = normalizeConfig(base({ server: { address: 'example.com', port: 8443, uuid: '11111111-2222-4333-8444-555555555555' }, inbound: { port: 8443 } }))
26
+ assert.equal(config.inbound.port, 20808)
27
+ assert.ok(warnings.some((warning) => warning.includes('remote') || warning.includes('远端')))
28
+ })