@decentnetwork/lan 0.1.52 → 0.1.54

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.
@@ -269,6 +269,16 @@ export declare function cmdDnsHosts(args: {
269
269
  export declare function cmdDiag(args: {
270
270
  configDir?: string;
271
271
  }): Promise<void>;
272
+ /**
273
+ * `agentnet doctor` — a self-service health check. Walks the common
274
+ * failure points we keep hitting (config in the wrong place under sudo,
275
+ * daemon down, no dora, peers stuck "connecting" because the other end
276
+ * runs old code) and prints the exact fix for each, so a user can debug
277
+ * without reading source or asking anyone.
278
+ */
279
+ export declare function cmdDoctor(args: {
280
+ configDir?: string;
281
+ }): Promise<void>;
272
282
  /**
273
283
  * Enable dora integration. Accepts either `--address` (preferred — the
274
284
  * full Carrier address of the dora server; we derive its userid and
@@ -1267,6 +1267,86 @@ export async function cmdDiag(args) {
1267
1267
  }
1268
1268
  console.log(JSON.stringify(res.data, null, 2));
1269
1269
  }
1270
+ /**
1271
+ * `agentnet doctor` — a self-service health check. Walks the common
1272
+ * failure points we keep hitting (config in the wrong place under sudo,
1273
+ * daemon down, no dora, peers stuck "connecting" because the other end
1274
+ * runs old code) and prints the exact fix for each, so a user can debug
1275
+ * without reading source or asking anyone.
1276
+ */
1277
+ export async function cmdDoctor(args) {
1278
+ const ok = (s) => console.log(` ✓ ${s}`);
1279
+ const bad = (s, fix) => {
1280
+ console.log(` ✗ ${s}`);
1281
+ if (fix)
1282
+ console.log(` → ${fix}`);
1283
+ };
1284
+ const info = (s) => console.log(` · ${s}`);
1285
+ console.log("agentnet doctor\n");
1286
+ // 1. Resolve config dir, working around the sudo HOME trap: under
1287
+ // `sudo` ~ resolves to /root, but the config usually lives in the
1288
+ // invoking user's home — the #1 "Not initialized" confusion.
1289
+ let dir = args.configDir || ConfigLoader.defaultConfigDir();
1290
+ let configPath = resolve(dir, "config.yaml");
1291
+ if (!existsSync(configPath) && process.env.SUDO_USER) {
1292
+ const altDir = resolve(`/home/${process.env.SUDO_USER}`, ".agentnet");
1293
+ if (existsSync(resolve(altDir, "config.yaml"))) {
1294
+ info(`Config not in ${dir} (running under sudo); using ${altDir}`);
1295
+ dir = altDir;
1296
+ configPath = resolve(altDir, "config.yaml");
1297
+ }
1298
+ }
1299
+ if (!existsSync(configPath)) {
1300
+ bad(`No config at ${configPath}`, "run: agentnet init");
1301
+ return;
1302
+ }
1303
+ ok(`Config: ${configPath}`);
1304
+ const config = await ConfigLoader.load(configPath);
1305
+ // 2. Daemon running?
1306
+ const pid = daemonPid(config);
1307
+ if (pid === null) {
1308
+ bad("Daemon is not running", `start it: sudo agentnet up --real-tun --config-dir ${dir}`);
1309
+ info("Logs go to /var/log/agentnet.log (NOT journalctl) when run as a service.");
1310
+ return;
1311
+ }
1312
+ ok(`Daemon running (pid ${pid})`);
1313
+ const res = await ipcCall(config, { op: "diag" });
1314
+ if (!res.ok) {
1315
+ bad(`Daemon not responding over IPC: ${res.error}`, "restart it");
1316
+ return;
1317
+ }
1318
+ const d = res.data;
1319
+ if (d.identity?.address)
1320
+ ok(`Identity: ${d.identity.address}`);
1321
+ const ip = d.tun?.ip || d.allocatedIp;
1322
+ if (ip)
1323
+ ok(`Virtual IP: ${ip}`);
1324
+ // 3. Dora connectivity — needed for auto-discovery of peers.
1325
+ const friends = d.friends ?? [];
1326
+ const ipamMap = new Map((d.ipam ?? []).map((p) => [p.carrierId ?? "", p]));
1327
+ const doraIds = new Set(DEFAULT_DORAS.map((x) => x.userid));
1328
+ const idOf = (f) => f.carrierId || f.pubkey || "";
1329
+ const nameOf = (f) => {
1330
+ const real = f.name && f.name !== "@decentnetwork/peer" ? f.name : undefined;
1331
+ const ix = ipamMap.get(idOf(f));
1332
+ const ipPart = ix?.virtualIp ? ` ${ix.virtualIp}` : "";
1333
+ return `${real || ix?.name || idOf(f).slice(0, 8) + "…"}${ipPart}`;
1334
+ };
1335
+ const doraOnline = friends.filter((f) => doraIds.has(idOf(f)) && f.status === "online");
1336
+ if (doraOnline.length > 0)
1337
+ ok(`Dora registry connected (${doraOnline.length} online) — peer auto-discovery active`);
1338
+ else
1339
+ bad("No dora registry online — can't auto-discover peers", "give it ~60s after start; check internet / that a dora is up");
1340
+ // 4. Friends — who's connected vs stuck connecting (and why).
1341
+ const online = friends.filter((f) => f.status === "online");
1342
+ const connecting = friends.filter((f) => f.status !== "online");
1343
+ info(`Peers: ${online.length} connected, ${connecting.length} connecting`);
1344
+ for (const f of online)
1345
+ ok(`${nameOf(f)} — connected`);
1346
+ for (const f of connecting)
1347
+ info(`${nameOf(f)} — connecting (other end offline, or upgraded-but-not-restarted: restart its daemon)`);
1348
+ console.log("\n Watch live: sudo tail -f /var/log/agentnet.log (\"Peers:\" line every 30s)");
1349
+ }
1270
1350
  /**
1271
1351
  * Enable dora integration. Accepts either `--address` (preferred — the
1272
1352
  * full Carrier address of the dora server; we derive its userid and
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, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, } 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, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -85,6 +85,9 @@ async function main() {
85
85
  })
86
86
  .command("diag", "Query the running daemon over IPC for a JSON snapshot of its runtime state (stats, friends, IPAM)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
87
87
  await cmdDiag({ configDir: argv["config-dir"] });
88
+ })
89
+ .command("doctor", "Self-check: diagnoses config/daemon/dora/peer problems and prints the fix for each", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
90
+ await cmdDoctor({ configDir: argv["config-dir"] });
88
91
  })
89
92
  .command("dns", "Manage OS-side DNS resolver wiring for the .decent zone", (y) => y
90
93
  .command("install", "Route .decent queries to the local daemon (writes /etc/resolver on macOS or resolvectl on Linux)", (yy) => yy
@@ -41,9 +41,17 @@ export declare class DoraIntegration {
41
41
  private retryBootstrapTimer?;
42
42
  /** The IP dora handed us. Falls back to preferredIp on registry failure. */
43
43
  private allocatedIp;
44
- /** Userids we've already attempted to friend this session keeps the
45
- * 60s roster refresh from spamming sendFriendRequest. */
46
- private friendRequested;
44
+ /** Userid -> last time (ms) we sent an auto-friend request. Keeps the
45
+ * 60s roster refresh from spamming sendFriendRequest, but — unlike a
46
+ * plain "sent once" Set — lets us RE-send periodically to a peer that's
47
+ * still not connected. A single missed delivery (express hiccup, or the
48
+ * peer's daemon was offline/running old code when we first sent) used to
49
+ * strand the friendship until a client restart; re-asserting every few
50
+ * minutes self-heals it the moment the other end comes back. */
51
+ private friendRequestedAt;
52
+ /** Re-send an auto-friend request to a still-unconnected roster peer at
53
+ * most this often. */
54
+ private static readonly AUTOFRIEND_RESEND_MS;
47
55
  /** Set once we've successfully registered. The retry-bootstrap loop
48
56
  * uses this to know when to stop trying. */
49
57
  private registered;
@@ -21,9 +21,17 @@ export class DoraIntegration {
21
21
  retryBootstrapTimer;
22
22
  /** The IP dora handed us. Falls back to preferredIp on registry failure. */
23
23
  allocatedIp;
24
- /** Userids we've already attempted to friend this session keeps the
25
- * 60s roster refresh from spamming sendFriendRequest. */
26
- friendRequested = new Set();
24
+ /** Userid -> last time (ms) we sent an auto-friend request. Keeps the
25
+ * 60s roster refresh from spamming sendFriendRequest, but — unlike a
26
+ * plain "sent once" Set — lets us RE-send periodically to a peer that's
27
+ * still not connected. A single missed delivery (express hiccup, or the
28
+ * peer's daemon was offline/running old code when we first sent) used to
29
+ * strand the friendship until a client restart; re-asserting every few
30
+ * minutes self-heals it the moment the other end comes back. */
31
+ friendRequestedAt = new Map();
32
+ /** Re-send an auto-friend request to a still-unconnected roster peer at
33
+ * most this often. */
34
+ static AUTOFRIEND_RESEND_MS = 5 * 60_000;
27
35
  /** Set once we've successfully registered. The retry-bootstrap loop
28
36
  * uses this to know when to stop trying. */
29
37
  registered = false;
@@ -362,10 +370,17 @@ export class DoraIntegration {
362
370
  * re-register the peer or fall back to a manual friend-request.
363
371
  */
364
372
  maybeFriend(entry) {
365
- if (this.friendRequested.has(entry.userid))
366
- return;
373
+ // Already connected → nothing to do (and clear any resend timer).
367
374
  if (this.opts.peerManager.isFriend(entry.userid)) {
368
- this.friendRequested.add(entry.userid);
375
+ this.friendRequestedAt.set(entry.userid, Date.now());
376
+ return;
377
+ }
378
+ // Not connected yet: send the first time, then RE-send at most every
379
+ // AUTOFRIEND_RESEND_MS. This is the self-heal — a peer that was offline
380
+ // or running old code when we first sent gets re-asserted once it's back,
381
+ // instead of staying stranded until a client restart.
382
+ const lastSent = this.friendRequestedAt.get(entry.userid);
383
+ if (lastSent !== undefined && Date.now() - lastSent < DoraIntegration.AUTOFRIEND_RESEND_MS) {
369
384
  return;
370
385
  }
371
386
  // Policy gate. The undefined default is "all" because a peer
@@ -389,19 +404,22 @@ export class DoraIntegration {
389
404
  if (!entry.address) {
390
405
  this.logger.warn(`Roster entry ${entry.name} (${entry.userid.slice(0, 12)}...) has no address — can't auto-friend. ` +
391
406
  `Have them re-register against a newer dora server, or run 'agentnet friend-request' manually.`);
392
- this.friendRequested.add(entry.userid); // don't keep warning every 60s
407
+ // Re-warn at most once per resend window, not every 60s.
408
+ this.friendRequestedAt.set(entry.userid, Date.now());
393
409
  return;
394
410
  }
395
- this.friendRequested.add(entry.userid);
411
+ const isResend = lastSent !== undefined;
412
+ this.friendRequestedAt.set(entry.userid, Date.now());
396
413
  this.opts.peerManager
397
414
  .sendFriendRequest(entry.address, `dora roster auto-friend (${this.opts.nodeName})`)
398
415
  .then(() => {
399
- this.logger.info(`Auto-friend sent to ${entry.name} (${entry.userid.slice(0, 12)}...)`);
416
+ this.logger.info(`Auto-friend ${isResend ? "re-sent" : "sent"} to ${entry.name} (${entry.userid.slice(0, 12)}...)`);
400
417
  })
401
418
  .catch((err) => {
402
419
  this.logger.warn(`Auto-friend ${entry.name}: ${err instanceof Error ? err.message : err}`);
403
- // Allow another attempt on the next refresh cycle.
404
- this.friendRequested.delete(entry.userid);
420
+ // Clear the timestamp so the next refresh cycle retries immediately
421
+ // rather than waiting out the resend window.
422
+ this.friendRequestedAt.delete(entry.userid);
405
423
  });
406
424
  }
407
425
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
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",