@decentnetwork/lan 0.1.15 → 0.1.17

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.
@@ -159,12 +159,29 @@ export class PeerManager extends EventEmitter {
159
159
  if (!this.peer) {
160
160
  throw new Error("Peer not created. Call create() first.");
161
161
  }
162
- return this.peer.friends().map((friend) => ({
163
- pubkey: friend.pubkey,
164
- name: friend.name,
165
- status: friend.status === "online" ? "online" : "offline",
166
- lastSeen: friend.acceptedAt,
167
- }));
162
+ return this.peer.friends().map((friend) => {
163
+ // Preserve the SDK's three-state status ("requested" matters —
164
+ // collapsing it into "offline" hid the "did my friend-request
165
+ // make it across?" diagnostic from diag output).
166
+ const status = friend.status === "online"
167
+ ? "online"
168
+ : friend.status === "requested"
169
+ ? "requested"
170
+ : "offline";
171
+ // Both pubkey and userid are populated with the base58 userid in
172
+ // every current SDK code path, but persisted records from
173
+ // pre-1.7.x clients may only have pubkey. Keep both forms.
174
+ const carrierId = friend.userid || friend.pubkey;
175
+ return {
176
+ pubkey: friend.pubkey,
177
+ carrierId,
178
+ name: friend.name,
179
+ status,
180
+ address: friend.address,
181
+ lastSeen: friend.acceptedAt,
182
+ acceptedAt: friend.acceptedAt,
183
+ };
184
+ });
168
185
  }
169
186
  /**
170
187
  * Check if a specific friend is currently online (direct UDP path established).
@@ -96,7 +96,10 @@ export declare function cmdFriendAccept(args: {
96
96
  configDir?: string;
97
97
  }): Promise<void>;
98
98
  /**
99
- * List friends in the friend store.
99
+ * List friends in the friend store. Prefers the live daemon over IPC —
100
+ * opening a second Peer with the same keyfile would stomp on the
101
+ * daemon's Carrier session, so we only fall back to that path when the
102
+ * daemon isn't running. Same pattern as `peers list` and `resolve`.
100
103
  */
