@decentnetwork/lan 0.1.0 → 0.1.4

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
@@ -104,6 +104,31 @@ export declare function cmdFriendAccept(args: {
104
104
  export declare function cmdFriendsList(args: {
105
105
  configDir?: string;
106
106
  }): Promise<void>;
107
+ /**
108
+ * List queued friend-requests held by the running daemon (the ones
109
+ * received while `friends.autoAccept` was false). Goes over IPC —
110
+ * the daemon must be up.
111
+ */
112
+ export declare function cmdFriendsPending(args: {
113
+ configDir?: string;
114
+ }): Promise<void>;
115
+ /**
116
+ * Accept a queued friend-request by userid. Routes through IPC;
117
+ * the daemon's own Carrier peer does the actual acceptFriendRequest
118
+ * call and drops the entry from disk. No daemon shutdown needed.
119
+ */
120
+ export declare function cmdFriendsAccept(args: {
121
+ userid: string;
122
+ configDir?: string;
123
+ }): Promise<void>;
124
+ /**
125
+ * Drop a queued friend-request without accepting. Sender doesn't
126
+ * get a notification — they just see no acceptance.
127
+ */
128
+ export declare function cmdFriendsReject(args: {
129
+ userid: string;
130
+ configDir?: string;
131
+ }): Promise<void>;
107
132
  /**
108
133
  * Show audit log
109
134
  */
@@ -198,12 +223,20 @@ export declare function cmdDiag(args: {
198
223
  configDir?: string;
199
224
  }): Promise<void>;
200
225
  /**
201
- * Enable dora integration and add a dora server's userid to the
202
- * registry list. Subsequent `agentnet up` runs will register with
203
- * dora to get a virtual IP and fetch the peer roster automatically.
226
+ * Enable dora integration. Accepts either `--address` (preferred the
227
+ * full Carrier address of the dora server; we derive its userid and
228
+ * also send the one-time friend-request automatically) or `--userid`
229
+ * (legacy — expects the operator to have already friended the server).
230
+ *
231
+ * The one-shot `--address` path is the right UX: an operator setting
232
+ * up a fresh node only knows "use this dora server, here's its
233
+ * address" — they shouldn't have to know that dora is also a Carrier
234
+ * peer, that they need to friend it first, or what the difference
235
+ * between userid and address is.
204
236
  */
205
237
  export declare function cmdDoraEnable(args: {
206
- userid: string;
238
+ address?: string;
239
+ userid?: string;
207
240
  configDir?: string;
208
241
  }): Promise<void>;
209
242
  /**
@@ -221,3 +254,14 @@ export declare function cmdDoraDisable(args: {
221
254
  export declare function cmdDoraStatus(args: {
222
255
  configDir?: string;
223
256
  }): Promise<void>;
257
+ /**
258
+ * Set the dora auto-friend policy. Three modes:
259
+ * - 'none' (default): never auto-friend roster peers
260
+ * - 'all': friend every roster entry that carries an address
261
+ * - 'allow <id-or-name>...' / 'remove <id-or-name>...': whitelist
262
+ */
263
+ export declare function cmdDoraAutofriend(args: {
264
+ mode: "none" | "all" | "allow" | "remove" | "list";
265
+ values?: string[];
266
+ configDir?: string;
267
+ }): Promise<void>;
@@ -405,8 +405,15 @@ export async function cmdFriendRequest(args) {
405
405
  const waitMs = args.waitMs ?? 30000;
406
406
  console.log(`Waiting ${waitMs}ms for relay delivery...`);
407
407
  await new Promise((r) => setTimeout(r, waitMs));
408
+ const myPubkey = peer.pubkey();
409
+ const myUserid = peer.userid();
408
410
  await peer.stop();
409
- console.log(`Friend request sent. The recipient must run 'agentnet friend-accept --pubkey ${peer.pubkey()}'.`);
411
+ console.log(`Friend request sent.`);
412
+ console.log(`If the recipient's daemon is running with friends.autoAccept (the default), it has already accepted —`);
413
+ console.log(`no action needed on their side. They can confirm with 'agentnet diag' and look for userid ${myUserid}.`);
414
+ console.log(`If their daemon was offline, the request is queued via the express relay; their daemon will`);
415
+ console.log(`pick it up on next start (and still auto-accept). Only run 'agentnet friend-accept --pubkey ${myPubkey}'`);
416
+ console.log(`manually if their daemon is down AND they've disabled autoAccept.`);
410
417
  }
411
418
  /**
412
419
  * Accept a pending friend request.
@@ -500,10 +507,10 @@ export async function cmdFriendsList(args) {
500
507
  for (const friend of friends) {
501
508
  console.log(``);
502
509
  console.log(` ${friend.name || "(unnamed)"} status=${friend.status}`);
503
- console.log(` pubkey: ${friend.pubkey}`);
504
- if (friend.userid && friend.userid !== friend.pubkey) {
505
- console.log(` userid: ${friend.userid}`);
506
- }
510
+ // userid is Carrier's first-class identifier; show that, hide
511
+ // the raw hex pubkey unless someone explicitly needs it.
512
+ const userid = friend.userid && friend.userid !== friend.pubkey ? friend.userid : friend.pubkey;
513
+ console.log(` userid: ${userid}`);
507
514
  if (friend.address)
508
515
  console.log(` address: ${friend.address}`);
509
516
  if (friend.acceptedAt)
@@ -511,6 +518,69 @@ export async function cmdFriendsList(args) {
511
518
  }
512
519
  await peer.stop();
513
520
  }
521
+ /**
522
+ * List queued friend-requests held by the running daemon (the ones
523
+ * received while `friends.autoAccept` was false). Goes over IPC —
524
+ * the daemon must be up.
525
+ */
526
+ export async function cmdFriendsPending(args) {
527
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
528
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
529
+ if (daemonPid(config) === null) {
530
+ throw new Error("Daemon not running — pending friend-requests are held by the daemon. Start it with 'agentnet up' first.");
531
+ }
532
+ const res = await ipcCall(config, { op: "friends-pending" });
533
+ if (!res.ok)
534
+ throw new Error(`Daemon refused: ${res.error}`);
535
+ const list = res.data?.pending ?? [];
536
+ if (list.length === 0) {
537
+ console.log("No pending friend-requests.");
538
+ return;
539
+ }
540
+ console.log(`Pending friend-requests (${list.length}):`);
541
+ for (const e of list) {
542
+ console.log("");
543
+ console.log(` ${e.name || "(unnamed)"}`);
544
+ console.log(` userid: ${e.userid}`);
545
+ if (e.hello)
546
+ console.log(` hello: ${e.hello}`);
547
+ console.log(` arrived: ${e.arrivedAt}`);
548
+ }
549
+ console.log("");
550
+ console.log("Accept with: agentnet friends accept --userid <USERID>");
551
+ console.log("Reject with: agentnet friends reject --userid <USERID>");
552
+ }
553
+ /**
554
+ * Accept a queued friend-request by userid. Routes through IPC;
555
+ * the daemon's own Carrier peer does the actual acceptFriendRequest
556
+ * call and drops the entry from disk. No daemon shutdown needed.
557
+ */
558
+ export async function cmdFriendsAccept(args) {
559
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
560
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
561
+ if (daemonPid(config) === null) {
562
+ throw new Error("Daemon not running. Start it with 'agentnet up' first — pending friend-requests are managed by the live daemon.");
563
+ }
564
+ const res = await ipcCall(config, { op: "friends-accept", userid: args.userid });
565
+ if (!res.ok)
566
+ throw new Error(`Daemon refused: ${res.error}`);
567
+ console.log(`Accepted ${args.userid}.`);
568
+ }
569
+ /**
570
+ * Drop a queued friend-request without accepting. Sender doesn't
571
+ * get a notification — they just see no acceptance.
572
+ */
573
+ export async function cmdFriendsReject(args) {
574
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
575
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
576
+ if (daemonPid(config) === null) {
577
+ throw new Error("Daemon not running. Start it with 'agentnet up' first.");
578
+ }
579
+ const res = await ipcCall(config, { op: "friends-reject", userid: args.userid });
580
+ if (!res.ok)
581
+ throw new Error(`Daemon refused: ${res.error}`);
582
+ console.log(`Rejected ${args.userid}.`);
583
+ }
514
584
  /**
515
585
  * Show audit log
516
586
  */
@@ -861,27 +931,75 @@ export async function cmdDiag(args) {
861
931
  console.log(JSON.stringify(res.data, null, 2));
862
932
  }
863
933
  /**
864
- * Enable dora integration and add a dora server's userid to the
865
- * registry list. Subsequent `agentnet up` runs will register with
866
- * dora to get a virtual IP and fetch the peer roster automatically.
934
+ * Enable dora integration. Accepts either `--address` (preferred the
935
+ * full Carrier address of the dora server; we derive its userid and
936
+ * also send the one-time friend-request automatically) or `--userid`
937
+ * (legacy — expects the operator to have already friended the server).
938
+ *
939
+ * The one-shot `--address` path is the right UX: an operator setting
940
+ * up a fresh node only knows "use this dora server, here's its
941
+ * address" — they shouldn't have to know that dora is also a Carrier
942
+ * peer, that they need to friend it first, or what the difference
943
+ * between userid and address is.
867
944
  */
868
945
  export async function cmdDoraEnable(args) {
946
+ if (!args.address && !args.userid) {
947
+ throw new Error("Need one of --address (preferred) or --userid");
948
+ }
949
+ // Derive userid from address when given an address. Address format:
950
+ // base58(pubkey || nospam || checksum); userid: base58(pubkey).
951
+ let userid = args.userid;
952
+ if (args.address) {
953
+ const { carrierIdFromAddress } = await import("@decentnetwork/peer");
954
+ try {
955
+ userid = carrierIdFromAddress(args.address);
956
+ }
957
+ catch (err) {
958
+ throw new Error(`--address didn't parse as a Carrier address: ${err instanceof Error ? err.message : err}`);
959
+ }
960
+ }
869
961
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
870
962
  const configPath = resolve(dir, "config.yaml");
871
963
  const config = await ConfigLoader.load(configPath);
964
+ // Send the friend-request first when we have an address. We route
965
+ // it via the daemon's IPC if it's running, or fall back to a
966
+ // standalone short-lived Peer when it isn't (same logic as
967
+ // cmdFriendRequest).
968
+ if (args.address) {
969
+ console.log(`Friending dora server at ${args.address.slice(0, 24)}...`);
970
+ try {
971
+ await cmdFriendRequest({
972
+ address: args.address,
973
+ hello: "decentlan: enable dora",
974
+ waitMs: 8000,
975
+ configDir: args.configDir,
976
+ });
977
+ }
978
+ catch (err) {
979
+ // If the SDK already knows this address as an accepted friend
980
+ // it short-circuits — treat that as success, not failure.
981
+ const msg = err instanceof Error ? err.message : String(err);
982
+ if (!/already (accepted|a friend|friend)/i.test(msg)) {
983
+ console.warn(`(friend-request had a warning: ${msg})`);
984
+ console.warn(`Proceeding with config update anyway — re-run 'agentnet friend-request' later if you see "friend offline" in the daemon log.`);
985
+ }
986
+ }
987
+ }
872
988
  const existing = config.dora ?? { enabled: false, userids: [], refreshIntervalMs: 60_000 };
873
989
  const userids = new Set(existing.userids ?? []);
874
- userids.add(args.userid);
990
+ userids.add(userid);
875
991
  config.dora = {
876
992
  enabled: true,
877
993
  userids: [...userids],
878
994
  refreshIntervalMs: existing.refreshIntervalMs ?? 60_000,
879
995
  };
880
996
  await ConfigLoader.save(config, configPath);
881
- console.log(`Dora enabled. Server userid added: ${args.userid}`);
997
+ console.log(`Dora enabled. Server userid added: ${userid}`);
882
998
  console.log(`Total dora servers configured: ${config.dora.userids?.length}`);
883
- console.log(`The dora server must be a Carrier friend — add it with 'agentnet friend-request' if you haven't.`);
884
- console.log(`Restart the daemon to take effect.`);
999
+ if (!args.address) {
1000
+ console.log(`Heads-up: passed --userid only; if you haven't friended the dora server, run 'agentnet friend-request --address <ADDRESS>' before starting the daemon.`);
1001
+ }
1002
+ console.log(`Restart the daemon (or just 'agentnet up') to take effect.`);
885
1003
  }
886
1004
  /**
887
1005
  * Disable dora integration without losing the configured server list.
@@ -927,6 +1045,65 @@ export async function cmdDoraStatus(args) {
927
1045
  }
928
1046
  const refresh = dora.refreshIntervalMs ?? 60_000;
929
1047
  console.log(` refresh: every ${Math.round(refresh / 1000)}s`);
1048
+ const af = dora.autoFriend ?? "none";
1049
+ if (af === "none" || af === "all") {
1050
+ console.log(` auto-friend: ${af}`);
1051
+ }
1052
+ else {
1053
+ console.log(` auto-friend: whitelist [${af.length}]: ${af.join(", ")}`);
1054
+ }
930
1055
  console.log("");
931
1056
  console.log("Note: live state (allocated IP, last refresh) requires querying a running daemon — not yet wired.");
932
1057
  }
1058
+ /**
1059
+ * Set the dora auto-friend policy. Three modes:
1060
+ * - 'none' (default): never auto-friend roster peers
1061
+ * - 'all': friend every roster entry that carries an address
1062
+ * - 'allow <id-or-name>...' / 'remove <id-or-name>...': whitelist
1063
+ */
1064
+ export async function cmdDoraAutofriend(args) {
1065
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1066
+ const configPath = resolve(dir, "config.yaml");
1067
+ const config = await ConfigLoader.load(configPath);
1068
+ const dora = config.dora ?? { enabled: false, userids: [], refreshIntervalMs: 60_000 };
1069
+ const current = dora.autoFriend ?? "none";
1070
+ if (args.mode === "list") {
1071
+ if (current === "none" || current === "all") {
1072
+ console.log(`auto-friend: ${current}`);
1073
+ }
1074
+ else {
1075
+ console.log(`auto-friend: whitelist (${current.length})`);
1076
+ for (const t of current)
1077
+ console.log(` - ${t}`);
1078
+ }
1079
+ return;
1080
+ }
1081
+ let next;
1082
+ if (args.mode === "none" || args.mode === "all") {
1083
+ next = args.mode;
1084
+ }
1085
+ else if (args.mode === "allow") {
1086
+ const list = Array.isArray(current) ? new Set(current) : new Set();
1087
+ for (const v of args.values ?? [])
1088
+ list.add(v);
1089
+ next = [...list];
1090
+ }
1091
+ else if (args.mode === "remove") {
1092
+ const list = Array.isArray(current) ? new Set(current) : new Set();
1093
+ for (const v of args.values ?? [])
1094
+ list.delete(v);
1095
+ next = list.size === 0 ? "none" : [...list];
1096
+ }
1097
+ else {
1098
+ throw new Error(`unknown autofriend mode: ${args.mode}`);
1099
+ }
1100
+ config.dora = { ...dora, autoFriend: next };
1101
+ await ConfigLoader.save(config, configPath);
1102
+ if (next === "none" || next === "all") {
1103
+ console.log(`auto-friend set to '${next}'.`);
1104
+ }
1105
+ else {
1106
+ console.log(`auto-friend whitelist now (${next.length}): ${next.join(", ")}`);
1107
+ }
1108
+ console.log(`Takes effect on the next roster refresh (~60s) or daemon restart.`);
1109
+ }
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, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, 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, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -126,8 +126,21 @@ async function main() {
126
126
  configDir: argv["config-dir"],
127
127
  });
128
128
  })
