@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,298 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Vendored from dsh-clash-proxy 0.2.0 (MIT).
|
|
3
|
+
// Source: H:\mycode\example\dsh-clash\lib\core\vmess.js
|
|
4
|
+
// UNMODIFIED except where a line is marked [dsh-downloader].
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* VMess AEAD client (alterId = 0, security AES-128-GCM, Option = ChunkStream).
|
|
10
|
+
*
|
|
11
|
+
* Faithful port of v2ray-core's v5 `proxy/vmess/aead` and `encoding/client`:
|
|
12
|
+
* - authID = AES-block(KDF16(cmdKey, "AES Auth ID Encryption")) over
|
|
13
|
+
* [time(8)][rand(4)][crc32(12) BE(4)]
|
|
14
|
+
* - header = authID(16) || AEAD-length(18) || connectionNonce(8) || AEAD-payload
|
|
15
|
+
* - body = chunks: [plain BE16 size][AES-128-GCM payload], nonce =
|
|
16
|
+
* [count(2)][requestBodyIV[2:12]], keys = the random 16-byte request keys
|
|
17
|
+
* - response = AEAD header under SHA256-derived keys, then same chunking
|
|
18
|
+
*
|
|
19
|
+
* Only the ChunkStream option bit is sent: no shake masking, no padding —
|
|
20
|
+
* servers accept any option subset and mirror it in their responses.
|
|
21
|
+
* @module dsh-clash-proxy/vmess
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const KDF_SALT_AUTH_ID = "AES Auth ID Encryption";
|
|
25
|
+
const KDF_SALT_HEADER_LEN_KEY = "VMess Header AEAD Key_Length";
|
|
26
|
+
const KDF_SALT_HEADER_LEN_IV = "VMess Header AEAD Nonce_Length";
|
|
27
|
+
const KDF_SALT_HEADER_KEY = "VMess Header AEAD Key";
|
|
28
|
+
const KDF_SALT_HEADER_IV = "VMess Header AEAD Nonce";
|
|
29
|
+
const KDF_SALT_RESP_LEN_KEY = "AEAD Resp Header Len Key";
|
|
30
|
+
const KDF_SALT_RESP_LEN_IV = "AEAD Resp Header Len IV";
|
|
31
|
+
const KDF_SALT_RESP_KEY = "AEAD Resp Header Key";
|
|
32
|
+
const KDF_SALT_RESP_IV = "AEAD Resp Header IV";
|
|
33
|
+
const KDF_BASE = "VMess AEAD KDF";
|
|
34
|
+
|
|
35
|
+
/** FNV-1a 32-bit hash of a buffer. */
|
|
36
|
+
export function fnv1a32(buffer) {
|
|
37
|
+
let hash = 0x811c9dc5;
|
|
38
|
+
for (const byte of buffer) {
|
|
39
|
+
hash ^= byte;
|
|
40
|
+
hash = Math.imul(hash, 0x01000193);
|
|
41
|
+
}
|
|
42
|
+
return hash >>> 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SHA-256 of one buffer. */
|
|
46
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest();
|
|
47
|
+
|
|
48
|
+
/** Go hmac key padding: zero-pad to 64, then XOR the pad byte. */
|
|
49
|
+
function pad64(keyBytes, padByte) {
|
|
50
|
+
const buf = Buffer.alloc(64, padByte);
|
|
51
|
+
for (let i = 0; i < Math.min(keyBytes.length, 64); i++) buf[i] ^= keyBytes[i];
|
|
52
|
+
return buf;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Xray's VMess AEAD KDF (proxy/vmess/aead/kdf.go), implemented as the exact
|
|
57
|
+
* state machine: a chain of nested HMACs whose ipad blocks all accumulate
|
|
58
|
+
* into one inner SHA-256 state, and whose Sum recursion rewrites that state
|
|
59
|
+
* level by level. Verified against a Go reference (see test/kdf-compare).
|
|
60
|
+
*
|
|
61
|
+
* level 0: HMAC-SHA256(key="VMess AEAD KDF")
|
|
62
|
+
* level i: HMAC(key=paths[i-1], hash=level i-1)
|
|
63
|
+
*/
|
|
64
|
+
export function vmessKdf(key, ...paths) {
|
|
65
|
+
const pads = [{ ipad: pad64(Buffer.from(KDF_BASE), 0x36), opad: pad64(Buffer.from(KDF_BASE), 0x5c) }];
|
|
66
|
+
for (const path of paths) {
|
|
67
|
+
// Paths may be latin1 strings (Buffer.toString("binary")) or raw Buffers;
|
|
68
|
+
// latin1 decoding preserves every byte exactly (UTF-8 would mangle >0x7F).
|
|
69
|
+
const pathBytes = Buffer.isBuffer(path) ? path : Buffer.from(String(path), "latin1");
|
|
70
|
+
pads.push({ ipad: pad64(pathBytes, 0x36), opad: pad64(pathBytes, 0x5c) });
|
|
71
|
+
}
|
|
72
|
+
const levelCount = paths.length;
|
|
73
|
+
|
|
74
|
+
// inner0 accumulates every ipad at construction, then the message key.
|
|
75
|
+
let inner = Buffer.concat([...pads.map((entry) => entry.ipad), key]);
|
|
76
|
+
|
|
77
|
+
const sumLevel = (level) => {
|
|
78
|
+
if (level === 0) {
|
|
79
|
+
const digest = sha256(inner);
|
|
80
|
+
return sha256(Buffer.concat([pads[0].opad, digest]));
|
|
81
|
+
}
|
|
82
|
+
const innerDigest = sumLevel(level - 1);
|
|
83
|
+
inner = Buffer.concat([...pads.slice(0, level).map((entry) => entry.ipad), pads[level].opad, innerDigest]);
|
|
84
|
+
return sumLevel(level - 1);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return sumLevel(levelCount);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** KDF16 = first 16 bytes of the KDF output. */
|
|
91
|
+
const kdf16 = (key, ...paths) => vmessKdf(key, ...paths).subarray(0, 16);
|
|
92
|
+
|
|
93
|
+
/** Single-block AES encrypt (authID step). */
|
|
94
|
+
function aesBlockEncrypt(key, block) {
|
|
95
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
96
|
+
cipher.setAutoPadding(false);
|
|
97
|
+
return Buffer.concat([cipher.update(block), cipher.final()]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** AES-128-GCM seal. */
|
|
101
|
+
function gcmSeal(key, nonce, plaintext, aad) {
|
|
102
|
+
const cipher = createCipheriv("aes-128-gcm", key, nonce);
|
|
103
|
+
if (aad !== undefined && aad.length > 0) cipher.setAAD(aad);
|
|
104
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** AES-128-GCM open. @throws on auth failure. */
|
|
108
|
+
function gcmOpen(key, nonce, sealed, aad) {
|
|
109
|
+
const decipher = createDecipheriv("aes-128-gcm", key, nonce);
|
|
110
|
+
if (aad !== undefined && aad.length > 0) decipher.setAAD(aad);
|
|
111
|
+
decipher.setAuthTag(sealed.subarray(sealed.length - 16));
|
|
112
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** CRC-32 (IEEE) — used by the authID. */
|
|
116
|
+
function crc32(buffer) {
|
|
117
|
+
let crc = 0xffffffff;
|
|
118
|
+
for (const byte of buffer) {
|
|
119
|
+
crc ^= byte;
|
|
120
|
+
for (let i = 0; i < 8; i++) {
|
|
121
|
+
crc = (crc >>> 1) ^ ((crc & 1) !== 0 ? 0xedb88320 : 0);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Build the 16-byte authID for the current time. */
|
|
128
|
+
function createAuthId(cmdKey, now) {
|
|
129
|
+
const time = Buffer.alloc(8);
|
|
130
|
+
time.writeBigUInt64BE(BigInt(now));
|
|
131
|
+
const rand = randomBytes(4);
|
|
132
|
+
const head = Buffer.concat([time, rand]);
|
|
133
|
+
const checksum = Buffer.alloc(4);
|
|
134
|
+
checksum.writeUInt32BE(crc32(head));
|
|
135
|
+
return aesBlockEncrypt(kdf16(cmdKey, KDF_SALT_AUTH_ID), Buffer.concat([head, checksum]));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** v2ray vmess address encoding: [port BE16][atyp][addr]; atyp 0x01=IPv4, 0x02=domain, 0x03=IPv6. */
|
|
139
|
+
export function vmessEncodeAddr(host, port) {
|
|
140
|
+
const portBytes = Buffer.from([port >> 8, port & 0xff]);
|
|
141
|
+
const ip4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255);
|
|
142
|
+
if (ip4) return Buffer.concat([portBytes, Buffer.from([0x01]), Buffer.from(host.split(".").map(Number))]);
|
|
143
|
+
if (host.includes(":")) {
|
|
144
|
+
const groups = host.split(":").map((part) => parseInt(part, 16));
|
|
145
|
+
const addr = Buffer.alloc(16);
|
|
146
|
+
groups.forEach((value, index) => addr.writeUInt16BE(value, index * 2));
|
|
147
|
+
return Buffer.concat([portBytes, Buffer.from([0x03]), addr]);
|
|
148
|
+
}
|
|
149
|
+
const hostBytes = Buffer.from(host);
|
|
150
|
+
return Buffer.concat([portBytes, Buffer.from([0x02, hostBytes.length]), hostBytes]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Seal the VMess AEAD request header (authID + encrypted command).
|
|
155
|
+
* @param cmdKey - MD5(uuid), 16 bytes.
|
|
156
|
+
* @param command - the plaintext command buffer.
|
|
157
|
+
*/
|
|
158
|
+
export function sealVmessAeadHeader(cmdKey, command) {
|
|
159
|
+
const authId = createAuthId(cmdKey, Math.floor(Date.now() / 1000));
|
|
160
|
+
const connectionNonce = randomBytes(8);
|
|
161
|
+
|
|
162
|
+
const lengthPlain = Buffer.alloc(2);
|
|
163
|
+
lengthPlain.writeUInt16BE(command.length);
|
|
164
|
+
const lengthSealed = gcmSeal(
|
|
165
|
+
kdf16(cmdKey, KDF_SALT_HEADER_LEN_KEY, authId.toString("binary"), connectionNonce.toString("binary")),
|
|
166
|
+
vmessKdf(cmdKey, KDF_SALT_HEADER_LEN_IV, authId.toString("binary"), connectionNonce.toString("binary")).subarray(0, 12),
|
|
167
|
+
lengthPlain,
|
|
168
|
+
authId
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const payloadSealed = gcmSeal(
|
|
172
|
+
kdf16(cmdKey, KDF_SALT_HEADER_KEY, authId.toString("binary"), connectionNonce.toString("binary")),
|
|
173
|
+
vmessKdf(cmdKey, KDF_SALT_HEADER_IV, authId.toString("binary"), connectionNonce.toString("binary")).subarray(0, 12),
|
|
174
|
+
command,
|
|
175
|
+
authId
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return Buffer.concat([authId, lengthSealed, connectionNonce, payloadSealed]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the plaintext AEAD command.
|
|
183
|
+
* @param reqKey/reqIV - 16-byte random request body key/IV.
|
|
184
|
+
* @param responseHeader - the V byte to echo-check (random).
|
|
185
|
+
*/
|
|
186
|
+
export function buildVmessCommand({ reqKey, reqIV, responseHeader, host, port }) {
|
|
187
|
+
const paddingLength = Math.floor(Math.random() * 16);
|
|
188
|
+
const security = (paddingLength << 4) | 0x03; // AES-128-GCM
|
|
189
|
+
const body = Buffer.concat([
|
|
190
|
+
Buffer.from([0x01]), // version
|
|
191
|
+
reqIV,
|
|
192
|
+
reqKey,
|
|
193
|
+
Buffer.from([responseHeader]),
|
|
194
|
+
Buffer.from([0x01]), // Option: ChunkStream only
|
|
195
|
+
Buffer.from([security, 0x00, 0x01]), // security | reserved | cmd TCP
|
|
196
|
+
vmessEncodeAddr(host, port),
|
|
197
|
+
randomBytes(paddingLength)
|
|
198
|
+
]);
|
|
199
|
+
const hash = Buffer.alloc(4);
|
|
200
|
+
hash.writeUInt32BE(fnv1a32(body));
|
|
201
|
+
return Buffer.concat([body, hash]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Chunk nonce: [count BE16][iv[2:12]]. */
|
|
205
|
+
function chunkNonce(iv, count) {
|
|
206
|
+
const nonce = Buffer.alloc(12);
|
|
207
|
+
nonce.writeUInt16BE(count);
|
|
208
|
+
iv.copy(nonce, 2, 2, 12);
|
|
209
|
+
return nonce;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One AEAD body chunk writer over a raw byte sink. */
|
|
213
|
+
export class VmessChunkWriter {
|
|
214
|
+
#sink;
|
|
215
|
+
#key;
|
|
216
|
+
#iv;
|
|
217
|
+
#count = 0;
|
|
218
|
+
|
|
219
|
+
/** @param sink - (buffer) => void sink (e.g. ws frame writer). */
|
|
220
|
+
constructor(sink, key, iv) {
|
|
221
|
+
this.#sink = sink;
|
|
222
|
+
this.#key = key;
|
|
223
|
+
this.#iv = iv;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
write(plaintext) {
|
|
227
|
+
if (plaintext.length === 0) return;
|
|
228
|
+
// The 2-byte chunk length field caps each chunk at 65535 bytes (sealed
|
|
229
|
+
// = plaintext + 16), so split large writes like xray's 8KB slices do.
|
|
230
|
+
for (let offset = 0; offset < plaintext.length; offset += 0x3fff) {
|
|
231
|
+
const part = plaintext.subarray(offset, Math.min(plaintext.length, offset + 0x3fff));
|
|
232
|
+
const sealed = gcmSeal(this.#key, chunkNonce(this.#iv, this.#count++), part);
|
|
233
|
+
const size = Buffer.alloc(2);
|
|
234
|
+
size.writeUInt16BE(sealed.length);
|
|
235
|
+
this.#sink(Buffer.concat([size, sealed]));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Signal end of stream with a zero-length chunk. */
|
|
240
|
+
end() {
|
|
241
|
+
const size = Buffer.alloc(2); // 0x0000
|
|
242
|
+
this.#sink(size);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** AEAD body chunk reader over an async byte source (SocketBuffer-like). */
|
|
247
|
+
export class VmessChunkReader {
|
|
248
|
+
#source; // { read(n): Promise<Buffer> }
|
|
249
|
+
#key;
|
|
250
|
+
#iv;
|
|
251
|
+
#count = 0;
|
|
252
|
+
|
|
253
|
+
constructor(source, key, iv) {
|
|
254
|
+
this.#source = source;
|
|
255
|
+
this.#key = key;
|
|
256
|
+
this.#iv = iv;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Read one chunk payload; returns null at the terminal (size 0) chunk. */
|
|
260
|
+
async readChunk() {
|
|
261
|
+
const sizeHeader = await this.#source.read(2);
|
|
262
|
+
const size = sizeHeader.readUInt16BE(0);
|
|
263
|
+
if (size === 0) return null;
|
|
264
|
+
const sealed = await this.#source.read(size);
|
|
265
|
+
return gcmOpen(this.#key, chunkNonce(this.#iv, this.#count++), sealed);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decode the VMess AEAD response header from the byte source.
|
|
271
|
+
* @returns the plaintext response header (4+ bytes: V, Opt, Cmd, Len, ...).
|
|
272
|
+
*/
|
|
273
|
+
export async function decodeVmessResponse(source, reqKey, reqIV) {
|
|
274
|
+
const respKey = createHash("sha256").update(reqKey).digest().subarray(0, 16);
|
|
275
|
+
const respIV = createHash("sha256").update(reqIV).digest().subarray(0, 16);
|
|
276
|
+
|
|
277
|
+
const lengthSealed = await source.read(18);
|
|
278
|
+
const lengthPlain = gcmOpen(
|
|
279
|
+
kdf16(respKey, KDF_SALT_RESP_LEN_KEY),
|
|
280
|
+
vmessKdf(respIV, KDF_SALT_RESP_LEN_IV).subarray(0, 12),
|
|
281
|
+
lengthSealed
|
|
282
|
+
);
|
|
283
|
+
const payloadLength = lengthPlain.readUInt16BE(0);
|
|
284
|
+
|
|
285
|
+
const payloadSealed = await source.read(payloadLength + 16);
|
|
286
|
+
const payload = gcmOpen(
|
|
287
|
+
kdf16(respKey, KDF_SALT_RESP_KEY),
|
|
288
|
+
vmessKdf(respIV, KDF_SALT_RESP_IV).subarray(0, 12),
|
|
289
|
+
payloadSealed
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Xray EncodeResponseHeader writes the plaintext header as
|
|
293
|
+
// [V(1)][Option(1)][0x00][0x00]
|
|
294
|
+
// MarshalCommand has no registered factory for ResponseCommand, so it
|
|
295
|
+
// errors and the two zero bytes are written instead (see xray
|
|
296
|
+
// proxy/vmess/encoding/commands.go). There is no trailing content to read.
|
|
297
|
+
return { responseHeader: payload, bodyKey: respKey, bodyIV: respIV };
|
|
298
|
+
}
|
package/lib/core/ws.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Vendored from dsh-clash-proxy 0.2.0 (MIT).
|
|
3
|
+
// Source: H:\mycode\example\dsh-clash\lib\core\ws.js
|
|
4
|
+
// UNMODIFIED except where a line is marked [dsh-downloader].
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal WebSocket client: HTTP upgrade handshake + binary frame codec
|
|
10
|
+
* (client frames masked per RFC 6455, server frames expected unmasked).
|
|
11
|
+
* Used as the transport layer for vmess-over-ws nodes.
|
|
12
|
+
* @module dsh-clash-proxy/ws
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Perform the WebSocket upgrade over an existing TCP socket, reading the
|
|
17
|
+
* response THROUGH the shared {@link SocketBuffer} so any bytes pipelined
|
|
18
|
+
* after the handshake stay buffered for the frame parser.
|
|
19
|
+
* @param buffer - the socket's shared byte buffer.
|
|
20
|
+
* @param path - request path (default "/").
|
|
21
|
+
* @param host - Host header value (usually the node server or ws-opts Host).
|
|
22
|
+
* @param extraHeaders - additional raw header lines.
|
|
23
|
+
*/
|
|
24
|
+
export async function wsHandshake(buffer, path = "/", host, extraHeaders = []) {
|
|
25
|
+
const socket = buffer.socket;
|
|
26
|
+
const key = randomBytes(16).toString("base64");
|
|
27
|
+
const request = [
|
|
28
|
+
`GET ${path} HTTP/1.1`,
|
|
29
|
+
`Host: ${host}`,
|
|
30
|
+
"Upgrade: websocket",
|
|
31
|
+
"Connection: Upgrade",
|
|
32
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
33
|
+
"Sec-WebSocket-Version: 13",
|
|
34
|
+
...extraHeaders,
|
|
35
|
+
"",
|
|
36
|
+
""
|
|
37
|
+
].join("\r\n");
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
socket.once("error", reject);
|
|
40
|
+
socket.write(request, () => resolve());
|
|
41
|
+
});
|
|
42
|
+
let head = "";
|
|
43
|
+
for (;;) {
|
|
44
|
+
const byte = await buffer.read(1);
|
|
45
|
+
head += byte.toString("latin1");
|
|
46
|
+
if (head.endsWith("\r\n\r\n")) {
|
|
47
|
+
if (!/^HTTP\/1\.[01] 101\b/.test(head)) {
|
|
48
|
+
throw new Error(`ws: upgrade rejected: ${head.split("\r\n")[0]}`);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (head.length > 65536) throw new Error("ws: handshake header too large");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mask a payload with the given 4-byte key. */
|
|
57
|
+
function maskPayload(payload, maskKey) {
|
|
58
|
+
const masked = Buffer.alloc(payload.length);
|
|
59
|
+
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ maskKey[i & 3];
|
|
60
|
+
return masked;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Frame codec over a socket + its SocketBuffer.
|
|
65
|
+
* write() emits one masked binary frame per call; read() is an async
|
|
66
|
+
* iterable of payload bytes across frames (ping answered, close ends).
|
|
67
|
+
*/
|
|
68
|
+
export class WsStream {
|
|
69
|
+
#socket;
|
|
70
|
+
#buffer;
|
|
71
|
+
|
|
72
|
+
/** @param socket - raw socket. @param buffer - the shared SocketBuffer. */
|
|
73
|
+
constructor(socket, buffer) {
|
|
74
|
+
this.#socket = socket;
|
|
75
|
+
this.#buffer = buffer;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Send a masked binary frame (FIN=1). */
|
|
79
|
+
write(payload) {
|
|
80
|
+
const maskKey = randomBytes(4);
|
|
81
|
+
let header;
|
|
82
|
+
if (payload.length < 126) {
|
|
83
|
+
header = Buffer.from([0x82, 0x80 | payload.length]);
|
|
84
|
+
} else if (payload.length < 65536) {
|
|
85
|
+
header = Buffer.alloc(4);
|
|
86
|
+
header[0] = 0x82;
|
|
87
|
+
header[1] = 0x80 | 126;
|
|
88
|
+
header.writeUInt16BE(payload.length, 2);
|
|
89
|
+
} else {
|
|
90
|
+
header = Buffer.alloc(10);
|
|
91
|
+
header[0] = 0x82;
|
|
92
|
+
header[1] = 0x80 | 127;
|
|
93
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
94
|
+
}
|
|
95
|
+
this.#socket.write(Buffer.concat([header, maskKey, maskPayload(payload, maskKey)]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async iterable of received payload bytes. */
|
|
99
|
+
read() {
|
|
100
|
+
const socket = this.#socket;
|
|
101
|
+
const buffer = this.#buffer;
|
|
102
|
+
return (async function* () {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const b0 = (await buffer.read(1))[0];
|
|
105
|
+
const b1 = (await buffer.read(1))[0];
|
|
106
|
+
const opcode = b0 & 0x0f;
|
|
107
|
+
const masked = (b1 & 0x80) !== 0;
|
|
108
|
+
let length = b1 & 0x7f;
|
|
109
|
+
if (length === 126) length = (await buffer.read(2)).readUInt16BE(0);
|
|
110
|
+
else if (length === 127) length = Number((await buffer.read(8)).readBigUInt64BE(0));
|
|
111
|
+
const maskKey = masked ? await buffer.read(4) : null;
|
|
112
|
+
let payload = await buffer.read(length);
|
|
113
|
+
if (maskKey !== null) payload = maskPayload(payload, maskKey);
|
|
114
|
+
|
|
115
|
+
if (opcode === 0x8) return; // close
|
|
116
|
+
if (opcode === 0x9) {
|
|
117
|
+
// ping → pong (masked, echo payload)
|
|
118
|
+
const pongMask = randomBytes(4);
|
|
119
|
+
const pongHeader = Buffer.from([0x8a, 0x80 | payload.length]);
|
|
120
|
+
socket.write(Buffer.concat([pongHeader, pongMask, maskPayload(payload, pongMask)]));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (opcode === 0xa) continue; // pong
|
|
124
|
+
if (payload.length > 0) yield payload;
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 凭据解析:把「引用」变成请求头。
|
|
3
|
+
*
|
|
4
|
+
* 存在的理由:需要鉴权的下载(GitHub Actions 产物、私有 Release、内部制品库)必须在请求里带
|
|
5
|
+
* `Authorization`,但**让模型把 token 当工具参数传进来,token 就会永久留在会话记录里**。
|
|
6
|
+
* 所以这里只接受「引用」,由插件自己去取:
|
|
7
|
+
*
|
|
8
|
+
* - `gh` —— 跑 `gh auth token`,从 GitHub CLI 自己的凭据库取
|
|
9
|
+
* - `env:NAME` —— 读环境变量
|
|
10
|
+
* - `bearer:xxx` —— 直接给字面量(方便,但 token 会进会话记录,仅限非敏感场景)
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-downloader/auth
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
/** 跨域跳转时必须丢弃的请求头:它们只该发给最初那台主机。 */
|
|
17
|
+
export const SENSITIVE_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
18
|
+
|
|
19
|
+
/** 跑一条命令并取回 stdout。 */
|
|
20
|
+
function run(command, args, timeoutMs = 10000) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
execFile(command, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
|
|
23
|
+
if (error !== null && error !== undefined) {
|
|
24
|
+
const detail = String(stderr ?? "").trim().slice(0, 200);
|
|
25
|
+
reject(new Error(detail.length > 0 ? detail : error.message));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve(String(stdout ?? ""));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 解析一个凭据引用。
|
|
35
|
+
* @param auth - `gh` / `env:NAME` / `bearer:token`,空值表示不需要鉴权。
|
|
36
|
+
* @returns `{ headers, source, warning }`;`source` 是给人看的中文说明。
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveAuth(auth, options = {}) {
|
|
39
|
+
const raw = typeof auth === "string" ? auth.trim() : "";
|
|
40
|
+
if (raw.length === 0) return { headers: {}, source: null, warning: null };
|
|
41
|
+
|
|
42
|
+
if (raw === "gh") {
|
|
43
|
+
const hostname = typeof options.ghHostname === "string" && options.ghHostname.length > 0 ? options.ghHostname : "github.com";
|
|
44
|
+
let token = "";
|
|
45
|
+
try {
|
|
46
|
+
token = (await run("gh", ["auth", "token", "--hostname", hostname])).trim();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`取 GitHub 凭据失败(auth="gh"):${error instanceof Error ? error.message : String(error)};` +
|
|
50
|
+
"确认 `gh auth status` 已登录,或改用 auth=\"env:变量名\"。"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (token.length === 0) throw new Error(`gh 没有返回 ${hostname} 的 token;先跑 gh auth login`);
|
|
54
|
+
return { headers: { Authorization: `Bearer ${token}` }, source: `gh CLI(${hostname})`, warning: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (raw.startsWith("env:")) {
|
|
58
|
+
const name = raw.slice(4).trim();
|
|
59
|
+
if (name.length === 0) throw new Error('auth="env:" 后面要写变量名,例如 auth="env:GH_TOKEN"');
|
|
60
|
+
const value = process.env[name];
|
|
61
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`环境变量 ${name} 没有值`);
|
|
62
|
+
return { headers: { Authorization: `Bearer ${value.trim()}` }, source: `环境变量 ${name}`, warning: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (raw.startsWith("bearer:")) {
|
|
66
|
+
const token = raw.slice(7).trim();
|
|
67
|
+
if (token.length === 0) throw new Error('auth="bearer:" 后面要写 token');
|
|
68
|
+
return {
|
|
69
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
70
|
+
source: "字面 token",
|
|
71
|
+
warning: "直接把 token 写在参数里会让它留在会话记录中;敏感场景请改用 auth=\"gh\" 或 auth=\"env:变量名\""
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error(`无法识别的 auth:${raw}(支持 gh / env:变量名 / bearer:token)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 这条请求头里有没有不该跟着重定向走的凭据。 */
|
|
79
|
+
export function hasSensitiveHeaders(headers) {
|
|
80
|
+
if (headers === null || headers === undefined) return false;
|
|
81
|
+
const keys = Object.keys(headers).map((key) => key.toLowerCase());
|
|
82
|
+
return keys.some((key) => SENSITIVE_HEADERS.includes(key));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 复制一份并去掉凭据头。 */
|
|
86
|
+
export function stripSensitiveHeaders(headers) {
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
89
|
+
if (SENSITIVE_HEADERS.includes(key.toLowerCase())) continue;
|
|
90
|
+
out[key] = value;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 清理调用方给的请求头:丢掉会破坏请求的头,避免把下载搞坏。
|
|
97
|
+
* @returns 可安全合并进请求的头。
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizeHeaders(headers) {
|
|
100
|
+
if (headers === null || headers === undefined || typeof headers !== "object") return {};
|
|
101
|
+
const blocked = ["host", "content-length", "connection", "transfer-encoding", "proxy-authorization", "proxy-connection", "upgrade"];
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
104
|
+
if (typeof key !== "string" || key.trim().length === 0) continue;
|
|
105
|
+
if (typeof value !== "string" && typeof value !== "number") continue;
|
|
106
|
+
if (blocked.includes(key.trim().toLowerCase())) continue;
|
|
107
|
+
out[key.trim()] = String(value);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|