@decentnetwork/lan 0.1.256 → 0.1.258

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.
@@ -81,6 +81,18 @@ export declare class PeerManager extends EventEmitter {
81
81
  sign(message: Uint8Array): Uint8Array;
82
82
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
83
83
  sendFile(userid: string, data: Uint8Array, name: string): string | null;
84
+ /**
85
+ * Send an APPLICATION packet to a friend. The payload is opaque to
86
+ * decentlan — this is the extension point that lets an app (beagle, an
87
+ * agent, any IPC client) run its own protocol over our Carrier session
88
+ * without a change in here.
89
+ *
90
+ * `id` must be in PACKET_ID_APP_MIN..MAX. Rejecting anything else is a
91
+ * security boundary, not tidiness: our own 161-164 carry session handshakes,
92
+ * dora control and IP frames, so a caller allowed to pick those ids could
93
+ * forge a handshake or inject a packet onto a peer's TUN.
94
+ */
95
+ sendAppPacket(userid: string, id: number, data: Uint8Array): Promise<void>;
84
96
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
85
97
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
86
98
  * receive online. Use for native friends; JS friends keep sendFile(). */
@@ -7,7 +7,7 @@ import { EventEmitter } from "events";
7
7
  import { OfflineCallBridge } from "./offline-call.js";
8
8
  import { PacketSession } from "./packet-session.js";
9
9
  import { FrameCodec } from "./frame.js";
10
- import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, } from "./types.js";
10
+ import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, isAppPacketId, } from "./types.js";
11
11
  import { Logger } from "../utils/logger.js";
12
12
  /**
13
13
  * True when a Carrier invite send failed for a reason that will recur
@@ -165,6 +165,25 @@ export class PeerManager extends EventEmitter {
165
165
  throw new Error("Peer not created. Call create() first.");
166
166
  return this.peer.sendFile(userid, data, { name });
167
167
  }
168
+ /**
169
+ * Send an APPLICATION packet to a friend. The payload is opaque to
170
+ * decentlan — this is the extension point that lets an app (beagle, an
171
+ * agent, any IPC client) run its own protocol over our Carrier session
172
+ * without a change in here.
173
+ *
174
+ * `id` must be in PACKET_ID_APP_MIN..MAX. Rejecting anything else is a
175
+ * security boundary, not tidiness: our own 161-164 carry session handshakes,
176
+ * dora control and IP frames, so a caller allowed to pick those ids could
177
+ * forge a handshake or inject a packet onto a peer's TUN.
178
+ */
179
+ async sendAppPacket(userid, id, data) {
180
+ if (!this.peer)
181
+ throw new Error("Peer not created. Call create() first.");
182
+ if (!isAppPacketId(id)) {
183
+ throw new Error(`packet id ${id} is outside the application range 165-191`);
184
+ }
185
+ await this.peer.sendCustomPacket(userid, id, data);
186
+ }
168
187
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
169
188
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
170
189
  * receive online. Use for native friends; JS friends keep sendFile(). */
@@ -687,6 +706,13 @@ export class PeerManager extends EventEmitter {
687
706
  return;
688
707
  }
689
708
  this.handleDecodedFrame(pubkey, frame);
709
+ return;
710
+ }
711
+ // Application range: hand the bytes up untouched. We do not parse them,
712
+ // so a malformed app payload can never reach any decentlan code path —
713
+ // validating it is the receiving app's job, at its own trust boundary.
714
+ if (isAppPacketId(id)) {
715
+ this.emit("app-packet", pubkey, id, data);
690
716
  }
691
717
  }
