@twinsearth/dsh-2xray 0.3.3 → 0.3.5

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5 — 2026-10-29
4
+
5
+ ### 规则数据
6
+
7
+ - 新增 `geodata.autoUpdate`(默认 true):XTLS 主源 + jsDelivr 回退,下载 `geoip.dat`/`geosite.dat` 并原子替换。
8
+ - 首次缺失时在启动前下载;之后每 12 小时检查,更新后调用 `supervisor.restart()` 让 xray 重新加载。
9
+ - 小于 100KB 的响应视为错误页,不会覆盖现有文件。
10
+ - 设置页新增「每日更新规则数据」勾选框。
11
+ - 实测确认 Xray 的顶层 `geodata` 块只被解析、不执行 cron,因此由插件侧实现。
12
+
13
+ ## 0.3.4 — 2026-10-29
14
+
15
+ ### 修复
16
+
17
+ - 侧边栏标题不再写死「全局代理」:读取 `status.routing.mode`,显示「全局代理 / 国内直连 / 白名单代理」;geodata 缺失降级时追加「已降级全局」。
18
+ - 状态卡增加「路由:…」一栏,并同步显示降级原因。
19
+ - 状态快照新增 `routing` 字段(mode / whitelist / blacklist)。
20
+
3
21
  ## 0.3.3 — 2026-10-29
4
22
 
5
23
  ### 健康检查与发布
package/README.md CHANGED
@@ -334,6 +334,12 @@ v0.2.0 提供两个入口,互不替代:
334
334
 
335
335
  维护者用 `tools/build-core-packages.mjs` 从官方 zip 生成平台包;`.github/workflows/core-packages.yml` 会在具备 `NPM_TOKEN` secret 时自动下载、校验并发布各平台包。
336
336
 
337
+ ## 规则数据自动更新
338
+
339
+ 设置页默认勾选「每日更新规则数据」:Host 会下载 XTLS 的 `geoip-lite.dat` / `geosite.dat`,失败时回退 jsDelivr(Loyalsoldier);文件小于 100KB 视为错误页并拒绝替换。首次缺失时在启动前下载,之后每 12 小时检查一次,更新成功后重启 xray 热加载。
340
+
341
+ > Xray 26.9.30 接受顶层 `geodata` 配置块但实测不执行其 cron(85 秒内无下载),因此更新由插件完成,不依赖该字段。
342
+
337
343
  ## 健康检查
338
344
 
339
345
  - 进程内(需 DSH 会话鉴权):`GET /api/xray/healthz` → `{ "ok": true, "version": "v0.3.3", ... }`
package/index.js CHANGED
@@ -9,6 +9,7 @@ import { installTools } from './lib/tools.js'
9
9
  import { LogBuffer } from './lib/logbuf.js'
10
10
  import { Monitor } from './lib/monitor.js'
11
11
  import { checkForUpdate, installUpdate, ownVersion } from './lib/update.js'
12
+ import { geodataStatus, updateGeodata } from './lib/geodata.js'
12
13
 
13
14
  export const name = 'dsh-2xray'
14
15
 
@@ -172,6 +173,7 @@ export function apply(ctx, rawConfig) {
172
173
  vpnName: node?.id ?? snapshot.activeNode ?? null,
173
174
  probe: monitor.snapshot(),
174
175
  update: updateState,
176
+ geodata: geodataStatus(dataDir),
175
177
  },
176
178
  logs: page.items,
177
179
  cursor: page.cursor,
@@ -281,10 +283,38 @@ export function apply(ctx, rawConfig) {
281
283
  'dsh-2xray/xray-supervisor',
282
284
  )
283
285
 
