@decentnetwork/lan 0.1.0

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.
Files changed (79) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +296 -0
  3. package/bin/tun-helper-darwin-amd64 +0 -0
  4. package/bin/tun-helper-darwin-arm64 +0 -0
  5. package/bin/tun-helper-linux-amd64 +0 -0
  6. package/bin/tun-helper-linux-arm64 +0 -0
  7. package/dist/acl/acl-engine.d.ts +43 -0
  8. package/dist/acl/acl-engine.js +189 -0
  9. package/dist/acl/audit.d.ts +70 -0
  10. package/dist/acl/audit.js +144 -0
  11. package/dist/acl/index.d.ts +4 -0
  12. package/dist/acl/index.js +3 -0
  13. package/dist/acl/policy.d.ts +31 -0
  14. package/dist/acl/policy.js +102 -0
  15. package/dist/acl/types.d.ts +18 -0
  16. package/dist/acl/types.js +4 -0
  17. package/dist/carrier/frame.d.ts +18 -0
  18. package/dist/carrier/frame.js +66 -0
  19. package/dist/carrier/index.d.ts +5 -0
  20. package/dist/carrier/index.js +4 -0
  21. package/dist/carrier/packet-session.d.ts +32 -0
  22. package/dist/carrier/packet-session.js +151 -0
  23. package/dist/carrier/peer-manager.d.ts +113 -0
  24. package/dist/carrier/peer-manager.js +392 -0
  25. package/dist/carrier/types.d.ts +10 -0
  26. package/dist/carrier/types.js +11 -0
  27. package/dist/cli/commands.d.ts +223 -0
  28. package/dist/cli/commands.js +932 -0
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +196 -0
  31. package/dist/config/loader.d.ts +10 -0
  32. package/dist/config/loader.js +152 -0
  33. package/dist/daemon/index.d.ts +1 -0
  34. package/dist/daemon/index.js +1 -0
  35. package/dist/daemon/ipc.d.ts +60 -0
  36. package/dist/daemon/ipc.js +144 -0
  37. package/dist/daemon/server.d.ts +63 -0
  38. package/dist/daemon/server.js +510 -0
  39. package/dist/dns/index.d.ts +1 -0
  40. package/dist/dns/index.js +1 -0
  41. package/dist/dns/resolver.d.ts +44 -0
  42. package/dist/dns/resolver.js +82 -0
  43. package/dist/dns/server.d.ts +70 -0
  44. package/dist/dns/server.js +393 -0
  45. package/dist/dora/dora-integration.d.ts +90 -0
  46. package/dist/dora/dora-integration.js +325 -0
  47. package/dist/index.d.ts +13 -0
  48. package/dist/index.js +15 -0
  49. package/dist/ipam/index.d.ts +1 -0
  50. package/dist/ipam/index.js +1 -0
  51. package/dist/ipam/ipam.d.ts +99 -0
  52. package/dist/ipam/ipam.js +254 -0
  53. package/dist/proxy/connect-proxy.d.ts +78 -0
  54. package/dist/proxy/connect-proxy.js +204 -0
  55. package/dist/router/index.d.ts +5 -0
  56. package/dist/router/index.js +4 -0
  57. package/dist/router/ip-parser.d.ts +36 -0
  58. package/dist/router/ip-parser.js +127 -0
  59. package/dist/router/packet-router.d.ts +49 -0
  60. package/dist/router/packet-router.js +251 -0
  61. package/dist/router/session-manager.d.ts +50 -0
  62. package/dist/router/session-manager.js +138 -0
  63. package/dist/router/types.d.ts +21 -0
  64. package/dist/router/types.js +6 -0
  65. package/dist/tun/index.d.ts +3 -0
  66. package/dist/tun/index.js +2 -0
  67. package/dist/tun/route-manager.d.ts +59 -0
  68. package/dist/tun/route-manager.js +353 -0
  69. package/dist/tun/tun-device.d.ts +45 -0
  70. package/dist/tun/tun-device.js +265 -0
  71. package/dist/tun/types.d.ts +28 -0
  72. package/dist/tun/types.js +4 -0
  73. package/dist/types.d.ts +176 -0
  74. package/dist/types.js +4 -0
  75. package/dist/utils/logger.d.ts +20 -0
  76. package/dist/utils/logger.js +43 -0
  77. package/docs/CONFIGURATION.md +197 -0
  78. package/docs/INSTALL.md +145 -0
  79. package/package.json +93 -0