129
- .command("friends list", "List Carrier friends", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
129
+ .command("friends list", "List Carrier friends (daemon must be down)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
130
130
  await cmdFriendsList({ configDir: argv["config-dir"] });
131
+ })
132
+ .command("friends pending", "List queued friend-requests (over IPC; daemon must be up)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
133
+ await cmdFriendsPending({ configDir: argv["config-dir"] });
134
+ })
135
+ .command("friends accept", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
136
+ .option("userid", { type: "string", demandOption: true, describe: "Sender's Carrier userid (base58)" })
137
+ .option("config-dir", { type: "string" }), async (argv) => {
138
+ await cmdFriendsAccept({ userid: argv.userid, configDir: argv["config-dir"] });
139
+ })
140
+ .command("friends reject", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
141
+ .option("userid", { type: "string", demandOption: true })
142
+ .option("config-dir", { type: "string" }), async (argv) => {
143
+ await cmdFriendsReject({ userid: argv.userid, configDir: argv["config-dir"] });
131
144
  })
132
145
  .command("audit log", "View audit log", (y) => y.option("tail", { type: "number", default: 50 }).option("config-dir", { type: "string" }), async (argv) => {
133
146
  await cmdAuditLog({ tail: argv.tail, configDir: argv["config-dir"] });
@@ -170,16 +183,46 @@ async function main() {
170
183
  // parent handler — never invoked because demandCommand above
171
184
  })
172
185
  .command("dora", "Manage the dora (DHCP-style) registry integration (run 'agentnet dora --help')", (y) => y
173
- .command("enable", "Enable dora and register a server's Carrier userid", (yy) => yy
174
- .option("userid", { type: "string", demandOption: true, describe: "Dora server's Carrier userid" })
175
- .option("config-dir", { type: "string" }), async (argv) => {
176
- await cmdDoraEnable({ userid: argv.userid, configDir: argv["config-dir"] });
186
+ .command("enable", "Enable dora preferred: --address <addr> (we friend + derive userid); legacy: --userid <id>", (yy) => yy
187
+ .option("address", { type: "string", describe: "Dora server's full Carrier address — friends it and derives the userid for you" })
188
+ .option("userid", { type: "string", describe: "Legacy: server's userid; only use if you've already friended it separately" })
189
+ .option("config-dir", { type: "string" })
190
+ .check((argv) => {
191
+ if (!argv.address && !argv.userid) {
192
+ throw new Error("Need one of --address (preferred) or --userid");
193
+ }
194
+ return true;
195
+ }), async (argv) => {
196
+ await cmdDoraEnable({
197
+ address: argv.address,
198
+ userid: argv.userid,
199
+ configDir: argv["config-dir"],
200
+ });
177
201
  })
178
202
  .command("disable", "Disable dora integration (keep configured server list)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
179
203
  await cmdDoraDisable({ configDir: argv["config-dir"] });
180
204
  })
181
205
  .command("status", "Show dora configuration", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
182
206
  await cmdDoraStatus({ configDir: argv["config-dir"] });
207
+ })
208
+ .command("autofriend <mode> [values..]", "Auto-friend policy: 'none' (default), 'all', 'allow <name|userid>...', 'remove <name|userid>...', or 'list'", (yy) => yy
209
+ .positional("mode", {
210
+ type: "string",
211
+ demandOption: true,
212
+ choices: ["none", "all", "allow", "remove", "list"],
213
+ describe: "Policy mode",
214
+ })
215
+ .positional("values", {
216
+ type: "string",
217
+ array: true,
218
+ describe: "For 'allow'/'remove': one or more names or userids",
219
+ })
220
+ .option("config-dir", { type: "string" }), async (argv) => {
221
+ await cmdDoraAutofriend({
222
+ mode: argv.mode,
223
+ values: argv.values,
224
+ configDir: argv["config-dir"],
225
+ });
183
226
  })
184
227
  .demandCommand(1, "Specify a dora subcommand (run 'agentnet dora --help')"), () => {
185
228
  // parent handler — never invoked because demandCommand above
@@ -108,6 +108,11 @@ export class ConfigLoader {
108
108
  enabled: false,
109
109
  userids: [],
110
110
  refreshIntervalMs: 60_000,
111
+ // Default: do NOT auto-friend roster peers. Dora is a naming
112
+ // / IP service, not a trust statement. Operators opt in with
113
+ // `agentnet dora autofriend all` (closed labs) or
114
+ // `agentnet dora autofriend allow <name|userid>` (whitelist).
115
+ autoFriend: "none",
111
116
  },
112
117
  };
113
118
  }
@@ -29,11 +29,22 @@ export interface IpcHandlers {
29
29
  * Used by `agentnet diag` to inspect a live daemon without
30
30
  * restarting it with AGENTNET_LOG_LEVEL=debug. */
31
31
  diag: () => Promise<Record<string, unknown>>;
32
+ /** List queued friend-requests that arrived while autoAccept was
33
+ * off. Returns an array of {userid, name, hello, arrivedAt}. */
34
+ friendsPending: () => Promise<Record<string, unknown>>;
35
+ /** Accept a queued friend-request by userid. Drops it from the
36
+ * queue and calls peer.acceptFriendRequest. Idempotent — no-op
37
+ * if userid isn't in the queue. */
38
+ friendsAccept: (userid: string) => Promise<void>;
39
+ /** Reject (drop) a queued friend-request by userid. Doesn't
40
+ * notify the sender — they'll just see no acceptance. */
41
+ friendsReject: (userid: string) => Promise<void>;
32
42
  }
33
43
  export interface IpcRequest {
34
- op: "friend-request" | "ping" | "diag";
44
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject";
35
45
  address?: string;
36
46
  hello?: string;
47
+ userid?: string;
37
48
  }
38
49
  export interface IpcResponseOk {
39
50
  ok: true;
@@ -130,6 +130,20 @@ export class IpcServer {
130
130
  }
131
131
  case "diag":
132
132
  return await this.handlers.diag();
133
+ case "friends-pending":
134
+ return await this.handlers.friendsPending();
135
+ case "friends-accept": {
136
+ if (!req.userid)
137
+ throw new Error("userid is required");
138
+ await this.handlers.friendsAccept(req.userid);
139
+ return;
140
+ }
141
+ case "friends-reject": {
142
+ if (!req.userid)
143
+ throw new Error("userid is required");
144
+ await this.handlers.friendsReject(req.userid);
145
+ return;
146
+ }
133
147
  default:
134
148
  throw new Error(`unknown op: ${req.op}`);
135
149
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pending-friend-request store.
3
+ *
4
+ * When `friends.autoAccept` is false (or for any other reason the
5
+ * operator wants to gate inbound friend requests), the daemon must
6
+ * NOT block the running session waiting for a CLI dance. It buffers
7
+ * each incoming request to disk and exposes IPC ops so an operator
8
+ * can list / accept / reject them on their own time, without
9
+ * stopping the daemon. Lost-on-restart pending requests would be
10
+ * frustrating, so the queue is persisted.
11
+ *
12
+ * File format: a single JSON array under
13
+ * `<configDir>/pending-friends.json`. Each entry is the same shape
14
+ * the SDK emits in the friend-request event, plus an arrivedAt
15
+ * timestamp.
16
+ */
17
+ export interface PendingFriendEntry {
18
+ /** Carrier userid (base58, same 32 pubkey bytes as the hex form
19
+ * the SDK emits — we store the userid form because that's what
20
+ * every user-facing surface uses). */
21
+ userid: string;
22
+ /** Hex pubkey, kept for compatibility with SDK calls that expect
23
+ * that form (acceptFriendRequest takes the pubkey/userid string
24
+ * the original event carried). */
25
+ pubkey: string;
26
+ name?: string;
27
+ hello?: string;
28
+ /** ISO 8601 timestamp; helps operators triage queues of pending
29
+ * requests. */
30
+ arrivedAt: string;
31
+ }
32
+ export declare class PendingFriendsStore {
33
+ private filePath;
34
+ private logger;
35
+ private entries;
36
+ constructor(filePath: string);
37
+ private load;
38
+ private save;
39
+ list(): PendingFriendEntry[];
40
+ /**
41
+ * Add (or replace) a pending request. The same userid arriving
42
+ * twice replaces the older entry — operators see the latest
43
+ * `hello` / `name` / `arrivedAt`, never a stale duplicate.
44
+ */
45
+ add(entry: PendingFriendEntry): void;
46
+ /**
47
+ * Drop an entry by userid. Returns the removed entry (so the
48
+ * caller has the original pubkey to pass to the SDK's
49
+ * acceptFriendRequest), or null if not present.
50
+ */
51
+ remove(userid: string): PendingFriendEntry | null;
52
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pending-friend-request store.
3
+ *
4
+ * When `friends.autoAccept` is false (or for any other reason the
5
+ * operator wants to gate inbound friend requests), the daemon must
6
+ * NOT block the running session waiting for a CLI dance. It buffers
7
+ * each incoming request to disk and exposes IPC ops so an operator
8
+ * can list / accept / reject them on their own time, without
9
+ * stopping the daemon. Lost-on-restart pending requests would be
10
+ * frustrating, so the queue is persisted.
11
+ *
12
+ * File format: a single JSON array under
13
+ * `<configDir>/pending-friends.json`. Each entry is the same shape
14
+ * the SDK emits in the friend-request event, plus an arrivedAt
15
+ * timestamp.
16
+ */
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
18
+ import { dirname } from "path";
19
+ import { Logger } from "../utils/logger.js";
20
+ export class PendingFriendsStore {
21
+ filePath;
22
+ logger;
23
+ entries = [];
24
+ constructor(filePath) {
25
+ this.filePath = filePath;
26
+ this.logger = new Logger({ prefix: "PendingFriends" });
27
+ this.load();
28
+ }
29
+ load() {
30
+ if (!existsSync(this.filePath))
31
+ return;
32
+ try {
33
+ const raw = readFileSync(this.filePath, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (Array.isArray(parsed)) {
36
+ this.entries = parsed.filter((e) => typeof e === "object" &&
37
+ e !== null &&
38
+ typeof e.userid === "string");
39
+ }
40
+ }
41
+ catch (err) {
42
+ this.logger.warn(`Could not parse ${this.filePath}: ${err}`);
43
+ }
44
+ }
45
+ save() {
46
+ try {
47
+ mkdirSync(dirname(this.filePath), { recursive: true });
48
+ writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2), "utf-8");
49
+ }
50
+ catch (err) {
51
+ this.logger.warn(`Could not write ${this.filePath}: ${err}`);
52
+ }
53
+ }
54
+ list() {
55
+ return [...this.entries];
56
+ }
57
+ /**
58
+ * Add (or replace) a pending request. The same userid arriving
59
+ * twice replaces the older entry — operators see the latest
60
+ * `hello` / `name` / `arrivedAt`, never a stale duplicate.
61
+ */
62
+ add(entry) {
63
+ this.entries = this.entries.filter((e) => e.userid !== entry.userid);
64
+ this.entries.push(entry);
65
+ this.save();
66
+ }
67
+ /**
68
+ * Drop an entry by userid. Returns the removed entry (so the
69
+ * caller has the original pubkey to pass to the SDK's
70
+ * acceptFriendRequest), or null if not present.
71
+ */
72
+ remove(userid) {
73
+ const idx = this.entries.findIndex((e) => e.userid === userid);
74
+ if (idx < 0)
75
+ return null;
76
+ const [removed] = this.entries.splice(idx, 1);
77
+ this.save();
78
+ return removed;
79
+ }
80
+ }
@@ -32,6 +32,7 @@ export declare class DaemonServer {
32
32
  private doraIntegration?;
33
33
  private ipcServer?;
34
34
  private dnsServer?;
35
+ private pendingFriends?;
35
36
  private startedAt;
36
37
  private isRunning;
37
38
  private pidFile?;
@@ -16,6 +16,7 @@ import { ConnectProxy } from "../proxy/connect-proxy.js";
16
16
  import { DoraIntegration } from "../dora/dora-integration.js";
17
17
  import { DnsServer } from "../dns/server.js";
18
18
  import { IpcServer, ipcSocketPath } from "./ipc.js";
19
+ import { PendingFriendsStore } from "./pending-friends.js";
19
20
  import { Logger } from "../utils/logger.js";
20
21
  export class DaemonServer {
21
22
  config;
@@ -35,6 +36,7 @@ export class DaemonServer {
35
36
  doraIntegration;
36
37
  ipcServer;
37
38
  dnsServer;
39
+ pendingFriends;
38
40
  startedAt = 0;
39
41
  isRunning = false;
40
42
  pidFile;
@@ -107,6 +109,31 @@ export class DaemonServer {
107
109
  friendRequest: async (address, hello) => {
108
110
  await this.peerManager.sendFriendRequest(address, hello);
109
111
  },
112
+ friendsPending: async () => {
113
+ const entries = this.pendingFriends?.list() ?? [];
114
+ // Hide the legacy hex `pubkey` field from the wire — the
115
+ // CLI side only needs userid + metadata to display + accept.
116
+ return {
117
+ pending: entries.map((e) => ({
118
+ userid: e.userid,
119
+ name: e.name,
120
+ hello: e.hello,
121
+ arrivedAt: e.arrivedAt,
122
+ })),
123
+ };
124
+ },
125
+ friendsAccept: async (userid) => {
126
+ const entry = this.pendingFriends?.remove(userid);
127
+ if (!entry)
128
+ throw new Error(`No pending friend-request for userid ${userid}`);
129
+ await this.peerManager.acceptFriendRequest(entry.pubkey);
130
+ },
131
+ friendsReject: async (userid) => {
132
+ const entry = this.pendingFriends?.remove(userid);
133
+ if (!entry)
134
+ throw new Error(`No pending friend-request for userid ${userid}`);
135
+ // No-op at the Carrier level — sender just sees no acceptance.
136
+ },
110
137
  diag: async () => {
111
138
  // Snapshot of everything an operator needs to debug why
112
139
  // packets aren't moving: forwarding counters, friend
@@ -188,24 +215,49 @@ export class DaemonServer {
188
215
  });
189
216
  }
190
217
  // Live friend-request handling. The daemon stays up and accepts
191
- // (or just logs) incoming requests — no more `friend-accept --wait`
192
- // ceremony where the operator has to stop the daemon. Default is
193
- // auto-accept; the Carrier friend store IS the trust boundary, and
194
- // requests only reach a peer whose address was deliberately shared.
218
+ // (or queues) every incoming request — no `friend-accept --wait`
219
+ // ceremony where the operator has to stop the daemon. Default
220
+ // is auto-accept; the Carrier friend store IS the trust
221
+ // boundary, and requests only reach a peer whose address was
222
+ // deliberately shared.
223
+ //
224
+ // When autoAccept is off, the request is persisted to
225
+ // pending-friends.json on disk. The operator inspects/handles
226
+ // it later via the IPC-routed CLI:
227
+ // agentnet friends pending
228
+ // agentnet friends accept --userid <userid>
229
+ // agentnet friends reject --userid <userid>
230
+ // Pending requests survive daemon restarts.
195
231
  const autoAcceptFriends = this.config.friends?.autoAccept ?? true;
232
+ const pendingPath = resolve(this.config.carrier.dataDir, "..", "pending-friends.json");
233
+ this.pendingFriends = new PendingFriendsStore(pendingPath);
234
+ const pubkeyHexToUserid = async (pubkeyHex) => {
235
+ const { carrierIdFromPublicKey } = await import("@decentnetwork/peer");
236
+ const bytes = Buffer.from(pubkeyHex, "hex");
237
+ return carrierIdFromPublicKey(new Uint8Array(bytes));
238
+ };
196
239
  this.peerManager.on("friend-request", (req) => {
197
- const who = `${req.name || "(unnamed)"} ${req.pubkey.slice(0, 16)}...`;
198
- const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
199
- if (autoAcceptFriends) {
200
- this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
201
- this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
202
- this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
203
- });
204
- }
205
- else {
206
- this.logger.info(`Friend request from ${who}${hello} — NOT auto-accepting ` +
207
- `(friends.autoAccept=false; accept manually later)`);
208
- }
240
+ void pubkeyHexToUserid(req.pubkey).then((userid) => {
241
+ const who = `${req.name || "(unnamed)"} ${userid}`;
242
+ const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
243
+ if (autoAcceptFriends) {
244
+ this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
245
+ this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
246
+ this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
247
+ });
248
+ }
249
+ else {
250
+ this.logger.info(`Friend request from ${who}${hello} — queued (friends.autoAccept=false). ` +
251
+ `Run 'agentnet friends pending' to triage.`);
252
+ this.pendingFriends?.add({
253
+ userid,
254
+ pubkey: req.pubkey,
255
+ name: req.name,
256
+ hello: req.hello,
257
+ arrivedAt: new Date().toISOString(),
258
+ });
259
+ }
260
+ });
209
261
  });
210
262
  // NOTE: auto-IPAM (hash-derived virtual IP per userid) was tried and
211
263
  // reverted — it produced IPs like 10.86.175.35 that conflicted with
@@ -87,4 +87,10 @@ export declare class DoraIntegration {
87
87
  * re-register the peer or fall back to a manual friend-request.
88
88
  */
89
89
  private maybeFriend;
90
+ /**
91
+ * Decide whether a roster entry is allowed by the autoFriend
92
+ * policy. Names match case-insensitively against the entry's
93
+ * `name`; userids match exact base58.
94
+ */
95
+ private policyAllows;
90
96
  }
@@ -304,6 +304,22 @@ export class DoraIntegration {
304
304
  this.friendRequested.add(entry.userid);
305
305
  return;
306
306
  }
307
+ // Policy gate. Dora is a name service, not a trust statement.
308
+ // Default ("none") means we DON'T auto-friend roster peers —
309
+ // an operator on a multi-tenant dora isn't asking to be
310
+ // mutually-friended with everyone else just because they share
311
+ // a name service. "all" reproduces the old behavior (closed
312
+ // labs). Otherwise the policy is a whitelist of names or
313
+ // userids; only matches are friended.
314
+ const policy = this.opts.config.autoFriend ?? "none";
315
+ if (!this.policyAllows(entry, policy)) {
316
+ // Mark as "seen" so we don't re-evaluate the policy every
317
+ // 60s for entries we deliberately skip — but DON'T mark as
318
+ // "friendRequested" since the operator may flip the policy
319
+ // later and we should pick them up on next refresh after a
320
+ // daemon restart.
321
+ return;
322
+ }
307
323
  if (!entry.address) {
308
324
  this.logger.warn(`Roster entry ${entry.name} (${entry.userid.slice(0, 12)}...) has no address — can't auto-friend. ` +
309
325
  `Have them re-register against a newer dora server, or run 'agentnet friend-request' manually.`);
@@ -322,4 +338,25 @@ export class DoraIntegration {
322
338
  this.friendRequested.delete(entry.userid);
323
339
  });
324
340
  }
