@decentnetwork/lan 0.1.76 → 0.1.77

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,15 +6,13 @@ import type { PacketFrame } from "./types.js";
6
6
  export interface PacketSessionOptions {
7
7
  peerId: string;
8
8
  sessionId: number;
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>;
9
+ sendText: (text: string) => Promise<void>;
12
10
  onClose?: () => void;
13
11
  }
14
12
  export declare class PacketSession extends EventEmitter {
15
13
  private peerId;
16
14
  private sessionId;
17
- private sendFrame;
15
+ private sendText;
18
16
  private isActive;
19
17
  private handshakeTimeout;
20
18
  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, PACKET_ID_DL_SESSION, PACKET_ID_DL_IP, } from "./types.js";
6
+ import { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_DATA, } from "./types.js";
7
7
  export class PacketSession extends EventEmitter {
8
8
  peerId;
9
9
  sessionId;
10
- sendFrame;
10
+ sendText;
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.sendFrame = opts.sendFrame;
18
+ this.sendText = opts.sendText;
19
19
  this.on("close", () => {
20
20
  if (opts.onClose) {
21
21
  opts.onClose();
@@ -38,8 +38,9 @@ export class PacketSession extends EventEmitter {
38
38
  return this.handshakePromise;
39
39
  }
40
40
  this.handshakePromise = new Promise((resolve, reject) => {
41
- // Send handshake request (lossless session channel — must arrive)
41
+ // Send handshake request
42
42
  const frame = FrameCodec.encode(new Uint8Array(0), this.sessionId, FRAME_OPCODE_HANDSHAKE_REQ);
43
+ const frameBase64 = Buffer.from(frame).toString("base64");
43
44
  // 30s default — Carrier express relay can add several seconds of latency
44
45
  // for cross-region peers. Configurable via AGENTNET_HANDSHAKE_TIMEOUT_MS.
45
46
  const timeoutMs = parseInt(process.env.AGENTNET_HANDSHAKE_TIMEOUT_MS || "30000", 10);
@@ -66,7 +67,7 @@ export class PacketSession extends EventEmitter {
66
67
  this.handshakePromise = null;
67
68
  reject(new Error(`Handshake aborted for peer ${this.peerId}`));
68
69
  });
69
- this.sendFrame(frame, PACKET_ID_DL_SESSION).catch((error) => {
70
+ this.sendText(frameBase64).catch((error) => {
70
71
  if (this.handshakeTimeout) {
71
72
  clearTimeout(this.handshakeTimeout);
72
73
  }
@@ -81,10 +82,9 @@ export class PacketSession extends EventEmitter {
81
82
  throw new Error("Packet session not active. Call handshake() first.");
82
83
  }
83
84
  const frame = FrameCodec.encode(packet, this.sessionId, FRAME_OPCODE_DATA);
85
+ const frameBase64 = Buffer.from(frame).toString("base64");
84
86
  try {
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);
87
+ await this.sendText(frameBase64);
88
88
  }
89
89
  catch (error) {
90
90
  // Don't tear down the whole session for a single failed sendText —
@@ -152,16 +152,5 @@ 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;
166
155
  private setupEventHandlers;
167
156
  }
@@ -6,8 +6,13 @@ 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, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, } from "./types.js";
9
+ import { FRAME_OPCODE_HANDSHAKE_ACK, } 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:";
11
16
  export class PeerManager extends EventEmitter {
12
17
  peer = null;
13
18
  identity = null;
@@ -326,11 +331,11 @@ export class PeerManager extends EventEmitter {
326
331
  const session = new PacketSession({
327
332
  peerId: pubkey,
328
333
  sessionId,
329
- sendFrame: async (frame, packetId) => {
334
+ sendText: async (text) => {
330
335
  if (!this.peer) {
331
336
  throw new Error("Peer not available");
332
337
  }
333
- await this.peer.sendCustomPacket(pubkey, packetId, frame);
338
+ await this.peer.sendText(pubkey, text);
334
339
  },
335
340
  onClose: () => {
336
341
  this.sessions.delete(pubkey);
@@ -359,42 +364,8 @@ export class PeerManager extends EventEmitter {
359
364
  throw new Error("Peer not available");
360
365
  }
361
366
  const frame = FrameCodec.encode(new Uint8Array(0), sessionId, FRAME_OPCODE_HANDSHAKE_ACK);
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
- }
367
+ const frameBase64 = Buffer.from(frame).toString("base64");
368
+ await this.peer.sendText(pubkey, frameBase64);
398
369
  }
399
370
  setupEventHandlers() {
400
371
  if (!this.peer) {
@@ -435,37 +406,54 @@ export class PeerManager extends EventEmitter {
435
406
  }
436
407
  }
437
408
  });
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.
409
+ // Text messages contain our framed packets
441
410
  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 }) => {
453
411
  try {
454
- if (id === PACKET_ID_DL_DORA) {
455
- this.emit("dora-message", pubkey, Buffer.from(data).toString("utf-8"));
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}`);
456
429
  return;
457
430
  }
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;
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;
463
442
  }
464
- this.handleDecodedFrame(pubkey, frame);
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)}`);
465
453
  }
466
454
  }
467
455
  catch (error) {
468
- this.logger.error(`Error processing custom packet from ${pubkey}:`, error);
456
+ this.logger.error(`Error processing frame from ${message.pubkey}:`, error);
469
457
  }
470
458
  });
471
459
  // Friend requests
@@ -8,6 +8,3 @@ 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,10 +9,3 @@ 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)
@@ -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.sendDora(toUserid, text),
43
+ sendText: (toUserid, text) => opts.peerManager.sendText(toUserid, text),
44
44
  onText: (handler) => {
45
45
  opts.peerManager.on("dora-message", (fromUserid, text) => {
46
46
  handler(fromUserid, text);
@@ -126,11 +126,15 @@ export class ConnectProxy {
126
126
  }
127
127
  let bytes = 0;
128
128
  let closed = false;
129
+ let lastBytes = -1;
130
+ let idleTimer;
129
131
  const upstream = net.connect(port, host);
130
132
  const tearDown = (reason) => {
131
133
  if (closed)
132
134
  return;
133
135
  closed = true;
136
+ if (idleTimer)
137
+ clearInterval(idleTimer);
134
138
  upstream.destroy();
135
139
  clientSocket.destroy();
136
140
  this.stats.active = Math.max(0, this.stats.active - 1);
@@ -181,6 +185,26 @@ export class ConnectProxy {
181
185
  upstream.on("end", () => tearDown("upstream end"));
182
186
  clientSocket.on("error", (err) => tearDown(`client error: ${err.message}`));
183
187
  clientSocket.on("end", () => tearDown("client end"));
188
+ // "close" catches abrupt teardowns (RST / socket destroyed) that never
189
+ // emit "end" — without these, a half-dead tunnel leaks.
190
+ upstream.on("close", () => tearDown("upstream close"));
191
+ clientSocket.on("close", () => tearDown("client close"));
192
+ // Idle reaper: when the Carrier path drops mid-stream the broken side
193
+ // sends no FIN, so neither "end" nor "error" fires and the sockets would
194
+ // leak until the kernel's multi-hour TCP keepalive — they pile up under
195
+ // load (1080p opens many high-bandwidth tunnels; two clients on one exit
196
+ // compounds it) and starve the exit. If no bytes flow in EITHER direction
197
+ // across two idle ticks, tear it down. Active streaming keeps `bytes`
198
+ // climbing (and backpressure pauses a one-sided flow), so only genuinely
199
+ // dead tunnels are reaped. Tunable via AGENTNET_TUNNEL_IDLE_MS.
200
+ const idleMs = Number(process.env.AGENTNET_TUNNEL_IDLE_MS) || 120_000;
201
+ idleTimer = setInterval(() => {
202
+ if (bytes === lastBytes)
203
+ tearDown("idle (no data — Carrier path likely dropped)");
204
+ else
205
+ lastBytes = bytes;
206
+ }, idleMs);
207
+ idleTimer.unref?.();
184
208
  }
185
209
  refuse(clientSocket, src, srcName, target, code, reason) {
186
210
  this.stats.totalRefused += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
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.33",
80
+ "@decentnetwork/peer": "^0.1.32",
81
81
  "js-yaml": "^4.1.0",
82
82
  "yargs": "^17.7.2"
83
83
  },