101
104
  export declare function cmdFriendsList(args: {
102
105
  configDir?: string;
@@ -511,46 +511,63 @@ export async function cmdFriendAccept(args) {
511
511
  console.log(`Friend accepted: ${pubkey}`);
512
512
  }
513
513
  /**
514
- * List friends in the friend store.
514
+ * List friends in the friend store. Prefers the live daemon over IPC —
515
+ * opening a second Peer with the same keyfile would stomp on the
516
+ * daemon's Carrier session, so we only fall back to that path when the
517
+ * daemon isn't running. Same pattern as `peers list` and `resolve`.
515
518
  */
516
519
  export async function cmdFriendsList(args) {
517
520
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
518
521
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
519
- assertDaemonNotRunning(config, "friends list");
520
- const { Peer } = await import("@decentnetwork/peer");
521
- const keyFile = resolve(config.carrier.dataDir, "keypair.json");
522
- if (!existsSync(keyFile)) {
523
- console.log("No identity yet. Start the daemon at least once to generate one.");
524
- return;
522
+ let friends;
523
+ let source;
524
+ if (daemonPid(config) !== null) {
525
+ const res = await ipcCall(config, { op: "diag" });
526
+ if (!res.ok)
527
+ throw new Error(`Daemon diag failed: ${res.error}`);
528
+ friends = (res.data.friends ?? []);
529
+ source = "daemon";
530
+ }
531
+ else {
532
+ const { Peer } = await import("@decentnetwork/peer");
533
+ const keyFile = resolve(config.carrier.dataDir, "keypair.json");
534
+ if (!existsSync(keyFile)) {
535
+ console.log("No identity yet. Start the daemon at least once to generate one.");
536
+ return;
537
+ }
538
+ const peer = await Peer.create({
539
+ keyFile,
540
+ compatibilityMode: "legacy",
541
+ bootstrapNodes: config.carrier.bootstrapNodes,
542
+ expressNodes: config.carrier.expressNodes,
543
+ });
544
+ await peer.start();
545
+ friends = peer.friends().map((f) => ({
546
+ pubkey: f.pubkey,
547
+ carrierId: f.userid || f.pubkey,
548
+ name: f.name,
549
+ status: f.status,
550
+ address: f.address,
551
+ acceptedAt: f.acceptedAt,
552
+ }));
553
+ await peer.stop();
554
+ source = "disk";
525
555
  }
526
- // Open peer and start (start() loads friends from disk)
527
- const peer = await Peer.create({
528
- keyFile,
529
- compatibilityMode: "legacy",
530
- bootstrapNodes: config.carrier.bootstrapNodes,
531
- expressNodes: config.carrier.expressNodes,
532
- });
533
- await peer.start();
534
- const friends = peer.friends();
535
556
  if (friends.length === 0) {
536
557
  console.log("No friends. Use 'agentnet friend-request --address <addr>' to add one.");
537
- await peer.stop();
538
558
  return;
539
559
  }
540
- console.log(`Friends (${friends.length}):`);
560
+ console.log(`Friends (${friends.length}) ${source === "daemon" ? "[live]" : "[disk]"}:`);
541
561
  for (const friend of friends) {
542
562
  console.log(``);
543
563
  console.log(` ${friend.name || "(unnamed)"} status=${friend.status}`);
544
- // userid is Carrier's first-class identifier; show that, hide
545
- // the raw hex pubkey unless someone explicitly needs it.
546
- const userid = friend.userid && friend.userid !== friend.pubkey ? friend.userid : friend.pubkey;
564
+ const userid = friend.carrierId || friend.pubkey;
547
565
  console.log(` userid: ${userid}`);
548
566
  if (friend.address)
549
567
  console.log(` address: ${friend.address}`);
550
568
  if (friend.acceptedAt)
551
569
  console.log(` accepted: ${new Date(friend.acceptedAt).toISOString()}`);
552
570
  }
553
- await peer.stop();
554
571
  }
555
572
  /**
556
573
  * List queued friend-requests held by the running daemon (the ones
@@ -170,6 +170,8 @@ export class DaemonServer {
170
170
  carrierId: f.carrierId,
171
171
  name: f.name,
172
172
  status: f.status,
173
+ address: f.address,
174
+ acceptedAt: f.acceptedAt,
173
175
  })),
174
176
  ipam: ipamPeers,
175
177
  };
@@ -212,8 +214,15 @@ export class DaemonServer {
212
214
  this.logger.info(`Reconfiguring TUN: dora-allocated IP changed ${currentIp} -> ${newIp}`);
213
215
  const ifname = this.tunDevice.getInterface();
214
216
  try {
215
- await this.routeManager.cleanup(ifname, this.config.network.subnet, currentIp);
217
+ // ORDER MATTERS: close TunDevice FIRST so the helper
218
+ // subprocess fully exits and releases agentnet0.
219
+ // routeManager.cleanup's `ip tuntap del` would otherwise
220
+ // fail with EBUSY and the subsequent helper spawn would
221
+ // race the kernel for the device name, exit code=1, and
222
+ // leave the daemon TUN-less. tunDevice.close() now waits
223
+ // for actual process exit.
216
224
  await this.tunDevice.close();
225
+ await this.routeManager.cleanup(ifname, this.config.network.subnet, currentIp);
217
226
  this.tunDevice = new TunDevice({
218
227
  config: {
219
228
  name: this.config.network.interface,
@@ -54,8 +54,35 @@ export class TunDevice extends EventEmitter {
54
54
  this.isOpen = false;
55
55
  this.packetQueue = [];
56
56
  if (this.helper) {
57
- this.helper.kill("SIGTERM");
57
+ // Wait for the helper subprocess to actually exit before
58
+ // returning. SIGTERM is async — without this await, a caller
59
+ // that immediately spawns a new helper for the same device
60
+ // name races the kernel: the old helper still owns
61
+ // `agentnet0`, the new helper's TUNSETIFF ioctl returns
62
+ // EBUSY, the helper exits with code=1, the daemon ends up
63
+ // with NO TUN device at all even though `systemctl is-active`
64
+ // reports green. Symptom: every outbound packet routes via
65
+ // the default gateway instead of the daemon, 100% packet
66
+ // loss with no obvious error. Force-kill after 2s in case
67
+ // SIGTERM is swallowed.
68
+ const helper = this.helper;
58
69
  this.helper = undefined;
70
+ await new Promise((res) => {
71
+ if (helper.exitCode !== null || helper.signalCode !== null) {
72
+ res();
73
+ return;
74
+ }
75
+ const timer = setTimeout(() => {
76
+ if (helper.exitCode === null && helper.signalCode === null) {
77
+ helper.kill("SIGKILL");
78
+ }
79
+ }, 2000);
80
+ helper.once("exit", () => {
81
+ clearTimeout(timer);
82
+ res();
83
+ });
84
+ helper.kill("SIGTERM");
85
+ });
59
86
  }
60
87
  this.emit("closed");
61
88
  }
package/dist/types.d.ts CHANGED
@@ -8,11 +8,21 @@ export interface PeerIdentity {
8
8
  }
9
9
  export interface RemotePeer {
10
10
  pubkey: string;
11
- status: "online" | "offline" | "connecting";
11
+ /**
12
+ * Mirrors the SDK's FriendRecord.status verbatim:
13
+ * - "online": crypto session established
14
+ * - "offline": was a friend, currently disconnected
15
+ * - "requested": we sent a friend-request; peer hasn't accepted
16
+ * Collapsing "requested" into "offline" loses the operator signal
17
+ * ("did my friend-request go through?") so keep them distinct.
18
+ */
19
+ status: "online" | "offline" | "requested";
12
20
  name?: string;
13
21
  carrierId?: string;
14
22
  virtualIp?: string;
23
+ address?: string;
15
24
  lastSeen?: number;
25
+ acceptedAt?: number;
16
26
  }
17
27
  export interface FriendConnectionEvent {
18
28
  pubkey: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",