@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.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/carrier/peer-manager.d.ts +10 -0
- package/dist/carrier/peer-manager.js +25 -1
- package/dist/carrier/types.d.ts +1 -0
- package/dist/carrier/types.js +6 -0
- package/dist/cli/commands.d.ts +1 -0
- package/dist/cli/commands.js +3 -0
- package/dist/cli/index.js +2 -0
- package/dist/daemon/server.js +7 -0
- package/dist/proxy/data/chnroutes-cn.d.ts +1 -0
- package/dist/proxy/data/chnroutes-cn.js +691 -0
- package/dist/proxy/geo-cn.d.ts +25 -0
- package/dist/proxy/geo-cn.js +144 -0
- package/dist/proxy/hls-prefetch.d.ts +67 -0
- package/dist/proxy/hls-prefetch.js +195 -0
- package/dist/proxy/mitm-ca.d.ts +27 -0
- package/dist/proxy/mitm-ca.js +120 -0
- package/dist/proxy/mitm-gateway.d.ts +22 -0
- package/dist/proxy/mitm-gateway.js +76 -0
- package/dist/proxy/multi-exit-router.d.ts +13 -0
- package/dist/proxy/multi-exit-router.js +693 -78
- package/dist/proxy/route-learner.d.ts +33 -0
- package/dist/proxy/route-learner.js +129 -0
- package/dist/router/paced-forwarder.d.ts +45 -0
- package/dist/router/paced-forwarder.js +188 -0
- package/dist/router/packet-router.d.ts +10 -0
- package/dist/router/packet-router.js +74 -1
- package/dist/types.d.ts +7 -0
- package/package.json +4 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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) => {
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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);
|