@decentnetwork/lan 0.1.27 → 0.1.29

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
@@ -296,3 +296,12 @@ export declare function cmdServiceInstall(args: {
296
296
  export declare function cmdServiceStatus(_args: {
297
297
  configDir?: string;
298
298
  }): Promise<void>;
299
+ /**
300
+ * Ask the running daemon to re-exec itself. Used after
301
+ * `npm install -g @decentnetwork/lan@<new>` so the daemon picks up
302
+ * the new code without a sudo prompt — the daemon already has the
303
+ * privileges it needs to rebind the TUN.
304
+ */
305
+ export declare function cmdRestart(args: {
306
+ configDir?: string;
307
+ }): Promise<void>;
@@ -1453,3 +1453,22 @@ export async function cmdServiceStatus(_args) {
1453
1453
  }
1454
1454
  console.log(`'service status' isn't wired up for ${process.platform}.`);
1455
1455
  }
1456
+ /**
1457
+ * Ask the running daemon to re-exec itself. Used after
1458
+ * `npm install -g @decentnetwork/lan@<new>` so the daemon picks up
1459
+ * the new code without a sudo prompt — the daemon already has the
1460
+ * privileges it needs to rebind the TUN.
1461
+ */
1462
+ export async function cmdRestart(args) {
1463
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1464
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1465
+ if (daemonPid(config) === null) {
1466
+ throw new Error("Daemon not running. Start it once with 'agentnet up' (needs sudo for TUN); after that 'agentnet restart' takes care of upgrades without further sudo prompts.");
1467
+ }
1468
+ const res = await ipcCall(config, { op: "self-restart" });
1469
+ if (!res.ok)
1470
+ throw new Error(`Daemon refused: ${res.error}`);
1471
+ const data = res.data;
1472
+ console.log(`Daemon scheduled for self-restart. New process will exec: ${(data.argv || []).join(" ")}`);
1473
+ console.log("Allow ~5s for the daemon to come back up. Verify with 'agentnet diag'.");
1474
+ }
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, cmdServiceInstall, cmdServiceStatus, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -118,6 +118,14 @@ async function main() {
118
118
  })
119
119
  .demandCommand(1, "Specify a service subcommand (run 'agentnet service --help')"), () => {
120
120
  // parent handler — never invoked because demandCommand above
121
+ })
122
+ // Tell the running daemon to re-exec itself with its original argv.
123
+ // The daemon inherits its own uid (root if it was launched as root)
124
+ // so the relaunch needs NO sudo prompt — useful after
125
+ // `npm install -g @decentnetwork/lan@<new>` to pick up new code
126
+ // without breaking the operator's flow.
127
+ .command("restart", "Tell the running daemon to re-exec itself (no sudo prompt; daemon must be up)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
128
+ await cmdRestart({ configDir: argv["config-dir"] });
121
129
  })