284
- // Detached by design: activation must not block the harness boot on xray
285
- // startup. Every rejection is caught — an unhandled one would dispose the
286
- // whole application.
287
- supervisor.start().catch((error) => logger.error(`xray 监督器异常:${String(error)}`))
286
+ const needsGeodata = config.routing?.mode === 'cn-direct' || config.routing?.geositeCn || config.routing?.blockAds
287
+ const boot = async () => {
288
+ if (dataDir && config.geodata?.autoUpdate !== false) {
289
+ const status = geodataStatus(dataDir)
290
+ if (status.missing.length > 0 && needsGeodata) {
291
+ // Download before the first spawn, otherwise cn-direct would either
292
+ // fail or degrade to global on a fresh machine.
293
+ const result = await updateGeodata({ dataDir, logger, timeoutMs: 20_000 }).catch((error) => ({
294
+ updated: [],
295
+ failed: ['download'],
296
+ error,
297
+ }))
298
+ if (result?.updated?.length > 0) logger.info('规则数据已下载,使用最新 geodata 启动 xray')
299
+ }
300
+ const refresh = () => {
301
+ ;(async () => {
302
+ if (!geodataStatus(dataDir).stale) return
303
+ const result = await updateGeodata({ dataDir, logger })
304
+ if (result?.updated?.length > 0) {
305
+ logger.info('规则数据已更新,重启 xray 加载新数据')
306
+ await supervisor.restart('geodata-updated')
307
+ }
308
+ })().catch((error) => logger.warn(`规则数据更新失败:${String(error)}`))
309
+ }
310
+ const first = setTimeout(refresh, 60_000)
311
+ const daily = setInterval(refresh, 12 * 60 * 60 * 1000)
312
+ first.unref?.()
313
+ daily.unref?.()
314
+ }
315
+ await supervisor.start()
316
+ }
317
+ boot().catch((error) => logger.error(`xray 监督器异常:${String(error)}`))
288
318
  installTools({ ctx, supervisor, logger }).catch((error) => logger.warn(`安装 xray_* 工具失败:${String(error)}`))
289
319
 
290
320
  return dispose
