@decentnetwork/lan 0.1.276 → 0.1.278

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.
@@ -533,6 +533,20 @@ export declare function cmdServiceRestart(args: {
533
533
  * the new code without a sudo prompt — the daemon already has the
534
534
  * privileges it needs to rebind the TUN.
535
535
  */
536
+ /**
537
+ * One-shot repair for the classic root-owned-config-dir mess.
538
+ *
539
+ * The daemon runs as root for the TUN device; the Beagle chat UI runs as you.
540
+ * Every file the daemon created under ~/.agentnet before this was fixed came
541
+ * out root-owned, so the UI's writes into downloads/ and outbox/ fail with
542
+ * EACCES and file sends die. New daemons repair this themselves at startup —
543
+ * this is the command for an install that predates that, or one whose daemon
544
+ * cannot be restarted right now.
545
+ */
546
+ export declare function cmdFixPerms(args: {
547
+ configDir?: string;
548
+ user?: string;
549
+ }): Promise<void>;
536
550
  export declare function cmdRestart(args: {
537
551
  configDir?: string;
538
552
  }): Promise<void>;
@@ -2665,6 +2665,54 @@ export async function cmdServiceRestart(args) {
2665
2665
  * the new code without a sudo prompt — the daemon already has the
2666
2666
  * privileges it needs to rebind the TUN.
2667
2667
  */
2668
+ /**
2669
+ * One-shot repair for the classic root-owned-config-dir mess.
2670
+ *
2671
+ * The daemon runs as root for the TUN device; the Beagle chat UI runs as you.
2672
+ * Every file the daemon created under ~/.agentnet before this was fixed came
2673
+ * out root-owned, so the UI's writes into downloads/ and outbox/ fail with
2674
+ * EACCES and file sends die. New daemons repair this themselves at startup —
2675
+ * this is the command for an install that predates that, or one whose daemon
2676
+ * cannot be restarted right now.
2677
+ */
2678
+ export async function cmdFixPerms(args) {
2679
+ const { configOwner, restoreOwnership } = await import("../utils/config-owner.js");
2680
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
2681
+ if (!existsSync(dir)) {
2682
+ throw new Error(`No config dir at ${dir}. Run 'agentnet init' first, or pass --config-dir.`);
2683
+ }
2684
+ let owner = configOwner(dir);
2685
+ if (args.user) {
2686
+ // Explicit target: `sudo agentnet fix-perms --user alice`, for a dir whose
2687
+ // own ownership is already wrong (a whole tree accidentally chowned root).
2688
+ const { execFileSync } = await import("child_process");
2689
+ const uid = parseInt(execFileSync("id", ["-u", args.user], { encoding: "utf-8" }).trim(), 10);
2690
+ const gid = parseInt(execFileSync("id", ["-g", args.user], { encoding: "utf-8" }).trim(), 10);
2691
+ if (!Number.isInteger(uid) || !Number.isInteger(gid))
2692
+ throw new Error(`Unknown user: ${args.user}`);
2693
+ owner = { uid, gid };
2694
+ }
2695
+ if (!owner) {
2696
+ // Not root, or the dir genuinely belongs to root. Either way there is
2697
+ // nothing this process can chown — say which, precisely.
2698
+ if (process.getuid?.() !== 0) {
2699
+ console.log(`Nothing to do: not running as root, so every file this user creates is already theirs.`);
2700
+ console.log(`If a file send failed with EACCES, the root-owned files need root to repair:`);
2701
+ console.log(` sudo agentnet fix-perms`);
2702
+ return;
2703
+ }
2704
+ console.log(`Nothing to do: ${dir} belongs to root and no invoking user was found.`);
2705
+ console.log(`If this node should belong to a normal user, name them: sudo agentnet fix-perms --user <name>`);
2706
+ return;
2707
+ }
2708
+ const changed = await restoreOwnership(dir, owner);
2709
+ if (changed === 0) {
2710
+ console.log(`Already correct: everything under ${dir} belongs to uid ${owner.uid}.`);
2711
+ return;
2712
+ }
2713
+ console.log(`Fixed ${changed} path(s) under ${dir} → uid ${owner.uid}:${owner.gid}.`);
2714
+ console.log(`File sends from the Beagle UI should work now — no restart needed.`);
2715
+ }
2668
2716
  export async function cmdRestart(args) {
2669
2717
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
2670
2718
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
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, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyFileWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdBootstrapShow, cmdBootstrapUpdate, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdHardenSsh, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyFileWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdBootstrapShow, cmdBootstrapUpdate, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdHardenSsh, cmdServiceInstall, cmdFixPerms, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -203,6 +203,11 @@ async function main() {
203
203
  throw new Error("path is required: agentnet file send <userid> <path>");
204
204
  await cmdFileSend({ to: argv.userid, path: argv.path, configDir: argv["config-dir"] });
205
205
  }
206
+ })
207
+ .command("fix-perms", "Repair root-owned files under ~/.agentnet (run with sudo) — fixes 'file send failed' EACCES", (y) => y
208
+ .option("config-dir", { type: "string" })
209
+ .option("user", { type: "string", describe: "Owner to restore to (default: the config dir's own user)" }), async (argv) => {
210
+ await cmdFixPerms({ configDir: argv["config-dir"], user: argv.user });
206
211
  })
207
212
  // Tell the running daemon to re-exec itself with its original argv.
208
213
  // The daemon inherits its own uid (root if it was launched as root)
@@ -654,7 +654,7 @@ function now() {
654
654
  import { createConnection } from "node:net";
655
655
  import { resolve as resolve2 } from "node:path";
656
656
  import { homedir as homedir3 } from "node:os";
657
- import { createRequire } from "node:module";
657
+ import { createRequire as createRequire2 } from "node:module";
658
658
 
659
659
  // src/config/loader.ts
660
660
  import { readFileSync, existsSync } from "fs";
@@ -975,7 +975,28 @@ var ConfigLoader = class {
975
975
  };
976
976
 
977
977
  // src/daemon/ipc.ts
978
+ import { chmodSync, existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync } from "fs";
978
979
  import { createHash } from "crypto";
980
+ import { createRequire } from "module";
981
+ import { dirname as dirname2, join } from "path";
982
+ import { fileURLToPath } from "url";
983
+ var daemonVersions = (() => {
984
+ const readVer = (file) => {
985
+ try {
986
+ return JSON.parse(readFileSync2(file, "utf-8")).version ?? "";
987
+ } catch {
988
+ return "";
989
+ }
990
+ };
991
+ const lanVer = readVer(join(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json"));
992
+ let peerVer = "";
993
+ try {
994
+ peerVer = readVer(createRequire(import.meta.url).resolve("@decentnetwork/peer/package.json"));
995
+ } catch {
996
+ peerVer = "";
997
+ }
998
+ return { lanVer, peerVer };
999
+ })();
979
1000
  function ipcSocketPath(dataDir, platform = process.platform) {
980
1001
  if (platform === "win32") {
981
1002
  const id = createHash("sha256").update(dataDir.toLowerCase()).digest("hex").slice(0, 16);
@@ -999,7 +1020,7 @@ function hhmm(ts) {
999
1020
  }
1000
1021
  function lanVersion() {
1001
1022
  try {
1002
- const req = createRequire(import.meta.url);
1023
+ const req = createRequire2(import.meta.url);
1003
1024
  return req("../../package.json").version ?? "";
1004
1025
  } catch {
1005
1026
  return "";
@@ -22,9 +22,41 @@
22
22
  * multi-user hardening can come later via peer-credential filtering.
23
23
  */
24
24
  import { createServer } from "net";
25
- import { chmodSync, existsSync, unlinkSync } from "fs";
25
+ import { chmodSync, existsSync, readFileSync, unlinkSync } from "fs";
26
26
  import { createHash } from "crypto";
27
+ import { createRequire } from "module";
28
+ import { dirname, join } from "path";
29
+ import { fileURLToPath } from "url";
27
30
  import { Logger } from "../utils/logger.js";
31
+ /**
32
+ * Versions of the code THIS daemon process is actually running.
33
+ *
34
+ * A UI that reads the versions off its own node_modules describes the package
35
+ * it was installed beside, not the process answering its calls — so after
36
+ * `npm i -g` but before a restart it cheerfully reports the fix as live while
37
+ * the daemon still runs the old build. `ping` carries these so the panel can
38
+ * show what is genuinely loaded.
39
+ */
40
+ const daemonVersions = (() => {
41
+ const readVer = (file) => {
42
+ try {
43
+ return JSON.parse(readFileSync(file, "utf-8")).version ?? "";
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ };
49
+ // dist/daemon/ipc.js → ../../package.json
50
+ const lanVer = readVer(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"));
51
+ let peerVer = "";
52
+ try {
53
+ peerVer = readVer(createRequire(import.meta.url).resolve("@decentnetwork/peer/package.json"));
54
+ }
55
+ catch {
56
+ peerVer = "";
57
+ }
58
+ return { lanVer, peerVer };
59
+ })();
28
60
  export class IpcServer {
29
61
  socketPath;
30
62
  handlers;
@@ -167,7 +199,8 @@ export class IpcServer {
167
199
  async dispatch(req) {
168
200
  switch (req.op) {
169
201
  case "ping":
170
- return { pong: true };
202
+ // Carries the running daemon's versions — see `daemonVersions`.
203
+ return { pong: true, ...daemonVersions };
171
204
  case "friend-request": {
172
205
  if (!req.address)
173
206
  throw new Error("address is required");
@@ -289,6 +289,20 @@ export class DaemonServer {
289
289
  catch (err) {
290
290
  this.logger.warn(`Could not write pidfile ${this.pidFile}: ${err}`);
291
291
  }
292
+ // We are probably root (TUN needs it) while the config dir belongs to a
293
+ // normal user, and unprivileged apps — the Beagle chat UI above all — write
294
+ // into downloads/ and outbox/. Anything root left behind there would fail
295
+ // their writes with EACCES, so hand it back before we create anything new.
296
+ try {
297
+ const { restoreConfigDirOwnership } = await import("../utils/config-owner.js");
298
+ const fixed = await restoreConfigDirOwnership(this.configDir);
299
+ if (fixed > 0) {
300
+ this.logger.info(`Restored ownership of ${fixed} path(s) under ${this.configDir} to the config dir's user`);
301
+ }
302
+ }
303
+ catch (err) {
304
+ this.logger.warn(`Could not restore config dir ownership: ${err.message}`);
305
+ }
292
306
  this.logger.info(`Starting daemon (node: ${this.config.node.name})`);
293
307
  this.startedAt = Date.now();
294
308
  try {
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.276";
1
+ window.__DK_UI_VERSION="0.1.278";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Ownership of the config dir when the daemon runs as root.
3
+ *
4
+ * The daemon needs root for the TUN device, but `~/.agentnet` belongs to a
5
+ * normal user — and so do the other programs that read and write it. The Beagle
6
+ * chat UI runs unprivileged and writes uploads into `downloads/`, queues into
7
+ * `outbox/`, and appends to the message store; every file root creates there
8
+ * comes out root-owned, so those writes fail with EACCES and a file send dies
9
+ * with nothing but a permission error in a log the user never sees.
10
+ *
11
+ * So: hand ownership of everything under the config dir back to the user who
12
+ * owns the dir itself. Best-effort throughout — a failure here must never stop
13
+ * the daemon from starting, it just leaves the user where they were.
14
+ */
15
+ export interface ConfigOwner {
16
+ uid: number;
17
+ gid: number;
18
+ }
19
+ /**
20
+ * The uid/gid that SHOULD own the config dir, or null when there is nothing to
21
+ * do (not root, or the dir is already root's own — a genuinely root-run node).
22
+ *
23
+ * `SUDO_UID` covers `sudo agentnet up`; the config dir's own owner covers a
24
+ * daemon started by launchd/systemd, where sudo never set anything.
25
+ */
26
+ export declare function configOwner(dir: string): ConfigOwner | null;
27
+ /**
28
+ * Recursively hand `dir` to `owner`, touching only the paths that are actually
29
+ * wrong. A full walk of the config dir costs a readdir per directory — cheap
30
+ * enough to run on every daemon start, and the steady state changes nothing.
31
+ *
32
+ * Note we cannot skip a correctly-owned directory: the daemon writing into a
33
+ * user-created `downloads/` produces exactly that shape — right dir, wrong
34
+ * files inside.
35
+ */
36
+ export declare function restoreOwnership(dir: string, owner: ConfigOwner): Promise<number>;
37
+ /**
38
+ * Startup sweep: if we are root and the config dir belongs to someone else,
39
+ * give everything under it back to them. Returns how many paths changed (0 when
40
+ * there was nothing to do, which is the steady state).
41
+ */
42
+ export declare function restoreConfigDirOwnership(dir: string): Promise<number>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Ownership of the config dir when the daemon runs as root.
3
+ *
4
+ * The daemon needs root for the TUN device, but `~/.agentnet` belongs to a
5
+ * normal user — and so do the other programs that read and write it. The Beagle
6
+ * chat UI runs unprivileged and writes uploads into `downloads/`, queues into
7
+ * `outbox/`, and appends to the message store; every file root creates there
8
+ * comes out root-owned, so those writes fail with EACCES and a file send dies
9
+ * with nothing but a permission error in a log the user never sees.
10
+ *
11
+ * So: hand ownership of everything under the config dir back to the user who
12
+ * owns the dir itself. Best-effort throughout — a failure here must never stop
13
+ * the daemon from starting, it just leaves the user where they were.
14
+ */
15
+ import { lchown, lstat, readdir } from "fs/promises";
16
+ import { statSync } from "fs";
17
+ import { join } from "path";
18
+ /**
19
+ * The uid/gid that SHOULD own the config dir, or null when there is nothing to
20
+ * do (not root, or the dir is already root's own — a genuinely root-run node).
21
+ *
22
+ * `SUDO_UID` covers `sudo agentnet up`; the config dir's own owner covers a
23
+ * daemon started by launchd/systemd, where sudo never set anything.
24
+ */
25
+ export function configOwner(dir) {
26
+ if (process.getuid?.() !== 0)
27
+ return null; // not root: whatever we create is already ours
28
+ const sudoUid = Number(process.env.SUDO_UID);
29
+ const sudoGid = Number(process.env.SUDO_GID);
30
+ if (Number.isInteger(sudoUid) && sudoUid > 0) {
31
+ return { uid: sudoUid, gid: Number.isInteger(sudoGid) && sudoGid >= 0 ? sudoGid : sudoUid };
32
+ }
33
+ try {
34
+ const st = statSync(dir);
35
+ if (st.uid > 0)
36
+ return { uid: st.uid, gid: st.gid };
37
+ }
38
+ catch {
39
+ // No dir yet — nothing to own.
40
+ }
41
+ return null;
42
+ }
43
+ /** Give one path to `owner` if it isn't already. Returns true when changed. */
44
+ async function chownOne(path, owner) {
45
+ try {
46
+ const st = await lstat(path);
47
+ if (st.uid === owner.uid && st.gid === owner.gid)
48
+ return false;
49
+ // lchown, not chown: a symlink inside the config dir must not let us
50
+ // retarget ownership of whatever it points at.
51
+ await lchown(path, owner.uid, owner.gid);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * Recursively hand `dir` to `owner`, touching only the paths that are actually
60
+ * wrong. A full walk of the config dir costs a readdir per directory — cheap
61
+ * enough to run on every daemon start, and the steady state changes nothing.
62
+ *
63
+ * Note we cannot skip a correctly-owned directory: the daemon writing into a
64
+ * user-created `downloads/` produces exactly that shape — right dir, wrong
65
+ * files inside.
66
+ */
67
+ export async function restoreOwnership(dir, owner) {
68
+ let changed = 0;
69
+ const walk = async (path, depth) => {
70
+ if (depth > 12)
71
+ return; // pathological nesting / symlink loop guard
72
+ let entries;
73
+ try {
74
+ entries = await readdir(path, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ return; // unreadable or vanished — nothing we can fix here
78
+ }
79
+ for (const entry of entries) {
80
+ const child = join(path, entry.name);
81
+ if (await chownOne(child, owner))
82
+ changed++;
83
+ // Directories only: never follow a symlink out of the config dir.
84
+ if (entry.isDirectory())
85
+ await walk(child, depth + 1);
86
+ }
87
+ };
88
+ if (await chownOne(dir, owner))
89
+ changed++;
90
+ await walk(dir, 0);
91
+ return changed;
92
+ }
93
+ /**
94
+ * Startup sweep: if we are root and the config dir belongs to someone else,
95
+ * give everything under it back to them. Returns how many paths changed (0 when
96
+ * there was nothing to do, which is the steady state).
97
+ */
98
+ export async function restoreConfigDirOwnership(dir) {
99
+ const owner = configOwner(dir);
100
+ if (!owner)
101
+ return 0;
102
+ return restoreOwnership(dir, owner);
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.276",
3
+ "version": "0.1.278",
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",