@decentnetwork/lan 0.1.250 → 0.1.252

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * CLI command handlers
3
3
  */
4
+ import type { BootstrapNode } from "../types.js";
4
5
  /**
5
6
  * Initialize ~/.agentnet directory and config
6
7
  */
@@ -516,3 +517,46 @@ export declare function cmdServiceRestart(args: {
516
517
  export declare function cmdRestart(args: {
517
518
  configDir?: string;
518
519
  }): Promise<void>;
520
+ /**
521
+ * Parse the published feed's node array into our BootstrapNode shape.
522
+ *
523
+ * The feed uses the native SDK's field names (`ipv4`, string `port`,
524
+ * `publicKey`); we use `host`/number `port`/`pk`. Both spellings are accepted
525
+ * so the same command works against a hand-written file too.
526
+ *
527
+ * Invalid entries are skipped and reported rather than aborting the whole
528
+ * update — one bad row in the feed shouldn't block adopting the other 14.
529
+ */
530
+ declare function parseFeedNodes(raw: unknown, rejected: string[]): BootstrapNode[];
531
+ /** Test seam — the feed parser is a trust boundary and gets pinned by
532
+ * test/unit/bootstrap-feed.test.ts without going over the network. */
533
+ export declare const __testParseFeedNodes: typeof parseFeedNodes;
534
+ /**
535
+ * `agentnet bootstrap update` — pull the published bootstrap list and adopt any
536
+ * node the installed build doesn't ship.
537
+ *
538
+ * Why this exists: `npm install -g @decentnetwork/lan@latest` updates the
539
+ * BUILT-IN defaults (config.yaml's own `bootstrapNodes:` block is ignored at
540
+ * load time — see ConfigLoader.normalizeConfig), so upgrading does refresh
541
+ * bootstraps. But that only ever gives you the list as of the last release. When
542
+ * we stand up a bootstrap mid-cycle, every node has to wait for an npm publish.
543
+ * This command closes that gap: fetch the live feed, append what's new to
544
+ * `carrier.extraBootstrapNodes`, and the next daemon start picks it up.
545
+ */
546
+ export declare function cmdBootstrapUpdate(args: {
547
+ configDir?: string;
548
+ url?: string;
549
+ dryRun?: boolean;
550
+ clear?: boolean;
551
+ }): Promise<void>;
552
+ /**
553
+ * `agentnet bootstrap show` — print the list the daemon will actually use,
554
+ * flagging which entries are shipped defaults vs. adopted from a feed.
555
+ *
556
+ * Worth its own command because the `bootstrapNodes:` block sitting in
557
+ * config.yaml is NOT what gets used — it's overwritten by the shipped defaults
558
+ * on every load, so reading the file gives the wrong answer.
559
+ */
560
+ export declare function cmdBootstrapShow(args: {
561
+ configDir?: string;
562
+ }): Promise<void>;
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, copyFileSync } from "fs";
6
6
  import { createConnection } from "net";
7
7
  import { createRequire } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { ConfigLoader, DEFAULT_DORAS, DEFAULT_EXITS } from "../config/loader.js";
9
+ import { ConfigLoader, DEFAULT_BOOTSTRAP_NODES, DEFAULT_DORAS, DEFAULT_EXITS, mergeBootstrapNodes, } from "../config/loader.js";
10
10
  import { ipcSocketPath, ipcSocketSupportsFsExistenceCheck, } from "../daemon/ipc.js";
11
11
  import { DaemonServer } from "../daemon/server.js";
12
12
  import { Ipam } from "../ipam/ipam.js";
@@ -2543,3 +2543,208 @@ export async function cmdRestart(args) {
2543
2543
  console.log(`Daemon scheduled for self-restart. New process will exec: ${(data.argv || []).join(" ")}`);
2544
2544
  console.log("Allow ~5s for the daemon to come back up. Verify with 'agentnet diag'.");
2545
2545
  }
2546
+ // ---------------------------------------------------------------------------
2547
+ // bootstrap feed
2548
+ // ---------------------------------------------------------------------------
2549
+ /**
2550
+ * Where the fleet's live bootstrap list is published. Same file the Beagle web
2551
+ * app reads, so app and daemon converge on one source of truth instead of
2552
+ * drifting apart (2026-07-25: the app's list had lens/gojipower/gfax while the
2553
+ * npm defaults didn't, and vice versa).
2554
+ */
2555
+ const DEFAULT_BOOTSTRAP_FEED_URL = "https://beagle.chat/assets/bgservers.json";
2556
+ /** Carrier public keys are base58-encoded 32-byte keys → 43-44 chars. */
2557
+ const BASE58_KEY_RE = /^[1-9A-HJ-NP-Za-km-z]{40,50}$/;
2558
+ /** IPv4 literal or hostname. Deliberately strict: this is remote input that
2559
+ * becomes network config, so anything with a scheme, path, port suffix,
2560
+ * whitespace or credentials is rejected rather than normalized. */
2561
+ const HOST_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
2562
+ /** Upper bound on how many nodes a feed may install. A bootstrap list is a
2563
+ * dozen or so entries; anything near this is a malformed or hostile feed. */
2564
+ const MAX_FEED_NODES = 64;
2565
+ /**
2566
+ * Parse the published feed's node array into our BootstrapNode shape.
2567
+ *
2568
+ * The feed uses the native SDK's field names (`ipv4`, string `port`,
2569
+ * `publicKey`); we use `host`/number `port`/`pk`. Both spellings are accepted
2570
+ * so the same command works against a hand-written file too.
2571
+ *
2572
+ * Invalid entries are skipped and reported rather than aborting the whole
2573
+ * update — one bad row in the feed shouldn't block adopting the other 14.
2574
+ */
2575
+ function parseFeedNodes(raw, rejected) {
2576
+ if (!Array.isArray(raw))
2577
+ return [];
2578
+ const out = [];
2579
+ for (const entry of raw.slice(0, MAX_FEED_NODES)) {
2580
+ if (!entry || typeof entry !== "object") {
2581
+ rejected.push(`${JSON.stringify(entry)} (not an object)`);
2582
+ continue;
2583
+ }
2584
+ const e = entry;
2585
+ const host = typeof e.ipv4 === "string" ? e.ipv4 : typeof e.host === "string" ? e.host : "";
2586
+ const portRaw = e.port;
2587
+ const port = typeof portRaw === "number"
2588
+ ? portRaw
2589
+ : typeof portRaw === "string"
2590
+ ? Number.parseInt(portRaw, 10)
2591
+ : NaN;
2592
+ const pk = typeof e.publicKey === "string" ? e.publicKey : typeof e.pk === "string" ? e.pk : "";
2593
+ if (!HOST_RE.test(host)) {
2594
+ rejected.push(`${host || "(no host)"} — bad host`);
2595
+ continue;
2596
+ }
2597
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
2598
+ rejected.push(`${host} — bad port ${String(portRaw)}`);
2599
+ continue;
2600
+ }
2601
+ if (!BASE58_KEY_RE.test(pk)) {
2602
+ rejected.push(`${host}:${port} — bad public key`);
2603
+ continue;
2604
+ }
2605
+ out.push({ host, port, pk });
2606
+ }
2607
+ if (Array.isArray(raw) && raw.length > MAX_FEED_NODES) {
2608
+ rejected.push(`(${raw.length - MAX_FEED_NODES} further entries ignored — feed over ${MAX_FEED_NODES} nodes)`);
2609
+ }
2610
+ return out;
2611
+ }
2612
+ /** Test seam — the feed parser is a trust boundary and gets pinned by
2613
+ * test/unit/bootstrap-feed.test.ts without going over the network. */
2614
+ export const __testParseFeedNodes = parseFeedNodes;
2615
+ /**
2616
+ * `agentnet bootstrap update` — pull the published bootstrap list and adopt any
2617
+ * node the installed build doesn't ship.
2618
+ *
2619
+ * Why this exists: `npm install -g @decentnetwork/lan@latest` updates the
2620
+ * BUILT-IN defaults (config.yaml's own `bootstrapNodes:` block is ignored at
2621
+ * load time — see ConfigLoader.normalizeConfig), so upgrading does refresh
2622
+ * bootstraps. But that only ever gives you the list as of the last release. When
2623
+ * we stand up a bootstrap mid-cycle, every node has to wait for an npm publish.
2624
+ * This command closes that gap: fetch the live feed, append what's new to
2625
+ * `carrier.extraBootstrapNodes`, and the next daemon start picks it up.
2626
+ */
2627
+ export async function cmdBootstrapUpdate(args) {
2628
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
2629
+ const configPath = resolve(dir, "config.yaml");
2630
+ const config = await ConfigLoader.load(configPath);
2631
+ if (args.clear) {
2632
+ const had = config.carrier.extraBootstrapNodes?.length ?? 0;
2633
+ delete config.carrier.extraBootstrapNodes;
2634
+ delete config.carrier.bootstrapSource;
2635
+ config.carrier.bootstrapNodes = [...DEFAULT_BOOTSTRAP_NODES];
2636
+ await ConfigLoader.save(config, configPath);
2637
+ console.log(`Cleared ${had} extra bootstrap node(s); back to the ${DEFAULT_BOOTSTRAP_NODES.length} shipped defaults.`);
2638
+ console.log("Takes effect on the next daemon start ('agentnet restart').");
2639
+ return;
2640
+ }
2641
+ const url = args.url || DEFAULT_BOOTSTRAP_FEED_URL;
2642
+ // Remote config over plain HTTP is trivially rewritable in transit, and the
2643
+ // payload here decides which nodes we hand our DHT traffic to.
2644
+ if (!url.startsWith("https://")) {
2645
+ console.error(`Refusing to fetch bootstrap config over a non-HTTPS URL: ${url}`);
2646
+ process.exit(1);
2647
+ }
2648
+ console.log(`Fetching ${url} ...`);
2649
+ let payload;
2650
+ try {
2651
+ const res = await fetch(url, {
2652
+ signal: AbortSignal.timeout(20_000),
2653
+ headers: { accept: "application/json" },
2654
+ });
2655
+ if (!res.ok)
2656
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
2657
+ payload = await res.json();
2658
+ }
2659
+ catch (error) {
2660
+ // A feed that's down or unreachable is an ordinary outcome (offline node,
2661
+ // GFW, publish in flight) — report it and leave the config alone. The
2662
+ // daemon keeps running on the shipped defaults either way.
2663
+ console.error(`Could not fetch the bootstrap feed (${url}): ${error instanceof Error ? error.message : String(error)}`);
2664
+ console.error("Config unchanged — the built-in defaults still apply.");
2665
+ process.exit(1);
2666
+ }
2667
+ const feed = payload;
2668
+ const rejected = [];
2669
+ const fetched = parseFeedNodes(feed?.bootstrapNodes, rejected);
2670
+ if (fetched.length === 0) {
2671
+ console.error(`Feed had no usable bootstrapNodes (${rejected.length} rejected).`);
2672
+ for (const bad of rejected.slice(0, 10))
2673
+ console.error(` ! ${bad}`);
2674
+ console.error("Config unchanged.");
2675
+ process.exit(1);
2676
+ }
2677
+ const shipped = new Set(DEFAULT_BOOTSTRAP_NODES.map((n) => `${n.host}:${n.port}`));
2678
+ const extras = fetched.filter((n) => !shipped.has(`${n.host}:${n.port}`));
2679
+ const previous = config.carrier.extraBootstrapNodes ?? [];
2680
+ const previousKeys = new Set(previous.map((n) => `${n.host}:${n.port}`));
2681
+ const added = extras.filter((n) => !previousKeys.has(`${n.host}:${n.port}`));
2682
+ const dropped = previous.filter((n) => !extras.some((e) => e.host === n.host && e.port === n.port));
2683
+ console.log(`Feed: ${fetched.length} node(s) — ${fetched.length - extras.length} already shipped in this build, ${extras.length} extra.`);
2684
+ for (const node of extras) {
2685
+ const mark = previousKeys.has(`${node.host}:${node.port}`) ? " " : "+";
2686
+ console.log(` ${mark} ${node.host}:${node.port} ${node.pk}`);
2687
+ }
2688
+ for (const node of dropped) {
2689
+ console.log(` - ${node.host}:${node.port} ${node.pk} (no longer in feed)`);
2690
+ }
2691
+ for (const bad of rejected) {
2692
+ console.log(` ! skipped ${bad}`);
2693
+ }
2694
+ if (args.dryRun) {
2695
+ console.log("\n--dry-run: config not written.");
2696
+ return;
2697
+ }
2698
+ if (added.length === 0 && dropped.length === 0) {
2699
+ console.log("\nAlready up to date — config unchanged.");
2700
+ return;
2701
+ }
2702
+ if (extras.length > 0) {
2703
+ config.carrier.extraBootstrapNodes = extras;
2704
+ config.carrier.bootstrapSource = { url, fetchedAt: new Date().toISOString() };
2705
+ }
2706
+ else {
2707
+ delete config.carrier.extraBootstrapNodes;
2708
+ delete config.carrier.bootstrapSource;
2709
+ }
2710
+ config.carrier.bootstrapNodes = mergeBootstrapNodes([...DEFAULT_BOOTSTRAP_NODES], config.carrier.extraBootstrapNodes);
2711
+ await ConfigLoader.save(config, configPath);
2712
+ console.log(`\nWrote ${configPath}: ${added.length} added, ${dropped.length} removed → ${config.carrier.bootstrapNodes.length} bootstrap node(s) total.`);
2713
+ console.log("Takes effect on the next daemon start — 'agentnet restart' (no sudo needed).");
2714
+ }
2715
+ /**
2716
+ * `agentnet bootstrap show` — print the list the daemon will actually use,
2717
+ * flagging which entries are shipped defaults vs. adopted from a feed.
2718
+ *
2719
+ * Worth its own command because the `bootstrapNodes:` block sitting in
2720
+ * config.yaml is NOT what gets used — it's overwritten by the shipped defaults
2721
+ * on every load, so reading the file gives the wrong answer.
2722
+ */
2723
+ export async function cmdBootstrapShow(args) {
2724
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
2725
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
2726
+ const extraKeys = new Set((config.carrier.extraBootstrapNodes ?? []).map((n) => `${n.host}:${n.port}`));
2727
+ console.log(`Effective bootstrap nodes (${config.carrier.bootstrapNodes.length}):`);
2728
+ for (const node of config.carrier.bootstrapNodes) {
2729
+ const origin = extraKeys.has(`${node.host}:${node.port}`) ? "feed" : "shipped";
2730
+ console.log(` ${node.host}:${node.port}`.padEnd(28) + `${node.pk} [${origin}]`);
2731
+ }
2732
+ if (config.carrier.bootstrapSource) {
2733
+ console.log(`\nFeed: ${config.carrier.bootstrapSource.url} (fetched ${config.carrier.bootstrapSource.fetchedAt})`);
2734
+ }
2735
+ else {
2736
+ console.log(`\nNo feed adopted — run 'agentnet bootstrap update' to pull ${DEFAULT_BOOTSTRAP_FEED_URL}.`);
2737
+ }
2738
+ // The live daemon may predate the current config; say so rather than letting
2739
+ // the operator assume the printed list is what's running.
2740
+ const pid = daemonPid(config);
2741
+ if (pid !== null) {
2742
+ const res = await ipcCall(config, { op: "diag" });
2743
+ if (res.ok) {
2744
+ const live = res.data?.dht?.bootstrapsConfigured;
2745
+ if (typeof live === "number" && live !== config.carrier.bootstrapNodes.length) {
2746
+ console.log(`\nNOTE: the running daemon (pid ${pid}) has ${live} bootstrap(s) loaded, not ${config.carrier.bootstrapNodes.length}. Restart it to apply: 'agentnet restart'.`);
2747
+ }
2748
+ }
2749
+ }
2750
+ }
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, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, 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, cmdServiceInstall, 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")
@@ -103,6 +103,40 @@ async function main() {
103
103
  })
