@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/bin/README.md +27 -0
- package/cordis.patch.yml +37 -0
- package/docs/AC-0.md +60 -0
- package/docs/EVIDENCE.md +135 -0
- package/examples/config.downgrade.json +20 -0
- package/examples/config.example.json +22 -0
- package/examples/config.multi.downgrade.json +39 -0
- package/examples/config.multi.json +46 -0
- package/icon.svg +9 -0
- package/index.js +89 -0
- package/lib/binary.js +133 -0
- package/lib/config.js +401 -0
- package/lib/control/client.js +100 -0
- package/lib/control/grpc.js +121 -0
- package/lib/control/protobuf.js +143 -0
- package/lib/health.js +109 -0
- package/lib/log.js +20 -0
- package/lib/paths.js +65 -0
- package/lib/process.js +197 -0
- package/lib/route.js +299 -0
- package/lib/share-link.js +132 -0
- package/lib/supervisor.js +687 -0
- package/lib/tools.js +106 -0
- package/lib/xray-config.js +225 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
- package/scripts/check-config.mjs +56 -0
- package/scripts/parse-share-link.mjs +23 -0
- package/scripts/probe-routing.mjs +66 -0
- package/scripts/release-check.mjs +104 -0
- package/test/binary.test.js +103 -0
- package/test/config.test.js +106 -0
- package/test/control.test.js +166 -0
- package/test/health.test.js +75 -0
- package/test/index.test.js +52 -0
- package/test/multi-node.test.js +111 -0
- package/test/process.test.js +49 -0
- package/test/route.test.js +158 -0
- package/test/share-link.test.js +113 -0
- package/test/supervisor.test.js +525 -0
- package/test/tools.test.js +88 -0
- 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
|
+
}
|