@decentnetwork/lan 0.1.14 → 0.1.16

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.
@@ -15,9 +15,6 @@ export declare function cmdInit(args: {
15
15
  export declare function cmdIdentityShow(args: {
16
16
  configDir?: string;
17
17
  }): Promise<void>;
18
- /**
19
- * List peers from IPAM
20
- */
21
18
  export declare function cmdPeersList(args: {
22
19
  configDir?: string;
23
20
  }): Promise<void>;
@@ -11,7 +11,6 @@ import { Ipam } from "../ipam/ipam.js";
11
11
  import { Policy } from "../acl/policy.js";
12
12
  import { AclEngine } from "../acl/acl-engine.js";
13
13
  import { AuditLog } from "../acl/audit.js";
14
- import { DnsResolver } from "../dns/resolver.js";
15
14
  /**
16
15
  * Refuse to open a second Carrier peer with this identity if the
17
16
  * daemon is already running with the same keypair. Two peers sharing
@@ -162,29 +161,51 @@ export async function cmdIdentityShow(args) {
162
161
  /**
163
162
  * List peers from IPAM
164
163
  */
164
+ /**
165
+ * Fetch the IPAM contents from wherever they're live. When the daemon
166
+ * is up, dora-merged entries live in the daemon's memory only — reading
167
+ * the on-disk file gives the operator a stale (often empty) snapshot.
168
+ * The daemon publishes the in-memory list over IPC via `diag`. Falls
169
+ * back to disk when the daemon is down.
170
+ */
171
+ async function fetchLiveIpam(config) {
172
+ if (daemonPid(config) !== null) {
173
+ const res = await ipcCall(config, { op: "diag" });
174
+ if (res.ok) {
175
+ const data = res.data;
176
+ return { peers: data.ipam ?? [], source: "daemon" };
177
+ }
178
+ }
179
+ const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
180
+ return {
181
+ peers: ipam.getPeers().map((p) => ({
182
+ name: p.name,
183
+ virtualIp: p.virtualIp,
184
+ carrierId: p.carrierId,
185
+ })),
186
+ source: "disk",
187
+ };
188
+ }
165
189
  export async function cmdPeersList(args) {
166
190
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
167
191
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
168
- const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
169
- const peers = ipam.getPeers();
192
+ const { peers, source } = await fetchLiveIpam(config);
170
193
  if (peers.length === 0) {
171
- console.log("No peers configured. Use 'agentnet ipam assign' to add peers.");
194
+ if (source === "daemon") {
195
+ console.log("Daemon up but IPAM is empty — dora roster hasn't merged yet, or dora isn't configured.");
196
+ }
197
+ else {
198
+ console.log("No peers configured. Daemon is down — use 'agentnet ipam assign' to add peers,");
199
+ console.log("or 'agentnet up' to start the daemon (dora will populate IPAM automatically).");
200
+ }
172
201
  return;
173
202
  }
174
- console.log(`Peers (${peers.length}):`);
203
+ console.log(`Peers (${peers.length}) ${source === "daemon" ? "[live]" : "[disk]"}:`);
175
204
  console.log("");
176
205
  for (const peer of peers) {
177
- const expires = peer.expiresAt
178
- ? ` (expires ${new Date(peer.expiresAt).toISOString()})`
179
- : "";
180
206
  console.log(` ${peer.name}.${config.network.dnsDomain}`);
181
207
  console.log(` Virtual IP: ${peer.virtualIp}`);
182
208
  console.log(` Carrier ID: ${peer.carrierId.slice(0, 32)}...`);
183
- if (peer.services.length > 0) {
184
- console.log(` Services: ${peer.services.map((s) => `${s.name}:${s.port}`).join(", ")}`);
185
- }
186
- if (expires)
187
- console.log(` ${expires.trim()}`);
188
209
  console.log("");
189
210
  }
190
211
  }
@@ -282,16 +303,29 @@ export async function cmdRevoke(args) {
282
303
  export async function cmdResolve(args) {
283
304
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
284
305
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
285
- const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
286
- const resolver = new DnsResolver(ipam, config.network.dnsDomain);
287
- const ip = resolver.resolve(args.name);
288
- if (ip) {
289
- console.log(`${args.name} -> ${ip}`);
290
- }
291
- else {
292
- console.log(`Cannot resolve: ${args.name}`);
293
- process.exit(1);
306
+ // Prefer the live daemon's IPAM — the on-disk ipam.yaml is empty
307
+ // until the operator runs `agentnet ipam assign` manually, but dora
308
+ // populates the daemon's in-memory IPAM with the full roster.
309
+ // Falls back to disk when the daemon is down.
310
+ const { peers } = await fetchLiveIpam(config);
311
+ const stripped = args.name.endsWith(`.${config.network.dnsDomain}`)
312
+ ? args.name.slice(0, -(config.network.dnsDomain.length + 1))
313
+ : args.name;
314
+ const byName = peers.find((p) => p.name === stripped);
315
+ const byIp = peers.find((p) => p.virtualIp === args.name);
316
+ const byCarrier = peers.find((p) => p.carrierId === args.name);
317
+ const hit = byName ?? byIp ?? byCarrier;
318
+ if (hit) {
319
+ if (byIp) {
320
+ console.log(`${args.name} -> ${hit.name} (${hit.carrierId.slice(0, 16)}...)`);
321
+ }
322
+ else {
323
+ console.log(`${args.name} -> ${hit.virtualIp}`);
324
+ }
325
+ return;
294
326
  }
327
+ console.log(`Cannot resolve: ${args.name}`);
328
+ process.exit(1);
295
329
  }
296
330
  /**
297
331
  * Show daemon status (must be run while daemon is up — placeholder for now)
@@ -529,26 +563,58 @@ export async function cmdFriendsPending(args) {
529
563
  if (daemonPid(config) === null) {
530
564
  throw new Error("Daemon not running — pending friend-requests are held by the daemon. Start it with 'agentnet up' first.");
531
565
  }
532
- const res = await ipcCall(config, { op: "friends-pending" });
533
- if (!res.ok)
534
- throw new Error(`Daemon refused: ${res.error}`);
535
- const list = res.data?.pending ?? [];
536
- if (list.length === 0) {
537
- console.log("No pending friend-requests.");
566
+ // Two sources of "in-flight" friend state, both useful to the
567
+ // operator under one command:
568
+ // 1) inbound peers who sent us a friend-request that hasn't
569
+ // been accepted yet. Auto-accept (default) drains this
570
+ // instantly, so the inbound list is usually empty.
571
+ // 2) outbound — peers WE sent a request to who haven't
572
+ // accepted; the SDK's friends() returns these with
573
+ // status="requested". Useful for "is my friend-request
574
+ // stuck?" debugging.
575
+ const [pendingRes, diagRes] = await Promise.all([
576
+ ipcCall(config, { op: "friends-pending" }),
577
+ ipcCall(config, { op: "diag" }),
578
+ ]);
579
+ if (!pendingRes.ok)
580
+ throw new Error(`Daemon refused: ${pendingRes.error}`);
581
+ if (!diagRes.ok)
582
+ throw new Error(`Daemon diag failed: ${diagRes.error}`);
583
+ const inbound = pendingRes.data?.pending ?? [];
584
+ const friends = diagRes.data.friends ?? [];
585
+ const outbound = friends.filter((f) => f.status === "requested");
586
+ if (inbound.length === 0 && outbound.length === 0) {
587
+ console.log("No friend-requests in flight.");
588
+ console.log(" inbound: none queued (autoAccept handles them automatically)");
589
+ console.log(" outbound: every sent request has been accepted");
538
590
  return;
539
591
  }
540
- console.log(`Pending friend-requests (${list.length}):`);
541
- for (const e of list) {
592
+ if (inbound.length > 0) {
593
+ console.log(`Inbound (waiting for your accept) — ${inbound.length}:`);
594
+ for (const e of inbound) {
595
+ console.log("");
596
+ console.log(` ${e.name || "(unnamed)"}`);
597
+ console.log(` userid: ${e.userid}`);
598
+ if (e.hello)
599
+ console.log(` hello: ${e.hello}`);
600
+ console.log(` arrived: ${e.arrivedAt}`);
601
+ }
602
+ console.log("");
603
+ console.log(" Accept: agentnet friends accept --userid <USERID>");
604
+ console.log(" Reject: agentnet friends reject --userid <USERID>");
542
605
  console.log("");
543
- console.log(` ${e.name || "(unnamed)"}`);
544
- console.log(` userid: ${e.userid}`);
545
- if (e.hello)
546
- console.log(` hello: ${e.hello}`);
547
- console.log(` arrived: ${e.arrivedAt}`);
548
606
  }
549
- console.log("");
550
- console.log("Accept with: agentnet friends accept --userid <USERID>");
551
- console.log("Reject with: agentnet friends reject --userid <USERID>");
607
+ if (outbound.length > 0) {
608
+ console.log(`Outbound (we requested, peer hasn't accepted yet) — ${outbound.length}:`);
609
+ for (const f of outbound) {
610
+ const id = f.carrierId || f.pubkey;
611
+ console.log(` ${f.name || "(no name)"} userid: ${id}`);
612
+ }
613
+ console.log("");
614
+ console.log(" These resolve automatically once the recipient's daemon comes up");
615
+ console.log(" and accepts. If a peer never accepts, re-send via:");
616
+ console.log(" agentnet friend-request --address <ADDRESS>");
617
+ }
552
618
  }
553
619
  /**
554
620
  * Accept a queued friend-request by userid. Routes through IPC;
package/dist/cli/index.js CHANGED
@@ -26,7 +26,14 @@ async function main() {
26
26
  .command("peers list", "List configured peers", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
27
27
  await cmdPeersList({ configDir: argv["config-dir"] });
28
28
  })
29
- .command("ipam assign", "Register peer with virtual IP", (y) => y
29
+ // Nested so 'ipam list' (user-expected) doesn't get routed to
30
+ // 'ipam assign' by yargs' flat-positional matching. Same trap
31
+ // 'friends' fell into.
32
+ .command("ipam", "Manage IP address allocations (run 'agentnet ipam --help')", (y) => y
33
+ .command("list", "List peer IP allocations (live from daemon if up)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
34
+ await cmdPeersList({ configDir: argv["config-dir"] });
35
+ })
36
+ .command("assign", "Register peer with virtual IP", (yy) => yy
30
37
  .option("peer", { type: "string", demandOption: true, describe: "Carrier ID" })
31
38
  .option("ip", { type: "string", describe: "Virtual IP (auto if omitted)" })
32
39
  .option("name", { type: "string", demandOption: true, describe: "Hostname" })
@@ -43,6 +50,9 @@ async function main() {
43
50
  services: argv.service,
44
51
  configDir: argv["config-dir"],
45
52
  });
53
+ })
54
+ .demandCommand(1, "Specify an ipam subcommand (run 'agentnet ipam --help')"), () => {
55
+ // parent handler — never invoked because demandCommand above
46
56
  })
47
57
  .command("grant", "Grant access to a peer", (y) => y
48
58
  .option("peer", { type: "string", demandOption: true })
@@ -152,8 +162,15 @@ async function main() {
152
162
  .demandCommand(1, "Specify a friends subcommand (run 'agentnet friends --help')"), () => {
153
163
  // parent handler — never invoked because demandCommand above
154
164
  })
155
- .command("audit log", "View audit log", (y) => y.option("tail", { type: "number", default: 50 }).option("config-dir", { type: "string" }), async (argv) => {
165
+ // Audit is also nested for consistency / future subcommands.
166
+ .command("audit", "View audit trail (run 'agentnet audit --help')", (y) => y
167
+ .command("log", "View audit log", (yy) => yy
168
+ .option("tail", { type: "number", default: 50 })
169
+ .option("config-dir", { type: "string" }), async (argv) => {
156
170
  await cmdAuditLog({ tail: argv.tail, configDir: argv["config-dir"] });
171
+ })
172
+ .demandCommand(1, "Specify an audit subcommand (run 'agentnet audit --help')"), () => {
173
+ // parent handler — never invoked because demandCommand above
157
174
  })
158
175
  // Proxy commands — nested under a single `proxy` command so yargs
159
176
  // builds a proper subcommand tree. Without nesting, the dotted form
@@ -212,8 +212,15 @@ export class DaemonServer {
212
212
  this.logger.info(`Reconfiguring TUN: dora-allocated IP changed ${currentIp} -> ${newIp}`);
213
213
  const ifname = this.tunDevice.getInterface();
214
214
  try {
215
- await this.routeManager.cleanup(ifname, this.config.network.subnet, currentIp);
215
+ // ORDER MATTERS: close TunDevice FIRST so the helper
216
+ // subprocess fully exits and releases agentnet0.
217
+ // routeManager.cleanup's `ip tuntap del` would otherwise
218
+ // fail with EBUSY and the subsequent helper spawn would
219
+ // race the kernel for the device name, exit code=1, and
220
+ // leave the daemon TUN-less. tunDevice.close() now waits
221
+ // for actual process exit.
216
222
  await this.tunDevice.close();
223
+ await this.routeManager.cleanup(ifname, this.config.network.subnet, currentIp);
217
224
  this.tunDevice = new TunDevice({
218
225
  config: {
219
226
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",