@decentnetwork/lan 0.1.18 → 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
@@ -787,7 +787,36 @@ export async function cmdProxyAllowHost(args) {
787
787
  config.proxy = { ...proxy, allowHosts: [...allowHosts] };
788
788
  await ConfigLoader.save(config, configPath);
789
789
  console.log(`Added '${args.host}' to proxy allow-hosts.`);
790
- 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
+ }
791
820
  }
792
821
  /**
793
822
  * Remove a host glob from the proxy allowlist.
@@ -803,6 +832,7 @@ export async function cmdProxyRevokeHost(args) {
803
832
  await ConfigLoader.save(config, configPath);
804
833
  if (filtered.length < before) {
805
834
  console.log(`Removed '${args.host}' from proxy allow-hosts.`);
835
+ await applyProxyReloadIfRunning(config);
806
836
  }
807
837
  else {
808
838
  console.log(`No exact match for '${args.host}' in allow-hosts.`);
@@ -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.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",