@decentnetwork/lan 0.1.149 → 0.1.151

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
@@ -131,9 +131,17 @@ export class ConnectProxy {
131
131
  }
132
132
  const host = m[1];
133
133
  const port = Number(m[2]);
134
- const allowedPorts = this.opts.allowConnectPorts ?? [443, 80];
135
- if (!allowedPorts.includes(port)) {
136
- this.refuse(clientSocket, src, srcName, `${host}:${port}`, 403, `port ${port} not in allowConnectPorts`);
134
+ // Port policy: an EXPLICIT allowConnectPorts restricts to that list; UNSET
135
+ // (the default) allows any port except known-abuse ones (SMTP). The old
136
+ // default of [443,80] silently broke real apps that use non-standard ports
137
+ // (verified: the CCTV/Migu app's uem.migu.cn:18088 got 403'd here while the
138
+ // host itself was perfectly reachable). These are friend-gated private
139
+ // exits, so blanket port blocking bought little and cost app compatibility.
140
+ const DENY_PORTS = new Set([25, 465, 587]); // SMTP — block spam relay abuse
141
+ const allowedPorts = this.opts.allowConnectPorts;
142
+ const portBlocked = allowedPorts ? !allowedPorts.includes(port) : DENY_PORTS.has(port);
143
+ if (portBlocked) {
144
+ this.refuse(clientSocket, src, srcName, `${host}:${port}`, 403, `port ${port} not permitted`);
137
145
  return;
138
146
  }
139
147
  const allowHosts = this.opts.allowHosts ?? [];
@@ -334,10 +334,17 @@ export function startMultiExitRouter(opts) {
334
334
  }
335
335
  // A tunnel established but stalled (delivered ~nothing) → count it; trip the
336
336
  // exit out of the pool once it stalls repeatedly, so a flaky node stops
337
- // getting traffic instead of stalling segments.
337
+ // getting traffic instead of stalling segments. BUT never trip an exit the
338
+ // health PROBE still says is UP: under a busy live stream on a congested
339
+ // cross-border path, healthy exits routinely stall a segment or idle a small
340
+ // request — tripping them there collapses the pool (5↔3) and makes the stream
341
+ // LESS stable, not more. The probe (a real round-trip) is the down-detector;
342
+ // the stall breaker only fires on an exit that is ALSO failing its probe.
338
343
  function recordStall(exit, why) {
339
344
  exit.stalls++;
340
- if (exit.stalls >= TRIP_AFTER_STALLS && exit.trippedUntil <= Date.now()) {
345
+ const probeAlive = exit.healthy ||
346
+ (exit.lastSuccessMs !== null && Date.now() - exit.lastSuccessMs < RECENT_SUCCESS_MS);
347
+ if (!probeAlive && exit.stalls >= TRIP_AFTER_STALLS && exit.trippedUntil <= Date.now()) {
341
348
  exit.trippedUntil = Date.now() + TRIP_COOLDOWN_MS;
342
349
  exit.healthy = false;
343
350
  log(`✗ ${exit.name} [${exit.region}] TRIPPED for ${TRIP_COOLDOWN_MS / 1000}s (${why}) — excluding from pool`);
@@ -386,8 +393,14 @@ export function startMultiExitRouter(opts) {
386
393
  // Unmeasured exits assume the region's median (or a neutral default) so a
387
394
  // new exit is tried but doesn't monopolize before it's proven.
388
395
  const measuredVals = pool.map((e) => e.speedEwma).filter((v) => v !== null).sort((a, b) => a - b);
389
- const medianBps = measuredVals.length > 0 ? measuredVals[Math.floor(measuredVals.length / 2)] : 2_000_000;
390
- const score = (e) => (e.speedEwma ?? medianBps) / (e.active + 1);
396
+ // Unmeasured exits get a PESSIMISTIC assumption (the region's slowest proven
397
+ // speed, or a low floor) NOT the median. A long-lived video tunnel pins to
398
+ // ONE exit for its whole duration, so it must land on a PROVEN-fast exit, not
399
+ // an unproven one that might turn out to be 0.3 Mbps and stall the player.
400
+ // Unproven exits still get overflow traffic once the proven ones fill up,
401
+ // which measures them safely without risking the video.
402
+ const unprovenBps = measuredVals.length > 0 ? measuredVals[0] : 800_000;
403
+ const score = (e) => (e.speedEwma ?? unprovenBps) / (e.active + 1);
391
404
  return [...pool].sort((a, b) => score(b) - score(a) || a.served - b.served);
392
405
  }
393
406
  // Whether ANY exit is configured for a region (healthy or not). Lets us tell
@@ -399,11 +412,26 @@ export function startMultiExitRouter(opts) {
399
412
  function forward(exit, target, client, head, onFail) {
400
413
  const up = net.connect(exit.port, exit.host);
401
414
  let established = false;
402
- const fail = (why) => {
415
+ const fail = (why, tripExit = true) => {
403
416
  if (!established) {
404
- // Couldn't even establish an instability signal for the breaker, then
405
- // fail over to the next exit.
406
- recordStall(exit, why);
417
+ // Only a CONNECTION-level failure (exit unreachable: timeout / conn
418
+ // error) is an EXIT fault → feed the breaker. An UPSTREAM status (the
419
+ // exit answered but the ORIGIN returned 502/403/etc.) is the origin's
420
+ // problem — it fails identically on every exit, so tripping the exit for
421
+ // it churns the whole pool (a single dead host like sdc3.10086.cn or
422
+ // uem.migu.cn was knocking out all 6 China exits). Just fail over.
423
+ //
424
+ // AND: a connect-timeout to an exit the health probe STILL says is UP is
425
+ // transient cross-border congestion under load — NOT a dead exit (proven:
426
+ // the exit answers its probe at ~200ms and reaches the origin in ~40ms).
427
+ // Tripping it here removes it from the pool, piles load onto fewer exits,
428
+ // causes MORE timeouts → the pool collapses to 1-2 and the whole stream
429
+ // stalls (the high-bitrate live-video failure mode). Only trip an exit
430
+ // that is ALSO failing its probe (genuinely unreachable); a healthy exit
431
+ // just fails this ONE connection over and stays in the pool.
432
+ const probeAlive = exit.healthy || (exit.lastSuccessMs !== null && Date.now() - exit.lastSuccessMs < RECENT_SUCCESS_MS);
433
+ if (tripExit && !probeAlive)
434
+ recordStall(exit, why);
407
435
  up.destroy();
408
436
  onFail(why);
409
437
  }
@@ -499,7 +527,8 @@ export function startMultiExitRouter(opts) {
499
527
  up.on("error", () => client.destroy());
500
528
  }
501
529
  else {
502
- fail(`upstream ${status}`);
530
+ // Exit answered but the ORIGIN errored (502/403/…) — not an exit fault.
531
+ fail(`upstream ${status}`, false);
503
532
  }
504
533
  };
505
534
  up.on("data", onData);
@@ -762,9 +791,20 @@ export function startMultiExitRouter(opts) {
762
791
  ? () => tls.connect({ socket: tun, servername: host, rejectUnauthorized: false })
763
792
  : () => tun;
764
793
  const up = reqFn({ host, port, path, method: req.method, headers, createConnection: conn }, (upRes) => {
794
+ log(` MITM ${req.method} ${host}${(path || "/").slice(0, 80)} → ${upRes.statusCode}`);
765
795
  const meter = new RateMeter();
766
- upRes.on("data", (d) => meter.add(d.length, Date.now()));
767
- upRes.once("end", () => sampleSpeed(exit, meter, Date.now() - t0));
796
+ // Sniff the stream-decision APIs' bodies (they say play:1+url vs play:0)
797
+ // so we can see WHY the app won't play even though every request is 200.
798
+ const sniff = /vdn\/live|cctv5\.json|getHttppdrp|\/vdn\//.test(path);
799
+ let sbuf = sniff ? Buffer.alloc(0) : null;
800
+ const enc = String(upRes.headers["content-encoding"] || "");
801
+ upRes.on("data", (d) => { meter.add(d.length, Date.now()); if (sbuf && sbuf.length < 1500)
802
+ sbuf = Buffer.concat([sbuf, d]); });
803
+ upRes.once("end", () => {
804
+ sampleSpeed(exit, meter, Date.now() - t0);
805
+ if (sbuf)
806
+ log(` body[${host}${enc ? " " + enc : ""}]: ${sbuf.slice(0, 1000).toString("utf8").replace(/\s+/g, " ")}`);
807
+ });
768
808
  res.writeHead(upRes.statusCode || 502, upRes.headers);
769
809
  upRes.pipe(res);
770
810
  });
@@ -918,6 +958,11 @@ export function startMultiExitRouter(opts) {
918
958
  }
919
959
  fetchViaRegionBuffered(u, fwd, isPlaylist ? "playlist" : "segment")
920
960
  .then((r) => {
961
+ log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
962
+ if (r.status >= 400) {
963
+ const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
964
+ log(` body: ${snip}`);
965
+ }
921
966
  const outHeaders = { ...r.headers };
922
967
  delete outHeaders["transfer-encoding"];
923
968
  outHeaders["content-length"] = r.body.length;
@@ -25,9 +25,17 @@ export interface PacedForwarderStats {
25
25
  export declare class PacedForwarder {
26
26
  #private;
27
27
  constructor(opts: PacedForwarderOptions);
28
- /** Set the paced target rate (bytes/sec) for a destination from a measured
29
- * delivered-rate estimate. */
28
+ /** Set the paced target rate (bytes/sec) for a destination directly. Kept for
29
+ * tests / explicit control; the live path uses reportDelivered. */
30
30
  setRate(dstIp: string, rateBps: number): void;
31
+ /** Feed a peer's measured delivered-rate: bytes/sec of the traffic we sent
32
+ * them that actually landed. Loss-driven: over a rolling window we compare it
33
+ * to what we sent and only TIGHTEN the pace when the path is dropping a real
34
+ * fraction of our egress; a clean path ramps the pace back toward uncapped.
35
+ * This is what stops a bursty app (HLS video) — whose per-second delivered
36
+ * rate swings between a full segment and an idle trough — from collapsing the
37
+ * pace and never recovering. */
38
+ reportDelivered(dstIp: string, deliveredBps: number): void;
31
39
  /** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
32
40
  * drain loop releases it paced + fairly. Returns false if it was dropped
33
41
  * (queue overflow). */
@@ -31,6 +31,25 @@ const DEFAULT_MAX_FLOW_BYTES = 256 * 1024;
31
31
  const DEFAULT_MAX_DEST_BYTES = 4 * 1024 * 1024;
32
32
  const DEFAULT_BURST_SECONDS = 0.05; // 50ms of rate as burst — smooths without adding latency
33
33
  const MIN_RATE_BPS = 32 * 1024; // never pace below 256 kbps
34
+ // LOSS-DRIVEN pacing. The old rule paced to `delivered × headroom`, but the
35
+ // delivered rate is itself CAPPED by the pace — a classic self-limiting loop: a
36
+ // single low sample (a bursty app's idle trough between HLS segments, or a slow
37
+ // first segment) drags the pace down, which caps delivery, which keeps the next
38
+ // sample low… and it never climbs back. That throttled healthy paths to a
39
+ // fraction of capacity (measured: CCTV stuck at 0.3–0.5 Mbps even over an
40
+ // all-local route with no congestion at all).
41
+ //
42
+ // Instead: judge the path over a rolling window by comparing what we SENT to
43
+ // what the peer reports DELIVERED. Only real loss (a chunk of our egress not
44
+ // landing) tightens the pace; a clean path ramps the pace back toward uncapped.
45
+ // So we never throttle a healthy path, yet still back off the instant we
46
+ // over-drive a congested one and induce drops. The window absorbs the timing
47
+ // skew between our egress and the peer's 1s report so a burst isn't misread as
48
+ // loss.
49
+ const LOSS_WINDOW_MS = 2000;
50
+ const LOSS_TRIGGER = 0.2; // >20% of sent bytes not landing ⇒ real congestion
51
+ const PROBE_GAIN = 1.5; // clean path: ramp the pace up briskly toward uncapped
52
+ const BACKOFF_GAIN = 1.1; // lossy path: pace just above what actually gets through
34
53
  class Dest {
35
54
  rateBps;
36
55
  tokens;
@@ -41,11 +60,19 @@ class Dest {
41
60
  cursor = 0;
42
61
  totalBytes = 0;
43
62
  draining = false;
63
+ // Loss-window accounting: bytes we've handed to the carrier and bytes the peer
64
+ // reports as delivered, since winStartMs. Compared once per LOSS_WINDOW_MS.
65
+ winSentBytes = 0;
66
+ winDeliveredBytes = 0;
67
+ winStartMs;
68
+ lastReportMs;
44
69
  constructor(rateBps, burstSeconds, nowMs) {
45
70
  this.rateBps = rateBps;
46
71
  this.burstBytes = Math.max(1500, rateBps * burstSeconds);
47
72
  this.tokens = this.burstBytes;
48
73
  this.lastRefillMs = nowMs;
74
+ this.winStartMs = nowMs;
75
+ this.lastReportMs = nowMs;
49
76
  }
50
77
  refill(nowMs) {
51
78
  const dt = nowMs - this.lastRefillMs;
@@ -77,12 +104,52 @@ export class PacedForwarder {
77
104
  schedule: opts.schedule ?? ((fn, ms) => { setTimeout(fn, ms).unref?.(); }),
78
105
  };
79
106
  }
80
- /** Set the paced target rate (bytes/sec) for a destination from a measured
81
- * delivered-rate estimate. */
107
+ /** Set the paced target rate (bytes/sec) for a destination directly. Kept for
108
+ * tests / explicit control; the live path uses reportDelivered. */
82
109
  setRate(dstIp, rateBps) {
83
110
  const d = this.#dest(dstIp);
84
111
  d.setRate(rateBps, this.#opts.burstSeconds);
85
112
  }
113
+ /** Feed a peer's measured delivered-rate: bytes/sec of the traffic we sent
114
+ * them that actually landed. Loss-driven: over a rolling window we compare it
115
+ * to what we sent and only TIGHTEN the pace when the path is dropping a real
116
+ * fraction of our egress; a clean path ramps the pace back toward uncapped.
117
+ * This is what stops a bursty app (HLS video) — whose per-second delivered
118
+ * rate swings between a full segment and an idle trough — from collapsing the
119
+ * pace and never recovering. */
120
+ reportDelivered(dstIp, deliveredBps) {
121
+ const d = this.#dest(dstIp);
122
+ const now = this.#opts.now();
123
+ const dt = now - d.lastReportMs;
124
+ d.lastReportMs = now;
125
+ if (dt > 0)
126
+ d.winDeliveredBytes += (deliveredBps * dt) / 1000;
127
+ const winMs = now - d.winStartMs;
128
+ if (winMs < LOSS_WINDOW_MS)
129
+ return;
130
+ const sent = d.winSentBytes;
131
+ const delivered = d.winDeliveredBytes;
132
+ const winSec = winMs / 1000;
133
+ d.winSentBytes = 0;
134
+ d.winDeliveredBytes = 0;
135
+ d.winStartMs = now;
136
+ // Too little egress this window to judge the path — leave the pace as is
137
+ // (don't let an idle stretch either shrink or grow it).
138
+ if (sent < MIN_RATE_BPS * winSec)
139
+ return;
140
+ const lossRatio = Math.max(0, 1 - delivered / sent);
141
+ const bs = this.#opts.burstSeconds;
142
+ if (lossRatio > LOSS_TRIGGER) {
143
+ // Congestion: a real fraction of what we sent didn't land. Pace down to
144
+ // the throughput that actually gets through (a hair above, to keep probing).
145
+ d.setRate(Math.max(MIN_RATE_BPS, (delivered / winSec) * BACKOFF_GAIN), bs);
146
+ }
147
+ else {
148
+ // Clean path: our egress is landing. Ramp the pace back up toward uncapped
149
+ // so we are never the bottleneck on a healthy path.
150
+ d.setRate(Math.min(this.#opts.defaultRateBps, d.rateBps * PROBE_GAIN), bs);
151
+ }
152
+ }
86
153
  /** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
87
154
  * drain loop releases it paced + fairly. Returns false if it was dropped
88
155
  * (queue overflow). */
@@ -175,6 +242,7 @@ export class PacedForwarder {
175
242
  f.bytes -= size;
176
243
  d.totalBytes -= size;
177
244
  d.tokens -= size;
245
+ d.winSentBytes += size;
178
246
  this.#sent++;
179
247
  void this.#opts.send(dstIp, pkt).catch(() => { this.#dropped++; });
180
248
  d.cursor = (d.cursor + 1) % Math.max(1, d.order.length);
@@ -14,12 +14,9 @@ const SUBNET_PREFIX = "10.86.";
14
14
  // How often each node reports the delivered rate of a peer's inbound flow.
15
15
  const RATE_REPORT_INTERVAL_MS = 1000;
16
16
  // Only report for peers whose inbound this interval exceeds this — no point
17
- // pacing a trickle (SSH/keepalives), and it keeps reports sparse.
17
+ // pacing a trickle (SSH/keepalives), and it keeps reports sparse. The pacer's
18
+ // loss window derives its verdict from these reports vs. what it sent.
18
19
  const RATE_REPORT_MIN_BYTES = 32 * 1024; // ~256 kbps sustained
19
- // The sender paces to measured × headroom so throughput can still probe upward
20
- // when the path frees up; loss caps it back down via the next report.
21
- const RATE_REPORT_HEADROOM = 1.25;
22
- const RATE_REPORT_MIN_BPS = 64 * 1024;
23
20
  export class PacketRouter extends EventEmitter {
24
21
  tunDevice;
25
22
  peerManager;
@@ -71,15 +68,16 @@ export class PacketRouter extends EventEmitter {
71
68
  this.stats.packetsForwarded++;
72
69
  },
73
70
  });
74
- // A peer's rate report tells us how fast our traffic to them is actually
75
- // landing pace our sends to their vIP toward it (with a little headroom
76
- // so throughput can still climb when capacity frees up).
71
+ // A peer's rate report tells us how much of our traffic to them actually
72
+ // landed. Feed it to the loss-driven pacer: it compares this to what we
73
+ // sent over a rolling window and only tightens the pace on real loss — a
74
+ // clean path stays uncapped, so a bursty app (HLS video) is never
75
+ // throttled by us and an idle trough can't collapse the pace.
77
76
  this.peerManager.on("rate-report", (pubkey, bytesPerSec) => {
78
77
  const mapping = this.ipam.resolveCarrierId(pubkey);
79
78
  if (!mapping?.virtualIp || !this.pacer)
80
79
  return;
81
- const target = Math.max(RATE_REPORT_MIN_BPS, bytesPerSec * RATE_REPORT_HEADROOM);
82
- this.pacer.setRate(mapping.virtualIp, target);
80
+ this.pacer.reportDelivered(mapping.virtualIp, bytesPerSec);
83
81
  });
84
82
  }
85
83
  this.setupCarrierHandlers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.149",
3
+ "version": "0.1.151",
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",