@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.
Binary file
Binary file
Binary file
Binary file
@@ -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;
@@ -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
- export type UrlFetcher = (url: URL, headers: Record<string, string>, label?: string) => Promise<FetchedResponse>;
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 6). 1 disables striping. Lets ONE segment ride the SUM
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
- /** Stripe chunk size also the first probe chunk (default 192 KB). Segments
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
- /** Parse the total size out of a `Content-Range: bytes 0-N/TOTAL` header. */
21
- function contentRangeTotal(cr) {
22
- const s = Array.isArray(cr) ? cr[0] : cr;
23
- const m = s ? /\/(\d+)\s*$/.exec(s) : null;
24
- return m ? Number(m[1]) : 0;
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 ?? 6);
112
- this.#stripeChunkBytes = opts.stripeChunkBytes ?? 192 * 1024;
113
- }
114
- /** Fetch `u` as PARALLEL byte-range stripes. Each range is a separate
115
- * `#fetcher` call, which the router load-balances onto a DIFFERENT exit so
116
- * one segment downloads at the SUM of the pool's bandwidth, beating the GFW
117
- * per-connection throttle (the reason a single stream stalls even with many
118
- * exits). Falls back to one whole-body fetch if the origin ignores Range or
119
- * any stripe fails (correctness over speed). */
120
- async #stripeFetch(u) {
121
- const whole = () => this.#fetcher(u, { host: u.host }, "prefetch");
122
- if (this.#stripes <= 1)
123
- return whole();
124
- const guess = this.#stripeChunkBytes;
125
- let first;
126
- try {
127
- first = await this.#fetcher(u, { host: u.host, range: `bytes=0-${guess - 1}` }, "stripe");
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
- catch {
130
- return whole();
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
- if (first.status === 200)
133
- return first; // origin ignored Range → whole body already
134
- if (first.status !== 206)
135
- return whole();
136
- const total = contentRangeTotal(first.headers["content-range"]);
137
- const outHeaders = { ...first.headers };
138
- delete outHeaders["content-range"];
139
- if (!total || total <= first.body.length)
140
- return { status: 200, headers: outHeaders, body: first.body };
141
- const remaining = total - first.body.length;
142
- const n = Math.max(1, Math.min(this.#stripes - 1, Math.ceil(remaining / guess)));
143
- const chunk = Math.ceil(remaining / n);
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 = first.body.length + i * chunk;
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
- return this.#fetcher(u, { host: u.host, range: `bytes=${start}-${end}` }, "stripe")
150
- .then((r) => ((r.status === 206 || r.status === 200) && r.body.length === end - start + 1 ? r.body : null))
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 failedrefetch whole for integrity
155
- const body = Buffer.concat([first.body, ...parts]);
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
- return { status: 200, headers: outHeaders, body };
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 >= this.#concurrency)
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
- // Keep a proven exit in rotation only if its measured speed is at least this
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
- const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
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
- // Rotates the starting exit across calls so concurrent prefetches spread over
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
- rejectp(new Error("no region for host"));
1077
+ reject(new Error("no region for host"));
946
1078
  return;
947
1079
  }
948
- const base = pickOrder(region);
949
- if (base.length === 0) {
950
- rejectp(new Error("no healthy exit"));
1080
+ const base0 = pickOrder(region);
1081
+ if (base0.length === 0) {
1082
+ reject(new Error("no healthy exit"));
951
1083
  return;
952
1084
  }
953
- const start = prefetchRR++ % Math.min(3, base.length);
954
- const order = [...base.slice(start), ...base.slice(0, start)];
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
- rejectp(new Error("all exits failed"));
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
- const tun = net.connect(exit.port, exit.host);
965
- let moved = false;
966
- const move = () => { if (!moved) {
967
- moved = true;
968
- tun.destroy();
969
- tryIdx(idx + 1);
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
- tun.setTimeout(EXIT_CONNECT_TIMEOUT_MS, move);
972
- tun.once("connect", () => tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
973
- tun.on("error", move);
974
- let buf = Buffer.alloc(0);
975
- const onHdr = (chunk) => {
976
- buf = Buffer.concat([buf, chunk]);
977
- const i = buf.indexOf("\r\n\r\n");
978
- if (i === -1)
979
- return;
980
- tun.removeListener("data", onHdr);
981
- const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
982
- if (!/ 200/.test(statusLine)) {
983
- move();
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
- const leftover = buf.slice(i + 4);
994
- if (leftover.length)
995
- tun.unshift(leftover);
996
- const t0 = Date.now();
997
- // For an https origin, run TLS over the exit tunnel so we speak
998
- // straight to the CDN (the exit only ever sees ciphertext). For http,
999
- // the raw tunnel socket carries plaintext HTTP directly.
1000
- const reqFn = isTls ? https.request : http.request;
1001
- const conn = isTls
1002
- ? () => tls.connect({ socket: tun, servername: host, rejectUnauthorized: false })
1003
- : () => tun;
1004
- const up = reqFn({ host, port, path, method: "GET", headers: { ...extraHeaders, host: u.host }, createConnection: conn }, (upRes) => {
1005
- const chunks = [];
1006
- const meter = new RateMeter();
1007
- upRes.on("data", (d) => { chunks.push(d); meter.add(d.length, Date.now()); });
1008
- upRes.once("end", () => {
1009
- const body = Buffer.concat(chunks);
1010
- const durMs = Date.now() - t0;
1011
- sampleSpeed(exit, meter, durMs);
1012
- if (label) {
1013
- const kb = (body.length / 1024).toFixed(0);
1014
- const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
1015
- log(` ${label} ${tail} via ${exit.name} ${kb}KB in ${durMs}ms = ${mbps}Mbps`);
1016
- }
1017
- resolvep({ status: upRes.statusCode || 502, headers: upRes.headers, body });
1018
- });
1019
- upRes.once("error", (e) => rejectp(e));
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
- up.setTimeout(30_000, () => up.destroy(new Error("prefetch timeout")));
1022
- up.on("error", (e) => rejectp(e));
1023
- up.end();
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
- tun.on("data", onHdr);
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
- const hls = new HlsPrefetcher(fetchViaRegionBuffered);
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
- res.writeHead(200, {
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
- fetchViaRegionBuffered(u, fwd, isPlaylist ? "playlist" : "segment")
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 (!res.headersSent)
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(" | ")}`);
@@ -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 } }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, region.nodes.filter((n) => n.online).length, "/", region.nodes.length, " ", T.up)), /* @__PURE__ */ React.createElement("div", null, region.nodes.map((n, i) => {
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
- 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.online }), /* @__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.online ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: n.ping > 200 ? "var(--warn)" : "var(--dim)" } }, n.ping, "ms") : /* @__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.online && onSetExit(n.ip), style: { opacity: n.online ? 1 : 0.4 } }, T.routeThru));
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.184",
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.91",
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",