341
+ /**
342
+ * Decide whether a roster entry is allowed by the autoFriend
343
+ * policy. Names match case-insensitively against the entry's
344
+ * `name`; userids match exact base58.
345
+ */
346
+ policyAllows(entry, policy) {
347
+ if (policy === "all")
348
+ return true;
349
+ if (policy === "none")
350
+ return false;
351
+ if (!Array.isArray(policy) || policy.length === 0)
352
+ return false;
353
+ const nameLower = entry.name.toLowerCase();
354
+ for (const token of policy) {
355
+ if (token === entry.userid)
356
+ return true;
357
+ if (token.toLowerCase() === nameLower)
358
+ return true;
359
+ }
360
+ return false;
361
+ }
325
362
  }
package/dist/types.d.ts CHANGED
@@ -142,6 +142,25 @@ export interface DoraConfig {
142
142
  userids?: string[];
143
143
  /** How often to re-fetch the full roster from dora. Default 60_000ms. */
144
144
  refreshIntervalMs?: number;
145
+ /**
146
+ * Policy for auto-friending peers found in the dora roster. Dora is
147
+ * a NAMING and IP-ALLOCATION service — it does NOT imply trust.
148
+ * On a large dora (many tenants, customer machines, etc.) you do
149
+ * NOT want to auto-friend everyone.
150
+ *
151
+ * - `"none"` (default): never auto-friend roster peers. The operator
152
+ * initiates friend-requests explicitly with `agentnet
153
+ * friend-request --address <addr>`. The dora server itself is
154
+ * always friended out-of-band when you run `agentnet dora enable
155
+ * --address`.
156
+ * - `"all"`: friend every roster entry that carries an address.
157
+ * Useful for a closed lab of trusted peers; **dangerous on a
158
+ * shared / public dora.**
159
+ * - `string[]`: whitelist by peer NAME (matches `name` in the roster
160
+ * record) or by userid (base58). Matching entries are auto-
161
+ * friended on every refresh; everything else is left alone.
162
+ */
163
+ autoFriend?: "none" | "all" | string[];
145
164
  }