692
718
  catch (error) {
@@ -12,3 +12,7 @@ export declare const PACKET_ID_DL_SESSION = 161;
12
12
  export declare const PACKET_ID_DL_DORA = 162;
13
13
  export declare const PACKET_ID_DL_IP = 163;
14
14
  export declare const PACKET_ID_DL_RATE = 164;
15
+ export declare const PACKET_ID_APP_MIN = 165;
16
+ export declare const PACKET_ID_APP_MAX = 191;
17
+ /** True when `id` is in the application range apps may send and receive on. */
18
+ export declare function isAppPacketId(id: unknown): id is number;
@@ -31,3 +31,23 @@ export const PACKET_ID_DL_IP = 163; // lossless: IP data frames (must traverse r
31
31
  // so it stops overrunning the path and inducing congestion drops. Payload: a
32
32
  // single uint32be = delivered bytes/sec. Rare + tiny, so lossless is free.
33
33
  export const PACKET_ID_DL_RATE = 164;
34
+ // --- application packet range -----------------------------------------------
35
+ // 165-191 is handed to APPLICATIONS built on this daemon (beagle, agents, any
36
+ // IPC client). decentlan carries these bytes and never interprets them, so an
37
+ // app can add a protocol of its own without a change here — the reason this
38
+ // range exists at all is so no app ever has to modify the SDK again.
39
+ //
40
+ // Lossless (160-191), like everything else we own: an app-level control message
41
+ // that vanishes on a relay-only session is worse than useless. See the note on
42
+ // PACKET_ID_DL_IP for why lossy is not an option on GFW-crossing paths.
43
+ //
44
+ // The range deliberately STARTS above our own ids. An IPC client is local, but
45
+ // it is not necessarily this daemon's code, and letting it emit on 161-164
46
+ // would let it forge session handshakes, dora control, or IP frames at a peer —
47
+ // i.e. inject onto someone's TUN. The bound is enforced on send AND on receive.
48
+ export const PACKET_ID_APP_MIN = 165;
49
+ export const PACKET_ID_APP_MAX = 191;
50
+ /** True when `id` is in the application range apps may send and receive on. */
51
+ export function isAppPacketId(id) {
52
+ return typeof id === "number" && Number.isInteger(id) && id >= PACKET_ID_APP_MIN && id <= PACKET_ID_APP_MAX;
53
+ }
@@ -94,6 +94,12 @@ export interface IpcHandlers {
94
94
  * Carrier "carrier" friend-invite extension — the channel iOS/Android Beagle
95
95
  * listen on for calls. Backs the desktop UI's audio/video calls. */
96
96
  callSignal: (userid: string, data: string) => Promise<void>;
97
+ /** Send an application packet (opaque bytes on a Carrier custom-packet id in
98
+ * the 165-191 app range) to a friend. The extension point for apps built on
99
+ * this daemon: beagle's chat components ride it, and so can anything else,
100
+ * without adding an op per feature. Inbound ones arrive as
101
+ * `{type:"app", userid, packetId, data}` events on `subscribe`. */
102
+ appSend: (userid: string, packetId: number, data: Uint8Array) => Promise<void>;
97
103
  /** Long-poll for inbound call-signaling payloads. Resolves with any queued
98
104
  * signals immediately, otherwise holds the connection until one arrives or a
99
105
  * ~20s timeout elapses (then resolves empty). The UI re-polls to get
@@ -121,7 +127,7 @@ export interface IpcHandlers {
121
127
  selfRestart: () => Promise<Record<string, unknown>>;
122
128
  }
123
129
  export interface IpcRequest {
124
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "file-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
130
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "file-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "app-send" | "self-restart";
125
131
  address?: string;
126
132
  hello?: string;
127
133
  userid?: string;
@@ -137,8 +143,11 @@ export interface IpcRequest {
137
143
  ts?: number;
138
144
  enabled?: boolean;
139
145
  ids?: string[];
140
- /** RtcSignal JSON payload for the "call-signal" op. */
146
+ /** RtcSignal JSON payload for the "call-signal" op; base64 payload for
147
+ * "app-send". */
141
148
  data?: string;
149
+ /** Carrier custom-packet id for the "app-send" op (165-191). */
150
+ packetId?: number;
142
151
  }
143
152
  export interface IpcResponseOk {
144
153
  ok: true;
@@ -276,6 +276,24 @@ export class IpcServer {
276
276
  await this.handlers.callSignal(req.userid, req.data);
277
277
  return;
278
278
  }
279
+ case "app-send": {
280
+ if (!req.userid)
281
+ throw new Error("userid is required");
282
+ if (typeof req.packetId !== "number")
283
+ throw new Error("packetId is required");
284
+ if (typeof req.data !== "string")
285
+ throw new Error("data is required (base64)");
286
+ // Base64 is the only sane way to carry bytes through a newline-delimited
287
+ // JSON socket. Buffer.from is lenient — it silently skips invalid chars
288
+ // rather than throwing — so re-encode and compare: a caller that sends
289
+ // corrupt base64 must be told, not have truncated bytes put on the wire.
290
+ const bytes = Buffer.from(req.data, "base64");
291
+ if (bytes.toString("base64").replace(/=+$/, "") !== req.data.replace(/=+$/, "")) {
292
+ throw new Error("data is not valid base64");
293
+ }
294
+ await this.handlers.appSend(req.userid, req.packetId, new Uint8Array(bytes));
295
+ return { sent: bytes.length };
296
+ }
279
297
  case "call-poll":
280
298
  return await this.handlers.callPoll();
281
299
  case "proxy-reload":
@@ -698,6 +698,11 @@ export class DaemonServer {
698
698
  // session (throws if the peer is unreachable, so the UI can surface it).
699
699
  await this.peerManager.sendCallSignal(userid, data);
700
700
  },
701
+ appSend: async (userid, packetId, data) => {
702
+ // Opaque passthrough. The range check lives in PeerManager so it
703
+ // applies to every caller, not just this socket.
704
+ await this.peerManager.sendAppPacket(userid, packetId, data);
705
+ },
701
706
  callPoll: async () => {
702
707
  // Drain immediately if signals are queued; otherwise hold up to ~20s
703
708
  // for one to arrive (near-instant delivery without a WebSocket). The
@@ -1196,6 +1201,18 @@ export class DaemonServer {
1196
1201
  for (const wake of waiters)
1197
1202
  wake();
1198
1203
  });
1204
+ // Application packets (165-191) — push straight to IPC subscribers as
1205
+ // base64. The daemon never parses them: whatever protocol an app runs up
1206
+ // there is the app's business, and keeping it opaque is what lets apps
1207
+ // evolve without touching this SDK.
1208
+ this.peerManager.on("app-packet", (pubkey, packetId, data) => {
1209
+ this.ipcEvents.emit("event", {
1210
+ type: "app",
1211
+ userid: pubkey,
1212
+ packetId,
1213
+ data: Buffer.from(data).toString("base64"),
1214
+ });
1215
+ });
1199
1216
  this.peerManager.on("friend-request", (req) => {
1200
1217
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
1201
1218
  const who = `${req.name || "(unnamed)"} ${userid}`;
@@ -17,6 +17,12 @@ const RATE_REPORT_INTERVAL_MS = 1000;
17
17
  // pacing a trickle (SSH/keepalives), and it keeps reports sparse. The pacer's
18
18
  // loss window derives its verdict from these reports vs. what it sent.
19
19
  const RATE_REPORT_MIN_BYTES = 32 * 1024; // ~256 kbps sustained
20
+ /** Session keepalive: one zero byte on the data channel.
21
+ *
22
+ * Deliberately not a valid IP packet — the receiver recognises and swallows
23
+ * it. It exists only to keep the Carrier session warm, so it must never be
24
+ * counted as forwarded traffic OR as a drop. */
25
+ const KEEPALIVE_PACKET = new Uint8Array([0]);
20
26
  export class PacketRouter extends EventEmitter {
21
27
  tunDevice;
22
28
  peerManager;
@@ -198,7 +204,7 @@ export class PacketRouter extends EventEmitter {
198
204
  if (!session?.isConnected())
199
205
  continue;
200
206
  try {
201
- await session.send(new Uint8Array([0])); // 1-byte keepalive, dropped at receiver
207
+ await session.send(KEEPALIVE_PACKET);
202
208
  }
203
209
  catch {
204
210
  // ignore — session may have just gone offline
@@ -345,8 +351,30 @@ export class PacketRouter extends EventEmitter {
345
351
  return;
346
352
  const parsed = IpParser.parse(packet);
347
353
  if (!parsed) {
348
- this.stats.packetsDropped++;
349
- this.logger.debug("Dropped invalid incoming IP packet");
354
+ // Classify instead of lumping. IpParser returns null for three very
355
+ // different situations and they need different fixes: a short packet
356
+ // means the transport handed us a truncated frame (MTU / reassembly),
357
+ // while IPv6 means the SENDER is forwarding traffic we never support --
358
+ // one is a bug in us, the other is a filter missing upstream. Recording
359
+ // it per-source also makes it visible in `agentnet diag`, which is how
360
+ // this stayed hidden: the old code bumped the counter and logged at
361
+ // debug, so tens of thousands of drops had neither reason nor owner.
362
+ // OUR OWN keepalive, not a loss. sendKeepalivesToActiveSessions emits a
363
+ // single zero byte on the data channel and the receiver was counting
364
+ // every one of them as a dropped packet: 24015 "drops" on mac-dev with
365
+ // only ~2038 attributable, 16919 on cn against 5. That made the drop
366
+ // metric useless exactly when it was needed to explain real packet loss,
367
+ // and sent this investigation down a wrong path first. Swallow it
368
+ // silently — it did its job by arriving.
369
+ if (packet.length === KEEPALIVE_PACKET.length && packet[0] === KEEPALIVE_PACKET[0]) {
370
+ return;
371
+ }
372
+ const reason = packet.length < 20
373
+ ? "inbound-too-short"
374
+ : ((packet[0] >> 4) & 0x0f) !== 4
375
+ ? "inbound-not-ipv4"
376
+ : "inbound-bad-header";
377
+ this.recordDrop(`from:${srcPubkey.slice(0, 12)}`, reason, `len=${packet.length}`);
350
378
  return;
351
379
  }
352
380
  // Auto-learn (srcPubkey -> srcIp) into IPAM. Without this, ubuntu
@@ -18,7 +18,7 @@ export declare const IP_PROTO_ICMP = 1;
18
18
  * isn't my ping working?" — without forcing them to crank up the
19
19
  * log level.
20
20
  */
21
- export type DropReason = "invalid-ip" | "no-port" | "no-ipam-record" | "friend-offline" | "handshake-failed" | "send-failed" | "tun-write-failed" | "tun-down";
21
+ export type DropReason = "invalid-ip" | "inbound-too-short" | "inbound-not-ipv4" | "inbound-bad-header" | "no-port" | "no-ipam-record" | "friend-offline" | "handshake-failed" | "send-failed" | "tun-write-failed" | "tun-down";
22
22
  export interface DstDropInfo {
23
23
  /** Total drops to this destination. */
24
24
  count: number;
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.256";
1
+ window.__DK_UI_VERSION="0.1.258";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.256",
3
+ "version": "0.1.258",
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",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@decentnetwork/dora": "^0.1.14",
87
- "@decentnetwork/peer": "^0.1.123",
87
+ "@decentnetwork/peer": "^0.1.124",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",