@decentnetwork/lan 0.1.138 → 0.1.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/carrier/peer-manager.d.ts +10 -0
- package/dist/carrier/peer-manager.js +25 -1
- package/dist/carrier/types.d.ts +1 -0
- package/dist/carrier/types.js +6 -0
- package/dist/cli/commands.d.ts +1 -0
- package/dist/cli/commands.js +3 -0
- package/dist/cli/index.js +2 -0
- package/dist/daemon/server.js +7 -0
- package/dist/proxy/data/chnroutes-cn.d.ts +1 -0
- package/dist/proxy/data/chnroutes-cn.js +691 -0
- package/dist/proxy/geo-cn.d.ts +25 -0
- package/dist/proxy/geo-cn.js +144 -0
- package/dist/proxy/hls-prefetch.d.ts +67 -0
- package/dist/proxy/hls-prefetch.js +195 -0
- package/dist/proxy/mitm-ca.d.ts +27 -0
- package/dist/proxy/mitm-ca.js +120 -0
- package/dist/proxy/mitm-gateway.d.ts +22 -0
- package/dist/proxy/mitm-gateway.js +76 -0
- package/dist/proxy/multi-exit-router.d.ts +13 -0
- package/dist/proxy/multi-exit-router.js +693 -78
- package/dist/proxy/route-learner.d.ts +33 -0
- package/dist/proxy/route-learner.js +129 -0
- package/dist/router/paced-forwarder.d.ts +45 -0
- package/dist/router/paced-forwarder.js +188 -0
- package/dist/router/packet-router.d.ts +10 -0
- package/dist/router/packet-router.js +74 -1
- package/dist/types.d.ts +7 -0
- package/package.json +4 -2
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
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.
|
|
82
|
+
"@decentnetwork/peer": "^0.1.73",
|
|
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",
|