@decentnetwork/lan 0.1.11 → 0.1.13

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.
@@ -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.friends().find((f) => f.pubkey === pubkey);
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
- // If a friend goes offline (or, more importantly, comes BACK
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
- // Fix: drop our PacketSession on every friend-status change.
326
- // The next outbound packet triggers a fresh handshake (or the
327
- // remote auto-creates from our HANDSHAKE_REQ); fast, clean,
328
- // no stale-session lingering.
329
- const existing = this.sessions.get(event.pubkey);
330
- if (existing) {
331
- this.logger.debug(`Closing stale packet session to ${event.pubkey} on friend-${event.status}`);
332
- existing.close();
333
- this.sessions.delete(event.pubkey);
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
@@ -20,6 +20,7 @@ export interface PacketRouterOptions {
20
20
  export declare class PacketRouter extends EventEmitter {
21
21
  private tunDevice;
22
22
  private peerManager;
23
+ private ipam;
23
24
  private acl;
24
25
  private sessionManager;
25
26
  private logger;
@@ -27,6 +28,13 @@ export declare class PacketRouter extends EventEmitter {
27
28
  private listenedSessions;
28
29
  private keepaliveTimer?;
29
30
  private stats;
31
+ /** Per-destination drop reasons. Exposed via getStats() for `agentnet diag`. */
32
+ private dropsByDst;
33
+ /** (dstIp,reason) tuples we've already logged at info level — avoids
34
+ * spamming the journal when a single broken destination receives
35
+ * thousands of packets. The first hit per tuple is logged loudly so
36
+ * the operator notices in `journalctl`; subsequent hits stay at debug. */
37
+ private loggedDropTuples;
30
38
  constructor(opts: PacketRouterOptions);
31
39
  start(): Promise<void>;
32
40
  stop(): Promise<void>;
@@ -47,6 +55,23 @@ export declare class PacketRouter extends EventEmitter {
47
55
  */
48
56
  private sendKeepalivesToActiveSessions;
49
57
  getStats(): ForwardingStats;
58
+ /**
59
+ * Add (or correct) a (carrierId, virtualIp) mapping in IPAM based on
60
+ * an authenticated inbound packet. Idempotent and cheap — does
61
+ * nothing when the mapping already matches.
62
+ *
63
+ * The IP can change for a single peer (dora re-allocation, daemon
64
+ * restart with a different fallback IP) so we always overwrite on
65
+ * mismatch rather than first-write-wins.
66
+ */
67
+ private learnPeerMapping;
68
+ /**
69
+ * Record a drop and bump per-destination diagnostics. Logs the FIRST
70
+ * occurrence of each (dstIp, reason) at info level so the failure mode
71
+ * shows up in `journalctl` without needing AGENTNET_LOG_LEVEL=debug.
72
+ * Subsequent drops are summed into the counter.
73
+ */
74
+ private recordDrop;
50
75
  /**
51
76
  * Handle outgoing packet (app -> TUN -> Carrier).
52
77
  */
@@ -9,9 +9,11 @@ import { EventEmitter } from "events";
9
9
  import { IpParser } from "./ip-parser.js";
10
10
  import { SessionManager } from "./session-manager.js";
11
11
  import { Logger } from "../utils/logger.js";
12
+ const SUBNET_PREFIX = "10.86.";
12
13
  export class PacketRouter extends EventEmitter {
13
14
  tunDevice;
14
15
  peerManager;
16
+ ipam;
15
17
  acl;
16
18
  sessionManager;
17
19
  logger;
@@ -25,10 +27,18 @@ export class PacketRouter extends EventEmitter {
25
27
  packetsDropped: 0,
26
28
  activeSessions: 0,
27
29
  };
30
+ /** Per-destination drop reasons. Exposed via getStats() for `agentnet diag`. */
31
+ dropsByDst = new Map();
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
+ loggedDropTuples = new Set();
28
37
  constructor(opts) {
29
38
  super();
30
39
  this.tunDevice = opts.tunDevice;
31
40
  this.peerManager = opts.peerManager;
41
+ this.ipam = opts.ipam;
32
42
  this.acl = opts.acl;
33
43
  this.sessionManager = new SessionManager({
34
44
  peerManager: opts.peerManager,
@@ -137,11 +147,65 @@ export class PacketRouter extends EventEmitter {
137
147
  }
138
148
  }
139
149
  getStats() {
150
+ const dropsByDst = {};
151
+ for (const [dstIp, info] of this.dropsByDst) {
152
+ dropsByDst[dstIp] = info;
153
+ }
140
154
  return {
141
155
  ...this.stats,
142
156
  activeSessions: this.sessionManager.getActiveCount(),
157
+ dropsByDst,
143
158
  };
144
159
  }
160
+ /**
161
+ * Add (or correct) a (carrierId, virtualIp) mapping in IPAM based on
162
+ * an authenticated inbound packet. Idempotent and cheap — does
163
+ * nothing when the mapping already matches.
164
+ *
165
+ * The IP can change for a single peer (dora re-allocation, daemon
166
+ * restart with a different fallback IP) so we always overwrite on
167
+ * mismatch rather than first-write-wins.
168
+ */
169
+ learnPeerMapping(srcPubkey, srcIp) {
170
+ const existing = this.ipam.resolveCarrierId(srcPubkey);
171
+ if (existing && existing.virtualIp === srcIp)
172
+ return;
173
+ // Skip our own pubkey — we already own our IPAM entry.
174
+ if (srcPubkey === this.peerManager.getPubkey())
175
+ return;
176
+ const name = existing?.name ?? `peer-${srcPubkey.slice(0, 8)}`;
177
+ this.ipam.assignPeer({
178
+ name,
179
+ carrierId: srcPubkey,
180
+ virtualIp: srcIp,
181
+ services: existing?.services ?? [],
182
+ });
183
+ this.logger.info(existing
184
+ ? `IPAM updated from inbound: ${name} ${existing.virtualIp} -> ${srcIp}`
185
+ : `IPAM learned from inbound: ${name} (${srcPubkey.slice(0, 12)}...) -> ${srcIp}`);
186
+ }
187
+ /**
188
+ * Record a drop and bump per-destination diagnostics. Logs the FIRST
189
+ * occurrence of each (dstIp, reason) at info level so the failure mode
190
+ * shows up in `journalctl` without needing AGENTNET_LOG_LEVEL=debug.
191
+ * Subsequent drops are summed into the counter.
192
+ */
193
+ recordDrop(dstIp, reason, errorMsg) {
194
+ this.stats.packetsDropped++;
195
+ const existing = this.dropsByDst.get(dstIp);
196
+ const next = {
197
+ count: (existing?.count ?? 0) + 1,
198
+ lastReason: reason,
199
+ lastError: errorMsg ? errorMsg.slice(0, 200) : undefined,
200
+ lastAt: Date.now(),
201
+ };
202
+ this.dropsByDst.set(dstIp, next);
203
+ const tuple = `${dstIp}|${reason}`;
204
+ if (!this.loggedDropTuples.has(tuple)) {
205
+ this.loggedDropTuples.add(tuple);
206
+ this.logger.info(`Drop ${dstIp}: ${reason}${errorMsg ? ` — ${errorMsg.slice(0, 120)}` : ""} (further drops with this reason silenced)`);
207
+ }
208
+ }
145
209
  /**
146
210
  * Handle outgoing packet (app -> TUN -> Carrier).
147
211
  */
@@ -150,8 +214,7 @@ export class PacketRouter extends EventEmitter {
150
214
  return;
151
215
  const parsed = IpParser.parse(packet);
152
216
  if (!parsed) {
153
- this.stats.packetsDropped++;
154
- this.logger.debug("Dropped invalid IP packet");
217
+ this.recordDrop("unknown", "invalid-ip");
155
218
  return;
156
219
  }
157
220
  // Get our own pubkey for ACL (we are the source for outbound)
@@ -159,7 +222,7 @@ export class PacketRouter extends EventEmitter {
159
222
  const proto = IpParser.protoName(parsed.protocol);
160
223
  // Only forward TCP/UDP packets that have ports
161
224
  if (parsed.dstPort === undefined && proto !== "icmp") {
162
- this.stats.packetsDropped++;
225
+ this.recordDrop(parsed.dstIp, "no-port");
163
226
  return;
164
227
  }
165
228
  // ACL check (outbound)
@@ -178,11 +241,23 @@ export class PacketRouter extends EventEmitter {
178
241
  return;
179
242
  }
180
243
  }
181
- // Get session for destination
182
- const session = await this.sessionManager.getOrOpenSession(parsed.dstIp);
244
+ // Get session for destination. getOrOpenSession returns null for two
245
+ // distinct reasons; we want them distinguished in diag so the operator
246
+ // can tell "I forgot to add this peer to IPAM" from "the friend session
247
+ // hasn't completed yet." sessionManager.classify() looks up the IPAM
248
+ // state without triggering a handshake.
249
+ let session;
250
+ try {
251
+ session = await this.sessionManager.getOrOpenSession(parsed.dstIp);
252
+ }
253
+ catch (err) {
254
+ const msg = err instanceof Error ? err.message : String(err);
255
+ this.recordDrop(parsed.dstIp, "handshake-failed", msg);
256
+ return;
257
+ }
183
258
  if (!session) {
184
- this.stats.packetsDropped++;
185
- this.logger.debug(`No peer for ${parsed.dstIp}, dropping`);
259
+ const reason = this.sessionManager.classifyMissingSession(parsed.dstIp);
260
+ this.recordDrop(parsed.dstIp, reason);
186
261
  return;
187
262
  }
188
263
  // Send packet
@@ -192,16 +267,8 @@ export class PacketRouter extends EventEmitter {
192
267
  this.logger.debug(`Forwarded ${proto} ${parsed.srcIp}:${parsed.srcPort || "-"} -> ${parsed.dstIp}:${parsed.dstPort || "-"} (${packet.length} bytes)`);
193
268
  }
194
269
  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
270
  const msg = err instanceof Error ? err.message : String(err);
198
- this.stats.packetsDropped++;
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
- }
271
+ this.recordDrop(parsed.dstIp, "send-failed", msg);
205
272
  }
206
273
  }
207
274
  /**
@@ -216,6 +283,25 @@ export class PacketRouter extends EventEmitter {
216
283
  this.logger.debug("Dropped invalid incoming IP packet");
217
284
  return;
218
285
  }
286
+ // Auto-learn (srcPubkey -> srcIp) into IPAM. Without this, ubuntu
287
+ // can receive cn's ICMP fine but the reply path fails: ubuntu's
288
+ // outbound handleOutgoingPacket calls ipam.resolveIp("10.86.1.15")
289
+ // which returns null when ubuntu's dora roster sync is broken
290
+ // (dora friend offline, no other source for cn's virtualIp).
291
+ //
292
+ // We trust the (srcPubkey, srcIp) pairing for two reasons:
293
+ // 1. srcPubkey is Carrier-authenticated — only the real cn can
294
+ // open a PacketSession against ubuntu under cn's userid.
295
+ // 2. The only damage a lying sender could cause is shadowing
296
+ // their OWN reply routing — the IP is the destination for
297
+ // packets WE send back to THEM, no third party affected.
298
+ //
299
+ // Scope to the agentnet subnet (10.86.0.0/16) so a buggy peer
300
+ // leaking RFC1918 traffic through the TUN can't pollute our IPAM
301
+ // with 192.168.x.x entries.
302
+ if (parsed.srcIp.startsWith(SUBNET_PREFIX)) {
303
+ this.learnPeerMapping(srcPubkey, parsed.srcIp);
304
+ }
219
305
  const proto = IpParser.protoName(parsed.protocol);
220
306
  // ACL check (inbound)
221
307
  if (parsed.dstPort !== undefined && (proto === "tcp" || proto === "udp")) {
@@ -240,7 +326,7 @@ export class PacketRouter extends EventEmitter {
240
326
  // between TUN close and full session teardown produces a "TUN device
241
327
  // not open" warning. Treat that case as a silent drop.
242
328
  if (!this.isRunning || !this.tunDevice.isActive()) {
243
- this.stats.packetsDropped++;
329
+ this.recordDrop(parsed.dstIp, "tun-down");
244
330
  return;
245
331
  }
246
332
  try {
@@ -249,16 +335,8 @@ export class PacketRouter extends EventEmitter {
249
335
  this.logger.debug(`Received ${proto} ${parsed.srcIp}:${parsed.srcPort || "-"} -> ${parsed.dstIp}:${parsed.dstPort || "-"} (${packet.length} bytes)`);
250
336
  }
251
337
  catch (err) {
252
- this.stats.packetsDropped++;
253
338
  const msg = err instanceof Error ? err.message : String(err);
254
- if (msg.includes("TUN device not open")) {
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
- }
339
+ this.recordDrop(parsed.dstIp, "tun-write-failed", msg);
262
340
  }
263
341
  }
264
342
  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
- // Drop our IP→session cache when a friend's Carrier-level
17
- // connection flips. PeerManager already closes the underlying
18
- // PacketSession, but our map still holds the (now-closed)
19
- // reference and an isConnected() check during the brief
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-${evt.status}`);
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.
@@ -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.11",
3
+ "version": "0.1.13",
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",