@decentnetwork/lan 0.1.18 → 0.1.22

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
@@ -265,3 +265,34 @@ export declare function cmdDoraAutofriend(args: {
265
265
  values?: string[];
266
266
  configDir?: string;
267
267
  }): Promise<void>;
268
+ /**
269
+ * Install the daemon as a persistent system service.
270
+ *
271
+ * Linux → writes /etc/systemd/system/agentnet.service, daemon-reload,
272
+ * enable+start. Sets ExecStart to the resolved `agentnet`
273
+ * binary and threads --config-dir so sudo's $HOME=/root
274
+ * doesn't pick up an empty /root/.agentnet.
275
+ * macOS → writes /Library/LaunchDaemons/com.decentlan.agentnet.plist,
276
+ * launchctl load (RunAtLoad + KeepAlive).
277
+ *
278
+ * Both surfaces stream stdout/stderr to /var/log/agentnet.log so a
279
+ * single `tail -f /var/log/agentnet.log` works regardless of platform.
280
+ * `--uninstall` reverses each respectively.
281
+ *
282
+ * Requires root (writes to /etc or /Library). On Linux the resolved
283
+ * unit will run as User=root; on macOS the LaunchDaemon already runs
284
+ * as root. The CLI complains loudly with the missing-sudo hint if
285
+ * the writes fail with EACCES.
286
+ */
287
+ export declare function cmdServiceInstall(args: {
288
+ uninstall?: boolean;
289
+ configDir?: string;
290
+ }): Promise<void>;
291
+ /**
292
+ * Show whether the service is installed + running. Cross-platform
293
+ * pass-through to systemctl status / launchctl list with a one-line
294
+ * up/down summary up top.
295
+ */
296
+ export declare function cmdServiceStatus(_args: {
297
+ configDir?: string;
298
+ }): Promise<void>;
@@ -4,7 +4,7 @@
4
4
  import { resolve, dirname } from "path";
5
5
  import { existsSync, mkdirSync, readFileSync } from "fs";
6
6
  import { createConnection } from "net";
7
- import { ConfigLoader } from "../config/loader.js";
7
+ import { ConfigLoader, DEFAULT_DORA_USERID, DEFAULT_DORA_ADDRESS } from "../config/loader.js";
8
8
  import { ipcSocketPath } from "../daemon/ipc.js";
9
9
  import { DaemonServer } from "../daemon/server.js";
10
10
  import { Ipam } from "../ipam/ipam.js";
@@ -129,7 +129,31 @@ export async function cmdInit(args) {
129
129
  console.log(` Config dir: ${dir}`);
130
130
  console.log(` Subnet: ${config.network.subnet}`);
131
131
  console.log(` Interface: ${config.network.interface}`);
132
- console.log(`\nNext: agentnet up --name ${nodeName}`);
132
+ // Send the one-time friend-request to the default dora so the daemon
133
+ // can register on first start. Best-effort: a friend-request requires
134
+ // joinNetwork + an announce round, ~10-30s; we cap the wait and
135
+ // surface a hint if it doesn't go through. Skipped when the user
136
+ // overrode dora.userids away from the default (i.e. they're pointing
137
+ // at a private dora — they'll run `agentnet dora enable` themselves).
138
+ const defaultDoraConfigured = (config.dora?.userids ?? []).includes(DEFAULT_DORA_USERID);
139
+ if (defaultDoraConfigured) {
140
+ console.log(`\nFriending the public dora (${DEFAULT_DORA_USERID.slice(0, 16)}...) so the daemon can join the shared network on first start.`);
141
+ try {
142
+ await cmdFriendRequest({
143
+ address: DEFAULT_DORA_ADDRESS,
144
+ hello: `decentlan init (${nodeName})`,
145
+ waitMs: 8000,
146
+ configDir: dir,
147
+ });
148
+ console.log(` Friend-request dispatched. Auto-accept on the dora side will take it from here.`);
149
+ }
150
+ catch (err) {
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ console.warn(` Friend-request to the default dora failed: ${msg}`);
153
+ console.warn(` Re-run later: agentnet dora enable --address ${DEFAULT_DORA_ADDRESS}`);
154
+ }
155
+ }
156
+ console.log(`\nNext: sudo agentnet service install # or 'agentnet up --real-tun' to run in foreground`);
133
157
  }