122
130
  .command("up", "Start the daemon", (y) => y
123
131
  .option("name", { type: "string" })
@@ -45,9 +45,17 @@ export interface IpcHandlers {
45
45
  * restart (which drops every Carrier session). Returns the applied
46
46
  * allowlist for the CLI to echo. */
47
47
  proxyReload: () => Promise<Record<string, unknown>>;
48
+ /** Re-exec the running daemon with its original argv. The new
49
+ * process inherits the current process's privileges (so a
50
+ * root-owned daemon re-execs as root with no sudo prompt),
51
+ * picks up freshly-installed code on disk, and rebinds the
52
+ * TUN. Used for the upgrade flow: `npm install` the new
53
+ * decentlan, then call this to roll forward without an
54
+ * operator-side sudo step. */
55
+ selfRestart: () => Promise<Record<string, unknown>>;
48
56
  }
49
57
  export interface IpcRequest {
50
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "proxy-reload";
58
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "proxy-reload" | "self-restart";
51
59
  address?: string;
52
60
  hello?: string;
53
61
  userid?: string;
@@ -146,6 +146,8 @@ export class IpcServer {
146
146
  }
147
147
  case "proxy-reload":
148
148
  return await this.handlers.proxyReload();
149
+ case "self-restart":
150
+ return await this.handlers.selfRestart();
149
151
  default:
150
152
  throw new Error(`unknown op: ${req.op}`);
151
153
  }
@@ -18,6 +18,18 @@ 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
+ /**
22
+ * Quote argv safely for `sh -c`. Wraps single-quotes by closing,
23
+ * escaping the quote, and reopening — covers paths with spaces and
24
+ * the rare argv that contains a literal '. Used by the self-restart
25
+ * relauncher so the spawned `sh -c '... exec X Y Z'` shell receives
26
+ * argv intact even if a path has weird characters.
27
+ */
28
+ function shellQuote(parts) {
29
+ return parts
30
+ .map((p) => "'" + p.replace(/'/g, `'\\''`) + "'")
31
+ .join(" ");
32
+ }
21
33
  import { ConfigLoader } from "../config/loader.js";
22
34
  export class DaemonServer {
23
35
  config;
@@ -175,6 +187,47 @@ export class DaemonServer {
175
187
  }
176
188
  return { applied: true, allowHosts };
177
189
  },
190
+ selfRestart: async () => {
191
+ // Detach a child process that waits briefly and then re-execs
192
+ // the same argv we were started with. Inherits privileges
193
+ // (root daemon → root child, no sudo prompt). The current
194
+ // process exits cleanly after responding to the IPC call so
195
+ // the child can rebind the TUN. The brief sleep gives the
196
+ // current process time to flush its IPC response, close
197
+ // sockets, and release the pidfile/TUN before the new
198
+ // process tries to claim them.
199
+ const { spawn } = await import("child_process");
200
+ const argv = process.argv.slice();
201
+ const node = argv.shift();
202
+ this.logger.info(`Self-restart requested via IPC — relaunching ${node} ${argv.join(" ")}`);
203
+ // Schedule shutdown for after the IPC response goes out. The
204
+ // 50ms timeout is plenty for the response to leave; the
205
+ // child's own 1s sleep below is the real arbiter.
206
+ setTimeout(() => {
207
+ try {
208
+ // sh -c so we can shell-quote argv safely and use sleep.
209
+ // The trailing `&` puts the relauncher into the
210
+ // background of sh — sh then exits — and Node's detached
211
+ // flag reparents the relauncher to PID 1. Result: a
212
+ // fully detached new daemon that inherits our uid/gid.
213
+ const cmd = `sleep 1 && exec ${shellQuote([node, ...argv])}`;
214
+ const child = spawn("sh", ["-c", cmd], {
215
+ detached: true,
216
+ stdio: "ignore",
217
+ env: process.env,
218
+ });
219
+ child.unref();
220
+ }
221
+ catch (err) {
222
+ this.logger.error(`Self-restart spawn failed: ${err instanceof Error ? err.message : err}`);
223
+ }
224
+ // Give the child its 1s head-start, then exit so the
225
+ // pidfile and TUN are released before the relauncher tries
226
+ // to grab them.
227
+ setTimeout(() => process.exit(0), 200);
228
+ }, 50);
229
+ return { scheduledMs: 250, argv: [node, ...argv] };
230
+ },
178
231
  diag: async () => {
179
232
  // Snapshot of everything an operator needs to debug why
180
233
  // packets aren't moving: forwarding counters, friend
@@ -218,9 +271,29 @@ export class DaemonServer {
218
271
  // 6. Optional dora (DHCP-style) registration. When enabled and the
219
272
  // server is reachable, dora hands us a virtual IP and tells us
220
273
  // who else is on the network — eliminating the manual ipam.yaml
221
- // sync between operators. On any failure we fall through to the
222
- // IP already loaded from config + ipam.yaml.
274
+ // sync between operators.
275
+ //
276
+ // Fallback policy when dora is unreachable: DON'T use
277
+ // config.network.ip blindly. `agentnet init` defaults every
278
+ // fresh install to 10.86.1.10, so two new peers that can't
279
+ // reach dora will BOTH claim the same fallback and silently
280
+ // collide — symptom seen in the wild as packets going to the
281
+ // wrong daemon and 100% loss to legitimate peers. Use the
282
+ // deterministic-from-userid IP instead (sha256(userid) → last
283
+ // two octets of 10.86.X.Y). That guarantees every peer gets a
284
+ // unique IP keyed off identity, with no shared default to
285
+ // collide on. If dora comes up later, the
286
+ // onAllocatedIpChanged callback swaps the TUN to dora's value.
223
287
  let tunIp = this.config.network.ip;
288
+ const ownUserid = this.peerManager.getPubkey();
289
+ const deterministicFallback = Ipam.deterministicIpForUserid(ownUserid);
290
+ if (tunIp === "10.86.1.10" || !tunIp) {
291
+ // The init-default fallback IP — every fresh decentlan installs with
292
+ // this. Override with a per-identity deterministic value before
293
+ // dora even gets to try.
294
+ tunIp = deterministicFallback;
295
+ this.logger.info(`Using deterministic fallback IP ${tunIp} (derived from userid; avoids the 10.86.1.10 init-default collision)`);
296
+ }
224
297
  if (this.config.dora?.enabled && (this.config.dora.userids?.length ?? 0) > 0) {
225
298
  // Need to be on the Carrier network before dora can talk to its
226
299
  // server. Wait synchronously here — without joinNetwork the
@@ -234,7 +307,11 @@ export class DaemonServer {
234
307
  peerManager: this.peerManager,
235
308
  ipam: this.ipam,
236
309
  nodeName: this.config.node.name,
237
- preferredIp: this.config.network.ip,
310
+ // Hand dora our deterministic fallback as the requestedIp so
311
+ // a successful register returns the same IP across restarts
312
+ // (avoids the dora-stole-someone-else's-IP race that bit us
313
+ // when ubuntu was momentarily offline during reallocation).
314
+ preferredIp: tunIp,
238
315
  // Fires when dora's background retry eventually succeeds
239
316
  // AFTER the initial bootstrap already returned the fallback
240
317
  // IP. At that point the TUN is up on the fallback (e.g.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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",