@decentnetwork/lan 0.1.138 → 0.1.139

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.
@@ -17,8 +17,14 @@
17
17
  // cloning the repo (it used to live in scripts/failover-proxy.mjs).
18
18
  //
19
19
  import http from "node:http";
20
+ import https from "node:https";
20
21
  import net from "node:net";
22
+ import tls from "node:tls";
21
23
  import { egressConnect } from "./egress.js";
24
+ import { HlsPrefetcher, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
25
+ import { RouteLearner } from "./route-learner.js";
26
+ import { GeoCnClassifier } from "./geo-cn.js";
27
+ import { MitmGateway } from "./mitm-gateway.js";
22
28
  // Built-in domain lists, so a user can reference a region by name in
23
29
  // routes.yaml without hand-listing every domain. Override by giving the
24
30
  // region its own `domains` list.
@@ -27,6 +33,7 @@ export const BUILTIN_REGION_DOMAINS = {
27
33
  china: [
28
34
  ".cn",
29
35
  ".cctv.com", ".cctvpic.com", ".cntv.cn", ".cctv.cn",
36
+ ".v.myalicdn.com", // CCTV live HLS rides Ali CDN video subdomains (piccpndali.v.myalicdn.com etc.)
30
37
  ".bilibili.com", ".biliapi.net", ".bilivideo.com", ".bilivideo.cn", ".hdslb.com",
31
38
  ".youku.com", ".iqiyi.com", ".iq.com", ".mgtv.com", ".hitv.com",
32
39
  ".miguvideo.com", ".cmvideo.cn", ".migu.cn",
@@ -83,6 +90,81 @@ const HEALTH_TIMEOUT_MS = 4000;
83
90
  // of transient jitter on a long-haul path (don't blackout a region on one blip).
84
91
  const DOWN_AFTER_FAILS = 3;
85
92
  const CONNECT_TIMEOUT_MS = 8000;
93
+ // Tunnel-establishment timeout through an EXIT (vs direct). Tighter than the
94
+ // direct timeout: on failure we fail over to the next exit in the order, and a
95
+ // video player can't hide an 8s stall.
96
+ const EXIT_CONNECT_TIMEOUT_MS = 4000;
97
+ // Throughput sampling: only transfers that moved at least this many bytes
98
+ // produce a speed sample. Below this, a transfer over a high-RTT path is all
99
+ // TCP slow-start and latency, never reaching path capacity — measuring it
100
+ // underestimates by ~5x and poisons the ordering. Video segments clear this;
101
+ // API calls and redirects don't (they stay unmeasured = balanced by load).
102
+ const SPEED_MIN_BYTES = 512 * 1024;
103
+ // We record PEAK 1-second throughput within a transfer, not lifetime average,
104
+ // so slow-start ramp-up doesn't drag the estimate down.
105
+ const SPEED_WINDOW_MS = 1000;
106
+ const SPEED_EWMA_KEEP = 0.6; // new = keep*old + (1-keep)*sample
107
+ // Circuit breaker for an unstable exit (e.g. a friend's laptop that comes and
108
+ // goes, or an exit whose CONNECT still answers but whose forwarding is dead).
109
+ // A tunnel that establishes but then delivers almost nothing counts as a STALL;
110
+ // enough consecutive stalls trip the exit OUT of the pool for a cooldown, so one
111
+ // flaky node never stalls the whole experience.
112
+ const STALL_IDLE_MS = 6000; // no bytes for this long on an open tunnel = stalled → tear down
113
+ const STALL_MIN_OPEN_MS = 4000; // only a tunnel open at least this long can count as a stall
114
+ const STALL_MIN_BYTES = 16 * 1024; // delivered less than this over that time = stall, not a short ok tunnel
115
+ const TRIP_AFTER_STALLS = 2; // consecutive stalls before an exit is tripped out
116
+ const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
117
+ // Measures peak short-window throughput of a byte stream. Feed it every chunk
118
+ // length via add(); read peakBps at the end. Peak (not average) shrugs off TCP
119
+ // slow-start on high-RTT paths, where a transfer spends its first seconds
120
+ // ramping and a lifetime-average badly understates real capacity.
121
+ class RateMeter {
122
+ total = 0;
123
+ #winBytes = 0;
124
+ #winStart = 0;
125
+ #peakBps = 0;
126
+ add(len, nowMs) {
127
+ this.total += len;
128
+ if (this.#winStart === 0) {
129
+ this.#winStart = nowMs;
130
+ this.#winBytes = len;
131
+ return;
132
+ }
133
+ const dt = nowMs - this.#winStart;
134
+ if (dt >= SPEED_WINDOW_MS) {
135
+ const bps = (this.#winBytes / dt) * 1000;
136
+ if (bps > this.#peakBps)
137
+ this.#peakBps = bps;
138
+ this.#winStart = nowMs;
139
+ this.#winBytes = len;
140
+ }
141
+ else {
142
+ this.#winBytes += len;
143
+ }
144
+ }
145
+ /** Peak bytes/sec, falling back to whole-transfer average if no full window
146
+ * ever closed (a fast small-ish transfer that still cleared SPEED_MIN_BYTES). */
147
+ peakBps(totalDurMs) {
148
+ if (this.#peakBps > 0)
149
+ return this.#peakBps;
150
+ return totalDurMs > 0 ? (this.total / totalDurMs) * 1000 : 0;
151
+ }
152
+ }
153
+ /** Feed one tunnel's rate meter into the exit's speed EWMA (peak-window based).
154
+ * Only transfers past SPEED_MIN_BYTES count — smaller ones never reveal path
155
+ * capacity on a high-RTT link. */
156
+ function sampleSpeed(exit, meter, durMs) {
157
+ exit.bytesDown += meter.total;
158
+ if (meter.total < SPEED_MIN_BYTES)
159
+ return;
160
+ const bps = meter.peakBps(durMs);
161
+ if (bps <= 0)
162
+ return;
163
+ exit.speedEwma = exit.speedEwma === null ? bps : SPEED_EWMA_KEEP * exit.speedEwma + (1 - SPEED_EWMA_KEEP) * bps;
164
+ }
165
+ function fmtMbps(bps) {
166
+ return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
167
+ }
86
168
  // An exit that carried a real tunnel within this window is treated as healthy
87
169
  // regardless of what the synthetic probe says (the probe adds load and flaps on
88
170
  // a busy/jittery exit; live traffic is the truth).
@@ -113,6 +195,10 @@ export function startMultiExitRouter(opts) {
113
195
  served: 0,
114
196
  lastRtt: null,
115
197
  lastSuccessMs: null,
198
+ speedEwma: null,
199
+ bytesDown: 0,
200
+ stalls: 0,
201
+ trippedUntil: 0,
116
202
  }));
117
203
  // Per-host routing decision, logged once on first sight so the user can SEE
118
204
  // why a hostname went DIRECT vs through a region (e.g. a CCTV video CDN that
@@ -122,6 +208,13 @@ export function startMultiExitRouter(opts) {
122
208
  let directTotal = 0;
123
209
  let lastPool = null;
124
210
  let stopped = false;
211
+ // Learned host→region routes: hosts the static table missed whose DIRECT
212
+ // route kept failing get auto-promoted into the fallback region (and
213
+ // persisted). Explicit domain lists always win over learned entries.
214
+ const learner = new RouteLearner(opts.learnedRoutesPath, log);
215
+ const fallbackRegion = opts.fallbackRegion
216
+ ?? regions.find((r) => opts.exits.some((e) => (e.region ?? "default") === r.name))?.name
217
+ ?? null;
125
218
  // ---- routing decision -----------------------------------------------------
126
219
  // Returns the region name a host should use, or "direct" for a direct
127
220
  // connection from this machine.
@@ -130,8 +223,43 @@ export function startMultiExitRouter(opts) {
130
223
  if (r.domains.length > 0 && hostMatches(host, r.domains))
131
224
  return r.name;
132
225
  }
226
+ const l = learner.get(host);
227
+ if (l !== null && regionHasExits(l))
228
+ return l;
133
229
  return defaultRegion;
134
230
  }
231
+ // A bad DIRECT outcome for an unmatched host; learn it once it crosses the
232
+ // threshold so future requests route through the fallback region.
233
+ function noteDirectBad(host) {
234
+ if (!fallbackRegion || !regionHasExits(fallbackRegion))
235
+ return;
236
+ if (learner.recordDirectBad(host))
237
+ learner.learn(host, fallbackRegion);
238
+ }
239
+ // GeoIP layer: for a host the domain table and learner both miss, resolve it
240
+ // and — if its IP is China-allocated — route it through the fallback (China)
241
+ // region. Catches China-CDN hostnames no suffix list anticipates (Baidu
242
+ // bdydns, etc.). Returns the region to use (may be "direct").
243
+ const geoCn = new GeoCnClassifier();
244
+ async function resolveRegion(host) {
245
+ let region = routeFor(host); // static domains + persistent learned routes
246
+ if (region === "direct" && !isLocalDest(host) && fallbackRegion && regionHasExits(fallbackRegion)) {
247
+ // peek() is sync for IP literals / cached verdicts; classify() resolves.
248
+ const cn = geoCn.peek(host);
249
+ if (cn === true || (cn === null && await geoCn.classify(host))) {
250
+ if (!seenGeo.has(host)) {
251
+ seenGeo.add(host);
252
+ log(`geo ${host} → region [${fallbackRegion}] (resolved to a China IP)`);
253
+ }
254
+ region = fallbackRegion;
255
+ }
256
+ }
257
+ if (region !== "direct" && !regionHasExits(region))
258
+ region = "direct";
259
+ return region;
260
+ }
261
+ const seenGeo = new Set();
262
+ const seenMitm = new Set();
135
263
  // ---- health checks --------------------------------------------------------
136
264
  function probe(exit) {
137
265
  return new Promise((resolve) => {
@@ -194,13 +322,37 @@ export function startMultiExitRouter(opts) {
194
322
  await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
195
323
  }
196
324
  }
325
+ // ---- circuit breaker ------------------------------------------------------
326
+ // A tunnel delivered healthy data → the exit is good; clear its stall streak
327
+ // and any trip.
328
+ function recordHealthy(exit) {
329
+ if (exit.stalls > 0 || exit.trippedUntil > 0) {
330
+ exit.stalls = 0;
331
+ exit.trippedUntil = 0;
332
+ }
333
+ }
334
+ // A tunnel established but stalled (delivered ~nothing) → count it; trip the
335
+ // exit out of the pool once it stalls repeatedly, so a flaky node stops
336
+ // getting traffic instead of stalling segments.
337
+ function recordStall(exit, why) {
338
+ exit.stalls++;
339
+ if (exit.stalls >= TRIP_AFTER_STALLS && exit.trippedUntil <= Date.now()) {
340
+ exit.trippedUntil = Date.now() + TRIP_COOLDOWN_MS;
341
+ exit.healthy = false;
342
+ log(`✗ ${exit.name} [${exit.region}] TRIPPED for ${TRIP_COOLDOWN_MS / 1000}s (${why}) — excluding from pool`);
343
+ printPool();
344
+ }
345
+ }
197
346
  // ---- exit selection (scoped to one region) --------------------------------
198
347
  function pickOrder(region) {
199
348
  const now = Date.now();
200
349
  // An exit is usable if the probe says healthy OR it carried a real tunnel
201
350
  // recently (live traffic beats a flapping probe).
202
351
  const isUp = (e) => e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS);
203
- const inRegion = exits.filter((e) => e.region === region);
352
+ // Exclude exits currently tripped by the circuit breaker (unstable: they
353
+ // established tunnels but stalled). A friend's laptop dropping off no longer
354
+ // gets segments routed to it. They rejoin automatically after the cooldown.
355
+ const inRegion = exits.filter((e) => e.region === region && e.trippedUntil <= now);
204
356
  const healthy = inRegion.filter(isUp);
205
357
  // Best-effort fallback: if NO exit currently passes health checks but the
206
358
  // region HAS exits, try them anyway instead of 503-ing the user. A single
@@ -209,12 +361,33 @@ export function startMultiExitRouter(opts) {
209
361
  // CONNECT tunnels keep working. The real forward() has its own timeout +
210
362
  // retry, so trying a "down" exit costs little and avoids blacking out the
211
363
  // whole region on a transient probe blip. Prefer healthy when we have them.
212
- const pool = healthy.length > 0 ? healthy : inRegion;
213
- if (pool.length === 0)
214
- return [];
364
+ let pool = healthy.length > 0 ? healthy : inRegion;
365
+ // If EVERY exit in the region is tripped, fall back to the least-recently-
366
+ // tripped ones anyway rather than black-holing the region entirely.
367
+ if (pool.length === 0) {
368
+ const all = exits.filter((e) => e.region === region);
369
+ if (all.length === 0)
370
+ return [];
371
+ pool = [...all].sort((a, b) => a.trippedUntil - b.trippedUntil).slice(0, 1);
372
+ }
215
373
  if (mode === "failover")
216
374
  return pool; // priority order (input order)
217
- return [...pool].sort((a, b) => a.active - b.active || a.served - b.served);
375
+ // Capacity-aware load-balance. Exits in one region differ wildly in real
376
+ // throughput (0.3 vs 2.6 Mbps observed), and HLS downloads segments
377
+ // sequentially — so a segment that lands on a slow exit stalls the player
378
+ // ("很卡"). Score each exit by its ESTIMATED AVAILABLE bandwidth right now:
379
+ //
380
+ // score = measured_peak_bps / (active + 1)
381
+ //
382
+ // The fastest idle exit wins; as it fills with connections its score falls
383
+ // and traffic spills to the next-fastest — self-balancing in proportion to
384
+ // capacity, and steering every segment to whoever can deliver it soonest.
385
+ // Unmeasured exits assume the region's median (or a neutral default) so a
386
+ // new exit is tried but doesn't monopolize before it's proven.
387
+ const measuredVals = pool.map((e) => e.speedEwma).filter((v) => v !== null).sort((a, b) => a - b);
388
+ const medianBps = measuredVals.length > 0 ? measuredVals[Math.floor(measuredVals.length / 2)] : 2_000_000;
389
+ const score = (e) => (e.speedEwma ?? medianBps) / (e.active + 1);
390
+ return [...pool].sort((a, b) => score(b) - score(a) || a.served - b.served);
218
391
  }
219
392
  // Whether ANY exit is configured for a region (healthy or not). Lets us tell
220
393
  // "region has no exits at all → fall through" from "exits all down → 503".
@@ -225,15 +398,20 @@ export function startMultiExitRouter(opts) {
225
398
  function forward(exit, target, client, head, onFail) {
226
399
  const up = net.connect(exit.port, exit.host);
227
400
  let established = false;
228
- const fail = (why) => { if (!established) {
229
- up.destroy();
230
- onFail(why);
231
- }
232
- else {
233
- client.destroy();
234
- } };
401
+ const fail = (why) => {
402
+ if (!established) {
403
+ // Couldn't even establish → an instability signal for the breaker, then
404
+ // fail over to the next exit.
405
+ recordStall(exit, why);
406
+ up.destroy();
407
+ onFail(why);
408
+ }
409
+ else {
410
+ client.destroy();
411
+ }
412
+ };
235
413
  up.once("connect", () => up.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
236
- up.setTimeout(CONNECT_TIMEOUT_MS, () => fail("timeout"));
414
+ up.setTimeout(EXIT_CONNECT_TIMEOUT_MS, () => fail("timeout"));
237
415
  let buf = Buffer.alloc(0);
238
416
  const onData = (d) => {
239
417
  buf = Buffer.concat([buf, d]);
@@ -265,7 +443,50 @@ export function startMultiExitRouter(opts) {
265
443
  client.write(rest);
266
444
  client.pipe(up);
267
445
  up.pipe(client);
268
- const done = () => { exit.active = Math.max(0, exit.active - 1); };
446
+ // Passively measure peak download throughput (exit client) to feed
447
+ // the quality-aware exit ordering.
448
+ const t0 = Date.now();
449
+ const meter = new RateMeter();
450
+ up.on("data", (d) => meter.add(d.length, Date.now()));
451
+ // Mid-tunnel stall watchdog: if the exit delivers no bytes for
452
+ // STALL_IDLE_MS while the tunnel is open, it's stalled — tear it down so
453
+ // the client retries elsewhere, and feed the circuit breaker. setTimeout
454
+ // re-arms on every I/O, so it only fires on genuine inactivity.
455
+ let stalledOut = false;
456
+ up.setTimeout(STALL_IDLE_MS, () => {
457
+ if (meter.total >= STALL_MIN_BYTES) {
458
+ up.setTimeout(0);
459
+ return;
460
+ } // real transfer, just slow
461
+ stalledOut = true;
462
+ up.destroy();
463
+ });
464
+ let closed = false;
465
+ const done = () => {
466
+ if (closed)
467
+ return;
468
+ closed = true;
469
+ exit.active = Math.max(0, exit.active - 1);
470
+ const durMs = Date.now() - t0;
471
+ sampleSpeed(exit, meter, durMs);
472
+ // Circuit-breaker classification: a tunnel that stayed open a while but
473
+ // delivered almost nothing is a stall (dead/flaky exit); a tunnel that
474
+ // moved real data proves the exit is healthy.
475
+ if (stalledOut || (durMs >= STALL_MIN_OPEN_MS && meter.total < STALL_MIN_BYTES)) {
476
+ recordStall(exit, stalledOut ? "idle stall" : "no data");
477
+ }
478
+ else if (meter.total >= STALL_MIN_BYTES) {
479
+ recordHealthy(exit);
480
+ }
481
+ // Log substantial tunnels (video segments) so we can see per-exit
482
+ // speed even for opaque https streams the prefetcher can't touch.
483
+ if (meter.total >= SPEED_MIN_BYTES && durMs > 0) {
484
+ const mb = (meter.total / 1048576).toFixed(1);
485
+ const mbps = ((meter.total * 8) / durMs / 1000).toFixed(1);
486
+ const peak = ((meter.peakBps(durMs) * 8) / 1e6).toFixed(1);
487
+ log(` tunnel ${target} via ${exit.name} — ${mb}MB in ${(durMs / 1000).toFixed(1)}s = ${mbps}Mbps (peak ${peak})`);
488
+ }
489
+ };
269
490
  up.once("close", done);
270
491
  client.once("close", () => up.destroy());
271
492
  client.on("error", () => up.destroy());
@@ -279,12 +500,49 @@ export function startMultiExitRouter(opts) {
279
500
  up.on("error", () => fail("conn error"));
280
501
  }
281
502
  // Direct connection from THIS machine — for hosts that match no region so
282
- // Google etc. aren't black-holed through a foreign exit.
503
+ // Google etc. aren't black-holed through a foreign exit. Unmatched hosts
504
+ // whose direct route fails are retried through the fallback region in the
505
+ // SAME request, and learned once they prove misclassified (the
506
+ // piccpndali.v.myalicdn.com case: a CDN host no suffix table anticipated).
283
507
  function directConnect(target, client, head) {
284
508
  const [host, portStr] = target.split(":");
509
+ const h = (host || "").toLowerCase();
510
+ const local = isLocalDest(h);
285
511
  const up = egressConnect(Number(portStr) || 443, host, opts.egressBindIp);
286
- up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy());
512
+ let established = false;
513
+ let failedOver = false;
514
+ // In-request fallback: the direct dial never came up → try the fallback
515
+ // region's exits before giving up (local destinations never fall back).
516
+ const failover = () => {
517
+ if (failedOver)
518
+ return;
519
+ failedOver = true;
520
+ if (local || !fallbackRegion) {
521
+ client.destroy();
522
+ return;
523
+ }
524
+ noteDirectBad(h);
525
+ const order = pickOrder(fallbackRegion);
526
+ if (order.length === 0) {
527
+ client.destroy();
528
+ return;
529
+ }
530
+ log(` ${target}: direct failed, retrying via region [${fallbackRegion}]`);
531
+ let i = 0;
532
+ const tryNext = () => {
533
+ if (i >= order.length) {
534
+ learner.unlearn(h);
535
+ client.end("HTTP/1.1 502 All routes failed\r\n\r\n");
536
+ return;
537
+ }
538
+ forward(order[i++], target, client, head, tryNext);
539
+ };
540
+ tryNext();
541
+ };
542
+ up.setTimeout(CONNECT_TIMEOUT_MS, () => { up.destroy(); if (!established)
543
+ failover(); });
287
544
  up.once("connect", () => {
545
+ established = true;
288
546
  up.setTimeout(0);
289
547
  directActive++;
290
548
  directTotal++;
@@ -293,12 +551,32 @@ export function startMultiExitRouter(opts) {
293
551
  up.write(head);
294
552
  client.pipe(up);
295
553
  up.pipe(client);
296
- const done = () => { directActive = Math.max(0, directActive - 1); };
554
+ // Outcome classification: a tunnel that dies almost immediately with
555
+ // almost nothing delivered looks like a geo-block RST, not a real
556
+ // exchange — two of those in a row and the host is learned into the
557
+ // fallback region for next time.
558
+ const t0 = Date.now();
559
+ let down = 0;
560
+ up.on("data", (d) => { down += d.length; });
561
+ let closed = false;
562
+ const done = () => {
563
+ if (closed)
564
+ return;
565
+ closed = true;
566
+ directActive = Math.max(0, directActive - 1);
567
+ if (local)
568
+ return;
569
+ if (RouteLearner.suspiciousClose(down, Date.now() - t0))
570
+ noteDirectBad(h);
571
+ else
572
+ learner.recordDirectGood(h);
573
+ };
297
574
  up.once("close", done);
298
575
  client.once("close", () => up.destroy());
299
576
  });
300
577
  client.on("error", () => up.destroy());
301
- up.on("error", () => client.destroy());
578
+ up.on("error", () => { client.destroy(); if (!established)
579
+ failover(); });
302
580
  }
303
581
  // A destination is "local" when it should be dialed straight from this box on
304
582
  // the normal routing table — loopback, RFC1918, the agentnet 10.86/16 vIPs
@@ -327,25 +605,118 @@ export function startMultiExitRouter(opts) {
327
605
  }
328
606
  return false;
329
607
  }
608
+ // HTTP status codes that, on a DIRECT dial to an unmatched host, smell like a
609
+ // geo-block rather than a real answer (Forbidden / legal / some CDNs' region
610
+ // gate). Two of these in a row and the host is learned into the fallback
611
+ // region — the soft-failure counterpart to a connection RST.
612
+ const GEO_BLOCK_STATUS = new Set([403, 451]);
330
613
  // Plain-HTTP forwarding (the browser's proxy request for an `http://` URL,
331
614
  // which arrives as a normal request with an absolute-form URL — NOT a CONNECT
332
615
  // tunnel). Dial the origin directly from here and pipe the streams both ways.
333
- function httpForwardDirect(host, port, path, req, res, headers) {
334
- const bindIp = isLocalDest(host) ? undefined : opts.egressBindIp;
616
+ // On a hard failure OR a geo-block status for an unmatched host, retry the
617
+ // whole request through the fallback region and learn the host.
618
+ function httpForwardDirect(host, port, path, req, res, headers, body) {
619
+ const local = isLocalDest(host);
620
+ const bindIp = local ? undefined : opts.egressBindIp;
335
621
  directTotal++;
622
+ let done = false;
623
+ const target = `${host}:${port}`;
624
+ // Retry through the fallback region (only possible before we've streamed a
625
+ // response, and only for a non-local unmatched host we buffered the body of).
626
+ // Only safe to replay through an exit when the request carried no body we'd
627
+ // have already streamed away (GET/HEAD and friends), or its body was
628
+ // pre-buffered. Otherwise we'd resend an empty body — skip the retry.
629
+ const bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"].includes((req.method || "GET").toUpperCase());
630
+ const viaFallback = (why) => {
631
+ if (done || local || res.headersSent || !fallbackRegion || !regionHasExits(fallbackRegion))
632
+ return false;
633
+ if (!bodyless && body === undefined)
634
+ return false;
635
+ done = true;
636
+ noteDirectBad(host);
637
+ const order = pickOrder(fallbackRegion);
638
+ if (order.length === 0)
639
+ return false;
640
+ log(` ${target}: direct ${why}, retrying via region [${fallbackRegion}] (http)`);
641
+ httpForwardViaExitBuffered(order, 0, target, host, port, path, req.method || "GET", headers, body, res);
642
+ return true;
643
+ };
336
644
  const up = http.request({ host, port, path, method: req.method, headers, localAddress: bindIp }, (upRes) => {
337
- res.writeHead(upRes.statusCode || 502, upRes.headers);
645
+ const code = upRes.statusCode || 502;
646
+ if (!local && GEO_BLOCK_STATUS.has(code) && viaFallback(`${code}`)) {
647
+ upRes.destroy();
648
+ return;
649
+ }
650
+ done = true;
651
+ learner.recordDirectGood(host);
652
+ res.writeHead(code, upRes.headers);
338
653
  upRes.pipe(res);
339
654
  });
340
655
  up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy(new Error("upstream timeout")));
341
- up.on("error", (e) => { if (!res.headersSent)
342
- res.writeHead(502, { "content-type": "text/plain" }); res.end(`direct proxy error: ${e.message}\n`); });
343
- req.pipe(up);
656
+ up.on("error", (e) => {
657
+ if (viaFallback(e.message))
658
+ return;
659
+ if (!res.headersSent)
660
+ res.writeHead(502, { "content-type": "text/plain" });
661
+ res.end(`direct proxy error: ${e.message}\n`);
662
+ });
663
+ if (body && body.length)
664
+ up.end(body);
665
+ else
666
+ req.pipe(up);
667
+ }
668
+ // Like httpForwardViaExit but re-sends a pre-buffered request body (used when
669
+ // the direct attempt already consumed the client stream before failing over).
670
+ function httpForwardViaExitBuffered(order, idx, target, host, port, path, method, headers, body, res) {
671
+ if (idx >= order.length) {
672
+ if (!res.headersSent)
673
+ res.writeHead(502, { "content-type": "text/plain" });
674
+ res.end("all exits failed\n");
675
+ return;
676
+ }
677
+ const exit = order[idx];
678
+ const tun = net.connect(exit.port, exit.host);
679
+ tun.setTimeout(EXIT_CONNECT_TIMEOUT_MS, () => tun.destroy());
680
+ tun.once("connect", () => { tun.setTimeout(0); tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`); });
681
+ let buf = Buffer.alloc(0);
682
+ const onHdr = (chunk) => {
683
+ buf = Buffer.concat([buf, chunk]);
684
+ const i = buf.indexOf("\r\n\r\n");
685
+ if (i === -1)
686
+ return;
687
+ tun.removeListener("data", onHdr);
688
+ const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
689
+ if (!/ 200/.test(statusLine)) {
690
+ tun.destroy();
691
+ httpForwardViaExitBuffered(order, idx + 1, target, host, port, path, method, headers, body, res);
692
+ return;
693
+ }
694
+ exit.lastSuccessMs = Date.now();
695
+ exit.fails = 0;
696
+ if (!exit.healthy)
697
+ exit.healthy = true;
698
+ exit.served++;
699
+ const leftover = buf.slice(i + 4);
700
+ if (leftover.length)
701
+ tun.unshift(leftover);
702
+ const up = http.request({ host, port, path, method, headers, createConnection: () => tun }, (upRes) => {
703
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
704
+ upRes.pipe(res);
705
+ });
706
+ up.on("error", (e) => { if (!res.headersSent)
707
+ res.writeHead(502, { "content-type": "text/plain" }); res.end(`exit proxy error: ${e.message}\n`); });
708
+ if (body && body.length)
709
+ up.end(body);
710
+ else
711
+ up.end();
712
+ };
713
+ tun.on("data", onHdr);
714
+ tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExitBuffered(order, idx + 1, target, host, port, path, method, headers, body, res); });
344
715
  }
345
716
  // Plain-HTTP through an exit region: open a CONNECT tunnel to host:port via the
346
717
  // exit, then ride the normal http.request over that raw socket. Fails over to
347
718
  // the next exit if CONNECT is refused.
348
- function httpForwardViaExit(order, idx, target, host, port, path, req, res, headers) {
719
+ function httpForwardViaExit(order, idx, target, host, port, path, req, res, headers, isTls = false) {
349
720
  if (idx >= order.length) {
350
721
  if (!res.headersSent)
351
722
  res.writeHead(502, { "content-type": "text/plain" });
@@ -366,17 +737,28 @@ export function startMultiExitRouter(opts) {
366
737
  const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
367
738
  if (!/ 200/.test(statusLine)) {
368
739
  tun.destroy();
369
- httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers);
740
+ httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers, isTls);
370
741
  return;
371
742
  }
372
743
  exit.lastSuccessMs = Date.now();
373
744
  exit.fails = 0;
374
745
  if (!exit.healthy)
375
746
  exit.healthy = true;
747
+ exit.served++;
376
748
  const leftover = buf.slice(i + 4);
377
749
  if (leftover.length)
378
750
  tun.unshift(leftover);
379
- const up = http.request({ host, port, path, method: req.method, headers, createConnection: () => tun }, (upRes) => {
751
+ const t0 = Date.now();
752
+ // For an https origin (MITM path) run TLS over the tunnel to the CDN; for
753
+ // http, the raw tunnel carries plaintext.
754
+ const reqFn = isTls ? https.request : http.request;
755
+ const conn = isTls
756
+ ? () => tls.connect({ socket: tun, servername: host, rejectUnauthorized: false })
757
+ : () => tun;
758
+ const up = reqFn({ host, port, path, method: req.method, headers, createConnection: conn }, (upRes) => {
759
+ const meter = new RateMeter();
760
+ upRes.on("data", (d) => meter.add(d.length, Date.now()));
761
+ upRes.once("end", () => sampleSpeed(exit, meter, Date.now() - t0));
380
762
  res.writeHead(upRes.statusCode || 502, upRes.headers);
381
763
  upRes.pipe(res);
382
764
  });
@@ -385,7 +767,219 @@ export function startMultiExitRouter(opts) {
385
767
  req.pipe(up);
386
768
  };
387
769
  tun.on("data", onHdr);
388
- tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers); });
770
+ tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers, isTls); });
771
+ }
772
+ // ---- HLS segment prefetch (plain-HTTP streams only) ------------------------
773
+ // Fetch one URL through the region's exit pool, buffered, with failover.
774
+ // Rotates the starting exit across calls so concurrent prefetches spread over
775
+ // the fast pool and aggregate its bandwidth.
776
+ let prefetchRR = 0;
777
+ function fetchViaRegionBuffered(u, extraHeaders, label) {
778
+ return new Promise((resolvep, rejectp) => {
779
+ const host = u.hostname.toLowerCase();
780
+ const isTls = u.protocol === "https:";
781
+ const port = Number(u.port) || (isTls ? 443 : 80);
782
+ // routeFor covers static + learned; also honor a cached GeoIP=China
783
+ // verdict (the playlist fetch just warmed it for this host) so segments
784
+ // on a geo-classified CDN ride the same region.
785
+ let region = routeFor(host);
786
+ if (region === "direct" && geoCn.peek(host) === true && fallbackRegion)
787
+ region = fallbackRegion;
788
+ if (region === "direct" || !regionHasExits(region)) {
789
+ rejectp(new Error("no region for host"));
790
+ return;
791
+ }
792
+ const base = pickOrder(region);
793
+ if (base.length === 0) {
794
+ rejectp(new Error("no healthy exit"));
795
+ return;
796
+ }
797
+ const start = prefetchRR++ % Math.min(3, base.length);
798
+ const order = [...base.slice(start), ...base.slice(0, start)];
799
+ const path = (u.pathname || "/") + (u.search || "");
800
+ const target = `${host}:${port}`;
801
+ const tail = (u.pathname || "/").split("/").pop() || path;
802
+ const tryIdx = (idx) => {
803
+ if (idx >= order.length) {
804
+ rejectp(new Error("all exits failed"));
805
+ return;
806
+ }
807
+ const exit = order[idx];
808
+ const tun = net.connect(exit.port, exit.host);
809
+ let moved = false;
810
+ const move = () => { if (!moved) {
811
+ moved = true;
812
+ tun.destroy();
813
+ tryIdx(idx + 1);
814
+ } };
815
+ tun.setTimeout(EXIT_CONNECT_TIMEOUT_MS, move);
816
+ tun.once("connect", () => tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
817
+ tun.on("error", move);
818
+ let buf = Buffer.alloc(0);
819
+ const onHdr = (chunk) => {
820
+ buf = Buffer.concat([buf, chunk]);
821
+ const i = buf.indexOf("\r\n\r\n");
822
+ if (i === -1)
823
+ return;
824
+ tun.removeListener("data", onHdr);
825
+ const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
826
+ if (!/ 200/.test(statusLine)) {
827
+ move();
828
+ return;
829
+ }
830
+ moved = true; // established — failover window is over
831
+ tun.setTimeout(0);
832
+ exit.lastSuccessMs = Date.now();
833
+ exit.fails = 0;
834
+ if (!exit.healthy)
835
+ exit.healthy = true;
836
+ exit.served++;
837
+ const leftover = buf.slice(i + 4);
838
+ if (leftover.length)
839
+ tun.unshift(leftover);
840
+ const t0 = Date.now();
841
+ // For an https origin, run TLS over the exit tunnel so we speak
842
+ // straight to the CDN (the exit only ever sees ciphertext). For http,
843
+ // the raw tunnel socket carries plaintext HTTP directly.
844
+ const reqFn = isTls ? https.request : http.request;
845
+ const conn = isTls
846
+ ? () => tls.connect({ socket: tun, servername: host, rejectUnauthorized: false })
847
+ : () => tun;
848
+ const up = reqFn({ host, port, path, method: "GET", headers: { ...extraHeaders, host: u.host }, createConnection: conn }, (upRes) => {
849
+ const chunks = [];
850
+ const meter = new RateMeter();
851
+ upRes.on("data", (d) => { chunks.push(d); meter.add(d.length, Date.now()); });
852
+ upRes.once("end", () => {
853
+ const body = Buffer.concat(chunks);
854
+ const durMs = Date.now() - t0;
855
+ sampleSpeed(exit, meter, durMs);
856
+ if (label) {
857
+ const kb = (body.length / 1024).toFixed(0);
858
+ const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
859
+ log(` ${label} ${tail} via ${exit.name} — ${kb}KB in ${durMs}ms = ${mbps}Mbps`);
860
+ }
861
+ resolvep({ status: upRes.statusCode || 502, headers: upRes.headers, body });
862
+ });
863
+ upRes.once("error", (e) => rejectp(e));
864
+ });
865
+ up.setTimeout(30_000, () => up.destroy(new Error("prefetch timeout")));
866
+ up.on("error", (e) => rejectp(e));
867
+ up.end();
868
+ };
869
+ tun.on("data", onHdr);
870
+ };
871
+ tryIdx(0);
872
+ });
873
+ }
874
+ const hls = new HlsPrefetcher(fetchViaRegionBuffered);
875
+ let lastHlsHits = 0;
876
+ function bumpHlsStats() {
877
+ const s = hls.stats;
878
+ if (s.hits - lastHlsHits >= 20) {
879
+ lastHlsHits = s.hits;
880
+ log(`hls cache: ${s.hits} hits / ${s.misses} misses / ${s.entries} entries / ${(s.bytes / 1048576).toFixed(0)}MB`);
881
+ }
882
+ }
883
+ // Shared HLS-accelerated GET path, used by BOTH the plain-http proxy handler
884
+ // (http origin) and the MITM gateway (https origin). A cached segment is
885
+ // answered from memory; a playlist is fetched buffered so its segment list
886
+ // seeds the prefetcher (which pulls the upcoming segments in parallel across
887
+ // the exit pool); a cold segment is fetched buffered and cached; everything
888
+ // else streams through one exit.
889
+ function serveHlsAware(o) {
890
+ const { fullUrl, host, port, path, target, method, headers, req, res, order, isTls } = o;
891
+ const u = new URL(fullUrl);
892
+ const cacheable = method === "GET" && !headers["range"];
893
+ if (cacheable) {
894
+ const hit = hls.lookup(fullUrl);
895
+ if (hit) {
896
+ res.writeHead(200, {
897
+ "content-type": hit.contentType ?? "application/octet-stream",
898
+ "content-length": hit.body.length,
899
+ "x-agentnet-cache": "hit",
900
+ });
901
+ res.end(hit.body);
902
+ bumpHlsStats();
903
+ return;
904
+ }
905
+ const isPlaylist = looksLikePlaylist(u.pathname || "");
906
+ const isSegment = looksLikeSegment(u.pathname || "");
907
+ if (isPlaylist || isSegment) {
908
+ const fwd = {};
909
+ for (const [k, v] of Object.entries(headers)) {
910
+ if (typeof v === "string" && ["user-agent", "referer", "origin", "cookie", "authorization"].includes(k))
911
+ fwd[k] = v;
912
+ }
913
+ fetchViaRegionBuffered(u, fwd, isPlaylist ? "playlist" : "segment")
914
+ .then((r) => {
915
+ const outHeaders = { ...r.headers };
916
+ delete outHeaders["transfer-encoding"];
917
+ outHeaders["content-length"] = r.body.length;
918
+ res.writeHead(r.status, outHeaders);
919
+ res.end(r.body);
920
+ if (r.status === 200) {
921
+ const ct = r.headers["content-type"];
922
+ if (isPlaylist) {
923
+ const text = r.body.toString("utf8");
924
+ const variants = parseM3u8Variants(text, fullUrl);
925
+ if (variants.length > 0) {
926
+ const desc = variants.map((v) => `${v.resolution || "?"}@${(v.bandwidth / 1e6).toFixed(1)}Mbps`).join(", ");
927
+ log(` playlist ${host} is MASTER — variants: ${desc}`);
928
+ }
929
+ else {
930
+ const segs = parseM3u8Segments(text, fullUrl);
931
+ log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
932
+ }
933
+ hls.onPlaylist(fullUrl, text);
934
+ }
935
+ else {
936
+ hls.put(fullUrl, r.body, Array.isArray(ct) ? ct[0] : ct);
937
+ }
938
+ }
939
+ })
940
+ .catch(() => {
941
+ if (!res.headersSent)
942
+ httpForwardViaExit(order, 0, target, host, port, path, req, res, headers, isTls);
943
+ else
944
+ res.destroy();
945
+ });
946
+ return;
947
+ }
948
+ }
949
+ httpForwardViaExit(order, 0, target, host, port, path, req, res, headers, isTls);
950
+ }
951
+ // ---- HLS aggregation gateway (selective HTTPS MITM) -----------------------
952
+ // Only enabled with hlsAccel + a configDir for the CA. We MITM a host only
953
+ // when it's routed to a region (China) AND its name looks like a video CDN —
954
+ // never generic HTTPS (banking, mail, APIs), which stays an opaque tunnel.
955
+ const VIDEO_HOST_RE = /(cctv|cntv|live|vod|hls|video|stream|liveplay|bdydns|myalicdn|volcfcdn|myqcloud|gslb|byteimg|volcfcdn|bilivideo|hdslb|ivideo|tvyun)/;
956
+ let mitm = null;
957
+ if (opts.hlsAccel && opts.configDir) {
958
+ mitm = new MitmGateway({
959
+ configDir: opts.configDir,
960
+ log,
961
+ onRequest: (req, res, host, port) => {
962
+ const path = req.url || "/";
963
+ const headers = { ...req.headers };
964
+ delete headers["proxy-connection"];
965
+ void resolveRegion(host).then((region) => {
966
+ const order = region === "direct" ? [] : pickOrder(region);
967
+ if (order.length === 0) {
968
+ res.writeHead(502, { "content-type": "text/plain" });
969
+ res.end("no exit for MITM host\n");
970
+ return;
971
+ }
972
+ serveHlsAware({
973
+ fullUrl: `https://${host}${port === 443 ? "" : `:${port}`}${path}`,
974
+ host, port, path, target: `${host}:${port}`,
975
+ method: req.method || "GET", headers, req, res, order, isTls: true,
976
+ });
977
+ });
978
+ },
979
+ });
980
+ }
981
+ function shouldMitm(host, region) {
982
+ return mitm !== null && region !== "direct" && VIDEO_HOST_RE.test(host);
389
983
  }
390
984
  const server = http.createServer((req, res) => {
391
985
  // Browsers send proxy HTTP requests in absolute-form: `GET http://h:p/path`.
@@ -411,62 +1005,76 @@ export function startMultiExitRouter(opts) {
411
1005
  // Forward the client's headers minus hop-by-hop proxy bits.
412
1006
  const headers = { ...req.headers };
413
1007
  delete headers["proxy-connection"];
414
- let region = routeFor(host);
415
- if (region !== "direct" && !regionHasExits(region))
416
- region = "direct";
417
- if (!seenHosts.has(host)) {
418
- seenHosts.add(host);
419
- log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`} (http)`);
420
- }
421
- if (region === "direct") {
422
- httpForwardDirect(host, port, path, req, res, headers);
423
- return;
424
- }
425
- const order = pickOrder(region);
426
- if (order.length === 0) {
427
- res.writeHead(503, { "content-type": "text/plain" });
428
- res.end("no healthy exit for region\n");
429
- return;
430
- }
431
- httpForwardViaExit(order, 0, target, host, port, path, req, res, headers);
1008
+ void resolveRegion(host).then((region) => {
1009
+ if (!seenHosts.has(host)) {
1010
+ seenHosts.add(host);
1011
+ log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`} (http)`);
1012
+ }
1013
+ if (region === "direct") {
1014
+ httpForwardDirect(host, port, path, req, res, headers);
1015
+ return;
1016
+ }
1017
+ const order = pickOrder(region);
1018
+ if (order.length === 0) {
1019
+ res.writeHead(503, { "content-type": "text/plain" });
1020
+ res.end("no healthy exit for region\n");
1021
+ return;
1022
+ }
1023
+ const fullUrl = `http://${host}${port === 80 ? "" : `:${port}`}${path}`;
1024
+ serveHlsAware({ fullUrl, host, port, path, target, method: req.method || "GET", headers, req, res, order, isTls: false });
1025
+ });
432
1026
  });
433
1027
  server.on("connect", (req, client, head) => {
434
1028
  const target = req.url || "";
435
1029
  const host = (target.split(":")[0] || "").toLowerCase();
436
1030
  client.on("error", () => { });
437
- let region = routeFor(host);
438
- // If the chosen region has no exits configured, fall back to direct rather
439
- // than 503 (a misconfigured default shouldn't black-hole everything).
440
- if (region !== "direct" && !regionHasExits(region))
441
- region = "direct";
442
- if (!seenHosts.has(host)) {
443
- seenHosts.add(host);
444
- log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`}`);
445
- }
446
- if (region === "direct") {
447
- directConnect(target, client, head);
448
- return;
449
- }
450
- const order = pickOrder(region);
451
- if (order.length === 0) {
452
- // Region matched but every exit in it is down. Do NOT silently leak to
453
- // direct — a .cn host would just fail from abroad, and the operator
454
- // should see the region is down.
455
- log(` ${target}: region [${region}] has no healthy exit`);
456
- client.end("HTTP/1.1 503 No healthy exit for region\r\n\r\n");
457
- return;
458
- }
459
- let i = 0;
460
- const tryNext = (why) => {
461
- if (why)
462
- log(` ${target}: ${order[i - 1]?.name} failed (${why}), retrying`);
463
- if (i >= order.length) {
464
- client.end("HTTP/1.1 502 All exits failed\r\n\r\n");
1031
+ void resolveRegion(host).then((region) => {
1032
+ if (client.destroyed)
465
1033
  return;
1034
+ if (!seenHosts.has(host)) {
1035
+ seenHosts.add(host);
1036
+ log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`}`);
466
1037
  }
467
- forward(order[i++], target, client, head, tryNext);
468
- };
469
- tryNext();
1038
+ if (region === "direct") {
1039
+ directConnect(target, client, head);
1040
+ return;
1041
+ }
1042
+ // HLS aggregation: for a China video-CDN host (and only those), MITM the
1043
+ // TLS so we can see and parallel-prefetch its segments across the pool.
1044
+ // Everything else stays an opaque tunnel.
1045
+ if (mitm && shouldMitm(host, region)) {
1046
+ const cport = Number(target.split(":")[1]) || 443;
1047
+ if (!seenMitm.has(host)) {
1048
+ seenMitm.add(host);
1049
+ log(` ${host}: HLS-accel (MITM) enabled`);
1050
+ }
1051
+ try {
1052
+ mitm.handleConnect(host, cport, client, head);
1053
+ return;
1054
+ }
1055
+ catch { /* fall through to opaque tunnel on any MITM error */ }
1056
+ }
1057
+ const order = pickOrder(region);
1058
+ if (order.length === 0) {
1059
+ // Region matched but every exit in it is down. Do NOT silently leak to
1060
+ // direct — a .cn host would just fail from abroad, and the operator
1061
+ // should see the region is down.
1062
+ log(` ${target}: region [${region}] has no healthy exit`);
1063
+ client.end("HTTP/1.1 503 No healthy exit for region\r\n\r\n");
1064
+ return;
1065
+ }
1066
+ let i = 0;
1067
+ const tryNext = (why) => {
1068
+ if (why)
1069
+ log(` ${target}: ${order[i - 1]?.name} failed (${why}), retrying`);
1070
+ if (i >= order.length) {
1071
+ client.end("HTTP/1.1 502 All exits failed\r\n\r\n");
1072
+ return;
1073
+ }
1074
+ forward(order[i++], target, client, head, tryNext);
1075
+ };
1076
+ tryNext();
1077
+ });
470
1078
  });
471
1079
  server.listen(listenPort, listenHost, () => {
472
1080
  log(`Multi-exit router [${mode}] on http://${listenHost}:${listenPort}`);
@@ -479,11 +1087,18 @@ export function startMultiExitRouter(opts) {
479
1087
  }
480
1088
  log(` default (unmatched hosts) → ${defaultRegion === "direct" ? "DIRECT" : `region [${defaultRegion}]`}`);
481
1089
  log(`Point your browser's HTTPS proxy at ${listenHost}:${listenPort}`);
1090
+ if (mitm) {
1091
+ log(`HLS acceleration ON — selectively decrypting China video CDNs to parallel-prefetch across exits.`);
1092
+ log(` TRUST THIS CA ONCE so the browser accepts the local certs:`);
1093
+ log(` ${mitm.caCertPath}`);
1094
+ log(` macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${mitm.caCertPath}"`);
1095
+ log(` (or import it in the browser's certificate settings as a trusted root)`);
1096
+ }
482
1097
  void healthLoop();
483
1098
  });
484
1099
  if (mode === "loadbalance") {
485
1100
  const reporter = setInterval(() => {
486
- const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total`);
1101
+ const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total / ${fmtMbps(e.speedEwma)}`);
487
1102
  parts.push(`direct: ${directActive} active / ${directTotal} total`);
488
1103
  log(`load — ${parts.join(" | ")}`);
489
1104
  }, 15000);