@ghfight/dsh-llm-proxy 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +244 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +182 -0
- package/lib/index.js +1777 -0
- package/lib/ui.html +654 -0
- package/package.json +63 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1777 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { execFile, spawn } from 'node:child_process'
|
|
3
|
+
import { constants as fsConstants, promises as fs } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import net from 'node:net'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
import zlib from 'node:zlib'
|
|
11
|
+
import YAML from 'yaml'
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
13
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile)
|
|
16
|
+
const VERSION = '0.4.0'
|
|
17
|
+
const AUTO_GROUP = 'DSH-AUTO'
|
|
18
|
+
const SELECT_GROUP = 'DSH'
|
|
19
|
+
const RESERVED_NAMES = new Set([AUTO_GROUP, SELECT_GROUP])
|
|
20
|
+
const MAX_SUBSCRIPTION_BYTES = 10 * 1024 * 1024
|
|
21
|
+
const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
|
|
22
|
+
const DEFAULT_BYPASS_HOSTS = [
|
|
23
|
+
'dsh.r2049.cn', '*.r2049.cn', '*.ts.net', '*.local', '*.lan',
|
|
24
|
+
'deepseek.com', '*.deepseek.com', 'deepseek.ai', '*.deepseek.ai', 'deepseek-ai.github.io'
|
|
25
|
+
]
|
|
26
|
+
const MODEL_HOSTS = [
|
|
27
|
+
'api.deepseek.com', 'api.anthropic.com', 'api.openai.com', 'chatgpt.com',
|
|
28
|
+
'generativelanguage.googleapis.com', 'openrouter.ai', 'api.groq.com',
|
|
29
|
+
'api.mistral.ai', 'api.x.ai', 'api.siliconflow.cn', 'api.moonshot.cn',
|
|
30
|
+
'dashscope.aliyuncs.com', 'open.bigmodel.cn', 'api.minimax.chat'
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const CORE_PACKAGES = {
|
|
34
|
+
'win32-x64': 'mihomo-windows-amd64',
|
|
35
|
+
'win32-arm64': 'mihomo-windows-arm64',
|
|
36
|
+
'linux-x64': 'mihomo-linux-amd64',
|
|
37
|
+
'linux-arm64': 'mihomo-linux-arm64',
|
|
38
|
+
'darwin-x64': 'mihomo-darwin-amd64',
|
|
39
|
+
'darwin-arm64': 'mihomo-darwin-arm64'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const name = '@ghfight/dsh-llm-proxy'
|
|
43
|
+
export const inject = ['tools', 'webServer']
|
|
44
|
+
|
|
45
|
+
export const Config = z.object({
|
|
46
|
+
autoStart: z.boolean().default(true),
|
|
47
|
+
subscriptionUrl: z.string().default(''),
|
|
48
|
+
corePath: z.string().default(''),
|
|
49
|
+
testUrl: z.string().default(DEFAULT_TEST_URL),
|
|
50
|
+
testInterval: z.number().step(1).min(30).max(3600).default(300),
|
|
51
|
+
mixedPort: z.number().step(1).min(0).max(65535).default(0),
|
|
52
|
+
dataDir: z.string().default(''),
|
|
53
|
+
downloadCore: z.boolean().default(true),
|
|
54
|
+
proxyScope: z.string().default('all'),
|
|
55
|
+
bypassHosts: z.string().default('')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function normalizeConfig(raw) {
|
|
59
|
+
const input = raw && typeof raw === 'object' ? raw : {}
|
|
60
|
+
return {
|
|
61
|
+
autoStart: input.autoStart !== false,
|
|
62
|
+
subscriptionUrl: typeof input.subscriptionUrl === 'string' ? input.subscriptionUrl.trim() : '',
|
|
63
|
+
corePath: typeof input.corePath === 'string' ? input.corePath.trim() : '',
|
|
64
|
+
testUrl: typeof input.testUrl === 'string' && input.testUrl.trim() ? input.testUrl.trim() : DEFAULT_TEST_URL,
|
|
65
|
+
testInterval: Number.isFinite(Number(input.testInterval)) ? Math.max(30, Math.min(3600, Math.trunc(Number(input.testInterval)))) : 300,
|
|
66
|
+
mixedPort: Number.isFinite(Number(input.mixedPort)) ? Math.max(0, Math.min(65535, Math.trunc(Number(input.mixedPort)))) : 0,
|
|
67
|
+
dataDir: typeof input.dataDir === 'string' ? input.dataDir.trim() : '',
|
|
68
|
+
downloadCore: input.downloadCore !== false,
|
|
69
|
+
proxyScope: input.proxyScope === 'models' ? 'models' : 'all',
|
|
70
|
+
bypassHosts: typeof input.bypassHosts === 'string' ? input.bypassHosts : ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function emptyState() {
|
|
75
|
+
return {
|
|
76
|
+
version: 1,
|
|
77
|
+
enabled: true,
|
|
78
|
+
proxyScope: 'all',
|
|
79
|
+
bypassHosts: DEFAULT_BYPASS_HOSTS.slice(),
|
|
80
|
+
subscription: null,
|
|
81
|
+
ports: { mixed: 0, controller: 0 },
|
|
82
|
+
secret: '',
|
|
83
|
+
corePath: '',
|
|
84
|
+
selected: AUTO_GROUP,
|
|
85
|
+
mode: 'auto',
|
|
86
|
+
lastError: ''
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function messageOf(error) {
|
|
91
|
+
if (error instanceof Error) return error.message
|
|
92
|
+
return String(error)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tail(text, limit) {
|
|
96
|
+
const value = String(text || '').replace(/\s+$/g, '')
|
|
97
|
+
if (value.length <= limit) return value
|
|
98
|
+
return value.slice(value.length - limit)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function throwIfAborted(signal) {
|
|
102
|
+
if (signal && signal.aborted) {
|
|
103
|
+
const reason = signal.reason
|
|
104
|
+
throw reason instanceof Error ? reason : new Error('操作已取消')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isDirectHost(hostname) {
|
|
113
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
114
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '0:0:0:0:0:0:0:1' || host === '::' || host === '0.0.0.0') return true
|
|
115
|
+
if (host.endsWith('.local') || host.endsWith('.lan')) return true
|
|
116
|
+
const parts = host.split('.')
|
|
117
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false
|
|
118
|
+
const octets = parts.map(Number)
|
|
119
|
+
if (octets.some((value) => value > 255)) return false
|
|
120
|
+
const [a, b] = octets
|
|
121
|
+
return a === 0 || a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeHostPattern(value) {
|
|
125
|
+
let host = String(value || '').trim().toLowerCase()
|
|
126
|
+
if (!host || host.startsWith('#')) return ''
|
|
127
|
+
host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '')
|
|
128
|
+
if (host.startsWith('.')) host = '*.' + host.slice(1)
|
|
129
|
+
if (host === '*') return '*'
|
|
130
|
+
return host.replace(/^\[|\]$/g, '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeBypassList(value) {
|
|
134
|
+
const list = Array.isArray(value) ? value : String(value || '').split(/[\n,]+/)
|
|
135
|
+
const seen = new Set()
|
|
136
|
+
const out = []
|
|
137
|
+
for (const item of list) {
|
|
138
|
+
const host = normalizeHostPattern(item)
|
|
139
|
+
if (!host || seen.has(host)) continue
|
|
140
|
+
seen.add(host)
|
|
141
|
+
out.push(host)
|
|
142
|
+
if (out.length >= 200) break
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hostMatchesPattern(hostname, pattern) {
|
|
148
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
149
|
+
const rule = normalizeHostPattern(pattern)
|
|
150
|
+
if (!rule) return false
|
|
151
|
+
if (rule === '*') return true
|
|
152
|
+
if (rule.startsWith('*.')) {
|
|
153
|
+
const base = rule.slice(2)
|
|
154
|
+
return host === base || host.endsWith('.' + base)
|
|
155
|
+
}
|
|
156
|
+
return host === rule
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isModelHost(hostname) {
|
|
160
|
+
const host = String(hostname || '').toLowerCase()
|
|
161
|
+
return MODEL_HOSTS.some((item) => host === item || host.endsWith('.' + item))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function binaryName() {
|
|
165
|
+
return process.platform === 'win32' ? 'mihomo.exe' : 'mihomo'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function corePackageName() {
|
|
169
|
+
return CORE_PACKAGES[process.platform + '-' + process.arch] || ''
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function displaySource(source) {
|
|
173
|
+
if (/^https?:\/\//i.test(source)) {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(source).host
|
|
176
|
+
} catch {
|
|
177
|
+
return 'subscription'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return path.basename(source.startsWith('file://') ? fileURLToPath(source) : source)
|
|
182
|
+
} catch {
|
|
183
|
+
return 'local-file'
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isHttpSource(source) {
|
|
188
|
+
return /^https?:\/\//i.test(source)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readCString(buffer, start, length) {
|
|
192
|
+
let end = start
|
|
193
|
+
const limit = start + length
|
|
194
|
+
while (end < limit && buffer[end] !== 0) end += 1
|
|
195
|
+
return buffer.subarray(start, end).toString('utf8')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractTarEntry(tarball, wantedName) {
|
|
199
|
+
const archive = zlib.gunzipSync(tarball)
|
|
200
|
+
let offset = 0
|
|
201
|
+
while (offset + 512 <= archive.length) {
|
|
202
|
+
const header = archive.subarray(offset, offset + 512)
|
|
203
|
+
let allZero = true
|
|
204
|
+
for (const byte of header) {
|
|
205
|
+
if (byte !== 0) {
|
|
206
|
+
allZero = false
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (allZero) break
|
|
211
|
+
const name = readCString(header, 0, 100)
|
|
212
|
+
const prefix = readCString(header, 345, 155)
|
|
213
|
+
const fullName = prefix ? prefix + '/' + name : name
|
|
214
|
+
const sizeText = readCString(header, 124, 12).trim()
|
|
215
|
+
const size = sizeText ? parseInt(sizeText, 8) : 0
|
|
216
|
+
const type = String.fromCharCode(header[156] || 48)
|
|
217
|
+
const dataStart = offset + 512
|
|
218
|
+
const normalized = fullName.replace(/\\/g, '/')
|
|
219
|
+
if ((type === '0' || type === '\0') && normalized.endsWith('/' + wantedName)) {
|
|
220
|
+
return archive.subarray(dataStart, dataStart + size)
|
|
221
|
+
}
|
|
222
|
+
offset = dataStart + Math.ceil(size / 512) * 512
|
|
223
|
+
}
|
|
224
|
+
throw new Error('下载的核心压缩包中未找到 ' + wantedName)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseSubscription(text) {
|
|
228
|
+
let doc
|
|
229
|
+
try {
|
|
230
|
+
doc = YAML.parse(text)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const compact = String(text || '').replace(/\s+/g, '')
|
|
233
|
+
if (compact.length > 0 && /^[A-Za-z0-9+/=]+$/.test(compact)) {
|
|
234
|
+
try {
|
|
235
|
+
doc = YAML.parse(Buffer.from(compact, 'base64').toString('utf8'))
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error('订阅不是有效的 Clash YAML: ' + messageOf(error))
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
throw new Error('订阅不是有效的 Clash YAML: ' + messageOf(error))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let rows
|
|
244
|
+
if (Array.isArray(doc)) rows = doc
|
|
245
|
+
else if (doc && Array.isArray(doc.proxies)) rows = doc.proxies
|
|
246
|
+
else if (doc && doc['proxy-providers']) throw new Error('该订阅只包含 proxy-providers,没有内联 proxies;请换用返回完整 Clash 配置的订阅链接')
|
|
247
|
+
else throw new Error('订阅中没有 proxies 列表;请确认链接返回的是 Clash / Mihomo 配置')
|
|
248
|
+
const seen = new Set()
|
|
249
|
+
const proxies = []
|
|
250
|
+
let skipped = 0
|
|
251
|
+
for (const row of rows) {
|
|
252
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
253
|
+
skipped += 1
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
const type = typeof row.type === 'string' ? row.type.trim() : ''
|
|
257
|
+
const server = typeof row.server === 'string' ? row.server.trim() : ''
|
|
258
|
+
const port = Number(row.port)
|
|
259
|
+
let proxyName = typeof row.name === 'string' ? row.name.trim() : ''
|
|
260
|
+
if (!type || !server || !Number.isInteger(port) || port < 1 || port > 65535 || !proxyName) {
|
|
261
|
+
skipped += 1
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
if (RESERVED_NAMES.has(proxyName)) proxyName = proxyName + ' (node)'
|
|
265
|
+
let base = proxyName
|
|
266
|
+
let suffix = 2
|
|
267
|
+
while (seen.has(proxyName)) {
|
|
268
|
+
proxyName = base + ' (' + suffix + ')'
|
|
269
|
+
suffix += 1
|
|
270
|
+
}
|
|
271
|
+
seen.add(proxyName)
|
|
272
|
+
proxies.push({ ...row, name: proxyName, type, server, port })
|
|
273
|
+
}
|
|
274
|
+
return { proxies, skipped, doc }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function historyDelay(entry) {
|
|
278
|
+
if (!entry || !Array.isArray(entry.history) || entry.history.length === 0) return 0
|
|
279
|
+
const last = entry.history[entry.history.length - 1]
|
|
280
|
+
return Number(last && last.delay) || 0
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function objectSchema(properties) {
|
|
284
|
+
return { type: 'object', additionalProperties: false, properties }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stringField(description) {
|
|
288
|
+
return { type: 'string', required: true, description }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function numberField(description) {
|
|
292
|
+
return { type: 'number', required: true, description }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function booleanField(description) {
|
|
296
|
+
return { type: 'boolean', required: true, description }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const nodeSchema = {
|
|
300
|
+
type: 'object',
|
|
301
|
+
additionalProperties: false,
|
|
302
|
+
properties: {
|
|
303
|
+
name: stringField('节点名称'),
|
|
304
|
+
type: stringField('协议类型'),
|
|
305
|
+
delay: numberField('最近一次延迟(毫秒,0 表示未知)'),
|
|
306
|
+
selected: booleanField('当前是否被手动选中')
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const summaryProperties = {
|
|
311
|
+
running: booleanField('核心是否正在运行'),
|
|
312
|
+
subscription: stringField('订阅显示名'),
|
|
313
|
+
subscriptionHost: stringField('订阅来源主机或文件名'),
|
|
314
|
+
nodeCount: numberField('节点数量'),
|
|
315
|
+
mode: stringField('auto 或 manual'),
|
|
316
|
+
selected: stringField('当前选中的节点或 DSH-AUTO'),
|
|
317
|
+
mixedPort: numberField('本机混合代理端口'),
|
|
318
|
+
coreVersion: stringField('Mihomo 版本'),
|
|
319
|
+
message: stringField('人类可读的结果摘要')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function textBlock(value) {
|
|
323
|
+
return [{ type: 'text', text: String(value == null ? '' : value) }]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function summaryText(args, value) {
|
|
327
|
+
return textBlock(value && value.message ? value.message : '操作完成')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
class ClashProxyRuntime {
|
|
331
|
+
constructor(ctx, config) {
|
|
332
|
+
this.ctx = ctx
|
|
333
|
+
this.config = config
|
|
334
|
+
this.home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
335
|
+
this.dataDir = config.dataDir || path.join(this.home, 'clash-proxy')
|
|
336
|
+
this.stateFile = path.join(this.dataDir, 'state.json')
|
|
337
|
+
this.configPath = path.join(this.dataDir, 'config.yaml')
|
|
338
|
+
this.proxiesFile = path.join(this.dataDir, 'proxies.json')
|
|
339
|
+
this.subscriptionFile = path.join(this.dataDir, 'subscription.yaml')
|
|
340
|
+
this.state = emptyState()
|
|
341
|
+
this.proxies = []
|
|
342
|
+
this.child = null
|
|
343
|
+
this.ports = { mixed: 0, controller: 0 }
|
|
344
|
+
this.secret = ''
|
|
345
|
+
this.active = false
|
|
346
|
+
this.dispatcherInstalled = false
|
|
347
|
+
this.previousDispatcher = null
|
|
348
|
+
this.agent = null
|
|
349
|
+
this.directAgent = null
|
|
350
|
+
this.undici = null
|
|
351
|
+
this.proxyAgent = null
|
|
352
|
+
this.proxyAgentPort = 0
|
|
353
|
+
this.nativeFetch = globalThis.fetch
|
|
354
|
+
this.wrappedFetch = null
|
|
355
|
+
this.previousFetch = null
|
|
356
|
+
this.ready = null
|
|
357
|
+
this.coreVersion = ''
|
|
358
|
+
this.lastError = ''
|
|
359
|
+
this.stderrTail = []
|
|
360
|
+
this.lock = Promise.resolve()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
log(level, message) {
|
|
364
|
+
const logger = this.ctx && this.ctx.logger
|
|
365
|
+
if (!logger) return
|
|
366
|
+
const fn = logger[level] || logger.info
|
|
367
|
+
if (typeof fn === 'function') {
|
|
368
|
+
try {
|
|
369
|
+
fn.call(logger, '[dsh-llm-proxy] ' + message)
|
|
370
|
+
} catch {
|
|
371
|
+
// logging must never break the proxy
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
runExclusive(fn) {
|
|
377
|
+
const next = this.lock.then(fn, fn)
|
|
378
|
+
this.lock = next.catch(() => {})
|
|
379
|
+
return next
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async loadState() {
|
|
383
|
+
try {
|
|
384
|
+
const raw = await fs.readFile(this.stateFile, 'utf8')
|
|
385
|
+
const parsed = JSON.parse(raw)
|
|
386
|
+
const base = emptyState()
|
|
387
|
+
this.state = {
|
|
388
|
+
...base,
|
|
389
|
+
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
|
390
|
+
ports: {
|
|
391
|
+
mixed: Number(parsed && parsed.ports && parsed.ports.mixed) || 0,
|
|
392
|
+
controller: Number(parsed && parsed.ports && parsed.ports.controller) || 0
|
|
393
|
+
},
|
|
394
|
+
subscription: parsed && parsed.subscription && typeof parsed.subscription === 'object' ? parsed.subscription : null,
|
|
395
|
+
proxyScope: parsed && (parsed.proxyScope === 'all' || parsed.proxyScope === 'models')
|
|
396
|
+
? parsed.proxyScope
|
|
397
|
+
: (this.config.proxyScope === 'models' ? 'models' : 'all'),
|
|
398
|
+
bypassHosts: parsed && Array.isArray(parsed.bypassHosts)
|
|
399
|
+
? normalizeBypassList(parsed.bypassHosts)
|
|
400
|
+
: normalizeBypassList(this.config.bypassHosts || DEFAULT_BYPASS_HOSTS)
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
this.state = emptyState()
|
|
404
|
+
}
|
|
405
|
+
if (!this.state.secret) this.state.secret = randomBytes(24).toString('hex')
|
|
406
|
+
this.secret = this.state.secret
|
|
407
|
+
this.ports = { ...this.state.ports }
|
|
408
|
+
this.coreVersion = ''
|
|
409
|
+
this.lastError = this.state.lastError || ''
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async saveState() {
|
|
413
|
+
await fs.mkdir(this.dataDir, { recursive: true })
|
|
414
|
+
this.state.ports = { ...this.ports }
|
|
415
|
+
this.state.secret = this.secret || this.state.secret
|
|
416
|
+
this.state.lastError = this.lastError || ''
|
|
417
|
+
const tmp = this.stateFile + '.tmp'
|
|
418
|
+
const body = JSON.stringify(this.state, null, 2)
|
|
419
|
+
try {
|
|
420
|
+
await fs.writeFile(tmp, body, { mode: 0o600 })
|
|
421
|
+
await fs.rename(tmp, this.stateFile)
|
|
422
|
+
} catch {
|
|
423
|
+
await fs.writeFile(this.stateFile, body, { mode: 0o600 })
|
|
424
|
+
await fs.rm(tmp, { force: true }).catch(() => {})
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async loadProxies() {
|
|
429
|
+
try {
|
|
430
|
+
const raw = await fs.readFile(this.proxiesFile, 'utf8')
|
|
431
|
+
const parsed = JSON.parse(raw)
|
|
432
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
433
|
+
this.proxies = parsed
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
// fall back to the saved subscription
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const text = await fs.readFile(this.subscriptionFile, 'utf8')
|
|
441
|
+
const parsed = parseSubscription(text)
|
|
442
|
+
this.proxies = parsed.proxies
|
|
443
|
+
} catch {
|
|
444
|
+
throw new Error('没有可用的节点配置,请先调用 clash_subscribe 导入订阅')
|
|
445
|
+
}
|
|
446
|
+
if (this.proxies.length === 0) throw new Error('没有可用的节点配置,请重新导入订阅')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async persistSubscription(text) {
|
|
450
|
+
await fs.mkdir(this.dataDir, { recursive: true })
|
|
451
|
+
await fs.writeFile(this.subscriptionFile, text, { mode: 0o600 })
|
|
452
|
+
await fs.writeFile(this.proxiesFile, JSON.stringify(this.proxies, null, 2), { mode: 0o600 })
|
|
453
|
+
await fs.chmod(this.subscriptionFile, 0o600).catch(() => {})
|
|
454
|
+
await fs.chmod(this.proxiesFile, 0o600).catch(() => {})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async readSource(source, signal) {
|
|
458
|
+
throwIfAborted(signal)
|
|
459
|
+
if (isHttpSource(source)) {
|
|
460
|
+
return { text: await this.fetchSubscription(source, signal), sourceType: 'url' }
|
|
461
|
+
}
|
|
462
|
+
let file = source
|
|
463
|
+
if (source.startsWith('file://')) file = fileURLToPath(source)
|
|
464
|
+
try {
|
|
465
|
+
const stat = await fs.stat(file)
|
|
466
|
+
if (stat.size > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅文件过大')
|
|
467
|
+
const text = await fs.readFile(file, 'utf8')
|
|
468
|
+
return { text, sourceType: 'file' }
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw new Error('读取本地订阅失败: ' + messageOf(error))
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async getUndici() {
|
|
475
|
+
if (this.undici) return this.undici
|
|
476
|
+
try {
|
|
477
|
+
this.undici = await import('undici')
|
|
478
|
+
} catch (error) {
|
|
479
|
+
this.log('warn', 'undici 不可用: ' + messageOf(error))
|
|
480
|
+
this.undici = null
|
|
481
|
+
}
|
|
482
|
+
return this.undici
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async getDirectAgent() {
|
|
486
|
+
if (this.directAgent) return this.directAgent
|
|
487
|
+
const undici = await this.getUndici()
|
|
488
|
+
if (!undici) return null
|
|
489
|
+
this.directAgent = new undici.Agent({ connections: 8 })
|
|
490
|
+
return this.directAgent
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async getProxyAgent() {
|
|
494
|
+
const undici = await this.getUndici()
|
|
495
|
+
if (!undici || !this.ports.mixed) return null
|
|
496
|
+
if (this.proxyAgent && this.proxyAgentPort === this.ports.mixed) return this.proxyAgent
|
|
497
|
+
await this.closeProxyAgent()
|
|
498
|
+
this.proxyAgent = new undici.ProxyAgent({ uri: 'http://127.0.0.1:' + this.ports.mixed, connections: 64 })
|
|
499
|
+
this.proxyAgentPort = this.ports.mixed
|
|
500
|
+
return this.proxyAgent
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async closeProxyAgent() {
|
|
504
|
+
const agent = this.proxyAgent
|
|
505
|
+
this.proxyAgent = null
|
|
506
|
+
this.proxyAgentPort = 0
|
|
507
|
+
if (!agent) return
|
|
508
|
+
try {
|
|
509
|
+
if (typeof agent.close === 'function') await agent.close()
|
|
510
|
+
} catch {
|
|
511
|
+
// ignore shutdown races
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async request(url, init, dispatcher) {
|
|
516
|
+
const undici = await this.getUndici()
|
|
517
|
+
const options = { ...(init || {}) }
|
|
518
|
+
if (dispatcher && options.dispatcher === undefined) options.dispatcher = dispatcher
|
|
519
|
+
if (undici && typeof undici.fetch === 'function') return await undici.fetch(url, options)
|
|
520
|
+
return await this.nativeFetch(url, options)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async fetchText(url, headers, signal, dispatcher) {
|
|
524
|
+
const timeout = AbortSignal.timeout(30000)
|
|
525
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
526
|
+
const response = await this.request(url, {
|
|
527
|
+
method: 'GET',
|
|
528
|
+
redirect: 'follow',
|
|
529
|
+
headers,
|
|
530
|
+
signal: combined,
|
|
531
|
+
...(dispatcher ? { dispatcher } : {})
|
|
532
|
+
})
|
|
533
|
+
if (!response.ok) throw new Error('订阅地址返回 HTTP ' + response.status)
|
|
534
|
+
const length = Number(response.headers.get('content-length') || 0)
|
|
535
|
+
if (length > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅内容超过 10MB 限制')
|
|
536
|
+
const text = await response.text()
|
|
537
|
+
if (text.length > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅内容超过 10MB 限制')
|
|
538
|
+
return text
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async fetchSubscription(url, signal) {
|
|
542
|
+
const headers = {
|
|
543
|
+
'user-agent': 'clash.meta/1.19.34',
|
|
544
|
+
accept: '*/*',
|
|
545
|
+
'accept-encoding': 'gzip, deflate, br'
|
|
546
|
+
}
|
|
547
|
+
const direct = await this.getDirectAgent()
|
|
548
|
+
try {
|
|
549
|
+
return await this.fetchText(url, headers, signal, direct)
|
|
550
|
+
} catch (firstError) {
|
|
551
|
+
if (this.active) {
|
|
552
|
+
try {
|
|
553
|
+
const proxy = await this.getProxyAgent()
|
|
554
|
+
return await this.fetchText(url, headers, signal, proxy || undefined)
|
|
555
|
+
} catch (secondError) {
|
|
556
|
+
throw new Error('下载订阅失败: ' + messageOf(secondError))
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
throw new Error('下载订阅失败: ' + messageOf(firstError))
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async fetchDirect(url, options) {
|
|
564
|
+
const direct = await this.getDirectAgent()
|
|
565
|
+
return await this.request(url, options, direct || undefined)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async isFileExecutable(file) {
|
|
569
|
+
try {
|
|
570
|
+
const stat = await fs.stat(file)
|
|
571
|
+
if (!stat.isFile()) return false
|
|
572
|
+
await fs.access(file, fsConstants.X_OK)
|
|
573
|
+
return true
|
|
574
|
+
} catch {
|
|
575
|
+
return false
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
coreCandidates() {
|
|
580
|
+
const result = []
|
|
581
|
+
if (this.config.corePath) result.push(this.config.corePath)
|
|
582
|
+
if (process.env.DSH_CLASH_CORE) result.push(process.env.DSH_CLASH_CORE)
|
|
583
|
+
if (this.state.corePath) result.push(this.state.corePath)
|
|
584
|
+
result.push(path.join(this.dataDir, 'bin', binaryName()))
|
|
585
|
+
const pkg = corePackageName()
|
|
586
|
+
if (pkg) {
|
|
587
|
+
try {
|
|
588
|
+
const require = createRequire(import.meta.url)
|
|
589
|
+
const entry = require.resolve('@pkgship/' + pkg)
|
|
590
|
+
result.push(path.join(path.dirname(entry), 'bin', binaryName()))
|
|
591
|
+
} catch {
|
|
592
|
+
// package not installed; the downloader handles it
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return [...new Set(result)]
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async resolveCore(allowDownload, signal) {
|
|
599
|
+
throwIfAborted(signal)
|
|
600
|
+
for (const candidate of this.coreCandidates()) {
|
|
601
|
+
if (candidate && (await this.isFileExecutable(candidate))) return candidate
|
|
602
|
+
}
|
|
603
|
+
if (allowDownload && this.config.downloadCore) {
|
|
604
|
+
const installed = await this.installCore(signal)
|
|
605
|
+
return installed.path
|
|
606
|
+
}
|
|
607
|
+
throw new Error('未找到 Mihomo 核心。请调用 clash_install_core 自动下载,或在插件配置 corePath 中指定 mihomo 可执行文件')
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async installCore(signal) {
|
|
611
|
+
throwIfAborted(signal)
|
|
612
|
+
const pkg = corePackageName()
|
|
613
|
+
if (!pkg) throw new Error('当前平台不受支持: ' + process.platform + '-' + process.arch)
|
|
614
|
+
const metaUrl = 'https://registry.npmjs.org/' + encodeURIComponent('@pkgship/' + pkg) + '/latest'
|
|
615
|
+
const metaResponse = await this.fetchDirect(metaUrl, {
|
|
616
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-llm-proxy/' + VERSION },
|
|
617
|
+
signal
|
|
618
|
+
})
|
|
619
|
+
if (!metaResponse.ok) throw new Error('获取核心版本信息失败: HTTP ' + metaResponse.status)
|
|
620
|
+
const meta = await metaResponse.json()
|
|
621
|
+
const tarballUrl = meta && meta.dist && meta.dist.tarball
|
|
622
|
+
const integrity = meta && meta.dist && meta.dist.integrity
|
|
623
|
+
if (!tarballUrl) throw new Error('核心包元数据缺少 tarball 地址')
|
|
624
|
+
this.log('info', 'downloading ' + pkg + '@' + (meta.version || 'latest'))
|
|
625
|
+
const response = await this.fetchDirect(tarballUrl, {
|
|
626
|
+
headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-llm-proxy/' + VERSION },
|
|
627
|
+
signal
|
|
628
|
+
})
|
|
629
|
+
if (!response.ok) throw new Error('下载核心失败: HTTP ' + response.status)
|
|
630
|
+
const bytes = Buffer.from(await response.arrayBuffer())
|
|
631
|
+
if (bytes.length > 100 * 1024 * 1024) throw new Error('核心压缩包超过 100MB,已中止')
|
|
632
|
+
if (integrity && integrity.startsWith('sha512-')) {
|
|
633
|
+
const actual = createHash('sha512').update(bytes).digest('base64')
|
|
634
|
+
if (actual !== integrity.slice('sha512-'.length)) throw new Error('核心包完整性校验失败')
|
|
635
|
+
}
|
|
636
|
+
const wanted = 'bin/' + binaryName()
|
|
637
|
+
const binary = extractTarEntry(bytes, wanted)
|
|
638
|
+
const dest = path.join(this.dataDir, 'bin', binaryName())
|
|
639
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
640
|
+
await fs.writeFile(dest, binary, { mode: 0o755 })
|
|
641
|
+
await fs.chmod(dest, 0o755).catch(() => {})
|
|
642
|
+
const version = await this.probeCore(dest)
|
|
643
|
+
this.state.corePath = dest
|
|
644
|
+
await this.saveState()
|
|
645
|
+
return { path: dest, version }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async probeCore(core) {
|
|
649
|
+
try {
|
|
650
|
+
const result = await execFileAsync(core, ['-v'], { timeout: 20000, windowsHide: true, maxBuffer: 1024 * 1024 })
|
|
651
|
+
const line = String(result.stdout || '').split(/\r?\n/).find((row) => row.trim().length > 0) || ''
|
|
652
|
+
return line.trim()
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throw new Error('核心无法执行: ' + messageOf(error))
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async freePort() {
|
|
659
|
+
return await new Promise((resolve, reject) => {
|
|
660
|
+
const server = net.createServer()
|
|
661
|
+
server.once('error', reject)
|
|
662
|
+
server.listen(0, '127.0.0.1', () => {
|
|
663
|
+
const address = server.address()
|
|
664
|
+
const port = address && typeof address === 'object' ? address.port : 0
|
|
665
|
+
server.close(() => resolve(port))
|
|
666
|
+
})
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async portFree(port) {
|
|
671
|
+
if (!port) return false
|
|
672
|
+
return await new Promise((resolve) => {
|
|
673
|
+
const server = net.createServer()
|
|
674
|
+
server.once('error', () => resolve(false))
|
|
675
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)))
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async ensurePorts() {
|
|
680
|
+
const saved = this.state.ports || {}
|
|
681
|
+
if (saved.mixed && saved.controller && (await this.portFree(saved.mixed)) && (await this.portFree(saved.controller))) {
|
|
682
|
+
return { mixed: saved.mixed, controller: saved.controller }
|
|
683
|
+
}
|
|
684
|
+
let mixed = 0
|
|
685
|
+
if (this.config.mixedPort > 0 && (await this.portFree(this.config.mixedPort))) mixed = this.config.mixedPort
|
|
686
|
+
if (!mixed) mixed = await this.freePort()
|
|
687
|
+
let controller = await this.freePort()
|
|
688
|
+
let guard = 0
|
|
689
|
+
while ((controller === mixed || !(await this.portFree(controller))) && guard < 20) {
|
|
690
|
+
controller = await this.freePort()
|
|
691
|
+
guard += 1
|
|
692
|
+
}
|
|
693
|
+
return { mixed, controller }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
buildConfig() {
|
|
697
|
+
const names = this.proxies.map((proxy) => proxy.name)
|
|
698
|
+
if (names.length === 0) throw new Error('没有可用节点,无法生成配置')
|
|
699
|
+
return {
|
|
700
|
+
'mixed-port': this.ports.mixed,
|
|
701
|
+
'allow-lan': false,
|
|
702
|
+
'bind-address': '127.0.0.1',
|
|
703
|
+
mode: 'rule',
|
|
704
|
+
'log-level': 'warning',
|
|
705
|
+
ipv6: false,
|
|
706
|
+
'unified-delay': true,
|
|
707
|
+
'tcp-concurrent': true,
|
|
708
|
+
'find-process-mode': 'off',
|
|
709
|
+
'external-controller': '127.0.0.1:' + this.ports.controller,
|
|
710
|
+
secret: this.secret,
|
|
711
|
+
'geodata-mode': false,
|
|
712
|
+
'geo-auto-update': false,
|
|
713
|
+
profile: { 'store-selected': true, 'store-fake-ip': false },
|
|
714
|
+
dns: { enable: false },
|
|
715
|
+
proxies: this.proxies,
|
|
716
|
+
'proxy-groups': [
|
|
717
|
+
{
|
|
718
|
+
name: AUTO_GROUP,
|
|
719
|
+
type: 'url-test',
|
|
720
|
+
url: this.config.testUrl,
|
|
721
|
+
interval: this.config.testInterval,
|
|
722
|
+
tolerance: 50,
|
|
723
|
+
lazy: false,
|
|
724
|
+
proxies: names
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
name: SELECT_GROUP,
|
|
728
|
+
type: 'select',
|
|
729
|
+
proxies: [AUTO_GROUP, ...names]
|
|
730
|
+
}
|
|
731
|
+
],
|
|
732
|
+
rules: ['MATCH,' + SELECT_GROUP]
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async writeConfig() {
|
|
737
|
+
await fs.mkdir(this.dataDir, { recursive: true })
|
|
738
|
+
const body = YAML.stringify(this.buildConfig(), { lineWidth: 0 })
|
|
739
|
+
await fs.writeFile(this.configPath, body, { mode: 0o600 })
|
|
740
|
+
await fs.chmod(this.configPath, 0o600).catch(() => {})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async testConfig(core) {
|
|
744
|
+
try {
|
|
745
|
+
const result = await execFileAsync(core, ['-t', '-d', this.dataDir, '-f', this.configPath], {
|
|
746
|
+
timeout: 30000,
|
|
747
|
+
windowsHide: true,
|
|
748
|
+
maxBuffer: 4 * 1024 * 1024
|
|
749
|
+
})
|
|
750
|
+
this.log('debug', 'config test ok: ' + tail(result.stdout || '', 200))
|
|
751
|
+
} catch (error) {
|
|
752
|
+
const output = [error && error.stdout, error && error.stderr].filter(Boolean).map(String).join('\n')
|
|
753
|
+
throw new Error('Mihomo 配置校验失败: ' + tail(output || messageOf(error), 1200))
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async spawnCore(core) {
|
|
758
|
+
this.stderrTail = []
|
|
759
|
+
this.lastError = ''
|
|
760
|
+
const child = spawn(core, ['-d', this.dataDir, '-f', this.configPath], {
|
|
761
|
+
cwd: this.dataDir,
|
|
762
|
+
windowsHide: true,
|
|
763
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
764
|
+
})
|
|
765
|
+
this.child = child
|
|
766
|
+
child.stdout?.setEncoding('utf8')
|
|
767
|
+
child.stderr?.setEncoding('utf8')
|
|
768
|
+
child.stdout?.on('data', (chunk) => this.log('debug', String(chunk).trim()))
|
|
769
|
+
child.stderr?.on('data', (chunk) => {
|
|
770
|
+
const line = String(chunk).trim()
|
|
771
|
+
if (!line) return
|
|
772
|
+
this.stderrTail.push(line)
|
|
773
|
+
if (this.stderrTail.length > 40) this.stderrTail.splice(0, this.stderrTail.length - 40)
|
|
774
|
+
this.log('debug', line)
|
|
775
|
+
})
|
|
776
|
+
child.once('error', (error) => {
|
|
777
|
+
this.lastError = 'Mihomo 进程错误: ' + messageOf(error)
|
|
778
|
+
if (this.child === child) {
|
|
779
|
+
this.child = null
|
|
780
|
+
this.active = false
|
|
781
|
+
}
|
|
782
|
+
})
|
|
783
|
+
child.once('exit', (code, signal) => {
|
|
784
|
+
if (this.child === child) {
|
|
785
|
+
this.child = null
|
|
786
|
+
this.active = false
|
|
787
|
+
}
|
|
788
|
+
this.log('warn', 'mihomo exited code=' + String(code) + ' signal=' + String(signal))
|
|
789
|
+
})
|
|
790
|
+
const deadline = Date.now() + 20000
|
|
791
|
+
let last = ''
|
|
792
|
+
while (Date.now() < deadline) {
|
|
793
|
+
if (this.child !== child) {
|
|
794
|
+
throw new Error('Mihomo 启动失败: ' + (this.lastError || tail(this.stderrTail.join(' | '), 1200)))
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
const response = await fetch(this.controllerUrl('/version'), {
|
|
798
|
+
headers: { Authorization: 'Bearer ' + this.secret },
|
|
799
|
+
signal: AbortSignal.timeout(800)
|
|
800
|
+
})
|
|
801
|
+
if (response.ok) {
|
|
802
|
+
const info = await response.json().catch(() => ({}))
|
|
803
|
+
this.coreVersion = String(info.version || '')
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
last = 'HTTP ' + response.status
|
|
807
|
+
} catch (error) {
|
|
808
|
+
last = messageOf(error)
|
|
809
|
+
}
|
|
810
|
+
await sleep(100)
|
|
811
|
+
}
|
|
812
|
+
throw new Error('Mihomo 控制器未就绪: ' + last + ' ' + tail(this.stderrTail.join(' | '), 800))
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
controllerUrl(pathname) {
|
|
816
|
+
return 'http://127.0.0.1:' + this.ports.controller + pathname
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async api(pathname, init) {
|
|
820
|
+
const options = { ...(init || {}) }
|
|
821
|
+
const headers = { Authorization: 'Bearer ' + this.secret }
|
|
822
|
+
if (options.body) headers['content-type'] = 'application/json'
|
|
823
|
+
options.headers = { ...headers, ...(options.headers || {}) }
|
|
824
|
+
if (!options.signal) options.signal = AbortSignal.timeout(6000)
|
|
825
|
+
const response = await fetch(this.controllerUrl(pathname), options)
|
|
826
|
+
const text = await response.text()
|
|
827
|
+
if (!response.ok) {
|
|
828
|
+
throw new Error('Mihomo API ' + pathname + ' 返回 HTTP ' + response.status + (text ? ': ' + text.slice(0, 300) : ''))
|
|
829
|
+
}
|
|
830
|
+
if (!text) return {}
|
|
831
|
+
try {
|
|
832
|
+
const parsed = JSON.parse(text)
|
|
833
|
+
if (pathname === '/proxies' && parsed && typeof parsed === 'object' && parsed.proxies && typeof parsed.proxies === 'object') {
|
|
834
|
+
return parsed.proxies
|
|
835
|
+
}
|
|
836
|
+
return parsed
|
|
837
|
+
} catch {
|
|
838
|
+
return { raw: text }
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async health() {
|
|
843
|
+
if (!this.child || !this.ports.controller) return false
|
|
844
|
+
try {
|
|
845
|
+
const response = await fetch(this.controllerUrl('/version'), {
|
|
846
|
+
headers: { Authorization: 'Bearer ' + this.secret },
|
|
847
|
+
signal: AbortSignal.timeout(1500)
|
|
848
|
+
})
|
|
849
|
+
return response.ok
|
|
850
|
+
} catch {
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
shouldBypass(hostname) {
|
|
856
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
857
|
+
if (isDirectHost(host)) return true
|
|
858
|
+
if (DEFAULT_BYPASS_HOSTS.some((rule) => hostMatchesPattern(host, rule))) return true
|
|
859
|
+
if (this.state.proxyScope === 'models' && !isModelHost(host)) return true
|
|
860
|
+
const rules = Array.isArray(this.state.bypassHosts) ? this.state.bypassHosts : []
|
|
861
|
+
return rules.some((rule) => hostMatchesPattern(host, rule))
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
installFetchWrapper() {
|
|
865
|
+
if (this.wrappedFetch) return
|
|
866
|
+
const previous = globalThis.fetch
|
|
867
|
+
if (typeof previous !== 'function') return
|
|
868
|
+
this.previousFetch = previous
|
|
869
|
+
const runtime = this
|
|
870
|
+
const wrapped = async function (input, init) {
|
|
871
|
+
if (!runtime.active) return await previous(input, init)
|
|
872
|
+
let hostname
|
|
873
|
+
try {
|
|
874
|
+
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input && input.url
|
|
875
|
+
hostname = new URL(raw).hostname
|
|
876
|
+
} catch {
|
|
877
|
+
return await previous(input, init)
|
|
878
|
+
}
|
|
879
|
+
if (runtime.shouldBypass(hostname)) return await previous(input, init)
|
|
880
|
+
const agent = await runtime.getProxyAgent()
|
|
881
|
+
if (!agent) return await previous(input, init)
|
|
882
|
+
const options = { ...(init || {}) }
|
|
883
|
+
if (options.dispatcher === undefined) options.dispatcher = agent
|
|
884
|
+
return await runtime.request(input, options)
|
|
885
|
+
}
|
|
886
|
+
this.wrappedFetch = wrapped
|
|
887
|
+
globalThis.fetch = wrapped
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
uninstallFetchWrapper() {
|
|
891
|
+
if (this.wrappedFetch && globalThis.fetch === this.wrappedFetch) {
|
|
892
|
+
globalThis.fetch = this.previousFetch || this.nativeFetch
|
|
893
|
+
}
|
|
894
|
+
this.wrappedFetch = null
|
|
895
|
+
this.previousFetch = null
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async installDispatcher() {
|
|
899
|
+
this.installFetchWrapper()
|
|
900
|
+
this.active = true
|
|
901
|
+
if (!this.wrappedFetch) this.log('warn', '无法安装 DSH fetch 代理包装器')
|
|
902
|
+
else this.log('info', 'DSH 的网络请求已指向 127.0.0.1:' + this.ports.mixed)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async uninstallDispatcher() {
|
|
906
|
+
this.active = false
|
|
907
|
+
await this.closeProxyAgent()
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async killChild(child) {
|
|
911
|
+
if (!child || child.exitCode !== null) return
|
|
912
|
+
await new Promise((resolve) => {
|
|
913
|
+
let settled = false
|
|
914
|
+
const finish = () => {
|
|
915
|
+
if (settled) return
|
|
916
|
+
settled = true
|
|
917
|
+
clearTimeout(timer)
|
|
918
|
+
resolve()
|
|
919
|
+
}
|
|
920
|
+
const timer = setTimeout(async () => {
|
|
921
|
+
try {
|
|
922
|
+
if (process.platform === 'win32' && child.pid) {
|
|
923
|
+
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }).catch(() => {})
|
|
924
|
+
} else {
|
|
925
|
+
child.kill('SIGKILL')
|
|
926
|
+
}
|
|
927
|
+
} catch {
|
|
928
|
+
// ignore
|
|
929
|
+
}
|
|
930
|
+
finish()
|
|
931
|
+
}, 4000)
|
|
932
|
+
child.once('exit', finish)
|
|
933
|
+
try {
|
|
934
|
+
child.kill()
|
|
935
|
+
} catch {
|
|
936
|
+
finish()
|
|
937
|
+
}
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async _stop(options) {
|
|
942
|
+
const persist = !options || options.persist !== false
|
|
943
|
+
await this.uninstallDispatcher()
|
|
944
|
+
const child = this.child
|
|
945
|
+
this.child = null
|
|
946
|
+
if (child) await this.killChild(child)
|
|
947
|
+
this.active = false
|
|
948
|
+
if (persist) {
|
|
949
|
+
this.state.enabled = false
|
|
950
|
+
await this.saveState().catch(() => {})
|
|
951
|
+
}
|
|
952
|
+
return { running: false, message: 'DSH LLM Proxy 已停止;DSH 的网络请求已恢复直连,系统代理未改动。' }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async _start(options) {
|
|
956
|
+
const selection = options && options.selection ? options.selection : 'auto'
|
|
957
|
+
const signal = options && options.signal
|
|
958
|
+
throwIfAborted(signal)
|
|
959
|
+
await this._stop({ persist: false })
|
|
960
|
+
try {
|
|
961
|
+
if (this.proxies.length === 0) await this.loadProxies()
|
|
962
|
+
const core = await this.resolveCore(true, signal)
|
|
963
|
+
this.ports = await this.ensurePorts()
|
|
964
|
+
this.secret = this.state.secret || randomBytes(24).toString('hex')
|
|
965
|
+
this.state.secret = this.secret
|
|
966
|
+
await this.writeConfig()
|
|
967
|
+
await this.testConfig(core)
|
|
968
|
+
await this.spawnCore(core)
|
|
969
|
+
this.active = true
|
|
970
|
+
await this.installDispatcher()
|
|
971
|
+
let target = selection
|
|
972
|
+
if (target !== 'auto' && target !== AUTO_GROUP && !this.proxies.some((proxy) => proxy.name === target)) target = 'auto'
|
|
973
|
+
try {
|
|
974
|
+
await this.selectInternal(target, signal)
|
|
975
|
+
} catch (error) {
|
|
976
|
+
this.lastError = '节点选择失败,已回退自动: ' + messageOf(error)
|
|
977
|
+
this.log('warn', this.lastError)
|
|
978
|
+
await this.selectInternal('auto', signal).catch(() => {})
|
|
979
|
+
}
|
|
980
|
+
this.state.enabled = true
|
|
981
|
+
this.state.lastError = this.lastError
|
|
982
|
+
await this.saveState()
|
|
983
|
+
return this.summary('已启动 DSH LLM Proxy')
|
|
984
|
+
} catch (error) {
|
|
985
|
+
this.lastError = messageOf(error)
|
|
986
|
+
this.state.enabled = false
|
|
987
|
+
this.state.lastError = this.lastError
|
|
988
|
+
await this.saveState().catch(() => {})
|
|
989
|
+
await this._stop({ persist: false }).catch(() => {})
|
|
990
|
+
throw error
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async selectInternal(name, signal) {
|
|
995
|
+
throwIfAborted(signal)
|
|
996
|
+
const target = name === 'auto' || name === AUTO_GROUP ? AUTO_GROUP : name
|
|
997
|
+
if (target !== AUTO_GROUP && !this.proxies.some((proxy) => proxy.name === target)) {
|
|
998
|
+
throw new Error('节点不存在: ' + target)
|
|
999
|
+
}
|
|
1000
|
+
await this.api('/proxies/' + encodeURIComponent(SELECT_GROUP), {
|
|
1001
|
+
method: 'PUT',
|
|
1002
|
+
body: JSON.stringify({ name: target }),
|
|
1003
|
+
signal
|
|
1004
|
+
})
|
|
1005
|
+
this.state.selected = target
|
|
1006
|
+
this.state.mode = target === AUTO_GROUP ? 'auto' : 'manual'
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async _subscribe(source, displayName, signal) {
|
|
1010
|
+
const trimmed = String(source || '').trim()
|
|
1011
|
+
if (!trimmed) throw new Error('订阅链接不能为空')
|
|
1012
|
+
throwIfAborted(signal)
|
|
1013
|
+
const loaded = await this.readSource(trimmed, signal)
|
|
1014
|
+
const parsed = parseSubscription(loaded.text)
|
|
1015
|
+
if (parsed.proxies.length === 0) throw new Error('订阅中没有可用的代理节点')
|
|
1016
|
+
this.proxies = parsed.proxies
|
|
1017
|
+
const host = displaySource(trimmed)
|
|
1018
|
+
this.state.subscription = {
|
|
1019
|
+
source: trimmed,
|
|
1020
|
+
sourceType: loaded.sourceType,
|
|
1021
|
+
host,
|
|
1022
|
+
name: displayName && displayName.trim() ? displayName.trim() : host,
|
|
1023
|
+
updatedAt: new Date().toISOString(),
|
|
1024
|
+
nodeCount: parsed.proxies.length,
|
|
1025
|
+
skipped: parsed.skipped
|
|
1026
|
+
}
|
|
1027
|
+
await this.persistSubscription(loaded.text)
|
|
1028
|
+
await this.saveState()
|
|
1029
|
+
this.log('info', 'imported ' + parsed.proxies.length + ' nodes from ' + host)
|
|
1030
|
+
return await this._start({ selection: 'auto', signal })
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async subscribe(source, displayName, signal) {
|
|
1034
|
+
await this.ready
|
|
1035
|
+
return await this.runExclusive(async () => {
|
|
1036
|
+
const result = await this._subscribe(source, displayName, signal)
|
|
1037
|
+
return {
|
|
1038
|
+
...result,
|
|
1039
|
+
imported: true,
|
|
1040
|
+
message: '已导入 ' + result.nodeCount + ' 个节点并启动代理;DSH 的请求将走 127.0.0.1:' + result.mixedPort + ',系统代理未改动。'
|
|
1041
|
+
}
|
|
1042
|
+
})
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async update(signal) {
|
|
1046
|
+
await this.ready
|
|
1047
|
+
return await this.runExclusive(async () => {
|
|
1048
|
+
const subscription = this.state.subscription
|
|
1049
|
+
if (!subscription || !subscription.source) throw new Error('尚未导入订阅,请先调用 clash_subscribe')
|
|
1050
|
+
const previousMode = this.state.mode
|
|
1051
|
+
const previousSelected = this.state.selected
|
|
1052
|
+
const loaded = await this.readSource(subscription.source, signal)
|
|
1053
|
+
const parsed = parseSubscription(loaded.text)
|
|
1054
|
+
if (parsed.proxies.length === 0) throw new Error('订阅中没有可用的代理节点')
|
|
1055
|
+
this.proxies = parsed.proxies
|
|
1056
|
+
subscription.updatedAt = new Date().toISOString()
|
|
1057
|
+
subscription.nodeCount = parsed.proxies.length
|
|
1058
|
+
subscription.skipped = parsed.skipped
|
|
1059
|
+
await this.persistSubscription(loaded.text)
|
|
1060
|
+
await this.saveState()
|
|
1061
|
+
const selection = previousMode === 'manual' && parsed.proxies.some((proxy) => proxy.name === previousSelected) ? previousSelected : 'auto'
|
|
1062
|
+
const result = await this._start({ selection, signal })
|
|
1063
|
+
return {
|
|
1064
|
+
...result,
|
|
1065
|
+
updated: true,
|
|
1066
|
+
message: '订阅已更新,共 ' + result.nodeCount + ' 个节点;' + result.message
|
|
1067
|
+
}
|
|
1068
|
+
})
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async start(signal) {
|
|
1072
|
+
await this.ready
|
|
1073
|
+
return await this.runExclusive(async () => {
|
|
1074
|
+
const selection = this.state.mode === 'manual' ? this.state.selected : 'auto'
|
|
1075
|
+
return await this._start({ selection, signal })
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async stop() {
|
|
1080
|
+
await this.ready
|
|
1081
|
+
return await this.runExclusive(async () => await this._stop({ persist: true }))
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
summary(prefix) {
|
|
1085
|
+
const subscription = this.state.subscription || {}
|
|
1086
|
+
return {
|
|
1087
|
+
running: true,
|
|
1088
|
+
subscription: String(subscription.name || ''),
|
|
1089
|
+
subscriptionHost: String(subscription.host || ''),
|
|
1090
|
+
nodeCount: this.proxies.length,
|
|
1091
|
+
mode: this.state.mode || 'auto',
|
|
1092
|
+
selected: this.state.selected || AUTO_GROUP,
|
|
1093
|
+
mixedPort: this.ports.mixed,
|
|
1094
|
+
coreVersion: this.coreVersion,
|
|
1095
|
+
message: prefix + ':' + this.proxies.length + ' 个节点,混合端口 127.0.0.1:' + this.ports.mixed + ',当前 ' + (this.state.mode === 'manual' ? '手动节点 ' + this.state.selected : '自动选择 ' + AUTO_GROUP) + '。系统代理未改动。'
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async status() {
|
|
1100
|
+
await this.ready
|
|
1101
|
+
const subscription = this.state.subscription
|
|
1102
|
+
const configured = Boolean(subscription)
|
|
1103
|
+
const running = await this.health()
|
|
1104
|
+
let selected = this.state.selected || AUTO_GROUP
|
|
1105
|
+
let mode = running ? (selected === AUTO_GROUP ? 'auto' : 'manual') : 'off'
|
|
1106
|
+
let coreVersion = this.coreVersion
|
|
1107
|
+
if (running) {
|
|
1108
|
+
try {
|
|
1109
|
+
const group = await this.api('/proxies/' + encodeURIComponent(SELECT_GROUP))
|
|
1110
|
+
if (group && group.now) selected = String(group.now)
|
|
1111
|
+
mode = selected === AUTO_GROUP ? 'auto' : 'manual'
|
|
1112
|
+
const version = await this.api('/version')
|
|
1113
|
+
coreVersion = String((version && version.version) || coreVersion)
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
this.lastError = messageOf(error)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
configured,
|
|
1120
|
+
running,
|
|
1121
|
+
mode,
|
|
1122
|
+
selected,
|
|
1123
|
+
subscription: String(subscription && subscription.name ? subscription.name : ''),
|
|
1124
|
+
subscriptionHost: String(subscription && subscription.host ? subscription.host : ''),
|
|
1125
|
+
updatedAt: String(subscription && subscription.updatedAt ? subscription.updatedAt : ''),
|
|
1126
|
+
nodeCount: subscription && Number(subscription.nodeCount) ? Number(subscription.nodeCount) : this.proxies.length,
|
|
1127
|
+
mixedPort: this.ports.mixed || 0,
|
|
1128
|
+
controllerPort: this.ports.controller || 0,
|
|
1129
|
+
coreVersion,
|
|
1130
|
+
pid: this.child && this.child.pid ? this.child.pid : 0,
|
|
1131
|
+
autoGroup: AUTO_GROUP,
|
|
1132
|
+
testUrl: this.config.testUrl,
|
|
1133
|
+
lastError: this.lastError || this.state.lastError || ''
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
policy() {
|
|
1138
|
+
const saved = Array.isArray(this.state.bypassHosts) ? this.state.bypassHosts : []
|
|
1139
|
+
return {
|
|
1140
|
+
proxyScope: this.state.proxyScope === 'models' ? 'models' : 'all',
|
|
1141
|
+
bypassHosts: normalizeBypassList([...DEFAULT_BYPASS_HOSTS, ...saved]),
|
|
1142
|
+
defaultBypassHosts: DEFAULT_BYPASS_HOSTS.slice(),
|
|
1143
|
+
modelHosts: MODEL_HOSTS.slice()
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async setPolicy(input) {
|
|
1148
|
+
const body = input && typeof input === 'object' ? input : {}
|
|
1149
|
+
this.state.proxyScope = body.proxyScope === 'models' ? 'models' : 'all'
|
|
1150
|
+
this.state.bypassHosts = normalizeBypassList(body.bypassHosts)
|
|
1151
|
+
await this.saveState()
|
|
1152
|
+
this.log('info', '代理范围已更新: ' + this.state.proxyScope + ',绕行规则 ' + this.state.bypassHosts.length + ' 条')
|
|
1153
|
+
return { policy: this.policy(), message: '代理范围与绕行规则已保存,立即生效。' }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async uiState() {
|
|
1157
|
+
await this.ready
|
|
1158
|
+
if (this.proxies.length === 0 && this.state.subscription) await this.loadProxies().catch(() => {})
|
|
1159
|
+
const status = await this.status()
|
|
1160
|
+
let activeNode = status.selected
|
|
1161
|
+
let list = this.proxies.map((proxy) => ({
|
|
1162
|
+
name: proxy.name,
|
|
1163
|
+
type: String(proxy.type || ''),
|
|
1164
|
+
delay: 0,
|
|
1165
|
+
selected: proxy.name === status.selected
|
|
1166
|
+
}))
|
|
1167
|
+
if (status.running) {
|
|
1168
|
+
try {
|
|
1169
|
+
const all = await this.api('/proxies')
|
|
1170
|
+
const group = all[SELECT_GROUP] || {}
|
|
1171
|
+
const auto = all[AUTO_GROUP] || {}
|
|
1172
|
+
if (group.now === AUTO_GROUP && auto.now) activeNode = String(auto.now)
|
|
1173
|
+
if (group.now && group.now !== AUTO_GROUP) activeNode = String(group.now)
|
|
1174
|
+
list = list.map((node) => ({
|
|
1175
|
+
...node,
|
|
1176
|
+
delay: historyDelay(all[node.name]),
|
|
1177
|
+
selected: node.name === group.now
|
|
1178
|
+
}))
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
this.lastError = messageOf(error)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return { status: { ...status, activeNode }, nodes: list, policy: this.policy() }
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async nodes(options) {
|
|
1187
|
+
await this.ready
|
|
1188
|
+
if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
|
|
1189
|
+
const all = await this.api('/proxies')
|
|
1190
|
+
const group = all[SELECT_GROUP] || {}
|
|
1191
|
+
let list = this.proxies.map((proxy) => ({
|
|
1192
|
+
name: proxy.name,
|
|
1193
|
+
type: String(proxy.type || ''),
|
|
1194
|
+
delay: historyDelay(all[proxy.name]),
|
|
1195
|
+
selected: proxy.name === group.now
|
|
1196
|
+
}))
|
|
1197
|
+
if (options && options.test === true) {
|
|
1198
|
+
const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.min(50, Math.trunc(Number(options.limit)))) : 12
|
|
1199
|
+
const names = list.slice(0, limit).map((node) => node.name)
|
|
1200
|
+
const delays = await this.testMany(names, options.signal)
|
|
1201
|
+
list = list.map((node) => (delays.has(node.name) ? { ...node, delay: delays.get(node.name) } : node))
|
|
1202
|
+
}
|
|
1203
|
+
return {
|
|
1204
|
+
count: list.length,
|
|
1205
|
+
selected: String(group.now || ''),
|
|
1206
|
+
auto: group.now === AUTO_GROUP,
|
|
1207
|
+
nodes: list
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async testMany(names, signal) {
|
|
1212
|
+
const unique = [...new Set(names.filter((name) => this.proxies.some((proxy) => proxy.name === name)))]
|
|
1213
|
+
const results = new Map()
|
|
1214
|
+
let cursor = 0
|
|
1215
|
+
const workers = Array.from({ length: Math.min(8, unique.length) }, async () => {
|
|
1216
|
+
while (cursor < unique.length) {
|
|
1217
|
+
throwIfAborted(signal)
|
|
1218
|
+
const name = unique[cursor]
|
|
1219
|
+
cursor += 1
|
|
1220
|
+
results.set(name, await this.testNode(name, signal).catch(() => 0))
|
|
1221
|
+
}
|
|
1222
|
+
})
|
|
1223
|
+
await Promise.all(workers)
|
|
1224
|
+
return results
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async testNode(name, signal) {
|
|
1228
|
+
const url = '/proxies/' + encodeURIComponent(name) + '/delay?url=' + encodeURIComponent(this.config.testUrl) + '&timeout=5000'
|
|
1229
|
+
const result = await this.api(url, { signal })
|
|
1230
|
+
return Number(result && result.delay) || 0
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async test(names, limit, signal) {
|
|
1234
|
+
await this.ready
|
|
1235
|
+
if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
|
|
1236
|
+
let requested = Array.isArray(names) ? names.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()) : []
|
|
1237
|
+
if (requested.length === 0) {
|
|
1238
|
+
const capped = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(50, Math.trunc(Number(limit)))) : 12
|
|
1239
|
+
requested = this.proxies.slice(0, capped).map((proxy) => proxy.name)
|
|
1240
|
+
}
|
|
1241
|
+
const delays = await this.testMany(requested, signal)
|
|
1242
|
+
const all = await this.api('/proxies')
|
|
1243
|
+
const group = all[SELECT_GROUP] || {}
|
|
1244
|
+
const list = this.proxies.map((proxy) => ({
|
|
1245
|
+
name: proxy.name,
|
|
1246
|
+
type: String(proxy.type || ''),
|
|
1247
|
+
delay: delays.has(proxy.name) ? delays.get(proxy.name) : historyDelay(all[proxy.name]),
|
|
1248
|
+
selected: proxy.name === group.now
|
|
1249
|
+
}))
|
|
1250
|
+
const tested = list.filter((node) => delays.has(node.name))
|
|
1251
|
+
const best = tested.slice().sort((a, b) => (a.delay || 999999) - (b.delay || 999999))[0]
|
|
1252
|
+
return {
|
|
1253
|
+
tested: tested.length,
|
|
1254
|
+
nodes: list,
|
|
1255
|
+
message: '已测试 ' + tested.length + ' 个节点' + (best ? ',当前最快 ' + best.name + '(' + best.delay + 'ms)' : '') + '。'
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async select(name, signal) {
|
|
1260
|
+
await this.ready
|
|
1261
|
+
return await this.runExclusive(async () => {
|
|
1262
|
+
if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
|
|
1263
|
+
const target = String(name || '').trim()
|
|
1264
|
+
if (!target) throw new Error('节点名称不能为空')
|
|
1265
|
+
await this.selectInternal(target, signal)
|
|
1266
|
+
await this.saveState()
|
|
1267
|
+
return {
|
|
1268
|
+
selected: this.state.selected,
|
|
1269
|
+
mode: this.state.mode,
|
|
1270
|
+
message: this.state.mode === 'auto' ? '已切换为自动选择(DSH-AUTO)' : '已切换到节点 ' + this.state.selected
|
|
1271
|
+
}
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async auto(signal) {
|
|
1276
|
+
return await this.select('auto', signal)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async installCoreTool(signal) {
|
|
1280
|
+
await this.ready
|
|
1281
|
+
return await this.runExclusive(async () => {
|
|
1282
|
+
const installed = await this.installCore(signal)
|
|
1283
|
+
return {
|
|
1284
|
+
installed: true,
|
|
1285
|
+
path: installed.path,
|
|
1286
|
+
version: installed.version,
|
|
1287
|
+
message: 'Mihomo 核心已安装到 ' + installed.path + '(' + installed.version + ')'
|
|
1288
|
+
}
|
|
1289
|
+
})
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async boot() {
|
|
1293
|
+
try {
|
|
1294
|
+
await this.loadState()
|
|
1295
|
+
if (!this.config.autoStart) return
|
|
1296
|
+
if (this.state.enabled === false) return
|
|
1297
|
+
if (!this.state.subscription && this.config.subscriptionUrl) {
|
|
1298
|
+
await this.runExclusive(async () => {
|
|
1299
|
+
await this._subscribe(this.config.subscriptionUrl, '', undefined)
|
|
1300
|
+
})
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
if (!this.state.subscription) return
|
|
1304
|
+
await this.runExclusive(async () => {
|
|
1305
|
+
const selection = this.state.mode === 'manual' ? this.state.selected : 'auto'
|
|
1306
|
+
await this._start({ selection })
|
|
1307
|
+
})
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
this.lastError = messageOf(error)
|
|
1310
|
+
this.state.lastError = this.lastError
|
|
1311
|
+
this.log('warn', '自动启动失败: ' + this.lastError)
|
|
1312
|
+
await this.saveState().catch(() => {})
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async dispose() {
|
|
1317
|
+
try {
|
|
1318
|
+
await this.runExclusive(async () => {
|
|
1319
|
+
await this._stop({ persist: false })
|
|
1320
|
+
})
|
|
1321
|
+
} catch {
|
|
1322
|
+
// ignore
|
|
1323
|
+
}
|
|
1324
|
+
this.uninstallFetchWrapper()
|
|
1325
|
+
await this.closeProxyAgent()
|
|
1326
|
+
if (this.directAgent) {
|
|
1327
|
+
try {
|
|
1328
|
+
if (typeof this.directAgent.destroy === 'function') this.directAgent.destroy()
|
|
1329
|
+
else await this.directAgent.close()
|
|
1330
|
+
} catch {
|
|
1331
|
+
// ignore
|
|
1332
|
+
}
|
|
1333
|
+
this.directAgent = null
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function registerTool(ctx, definition) {
|
|
1339
|
+
ctx.tools.register(defineTool(definition))
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function writeJson(res, status, value) {
|
|
1343
|
+
const body = JSON.stringify(value)
|
|
1344
|
+
res.writeHead(status, {
|
|
1345
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1346
|
+
'cache-control': 'no-store',
|
|
1347
|
+
'x-content-type-options': 'nosniff'
|
|
1348
|
+
})
|
|
1349
|
+
res.end(body)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function readJsonBody(req, limit = 64 * 1024) {
|
|
1353
|
+
return new Promise((resolve, reject) => {
|
|
1354
|
+
let size = 0
|
|
1355
|
+
const chunks = []
|
|
1356
|
+
req.on('data', (chunk) => {
|
|
1357
|
+
size += chunk.length
|
|
1358
|
+
if (size > limit) {
|
|
1359
|
+
const error = new Error('请求体过大')
|
|
1360
|
+
error.statusCode = 413
|
|
1361
|
+
reject(error)
|
|
1362
|
+
req.destroy()
|
|
1363
|
+
return
|
|
1364
|
+
}
|
|
1365
|
+
chunks.push(chunk)
|
|
1366
|
+
})
|
|
1367
|
+
req.on('end', () => {
|
|
1368
|
+
if (size === 0) {
|
|
1369
|
+
resolve({})
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
1374
|
+
} catch {
|
|
1375
|
+
const error = new Error('请求体不是有效 JSON')
|
|
1376
|
+
error.statusCode = 400
|
|
1377
|
+
reject(error)
|
|
1378
|
+
}
|
|
1379
|
+
})
|
|
1380
|
+
req.on('error', reject)
|
|
1381
|
+
})
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function isTrustedBrowserRequest(req) {
|
|
1385
|
+
const origin = req.headers.origin
|
|
1386
|
+
if (typeof origin === 'string' && origin) {
|
|
1387
|
+
try {
|
|
1388
|
+
return new URL(origin).host === req.headers.host
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const site = req.headers['sec-fetch-site']
|
|
1394
|
+
if (typeof site === 'string' && site && site !== 'same-origin' && site !== 'none') return false
|
|
1395
|
+
return true
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function panelUrlForExternalBrowser(value) {
|
|
1399
|
+
let parsed
|
|
1400
|
+
try {
|
|
1401
|
+
parsed = new URL(String(value || '').trim())
|
|
1402
|
+
} catch {
|
|
1403
|
+
const error = new Error('页面地址无效')
|
|
1404
|
+
error.statusCode = 400
|
|
1405
|
+
throw error
|
|
1406
|
+
}
|
|
1407
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1408
|
+
const error = new Error('只允许打开 http(s) 页面')
|
|
1409
|
+
error.statusCode = 400
|
|
1410
|
+
throw error
|
|
1411
|
+
}
|
|
1412
|
+
if (parsed.username || parsed.password || !isDirectHost(parsed.hostname)) {
|
|
1413
|
+
const error = new Error('只允许打开本机 DSH 页面')
|
|
1414
|
+
error.statusCode = 400
|
|
1415
|
+
throw error
|
|
1416
|
+
}
|
|
1417
|
+
if (parsed.pathname !== '/clash-proxy' && parsed.pathname !== '/clash-proxy/') {
|
|
1418
|
+
const error = new Error('只能打开 Clash 控制面板')
|
|
1419
|
+
error.statusCode = 400
|
|
1420
|
+
throw error
|
|
1421
|
+
}
|
|
1422
|
+
parsed.search = ''
|
|
1423
|
+
parsed.hash = ''
|
|
1424
|
+
return parsed.toString()
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function spawnDetached(command, args) {
|
|
1428
|
+
return new Promise((resolve, reject) => {
|
|
1429
|
+
let child
|
|
1430
|
+
try {
|
|
1431
|
+
child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
reject(error)
|
|
1434
|
+
return
|
|
1435
|
+
}
|
|
1436
|
+
child.once('error', reject)
|
|
1437
|
+
child.once('spawn', () => {
|
|
1438
|
+
child.unref()
|
|
1439
|
+
resolve()
|
|
1440
|
+
})
|
|
1441
|
+
})
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function spawnDefaultBrowser(url) {
|
|
1445
|
+
if (process.platform === 'win32') {
|
|
1446
|
+
const root = process.env.SystemRoot || 'C:\\Windows'
|
|
1447
|
+
const rundll = path.join(root, 'System32', 'rundll32.exe')
|
|
1448
|
+
const explorer = path.join(root, 'explorer.exe')
|
|
1449
|
+
const comspec = process.env.ComSpec || 'cmd.exe'
|
|
1450
|
+
return spawnDetached(comspec, ['/d', '/s', '/c', 'start', '', url])
|
|
1451
|
+
.catch(() => spawnDetached(rundll, ['url.dll,FileProtocolHandler', url]))
|
|
1452
|
+
.catch(() => spawnDetached(explorer, [url]))
|
|
1453
|
+
}
|
|
1454
|
+
return spawnDetached(process.platform === 'darwin' ? 'open' : 'xdg-open', [url])
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function openInSystemBrowser(url) {
|
|
1458
|
+
if (process.versions && process.versions.electron) {
|
|
1459
|
+
try {
|
|
1460
|
+
const electron = createRequire(import.meta.url)('electron')
|
|
1461
|
+
if (electron && electron.shell && typeof electron.shell.openExternal === 'function') {
|
|
1462
|
+
await electron.shell.openExternal(url)
|
|
1463
|
+
return 'electron'
|
|
1464
|
+
}
|
|
1465
|
+
} catch {}
|
|
1466
|
+
}
|
|
1467
|
+
await spawnDefaultBrowser(url)
|
|
1468
|
+
return 'spawn'
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function registerWebUi(ctx, runtime) {
|
|
1472
|
+
if (!ctx.webServer || typeof ctx.webServer.register !== 'function') return
|
|
1473
|
+
let pageCache = ''
|
|
1474
|
+
const pageFile = new URL('./ui.html', import.meta.url)
|
|
1475
|
+
const loadPage = async () => {
|
|
1476
|
+
if (!pageCache) pageCache = await fs.readFile(pageFile, 'utf8')
|
|
1477
|
+
return pageCache
|
|
1478
|
+
}
|
|
1479
|
+
const api = {
|
|
1480
|
+
open: async (body) => {
|
|
1481
|
+
const url = panelUrlForExternalBrowser(body.url)
|
|
1482
|
+
const via = await openInSystemBrowser(url)
|
|
1483
|
+
return { opened: true, url, via, message: '已在系统默认浏览器中打开' }
|
|
1484
|
+
},
|
|
1485
|
+
state: async () => ({ state: await runtime.uiState() }),
|
|
1486
|
+
settings: async (body) => {
|
|
1487
|
+
const result = await runtime.setPolicy(body)
|
|
1488
|
+
return { state: await runtime.uiState(), message: result.message }
|
|
1489
|
+
},
|
|
1490
|
+
subscribe: async (body) => {
|
|
1491
|
+
const url = String(body.url || '').trim()
|
|
1492
|
+
if (!url) throw new Error('订阅链接不能为空')
|
|
1493
|
+
await runtime.subscribe(url, body.name ? String(body.name).trim() : undefined)
|
|
1494
|
+
return { state: await runtime.uiState() }
|
|
1495
|
+
},
|
|
1496
|
+
select: async (body) => {
|
|
1497
|
+
const name = String(body.name || '').trim()
|
|
1498
|
+
if (!name) throw new Error('节点名称不能为空')
|
|
1499
|
+
await runtime.select(name)
|
|
1500
|
+
return { state: await runtime.uiState() }
|
|
1501
|
+
},
|
|
1502
|
+
auto: async () => {
|
|
1503
|
+
await runtime.auto()
|
|
1504
|
+
return { state: await runtime.uiState() }
|
|
1505
|
+
},
|
|
1506
|
+
start: async () => {
|
|
1507
|
+
await runtime.start()
|
|
1508
|
+
return { state: await runtime.uiState() }
|
|
1509
|
+
},
|
|
1510
|
+
stop: async () => {
|
|
1511
|
+
await runtime.stop()
|
|
1512
|
+
return { state: await runtime.uiState() }
|
|
1513
|
+
},
|
|
1514
|
+
update: async () => {
|
|
1515
|
+
await runtime.update()
|
|
1516
|
+
return { state: await runtime.uiState() }
|
|
1517
|
+
},
|
|
1518
|
+
test: async (body) => {
|
|
1519
|
+
const result = await runtime.test(Array.isArray(body.names) ? body.names : undefined, body.limit)
|
|
1520
|
+
return { state: await runtime.uiState(), tested: result.tested, nodes: result.nodes }
|
|
1521
|
+
},
|
|
1522
|
+
installCore: async () => {
|
|
1523
|
+
await runtime.installCoreTool()
|
|
1524
|
+
return { state: await runtime.uiState() }
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1528
|
+
kind: 'prefix',
|
|
1529
|
+
path: '/clash-proxy',
|
|
1530
|
+
handler: async (req, res) => {
|
|
1531
|
+
try {
|
|
1532
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
1533
|
+
if (pathname === '/clash-proxy' || pathname === '/clash-proxy/') {
|
|
1534
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
|
|
1535
|
+
const page = await loadPage()
|
|
1536
|
+
res.writeHead(200, {
|
|
1537
|
+
'content-type': 'text/html; charset=utf-8',
|
|
1538
|
+
'cache-control': 'no-store',
|
|
1539
|
+
'x-content-type-options': 'nosniff',
|
|
1540
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
1541
|
+
'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'self'"
|
|
1542
|
+
})
|
|
1543
|
+
res.end(page)
|
|
1544
|
+
return
|
|
1545
|
+
}
|
|
1546
|
+
const prefix = '/clash-proxy/api/'
|
|
1547
|
+
if (!pathname.startsWith(prefix)) { writeJson(res, 404, { ok: false, error: { message: 'not found' } }); return }
|
|
1548
|
+
if (!isTrustedBrowserRequest(req)) { writeJson(res, 403, { ok: false, error: { message: 'forbidden' } }); return }
|
|
1549
|
+
const method = pathname.slice(prefix.length)
|
|
1550
|
+
if (!method || method.includes('/') || !Object.hasOwn(api, method) || typeof api[method] !== 'function') {
|
|
1551
|
+
writeJson(res, 404, { ok: false, error: { message: 'unknown api method' } }); return
|
|
1552
|
+
}
|
|
1553
|
+
if (req.method !== 'GET' && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
|
|
1554
|
+
if (method === 'state' && req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
|
|
1555
|
+
if (method !== 'state' && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
|
|
1556
|
+
const body = req.method === 'POST' ? await readJsonBody(req) : {}
|
|
1557
|
+
const result = await api[method](body)
|
|
1558
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
writeJson(res, Number(error && error.statusCode) || 500, { ok: false, error: { message: messageOf(error) } })
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}), 'dsh-llm-proxy: web UI')
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function registerTools(ctx, runtime) {
|
|
1567
|
+
registerTool(ctx, {
|
|
1568
|
+
name: 'clash_subscribe',
|
|
1569
|
+
description: '导入 Clash / Mihomo 订阅链接并启动 DSH 专用代理。订阅只影响 DSH 进程,不修改 Windows 系统代理。支持 http(s) 链接或本地 YAML 文件路径。',
|
|
1570
|
+
parameters: {
|
|
1571
|
+
url: { type: 'string', required: true, description: 'Clash 订阅链接,或本地 Clash YAML 文件路径' },
|
|
1572
|
+
name: { type: 'string', description: '可选的订阅显示名' }
|
|
1573
|
+
},
|
|
1574
|
+
output: {
|
|
1575
|
+
schema: objectSchema({
|
|
1576
|
+
imported: booleanField('是否完成导入'),
|
|
1577
|
+
...summaryProperties
|
|
1578
|
+
}),
|
|
1579
|
+
render: summaryText
|
|
1580
|
+
},
|
|
1581
|
+
timeoutMs: 180000,
|
|
1582
|
+
isConcurrencySafe: false,
|
|
1583
|
+
execute: async (args, exec) => await runtime.subscribe(args.url, args.name, exec.signal)
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
registerTool(ctx, {
|
|
1587
|
+
name: 'clash_status',
|
|
1588
|
+
description: '查看 DSH LLM Proxy 状态:是否运行、当前节点、自动/手动模式、端口、订阅和核心版本。',
|
|
1589
|
+
parameters: {},
|
|
1590
|
+
output: {
|
|
1591
|
+
schema: objectSchema({
|
|
1592
|
+
configured: booleanField('是否已导入订阅'),
|
|
1593
|
+
running: booleanField('核心是否正在运行'),
|
|
1594
|
+
mode: stringField('auto / manual / off'),
|
|
1595
|
+
selected: stringField('当前节点或 DSH-AUTO'),
|
|
1596
|
+
subscription: stringField('订阅显示名'),
|
|
1597
|
+
subscriptionHost: stringField('订阅主机或文件名'),
|
|
1598
|
+
updatedAt: stringField('订阅更新时间'),
|
|
1599
|
+
nodeCount: numberField('节点数量'),
|
|
1600
|
+
mixedPort: numberField('混合代理端口'),
|
|
1601
|
+
controllerPort: numberField('控制端口'),
|
|
1602
|
+
coreVersion: stringField('Mihomo 版本'),
|
|
1603
|
+
pid: numberField('核心进程 ID,0 表示未运行'),
|
|
1604
|
+
autoGroup: stringField('自动选择组名'),
|
|
1605
|
+
testUrl: stringField('自动测速 URL'),
|
|
1606
|
+
lastError: stringField('最近一次错误,空表示无')
|
|
1607
|
+
}),
|
|
1608
|
+
render: (args, value) => textBlock(
|
|
1609
|
+
'DSH LLM Proxy:' + (value.running ? '运行中' : '未运行') + '\n' +
|
|
1610
|
+
'订阅:' + (value.subscription || '未导入') + (value.subscriptionHost ? '(' + value.subscriptionHost + ')' : '') + '\n' +
|
|
1611
|
+
'节点:' + value.nodeCount + ',模式:' + value.mode + ',当前:' + (value.selected || '无') + '\n' +
|
|
1612
|
+
'端口:混合 ' + value.mixedPort + ' / 控制 ' + value.controllerPort + ',核心:' + (value.coreVersion || '未知') + '\n' +
|
|
1613
|
+
(value.lastError ? '错误:' + value.lastError : '系统代理未改动。')
|
|
1614
|
+
)
|
|
1615
|
+
},
|
|
1616
|
+
timeoutMs: 20000,
|
|
1617
|
+
isConcurrencySafe: true,
|
|
1618
|
+
execute: async () => await runtime.status()
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
registerTool(ctx, {
|
|
1622
|
+
name: 'clash_nodes',
|
|
1623
|
+
description: '列出订阅中的节点、当前选择和已知延迟;test=true 时会对前 limit 个节点发起延迟测试。',
|
|
1624
|
+
parameters: {
|
|
1625
|
+
test: { type: 'boolean', description: '是否立即测速' },
|
|
1626
|
+
limit: { type: 'number', description: '最多测试多少个节点,默认 12,最大 50' }
|
|
1627
|
+
},
|
|
1628
|
+
output: {
|
|
1629
|
+
schema: objectSchema({
|
|
1630
|
+
count: numberField('节点总数'),
|
|
1631
|
+
selected: stringField('当前节点或 DSH-AUTO'),
|
|
1632
|
+
auto: booleanField('是否处于自动选择'),
|
|
1633
|
+
nodes: { type: 'array', required: true, description: '节点列表', items: nodeSchema }
|
|
1634
|
+
}),
|
|
1635
|
+
render: (args, value) => {
|
|
1636
|
+
const rows = value.nodes.slice(0, 30).map((node) => (node.selected ? '* ' : ' ') + node.name + ' [' + node.type + '] ' + (node.delay ? node.delay + 'ms' : '未测速'))
|
|
1637
|
+
return textBlock('节点 ' + value.count + ' 个,当前:' + (value.selected || '无') + '\n' + rows.join('\n'))
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
timeoutMs: 60000,
|
|
1641
|
+
isConcurrencySafe: true,
|
|
1642
|
+
execute: async (args, exec) => await runtime.nodes({ test: args.test === true, limit: args.limit, signal: exec.signal })
|
|
1643
|
+
})
|
|
1644
|
+
|
|
1645
|
+
registerTool(ctx, {
|
|
1646
|
+
name: 'clash_select',
|
|
1647
|
+
description: '手动指定 DSH 使用的节点。传节点名称,或传 auto 恢复自动选择。不会影响其他程序。',
|
|
1648
|
+
parameters: {
|
|
1649
|
+
name: { type: 'string', required: true, description: '节点名称,或 auto' }
|
|
1650
|
+
},
|
|
1651
|
+
output: {
|
|
1652
|
+
schema: objectSchema({
|
|
1653
|
+
selected: stringField('当前节点或 DSH-AUTO'),
|
|
1654
|
+
mode: stringField('auto 或 manual'),
|
|
1655
|
+
message: stringField('结果摘要')
|
|
1656
|
+
}),
|
|
1657
|
+
render: summaryText
|
|
1658
|
+
},
|
|
1659
|
+
timeoutMs: 30000,
|
|
1660
|
+
isConcurrencySafe: false,
|
|
1661
|
+
execute: async (args, exec) => await runtime.select(args.name, exec.signal)
|
|
1662
|
+
})
|
|
1663
|
+
|
|
1664
|
+
registerTool(ctx, {
|
|
1665
|
+
name: 'clash_auto',
|
|
1666
|
+
description: '恢复自动节点调度:Mihomo 的 DSH-AUTO url-test 组会按延迟自动选择并定期切换节点。',
|
|
1667
|
+
parameters: {},
|
|
1668
|
+
output: {
|
|
1669
|
+
schema: objectSchema({
|
|
1670
|
+
selected: stringField('当前节点或 DSH-AUTO'),
|
|
1671
|
+
mode: stringField('auto 或 manual'),
|
|
1672
|
+
message: stringField('结果摘要')
|
|
1673
|
+
}),
|
|
1674
|
+
render: summaryText
|
|
1675
|
+
},
|
|
1676
|
+
timeoutMs: 30000,
|
|
1677
|
+
isConcurrencySafe: false,
|
|
1678
|
+
execute: async (args, exec) => await runtime.auto(exec.signal)
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1681
|
+
registerTool(ctx, {
|
|
1682
|
+
name: 'clash_test',
|
|
1683
|
+
description: '测试节点延迟。可传 names 指定节点;不传则测试前 limit 个节点(默认 12)。',
|
|
1684
|
+
parameters: {
|
|
1685
|
+
names: { type: 'array', items: { type: 'string' }, description: '要测试的节点名称列表' },
|
|
1686
|
+
limit: { type: 'number', description: '未指定 names 时测试前多少个节点,默认 12,最大 50' }
|
|
1687
|
+
},
|
|
1688
|
+
output: {
|
|
1689
|
+
schema: objectSchema({
|
|
1690
|
+
tested: numberField('实际测试的节点数'),
|
|
1691
|
+
nodes: { type: 'array', required: true, description: '节点列表', items: nodeSchema },
|
|
1692
|
+
message: stringField('结果摘要')
|
|
1693
|
+
}),
|
|
1694
|
+
render: summaryText
|
|
1695
|
+
},
|
|
1696
|
+
timeoutMs: 90000,
|
|
1697
|
+
isConcurrencySafe: true,
|
|
1698
|
+
execute: async (args, exec) => await runtime.test(args.names, args.limit, exec.signal)
|
|
1699
|
+
})
|
|
1700
|
+
|
|
1701
|
+
registerTool(ctx, {
|
|
1702
|
+
name: 'clash_update',
|
|
1703
|
+
description: '重新下载当前订阅并热重启代理,尽量保留当前手动选择的节点。',
|
|
1704
|
+
parameters: {},
|
|
1705
|
+
output: {
|
|
1706
|
+
schema: objectSchema({
|
|
1707
|
+
updated: booleanField('是否完成更新'),
|
|
1708
|
+
...summaryProperties
|
|
1709
|
+
}),
|
|
1710
|
+
render: summaryText
|
|
1711
|
+
},
|
|
1712
|
+
timeoutMs: 180000,
|
|
1713
|
+
isConcurrencySafe: false,
|
|
1714
|
+
execute: async (args, exec) => await runtime.update(exec.signal)
|
|
1715
|
+
})
|
|
1716
|
+
|
|
1717
|
+
registerTool(ctx, {
|
|
1718
|
+
name: 'clash_start',
|
|
1719
|
+
description: '用已保存的订阅启动 DSH 专用代理。',
|
|
1720
|
+
parameters: {},
|
|
1721
|
+
output: {
|
|
1722
|
+
schema: objectSchema(summaryProperties),
|
|
1723
|
+
render: summaryText
|
|
1724
|
+
},
|
|
1725
|
+
timeoutMs: 60000,
|
|
1726
|
+
isConcurrencySafe: false,
|
|
1727
|
+
execute: async (args, exec) => await runtime.start(exec.signal)
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
registerTool(ctx, {
|
|
1731
|
+
name: 'clash_stop',
|
|
1732
|
+
description: '停止 DSH 专用代理并恢复直连;不会改动系统代理。',
|
|
1733
|
+
parameters: {},
|
|
1734
|
+
output: {
|
|
1735
|
+
schema: objectSchema({
|
|
1736
|
+
running: booleanField('核心是否仍在运行'),
|
|
1737
|
+
message: stringField('结果摘要')
|
|
1738
|
+
}),
|
|
1739
|
+
render: summaryText
|
|
1740
|
+
},
|
|
1741
|
+
timeoutMs: 30000,
|
|
1742
|
+
isConcurrencySafe: false,
|
|
1743
|
+
execute: async () => await runtime.stop()
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1746
|
+
registerTool(ctx, {
|
|
1747
|
+
name: 'clash_install_core',
|
|
1748
|
+
description: '下载并安装匹配当前平台的 Mihomo 核心到 DSH 数据目录,用于首次使用或修复核心缺失。',
|
|
1749
|
+
parameters: {},
|
|
1750
|
+
output: {
|
|
1751
|
+
schema: objectSchema({
|
|
1752
|
+
installed: booleanField('是否安装成功'),
|
|
1753
|
+
path: stringField('核心可执行文件路径'),
|
|
1754
|
+
version: stringField('核心版本'),
|
|
1755
|
+
message: stringField('结果摘要')
|
|
1756
|
+
}),
|
|
1757
|
+
render: summaryText
|
|
1758
|
+
},
|
|
1759
|
+
timeoutMs: 300000,
|
|
1760
|
+
isConcurrencySafe: false,
|
|
1761
|
+
execute: async (args, exec) => await runtime.installCoreTool(exec.signal)
|
|
1762
|
+
})
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
export function apply(ctx, rawConfig) {
|
|
1766
|
+
const config = normalizeConfig(rawConfig)
|
|
1767
|
+
const runtime = new ClashProxyRuntime(ctx, config)
|
|
1768
|
+
runtime.installFetchWrapper()
|
|
1769
|
+
registerTools(ctx, runtime)
|
|
1770
|
+
registerWebUi(ctx, runtime)
|
|
1771
|
+
if (typeof ctx.effect === 'function') {
|
|
1772
|
+
ctx.effect(() => () => {
|
|
1773
|
+
runtime.dispose().catch(() => {})
|
|
1774
|
+
}, 'dsh-llm-proxy: lifecycle')
|
|
1775
|
+
}
|
|
1776
|
+
runtime.ready = runtime.boot()
|
|
1777
|
+
}
|