134
158
  /**
135
159
  * Show identity information.
@@ -787,7 +811,36 @@ export async function cmdProxyAllowHost(args) {
787
811
  config.proxy = { ...proxy, allowHosts: [...allowHosts] };
788
812
  await ConfigLoader.save(config, configPath);
789
813
  console.log(`Added '${args.host}' to proxy allow-hosts.`);
790
- console.log(`Restart the daemon to take effect.`);
814
+ await applyProxyReloadIfRunning(config);
815
+ }
816
+ /**
817
+ * If the daemon is up, push the freshly-saved proxy allowlist into the
818
+ * running proxy over IPC — no restart, no Carrier-session churn. Prints
819
+ * the outcome. When the daemon is down (or the proxy isn't running yet),
820
+ * tells the operator a restart is needed.
821
+ */
822
+ async function applyProxyReloadIfRunning(config) {
823
+ if (daemonPid(config) === null) {
824
+ console.log("Daemon is down — change takes effect on next 'agentnet up'.");
825
+ return;
826
+ }
827
+ try {
828
+ const res = await ipcCall(config, { op: "proxy-reload" });
829
+ if (!res.ok) {
830
+ console.log(`(live reload failed: ${res.error}) — restart the daemon to apply.`);
831
+ return;
832
+ }
833
+ const data = res.data;
834
+ if (data.applied) {
835
+ console.log(`Applied live to the running proxy — no restart needed.`);
836
+ }
837
+ else {
838
+ console.log(`Not applied live: ${data.reason ?? "proxy not running"}.`);
839
+ }
840
+ }
841
+ catch (err) {
842
+ console.log(`(live reload error: ${err instanceof Error ? err.message : err}) — restart to apply.`);
843
+ }
791
844
  }
