@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,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming file download with integrity, limits, and cancellation.
|
|
3
|
+
*
|
|
4
|
+
* Invariants:
|
|
5
|
+
* - the target file only ever appears complete (written to `<target>.part`,
|
|
6
|
+
* then renamed);
|
|
7
|
+
* - **every** failure path removes the `.part` file;
|
|
8
|
+
* - the byte count is capped while streaming, so a wrong URL cannot fill a disk.
|
|
9
|
+
* @module dsh-downloader/fetch-file
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { httpFlow, readBody } from "./http.js";
|
|
15
|
+
import { humanSize } from "./util.js";
|
|
16
|
+
import { RangeUnusableError, SEGMENT_MIN_TOTAL, downloadSegmented, supportsRanges } from "./segmented.js";
|
|
17
|
+
|
|
18
|
+
/** Raised for a non-2xx response, carrying the status for callers that retry. */
|
|
19
|
+
export class HttpStatusError extends Error {
|
|
20
|
+
constructor(message, status, url) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "HttpStatusError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.url = url;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stream a response body into `partPath`, hashing and capping as it goes. */
|
|
29
|
+
function streamToFile(flow, partPath, { maxBytes, stallMs, signal, onProgress }) {
|
|
30
|
+
const hash = createHash("sha256");
|
|
31
|
+
let written = 0;
|
|
32
|
+
let lastTick = Date.now();
|
|
33
|
+
const sink = fs.createWriteStream(partPath);
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let failure = null;
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
clearInterval(stallWatch);
|
|
39
|
+
signal?.removeEventListener("abort", onAbort);
|
|
40
|
+
};
|
|
41
|
+
const fail = (error) => {
|
|
42
|
+
if (failure === null) failure = error instanceof Error ? error : new Error(String(error));
|
|
43
|
+
try {
|
|
44
|
+
flow.res.destroy();
|
|
45
|
+
} catch {
|
|
46
|
+
/* already gone */
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
sink.destroy();
|
|
50
|
+
} catch {
|
|
51
|
+
/* already gone */
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const onAbort = () => fail(new Error("下载已取消"));
|
|
55
|
+
const stallWatch = setInterval(() => {
|
|
56
|
+
if (Date.now() - lastTick > stallMs) fail(new Error(`下载停滞:已 ${Math.round(stallMs / 1000)} 秒没有收到数据`));
|
|
57
|
+
}, 1000);
|
|
58
|
+
|
|
59
|
+
if (signal !== null && signal !== undefined) {
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
onAbort();
|
|
62
|
+
} else {
|
|
63
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
flow.res.on("data", (chunk) => {
|
|
68
|
+
if (failure !== null) return;
|
|
69
|
+
written += chunk.length;
|
|
70
|
+
lastTick = Date.now();
|
|
71
|
+
if (written > maxBytes) {
|
|
72
|
+
fail(new Error(`文件超过配置的大小上限(${humanSize(maxBytes)});确认文件大小后可提高 max_mb`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
hash.update(chunk);
|
|
76
|
+
onProgress?.(written);
|
|
77
|
+
if (!sink.write(chunk)) flow.res.pause();
|
|
78
|
+
});
|
|
79
|
+
sink.on("drain", () => flow.res.resume());
|
|
80
|
+
flow.res.once("end", () => sink.end());
|
|
81
|
+
flow.res.once("error", (error) => fail(error));
|
|
82
|
+
sink.once("error", (error) => fail(error));
|
|
83
|
+
sink.once("close", () => {
|
|
84
|
+
cleanup();
|
|
85
|
+
if (failure !== null) reject(failure);
|
|
86
|
+
else resolve({ bytes: written, sha256: hash.digest("hex") });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One plain GET streamed into `partPath`; used when the probe body is gone. */
|
|
92
|
+
async function attemptSingleStream({ url, partPath, proxy, insecure, maxRedirects, maxBytes, stallMs, signal, onEvent, headers = null }) {
|
|
93
|
+
const flow = await httpFlow(url, { proxy, insecure, maxRedirects, timeoutMs: 20000, signal, headers });
|
|
94
|
+
if (flow.status >= 400) {
|
|
95
|
+
let snippet = "";
|
|
96
|
+
try {
|
|
97
|
+
snippet = (await readBody(flow, 16 * 1024)).slice(0, 200).replace(/\s+/g, " ");
|
|
98
|
+
} catch {
|
|
99
|
+
/* body already consumed or torn down */
|
|
100
|
+
}
|
|
101
|
+
throw new HttpStatusError(
|
|
102
|
+
`HTTP ${flow.status}${flow.statusMessage ? ` ${flow.statusMessage}` : ""} @ ${flow.url}${snippet ? ` | ${snippet}` : ""}`,
|
|
103
|
+
flow.status,
|
|
104
|
+
flow.url
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const declared = Number(flow.headers["content-length"]);
|
|
108
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
109
|
+
flow.res.resume();
|
|
110
|
+
flow.abort();
|
|
111
|
+
throw new Error(`服务端声明大小 ${humanSize(declared)},超过配置上限(${humanSize(maxBytes)});提高 max_mb 后重试`);
|
|
112
|
+
}
|
|
113
|
+
const streamed = await streamToFile(flow, partPath, {
|
|
114
|
+
maxBytes,
|
|
115
|
+
stallMs,
|
|
116
|
+
signal,
|
|
117
|
+
onProgress: (bytes) => onEvent?.({ kind: "progress", bytes })
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
...streamed,
|
|
121
|
+
http: {
|
|
122
|
+
status: flow.status,
|
|
123
|
+
redirects: flow.redirects,
|
|
124
|
+
content_length: Number.isFinite(declared) ? declared : null,
|
|
125
|
+
content_type: flow.headers["content-type"] ?? null
|
|
126
|
+
},
|
|
127
|
+
finalUrl: flow.url
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Try the multi-connection path.
|
|
133
|
+
*
|
|
134
|
+
* Returns null (never throws) whenever the segmented attempt is not applicable
|
|
135
|
+
* or turned out to be unusable, so the caller can fall back to one stream. The
|
|
136
|
+
* probe response is discarded: a range request is issued per slice instead, so
|
|
137
|
+
* a server that lies about `Accept-Ranges` costs one wasted request, not a
|
|
138
|
+
* corrupt file.
|
|
139
|
+
*/
|
|
140
|
+
async function trySegmented(flow, options, context) {
|
|
141
|
+
const { partPath, maxBytes, threads, signal, onEvent, stallMs, onConsume } = context;
|
|
142
|
+
const declaredLength = Number(flow.headers["content-length"]);
|
|
143
|
+
if (threads <= 1) return null;
|
|
144
|
+
if (!Number.isFinite(declaredLength) || declaredLength < SEGMENT_MIN_TOTAL) return null;
|
|
145
|
+
if (!supportsRanges(flow.status, flow.headers)) return null;
|
|
146
|
+
if (declaredLength > maxBytes) return null; // the single-stream path raises the proper error
|
|
147
|
+
|
|
148
|
+
// Only headers were needed; drop the body before opening our own requests.
|
|
149
|
+
try {
|
|
150
|
+
flow.res.resume();
|
|
151
|
+
flow.abort();
|
|
152
|
+
} catch {
|
|
153
|
+
/* already gone */
|
|
154
|
+
}
|
|
155
|
+
onConsume();
|
|
156
|
+
|
|
157
|
+
const validator = flow.headers.etag ?? flow.headers["last-modified"] ?? null;
|
|
158
|
+
const handle = await fs.promises.open(partPath, "w+");
|
|
159
|
+
try {
|
|
160
|
+
onEvent?.({ kind: "segments", threads, bytes: declaredLength });
|
|
161
|
+
return await downloadSegmented({
|
|
162
|
+
url: flow.url,
|
|
163
|
+
handle,
|
|
164
|
+
totalBytes: declaredLength,
|
|
165
|
+
threads,
|
|
166
|
+
proxy: options.proxy,
|
|
167
|
+
insecure: options.insecure,
|
|
168
|
+
maxRedirects: options.maxRedirects,
|
|
169
|
+
timeoutMs: options.timeoutMs,
|
|
170
|
+
stallMs,
|
|
171
|
+
maxBytes,
|
|
172
|
+
signal,
|
|
173
|
+
validator,
|
|
174
|
+
headers: options.headers,
|
|
175
|
+
onProgress: (bytes) => onEvent?.({ kind: "progress", bytes })
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
await handle.close().catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Perform exactly one download attempt through an explicit proxy (or directly).
|
|
184
|
+
*
|
|
185
|
+
* @param options - target, destination, proxy, limits, and cancellation.
|
|
186
|
+
* @returns the attempt result; the caller owns route reporting.
|
|
187
|
+
* @throws {HttpStatusError} on a non-2xx response.
|
|
188
|
+
*/
|
|
189
|
+
export async function attemptDownload(options) {
|
|
190
|
+
const {
|
|
191
|
+
url,
|
|
192
|
+
savePath,
|
|
193
|
+
overwrite = false,
|
|
194
|
+
proxy = null,
|
|
195
|
+
maxBytes = 512 * 1024 * 1024,
|
|
196
|
+
timeoutS = 600,
|
|
197
|
+
stallS = 30,
|
|
198
|
+
threads = 1,
|
|
199
|
+
insecure = false,
|
|
200
|
+
maxRedirects = 10,
|
|
201
|
+
signal = null,
|
|
202
|
+
headers = null,
|
|
203
|
+
onEvent = null
|
|
204
|
+
} = options;
|
|
205
|
+
|
|
206
|
+
if (fs.existsSync(savePath) && !overwrite) {
|
|
207
|
+
const stats = fs.statSync(savePath);
|
|
208
|
+
if (stats.isDirectory()) throw new Error(`目标是目录,不是文件:${savePath}`);
|
|
209
|
+
throw new Error(`目标文件已存在:${savePath}(${humanSize(stats.size)});加 overwrite=true 覆盖,或换一个 save_path`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
213
|
+
const partPath = `${savePath}.part`;
|
|
214
|
+
const started = Date.now();
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const forwardAbort = () => controller.abort(new Error("下载已取消"));
|
|
217
|
+
if (signal !== null && signal !== undefined) {
|
|
218
|
+
if (signal.aborted) throw new Error("下载已取消");
|
|
219
|
+
signal.addEventListener("abort", forwardAbort, { once: true });
|
|
220
|
+
}
|
|
221
|
+
const deadline = setTimeout(
|
|
222
|
+
() => controller.abort(new Error(`下载超过 ${timeoutS} 秒预算;大文件请提高 timeout_seconds`)),
|
|
223
|
+
Math.max(1, timeoutS) * 1000
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
let flow = null;
|
|
227
|
+
try {
|
|
228
|
+
flow = await httpFlow(url, {
|
|
229
|
+
proxy,
|
|
230
|
+
headers,
|
|
231
|
+
timeoutMs: 20000,
|
|
232
|
+
signal: controller.signal,
|
|
233
|
+
insecure,
|
|
234
|
+
maxRedirects,
|
|
235
|
+
onHop: (hop) => onEvent?.({ kind: "redirect", ...hop })
|
|
236
|
+
});
|
|
237
|
+
if (flow.status >= 400) {
|
|
238
|
+
let snippet = "";
|
|
239
|
+
try {
|
|
240
|
+
snippet = (await readBody(flow, 16 * 1024)).slice(0, 200).replace(/\s+/g, " ");
|
|
241
|
+
} catch {
|
|
242
|
+
/* body already consumed or torn down */
|
|
243
|
+
}
|
|
244
|
+
throw new HttpStatusError(
|
|
245
|
+
`HTTP ${flow.status}${flow.statusMessage ? ` ${flow.statusMessage}` : ""} @ ${flow.url}${snippet ? ` | ${snippet}` : ""}`,
|
|
246
|
+
flow.status,
|
|
247
|
+
flow.url
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const declaredLength = Number(flow.headers["content-length"]);
|
|
252
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
253
|
+
flow.res.resume();
|
|
254
|
+
flow.abort();
|
|
255
|
+
throw new Error(
|
|
256
|
+
`服务端声明大小 ${humanSize(declaredLength)},超过配置上限(${humanSize(maxBytes)});提高 max_mb 后重试`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let probeConsumed = false;
|
|
261
|
+
const streamed = await (async () => {
|
|
262
|
+
try {
|
|
263
|
+
const segmented = await trySegmented(flow, { url, proxy, insecure, maxRedirects, timeoutMs: 20000, headers }, {
|
|
264
|
+
partPath,
|
|
265
|
+
maxBytes,
|
|
266
|
+
threads,
|
|
267
|
+
signal: controller.signal,
|
|
268
|
+
onEvent,
|
|
269
|
+
stallMs: Math.max(1, stallS) * 1000,
|
|
270
|
+
onConsume: () => { probeConsumed = true; }
|
|
271
|
+
});
|
|
272
|
+
if (segmented !== null) return { ...segmented, viaThreads: true };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// A range problem must never lose the download: redo it on one stream.
|
|
275
|
+
if (!(error instanceof RangeUnusableError)) throw error;
|
|
276
|
+
onEvent?.({ kind: "fallback", reason: `分片下载不可用(${error.message}),改用单连接` });
|
|
277
|
+
}
|
|
278
|
+
if (!probeConsumed) {
|
|
279
|
+
return {
|
|
280
|
+
...(await streamToFile(flow, partPath, {
|
|
281
|
+
maxBytes,
|
|
282
|
+
stallMs: Math.max(1, stallS) * 1000,
|
|
283
|
+
signal: controller.signal,
|
|
284
|
+
onProgress: (bytes) => onEvent?.({ kind: "progress", bytes })
|
|
285
|
+
})),
|
|
286
|
+
viaThreads: false
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// The probe body was consumed, so the single-stream retry needs its own request.
|
|
290
|
+
const retried = await attemptSingleStream({
|
|
291
|
+
url: flow.url,
|
|
292
|
+
partPath,
|
|
293
|
+
proxy,
|
|
294
|
+
insecure,
|
|
295
|
+
maxRedirects,
|
|
296
|
+
headers,
|
|
297
|
+
maxBytes,
|
|
298
|
+
stallMs: Math.max(1, stallS) * 1000,
|
|
299
|
+
signal: controller.signal,
|
|
300
|
+
onEvent
|
|
301
|
+
});
|
|
302
|
+
return { ...retried, viaThreads: false };
|
|
303
|
+
})();
|
|
304
|
+
|
|
305
|
+
if (fs.existsSync(savePath) && !overwrite) {
|
|
306
|
+
throw new Error(`下载期间目标文件出现了:${savePath}`);
|
|
307
|
+
}
|
|
308
|
+
fs.renameSync(partPath, savePath);
|
|
309
|
+
const elapsedMs = Date.now() - started;
|
|
310
|
+
return {
|
|
311
|
+
savedTo: savePath,
|
|
312
|
+
bytes: streamed.bytes,
|
|
313
|
+
sha256: streamed.sha256,
|
|
314
|
+
elapsedMs,
|
|
315
|
+
speedBps: elapsedMs > 0 ? Math.round((streamed.bytes / elapsedMs) * 1000) : null,
|
|
316
|
+
viaThreads: streamed.viaThreads,
|
|
317
|
+
segments: streamed.segments ?? null,
|
|
318
|
+
http: {
|
|
319
|
+
status: flow.status,
|
|
320
|
+
redirects: flow.redirects,
|
|
321
|
+
content_length: Number.isFinite(declaredLength) ? declaredLength : null,
|
|
322
|
+
content_type: flow.headers["content-type"] ?? null
|
|
323
|
+
},
|
|
324
|
+
finalUrl: flow.url
|
|
325
|
+
};
|
|
326
|
+
} catch (error) {
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(partPath, { force: true });
|
|
329
|
+
} catch {
|
|
330
|
+
/* nothing to clean */
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(deadline);
|
|
335
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Download with the automatic direct → proxy fallback.
|
|
341
|
+
*
|
|
342
|
+
* @param options - as {@link attemptDownload} plus `decidedRoute` and `upstream`.
|
|
343
|
+
* @returns the attempt result plus `route` / `routeReason` / `fallbackUsed`.
|
|
344
|
+
*/
|
|
345
|
+
export async function downloadWithFallback(options) {
|
|
346
|
+
const { decidedRoute, upstream, forceRoute = "auto", onEvent = null, ...attempt } = options;
|
|
347
|
+
const canFallback = decidedRoute.route === "direct" && forceRoute !== "direct" && upstream.available === true && upstream.proxy !== null;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const result = await attemptDownload({ ...attempt, proxy: decidedRoute.route === "proxy" ? upstream.proxy : null, onEvent });
|
|
351
|
+
return { ...result, route: decidedRoute.route, routeReason: decidedRoute.reason, fallbackUsed: false };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (!canFallback || error instanceof HttpStatusError) throw error;
|
|
354
|
+
onEvent?.({ kind: "fallback", reason: error instanceof Error ? error.message : String(error) });
|
|
355
|
+
const result = await attemptDownload({ ...attempt, proxy: upstream.proxy, onEvent });
|
|
356
|
+
return {
|
|
357
|
+
...result,
|
|
358
|
+
route: "proxy",
|
|
359
|
+
routeReason: `直连失败(${error instanceof Error ? error.message : String(error)}),已改用代理重试`,
|
|
360
|
+
fallbackUsed: true
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP/1.1 client for the download pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately built on `node:http` / `node:tls` instead of undici so that the
|
|
5
|
+
* proxy hop is explicit and observable: the caller passes a `{ host, port }`
|
|
6
|
+
* HTTP-CONNECT proxy (in practice always the plugin's own loopback rule core),
|
|
7
|
+
* and nothing about the request depends on a process-wide dispatcher.
|
|
8
|
+
*
|
|
9
|
+
* Every `openTarget` resolves on response headers; the body stays streaming on
|
|
10
|
+
* the returned `res` for the caller to consume or discard.
|
|
11
|
+
* @module dsh-downloader/http
|
|
12
|
+
*/
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import https from "node:https";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import tls from "node:tls";
|
|
17
|
+
import { hasSensitiveHeaders, stripSensitiveHeaders } from "./auth.js";
|
|
18
|
+
|
|
19
|
+
/** User agent used for every plugin-originated request. */
|
|
20
|
+
export const DEFAULT_UA = "DeepSeek-Harness/dsh-downloader (+https://github.com/having5548/dsh-downloader)";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a proxy URL into `{ socks, host, port, raw, auth }`, or null.
|
|
24
|
+
* @param value - `host:port`, `http://…`, `https://…`, `socks5://…` or null.
|
|
25
|
+
*/
|
|
26
|
+
export function parseProxyUrl(value) {
|
|
27
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
28
|
+
const raw = value.trim();
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(raw.includes("://") ? raw : `http://${raw}`);
|
|
31
|
+
const protocol = url.protocol.toLowerCase();
|
|
32
|
+
const socks = protocol === "socks5:" || protocol === "socks5h:" || protocol === "socks4:" || protocol === "socks:";
|
|
33
|
+
const supported = socks || protocol === "http:" || protocol === "https:";
|
|
34
|
+
if (!supported) return null;
|
|
35
|
+
const port = Number(url.port) || (socks ? 1080 : protocol === "https:" ? 443 : 80);
|
|
36
|
+
const auth = url.username.length > 0
|
|
37
|
+
? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) }
|
|
38
|
+
: null;
|
|
39
|
+
return { socks, secure: protocol === "https:", host: url.hostname, port, raw, auth };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Open an HTTP CONNECT tunnel through `proxy` to `host:port`.
|
|
47
|
+
* @param proxy - a parsed proxy (must not be a SOCKS proxy).
|
|
48
|
+
* @returns the raw tunnelled socket.
|
|
49
|
+
*/
|
|
50
|
+
export function connectTunnel(proxy, host, port, timeoutMs) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const headers = { Host: `${host}:${port}` };
|
|
53
|
+
if (proxy.auth !== null && proxy.auth !== undefined) {
|
|
54
|
+
const token = Buffer.from(`${proxy.auth.username}:${proxy.auth.password}`).toString("base64");
|
|
55
|
+
headers["Proxy-Authorization"] = `Basic ${token}`;
|
|
56
|
+
}
|
|
57
|
+
// [dsh-downloader] The plugin's own loopback core is token-gated so that
|
|
58
|
+
// nothing outside it can borrow the listener as a proxy.
|
|
59
|
+
if (typeof proxy.token === "string" && proxy.token.length > 0) {
|
|
60
|
+
headers["Proxy-Authorization"] = `Bearer ${proxy.token}`;
|
|
61
|
+
}
|
|
62
|
+
const transport = proxy.secure === true ? https : http;
|
|
63
|
+
const request = transport.request({
|
|
64
|
+
host: proxy.host,
|
|
65
|
+
port: proxy.port,
|
|
66
|
+
method: "CONNECT",
|
|
67
|
+
path: `${host}:${port}`,
|
|
68
|
+
headers,
|
|
69
|
+
agent: false,
|
|
70
|
+
...(proxy.secure === true ? { rejectUnauthorized: true } : {})
|
|
71
|
+
});
|
|
72
|
+
const timer = setTimeout(() => request.destroy(new Error(`代理 CONNECT 超时(${timeoutMs}ms)`)), timeoutMs);
|
|
73
|
+
request.once("connect", (response, socket) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
if (response.statusCode === 200) resolve(socket);
|
|
76
|
+
else {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
reject(new Error(`代理 CONNECT 被拒绝:HTTP ${response.statusCode}`));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
request.once("error", (error) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(error);
|
|
84
|
+
});
|
|
85
|
+
request.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start one HTTP(S) request, optionally through an HTTP CONNECT proxy.
|
|
91
|
+
*
|
|
92
|
+
* Resolves as soon as response headers arrive. `result.abort()` tears the
|
|
93
|
+
* request down; the caller owns `result.res`.
|
|
94
|
+
*
|
|
95
|
+
* @param rawUrl - absolute http/https URL.
|
|
96
|
+
* @param options - method, headers, proxy (`{host,port,socks,auth,secure}`|null), timeoutMs, signal, insecure, body.
|
|
97
|
+
* @returns `{ status, statusMessage, headers, res, url, abort }`.
|
|
98
|
+
*/
|
|
99
|
+
export function openTarget(rawUrl, options = {}) {
|
|
100
|
+
const {
|
|
101
|
+
method = "GET",
|
|
102
|
+
headers = {},
|
|
103
|
+
proxy = null,
|
|
104
|
+
timeoutMs = 20000,
|
|
105
|
+
signal = null,
|
|
106
|
+
insecure = false,
|
|
107
|
+
body = null
|
|
108
|
+
} = options;
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
let url;
|
|
112
|
+
try {
|
|
113
|
+
url = new URL(rawUrl);
|
|
114
|
+
} catch {
|
|
115
|
+
reject(new Error(`无效的 URL:${rawUrl}`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const isHttps = url.protocol === "https:";
|
|
119
|
+
const port = Number(url.port) || (isHttps ? 443 : 80);
|
|
120
|
+
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const onOuterAbort = () => controller.abort(new Error("调用方已取消"));
|
|
123
|
+
if (signal !== null && signal !== undefined) {
|
|
124
|
+
if (signal.aborted) {
|
|
125
|
+
reject(new Error("调用方已取消"));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
signal.addEventListener("abort", onOuterAbort, { once: true });
|
|
129
|
+
}
|
|
130
|
+
const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms):${url.host}`)), timeoutMs);
|
|
131
|
+
|
|
132
|
+
let settled = false;
|
|
133
|
+
let request = null;
|
|
134
|
+
const finish = (error, response) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
139
|
+
if (error !== null && error !== undefined) reject(error instanceof Error ? error : new Error(String(error)));
|
|
140
|
+
else {
|
|
141
|
+
resolve({
|
|
142
|
+
status: response.statusCode,
|
|
143
|
+
statusMessage: response.statusMessage,
|
|
144
|
+
headers: response.headers,
|
|
145
|
+
res: response,
|
|
146
|
+
url: url.href,
|
|
147
|
+
abort: () => {
|
|
148
|
+
try {
|
|
149
|
+
request?.destroy();
|
|
150
|
+
} catch {
|
|
151
|
+
/* already gone */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const baseHeaders = { "User-Agent": DEFAULT_UA, "Accept-Encoding": "identity", ...headers };
|
|
159
|
+
const onResponse = (response) => finish(null, response);
|
|
160
|
+
const onRequestError = (error) => {
|
|
161
|
+
const reason = controller.signal.reason;
|
|
162
|
+
finish(controller.signal.aborted && reason instanceof Error ? reason : error);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (proxy !== null && proxy !== undefined && proxy.socks === true) {
|
|
166
|
+
finish(new Error("SOCKS 代理由规则内核处理,请改传内核的 HTTP 端口"));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (proxy !== null && proxy !== undefined && isHttps) {
|
|
171
|
+
connectTunnel(proxy, url.hostname, port, Math.min(timeoutMs, 15000))
|
|
172
|
+
.then((socket) => {
|
|
173
|
+
socket.once("error", () => {});
|
|
174
|
+
const tlsSocket = tls.connect({ socket, servername: url.hostname, rejectUnauthorized: !insecure });
|
|
175
|
+
tlsSocket.once("error", () => {});
|
|
176
|
+
request = https.request(
|
|
177
|
+
url,
|
|
178
|
+
{ method, headers: baseHeaders, signal: controller.signal, agent: false, createConnection: () => tlsSocket },
|
|
179
|
+
onResponse
|
|
180
|
+
);
|
|
181
|
+
request.once("error", onRequestError);
|
|
182
|
+
if (body !== null && body !== undefined) request.end(body);
|
|
183
|
+
else request.end();
|
|
184
|
+
})
|
|
185
|
+
.catch((error) => finish(error));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
if (proxy !== null && proxy !== undefined) {
|
|
191
|
+
// Plaintext http through a proxy: absolute-form request line.
|
|
192
|
+
const proxyHeaders = { ...baseHeaders, Host: url.host };
|
|
193
|
+
if (typeof proxy.token === "string" && proxy.token.length > 0) {
|
|
194
|
+
proxyHeaders["Proxy-Authorization"] = `Bearer ${proxy.token}`;
|
|
195
|
+
}
|
|
196
|
+
request = http.request(
|
|
197
|
+
{
|
|
198
|
+
host: proxy.host,
|
|
199
|
+
port: proxy.port,
|
|
200
|
+
path: url.href,
|
|
201
|
+
method,
|
|
202
|
+
headers: proxyHeaders,
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
agent: false
|
|
205
|
+
},
|
|
206
|
+
onResponse
|
|
207
|
+
);
|
|
208
|
+
} else if (isHttps) {
|
|
209
|
+
request = https.request(url, { method, headers: baseHeaders, signal: controller.signal, agent: false, rejectUnauthorized: !insecure }, onResponse);
|
|
210
|
+
} else {
|
|
211
|
+
request = http.request(url, { method, headers: baseHeaders, signal: controller.signal, agent: false }, onResponse);
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
finish(error);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
request.once("error", onRequestError);
|
|
219
|
+
if (body !== null && body !== undefined) request.end(body);
|
|
220
|
+
else request.end();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read a response body up to `limit` bytes and decode it as UTF-8. */
|
|
225
|
+
export async function readBody(result, limit = 64 * 1024) {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let size = 0;
|
|
228
|
+
await new Promise((resolve, reject) => {
|
|
229
|
+
result.res.on("data", (chunk) => {
|
|
230
|
+
size += chunk.length;
|
|
231
|
+
if (size <= limit) chunks.push(chunk);
|
|
232
|
+
else result.abort();
|
|
233
|
+
});
|
|
234
|
+
result.res.once("end", resolve);
|
|
235
|
+
result.res.once("aborted", () => reject(new Error("响应被中断")));
|
|
236
|
+
result.res.once("error", reject);
|
|
237
|
+
});
|
|
238
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Follow redirects and return the final response.
|
|
243
|
+
*
|
|
244
|
+
* `onHop` (optional) is called once per hop with `{ status, url, location }`
|
|
245
|
+
* before the redirect is followed, so callers can log the chain.
|
|
246
|
+
*
|
|
247
|
+
* [dsh-downloader] 跨主机跳转时会**丢掉凭据类请求头**:GitHub 的
|
|
248
|
+
* `archive_download_url` 会 302 到 blob 域,把 `Authorization` 带过去既没用,
|
|
249
|
+
* 还可能被对端以 400 拒绝(也等于把 token 交给了第三方主机)。
|
|
250
|
+
*/
|
|
251
|
+
export async function httpFlow(rawUrl, options = {}) {
|
|
252
|
+
const { maxRedirects = 10, onHop, headers, ...rest } = options;
|
|
253
|
+
let current = rawUrl;
|
|
254
|
+
let currentHeaders = headers;
|
|
255
|
+
let redirects = 0;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const result = await openTarget(current, { ...rest, headers: currentHeaders });
|
|
258
|
+
if ([301, 302, 303, 307, 308].includes(result.status)) {
|
|
259
|
+
const location = result.headers.location;
|
|
260
|
+
result.res.resume();
|
|
261
|
+
result.abort();
|
|
262
|
+
if (typeof location !== "string" || location.length === 0) throw new Error(`HTTP ${result.status} 重定向缺少 Location 头`);
|
|
263
|
+
if (++redirects > maxRedirects) throw new Error(`重定向次数超过上限(> ${maxRedirects})`);
|
|
264
|
+
const next = new URL(location, current).href;
|
|
265
|
+
if (hasSensitiveHeaders(currentHeaders)) {
|
|
266
|
+
let sameHost = false;
|
|
267
|
+
try {
|
|
268
|
+
sameHost = new URL(next).host === new URL(current).host;
|
|
269
|
+
} catch {
|
|
270
|
+
sameHost = false;
|
|
271
|
+
}
|
|
272
|
+
if (!sameHost) currentHeaders = stripSensitiveHeaders(currentHeaders);
|
|
273
|
+
}
|
|
274
|
+
onHop?.({ status: result.status, url: current, location: next });
|
|
275
|
+
current = next;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
return { ...result, redirects };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Open a plain TCP connection with a deadline. Rejects on error or timeout. */
|
|
283
|
+
export function tcpConnect(host, port, timeoutMs = 3000) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
const socket = net.connect({ host, port });
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
socket.destroy();
|
|
288
|
+
reject(new Error(`TCP 连接超时(${timeoutMs}ms)`));
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
socket.once("connect", () => {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
resolve(socket);
|
|
293
|
+
});
|
|
294
|
+
socket.once("error", (error) => {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
reject(error);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|