104
104
  .command("doctor", "Self-check: diagnoses config/daemon/dora/peer problems and prints the fix for each", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
105
105
  await cmdDoctor({ configDir: argv["config-dir"] });
106
+ })
107
+ // An npm upgrade refreshes the BUILT-IN bootstrap defaults, but only to
108
+ // whatever the release shipped with. When a bootstrap is deployed
109
+ // mid-cycle, this pulls it from the published feed so nodes don't have to
110
+ // wait for the next publish.
111
+ .command("bootstrap", "Show or refresh the Carrier bootstrap node list", (y) => y
112
+ .command("show", "Print the bootstrap nodes the daemon will actually use", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
113
+ await cmdBootstrapShow({ configDir: argv["config-dir"] });
114
+ })
115
+ .command("update", "Fetch the published server list and adopt any bootstrap this build doesn't ship", (yy) => yy
116
+ .option("url", {
117
+ type: "string",
118
+ describe: "Feed URL (must be https). Defaults to the published beagle.chat list",
119
+ })
120
+ .option("dry-run", {
121
+ type: "boolean",
122
+ default: false,
123
+ describe: "Show what would change without writing the config",
124
+ })
125
+ .option("clear", {
126
+ type: "boolean",
127
+ default: false,
128
+ describe: "Drop all feed-adopted nodes and go back to the shipped defaults",
129
+ })
130
+ .option("config-dir", { type: "string" }), async (argv) => {
131
+ await cmdBootstrapUpdate({
132
+ url: argv.url,
133
+ dryRun: argv["dry-run"],
134
+ clear: argv.clear,
135
+ configDir: argv["config-dir"],
136
+ });
137
+ })
138
+ .demandCommand(1, "Specify a bootstrap subcommand: show | update"), () => {
139
+ // parent handler — never invoked because demandCommand above
106
140
  })
