@decentnetwork/lan 0.1.184 → 0.1.187
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/cli/commands.d.ts +11 -0
- package/dist/cli/commands.js +40 -0
- package/dist/cli/index.js +4 -1
- package/dist/proxy/hls-prefetch.d.ts +23 -4
- package/dist/proxy/hls-prefetch.js +182 -46
- package/dist/proxy/multi-exit-router.js +413 -96
- package/dist/ui/desktop/app.js +9 -4
- package/dist/ui/server.js +38 -1
- package/package.json +2 -2
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -341,6 +341,17 @@ export declare function cmdUi(args: {
|
|
|
341
341
|
doraDir?: string;
|
|
342
342
|
configDir?: string;
|
|
343
343
|
}): Promise<void>;
|
|
344
|
+
/**
|
|
345
|
+
* `agentnet proxy trust-ca` — one command for a new user to generate the
|
|
346
|
+
* HLS-accel MITM CA (if it doesn't exist yet) and install it as a trusted
|
|
347
|
+
* root, so `agentnet proxy router --hls-accel` works without cert errors.
|
|
348
|
+
* The CA is generated LOCALLY per machine (never shared), lives in
|
|
349
|
+
* ~/.agentnet/mitm-ca-cert.pem, and only ever signs certs for the China
|
|
350
|
+
* video-CDN hosts the router MITMs.
|
|
351
|
+
*/
|
|
352
|
+
export declare function cmdProxyTrustCa(args: {
|
|
353
|
+
configDir?: string;
|
|
354
|
+
}): Promise<void>;
|
|
344
355
|
export declare function cmdProxyRouter(args: {
|
|
345
356
|
exit?: string[];
|
|
346
357
|
port?: number;
|
package/dist/cli/commands.js
CHANGED
|
@@ -1392,6 +1392,46 @@ export async function cmdUi(args) {
|
|
|
1392
1392
|
// Keep the process alive; runs until Ctrl-C / signal.
|
|
1393
1393
|
await new Promise(() => { });
|
|
1394
1394
|
}
|
|
1395
|
+
/**
|
|
1396
|
+
* `agentnet proxy trust-ca` — one command for a new user to generate the
|
|
1397
|
+
* HLS-accel MITM CA (if it doesn't exist yet) and install it as a trusted
|
|
1398
|
+
* root, so `agentnet proxy router --hls-accel` works without cert errors.
|
|
1399
|
+
* The CA is generated LOCALLY per machine (never shared), lives in
|
|
1400
|
+
* ~/.agentnet/mitm-ca-cert.pem, and only ever signs certs for the China
|
|
1401
|
+
* video-CDN hosts the router MITMs.
|
|
1402
|
+
*/
|
|
1403
|
+
export async function cmdProxyTrustCa(args) {
|
|
1404
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
1405
|
+
const { loadOrCreateCa } = await import("../proxy/mitm-ca.js");
|
|
1406
|
+
const { caCertPath, created } = loadOrCreateCa(dir);
|
|
1407
|
+
console.log(created ? `Generated new MITM CA: ${caCertPath}` : `MITM CA already exists: ${caCertPath}`);
|
|
1408
|
+
const { spawnSync } = await import("child_process");
|
|
1409
|
+
if (process.platform === "darwin") {
|
|
1410
|
+
console.log("Installing into the System keychain (asks for your admin password)...");
|
|
1411
|
+
const r = spawnSync("sudo", ["security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", caCertPath], { stdio: "inherit" });
|
|
1412
|
+
if (r.status === 0) {
|
|
1413
|
+
console.log("✓ Trusted. Chrome/Safari pick it up immediately (reload the page).");
|
|
1414
|
+
}
|
|
1415
|
+
else {
|
|
1416
|
+
console.log("Install failed — run manually:");
|
|
1417
|
+
console.log(` sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${caCertPath}"`);
|
|
1418
|
+
}
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (process.platform === "linux") {
|
|
1422
|
+
console.log("Installing into the system CA store (asks for your sudo password)...");
|
|
1423
|
+
const cp = spawnSync("sudo", ["cp", caCertPath, "/usr/local/share/ca-certificates/agentnet-mitm-ca.crt"], { stdio: "inherit" });
|
|
1424
|
+
const up = cp.status === 0 ? spawnSync("sudo", ["update-ca-certificates"], { stdio: "inherit" }) : cp;
|
|
1425
|
+
if (up.status === 0)
|
|
1426
|
+
console.log("✓ System store updated.");
|
|
1427
|
+
console.log("NOTE: Chrome/Chromium on Linux use their OWN store (NSS). Also run:");
|
|
1428
|
+
console.log(` certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n agentnet-mitm-ca -i "${caCertPath}"`);
|
|
1429
|
+
console.log("(package: libnss3-tools). Firefox: Settings → Certificates → Import.");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
console.log("Windows: import the cert into 'Trusted Root Certification Authorities':");
|
|
1433
|
+
console.log(` certutil -addstore -f ROOT "${caCertPath}"`);
|
|
1434
|
+
}
|
|
1395
1435
|
export async function cmdProxyRouter(args) {
|
|
1396
1436
|
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
1397
1437
|
const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
|
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
|
|
|
10
10
|
// Belt-and-braces — also raise it here in case the CLI is run directly
|
|
11
11
|
// (e.g. `node dist/cli/index.js` rather than via dist/index.js).
|
|
12
12
|
EventEmitter.defaultMaxListeners = 100;
|
|
13
|
-
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
13
|
+
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
14
14
|
async function main() {
|
|
15
15
|
await yargs(hideBin(process.argv))
|
|
16
16
|
.scriptName("agentnet")
|
|
@@ -350,6 +350,9 @@ async function main() {
|
|
|
350
350
|
.option("peer", { type: "string", demandOption: true, describe: "Peer name or carrier ID" })
|
|
351
351
|
.option("config-dir", { type: "string" }), async (argv) => {
|
|
352
352
|
await cmdProxyUse({ peer: argv.peer, configDir: argv["config-dir"] });
|
|
353
|
+
})
|
|
354
|
+
.command("trust-ca", "Generate (if needed) and trust the HLS-accel MITM CA so --hls-accel works without cert errors", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
|
|
355
|
+
await cmdProxyTrustCa({ configDir: argv["config-dir"] });
|
|
353
356
|
})
|
|
354
357
|
.command("router", "Run a local multi-exit HTTPS proxy: load-balance/failover across exits, China-split tunnel", (yy) => yy
|
|
355
358
|
.option("exit", {
|
|
@@ -4,8 +4,9 @@ export interface FetchedResponse {
|
|
|
4
4
|
body: Buffer;
|
|
5
5
|
}
|
|
6
6
|
/** Fetch one absolute http:// URL through some exit; provided by the router.
|
|
7
|
-
* `label` lets the router log the fetch (e.g. "prefetch").
|
|
8
|
-
|
|
7
|
+
* `label` lets the router log the fetch (e.g. "prefetch"). `signal` lets a
|
|
8
|
+
* hedged stripe abort a slow fetch and retry on another exit. */
|
|
9
|
+
export type UrlFetcher = (url: URL, headers: Record<string, string>, label?: string, signal?: AbortSignal, exitHint?: number) => Promise<FetchedResponse>;
|
|
9
10
|
export declare function looksLikePlaylist(pathname: string): boolean;
|
|
10
11
|
export declare function looksLikeSegment(pathname: string): boolean;
|
|
11
12
|
export interface HlsVariant {
|
|
@@ -39,19 +40,37 @@ export interface HlsPrefetcherOptions {
|
|
|
39
40
|
prefetchAhead?: number;
|
|
40
41
|
/** Max concurrent prefetch downloads (default 4). */
|
|
41
42
|
concurrency?: number;
|
|
43
|
+
/** Dynamic concurrency: how many DISTINCT exits are actually delivering right
|
|
44
|
+
* now. Each concurrent prefetch is a whole-segment fetch that lands on its
|
|
45
|
+
* own exit (the router's load-aware pick spreads them), so parallelism above
|
|
46
|
+
* the working-exit count just splits one exit's bandwidth between segments —
|
|
47
|
+
* while parallelism below it leaves working exits idle. Overrides
|
|
48
|
+
* `concurrency` when provided. */
|
|
49
|
+
concurrencyFn?: () => number;
|
|
42
50
|
/** Skip caching bodies larger than this (default 32 MB). */
|
|
43
51
|
maxEntryBytes?: number;
|
|
44
52
|
/** Split each segment into this many parallel byte-range stripes across the
|
|
45
|
-
* exit pool (default
|
|
53
|
+
* exit pool (default 4). 1 disables striping. Lets ONE segment ride the SUM
|
|
46
54
|
* of the exits' bandwidth instead of a single throttled connection. */
|
|
47
55
|
stripes?: number;
|
|
48
|
-
/**
|
|
56
|
+
/** Dynamic stripe count: returns how many exits are ACTUALLY delivering right
|
|
57
|
+
* now, so a segment is split only as many ways as there are working exits.
|
|
58
|
+
* With one working exit it returns 1 (whole fetch — striping onto a single
|
|
59
|
+
* exit just serializes and stalls); as exits recover it rises and aggregation
|
|
60
|
+
* resumes. Overrides `stripes` when provided. */
|
|
61
|
+
stripesFn?: () => number;
|
|
62
|
+
/** Stripe chunk size — also the first probe chunk (default 256 KB). Segments
|
|
49
63
|
* smaller than this aren't striped. */
|
|
50
64
|
stripeChunkBytes?: number;
|
|
51
65
|
}
|
|
52
66
|
export declare class HlsPrefetcher {
|
|
53
67
|
#private;
|
|
54
68
|
constructor(fetcher: UrlFetcher, opts?: HlsPrefetcherOptions);
|
|
69
|
+
/** Fetch a segment as PARALLEL byte-range stripes across the exit pool, so one
|
|
70
|
+
* segment downloads at the SUM of the exits' bandwidth (beating the GFW
|
|
71
|
+
* per-connection throttle). Public entry for a cold on-demand segment miss;
|
|
72
|
+
* the result is what the router serves to the browser. */
|
|
73
|
+
fetchSegmentStriped(url: string, extraHeaders?: Record<string, string>): Promise<FetchedResponse>;
|
|
55
74
|
get stats(): {
|
|
56
75
|
hits: number;
|
|
57
76
|
misses: number;
|
|
@@ -17,11 +17,24 @@
|
|
|
17
17
|
// master playlist (#EXT-X-STREAM-INF) lists variants, not segments.
|
|
18
18
|
//
|
|
19
19
|
const SEGMENT_EXTS = [".ts", ".m4s", ".m4a", ".m4v", ".aac", ".mp3", ".mp4", ".vtt"];
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/** Per-stripe deadline: if a byte-range hasn't landed in this long, abort it and
|
|
21
|
+
* retry the SAME range on another exit — so one dead/throttled exit (0.1 Mbps)
|
|
22
|
+
* can't hang a whole segment for 30–54s. */
|
|
23
|
+
const STRIPE_DEADLINE_MS = 6000;
|
|
24
|
+
/** How many exits to try for one stripe before giving up on it. */
|
|
25
|
+
const STRIPE_TRIES = 3;
|
|
26
|
+
/** Whole-segment hedge delay: a live segment is ~4s of video; one that hasn't
|
|
27
|
+
* landed in this long is late (a healthy exit delivers in 1-3.5s), so a
|
|
28
|
+
* parallel duplicate on another exit is launched. Rarely fires in steady
|
|
29
|
+
* state; mainly absorbs redemption probes trapped by a still-bad exit. */
|
|
30
|
+
const WHOLE_HEDGE_MS = 5000;
|
|
31
|
+
/** Total size from a `Content-Range: bytes 0-255/900000` header, or null. */
|
|
32
|
+
function contentRangeTotal(h) {
|
|
33
|
+
const v = Array.isArray(h) ? h[0] : h;
|
|
34
|
+
if (!v)
|
|
35
|
+
return null;
|
|
36
|
+
const m = /\/\s*(\d+)\s*$/.exec(v.trim());
|
|
37
|
+
return m ? Number(m[1]) : null;
|
|
25
38
|
}
|
|
26
39
|
export function looksLikePlaylist(pathname) {
|
|
27
40
|
const p = pathname.toLowerCase();
|
|
@@ -96,8 +109,10 @@ export class HlsPrefetcher {
|
|
|
96
109
|
#ttlMs;
|
|
97
110
|
#ahead;
|
|
98
111
|
#concurrency;
|
|
112
|
+
#concurrencyFn;
|
|
99
113
|
#maxEntryBytes;
|
|
100
114
|
#stripes;
|
|
115
|
+
#stripesFn;
|
|
101
116
|
#stripeChunkBytes;
|
|
102
117
|
#hits = 0;
|
|
103
118
|
#misses = 0;
|
|
@@ -107,55 +122,175 @@ export class HlsPrefetcher {
|
|
|
107
122
|
this.#ttlMs = opts.ttlMs ?? 120_000;
|
|
108
123
|
this.#ahead = opts.prefetchAhead ?? 4;
|
|
109
124
|
this.#concurrency = opts.concurrency ?? 4;
|
|
125
|
+
this.#concurrencyFn = opts.concurrencyFn;
|
|
110
126
|
this.#maxEntryBytes = opts.maxEntryBytes ?? 32 * 1024 * 1024;
|
|
111
|
-
this.#stripes = Math.max(1, opts.stripes ??
|
|
112
|
-
this.#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
this.#stripes = Math.max(1, opts.stripes ?? 4);
|
|
128
|
+
this.#stripesFn = opts.stripesFn;
|
|
129
|
+
this.#stripeChunkBytes = opts.stripeChunkBytes ?? 256 * 1024;
|
|
130
|
+
}
|
|
131
|
+
/** Effective stripe count right now (dynamic if a stripesFn was provided). */
|
|
132
|
+
#stripeCount() {
|
|
133
|
+
const n = this.#stripesFn ? this.#stripesFn() : this.#stripes;
|
|
134
|
+
return Math.max(1, Math.floor(n));
|
|
135
|
+
}
|
|
136
|
+
/** Fetch a segment as PARALLEL byte-range stripes across the exit pool, so one
|
|
137
|
+
* segment downloads at the SUM of the exits' bandwidth (beating the GFW
|
|
138
|
+
* per-connection throttle). Public entry for a cold on-demand segment miss;
|
|
139
|
+
* the result is what the router serves to the browser. */
|
|
140
|
+
async fetchSegmentStriped(url, extraHeaders = {}) {
|
|
141
|
+
const cached = this.#peek(url);
|
|
142
|
+
if (cached)
|
|
143
|
+
return { status: 200, headers: { "content-type": cached.contentType ?? "application/octet-stream" }, body: cached.body };
|
|
144
|
+
// If a prefetch for this exact segment is already in flight, JOIN it instead
|
|
145
|
+
// of starting a second download — dedups the player's request against the
|
|
146
|
+
// prefetcher so the pool's bandwidth isn't split fetching the same bytes twice.
|
|
147
|
+
const inflight = this.#inflight.get(url);
|
|
148
|
+
if (inflight) {
|
|
149
|
+
const e = await inflight;
|
|
150
|
+
if (e)
|
|
151
|
+
return { status: 200, headers: { "content-type": e.contentType ?? "application/octet-stream" }, body: e.body };
|
|
128
152
|
}
|
|
129
|
-
|
|
130
|
-
|
|
153
|
+
// Start a TRACKED fetch so a concurrent prefetch+player collapse onto one.
|
|
154
|
+
const p = (async () => {
|
|
155
|
+
try {
|
|
156
|
+
const res = await this.#stripeFetch(new URL(url), extraHeaders);
|
|
157
|
+
if (res.status === 200 && res.body.length > 0 && res.body.length <= this.#maxEntryBytes) {
|
|
158
|
+
const ct = res.headers["content-type"];
|
|
159
|
+
const entry = { body: res.body, contentType: Array.isArray(ct) ? ct[0] : ct, at: Date.now() };
|
|
160
|
+
this.#insert(url, entry);
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
this.#inflight.delete(url);
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
this.#inflight.set(url, p);
|
|
173
|
+
const e = await p;
|
|
174
|
+
if (e)
|
|
175
|
+
return { status: 200, headers: { "content-type": e.contentType ?? "application/octet-stream" }, body: e.body };
|
|
176
|
+
return this.#stripeFetch(new URL(url), extraHeaders); // fetch failed → one direct attempt for a real status
|
|
177
|
+
}
|
|
178
|
+
/** One whole-body fetch through a single exit — the correctness fallback when
|
|
179
|
+
* the origin ignores Range or a stripe can't be completed. No deadline: if
|
|
180
|
+
* every exit is slow, a slow whole body still beats a corrupt/empty one.
|
|
181
|
+
*
|
|
182
|
+
* HEDGED when ≥2 exits are delivering: if the body hasn't landed within
|
|
183
|
+
* WHOLE_HEDGE_MS, launch a PARALLEL duplicate (the router's load-aware pick
|
|
184
|
+
* sends it to the best other exit) and take whichever finishes first. This
|
|
185
|
+
* absorbs the redemption probe: a just-redeemed exit that traps its probe
|
|
186
|
+
* segment for 13-15s used to stall playback every REDEMPTION cycle — now
|
|
187
|
+
* the hedge covers the segment while the probe runs to its deadline and
|
|
188
|
+
* floors the bad exit properly (the loser is NOT aborted, on purpose: an
|
|
189
|
+
* aborted probe never gets floored and would be re-probed forever). */
|
|
190
|
+
#whole(u, extra) {
|
|
191
|
+
const one = () => this.#fetcher(u, { ...extra, host: u.host }, "prefetch");
|
|
192
|
+
const canHedge = (this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency) >= 2;
|
|
193
|
+
if (!canHedge)
|
|
194
|
+
return one(); // single exit: a duplicate would just split its bandwidth
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
let settled = false;
|
|
197
|
+
let fails = 0;
|
|
198
|
+
let hedged = false;
|
|
199
|
+
let timer = null;
|
|
200
|
+
const win = (r) => { if (settled)
|
|
201
|
+
return; settled = true; if (timer)
|
|
202
|
+
clearTimeout(timer); resolve(r); };
|
|
203
|
+
const lose = (e) => { fails++; if (!settled && fails >= (hedged ? 2 : 1)) {
|
|
204
|
+
settled = true;
|
|
205
|
+
if (timer)
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
reject(e);
|
|
208
|
+
} };
|
|
209
|
+
one().then(win, lose);
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
timer = null;
|
|
212
|
+
if (settled)
|
|
213
|
+
return;
|
|
214
|
+
hedged = true;
|
|
215
|
+
this.#fetcher(u, { ...extra, host: u.host }, "prefetch-hedge").then(win, lose);
|
|
216
|
+
}, WHOLE_HEDGE_MS);
|
|
217
|
+
timer.unref?.();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Fetch one byte-range, HEDGED: on timeout/error/wrong-length, abort and retry
|
|
221
|
+
* the SAME range (the router rotates to a different exit each call), up to
|
|
222
|
+
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
223
|
+
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
224
|
+
* byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
|
|
225
|
+
async #fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
226
|
+
for (let t = 0; t < STRIPE_TRIES; t++) {
|
|
227
|
+
const ac = new AbortController();
|
|
228
|
+
const timer = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
|
|
229
|
+
timer.unref?.();
|
|
230
|
+
try {
|
|
231
|
+
// Each retry shifts to the NEXT exit so a failed range moves off a bad one.
|
|
232
|
+
const hint = exitHint === undefined ? undefined : exitHint + t;
|
|
233
|
+
const r = await this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint);
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
if (r.status === 200)
|
|
236
|
+
return r; // origin ignored Range → whole body
|
|
237
|
+
if (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))
|
|
238
|
+
return r;
|
|
239
|
+
// wrong status/length → try another exit
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
}
|
|
131
244
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
/** Split a segment into `stripes` parallel byte-ranges across the exit pool,
|
|
248
|
+
* each hedged. Assembles them into the full body with a corrected
|
|
249
|
+
* content-length. Falls back to one whole fetch if the origin ignores Range or
|
|
250
|
+
* any stripe truly can't be fetched (correctness over speed). */
|
|
251
|
+
async #stripeFetch(u, extra) {
|
|
252
|
+
const stripeCount = this.#stripeCount();
|
|
253
|
+
// Only ONE exit is delivering right now → striping would split the segment
|
|
254
|
+
// onto a single exit that serializes the parts (14s vs a 2.4s whole fetch).
|
|
255
|
+
// Just fetch the whole segment; aggregation resumes when a 2nd exit recovers.
|
|
256
|
+
if (stripeCount <= 1)
|
|
257
|
+
return this.#whole(u, extra);
|
|
258
|
+
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
259
|
+
// Range, cheaply — a big sequential probe would add a whole round-trip of
|
|
260
|
+
// latency before any stripe starts and starve the live buffer.
|
|
261
|
+
const probe = await this.#fetchRange(u, 0, 1, extra);
|
|
262
|
+
if (!probe)
|
|
263
|
+
return this.#whole(u, extra);
|
|
264
|
+
if (probe.status === 200)
|
|
265
|
+
return probe; // range ignored → whole body already here
|
|
266
|
+
if (probe.status !== 206)
|
|
267
|
+
return this.#whole(u, extra);
|
|
268
|
+
const total = contentRangeTotal(probe.headers["content-range"]);
|
|
269
|
+
if (!total || total <= 0)
|
|
270
|
+
return this.#whole(u, extra);
|
|
271
|
+
const headers = { ...probe.headers };
|
|
272
|
+
delete headers["content-range"];
|
|
273
|
+
// Split the WHOLE body into N equal stripes, fetched IN PARALLEL across the
|
|
274
|
+
// exit pool — so the segment rides the sum of the exits' bandwidth. Each
|
|
275
|
+
// stripe is hedged (timeout → retry on another exit), so a dead exit never
|
|
276
|
+
// stalls it.
|
|
277
|
+
const n = Math.max(1, Math.min(stripeCount, Math.ceil(total / this.#stripeChunkBytes)));
|
|
278
|
+
const chunk = Math.ceil(total / n);
|
|
144
279
|
const parts = await Promise.all(Array.from({ length: n }, (_, i) => {
|
|
145
|
-
const start =
|
|
280
|
+
const start = i * chunk;
|
|
146
281
|
const end = Math.min(start + chunk - 1, total - 1);
|
|
147
282
|
if (start > end)
|
|
148
283
|
return Promise.resolve(Buffer.alloc(0));
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.catch(() => null);
|
|
284
|
+
const want = end - start + 1;
|
|
285
|
+
return this.#fetchRange(u, start, end, extra, want, i).then((r) => (r && r.body.length === want ? r.body : null));
|
|
152
286
|
}));
|
|
153
287
|
if (parts.some((p) => p === null))
|
|
154
|
-
return whole(); // a stripe
|
|
155
|
-
const body = Buffer.concat(
|
|
288
|
+
return this.#whole(u, extra); // a stripe couldn't be fetched → whole for integrity
|
|
289
|
+
const body = Buffer.concat(parts);
|
|
156
290
|
if (body.length !== total)
|
|
157
|
-
return whole();
|
|
158
|
-
|
|
291
|
+
return this.#whole(u, extra);
|
|
292
|
+
headers["content-length"] = String(body.length);
|
|
293
|
+
return { status: 200, headers, body };
|
|
159
294
|
}
|
|
160
295
|
get stats() {
|
|
161
296
|
return { hits: this.#hits, misses: this.#misses, entries: this.#cache.size, bytes: this.#cacheBytes };
|
|
@@ -203,9 +338,10 @@ export class HlsPrefetcher {
|
|
|
203
338
|
// list from wherever it is, and refetches of the playlist don't happen —
|
|
204
339
|
// prefetching the head of the list still fills the startup buffer. Either
|
|
205
340
|
// way: take the last `ahead` entries we don't already hold.
|
|
341
|
+
const conc = Math.max(1, Math.floor(this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency));
|
|
206
342
|
const want = segs.slice(-this.#ahead).filter((u) => !this.#peek(u) && !this.#inflight.has(u));
|
|
207
343
|
for (const u of want) {
|
|
208
|
-
if (this.#inflight.size >=
|
|
344
|
+
if (this.#inflight.size >= conc)
|
|
209
345
|
break;
|
|
210
346
|
this.#prefetch(u);
|
|
211
347
|
}
|
|
@@ -214,7 +350,7 @@ export class HlsPrefetcher {
|
|
|
214
350
|
const p = (async () => {
|
|
215
351
|
try {
|
|
216
352
|
const u = new URL(url);
|
|
217
|
-
const res = await this.#stripeFetch(u);
|
|
353
|
+
const res = await this.#stripeFetch(u, {});
|
|
218
354
|
if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
|
|
219
355
|
return null;
|
|
220
356
|
const ct = res.headers["content-type"];
|
|
@@ -131,16 +131,40 @@ const STALL_MIN_BYTES = 16 * 1024; // delivered at least this = a healthy, real
|
|
|
131
131
|
const STALL_DEAD_BYTES = 1024; // relayed LESS than this = the exit is genuinely dead (only THIS counts as a stall); a small-but-real request (playlist/key/keep-alive, 1-16 KB) is NEUTRAL, so it can't flap a healthy exit out of the pool
|
|
132
132
|
const TRIP_AFTER_STALLS = 3; // consecutive DEAD tunnels before an exit is tripped out (hysteresis)
|
|
133
133
|
const TRIP_AFTER_UPSTREAM_FAILS = 5; // consecutive non-200 CONNECT responses (exit's proxy broken, e.g. 502s everything) before tripping
|
|
134
|
-
|
|
135
|
-
// fraction of the region's FASTEST proven exit. Below it (e.g. a 0.4 Mbps node
|
|
136
|
-
// next to a 1.2 Mbps one) it's benched while a healthy exit exists, so heavy
|
|
137
|
-
// streams don't stall on it. Unproven (unmeasured) exits are never benched.
|
|
138
|
-
const SLOW_EXIT_FLOOR_FRACTION = 0.4;
|
|
134
|
+
const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
|
|
139
135
|
// Hosts that must NEVER be learned into an exit region — global services
|
|
140
136
|
// reachable direct from anywhere; routing them through a china exit only 502s
|
|
141
137
|
// and starves it. Guards against the recurring google→china mislearning.
|
|
142
138
|
const NEVER_LEARN = /(^|\.)(google|gstatic|googleapis|googleusercontent|ggpht|gvt1|gvt2|youtube|ytimg|doubleclick|google-analytics|googletagmanager|firebase|crashlytics|facebook|fbcdn|instagram|twitter|twimg|cloudflare|cloudfront|akamai|akamaihd|amazonaws|apple|icloud|mzstatic|microsoft|windows|office|live|azureedge|github|githubusercontent|gitlab|cdn\.jsdelivr|unpkg|npmjs)\.(com|net|org|io|co|edu|dev|app)$/i;
|
|
143
|
-
|
|
139
|
+
// A prefetch/stripe exit whose measured speed is below this is "near-dead" and
|
|
140
|
+
// skipped while a healthier exit exists, so a single throttled exit (0.1 Mbps
|
|
141
|
+
// 1.16) can't be handed a segment/stripe and stall the player for 30–54s.
|
|
142
|
+
// Unproven (never-measured) exits are NOT skipped — they get a chance, and the
|
|
143
|
+
// per-stripe hedge covers them if they turn out dead.
|
|
144
|
+
// UNITS: speedEwma is BYTES/sec (fmtMbps multiplies by 8 for display), so this
|
|
145
|
+
// threshold is bytes/sec too. It was 250_000 with a "0.25 Mbps" comment — i.e.
|
|
146
|
+
// bits/sec — which silently meant "under 2 Mbit/s = near-dead": real working
|
|
147
|
+
// exits (1.6 Mbit/s = 200 KB/s) got filtered OUT of the fast pool and the one
|
|
148
|
+
// truly-broken unproven exit was left ALONE in the segment order. 60 KB/s
|
|
149
|
+
// (≈0.5 Mbit/s) keeps every usable exit in and sits safely ABOVE the 40 KB/s
|
|
150
|
+
// deprioritize floor so a floored exit still drops out.
|
|
151
|
+
const NEAR_DEAD_EXIT_BPS = 60_000;
|
|
152
|
+
// A buffered segment fetch is given a deadline sized to deliver its body at
|
|
153
|
+
// roughly this bitrate; an exit that can't keep up is abandoned and the next is
|
|
154
|
+
// tried. Live HLS needs each ~4s segment in a few seconds, so this is set near
|
|
155
|
+
// a mid-quality stream's rate — a slow exit is dropped in seconds (not the 27s a
|
|
156
|
+
// 0.35 Mbps floor allowed), and the player's ABR settles on a variant the pool
|
|
157
|
+
// can actually sustain instead of stalling on a too-high one.
|
|
158
|
+
const MIN_USEFUL_FETCH_BPS = 400_000; // 0.4 Mbps — abandon a trickle, but NOT a healthy-slow 0.6 Mbps exit
|
|
159
|
+
const FETCH_DEADLINE_MIN_MS = 5_000;
|
|
160
|
+
const FETCH_DEADLINE_MAX_MS = 13_000;
|
|
161
|
+
// On a lossy link a whole pass over the exits can fail by bad luck; loop the
|
|
162
|
+
// order this many rounds (with fast-failing attempts, most retries are cheap)
|
|
163
|
+
// before giving up, so the player almost never sees a hard failure.
|
|
164
|
+
const MAX_FETCH_ROUNDS = 4;
|
|
165
|
+
// Absolute cap on a cold segment fetch across ALL failover attempts — past this
|
|
166
|
+
// we give up and let the player re-request, never hanging the tab.
|
|
167
|
+
const SEGMENT_TOTAL_DEADLINE_MS = 25_000;
|
|
144
168
|
// Measures peak short-window throughput of a byte stream. Feed it every chunk
|
|
145
169
|
// length via add(); read peakBps at the end. Peak (not average) shrugs off TCP
|
|
146
170
|
// slow-start on high-RTT paths, where a transfer spends its first seconds
|
|
@@ -177,6 +201,15 @@ class RateMeter {
|
|
|
177
201
|
return totalDurMs > 0 ? (this.total / totalDurMs) * 1000 : 0;
|
|
178
202
|
}
|
|
179
203
|
}
|
|
204
|
+
/** Deprioritize an exit that trapped a fetch until the deadline or returned an
|
|
205
|
+
* empty 2xx: pin its speed score to the floor so pickOrder stops handing it
|
|
206
|
+
* segments — but KEEP it in the pool (last-resort failover, small requests). */
|
|
207
|
+
const FLOOR_BPS = 40_000;
|
|
208
|
+
const REDEMPTION_MS = 90_000;
|
|
209
|
+
function floorExit(exit) {
|
|
210
|
+
exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
|
|
211
|
+
exit.flooredAtMs = Date.now();
|
|
212
|
+
}
|
|
180
213
|
/** Feed one tunnel's rate meter into the exit's speed EWMA (peak-window based).
|
|
181
214
|
* Only transfers past SPEED_MIN_BYTES count — smaller ones never reveal path
|
|
182
215
|
* capacity on a high-RTT link. */
|
|
@@ -188,6 +221,7 @@ function sampleSpeed(exit, meter, durMs) {
|
|
|
188
221
|
if (bps <= 0)
|
|
189
222
|
return;
|
|
190
223
|
exit.speedEwma = exit.speedEwma === null ? bps : SPEED_EWMA_KEEP * exit.speedEwma + (1 - SPEED_EWMA_KEEP) * bps;
|
|
224
|
+
exit.flooredAtMs = null; // a real qualifying transfer is proof of recovery
|
|
191
225
|
}
|
|
192
226
|
function fmtMbps(bps) {
|
|
193
227
|
return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
|
|
@@ -223,10 +257,13 @@ export function startMultiExitRouter(opts) {
|
|
|
223
257
|
lastRtt: null,
|
|
224
258
|
lastSuccessMs: null,
|
|
225
259
|
consecUpstreamFails: 0,
|
|
260
|
+
emptyStreak: 0,
|
|
261
|
+
blackholeUntil: 0,
|
|
226
262
|
speedEwma: null,
|
|
227
263
|
bytesDown: 0,
|
|
228
264
|
stalls: 0,
|
|
229
265
|
trippedUntil: 0,
|
|
266
|
+
flooredAtMs: null,
|
|
230
267
|
}));
|
|
231
268
|
// Per-host routing decision, logged once on first sight so the user can SEE
|
|
232
269
|
// why a hostname went DIRECT vs through a region (e.g. a CCTV video CDN that
|
|
@@ -281,6 +318,16 @@ export function startMultiExitRouter(opts) {
|
|
|
281
318
|
// preconnect fix in suspiciousClose.
|
|
282
319
|
if (NEVER_LEARN.test(host))
|
|
283
320
|
return;
|
|
321
|
+
// Only learn a host into the china exit if GeoIP CONFIRMS it resolves to a
|
|
322
|
+
// China IP. A US-geo-blocked GLOBAL host (okx, okex, metamask, phantom,
|
|
323
|
+
// linkedin) ALSO fails DIRECT — but routing it through a china exit is wrong
|
|
324
|
+
// (china can't reach it either) and pollutes the pool. This gates ALL global
|
|
325
|
+
// hosts generically, no per-domain denylist needed. Kick off classification
|
|
326
|
+
// so the verdict is ready on a later failure (learning needs 2 strikes).
|
|
327
|
+
if (geoCn.peek(host) !== true) {
|
|
328
|
+
void geoCn.classify(host);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
284
331
|
if (learner.recordDirectBad(host))
|
|
285
332
|
learner.learn(host, fallbackRegion);
|
|
286
333
|
}
|
|
@@ -289,6 +336,25 @@ export function startMultiExitRouter(opts) {
|
|
|
289
336
|
// region. Catches China-CDN hostnames no suffix list anticipates (Baidu
|
|
290
337
|
// bdydns, etc.). Returns the region to use (may be "direct").
|
|
291
338
|
const geoCn = new GeoCnClassifier();
|
|
339
|
+
// Startup self-heal: re-validate every learned route against GeoIP and unlearn
|
|
340
|
+
// any that does NOT resolve to a China IP. This generically purges global hosts
|
|
341
|
+
// that older builds mislearned into the china exit (okx/okex/metamask/phantom/
|
|
342
|
+
// linkedin — geo-blocked from the US, so DIRECT failed and they got promoted),
|
|
343
|
+
// without a hand-maintained denylist. Best-effort + async (one DNS per host).
|
|
344
|
+
void (async () => {
|
|
345
|
+
let purged = 0;
|
|
346
|
+
for (const { host } of learner.entries()) {
|
|
347
|
+
try {
|
|
348
|
+
if (!(await geoCn.classify(host))) {
|
|
349
|
+
learner.unlearn(host);
|
|
350
|
+
purged++;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch { /* skip unresolvable */ }
|
|
354
|
+
}
|
|
355
|
+
if (purged > 0)
|
|
356
|
+
log(`geo-purged ${purged} learned route(s) that don't resolve to a China IP (global hosts mislearned into an exit)`);
|
|
357
|
+
})();
|
|
292
358
|
async function resolveRegion(host) {
|
|
293
359
|
let region = routeFor(host); // static domains + persistent learned routes
|
|
294
360
|
// Consult the geo classifier even when the fallback region currently has
|
|
@@ -459,24 +525,6 @@ export function startMultiExitRouter(opts) {
|
|
|
459
525
|
// Unproven exits still get overflow traffic once the proven ones fill up,
|
|
460
526
|
// which measures them safely without risking the video.
|
|
461
527
|
const unprovenBps = measuredVals.length > 0 ? measuredVals[0] : 800_000;
|
|
462
|
-
// Speed FLOOR: exclude exits that are dramatically slower than the best
|
|
463
|
-
// PROVEN exit. `score = speed/(active+1)` self-balances, but once the fast
|
|
464
|
-
// exit fills, a bandwidth-heavy stream (CCTV opens dozens of parallel
|
|
465
|
-
// segment tunnels) spills onto a degraded node (observed 0.4 Mbps vs 1.2
|
|
466
|
-
// Mbps) and stalls/rebuffers. Keep such nodes OUT of rotation while a
|
|
467
|
-
// healthy-speed exit exists; unproven exits (null speed) stay in so they
|
|
468
|
-
// still get measured, and we never empty the pool.
|
|
469
|
-
// Skip the floor when hls-accel is on: parallel-prefetch AGGREGATION wants
|
|
470
|
-
// every exit contributing bandwidth (even a slow 0.4 Mbps one adds to the
|
|
471
|
-
// sum), so benching slow exits would REDUCE aggregate throughput — the
|
|
472
|
-
// opposite of what a bandwidth-heavy HD stream needs.
|
|
473
|
-
if (!opts.hlsAccel && measuredVals.length >= 2) {
|
|
474
|
-
const best = measuredVals[measuredVals.length - 1];
|
|
475
|
-
const floor = best * SLOW_EXIT_FLOOR_FRACTION;
|
|
476
|
-
const fast = pool.filter((e) => e.speedEwma === null || e.speedEwma >= floor);
|
|
477
|
-
if (fast.length > 0)
|
|
478
|
-
pool = fast;
|
|
479
|
-
}
|
|
480
528
|
const score = (e) => (e.speedEwma ?? unprovenBps) / (e.active + 1);
|
|
481
529
|
return [...pool].sort((a, b) => score(b) - score(a) || a.served - b.served);
|
|
482
530
|
}
|
|
@@ -925,13 +973,97 @@ export function startMultiExitRouter(opts) {
|
|
|
925
973
|
tun.on("data", onHdr);
|
|
926
974
|
tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers, isTls); });
|
|
927
975
|
}
|
|
976
|
+
// ---- Keep-alive connection pool, one Agent per (exit, protocol) ------------
|
|
977
|
+
// Every buffered fetch used to build a FRESH path: TCP to the exit (1 RTT) +
|
|
978
|
+
// CONNECT (1 RTT) + TLS to the origin (1-2 RTT). Over a 280ms/lossy tunnel
|
|
979
|
+
// that's a ~2s handshake tax on EVERY playlist/segment/stripe — often more
|
|
980
|
+
// than the transfer itself (a 407-byte playlist measured 2.5s). A keep-alive
|
|
981
|
+
// Agent per exit reuses the tunneled (TLS) socket across requests to the same
|
|
982
|
+
// origin, so a hot stream pays the handshake once per (exit, origin).
|
|
983
|
+
const exitAgents = new Map();
|
|
984
|
+
function agentFor(exit, isTls) {
|
|
985
|
+
const key = `${exit.name}|${isTls ? "https" : "http"}`;
|
|
986
|
+
const hit = exitAgents.get(key);
|
|
987
|
+
if (hit)
|
|
988
|
+
return hit;
|
|
989
|
+
const mkConn = (options, cb) => {
|
|
990
|
+
const oHost = options.host ?? "";
|
|
991
|
+
const oPort = Number(options.port) || (isTls ? 443 : 80);
|
|
992
|
+
const target = `${oHost}:${oPort}`;
|
|
993
|
+
const tun = net.connect(exit.port, exit.host);
|
|
994
|
+
let done = false;
|
|
995
|
+
const fail = (e) => { if (done)
|
|
996
|
+
return; done = true; tun.destroy(); cb(e); };
|
|
997
|
+
tun.setTimeout(EXIT_CONNECT_TIMEOUT_MS, () => fail(new Error(`exit ${exit.name} connect timeout`)));
|
|
998
|
+
tun.once("error", fail);
|
|
999
|
+
tun.once("connect", () => tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
|
|
1000
|
+
let buf = Buffer.alloc(0);
|
|
1001
|
+
const onHdr = (chunk) => {
|
|
1002
|
+
buf = Buffer.concat([buf, chunk]);
|
|
1003
|
+
const i = buf.indexOf("\r\n\r\n");
|
|
1004
|
+
if (i === -1)
|
|
1005
|
+
return;
|
|
1006
|
+
tun.removeListener("data", onHdr);
|
|
1007
|
+
const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
|
|
1008
|
+
if (!/ 200/.test(statusLine)) {
|
|
1009
|
+
fail(new Error(`CONNECT via ${exit.name}: ${statusLine || "no status"}`));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
tun.setTimeout(0);
|
|
1013
|
+
tun.removeListener("error", fail);
|
|
1014
|
+
const leftover = buf.slice(i + 4);
|
|
1015
|
+
if (leftover.length)
|
|
1016
|
+
tun.unshift(leftover);
|
|
1017
|
+
done = true;
|
|
1018
|
+
if (!isTls) {
|
|
1019
|
+
cb(null, tun);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const s = tls.connect({ socket: tun, servername: options.servername || oHost, rejectUnauthorized: false });
|
|
1023
|
+
// The raw tunnel socket keeps living UNDER the TLS socket, and node
|
|
1024
|
+
// does NOT forward its 'error' events — an unhandled ECONNRESET on the
|
|
1025
|
+
// lossy tunnel would CRASH the process. Fold it into a TLS teardown.
|
|
1026
|
+
tun.on("error", () => { try {
|
|
1027
|
+
s.destroy();
|
|
1028
|
+
}
|
|
1029
|
+
catch { /* gone */ } });
|
|
1030
|
+
s.once("secureConnect", () => { s.removeAllListeners("error"); cb(null, s); });
|
|
1031
|
+
s.once("error", (e) => { s.destroy(); cb(e); });
|
|
1032
|
+
};
|
|
1033
|
+
tun.on("data", onHdr);
|
|
1034
|
+
};
|
|
1035
|
+
// lifo keeps the hottest (most recently proven alive) socket on top; the
|
|
1036
|
+
// socket timeout culls idle pooled sockets the CDN has silently dropped.
|
|
1037
|
+
const agentOpts = { keepAlive: true, keepAliveMsecs: 15_000, maxSockets: 8, maxFreeSockets: 6, scheduling: "lifo", timeout: 45_000 };
|
|
1038
|
+
const a = isTls ? new https.Agent(agentOpts) : new http.Agent(agentOpts);
|
|
1039
|
+
a.createConnection = mkConn;
|
|
1040
|
+
exitAgents.set(key, a);
|
|
1041
|
+
return a;
|
|
1042
|
+
}
|
|
928
1043
|
// ---- HLS segment prefetch (plain-HTTP streams only) ------------------------
|
|
929
1044
|
// Fetch one URL through the region's exit pool, buffered, with failover.
|
|
930
|
-
|
|
931
|
-
// the fast pool and aggregate its bandwidth.
|
|
932
|
-
let prefetchRR = 0;
|
|
933
|
-
function fetchViaRegionBuffered(u, extraHeaders, label) {
|
|
1045
|
+
function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint) {
|
|
934
1046
|
return new Promise((resolvep, rejectp) => {
|
|
1047
|
+
let settled = false;
|
|
1048
|
+
let curReq = null;
|
|
1049
|
+
let activeExit = null; // the exit whose active-count we've incremented
|
|
1050
|
+
const uncount = () => { if (activeExit) {
|
|
1051
|
+
activeExit.active = Math.max(0, activeExit.active - 1);
|
|
1052
|
+
activeExit = null;
|
|
1053
|
+
} };
|
|
1054
|
+
const onAbort = () => { try {
|
|
1055
|
+
curReq?.destroy();
|
|
1056
|
+
}
|
|
1057
|
+
catch { /* already gone */ } uncount(); finish(() => rejectp(new Error("aborted"))); };
|
|
1058
|
+
const finish = (fn) => { if (settled)
|
|
1059
|
+
return; settled = true; signal?.removeEventListener("abort", onAbort); fn(); };
|
|
1060
|
+
const resolve = (r) => finish(() => resolvep(r));
|
|
1061
|
+
const reject = (e) => finish(() => rejectp(e));
|
|
1062
|
+
if (signal?.aborted) {
|
|
1063
|
+
rejectp(new Error("aborted"));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
signal?.addEventListener("abort", onAbort);
|
|
935
1067
|
const host = u.hostname.toLowerCase();
|
|
936
1068
|
const isTls = u.protocol === "https:";
|
|
937
1069
|
const port = Number(u.port) || (isTls ? 443 : 80);
|
|
@@ -942,92 +1074,215 @@ export function startMultiExitRouter(opts) {
|
|
|
942
1074
|
if (region === "direct" && geoCn.peek(host) === true && fallbackRegion)
|
|
943
1075
|
region = fallbackRegion;
|
|
944
1076
|
if (region === "direct" || !regionHasExits(region)) {
|
|
945
|
-
|
|
1077
|
+
reject(new Error("no region for host"));
|
|
946
1078
|
return;
|
|
947
1079
|
}
|
|
948
|
-
const
|
|
949
|
-
if (
|
|
950
|
-
|
|
1080
|
+
const base0 = pickOrder(region);
|
|
1081
|
+
if (base0.length === 0) {
|
|
1082
|
+
reject(new Error("no healthy exit"));
|
|
951
1083
|
return;
|
|
952
1084
|
}
|
|
953
|
-
|
|
954
|
-
|
|
1085
|
+
// Skip near-dead exits (measured below the floor) while a healthier exit
|
|
1086
|
+
// exists, so a segment/stripe is never handed to a 0.1 Mbps exit that would
|
|
1087
|
+
// stall the player. Unproven exits are kept (they get a chance; the caller's
|
|
1088
|
+
// hedge covers them if they turn out dead).
|
|
1089
|
+
// A striped range (exitHint set) pins to a DISTINCT exit across the FULL
|
|
1090
|
+
// healthy pool (base0, already fastest-first, tripped exits excluded) so
|
|
1091
|
+
// parallel ranges spread over ALL usable exits and aggregate — even the
|
|
1092
|
+
// moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
|
|
1093
|
+
// single (non-striped) fetch instead skips near-dead exits and rotates.
|
|
1094
|
+
let order;
|
|
1095
|
+
if (exitHint !== undefined) {
|
|
1096
|
+
// Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
|
|
1097
|
+
// sorted by speed/(active+1), least-loaded-fastest first). Because each
|
|
1098
|
+
// buffered fetch now increments exit.active at connect time, concurrent
|
|
1099
|
+
// stripes each see the prior stripe's load and pick the NEXT exit —
|
|
1100
|
+
// spreading across DISTINCT exits and aggregating their bandwidth. (An
|
|
1101
|
+
// explicit exitHint pin fought this: base0 reorders under load, so the
|
|
1102
|
+
// pinned index kept landing back on the same fast exit.)
|
|
1103
|
+
order = base0;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
// Load-aware order for whole fetches too: exit.active is incremented at
|
|
1107
|
+
// DISPATCH, so when the prefetcher launches several segments in one tick
|
|
1108
|
+
// the 2nd fetch already sees the 1st one's load and starts on the NEXT
|
|
1109
|
+
// exit — concurrent segments spread across DISTINCT exits instead of
|
|
1110
|
+
// splitting one exit's bandwidth N ways (observed: 3 segments all on
|
|
1111
|
+
// 1.15 at ~1Mbps each while 1.16 sat idle). A blind RR rotation kept
|
|
1112
|
+
// colliding because it ignored who was actually busy.
|
|
1113
|
+
const fast = base0.filter((e) => e.speedEwma === null || e.speedEwma >= NEAR_DEAD_EXIT_BPS);
|
|
1114
|
+
order = fast.length > 0 ? fast : base0;
|
|
1115
|
+
}
|
|
955
1116
|
const path = (u.pathname || "/") + (u.search || "");
|
|
956
|
-
const target = `${host}:${port}`;
|
|
957
1117
|
const tail = (u.pathname || "/").split("/").pop() || path;
|
|
958
|
-
const tryIdx = (idx) => {
|
|
1118
|
+
const tryIdx = (idx, round = 0) => {
|
|
1119
|
+
if (settled)
|
|
1120
|
+
return;
|
|
959
1121
|
if (idx >= order.length) {
|
|
960
|
-
|
|
1122
|
+
// On this lossy link (25–50% loss) a single pass can fail every exit by
|
|
1123
|
+
// bad luck; a fresh retry usually succeeds. Loop the whole exit order a
|
|
1124
|
+
// few rounds before giving up, so the player almost never sees a hard
|
|
1125
|
+
// failure (which trips its error watchdog → error.html → black screen).
|
|
1126
|
+
if (round + 1 < MAX_FETCH_ROUNDS) {
|
|
1127
|
+
const t = setTimeout(() => tryIdx(0, round + 1), 150);
|
|
1128
|
+
t.unref?.();
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
reject(new Error("all exits failed"));
|
|
961
1132
|
return;
|
|
962
1133
|
}
|
|
963
1134
|
const exit = order[idx];
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1135
|
+
// `order` is frozen at call start, but an exit can get FLOORED mid-call
|
|
1136
|
+
// (it just trapped another fetch to its deadline). Skip it while a
|
|
1137
|
+
// non-floored alternative exists in the order — otherwise the round
|
|
1138
|
+
// loop replays the same dead exit for 13s × MAX_FETCH_ROUNDS while the
|
|
1139
|
+
// good exits sit idle (observed: one segment held hostage for 52s).
|
|
1140
|
+
if (exit.flooredAtMs !== null && order.some((e) => e.flooredAtMs === null)) {
|
|
1141
|
+
tryIdx(idx + 1, round);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
// Count load NOW (at connect time, not at establishment) so that stripes
|
|
1145
|
+
// dispatched together via Promise.all each see the prior stripe's load in
|
|
1146
|
+
// pickOrder and spread across DISTINCT exits instead of all piling on the
|
|
1147
|
+
// current fastest. Released in nextExit/commit/onAbort via uncount().
|
|
1148
|
+
uncount();
|
|
1149
|
+
activeExit = exit;
|
|
1150
|
+
exit.active++;
|
|
1151
|
+
let attemptDone = false;
|
|
1152
|
+
let deadline = null;
|
|
1153
|
+
const clearDeadline = () => { if (deadline) {
|
|
1154
|
+
clearTimeout(deadline);
|
|
1155
|
+
deadline = null;
|
|
970
1156
|
} };
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
if (
|
|
983
|
-
|
|
1157
|
+
// A deadline hit means this exit TRAPPED the fetch (accepted it, then
|
|
1158
|
+
// trickled/hung for 10s+). Floor its score so it stops being handed
|
|
1159
|
+
// segments (redemption after REDEMPTION_MS), then fail over.
|
|
1160
|
+
const armDeadline = (ms) => { clearDeadline(); deadline = setTimeout(() => { floorExit(exit); nextExit("deadline"); }, ms); deadline.unref?.(); };
|
|
1161
|
+
const t0 = Date.now();
|
|
1162
|
+
// Ride the exit's keep-alive Agent: a pooled tunneled-TLS socket to this
|
|
1163
|
+
// origin is reused instantly; only a cold (exit, origin) pair pays the
|
|
1164
|
+
// TCP+CONNECT+TLS handshake. This is what makes playlist polls ~1 RTT
|
|
1165
|
+
// and segment fetches transfer-bound instead of handshake-bound.
|
|
1166
|
+
const reqFn = isTls ? https.request : http.request;
|
|
1167
|
+
const up = reqFn({ host, port, path, method: "GET", headers: { ...extraHeaders, host: u.host }, agent: agentFor(exit, isTls) }, (upRes) => {
|
|
1168
|
+
if (attemptDone) {
|
|
1169
|
+
upRes.destroy();
|
|
984
1170
|
return;
|
|
985
1171
|
}
|
|
986
|
-
moved = true; // established — failover window is over
|
|
987
|
-
tun.setTimeout(0);
|
|
988
1172
|
exit.lastSuccessMs = Date.now();
|
|
989
1173
|
exit.fails = 0;
|
|
990
1174
|
if (!exit.healthy)
|
|
991
1175
|
exit.healthy = true;
|
|
992
1176
|
exit.served++;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1177
|
+
// Min-throughput deadline: once we know the body size, a body that
|
|
1178
|
+
// can't arrive at ~0.35 Mbps means the exit is trickling (dead
|
|
1179
|
+
// 0.1 Mbps 1.16) — abandon it and try the next, rather than hang
|
|
1180
|
+
// for a minute and clog the prefetch pipeline.
|
|
1181
|
+
const cl = Number(upRes.headers["content-length"]) || 0;
|
|
1182
|
+
armDeadline(cl > 0
|
|
1183
|
+
? Math.min(FETCH_DEADLINE_MAX_MS, Math.max(FETCH_DEADLINE_MIN_MS, Math.ceil((cl * 8 / MIN_USEFUL_FETCH_BPS) * 1000) + 2000))
|
|
1184
|
+
: FETCH_DEADLINE_MAX_MS);
|
|
1185
|
+
const chunks = [];
|
|
1186
|
+
const meter = new RateMeter();
|
|
1187
|
+
upRes.on("data", (d) => { chunks.push(d); meter.add(d.length, Date.now()); });
|
|
1188
|
+
upRes.once("end", () => {
|
|
1189
|
+
const body = Buffer.concat(chunks);
|
|
1190
|
+
const durMs = Date.now() - t0;
|
|
1191
|
+
const status = upRes.statusCode || 502;
|
|
1192
|
+
if (body.length === 0 && status >= 200 && status < 300) {
|
|
1193
|
+
// A 2xx with an EMPTY body (e.g. an exit that can't serve this
|
|
1194
|
+
// byte-range, or a lossy link truncating it): DEPRIORITIZE the
|
|
1195
|
+
// exit by pinning its speed score to the floor, so pickOrder
|
|
1196
|
+
// stops sending stripes to it first and they go straight to an
|
|
1197
|
+
// exit that actually delivers — but KEEP it in the pool. The
|
|
1198
|
+
// moment it serves real bytes again, sampleSpeed lifts its score
|
|
1199
|
+
// back up. Then fail THIS request over to the next exit.
|
|
1200
|
+
floorExit(exit);
|
|
1201
|
+
nextExit(`empty ${status}`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
sampleSpeed(exit, meter, durMs);
|
|
1205
|
+
if (label) {
|
|
1206
|
+
const kb = (body.length / 1024).toFixed(0);
|
|
1207
|
+
const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
|
|
1208
|
+
log(` ${label} ${tail} via ${exit.name} — ${kb}KB in ${durMs}ms = ${mbps}Mbps`);
|
|
1209
|
+
}
|
|
1210
|
+
commit({ status, headers: upRes.headers, body });
|
|
1020
1211
|
});
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1212
|
+
upRes.once("error", (e) => nextExit(`body: ${e.message}`));
|
|
1213
|
+
});
|
|
1214
|
+
curReq = up;
|
|
1215
|
+
// Abandon this exit and try the next — connect/CONNECT/TLS failure, a
|
|
1216
|
+
// mid-download error, no response headers, OR a min-throughput timeout
|
|
1217
|
+
// (the exit is trickling too slowly to be useful; don't hang on it and
|
|
1218
|
+
// clog the pipeline). Only when EVERY exit is exhausted do we reject.
|
|
1219
|
+
// up.destroy() also removes the (possibly bad) socket from the pool.
|
|
1220
|
+
const nextExit = (reason) => {
|
|
1221
|
+
if (attemptDone)
|
|
1222
|
+
return;
|
|
1223
|
+
attemptDone = true;
|
|
1224
|
+
clearDeadline();
|
|
1225
|
+
uncount();
|
|
1226
|
+
up.destroy();
|
|
1227
|
+
if (label)
|
|
1228
|
+
log(` ${label} ${tail} via ${exit.name} FAILED after ${Date.now() - t0}ms (${reason ?? "?"}) → next exit`);
|
|
1229
|
+
tryIdx(idx + 1, round);
|
|
1024
1230
|
};
|
|
1025
|
-
|
|
1231
|
+
const commit = (r) => {
|
|
1232
|
+
if (attemptDone)
|
|
1233
|
+
return;
|
|
1234
|
+
attemptDone = true;
|
|
1235
|
+
clearDeadline();
|
|
1236
|
+
uncount();
|
|
1237
|
+
// No destroy: the response was fully consumed, so the keep-alive
|
|
1238
|
+
// socket returns to the pool for the next fetch to this origin.
|
|
1239
|
+
resolve(r);
|
|
1240
|
+
};
|
|
1241
|
+
// Covers pooled-socket pickup OR the full cold handshake + response
|
|
1242
|
+
// headers; the response callback re-arms with the size-based deadline.
|
|
1243
|
+
armDeadline(FETCH_DEADLINE_MAX_MS);
|
|
1244
|
+
up.on("error", (e) => nextExit(e.message));
|
|
1245
|
+
up.end();
|
|
1026
1246
|
};
|
|
1027
1247
|
tryIdx(0);
|
|
1028
1248
|
});
|
|
1029
1249
|
}
|
|
1030
|
-
|
|
1250
|
+
// Striping ON — PROVEN to aggregate: a 600KB range that takes 8.5s on one
|
|
1251
|
+
// China exit (0.57 Mbps) lands in 3.9s when split 3 ways across exits
|
|
1252
|
+
// (1.24 Mbps, 2.2×). The GFW throttles PER-CONNECTION, so parallel ranges on
|
|
1253
|
+
// separate exit tunnels sum. A tiny 2-byte probe learns the size, then the
|
|
1254
|
+
// whole body is pulled as parallel byte-ranges; each range is hedged
|
|
1255
|
+
// (timeout → retry another exit) and the empty-body trip + min-throughput
|
|
1256
|
+
// deadline in fetchViaRegionBuffered keep a dead/trickling exit from stalling
|
|
1257
|
+
// it. Player requests JOIN an in-flight prefetch (dedup) so bandwidth isn't
|
|
1258
|
+
// split fetching the same segment twice. concurrency:2 keeps at most 2
|
|
1259
|
+
// segments (≈6 stripes over ~3 exits) in flight so the pool isn't overwhelmed.
|
|
1260
|
+
const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 1;
|
|
1261
|
+
// How many china exits are ACTUALLY delivering right now — healthy (or with
|
|
1262
|
+
// live traffic), not tripped, and not speed-penalized (an exit that returned
|
|
1263
|
+
// empty/0-byte gets its score floored, so it drops out here until it serves
|
|
1264
|
+
// real bytes again). Striping splits a segment only this many ways: with one
|
|
1265
|
+
// working exit → 1 (whole fetch, no serialized-stripe stall); as 1.16/1.17
|
|
1266
|
+
// recover they rejoin and aggregation resumes automatically — nothing excluded.
|
|
1267
|
+
const deliveringChinaExits = () => {
|
|
1268
|
+
if (!fallbackRegion)
|
|
1269
|
+
return 1;
|
|
1270
|
+
const now = Date.now();
|
|
1271
|
+
const n = exits.filter((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
|
|
1272
|
+
(e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)) &&
|
|
1273
|
+
(e.speedEwma === null || e.speedEwma >= NEAR_DEAD_EXIT_BPS)).length;
|
|
1274
|
+
return Math.max(1, n);
|
|
1275
|
+
};
|
|
1276
|
+
const usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
|
|
1277
|
+
// Prefetch parallelism = the number of exits ACTUALLY delivering right now
|
|
1278
|
+
// (deliveringChinaExits). Each concurrent prefetch is a whole-segment fetch,
|
|
1279
|
+
// and the load-aware exit pick (active++ at dispatch) lands each one on a
|
|
1280
|
+
// DIFFERENT exit — so 3 working exits download 3 segments side by side and
|
|
1281
|
+
// the stream rides the pool's aggregate bandwidth. With one working exit it
|
|
1282
|
+
// degrades to one-at-a-time (full bandwidth per segment, no splitting —
|
|
1283
|
+
// several fetches on ONE exit divide it N ways and everything stalls).
|
|
1284
|
+
// prefetchAhead=3 matches the 3-segment live window CCTV publishes.
|
|
1285
|
+
const hls = new HlsPrefetcher(fetchViaRegionBuffered, { stripes: AGG, stripesFn: usableChinaExits, stripeChunkBytes: 256 * 1024, concurrencyFn: deliveringChinaExits, prefetchAhead: 3 });
|
|
1031
1286
|
let lastHlsHits = 0;
|
|
1032
1287
|
function bumpHlsStats() {
|
|
1033
1288
|
const s = hls.stats;
|
|
@@ -1045,15 +1300,45 @@ export function startMultiExitRouter(opts) {
|
|
|
1045
1300
|
function serveHlsAware(o) {
|
|
1046
1301
|
const { fullUrl, host, port, path, target, method, headers, req, res, order, isTls } = o;
|
|
1047
1302
|
const u = new URL(fullUrl);
|
|
1303
|
+
// The CCTV H5 player fetches segments via XHR/fetch (HLSP2P/DRM), so the
|
|
1304
|
+
// browser enforces CORS. The CDN sends Access-Control-Allow-Origin, but our
|
|
1305
|
+
// cache-hit and dedup-join serving paths only carried content-type — dropping
|
|
1306
|
+
// it, so the browser blocked every cached segment ("No 'Access-Control-Allow-
|
|
1307
|
+
// Origin' header", net::ERR_FAILED) and the player never got a decodable
|
|
1308
|
+
// segment → black screen. Force the CORS headers on every HLS response.
|
|
1309
|
+
const addCors = (h) => {
|
|
1310
|
+
const origin = typeof headers["origin"] === "string" ? headers["origin"] : "*";
|
|
1311
|
+
h["access-control-allow-origin"] = origin;
|
|
1312
|
+
if (origin !== "*")
|
|
1313
|
+
h["access-control-allow-credentials"] = "true";
|
|
1314
|
+
h["access-control-expose-headers"] = "*";
|
|
1315
|
+
h["timing-allow-origin"] = "*";
|
|
1316
|
+
};
|
|
1317
|
+
// Answer CORS preflights LOCALLY (204) instead of forwarding them to an exit
|
|
1318
|
+
// — a preflight that hangs on a lossy China exit blocks the segment GET that
|
|
1319
|
+
// follows it, starving the player. Instant 204 keeps segments flowing.
|
|
1320
|
+
if (method === "OPTIONS") {
|
|
1321
|
+
const h = { "content-length": 0 };
|
|
1322
|
+
addCors(h);
|
|
1323
|
+
h["access-control-allow-methods"] = "GET, HEAD, OPTIONS";
|
|
1324
|
+
h["access-control-allow-headers"] = typeof headers["access-control-request-headers"] === "string"
|
|
1325
|
+
? headers["access-control-request-headers"] : "*";
|
|
1326
|
+
h["access-control-max-age"] = "86400";
|
|
1327
|
+
res.writeHead(204, h);
|
|
1328
|
+
res.end();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1048
1331
|
const cacheable = method === "GET" && !headers["range"];
|
|
1049
1332
|
if (cacheable) {
|
|
1050
1333
|
const hit = hls.lookup(fullUrl);
|
|
1051
1334
|
if (hit) {
|
|
1052
|
-
|
|
1335
|
+
const hh = {
|
|
1053
1336
|
"content-type": hit.contentType ?? "application/octet-stream",
|
|
1054
1337
|
"content-length": hit.body.length,
|
|
1055
1338
|
"x-agentnet-cache": "hit",
|
|
1056
|
-
}
|
|
1339
|
+
};
|
|
1340
|
+
addCors(hh);
|
|
1341
|
+
res.writeHead(200, hh);
|
|
1057
1342
|
res.end(hit.body);
|
|
1058
1343
|
bumpHlsStats();
|
|
1059
1344
|
return;
|
|
@@ -1066,7 +1351,17 @@ export function startMultiExitRouter(opts) {
|
|
|
1066
1351
|
if (typeof v === "string" && ["user-agent", "referer", "origin", "cookie", "authorization"].includes(k))
|
|
1067
1352
|
fwd[k] = v;
|
|
1068
1353
|
}
|
|
1069
|
-
|
|
1354
|
+
// Playlists are tiny → one fetch. A cold segment gets an ABSOLUTE cap
|
|
1355
|
+
// across all failover attempts so it can never hang the tab; the
|
|
1356
|
+
// in-flight fetch keeps running and caches, so the player's re-request
|
|
1357
|
+
// usually lands on a warm cache.
|
|
1358
|
+
const fetchPromise = isPlaylist
|
|
1359
|
+
? fetchViaRegionBuffered(u, fwd, "playlist")
|
|
1360
|
+
: Promise.race([
|
|
1361
|
+
hls.fetchSegmentStriped(fullUrl, fwd),
|
|
1362
|
+
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error("segment total deadline")), SEGMENT_TOTAL_DEADLINE_MS); t.unref?.(); }),
|
|
1363
|
+
]);
|
|
1364
|
+
fetchPromise
|
|
1070
1365
|
.then((r) => {
|
|
1071
1366
|
log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
|
|
1072
1367
|
if (r.status >= 400) {
|
|
@@ -1076,6 +1371,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1076
1371
|
const outHeaders = { ...r.headers };
|
|
1077
1372
|
delete outHeaders["transfer-encoding"];
|
|
1078
1373
|
outHeaders["content-length"] = r.body.length;
|
|
1374
|
+
addCors(outHeaders); // cache/dedup/striped paths drop the CDN's CORS header — always re-add
|
|
1079
1375
|
res.writeHead(r.status, outHeaders);
|
|
1080
1376
|
res.end(r.body);
|
|
1081
1377
|
if (r.status === 200) {
|
|
@@ -1099,10 +1395,20 @@ export function startMultiExitRouter(opts) {
|
|
|
1099
1395
|
}
|
|
1100
1396
|
})
|
|
1101
1397
|
.catch(() => {
|
|
1102
|
-
if (
|
|
1103
|
-
httpForwardViaExit(order, 0, target, host, port, path, req, res, headers, isTls);
|
|
1104
|
-
else
|
|
1398
|
+
if (res.headersSent) {
|
|
1105
1399
|
res.destroy();
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
// Playlists must succeed → fall back to a direct stream. A cold
|
|
1403
|
+
// segment that couldn't be fetched in time gets a fast 504 so the
|
|
1404
|
+
// player re-requests (likely a cache hit by then) instead of
|
|
1405
|
+
// hanging on an unbounded stream.
|
|
1406
|
+
if (isPlaylist)
|
|
1407
|
+
httpForwardViaExit(order, 0, target, host, port, path, req, res, headers, isTls);
|
|
1408
|
+
else {
|
|
1409
|
+
res.writeHead(504, { "content-length": 0 });
|
|
1410
|
+
res.end();
|
|
1411
|
+
}
|
|
1106
1412
|
});
|
|
1107
1413
|
return;
|
|
1108
1414
|
}
|
|
@@ -1259,6 +1565,17 @@ export function startMultiExitRouter(opts) {
|
|
|
1259
1565
|
});
|
|
1260
1566
|
if (mode === "loadbalance") {
|
|
1261
1567
|
const reporter = setInterval(() => {
|
|
1568
|
+
// Redemption: lift an expired speed-floor back to "unproven" so the exit
|
|
1569
|
+
// gets a fresh segment chance (only segments can re-prove it; without
|
|
1570
|
+
// this a floor is a permanent exclusion, which we never want).
|
|
1571
|
+
const rnow = Date.now();
|
|
1572
|
+
for (const e of exits) {
|
|
1573
|
+
if (e.flooredAtMs !== null && rnow - e.flooredAtMs > REDEMPTION_MS) {
|
|
1574
|
+
e.flooredAtMs = null;
|
|
1575
|
+
e.speedEwma = null;
|
|
1576
|
+
log(`${e.name}: floor lifted after ${Math.round(REDEMPTION_MS / 1000)}s — giving it another chance`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1262
1579
|
const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total / ${fmtMbps(e.speedEwma)}`);
|
|
1263
1580
|
parts.push(`direct: ${directActive} active / ${directTotal} total`);
|
|
1264
1581
|
log(`load — ${parts.join(" | ")}`);
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -404,7 +404,7 @@ function RouteTag({ peer }) {
|
|
|
404
404
|
const via = peer.via || "online";
|
|
405
405
|
return /* @__PURE__ */ React.createElement(Tag, { tone: relay ? "warn" : "ok" }, peer.ping != null ? `${via} \xB7 ${peer.ping}ms` : via);
|
|
406
406
|
}
|
|
407
|
-
function Tag({ children, tone = "neutral", style }) {
|
|
407
|
+
function Tag({ children, tone = "neutral", style, title }) {
|
|
408
408
|
const tones = {
|
|
409
409
|
neutral: { bg: "var(--chip)", fg: "var(--dim)", bd: "transparent" },
|
|
410
410
|
ok: { bg: "color-mix(in oklab, var(--online), transparent 86%)", fg: "var(--online)", bd: "transparent" },
|
|
@@ -414,7 +414,7 @@ function Tag({ children, tone = "neutral", style }) {
|
|
|
414
414
|
danger: { bg: "color-mix(in oklab, var(--danger), transparent 88%)", fg: "var(--danger)", bd: "transparent" }
|
|
415
415
|
};
|
|
416
416
|
const t = tones[tone] || tones.neutral;
|
|
417
|
-
return /* @__PURE__ */ React.createElement("span", { style: {
|
|
417
|
+
return /* @__PURE__ */ React.createElement("span", { title, style: {
|
|
418
418
|
display: "inline-flex",
|
|
419
419
|
alignItems: "center",
|
|
420
420
|
gap: 4,
|
|
@@ -1480,9 +1480,14 @@ function PeerTable({ T, peers, onOpenChat }) {
|
|
|
1480
1480
|
} }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: p, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: p.alias ? "var(--ui)" : "var(--mono)", fontSize: 13, fontWeight: 600, color: "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.alias || shortKey(p.userId, 8, 5)), p.agent && /* @__PURE__ */ React.createElement(Icon, { name: "bot", size: 12, color: "var(--faint)", stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, dim: true, copy: p.ip }, p.ip), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(RouteTag, { peer: p })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: p.wire === "64" ? "var(--warn)" : "var(--dim)" } }, p.wire === "64" ? "old\xB764" : "new\xB7163"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "message", size: "sm", title: T.openChat, onClick: () => onOpenChat(p.id) }), /* @__PURE__ */ React.createElement(Btn, { icon: "signal", size: "sm", title: T.ping })))));
|
|
1481
1481
|
}
|
|
1482
1482
|
function ExitCard({ T, region, activeExit, onSetExit }) {
|
|
1483
|
-
return /* @__PURE__ */ React.createElement("div", { style: { borderRadius: 11, border: "1px solid var(--line)", overflow: "hidden", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, padding: "2px 7px", borderRadius: 5, background: "var(--chip)", color: "var(--dim)" } }, region.flag), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, region.label), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }),
|
|
1483
|
+
return /* @__PURE__ */ React.createElement("div", { style: { borderRadius: 11, border: "1px solid var(--line)", overflow: "hidden", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, padding: "2px 7px", borderRadius: 5, background: "var(--chip)", color: "var(--dim)" } }, region.flag), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, region.label), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), (() => {
|
|
1484
|
+
const reach = region.nodes.filter((n) => n.reachable).length;
|
|
1485
|
+
const down = region.nodes.length - reach;
|
|
1486
|
+
return /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: down > 0 ? "var(--warn)" : "var(--faint)" } }, reach, "/", region.nodes.length, " ", T.up, down > 0 ? ` \xB7 ${down} down` : "");
|
|
1487
|
+
})()), /* @__PURE__ */ React.createElement("div", null, region.nodes.map((n, i) => {
|
|
1484
1488
|
const active = n.ip === activeExit;
|
|
1485
|
-
|
|
1489
|
+
const stuck = n.online && !n.reachable;
|
|
1490
|
+
return /* @__PURE__ */ React.createElement("div", { key: n.ip, style: { display: "flex", alignItems: "center", gap: 12, padding: "10px 14px", borderBottom: i < region.nodes.length - 1 ? "1px solid var(--line)" : "none", background: active ? "color-mix(in oklab, var(--accent), transparent 92%)" : "transparent" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: n.reachable }), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: n.ip }, n.ip), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, n.host), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), n.reachable ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: n.ping > 200 ? "var(--warn)" : "var(--dim)" } }, n.ping, "ms") : stuck ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "Announces online but IP won't pass \u2014 session desync or NAT-blocked" }, "online \xB7 no route") : /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, "offline"), active ? /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onSetExit(null) }, T.routing) : /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "route", onClick: () => n.reachable && onSetExit(n.ip), style: { opacity: n.reachable ? 1 : 0.4 } }, T.routeThru));
|
|
1486
1491
|
})));
|
|
1487
1492
|
}
|
|
1488
1493
|
function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat }) {
|
package/dist/ui/server.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// empty because the daemon accepts immediately.
|
|
14
14
|
//
|
|
15
15
|
import http from "node:http";
|
|
16
|
+
import net from "node:net";
|
|
16
17
|
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
|
|
17
18
|
import { fileURLToPath } from "node:url";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
@@ -21,6 +22,11 @@ import { DEFAULT_EXITS } from "../config/loader.js";
|
|
|
21
22
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
22
23
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
23
24
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
25
|
+
// Exit reachability probe: the CONNECT-proxy port every exit runs, and how long
|
|
26
|
+
// to wait before calling an exit unreachable. Kept short so the network panel
|
|
27
|
+
// (which probes all exits on each poll) stays responsive over a lossy mesh.
|
|
28
|
+
const EXIT_PROBE_PORT = 8888;
|
|
29
|
+
const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
24
30
|
/** True only when the request came from the local machine. Used to gate the
|
|
25
31
|
* "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
|
|
26
32
|
* identity signing to other hosts. The popup always runs in the local user's
|
|
@@ -561,6 +567,7 @@ export function startFriendUi(opts) {
|
|
|
561
567
|
ip: e.virtual_ip,
|
|
562
568
|
host: e.name,
|
|
563
569
|
online: onlineUserids.has(e.userid) || onlineIps.has(e.virtual_ip),
|
|
570
|
+
reachable: false,
|
|
564
571
|
ping: null,
|
|
565
572
|
});
|
|
566
573
|
}
|
|
@@ -573,14 +580,44 @@ export function startFriendUi(opts) {
|
|
|
573
580
|
knownIps.add(ip);
|
|
574
581
|
if (!byRegion.has(r.name))
|
|
575
582
|
byRegion.set(r.name, []);
|
|
576
|
-
byRegion.get(r.name).push({ ip, host: ip, online: onlineIps.has(ip), ping: null });
|
|
583
|
+
byRegion.get(r.name).push({ ip, host: ip, online: onlineIps.has(ip), reachable: false, ping: null });
|
|
577
584
|
}
|
|
578
585
|
}
|
|
586
|
+
// REACHABILITY (data plane): presence "online" only means the peer is
|
|
587
|
+
// announcing on the DHT — it does NOT mean IP packets get through (a
|
|
588
|
+
// desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
|
|
589
|
+
// e.g. GFAX). Actively probe each exit's CONNECT-proxy port over the mesh:
|
|
590
|
+
// a completed TCP handshake proves the L3 path + the exit process are both
|
|
591
|
+
// live. Probed in parallel with a short timeout so the panel stays snappy.
|
|
592
|
+
const allNodes = [...byRegion.values()].flat();
|
|
593
|
+
await Promise.all(allNodes.map((n) => new Promise((done) => {
|
|
594
|
+
const t0 = Date.now();
|
|
595
|
+
const sock = net.connect({ host: n.ip, port: EXIT_PROBE_PORT });
|
|
596
|
+
let settled = false;
|
|
597
|
+
const finish = (ok) => {
|
|
598
|
+
if (settled)
|
|
599
|
+
return;
|
|
600
|
+
settled = true;
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
sock.destroy();
|
|
603
|
+
n.reachable = ok;
|
|
604
|
+
n.ping = ok ? Date.now() - t0 : null;
|
|
605
|
+
done();
|
|
606
|
+
};
|
|
607
|
+
const timer = setTimeout(() => finish(false), EXIT_PROBE_TIMEOUT_MS);
|
|
608
|
+
sock.once("connect", () => finish(true));
|
|
609
|
+
// ECONNREFUSED means the host answered (L3 reachable) but the proxy
|
|
610
|
+
// isn't listening — still "reachable" for triage; a timeout/no-route is
|
|
611
|
+
// the real "can't connect".
|
|
612
|
+
sock.once("error", (e) => finish(e.code === "ECONNREFUSED"));
|
|
613
|
+
})));
|
|
579
614
|
const exits = [...byRegion.entries()].map(([region, nodes]) => ({
|
|
580
615
|
region,
|
|
581
616
|
flag: regionMeta[region]?.flag ?? region.slice(0, 2).toUpperCase(),
|
|
582
617
|
label: regionMeta[region]?.label ?? region,
|
|
583
618
|
nodes,
|
|
619
|
+
reachableCount: nodes.filter((n) => n.reachable).length,
|
|
620
|
+
unreachableCount: nodes.filter((n) => !n.reachable).length,
|
|
584
621
|
}));
|
|
585
622
|
const activeExit = routes.default && routes.default !== "direct" ? routes.default : null;
|
|
586
623
|
sendJson(res, 200, { me, peers, requests, exits, activeExit });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.187",
|
|
4
4
|
"description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
81
|
"@decentnetwork/dora": "^0.1.13",
|
|
82
|
-
"@decentnetwork/peer": "^0.1.
|
|
82
|
+
"@decentnetwork/peer": "^0.1.92",
|
|
83
83
|
"@decentnetwork/peer-webrtc": "^0.2.10",
|
|
84
84
|
"ink": "^5.2.1",
|
|
85
85
|
"js-yaml": "^4.1.0",
|