package/lib/client.js CHANGED
@@ -199,6 +199,8 @@ window.__ModuleLoader__.load({
199
199
  }
200
200
 
201
201
  function StatusCard({ status, latency, egress, phase }) {
202
+ const routingLabel =
203
+ { global: '全局', 'cn-direct': '国内直连', whitelist: '白名单' }[status?.routing?.mode] ?? '全局'
202
204
  return h('section', { style: C.card },
203
205
  h('div', { style: C.cardHead }, '③ 连接状态'),
204
206
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
@@ -209,6 +211,8 @@ window.__ModuleLoader__.load({
209
211
  egress?.ip ? `出口 IP ${egress.ip}${egress.country ? ` (${egress.country})` : ''}` : '出口 IP —')),
210
212
  h('div', { style: { ...C.muted, fontSize: 12.5, marginTop: 6 } },
211
213
  `节点「${status?.vpnName ?? status?.activeNode ?? '—'}」` +
214
+ ` · 路由:${routingLabel}` +
215
+ (status?.routingFallback ? '(geodata 缺失,已降级全局)' : '') +
212
216
  (status?.route?.installed ? ` · ${status.route.mode === 'harness' ? 'DSH 策略已接管' : '内置 dispatcher'}` : ' · 尚未接管')))
213
217
  }
214
218
 
@@ -238,13 +242,16 @@ window.__ModuleLoader__.load({
238
242
  const latency = probe.latency?.ms
239
243
  const egress = probe.egress ?? {}
240
244
  const localEndpoint = status ? `127.0.0.1:${status.port}` : '—'
245
+ const routingLabel =
246
+ { global: '全局代理', 'cn-direct': '国内直连', whitelist: '白名单代理' }[status?.routing?.mode] ?? '全局代理'
247
+ const fallbackNote = status?.routingFallback ? ' · geodata 缺失,已降级全局' : ''
241
248
 
242
249
  return h('div', { style: C.page },
243
250
  h('div', { style: C.header },
244
251
  h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
245
252
  h('div', null,
246
253
  h('h1', { style: C.title }, '2Xray'),
247
- h('p', { style: C.sub }, `Xray 全局代理 · 本地 ${localEndpoint}`)),
254
+ h('p', { style: C.sub }, `Xray ${routingLabel} · 本地 ${localEndpoint}${fallbackNote}`)),
248
255
  h(StatusPill, { status })),
249
256
  error
250
257
  ? h('div', { style: { ...C.card, ...C.error, fontSize: 12.5 } }, `状态通道不可用:${error}(代理本身可能仍在工作)`)
@@ -319,6 +326,7 @@ window.__ModuleLoader__.load({
319
326
  const [autoUpdate, setAutoUpdate] = React.useState(value.updates?.auto === true)
320
327
  const [updateInfo, setUpdateInfo] = React.useState(null)
321
328
  const [updateBusy, setUpdateBusy] = React.useState('')
329
+ const [autoGeodata, setAutoGeodata] = React.useState(value.geodata?.autoUpdate !== false)
322
330
  const [message, setMessage] = React.useState('')
323
331
  const [kind, setKind] = React.useState('')
324
332
  const [busy, setBusy] = React.useState(false)
@@ -471,6 +479,7 @@ window.__ModuleLoader__.load({
471
479
  { path: ['routing', 'whitelist'], value: splitList(whitelist) },
472
480
  { path: ['routing', 'blacklist'], value: splitList(blacklist) },
473
481
  { path: ['updates', 'auto'], value: autoUpdate },
482
+ { path: ['geodata', 'autoUpdate'], value: autoGeodata },
474
483
  ]
475
484
  if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
476
485
  const numericPort = Number(port)
@@ -542,7 +551,9 @@ window.__ModuleLoader__.load({
542
551
  onClick: installNow,
543
552
  }, updateBusy === 'install' ? '更新中…' : '立即更新'),
544
553
  h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6 } },
545
- h('input', { type: 'checkbox', checked: autoUpdate, onChange: (event) => setAutoUpdate(event.target.checked) }), '自动更新')),
554
+ h('input', { type: 'checkbox', checked: autoUpdate, onChange: (event) => setAutoUpdate(event.target.checked) }), '自动更新'),
555
+ h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6 } },
556
+ h('input', { type: 'checkbox', checked: autoGeodata, onChange: (event) => setAutoGeodata(event.target.checked) }), '每日更新规则数据')),
546
557
  h('div', { style: { ...C.muted, fontSize: 12, marginTop: 6 } },
547
558
  updateInfo
548
559
  ? `当前 ${updateInfo.current} · 最新 ${updateInfo.latest ?? '未知'}${updateInfo.available ? '(可更新)' : '(已是最新)'}` +
package/lib/config.js CHANGED
@@ -70,6 +70,7 @@ export const DEFAULT_CONFIG = {
70
70
  timeoutMs: 3000,
71
71
  },
72
72
  updates: { auto: false },
73
+ geodata: { autoUpdate: true },
73
74
  binary: { path: '', dataDir: '' },
74
75
  lifecycle: {
75
76
  startTimeoutMs: 15000,
@@ -353,6 +354,8 @@ export function normalizeConfig(raw) {
353
354
 
354
355
  merged.updates = merged.updates ?? {}
355
356
  merged.updates.auto = asBool(merged.updates.auto, false)
357
+ merged.geodata = merged.geodata ?? {}
358
+ merged.geodata.autoUpdate = asBool(merged.geodata.autoUpdate, true)
356
359
 
357
360
  const control = merged.control
358
361
  control.enabled = asBool(control.enabled, false)
package/lib/geodata.js ADDED
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const DAY_MS = 24 * 60 * 60 * 1000
5
+ const MIN_BYTES = 100_000
6
+
7
+ export const GEODATA_FILES = [
8
+ {
9
+ file: 'geoip.dat',
10
+ urls: [
11
+ 'https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geoip-lite.dat',
12
+ 'https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geoip.dat',
13
+ ],
14
+ },
15
+ {
16
+ file: 'geosite.dat',
17
+ urls: [
18
+ 'https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geosite.dat',
19
+ 'https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geosite.dat',
20
+ ],
21
+ },
22
+ ]
23
+
24
+ export function geodataStatus(dataDir) {
25
+ if (!dataDir) return { files: [], stale: true, missing: GEODATA_FILES.map((entry) => entry.file) }
26
+ const files = []
27
+ const missing = []
28
+ let oldest = Infinity
29
+ for (const entry of GEODATA_FILES) {
30
+ const path = join(dataDir, entry.file)
31
+ try {
32
+ const stat = statSync(path)
33
+ files.push({ file: entry.file, size: stat.size, mtimeMs: stat.mtimeMs })
34
+ oldest = Math.min(oldest, stat.mtimeMs)
35
+ } catch {
36
+ missing.push(entry.file)
37
+ }
38
+ }
39
+ return {
40
+ files,
41
+ missing,
42
+ stale: missing.length > 0 || Date.now() - oldest > DAY_MS,
43
+ updatedAt: files.length > 0 ? Math.min(...files.map((entry) => entry.mtimeMs)) : null,
44
+ }
45
+ }
46
+
47
+ async function download(url, timeoutMs) {
48
+ const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) })
49
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
50
+ const bytes = Buffer.from(await response.arrayBuffer())
51
+ if (bytes.length < MIN_BYTES) throw new Error(`payload too small (${bytes.length} B)`)
52
+ return bytes
53
+ }
54
+
55
+ /**
56
+ * Xray accepts a top-level `geodata` block but does not execute its cron (live
57
+ * test: no download within 85 s), so the host performs the update itself and
58
+ * restarts the child afterwards. Writes are atomic per file.
59
+ */
60
+ export async function updateGeodata({ dataDir, logger, timeoutMs = 30_000, force = false, files = GEODATA_FILES } = {}) {
61
+ if (!dataDir) return { updated: [], failed: [], skipped: true }
62
+ mkdirSync(dataDir, { recursive: true })
63
+ const status = geodataStatus(dataDir)
64
+ if (!force && !status.stale) {
65
+ return { updated: [], failed: [], skipped: true, status }
66
+ }
67
+ const updated = []
68
+ const failed = []
69
+ for (const entry of files) {
70
+ const target = join(dataDir, entry.file)
71
+ let done = false
72
+ for (const url of entry.urls) {
73
+ try {
74
+ const bytes = await download(url, timeoutMs)
75
+ const temporary = `${target}.tmp-${process.pid}`
76
+ writeFileSync(temporary, bytes)
77
+ renameSync(temporary, target)
78
+ updated.push({ file: entry.file, bytes: bytes.length, url })
79
+ logger?.info?.(`规则数据已更新:${entry.file}(${(bytes.length / 1048576).toFixed(1)} MB)`)
80
+ done = true
81
+ break
82
+ } catch (error) {
83
+ logger?.warn?.(`下载 ${entry.file} 失败(${url}):${String(error.message ?? error)}`)
84
+ }
85
+ }
86
+ if (!done) {
87
+ failed.push(entry.file)
88
+ try {
89
+ rmSync(`${target}.tmp-${process.pid}`, { force: true })
90
+ } catch {
91
+ // ignore
92
+ }
93
+ }
94
+ }
95
+ return { updated, failed, status: geodataStatus(dataDir) }
96
+ }
package/lib/schema.js CHANGED
@@ -113,4 +113,10 @@ export const Config = Schema.object({
113
113
  .description('启动时检查 npm 上的新版本并自动安装;重启 DSH 后生效。')
114
114
  .volatile(),
115
115
  }),
