@decentnetwork/lan 0.1.137 → 0.1.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/tun-helper-darwin-amd64 +0 -0
  2. package/bin/tun-helper-darwin-arm64 +0 -0
  3. package/bin/tun-helper-linux-amd64 +0 -0
  4. package/bin/tun-helper-linux-arm64 +0 -0
  5. package/dist/carrier/peer-manager.d.ts +10 -0
  6. package/dist/carrier/peer-manager.js +25 -1
  7. package/dist/carrier/types.d.ts +1 -0
  8. package/dist/carrier/types.js +6 -0
  9. package/dist/cli/commands.d.ts +22 -0
  10. package/dist/cli/commands.js +99 -0
  11. package/dist/cli/index.js +16 -1
  12. package/dist/daemon/ipc.d.ts +7 -1
  13. package/dist/daemon/ipc.js +4 -0
  14. package/dist/daemon/server.js +38 -0
  15. package/dist/proxy/connect-proxy.d.ts +17 -0
  16. package/dist/proxy/connect-proxy.js +29 -1
  17. package/dist/proxy/data/chnroutes-cn.d.ts +1 -0
  18. package/dist/proxy/data/chnroutes-cn.js +691 -0
  19. package/dist/proxy/geo-cn.d.ts +25 -0
  20. package/dist/proxy/geo-cn.js +144 -0
  21. package/dist/proxy/hls-prefetch.d.ts +67 -0
  22. package/dist/proxy/hls-prefetch.js +195 -0
  23. package/dist/proxy/mitm-ca.d.ts +27 -0
  24. package/dist/proxy/mitm-ca.js +120 -0
  25. package/dist/proxy/mitm-gateway.d.ts +22 -0
  26. package/dist/proxy/mitm-gateway.js +76 -0
  27. package/dist/proxy/multi-exit-router.d.ts +13 -0
  28. package/dist/proxy/multi-exit-router.js +693 -78
  29. package/dist/proxy/route-learner.d.ts +33 -0
  30. package/dist/proxy/route-learner.js +129 -0
  31. package/dist/router/paced-forwarder.d.ts +45 -0
  32. package/dist/router/paced-forwarder.js +188 -0
  33. package/dist/router/packet-router.d.ts +10 -0
  34. package/dist/router/packet-router.js +74 -1
  35. package/dist/types.d.ts +7 -0
  36. package/dist/ui/desktop/app.js +1 -1
  37. package/dist/ui/desktop/index.html +1 -1
  38. package/dist/ui/server.js +1 -1
  39. package/package.json +4 -2
