@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,341 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Vendored from dsh-clash-proxy 0.2.0 (MIT).
3
+ // Source: H:\mycode\example\dsh-clash\lib\core\proxy-server.js
4
+ // UNMODIFIED except where a line is marked [dsh-downloader].
5
+ // ---------------------------------------------------------------------------
6
+ import net from "node:net";
7
+ import { connectThrough } from "./transports.js";
8
+
9
+ /**
10
+ * The loopback rule-splitting proxy server.
11
+ *
12
+ * One port speaks both:
13
+ * - HTTP proxy: CONNECT for tunnels, absolute-form GET/POST for plain http
14
+ * - SOCKS5: greeting/negotiation then TCP connect
15
+ *
16
+ * Every target is decided by the {@link RuleEngine}: `direct` connects
17
+ * straight to the destination, `proxy` goes through the current upstream
18
+ * node, `reject` drops the connection. Byte counters feed the GUI traffic
19
+ * panel. No system proxy, no TUN, loopback only.
20
+ * @module dsh-clash-proxy/proxy-server
21
+ */
22
+
23
+ /** Pipe an async iterable of chunks into a writable socket. */
24
+ async function pumpInto(iterable, socket, counter) {
25
+ try {
26
+ for await (const chunk of iterable) {
27
+ if (socket.destroyed) return;
28
+ counter?.(chunk.length);
29
+ if (!socket.write(chunk)) await new Promise((resolve) => socket.once("drain", resolve));
30
+ }
31
+ if (!socket.destroyed) socket.end();
32
+ } catch (error) {
33
+ // [dsh-downloader] surface the relay failure instead of failing silently.
34
+ if (process.env.DSH_PROXY_DEBUG === "1") console.error("[proxy] relay failed:", error instanceof Error ? error.message : String(error));
35
+ socket.destroy();
36
+ }
37
+ }
38
+
39
+ /** Pipe one socket into another (plain duplex). */
40
+ function pipeSockets(source, target, counter) {
41
+ source.on("data", (chunk) => {
42
+ counter?.(chunk.length);
43
+ if (!target.write(chunk)) source.pause();
44
+ });
45
+ target.on("drain", () => source.resume());
46
+ source.on("end", () => target.end());
47
+ source.on("close", () => target.destroy());
48
+ source.on("error", () => target.destroy());
49
+ }
50
+
51
+ export class ProxyServer {
52
+ #engine;
53
+ #resolveNode;
54
+ #token = null;
55
+ #server = null;
56
+ #port = 0;
57
+ /** @type {{up: number, down: number}} */
58
+ counters = { up: 0, down: 0 };
59
+
60
+ /**
61
+ * @param engine - rule engine deciding direct/proxy/reject.
62
+ * @param resolveNode - thunk returning the current upstream node
63
+ * (null → direct-only fallback when no node is selected).
64
+ * @param token - [dsh-downloader] per-process secret. When set, only callers
65
+ * that present it are served; every other connection is refused, so nothing
66
+ * outside the owning plugin can borrow this listener as a proxy.
67
+ */
68
+ constructor({ engine, resolveNode, token = null }) {
69
+ this.#engine = engine;
70
+ this.#resolveNode = resolveNode;
71
+ this.#token = typeof token === "string" && token.length > 0 ? token : null;
72
+ }
73
+
74
+ /** Whether this listener is token-gated. */
75
+ get gated() {
76
+ return this.#token !== null;
77
+ }
78
+
79
+ /** Bound loopback port (0 until started). */
80
+ get port() {
81
+ return this.#port;
82
+ }
83
+
84
+ /**
85
+ * [dsh-downloader] Accept an HTTP request only from a caller holding the token.
86
+ * Both the `Bearer` form (what this plugin's own client sends) and the
87
+ * `Basic dsh:<token>` form (what `curl -x dsh:<token>@…` would send) count.
88
+ */
89
+ #authorized(headers) {
90
+ if (this.#token === null) return true;
91
+ const raw = headers["proxy-authorization"];
92
+ if (typeof raw !== "string" || raw.length === 0) return false;
93
+ if (raw === `Bearer ${this.#token}`) return true;
94
+ const expectedBasic = `Basic ${Buffer.from(`dsh:${this.#token}`).toString("base64")}`;
95
+ return raw === expectedBasic;
96
+ }
97
+
98
+ /** Start listening on 127.0.0.1. @returns the bound port. */
99
+ start(port = 0) {
100
+ return new Promise((resolve, reject) => {
101
+ this.#server = net.createServer((client) => this.#handle(client));
102
+ this.#server.once("error", reject);
103
+ // Loopback only: nothing outside this machine can reach the core at all.
104
+ this.#server.listen(port, "127.0.0.1", () => {
105
+ this.#port = this.#server.address().port;
106
+ resolve(this.#port);
107
+ });
108
+ });
109
+ }
110
+
111
+ /** Stop listening and destroy every in-flight connection. */
112
+ stop() {
113
+ const server = this.#server;
114
+ this.#server = null;
115
+ if (server !== null) server.close();
116
+ }
117
+
118
+ /** Route one inbound connection by first byte: 0x05 → SOCKS5, else HTTP. */
119
+ #handle(client) {
120
+ client.once("data", (first) => {
121
+ if (first[0] === 0x05) {
122
+ // [dsh-downloader] A SOCKS5 greeting cannot carry the token, so a
123
+ // gated listener refuses the protocol outright instead of serving
124
+ // an unauthenticated caller. This plugin's own client uses CONNECT.
125
+ if (this.#token !== null) {
126
+ client.end(Buffer.from([0x05, 0xff]));
127
+ return;
128
+ }
129
+ this.#handleSocks5(client, first).catch(() => client.destroy());
130
+ } else {
131
+ this.#handleHttp(client, first).catch(() => client.destroy());
132
+ }
133
+ });
134
+ client.on("error", () => {});
135
+ }
136
+
137
+ /** Connect to the target per rules; returns the duplex face or throws. */
138
+ async #connect(host, port) {
139
+ const action = this.#engine.decide(host);
140
+ if (action === "reject") throw new Error(`rejected by rule: ${host}`);
141
+ const node = action === "proxy" ? this.#resolveNode() : null;
142
+ if (action === "proxy" && (node === null || node === undefined)) throw new Error(`no upstream node available for ${host} (select a node or check the subscription)`);
143
+ // eslint-disable-next-line no-console
144
+ if (process.env.DSH_PROXY_DEBUG === "1") console.error(`[proxy] ${host}:${port} → ${action}${action === "proxy" ? ` via ${typeof node === "string" ? node : node?.name}(${node?.type})` : ""}`);
145
+ return connectThrough(node, host, port);
146
+ }
147
+
148
+ #relay(face, client) {
149
+ pumpInto(face.read, client, (bytes) => {
150
+ this.counters.down += bytes;
151
+ }).catch(() => client.destroy());
152
+ client.on("data", (chunk) => {
153
+ this.counters.up += chunk.length;
154
+ face.write(chunk);
155
+ });
156
+ client.on("end", () => face.close());
157
+ client.on("close", () => face.close());
158
+ client.on("error", () => face.close());
159
+ }
160
+
161
+ // ---- SOCKS5 inbound ------------------------------------------------------
162
+
163
+ async #handleSocks5(client, greeting) {
164
+ const methods = [...greeting.subarray(2, 2 + greeting[1])];
165
+ const method = methods.includes(0x00) ? 0x00 : 0xff;
166
+ client.write(Buffer.from([0x05, method]));
167
+ if (method === 0xff) return client.end();
168
+
169
+ const { host, port, rest } = await this.#readSocks5Request(client);
170
+ const action = this.#engine.decide(host);
171
+ if (action === "reject") {
172
+ client.end(Buffer.from([0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
173
+ return;
174
+ }
175
+ try {
176
+ const face = await this.#connect(host, port);
177
+ client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
178
+ if (rest.length > 0) client.unshift(rest);
179
+ this.#relay(face, client);
180
+ } catch (error) {
181
+ client.end(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
182
+ }
183
+ }
184
+
185
+ async #readSocks5Request(client) {
186
+ const header = await new Promise((resolve, reject) => {
187
+ const onData = (chunk) => {
188
+ cleanup();
189
+ resolve(chunk);
190
+ };
191
+ const onError = (error) => {
192
+ cleanup();
193
+ reject(error);
194
+ };
195
+ const cleanup = () => {
196
+ client.off("data", onData);
197
+ client.off("error", onError);
198
+ };
199
+ client.once("data", onData);
200
+ client.once("error", onError);
201
+ });
202
+ const atyp = header[3];
203
+ let host;
204
+ let port;
205
+ let rest;
206
+ if (atyp === 0x01) {
207
+ host = [...header.subarray(4, 8)].join(".");
208
+ port = header.readUInt16BE(8);
209
+ rest = header.subarray(10);
210
+ } else if (atyp === 0x04) {
211
+ const groups = [];
212
+ for (let i = 0; i < 8; i++) groups.push(header.subarray(4 + i * 2, 6 + i * 2).toString("hex"));
213
+ host = groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
214
+ port = header.readUInt16BE(20);
215
+ rest = header.subarray(22);
216
+ } else if (atyp === 0x03) {
217
+ const length = header[4];
218
+ host = header.subarray(5, 5 + length).toString("utf8");
219
+ port = header.readUInt16BE(5 + length);
220
+ rest = header.subarray(7 + length);
221
+ } else {
222
+ throw new Error("socks5: unsupported address type");
223
+ }
224
+ return { host, port, rest };
225
+ }
226
+
227
+ // ---- HTTP inbound --------------------------------------------------------
228
+
229
+ async #handleHttp(client, first) {
230
+ const head = await this.#readHttpHead(client, first);
231
+ const lines = head.split("\r\n");
232
+ const requestLine = lines[0] ?? "";
233
+ const [method, target] = requestLine.split(" ");
234
+ const headers = {};
235
+ for (const line of lines.slice(1)) {
236
+ const colon = line.indexOf(":");
237
+ if (colon === -1) continue;
238
+ headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
239
+ }
240
+ // [dsh-downloader] Refuse any caller that does not hold this process's token.
241
+ if (!this.#authorized(headers)) {
242
+ if (process.env.DSH_PROXY_DEBUG === "1") console.error("[proxy] refused an unauthenticated caller (scope: this plugin only)");
243
+ // [dsh-downloader] 挑战必须写 `Basic`:我们两种都接受
244
+ // (`Bearer <令牌>` 与 `Basic dsh:<令牌>`),但**只有 Basic 是客户端
245
+ // 普遍能应答的方案**。原先写 `Bearer`,curl/git 拿到不认识的方案就
246
+ // 直接放弃重试 —— git 连预发都不会做,于是必然吃 407。
247
+ client.end(
248
+ "HTTP/1.1 407 Proxy Authentication Required\r\n" +
249
+ 'Proxy-Authenticate: Basic realm="dsh-downloader"\r\n' +
250
+ "Content-Length: 0\r\n" +
251
+ "Connection: close\r\n\r\n"
252
+ );
253
+ return;
254
+ }
255
+ if (method === "CONNECT") {
256
+ await this.#handleConnect(client, target, headers);
257
+ return;
258
+ }
259
+ if (target !== undefined && (target.startsWith("http://") || target.startsWith("https://"))) {
260
+ await this.#handleAbsolute(client, method, target, requestLine, lines);
261
+ return;
262
+ }
263
+ client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
264
+ }
265
+
266
+ async #readHttpHead(client, first) {
267
+ let buffer = first;
268
+ for (;;) {
269
+ const end = buffer.indexOf("\r\n\r\n");
270
+ if (end !== -1) {
271
+ const head = buffer.subarray(0, end).toString("latin1");
272
+ if (buffer.length > end + 4) client.unshift(buffer.subarray(end + 4));
273
+ return head;
274
+ }
275
+ const chunk = await new Promise((resolve, reject) => {
276
+ const onData = (data) => {
277
+ cleanup();
278
+ resolve(data);
279
+ };
280
+ const onError = (error) => {
281
+ cleanup();
282
+ reject(error);
283
+ };
284
+ const cleanup = () => {
285
+ client.off("data", onData);
286
+ client.off("error", onError);
287
+ };
288
+ client.once("data", onData);
289
+ client.once("error", onError);
290
+ });
291
+ buffer = Buffer.concat([buffer, chunk]);
292
+ }
293
+ }
294
+
295
+ async #handleConnect(client, target, headers) {
296
+ const colon = target.lastIndexOf(":");
297
+ if (colon <= 0) {
298
+ client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
299
+ return;
300
+ }
301
+ const host = target.slice(0, colon);
302
+ const port = Number(target.slice(colon + 1));
303
+ try {
304
+ const face = await this.#connect(host, port);
305
+ // eslint-disable-next-line no-console
306
+ if (process.env.DSH_PROXY_DEBUG === "1") console.error(`[proxy] tunnel established for ${host}:${port}`);
307
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
308
+ this.#relay(face, client);
309
+ } catch (error) {
310
+ // eslint-disable-next-line no-console
311
+ if (process.env.DSH_PROXY_DEBUG === "1") console.error(`[proxy] connect failed for ${host}:${port}:`, error.message);
312
+ client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
313
+ }
314
+ }
315
+
316
+ async #handleAbsolute(client, method, target, requestLine, lines) {
317
+ let url;
318
+ try {
319
+ url = new URL(target);
320
+ } catch {
321
+ client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
322
+ return;
323
+ }
324
+ const host = url.hostname;
325
+ const port = url.port.length > 0 ? Number(url.port) : 80;
326
+ try {
327
+ const face = await this.#connect(host, port);
328
+ // Rebuild the request with an origin-form target and forced close.
329
+ const path = `${url.pathname}${url.search}`;
330
+ const rewritten = lines.slice(1).filter((line) => !/^(proxy-connection|connection):/i.test(line.trim()));
331
+ const outbound = `${method} ${path} HTTP/1.1\r\n${rewritten.join("\r\n")}\r\nConnection: close\r\n\r\n`;
332
+ this.counters.up += Buffer.byteLength(outbound);
333
+ face.write(Buffer.from(outbound));
334
+ pumpInto(face.read, client, (bytes) => {
335
+ this.counters.down += bytes;
336
+ }).catch(() => client.destroy());
337
+ } catch (error) {
338
+ client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,242 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Vendored from dsh-clash-proxy 0.2.0 (MIT).
3
+ // Source: H:\mycode\example\dsh-clash\lib\core\rules.js
4
+ // UNMODIFIED except where a line is marked [dsh-downloader].
5
+ // ---------------------------------------------------------------------------
6
+ import { readFileSync } from "node:fs";
7
+ import { gunzipSync } from "node:zlib";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ /**
12
+ * Rule engine: decides `direct` | `proxy` | `reject` per target host.
13
+ *
14
+ * Matching order for a hostname:
15
+ * 1. user extraRules, in order (DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, IP-CIDR)
16
+ * 2. subscription passthrough rules (same types, DIRECT/REJECT only)
17
+ * 3. the embedded CN domain suffix table → direct
18
+ * 4. default → proxy
19
+ *
20
+ * IP literals skip domain rules and use the embedded CN CIDR tables plus any
21
+ * custom IP-CIDR rules, defaulting to proxy.
22
+ * @module dsh-clash-proxy/rules
23
+ */
24
+
25
+ const dataDir = join(dirname(fileURLToPath(import.meta.url)), "data");
26
+
27
+ function loadJson(name) {
28
+ return JSON.parse(gunzipSync(readFileSync(join(dataDir, name))).toString("utf8"));
29
+ }
30
+
31
+ /** Embedded domestic domain suffixes (DOMAIN-SUFFIX semantics). */
32
+ const CN_SUFFIXES = new Set(loadJson("cn-domains.json.gz"));
33
+ /** Embedded domestic IPv4 CIDRs [[baseInt, prefixLen], ...] sorted by base. */
34
+ const CN_CIDRS4 = loadJson("cn-cidrs.json.gz");
35
+ /** Embedded domestic IPv6 CIDRs [network(6×u16), prefixLen] parsed at load. */
36
+ const CN_CIDRS6 = loadJson("cn-cidrs6.json.gz").map((cidr) => {
37
+ const slash = cidr.indexOf("/");
38
+ const network = cidr.slice(0, slash);
39
+ const prefix = Number(cidr.slice(slash + 1));
40
+ const parts = network.split(":");
41
+ const groups = new Array(8).fill(0);
42
+ if (parts.includes("")) {
43
+ // "::" compression: place the right-hand groups at the tail.
44
+ const empty = parts.indexOf("");
45
+ const left = parts.slice(0, empty);
46
+ const right = parts.slice(empty + 1);
47
+ left.forEach((part, index) => {
48
+ if (part.length > 0) groups[index] = parseInt(part, 16);
49
+ });
50
+ right.forEach((part, index) => {
51
+ if (part.length > 0) groups[8 - right.length + index] = parseInt(part, 16);
52
+ });
53
+ } else {
54
+ parts.forEach((part, index) => {
55
+ groups[index] = parseInt(part, 16);
56
+ });
57
+ }
58
+ return { groups, prefix };
59
+ }).sort((a, b) => {
60
+ for (let i = 0; i < 8; i++) if (a.groups[i] !== b.groups[i]) return a.groups[i] - b.groups[i];
61
+ return a.prefix - b.prefix;
62
+ });
63
+
64
+ /** IPv4 dotted string → uint32. */
65
+ export function ipv4ToInt(ip) {
66
+ const parts = ip.split(".").map(Number);
67
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
68
+ return ((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3];
69
+ }
70
+
71
+ /** IPv6 string → 8×uint16 groups (uncompressed). */
72
+ export function ipv6ToGroups(ip) {
73
+ if (ip.includes("::")) {
74
+ const [left, right] = ip.split("::");
75
+ const leftGroups = left.length > 0 ? left.split(":").map((part) => parseInt(part, 16)) : [];
76
+ const rightGroups = right.length > 0 ? right.split(":").map((part) => parseInt(part, 16)) : [];
77
+ if (leftGroups.length + rightGroups.length > 7 || leftGroups.some(Number.isNaN) || rightGroups.some(Number.isNaN)) return null;
78
+ const middle = new Array(8 - leftGroups.length - rightGroups.length).fill(0);
79
+ return [...leftGroups, ...middle, ...rightGroups];
80
+ }
81
+ const groups = ip.split(":").map((part) => parseInt(part, 16));
82
+ if (groups.length !== 8 || groups.some(Number.isNaN)) return null;
83
+ return groups;
84
+ }
85
+
86
+ /** True when the IPv4 int falls inside any embedded CN CIDR. */
87
+ export function inCnCidr4(ipInt) {
88
+ for (const [base, prefix] of CN_CIDRS4) {
89
+ if (base > ipInt) return false; // sorted: no later row can match
90
+ const mask = prefix === 0 ? 0 : (-1 << (32 - prefix)) >>> 0;
91
+ if ((ipInt & mask) === (base & mask)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ /** True when the IPv6 groups fall inside any embedded CN CIDR. */
97
+ export function inCnCidr6(groups) {
98
+ for (const { groups: network, prefix } of CN_CIDRS6) {
99
+ let before = false;
100
+ let equal = true;
101
+ for (let i = 0; i < 8 && equal; i++) {
102
+ if (groups[i] !== network[i]) {
103
+ equal = false;
104
+ before = groups[i] < network[i];
105
+ }
106
+ }
107
+ if (before) return false; // sorted
108
+ if (!equal) continue;
109
+ // network equals the prefix bits so far — compare remaining bits below.
110
+ let matched = true;
111
+ for (let bit = 0; bit < prefix; bit++) {
112
+ const groupIndex = bit >> 4;
113
+ const bitIndex = 15 - (bit & 15);
114
+ if (((groups[groupIndex] >> bitIndex) & 1) !== ((network[groupIndex] >> bitIndex) & 1)) {
115
+ matched = false;
116
+ break;
117
+ }
118
+ }
119
+ if (matched) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Compile a rule line ("TYPE,param[,param],TARGET") into a matcher.
126
+ * Supported types: DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, IP-CIDR, IP-CIDR6,
127
+ * MATCH, GEOIP (CN only). Unknown types yield null.
128
+ */
129
+ function compileRule(line) {
130
+ if (typeof line !== "string") return null;
131
+ const trimmed = line.trim();
132
+ if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("//")) return null;
133
+ const parts = trimmed.split(",").map((part) => part.trim());
134
+ const target = (parts.pop() ?? "").toUpperCase();
135
+ if (target !== "DIRECT" && target !== "PROXY" && target !== "REJECT" && target !== "REJECT-DROP") return null;
136
+ const action = target === "REJECT-DROP" ? "reject" : target.toLowerCase();
137
+ const type = (parts.shift() ?? "").toUpperCase();
138
+ const param = parts.join(",");
139
+ switch (type) {
140
+ case "DOMAIN":
141
+ return { match: (host) => host === param.toLowerCase(), action };
142
+ case "DOMAIN-SUFFIX":
143
+ return { match: (host) => host === param.toLowerCase() || host.endsWith(`.${param.toLowerCase()}`), action };
144
+ case "DOMAIN-KEYWORD":
145
+ return { match: (host) => host.includes(param.toLowerCase()), action };
146
+ case "IP-CIDR": {
147
+ const slash = param.indexOf("/");
148
+ const base = ipv4ToInt(param.slice(0, slash));
149
+ const prefix = Number(param.slice(slash + 1));
150
+ if (base === null || !Number.isInteger(prefix)) return null;
151
+ const mask = prefix === 0 ? 0 : (-1 << (32 - prefix)) >>> 0;
152
+ return { matchIp: (ipInt) => (ipInt & mask) === (base & mask), action };
153
+ }
154
+ case "IP-CIDR6": {
155
+ const slash = param.indexOf("/");
156
+ const groups = ipv6ToGroups(param.slice(0, slash));
157
+ const prefix = Number(param.slice(slash + 1));
158
+ if (groups === null || !Number.isInteger(prefix)) return null;
159
+ return {
160
+ matchIp6: (candidate) => {
161
+ for (let bit = 0; bit < prefix; bit++) {
162
+ const groupIndex = bit >> 4;
163
+ const bitIndex = 15 - (bit & 15);
164
+ if (((candidate[groupIndex] >> bitIndex) & 1) !== ((groups[groupIndex] >> bitIndex) & 1)) return false;
165
+ }
166
+ return true;
167
+ },
168
+ action
169
+ };
170
+ }
171
+ case "MATCH":
172
+ return { match: () => true, action };
173
+ default:
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /** @typedef {{ruleLines: string[], excludeRules: string[]}} RuleEngineOptions */
179
+
180
+ export class RuleEngine {
181
+ #hostRules;
182
+ #ipRules;
183
+ #ip6Rules;
184
+
185
+ /**
186
+ * @param extraRules - user rule lines, highest priority.
187
+ * @param excludeRules - subscription rule lines to drop (substring).
188
+ * @param subscriptionRules - subscription DIRECT/REJECT passthrough lines.
189
+ */
190
+ constructor({ extraRules = [], excludeRules = [], subscriptionRules = [] } = {}) {
191
+ this.#hostRules = [];
192
+ this.#ipRules = [];
193
+ this.#ip6Rules = [];
194
+ const lines = [
195
+ ...extraRules.filter((line) => typeof line === "string"),
196
+ ...subscriptionRules.filter((line) => typeof line === "string" && !excludeRules.some((excluded) => line.includes(excluded)))
197
+ ];
198
+ for (const line of lines) {
199
+ const rule = compileRule(line);
200
+ if (rule === null) continue;
201
+ if (rule.matchIp !== undefined) this.#ipRules.push(rule);
202
+ else if (rule.matchIp6 !== undefined) this.#ip6Rules.push(rule);
203
+ else this.#hostRules.push(rule);
204
+ }
205
+ }
206
+
207
+ /** Decision for a hostname (lowercased). */
208
+ matchHost(host) {
209
+ for (const rule of this.#hostRules) if (rule.match(host)) return rule.action;
210
+ return null;
211
+ }
212
+
213
+ /** Decision for an IPv4 int. */
214
+ matchIp(ipInt) {
215
+ for (const rule of this.#ipRules) if (rule.matchIp(ipInt)) return rule.action;
216
+ if (inCnCidr4(ipInt)) return "direct";
217
+ return null;
218
+ }
219
+
220
+ /** Decision for IPv6 groups. */
221
+ matchIp6(groups) {
222
+ for (const rule of this.#ip6Rules) if (rule.matchIp6(groups)) return rule.action;
223
+ if (inCnCidr6(groups)) return "direct";
224
+ return null;
225
+ }
226
+
227
+ /** Final decision for a raw host string (hostname or IP literal). */
228
+ decide(rawHost) {
229
+ const host = rawHost.toLowerCase().trim();
230
+ const ip4 = ipv4ToInt(host);
231
+ if (ip4 !== null) return this.matchIp(ip4) ?? "proxy";
232
+ const ip6 = ipv6ToGroups(host);
233
+ if (ip6 !== null) return this.matchIp6(ip6) ?? "proxy";
234
+ const ruleAction = this.matchHost(host);
235
+ if (ruleAction !== null) return ruleAction;
236
+ if (CN_SUFFIXES.has(host) || host.endsWith(".cn")) return "direct";
237
+ for (let dot = host.indexOf("."); dot !== -1; dot = host.indexOf(".", dot + 1)) {
238
+ if (CN_SUFFIXES.has(host.slice(dot + 1))) return "direct";
239
+ }
240
+ return "proxy";
241
+ }
242
+ }