@decentnetwork/lan 0.1.10 → 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.
Binary file
Binary file
Binary file
Binary file
@@ -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
@@ -90,21 +90,26 @@ export class DaemonServer {
90
90
  // optional dora registration can decide our IP.
91
91
  const keyFile = resolve(this.config.carrier.dataDir, "keypair.json");
92
92
  this.peerManager = new PeerManager();
93
- // Use express nodes. Previously the daemon passed an empty array
94
- // here on the theory that "express is for offline messages, not
95
- // live packet forwarding". But asymmetric Carrier sessions (peer
96
- // A thinks B is online, B thinks A is offline — common across
97
- // China-WAN paths) make dora's sendText reply fail direct and
98
- // fall back to express; if we don't subscribe to express we
99
- // never see the response. dora -> cn for register-ok / list-ok
100
- // hits this exact case. The cost of enabling express here is
101
- // that idle text messages may take an extra hop through the
102
- // HTTPS relay — packet-router doesn't use sendText, so its
103
- // hot path is unaffected.
93
+ // Daemon does NOT use express nodes. decentlan is a virtual
94
+ // LAN peers must be ONLINE for IP packets to flow. Express
95
+ // is an offline-message store-and-forward relay for chat-style
96
+ // apps; if we enable it, sendText silently falls back to a
97
+ // queue when a friend is offline. That creates the illusion of
98
+ // connectivity ("the daemon says X is reachable") while
99
+ // packets actually pile up in HTTPS storage and never get
100
+ // forwarded in real time. For a VPN-like data plane, that's
101
+ // wrong better to fail fast and let the operator see the
102
+ // peer as offline.
103
+ //
104
+ // Friend-request bootstrap is the only thing that benefits
105
+ // from express; for that case use the standalone CLI
106
+ // `agentnet friend-request` (which DOES use express via its
107
+ // own short-lived Peer instance) and accept the request on
108
+ // both ends before bringing the daemon up.
104
109
  await this.peerManager.create({
105
110
  keyFile,
106
111
  bootstrapNodes: this.config.carrier.bootstrapNodes,
107
- expressNodes: this.config.carrier.expressNodes ?? [],
112
+ expressNodes: [],
108
113
  });
109
114
  await this.peerManager.start();
110
115
  this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
@@ -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.stats.packetsDropped++;
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.stats.packetsDropped++;
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
- const session = await this.sessionManager.getOrOpenSession(parsed.dstIp);
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.stats.packetsDropped++;
185
- this.logger.debug(`No peer for ${parsed.dstIp}, dropping`);
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.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
- }
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.stats.packetsDropped++;
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
- 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
- }
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
- // 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.10",
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",