792
845
  /**
793
846
  * Remove a host glob from the proxy allowlist.
@@ -803,6 +856,7 @@ export async function cmdProxyRevokeHost(args) {
803
856
  await ConfigLoader.save(config, configPath);
804
857
  if (filtered.length < before) {
805
858
  console.log(`Removed '${args.host}' from proxy allow-hosts.`);
859
+ await applyProxyReloadIfRunning(config);
806
860
  }
807
861
  else {
808
862
  console.log(`No exact match for '${args.host}' in allow-hosts.`);
@@ -1208,3 +1262,169 @@ export async function cmdDoraAutofriend(args) {
1208
1262
  }
1209
1263
  console.log(`Takes effect on the next roster refresh (~60s) or daemon restart.`);
1210
1264
  }
1265
+ /**
1266
+ * Install the daemon as a persistent system service.
1267
+ *
1268
+ * Linux → writes /etc/systemd/system/agentnet.service, daemon-reload,
1269
+ * enable+start. Sets ExecStart to the resolved `agentnet`
1270
+ * binary and threads --config-dir so sudo's $HOME=/root
1271
+ * doesn't pick up an empty /root/.agentnet.
1272
+ * macOS → writes /Library/LaunchDaemons/com.decentlan.agentnet.plist,
1273
+ * launchctl load (RunAtLoad + KeepAlive).
1274
+ *
1275
+ * Both surfaces stream stdout/stderr to /var/log/agentnet.log so a
1276
+ * single `tail -f /var/log/agentnet.log` works regardless of platform.
1277
+ * `--uninstall` reverses each respectively.
1278
+ *
1279
+ * Requires root (writes to /etc or /Library). On Linux the resolved
1280
+ * unit will run as User=root; on macOS the LaunchDaemon already runs
1281
+ * as root. The CLI complains loudly with the missing-sudo hint if
1282
+ * the writes fail with EACCES.
1283
+ */
1284
+ export async function cmdServiceInstall(args) {
1285
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1286
+ const { spawnSync, execSync } = await import("child_process");
1287
+ if (process.platform === "linux") {
1288
+ const unitPath = "/etc/systemd/system/agentnet.service";
1289
+ if (args.uninstall) {
1290
+ spawnSync("systemctl", ["disable", "--now", "agentnet"], { stdio: "inherit" });
1291
+ try {
1292
+ const { unlinkSync, existsSync } = await import("fs");
1293
+ if (existsSync(unitPath))
1294
+ unlinkSync(unitPath);
1295
+ execSync("systemctl daemon-reload");
1296
+ console.log(`Removed ${unitPath} and disabled the service.`);
1297
+ }
1298
+ catch (err) {
1299
+ const msg = err instanceof Error ? err.message : String(err);
1300
+ throw new Error(`Could not remove ${unitPath} (need root?): ${msg}`);
1301
+ }
1302
+ return;
1303
+ }
1304
+ // Resolve the `agentnet` binary. argv[1] is dist/cli/index.js when
1305
+ // invoked as `node …/index.js`, so a `which` lookup is more
1306
+ // portable. Fall back to /usr/local/bin/agentnet (npm-default
1307
+ // global prefix on most Linux setups) if the lookup fails.
1308
+ let agentnetBin;
1309
+ try {
1310
+ agentnetBin = execSync("command -v agentnet", { encoding: "utf-8" }).trim();
1311
+ if (!agentnetBin)
1312
+ throw new Error("not found");
1313
+ }
1314
+ catch {
1315
+ agentnetBin = "/usr/local/bin/agentnet";
1316
+ }
1317
+ const unit = `[Unit]
1318
+ Description=Decent AgentNet daemon
1319
+ After=network-online.target
1320
+ Wants=network-online.target
1321
+
1322
+ [Service]
1323
+ Type=simple
1324
+ User=root
1325
+ ExecStart=${agentnetBin} up --real-tun --config-dir ${dir}
1326
+ Restart=on-failure
1327
+ RestartSec=5
1328
+ StandardOutput=append:/var/log/agentnet.log
1329
+ StandardError=append:/var/log/agentnet.log
1330
+
1331
+ [Install]
1332
+ WantedBy=multi-user.target
1333
+ `;
1334
+ try {
1335
+ const fs = await import("fs/promises");
1336
+ await fs.writeFile(unitPath, unit, "utf-8");
1337
+ }
1338
+ catch (err) {
1339
+ const msg = err instanceof Error ? err.message : String(err);
1340
+ throw new Error(`Could not write ${unitPath} (need root? try 'sudo'): ${msg}`);
1341
+ }
1342
+ execSync("systemctl daemon-reload");
1343
+ execSync("systemctl enable --now agentnet");
1344
+ console.log(`Installed ${unitPath} and started agentnet.service.`);
1345
+ console.log(`Logs: journalctl -u agentnet -f (or /var/log/agentnet.log)`);
1346
+ return;
1347
+ }
1348
+ if (process.platform === "darwin") {
1349
+ const plistPath = "/Library/LaunchDaemons/com.decentlan.agentnet.plist";
1350
+ if (args.uninstall) {
1351
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "inherit" });
1352
+ try {
1353
+ const { unlinkSync, existsSync } = await import("fs");
1354
+ if (existsSync(plistPath))
1355
+ unlinkSync(plistPath);
1356
+ console.log(`Unloaded and removed ${plistPath}.`);
1357
+ }
1358
+ catch (err) {
1359
+ const msg = err instanceof Error ? err.message : String(err);
1360
+ throw new Error(`Could not remove ${plistPath} (need root?): ${msg}`);
1361
+ }
1362
+ return;
1363
+ }
1364
+ let agentnetBin;
1365
+ try {
1366
+ agentnetBin = execSync("command -v agentnet", { encoding: "utf-8" }).trim();
1367
+ if (!agentnetBin)
1368
+ throw new Error("not found");
1369
+ }
1370
+ catch {
1371
+ agentnetBin = "/usr/local/bin/agentnet";
1372
+ }
1373
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
1374
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1375
+ <plist version="1.0"><dict>
1376
+ <key>Label</key><string>com.decentlan.agentnet</string>
1377
+ <key>UserName</key><string>root</string>
1378
+ <key>ProgramArguments</key><array>
1379
+ <string>${agentnetBin}</string>
1380
+ <string>up</string>
1381
+ <string>--real-tun</string>
1382
+ <string>--config-dir</string>
1383
+ <string>${dir}</string>
1384
+ </array>
1385
+ <key>RunAtLoad</key><true/>
1386
+ <key>KeepAlive</key><true/>
1387
+ <key>StandardOutPath</key><string>/var/log/agentnet.log</string>
1388
+ <key>StandardErrorPath</key><string>/var/log/agentnet.log</string>
1389
+ </dict></plist>
1390
+ `;
1391
+ try {
1392
+ const fs = await import("fs/promises");
1393
+ await fs.writeFile(plistPath, plist, "utf-8");
1394
+ }
1395
+ catch (err) {
1396
+ const msg = err instanceof Error ? err.message : String(err);
1397
+ throw new Error(`Could not write ${plistPath} (need root? try 'sudo'): ${msg}`);
1398
+ }
1399
+ execSync(`launchctl load ${plistPath}`);
1400
+ console.log(`Installed ${plistPath} and started com.decentlan.agentnet.`);
1401
+ console.log(`Logs: tail -f /var/log/agentnet.log`);
1402
+ return;
1403
+ }
1404
+ throw new Error(`'service install' isn't wired up for ${process.platform}. Run 'agentnet up --real-tun' in the foreground for now.`);
1405
+ }
1406
+ /**
1407
+ * Show whether the service is installed + running. Cross-platform
1408
+ * pass-through to systemctl status / launchctl list with a one-line
1409
+ * up/down summary up top.
1410
+ */
1411
+ export async function cmdServiceStatus(_args) {
1412
+ const { spawnSync } = await import("child_process");
1413
+ if (process.platform === "linux") {
1414
+ const r = spawnSync("systemctl", ["status", "agentnet", "--no-pager"], { encoding: "utf-8" });
1415
+ process.stdout.write(r.stdout || "");
1416
+ if (r.stderr)
1417
+ process.stderr.write(r.stderr);
1418
+ process.exit(r.status ?? 0);
1419
+ }
1420
+ if (process.platform === "darwin") {
1421
+ const r = spawnSync("launchctl", ["list", "com.decentlan.agentnet"], { encoding: "utf-8" });
1422
+ if ((r.status ?? 0) !== 0) {
1423
+ console.log("Not loaded. Install with 'sudo agentnet service install'.");
1424
+ return;
1425
+ }
1426
+ process.stdout.write(r.stdout || "");
1427
+ return;
1428
+ }
1429
+ console.log(`'service status' isn't wired up for ${process.platform}.`);
1430
+ }
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, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDnsInstall, cmdDnsHosts, } 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, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdServiceStatus, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -97,6 +97,27 @@ async function main() {
97
97
  })