107
141
  .command("dns", "Manage OS-side DNS resolver wiring for the .decent zone", (y) => y
108
142
  .command("install", "Route .decent queries to the local daemon (writes /etc/resolver on macOS or resolvectl on Linux)", (yy) => yy
@@ -1,4 +1,20 @@
1
- import type { DecentAgentNetConfig } from "../types.js";
1
+ import type { BootstrapNode, DecentAgentNetConfig } from "../types.js";
2
+ export declare const DEFAULT_BOOTSTRAP_NODES: {
3
+ host: string;
4
+ port: number;
5
+ pk: string;
6
+ }[];
7
+ /**
8
+ * Shipped defaults first, then any node the operator (or `agentnet bootstrap
9
+ * update`) added that we don't already ship, deduped on host:port.
10
+ *
11
+ * Extras are APPENDED, never interleaved: the ordering of
12
+ * DEFAULT_BOOTSTRAP_NODES is deliberate (US relays first so a US↔US session
13
+ * doesn't land on a China relay — see the comment above it), and a remote feed
14
+ * has no idea where this node is. So a fetched list can add reachability, but
15
+ * it can't rearrange the relay-landing preference we tuned.
16
+ */
17
+ export declare function mergeBootstrapNodes(base: BootstrapNode[], extra?: BootstrapNode[]): BootstrapNode[];
2
18
  /**
3
19
  * Public dora servers baked into `agentnet init` so an operator can join
4
20
  * the canonical Decent AgentNet without first hunting down a registry
@@ -9,43 +9,90 @@ const DEFAULT_CONFIG_FILE = resolve(DEFAULT_CONFIG_DIR, "config.yaml");
9
9
  // Mirrored from peer SDK's legacy-bootstraps.mjs
10
10
  // 45.207.220.155 omitted — observed timing out repeatedly during testing,
11
11
  // causing relay#2 to flap which knocks the friend connection offline.
12
- // Ordered with US relays first so a US↔US session (the common case)
13
- // doesn't land on a China relay just because it was first in the list.
14
- // Observed in the wild: both Vultr (US public IP) and a Mac (US
15
- // residential) connected to 47.100.103.201 (Alibaba Cloud Shanghai)
16
- // as their sole TCP relay every packet between them transited
17
- // China, ~600ms RTT, and net_crypto handshakes often dropped because
18
- // the path was lossy. Reordering puts AWS US-East/US-West first so
19
- // the SDK's "connect to top-N relays" path naturally picks closer
20
- // nodes. The China + Singapore nodes stay in the list as fallback for
21
- // peers actually in those regions.
22
- const DEFAULT_BOOTSTRAP_NODES = [
12
+ //
13
+ // ORDER IS LOAD-BEARING, and more than the old comment implied. The SDK's TCP
14
+ // relay pool takes the FIRST 3 entries positionally (TcpRelayPool.start(),
15
+ // DECENT_TCP_MAX_RELAYS default 3) not a random sample, not by health. So
16
+ // every node running the same release dials the SAME three hosts, and those
17
+ // three are effectively the fleet's rendezvous set. Two consequences:
18
+ //
19
+ // 1. A host in the top 3 that doesn't accept TCP burns one of only three
20
+ // relay slots on every node in the world. That is exactly what happened
21
+ // when gojipower was added at position 2 in 0.1.249: air-wli went from 6
22
+ // connected relays to 2 after picking up the new list, because gojipower's
23
+ // ufw has no rule for 33445 (verified 2026-07-25 — `ufw status` shows the
24
+ // port absent while ela-bootstrapd LISTENs locally: alive != serving).
25
+ // 2. A China relay in the top 3 puts a US<->US session on a China path.
26
+ // Observed in the wild: a Vultr US box and a US residential Mac both
27
+ // landed on 47.100.103.201 (Alibaba Shanghai) as their sole relay —
28
+ // ~600ms RTT and lossy enough to drop net_crypto handshakes.
29
+ //
30
+ // So: positions 1-3 are US-reachable hosts whose TCP relay port is CONFIRMED
31
+ // open; everything after is DHT-bootstrap breadth and the reserve pool that
32
+ // the dynamic-relay-add path draws from when a friend advertises one.
33
+ // TCP-relay reachability below was measured from a US vantage point on
34
+ // 2026-07-25. "TCP filtered" does NOT mean dead — several still answer UDP
35
+ // DHT, and CN-side reachability was not re-tested (see docs/BOOTSTRAP-FLEET.md
36
+ // on why a single vantage point must not be used to prune).
37
+ export const DEFAULT_BOOTSTRAP_NODES = [
38
+ // --- Rendezvous set: the three every node dials. TCP verified open. ---
39
+ // US-East — the historical workhorse, and the relay mac-dev has held
40
+ // continuously through every incident this month.
41
+ { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
23
42
  // lens (Vultr US) — OUR OWN ela-bootstrapd, same box as the express relay.
24
43
  // It was in the iOS app's bootstrap list but missing here, which is a big
25
44
  // part of why natives could always bootstrap while JS peers struggled
26
45
  // (2026-07-25 discovery-outage postmortem: docs/BOOTSTRAP-FLEET.md).
27
46
  { host: "144.202.113.167", port: 33445, pk: "EfT4YMq6qfHdDsCiBCgsEmA78E2NxVYVKVUS9bD6w9GH" },
28
- // gojipower (Vultr US) — ours, deployed 2026-07-25, systemd + rcvbuf tuned.
29
- { host: "149.28.98.141", port: 33445, pk: "81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7" },
47
+ // US-East.
48
+ { host: "18.216.6.197", port: 33445, pk: "H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2" },
49
+ // --- Reserve pool. TCP verified open; reached via the dynamic-relay-add
50
+ // path when a friend advertises them, and used for DHT bootstrap breadth.
51
+ { host: "52.57.248.163", port: 33445, pk: "CfJLve8FNQPQJ9xYQ8oEVkPxeAPCN7iSdhnYFkWmWgLn" }, // eu-central
52
+ { host: "35.179.41.220", port: 33445, pk: "6u2vKadPa9wqDf531QaZy7FJN3c7Wzntm7dKXxXqvyNB" }, // eu-west
53
+ { host: "52.63.19.190", port: 33445, pk: "EeNenbyS4sx3qtu82esT1V1NMe9dZib5LyQmYGM6fboK" }, // ap-southeast
54
+ // CN — ours. Nearest for peers actually in China; deliberately NOT in the
55
+ // top 3 so US<->US traffic can't land here as its only relay.
56
+ { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" }, // sh
30
57
  // gfax (Aliyun CN) — ours, running since 2026-07-02, moved under systemd
31
- // 2026-07-25 (identity preserved). Nearest bootstrap for CN peers.
58
+ // 2026-07-25 (identity preserved).
32
59
  { host: "139.129.193.117", port: 33445, pk: "67UQhssARwMky1YgFA2oDGZ1RiQYQmS6JqWFHxtopKq5" },
33
- // US-East closest for typical North-American peers.
34
- { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
60
+ // --- TCP filtered from the US as of 2026-07-25. Kept for UDP DHT bootstrap
61
+ // and in case they serve other regions; must not occupy a relay slot.
62
+ // gojipower (Vultr US) — ours, deployed 2026-07-25. Demote until its ufw
63
+ // allows 33445; promote back to the rendezvous set once verified.
64
+ { host: "149.28.98.141", port: 33445, pk: "81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7" },
35
65
  { host: "18.216.102.47", port: 33445, pk: "G5z8MqiNDFTadFUPfMdYsYtkUDbX5mNCMVHMZtsCnFeb" },
36
- { host: "18.216.6.197", port: 33445, pk: "H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2" },
37
- // US-West.
38
66
  { host: "54.193.141.205", port: 33445, pk: "7TfZWZNV8vnBxxWzJXuvKgX2QyKkLpg2oXx3LQ5tg8LW" },
39
- // Unknown / global.
40
67
  { host: "154.64.235.176", port: 33445, pk: "GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL" },
41
- // Asia-Pacific (Singapore + China). Kept as fallback so peers
42
- // actually in CN/SG can use a nearby relay. Peers in the US should
43
- // not be hitting these for normal traffic.
44
68
  { host: "52.74.215.181", port: 33445, pk: "Xv6d34WaUw9bPn7YihzVAFw7D2igbQJZ3jwmzzfYVFV" },
45
- { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" },
46
69
  { host: "52.83.171.135", port: 443, pk: "5tuHgK1Q4CYf4K5PutsEPK5E3Z7cbtEBdx7LwmdzqXHL" },
47
70
  { host: "52.83.191.228", port: 33445, pk: "3khtxZo89SBScAMaHhTvD68pPHiKxgZT6hTCSZZVgNEm" },
48
71
  ];
72
+ /**
73
+ * Shipped defaults first, then any node the operator (or `agentnet bootstrap
74
+ * update`) added that we don't already ship, deduped on host:port.
75
+ *
76
+ * Extras are APPENDED, never interleaved: the ordering of
77
+ * DEFAULT_BOOTSTRAP_NODES is deliberate (US relays first so a US↔US session
78
+ * doesn't land on a China relay — see the comment above it), and a remote feed
79
+ * has no idea where this node is. So a fetched list can add reachability, but
80
+ * it can't rearrange the relay-landing preference we tuned.
81
+ */
82
+ export function mergeBootstrapNodes(base, extra) {
83
+ if (!extra?.length)
84
+ return base;
85
+ const seen = new Set(base.map((n) => `${n.host}:${n.port}`));
86
+ const merged = [...base];
87
+ for (const node of extra) {
88
+ const key = `${node.host}:${node.port}`;
89
+ if (seen.has(key))
90
+ continue;
91
+ seen.add(key);
92
+ merged.push(node);
93
+ }
94
+ return merged;
95
+ }
49
96
  const DEFAULT_EXPRESS_NODES = [
50
97
  // lens stays PRIMARY for backward-compat: older clients (peer < 0.1.25)
51
98
  // only know lens and pull from the first relay, so replies must land on
@@ -282,7 +329,11 @@ export class ConfigLoader {
282
329
  const dir = dirname(path);
283
330
  // Create directory if it doesn't exist
284
331
  mkdirSync(dir, { recursive: true });
285
- const content = yaml.dump(config, { lineWidth: -1 });
332
+ // noRefs: config.yaml is hand-edited. Any object that appears twice in the
333
+ // tree (e.g. a node in both `bootstrapNodes` and `extraBootstrapNodes`)
334
+ // would otherwise be written as a YAML anchor/alias — valid, but it turns
335
+ // the list into `- *ref_0` and silently couples two entries under editing.
336
+ const content = yaml.dump(config, { lineWidth: -1, noRefs: true });
286
337
  const fs = await import("fs/promises");
287
338
  await fs.writeFile(path, content, "utf-8");
288
339
  }
@@ -304,7 +355,19 @@ export class ConfigLoader {
304
355
  // pins a US peer to a China relay forever even after we
305
356
  // reorder defaults, and every install upgrade keeps the bad
306
357
  // first-relay landing pattern.
307
- bootstrapNodes: defaults.carrier.bootstrapNodes,
358
+ //
359
+ // `extraBootstrapNodes` is the escape hatch: nodes we don't ship yet
360
+ // (published feed, or a private deployment) get APPENDED to the
361
+ // defaults rather than replacing them, so an operator can adopt a
362
+ // freshly-deployed bootstrap without an npm release and still track
363
+ // our ordering. `agentnet bootstrap update` writes that field.
364
+ bootstrapNodes: mergeBootstrapNodes(defaults.carrier.bootstrapNodes, config.carrier?.extraBootstrapNodes),
365
+ ...(config.carrier?.extraBootstrapNodes?.length
366
+ ? { extraBootstrapNodes: config.carrier.extraBootstrapNodes }
367
+ : {}),
368
+ ...(config.carrier?.bootstrapSource
369
+ ? { bootstrapSource: config.carrier.bootstrapSource }
370
+ : {}),
308
371
  expressNodes: config.carrier?.expressNodes || defaults.carrier.expressNodes,
309
372
  },
310
373
  network: {
@@ -665,32 +665,56 @@ import yaml from "js-yaml";
665
665
  var DEFAULT_CONFIG_DIR = resolve(homedir2(), ".agentnet");
666
666
  var DEFAULT_CONFIG_FILE = resolve(DEFAULT_CONFIG_DIR, "config.yaml");
667
667
  var DEFAULT_BOOTSTRAP_NODES = [
668
+ // --- Rendezvous set: the three every node dials. TCP verified open. ---
669
+ // US-East — the historical workhorse, and the relay mac-dev has held
670
+ // continuously through every incident this month.
671
+ { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
668
672
  // lens (Vultr US) — OUR OWN ela-bootstrapd, same box as the express relay.
669
673
  // It was in the iOS app's bootstrap list but missing here, which is a big
670
674
  // part of why natives could always bootstrap while JS peers struggled
671
675
  // (2026-07-25 discovery-outage postmortem: docs/BOOTSTRAP-FLEET.md).
672
676
  { host: "144.202.113.167", port: 33445, pk: "EfT4YMq6qfHdDsCiBCgsEmA78E2NxVYVKVUS9bD6w9GH" },
673
- // gojipower (Vultr US) — ours, deployed 2026-07-25, systemd + rcvbuf tuned.
674
- { host: "149.28.98.141", port: 33445, pk: "81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7" },
677
+ // US-East.
678
+ { host: "18.216.6.197", port: 33445, pk: "H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2" },
679
+ // --- Reserve pool. TCP verified open; reached via the dynamic-relay-add
680
+ // path when a friend advertises them, and used for DHT bootstrap breadth.
681
+ { host: "52.57.248.163", port: 33445, pk: "CfJLve8FNQPQJ9xYQ8oEVkPxeAPCN7iSdhnYFkWmWgLn" },
682
+ // eu-central
683
+ { host: "35.179.41.220", port: 33445, pk: "6u2vKadPa9wqDf531QaZy7FJN3c7Wzntm7dKXxXqvyNB" },
684
+ // eu-west
685
+ { host: "52.63.19.190", port: 33445, pk: "EeNenbyS4sx3qtu82esT1V1NMe9dZib5LyQmYGM6fboK" },
686
+ // ap-southeast
687
+ // CN — ours. Nearest for peers actually in China; deliberately NOT in the
688
+ // top 3 so US<->US traffic can't land here as its only relay.
689
+ { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" },
690
+ // sh
675
691
  // gfax (Aliyun CN) — ours, running since 2026-07-02, moved under systemd
676
- // 2026-07-25 (identity preserved). Nearest bootstrap for CN peers.
692
+ // 2026-07-25 (identity preserved).
677
693
  { host: "139.129.193.117", port: 33445, pk: "67UQhssARwMky1YgFA2oDGZ1RiQYQmS6JqWFHxtopKq5" },
678
- // US-East closest for typical North-American peers.
679
- { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
694
+ // --- TCP filtered from the US as of 2026-07-25. Kept for UDP DHT bootstrap
695
+ // and in case they serve other regions; must not occupy a relay slot.
696
+ // gojipower (Vultr US) — ours, deployed 2026-07-25. Demote until its ufw
697
+ // allows 33445; promote back to the rendezvous set once verified.
698
+ { host: "149.28.98.141", port: 33445, pk: "81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7" },
680
699
  { host: "18.216.102.47", port: 33445, pk: "G5z8MqiNDFTadFUPfMdYsYtkUDbX5mNCMVHMZtsCnFeb" },
681
- { host: "18.216.6.197", port: 33445, pk: "H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2" },
682
- // US-West.
683
700
  { host: "54.193.141.205", port: 33445, pk: "7TfZWZNV8vnBxxWzJXuvKgX2QyKkLpg2oXx3LQ5tg8LW" },
684
- // Unknown / global.
685
701
  { host: "154.64.235.176", port: 33445, pk: "GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL" },
686
- // Asia-Pacific (Singapore + China). Kept as fallback so peers
687
- // actually in CN/SG can use a nearby relay. Peers in the US should
688
- // not be hitting these for normal traffic.
689
702
  { host: "52.74.215.181", port: 33445, pk: "Xv6d34WaUw9bPn7YihzVAFw7D2igbQJZ3jwmzzfYVFV" },
690
- { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" },
691
703
  { host: "52.83.171.135", port: 443, pk: "5tuHgK1Q4CYf4K5PutsEPK5E3Z7cbtEBdx7LwmdzqXHL" },
692
704
  { host: "52.83.191.228", port: 33445, pk: "3khtxZo89SBScAMaHhTvD68pPHiKxgZT6hTCSZZVgNEm" }
693
705
  ];
706
+ function mergeBootstrapNodes(base, extra) {
707
+ if (!extra?.length) return base;
708
+ const seen = new Set(base.map((n) => `${n.host}:${n.port}`));
709
+ const merged = [...base];
710
+ for (const node of extra) {
711
+ const key = `${node.host}:${node.port}`;
712
+ if (seen.has(key)) continue;
713
+ seen.add(key);
714
+ merged.push(node);
715
+ }
716
+ return merged;
717
+ }
694
718
  var DEFAULT_EXPRESS_NODES = [
695
719
  // lens stays PRIMARY for backward-compat: older clients (peer < 0.1.25)
696
720
  // only know lens and pull from the first relay, so replies must land on
@@ -885,7 +909,7 @@ var ConfigLoader = class {
885
909
  const path = filePath || DEFAULT_CONFIG_FILE;
886
910
  const dir = dirname(path);
887
911
  mkdirSync(dir, { recursive: true });
888
- const content = yaml.dump(config, { lineWidth: -1 });
912
+ const content = yaml.dump(config, { lineWidth: -1, noRefs: true });
889
913
  const fs = await import("fs/promises");
890
914
  await fs.writeFile(path, content, "utf-8");
891
915
  }
@@ -907,7 +931,18 @@ var ConfigLoader = class {
907
931
  // pins a US peer to a China relay forever even after we
908
932
  // reorder defaults, and every install upgrade keeps the bad
909
933
  // first-relay landing pattern.
910
- bootstrapNodes: defaults.carrier.bootstrapNodes,
934
+ //
935
+ // `extraBootstrapNodes` is the escape hatch: nodes we don't ship yet
936
+ // (published feed, or a private deployment) get APPENDED to the
937
+ // defaults rather than replacing them, so an operator can adopt a
938
+ // freshly-deployed bootstrap without an npm release and still track
939
+ // our ordering. `agentnet bootstrap update` writes that field.
940
+ bootstrapNodes: mergeBootstrapNodes(
941
+ defaults.carrier.bootstrapNodes,
942
+ config.carrier?.extraBootstrapNodes
943
+ ),
944
+ ...config.carrier?.extraBootstrapNodes?.length ? { extraBootstrapNodes: config.carrier.extraBootstrapNodes } : {},
945
+ ...config.carrier?.bootstrapSource ? { bootstrapSource: config.carrier.bootstrapSource } : {},
911
946
  expressNodes: config.carrier?.expressNodes || defaults.carrier.expressNodes
912
947
  },
913
948
  network: {
package/dist/types.d.ts CHANGED
@@ -119,6 +119,18 @@ export interface CarrierConfig {
119
119
  dataDir: string;
120
120
  bootstrapNodes: BootstrapNode[];
121
121
  expressNodes?: BootstrapNode[];
122
+ /** Bootstraps the shipped build doesn't know about, appended to the
123
+ * built-in defaults at load time. `agentnet bootstrap update` fills this
124
+ * from the published server feed so a node can pick up newly-deployed
125
+ * bootstraps without waiting for an npm release. Written by that command;
126
+ * hand-editing is fine too. */
127
+ extraBootstrapNodes?: BootstrapNode[];
128
+ /** Provenance for `extraBootstrapNodes` — which feed it came from and when.
129
+ * Purely informational (printed by `agentnet bootstrap show`). */
130
+ bootstrapSource?: {
131
+ url: string;
132
+ fetchedAt: string;
133
+ };
122
134
  }
123
135
  export interface NetworkConfig {
124
136
  interface: string;
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.250";
1
+ window.__DK_UI_VERSION="0.1.252";
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"/>',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.250",
3
+ "version": "0.1.252",
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",