@decentnetwork/lan 0.1.12 → 0.1.14

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.
package/dist/cli/index.js CHANGED
@@ -126,21 +126,31 @@ async function main() {
126
126
  configDir: argv["config-dir"],
127
127
  });
128
128
  })
129
- .command("friends list", "List Carrier friends (daemon must be down)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
129
+ // `friends X` MUST be defined as a nested subcommand tree, not as
130
+ // four flat `.command("friends X", ...)` entries — yargs treats the
131
+ // second word as a positional in that flat form and the
132
+ // last-registered subcommand silently swallows the others.
133
+ // Symptom: `agentnet friends pending` would run `friends reject` and
134
+ // complain about a missing --userid.
135
+ .command("friends", "Manage Carrier friends (run 'agentnet friends --help')", (y) => y
136
+ .command("list", "List Carrier friends (daemon must be down)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
130
137
  await cmdFriendsList({ configDir: argv["config-dir"] });
131
138
  })
132
- .command("friends pending", "List queued friend-requests (over IPC; daemon must be up)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
139
+ .command("pending", "List queued friend-requests (over IPC; daemon must be up)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
133
140
  await cmdFriendsPending({ configDir: argv["config-dir"] });
134
141
  })
135
- .command("friends accept", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
142
+ .command("accept", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
136
143
  .option("userid", { type: "string", demandOption: true, describe: "Sender's Carrier userid (base58)" })
137
144
  .option("config-dir", { type: "string" }), async (argv) => {
138
145
  await cmdFriendsAccept({ userid: argv.userid, configDir: argv["config-dir"] });
139
146
  })
140
- .command("friends reject", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
147
+ .command("reject", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (yy) => yy
141
148
  .option("userid", { type: "string", demandOption: true })
142
149
  .option("config-dir", { type: "string" }), async (argv) => {
143
150
  await cmdFriendsReject({ userid: argv.userid, configDir: argv["config-dir"] });
151
+ })
152
+ .demandCommand(1, "Specify a friends subcommand (run 'agentnet friends --help')"), () => {
153
+ // parent handler — never invoked because demandCommand above
144
154
  })
145
155
  .command("audit log", "View audit log", (y) => y.option("tail", { type: "number", default: 50 }).option("config-dir", { type: "string" }), async (argv) => {
146
156
  await cmdAuditLog({ tail: argv.tail, configDir: argv["config-dir"] });
@@ -20,6 +20,7 @@ export interface PacketRouterOptions {
20
20
  export declare class PacketRouter extends EventEmitter {
21
21
  private tunDevice;
22
22
  private peerManager;
23
+ private ipam;
23
24
  private acl;
24
25
  private sessionManager;
25
26
  private logger;
@@ -54,6 +55,16 @@ export declare class PacketRouter extends EventEmitter {
54
55
  */
55
56
  private sendKeepalivesToActiveSessions;
56
57
  getStats(): ForwardingStats;
58
+ /**
59
+ * Add (or correct) a (carrierId, virtualIp) mapping in IPAM based on
60
+ * an authenticated inbound packet. Idempotent and cheap — does
61
+ * nothing when the mapping already matches.
62
+ *
63
+ * The IP can change for a single peer (dora re-allocation, daemon
64
+ * restart with a different fallback IP) so we always overwrite on
65
+ * mismatch rather than first-write-wins.
66
+ */
67
+ private learnPeerMapping;
57
68
  /**
58
69
  * Record a drop and bump per-destination diagnostics. Logs the FIRST
59
70
  * occurrence of each (dstIp, reason) at info level so the failure mode
@@ -9,9 +9,11 @@ import { EventEmitter } from "events";
9
9
  import { IpParser } from "./ip-parser.js";
10
10
  import { SessionManager } from "./session-manager.js";
11
11
  import { Logger } from "../utils/logger.js";
12
+ const SUBNET_PREFIX = "10.86.";
12
13
  export class PacketRouter extends EventEmitter {
13
14
  tunDevice;
14
15
  peerManager;
16
+ ipam;
15
17
  acl;
16
18
  sessionManager;
17
19
  logger;
@@ -36,6 +38,7 @@ export class PacketRouter extends EventEmitter {
36
38
  super();
37
39
  this.tunDevice = opts.tunDevice;
38
40
  this.peerManager = opts.peerManager;
41
+ this.ipam = opts.ipam;
39
42
  this.acl = opts.acl;
40
43
  this.sessionManager = new SessionManager({
41
44
  peerManager: opts.peerManager,
@@ -154,6 +157,33 @@ export class PacketRouter extends EventEmitter {
154
157
  dropsByDst,
155
158
  };
156
159
  }
160
+ /**
161
+ * Add (or correct) a (carrierId, virtualIp) mapping in IPAM based on
162
+ * an authenticated inbound packet. Idempotent and cheap — does
163
+ * nothing when the mapping already matches.
164
+ *
165
+ * The IP can change for a single peer (dora re-allocation, daemon
166
+ * restart with a different fallback IP) so we always overwrite on
167
+ * mismatch rather than first-write-wins.
168
+ */
169
+ learnPeerMapping(srcPubkey, srcIp) {
170
+ const existing = this.ipam.resolveCarrierId(srcPubkey);
171
+ if (existing && existing.virtualIp === srcIp)
172
+ return;
173
+ // Skip our own pubkey — we already own our IPAM entry.
174
+ if (srcPubkey === this.peerManager.getPubkey())
175
+ return;
176
+ const name = existing?.name ?? `peer-${srcPubkey.slice(0, 8)}`;
177
+ this.ipam.assignPeer({
178
+ name,
179
+ carrierId: srcPubkey,
180
+ virtualIp: srcIp,
181
+ services: existing?.services ?? [],
182
+ });
183
+ this.logger.info(existing
184
+ ? `IPAM updated from inbound: ${name} ${existing.virtualIp} -> ${srcIp}`
185
+ : `IPAM learned from inbound: ${name} (${srcPubkey.slice(0, 12)}...) -> ${srcIp}`);
186
+ }
157
187
  /**
158
188
  * Record a drop and bump per-destination diagnostics. Logs the FIRST
159
189
  * occurrence of each (dstIp, reason) at info level so the failure mode
@@ -253,6 +283,25 @@ export class PacketRouter extends EventEmitter {
253
283
  this.logger.debug("Dropped invalid incoming IP packet");
254
284
  return;
255
285
  }
286
+ // Auto-learn (srcPubkey -> srcIp) into IPAM. Without this, ubuntu
287
+ // can receive cn's ICMP fine but the reply path fails: ubuntu's
288
+ // outbound handleOutgoingPacket calls ipam.resolveIp("10.86.1.15")
289
+ // which returns null when ubuntu's dora roster sync is broken
290
+ // (dora friend offline, no other source for cn's virtualIp).
291
+ //
292
+ // We trust the (srcPubkey, srcIp) pairing for two reasons:
293
+ // 1. srcPubkey is Carrier-authenticated — only the real cn can
294
+ // open a PacketSession against ubuntu under cn's userid.
295
+ // 2. The only damage a lying sender could cause is shadowing
296
+ // their OWN reply routing — the IP is the destination for
297
+ // packets WE send back to THEM, no third party affected.
298
+ //
299
+ // Scope to the agentnet subnet (10.86.0.0/16) so a buggy peer
300
+ // leaking RFC1918 traffic through the TUN can't pollute our IPAM
301
+ // with 192.168.x.x entries.
302
+ if (parsed.srcIp.startsWith(SUBNET_PREFIX)) {
303
+ this.learnPeerMapping(srcPubkey, parsed.srcIp);
304
+ }
256
305
  const proto = IpParser.protoName(parsed.protocol);
257
306
  // ACL check (inbound)
258
307
  if (parsed.dstPort !== undefined && (proto === "tcp" || proto === "udp")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",