98
98
  .demandCommand(1, "Specify a dns subcommand (run 'agentnet dns --help')"), () => {
99
99
  // parent handler — never invoked because demandCommand
100
+ })
101
+ // Persistent system service — wraps systemctl (Linux) and
102
+ // launchctl (macOS) so a new operator runs ONE command to install
103
+ // + start + persist instead of hand-writing a unit/plist.
104
+ .command("service", "Install/uninstall/inspect the daemon as a system service (systemd on Linux, launchd on macOS)", (y) => y
105
+ .command("install", "Write the unit/plist, enable + start (needs sudo)", (yy) => yy
106
+ .option("uninstall", { type: "boolean", default: false, describe: "Reverse the install" })
107
+ .option("config-dir", { type: "string" }), async (argv) => {
108
+ await cmdServiceInstall({
109
+ uninstall: argv.uninstall,
110
+ configDir: argv["config-dir"],
111
+ });
112
+ })
113
+ .command("uninstall", "Stop and remove the system service (alias for 'service install --uninstall')", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
114
+ await cmdServiceInstall({ uninstall: true, configDir: argv["config-dir"] });
115
+ })
116
+ .command("status", "Show service status (systemctl status / launchctl list)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
117
+ await cmdServiceStatus({ configDir: argv["config-dir"] });
118
+ })
119
+ .demandCommand(1, "Specify a service subcommand (run 'agentnet service --help')"), () => {
120
+ // parent handler — never invoked because demandCommand above
100
121
  })