146
165
  export interface DecentAgentNetConfig {
147
166
  node: NodeConfig;
@@ -112,7 +112,27 @@ ACL-gated; see `agentnet proxy --help`.
112
112
  - **`userids`** — list of dora servers to try, in order. First
113
113
  responder wins.
114
114
  - **`refreshIntervalMs`** — how often to re-pull the roster from
115
- dora (default 60s). Drives auto-friend discovery of new peers.
115
+ dora (default 60s).
116
+ - **`autoFriend`** — policy for friending peers found in the dora
117
+ roster. Dora is a name service, NOT a trust statement; on a
118
+ multi-tenant dora you do not want the daemon to silently friend
119
+ every other peer.
120
+ - `"none"` (default) — never auto-friend roster peers; only the
121
+ dora server itself is friended (you do that explicitly with
122
+ `agentnet dora enable --address`).
123
+ - `"all"` — friend every roster entry that carries an address.
124
+ Reasonable for closed labs of mutually-trusted peers.
125
+ - `string[]` — whitelist by peer name OR userid. Only matching
126
+ entries get auto-friended.
127
+
128
+ CLI:
129
+ ```
130
+ agentnet dora autofriend none # default
131
+ agentnet dora autofriend all # closed lab
132
+ agentnet dora autofriend allow mac-dev snoopy peerX-userid # whitelist
133
+ agentnet dora autofriend remove snoopy
134
+ agentnet dora autofriend list
135
+ ```
116
136
 
117
137
  ## `policy.yaml` reference
118
138
 
package/docs/INSTALL.md CHANGED
@@ -41,17 +41,20 @@ agentnet --help
41
41
  # 1. Generate this machine's Carrier identity + default config
42
42
  agentnet init --name my-laptop
43
43
 
44
- # 2. (Once per network) friend the dora name server.
45
- # Get the address from whoever runs dora.
46
- agentnet friend-request --address Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd
44
+ # 2. Point at the dora name server. Pass the FULL ADDRESS (whoever
45
+ # runs dora publishes it). This one command friends dora,
46
+ # derives its userid, and writes the config — no separate
47
+ # friend-request step needed.
48
+ agentnet dora enable --address <dora's Carrier address>
47
49
 
48
- # 3. Tell decentlan to use that dora server
49
- agentnet dora enable --userid 98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv
50
-
51
- # 4. Start the daemon (needs sudo for the TUN device)
50
+ # 3. Start the daemon (needs sudo for the TUN device)
52
51
  sudo $(which agentnet) up --real-tun
53
52
  ```
54
53
 
54
+ Three commands, no "userid vs address" gymnastics. The daemon will
55
+ register with dora, pull the roster, and auto-friend every other
56
+ peer in the network.
57
+
55
58
  The daemon will:
56
59
 
57
60
  1. Open a Carrier connection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
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",