@twinsearth/dsh-2xray 0.0.0-stage → 0.1.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/bin/README.md +27 -0
  5. package/cordis.patch.yml +37 -0
  6. package/docs/AC-0.md +60 -0
  7. package/docs/EVIDENCE.md +135 -0
  8. package/examples/config.downgrade.json +20 -0
  9. package/examples/config.example.json +22 -0
  10. package/examples/config.multi.downgrade.json +39 -0
  11. package/examples/config.multi.json +46 -0
  12. package/icon.svg +9 -0
  13. package/index.js +89 -0
  14. package/lib/binary.js +133 -0
  15. package/lib/config.js +401 -0
  16. package/lib/control/client.js +100 -0
  17. package/lib/control/grpc.js +121 -0
  18. package/lib/control/protobuf.js +143 -0
  19. package/lib/health.js +109 -0
  20. package/lib/log.js +20 -0
  21. package/lib/paths.js +65 -0
  22. package/lib/process.js +197 -0
  23. package/lib/route.js +299 -0
  24. package/lib/share-link.js +132 -0
  25. package/lib/supervisor.js +687 -0
  26. package/lib/tools.js +106 -0
  27. package/lib/xray-config.js +225 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +42 -4
  31. package/scripts/check-config.mjs +56 -0
  32. package/scripts/parse-share-link.mjs +23 -0
  33. package/scripts/probe-routing.mjs +66 -0
  34. package/scripts/release-check.mjs +104 -0
  35. package/test/binary.test.js +103 -0
  36. package/test/config.test.js +106 -0
  37. package/test/control.test.js +166 -0
  38. package/test/health.test.js +75 -0
  39. package/test/index.test.js +52 -0
  40. package/test/multi-node.test.js +111 -0
  41. package/test/process.test.js +49 -0
  42. package/test/route.test.js +158 -0
  43. package/test/share-link.test.js +113 -0
  44. package/test/supervisor.test.js +525 -0
  45. package/test/tools.test.js +88 -0
  46. package/test/xray-config.test.js +132 -0