101
122
  .command("up", "Start the daemon", (y) => y
102
123
  .option("name", { type: "string" })
@@ -1,4 +1,17 @@
1
1
  import type { DecentAgentNetConfig } from "../types.js";
2
+ /**
3
+ * Public dora server baked into `agentnet init` so an operator can join
4
+ * the canonical Decent AgentNet without first hunting down a registry
5
+ * address. Override by editing `dora.userids` (and re-running
6
+ * `agentnet dora enable --address <yours>`) — or by running a private
7
+ * dora and pointing your own peers at it.
8
+ *
9
+ * Both fields are required: the daemon needs `userid` to talk to the
10
+ * server over Carrier, and `address` so `agentnet init` can send the
11
+ * one-time friend-request that establishes the Carrier session.
12
+ */
13
+ export declare const DEFAULT_DORA_USERID = "98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv";
14
+ export declare const DEFAULT_DORA_ADDRESS = "Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd";
2
15
  export declare class ConfigLoader {
3
16
  static defaultConfigPath(): string;
4
17
  static defaultConfigDir(): string;
@@ -23,6 +23,19 @@ const DEFAULT_BOOTSTRAP_NODES = [
23
23
  const DEFAULT_EXPRESS_NODES = [
24
24
  { host: "lens.beagle.chat", port: 443, pk: "ECbs4GxwGzxGerNkmqDJFibEmevu8jAXqAZtikccvD95" },
25
25
  ];
26
+ /**
27
+ * Public dora server baked into `agentnet init` so an operator can join
28
+ * the canonical Decent AgentNet without first hunting down a registry
29
+ * address. Override by editing `dora.userids` (and re-running
30
+ * `agentnet dora enable --address <yours>`) — or by running a private
31
+ * dora and pointing your own peers at it.
32
+ *
33
+ * Both fields are required: the daemon needs `userid` to talk to the
34
+ * server over Carrier, and `address` so `agentnet init` can send the
35
+ * one-time friend-request that establishes the Carrier session.
36
+ */
37
+ export const DEFAULT_DORA_USERID = "98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv";
38
+ export const DEFAULT_DORA_ADDRESS = "Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd";
26
39
  export class ConfigLoader {
27
40
  static defaultConfigPath() {
28
41
  return DEFAULT_CONFIG_FILE;
@@ -101,12 +114,16 @@ export class ConfigLoader {
101
114
  friends: {
102
115
  autoAccept: true,
103
116
  },
104
- // Dora integration is opt-in. Leave userids empty to fall back to
105
- // manual ipam.yaml mode. Once a dora server's userid is added,
106
- // the daemon registers itself on startup and pulls the roster.
117
+ // Dora integration is ON by default and points at the public
118
+ // canonical dora `agentnet init` follows up with a one-time
119
+ // friend-request to its address, so a fresh install joins the
120
+ // shared network with zero additional commands. To run in
121
+ // private (no dora) mode: `agentnet dora disable`. To point at
122
+ // your own dora: `agentnet dora enable --address <addr>` (it
123
+ // replaces the default).
107
124
  dora: {
108
- enabled: false,
109
- userids: [],
125
+ enabled: true,
126
+ userids: [DEFAULT_DORA_USERID],
110
127
  refreshIntervalMs: 60_000,
111
128
  // Default: auto-friend every peer in the dora roster. Dora
112
129
  // membership IS the trust statement — joining a dora means
@@ -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.18",
3
+ "version": "0.1.22",
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",