116
+ geodata: Schema.object({
117
+ autoUpdate: Schema.boolean()
118
+ .default(true)
119
+ .description('每日自动下载 geoip.dat/geosite.dat(XTLS 主源,jsDelivr 回退),更新后重启 xray。')
120
+ .volatile(),
121
+ }),
116
122
  })
package/lib/supervisor.js CHANGED
@@ -165,6 +165,12 @@ export class Supervisor {
165
165
  return this.#startTask
166
166
  }
167
167
 
168
+ /** Restart the child so a freshly downloaded geodata set is loaded. */
169
+ async restart(reason = 'manual') {
170
+ if (this.#stopped || this.#stopRequested) return
171
+ await this.#recycle(reason)
172
+ }
173
+
168
174
  async stop() {
169
175
  if (this.#stopped) return
170
176
  this.#stopped = true
@@ -732,6 +738,11 @@ export class Supervisor {
732
738
  takeover: this.config.takeover,
733
739
  settingsSchema: this.#schemaLoaded,
734
740
  routingFallback: this.#routingFallback,
741
+ routing: {
742
+ mode: this.config.routing?.mode ?? 'global',
743
+ whitelist: this.config.routing?.whitelist ?? [],
744
+ blacklist: this.config.routing?.blacklist ?? [],
745
+ },
735
746
  nodes: this.config.nodes.map((node) => ({
736
747
  id: node.id,
737
748
  tag: node.tag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-2xray",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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,78 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import http from 'node:http'
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { geodataStatus, updateGeodata } from '../lib/geodata.js'
8
+
9
+ async function withServer(payload, run) {
10
+ const server = http.createServer((request, response) => {
11
+ response.writeHead(200, { 'content-type': 'application/octet-stream' })
12
+ response.end(payload)
13
+ })
14
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
15
+ const { port } = server.address()
16
+ try {
17
+ return await run(`http://127.0.0.1:${port}`)
18
+ } finally {
19
+ server.close()
20
+ }
21
+ }
22
+
23
+ test('downloads a geodata file atomically and reports freshness', async () => {
24
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-geodata-'))
25
+ try {
26
+ const payload = Buffer.alloc(200_000, 0x1f)
27
+ await withServer(payload, async (base) => {
28
+ const result = await updateGeodata({
29
+ dataDir: dir,
30
+ force: true,
31
+ files: [{ file: 'geoip.dat', urls: [`${base}/geoip.dat`] }],
32
+ })
33
+ assert.deepEqual(result.updated.map((entry) => entry.file), ['geoip.dat'])
34
+ assert.equal(existsSync(join(dir, 'geoip.dat')), true)
35
+ assert.equal(statSync(join(dir, 'geoip.dat')).size, payload.length)
36
+ assert.deepEqual(geodataStatus(dir).missing, ['geosite.dat'])
37
+ })
38
+ } finally {
39
+ rmSync(dir, { recursive: true, force: true })
40
+ }
41
+ })
42
+
43
+ test('a tiny payload is rejected instead of replacing the file', async () => {
44
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-geodata-'))
45
+ try {
46
+ await withServer(Buffer.from('<html>blocked</html>'), async (base) => {
47
+ const result = await updateGeodata({
48
+ dataDir: dir,
49
+ force: true,
50
+ files: [{ file: 'geosite.dat', urls: [`${base}/geosite.dat`] }],
51
+ })
52
+ assert.deepEqual(result.updated, [])
53
+ assert.deepEqual(result.failed, ['geosite.dat'])
54
+ assert.equal(existsSync(join(dir, 'geosite.dat')), false)
55
+ })
56
+ } finally {
57
+ rmSync(dir, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('fresh files are skipped unless forced', async () => {
62
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-geodata-'))
63
+ try {
64
+ const payload = Buffer.alloc(200_000, 0x1f)
65
+ await withServer(payload, async (base) => {
66
+ const files = [
67
+ { file: 'geoip.dat', urls: [`${base}/geoip.dat`] },
68
+ { file: 'geosite.dat', urls: [`${base}/geosite.dat`] },
69
+ ]
70
+ await updateGeodata({ dataDir: dir, force: true, files })
71
+ const second = await updateGeodata({ dataDir: dir, files })
72
+ assert.equal(second.skipped, true)
73
+ assert.equal(readFileSync(join(dir, 'geosite.dat')).length, payload.length)
74
+ })
75
+ } finally {
76
+ rmSync(dir, { recursive: true, force: true })
77
+ }
78
+ })