@decentnetwork/lan 0.1.138 → 0.1.140

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,25 @@
1
+ /** True if the IPv4 literal falls in a China-allocated block. */
2
+ export declare function isChinaIpv4(ip: string): boolean;
3
+ export interface GeoCnOptions {
4
+ /** Cache lifetime for a host verdict (default 10 min — DNS/GeoDNS shifts). */
5
+ ttlMs?: number;
6
+ /** Per-lookup DNS timeout (default 2s — never block a page on a slow resolve). */
7
+ dnsTimeoutMs?: number;
8
+ /** Max hosts cached (default 4000). */
9
+ maxHosts?: number;
10
+ }
11
+ /**
12
+ * Caching China-host classifier. `isChinaHost(host)` resolves the name once,
13
+ * caches the verdict, and answers instantly thereafter.
14
+ */
15
+ export declare class GeoCnClassifier {
16
+ #private;
17
+ constructor(opts?: GeoCnOptions);
18
+ /** Synchronous verdict if the host is a literal IP or already cached; else
19
+ * null (caller may fall back to direct and let `classify()` warm the cache
20
+ * for next time). */
21
+ peek(host: string): boolean | null;
22
+ /** Resolve (if needed) and classify, caching the verdict. Never rejects —
23
+ * a failed/timed-out resolve yields `false` (treat as non-China). */
24
+ classify(host: string): Promise<boolean>;
25
+ }
@@ -0,0 +1,144 @@
1
+ //
2
+ // China-IP geolocation — the "is this host in China?" oracle that lets the
3
+ // router auto-classify hosts the domain table misses (e.g. CCTV content on
4
+ // ldcctvwbcdbd.a.bdydns.com, a Baidu CDN hostname no suffix list anticipates).
5
+ //
6
+ // It resolves a hostname and tests the resolved IPv4 against the bundled APNIC
7
+ // China allocation table (chnroutes-cn.ts). China-hosted CDNs (Baidu, Ali,
8
+ // Tencent) have no foreign edge, so from abroad they resolve to a China IP and
9
+ // this correctly routes them through a China exit — no hand-maintained domain
10
+ // list required.
11
+ //
12
+ // Limit: GeoIP tells you where the resolved edge IS, not what the content is
13
+ // authorized for. A global CDN (Akamai/Cloudflare) that geo-fences by policy
14
+ // resolves to a LOCAL edge → looks non-China here. Those are caught by the
15
+ // route learner's 403/RST signals instead. This is one heuristic layer, not a
16
+ // silver bullet.
17
+ //
18
+ import dns from "node:dns";
19
+ import net from "node:net";
20
+ import { CN_IPV4_RANGES } from "./data/chnroutes-cn.js";
21
+ const resolver = new dns.promises.Resolver();
22
+ function ipv4ToInt(ip) {
23
+ const p = ip.split(".");
24
+ if (p.length !== 4)
25
+ return null;
26
+ let v = 0;
27
+ for (const oct of p) {
28
+ const n = Number(oct);
29
+ if (!Number.isInteger(n) || n < 0 || n > 255)
30
+ return null;
31
+ v = v * 256 + n;
32
+ }
33
+ return v >>> 0;
34
+ }
35
+ /** True if the IPv4 literal falls in a China-allocated block. */
36
+ export function isChinaIpv4(ip) {
37
+ const v = ipv4ToInt(ip);
38
+ if (v === null)
39
+ return false;
40
+ // Binary search the flat [start,end,start,end,...] sorted interval array.
41
+ let lo = 0;
42
+ let hi = CN_IPV4_RANGES.length / 2 - 1;
43
+ while (lo <= hi) {
44
+ const mid = (lo + hi) >> 1;
45
+ const s = CN_IPV4_RANGES[mid * 2];
46
+ const e = CN_IPV4_RANGES[mid * 2 + 1];
47
+ if (v < s)
48
+ hi = mid - 1;
49
+ else if (v > e)
50
+ lo = mid + 1;
51
+ else
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * Caching China-host classifier. `isChinaHost(host)` resolves the name once,
58
+ * caches the verdict, and answers instantly thereafter.
59
+ */
60
+ export class GeoCnClassifier {
61
+ #cache = new Map();
62
+ #inflight = new Map();
63
+ #ttlMs;
64
+ #dnsTimeoutMs;
65
+ #maxHosts;
66
+ constructor(opts = {}) {
67
+ this.#ttlMs = opts.ttlMs ?? 600_000;
68
+ this.#dnsTimeoutMs = opts.dnsTimeoutMs ?? 2000;
69
+ this.#maxHosts = opts.maxHosts ?? 4000;
70
+ }
71
+ /** Synchronous verdict if the host is a literal IP or already cached; else
72
+ * null (caller may fall back to direct and let `classify()` warm the cache
73
+ * for next time). */
74
+ peek(host) {
75
+ const h = host.toLowerCase();
76
+ if (net.isIPv4(h))
77
+ return isChinaIpv4(h);
78
+ if (net.isIPv6(h))
79
+ return false; // no v6 table yet — treat as non-CN
80
+ const e = this.#cache.get(h);
81
+ if (e && Date.now() - e.at <= this.#ttlMs)
82
+ return e.cn;
83
+ return null;
84
+ }
85
+ /** Resolve (if needed) and classify, caching the verdict. Never rejects —
86
+ * a failed/timed-out resolve yields `false` (treat as non-China). */
87
+ async classify(host) {
88
+ const cached = this.peek(host);
89
+ if (cached !== null)
90
+ return cached;
91
+ const h = host.toLowerCase();
92
+ const inflight = this.#inflight.get(h);
93
+ if (inflight)
94
+ return inflight;
95
+ const p = this.#resolveAndClassify(h);
96
+ this.#inflight.set(h, p);
97
+ try {
98
+ return await p;
99
+ }
100
+ finally {
101
+ this.#inflight.delete(h);
102
+ }
103
+ }
104
+ async #resolveAndClassify(host) {
105
+ let cn = false;
106
+ try {
107
+ const addrs = await this.#resolve4(host);
108
+ // Any resolved address in China → treat the host as China. A China CDN
109
+ // returns only China IPs from abroad; a global CDN returns a local edge.
110
+ cn = addrs.some((a) => isChinaIpv4(a));
111
+ }
112
+ catch {
113
+ cn = false;
114
+ }
115
+ this.#insert(host, cn);
116
+ return cn;
117
+ }
118
+ #resolve4(host) {
119
+ return new Promise((resolvep, rejectp) => {
120
+ let settled = false;
121
+ const timer = setTimeout(() => { if (!settled) {
122
+ settled = true;
123
+ rejectp(new Error("dns timeout"));
124
+ } }, this.#dnsTimeoutMs);
125
+ resolver.resolve4(host).then((a) => { if (!settled) {
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ resolvep(a);
129
+ } }, (e) => { if (!settled) {
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ rejectp(e);
133
+ } });
134
+ });
135
+ }
136
+ #insert(host, cn) {
137
+ if (this.#cache.size >= this.#maxHosts) {
138
+ const oldest = this.#cache.keys().next().value;
139
+ if (oldest)
140
+ this.#cache.delete(oldest);
141
+ }
142
+ this.#cache.set(host, { cn, at: Date.now() });
143
+ }
144
+ }
@@ -0,0 +1,67 @@
1
+ export interface FetchedResponse {
2
+ status: number;
3
+ headers: Record<string, string | string[] | undefined>;
4
+ body: Buffer;
5
+ }
6
+ /** Fetch one absolute http:// URL through some exit; provided by the router.
7
+ * `label` lets the router log the fetch (e.g. "prefetch"). */
8
+ export type UrlFetcher = (url: URL, headers: Record<string, string>, label?: string) => Promise<FetchedResponse>;
9
+ export declare function looksLikePlaylist(pathname: string): boolean;
10
+ export declare function looksLikeSegment(pathname: string): boolean;
11
+ export interface HlsVariant {
12
+ bandwidth: number;
13
+ resolution: string;
14
+ url: string;
15
+ }
16
+ /**
17
+ * Extract the variant streams from a MASTER playlist (#EXT-X-STREAM-INF), so we
18
+ * can see what resolutions a source offers and which one the player picks.
19
+ * Returns [] for a media playlist (no variants).
20
+ */
21
+ export declare function parseM3u8Variants(playlist: string, baseUrl: string): HlsVariant[];
22
+ /**
23
+ * Extract the media-segment URLs from an m3u8 playlist, resolved against the
24
+ * playlist's own URL. Returns [] for master playlists (variant lists) — those
25
+ * reference further playlists, not media.
26
+ */
27
+ export declare function parseM3u8Segments(playlist: string, baseUrl: string): string[];
28
+ interface CacheEntry {
29
+ body: Buffer;
30
+ contentType: string | undefined;
31
+ at: number;
32
+ }
33
+ export interface HlsPrefetcherOptions {
34
+ /** Cache budget in bytes (default 128 MB). */
35
+ maxBytes?: number;
36
+ /** Entry lifetime (default 120 s — live segments rotate fast). */
37
+ ttlMs?: number;
38
+ /** How many upcoming segments to keep fetched ahead (default 4). */
39
+ prefetchAhead?: number;
40
+ /** Max concurrent prefetch downloads (default 4). */
41
+ concurrency?: number;
42
+ /** Skip caching bodies larger than this (default 32 MB). */
43
+ maxEntryBytes?: number;
44
+ }
45
+ export declare class HlsPrefetcher {
46
+ #private;
47
+ constructor(fetcher: UrlFetcher, opts?: HlsPrefetcherOptions);
48
+ get stats(): {
49
+ hits: number;
50
+ misses: number;
51
+ entries: number;
52
+ bytes: number;
53
+ };
54
+ /** Insert a fetched body into the cache (for a cold segment miss the router
55
+ * fetched directly, so a re-request is served from memory). */
56
+ put(url: string, body: Buffer, contentType: string | undefined): void;
57
+ /** Cached entry for a URL, or null. Counts hit/miss for the stats line. */
58
+ lookup(url: string): CacheEntry | null;
59
+ /** If the URL is currently being prefetched, the promise resolving when it
60
+ * lands — lets a player request "join" an in-flight prefetch instead of
61
+ * double-downloading. */
62
+ pending(url: string): Promise<CacheEntry | null> | null;
63
+ /** Called with every playlist body that passes through the router: parse it
64
+ * and top up the prefetch pipeline for its upcoming segments. */
65
+ onPlaylist(playlistUrl: string, body: string): void;
66
+ }
67
+ export {};
@@ -0,0 +1,195 @@
1
+ //
2
+ // HLS-aware segment prefetcher for the multi-exit router.
3
+ //
4
+ // Problem: an HLS player downloads media segments SEQUENTIALLY — one segment,
5
+ // then the next. So no matter how many exits a region has, a single stream
6
+ // only ever rides ONE exit's bandwidth at a time, and the aggregate capacity
7
+ // of the pool (observed ~7 Mbps across five 0.4–2.5 Mbps exits) goes unused.
8
+ //
9
+ // Fix: when a plain-HTTP .m3u8 playlist passes through the router, parse the
10
+ // segment URLs it lists and prefetch the upcoming segments IN PARALLEL, each
11
+ // through a different exit, into a small in-memory cache. When the player asks
12
+ // for a segment we already hold, answer from cache instantly. The player's
13
+ // ABR sees the pool's aggregate bandwidth instead of one exit's.
14
+ //
15
+ // Scope: transparent for http:// streams only (an https:// stream is an opaque
16
+ // CONNECT tunnel — the router never sees its URLs). Media playlists only; a
17
+ // master playlist (#EXT-X-STREAM-INF) lists variants, not segments.
18
+ //
19
+ const SEGMENT_EXTS = [".ts", ".m4s", ".m4a", ".m4v", ".aac", ".mp3", ".mp4", ".vtt"];
20
+ export function looksLikePlaylist(pathname) {
21
+ const p = pathname.toLowerCase();
22
+ return p.endsWith(".m3u8") || p.endsWith(".m3u");
23
+ }
24
+ export function looksLikeSegment(pathname) {
25
+ const p = pathname.toLowerCase();
26
+ return SEGMENT_EXTS.some((e) => p.endsWith(e));
27
+ }
28
+ /**
29
+ * Extract the variant streams from a MASTER playlist (#EXT-X-STREAM-INF), so we
30
+ * can see what resolutions a source offers and which one the player picks.
31
+ * Returns [] for a media playlist (no variants).
32
+ */
33
+ export function parseM3u8Variants(playlist, baseUrl) {
34
+ if (!playlist.includes("#EXT-X-STREAM-INF"))
35
+ return [];
36
+ const out = [];
37
+ const lines = playlist.split(/\r?\n/);
38
+ for (let i = 0; i < lines.length; i++) {
39
+ const line = lines[i].trim();
40
+ if (!line.startsWith("#EXT-X-STREAM-INF"))
41
+ continue;
42
+ const bw = /BANDWIDTH=(\d+)/.exec(line);
43
+ const res = /RESOLUTION=([0-9x]+)/.exec(line);
44
+ // The URL is the next non-comment line.
45
+ let url = "";
46
+ for (let j = i + 1; j < lines.length; j++) {
47
+ const l = lines[j].trim();
48
+ if (l.length === 0 || l.startsWith("#"))
49
+ continue;
50
+ try {
51
+ url = new URL(l, baseUrl).toString();
52
+ }
53
+ catch {
54
+ url = l;
55
+ }
56
+ break;
57
+ }
58
+ out.push({ bandwidth: bw ? Number(bw[1]) : 0, resolution: res ? res[1] : "", url });
59
+ }
60
+ return out;
61
+ }
62
+ /**
63
+ * Extract the media-segment URLs from an m3u8 playlist, resolved against the
64
+ * playlist's own URL. Returns [] for master playlists (variant lists) — those
65
+ * reference further playlists, not media.
66
+ */
67
+ export function parseM3u8Segments(playlist, baseUrl) {
68
+ if (playlist.includes("#EXT-X-STREAM-INF"))
69
+ return [];
70
+ const out = [];
71
+ for (const raw of playlist.split(/\r?\n/)) {
72
+ const line = raw.trim();
73
+ if (line.length === 0 || line.startsWith("#"))
74
+ continue;
75
+ try {
76
+ out.push(new URL(line, baseUrl).toString());
77
+ }
78
+ catch {
79
+ /* malformed line — skip */
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+ export class HlsPrefetcher {
85
+ #cache = new Map(); // Map preserves insertion order → LRU-ish
86
+ #cacheBytes = 0;
87
+ #inflight = new Map();
88
+ #fetcher;
89
+ #maxBytes;
90
+ #ttlMs;
91
+ #ahead;
92
+ #concurrency;
93
+ #maxEntryBytes;
94
+ #hits = 0;
95
+ #misses = 0;
96
+ constructor(fetcher, opts = {}) {
97
+ this.#fetcher = fetcher;
98
+ this.#maxBytes = opts.maxBytes ?? 128 * 1024 * 1024;
99
+ this.#ttlMs = opts.ttlMs ?? 120_000;
100
+ this.#ahead = opts.prefetchAhead ?? 4;
101
+ this.#concurrency = opts.concurrency ?? 4;
102
+ this.#maxEntryBytes = opts.maxEntryBytes ?? 32 * 1024 * 1024;
103
+ }
104
+ get stats() {
105
+ return { hits: this.#hits, misses: this.#misses, entries: this.#cache.size, bytes: this.#cacheBytes };
106
+ }
107
+ /** Insert a fetched body into the cache (for a cold segment miss the router
108
+ * fetched directly, so a re-request is served from memory). */
109
+ put(url, body, contentType) {
110
+ if (body.length === 0 || body.length > this.#maxEntryBytes)
111
+ return;
112
+ this.#insert(url, { body, contentType, at: Date.now() });
113
+ }
114
+ /** Cached entry for a URL, or null. Counts hit/miss for the stats line. */
115
+ lookup(url) {
116
+ const e = this.#peek(url);
117
+ if (e)
118
+ this.#hits++;
119
+ else
120
+ this.#misses++;
121
+ return e;
122
+ }
123
+ /** If the URL is currently being prefetched, the promise resolving when it
124
+ * lands — lets a player request "join" an in-flight prefetch instead of
125
+ * double-downloading. */
126
+ pending(url) {
127
+ return this.#inflight.get(url) ?? null;
128
+ }
129
+ #peek(url) {
130
+ const e = this.#cache.get(url);
131
+ if (!e)
132
+ return null;
133
+ if (Date.now() - e.at > this.#ttlMs) {
134
+ this.#cache.delete(url);
135
+ this.#cacheBytes -= e.body.length;
136
+ return null;
137
+ }
138
+ return e;
139
+ }
140
+ /** Called with every playlist body that passes through the router: parse it
141
+ * and top up the prefetch pipeline for its upcoming segments. */
142
+ onPlaylist(playlistUrl, body) {
143
+ const segs = parseM3u8Segments(body, playlistUrl);
144
+ if (segs.length === 0)
145
+ return;
146
+ // For a live playlist the tail is "upcoming"; for VOD the player walks the
147
+ // list from wherever it is, and refetches of the playlist don't happen —
148
+ // prefetching the head of the list still fills the startup buffer. Either
149
+ // way: take the last `ahead` entries we don't already hold.
150
+ const want = segs.slice(-this.#ahead).filter((u) => !this.#peek(u) && !this.#inflight.has(u));
151
+ for (const u of want) {
152
+ if (this.#inflight.size >= this.#concurrency)
153
+ break;
154
+ this.#prefetch(u);
155
+ }
156
+ }
157
+ #prefetch(url) {
158
+ const p = (async () => {
159
+ try {
160
+ const u = new URL(url);
161
+ const res = await this.#fetcher(u, { host: u.host }, "prefetch");
162
+ if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
163
+ return null;
164
+ const ct = res.headers["content-type"];
165
+ const entry = {
166
+ body: res.body,
167
+ contentType: Array.isArray(ct) ? ct[0] : ct,
168
+ at: Date.now(),
169
+ };
170
+ this.#insert(url, entry);
171
+ return entry;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ finally {
177
+ this.#inflight.delete(url);
178
+ }
179
+ })();
180
+ this.#inflight.set(url, p);
181
+ }
182
+ #insert(url, entry) {
183
+ const old = this.#cache.get(url);
184
+ if (old)
185
+ this.#cacheBytes -= old.body.length;
186
+ this.#cache.set(url, entry);
187
+ this.#cacheBytes += entry.body.length;
188
+ while (this.#cacheBytes > this.#maxBytes && this.#cache.size > 0) {
189
+ const oldest = this.#cache.keys().next().value;
190
+ const e = this.#cache.get(oldest);
191
+ this.#cache.delete(oldest);
192
+ this.#cacheBytes -= e.body.length;
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,27 @@
1
+ export interface CaFiles {
2
+ certPem: string;
3
+ keyPem: string;
4
+ }
5
+ /** Load the root CA from configDir, generating and persisting it on first use. */
6
+ export declare function loadOrCreateCa(configDir: string): {
7
+ ca: CaFiles;
8
+ caCertPath: string;
9
+ created: boolean;
10
+ };
11
+ interface LeafEntry {
12
+ key: string;
13
+ cert: string;
14
+ }
15
+ /**
16
+ * Mints and caches leaf certificates for MITM'd hosts, each signed by the
17
+ * loaded root CA. One RSA keypair is reused across leaves (fine for a local
18
+ * MITM — the trust boundary is the CA, and generating a keypair per host is
19
+ * needlessly slow).
20
+ */
21
+ export declare class LeafCertFactory {
22
+ #private;
23
+ constructor(ca: CaFiles);
24
+ /** { key, cert } PEM pair for a host, minted on first use and cached. */
25
+ for(host: string): LeafEntry;
26
+ }
27
+ export {};
@@ -0,0 +1,120 @@
1
+ //
2
+ // MITM certificate authority for the HLS aggregation gateway.
3
+ //
4
+ // To aggregate an HTTPS video stream across several exits we must SEE the
5
+ // playlist and segment URLs — which are encrypted inside the browser↔CDN TLS
6
+ // session. So for the (few, explicitly China-video) hosts we choose to
7
+ // accelerate, the router terminates TLS locally with a leaf certificate signed
8
+ // by a root CA the user has trusted once, reads the plaintext HTTP, and
9
+ // prefetches segments across the exit pool.
10
+ //
11
+ // Scope guard: this is used ONLY for hosts the router decides to MITM (China
12
+ // video CDNs). Every other HTTPS host stays an opaque CONNECT tunnel the router
13
+ // never decrypts. The CA private key lives in the user's own config dir.
14
+ //
15
+ // The root CA is generated once and persisted; leaf certs are generated per
16
+ // SNI host on demand and cached in memory.
17
+ //
18
+ import forge from "node-forge";
19
+ import fs from "node:fs";
20
+ import { resolve } from "node:path";
21
+ const CA_CERT_FILE = "mitm-ca-cert.pem";
22
+ const CA_KEY_FILE = "mitm-ca-key.pem";
23
+ /** Load the root CA from configDir, generating and persisting it on first use. */
24
+ export function loadOrCreateCa(configDir) {
25
+ const caCertPath = resolve(configDir, CA_CERT_FILE);
26
+ const caKeyPath = resolve(configDir, CA_KEY_FILE);
27
+ if (fs.existsSync(caCertPath) && fs.existsSync(caKeyPath)) {
28
+ return {
29
+ ca: { certPem: fs.readFileSync(caCertPath, "utf8"), keyPem: fs.readFileSync(caKeyPath, "utf8") },
30
+ caCertPath,
31
+ created: false,
32
+ };
33
+ }
34
+ const ca = generateCa();
35
+ fs.writeFileSync(caCertPath, ca.certPem, { mode: 0o644 });
36
+ fs.writeFileSync(caKeyPath, ca.keyPem, { mode: 0o600 });
37
+ return { ca, caCertPath, created: true };
38
+ }
39
+ function generateCa() {
40
+ const keys = forge.pki.rsa.generateKeyPair(2048);
41
+ const cert = forge.pki.createCertificate();
42
+ cert.publicKey = keys.publicKey;
43
+ cert.serialNumber = randomSerial();
44
+ cert.validity.notBefore = new Date();
45
+ // 10-year CA. notBefore backdated a day to tolerate clock skew.
46
+ cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
47
+ cert.validity.notAfter = new Date();
48
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
49
+ const attrs = [
50
+ { name: "commonName", value: "AgentNet Local HLS CA" },
51
+ { name: "organizationName", value: "Decent AgentNet" },
52
+ { shortName: "OU", value: "Local MITM (video acceleration)" },
53
+ ];
54
+ cert.setSubject(attrs);
55
+ cert.setIssuer(attrs);
56
+ cert.setExtensions([
57
+ { name: "basicConstraints", cA: true, critical: true },
58
+ { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
59
+ { name: "subjectKeyIdentifier" },
60
+ ]);
61
+ cert.sign(keys.privateKey, forge.md.sha256.create());
62
+ return {
63
+ certPem: forge.pki.certificateToPem(cert),
64
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
65
+ };
66
+ }
67
+ /**
68
+ * Mints and caches leaf certificates for MITM'd hosts, each signed by the
69
+ * loaded root CA. One RSA keypair is reused across leaves (fine for a local
70
+ * MITM — the trust boundary is the CA, and generating a keypair per host is
71
+ * needlessly slow).
72
+ */
73
+ export class LeafCertFactory {
74
+ #caCert;
75
+ #caKey;
76
+ #leafKeys;
77
+ #leafKeyPem;
78
+ #cache = new Map();
79
+ constructor(ca) {
80
+ this.#caCert = forge.pki.certificateFromPem(ca.certPem);
81
+ this.#caKey = forge.pki.privateKeyFromPem(ca.keyPem);
82
+ this.#leafKeys = forge.pki.rsa.generateKeyPair(2048);
83
+ this.#leafKeyPem = forge.pki.privateKeyToPem(this.#leafKeys.privateKey);
84
+ }
85
+ /** { key, cert } PEM pair for a host, minted on first use and cached. */
86
+ for(host) {
87
+ const cached = this.#cache.get(host);
88
+ if (cached)
89
+ return cached;
90
+ const cert = forge.pki.createCertificate();
91
+ cert.publicKey = this.#leafKeys.publicKey;
92
+ cert.serialNumber = randomSerial();
93
+ cert.validity.notBefore = new Date();
94
+ cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
95
+ cert.validity.notAfter = new Date();
96
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 2);
97
+ cert.setSubject([{ name: "commonName", value: host }]);
98
+ cert.setIssuer(this.#caCert.subject.attributes);
99
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
100
+ cert.setExtensions([
101
+ { name: "basicConstraints", cA: false },
102
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
103
+ { name: "extKeyUsage", serverAuth: true },
104
+ { name: "subjectAltName", altNames: [isIp ? { type: 7, ip: host } : { type: 2, value: host }] },
105
+ ]);
106
+ cert.sign(this.#caKey, forge.md.sha256.create());
107
+ const entry = { key: this.#leafKeyPem, cert: forge.pki.certificateToPem(cert) };
108
+ this.#cache.set(host, entry);
109
+ return entry;
110
+ }
111
+ }
112
+ function randomSerial() {
113
+ // Positive hex serial. forge wants a hex string; ensure the high bit is clear
114
+ // so it isn't read as negative.
115
+ const bytes = forge.random.getBytesSync(16);
116
+ let hex = forge.util.bytesToHex(bytes);
117
+ // Force the first nibble low so the integer is positive.
118
+ hex = "0" + hex.slice(1);
119
+ return hex;
120
+ }
@@ -0,0 +1,22 @@
1
+ import http from "node:http";
2
+ import net from "node:net";
3
+ export interface MitmGatewayOptions {
4
+ configDir: string;
5
+ /** Handles a decrypted request. `host`/`port` identify the real origin so the
6
+ * handler can build the absolute https URL and fetch it via the exit pool. */
7
+ onRequest: (req: http.IncomingMessage, res: http.ServerResponse, host: string, port: number) => void;
8
+ log: (msg: string) => void;
9
+ }
10
+ export declare class MitmGateway {
11
+ #private;
12
+ readonly caCertPath: string;
13
+ readonly caCreated: boolean;
14
+ constructor(opts: MitmGatewayOptions);
15
+ get caCertPem(): string;
16
+ /**
17
+ * MITM a CONNECT to `host:port`: acknowledge the tunnel, complete a TLS
18
+ * handshake against the browser using a leaf cert for `host`, then feed the
19
+ * decrypted socket into the internal HTTP server.
20
+ */
21
+ handleConnect(host: string, port: number, client: net.Socket, head: Buffer): void;
22
+ }
@@ -0,0 +1,76 @@
1
+ //
2
+ // HLS aggregation gateway — selective HTTPS MITM for video acceleration.
3
+ //
4
+ // For the (few, explicitly China-video) hosts the router chooses to accelerate,
5
+ // we terminate the browser↔CDN TLS locally so we can read the .m3u8 and segment
6
+ // URLs, then prefetch segments in PARALLEL across the whole exit pool. That
7
+ // turns a set of small, unstable friend-run exits into one smooth aggregate
8
+ // pipe — the point is not raw bandwidth but hiding any single exit's stalls
9
+ // behind a prefetch buffer fed from several exits at once.
10
+ //
11
+ // Every other HTTPS host stays an opaque CONNECT tunnel the router never
12
+ // decrypts (see the router's shouldMitm gate). The CA is the user's own, in
13
+ // their config dir, trusted once.
14
+ //
15
+ import http from "node:http";
16
+ import tls from "node:tls";
17
+ import { loadOrCreateCa, LeafCertFactory } from "./mitm-ca.js";
18
+ export class MitmGateway {
19
+ #leaf;
20
+ #server;
21
+ caCertPath;
22
+ caCreated;
23
+ #ca;
24
+ constructor(opts) {
25
+ const { ca, caCertPath, created } = loadOrCreateCa(opts.configDir);
26
+ this.#ca = ca;
27
+ this.caCertPath = caCertPath;
28
+ this.caCreated = created;
29
+ this.#leaf = new LeafCertFactory(ca);
30
+ // Internal HTTP server: it never listens on a socket. We hand it decrypted
31
+ // TLS sockets via emit('connection'), and it parses HTTP requests off them
32
+ // and calls our handler — reusing Node's battle-tested HTTP parser instead
33
+ // of hand-rolling one over the TLS stream.
34
+ this.#server = new http.Server((req, res) => {
35
+ // The Host header carries the real origin; the socket remembers the port.
36
+ const sock = req.socket;
37
+ const host = (sock._mitmHost || (req.headers.host || "").split(":")[0] || "").toLowerCase();
38
+ const port = sock._mitmPort || 443;
39
+ opts.onRequest(req, res, host, port);
40
+ });
41
+ this.#server.on("clientError", (_e, socket) => { try {
42
+ socket.destroy();
43
+ }
44
+ catch { /* ignore */ } });
45
+ }
46
+ get caCertPem() { return this.#ca.certPem; }
47
+ /**
48
+ * MITM a CONNECT to `host:port`: acknowledge the tunnel, complete a TLS
49
+ * handshake against the browser using a leaf cert for `host`, then feed the
50
+ * decrypted socket into the internal HTTP server.
51
+ */
52
+ handleConnect(host, port, client, head) {
53
+ const leaf = this.#leaf.for(host);
54
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
55
+ // Any bytes already read past the CONNECT request are the start of the
56
+ // browser's TLS ClientHello — put them back on the RAW socket so the TLS
57
+ // layer (which reads ciphertext from `client`) sees them.
58
+ if (head && head.length)
59
+ client.unshift(head);
60
+ const tlsSock = new tls.TLSSocket(client, {
61
+ isServer: true,
62
+ key: leaf.key,
63
+ cert: leaf.cert,
64
+ // Speak HTTP/1.1 only — our internal server is HTTP/1.1 and h2 over this
65
+ // path would need a different parser.
66
+ ALPNProtocols: ["http/1.1"],
67
+ });
68
+ tlsSock._mitmHost = host;
69
+ tlsSock._mitmPort = port;
70
+ tlsSock.on("error", () => { try {
71
+ tlsSock.destroy();
72
+ }
73
+ catch { /* ignore */ } });
74
+ this.#server.emit("connection", tlsSock);
75
+ }
76
+ }