@twinsearth/dsh-2xray 0.3.4 → 0.3.6

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 — 2026-10-29
4
+
5
+ ### 规则数据
6
+
7
+ - 自动更新周期从 24h/12h 调整为 **72 小时**:新鲜度阈值与定时检查间隔一致。
8
+ - 启动前仍会补缺(缺失时先下载再启动);更新成功后重启 xray 加载新数据。
9
+ - 小于 100KB 的响应仍视为错误页,不覆盖现有文件。
10
+ - 设置页文案改为「每 72 小时更新规则数据」。
11
+
12
+ ## 0.3.5 — 2026-10-29
13
+
14
+ ### 规则数据
15
+
16
+ - 新增 `geodata.autoUpdate`(默认 true):XTLS 主源 + jsDelivr 回退,下载 `geoip.dat`/`geosite.dat` 并原子替换。
17
+ - 首次缺失时在启动前下载;之后每 12 小时检查,更新后调用 `supervisor.restart()` 让 xray 重新加载。
18
+ - 小于 100KB 的响应视为错误页,不会覆盖现有文件。
19
+ - 设置页新增「每日更新规则数据」勾选框。
20
+ - 实测确认 Xray 的顶层 `geodata` 块只被解析、不执行 cron,因此由插件侧实现。
21
+
3
22
  ## 0.3.4 — 2026-10-29
4
23
 
5
24
  ### 修复
package/README.md CHANGED
@@ -334,11 +334,27 @@ 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
+ 设置页默认勾选「每 72 小时更新规则数据」:Host 会下载 XTLS 的 `geoip-lite.dat` / `geosite.dat`,失败时回退 jsDelivr(Loyalsoldier);文件小于 100KB 视为错误页并拒绝替换。首次缺失时在启动前下载,之后每 72 小时检查一次,更新成功后重启 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", ... }`
340
346
  - 发布物(公开、静态):Release 资产 `healthz.json`,`version` 与 tag 一致,可用于部署流水线的健康探针。
341
347
 
348
+ ## DSH 插件市场
349
+
350
+ 索引地址(format 1):
351
+
352
+ ```
353
+ https://raw.githubusercontent.com/TwinsEarth/DSH-2Xray/main/market/index.json
354
+ ```
355
+
356
+ 在 DSH 的「设置 → 插件市场 → 添加源」粘贴该地址,即可搜索并安装 DSH-2Xray。索引只声明 npm 包 `@twinsearth/dsh-2xray` 与其版本,安装仍走 npm registry。
357
+
342
358
  ## 更新
343
359
 
344
360
  设置页 2Xray 里有三个入口:
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, 72 * 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
@@ -326,6 +326,7 @@ window.__ModuleLoader__.load({
326
326
  const [autoUpdate, setAutoUpdate] = React.useState(value.updates?.auto === true)
327
327
  const [updateInfo, setUpdateInfo] = React.useState(null)
328
328
  const [updateBusy, setUpdateBusy] = React.useState('')
329
+ const [autoGeodata, setAutoGeodata] = React.useState(value.geodata?.autoUpdate !== false)
329
330
  const [message, setMessage] = React.useState('')
330
331
  const [kind, setKind] = React.useState('')
331
332
  const [busy, setBusy] = React.useState(false)
@@ -478,6 +479,7 @@ window.__ModuleLoader__.load({
478
479
  { path: ['routing', 'whitelist'], value: splitList(whitelist) },
479
480
  { path: ['routing', 'blacklist'], value: splitList(blacklist) },
480
481
  { path: ['updates', 'auto'], value: autoUpdate },
482
+ { path: ['geodata', 'autoUpdate'], value: autoGeodata },
481
483
  ]
482
484
  if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
483
485
  const numericPort = Number(port)
@@ -549,7 +551,9 @@ window.__ModuleLoader__.load({
549
551
  onClick: installNow,
550
552
  }, updateBusy === 'install' ? '更新中…' : '立即更新'),
551
553
  h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6 } },
552
- 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) }), '每 72 小时更新规则数据')),
553
557
  h('div', { style: { ...C.muted, fontSize: 12, marginTop: 6 } },
554
558
  updateInfo
555
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 REFRESH_MS = 72 * 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 > REFRESH_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('每 72 小时自动下载 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-2xray",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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,92 @@
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, utimesSync, writeFileSync } 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
+ })
79
+
80
+ test('the freshness window is 72 hours', () => {
81
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-geodata-'))
82
+ try {
83
+ for (const file of ['geoip.dat', 'geosite.dat']) writeFileSync(join(dir, file), Buffer.alloc(200_000, 0x1f))
84
+ const now = Math.floor(Date.now() / 1000)
85
+ for (const file of ['geoip.dat', 'geosite.dat']) utimesSync(join(dir, file), now - 48 * 3600, now - 48 * 3600)
86
+ assert.equal(geodataStatus(dir).stale, false, '48h old must still be fresh')
87
+ for (const file of ['geoip.dat', 'geosite.dat']) utimesSync(join(dir, file), now - 73 * 3600, now - 73 * 3600)
88
+ assert.equal(geodataStatus(dir).stale, true, '73h old must be stale')
89
+ } finally {
90
+ rmSync(dir, { recursive: true, force: true })
91
+ }
92
+ })