@decentnetwork/lan 0.1.11 → 0.1.12
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/dist/carrier/peer-manager.d.ts +7 -0
- package/dist/carrier/peer-manager.js +33 -19
- package/dist/router/packet-router.d.ts +14 -0
- package/dist/router/packet-router.js +55 -26
- package/dist/router/session-manager.d.ts +8 -0
- package/dist/router/session-manager.js +22 -7
- package/dist/router/types.d.ts +19 -0
- package/package.json +1 -1
|
@@ -65,6 +65,13 @@ export declare class PeerManager extends EventEmitter {
|
|
|
65
65
|
/**
|
|
66
66
|
* Check if a specific friend is currently online (direct UDP path established).
|
|
67
67
|
* Returns false for unknown pubkeys.
|
|
68
|
+
*
|
|
69
|
+
* Matches on BOTH `pubkey` and `userid` because the SDK's FriendRecord
|
|
70
|
+
* is inconsistent across code paths: outbound friend requests set
|
|
71
|
+
* `pubkey: friendId` (the base58 userid), inbound friend requests do
|
|
72
|
+
* the same, but older persisted entries (pre-1.7.x) only had `pubkey`
|
|
73
|
+
* and the comparison failed silently when callers pass a userid.
|
|
74
|
+
* Same belt-and-braces as `isFriend()` above.
|
|
68
75
|
*/
|
|
69
76
|
isFriendOnline(pubkey: string): boolean;
|
|
70
77
|
/**
|
|
@@ -169,11 +169,20 @@ export class PeerManager extends EventEmitter {
|
|
|
169
169
|
/**
|
|
170
170
|
* Check if a specific friend is currently online (direct UDP path established).
|
|
171
171
|
* Returns false for unknown pubkeys.
|
|
172
|
+
*
|
|
173
|
+
* Matches on BOTH `pubkey` and `userid` because the SDK's FriendRecord
|
|
174
|
+
* is inconsistent across code paths: outbound friend requests set
|
|
175
|
+
* `pubkey: friendId` (the base58 userid), inbound friend requests do
|
|
176
|
+
* the same, but older persisted entries (pre-1.7.x) only had `pubkey`
|
|
177
|
+
* and the comparison failed silently when callers pass a userid.
|
|
178
|
+
* Same belt-and-braces as `isFriend()` above.
|
|
172
179
|
*/
|
|
173
180
|
isFriendOnline(pubkey) {
|
|
174
181
|
if (!this.peer)
|
|
175
182
|
return false;
|
|
176
|
-
const friend = this.peer
|
|
183
|
+
const friend = this.peer
|
|
184
|
+
.friends()
|
|
185
|
+
.find((f) => f.pubkey === pubkey || f.userid === pubkey);
|
|
177
186
|
return friend?.status === "online";
|
|
178
187
|
}
|
|
179
188
|
/**
|
|
@@ -312,25 +321,30 @@ export class PeerManager extends EventEmitter {
|
|
|
312
321
|
};
|
|
313
322
|
this.emit("friend-connection", evt);
|
|
314
323
|
this.logger.info(`Friend ${event.pubkey} ${event.status}`);
|
|
315
|
-
//
|
|
316
|
-
// online after we already had a session — e.g. they restarted
|
|
317
|
-
// their daemon), the Carrier crypto session under us has
|
|
318
|
-
// rotated. Our cached PacketSession is bound to the previous
|
|
319
|
-
// session's frame-sequence state. Sending DATA frames through
|
|
320
|
-
// it produces ciphertext the new peer can't decrypt; the
|
|
321
|
-
// packets land in their PeerManager.onText but no session
|
|
322
|
-
// matches and they're dropped silently. Result: 100% loss
|
|
323
|
-
// that looks like "the link is up but pings time out."
|
|
324
|
+
// Drop our cached PacketSession ONLY on disconnect.
|
|
324
325
|
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
326
|
+
// History: we used to also close on `connected` events under the
|
|
327
|
+
// theory that the Carrier crypto session had "rotated" and our
|
|
328
|
+
// session was bound to old state. That theory was wrong —
|
|
329
|
+
// PacketSession holds only `sessionId` (decentlan-layer demux ID)
|
|
330
|
+
// and `isActive`; all crypto state lives inside the SDK and is
|
|
331
|
+
// read fresh on every sendText. Worse, the SDK fires `connected`
|
|
332
|
+
// multiple times during initial session-up (once when a TCP relay
|
|
333
|
+
// route appears, again when UDP holepunching lands). Each one
|
|
334
|
+
// destroyed our just-created PacketSession mid-handshake,
|
|
335
|
+
// killing the HANDSHAKE_ACK before it could fire — symptom:
|
|
336
|
+
// activeSessions=0 forever, even though friend status shows
|
|
337
|
+
// "online". The restart-case the old code worried about is
|
|
338
|
+
// already covered: a fresh HANDSHAKE_REQ from the remote enters
|
|
339
|
+
// the onText path below and triggers session replacement when
|
|
340
|
+
// the sessionId differs.
|
|
341
|
+
if (event.status !== "connected") {
|
|
342
|
+
const existing = this.sessions.get(event.pubkey);
|
|
343
|
+
if (existing) {
|
|
344
|
+
this.logger.debug(`Closing packet session to ${event.pubkey} on friend-disconnect`);
|
|
345
|
+
existing.close();
|
|
346
|
+
this.sessions.delete(event.pubkey);
|
|
347
|
+
}
|
|
334
348
|
}
|
|
335
349
|
});
|
|
336
350
|
// Text messages contain our framed packets
|
|
@@ -27,6 +27,13 @@ export declare class PacketRouter extends EventEmitter {
|
|
|
27
27
|
private listenedSessions;
|
|
28
28
|
private keepaliveTimer?;
|
|
29
29
|
private stats;
|
|
30
|
+
/** Per-destination drop reasons. Exposed via getStats() for `agentnet diag`. */
|
|
31
|
+
private dropsByDst;
|
|
32
|
+
/** (dstIp,reason) tuples we've already logged at info level — avoids
|
|
33
|
+
* spamming the journal when a single broken destination receives
|
|
34
|
+
* thousands of packets. The first hit per tuple is logged loudly so
|
|
35
|
+
* the operator notices in `journalctl`; subsequent hits stay at debug. */
|
|
36
|
+
private loggedDropTuples;
|
|
30
37
|
constructor(opts: PacketRouterOptions);
|
|
31
38
|
start(): Promise<void>;
|
|
32
39
|
stop(): Promise<void>;
|
|
@@ -47,6 +54,13 @@ export declare class PacketRouter extends EventEmitter {
|
|
|
47
54
|
*/
|
|
48
55
|
private sendKeepalivesToActiveSessions;
|
|
49
56
|
getStats(): ForwardingStats;
|
|
57
|
+
/**
|
|
58
|
+
* Record a drop and bump per-destination diagnostics. Logs the FIRST
|
|
59
|
+
* occurrence of each (dstIp, reason) at info level so the failure mode
|
|
60
|
+
* shows up in `journalctl` without needing AGENTNET_LOG_LEVEL=debug.
|
|
61
|
+
* Subsequent drops are summed into the counter.
|
|
62
|
+
*/
|
|
63
|
+
private recordDrop;
|
|
50
64
|
/**
|
|
51
65
|
* Handle outgoing packet (app -> TUN -> Carrier).
|
|
52
66
|
*/
|
|
@@ -25,6 +25,13 @@ export class PacketRouter extends EventEmitter {
|
|
|
25
25
|
packetsDropped: 0,
|
|
26
26
|
activeSessions: 0,
|
|
27
27
|
};
|
|
28
|
+
/** Per-destination drop reasons. Exposed via getStats() for `agentnet diag`. */
|
|
29
|
+
dropsByDst = new Map();
|
|
30
|
+
/** (dstIp,reason) tuples we've already logged at info level — avoids
|
|
31
|
+
* spamming the journal when a single broken destination receives
|
|
32
|
+
* thousands of packets. The first hit per tuple is logged loudly so
|
|
33
|
+
* the operator notices in `journalctl`; subsequent hits stay at debug. */
|
|
34
|
+
loggedDropTuples = new Set();
|
|
28
35
|
constructor(opts) {
|
|
29
36
|
super();
|
|
30
37
|
this.tunDevice = opts.tunDevice;
|
|
@@ -137,10 +144,37 @@ export class PacketRouter extends EventEmitter {
|
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
getStats() {
|
|
147
|
+
const dropsByDst = {};
|
|
148
|
+
for (const [dstIp, info] of this.dropsByDst) {
|
|
149
|
+
dropsByDst[dstIp] = info;
|
|
150
|
+
}
|
|
140
151
|
return {
|
|
141
152
|
...this.stats,
|
|
142
153
|
activeSessions: this.sessionManager.getActiveCount(),
|
|
154
|
+
dropsByDst,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Record a drop and bump per-destination diagnostics. Logs the FIRST
|
|
159
|
+
* occurrence of each (dstIp, reason) at info level so the failure mode
|
|
160
|
+
* shows up in `journalctl` without needing AGENTNET_LOG_LEVEL=debug.
|
|
161
|
+
* Subsequent drops are summed into the counter.
|
|
162
|
+
*/
|
|
163
|
+
recordDrop(dstIp, reason, errorMsg) {
|
|
164
|
+
this.stats.packetsDropped++;
|
|
165
|
+
const existing = this.dropsByDst.get(dstIp);
|
|
166
|
+
const next = {
|
|
167
|
+
count: (existing?.count ?? 0) + 1,
|
|
168
|
+
lastReason: reason,
|
|
169
|
+
lastError: errorMsg ? errorMsg.slice(0, 200) : undefined,
|
|
170
|
+
lastAt: Date.now(),
|
|
143
171
|
};
|
|
172
|
+
this.dropsByDst.set(dstIp, next);
|
|
173
|
+
const tuple = `${dstIp}|${reason}`;
|
|
174
|
+
if (!this.loggedDropTuples.has(tuple)) {
|
|
175
|
+
this.loggedDropTuples.add(tuple);
|
|
176
|
+
this.logger.info(`Drop ${dstIp}: ${reason}${errorMsg ? ` — ${errorMsg.slice(0, 120)}` : ""} (further drops with this reason silenced)`);
|
|
177
|
+
}
|
|
144
178
|
}
|
|
145
179
|
/**
|
|
146
180
|
* Handle outgoing packet (app -> TUN -> Carrier).
|
|
@@ -150,8 +184,7 @@ export class PacketRouter extends EventEmitter {
|
|
|
150
184
|
return;
|
|
151
185
|
const parsed = IpParser.parse(packet);
|
|
152
186
|
if (!parsed) {
|
|
153
|
-
this.
|
|
154
|
-
this.logger.debug("Dropped invalid IP packet");
|
|
187
|
+
this.recordDrop("unknown", "invalid-ip");
|
|
155
188
|
return;
|
|
156
189
|
}
|
|
157
190
|
// Get our own pubkey for ACL (we are the source for outbound)
|
|
@@ -159,7 +192,7 @@ export class PacketRouter extends EventEmitter {
|
|
|
159
192
|
const proto = IpParser.protoName(parsed.protocol);
|
|
160
193
|
// Only forward TCP/UDP packets that have ports
|
|
161
194
|
if (parsed.dstPort === undefined && proto !== "icmp") {
|
|
162
|
-
this.
|
|
195
|
+
this.recordDrop(parsed.dstIp, "no-port");
|
|
163
196
|
return;
|
|
164
197
|
}
|
|
165
198
|
// ACL check (outbound)
|
|
@@ -178,11 +211,23 @@ export class PacketRouter extends EventEmitter {
|
|
|
178
211
|
return;
|
|
179
212
|
}
|
|
180
213
|
}
|
|
181
|
-
// Get session for destination
|
|
182
|
-
|
|
214
|
+
// Get session for destination. getOrOpenSession returns null for two
|
|
215
|
+
// distinct reasons; we want them distinguished in diag so the operator
|
|
216
|
+
// can tell "I forgot to add this peer to IPAM" from "the friend session
|
|
217
|
+
// hasn't completed yet." sessionManager.classify() looks up the IPAM
|
|
218
|
+
// state without triggering a handshake.
|
|
219
|
+
let session;
|
|
220
|
+
try {
|
|
221
|
+
session = await this.sessionManager.getOrOpenSession(parsed.dstIp);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
225
|
+
this.recordDrop(parsed.dstIp, "handshake-failed", msg);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
183
228
|
if (!session) {
|
|
184
|
-
this.
|
|
185
|
-
this.
|
|
229
|
+
const reason = this.sessionManager.classifyMissingSession(parsed.dstIp);
|
|
230
|
+
this.recordDrop(parsed.dstIp, reason);
|
|
186
231
|
return;
|
|
187
232
|
}
|
|
188
233
|
// Send packet
|
|
@@ -192,16 +237,8 @@ export class PacketRouter extends EventEmitter {
|
|
|
192
237
|
this.logger.debug(`Forwarded ${proto} ${parsed.srcIp}:${parsed.srcPort || "-"} -> ${parsed.dstIp}:${parsed.dstPort || "-"} (${packet.length} bytes)`);
|
|
193
238
|
}
|
|
194
239
|
catch (err) {
|
|
195
|
-
// Friend going offline mid-send is normal during connection flapping;
|
|
196
|
-
// log at debug rather than warn to avoid spamming the operator.
|
|
197
240
|
const msg = err instanceof Error ? err.message : String(err);
|
|
198
|
-
this.
|
|
199
|
-
if (msg.includes("offline") || msg.includes("no transport")) {
|
|
200
|
-
this.logger.debug(`Drop ${parsed.dstIp}: ${msg}`);
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
this.logger.warn(`Failed to forward to ${parsed.dstIp}: ${msg}`);
|
|
204
|
-
}
|
|
241
|
+
this.recordDrop(parsed.dstIp, "send-failed", msg);
|
|
205
242
|
}
|
|
206
243
|
}
|
|
207
244
|
/**
|
|
@@ -240,7 +277,7 @@ export class PacketRouter extends EventEmitter {
|
|
|
240
277
|
// between TUN close and full session teardown produces a "TUN device
|
|
241
278
|
// not open" warning. Treat that case as a silent drop.
|
|
242
279
|
if (!this.isRunning || !this.tunDevice.isActive()) {
|
|
243
|
-
this.
|
|
280
|
+
this.recordDrop(parsed.dstIp, "tun-down");
|
|
244
281
|
return;
|
|
245
282
|
}
|
|
246
283
|
try {
|
|
@@ -249,16 +286,8 @@ export class PacketRouter extends EventEmitter {
|
|
|
249
286
|
this.logger.debug(`Received ${proto} ${parsed.srcIp}:${parsed.srcPort || "-"} -> ${parsed.dstIp}:${parsed.dstPort || "-"} (${packet.length} bytes)`);
|
|
250
287
|
}
|
|
251
288
|
catch (err) {
|
|
252
|
-
this.stats.packetsDropped++;
|
|
253
289
|
const msg = err instanceof Error ? err.message : String(err);
|
|
254
|
-
|
|
255
|
-
// Late packet during shutdown — already covered by the guard
|
|
256
|
-
// above on most paths but the TUN can also close mid-write.
|
|
257
|
-
this.logger.debug(`Drop late inbound packet: ${msg}`);
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
this.logger.warn(`Failed to write to TUN:`, err);
|
|
261
|
-
}
|
|
290
|
+
this.recordDrop(parsed.dstIp, "tun-write-failed", msg);
|
|
262
291
|
}
|
|
263
292
|
}
|
|
264
293
|
setupCarrierHandlers() {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { PacketSession } from "../carrier/packet-session.js";
|
|
6
6
|
import type { PeerManager } from "../carrier/peer-manager.js";
|
|
7
7
|
import type { Ipam } from "../ipam/ipam.js";
|
|
8
|
+
import type { DropReason } from "./types.js";
|
|
8
9
|
export declare class SessionManager {
|
|
9
10
|
private peerManager;
|
|
10
11
|
private ipam;
|
|
@@ -25,6 +26,13 @@ export declare class SessionManager {
|
|
|
25
26
|
* Get session by destination IP (no handshake)
|
|
26
27
|
*/
|
|
27
28
|
getSession(dstIp: string): PacketSession | null;
|
|
29
|
+
/**
|
|
30
|
+
* Classify why getOrOpenSession() returned null for a given dstIp. Used
|
|
31
|
+
* by PacketRouter to record an accurate drop reason in diag without
|
|
32
|
+
* having to plumb a (session | string-reason) union through the hot
|
|
33
|
+
* path.
|
|
34
|
+
*/
|
|
35
|
+
classifyMissingSession(dstIp: string): DropReason;
|
|
28
36
|
/**
|
|
29
37
|
* Register a session that was opened by remote peer (responder side).
|
|
30
38
|
* Called when PeerManager auto-creates session for inbound HANDSHAKE_REQ.
|
|
@@ -13,16 +13,16 @@ export class SessionManager {
|
|
|
13
13
|
this.peerManager = opts.peerManager;
|
|
14
14
|
this.ipam = opts.ipam;
|
|
15
15
|
this.logger = new Logger({ prefix: "SessionManager" });
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// window between the close() call and our re-keying race
|
|
21
|
-
// could return stale data. Belt-and-braces.
|
|
16
|
+
// Mirror PeerManager's policy: drop the IP→session cache on
|
|
17
|
+
// disconnect only. `connected` events fire repeatedly during
|
|
18
|
+
// initial session-up (TCP relay route, then UDP holepunch) and
|
|
19
|
+
// dropping on each one races with the handshake we just started.
|
|
22
20
|
this.peerManager.on("friend-connection", (evt) => {
|
|
21
|
+
if (evt.status === "connected")
|
|
22
|
+
return;
|
|
23
23
|
const record = this.ipam.resolveCarrierId(evt.pubkey);
|
|
24
24
|
if (record && this.sessions.has(record.virtualIp)) {
|
|
25
|
-
this.logger.debug(`Dropping ${record.name} (${record.virtualIp}) session on friend
|
|
25
|
+
this.logger.debug(`Dropping ${record.name} (${record.virtualIp}) session on friend-disconnect`);
|
|
26
26
|
this.sessions.delete(record.virtualIp);
|
|
27
27
|
}
|
|
28
28
|
});
|
|
@@ -74,6 +74,21 @@ export class SessionManager {
|
|
|
74
74
|
getSession(dstIp) {
|
|
75
75
|
return this.sessions.get(dstIp) || null;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Classify why getOrOpenSession() returned null for a given dstIp. Used
|
|
79
|
+
* by PacketRouter to record an accurate drop reason in diag without
|
|
80
|
+
* having to plumb a (session | string-reason) union through the hot
|
|
81
|
+
* path.
|
|
82
|
+
*/
|
|
83
|
+
classifyMissingSession(dstIp) {
|
|
84
|
+
const record = this.ipam.resolveIp(dstIp);
|
|
85
|
+
if (!record)
|
|
86
|
+
return "no-ipam-record";
|
|
87
|
+
if (!this.peerManager.isFriendOnline(record.carrierId)) {
|
|
88
|
+
return "friend-offline";
|
|
89
|
+
}
|
|
90
|
+
return "handshake-failed";
|
|
91
|
+
}
|
|
77
92
|
/**
|
|
78
93
|
* Register a session that was opened by remote peer (responder side).
|
|
79
94
|
* Called when PeerManager auto-creates session for inbound HANDSHAKE_REQ.
|
package/dist/router/types.d.ts
CHANGED
|
@@ -12,10 +12,29 @@ export interface ParsedPacket {
|
|
|
12
12
|
export declare const IP_PROTO_TCP = 6;
|
|
13
13
|
export declare const IP_PROTO_UDP = 17;
|
|
14
14
|
export declare const IP_PROTO_ICMP = 1;
|
|
15
|
+
/**
|
|
16
|
+
* Why a single outbound packet was dropped. Tracked per-destination so
|
|
17
|
+
* `agentnet diag` can answer the operator's first question — "why
|
|
18
|
+
* isn't my ping working?" — without forcing them to crank up the
|
|
19
|
+
* log level.
|
|
20
|
+
*/
|
|
21
|
+
export type DropReason = "invalid-ip" | "no-port" | "no-ipam-record" | "friend-offline" | "handshake-failed" | "send-failed" | "tun-write-failed" | "tun-down";
|
|
22
|
+
export interface DstDropInfo {
|
|
23
|
+
/** Total drops to this destination. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Most recent reason. */
|
|
26
|
+
lastReason: DropReason;
|
|
27
|
+
/** Truncated error message from the most recent drop, if any. */
|
|
28
|
+
lastError?: string;
|
|
29
|
+
/** Timestamp (ms since epoch) of the most recent drop. */
|
|
30
|
+
lastAt: number;
|
|
31
|
+
}
|
|
15
32
|
export interface ForwardingStats {
|
|
16
33
|
packetsForwarded: number;
|
|
17
34
|
packetsReceived: number;
|
|
18
35
|
packetsDenied: number;
|
|
19
36
|
packetsDropped: number;
|
|
20
37
|
activeSessions: number;
|
|
38
|
+
/** Per-destination drop diagnostics. Keyed by virtual IP. */
|
|
39
|
+
dropsByDst?: Record<string, DstDropInfo>;
|
|
21
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|