@having5548/dsh-downloader 0.0.0-stage → 0.5.2
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 +376 -0
- package/LICENSE +29 -0
- package/README.en.md +378 -0
- package/README.md +382 -2
- package/cordis.patch.yml +9 -0
- package/lib/client.js +862 -0
- package/lib/core/ai-domains.js +130 -0
- package/lib/core/data/cn-cidrs.json.gz +0 -0
- package/lib/core/data/cn-cidrs6.json.gz +0 -0
- package/lib/core/data/cn-domains.json.gz +0 -0
- package/lib/core/profiles.js +372 -0
- package/lib/core/proxy-server.js +341 -0
- package/lib/core/rules.js +242 -0
- package/lib/core/session-guard.js +208 -0
- package/lib/core/share-links.js +371 -0
- package/lib/core/shell-proxy.js +143 -0
- package/lib/core/subscription.js +126 -0
- package/lib/core/transports.js +664 -0
- package/lib/core/upstream.js +1044 -0
- package/lib/core/vmess.js +298 -0
- package/lib/core/ws.js +128 -0
- package/lib/download/auth.js +110 -0
- package/lib/download/fetch-file.js +363 -0
- package/lib/download/http.js +299 -0
- package/lib/download/net-probe.js +126 -0
- package/lib/download/route.js +103 -0
- package/lib/download/segmented.js +279 -0
- package/lib/download/util.js +103 -0
- package/lib/index.js +1199 -0
- package/lib/native/connector.exe +0 -0
- package/native/README.md +59 -0
- package/native/dial.go +17 -0
- package/native/frame.go +112 -0
- package/native/go.mod +19 -0
- package/native/go.sum +32 -0
- package/native/hysteria2.go +178 -0
- package/native/main.go +79 -0
- package/native/reality.go +188 -0
- package/native/salamander.go +84 -0
- package/native/vless.go +455 -0
- package/package.json +86 -4
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream resolution: turns the plugin config into "where does proxied
|
|
3
|
+
* traffic leave this machine", and owns the loopback rule core that does it.
|
|
4
|
+
*
|
|
5
|
+
* Three modes, tried in order (`resolve()`):
|
|
6
|
+
* 1. `explicit` — `config.proxyUrl`
|
|
7
|
+
* 2. `environment` — `$HTTPS_PROXY` / `$HTTP_PROXY` (the values DSH's own
|
|
8
|
+
* launch-time proxy policy publishes into `process.env`)
|
|
9
|
+
* 3. `subscription` — a self-contained Clash subscription (the only mode that
|
|
10
|
+
* needs no external program at all)
|
|
11
|
+
*
|
|
12
|
+
* Every mode that has a usable exit starts the *same* loopback mixed proxy
|
|
13
|
+
* (`ProxyServer` + `RuleEngine`), so the downloader has exactly one proxy code
|
|
14
|
+
* path: HTTP CONNECT to `127.0.0.1:<port>`.
|
|
15
|
+
*
|
|
16
|
+
* The plugin never mutates `process.env` and never touches a global dispatcher.
|
|
17
|
+
* @module dsh-downloader/upstream
|
|
18
|
+
*/
|
|
19
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { fetchSubscription, parseSubscription, sanitizeGroups } from "./subscription.js";
|
|
24
|
+
import { RuleEngine } from "./rules.js";
|
|
25
|
+
import { ProxyServer } from "./proxy-server.js";
|
|
26
|
+
import { hasNativeConnector, measureLatency } from "./transports.js";
|
|
27
|
+
import { parseProxyUrl } from "../download/http.js";
|
|
28
|
+
import { humanSize } from "../download/util.js";
|
|
29
|
+
import { composeDirectDomains, directRuleLines } from "./ai-domains.js";
|
|
30
|
+
import { applyGuard, composeNoProxy, envFilePath, inspectGuard, restoreGuard } from "./session-guard.js";
|
|
31
|
+
import { ProfileStore, parseImportLink } from "./profiles.js";
|
|
32
|
+
import { buildProxyUrl } from "./shell-proxy.js";
|
|
33
|
+
|
|
34
|
+
/** Node types the vendored transports implement. */
|
|
35
|
+
const SUPPORTED_TYPES = new Set(["http", "socks5", "socks", "ss", "trojan", "vless", "vmess", "hysteria2"]);
|
|
36
|
+
|
|
37
|
+
/** Group types surfaced in the management panel. */
|
|
38
|
+
const GROUP_TYPES = new Set(["select", "url-test", "fallback", "load-balance"]);
|
|
39
|
+
|
|
40
|
+
/** Config fields whose change requires rebuilding the core. */
|
|
41
|
+
const REBUILD_FIELDS = [
|
|
42
|
+
"enabled",
|
|
43
|
+
"proxyUrl",
|
|
44
|
+
"subscriptionUrl",
|
|
45
|
+
"fetchProxyUrl",
|
|
46
|
+
"groupType",
|
|
47
|
+
"preferredNode",
|
|
48
|
+
"latencyTestUrl",
|
|
49
|
+
"latencyTimeoutMs",
|
|
50
|
+
"domesticDirect",
|
|
51
|
+
"protectAiPlatforms",
|
|
52
|
+
"extraDirectDomains",
|
|
53
|
+
"extraRules",
|
|
54
|
+
"excludeRules"
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** True when a parsed node object speaks a protocol the transports implement. */
|
|
58
|
+
export function isSupportedNode(node) {
|
|
59
|
+
if (typeof node === "string") return false;
|
|
60
|
+
return SUPPORTED_TYPES.has(String(node?.type ?? "").toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether a node needs the optional bundled Go connector. */
|
|
64
|
+
export function needsNativeConnector(node) {
|
|
65
|
+
if (typeof node === "string") return false;
|
|
66
|
+
const type = String(node?.type ?? "").toLowerCase();
|
|
67
|
+
return type === "hysteria2" || (type === "vless" && node?.["reality-opts"] !== undefined) || node?.["reality-opts"] !== undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Loopback URLs must never be handed to a proxy. */
|
|
71
|
+
function isLoopbackUrl(value) {
|
|
72
|
+
try {
|
|
73
|
+
const host = new URL(value.includes("://") ? value : `http://${value}`).hostname.toLowerCase();
|
|
74
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class UpstreamManager {
|
|
81
|
+
#ctx;
|
|
82
|
+
#resolveConfig;
|
|
83
|
+
#dataDirName;
|
|
84
|
+
|
|
85
|
+
#state = "idle";
|
|
86
|
+
#error = null;
|
|
87
|
+
#mode = "none";
|
|
88
|
+
#reason = "尚未解析";
|
|
89
|
+
#signature = null;
|
|
90
|
+
|
|
91
|
+
#server = null;
|
|
92
|
+
#port = 0;
|
|
93
|
+
#engine = null;
|
|
94
|
+
/** @type {Map<string, object>} */
|
|
95
|
+
#nodes = new Map();
|
|
96
|
+
#unsupported = 0;
|
|
97
|
+
#nativeNodes = 0;
|
|
98
|
+
#selected = null;
|
|
99
|
+
/** @type {Record<string, number|null>} */
|
|
100
|
+
#latencies = {};
|
|
101
|
+
#subscription = null;
|
|
102
|
+
#traffic = { up: 0, down: 0, at: 0 };
|
|
103
|
+
#trafficCache = { at: 0, value: null };
|
|
104
|
+
#autoUpdateDisposer = null;
|
|
105
|
+
#latencyDisposer = null;
|
|
106
|
+
#scheduler = null;
|
|
107
|
+
/**
|
|
108
|
+
* Per-process secret the loopback core requires. It is never persisted and
|
|
109
|
+
* never leaves this process, so the listener serves this plugin and nothing
|
|
110
|
+
* else — that is what "只有 DSH 自己发起的下载走代理" means in practice.
|
|
111
|
+
*/
|
|
112
|
+
#scopeToken = randomBytes(24).toString("hex");
|
|
113
|
+
#task = Promise.resolve();
|
|
114
|
+
|
|
115
|
+
constructor(ctx, resolveConfig, options = {}) {
|
|
116
|
+
this.#ctx = ctx;
|
|
117
|
+
this.#resolveConfig = resolveConfig;
|
|
118
|
+
this.#dataDirName = options.dataDirName ?? "dsh-downloader";
|
|
119
|
+
this.profiles = new ProfileStore({ dataDir: this.dataDir });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Data directory (`$DSH_HOME/<name>`). */
|
|
123
|
+
get dataDir() {
|
|
124
|
+
const root = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
125
|
+
return join(root, this.#dataDirName);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Bound loopback port of the rule core (0 when not running). */
|
|
129
|
+
get port() {
|
|
130
|
+
return this.#port;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- public API ---------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the upstream for the current config. Cheap after the first call:
|
|
137
|
+
* the core is rebuilt only when a rebuild-relevant field changed.
|
|
138
|
+
* @returns the routing view consumed by `decideRoute` and the download tool.
|
|
139
|
+
*/
|
|
140
|
+
async resolve({ refresh = false } = {}) {
|
|
141
|
+
const config = this.#resolveConfig();
|
|
142
|
+
const signature = JSON.stringify(Object.fromEntries(REBUILD_FIELDS.map((field) => [field, config[field]])));
|
|
143
|
+
if (refresh || signature !== this.#signature) {
|
|
144
|
+
this.#signature = signature;
|
|
145
|
+
await this.#rebuild(config, refresh);
|
|
146
|
+
}
|
|
147
|
+
return this.#view();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The current routing view without triggering a rebuild. */
|
|
151
|
+
view() {
|
|
152
|
+
return this.#view();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Live status for the management panel (never includes secrets). */
|
|
156
|
+
status() {
|
|
157
|
+
const config = this.#resolveConfig();
|
|
158
|
+
const protectedDomains = this.directDomains();
|
|
159
|
+
const profiles = this.profiles.list();
|
|
160
|
+
const current = profiles.find((profile) => profile.current) ?? null;
|
|
161
|
+
return {
|
|
162
|
+
state: this.#state,
|
|
163
|
+
error: this.#error,
|
|
164
|
+
mode: this.#mode,
|
|
165
|
+
reason: this.#reason,
|
|
166
|
+
enabled: config.enabled !== false,
|
|
167
|
+
port: this.#port,
|
|
168
|
+
nodeCount: this.#nodes.size,
|
|
169
|
+
unsupportedNodes: this.#unsupported,
|
|
170
|
+
nativeConnector: hasNativeConnector(),
|
|
171
|
+
nativeNodes: this.#nativeNodes,
|
|
172
|
+
selected: this.#selected,
|
|
173
|
+
groupType: config.groupType,
|
|
174
|
+
domesticDirect: config.domesticDirect !== false,
|
|
175
|
+
protectAiPlatforms: config.protectAiPlatforms !== false,
|
|
176
|
+
protectedDomains,
|
|
177
|
+
// "只服务 DSH 自身发起的下载请求" —— 这两条是它的可验证形式。
|
|
178
|
+
scope: {
|
|
179
|
+
loopbackOnly: true,
|
|
180
|
+
tokenGated: this.#server?.gated === true,
|
|
181
|
+
socksRefused: this.#server?.gated === true,
|
|
182
|
+
note: "内核只绑 127.0.0.1,并要求本进程的随机令牌;其它程序连不上也借不走,系统代理与环境变量从未被修改。"
|
|
183
|
+
},
|
|
184
|
+
profiles: profiles.map((profile) => ({
|
|
185
|
+
id: profile.id,
|
|
186
|
+
label: profile.label,
|
|
187
|
+
source: profile.source,
|
|
188
|
+
url: profile.source === "url" ? profile.url : "",
|
|
189
|
+
current: profile.current,
|
|
190
|
+
autoUpdate: profile.autoUpdate,
|
|
191
|
+
autoUpdateHours: profile.autoUpdateHours,
|
|
192
|
+
lastUpdateAt: profile.lastUpdateAt ?? null,
|
|
193
|
+
dueAt: profile.dueAt,
|
|
194
|
+
subscriptionInfo: profile.subscriptionInfo
|
|
195
|
+
})),
|
|
196
|
+
profileCount: profiles.length,
|
|
197
|
+
currentProfile: current === null ? null : { id: current.id, label: current.label, source: current.source },
|
|
198
|
+
activeProfileLabel: this.#subscription?.profileLabel ?? null,
|
|
199
|
+
subscriptionUrlSet: typeof config.subscriptionUrl === "string" && config.subscriptionUrl.trim().length > 0,
|
|
200
|
+
proxyUrlSet: typeof config.proxyUrl === "string" && config.proxyUrl.trim().length > 0,
|
|
201
|
+
envProxy: this.#environmentProxy()?.raw ?? null,
|
|
202
|
+
lastSubscriptionUpdate: this.#subscription?.updatedAt ?? null,
|
|
203
|
+
latencyTestUrl: config.latencyTestUrl,
|
|
204
|
+
autoUpdateHours: config.autoUpdateHours,
|
|
205
|
+
dataDir: this.dataDir,
|
|
206
|
+
traffic: this.#server === null ? { up: 0, down: 0, upRate: 0, downRate: 0 } : this.#trafficSnapshot()
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The domains currently pinned to DIRECT (AI platforms + user additions). */
|
|
211
|
+
directDomains() {
|
|
212
|
+
const config = this.#resolveConfig();
|
|
213
|
+
return composeDirectDomains({
|
|
214
|
+
protectAiPlatforms: config.protectAiPlatforms !== false,
|
|
215
|
+
extraDirectDomains: config.extraDirectDomains ?? []
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether `$DSH_HOME/.env` already keeps those domains off the global proxy. */
|
|
220
|
+
guardState() {
|
|
221
|
+
return inspectGuard({ envPath: envFilePath(process.env.DSH_HOME), domains: this.directDomains() });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 给 shell 命令用的代理环境。
|
|
226
|
+
*
|
|
227
|
+
* 只在内核确实在跑(因而有令牌与端口)时返回;调用方把它拼进**一次** shell
|
|
228
|
+
* 调用的前缀里,git / curl / npm 的子进程就会经由本插件的规则内核分流。
|
|
229
|
+
* 国内 AI 平台域名与回环写进 NO_PROXY,让它们连内核都不必经过。
|
|
230
|
+
* @returns `{ proxyUrl, noProxy, port }` 或 null。
|
|
231
|
+
*/
|
|
232
|
+
shellProxyEnv() {
|
|
233
|
+
if (this.#port <= 0 || this.#server === null || this.#server.gated !== true) return null;
|
|
234
|
+
return {
|
|
235
|
+
proxyUrl: buildProxyUrl({ port: this.#port, token: this.#scopeToken }),
|
|
236
|
+
noProxy: composeNoProxy("", this.directDomains()),
|
|
237
|
+
port: this.#port
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Proxy groups for the panel (subscription groups plus a synthesized AUTO). */
|
|
242
|
+
groups() {
|
|
243
|
+
const config = this.#resolveConfig();
|
|
244
|
+
const parsed = this.#subscription?.parsed;
|
|
245
|
+
if (parsed === null || parsed === undefined) return [];
|
|
246
|
+
const names = parsed.names;
|
|
247
|
+
const groups = sanitizeGroups(parsed.groups, names).filter((group) => GROUP_TYPES.has(group.type));
|
|
248
|
+
if (!groups.some((group) => group.name === "AUTO") && names.length > 0) {
|
|
249
|
+
groups.unshift({ name: "AUTO", type: config.groupType, proxies: names });
|
|
250
|
+
}
|
|
251
|
+
return groups.map((group) => ({
|
|
252
|
+
name: group.name,
|
|
253
|
+
type: group.type,
|
|
254
|
+
now: group.name === "AUTO" ? this.#currentNodeName() : group.now ?? group.proxies?.[0] ?? null,
|
|
255
|
+
all: group.proxies
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Every parsed node with its latency and whether the transports support it. */
|
|
260
|
+
nodeList() {
|
|
261
|
+
return [...this.#nodes.entries()].map(([name, node]) => ({
|
|
262
|
+
name,
|
|
263
|
+
type: typeof node === "string" ? "uri" : node.type,
|
|
264
|
+
server: typeof node === "string" ? null : node.server ?? null,
|
|
265
|
+
port: typeof node === "string" ? null : node.port ?? null,
|
|
266
|
+
delay: this.#latencies[name] ?? null,
|
|
267
|
+
alive: this.#latencies[name] === undefined ? null : this.#latencies[name] !== null,
|
|
268
|
+
needsNative: needsNativeConnector(node)
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Persist a manual node selection and refresh the core's resolver. */
|
|
273
|
+
async selectNode(name) {
|
|
274
|
+
if (!this.#nodes.has(name)) throw new Error(`未知节点:${name}`);
|
|
275
|
+
this.#selected = name;
|
|
276
|
+
await this.#saveState();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One latency test, for the panel. */
|
|
280
|
+
async testLatency(name) {
|
|
281
|
+
const node = this.#nodes.get(name);
|
|
282
|
+
if (node === undefined) throw new Error(`未知节点:${name}`);
|
|
283
|
+
const delay = await this.#testOne(node);
|
|
284
|
+
this.#latencies[name] = delay;
|
|
285
|
+
return delay;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Latency-test every node with bounded concurrency (in-memory only). */
|
|
289
|
+
async testAllLatencies(names, concurrency = 8) {
|
|
290
|
+
const list = (names ?? [...this.#nodes.keys()]).filter((name) => this.#nodes.has(name));
|
|
291
|
+
const results = {};
|
|
292
|
+
let cursor = 0;
|
|
293
|
+
const worker = async () => {
|
|
294
|
+
for (;;) {
|
|
295
|
+
const index = cursor;
|
|
296
|
+
cursor += 1;
|
|
297
|
+
if (index >= list.length) return;
|
|
298
|
+
const name = list[index];
|
|
299
|
+
results[name] = await this.#testOne(this.#nodes.get(name));
|
|
300
|
+
this.#latencies[name] = results[name];
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length || 1)) }, worker));
|
|
304
|
+
return results;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Drop cached latencies (fresh panel load). */
|
|
308
|
+
clearLatencies() {
|
|
309
|
+
this.#latencies = {};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Re-fetch the subscription and rebuild the core. */
|
|
313
|
+
updateSubscription(refresh = true) {
|
|
314
|
+
return this.#queue(async () => {
|
|
315
|
+
const config = this.#resolveConfig();
|
|
316
|
+
await this.#rebuild(config, refresh, true);
|
|
317
|
+
if (this.#nodes.size > 0) void this.testAllLatencies().catch(() => {});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Tear everything down: timers, listener, state. */
|
|
322
|
+
stop() {
|
|
323
|
+
return this.#queue(async () => {
|
|
324
|
+
this.#disposeTimers();
|
|
325
|
+
this.#server?.stop();
|
|
326
|
+
this.#server = null;
|
|
327
|
+
this.#engine = null;
|
|
328
|
+
this.#port = 0;
|
|
329
|
+
this.#nodes = new Map();
|
|
330
|
+
this.#unsupported = 0;
|
|
331
|
+
this.#nativeNodes = 0;
|
|
332
|
+
this.#latencies = {};
|
|
333
|
+
this.#mode = "none";
|
|
334
|
+
this.#reason = "已停止";
|
|
335
|
+
this.#state = "idle";
|
|
336
|
+
this.#signature = null;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- HTTP API for the management panel ----------------------------------
|
|
341
|
+
|
|
342
|
+
/** Route one request from the DSH webserver under `/dsh-downloader`. */
|
|
343
|
+
async handleHttp(req, res) {
|
|
344
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
345
|
+
const sub = url.pathname.slice("/dsh-downloader".length).replace(/^\/+/, "");
|
|
346
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
347
|
+
const send = (status, payload) => {
|
|
348
|
+
if (res.headersSent) return;
|
|
349
|
+
const body = JSON.stringify(payload);
|
|
350
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
351
|
+
res.end(body);
|
|
352
|
+
};
|
|
353
|
+
const readJson = () =>
|
|
354
|
+
new Promise((resolve, reject) => {
|
|
355
|
+
const chunks = [];
|
|
356
|
+
let size = 0;
|
|
357
|
+
req.on("data", (chunk) => {
|
|
358
|
+
size += chunk.length;
|
|
359
|
+
if (size > 1024 * 1024) {
|
|
360
|
+
reject(new Error("请求体过大"));
|
|
361
|
+
req.destroy();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
chunks.push(chunk);
|
|
365
|
+
});
|
|
366
|
+
req.on("end", () => {
|
|
367
|
+
try {
|
|
368
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
369
|
+
} catch {
|
|
370
|
+
resolve({});
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
req.on("error", reject);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
switch (sub) {
|
|
378
|
+
case "status": {
|
|
379
|
+
if (url.searchParams.get("refresh") === "1") await this.resolve({ refresh: true });
|
|
380
|
+
return send(200, this.status());
|
|
381
|
+
}
|
|
382
|
+
case "proxies": {
|
|
383
|
+
if (url.searchParams.get("refresh") === "1") await this.resolve({ refresh: true });
|
|
384
|
+
return send(200, { groups: this.groups(), nodes: this.nodeList() });
|
|
385
|
+
}
|
|
386
|
+
case "restart": {
|
|
387
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
388
|
+
await this.resolve({ refresh: true });
|
|
389
|
+
return send(200, this.status());
|
|
390
|
+
}
|
|
391
|
+
case "update-subscription": {
|
|
392
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
393
|
+
await this.updateSubscription(true);
|
|
394
|
+
return send(200, this.status());
|
|
395
|
+
}
|
|
396
|
+
case "delay": {
|
|
397
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
398
|
+
const body = await readJson();
|
|
399
|
+
if (typeof body.name !== "string") return send(400, { error: "缺少 name 参数" });
|
|
400
|
+
return send(200, { name: body.name, delay: await this.testLatency(body.name) });
|
|
401
|
+
}
|
|
402
|
+
case "delay-all": {
|
|
403
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
404
|
+
await this.resolve();
|
|
405
|
+
const body = await readJson();
|
|
406
|
+
const names = Array.isArray(body.names) && body.names.length > 0 ? body.names.filter((name) => typeof name === "string") : undefined;
|
|
407
|
+
return send(200, { results: await this.testAllLatencies(names) });
|
|
408
|
+
}
|
|
409
|
+
case "clear-latencies": {
|
|
410
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
411
|
+
this.clearLatencies();
|
|
412
|
+
return send(200, { ok: true });
|
|
413
|
+
}
|
|
414
|
+
case "select": {
|
|
415
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
416
|
+
const body = await readJson();
|
|
417
|
+
if (typeof body.name !== "string") return send(400, { error: "缺少 name 参数" });
|
|
418
|
+
await this.selectNode(body.name);
|
|
419
|
+
return send(200, { ok: true, selected: body.name });
|
|
420
|
+
}
|
|
421
|
+
case "traffic":
|
|
422
|
+
return send(200, this.#trafficSnapshot());
|
|
423
|
+
case "guard":
|
|
424
|
+
return send(200, this.guardState());
|
|
425
|
+
case "guard-apply": {
|
|
426
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
427
|
+
return send(200, { ...applyGuard({ envPath: envFilePath(process.env.DSH_HOME), domains: this.directDomains() }), state: this.guardState() });
|
|
428
|
+
}
|
|
429
|
+
case "guard-restore": {
|
|
430
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
431
|
+
return send(200, { ...restoreGuard({ envPath: envFilePath(process.env.DSH_HOME) }), state: this.guardState() });
|
|
432
|
+
}
|
|
433
|
+
// ---- FlClash 式配置管理 ------------------------------------
|
|
434
|
+
case "profiles": {
|
|
435
|
+
this.profiles.load();
|
|
436
|
+
return send(200, { currentId: this.profiles.current()?.id ?? null, profiles: this.profiles.list() });
|
|
437
|
+
}
|
|
438
|
+
case "profile-add-url": {
|
|
439
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
440
|
+
const body = await readJson();
|
|
441
|
+
if (typeof body.value !== "string" || body.value.trim().length === 0) return send(400, { error: "缺少订阅地址" });
|
|
442
|
+
const profile = await this.addProfileFromUrl(body.value, {
|
|
443
|
+
label: typeof body.label === "string" && body.label.trim().length > 0 ? body.label.trim() : undefined,
|
|
444
|
+
autoUpdateHours: Number(body.autoUpdateHours) > 0 ? Number(body.autoUpdateHours) : undefined
|
|
445
|
+
});
|
|
446
|
+
return send(200, { ok: true, profile, status: this.status() });
|
|
447
|
+
}
|
|
448
|
+
case "profile-add-file": {
|
|
449
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
450
|
+
const body = await readJson();
|
|
451
|
+
if (typeof body.content !== "string" || body.content.trim().length === 0) return send(400, { error: "缺少配置内容" });
|
|
452
|
+
const profile = await this.addProfileFromFile(typeof body.name === "string" ? body.name : "本地配置.yaml", body.content);
|
|
453
|
+
return send(200, { ok: true, profile, status: this.status() });
|
|
454
|
+
}
|
|
455
|
+
case "profile-update": {
|
|
456
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
457
|
+
const body = await readJson();
|
|
458
|
+
if (typeof body.id !== "string") return send(400, { error: "缺少配置 id" });
|
|
459
|
+
const profile = await this.updateProfile(body.id);
|
|
460
|
+
return send(200, { ok: true, profile, status: this.status() });
|
|
461
|
+
}
|
|
462
|
+
case "profile-remove": {
|
|
463
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
464
|
+
const body = await readJson();
|
|
465
|
+
if (typeof body.id !== "string") return send(400, { error: "缺少配置 id" });
|
|
466
|
+
const current = await this.removeProfile(body.id);
|
|
467
|
+
return send(200, { ok: true, current, status: this.status() });
|
|
468
|
+
}
|
|
469
|
+
case "profile-select": {
|
|
470
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
471
|
+
const body = await readJson();
|
|
472
|
+
if (typeof body.id !== "string") return send(400, { error: "缺少配置 id" });
|
|
473
|
+
const profile = await this.selectProfile(body.id);
|
|
474
|
+
return send(200, { ok: true, profile, status: this.status() });
|
|
475
|
+
}
|
|
476
|
+
case "profile-reorder": {
|
|
477
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
478
|
+
const body = await readJson();
|
|
479
|
+
const profiles = await this.reorderProfiles(Array.isArray(body.ids) ? body.ids : []);
|
|
480
|
+
return send(200, { ok: true, profiles });
|
|
481
|
+
}
|
|
482
|
+
case "profile-auto": {
|
|
483
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
484
|
+
const body = await readJson();
|
|
485
|
+
if (typeof body.id !== "string") return send(400, { error: "缺少配置 id" });
|
|
486
|
+
const profile = await this.setProfileAutoUpdate(body.id, body.enabled !== false, Number(body.hours) > 0 ? Number(body.hours) : undefined);
|
|
487
|
+
return send(200, { ok: true, profile });
|
|
488
|
+
}
|
|
489
|
+
case "profile-rename": {
|
|
490
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
491
|
+
const body = await readJson();
|
|
492
|
+
if (typeof body.id !== "string") return send(400, { error: "缺少配置 id" });
|
|
493
|
+
return send(200, { ok: true, profile: await this.renameProfile(body.id, String(body.label ?? "")) });
|
|
494
|
+
}
|
|
495
|
+
default:
|
|
496
|
+
return send(404, { error: `未知接口:${sub}` });
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
send(500, { error: error instanceof Error ? error.message : String(error) });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---- internals ----------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
#queue(operation) {
|
|
506
|
+
const task = this.#task.then(operation, operation);
|
|
507
|
+
this.#task = task.catch(() => {});
|
|
508
|
+
return task;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#view() {
|
|
512
|
+
const config = this.#resolveConfig();
|
|
513
|
+
const disabled = config.enabled === false;
|
|
514
|
+
return {
|
|
515
|
+
mode: disabled ? "none" : this.#mode,
|
|
516
|
+
reason: disabled ? "插件已在设置里关闭" : this.#reason,
|
|
517
|
+
engine: disabled ? null : this.#engine,
|
|
518
|
+
available: !disabled && this.#port > 0 && this.#nodes.size > 0,
|
|
519
|
+
// The token rides on the descriptor so the download client can present
|
|
520
|
+
// it; it is never persisted, logged, or returned by the status API.
|
|
521
|
+
proxy: this.#port > 0
|
|
522
|
+
? { socks: false, host: "127.0.0.1", port: this.#port, token: this.#scopeToken, raw: `http://127.0.0.1:${this.#port}` }
|
|
523
|
+
: null,
|
|
524
|
+
corePort: this.#port,
|
|
525
|
+
nodeName: this.#currentNodeName(),
|
|
526
|
+
nodeType: (() => {
|
|
527
|
+
const name = this.#currentNodeName();
|
|
528
|
+
if (name === null) return null;
|
|
529
|
+
const node = this.#nodes.get(name);
|
|
530
|
+
return node === undefined ? null : typeof node === "string" ? "uri" : node.type ?? null;
|
|
531
|
+
})(),
|
|
532
|
+
subscriptionRules: this.#subscription?.parsed?.rules?.length ?? 0
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The proxy named by the ambient environment, if any (never loopback). */
|
|
537
|
+
#environmentProxy() {
|
|
538
|
+
for (const name of ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) {
|
|
539
|
+
const value = process.env[name];
|
|
540
|
+
if (typeof value !== "string" || value.trim().length === 0) continue;
|
|
541
|
+
if (isLoopbackUrl(value)) continue;
|
|
542
|
+
const parsed = parseProxyUrl(value);
|
|
543
|
+
if (parsed !== null) return parsed;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Proxy used to fetch the subscription itself. */
|
|
549
|
+
#resolveFetchProxy(config) {
|
|
550
|
+
const candidates = [config.fetchProxyUrl, config.proxyUrl, this.#environmentProxy()?.raw];
|
|
551
|
+
for (const candidate of candidates) {
|
|
552
|
+
if (typeof candidate !== "string" || candidate.trim().length === 0) continue;
|
|
553
|
+
const parsed = parseProxyUrl(candidate);
|
|
554
|
+
if (parsed === null || parsed.socks === true) continue;
|
|
555
|
+
return parsed;
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#buildEngine(config, subscriptionRules) {
|
|
561
|
+
// Domestic AI platforms are pinned to DIRECT ahead of everything else, so
|
|
562
|
+
// a flapping node can never take a model endpoint down — not even in
|
|
563
|
+
// "everything through the proxy" mode.
|
|
564
|
+
const protection = directRuleLines(
|
|
565
|
+
composeDirectDomains({
|
|
566
|
+
protectAiPlatforms: config.protectAiPlatforms !== false,
|
|
567
|
+
extraDirectDomains: config.extraDirectDomains ?? []
|
|
568
|
+
})
|
|
569
|
+
);
|
|
570
|
+
if (config.domesticDirect === false) {
|
|
571
|
+
if (protection.length === 0) return { decide: () => "proxy" };
|
|
572
|
+
return new RuleEngine({ extraRules: [...protection, "MATCH,PROXY"], subscriptionRules: [] });
|
|
573
|
+
}
|
|
574
|
+
return new RuleEngine({
|
|
575
|
+
extraRules: [...protection, ...(config.extraRules ?? [])],
|
|
576
|
+
excludeRules: config.excludeRules ?? [],
|
|
577
|
+
subscriptionRules: subscriptionRules ?? []
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Rebuild mode + core for the current config. */
|
|
582
|
+
async #rebuild(config, refresh, forceSubscriptionReload = false) {
|
|
583
|
+
this.#disposeTimers();
|
|
584
|
+
this.#server?.stop();
|
|
585
|
+
this.#server = null;
|
|
586
|
+
this.#engine = null;
|
|
587
|
+
this.#port = 0;
|
|
588
|
+
this.#nodes = new Map();
|
|
589
|
+
this.#unsupported = 0;
|
|
590
|
+
this.#nativeNodes = 0;
|
|
591
|
+
this.#error = null;
|
|
592
|
+
|
|
593
|
+
if (config.enabled === false) {
|
|
594
|
+
this.#mode = "none";
|
|
595
|
+
this.#reason = "插件已在设置里关闭";
|
|
596
|
+
this.#state = "disabled";
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const explicitRaw = typeof config.proxyUrl === "string" ? config.proxyUrl.trim() : "";
|
|
601
|
+
const envProxy = this.#environmentProxy();
|
|
602
|
+
const subscriptionUrl = typeof config.subscriptionUrl === "string" ? config.subscriptionUrl.trim() : "";
|
|
603
|
+
|
|
604
|
+
// 订阅优先于 proxyUrl:订阅给的是**可选节点的列表**,proxyUrl 只是一条
|
|
605
|
+
// 固定上游。两者都填时把订阅挤掉,用户看到的就是"填了订阅却没反应、
|
|
606
|
+
// 也没有节点可选"(实测踩过)。proxyUrl 退居两用:抓订阅时用的代理,
|
|
607
|
+
// 以及订阅拿不到节点时的兜底上游。
|
|
608
|
+
this.profiles.load();
|
|
609
|
+
let subscriptionNote = null;
|
|
610
|
+
if (this.profiles.list().length > 0 || subscriptionUrl.length > 0) {
|
|
611
|
+
this.#mode = "subscription";
|
|
612
|
+
await this.#loadSubscription(config, forceSubscriptionReload || refresh);
|
|
613
|
+
if (this.#subscription === null) {
|
|
614
|
+
subscriptionNote = `订阅不可用:${this.#error ?? "内容为空或无法解析"}`;
|
|
615
|
+
} else {
|
|
616
|
+
const parsed = this.#subscription.parsed;
|
|
617
|
+
const supported = [];
|
|
618
|
+
for (const node of parsed.proxies) {
|
|
619
|
+
const name = typeof node === "string" ? node : node?.name;
|
|
620
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
621
|
+
if (!isSupportedNode(node)) {
|
|
622
|
+
this.#unsupported += 1;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (needsNativeConnector(node)) {
|
|
626
|
+
this.#nativeNodes += 1;
|
|
627
|
+
if (!hasNativeConnector()) {
|
|
628
|
+
this.#unsupported += 1;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
supported.push([name, node]);
|
|
633
|
+
}
|
|
634
|
+
this.#nodes = new Map(supported);
|
|
635
|
+
this.#engine = this.#buildEngine(config, parsed.rules);
|
|
636
|
+
|
|
637
|
+
if (this.#nodes.size > 0) {
|
|
638
|
+
await this.#loadState();
|
|
639
|
+
await this.#startCore();
|
|
640
|
+
if (this.#state === "running") {
|
|
641
|
+
const parts = [`${this.#nodes.size} 个节点就绪`];
|
|
642
|
+
if (this.#unsupported > 0) parts.push(`${this.#unsupported} 个因不受支持被跳过`);
|
|
643
|
+
if ((parsed.skipped?.length ?? 0) > 0) parts.push(`${parsed.skipped.length} 条链接未能解析`);
|
|
644
|
+
this.#reason = `${parts.join(";")};订阅规则 ${parsed.rules.length} 条`;
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
subscriptionNote = `订阅已解析,但没有可用节点(${this.#unsupported} 个不受支持)`;
|
|
649
|
+
}
|
|
650
|
+
if (explicitRaw.length === 0 && envProxy === null) {
|
|
651
|
+
this.#reason = subscriptionNote;
|
|
652
|
+
this.#state = "no-subscription";
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// 订阅不可用:继续往下走,用 proxyUrl / 环境代理兜底。
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (explicitRaw.length > 0 || envProxy !== null) {
|
|
659
|
+
const fromConfig = explicitRaw.length > 0;
|
|
660
|
+
const parsed = fromConfig ? parseProxyUrl(explicitRaw) : envProxy;
|
|
661
|
+
if (parsed === null) {
|
|
662
|
+
this.#mode = "explicit";
|
|
663
|
+
this.#reason = `proxyUrl 不是可用的 http(s) / socks5 代理地址:${explicitRaw}`;
|
|
664
|
+
this.#state = "failed";
|
|
665
|
+
this.#error = this.#reason;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
this.#mode = fromConfig ? "explicit" : "environment";
|
|
669
|
+
this.#reason =
|
|
670
|
+
(fromConfig
|
|
671
|
+
? `使用配置的 proxyUrl(${parsed.host}:${parsed.port})`
|
|
672
|
+
: `使用 DSH 启动环境发布的代理(${parsed.host}:${parsed.port})`) +
|
|
673
|
+
(subscriptionNote === null ? "" : `;${subscriptionNote}`);
|
|
674
|
+
const credentials = parsed.auth === null || parsed.auth === undefined
|
|
675
|
+
? {}
|
|
676
|
+
: { username: parsed.auth.username, password: parsed.auth.password };
|
|
677
|
+
const node = parsed.socks === true
|
|
678
|
+
? { name: "configured-proxy", type: "socks5", server: parsed.host, port: parsed.port, ...credentials }
|
|
679
|
+
: { name: "configured-proxy", type: "http", server: parsed.host, port: parsed.port, ...credentials };
|
|
680
|
+
this.#nodes = new Map([[node.name, node]]);
|
|
681
|
+
this.#engine = this.#buildEngine(config, []);
|
|
682
|
+
await this.#startCore();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
this.#mode = "none";
|
|
687
|
+
this.#reason = subscriptionNote ?? "既没有配置订阅地址,也没有 proxyUrl";
|
|
688
|
+
this.#state = "no-subscription";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Start the loopback rule core and its timers. */
|
|
692
|
+
async #startCore() {
|
|
693
|
+
try {
|
|
694
|
+
this.#server = new ProxyServer({ engine: this.#engine, resolveNode: () => this.#currentNode(), token: this.#scopeToken });
|
|
695
|
+
this.#port = await this.#server.start(0);
|
|
696
|
+
this.#state = "running";
|
|
697
|
+
this.#scheduleTimers(this.#resolveConfig());
|
|
698
|
+
this.#ctx.logger?.info?.("dsh-downloader: rule core listening on 127.0.0.1:%d (%s)", this.#port, this.#mode);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
this.#state = "failed";
|
|
701
|
+
this.#error = error instanceof Error ? error.message : String(error);
|
|
702
|
+
this.#reason = this.#error;
|
|
703
|
+
this.#server?.stop();
|
|
704
|
+
this.#server = null;
|
|
705
|
+
this.#port = 0;
|
|
706
|
+
this.#ctx.logger?.warn?.("dsh-downloader: rule core failed to start: %s", this.#error);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
#scheduleTimers(config) {
|
|
711
|
+
// The scheduler comes from the optional `timer` service. Without it the
|
|
712
|
+
// core still works, it just never auto-updates the subscription or
|
|
713
|
+
// re-tests latencies.
|
|
714
|
+
const schedule = this.#scheduler;
|
|
715
|
+
if (schedule === null) return;
|
|
716
|
+
if (this.#mode === "subscription") {
|
|
717
|
+
if (this.profiles.list().length > 0) {
|
|
718
|
+
// FlClash semantics: every profile carries its own interval, so a
|
|
719
|
+
// periodic sweep refreshes whichever ones are actually due rather
|
|
720
|
+
// than forcing one global cadence on all of them.
|
|
721
|
+
this.#autoUpdateDisposer = schedule(() => {
|
|
722
|
+
void this.autoUpdateProfiles().catch((error) => {
|
|
723
|
+
this.#ctx.logger?.warn?.("dsh-downloader: profile auto-update sweep failed: %s", String(error));
|
|
724
|
+
});
|
|
725
|
+
}, 5 * 60 * 1000);
|
|
726
|
+
} else {
|
|
727
|
+
const hours = Number(config.autoUpdateHours ?? 0);
|
|
728
|
+
if (Number.isFinite(hours) && hours > 0) {
|
|
729
|
+
this.#autoUpdateDisposer = schedule(() => {
|
|
730
|
+
void this.updateSubscription(true).catch((error) => {
|
|
731
|
+
this.#ctx.logger?.warn?.("dsh-downloader: subscription auto-update failed: %s", String(error));
|
|
732
|
+
});
|
|
733
|
+
}, hours * 3600 * 1000);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (config.groupType === "url-test" && this.#nodes.size > 0) {
|
|
738
|
+
this.#latencyDisposer = schedule(() => {
|
|
739
|
+
void this.testAllLatencies().catch(() => {});
|
|
740
|
+
}, 300 * 1000);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Attach the host's interval scheduler (from the optional `timer` service).
|
|
746
|
+
* Safe to call before or after `resolve()`; timers are (re)armed either way.
|
|
747
|
+
* @param schedule - `(callback, delayMs) => disposer`.
|
|
748
|
+
*/
|
|
749
|
+
attachScheduler(schedule) {
|
|
750
|
+
this.#scheduler = typeof schedule === "function" ? schedule : null;
|
|
751
|
+
if (this.#state === "running") {
|
|
752
|
+
this.#disposeTimers();
|
|
753
|
+
this.#scheduleTimers(this.#resolveConfig());
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#disposeTimers() {
|
|
758
|
+
this.#autoUpdateDisposer?.();
|
|
759
|
+
this.#autoUpdateDisposer = null;
|
|
760
|
+
this.#latencyDisposer?.();
|
|
761
|
+
this.#latencyDisposer = null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Load the active configuration body.
|
|
766
|
+
*
|
|
767
|
+
* FlClash-style profiles win when the store has any; the legacy single
|
|
768
|
+
* `subscriptionUrl` config field stays supported as a fallback so an
|
|
769
|
+
* existing install keeps working without touching anything.
|
|
770
|
+
*/
|
|
771
|
+
async #loadSubscription(config, force) {
|
|
772
|
+
this.profiles.load();
|
|
773
|
+
if (this.profiles.list().length > 0) {
|
|
774
|
+
await this.#loadFromProfiles(config, force);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
await this.#loadFromLegacyUrl(config, force);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The FlClash-style path: the selected profile's cached body. */
|
|
781
|
+
async #loadFromProfiles(config, force) {
|
|
782
|
+
const store = this.profiles;
|
|
783
|
+
let profile = store.current() ?? store.list()[0] ?? null;
|
|
784
|
+
if (profile === null) {
|
|
785
|
+
this.#subscription = null;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Only touch the network when forced, or when there is no cached body yet.
|
|
790
|
+
const needsFetch = force || store.body(profile.id) === null;
|
|
791
|
+
if (needsFetch && profile.source === "url") {
|
|
792
|
+
try {
|
|
793
|
+
await store.update(profile.id, { fetcher: this.#fetcher(), fetchProxyUrl: config.fetchProxyUrl ?? "" });
|
|
794
|
+
profile = store.get(profile.id) ?? profile;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
797
|
+
this.#error = `配置「${profile.label}」更新失败:${reason}${store.body(profile.id) === null ? "" : ",改用本地缓存"}`;
|
|
798
|
+
this.#ctx.logger?.warn?.("dsh-downloader: %s", this.#error);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const body = store.body(profile.id);
|
|
803
|
+
if (body === null || body.trim().length === 0) {
|
|
804
|
+
this.#subscription = null;
|
|
805
|
+
if (this.#error === null) this.#error = `配置「${profile.label}」没有可用的内容`;
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
this.#subscription = {
|
|
810
|
+
raw: body,
|
|
811
|
+
updatedAt: profile.lastUpdateAt ?? null,
|
|
812
|
+
parsed: parseSubscription(body),
|
|
813
|
+
stale: needsFetch && profile.source === "url" && this.#error !== null,
|
|
814
|
+
profileId: profile.id,
|
|
815
|
+
profileLabel: profile.label
|
|
816
|
+
};
|
|
817
|
+
} catch (error) {
|
|
818
|
+
this.#subscription = null;
|
|
819
|
+
this.#error = `配置「${profile.label}」解析失败:${error instanceof Error ? error.message : String(error)}`;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Legacy single-URL path (kept for installs configured before profiles existed). */
|
|
824
|
+
async #loadFromLegacyUrl(config, force) {
|
|
825
|
+
const cachePath = join(this.dataDir, "subscription.yaml");
|
|
826
|
+
const metaPath = join(this.dataDir, "subscription.meta.json");
|
|
827
|
+
const url = typeof config.subscriptionUrl === "string" ? config.subscriptionUrl.trim() : "";
|
|
828
|
+
|
|
829
|
+
if (!force && this.#subscription !== null) return;
|
|
830
|
+
|
|
831
|
+
let fetchError = null;
|
|
832
|
+
if (url.length > 0) {
|
|
833
|
+
try {
|
|
834
|
+
const response = await fetchSubscription(url, config.fetchProxyUrl ?? "", 30000);
|
|
835
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
836
|
+
await writeFile(cachePath, response.text, "utf8");
|
|
837
|
+
await writeFile(metaPath, JSON.stringify({ at: Date.now(), url }), "utf8");
|
|
838
|
+
this.#subscription = { raw: response.text, updatedAt: Date.now(), parsed: parseSubscription(response.text), stale: false };
|
|
839
|
+
this.#ctx.logger?.info?.("dsh-downloader: subscription updated (%d nodes)", this.#subscription.parsed.names.length);
|
|
840
|
+
return;
|
|
841
|
+
} catch (error) {
|
|
842
|
+
fetchError = error;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const cached = await readFile(cachePath, "utf8").catch(() => null);
|
|
847
|
+
if (cached === null || cached.trim().length === 0) {
|
|
848
|
+
this.#subscription = null;
|
|
849
|
+
if (fetchError !== null) {
|
|
850
|
+
this.#error = fetchError instanceof Error ? fetchError.message : String(fetchError);
|
|
851
|
+
this.#ctx.logger?.warn?.("dsh-downloader: subscription fetch failed and no cache exists: %s", this.#error);
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const meta = await readFile(metaPath, "utf8")
|
|
856
|
+
.then((text) => JSON.parse(text))
|
|
857
|
+
.catch(() => null);
|
|
858
|
+
try {
|
|
859
|
+
this.#subscription = { raw: cached, updatedAt: meta?.at ?? null, parsed: parseSubscription(cached), stale: fetchError !== null };
|
|
860
|
+
} catch (error) {
|
|
861
|
+
this.#subscription = null;
|
|
862
|
+
this.#error = error instanceof Error ? error.message : String(error);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (fetchError !== null) {
|
|
866
|
+
this.#error = `订阅抓取失败(${fetchError instanceof Error ? fetchError.message : String(fetchError)}),改用本地缓存`;
|
|
867
|
+
this.#ctx.logger?.warn?.("dsh-downloader: %s", this.#error);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---- FlClash-style profile management -----------------------------------
|
|
872
|
+
|
|
873
|
+
#fetcher() {
|
|
874
|
+
return (url, fetchProxyUrl) => fetchSubscription(url, fetchProxyUrl, 30000);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Every stored configuration, for the panel and the agent tool. */
|
|
878
|
+
listProfiles() {
|
|
879
|
+
return this.profiles.list();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Import a subscription URL or a `clash://` link, then rebuild. */
|
|
883
|
+
async addProfileFromUrl(value, options = {}) {
|
|
884
|
+
const url = parseImportLink(value);
|
|
885
|
+
if (url === null) throw new Error(`无法从这段内容里认出订阅地址:${String(value).slice(0, 120)}`);
|
|
886
|
+
const config = this.#resolveConfig();
|
|
887
|
+
const profile = await this.profiles.addFromUrl(url, {
|
|
888
|
+
label: options.label,
|
|
889
|
+
autoUpdateHours: options.autoUpdateHours,
|
|
890
|
+
fetcher: this.#fetcher(),
|
|
891
|
+
fetchProxyUrl: config.fetchProxyUrl ?? ""
|
|
892
|
+
});
|
|
893
|
+
await this.resolve({ refresh: true });
|
|
894
|
+
return profile;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Import a configuration the user already has on disk / pasted in. */
|
|
898
|
+
async addProfileFromFile(name, content) {
|
|
899
|
+
const profile = this.profiles.addFromFile(name, content);
|
|
900
|
+
await this.resolve({ refresh: true });
|
|
901
|
+
return profile;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async updateProfile(id) {
|
|
905
|
+
const config = this.#resolveConfig();
|
|
906
|
+
const profile = await this.profiles.update(id, { fetcher: this.#fetcher(), fetchProxyUrl: config.fetchProxyUrl ?? "" });
|
|
907
|
+
await this.resolve({ refresh: true });
|
|
908
|
+
return profile;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async removeProfile(id) {
|
|
912
|
+
const current = this.profiles.remove(id);
|
|
913
|
+
await this.resolve({ refresh: true });
|
|
914
|
+
return current;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async selectProfile(id) {
|
|
918
|
+
const profile = this.profiles.select(id);
|
|
919
|
+
await this.resolve({ refresh: true });
|
|
920
|
+
return profile;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async reorderProfiles(ids) {
|
|
924
|
+
return this.profiles.reorder(ids);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async setProfileAutoUpdate(id, enabled, hours) {
|
|
928
|
+
const profile = this.profiles.setAutoUpdate(id, enabled, hours);
|
|
929
|
+
return profile;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async renameProfile(id, label) {
|
|
933
|
+
return this.profiles.rename(id, label);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Refresh every profile whose interval has elapsed. FlClash compares each
|
|
938
|
+
* profile's own `lastUpdateDate + autoUpdateDuration`, which is what
|
|
939
|
+
* `ProfileStore.dueProfiles()` reproduces.
|
|
940
|
+
*/
|
|
941
|
+
async autoUpdateProfiles() {
|
|
942
|
+
const due = this.profiles.dueProfiles();
|
|
943
|
+
if (due.length === 0) return 0;
|
|
944
|
+
const config = this.#resolveConfig();
|
|
945
|
+
let updated = 0;
|
|
946
|
+
for (const profile of due) {
|
|
947
|
+
try {
|
|
948
|
+
await this.profiles.update(profile.id, { fetcher: this.#fetcher(), fetchProxyUrl: config.fetchProxyUrl ?? "" });
|
|
949
|
+
updated += 1;
|
|
950
|
+
} catch (error) {
|
|
951
|
+
this.#ctx.logger?.warn?.("dsh-downloader: auto-update of 「%s」 failed: %s", profile.label, String(error));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (updated > 0) await this.resolve({ refresh: true });
|
|
955
|
+
return updated;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
async #saveState() {
|
|
960
|
+
try {
|
|
961
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
962
|
+
await writeFile(join(this.dataDir, "state.json"), JSON.stringify({ selected: this.#selected }, null, 2), "utf8");
|
|
963
|
+
} catch {
|
|
964
|
+
// Non-fatal: selection simply will not survive the next restart.
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async #loadState() {
|
|
969
|
+
try {
|
|
970
|
+
const state = JSON.parse(await readFile(join(this.dataDir, "state.json"), "utf8"));
|
|
971
|
+
if (typeof state.selected === "string" && this.#nodes.has(state.selected)) this.#selected = state.selected;
|
|
972
|
+
} catch {
|
|
973
|
+
// First run.
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The node that currently answers proxied traffic. */
|
|
978
|
+
#currentNode() {
|
|
979
|
+
const name = this.#currentNodeName();
|
|
980
|
+
return name === null ? null : this.#nodes.get(name) ?? null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#currentNodeName() {
|
|
984
|
+
const config = this.#resolveConfig();
|
|
985
|
+
const preferred = typeof config.preferredNode === "string" ? config.preferredNode.trim() : "";
|
|
986
|
+
if (preferred.length > 0 && this.#nodes.has(preferred)) return preferred;
|
|
987
|
+
if (this.#selected !== null && this.#nodes.has(this.#selected)) return this.#selected;
|
|
988
|
+
const names = [...this.#nodes.keys()];
|
|
989
|
+
if (names.length === 0) return null;
|
|
990
|
+
if (config.groupType === "url-test") {
|
|
991
|
+
let best = null;
|
|
992
|
+
let bestDelay = Infinity;
|
|
993
|
+
for (const name of names) {
|
|
994
|
+
const delay = this.#latencies[name];
|
|
995
|
+
if (typeof delay === "number" && delay < bestDelay) {
|
|
996
|
+
best = name;
|
|
997
|
+
bestDelay = delay;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (best !== null) return best;
|
|
1001
|
+
}
|
|
1002
|
+
return names[0];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#latencyTarget() {
|
|
1006
|
+
const config = this.#resolveConfig();
|
|
1007
|
+
try {
|
|
1008
|
+
const url = new URL(config.latencyTestUrl ?? "http://www.gstatic.com/generate_204");
|
|
1009
|
+
return { host: url.hostname, port: Number(url.port) || (url.protocol === "http:" ? 80 : 443) };
|
|
1010
|
+
} catch {
|
|
1011
|
+
return { host: "www.gstatic.com", port: 80 };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async #testOne(node) {
|
|
1016
|
+
if (node === undefined) return null;
|
|
1017
|
+
const config = this.#resolveConfig();
|
|
1018
|
+
const { host, port } = this.#latencyTarget();
|
|
1019
|
+
return measureLatency(node, host, port, config.latencyTimeoutMs ?? 3000);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Byte counters plus the rate since the previous sample.
|
|
1024
|
+
*
|
|
1025
|
+
* The rate is a delta over a sampling window, so two callers polling at
|
|
1026
|
+
* different cadences (the panel at 5s, the sidebar badge at 1.5s) would
|
|
1027
|
+
* otherwise keep stealing each other's window and make the rate jump.
|
|
1028
|
+
* A short TTL makes every caller within it observe the same sample.
|
|
1029
|
+
*/
|
|
1030
|
+
#trafficSnapshot() {
|
|
1031
|
+
const now = Date.now();
|
|
1032
|
+
if (this.#trafficCache.value !== null && now - this.#trafficCache.at < 1200) return this.#trafficCache.value;
|
|
1033
|
+
|
|
1034
|
+
const up = this.#server?.counters.up ?? 0;
|
|
1035
|
+
const down = this.#server?.counters.down ?? 0;
|
|
1036
|
+
const seconds = Math.max(0.001, (now - this.#traffic.at) / 1000);
|
|
1037
|
+
const upRate = Math.max(0, up - this.#traffic.up) / seconds;
|
|
1038
|
+
const downRate = Math.max(0, down - this.#traffic.down) / seconds;
|
|
1039
|
+
this.#traffic = { up, down, at: now };
|
|
1040
|
+
const value = { up, down, upRate, downRate, total: humanSize(up + down), active: this.#server !== null };
|
|
1041
|
+
this.#trafficCache = { at: now, value };
|
|
1042
|
+
return value;
|
|
1043
|
+
}
|
|
1044
|
+
}
|