@decentnetwork/lan 0.1.74 → 0.1.76

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
@@ -6,13 +6,15 @@ import type { PacketFrame } from "./types.js";
6
6
  export interface PacketSessionOptions {
7
7
  peerId: string;
8
8
  sessionId: number;
9
- sendText: (text: string) => Promise<void>;
9
+ /** Send a raw decentlan frame over the given Carrier custom packet id
10
+ * (PACKET_ID_DL_SESSION for handshake, PACKET_ID_DL_IP for data). */
11
+ sendFrame: (frame: Uint8Array, packetId: number) => Promise<void>;
10
12
  onClose?: () => void;
11
13
  }
12
14
  export declare class PacketSession extends EventEmitter {
13
15
  private peerId;
14
16
  private sessionId;
15
- private sendText;
17
+ private sendFrame;
16
18
  private isActive;
17
19
  private handshakeTimeout;
18
20
  private handshakePromise;
@@ -3,11 +3,11 @@
3
3
  */
4
4
  import { EventEmitter } from "events";
5
5
  import { FrameCodec } from "./frame.js";
6
- import { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_DATA, } from "./types.js";
6
+ import { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_DATA, PACKET_ID_DL_SESSION, PACKET_ID_DL_IP, } from "./types.js";
7
7
  export class PacketSession extends EventEmitter {
8
8
  peerId;
9
9
  sessionId;
10
- sendText;
10
+ sendFrame;
11
11
  isActive = false;
12
12
  handshakeTimeout = null;
13
13
  handshakePromise = null;
@@ -15,7 +15,7 @@ export class PacketSession extends EventEmitter {
15
15
  super();
16
16
  this.peerId = opts.peerId;
17
17
  this.sessionId = opts.sessionId;
18
- this.sendText = opts.sendText;
18
+ this.sendFrame = opts.sendFrame;
19
19
  this.on("close", () => {
20
20
  if (opts.onClose) {
21
21
  opts.onClose();
@@ -38,9 +38,8 @@ export class PacketSession extends EventEmitter {
38
38
  return this.handshakePromise;
39
39
  }
40
40
  this.handshakePromise = new Promise((resolve, reject) => {
41
- // Send handshake request
41
+ // Send handshake request (lossless session channel — must arrive)
42
42
  const frame = FrameCodec.encode(new Uint8Array(0), this.sessionId, FRAME_OPCODE_HANDSHAKE_REQ);
43
- const frameBase64 = Buffer.from(frame).toString("base64");
44
43
  // 30s default — Carrier express relay can add several seconds of latency
45
44
  // for cross-region peers. Configurable via AGENTNET_HANDSHAKE_TIMEOUT_MS.
46
45
  const timeoutMs = parseInt(process.env.AGENTNET_HANDSHAKE_TIMEOUT_MS || "30000", 10);
@@ -67,7 +66,7 @@ export class PacketSession extends EventEmitter {
67
66
  this.handshakePromise = null;
68
67
  reject(new Error(`Handshake aborted for peer ${this.peerId}`));
69
68
  });
70
- this.sendText(frameBase64).catch((error) => {
69
+ this.sendFrame(frame, PACKET_ID_DL_SESSION).catch((error) => {
71
70
  if (this.handshakeTimeout) {
72
71
  clearTimeout(this.handshakeTimeout);
73
72
  }
@@ -82,9 +81,10 @@ export class PacketSession extends EventEmitter {
82
81
  throw new Error("Packet session not active. Call handshake() first.");
83
82
  }
84
83
  const frame = FrameCodec.encode(packet, this.sessionId, FRAME_OPCODE_DATA);
85
- const frameBase64 = Buffer.from(frame).toString("base64");
86
84
  try {
87
- await this.sendText(frameBase64);
85
+ // IP data → lossy custom packet (best-effort; inner TCP handles
86
+ // reliability — see docs/PROTOCOL.md, avoids TCP-over-TCP).
87
+ await this.sendFrame(frame, PACKET_ID_DL_IP);
88
88
  }
89
89
  catch (error) {
90
90
  // Don't tear down the whole session for a single failed sendText —
@@ -152,5 +152,16 @@ export declare class PeerManager extends EventEmitter {
152
152
  * Send a HANDSHAKE_ACK frame to a peer.
153
153
  */
154
154
  private sendHandshakeAck;
155
+ /**
156
+ * Send a dora control message to a dora server over the dora custom packet
157
+ * (162, lossless). Replaces the old `DORA:`-prefixed text on packet 64.
158
+ */
159
+ sendDora(userid: string, text: string): Promise<void>;
160
+ /**
161
+ * Route a decoded decentlan frame to its packet-session, creating or
162
+ * replacing the session on a HANDSHAKE_REQ (handles the Carrier re-pair
163
+ * case where both sides hold different sessionIds).
164
+ */
165
+ private handleDecodedFrame;
155
166
  private setupEventHandlers;
156
167
  }
@@ -6,13 +6,8 @@ import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
7
  import { PacketSession } from "./packet-session.js";
8
8
  import { FrameCodec } from "./frame.js";
9
- import { FRAME_OPCODE_HANDSHAKE_ACK, } from "./types.js";
9
+ import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, } from "./types.js";
10
10
  import { Logger } from "../utils/logger.js";
11
- // Dora wire-prefix. Kept inline (rather than imported from @decentnetwork/dora) to
12
- // avoid coupling peer-manager — a transport-layer module — to an
13
- // application-protocol package. The prefix is part of decentlan's text
14
- // channel contract and must match the constant defined in dora's types.ts.
15
- const DORA_PREFIX = "DORA:";
16
11
  export class PeerManager extends EventEmitter {
17
12
  peer = null;
18
13
  identity = null;
@@ -331,11 +326,11 @@ export class PeerManager extends EventEmitter {
331
326
  const session = new PacketSession({
332
327
  peerId: pubkey,
333
328
  sessionId,
334
- sendText: async (text) => {
329
+ sendFrame: async (frame, packetId) => {
335
330
  if (!this.peer) {
336
331
  throw new Error("Peer not available");
337
332
  }
338
- await this.peer.sendText(pubkey, text);
333
+ await this.peer.sendCustomPacket(pubkey, packetId, frame);
339
334
  },
340
335
  onClose: () => {
341
336
  this.sessions.delete(pubkey);
@@ -364,8 +359,42 @@ export class PeerManager extends EventEmitter {
364
359
  throw new Error("Peer not available");
365
360
  }
366
361
  const frame = FrameCodec.encode(new Uint8Array(0), sessionId, FRAME_OPCODE_HANDSHAKE_ACK);
367
- const frameBase64 = Buffer.from(frame).toString("base64");
368
- await this.peer.sendText(pubkey, frameBase64);
362
+ await this.peer.sendCustomPacket(pubkey, PACKET_ID_DL_SESSION, frame);
363
+ }
364
+ /**
365
+ * Send a dora control message to a dora server over the dora custom packet
366
+ * (162, lossless). Replaces the old `DORA:`-prefixed text on packet 64.
367
+ */
368
+ async sendDora(userid, text) {
369
+ if (!this.peer) {
370
+ throw new Error("Peer not created. Call create() first.");
371
+ }
372
+ await this.peer.sendCustomPacket(userid, PACKET_ID_DL_DORA, Buffer.from(text, "utf-8"));
373
+ }
374
+ /**
375
+ * Route a decoded decentlan frame to its packet-session, creating or
376
+ * replacing the session on a HANDSHAKE_REQ (handles the Carrier re-pair
377
+ * case where both sides hold different sessionIds).
378
+ */
379
+ handleDecodedFrame(pubkey, frame) {
380
+ let session = this.sessions.get(pubkey);
381
+ if (FrameCodec.isHandshakeRequest(frame.opcode)) {
382
+ if (session && session.getSessionId() !== frame.sessionId) {
383
+ this.logger.info(`Replacing stale session for ${pubkey} (old=${session.getSessionId()}, new=${frame.sessionId})`);
384
+ session.close();
385
+ session = undefined;
386
+ }
387
+ if (!session) {
388
+ this.logger.info(`Auto-creating session for inbound peer ${pubkey} (sessionId=${frame.sessionId})`);
389
+ session = this.createSession(pubkey, frame.sessionId);
390
+ }
391
+ }
392
+ if (session) {
393
+ session.handleIncomingFrame(frame);
394
+ }
395
+ else {
396
+ this.logger.debug(`No session for peer ${pubkey}, ignoring frame opcode=0x${frame.opcode.toString(16)}`);
397
+ }
369
398
  }
370
399
  setupEventHandlers() {
371
400
  if (!this.peer) {
@@ -406,54 +435,37 @@ export class PeerManager extends EventEmitter {
406
435
  }
407
436
  }
408
437
  });
409
- // Text messages contain our framed packets
438
+ // Packet 64 (PACKET_ID_MESSAGE) is now CHAT only — plain Carrier text,
439
+ // interoperable with native Carrier clients. IP traffic and dora moved to
440
+ // their own custom packets (onCustomPacket below). See docs/PROTOCOL.md.
410
441
  this.peer.onText((message) => {
442
+ // Empty messages are kickSessionEstablishment keepalives — ignore.
443
+ if (message.text.length === 0) {
444
+ return;
445
+ }
446
+ this.emit("message", message.pubkey, message.text);
447
+ });
448
+ // decentlan custom packets: IP data (192, lossy), session handshake
449
+ // (161, lossless), dora control (162, lossless). Each on its own Carrier
450
+ // packet id — a chat message can never be mistaken for an IP packet, and
451
+ // no crafted message can inject onto the TUN.
452
+ this.peer.onCustomPacket(({ pubkey, id, data }) => {
411
453
  try {
412
- // Silently skip empty messages — kickSessionEstablishment sends
413
- // an empty sendText to trigger the SDK's #initiateSession path,
414
- // and the receiver doesn't need to log a warning for that.
415
- if (message.text.length === 0) {
416
- return;
417
- }
418
- // Dora messages share the same text channel as packet frames but
419
- // carry a `DORA:` ASCII prefix that's not valid base64. Route them
420
- // to whoever is subscribed (DoraClient/DoraServer in daemon).
421
- if (message.text.startsWith(DORA_PREFIX)) {
422
- this.emit("dora-message", message.pubkey, message.text);
423
- return;
424
- }
425
- const frameData = Buffer.from(message.text, "base64");
426
- const frame = FrameCodec.decode(new Uint8Array(frameData));
427
- if (!frame) {
428
- this.logger.warn(`Invalid frame from ${message.pubkey}`);
454
+ if (id === PACKET_ID_DL_DORA) {
455
+ this.emit("dora-message", pubkey, Buffer.from(data).toString("utf-8"));
429
456
  return;
430
457
  }
431
- let session = this.sessions.get(message.pubkey);
432
- // For HANDSHAKE_REQ: always create or REPLACE the session with the
433
- // new sessionId. Without this, after a Carrier session drop + re-pair
434
- // the peer's new HANDSHAKE_REQ would be dropped because the existing
435
- // local session has the old sessionId — so the responder never sends
436
- // HANDSHAKE_ACK and the initiator times out forever.
437
- if (FrameCodec.isHandshakeRequest(frame.opcode)) {
438
- if (session && session.getSessionId() !== frame.sessionId) {
439
- this.logger.info(`Replacing stale session for ${message.pubkey} (old=${session.getSessionId()}, new=${frame.sessionId})`);
440
- session.close();
441
- session = undefined;
458
+ if (id === PACKET_ID_DL_IP || id === PACKET_ID_DL_SESSION) {
459
+ const frame = FrameCodec.decode(data);
460
+ if (!frame) {
461
+ this.logger.warn(`Invalid frame from ${pubkey} (packet ${id})`);
462
+ return;
442
463
  }
443
- if (!session) {
444
- this.logger.info(`Auto-creating session for inbound peer ${message.pubkey} (sessionId=${frame.sessionId})`);
445
- session = this.createSession(message.pubkey, frame.sessionId);
446
- }
447
- }
448
- if (session) {
449
- session.handleIncomingFrame(frame);
450
- }
451
- else {
452
- this.logger.debug(`No session for peer ${message.pubkey}, ignoring frame opcode=0x${frame.opcode.toString(16)}`);
464
+ this.handleDecodedFrame(pubkey, frame);
453
465
  }
454
466
  }
455
467
  catch (error) {
456
- this.logger.error(`Error processing frame from ${message.pubkey}:`, error);
468
+ this.logger.error(`Error processing custom packet from ${pubkey}:`, error);
457
469
  }
458
470
  });
459
471
  // Friend requests
@@ -8,3 +8,6 @@ export declare const FRAME_OPCODE_HANDSHAKE_ACK = 2;
8
8
  export declare const FRAME_OPCODE_DATA = 16;
9
9
  export declare const FRAME_MAGIC = 170;
10
10
  export declare const FRAME_HEADER_SIZE = 6;
11
+ export declare const PACKET_ID_DL_SESSION = 161;
12
+ export declare const PACKET_ID_DL_DORA = 162;
13
+ export declare const PACKET_ID_DL_IP = 192;
@@ -9,3 +9,10 @@ export const FRAME_OPCODE_DATA = 0x10;
9
9
  // Frame structure
10
10
  export const FRAME_MAGIC = 0xaa;
11
11
  export const FRAME_HEADER_SIZE = 6; // magic(1) + length(2) + sessionId(2) + opcode(1)
12
+ // decentlan application packet IDs in toxcore's custom ranges (see
13
+ // docs/PROTOCOL.md). Sent via peer.sendCustomPacket / received via
14
+ // peer.onCustomPacket — a separate channel from chat (PACKET_ID_MESSAGE 64),
15
+ // so IP traffic can never be confused with a Carrier message.
16
+ export const PACKET_ID_DL_SESSION = 161; // lossless: session handshake frames (must arrive)
17
+ export const PACKET_ID_DL_DORA = 162; // lossless: dora control (must arrive)
18
+ export const PACKET_ID_DL_IP = 192; // lossy: IP data frames (best-effort; inner TCP retransmits)
@@ -51,11 +51,25 @@ export declare class DaemonServer {
51
51
  */
52
52
  private startPeerStatusSummary;
53
53
  getStatus(): DaemonStatus;
54
+ /** Per-peer timestamp of the last self-heal friend-request re-send, so
55
+ * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
56
+ private reFriendAt;
57
+ /** How long a friend must stay non-online before we re-send the
58
+ * friend-request again. Long enough not to flood express for a roster
59
+ * full of genuinely-dead nodes, short enough to recover a wedged but
60
+ * live peer in well under a minute. */
61
+ private static readonly SELF_HEAL_REFRIEND_MS;
54
62
  /**
55
- * Periodically poke the SDK to (re-)initiate a session with the friend.
56
- * This works around the asymmetric friendOnline issue: without this,
57
- * one side may never spontaneously try to talk to the other and the
58
- * friend stays perpetually offline.
63
+ * Periodically poke the SDK to (re-)initiate a session with the friend,
64
+ * and SELF-HEAL a wedged one. `kickSessionEstablishment` is the weak
65
+ * nudge; for a friend that's accepted but stuck `offline` (the classic
66
+ * NAT-to-NAT case where one side restarted, or a connection that simply
67
+ * died and never recovered) it isn't enough. So when a peer stays
68
+ * non-online, we escalate to re-sending the actual friend-request — the
69
+ * strong kick that forces a fresh Carrier handshake. This is the
70
+ * automatic version of `agentnet friend-request <addr>`, so a private or
71
+ * friend-invited exit recovers on its own instead of needing a human to
72
+ * run a command (which an invited operator never will).
59
73
  */
60
74
  private kickSessionForever;
61
75
  /**
@@ -649,16 +649,47 @@ export class DaemonServer {
649
649
  activeSessions: this.packetRouter?.getStats().activeSessions || 0,
650
650
  };
651
651
  }
652
+ /** Per-peer timestamp of the last self-heal friend-request re-send, so
653
+ * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
654
+ reFriendAt = new Map();
655
+ /** How long a friend must stay non-online before we re-send the
656
+ * friend-request again. Long enough not to flood express for a roster
657
+ * full of genuinely-dead nodes, short enough to recover a wedged but
658
+ * live peer in well under a minute. */
659
+ static SELF_HEAL_REFRIEND_MS = 45_000;
652
660
  /**
653
- * Periodically poke the SDK to (re-)initiate a session with the friend.
654
- * This works around the asymmetric friendOnline issue: without this,
655
- * one side may never spontaneously try to talk to the other and the
656
- * friend stays perpetually offline.
661
+ * Periodically poke the SDK to (re-)initiate a session with the friend,
662
+ * and SELF-HEAL a wedged one. `kickSessionEstablishment` is the weak
663
+ * nudge; for a friend that's accepted but stuck `offline` (the classic
664
+ * NAT-to-NAT case where one side restarted, or a connection that simply
665
+ * died and never recovered) it isn't enough. So when a peer stays
666
+ * non-online, we escalate to re-sending the actual friend-request — the
667
+ * strong kick that forces a fresh Carrier handshake. This is the
668
+ * automatic version of `agentnet friend-request <addr>`, so a private or
669
+ * friend-invited exit recovers on its own instead of needing a human to
670
+ * run a command (which an invited operator never will).
657
671
  */
658
672
  async kickSessionForever(carrierId, name) {
659
673
  while (this.isRunning) {
660
674
  try {
661
675
  await this.peerManager.kickSessionEstablishment(carrierId);
676
+ const f = this.peerManager
677
+ .getFriends()
678
+ .find((x) => x.carrierId === carrierId || x.pubkey === carrierId);
679
+ if (f && f.status === "online") {
680
+ // Healthy — clear the timer so a future drop re-heals promptly.
681
+ this.reFriendAt.delete(carrierId);
682
+ }
683
+ else if (f && f.status !== "online" && f.address) {
684
+ const last = this.reFriendAt.get(carrierId) ?? 0;
685
+ if (Date.now() - last >= DaemonServer.SELF_HEAL_REFRIEND_MS) {
686
+ this.reFriendAt.set(carrierId, Date.now());
687
+ this.logger.info(`Self-heal: ${name} is ${f.status} — re-sending friend-request to re-establish the session`);
688
+ void this.peerManager
689
+ .sendFriendRequest(f.address, "decentlan: self-heal")
690
+ .catch((err) => this.logger.debug(`self-heal friend-request ${name}: ${err instanceof Error ? err.message : err}`));
691
+ }
692
+ }
662
693
  }
663
694
  catch (err) {
664
695
  this.logger.debug(`kickSessionEstablishment ${name}: ${err}`);
@@ -40,7 +40,7 @@ export class DoraIntegration {
40
40
  this.logger = new Logger({ prefix: "Dora" });
41
41
  this.client = new DoraClient({
42
42
  registryUserids: opts.config.userids ?? [],
43
- sendText: (toUserid, text) => opts.peerManager.sendText(toUserid, text),
43
+ sendText: (toUserid, text) => opts.peerManager.sendDora(toUserid, text),
44
44
  onText: (handler) => {
45
45
  opts.peerManager.on("dora-message", (fromUserid, text) => {
46
46
  handler(fromUserid, text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",
@@ -77,7 +77,7 @@
77
77
  },
78
78
  "dependencies": {
79
79
  "@decentnetwork/dora": "^0.1.6",
80
- "@decentnetwork/peer": "^0.1.32",
80
+ "@decentnetwork/peer": "^0.1.33",
81
81
  "js-yaml": "^4.1.0",
82
82
  "yargs": "^17.7.2"
83
83
  },