@@ -0,0 +1,932 @@
1
+ /**
2
+ * CLI command handlers
3
+ */
4
+ import { resolve, dirname } from "path";
5
+ import { existsSync, mkdirSync, readFileSync } from "fs";
6
+ import { createConnection } from "net";
7
+ import { ConfigLoader } from "../config/loader.js";
8
+ import { ipcSocketPath } from "../daemon/ipc.js";
9
+ import { DaemonServer } from "../daemon/server.js";
10
+ import { Ipam } from "../ipam/ipam.js";
11
+ import { Policy } from "../acl/policy.js";
12
+ import { AclEngine } from "../acl/acl-engine.js";
13
+ import { AuditLog } from "../acl/audit.js";
14
+ import { DnsResolver } from "../dns/resolver.js";
15
+ /**
16
+ * Refuse to open a second Carrier peer with this identity if the
17
+ * daemon is already running with the same keypair. Two peers sharing
18
+ * a keyfile race on session establishment, scramble each other's
19
+ * cookie/handshake state, and produce a stream of "Replacing stale
20
+ * session" log lines — the equivalent of two processes binding the
21
+ * same TCP port. Daemon-side commands (friend-request, friend-accept,
22
+ * friends-list) all must call this before importing the SDK.
23
+ */
24
+ /** Returns the live daemon's PID if one is running for this config,
25
+ * or null when the pidfile is missing/stale. Used by both the
26
+ * "refuse second peer" guard and the IPC-routing path. */
27
+ function daemonPid(config) {
28
+ const pidFile = resolve(config.carrier.dataDir, "daemon.pid");
29
+ if (!existsSync(pidFile))
30
+ return null;
31
+ let pid;
32
+ try {
33
+ pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ if (!pid || pid === process.pid)
39
+ return null;
40
+ // process.kill(pid, 0) throws with .code === "ESRCH" if the process
41
+ // is gone, OR "EPERM" if it's alive but owned by another user
42
+ // (e.g. daemon started via sudo, CLI run as the operator's user).
43
+ // EPERM means ALIVE — the earlier version caught both ESRCH and
44
+ // EPERM as "stale", which let CLI commands sneak past the guard
45
+ // and open a second Carrier peer with the same identity.
46
+ try {
47
+ process.kill(pid, 0);
48
+ return pid;
49
+ }
50
+ catch (err) {
51
+ const code = err.code;
52
+ if (code === "EPERM")
53
+ return pid;
54
+ return null;
55
+ }
56
+ }
57
+ function assertDaemonNotRunning(config, commandName) {
58
+ const pid = daemonPid(config);
59
+ if (pid === null)
60
+ return;
61
+ throw new Error(`'agentnet ${commandName}' would open a second Carrier peer using the same identity ` +
62
+ `as the running daemon (pid ${pid}). That stomps on the daemon's Carrier session. ` +
63
+ `Stop the daemon first, or use the IPC-routed equivalent (e.g. 'agentnet friend-request' ` +
64
+ `now talks to the running daemon when it's up).`);
65
+ }
66
+ /**
67
+ * Send a single IPC request to the running daemon and await its
68
+ * response. Throws with the daemon's error message on failure, or
69
+ * if the socket isn't there / hangs up.
70
+ */
71
+ async function ipcCall(config, req, timeoutMs = 30_000) {
72
+ const sockPath = ipcSocketPath(config.carrier.dataDir);
73
+ if (!existsSync(sockPath)) {
74
+ throw new Error(`Daemon socket not found at ${sockPath} — is the daemon running?`);
75
+ }
76
+ return new Promise((resolve, reject) => {
77
+ const sock = createConnection(sockPath);
78
+ let buf = "";
79
+ const timer = setTimeout(() => {
80
+ sock.destroy();
81
+ reject(new Error(`IPC timed out after ${timeoutMs}ms`));
82
+ }, timeoutMs);
83
+ sock.on("connect", () => {
84
+ sock.write(JSON.stringify(req) + "\n");
85
+ });
86
+ sock.on("data", (chunk) => {
87
+ buf += chunk.toString("utf-8");
88
+ const nl = buf.indexOf("\n");
89
+ if (nl < 0)
90
+ return;
91
+ clearTimeout(timer);
92
+ sock.end();
93
+ try {
94
+ const res = JSON.parse(buf.slice(0, nl));
95
+ resolve(res);
96
+ }
97
+ catch (err) {
98
+ reject(new Error(`malformed IPC response: ${err.message}`));
99
+ }
100
+ });
101
+ sock.on("error", (err) => {
102
+ clearTimeout(timer);
103
+ reject(err);
104
+ });
105
+ });
106
+ }
107
+ /**
108
+ * Initialize ~/.agentnet directory and config
109
+ */
110
+ export async function cmdInit(args) {
111
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
112
+ const nodeName = args.name || `node-${Math.floor(Math.random() * 10000)}`;
113
+ if (!existsSync(dir)) {
114
+ mkdirSync(dir, { recursive: true });
115
+ console.log(`Created config directory: ${dir}`);
116
+ }
117
+ const configPath = resolve(dir, "config.yaml");
118
+ if (existsSync(configPath)) {
119
+ console.log(`Config already exists: ${configPath}`);
120
+ return;
121
+ }
122
+ const config = ConfigLoader.createDefault(nodeName, dir);
123
+ await ConfigLoader.save(config, configPath);
124
+ console.log(`Created config: ${configPath}`);
125
+ // Create empty IPAM and policy files
126
+ await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
127
+ await Policy.loadOrCreate(config.paths.policyFile);
128
+ console.log(`\nDecent AgentNet initialized:`);
129
+ console.log(` Node name: ${nodeName}`);
130
+ console.log(` Config dir: ${dir}`);
131
+ console.log(` Subnet: ${config.network.subnet}`);
132
+ console.log(` Interface: ${config.network.interface}`);
133
+ console.log(`\nNext: agentnet up --name ${nodeName}`);
134
+ }
135
+ /**
136
+ * Show identity information.
137
+ * Loads (or creates) keypair without joining network — fast and offline.
138
+ */
139
+ export async function cmdIdentityShow(args) {
140
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
141
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
142
+ const { Peer } = await import("@decentnetwork/peer");
143
+ const keyFile = resolve(config.carrier.dataDir, "keypair.json");
144
+ // Ensure carrier data dir exists (Peer.create won't create parent dirs)
145
+ mkdirSync(dirname(keyFile), { recursive: true });
146
+ const wasExisting = existsSync(keyFile);
147
+ const peer = await Peer.create({
148
+ keyFile,
149
+ compatibilityMode: "legacy",
150
+ bootstrapNodes: config.carrier.bootstrapNodes,
151
+ expressNodes: config.carrier.expressNodes,
152
+ });
153
+ await peer.start();
154
+ if (!wasExisting) {
155
+ console.log(`(generated new identity at ${keyFile})`);
156
+ }
157
+ console.log(`Address: ${peer.address()}`);
158
+ console.log(`Public Key: ${peer.pubkey()}`);
159
+ console.log(`User ID: ${peer.userid()}`);
160
+ await peer.stop();
161
+ }
162
+ /**
163
+ * List peers from IPAM
164
+ */
165
+ export async function cmdPeersList(args) {
166
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
167
+ 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();
170
+ if (peers.length === 0) {
171
+ console.log("No peers configured. Use 'agentnet ipam assign' to add peers.");
172
+ return;
173
+ }
174
+ console.log(`Peers (${peers.length}):`);
175
+ console.log("");
176
+ for (const peer of peers) {
177
+ const expires = peer.expiresAt
178
+ ? ` (expires ${new Date(peer.expiresAt).toISOString()})`
179
+ : "";
180
+ console.log(` ${peer.name}.${config.network.dnsDomain}`);
181
+ console.log(` Virtual IP: ${peer.virtualIp}`);
182
+ 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
+ console.log("");
189
+ }
190
+ }
191
+ /**
192
+ * Assign a virtual IP to a Carrier ID
193
+ */
194
+ export async function cmdIpamAssign(args) {
195
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
196
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
197
+ const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
198
+ // Auto-allocate IP if not provided
199
+ const virtualIp = args.ip || ipam.findNextAvailableIp(config.network.subnet);
200
+ // Parse services (format: "name:proto:port", e.g. "ssh:tcp:22")
201
+ const services = args.services?.map((s) => {
202
+ const parts = s.split(":");
203
+ return {
204
+ name: parts[0],
205
+ proto: (parts[1] || "tcp"),
206
+ port: parseInt(parts[2] || "0", 10),
207
+ };
208
+ }) || [];
209
+ ipam.assignPeer({
210
+ name: args.name,
211
+ carrierId: args.peer,
212
+ virtualIp,
213
+ services,
214
+ });
215
+ await ipam.save();
216
+ console.log(`Assigned: ${args.name}.${config.network.dnsDomain} -> ${virtualIp}`);
217
+ console.log(`Carrier ID: ${args.peer}`);
218
+ if (services.length > 0) {
219
+ console.log(`Services: ${services.map((s) => `${s.name}:${s.port}`).join(", ")}`);
220
+ }
221
+ }
222
+ /**
223
+ * Grant access to a peer.
224
+ * Default direction is "both" so TCP return packets are allowed.
225
+ */
226
+ export async function cmdGrant(args) {
227
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
228
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
229
+ const policy = await Policy.loadOrCreate(config.paths.policyFile);
230
+ const auditLog = new AuditLog(config.paths.auditLog);
231
+ const engine = new AclEngine({ policy, auditLog });
232
+ const expiresMs = args.expires ? parseDuration(args.expires) : undefined;
233
+ const direction = args.direction || "both";
234
+ if (args.tcp && args.tcp.length > 0) {
235
+ engine.grant({
236
+ peer: args.peer,
237
+ ports: args.tcp,
238
+ proto: "tcp",
239
+ expiresMs,
240
+ purpose: args.purpose,
241
+ direction,
242
+ });
243
+ }
244
+ if (args.udp && args.udp.length > 0) {
245
+ engine.grant({
246
+ peer: args.peer,
247
+ ports: args.udp,
248
+ proto: "udp",
249
+ expiresMs,
250
+ purpose: args.purpose,
251
+ direction,
252
+ });
253
+ }
254
+ await policy.save();
255
+ const ports = [
256
+ ...(args.tcp?.map((p) => `tcp:${p}`) || []),
257
+ ...(args.udp?.map((p) => `udp:${p}`) || []),
258
+ ].join(", ");
259
+ console.log(`Granted ${args.peer} (${direction}): ${ports}${expiresMs ? ` (expires in ${args.expires})` : ""}`);
260
+ }
261
+ /**
262
+ * Revoke access from a peer
263
+ */
264
+ export async function cmdRevoke(args) {
265
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
266
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
267
+ const policy = await Policy.loadOrCreate(config.paths.policyFile);
268
+ const auditLog = new AuditLog(config.paths.auditLog);
269
+ const engine = new AclEngine({ policy, auditLog });
270
+ const removed = engine.revoke(args.peer);
271
+ await policy.save();
272
+ if (removed) {
273
+ console.log(`Revoked all access for ${args.peer}`);
274
+ }
275
+ else {
276
+ console.log(`No rules found for ${args.peer}`);
277
+ }
278
+ }
279
+ /**
280
+ * Resolve a hostname to a virtual IP
281
+ */
282
+ export async function cmdResolve(args) {
283
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
284
+ 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);
294
+ }
295
+ }
296
+ /**
297
+ * Show daemon status (must be run while daemon is up — placeholder for now)
298
+ */
299
+ export async function cmdStatus(args) {
300
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
301
+ const configPath = resolve(dir, "config.yaml");
302
+ if (!existsSync(configPath)) {
303
+ console.log("Not initialized. Run 'agentnet init' first.");
304
+ return;
305
+ }
306
+ const config = await ConfigLoader.load(configPath);
307
+ console.log(`Decent AgentNet`);
308
+ console.log(`Node: ${config.node.name}`);
309
+ console.log(`Namespace: ${config.node.namespace}`);
310
+ console.log(`Subnet: ${config.network.subnet}`);
311
+ console.log(`Interface: ${config.network.interface}`);
312
+ console.log(`Virtual IP: ${config.network.ip}`);
313
+ console.log(`Bootstrap: ${config.carrier.bootstrapNodes.join(", ")}`);
314
+ // Show IPAM peers count
315
+ const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
316
+ console.log(`Peers configured: ${ipam.getPeers().length}`);
317
+ // Show ACL rules count
318
+ const policy = await Policy.loadOrCreate(config.paths.policyFile);
319
+ console.log(`ACL rules: ${policy.getRules().length}`);
320
+ }
321
+ /**
322
+ * Start the daemon (foreground)
323
+ */
324
+ export async function cmdUp(args) {
325
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
326
+ const configPath = resolve(dir, "config.yaml");
327
+ if (!existsSync(configPath)) {
328
+ console.log("Not initialized. Run 'agentnet init' first.");
329
+ process.exit(1);
330
+ }
331
+ const config = await ConfigLoader.load(configPath);
332
+ if (args.name) {
333
+ config.node.name = args.name;
334
+ }
335
+ const daemon = new DaemonServer({
336
+ config,
337
+ configDir: dir,
338
+ useMockTun: !args.realTun,
339
+ });
340
+ // Graceful shutdown
341
+ const shutdown = async () => {
342
+ console.log("\nShutting down...");
343
+ await daemon.stop();
344
+ process.exit(0);
345
+ };
346
+ process.on("SIGINT", shutdown);
347
+ process.on("SIGTERM", shutdown);
348
+ await daemon.start();
349
+ const status = daemon.getStatus();
350
+ console.log(`Daemon started. Identity: ${status.identity?.address}`);
351
+ console.log(`Press Ctrl+C to stop.`);
352
+ // Keep alive
353
+ await new Promise(() => { });
354
+ }
355
+ /**
356
+ * Send a friend request to another peer's address.
357
+ * Run while daemon is DOWN — opens a temporary peer, sends request, exits.
358
+ * The request is delivered via Carrier express relay (HTTP store-and-forward)
359
+ * AND via onion routing if recipient is announced on the DHT.
360
+ */
361
+ export async function cmdFriendRequest(args) {
362
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
363
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
364
+ // If the daemon is running, route through IPC instead of spawning a
365
+ // second Carrier peer. The daemon's existing Peer instance sends the
366
+ // request — no session conflict, no second DHT announce, no race.
367
+ const livePid = daemonPid(config);
368
+ if (livePid !== null) {
369
+ console.log(`Daemon is running (pid ${livePid}); routing friend-request via IPC...`);
370
+ const res = await ipcCall(config, {
371
+ op: "friend-request",
372
+ address: args.address,
373
+ hello: args.hello,
374
+ });
375
+ if (!res.ok) {
376
+ throw new Error(`Daemon refused friend-request: ${res.error}`);
377
+ }
378
+ console.log(`Friend request sent via daemon. The recipient (if running decentlan with autoAccept) will accept automatically.`);
379
+ return;
380
+ }
381
+ // Daemon is down — fall through to the standalone-Peer path, which
382
+ // generates/loads the keypair, announces, sends, and exits.
383
+ const { Peer } = await import("@decentnetwork/peer");
384
+ const keyFile = resolve(config.carrier.dataDir, "keypair.json");
385
+ console.log(`Opening peer with identity at ${keyFile}...`);
386
+ const peer = await Peer.create({
387
+ keyFile,
388
+ compatibilityMode: "legacy",
389
+ bootstrapNodes: config.carrier.bootstrapNodes,
390
+ expressNodes: config.carrier.expressNodes,
391
+ });
392
+ await peer.start();
393
+ console.log(`My address: ${peer.address()}`);
394
+ console.log(`My pubkey: ${peer.pubkey()}`);
395
+ console.log(`Joining Carrier network...`);
396
+ const joinResult = await peer.joinNetwork();
397
+ console.log(`Joined via ${joinResult.respondingNode.host}:${joinResult.respondingNode.port}`);
398
+ // Brief self-announce so the recipient's reply (via onion) can reach us
399
+ console.log(`Announcing self (15s)...`);
400
+ await peer.announceSelf(15000).catch((err) => {
401
+ console.warn(`Self-announce failed: ${err.message}`);
402
+ });
403
+ console.log(`Sending friend request to ${args.address}...`);
404
+ await peer.sendFriendRequest(args.address, args.hello || "Decent AgentNet friend request");
405
+ const waitMs = args.waitMs ?? 30000;
406
+ console.log(`Waiting ${waitMs}ms for relay delivery...`);
407
+ await new Promise((r) => setTimeout(r, waitMs));
408
+ await peer.stop();
409
+ console.log(`Friend request sent. The recipient must run 'agentnet friend-accept --pubkey ${peer.pubkey()}'.`);
410
+ }
411
+ /**
412
+ * Accept a pending friend request.
413
+ * Run while daemon is DOWN — opens a temporary peer, accepts, exits.
414
+ *
415
+ * IMPORTANT: This must be running for the friend request to be delivered
416
+ * via onion routing. The peer must announce itself on the DHT first
417
+ * (this takes ~45s), then wait for the request to arrive.
418
+ */
419
+ export async function cmdFriendAccept(args) {
420
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
421
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
422
+ assertDaemonNotRunning(config, "friend-accept");
423
+ const { Peer } = await import("@decentnetwork/peer");
424
+ const keyFile = resolve(config.carrier.dataDir, "keypair.json");
425
+ console.log(`Opening peer with identity at ${keyFile}...`);
426
+ const peer = await Peer.create({
427
+ keyFile,
428
+ compatibilityMode: "legacy",
429
+ bootstrapNodes: config.carrier.bootstrapNodes,
430
+ expressNodes: config.carrier.expressNodes,
431
+ });
432
+ await peer.start();
433
+ console.log(`My address: ${peer.address()}`);
434
+ console.log(`My pubkey: ${peer.pubkey()}`);
435
+ const joinResult = await peer.joinNetwork();
436
+ console.log(`Joined via ${joinResult.respondingNode.host}:${joinResult.respondingNode.port}`);
437
+ // Critical: announce self so requests routed via onion can find us.
438
+ // Retry up to 3 rounds of 45s each, like the SDK demo script.
439
+ console.log(`Announcing self (this may take up to 2 minutes)...`);
440
+ let stored = [];
441
+ for (let round = 1; round <= 3 && stored.length === 0; round++) {
442
+ console.log(` announce round ${round}/3 (45s)...`);
443
+ stored = await peer.announceSelf(45000).catch(() => []);
444
+ console.log(` stored on ${stored.length} nodes`);
445
+ }
446
+ if (stored.length === 0) {
447
+ console.warn(`Self-announce got 0 storage nodes — request may still arrive via express relay`);
448
+ }
449
+ let pubkey = args.pubkey;
450
+ const wait = args.waitForRequest;
451
+ if (!pubkey || wait) {
452
+ const waitMs = args.waitMs ?? 120000;
453
+ console.log(`Waiting up to ${waitMs / 1000}s for incoming friend request...`);
454
+ try {
455
+ const request = await peer.waitForFriendRequest(waitMs);
456
+ pubkey = request.pubkey;
457
+ console.log(`Got request from ${pubkey} (name: ${request.name || "—"}): "${request.hello || ""}"`);
458
+ }
459
+ catch (err) {
460
+ if (!pubkey)
461
+ throw err;
462
+ console.warn(`No incoming request — will try acceptance with provided pubkey ${pubkey}`);
463
+ }
464
+ }
465
+ console.log(`Accepting friend request from ${pubkey}...`);
466
+ await peer.acceptFriendRequest(pubkey);
467
+ console.log(`Waiting 10s to flush state...`);
468
+ await new Promise((r) => setTimeout(r, 10000));
469
+ await peer.stop();
470
+ console.log(`Friend accepted: ${pubkey}`);
471
+ }
472
+ /**
473
+ * List friends in the friend store.
474
+ */
475
+ export async function cmdFriendsList(args) {
476
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
477
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
478
+ assertDaemonNotRunning(config, "friends list");
479
+ const { Peer } = await import("@decentnetwork/peer");
480
+ const keyFile = resolve(config.carrier.dataDir, "keypair.json");
481
+ if (!existsSync(keyFile)) {
482
+ console.log("No identity yet. Start the daemon at least once to generate one.");
483
+ return;
484
+ }
485
+ // Open peer and start (start() loads friends from disk)
486
+ const peer = await Peer.create({
487
+ keyFile,
488
+ compatibilityMode: "legacy",
489
+ bootstrapNodes: config.carrier.bootstrapNodes,
490
+ expressNodes: config.carrier.expressNodes,
491
+ });
492
+ await peer.start();
493
+ const friends = peer.friends();
494
+ if (friends.length === 0) {
495
+ console.log("No friends. Use 'agentnet friend-request --address <addr>' to add one.");
496
+ await peer.stop();
497
+ return;
498
+ }
499
+ console.log(`Friends (${friends.length}):`);
500
+ for (const friend of friends) {
501
+ console.log(``);
502
+ console.log(` ${friend.name || "(unnamed)"} status=${friend.status}`);
503
+ console.log(` pubkey: ${friend.pubkey}`);
504
+ if (friend.userid && friend.userid !== friend.pubkey) {
505
+ console.log(` userid: ${friend.userid}`);
506
+ }
507
+ if (friend.address)
508
+ console.log(` address: ${friend.address}`);
509
+ if (friend.acceptedAt)
510
+ console.log(` accepted: ${new Date(friend.acceptedAt).toISOString()}`);
511
+ }
512
+ await peer.stop();
513
+ }
514
+ /**
515
+ * Show audit log
516
+ */
517
+ export async function cmdAuditLog(args) {
518
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
519
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
520
+ const auditLog = new AuditLog(config.paths.auditLog);
521
+ const entries = auditLog.readRecent(args.tail || 50);
522
+ if (entries.length === 0) {
523
+ console.log("No audit entries.");
524
+ return;
525
+ }
526
+ for (const entry of entries) {
527
+ const time = new Date(entry.timestamp).toISOString();
528
+ const status = entry.allowed === true ? "ALLOW" : entry.allowed === false ? "DENY " : "INFO ";
529
+ const peer = entry.srcName || entry.srcPubkey?.slice(0, 16) || "—";
530
+ const dest = entry.dstIp ? `${entry.dstIp}:${entry.dstPort}/${entry.proto}` : "—";
531
+ const reason = entry.reason || "";
532
+ console.log(`${time} ${status} ${entry.type.padEnd(10)} ${peer.padEnd(20)} ${dest.padEnd(30)} ${reason}`);
533
+ }
534
+ }
535
+ /**
536
+ * Enable the built-in CONNECT proxy.
537
+ * Persists `proxy.enabled = true` in config.yaml. Takes effect on next
538
+ * `agentnet up`. If port is provided, updates the listener port.
539
+ */
540
+ export async function cmdProxyEnable(args) {
541
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
542
+ const configPath = resolve(dir, "config.yaml");
543
+ const config = await ConfigLoader.load(configPath);
544
+ config.proxy = {
545
+ enabled: true,
546
+ port: args.port ?? config.proxy?.port ?? 8888,
547
+ allowHosts: config.proxy?.allowHosts ?? [],
548
+ allowConnectPorts: config.proxy?.allowConnectPorts,
549
+ };
550
+ await ConfigLoader.save(config, configPath);
551
+ console.log(`Proxy enabled on port ${config.proxy.port}.`);
552
+ console.log(`Listener will bind to ${config.network.ip}:${config.proxy.port} on next 'agentnet up'.`);
553
+ if ((config.proxy.allowHosts ?? []).length === 0) {
554
+ console.log(`(No host allowlist set — any host will be accepted as a CONNECT target.)`);
555
+ console.log(`To restrict: agentnet proxy allow-host '*.binance.com'`);
556
+ }
557
+ }
558
+ /**
559
+ * Disable the built-in CONNECT proxy.
560
+ * Sets `proxy.enabled = false`. Existing tunnels are torn down on next
561
+ * daemon restart.
562
+ */
563
+ export async function cmdProxyDisable(args) {
564
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
565
+ const configPath = resolve(dir, "config.yaml");
566
+ const config = await ConfigLoader.load(configPath);
567
+ config.proxy = {
568
+ enabled: false,
569
+ port: config.proxy?.port ?? 8888,
570
+ allowHosts: config.proxy?.allowHosts ?? [],
571
+ allowConnectPorts: config.proxy?.allowConnectPorts,
572
+ };
573
+ await ConfigLoader.save(config, configPath);
574
+ console.log(`Proxy disabled. Restart the daemon to take effect.`);
575
+ }
576
+ /**
577
+ * Show proxy status from config.yaml.
578
+ */
579
+ export async function cmdProxyStatus(args) {
580
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
581
+ const configPath = resolve(dir, "config.yaml");
582
+ const config = await ConfigLoader.load(configPath);
583
+ const proxy = config.proxy;
584
+ if (!proxy || !proxy.enabled) {
585
+ console.log("Proxy: disabled");
586
+ console.log("Enable with: agentnet proxy enable");
587
+ return;
588
+ }
589
+ console.log("Proxy: enabled");
590
+ console.log(` listen: ${config.network.ip}:${proxy.port}`);
591
+ console.log(` protocol: HTTP CONNECT`);
592
+ const allowHosts = proxy.allowHosts ?? [];
593
+ if (allowHosts.length === 0) {
594
+ console.log(` allow_hosts: (any) — recommend running 'agentnet proxy allow-host'`);
595
+ }
596
+ else {
597
+ console.log(` allow_hosts:`);
598
+ for (const h of allowHosts)
599
+ console.log(` - ${h}`);
600
+ }
601
+ const allowPorts = proxy.allowConnectPorts ?? [443, 80];
602
+ console.log(` allow_ports: ${allowPorts.join(", ")}`);
603
+ console.log(``);
604
+ console.log(`Note: live counters require querying a running daemon — not yet wired.`);
605
+ }
606
+ /**
607
+ * Add a host glob to the proxy allowlist.
608
+ */
609
+ export async function cmdProxyAllowHost(args) {
610
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
611
+ const configPath = resolve(dir, "config.yaml");
612
+ const config = await ConfigLoader.load(configPath);
613
+ const proxy = config.proxy ?? { enabled: false, port: 8888 };
614
+ const allowHosts = new Set(proxy.allowHosts ?? []);
615
+ allowHosts.add(args.host);
616
+ config.proxy = { ...proxy, allowHosts: [...allowHosts] };
617
+ await ConfigLoader.save(config, configPath);
618
+ console.log(`Added '${args.host}' to proxy allow-hosts.`);
619
+ console.log(`Restart the daemon to take effect.`);
620
+ }
621
+ /**
622
+ * Remove a host glob from the proxy allowlist.
623
+ */
624
+ export async function cmdProxyRevokeHost(args) {
625
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
626
+ const configPath = resolve(dir, "config.yaml");
627
+ const config = await ConfigLoader.load(configPath);
628
+ const proxy = config.proxy ?? { enabled: false, port: 8888 };
629
+ const before = (proxy.allowHosts ?? []).length;
630
+ const filtered = (proxy.allowHosts ?? []).filter((h) => h !== args.host);
631
+ config.proxy = { ...proxy, allowHosts: filtered };
632
+ await ConfigLoader.save(config, configPath);
633
+ if (filtered.length < before) {
634
+ console.log(`Removed '${args.host}' from proxy allow-hosts.`);
635
+ }
636
+ else {
637
+ console.log(`No exact match for '${args.host}' in allow-hosts.`);
638
+ }
639
+ }
640
+ /**
641
+ * List proxy allow-host globs.
642
+ */
643
+ export async function cmdProxyListHosts(args) {
644
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
645
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
646
+ const allowHosts = config.proxy?.allowHosts ?? [];
647
+ if (allowHosts.length === 0) {
648
+ console.log("No host allowlist set. Proxy will accept any CONNECT target.");
649
+ return;
650
+ }
651
+ console.log(`Proxy allow-hosts (${allowHosts.length}):`);
652
+ for (const h of allowHosts)
653
+ console.log(` ${h}`);
654
+ }
655
+ /**
656
+ * Print the env-var line for a peer to use this node's proxy.
657
+ */
658
+ export async function cmdProxyUse(args) {
659
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
660
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
661
+ const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
662
+ const record = ipam.resolveCarrierId(args.peer) || ipam.getPeers().find((p) => p.name === args.peer);
663
+ if (!record) {
664
+ console.error(`Unknown peer: ${args.peer}. Add it first via 'agentnet ipam assign'.`);
665
+ process.exit(1);
666
+ }
667
+ const port = config.proxy?.port ?? 8888;
668
+ const proxyUrl = `http://${record.virtualIp}:${port}`;
669
+ console.log(`# Use these env vars to route HTTPS through ${record.name}:`);
670
+ console.log(`HTTPS_PROXY=${proxyUrl}`);
671
+ console.log(`HTTP_PROXY=${proxyUrl}`);
672
+ console.log(`NO_PROXY=localhost,127.0.0.1,*.local,10.0.0.0/8,172.16.0.0/12`);
673
+ console.log(``);
674
+ console.log(`# On the proxy node (${record.name}), make sure the operator has run:`);
675
+ console.log(`# agentnet proxy enable`);
676
+ console.log(`# agentnet grant --peer <your-userid> --tcp ${port}`);
677
+ }
678
+ /**
679
+ * Parse duration string like "1h", "24h", "30m"
680
+ */
681
+ function parseDuration(input) {
682
+ const match = input.match(/^(\d+)([smhd])$/);
683
+ if (!match) {
684
+ throw new Error(`Invalid duration: ${input}. Use format like 1h, 30m, 7d`);
685
+ }
686
+ const value = parseInt(match[1], 10);
687
+ const unit = match[2];
688
+ const multipliers = {
689
+ s: 1000,
690
+ m: 60 * 1000,
691
+ h: 60 * 60 * 1000,
692
+ d: 24 * 60 * 60 * 1000,
693
+ };
694
+ return value * multipliers[unit];
695
+ }
696
+ export { parseDuration };
697
+ /**
698
+ * Install (or remove) OS-side resolver config so `<name>.<dnsDomain>`
699
+ * queries get routed to the daemon's DNS server on
700
+ * 127.0.0.1:<dnsPort>. macOS uses /etc/resolver/<domain>; Linux uses
701
+ * systemd-resolved's per-link config via `resolvectl`. Idempotent;
702
+ * `--uninstall` reverses it.
703
+ *
704
+ * Requires root on both platforms (file write to /etc, or root-only
705
+ * resolvectl). The shipped error tells the operator if so.
706
+ */
707
+ export async function cmdDnsInstall(args) {
708
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
709
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
710
+ const domain = config.network.dnsDomain;
711
+ const port = config.network.dnsPort;
712
+ const iface = config.network.interface;
713
+ // The OS resolver runs locally and the daemon binds DNS on
714
+ // 0.0.0.0, so the right nameserver for /etc/resolver is just
715
+ // 127.0.0.1 — guaranteed to loopback through lo0 to the socket.
716
+ //
717
+ // Using the TUN IP (e.g. 10.86.1.11) seems intuitive but breaks:
718
+ // macOS routes the local TUN IP through utun10 itself, so the
719
+ // UDP query arrives at the packet-router as an IP datagram
720
+ // instead of hitting the DNS UDP socket. dig @127.0.0.1 works;
721
+ // dig @<tun-ip> from the same host doesn't.
722
+ //
723
+ // We still need the live bound PORT from diag because the
724
+ // daemon's port-fallback may have nudged us off the configured
725
+ // value (mDNSResponder / Bonjour hold :5353 on macOS).
726
+ const nameserverIp = "127.0.0.1";
727
+ let nameserverPort = port;
728
+ const pid = daemonPid(config);
729
+ if (pid !== null) {
730
+ try {
731
+ const res = await ipcCall(config, { op: "diag" });
732
+ if (res.ok) {
733
+ const data = res.data;
734
+ if (data.dns?.port)
735
+ nameserverPort = data.dns.port;
736
+ }
737
+ }
738
+ catch {
739
+ // diag failed — stick with config port
740
+ }
741
+ }
742
+ if (process.platform === "darwin") {
743
+ const file = `/etc/resolver/${domain}`;
744
+ if (args.uninstall) {
745
+ const { existsSync, unlinkSync } = await import("fs");
746
+ if (!existsSync(file)) {
747
+ console.log(`Nothing to remove — ${file} doesn't exist.`);
748
+ return;
749
+ }
750
+ try {
751
+ unlinkSync(file);
752
+ console.log(`Removed ${file}.`);
753
+ }
754
+ catch (err) {
755
+ const msg = err instanceof Error ? err.message : String(err);
756
+ throw new Error(`Could not remove ${file} (need root?): ${msg}`);
757
+ }
758
+ return;
759
+ }
760
+ const body = `# Routes *.${domain} queries to the decentlan daemon.\n# Generated by 'agentnet dns install'.\nnameserver ${nameserverIp}\nport ${nameserverPort}\n`;
761
+ try {
762
+ const fs = await import("fs/promises");
763
+ await fs.mkdir("/etc/resolver", { recursive: true });
764
+ await fs.writeFile(file, body, "utf-8");
765
+ console.log(`Wrote ${file}.`);
766
+ console.log(`Test with: dscacheutil -q host -a name <peer>.${domain}`);
767
+ console.log(`Or just: ping <peer>.${domain}`);
768
+ }
769
+ catch (err) {
770
+ const msg = err instanceof Error ? err.message : String(err);
771
+ throw new Error(`Could not write ${file} (need root? try 'sudo'): ${msg}`);
772
+ }
773
+ return;
774
+ }
775
+ if (process.platform === "linux") {
776
+ const { spawnSync } = await import("child_process");
777
+ if (args.uninstall) {
778
+ const r = spawnSync("resolvectl", ["revert", iface], { encoding: "utf-8" });
779
+ if (r.error || r.status !== 0) {
780
+ throw new Error(`resolvectl revert failed: ${r.stderr || r.error?.message || `exit ${r.status}`}. Try 'sudo'.`);
781
+ }
782
+ console.log(`Reverted resolver config on ${iface}.`);
783
+ return;
784
+ }
785
+ // The daemon binds DNS on the TUN IP (avoids the 5353 / mDNS
786
+ // collision). systemd-resolved sends DNS queries on UDP 53 and
787
+ // doesn't support a per-link port override, so we'd need a
788
+ // port-forward of some kind. For now we tell the operator the
789
+ // limitation and suggest dnsmasq or /etc/hosts as a stopgap.
790
+ if (nameserverPort !== 53) {
791
+ console.log("[linux warning] systemd-resolved only talks to upstreams on port 53;");
792
+ console.log(` the daemon is on ${nameserverIp}:${nameserverPort}. To bridge, either:`);
793
+ console.log(` 1) re-init with dnsPort: 53 (needs daemon to run as root)`);
794
+ console.log(` 2) install dnsmasq + the helper config printed below`);
795
+ console.log(` 3) use 'agentnet dns hosts | sudo tee -a /etc/hosts'`);
796
+ console.log(``);
797
+ console.log(`# dnsmasq snippet (write to /etc/dnsmasq.d/${domain}.conf):`);
798
+ console.log(`server=/${domain}/${nameserverIp}#${nameserverPort}`);
799
+ console.log(`# then: sudo systemctl restart dnsmasq`);
800
+ return;
801
+ }
802
+ // dnsPort === 53 path:
803
+ const set = spawnSync("resolvectl", ["dns", iface, nameserverIp, "domain", iface, `~${domain}`], { encoding: "utf-8" });
804
+ if (set.error || set.status !== 0) {
805
+ throw new Error(`resolvectl failed: ${set.stderr || set.error?.message || `exit ${set.status}`}. Try 'sudo'.`);
806
+ }
807
+ console.log(`Configured systemd-resolved on ${iface} for *.${domain} -> 127.0.0.1.`);
808
+ return;
809
+ }
810
+ throw new Error(`'dns install' not supported on ${process.platform}. Use /etc/hosts manually.`);
811
+ }
812
+ /**
813
+ * Print /etc/hosts entries for the current IPAM contents. Stopgap
814
+ * for OSes / setups where the resolver wiring is awkward — pipe to
815
+ * `sudo tee -a /etc/hosts`.
816
+ */
817
+ export async function cmdDnsHosts(args) {
818
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
819
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
820
+ const domain = config.network.dnsDomain;
821
+ // Use the running daemon if available so we get the live roster.
822
+ const pid = daemonPid(config);
823
+ if (pid !== null) {
824
+ const res = await ipcCall(config, { op: "diag" });
825
+ if (!res.ok)
826
+ throw new Error(`Daemon diag failed: ${res.error}`);
827
+ const data = res.data;
828
+ console.log(`# decentlan peers — append to /etc/hosts`);
829
+ for (const r of data.ipam) {
830
+ console.log(`${r.virtualIp}\t${r.name}\t${r.name}.${domain}`);
831
+ }
832
+ return;
833
+ }
834
+ // Daemon down — fall back to the on-disk IPAM (likely empty
835
+ // unless the operator runs with a static ipam.yaml fallback).
836
+ const { Ipam } = await import("../ipam/ipam.js");
837
+ const ipam = await Ipam.load(config.paths.ipamFile);
838
+ console.log(`# decentlan peers (from ${config.paths.ipamFile})`);
839
+ for (const r of ipam.getPeers()) {
840
+ console.log(`${r.virtualIp}\t${r.name}\t${r.name}.${domain}`);
841
+ }
842
+ }
843
+ /**
844
+ * Query the running daemon for a JSON-formatted snapshot of its state.
845
+ * Useful when packets aren't moving and you want to see forwarding
846
+ * counters, friend status, and IPAM contents without restarting the
847
+ * daemon with AGENTNET_LOG_LEVEL=debug.
848
+ */
849
+ export async function cmdDiag(args) {
850
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
851
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
852
+ const pid = daemonPid(config);
853
+ if (pid === null) {
854
+ console.error("No running daemon found for this config — start it with 'agentnet up'.");
855
+ process.exit(1);
856
+ }
857
+ const res = await ipcCall(config, { op: "diag" });
858
+ if (!res.ok) {
859
+ throw new Error(`Daemon diag failed: ${res.error}`);
860
+ }
861
+ console.log(JSON.stringify(res.data, null, 2));
862
+ }
863
+ /**
864
+ * Enable dora integration and add a dora server's userid to the
865
+ * registry list. Subsequent `agentnet up` runs will register with
866
+ * dora to get a virtual IP and fetch the peer roster automatically.
867
+ */
868
+ export async function cmdDoraEnable(args) {
869
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
870
+ const configPath = resolve(dir, "config.yaml");
871
+ const config = await ConfigLoader.load(configPath);
872
+ const existing = config.dora ?? { enabled: false, userids: [], refreshIntervalMs: 60_000 };
873
+ const userids = new Set(existing.userids ?? []);
874
+ userids.add(args.userid);
875
+ config.dora = {
876
+ enabled: true,
877
+ userids: [...userids],
878
+ refreshIntervalMs: existing.refreshIntervalMs ?? 60_000,
879
+ };
880
+ await ConfigLoader.save(config, configPath);
881
+ console.log(`Dora enabled. Server userid added: ${args.userid}`);
882
+ console.log(`Total dora servers configured: ${config.dora.userids?.length}`);
883
+ console.log(`The dora server must be a Carrier friend — add it with 'agentnet friend-request' if you haven't.`);
884
+ console.log(`Restart the daemon to take effect.`);
885
+ }
886
+ /**
887
+ * Disable dora integration without losing the configured server list.
888
+ * Daemon falls back to manual ipam.yaml mode.
889
+ */
890
+ export async function cmdDoraDisable(args) {
891
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
892
+ const configPath = resolve(dir, "config.yaml");
893
+ const config = await ConfigLoader.load(configPath);
894
+ config.dora = {
895
+ enabled: false,
896
+ userids: config.dora?.userids ?? [],
897
+ refreshIntervalMs: config.dora?.refreshIntervalMs ?? 60_000,
898
+ };
899
+ await ConfigLoader.save(config, configPath);
900
+ console.log(`Dora disabled. Restart the daemon to take effect.`);
901
+ console.log(`(Configured server userids preserved — re-enable with 'agentnet dora enable --userid <id>'.)`);
902
+ }
903
+ /**
904
+ * Show dora config. Live runtime state (currently-allocated IP,
905
+ * last roster refresh) would require IPC to the running daemon —
906
+ * not wired yet.
907
+ */
908
+ export async function cmdDoraStatus(args) {
909
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
910
+ const configPath = resolve(dir, "config.yaml");
911
+ const config = await ConfigLoader.load(configPath);
912
+ const dora = config.dora;
913
+ if (!dora || !dora.enabled) {
914
+ console.log("Dora: disabled");
915
+ console.log("Enable with: agentnet dora enable --userid <server-userid>");
916
+ return;
917
+ }
918
+ console.log("Dora: enabled");
919
+ const userids = dora.userids ?? [];
920
+ if (userids.length === 0) {
921
+ console.log(" servers: (none configured — add one with 'agentnet dora enable --userid <id>')");
922
+ }
923
+ else {
924
+ console.log(" servers:");
925
+ for (const id of userids)
926
+ console.log(` - ${id}`);
927
+ }
928
+ const refresh = dora.refreshIntervalMs ?? 60_000;
929
+ console.log(` refresh: every ${Math.round(refresh / 1000)}s`);
930
+ console.log("");
931
+ console.log("Note: live state (allocated IP, last refresh) requires querying a running daemon — not yet wired.");
932
+ }