@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.
@@ -0,0 +1,664 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Vendored from dsh-clash-proxy 0.2.0 (MIT).
3
+ // Source: H:\mycode\example\dsh-clash\lib\core\transports.js
4
+ // UNMODIFIED except where a line is marked [dsh-downloader].
5
+ // ---------------------------------------------------------------------------
6
+ import net from "node:net";
7
+ import tls from "node:tls";
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+ import { createHash, createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
13
+ import { sealVmessAeadHeader, buildVmessCommand, decodeVmessResponse, VmessChunkWriter, VmessChunkReader, vmessEncodeAddr } from "./vmess.js";
14
+ import { wsHandshake, WsStream } from "./ws.js";
15
+
16
+ /**
17
+ * Lightweight upstream transports for subscription nodes.
18
+ *
19
+ * Supported node types: `direct` (no node), `http`, `socks5`, `ss`
20
+ * (aes-128-gcm / aes-256-gcm / chacha20-ietf-poly1305), `trojan`
21
+ * (tcp / tls / ws), `vless` (tcp / tls / ws), and `vmess` (AEAD alterId=0,
22
+ * tcp / ws, AES-128-GCM). `vless` reality (xtls-rprx-vision) and `hysteria2`
23
+ * (QUIC) are delegated to the bundled Go native connector (see nativeConnect);
24
+ * `tuic` (another QUIC variant) is not implemented.
25
+ *
26
+ * Every transport resolves to a uniform duplex face:
27
+ * { read: AsyncIterable<Buffer> (decrypted bytes), write(buf), close() }
28
+ * so the proxy server never cares which protocol a node speaks.
29
+ *
30
+ * One {@link SocketBuffer} per connection owns all inbound bytes: handshakes
31
+ * consume exact counts from it and any leftovers stay buffered for the relay
32
+ * phase, so pipelined early data (TLS ClientHello etc.) is never lost.
33
+ * @module dsh-clash-proxy/transports
34
+ */
35
+
36
+ const SS_SALT_SIZES = { "aes-128-gcm": 16, "aes-256-gcm": 32, "chacha20-ietf-poly1305": 32 };
37
+ const SS_KEY_SIZES = { "aes-128-gcm": 16, "aes-256-gcm": 32, "chacha20-ietf-poly1305": 32 };
38
+ const SS_CIPHER_NAMES = { "chacha20-ietf-poly1305": "chacha20-poly1305" };
39
+
40
+ /** Persistent inbound-byte buffer: handshake + relay share one stream. */
41
+ export class SocketBuffer {
42
+ #buffer = Buffer.alloc(0);
43
+ #waiters = [];
44
+ #ended = false;
45
+ #error = null;
46
+
47
+ /** @param socket - the socket whose "data" events feed this buffer. */
48
+ constructor(socket) {
49
+ this.socket = socket;
50
+ socket.on("data", (chunk) => {
51
+ if (this.#ended) return;
52
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
53
+ this.#flush();
54
+ });
55
+ socket.once("close", () => {
56
+ this.#ended = true;
57
+ for (const waiter of this.#waiters.splice(0)) {
58
+ if (waiter.any) waiter.resolve(null);
59
+ else waiter.reject(new Error("connection closed while reading"));
60
+ }
61
+ });
62
+ socket.on("error", (error) => {
63
+ this.#error = error;
64
+ for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
65
+ });
66
+ }
67
+
68
+ #flush() {
69
+ while (this.#waiters.length > 0) {
70
+ const waiter = this.#waiters[0];
71
+ if (waiter.any) {
72
+ if (this.#buffer.length === 0) return;
73
+ this.#waiters.shift();
74
+ waiter.resolve(this.#buffer);
75
+ this.#buffer = Buffer.alloc(0);
76
+ continue;
77
+ }
78
+ if (this.#buffer.length < waiter.count) return;
79
+ this.#waiters.shift();
80
+ waiter.resolve(this.#buffer.subarray(0, waiter.count));
81
+ this.#buffer = this.#buffer.subarray(waiter.count);
82
+ }
83
+ }
84
+
85
+ /** Exactly `count` bytes (waits for them to arrive). */
86
+ read(count) {
87
+ return new Promise((resolve, reject) => {
88
+ if (this.#error !== null) return reject(this.#error);
89
+ if (this.#ended) return reject(new Error("connection closed"));
90
+ this.#waiters.push({ count, resolve, reject });
91
+ this.#flush();
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Whatever is currently buffered, waiting for at least one byte; null on close.
97
+ *
98
+ * [dsh-downloader] FIX: drain the buffer BEFORE consulting `#ended`.
99
+ * The original checked `#ended` first, so bytes that had already arrived but
100
+ * had not yet been consumed when the socket closed were silently dropped —
101
+ * which truncated any transfer whose consumer was slower than the socket
102
+ * (measured: a 512 KiB body ended after 384 KiB, i.e. a corrupt download).
103
+ */
104
+ readAny() {
105
+ return new Promise((resolve, reject) => {
106
+ if (this.#buffer.length > 0) {
107
+ const out = this.#buffer;
108
+ this.#buffer = Buffer.alloc(0);
109
+ resolve(out);
110
+ return;
111
+ }
112
+ if (this.#error !== null) return reject(this.#error);
113
+ if (this.#ended) return resolve(null);
114
+ this.#waiters.push({ any: true, resolve, reject });
115
+ this.#flush();
116
+ });
117
+ }
118
+ }
119
+
120
+ /** Promisified TCP connect. */
121
+ function tcpConnect(host, port) {
122
+ return new Promise((resolve, reject) => {
123
+ const socket = net.connect({ host, port });
124
+ socket.once("connect", () => resolve(socket));
125
+ socket.once("error", reject);
126
+ });
127
+ }
128
+
129
+ /** Promisified TLS connect. */
130
+ function tlsConnect(host, port, servername, skipVerify) {
131
+ return new Promise((resolve, reject) => {
132
+ const socket = tls.connect({ host, port, servername: servername ?? host, rejectUnauthorized: !skipVerify });
133
+ socket.once("secureConnect", () => resolve(socket));
134
+ socket.once("error", reject);
135
+ });
136
+ }
137
+
138
+ /** Promisified socket write (drain-aware). */
139
+ function socketWrite(socket, data) {
140
+ return new Promise((resolve, reject) => {
141
+ const onError = (error) => {
142
+ cleanup();
143
+ reject(error);
144
+ };
145
+ const onDrain = () => {
146
+ cleanup();
147
+ resolve();
148
+ };
149
+ const cleanup = () => {
150
+ socket.off("error", onError);
151
+ socket.off("drain", onDrain);
152
+ };
153
+ socket.once("error", onError);
154
+ socket.once("drain", onDrain);
155
+ if (!socket.write(data)) return;
156
+ cleanup();
157
+ resolve();
158
+ });
159
+ }
160
+
161
+ /** SOCKS-style address encoding: [atyp][address][port BE16]. */
162
+ export function encodeAddr(host, port) {
163
+ const ip4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255);
164
+ if (ip4) {
165
+ const addr = Buffer.concat([Buffer.from(host.split(".").map(Number)), Buffer.from([port >> 8, port & 0xff])]);
166
+ return Buffer.concat([Buffer.from([0x01]), addr]);
167
+ }
168
+ if (host.includes(":")) {
169
+ const groups = host.split(":");
170
+ const addr = Buffer.concat([Buffer.from(groups.map((part) => {
171
+ const value = parseInt(part, 16);
172
+ return [value >> 8, value & 0xff];
173
+ }).flat()), Buffer.from([port >> 8, port & 0xff])]);
174
+ return Buffer.concat([Buffer.from([0x04]), addr]);
175
+ }
176
+ const hostBytes = Buffer.from(host);
177
+ return Buffer.concat([Buffer.from([0x03, hostBytes.length]), hostBytes, Buffer.from([port >> 8, port & 0xff])]);
178
+ }
179
+
180
+ /** Read a CRLF-terminated header block from the buffer (leftovers stay buffered). */
181
+ async function readUntilCrlfCrlf(buffer) {
182
+ let text = "";
183
+ for (;;) {
184
+ const byte = await buffer.read(1);
185
+ text += byte.toString("latin1");
186
+ if (text.endsWith("\r\n\r\n")) return text.slice(0, -4);
187
+ if (text.length > 65536) throw new Error("header block too large");
188
+ }
189
+ }
190
+
191
+ /** Plain duplex face over a raw socket + shared buffer. */
192
+ function rawFace(socket, buffer) {
193
+ return {
194
+ read: (async function* () {
195
+ for (;;) {
196
+ const chunk = await buffer.readAny();
197
+ if (chunk === null) return;
198
+ yield chunk;
199
+ }
200
+ })(),
201
+ write: (data) => socket.write(data),
202
+ close: () => socket.destroy()
203
+ };
204
+ }
205
+
206
+ // ---- plain transports -----------------------------------------------------
207
+
208
+ async function httpConnect(node, host, port) {
209
+ const socket = node.tls ? await tlsConnect(node.server, node.port, node.sni ?? node.server, node["skip-cert-verify"]) : await tcpConnect(node.server, node.port);
210
+ const buffer = new SocketBuffer(socket);
211
+ const auth = node.username !== undefined && node.username !== null && String(node.username).length > 0
212
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${node.username}:${node.password ?? ""}`).toString("base64")}\r\n`
213
+ : "";
214
+ await socketWrite(socket, `CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n${auth}\r\n`);
215
+ const head = await readUntilCrlfCrlf(buffer);
216
+ const status = Number((head.split("\r\n")[0] ?? " 0").split(" ")[1] ?? 0);
217
+ if (status < 200 || status >= 300) throw new Error(`http upstream CONNECT failed: ${head.split("\r\n")[0]}`);
218
+ return rawFace(socket, buffer);
219
+ }
220
+
221
+ async function socks5Connect(node, host, port) {
222
+ const socket = await tcpConnect(node.server, node.port);
223
+ const buffer = new SocketBuffer(socket);
224
+ const hasAuth = node.username !== undefined && node.username !== null && String(node.username).length > 0;
225
+ await socketWrite(socket, Buffer.from([0x05, hasAuth ? 2 : 1, 0x00, ...(hasAuth ? [0x02] : [])]));
226
+ const greeting = await buffer.read(2);
227
+ if (greeting[1] === 0x02) {
228
+ const user = Buffer.from(String(node.username));
229
+ const pass = Buffer.from(String(node.password ?? ""));
230
+ await socketWrite(socket, Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass]));
231
+ const authReply = await buffer.read(2);
232
+ if (authReply[1] !== 0x00) throw new Error("socks5 upstream auth failed");
233
+ } else if (greeting[1] !== 0x00) {
234
+ throw new Error("socks5 upstream: no acceptable auth method");
235
+ }
236
+ await socketWrite(socket, Buffer.concat([Buffer.from([0x05, 0x01, 0x00]), encodeAddr(host, port)]));
237
+ const reply = await buffer.read(4);
238
+ if (reply[1] !== 0x00) throw new Error(`socks5 upstream connect failed: code ${reply[1]}`);
239
+ const restLength = reply[3] === 0x01 ? 6 : reply[3] === 0x04 ? 18 : 0;
240
+ if (restLength === 0 && reply[3] === 0x03) {
241
+ const length = (await buffer.read(1))[0];
242
+ await buffer.read(length + 2);
243
+ } else if (restLength > 0) {
244
+ await buffer.read(restLength);
245
+ }
246
+ return rawFace(socket, buffer);
247
+ }
248
+
249
+ // ---- shadowsocks AEAD -----------------------------------------------------
250
+
251
+ /** EVP_BytesToKey (MD5 iteration), shadowsocks master key derivation. */
252
+ export function ssMasterKey(password, keyLength) {
253
+ const out = [];
254
+ let previous = Buffer.alloc(0);
255
+ while (Buffer.concat(out).length < keyLength) {
256
+ previous = createHash("md5").update(Buffer.concat([previous, Buffer.from(password)])).digest();
257
+ out.push(previous);
258
+ }
259
+ return Buffer.concat(out).subarray(0, keyLength);
260
+ }
261
+
262
+ /** SIP004 subkey derivation. */
263
+ export function ssSubkey(password, salt, cipherName) {
264
+ const keyLength = SS_KEY_SIZES[cipherName];
265
+ const master = ssMasterKey(password, keyLength);
266
+ return hkdfSync("sha1", master, salt, "ss-subkey", keyLength);
267
+ }
268
+
269
+ /** Nonce with a little-endian 64-bit counter (SIP004). */
270
+ export function makeSsNonce() {
271
+ const nonce = Buffer.alloc(12);
272
+ let counter = 0;
273
+ return {
274
+ next() {
275
+ nonce.writeBigUInt64LE(BigInt(counter++));
276
+ return Buffer.from(nonce);
277
+ }
278
+ };
279
+ }
280
+
281
+ /** One AEAD seal: ciphertext = [cipher || tag], AAD = the 2-byte length. */
282
+ export function ssSeal(cipherName, key, nonce, plaintext) {
283
+ const length = Buffer.from([plaintext.length >> 8, plaintext.length & 0xff]);
284
+ const cipher = createCipheriv(SS_CIPHER_NAMES[cipherName] ?? cipherName, key, nonce);
285
+ cipher.setAAD(length);
286
+ return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
287
+ }
288
+
289
+ /** One AEAD open. */
290
+ export function ssOpen(cipherName, key, nonce, sealed) {
291
+ const length = Buffer.from([(sealed.length - 16) >> 8, (sealed.length - 16) & 0xff]);
292
+ const decipher = createDecipheriv(SS_CIPHER_NAMES[cipherName] ?? cipherName, key, nonce);
293
+ decipher.setAAD(length);
294
+ decipher.setAuthTag(sealed.subarray(sealed.length - 16));
295
+ return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
296
+ }
297
+
298
+ async function ssConnect(node, host, port) {
299
+ const cipherName = String(node.cipher ?? "aes-256-gcm");
300
+ if (SS_KEY_SIZES[cipherName] === undefined) throw new Error(`ss: unsupported cipher ${cipherName}`);
301
+ const salt = randomBytes(SS_SALT_SIZES[cipherName]);
302
+ const key = ssSubkey(String(node.password ?? ""), salt, cipherName);
303
+ const socket = await tcpConnect(node.server, node.port);
304
+ const buffer = new SocketBuffer(socket);
305
+ const outNonce = makeSsNonce();
306
+ const inNonce = makeSsNonce();
307
+ const first = ssSeal(cipherName, key, outNonce.next(), encodeAddr(host, port));
308
+ await socketWrite(socket, Buffer.concat([salt, Buffer.from([first.length >> 8, first.length & 0xff]), first]));
309
+
310
+ return {
311
+ read: (async function* () {
312
+ for (;;) {
313
+ const lengthHeader = await buffer.read(2);
314
+ const length = (lengthHeader[0] << 8) | lengthHeader[1];
315
+ // The length field covers ciphertext + tag (ssSeal's output size).
316
+ const sealed = await buffer.read(length);
317
+ yield ssOpen(cipherName, key, inNonce.next(), sealed);
318
+ }
319
+ })(),
320
+ write: (data) => {
321
+ for (let offset = 0; offset < data.length; offset += 0x3fff) {
322
+ const part = data.subarray(offset, Math.min(data.length, offset + 0x3fff));
323
+ const sealed = ssSeal(cipherName, key, outNonce.next(), part);
324
+ socket.write(Buffer.concat([Buffer.from([sealed.length >> 8, sealed.length & 0xff]), sealed]));
325
+ }
326
+ },
327
+ close: () => socket.destroy()
328
+ };
329
+ }
330
+
331
+ // ---- vmess (AEAD, tcp / ws transport) ------------------------------------
332
+
333
+ /** Wrap an async iterable of chunks as a fixed-size read(n) source. */
334
+ export function iterableSource(iterable) {
335
+ const iterator = iterable[Symbol.asyncIterator]();
336
+ let pending = Buffer.alloc(0);
337
+ return {
338
+ async read(count) {
339
+ while (pending.length < count) {
340
+ const { value, done } = await iterator.next();
341
+ if (done) throw new Error("connection closed while reading");
342
+ pending = Buffer.concat([pending, value]);
343
+ }
344
+ const out = pending.subarray(0, count);
345
+ pending = pending.subarray(count);
346
+ return out;
347
+ },
348
+ /** Whatever is buffered (or the next chunk); null when the stream ends. */
349
+ async readAny() {
350
+ if (pending.length > 0) {
351
+ const out = pending;
352
+ pending = Buffer.alloc(0);
353
+ return out;
354
+ }
355
+ const { value, done } = await iterator.next();
356
+ return done ? null : value;
357
+ }
358
+ };
359
+ }
360
+
361
+ async function vmessConnect(node, host, port) {
362
+ const socket = node.tls
363
+ ? await tlsConnect(node.server, node.port, node.sni ?? node.server, node["skip-cert-verify"])
364
+ : await tcpConnect(node.server, node.port);
365
+ const buffer = new SocketBuffer(socket);
366
+ const network = String(node.network ?? "tcp").toLowerCase();
367
+ let writeRaw;
368
+ let source;
369
+ if (network === "ws") {
370
+ const path = node["ws-opts"]?.path ?? "/";
371
+ const headers = node["ws-opts"]?.headers ?? {};
372
+ const hostHeader = headers.Host ?? headers.host ?? node.server;
373
+ await wsHandshake(buffer, path, hostHeader);
374
+ const ws = new WsStream(socket, buffer);
375
+ writeRaw = (data) => ws.write(data);
376
+ source = iterableSource(ws.read());
377
+ } else {
378
+ writeRaw = (data) => socket.write(data);
379
+ source = buffer;
380
+ }
381
+
382
+ const cmdKey = createHash("md5")
383
+ .update(uuidBytes(node.uuid))
384
+ .update("c48619fe-8f02-49e0-b9e9-edf763e17e21")
385
+ .digest();
386
+ const reqKey = randomBytes(16);
387
+ const reqIV = randomBytes(16);
388
+ const responseHeader = randomBytes(1)[0];
389
+ const command = buildVmessCommand({ reqKey, reqIV, responseHeader, host, port });
390
+ const header = sealVmessAeadHeader(cmdKey, command);
391
+ writeRaw(header);
392
+
393
+ // The request body writer is ready immediately: the server only flushes its
394
+ // (buffered) response header after it receives data from the target, which
395
+ // in turn needs the client's request body first. Writes must therefore be
396
+ // pipelined with — not gated behind — the response header read.
397
+ const writer = new VmessChunkWriter((chunk) => writeRaw(chunk), reqKey, reqIV);
398
+
399
+ let decodedPromise = null;
400
+ const decoded = () => {
401
+ if (decodedPromise === null) decodedPromise = decodeVmessResponse(source, reqKey, reqIV);
402
+ return decodedPromise;
403
+ };
404
+
405
+ return {
406
+ read: (async function* () {
407
+ const { responseHeader: resp, bodyKey, bodyIV } = await decoded();
408
+ if (resp[0] !== responseHeader) throw new Error("vmess: response header V mismatch");
409
+ const reader = new VmessChunkReader(source, bodyKey, bodyIV);
410
+ for (;;) {
411
+ const chunk = await reader.readChunk();
412
+ if (chunk === null) return;
413
+ yield chunk;
414
+ }
415
+ })(),
416
+ write: (data) => writer.write(data),
417
+ close: () => {
418
+ try {
419
+ writer.end();
420
+ } catch {
421
+ // Socket may already be gone.
422
+ }
423
+ socket.destroy();
424
+ }
425
+ };
426
+ }
427
+
428
+ // ---- trojan / vless -------------------------------------------------------
429
+
430
+ async function trojanConnect(node, host, port) {
431
+ const socket = await tlsConnect(node.server, node.port, node.sni ?? node.server, node["skip-cert-verify"]);
432
+ const buffer = new SocketBuffer(socket);
433
+ const network = String(node.network ?? "tcp").toLowerCase();
434
+ let writeRaw;
435
+ let source; // async iterable of payload bytes
436
+ if (network === "ws") {
437
+ const path = node["ws-opts"]?.path ?? "/";
438
+ const headers = node["ws-opts"]?.headers ?? {};
439
+ const hostHeader = headers.Host ?? headers.host ?? node.sni ?? node.server;
440
+ await wsHandshake(buffer, path, hostHeader);
441
+ const ws = new WsStream(socket, buffer);
442
+ writeRaw = (data) => ws.write(data);
443
+ source = ws.read();
444
+ } else {
445
+ writeRaw = (data) => socket.write(data);
446
+ source = (async function* () {
447
+ for (;;) {
448
+ const chunk = await buffer.readAny();
449
+ if (chunk === null) return;
450
+ yield chunk;
451
+ }
452
+ })();
453
+ }
454
+ const passwordHash = createHash("sha224").update(String(node.password ?? "")).digest("hex");
455
+ const request = Buffer.concat([
456
+ Buffer.from(passwordHash),
457
+ Buffer.from("\r\n"),
458
+ Buffer.from([0x01]),
459
+ encodeAddr(host, port),
460
+ Buffer.from("\r\n")
461
+ ]);
462
+ writeRaw(request);
463
+ return {
464
+ read: source,
465
+ write: (data) => writeRaw(data),
466
+ close: () => socket.destroy()
467
+ };
468
+ }
469
+
470
+ function uuidBytes(uuid) {
471
+ const hex = String(uuid ?? "").replace(/-/g, "");
472
+ if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("vless: invalid uuid");
473
+ return Buffer.from(hex, "hex");
474
+ }
475
+
476
+ async function vlessConnect(node, host, port) {
477
+ const flow = String(node.flow ?? "").toLowerCase();
478
+ if (flow === "xtls-rprx-vision" || (node["reality-opts"] !== undefined && node["reality-opts"] !== null)) {
479
+ throw new Error("vless: reality (xtls-rprx-vision + reality-opts) needs uTLS fingerprinting that Node cannot provide");
480
+ }
481
+ const uuid = uuidBytes(node.uuid);
482
+ const tls = node.tls === true || node.tls === "true";
483
+ const socket = tls
484
+ ? await tlsConnect(node.server, node.port, node.servername ?? node.sni ?? node.server, node["skip-cert-verify"])
485
+ : await tcpConnect(node.server, node.port);
486
+ const buffer = new SocketBuffer(socket);
487
+ const network = String(node.network ?? "tcp").toLowerCase();
488
+ let writeRaw;
489
+ let source; // async iterable of payload bytes
490
+ if (network === "ws") {
491
+ const path = node["ws-opts"]?.path ?? "/";
492
+ const headers = node["ws-opts"]?.headers ?? {};
493
+ const hostHeader = headers.Host ?? headers.host ?? node.server;
494
+ await wsHandshake(buffer, path, hostHeader);
495
+ const ws = new WsStream(socket, buffer);
496
+ writeRaw = (data) => ws.write(data);
497
+ source = ws.read();
498
+ } else {
499
+ writeRaw = (data) => socket.write(data);
500
+ source = (async function* () {
501
+ for (;;) {
502
+ const chunk = await buffer.readAny();
503
+ if (chunk === null) return;
504
+ yield chunk;
505
+ }
506
+ })();
507
+ }
508
+ const request = Buffer.concat([
509
+ Buffer.from([0x00]), // version
510
+ uuid,
511
+ Buffer.from([0x00]), // addons length
512
+ Buffer.from([0x01]), // cmd: TCP
513
+ vmessEncodeAddr(host, port) // [port BE16][atyp][addr] (PortThenAddress)
514
+ ]);
515
+ writeRaw(request);
516
+
517
+ // VLESS response header: [version(1)][addons len(1)][addons...]. It is
518
+ // buffered by the server until the target replies, so it must be stripped
519
+ // lazily inside read() — never awaited before the face is returned — or
520
+ // writes would deadlock exactly like vmess.
521
+ const reader = iterableSource(source);
522
+ let headerStripped = false;
523
+ const stripHeader = async () => {
524
+ if (headerStripped) return;
525
+ headerStripped = true;
526
+ const version = (await reader.read(1))[0];
527
+ if (version !== 0x00) throw new Error(`vless: unexpected response version ${version}`);
528
+ const addonsLen = (await reader.read(1))[0];
529
+ if (addonsLen > 0) await reader.read(addonsLen);
530
+ };
531
+
532
+ return {
533
+ read: (async function* () {
534
+ await stripHeader();
535
+ for (;;) {
536
+ const chunk = await reader.readAny();
537
+ if (chunk === null) return;
538
+ yield chunk;
539
+ }
540
+ })(),
541
+ write: (data) => writeRaw(data),
542
+ close: () => socket.destroy()
543
+ };
544
+ }
545
+
546
+ // ---- unified entry --------------------------------------------------------
547
+
548
+ /** Path to the self-compiled Go connector (QUIC/uTLS transports). */
549
+ const CONNECTOR_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "native", "connector.exe");
550
+ /** Whether the native connector binary is present. */
551
+ export function hasNativeConnector() {
552
+ try { return existsSync(CONNECTOR_PATH); } catch { return false; }
553
+ }
554
+
555
+ /**
556
+ * Connect through the self-compiled Go connector via stdio: stdin feeds the
557
+ * remote target, stdout carries the reply. Supports `hysteria2` and `reality`.
558
+ */
559
+ function nativeConnect(proto, node, host, port) {
560
+ const payload = {
561
+ type: node.type,
562
+ server: node.server,
563
+ port: Number(node.port),
564
+ password: node.password,
565
+ sni: node.sni,
566
+ "skip-cert-verify": node["skip-cert-verify"] === true,
567
+ ports: node.ports,
568
+ uuid: node.uuid,
569
+ flow: node.flow,
570
+ servername: node.servername,
571
+ "public-key": node["reality-opts"]?.["public-key"],
572
+ "short-id": node["reality-opts"]?.["short-id"],
573
+ "client-fingerprint": node["client-fingerprint"],
574
+ // [dsh-downloader] hysteria2 的 salamander 混淆参数:漏传这两个字段时
575
+ // 服务端会直接丢掉我们的 QUIC 包,表现为「拨号超时」而不是认证失败。
576
+ obfs: node.obfs,
577
+ "obfs-password": node["obfs-password"]
578
+ };
579
+ const nodeB64 = Buffer.from(JSON.stringify(payload)).toString("base64");
580
+ const child = spawn(CONNECTOR_PATH, [proto, nodeB64, host, String(port)], {
581
+ stdio: ["pipe", "pipe", "pipe"],
582
+ windowsHide: true
583
+ });
584
+ let ended = false;
585
+ let stderrTail = "";
586
+ child.stderr.on("data", (chunk) => {
587
+ stderrTail = (stderrTail + chunk.toString()).slice(-512);
588
+ });
589
+ child.on("exit", () => { ended = true; });
590
+
591
+ return {
592
+ read: (async function* () {
593
+ for await (const chunk of child.stdout) {
594
+ yield chunk;
595
+ }
596
+ })(),
597
+ write: (data) => {
598
+ if (!ended) child.stdin.write(data);
599
+ },
600
+ close: () => {
601
+ try { child.stdin.end(); } catch { /* already closed */ }
602
+ try { child.stdout.destroy(); } catch { /* already closed */ }
603
+ if (!ended) setTimeout(() => child.kill(), 500);
604
+ }
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Connect to target (host, port) through a node.
610
+ * @param node - subscription node object, or null/undefined for direct.
611
+ * @returns the uniform duplex face.
612
+ */
613
+ export async function connectThrough(node, host, port) {
614
+ if (node === null || node === undefined || node.type === "direct" || node.type === "Direct") {
615
+ const socket = await tcpConnect(host, port);
616
+ return rawFace(socket, new SocketBuffer(socket));
617
+ }
618
+ switch (String(node.type).toLowerCase()) {
619
+ case "http":
620
+ return httpConnect(node, host, port);
621
+ case "socks5":
622
+ case "socks":
623
+ return socks5Connect(node, host, port);
624
+ case "ss":
625
+ return ssConnect(node, host, port);
626
+ case "trojan":
627
+ return trojanConnect(node, host, port);
628
+ case "vless":
629
+ if (String(node.flow ?? "").toLowerCase() === "xtls-rprx-vision" && node["reality-opts"] !== undefined && node["reality-opts"] !== null) {
630
+ if (!hasNativeConnector()) throw new Error("vless reality needs the native connector (lib/native/connector.exe)");
631
+ return nativeConnect("reality", node, host, port);
632
+ }
633
+ return vlessConnect(node, host, port);
634
+ case "vmess":
635
+ return vmessConnect(node, host, port);
636
+ case "hysteria2":
637
+ case "hy2":
638
+ if (!hasNativeConnector()) throw new Error("hysteria2 needs the native connector (lib/native/connector.exe)");
639
+ return nativeConnect("hysteria2", node, host, port);
640
+ case "tuic":
641
+ throw new Error(`unsupported node type "tuic"`);
642
+ default:
643
+ throw new Error(`unsupported node type "${node.type}" (supported: http, socks5, ss, trojan, vless, vmess, hysteria2)`);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Measure TCP connect latency through a node (or direct). Resolves to
649
+ * milliseconds, or null when it fails within the timeout.
650
+ */
651
+ export async function measureLatency(node, host, port, timeoutMs = 3000) {
652
+ const start = Date.now();
653
+ try {
654
+ const face = await Promise.race([
655
+ connectThrough(node, host, port),
656
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs))
657
+ ]);
658
+ const delay = Date.now() - start;
659
+ face.close();
660
+ return delay;
661
+ } catch {
662
+ return null;
663
+ }
664
+ }