@@ -0,0 +1,33 @@
1
+ /** Consecutive bad DIRECT outcomes before a host is learned into a region. */
2
+ export declare const MISCLASSIFIED_AFTER = 2;
3
+ /** A tunnel that lived less than this and delivered less than SUSPICIOUS_BYTES
4
+ * downstream is a "suspicious close" — established, then killed before the
5
+ * TLS handshake could even finish (a completed handshake alone sends >1KB of
6
+ * certificate data down, so <1KB ≈ RST-after-ClientHello, the GFW signature).
7
+ * A legit-but-tiny exchange (tracking pixel, 204 API poll) clears 1KB easily
8
+ * and is never counted. */
9
+ export declare const SUSPICIOUS_MS = 3000;
10
+ export declare const SUSPICIOUS_BYTES = 1024;
11
+ export declare class RouteLearner {
12
+ #private;
13
+ constructor(path?: string, log?: (msg: string) => void);
14
+ /** The learned region for a host, if any (and not expired). */
15
+ get(host: string): string | null;
16
+ /** Record a bad DIRECT outcome (hard failure or suspicious close).
17
+ * Returns true when the host has crossed the threshold and should be
18
+ * learned into the fallback region. */
19
+ recordDirectBad(host: string): boolean;
20
+ /** A DIRECT tunnel to the host worked properly — clear its strike counter. */
21
+ recordDirectGood(host: string): void;
22
+ /** Classify an established tunnel's ending: was it a suspicious early close? */
23
+ static suspiciousClose(bytesDown: number, lifetimeMs: number): boolean;
24
+ learn(host: string, region: string): void;
25
+ /** The learned region failed too (all exits down for this host) — forget it
26
+ * so the host can try DIRECT again next time. */
27
+ unlearn(host: string): void;
28
+ get size(): number;
29
+ entries(): Array<{
30
+ host: string;
31
+ region: string;
32
+ }>;
33
+ }
@@ -0,0 +1,129 @@
1
+ //
2
+ // Route learner — automatic classification of hosts the static domain table
3
+ // misses (e.g. piccpndali.v.myalicdn.com, CCTV's real video CDN, which no
4
+ // suffix list anticipated and which therefore leaked DIRECT and geo-failed).
5
+ //
6
+ // Signals that a DIRECT route is wrong for a host:
7
+ // * hard failure — the dial itself errors or times out;
8
+ // * suspicious close — the tunnel establishes but dies almost immediately
9
+ // with almost no payload (the GFW-style RST-after-ClientHello, or a
10
+ // geo-block that drops the connection instead of answering).
11
+ //
12
+ // After MISCLASSIFIED_AFTER consecutive bad DIRECT outcomes the host is
13
+ // learned into a fallback region and persisted, so the fix survives restarts.
14
+ // A learned route that stops working (all exits fail) is unlearned — a host
15
+ // can always find its way back to DIRECT.
16
+ //
17
+ import fs from "node:fs";
18
+ /** Consecutive bad DIRECT outcomes before a host is learned into a region. */
19
+ export const MISCLASSIFIED_AFTER = 2;
20
+ /** A tunnel that lived less than this and delivered less than SUSPICIOUS_BYTES
21
+ * downstream is a "suspicious close" — established, then killed before the
22
+ * TLS handshake could even finish (a completed handshake alone sends >1KB of
23
+ * certificate data down, so <1KB ≈ RST-after-ClientHello, the GFW signature).
24
+ * A legit-but-tiny exchange (tracking pixel, 204 API poll) clears 1KB easily
25
+ * and is never counted. */
26
+ export const SUSPICIOUS_MS = 3000;
27
+ export const SUSPICIOUS_BYTES = 1024;
28
+ /** Learned entries expire after this long (the internet moves; re-verify). */
29
+ const LEARNED_TTL_MS = 30 * 24 * 3600 * 1000;
30
+ /** Cap the failure-counter table so hostile traffic can't grow it unbounded. */
31
+ const MAX_TRACKED_HOSTS = 2000;
32
+ export class RouteLearner {
33
+ #learned = new Map();
34
+ #fails = new Map();
35
+ #path;
36
+ #saveTimer = null;
37
+ #log;
38
+ constructor(path, log) {
39
+ this.#path = path;
40
+ this.#log = log ?? (() => undefined);
41
+ this.#load();
42
+ }
43
+ /** The learned region for a host, if any (and not expired). */
44
+ get(host) {
45
+ const e = this.#learned.get(host);
46
+ if (!e)
47
+ return null;
48
+ if (Date.now() - e.at > LEARNED_TTL_MS) {
49
+ this.#learned.delete(host);
50
+ this.#scheduleSave();
51
+ return null;
52
+ }
53
+ return e.region;
54
+ }
55
+ /** Record a bad DIRECT outcome (hard failure or suspicious close).
56
+ * Returns true when the host has crossed the threshold and should be
57
+ * learned into the fallback region. */
58
+ recordDirectBad(host) {
59
+ if (this.#fails.size >= MAX_TRACKED_HOSTS && !this.#fails.has(host)) {
60
+ const oldest = this.#fails.keys().next().value;
61
+ this.#fails.delete(oldest);
62
+ }
63
+ const n = (this.#fails.get(host) ?? 0) + 1;
64
+ this.#fails.set(host, n);
65
+ return n >= MISCLASSIFIED_AFTER;
66
+ }
67
+ /** A DIRECT tunnel to the host worked properly — clear its strike counter. */
68
+ recordDirectGood(host) {
69
+ this.#fails.delete(host);
70
+ }
71
+ /** Classify an established tunnel's ending: was it a suspicious early close? */
72
+ static suspiciousClose(bytesDown, lifetimeMs) {
73
+ return lifetimeMs < SUSPICIOUS_MS && bytesDown < SUSPICIOUS_BYTES;
74
+ }
75
+ learn(host, region) {
76
+ this.#learned.set(host, { region, at: Date.now() });
77
+ this.#fails.delete(host);
78
+ this.#log(`learned route ${host} → region [${region}] (direct kept failing)`);
79
+ this.#scheduleSave();
80
+ }
81
+ /** The learned region failed too (all exits down for this host) — forget it
82
+ * so the host can try DIRECT again next time. */
83
+ unlearn(host) {
84
+ if (this.#learned.delete(host)) {
85
+ this.#log(`unlearned route ${host} (region exits failed; back to direct)`);
86
+ this.#scheduleSave();
87
+ }
88
+ }
89
+ get size() {
90
+ return this.#learned.size;
91
+ }
92
+ entries() {
93
+ return [...this.#learned.entries()].map(([host, e]) => ({ host, region: e.region }));
94
+ }
95
+ #load() {
96
+ if (!this.#path)
97
+ return;
98
+ try {
99
+ const raw = JSON.parse(fs.readFileSync(this.#path, "utf8"));
100
+ const now = Date.now();
101
+ for (const [host, e] of Object.entries(raw)) {
102
+ if (e && typeof e.region === "string" && now - e.at <= LEARNED_TTL_MS)
103
+ this.#learned.set(host, e);
104
+ }
105
+ if (this.#learned.size > 0)
106
+ this.#log(`loaded ${this.#learned.size} learned route(s) from ${this.#path}`);
107
+ }
108
+ catch {
109
+ /* no file yet / unreadable — start empty */
110
+ }
111
+ }
112
+ #scheduleSave() {
113
+ if (!this.#path || this.#saveTimer)
114
+ return;
115
+ this.#saveTimer = setTimeout(() => {
116
+ this.#saveTimer = null;
117
+ try {
118
+ const obj = {};
119
+ for (const [h, e] of this.#learned)
120
+ obj[h] = e;
121
+ fs.writeFileSync(this.#path, JSON.stringify(obj, null, 2));
122
+ }
123
+ catch {
124
+ /* best-effort persistence */
125
+ }
126
+ }, 3000);
127
+ this.#saveTimer.unref?.();
128
+ }
129
+ }
@@ -0,0 +1,45 @@
1
+ export type FlowKey = string;
2
+ export interface PacedForwarderOptions {
3
+ /** Actually hand a packet to the carrier for `dstIp`. Should resolve quickly
4
+ * (best-effort UDP); rejection is counted as a drop. */
5
+ send: (dstIp: string, packet: Uint8Array) => Promise<void>;
6
+ /** Default per-destination rate (bytes/sec) before a measured estimate
7
+ * arrives. Generous so pacing is a no-op until setRate tightens it. */
8
+ defaultRateBps?: number;
9
+ /** Max queued bytes per flow before tail-drop (bounds latency + memory). */
10
+ maxFlowBytes?: number;
11
+ /** Max queued bytes across a destination before shed (backstop). */
12
+ maxDestBytes?: number;
13
+ /** Token-bucket burst ceiling as seconds-worth of rate. */
14
+ burstSeconds?: number;
15
+ now?: () => number;
16
+ schedule?: (fn: () => void, ms: number) => void;
17
+ }
18
+ export interface PacedForwarderStats {
19
+ dests: number;
20
+ flows: number;
21
+ queuedBytes: number;
22
+ sent: number;
23
+ dropped: number;
24
+ }
25
+ export declare class PacedForwarder {
26
+ #private;
27
+ constructor(opts: PacedForwarderOptions);
28
+ /** Set the paced target rate (bytes/sec) for a destination from a measured
29
+ * delivered-rate estimate. */
30
+ setRate(dstIp: string, rateBps: number): void;
31
+ /** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
32
+ * drain loop releases it paced + fairly. Returns false if it was dropped
33
+ * (queue overflow). */
34
+ enqueue(dstIp: string, flowKey: FlowKey, packet: Uint8Array): boolean;
35
+ stats(): PacedForwarderStats;
36
+ }
37
+ /** Build a stable per-flow key from the 5-tuple (direction-agnostic within a
38
+ * destination — src identifies the flow, dst is the queue owner). */
39
+ export declare function flowKeyOf(p: {
40
+ srcIp: string;
41
+ dstIp: string;
42
+ srcPort?: number;
43
+ dstPort?: number;
44
+ protocol: number;
45
+ }): FlowKey;
@@ -0,0 +1,188 @@
1
+ //
2
+ // Paced, fair egress forwarder for TUN → carrier IP data.
3
+ //
4
+ // Problem (measured): the raw path drops nothing when idle but ~90% under load
5
+ // — classic CONGESTION. Every forwarded IP packet was fired at the carrier the
6
+ // instant it arrived, with no pacing and no fairness. When several flows (a
7
+ // video's many connections, an SSH session, a download) together exceed the
8
+ // path's capacity, the network drops indiscriminately; some flows' inner TCP
9
+ // reads that as loss and collapses into multi-second RTO backoff — the stall
10
+ // that stutters video even at low resolution and lags SSH.
11
+ //
12
+ // This inserts two classic mechanisms in front of session.send:
13
+ //
14
+ // 1. Per-flow FAIR QUEUE (round-robin over the 5-tuple). Each flow gets its
15
+ // own queue; we serve them in rotation, one packet each, so no single
16
+ // bursty flow can starve the others. A flooding flow's own queue overflows
17
+ // (tail-drop) — signalling congestion to just that flow's TCP — instead of
18
+ // drowning everyone.
19
+ //
20
+ // 2. Per-destination TOKEN-BUCKET PACER. Packets are released toward a target
21
+ // rate instead of in bursts, so we stop overrunning the path and inducing
22
+ // the drops ourselves. The target rate is set externally from a delivered-
23
+ // rate estimate (setRate); until one is known it defaults generously so the
24
+ // pacer never becomes the bottleneck — the fair queue still does its work.
25
+ //
26
+ // Clock and scheduler are injectable so the logic is unit-testable without
27
+ // real time.
28
+ //
29
+ const DEFAULT_RATE_BPS = 12_500_000; // 100 Mbps — effectively uncapped until measured
30
+ const DEFAULT_MAX_FLOW_BYTES = 256 * 1024;
31
+ const DEFAULT_MAX_DEST_BYTES = 4 * 1024 * 1024;
32
+ const DEFAULT_BURST_SECONDS = 0.05; // 50ms of rate as burst — smooths without adding latency
33
+ const MIN_RATE_BPS = 32 * 1024; // never pace below 256 kbps
34
+ class Dest {
35
+ rateBps;
36
+ tokens;
37
+ lastRefillMs;
38
+ burstBytes;
39
+ flows = new Map();
40
+ order = []; // round-robin ring
41
+ cursor = 0;
42
+ totalBytes = 0;
43
+ draining = false;
44
+ constructor(rateBps, burstSeconds, nowMs) {
45
+ this.rateBps = rateBps;
46
+ this.burstBytes = Math.max(1500, rateBps * burstSeconds);
47
+ this.tokens = this.burstBytes;
48
+ this.lastRefillMs = nowMs;
49
+ }
50
+ refill(nowMs) {
51
+ const dt = nowMs - this.lastRefillMs;
52
+ if (dt <= 0)
53
+ return;
54
+ this.tokens = Math.min(this.burstBytes, this.tokens + (this.rateBps * dt) / 1000);
55
+ this.lastRefillMs = nowMs;
56
+ }
57
+ setRate(rateBps, burstSeconds) {
58
+ this.rateBps = Math.max(MIN_RATE_BPS, rateBps);
59
+ this.burstBytes = Math.max(1500, this.rateBps * burstSeconds);
60
+ if (this.tokens > this.burstBytes)
61
+ this.tokens = this.burstBytes;
62
+ }
63
+ }
64
+ export class PacedForwarder {
65
+ #opts;
66
+ #dests = new Map();
67
+ #sent = 0;
68
+ #dropped = 0;
69
+ constructor(opts) {
70
+ this.#opts = {
71
+ send: opts.send,
72
+ defaultRateBps: opts.defaultRateBps ?? DEFAULT_RATE_BPS,
73
+ maxFlowBytes: opts.maxFlowBytes ?? DEFAULT_MAX_FLOW_BYTES,
74
+ maxDestBytes: opts.maxDestBytes ?? DEFAULT_MAX_DEST_BYTES,
75
+ burstSeconds: opts.burstSeconds ?? DEFAULT_BURST_SECONDS,
76
+ now: opts.now ?? Date.now,
77
+ schedule: opts.schedule ?? ((fn, ms) => { setTimeout(fn, ms).unref?.(); }),
78
+ };
79
+ }
80
+ /** Set the paced target rate (bytes/sec) for a destination from a measured
81
+ * delivered-rate estimate. */
82
+ setRate(dstIp, rateBps) {
83
+ const d = this.#dest(dstIp);
84
+ d.setRate(rateBps, this.#opts.burstSeconds);
85
+ }
86
+ /** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
87
+ * drain loop releases it paced + fairly. Returns false if it was dropped
88
+ * (queue overflow). */
89
+ enqueue(dstIp, flowKey, packet) {
90
+ const d = this.#dest(dstIp);
91
+ // Backstop: shed if the whole destination is way over budget.
92
+ if (d.totalBytes >= this.#opts.maxDestBytes) {
93
+ this.#dropped++;
94
+ return false;
95
+ }
96
+ let f = d.flows.get(flowKey);
97
+ if (!f) {
98
+ f = { key: flowKey, packets: [], bytes: 0 };
99
+ d.flows.set(flowKey, f);
100
+ d.order.push(flowKey);
101
+ }
102
+ // Per-flow tail-drop: a flooding flow overflows its own queue, signalling
103
+ // congestion to just that flow's TCP without starving the others.
104
+ if (f.bytes + packet.length > this.#opts.maxFlowBytes) {
105
+ const oldest = f.packets.shift();
106
+ if (oldest) {
107
+ f.bytes -= oldest.length;
108
+ d.totalBytes -= oldest.length;
109
+ this.#dropped++;
110
+ }
111
+ }
112
+ f.packets.push(packet);
113
+ f.bytes += packet.length;
114
+ d.totalBytes += packet.length;
115
+ this.#drain(dstIp);
116
+ return true;
117
+ }
118
+ stats() {
119
+ let flows = 0, queued = 0;
120
+ for (const d of this.#dests.values()) {
121
+ flows += d.flows.size;
122
+ queued += d.totalBytes;
123
+ }
124
+ return { dests: this.#dests.size, flows, queuedBytes: queued, sent: this.#sent, dropped: this.#dropped };
125
+ }
126
+ #dest(dstIp) {
127
+ let d = this.#dests.get(dstIp);
128
+ if (!d) {
129
+ d = new Dest(this.#opts.defaultRateBps, this.#opts.burstSeconds, this.#opts.now());
130
+ this.#dests.set(dstIp, d);
131
+ }
132
+ return d;
133
+ }
134
+ #drain(dstIp) {
135
+ const d = this.#dests.get(dstIp);
136
+ if (!d || d.draining)
137
+ return;
138
+ d.draining = true;
139
+ this.#pump(dstIp, d);
140
+ }
141
+ #pump(dstIp, d) {
142
+ d.draining = false;
143
+ const now = this.#opts.now();
144
+ d.refill(now);
145
+ // Round-robin across flows: emit one packet from the next non-empty flow
146
+ // each step, spending tokens; stop when out of tokens or all queues empty.
147
+ let guard = 0;
148
+ while (d.order.length > 0) {
149
+ if (++guard > 100_000)
150
+ break; // safety
151
+ const key = d.order[d.cursor % d.order.length];
152
+ const f = d.flows.get(key);
153
+ if (!f || f.packets.length === 0) {
154
+ // Drop this flow from the ring.
155
+ d.order.splice(d.cursor % d.order.length, 1);
156
+ if (f && f.packets.length === 0)
157
+ d.flows.delete(key);
158
+ if (d.order.length === 0)
159
+ break;
160
+ if (d.cursor >= d.order.length)
161
+ d.cursor = 0;
162
+ continue;
163
+ }
164
+ const size = f.packets[0].length;
165
+ if (d.tokens < size) {
166
+ // Out of tokens — resume when enough have refilled.
167
+ const need = size - d.tokens;
168
+ const ms = Math.max(1, Math.ceil((need / d.rateBps) * 1000));
169
+ d.draining = true;
170
+ this.#opts.schedule(() => this.#pump(dstIp, d), ms);
171
+ return;
172
+ }
173
+ // Emit one packet from this flow, then advance the cursor (fairness).
174
+ const pkt = f.packets.shift();
175
+ f.bytes -= size;
176
+ d.totalBytes -= size;
177
+ d.tokens -= size;
178
+ this.#sent++;
179
+ void this.#opts.send(dstIp, pkt).catch(() => { this.#dropped++; });
180
+ d.cursor = (d.cursor + 1) % Math.max(1, d.order.length);
181
+ }
182
+ }
183
+ }
184
+ /** Build a stable per-flow key from the 5-tuple (direction-agnostic within a
185
+ * destination — src identifies the flow, dst is the queue owner). */
186
+ export function flowKeyOf(p) {
187
+ return `${p.protocol}|${p.srcIp}:${p.srcPort ?? 0}|${p.dstIp}:${p.dstPort ?? 0}`;
188
+ }
@@ -16,6 +16,10 @@ export interface PacketRouterOptions {
16
16
  peerManager: PeerManager;
17
17
  ipam: Ipam;
18
18
  acl: AclEngine;
19
+ /** Pace + fair-queue egress IP data (default true). Under load this prevents
20
+ * one bursty flow from congestion-collapsing others. Set false to fire every
21
+ * packet immediately (the old behavior). */
22
+ pacedForwarding?: boolean;
19
23
  }
20
24
  export declare class PacketRouter extends EventEmitter {
21
25
  private tunDevice;
@@ -23,6 +27,10 @@ export declare class PacketRouter extends EventEmitter {
23
27
  private ipam;
24
28
  private acl;
25
29
  private sessionManager;
30
+ private pacer;
31
+ /** Inbound IP bytes received per source peer since the last rate report. */
32
+ private inboundBytes;
33
+ private rateReportTimer?;
26
34
  private logger;
27
35
  private isRunning;
28
36
  private listenedSessions;
@@ -37,6 +45,8 @@ export declare class PacketRouter extends EventEmitter {
37
45
  private loggedDropTuples;
38
46
  constructor(opts: PacketRouterOptions);
39
47
  start(): Promise<void>;
48
+ /** Emit one delivered-rate report per peer with meaningful inbound this tick. */
49
+ private sendRateReports;
40
50
  stop(): Promise<void>;
41
51
  /**
42
52
  * Swap the active TUN device. Used when dora's background retry
@@ -8,14 +8,28 @@
8
8
  import { EventEmitter } from "events";
9
9
  import { IpParser } from "./ip-parser.js";
10
10
  import { SessionManager } from "./session-manager.js";
11
+ import { PacedForwarder, flowKeyOf } from "./paced-forwarder.js";
11
12
  import { Logger } from "../utils/logger.js";
12
13
  const SUBNET_PREFIX = "10.86.";
14
+ // How often each node reports the delivered rate of a peer's inbound flow.
15
+ const RATE_REPORT_INTERVAL_MS = 1000;
16
+ // Only report for peers whose inbound this interval exceeds this — no point
17
+ // pacing a trickle (SSH/keepalives), and it keeps reports sparse.
18
+ 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;
13
23
  export class PacketRouter extends EventEmitter {
14
24
  tunDevice;
15
25
  peerManager;
16
26
  ipam;
17
27
  acl;
18
28
  sessionManager;
29
+ pacer = null;
30
+ /** Inbound IP bytes received per source peer since the last rate report. */
31
+ inboundBytes = new Map();
32
+ rateReportTimer;
19
33
  logger;
20
34
  isRunning = false;
21
35
  listenedSessions = new WeakSet();
@@ -45,6 +59,29 @@ export class PacketRouter extends EventEmitter {
45
59
  ipam: opts.ipam,
46
60
  });
47
61
  this.logger = new Logger({ prefix: "PacketRouter" });
62
+ if (opts.pacedForwarding !== false) {
63
+ // The pacer's send callback resolves the (cached) session for the dest at
64
+ // release time and hands it the packet. A rejected send counts as a drop.
65
+ this.pacer = new PacedForwarder({
66
+ send: async (dstIp, pkt) => {
67
+ const s = await this.sessionManager.getOrOpenSession(dstIp);
68
+ if (!s)
69
+ throw new Error("no session");
70
+ await s.send(pkt);
71
+ this.stats.packetsForwarded++;
72
+ },
73
+ });
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).
77
+ this.peerManager.on("rate-report", (pubkey, bytesPerSec) => {
78
+ const mapping = this.ipam.resolveCarrierId(pubkey);
79
+ if (!mapping?.virtualIp || !this.pacer)
80
+ return;
81
+ const target = Math.max(RATE_REPORT_MIN_BPS, bytesPerSec * RATE_REPORT_HEADROOM);
82
+ this.pacer.setRate(mapping.virtualIp, target);
83
+ });
84
+ }
48
85
  this.setupCarrierHandlers();
49
86
  }
50
87
  async start() {
@@ -78,8 +115,28 @@ export class PacketRouter extends EventEmitter {
78
115
  this.keepaliveTimer = setInterval(() => {
79
116
  this.sendKeepalivesToActiveSessions();
80
117
  }, 10000);
118
+ // Periodic delivered-rate reports: tell each peer how fast their inbound IP
119
+ // flow is actually landing here, so their pacer can size to it.
120
+ if (this.pacer) {
121
+ this.rateReportTimer = setInterval(() => {
122
+ this.sendRateReports();
123
+ }, RATE_REPORT_INTERVAL_MS);
124
+ this.rateReportTimer.unref?.();
125
+ }
81
126
  this.logger.info("Packet router started");
82
127
  }
128
+ /** Emit one delivered-rate report per peer with meaningful inbound this tick. */
129
+ sendRateReports() {
130
+ if (this.inboundBytes.size === 0)
131
+ return;
132
+ const perSec = 1000 / RATE_REPORT_INTERVAL_MS;
133
+ for (const [pubkey, bytes] of this.inboundBytes) {
134
+ if (bytes >= RATE_REPORT_MIN_BYTES) {
135
+ void this.peerManager.sendRateReport(pubkey, bytes * perSec);
136
+ }
137
+ }
138
+ this.inboundBytes.clear();
139
+ }
83
140
  async stop() {
84
141
  if (!this.isRunning)
85
142
  return;
@@ -89,6 +146,10 @@ export class PacketRouter extends EventEmitter {
89
146
  clearInterval(this.keepaliveTimer);
90
147
  this.keepaliveTimer = undefined;
91
148
  }
149
+ if (this.rateReportTimer) {
150
+ clearInterval(this.rateReportTimer);
151
+ this.rateReportTimer = undefined;
152
+ }
92
153
  await this.tunDevice.stopReadLoop();
93
154
  this.sessionManager.closeAll();
94
155
  this.logger.info("Packet router stopped");
@@ -260,7 +321,14 @@ export class PacketRouter extends EventEmitter {
260
321
  this.recordDrop(parsed.dstIp, reason);
261
322
  return;
262
323
  }
263
- // Send packet
324
+ // Send packet. With pacing on, enqueue into the per-flow fair queue and
325
+ // return immediately (the pacer releases it toward the target rate without
326
+ // one flow starving another under load); the send itself happens in the
327
+ // pacer's drain. Without pacing, fire it straight at the session (old path).
328
+ if (this.pacer) {
329
+ this.pacer.enqueue(parsed.dstIp, flowKeyOf(parsed), packet);
330
+ return;
331
+ }
264
332
  try {
265
333
  await session.send(packet);
266
334
  this.stats.packetsForwarded++;
@@ -332,6 +400,11 @@ export class PacketRouter extends EventEmitter {
332
400
  try {
333
401
  await this.tunDevice.write(packet);
334
402
  this.stats.packetsReceived++;
403
+ // Accumulate delivered bytes from this peer so the periodic rate report
404
+ // can tell them how fast their traffic is actually landing here — the
405
+ // signal their pacer uses to size its token bucket.
406
+ if (this.pacer)
407
+ this.inboundBytes.set(srcPubkey, (this.inboundBytes.get(srcPubkey) ?? 0) + packet.length);
335
408
  this.logger.debug(`Received ${proto} ${parsed.srcIp}:${parsed.srcPort || "-"} -> ${parsed.dstIp}:${parsed.dstPort || "-"} (${packet.length} bytes)`);
336
409
  }
337
410
  catch (err) {
package/dist/types.d.ts CHANGED
@@ -186,6 +186,13 @@ export interface DecentAgentNetConfig {
186
186
  proxy?: ProxyConfig;
187
187
  friends?: FriendsConfig;
188
188
  dora?: DoraConfig;
189
+ forwarding?: ForwardingConfig;
190
+ }
191
+ export interface ForwardingConfig {
192
+ /** Pace + fair-queue egress IP data (default true). Prevents one bursty flow
193
+ * from congestion-collapsing others under load. Set false for the old
194
+ * fire-every-packet-immediately behavior. */
195
+ paced?: boolean;
189
196
  }
190
197
  export interface PacketSessionInfo {
191
198
  peerId: string;
@@ -1607,7 +1607,7 @@ function DkApp() {
1607
1607
  { id: "network", icon: "network", label: T.network },
1608
1608
  { id: "profile", icon: "userRound", label: T.profile }
1609
1609
  ];
1610
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "decentlan"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
1610
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
1611
1611
  TweakRadio,
1612
1612
  {
1613
1613
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>decentlan — Beagle Chat</title>
6
+ <title>Beagle</title>
7
7
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
8
8
  <style>
9
9
  * { box-sizing: border-box; }
package/dist/ui/server.js CHANGED
@@ -651,7 +651,7 @@ export function startFriendUi(opts) {
651
651
  const PAGE = `<!doctype html>
652
652
  <html lang="en"><head><meta charset="utf-8"/>
653
653
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
654
- <title>decentlan — friends</title>
654
+ <title>Beagle — friends</title>
655
655
  <style>
656
656
  :root { color-scheme: light dark; }
657
657
  body { font: 15px/1.5 -apple-system, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.137",
3
+ "version": "0.1.139",
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,15 +79,17 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.11",
82
- "@decentnetwork/peer": "^0.1.70",
82
+ "@decentnetwork/peer": "^0.1.72",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
+ "node-forge": "^1.4.0",
85
86
  "react": "^18.3.1",
86
87
  "yargs": "^17.7.2"
87
88
  },
88
89
  "devDependencies": {
89
90
  "@types/js-yaml": "^4.0.9",
90
91
  "@types/node": "^20.12.12",
92
+ "@types/node-forge": "^1.3.14",
91
93
  "@types/yargs": "^17.0.35",
92
94
  "@typescript-eslint/eslint-plugin": "^7.10.0",
93
95
  "@typescript-eslint/parser": "^7.10.0",