@decentnetwork/lan 0.1.17 → 0.1.21

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
@@ -90,7 +90,7 @@ export declare function cmdFriendRequest(args: {
90
90
  * (this takes ~45s), then wait for the request to arrive.
91
91
  */
92
92
  export declare function cmdFriendAccept(args: {
93
- pubkey?: string;
93
+ userid?: string;
94
94
  waitForRequest?: boolean;
95
95
  waitMs?: number;
96
96
  configDir?: string;
@@ -460,6 +460,21 @@ export async function cmdFriendRequest(args) {
460
460
  export async function cmdFriendAccept(args) {
461
461
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
462
462
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
463
+ // When the daemon is up, route the accept through IPC — no need
464
+ // to bring the daemon down. This is what 'friends accept' does
465
+ // and 'friend-accept' is just an alias for that path now.
466
+ if (daemonPid(config) !== null) {
467
+ if (!args.userid) {
468
+ throw new Error("userid is required when the daemon is up. Use 'agentnet friends pending' to see queued requests, then 'agentnet friend-accept <userid>'.");
469
+ }
470
+ await cmdFriendsAccept({ userid: args.userid, configDir: args.configDir });
471
+ return;
472
+ }
473
+ // Daemon-down fallback: open a standalone peer. This path stays
474
+ // because it's the only way to accept a request when the daemon
475
+ // can't run (e.g. the operator hasn't set up the helper binary
476
+ // yet). The --wait interactive mode is also useful for one-shot
477
+ // bootstrapping.
463
478
  assertDaemonNotRunning(config, "friend-accept");
464
479
  const { Peer } = await import("@decentnetwork/peer");
465
480
  const keyFile = resolve(config.carrier.dataDir, "keypair.json");
@@ -487,7 +502,10 @@ export async function cmdFriendAccept(args) {
487
502
  if (stored.length === 0) {
488
503
  console.warn(`Self-announce got 0 storage nodes — request may still arrive via express relay`);
489
504
  }
490
- let pubkey = args.pubkey;
505
+ // The SDK uses `pubkey` as the local-var name historically; semantically
506
+ // this is the sender's userid (base58). Keep the variable name to
507
+ // minimize churn but document the mapping.
508
+ let pubkey = args.userid;
491
509
  const wait = args.waitForRequest;
492
510
  if (!pubkey || wait) {
493
511
  const waitMs = args.waitMs ?? 120000;
@@ -500,7 +518,7 @@ export async function cmdFriendAccept(args) {
500
518
  catch (err) {
501
519
  if (!pubkey)
502
520
  throw err;
503
- console.warn(`No incoming request — will try acceptance with provided pubkey ${pubkey}`);
521
+ console.warn(`No incoming request — will try acceptance with provided userid ${pubkey}`);
504
522
  }
505
523
  }
506
524
  console.log(`Accepting friend request from ${pubkey}...`);
@@ -769,7 +787,36 @@ export async function cmdProxyAllowHost(args) {
769
787
  config.proxy = { ...proxy, allowHosts: [...allowHosts] };
770
788
  await ConfigLoader.save(config, configPath);
771
789
  console.log(`Added '${args.host}' to proxy allow-hosts.`);
772
- console.log(`Restart the daemon to take effect.`);
790
+ await applyProxyReloadIfRunning(config);
791
+ }
792
+ /**
793
+ * If the daemon is up, push the freshly-saved proxy allowlist into the
794
+ * running proxy over IPC — no restart, no Carrier-session churn. Prints
795
+ * the outcome. When the daemon is down (or the proxy isn't running yet),
796
+ * tells the operator a restart is needed.
797
+ */
798
+ async function applyProxyReloadIfRunning(config) {
799
+ if (daemonPid(config) === null) {
800
+ console.log("Daemon is down — change takes effect on next 'agentnet up'.");
801
+ return;
802
+ }
803
+ try {
804
+ const res = await ipcCall(config, { op: "proxy-reload" });
805
+ if (!res.ok) {
806
+ console.log(`(live reload failed: ${res.error}) — restart the daemon to apply.`);
807
+ return;
808
+ }
809
+ const data = res.data;
810
+ if (data.applied) {
811
+ console.log(`Applied live to the running proxy — no restart needed.`);
812
+ }
813
+ else {
814
+ console.log(`Not applied live: ${data.reason ?? "proxy not running"}.`);
815
+ }
816
+ }
817
+ catch (err) {
818
+ console.log(`(live reload error: ${err instanceof Error ? err.message : err}) — restart to apply.`);
819
+ }
773
820
  }
774
821
  /**
775
822
  * Remove a host glob from the proxy allowlist.
@@ -785,6 +832,7 @@ export async function cmdProxyRevokeHost(args) {
785
832
  await ConfigLoader.save(config, configPath);
786
833
  if (filtered.length < before) {
787
834
  console.log(`Removed '${args.host}' from proxy allow-hosts.`);
835
+ await applyProxyReloadIfRunning(config);
788
836
  }
789
837
  else {
790
838
  console.log(`No exact match for '${args.host}' in allow-hosts.`);
package/dist/cli/index.js CHANGED
@@ -112,8 +112,8 @@ async function main() {
112
112
  configDir: argv["config-dir"],
113
113
  });
114
114
  })
115
- .command("friend-request", "Send a friend request (run while daemon is down)", (y) => y
116
- .option("address", { type: "string", demandOption: true, describe: "Recipient Carrier address" })
115
+ .command("friend-request <address>", "Send a friend request (routes through the running daemon when up; opens a standalone peer when down)", (y) => y
116
+ .positional("address", { type: "string", demandOption: true, describe: "Recipient Carrier address" })
117
117
  .option("hello", { type: "string", describe: "Greeting message" })
118
118
  .option("wait-ms", { type: "number", default: 8000, describe: "Wait time for relay delivery" })
119
119
  .option("config-dir", { type: "string" }), async (argv) => {
@@ -124,13 +124,27 @@ async function main() {
124
124
  configDir: argv["config-dir"],
125
125
  });
126
126
  })
127
- .command("friend-accept", "Accept a friend request (run while daemon is down)", (y) => y
128
- .option("pubkey", { type: "string", describe: "Sender pubkey (omit with --wait to receive interactively)" })
129
- .option("wait", { type: "boolean", default: false, describe: "Wait for incoming request" })
127
+ // `friend-accept` is the legacy daemon-down command. With autoAccept
128
+ // on by default (the standard config), this is rarely used but we
129
+ // keep it as an alias for `friends accept` so an operator running
130
+ // an old recipe doesn't dead-end.
131
+ //
132
+ // Positional userid is supported. The flag is named --userid (NOT
133
+ // --pubkey) because Carrier's identifier model is address + userid;
134
+ // "pubkey" only exists as a hex-encoded internal representation
135
+ // operators never need to touch.
136
+ .command("friend-accept [userid]", "Accept a queued friend-request by userid (alias of 'friends accept')", (y) => y
137
+ .positional("userid", { type: "string", describe: "Sender's Carrier userid (base58)" })
138
+ .option("userid", { type: "string", describe: "Sender's Carrier userid (base58)" })
139
+ // --wait is kept for the daemon-down interactive path
140
+ // (rarely used now; only matters when autoAccept is off
141
+ // AND the operator wants to run a one-shot accept without
142
+ // bringing the daemon up).
143
+ .option("wait", { type: "boolean", default: false, describe: "Daemon-down mode: wait for an incoming request" })
130
144
  .option("wait-ms", { type: "number", default: 120000, describe: "Time to wait for request (ms)" })
131
145
  .option("config-dir", { type: "string" }), async (argv) => {
132
146
  await cmdFriendAccept({
133
- pubkey: argv.pubkey,
147
+ userid: argv.userid,
134
148
  waitForRequest: argv.wait,
135
149
  waitMs: argv["wait-ms"],
136
150
  configDir: argv["config-dir"],
@@ -149,14 +163,24 @@ async function main() {
149
163
  .command("pending", "List queued friend-requests (over IPC; daemon must be up)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
150
164
  await cmdFriendsPending({ configDir: argv["config-dir"] });
151
165
  })
152
- .command("accept", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
153
- .option("userid", { type: "string", demandOption: true, describe: "Sender's Carrier userid (base58)" })
166
+ // Accept positional userid OR `--userid X`. Operators expect
167
+ // `agentnet friends accept <userid>` to just work without
168
+ // a positional that fails with "Unknown argument: <userid>"
169
+ // because yargs treats the unflagged value as junk.
170
+ .command("accept [userid]", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
171
+ .positional("userid", { type: "string", describe: "Sender's Carrier userid (base58)" })
172
+ .option("userid", { type: "string", describe: "Same as positional; either form works" })
154
173
  .option("config-dir", { type: "string" }), async (argv) => {
174
+ if (!argv.userid)
175
+ throw new Error("userid is required (positional or --userid)");
155
176
  await cmdFriendsAccept({ userid: argv.userid, configDir: argv["config-dir"] });
156
177
  })
157
- .command("reject", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
158
- .option("userid", { type: "string", demandOption: true })
178
+ .command("reject [userid]", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
179
+ .positional("userid", { type: "string", describe: "Sender's Carrier userid (base58)" })
180
+ .option("userid", { type: "string", describe: "Same as positional; either form works" })
159
181
  .option("config-dir", { type: "string" }), async (argv) => {
182
+ if (!argv.userid)
183
+ throw new Error("userid is required (positional or --userid)");
160
184
  await cmdFriendsReject({ userid: argv.userid, configDir: argv["config-dir"] });
161
185
  })
162
186
  .demandCommand(1, "Specify a friends subcommand (run 'agentnet friends --help')"), () => {
@@ -39,9 +39,15 @@ 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
+ /** Re-read proxy allowlist from config and apply it to the running
43
+ * proxy WITHOUT restarting the daemon. Lets `agentnet proxy
44
+ * allow-host` take effect instantly instead of forcing a daemon
45
+ * restart (which drops every Carrier session). Returns the applied
46
+ * allowlist for the CLI to echo. */
47
+ proxyReload: () => Promise<Record<string, unknown>>;
42
48
  }
43
49
  export interface IpcRequest {
44
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject";
50
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "proxy-reload";
45
51
  address?: string;
46
52
  hello?: string;
47
53
  userid?: string;
@@ -144,6 +144,8 @@ export class IpcServer {
144
144
  await this.handlers.friendsReject(req.userid);
145
145
  return;
146
146
  }
147
+ case "proxy-reload":
148
+ return await this.handlers.proxyReload();
147
149
  default:
148
150
  throw new Error(`unknown op: ${req.op}`);
149
151
  }
@@ -36,6 +36,7 @@ export declare class DaemonServer {
36
36
  private startedAt;
37
37
  private isRunning;
38
38
  private pidFile?;
39
+ private configDir;
39
40
  constructor(opts: DaemonOptions);
40
41
  start(): Promise<void>;
41
42
  stop(): Promise<void>;
@@ -18,6 +18,7 @@ import { DnsServer } from "../dns/server.js";
18
18
  import { IpcServer, ipcSocketPath } from "./ipc.js";
19
19
  import { PendingFriendsStore } from "./pending-friends.js";
20
20
  import { Logger } from "../utils/logger.js";
21
+ import { ConfigLoader } from "../config/loader.js";
21
22
  export class DaemonServer {
22
23
  config;
23
24
  useMockTun;
@@ -40,9 +41,13 @@ export class DaemonServer {
40
41
  startedAt = 0;
41
42
  isRunning = false;
42
43
  pidFile;
44
+ configDir;
43
45
  constructor(opts) {
44
46
  this.config = opts.config;
45
47
  this.useMockTun = opts.useMockTun ?? true; // Default to mock; override for production
48
+ // Remember where config.yaml lives so live-reload ops (e.g.
49
+ // proxy-reload) can re-read it without a daemon restart.
50
+ this.configDir = opts.configDir ?? ConfigLoader.defaultConfigDir();
46
51
  this.logger = new Logger({ prefix: "Daemon" });
47
52
  }
48
53
  async start() {
@@ -145,6 +150,31 @@ export class DaemonServer {
145
150
  throw new Error(`No pending friend-request for userid ${userid}`);
146
151
  // No-op at the Carrier level — sender just sees no acceptance.
147
152
  },
153
+ proxyReload: async () => {
154
+ // Re-read the proxy allowlist from disk and push it into the
155
+ // running proxy without a daemon restart (which would drop
156
+ // every Carrier session). If the proxy isn't running yet,
157
+ // tell the caller so it can fall back to "restart needed".
158
+ const fresh = await ConfigLoader.load(resolve(this.configDir, "config.yaml"));
159
+ const allowHosts = fresh.proxy?.allowHosts ?? [];
160
+ const allowConnectPorts = fresh.proxy?.allowConnectPorts;
161
+ if (!this.connectProxy) {
162
+ return {
163
+ applied: false,
164
+ reason: fresh.proxy?.enabled
165
+ ? "proxy enabled in config but not running — restart the daemon once to start the listener"
166
+ : "proxy is disabled — run 'agentnet proxy enable' then restart once",
167
+ };
168
+ }
169
+ this.connectProxy.updateAllowlist(allowHosts, allowConnectPorts);
170
+ // Keep the in-memory config in sync so a later diag reflects it.
171
+ if (this.config.proxy) {
172
+ this.config.proxy.allowHosts = allowHosts;
173
+ if (allowConnectPorts)
174
+ this.config.proxy.allowConnectPorts = allowConnectPorts;
175
+ }
176
+ return { applied: true, allowHosts };
177
+ },
148
178
  diag: async () => {
149
179
  // Snapshot of everything an operator needs to debug why
150
180
  // packets aren't moving: forwarding counters, friend
@@ -413,26 +443,43 @@ export class DaemonServer {
413
443
  // explicitly enabled it via `agentnet proxy enable`. Binds to the
414
444
  // virtual IP so reach is gated by the existing per-peer ACL — we
415
445
  // don't add a second auth layer at the HTTP level.
446
+ //
447
+ // Bind to `tunIp` (the dora-ALLOCATED IP), NOT config.network.ip.
448
+ // They diverge when dora hands out an address different from the
449
+ // init default: the listener would try to bind config's
450
+ // 10.86.1.10 while the TUN is actually at 10.86.1.15, and
451
+ // Node throws EADDRNOTAVAIL because that address isn't local.
452
+ //
453
+ // Also: a proxy bind failure must NOT take down the whole daemon.
454
+ // Packet forwarding, DNS, dora, friends — all work fine without
455
+ // the proxy. Catch + log so an EADDRNOTAVAIL (or port-in-use)
456
+ // degrades gracefully to "no proxy" instead of a crash loop.
416
457
  if (this.config.proxy?.enabled) {
417
458
  if (this.useMockTun) {
418
459
  this.logger.warn("Proxy enabled but daemon is in mock-TUN mode; skipping proxy listener");
419
460
  }
420
461
  else {
421
- this.connectProxy = new ConnectProxy({
422
- bindIp: this.config.network.ip,
423
- port: this.config.proxy.port,
424
- allowHosts: this.config.proxy.allowHosts ?? [],
425
- allowConnectPorts: this.config.proxy.allowConnectPorts,
426
- resolvePeerName: (srcIp) => this.ipam.resolveIp(srcIp)?.name,
427
- onTunnelOpen: ({ src, srcName, target }) => {
428
- this.auditLog.logProxyOpen({ srcIp: src, srcName, target });
429
- },
430
- onTunnelClose: ({ src, srcName, target, bytesTransferred }) => {
431
- this.auditLog.logProxyClose({ srcIp: src, srcName, target, bytesTransferred });
432
- },
433
- });
434
- await this.connectProxy.start();
435
- this.logger.info(`Proxy listening on ${this.config.network.ip}:${this.config.proxy.port}`);
462
+ try {
463
+ this.connectProxy = new ConnectProxy({
464
+ bindIp: tunIp,
465
+ port: this.config.proxy.port,
466
+ allowHosts: this.config.proxy.allowHosts ?? [],
467
+ allowConnectPorts: this.config.proxy.allowConnectPorts,
468
+ resolvePeerName: (srcIp) => this.ipam.resolveIp(srcIp)?.name,
469
+ onTunnelOpen: ({ src, srcName, target }) => {
470
+ this.auditLog.logProxyOpen({ srcIp: src, srcName, target });
471
+ },
472
+ onTunnelClose: ({ src, srcName, target, bytesTransferred }) => {
473
+ this.auditLog.logProxyClose({ srcIp: src, srcName, target, bytesTransferred });
474
+ },
475
+ });
476
+ await this.connectProxy.start();
477
+ this.logger.info(`Proxy listening on ${tunIp}:${this.config.proxy.port}`);
478
+ }
479
+ catch (err) {
480
+ this.connectProxy = undefined;
481
+ this.logger.error(`Proxy failed to start on ${tunIp}:${this.config.proxy.port} — continuing without it: ${err instanceof Error ? err.message : err}`);
482
+ }
436
483
  }
437
484
  }
438
485
  this.isRunning = true;
@@ -65,6 +65,17 @@ export declare class ConnectProxy {
65
65
  start(): Promise<void>;
66
66
  stop(): Promise<void>;
67
67
  getStats(): ConnectProxyStats;
68
+ /**
69
+ * Update the host allowlist (and optionally the port allowlist) of a
70
+ * RUNNING proxy without restarting it. handleConnect reads
71
+ * `this.opts.allowHosts` / `allowConnectPorts` at request time, so a
72
+ * plain field swap takes effect on the next CONNECT — no need to tear
73
+ * down the listener or, more importantly, the daemon's Carrier
74
+ * sessions. Lets `agentnet proxy allow-host` apply instantly instead
75
+ * of forcing a daemon restart that would drop every peer session and
76
+ * trigger a slow reconvergence storm.
77
+ */
78
+ updateAllowlist(allowHosts: string[], allowConnectPorts?: number[]): void;
68
79
  private handleConnect;
69
80
  private refuse;
70
81
  }
@@ -86,6 +86,23 @@ export class ConnectProxy {
86
86
  getStats() {
87
87
  return { ...this.stats };
88
88
  }
89
+ /**
90
+ * Update the host allowlist (and optionally the port allowlist) of a
91
+ * RUNNING proxy without restarting it. handleConnect reads
92
+ * `this.opts.allowHosts` / `allowConnectPorts` at request time, so a
93
+ * plain field swap takes effect on the next CONNECT — no need to tear
94
+ * down the listener or, more importantly, the daemon's Carrier
95
+ * sessions. Lets `agentnet proxy allow-host` apply instantly instead
96
+ * of forcing a daemon restart that would drop every peer session and
97
+ * trigger a slow reconvergence storm.
98
+ */
99
+ updateAllowlist(allowHosts, allowConnectPorts) {
100
+ this.opts.allowHosts = allowHosts;
101
+ if (allowConnectPorts)
102
+ this.opts.allowConnectPorts = allowConnectPorts;
103
+ this.logger.info(`Allowlist updated live: ${allowHosts.length} host glob(s)` +
104
+ (allowConnectPorts ? `, ports ${allowConnectPorts.join(",")}` : ""));
105
+ }
89
106
  handleConnect(req, clientSocket, head) {
90
107
  const src = clientSocket.remoteAddress ?? "?";
91
108
  const srcName = this.opts.resolvePeerName?.(src);
package/docs/INSTALL.md CHANGED
@@ -102,33 +102,146 @@ ping <peer-name>.decent # if you ran 'dns install'
102
102
 
103
103
  ## Running the daemon as a service
104
104
 
105
- There's no built-in init script. Pattern is the same as any
106
- foreground binary; rough sketches:
105
+ The daemon is a foreground binary. Pick one of:
107
106
 
108
- ### systemd (Linux)
107
+ ### Option A — `nohup` (fastest; no auto-restart)
109
108
 
110
- ```ini
111
- # /etc/systemd/system/agentnet.service
109
+ ```bash
110
+ # pre-create the log so the unprivileged user can tail it
111
+ sudo touch /var/log/agentnet.log
112
+ sudo chmod 666 /var/log/agentnet.log
113
+
114
+ # IMPORTANT: wrap the whole pipeline in `sudo sh -c '...'`.
115
+ # Otherwise `>>` is interpreted by your unprivileged shell and the
116
+ # redirect fails with "Permission denied" before sudo elevates.
117
+ sudo sh -c 'nohup agentnet up --real-tun --config-dir /home/YOURUSER/.agentnet >> /var/log/agentnet.log 2>&1 &'
118
+
119
+ # verify
120
+ ps -ef | grep -E 'agentnet up|tun-helper' | grep -v grep
121
+ tail -f /var/log/agentnet.log
122
+
123
+ # stop
124
+ sudo pkill -f 'agentnet up'; sudo pkill -f tun-helper
125
+ ```
126
+
127
+ Always pass `--config-dir /home/YOURUSER/.agentnet` when launching with
128
+ `sudo` — `sudo` changes `$HOME` to `/root`, so without an explicit
129
+ `--config-dir` the daemon (and any one-off CLI invocations) will look
130
+ at `/root/.agentnet/` and report "Not initialized."
131
+
132
+ ### Option B — systemd (Linux, persistent + auto-restart)
133
+
134
+ ```bash
135
+ sudo tee /etc/systemd/system/agentnet.service <<'EOF'
112
136
  [Unit]
113
137
  Description=Decent AgentNet daemon
114
138
  After=network-online.target
115
139
  Wants=network-online.target
116
140
 
117
141
  [Service]
118
- ExecStart=/usr/bin/agentnet up --real-tun --config-dir /home/<you>/.agentnet
142
+ Type=simple
119
143
  User=root
144
+ ExecStart=/usr/local/bin/agentnet up --real-tun --config-dir /home/YOURUSER/.agentnet
120
145
  Restart=on-failure
121
146
  RestartSec=5
147
+ StandardOutput=append:/var/log/agentnet.log
148
+ StandardError=append:/var/log/agentnet.log
122
149
 
123
150
  [Install]
124
151
  WantedBy=multi-user.target
152
+ EOF
153
+
154
+ sudo systemctl daemon-reload
155
+ sudo systemctl enable --now agentnet
156
+ sudo systemctl status agentnet
157
+ journalctl -u agentnet -f # live logs (alternative to /var/log/agentnet.log)
158
+ ```
159
+
160
+ Persists across reboots. If you ever previously launched via
161
+ `systemd-run --unit=agentnet ...` (transient unit), the unit file
162
+ above replaces it cleanly.
163
+
164
+ ### Option C — launchd (macOS, persistent + auto-restart)
165
+
166
+ ```bash
167
+ sudo tee /Library/LaunchDaemons/com.decentlan.agentnet.plist <<EOF
168
+ <?xml version="1.0"?>
169
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
170
+ <plist version="1.0"><dict>
171
+ <key>Label</key><string>com.decentlan.agentnet</string>
172
+ <key>UserName</key><string>root</string>
173
+ <key>ProgramArguments</key><array>
174
+ <string>/usr/local/bin/agentnet</string>
175
+ <string>up</string>
176
+ <string>--real-tun</string>
177
+ <string>--config-dir</string>
178
+ <string>/Users/YOURUSER/.agentnet</string>
179
+ </array>
180
+ <key>RunAtLoad</key><true/>
181
+ <key>KeepAlive</key><true/>
182
+ <key>StandardOutPath</key><string>/var/log/agentnet.log</string>
183
+ <key>StandardErrorPath</key><string>/var/log/agentnet.log</string>
184
+ </dict></plist>
185
+ EOF
186
+ sudo launchctl load /Library/LaunchDaemons/com.decentlan.agentnet.plist
187
+ tail -f /var/log/agentnet.log
188
+ ```
189
+
190
+ `sudo launchctl unload /Library/LaunchDaemons/com.decentlan.agentnet.plist`
191
+ to stop. Drop the `.plist` file to disable persistence.
192
+
193
+ ## Running CLI commands once the daemon is up
194
+
195
+ The CLI talks to the daemon over a Unix socket in the config-dir.
196
+ Most commands (`diag`, `ipam list`, `friends list`, `friends pending`,
197
+ `resolve`, `dora autofriend`) just read or write config files — they
198
+ **don't need sudo**:
199
+
200
+ ```bash
201
+ agentnet diag
202
+ agentnet ipam list
203
+ agentnet friends list
204
+ agentnet dora autofriend all
205
+ ```
206
+
207
+ The few that DO need sudo (writing `/etc/resolver/` on macOS, or
208
+ `/etc/dnsmasq.d/` on Linux) require an explicit `--config-dir`
209
+ because `sudo`'s `$HOME` is `/root`:
210
+
211
+ ```bash
212
+ sudo agentnet dns install --config-dir /home/YOURUSER/.agentnet
213
+ ```
214
+
215
+ ## DNS on Linux when dnsPort isn't 53
216
+
217
+ `agentnet init` defaults to `dnsPort: 5354` (5353 is reserved for
218
+ mDNS / Avahi; we step around it). systemd-resolved only forwards to
219
+ upstreams on port 53, so `agentnet dns install` on Linux will warn
220
+ and refuse rather than write a broken config. Two workarounds:
221
+
222
+ ```bash
223
+ # Workaround 1 — static /etc/hosts (simple; re-run on roster changes)
224
+ agentnet dns hosts | sudo tee -a /etc/hosts > /dev/null
225
+
226
+ # Workaround 2 — dnsmasq forward
227
+ agentnet dns install # prints a dnsmasq snippet; paste into
228
+ # /etc/dnsmasq.d/decent.conf, restart dnsmasq
125
229
  ```
126
230
 
127
- ### launchd (macOS)
231
+ macOS has no port-53 restriction — `agentnet dns install` works
232
+ out of the box there.
233
+
234
+ ## Troubleshooting
128
235
 
129
- `/Library/LaunchDaemons/com.decent.agentnet.plist` see
130
- `man launchd.plist`. Important: `Sudo` env isn't a thing in
131
- launchd, so put the daemon under `<key>UserName</key><string>root</string>`.
236
+ | symptom | likely cause | fix |
237
+ |---|---|---|
238
+ | `Not initialized. Run 'agentnet init' first.` after running with sudo | `sudo` set `$HOME=/root`; CLI is looking at `/root/.agentnet/` | add `--config-dir /home/YOURUSER/.agentnet` |
239
+ | `/var/log/agentnet.log: Permission denied` when starting with `sudo nohup ... >> ...` | `>>` runs in your unprivileged shell before sudo elevates | wrap in `sudo sh -c '...'`, OR pre-create the log file with `chmod 666` |
240
+ | `Unit agentnet.service not found.` after a successful `systemctl restart` | previous launch was a transient `systemd-run` unit (auto-removed on stop) | install the persistent unit file (Option B above) |
241
+ | `dora friend is offline` for >60s right after restart | Carrier session re-handshake takes 30–90s; not a real failure | wait, or `agentnet diag` to confirm `friends[].status == "online"` |
242
+ | `ipam list` shows only the self entry | dora roster fetch hasn't merged yet, or dora server is down | wait one refresh interval (60s default), then check the dora server; also confirm `dora.autoFriend` isn't `none` |
243
+ | `ping <peer>.decent` resolves but times out | TUN routing OK but the remote daemon is older without the auto-learn-IPAM fix | upgrade the remote to `@decentnetwork/lan@0.1.13+` |
244
+ | `ping <peer>.decent` fails with `Unknown host` even though `nslookup -port=5354 <peer>.decent 127.0.0.1` works | macOS hasn't reloaded `/etc/resolver/` | `sudo killall -HUP mDNSResponder` |
132
245
 
133
246
  ## Uninstall
134
247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.17",
3
+ "version": "0.1.21",
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",