@decentnetwork/lan 0.1.77 → 0.1.78

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)
@@ -214,6 +214,30 @@ export declare function cmdProxyUse(args: {
214
214
  peer: string;
215
215
  configDir?: string;
216
216
  }): Promise<void>;
217
+ /**
218
+ * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
219
+ * load-balances / fails over across several exit nodes, sending only China
220
+ * traffic through them and everything else direct. This is the client-side
221
+ * counterpart to `agentnet proxy enable` (which is the per-exit server).
222
+ *
223
+ * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
224
+ * If none are given we auto-discover them from the daemon's live IPAM (every
225
+ * known peer becomes a candidate exit on its proxy port; health checks drop
226
+ * the ones that aren't actually serving a proxy).
227
+ */
228
+ /**
229
+ * Start the local Friend UI — a web page to view friends, accept/reject
230
+ * incoming friend-requests, and add friends by address. Talks to the running
231
+ * daemon over IPC. For manual accept/reject the daemon must run with
232
+ * `friends.autoAccept: false` (otherwise requests are accepted instantly and
233
+ * never queue).
234
+ */
235
+ export declare function cmdUi(args: {
236
+ port?: number;
237
+ listen?: string;
238
+ doraDir?: string;
239
+ configDir?: string;
240
+ }): Promise<void>;
217
241
  export declare function cmdProxyRouter(args: {
218
242
  exit?: string[];
219
243
  port?: number;
@@ -13,6 +13,7 @@ import { AclEngine } from "../acl/acl-engine.js";
13
13
  import { AuditLog } from "../acl/audit.js";
14
14
  import yaml from "js-yaml";
15
15
  import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
16
+ import { startFriendUi } from "../ui/server.js";
16
17
  /**
17
18
  * Refuse to open a second Carrier peer with this identity if the
18
19
  * daemon is already running with the same keypair. Two peers sharing
@@ -1030,6 +1031,48 @@ export async function cmdProxyUse(args) {
1030
1031
  console.log(`# agentnet proxy enable`);
1031
1032
  console.log(`# agentnet grant --peer <your-userid> --tcp ${port}`);
1032
1033
  }
1034
+ /**
1035
+ * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
1036
+ * load-balances / fails over across several exit nodes, sending only China
1037
+ * traffic through them and everything else direct. This is the client-side
1038
+ * counterpart to `agentnet proxy enable` (which is the per-exit server).
1039
+ *
1040
+ * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
1041
+ * If none are given we auto-discover them from the daemon's live IPAM (every
1042
+ * known peer becomes a candidate exit on its proxy port; health checks drop
1043
+ * the ones that aren't actually serving a proxy).
1044
+ */
1045
+ /**
1046
+ * Start the local Friend UI — a web page to view friends, accept/reject
1047
+ * incoming friend-requests, and add friends by address. Talks to the running
1048
+ * daemon over IPC. For manual accept/reject the daemon must run with
1049
+ * `friends.autoAccept: false` (otherwise requests are accepted instantly and
1050
+ * never queue).
1051
+ */
1052
+ export async function cmdUi(args) {
1053
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1054
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1055
+ if (daemonPid(config) === null) {
1056
+ console.error("Daemon is not running — start it with 'agentnet up' first.");
1057
+ process.exit(1);
1058
+ }
1059
+ // If this node also runs a dora server, surface its allocation table.
1060
+ // Use --dora-dir, else auto-detect the common locations.
1061
+ const { homedir } = await import("os");
1062
+ const doraCandidates = args.doraDir
1063
+ ? [resolve(args.doraDir, "roster.yaml")]
1064
+ : [resolve(homedir(), ".dora-test", "roster.yaml"), resolve(homedir(), ".decent-registry", "roster.yaml")];
1065
+ const doraRosterPath = doraCandidates.find((p) => existsSync(p)) ?? (args.doraDir ? doraCandidates[0] : undefined);
1066
+ startFriendUi({
1067
+ call: (req) => ipcCall(config, req),
1068
+ routesPath: resolve(dir, "routes.yaml"),
1069
+ doraRosterPath,
1070
+ listenHost: args.listen ?? "127.0.0.1",
1071
+ listenPort: args.port ?? 8765,
1072
+ });
1073
+ // Keep the process alive; runs until Ctrl-C / signal.
1074
+ await new Promise(() => { });
1075
+ }
1033
1076
  export async function cmdProxyRouter(args) {
1034
1077
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
1035
1078
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
10
10
  // Belt-and-braces — also raise it here in case the CLI is run directly
11
11
  // (e.g. `node dist/cli/index.js` rather than via dist/index.js).
12
12
  EventEmitter.defaultMaxListeners = 100;
13
- import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -124,6 +124,13 @@ async function main() {
124
124
  })
125
125
  .demandCommand(1, "Specify a service subcommand (run 'agentnet service --help')"), () => {
126
126
  // parent handler — never invoked because demandCommand above
127
+ })
128
+ .command("ui", "Start the local Friend UI web page (friends list, accept/reject requests, add friend)", (y) => y
129
+ .option("port", { type: "number", default: 8765, describe: "Local listen port" })
130
+ .option("listen", { type: "string", default: "127.0.0.1", describe: "Listen host" })
131
+ .option("dora-dir", { type: "string", describe: "If this node runs a dora server, its data-dir (to show allocations)" })
132
+ .option("config-dir", { type: "string" }), async (argv) => {
133
+ await cmdUi({ port: argv.port, listen: argv.listen, doraDir: argv["dora-dir"], configDir: argv["config-dir"] });
127
134
  })
128
135
  // Tell the running daemon to re-exec itself with its original argv.
129
136
  // The daemon inherits its own uid (root if it was launched as root)
@@ -39,6 +39,10 @@ export interface IpcHandlers {
39
39
  /** Reject (drop) a queued friend-request by userid. Doesn't
40
40
  * notify the sender — they'll just see no acceptance. */
41
41
  friendsReject: (userid: string) => Promise<void>;
42
+ /** Send a chat message (Carrier text, packet 64) to a friend and log it. */
43
+ chatSend: (userid: string, text: string) => Promise<void>;
44
+ /** Return recent chat history grouped by friend userid. */
45
+ chatHistory: () => Promise<Record<string, unknown>>;
42
46
  /** Re-read proxy allowlist from config and apply it to the running
43
47
  * proxy WITHOUT restarting the daemon. Lets `agentnet proxy
44
48
  * allow-host` take effect instantly instead of forcing a daemon
@@ -55,10 +59,11 @@ export interface IpcHandlers {
55
59
  selfRestart: () => Promise<Record<string, unknown>>;
56
60
  }
57
61
  export interface IpcRequest {
58
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "proxy-reload" | "self-restart";
62
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "proxy-reload" | "self-restart";
59
63
  address?: string;
60
64
  hello?: string;
61
65
  userid?: string;
66
+ text?: string;
62
67
  }
63
68
  export interface IpcResponseOk {
64
69
  ok: true;
@@ -144,6 +144,14 @@ export class IpcServer {
144
144
  await this.handlers.friendsReject(req.userid);
145
145
  return;
146
146
  }
147
+ case "chat-send": {
148
+ if (!req.userid)
149
+ throw new Error("userid is required");
150
+ await this.handlers.chatSend(req.userid, req.text ?? "");
151
+ return;
152
+ }
153
+ case "chat-history":
154
+ return await this.handlers.chatHistory();
147
155
  case "proxy-reload":
148
156
  return await this.handlers.proxyReload();
149
157
  case "self-restart":
@@ -33,6 +33,9 @@ export declare class DaemonServer {
33
33
  private ipcServer?;
34
34
  private dnsServer?;
35
35
  private pendingFriends?;
36
+ /** In-memory chat history, keyed by friend userid (last 200 each). Drives
37
+ * the `agentnet ui` chat window; not persisted across restarts. */
38
+ private chatLog;
36
39
  private startedAt;
37
40
  private isRunning;
38
41
  private statusTimer?;
@@ -51,6 +54,8 @@ export declare class DaemonServer {
51
54
  */
52
55
  private startPeerStatusSummary;
53
56
  getStatus(): DaemonStatus;
57
+ /** Append a chat message to the in-memory log (capped at 200 per friend). */
58
+ private logChat;
54
59
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
55
60
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
56
61
  private reFriendAt;
@@ -50,6 +50,9 @@ export class DaemonServer {
50
50
  ipcServer;
51
51
  dnsServer;
52
52
  pendingFriends;
53
+ /** In-memory chat history, keyed by friend userid (last 200 each). Drives
54
+ * the `agentnet ui` chat window; not persisted across restarts. */
55
+ chatLog = new Map();
53
56
  startedAt = 0;
54
57
  isRunning = false;
55
58
  statusTimer;
@@ -167,6 +170,18 @@ export class DaemonServer {
167
170
  throw new Error(`No pending friend-request for userid ${userid}`);
168
171
  // No-op at the Carrier level — sender just sees no acceptance.
169
172
  },
173
+ chatSend: async (userid, text) => {
174
+ if (!text)
175
+ return;
176
+ await this.peerManager.sendText(userid, text);
177
+ this.logChat(userid, "out", text);
178
+ },
179
+ chatHistory: async () => {
180
+ const chats = {};
181
+ for (const [userid, msgs] of this.chatLog)
182
+ chats[userid] = msgs;
183
+ return { chats };
184
+ },
170
185
  proxyReload: async () => {
171
186
  // Re-read the proxy allowlist from disk and push it into the
172
187
  // running proxy without a daemon restart (which would drop
@@ -429,6 +444,10 @@ export class DaemonServer {
429
444
  }
430
445
  return input;
431
446
  };
447
+ // Chat: log incoming Carrier text messages (packet 64) for the UI.
448
+ this.peerManager.on("message", (pubkey, text) => {
449
+ this.logChat(pubkey, "in", text);
450
+ });
432
451
  this.peerManager.on("friend-request", (req) => {
433
452
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
434
453
  const who = `${req.name || "(unnamed)"} ${userid}`;
@@ -649,6 +668,17 @@ export class DaemonServer {
649
668
  activeSessions: this.packetRouter?.getStats().activeSessions || 0,
650
669
  };
651
670
  }
671
+ /** Append a chat message to the in-memory log (capped at 200 per friend). */
672
+ logChat(userid, dir, text) {
673
+ let msgs = this.chatLog.get(userid);
674
+ if (!msgs) {
675
+ msgs = [];
676
+ this.chatLog.set(userid, msgs);
677
+ }
678
+ msgs.push({ dir, text, ts: Date.now() });
679
+ if (msgs.length > 200)
680
+ msgs.splice(0, msgs.length - 200);
681
+ }
652
682
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
653
683
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
654
684
  reFriendAt = new Map();
@@ -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);
@@ -0,0 +1,17 @@
1
+ import type { IpcRequest, IpcResponse } from "../daemon/ipc.js";
2
+ export interface FriendUiOptions {
3
+ /** Call the running daemon over IPC (injected so this module stays
4
+ * decoupled from the CLI's ipc client). */
5
+ call: (req: IpcRequest) => Promise<IpcResponse>;
6
+ /** Path to ~/.agentnet/routes.yaml — the exit-routing table the UI edits. */
7
+ routesPath: string;
8
+ /** If this node also runs a dora server, the path to its roster.yaml — the
9
+ * UI shows the allocation table (userid → IP → name). Undefined = no dora. */
10
+ doraRosterPath?: string;
11
+ listenHost?: string;
12
+ listenPort?: number;
13
+ log?: (msg: string) => void;
14
+ }
15
+ export declare function startFriendUi(opts: FriendUiOptions): {
16
+ stop: () => void;
17
+ };
@@ -0,0 +1,315 @@
1
+ //
2
+ // Friend UI — a tiny local web page for managing a peer's friends:
3
+ // 1. display the friend list (name / status / userid)
4
+ // 2. display incoming friend-requests, with Accept / Reject
5
+ // 3. add a friend by address
6
+ //
7
+ // It's a thin frontend over the daemon's existing IPC ops (diag,
8
+ // friends-pending, friends-accept, friends-reject, friend-request) — no new
9
+ // daemon logic. Started with `agentnet ui`; point a browser at the printed URL.
10
+ //
11
+ // Note: requests only QUEUE for manual Accept/Reject when the daemon runs with
12
+ // `friends.autoAccept = false`. With auto-accept on, the pending list stays
13
+ // empty because the daemon accepts immediately.
14
+ //
15
+ import http from "node:http";
16
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import yaml from "js-yaml";
18
+ export function startFriendUi(opts) {
19
+ const host = opts.listenHost ?? "127.0.0.1";
20
+ const port = opts.listenPort ?? 8765;
21
+ const log = opts.log ?? ((s) => console.log(s));
22
+ const sendJson = (res, code, body) => {
23
+ res.writeHead(code, { "content-type": "application/json" });
24
+ res.end(JSON.stringify(body));
25
+ };
26
+ const readBody = (req) => new Promise((resolve) => {
27
+ let b = "";
28
+ req.on("data", (c) => (b += c));
29
+ req.on("end", () => {
30
+ try {
31
+ resolve(b ? JSON.parse(b) : {});
32
+ }
33
+ catch {
34
+ resolve({});
35
+ }
36
+ });
37
+ });
38
+ const server = http.createServer(async (req, res) => {
39
+ try {
40
+ const url = (req.url || "/").split("?")[0];
41
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
42
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
43
+ res.end(PAGE);
44
+ return;
45
+ }
46
+ if (req.method === "GET" && url === "/api/state") {
47
+ const [diag, pending] = await Promise.all([
48
+ opts.call({ op: "diag" }),
49
+ opts.call({ op: "friends-pending" }),
50
+ ]);
51
+ const d = diag.ok ? (diag.data ?? {}) : {};
52
+ const identity = d.identity ?? {};
53
+ const tun = d.tun ?? {};
54
+ const me = {
55
+ userid: identity.userid,
56
+ address: identity.address,
57
+ ip: d.allocatedIp ?? tun.ip,
58
+ installed: !!tun.ip, // lan/TUN is up
59
+ };
60
+ const friends = d.friends ?? [];
61
+ const pend = pending.ok ? (pending.data?.pending ?? []) : [];
62
+ sendJson(res, 200, { me, friends, pending: pend });
63
+ return;
64
+ }
65
+ if (req.method === "GET" && url === "/api/chat-history") {
66
+ const r = await opts.call({ op: "chat-history" });
67
+ sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
68
+ return;
69
+ }
70
+ if (req.method === "POST" && url === "/api/chat-send") {
71
+ const { userid, text } = await readBody(req);
72
+ const r = await opts.call({ op: "chat-send", userid, text });
73
+ sendJson(res, r.ok ? 200 : 400, r);
74
+ return;
75
+ }
76
+ if (req.method === "GET" && url === "/api/routes") {
77
+ let routes = { regions: [], default: "direct" };
78
+ if (existsSync(opts.routesPath)) {
79
+ routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
80
+ }
81
+ const diag = await opts.call({ op: "diag" });
82
+ const available = diag.ok ? (diag.data?.ipam ?? []) : [];
83
+ sendJson(res, 200, { routes, available });
84
+ return;
85
+ }
86
+ if (req.method === "GET" && url === "/api/dora") {
87
+ let records = [];
88
+ if (opts.doraRosterPath && existsSync(opts.doraRosterPath)) {
89
+ const r = yaml.load(readFileSync(opts.doraRosterPath, "utf-8"));
90
+ records = r?.records ?? [];
91
+ }
92
+ sendJson(res, 200, { isDora: !!opts.doraRosterPath, records });
93
+ return;
94
+ }
95
+ if (req.method === "POST" && url === "/api/routes") {
96
+ const body = await readBody(req);
97
+ try {
98
+ const routes = JSON.parse(body.routes ?? "{}");
99
+ writeFileSync(opts.routesPath, yaml.dump(routes, { lineWidth: -1 }), "utf-8");
100
+ sendJson(res, 200, { ok: true });
101
+ }
102
+ catch (err) {
103
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
104
+ }
105
+ return;
106
+ }
107
+ if (req.method === "POST" && url === "/api/accept") {
108
+ const { userid } = await readBody(req);
109
+ const r = await opts.call({ op: "friends-accept", userid });
110
+ sendJson(res, r.ok ? 200 : 400, r);
111
+ return;
112
+ }
113
+ if (req.method === "POST" && url === "/api/reject") {
114
+ const { userid } = await readBody(req);
115
+ const r = await opts.call({ op: "friends-reject", userid });
116
+ sendJson(res, r.ok ? 200 : 400, r);
117
+ return;
118
+ }
119
+ if (req.method === "POST" && url === "/api/add") {
120
+ const { address } = await readBody(req);
121
+ const r = await opts.call({ op: "friend-request", address });
122
+ sendJson(res, r.ok ? 200 : 400, r);
123
+ return;
124
+ }
125
+ res.writeHead(404);
126
+ res.end("not found");
127
+ }
128
+ catch (err) {
129
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
130
+ }
131
+ });
132
+ server.listen(port, host, () => {
133
+ log(`Friend UI on http://${host}:${port}`);
134
+ log(`(serves the running daemon's friends + pending requests over IPC)`);
135
+ });
136
+ return { stop: () => server.close() };
137
+ }
138
+ const PAGE = `<!doctype html>
139
+ <html lang="en"><head><meta charset="utf-8"/>
140
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
141
+ <title>decentlan — friends</title>
142
+ <style>
143
+ :root { color-scheme: light dark; }
144
+ body { font: 15px/1.5 -apple-system, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
145
+ h1 { font-size: 1.3rem; } h2 { font-size: 1rem; margin: 1.5rem 0 .5rem; color: #888; text-transform: uppercase; letter-spacing: .04em; }
146
+ .row { display: flex; align-items: center; gap: .75rem; padding: .55rem .7rem; border: 1px solid #8883; border-radius: 8px; margin-bottom: .4rem; }
147
+ .row .meta { flex: 1; min-width: 0; }
148
+ .row .name { font-weight: 600; }
149
+ .row .sub { font-size: .8rem; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
+ .dot { width: .6rem; height: .6rem; border-radius: 50%; flex: 0 0 auto; }
151
+ .online { background: #2ecc71; } .offline { background: #bbb; } .requested { background: #f1c40f; }
152
+ button { font: inherit; padding: .3rem .7rem; border-radius: 6px; border: 1px solid #8886; background: transparent; cursor: pointer; }
153
+ button.accept { border-color: #2ecc71; color: #2ecc71; } button.reject { border-color: #e74c3c; color: #e74c3c; }
154
+ button:hover { background: #8881; }
155
+ .add { display: flex; gap: .5rem; margin: .5rem 0 1.5rem; }
156
+ .add input { flex: 1; font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid #8886; background: transparent; color: inherit; }
157
+ .empty { color: #999; font-style: italic; padding: .4rem 0; }
158
+ #toast { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: .5rem 1rem; border-radius: 8px; opacity: 0; transition: opacity .2s; }
159
+ #toast.show { opacity: 1; }
160
+ </style></head>
161
+ <body>
162
+ <h1>decentlan · friends</h1>
163
+ <div id="me" class="sub" style="margin:-.5rem 0 1rem"></div>
164
+
165
+ <div class="add">
166
+ <input id="addr" placeholder="paste a friend's address to send a request" autocomplete="off"/>
167
+ <button onclick="addFriend()">Add</button>
168
+ </div>
169
+
170
+ <h2>Friend requests <span id="pcount"></span></h2>
171
+ <div id="pending"></div>
172
+
173
+ <div id="doraPanel" style="display:none">
174
+ <h2>Dora allocations <span id="dcount"></span></h2>
175
+ <div id="dora"></div>
176
+ </div>
177
+
178
+ <h2>Exit nodes <span id="ecount"></span></h2>
179
+ <div id="exits"></div>
180
+ <div class="add">
181
+ <input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
182
+ <input id="exitRegion" placeholder="region (china/japan/us)" autocomplete="off" list="regionList" style="flex:1"/>
183
+ <datalist id="regionList"><option>china</option><option>japan</option><option>us</option></datalist>
184
+ <button onclick="addExit()">Add exit</button>
185
+ </div>
186
+
187
+ <h2>Friends <span id="fcount"></span></h2>
188
+ <div id="friends"></div>
189
+
190
+ <div id="chat" style="display:none; position:fixed; inset:0; background:Canvas; padding:1.5rem 1rem; max-width:760px; margin:0 auto;">
191
+ <div class="row" style="border:none; padding-left:0">
192
+ <button onclick="closeChat()">← Back</button>
193
+ <div class="meta"><div class="name" id="chatName"></div><div class="sub" id="chatSub"></div></div>
194
+ </div>
195
+ <div id="chatLog" style="height:calc(100vh - 13rem); overflow-y:auto; display:flex; flex-direction:column; gap:.35rem; padding:.5rem 0;"></div>
196
+ <div class="add"><input id="chatInput" placeholder="message…" autocomplete="off"/><button onclick="sendChat()">Send</button></div>
197
+ </div>
198
+
199
+ <div id="toast"></div>
200
+ <script>
201
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
202
+ const short = s => { s = String(s ?? ''); return s.length > 20 ? s.slice(0,10)+'…'+s.slice(-6) : s; };
203
+ let toastT;
204
+ function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),2200); }
205
+
206
+ async function api(path, body){ const r = await fetch(path, body?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}:{}); return r.json(); }
207
+
208
+ window.friendsById = {};
209
+ async function refresh(){
210
+ let s; try { s = await api('/api/state'); } catch(e){ return; }
211
+ const pend = s.pending || [], fr = s.friends || [], me = s.me || {};
212
+ document.getElementById('me').textContent = me.ip
213
+ ? ('My IP: ' + me.ip + (me.userid ? ' · ' + short(me.userid) : ''))
214
+ : (me.userid ? ('userid: ' + short(me.userid) + ' · lan/TUN not up') : 'daemon: no identity');
215
+ fr.forEach(f => { window.friendsById[f.userid||f.carrierId] = f; });
216
+ document.getElementById('pcount').textContent = pend.length ? '('+pend.length+')' : '';
217
+ document.getElementById('fcount').textContent = fr.length ? '('+fr.length+')' : '';
218
+ document.getElementById('pending').innerHTML = pend.length ? pend.map(p => \`
219
+ <div class="row">
220
+ <div class="meta"><div class="name">\${esc(p.name || 'unnamed')}</div>
221
+ <div class="sub">\${esc(short(p.userid))}\${p.hello? ' · "'+esc(p.hello)+'"':''}</div></div>
222
+ <button class="accept" onclick="act('accept','\${esc(p.userid)}')">Accept</button>
223
+ <button class="reject" onclick="act('reject','\${esc(p.userid)}')">Reject</button>
224
+ </div>\`).join('') : '<div class="empty">No pending requests.</div>';
225
+ document.getElementById('friends').innerHTML = fr.length ? fr.map(f => \`
226
+ <div class="row" style="cursor:pointer" onclick="openChat('\${esc(f.userid||f.carrierId)}')" title="open chat">
227
+ <span class="dot \${esc(f.status||'offline')}"></span>
228
+ <div class="meta"><div class="name">\${esc(f.name || 'unnamed')}</div>
229
+ <div class="sub">\${esc(f.status||'')} · \${esc(short(f.userid||f.carrierId))}\${f.virtualIp? ' · '+esc(f.virtualIp):''}</div></div>
230
+ </div>\`).join('') : '<div class="empty">No friends yet.</div>';
231
+ }
232
+ async function act(kind, userid){ const r = await api('/api/'+kind, {userid}); toast(r.ok? (kind==='accept'?'Accepted':'Rejected') : (r.error||'failed')); refresh(); }
233
+ async function addFriend(){ const a=document.getElementById('addr'); const v=a.value.trim(); if(!v) return; const r=await api('/api/add',{address:v}); toast(r.ok?'Friend-request sent':(r.error||'failed')); if(r.ok) a.value=''; refresh(); }
234
+ document.getElementById('addr').addEventListener('keydown', e=>{ if(e.key==='Enter') addFriend(); });
235
+
236
+ let chatWith = null, chatTimer = null;
237
+ async function openChat(userid){
238
+ chatWith = userid;
239
+ const f = window.friendsById[userid] || {};
240
+ document.getElementById('chatName').textContent = f.name || 'unnamed';
241
+ document.getElementById('chatSub').textContent = (f.status||'') + ' · ' + short(userid);
242
+ document.getElementById('chat').style.display = 'block';
243
+ await renderChat();
244
+ clearInterval(chatTimer); chatTimer = setInterval(renderChat, 2000);
245
+ document.getElementById('chatInput').focus();
246
+ }
247
+ function closeChat(){ chatWith = null; clearInterval(chatTimer); document.getElementById('chat').style.display='none'; refresh(); }
248
+ async function renderChat(){
249
+ if(!chatWith) return;
250
+ let h; try { h = await api('/api/chat-history'); } catch(e){ return; }
251
+ const msgs = (h.chats && h.chats[chatWith]) || [];
252
+ const log = document.getElementById('chatLog');
253
+ log.innerHTML = msgs.length ? msgs.map(m => \`<div style="align-self:\${m.dir==='out'?'flex-end':'flex-start'};max-width:75%;padding:.4rem .7rem;border-radius:12px;background:\${m.dir==='out'?'#3478f6':'#8883'};color:\${m.dir==='out'?'#fff':'inherit'}">\${esc(m.text)}</div>\`).join('') : '<div class="empty">No messages yet — say hi.</div>';
254
+ log.scrollTop = log.scrollHeight;
255
+ }
256
+ async function sendChat(){
257
+ const i = document.getElementById('chatInput'); const t = i.value.trim();
258
+ if(!t || !chatWith) return;
259
+ i.value='';
260
+ const r = await api('/api/chat-send', {userid: chatWith, text: t});
261
+ if(!r.ok) toast(r.error||'send failed');
262
+ renderChat();
263
+ }
264
+ document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
265
+
266
+ // ---- Exit nodes (routes.yaml) ----
267
+ let routesObj = { regions: [], default: 'direct' };
268
+ async function refreshExits(){
269
+ let r; try { r = await api('/api/routes'); } catch(e){ return; }
270
+ routesObj = r.routes || { regions: [], default: 'direct' };
271
+ if(!Array.isArray(routesObj.regions)) routesObj.regions = [];
272
+ const regions = routesObj.regions;
273
+ const total = regions.reduce((n,rg)=>n+((rg.exits||[]).length),0);
274
+ document.getElementById('ecount').textContent = total ? '('+total+')' : '';
275
+ document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
276
+ <div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
277
+ <div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
278
+ \${(rg.exits||[]).map(ip=>\`<div class="sub" style="display:flex; gap:.5rem; align-items:center"><span style="flex:1">\${esc(ip)}</span><button class="reject" onclick="removeExit('\${esc(rg.name)}','\${esc(ip)}')">×</button></div>\`).join('')}
279
+ </div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
280
+ }
281
+ function saveRoutes(){ return api('/api/routes', { routes: JSON.stringify(routesObj) }); }
282
+ async function addExit(){
283
+ const ip=document.getElementById('exitIp').value.trim(), region=(document.getElementById('exitRegion').value.trim()||'china');
284
+ if(!ip) return;
285
+ let rg = routesObj.regions.find(x=>x.name===region);
286
+ if(!rg){ rg={name:region, exits:[]}; routesObj.regions.push(rg); }
287
+ rg.exits = rg.exits||[]; if(!rg.exits.includes(ip)) rg.exits.push(ip);
288
+ const r = await saveRoutes(); toast(r.ok?'Added — restart the router to apply':(r.error||'failed'));
289
+ document.getElementById('exitIp').value=''; refreshExits();
290
+ }
291
+ async function removeExit(region, ip){
292
+ const rg = routesObj.regions.find(x=>x.name===region); if(!rg) return;
293
+ rg.exits = (rg.exits||[]).filter(x=>x!==ip);
294
+ const r = await saveRoutes(); toast(r.ok?'Removed — restart the router to apply':(r.error||'failed')); refreshExits();
295
+ }
296
+
297
+ // ---- Dora allocations (if this node runs a dora server) ----
298
+ async function refreshDora(){
299
+ let d; try { d = await api('/api/dora'); } catch(e){ return; }
300
+ const panel = document.getElementById('doraPanel');
301
+ if(!d.isDora){ panel.style.display='none'; return; }
302
+ panel.style.display='block';
303
+ const recs = d.records || [];
304
+ document.getElementById('dcount').textContent = '('+recs.length+')';
305
+ document.getElementById('dora').innerHTML = recs.length ? recs.map(r => \`
306
+ <div class="row">
307
+ <div class="meta"><div class="name">\${esc(r.name||'unnamed')} · \${esc(r.virtualIp||'')}</div>
308
+ <div class="sub">\${esc(short(r.userid))}</div></div>
309
+ </div>\`).join('') : '<div class="empty">No nodes registered yet.</div>';
310
+ }
311
+
312
+ refresh(); refreshExits(); refreshDora();
313
+ setInterval(refresh, 3000); setInterval(refreshExits, 8000); setInterval(refreshDora, 5000);
314
+ </script>
315
+ </body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
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
  },