package/lib/route.js ADDED
@@ -0,0 +1,299 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ const PROXY_PACKAGE = '@deepseek-ai/dsh-http-proxy'
6
+ const PROBE_URL = 'https://api.deepseek.com/'
7
+ const DISPATCHER_SYMBOLS = [Symbol.for('undici.globalDispatcher.2'), Symbol.for('undici.globalDispatcher.1')]
8
+
9
+ export class RouteError extends Error {
10
+ constructor(code, message) {
11
+ super(message)
12
+ this.name = 'RouteError'
13
+ this.code = code
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Anchor candidates for the harness installation. The desktop app ships one
19
+ * asar archive; CLI layouts use a plain package directory. Every candidate is
20
+ * tried with `createRequire` because module resolution inside an asar archive
21
+ * works in the Electron-as-Node runtime, while a bare `import('undici')` from a
22
+ * profile plugin does not (probed on 0.2.1-rc.3).
23
+ */
24
+ export function installationAnchors({ execPath = process.execPath, env = process.env } = {}) {
25
+ const dir = dirname(execPath)
26
+ const candidates = [
27
+ env.DSH_APP_ANCHOR,
28
+ join(dir, 'resources', 'app.asar', 'dsh', 'package.json'),
29
+ join(dir, 'resources', 'app', 'dsh', 'package.json'),
30
+ join(dir, '..', 'Resources', 'app.asar', 'dsh', 'package.json'),
31
+ join(dir, '..', 'Resources', 'app', 'dsh', 'package.json'),
32
+ ]
33
+ return candidates.filter(Boolean)
34
+ }
35
+
36
+ function tryResolve(anchor, specifier) {
37
+ try {
38
+ const require = createRequire(pathToFileURL(anchor))
39
+ return require.resolve(specifier)
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Import the harness' own copy of a core package. Returning the module that the
47
+ * launcher itself imported is what makes the policy singleton shared: a pnpm
48
+ * copy installed for this plugin would have its own `active`/`installed`
49
+ * variables and `web_fetch` would keep routing around it.
50
+ */
51
+ export async function loadHarnessModule(specifier, { anchors = installationAnchors() } = {}) {
52
+ for (const anchor of anchors) {
53
+ const file = tryResolve(anchor, specifier)
54
+ if (!file) continue
55
+ try {
56
+ const module = await import(pathToFileURL(file).href)
57
+ return { module, file, anchor }
58
+ } catch {
59
+ // keep trying other anchors
60
+ }
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ export function currentGlobalDispatcher() {
66
+ for (const symbol of DISPATCHER_SYMBOLS) {
67
+ const dispatcher = globalThis[symbol]
68
+ if (dispatcher) return dispatcher
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ function isLoopback(hostname) {
74
+ const host = String(hostname).replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
75
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
76
+ if (host === '::1' || host === '::' || host === '0.0.0.0') return true
77
+ if (host.startsWith('::ffff:')) return isLoopback(host.slice(7))
78
+ return /^127\./.test(host)
79
+ }
80
+
81
+ function bypasses(noProxy, url) {
82
+ const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
83
+ const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
84
+ for (const raw of String(noProxy ?? '').split(/[,\s]+/)) {
85
+ const entry = raw.trim().toLowerCase()
86
+ if (entry === '') continue
87
+ if (entry === '*') return true
88
+ let hostPart = entry
89
+ let entryPort
90
+ if (entry.startsWith('[')) {
91
+ const close = entry.indexOf(']')
92
+ if (close !== -1) {
93
+ hostPart = entry.slice(1, close)
94
+ const rest = entry.slice(close + 1)
95
+ if (rest.startsWith(':')) entryPort = rest.slice(1)
96
+ }
97
+ } else {
98
+ const first = entry.indexOf(':')
99
+ if (first !== -1 && entry.indexOf(':', first + 1) === -1) {
100
+ hostPart = entry.slice(0, first)
101
+ entryPort = entry.slice(first + 1)
102
+ }
103
+ }
104
+ if (entryPort !== undefined && entryPort !== port) continue
105
+ const candidate = hostPart.replace(/^\*?\./, '').replace(/\.$/, '')
106
+ if (!candidate) continue
107
+ if (host === candidate || host.endsWith(`.${candidate}`)) return true
108
+ }
109
+ return false
110
+ }
111
+
112
+ const STREAM_OPTIONS = {
113
+ // A long-lived SSE stream occupies one pooled connection; the cap must stay
114
+ // above peak concurrent streams or the extra request queues until the
115
+ // harness' 300s idle watchdog kills it.
116
+ connections: 64,
117
+ pipelining: 1,
118
+ keepAliveTimeout: 30_000,
119
+ keepAliveMaxTimeout: 120_000,
120
+ // Fail proxy/upstream faults before the harness' own watchdog would.
121
+ headersTimeout: 120_000,
122
+ // Must exceed the provider's 300s stream idle budget so a quiet long
123
+ // reasoning turn is not misread as a transport failure.
124
+ bodyTimeout: 330_000,
125
+ connectTimeout: 15_000,
126
+ allowH2: true,
127
+ }
128
+
129
+ /**
130
+ * Owns the process-global proxy policy for one plugin instance.
131
+ *
132
+ * Harness mode installs the DSH policy through its public entry point, which
133
+ * keeps `fetch` and `web_fetch` reading the same answer. Builtin mode is the
134
+ * fallback for layouts where that package cannot be resolved: it installs a
135
+ * dispatcher itself and explicitly reports that `web_fetch` is not covered.
136
+ *
137
+ * Installation is idempotent and restore is ordered: the dispatcher is put back
138
+ * before any agent it replaced is closed, otherwise in-flight requests fail
139
+ * with UND_ERR_DESTROYED.
140
+ */
141
+ export class RouteManager {
142
+ #installed
143
+ #sentinelSeq = 0
144
+ #logger
145
+ #loadHarness
146
+ #loadUndici
147
+
148
+ constructor({ logger, loadHarness = loadHarnessModule, loadUndici } = {}) {
149
+ this.#logger = logger
150
+ this.#loadHarness = loadHarness
151
+ this.#loadUndici = loadUndici ?? (async () => (await loadHarnessModule('undici'))?.module)
152
+ }
153
+
154
+ get installed() {
155
+ return Boolean(this.#installed)
156
+ }
157
+
158
+ snapshot() {
159
+ const installed = this.#installed
160
+ if (!installed) return { installed: false, mode: 'none', proxy: null, webFetchCovered: false, sentinel: null }
161
+ return {
162
+ installed: true,
163
+ mode: installed.mode,
164
+ proxy: installed.proxy,
165
+ webFetchCovered: installed.mode === 'harness',
166
+ sentinel: installed.sentinel,
167
+ dispatcherIsOurs: currentGlobalDispatcher() === installed.dispatcher,
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {object} options
173
+ * @param {string} options.proxyUrl - e.g. http://127.0.0.1:20808
174
+ * @param {string} [options.noProxy] - comma separated bypass list
175
+ * @param {boolean} [options.force] - install even if a proxy is already active
176
+ * @returns {Promise<object>} snapshot plus `reason` when not installed
177
+ */
178
+ async install({ proxyUrl, noProxy = '', force = false }) {
179
+ if (this.#installed) return { ...this.snapshot(), reason: 'already-installed' }
180
+
181
+ // The harness policy rejects SOCKS URLs by design, so a SOCKS inbound can
182
+ // only work through our own dispatcher — and then `web_fetch` is not
183
+ // covered. This is the documented cost of choosing socks over http.
184
+ if (proxyUrl.startsWith('socks')) {
185
+ return this.#installBuiltin({ proxyUrl, noProxy })
186
+ }
187
+
188
+ const harness = await this.#loadHarness(PROXY_PACKAGE)
189
+ if (harness && typeof harness.module.installProxyFromEnvironment === 'function' && typeof harness.module.proxyRouteFor === 'function') {
190
+ if (!force) {
191
+ const before = harness.module.proxyRouteFor(new URL(PROBE_URL))
192
+ if (before?.proxied) {
193
+ this.#logger?.info?.('检测到启动器已安装的代理策略,takeover=auto 时不覆盖;设置 takeover: force 可强制接管')
194
+ return { ...this.snapshot(), reason: 'external-proxy-active' }
195
+ }
196
+ }
197
+
198
+ const values = { http_proxy: proxyUrl, https_proxy: proxyUrl, no_proxy: noProxy }
199
+ const env = {
200
+ get(name) {
201
+ const value = values[String(name).toLowerCase()]
202
+ return value === undefined ? undefined : { value }
203
+ },
204
+ }
205
+ const sentinel = `harness-${++this.#sentinelSeq}`
206
+ const disposer = await harness.module.installProxyFromEnvironment(env, (message) =>
207
+ this.#logger?.warn?.(`代理策略诊断:${message}`),
208
+ )
209
+ const after = harness.module.proxyRouteFor(new URL(PROBE_URL))
210
+ if (!after?.proxied || after.proxy !== proxyUrl) {
211
+ // Either a different module instance (its singleton did not change) or
212
+ // a policy that did not accept the URL. Restore and fall back rather
213
+ // than pretending the traffic is proxied.
214
+ await Promise.resolve(disposer()).catch(() => {})
215
+ throw new RouteError(
216
+ 'route-instance-mismatch',
217
+ `已调用 ${PROXY_PACKAGE} 的 installProxyFromEnvironment,但 proxyRouteFor() 未返回代理路由;` +
218
+ '说明插件拿到的是另一份模块实例。请改用 builtin 模式或把该包声明为可解析的同版本依赖。',
219
+ )
220
+ }
221
+ this.#installed = {
222
+ mode: 'harness',
223
+ proxy: proxyUrl,
224
+ sentinel,
225
+ disposer,
226
+ dispatcher: after.dispatcher,
227
+ }
228
+ this.#logger?.info?.(`全局代理策略已接管:${proxyUrl}(sentinel=${sentinel})`)
229
+ return this.snapshot()
230
+ }
231
+
232
+ // Fallback: install a dispatcher ourselves. This keeps model requests
233
+ // proxied but leaves `web_fetch` on its pinned direct path.
234
+ return this.#installBuiltin({ proxyUrl, noProxy })
235
+ }
236
+
237
+ async #installBuiltin({ proxyUrl, noProxy }) {
238
+ const undici = await this.#loadUndici()
239
+ if (!undici) {
240
+ throw new RouteError('route-unavailable', '找不到 dsh-http-proxy 或 undici,无法接管全局代理')
241
+ }
242
+ const { Agent, Pool, ProxyAgent, getGlobalDispatcher, setGlobalDispatcher } = undici
243
+ const previous = getGlobalDispatcher()
244
+ const agent = new Agent({
245
+ ...STREAM_OPTIONS,
246
+ factory(origin, options) {
247
+ const url = new URL(origin.toString())
248
+ if (isLoopback(url.hostname) || bypasses(noProxy, url)) return new Pool(origin, options)
249
+ return new ProxyAgent({ ...options, uri: proxyUrl, proxyTunnel: true })
250
+ },
251
+ })
252
+ setGlobalDispatcher(agent)
253
+ const sentinel = `builtin-${++this.#sentinelSeq}`
254
+ this.#installed = { mode: 'builtin', proxy: proxyUrl, sentinel, agent, previous, dispatcher: agent }
255
+ const reason = proxyUrl.startsWith('socks')
256
+ ? 'SOCKS 入站无法走 DSH 代理策略,已用内置 undici dispatcher 接管全局 fetch'
257
+ : `未能加载 ${PROXY_PACKAGE},已用内置 undici dispatcher 接管全局 fetch`
258
+ this.#logger?.warn?.(`${reason}(sentinel=${sentinel});web_fetch 仍走直连路径,这是已知缺口`)
259
+ return this.snapshot()
260
+ }
261
+
262
+ async restore() {
263
+ const installed = this.#installed
264
+ if (!installed) return
265
+ this.#installed = undefined
266
+ if (installed.mode === 'harness') {
267
+ await Promise.resolve(installed.disposer()).catch((error) => {
268
+ this.#logger?.warn?.(`恢复原代理策略失败:${String(error)}`)
269
+ })
270
+ this.#logger?.info?.('已恢复启动器原有的代理策略')
271
+ return
272
+ }
273
+ const undici = await this.#loadUndici()
274
+ if (undici) {
275
+ undici.setGlobalDispatcher(installed.previous)
276
+ const closed = await new Promise((resolve) => {
277
+ let timer
278
+ const settle = (value) => {
279
+ clearTimeout(timer)
280
+ resolve(value)
281
+ }
282
+ Promise.resolve(installed.agent.close()).then(
283
+ () => settle(true),
284
+ () => settle(true),
285
+ )
286
+ timer = setTimeout(() => settle(false), 5000)
287
+ timer.unref?.()
288
+ })
289
+ if (!closed) {
290
+ try {
291
+ installed.agent.destroy()
292
+ } catch {
293
+ // best effort
294
+ }
295
+ }
296
+ }
297
+ this.#logger?.info?.('已恢复原始全局 dispatcher')
298
+ }
299
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Parse proxy share links into the plugin's node shape.
3
+ *
4
+ * Only `vless://` is supported: the outbound builder currently emits VLESS
5
+ * (Vision + REALITY). Other schemes are rejected with a named reason instead of
6
+ * being half-translated into a config Xray would reject at startup.
7
+ *
8
+ * Supported link shape (standard Xray/v2rayN export):
9
+ *
10
+ * vless://<uuid>@<host>:<port>?encryption=none&security=reality
11
+ * &sni=www.example.com&fp=chrome&pbk=<base64url>&sid=<hex>
12
+ * &spx=%2F&flow=xtls-rprx-vision&type=tcp#Hong%20Kong
13
+ *
14
+ * QR codes contain the same link text, so the parser is the one piece both the
15
+ * "paste link" and "scan code" flows share.
16
+ */
17
+
18
+ export class ShareLinkError extends Error {
19
+ constructor(code, message) {
20
+ super(message)
21
+ this.name = 'ShareLinkError'
22
+ this.code = code
23
+ }
24
+ }
25
+
26
+ const SUPPORTED_SCHEMES = new Set(['vless:'])
27
+ const KNOWN_SCHEMES = new Set(['vmess:', 'trojan:', 'ss:', 'socks:', 'http:', 'https:', 'hysteria2:', 'tuic:'])
28
+
29
+ function decode(value) {
30
+ try {
31
+ return decodeURIComponent(value)
32
+ } catch {
33
+ return value
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {string} link - `vless://...` share link.
39
+ * @param {object} [options]
40
+ * @param {number} [options.index] - used for the fallback node id.
41
+ * @returns {object} node config accepted by `normalizeConfig({ nodes: [...] })`.
42
+ */
43
+ export function parseShareLink(link, { index = 0 } = {}) {
44
+ const trimmed = String(link ?? '').trim()
45
+ if (!trimmed) throw new ShareLinkError('invalid-link', '分享链接为空')
46
+
47
+ let url
48
+ try {
49
+ url = new URL(trimmed)
50
+ } catch {
51
+ throw new ShareLinkError('invalid-link', `不是合法 URL:${trimmed.slice(0, 40)}…`)
52
+ }
53
+
54
+ if (!SUPPORTED_SCHEMES.has(url.protocol)) {
55
+ if (KNOWN_SCHEMES.has(url.protocol)) {
56
+ throw new ShareLinkError(
57
+ 'unsupported-scheme',
58
+ `暂不支持 ${url.protocol.replace(':', '')} 分享链接;当前只能生成 VLESS 出站,请改用 vless:// 链接或手写 nodes[]`,
59
+ )
60
+ }
61
+ throw new ShareLinkError('unsupported-scheme', `未知的分享链接协议:${url.protocol}`)
62
+ }
63
+
64
+ const uuid = decode(url.username)
65
+ const address = url.hostname.replace(/^\[|\]$/g, '')
66
+ if (!uuid) throw new ShareLinkError('invalid-link', 'vless 链接缺少 uuid')
67
+ if (!address) throw new ShareLinkError('invalid-link', 'vless 链接缺少服务器地址')
68
+
69
+ const port = url.port ? Number(url.port) : 443
70
+ const query = url.searchParams
71
+ const security = (query.get('security') ?? 'none').toLowerCase()
72
+ const rawNetwork = (query.get('type') ?? 'tcp').toLowerCase()
73
+ const network = rawNetwork === 'tcp' ? 'raw' : rawNetwork
74
+ const publicKey = query.get('pbk') ?? query.get('publicKey') ?? ''
75
+ const name = decode(url.hash.replace(/^#/, '')).trim()
76
+
77
+ const node = {
78
+ id: name || `link-${index + 1}`,
79
+ address,
80
+ port,
81
+ uuid,
82
+ flow: query.get('flow') ?? (security === 'none' ? '' : 'xtls-rprx-vision'),
83
+ serverName: query.get('sni') ?? query.get('host') ?? '',
84
+ // `password` is the current Xray field, `publicKey` the legacy one; writing
85
+ // both keeps the generated config valid across versions.
86
+ password: publicKey,
87
+ publicKey,
88
+ shortId: query.get('sid') ?? '',
89
+ fingerprint: query.get('fp') ?? 'chrome',
90
+ spiderX: query.get('spx') ?? '/',
91
+ network,
92
+ security,
93
+ }
94
+
95
+ if (security === 'none') {
96
+ node.flow = query.get('flow') ?? ''
97
+ node.serverName = ''
98
+ node.shortId = ''
99
+ }
100
+
101
+ return node
102
+ }
103
+
104
+ /**
105
+ * Parse every entry and keep the failures addressable per item, so one bad
106
+ * link does not hide the rest.
107
+ *
108
+ * @returns {{ nodes: object[], errors: {field: string, message: string}[], warnings: string[] }}
109
+ */
110
+ export function parseShareLinks(links) {
111
+ const nodes = []
112
+ const errors = []
113
+ const warnings = []
114
+ const list = Array.isArray(links) ? links : [links]
115
+ for (let index = 0; index < list.length; index++) {
116
+ const link = list[index]
117
+ if (typeof link !== 'string' || link.trim() === '') {
118
+ errors.push({ field: `shareLinks[${index}]`, message: '分享链接必须是非空字符串' })
119
+ continue
120
+ }
121
+ try {
122
+ const node = parseShareLink(link, { index })
123
+ if (!node.serverName && node.security !== 'none') {
124
+ warnings.push(`shareLinks[${index}] 缺少 sni,REALITY/TLS 需要 serverName`)
125
+ }
126
+ nodes.push(node)
127
+ } catch (error) {
128
+ errors.push({ field: `shareLinks[${index}]`, message: error.message })
129
+ }
130
+ }
131
+ return { nodes, errors, warnings }
132
+ }