@decentnetwork/lan 0.1.69 → 0.1.71

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
@@ -0,0 +1,34 @@
1
+ # routes.yaml — multi-region domain routing for `agentnet proxy router`.
2
+ #
3
+ # Copy to ~/.agentnet/routes.yaml (or pass --routes <path>) and the router
4
+ # sends each hostname to the right region's exits, load-balancing across the
5
+ # exits in a region for bandwidth and failing over instantly if one drops.
6
+ #
7
+ # This is what beats a single Tailscale-style exit node: Tailscale routes ALL
8
+ # traffic through ONE exit; here .cn rides your China exits, .jp rides Japan,
9
+ # US streaming rides the US exit, and everything else stays DIRECT.
10
+ #
11
+ # Each exit is named by its IPAM peer name (see `agentnet peers`), or written
12
+ # as host[:port] / label=host[:port]. Port defaults to your proxy.port (8888).
13
+
14
+ regions:
15
+ - name: china
16
+ # Several exits → traffic spreads across them for more bandwidth.
17
+ exits: [cn, node-5123, node-9595]
18
+ # `domains` omitted → uses the built-in China list (.cn, bilibili, qq, …).
19
+ # Add your own to extend it:
20
+ # domains: [".cn", ".bilibili.com", ".some-cn-only-site.com"]
21
+
22
+ - name: japan
23
+ exits: [tokyo] # the exit node you run in Japan
24
+ # domains omitted → built-in Japan list (.jp, nicovideo, abema, dmm, …)
25
+
26
+ - name: us
27
+ exits: [sjc] # the exit node you run in the US
28
+ # domains omitted → built-in US list (hulu, peacock, max, …)
29
+
30
+ # Where hosts that match no region go:
31
+ # direct — connect straight from this machine (recommended; keeps Google
32
+ # and other global sites fast and unproxied)
33
+ # <region> — a region name, to force everything else through that region
34
+ default: direct
@@ -11,6 +11,8 @@ export interface PeerManagerOptions {
11
11
  keyFile: string;
12
12
  bootstrapNodes: BootstrapNode[];
13
13
  expressNodes?: BootstrapNode[];
14
+ /** Use express only for friend-request bootstrap, never for data-plane sendText. */
15
+ expressControlPlaneOnly?: boolean;
14
16
  }
15
17
  export declare class PeerManager extends EventEmitter {
16
18
  private peer;
@@ -30,6 +30,7 @@ export class PeerManager extends EventEmitter {
30
30
  compatibilityMode: "legacy",
31
31
  bootstrapNodes: opts.bootstrapNodes,
32
32
  expressNodes: opts.expressNodes,
33
+ expressControlPlaneOnly: opts.expressControlPlaneOnly,
33
34
  });
34
35
  this.setupEventHandlers();
35
36
  this.logger.info("Peer instance created (not yet started)");
@@ -214,23 +214,13 @@ export declare function cmdProxyUse(args: {
214
214
  peer: string;
215
215
  configDir?: string;
216
216
  }): Promise<void>;
217
- /**
218
- * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
219
- * load-balances / fails over across several exit nodes, sending only China
220
- * traffic through them and everything else direct. This is the client-side
221
- * counterpart to `agentnet proxy enable` (which is the per-exit server).
222
- *
223
- * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
224
- * If none are given we auto-discover them from the daemon's live IPAM (every
225
- * known peer becomes a candidate exit on its proxy port; health checks drop
226
- * the ones that aren't actually serving a proxy).
227
- */
228
217
  export declare function cmdProxyRouter(args: {
229
218
  exit?: string[];
230
219
  port?: number;
231
220
  listen?: string;
232
221
  mode?: string;
233
222
  all?: boolean;
223
+ routes?: string;
234
224
  configDir?: string;
235
225
  }): Promise<void>;
236
226
  /**
@@ -353,6 +343,16 @@ export declare function cmdServiceInstall(args: {
353
343
  export declare function cmdServiceStatus(_args: {
354
344
  configDir?: string;
355
345
  }): Promise<void>;
346
+ /**
347
+ * Restart the system-service-managed daemon (systemd on Linux, launchd on
348
+ * macOS). Needs root, same as install. Distinct from the top-level
349
+ * `agentnet restart`, which re-execs an already-running daemon over IPC with
350
+ * no sudo — use that for picking up an upgrade; use this when the service
351
+ * itself is wedged/stopped and you want launchd/systemd to bring it back.
352
+ */
353
+ export declare function cmdServiceRestart(_args: {
354
+ configDir?: string;
355
+ }): Promise<void>;
356
356
  /**
357
357
  * Ask the running daemon to re-exec itself. Used after
358
358
  * `npm install -g @decentnetwork/lan@<new>` so the daemon picks up
@@ -11,6 +11,7 @@ import { Ipam } from "../ipam/ipam.js";
11
11
  import { Policy } from "../acl/policy.js";
12
12
  import { AclEngine } from "../acl/acl-engine.js";
13
13
  import { AuditLog } from "../acl/audit.js";
14
+ import yaml from "js-yaml";
14
15
  import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
15
16
  /**
16
17
  * Refuse to open a second Carrier peer with this identity if the
@@ -1029,55 +1030,106 @@ export async function cmdProxyUse(args) {
1029
1030
  console.log(`# agentnet proxy enable`);
1030
1031
  console.log(`# agentnet grant --peer <your-userid> --tcp ${port}`);
1031
1032
  }
1032
- /**
1033
- * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
1034
- * load-balances / fails over across several exit nodes, sending only China
1035
- * traffic through them and everything else direct. This is the client-side
1036
- * counterpart to `agentnet proxy enable` (which is the per-exit server).
1037
- *
1038
- * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
1039
- * If none are given we auto-discover them from the daemon's live IPAM (every
1040
- * known peer becomes a candidate exit on its proxy port; health checks drop
1041
- * the ones that aren't actually serving a proxy).
1042
- */
1043
1033
  export async function cmdProxyRouter(args) {
1044
1034
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
1045
1035
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1046
1036
  const defaultExitPort = config.proxy?.port ?? 8888;
1047
- const parseExit = (spec) => {
1048
- let name;
1049
- let rest = spec;
1050
- const eq = spec.indexOf("=");
1037
+ // Resolve a live name→IP map so routes.yaml / --exit can name exits by their
1038
+ // IPAM peer name instead of hard-coding virtual IPs.
1039
+ const { peers, source } = await fetchLiveIpam(config);
1040
+ const ipamByName = new Map(peers.filter((p) => p.virtualIp).map((p) => [p.name, p.virtualIp]));
1041
+ // Turn one exit token into an ExitTarget. A token is either an IPAM peer
1042
+ // name ("cn"), a host[:port] ("10.86.1.15:8888"), or "label=host[:port]".
1043
+ // IPAM names win, so "15-MacBook-Pro.local" resolves as a name, not a host.
1044
+ const resolveExit = (token, region) => {
1045
+ let label;
1046
+ let rest = token.trim();
1047
+ const eq = rest.indexOf("=");
1051
1048
  if (eq !== -1) {
1052
- name = spec.slice(0, eq);
1053
- rest = spec.slice(eq + 1);
1049
+ label = rest.slice(0, eq);
1050
+ rest = rest.slice(eq + 1);
1051
+ }
1052
+ if (ipamByName.has(rest)) {
1053
+ return { name: label || rest, host: ipamByName.get(rest), port: defaultExitPort, region };
1054
1054
  }
1055
1055
  const [host, portStr] = rest.split(":");
1056
- return { name: name || host, host, port: Number(portStr) || defaultExitPort };
1056
+ if (!host)
1057
+ return null;
1058
+ return { name: label || host, host, port: Number(portStr) || defaultExitPort, region };
1057
1059
  };
1058
- let exits = (args.exit ?? []).map(parseExit);
1059
- if (exits.length === 0) {
1060
- // Auto-discover from the daemon's live IPAM.
1061
- const { peers, source } = await fetchLiveIpam(config);
1062
- exits = peers
1060
+ const mode = args.mode === "failover" ? "failover" : "loadbalance";
1061
+ const exits = [];
1062
+ const regions = [];
1063
+ let defaultRegion = "direct";
1064
+ const cliExits = args.exit ?? [];
1065
+ const routesPath = args.routes ?? resolve(dir, "routes.yaml");
1066
+ const routesFile = existsSync(routesPath) ? (yaml.load(readFileSync(routesPath, "utf-8")) ?? null) : null;
1067
+ if (cliExits.length > 0) {
1068
+ // ---- CLI-driven: --exit name=host:port@region (region optional) --------
1069
+ // Bare exits (no @region) default to the "china" region, preserving the
1070
+ // original China-split behavior. --all overrides: one "all" region that
1071
+ // every host falls into.
1072
+ const seenRegions = new Set();
1073
+ for (const tok of cliExits) {
1074
+ const at = tok.lastIndexOf("@");
1075
+ const region = args.all ? "all" : at !== -1 ? tok.slice(at + 1) : "china";
1076
+ const spec = at !== -1 ? tok.slice(0, at) : tok;
1077
+ const ex = resolveExit(spec, region);
1078
+ if (!ex) {
1079
+ console.error(`Could not resolve exit '${tok}' (not an IPAM name and not host[:port])`);
1080
+ process.exit(1);
1081
+ }
1082
+ exits.push(ex);
1083
+ seenRegions.add(region);
1084
+ }
1085
+ // Empty domains → the router fills in BUILTIN_REGION_DOMAINS for known
1086
+ // region names (china/japan/us); "all" stays empty and is the default.
1087
+ for (const name of seenRegions)
1088
+ regions.push({ name, domains: [] });
1089
+ defaultRegion = args.all ? "all" : "direct";
1090
+ }
1091
+ else if (routesFile?.regions?.length) {
1092
+ // ---- File-driven: routes.yaml -----------------------------------------
1093
+ for (const r of routesFile.regions) {
1094
+ regions.push({ name: r.name, domains: r.domains ?? [] });
1095
+ for (const tok of r.exits ?? []) {
1096
+ const ex = resolveExit(tok, r.name);
1097
+ if (!ex) {
1098
+ console.error(`routes.yaml region '${r.name}': could not resolve exit '${tok}'`);
1099
+ process.exit(1);
1100
+ }
1101
+ exits.push(ex);
1102
+ }
1103
+ }
1104
+ defaultRegion = routesFile.default ?? "direct";
1105
+ console.log(`Loaded ${regions.length} region(s) from ${routesPath}`);
1106
+ }
1107
+ else {
1108
+ // ---- Legacy auto-discovery: all IPAM peers as China exits -------------
1109
+ const discovered = peers
1063
1110
  .filter((p) => p.virtualIp)
1064
- .map((p) => ({ name: p.name || p.virtualIp, host: p.virtualIp, port: defaultExitPort }));
1065
- if (exits.length === 0) {
1111
+ .map((p) => resolveExit(p.name || p.virtualIp, args.all ? "all" : "china"))
1112
+ .filter(Boolean);
1113
+ if (discovered.length === 0) {
1066
1114
  console.error(`No exits given and none discovered from IPAM (${source}).`);
1067
1115
  console.error(`Pass exits explicitly, e.g.:`);
1068
- console.error(` agentnet proxy router --exit 10.86.1.15 --exit 10.86.1.16 --exit 10.86.1.17`);
1116
+ console.error(` agentnet proxy router --exit cn --exit node-5123`);
1117
+ console.error(`Or define regions in ${routesPath} (see docs/INSTALL-NODES.md).`);
1069
1118
  process.exit(1);
1070
1119
  }
1071
- console.log(`Auto-discovered ${exits.length} exit(s) from IPAM (${source}): ${exits.map((e) => e.name).join(", ")}`);
1120
+ exits.push(...discovered);
1121
+ regions.push({ name: args.all ? "all" : "china", domains: [] });
1122
+ defaultRegion = args.all ? "all" : "direct";
1123
+ console.log(`Auto-discovered ${discovered.length} exit(s) from IPAM (${source}): ${discovered.map((e) => e.name).join(", ")}`);
1072
1124
  console.log(`(health checks will drop any that aren't actually running a proxy)\n`);
1073
1125
  }
1074
- const mode = args.mode === "failover" ? "failover" : "loadbalance";
1075
1126
  startMultiExitRouter({
1076
1127
  exits,
1128
+ regions,
1129
+ defaultRegion,
1077
1130
  listenHost: args.listen ?? "127.0.0.1",
1078
1131
  listenPort: args.port ?? 8889,
1079
1132
  mode,
1080
- routeAll: args.all ?? false,
1081
1133
  });
1082
1134
  // Keep the process alive; the router runs until Ctrl-C / signal.
1083
1135
  await new Promise(() => { });
@@ -1557,8 +1609,10 @@ export async function cmdServiceInstall(args) {
1557
1609
  else if (process.env.SUDO_USER && process.env.SUDO_USER !== "root") {
1558
1610
  const { execSync } = await import("child_process");
1559
1611
  try {
1560
- const sudoHome = execSync(`getent passwd ${process.env.SUDO_USER} | cut -d: -f6`, { encoding: "utf-8" }).trim();
1561
- dir = sudoHome
1612
+ // Resolve SUDO_USER's home without getent (absent on macOS). `echo ~user`
1613
+ // tilde-expands to the home dir in sh on both Linux and macOS.
1614
+ const sudoHome = execSync(`echo ~${process.env.SUDO_USER}`, { encoding: "utf-8", shell: "/bin/sh" }).trim();
1615
+ dir = sudoHome && sudoHome.startsWith("/") && !sudoHome.startsWith("~")
1562
1616
  ? `${sudoHome}/.agentnet`
1563
1617
  : ConfigLoader.defaultConfigDir();
1564
1618
  console.log(`[service install] sudo detected — using ${dir} (override with --config-dir)`);
@@ -1571,6 +1625,16 @@ export async function cmdServiceInstall(args) {
1571
1625
  dir = ConfigLoader.defaultConfigDir();
1572
1626
  }
1573
1627
  const { spawnSync, execSync } = await import("child_process");
1628
+ // System services (systemd/launchd) run with a minimal PATH that does NOT
1629
+ // include a node installed via nvm/homebrew/an unofficial build. Relying on
1630
+ // the `agentnet` shebang (#!/usr/bin/env node) then fails with
1631
+ // "env: node: No such file or directory" and the daemon never starts.
1632
+ // Invoke node by ABSOLUTE path — process.execPath is the node running this
1633
+ // very install, so it's guaranteed to exist — and add its dir to the
1634
+ // service's PATH for any child tools (ifconfig/route/etc.).
1635
+ const nodeBin = process.execPath;
1636
+ const nodeDir = dirname(nodeBin);
1637
+ const servicePath = `${nodeDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
1574
1638
  if (process.platform === "linux") {
1575
1639
  const unitPath = "/etc/systemd/system/agentnet.service";
1576
1640
  if (args.uninstall) {
@@ -1609,7 +1673,8 @@ Wants=network-online.target
1609
1673
  [Service]
1610
1674
  Type=simple
1611
1675
  User=root
1612
- ExecStart=${agentnetBin} up --real-tun --config-dir ${dir}
1676
+ Environment=PATH=${servicePath}
1677
+ ExecStart=${nodeBin} ${agentnetBin} up --real-tun --config-dir ${dir}
1613
1678
  Restart=on-failure
1614
1679
  RestartSec=5
1615
1680
  # Log to the journal so the intuitive 'journalctl -u agentnet -f' just works.
@@ -1667,12 +1732,16 @@ WantedBy=multi-user.target
1667
1732
  <key>Label</key><string>com.decentlan.agentnet</string>
1668
1733
  <key>UserName</key><string>root</string>
1669
1734
  <key>ProgramArguments</key><array>
1735
+ <string>${nodeBin}</string>
1670
1736
  <string>${agentnetBin}</string>
1671
1737
  <string>up</string>
1672
1738
  <string>--real-tun</string>
1673
1739
  <string>--config-dir</string>
1674
1740
  <string>${dir}</string>
1675
1741
  </array>
1742
+ <key>EnvironmentVariables</key><dict>
1743
+ <key>PATH</key><string>${servicePath}</string>
1744
+ </dict>
1676
1745
  <key>RunAtLoad</key><true/>
1677
1746
  <key>KeepAlive</key><true/>
1678
1747
  <key>StandardOutPath</key><string>/var/log/agentnet.log</string>
@@ -1719,6 +1788,47 @@ export async function cmdServiceStatus(_args) {
1719
1788
  }
1720
1789
  console.log(`'service status' isn't wired up for ${process.platform}.`);
1721
1790
  }
1791
+ /**
1792
+ * Restart the system-service-managed daemon (systemd on Linux, launchd on
1793
+ * macOS). Needs root, same as install. Distinct from the top-level
1794
+ * `agentnet restart`, which re-execs an already-running daemon over IPC with
1795
+ * no sudo — use that for picking up an upgrade; use this when the service
1796
+ * itself is wedged/stopped and you want launchd/systemd to bring it back.
1797
+ */
1798
+ export async function cmdServiceRestart(_args) {
1799
+ const { spawnSync } = await import("child_process");
1800
+ if (process.platform === "linux") {
1801
+ const r = spawnSync("systemctl", ["restart", "agentnet"], { stdio: "inherit" });
1802
+ if ((r.status ?? 0) !== 0) {
1803
+ throw new Error(`'systemctl restart agentnet' failed (need root? try 'sudo agentnet service restart'). ` +
1804
+ `If the service isn't installed, run 'sudo agentnet service install' first.`);
1805
+ }
1806
+ console.log("Restarted agentnet.service. Logs: journalctl -u agentnet -f");
1807
+ return;
1808
+ }
1809
+ if (process.platform === "darwin") {
1810
+ const label = "com.decentlan.agentnet";
1811
+ // kickstart -k restarts the (loaded) daemon in one call. Falls back to
1812
+ // unload+load if kickstart isn't available (older launchctl).
1813
+ const kick = spawnSync("launchctl", ["kickstart", "-k", `system/${label}`], { stdio: "inherit" });
1814
+ if ((kick.status ?? 0) === 0) {
1815
+ console.log(`Restarted ${label}. Logs: tail -f /var/log/agentnet.log`);
1816
+ return;
1817
+ }
1818
+ const plistPath = "/Library/LaunchDaemons/com.decentlan.agentnet.plist";
1819
+ if (!existsSync(plistPath)) {
1820
+ throw new Error(`${plistPath} not found — run 'sudo agentnet service install' first.`);
1821
+ }
1822
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "inherit" });
1823
+ const load = spawnSync("launchctl", ["load", plistPath], { stdio: "inherit" });
1824
+ if ((load.status ?? 0) !== 0) {
1825
+ throw new Error(`launchctl reload failed (need root? try 'sudo agentnet service restart').`);
1826
+ }
1827
+ console.log(`Restarted ${label}. Logs: tail -f /var/log/agentnet.log`);
1828
+ return;
1829
+ }
1830
+ throw new Error(`'service restart' isn't wired up for ${process.platform}.`);
1831
+ }
1722
1832
  /**
1723
1833
  * Ask the running daemon to re-exec itself. Used after
1724
1834
  * `npm install -g @decentnetwork/lan@<new>` so the daemon picks up
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
10
10
  // Belt-and-braces — also raise it here in case the CLI is run directly
11
11
  // (e.g. `node dist/cli/index.js` rather than via dist/index.js).
12
12
  EventEmitter.defaultMaxListeners = 100;
13
- import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -118,6 +118,9 @@ async function main() {
118
118
  })
119
119
  .command("status", "Show service status (systemctl status / launchctl list)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
120
120
  await cmdServiceStatus({ configDir: argv["config-dir"] });
121
+ })
122
+ .command("restart", "Restart the service via systemd/launchd (needs sudo)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
123
+ await cmdServiceRestart({ configDir: argv["config-dir"] });
121
124
  })
122
125
  .demandCommand(1, "Specify a service subcommand (run 'agentnet service --help')"), () => {
123
126
  // parent handler — never invoked because demandCommand above
@@ -266,8 +269,9 @@ async function main() {
266
269
  .option("exit", {
267
270
  type: "string",
268
271
  array: true,
269
- describe: "Exit as 'host[:port]' or 'name=host[:port]' (repeatable). Default: auto-discover from IPAM",
272
+ describe: "Exit as 'name', 'host[:port]', or 'label=host[:port]', optionally '...@region' (repeatable). Default: auto-discover from IPAM",
270
273
  })
274
+ .option("routes", { type: "string", describe: "Path to a routes.yaml (domain→region table). Default: ~/.agentnet/routes.yaml if present" })
271
275
  .option("port", { type: "number", default: 8889, describe: "Local listen port" })
272
276
  .option("listen", { type: "string", default: "127.0.0.1", describe: "Listen host (use 0.0.0.0 to reach from Windows → WSL)" })
273
277
  .option("mode", { choices: ["loadbalance", "failover"], default: "loadbalance" })
@@ -275,6 +279,7 @@ async function main() {
275
279
  .option("config-dir", { type: "string" }), async (argv) => {
276
280
  await cmdProxyRouter({
277
281
  exit: argv.exit,
282
+ routes: argv.routes,
278
283
  port: argv.port,
279
284
  listen: argv.listen,
280
285
  mode: argv.mode,
@@ -108,26 +108,30 @@ export class DaemonServer {
108
108
  // optional dora registration can decide our IP.
109
109
  const keyFile = resolve(this.config.carrier.dataDir, "keypair.json");
110
110
  this.peerManager = new PeerManager();
111
- // Daemon does NOT use express nodes. decentlan is a virtual
112
- // LAN peers must be ONLINE for IP packets to flow. Express
113
- // is an offline-message store-and-forward relay for chat-style
114
- // apps; if we enable it, sendText silently falls back to a
115
- // queue when a friend is offline. That creates the illusion of
116
- // connectivity ("the daemon says X is reachable") while
117
- // packets actually pile up in HTTPS storage and never get
118
- // forwarded in real time. For a VPN-like data plane, that's
119
- // wrong — better to fail fast and let the operator see the
120
- // peer as offline.
111
+ // The daemon enables express in CONTROL-PLANE-ONLY mode. Two
112
+ // distinct concerns were previously conflated:
121
113
  //
122
- // Friend-request bootstrap is the only thing that benefits
123
- // from express; for that case use the standalone CLI
124
- // `agentnet friend-request` (which DOES use express via its
125
- // own short-lived Peer instance) and accept the request on
126
- // both ends before bringing the daemon up.
114
+ // - DATA plane (sendText / IP packets): express must NEVER be a
115
+ // fallback. decentlan is a virtual LAN if `sendText` silently
116
+ // queued to HTTPS store-and-forward when a friend is offline, the
117
+ // daemon would report "reachable" while packets piled up undelivered.
118
+ // A VPN-like data plane must fail fast and surface the peer as
119
+ // offline. `expressControlPlaneOnly: true` enforces exactly that.
120
+ //
121
+ // - CONTROL plane (friend-request bootstrap): express IS needed. The
122
+ // dora-registration friend-request (and the 30s re-send loop in
123
+ // dora-integration) runs INSIDE this daemon. With express disabled
124
+ // here, a node whose onion announce hasn't propagated — e.g. callpass
125
+ // behind a strict NAT — could NEVER deliver its friend-request to the
126
+ // dora, so it never registered and never got a virtual IP. Routing
127
+ // operators through the standalone `agentnet friend-request` CLI was a
128
+ // manual workaround, not a fix. Giving the daemon express for the
129
+ // one-time handshake closes that gap without touching the data plane.
127
130
  await this.peerManager.create({
128
131
  keyFile,
129
132
  bootstrapNodes: this.config.carrier.bootstrapNodes,
130
- expressNodes: [],
133
+ expressNodes: this.config.carrier.expressNodes ?? [],
134
+ expressControlPlaneOnly: true,
131
135
  });
132
136
  await this.peerManager.start();
133
137
  this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
@@ -2,15 +2,35 @@ export interface ExitTarget {
2
2
  name: string;
3
3
  host: string;
4
4
  port: number;
5
+ /** Which region this exit serves (matches a RouteRegion.name). Exits with
6
+ * the same region pool together for load-balance/failover. Defaults to
7
+ * "default" when unset. */
8
+ region?: string;
9
+ }
10
+ export interface RouteRegion {
11
+ /** Region name; exits tag themselves with this to join the pool. */
12
+ name: string;
13
+ /** Host suffixes that route to this region, e.g. [".cn", ".bilibili.com"].
14
+ * A leading dot matches the domain and all subdomains; an entry without a
15
+ * leading dot matches that exact host. Empty list = this region is never
16
+ * auto-matched by hostname (only reachable as the explicit `defaultRegion`). */
17
+ domains: string[];
5
18
  }
6
19
  export interface MultiExitRouterOptions {
7
20
  exits: ExitTarget[];
21
+ /** Ordered domain→region rules. First region whose `domains` match the
22
+ * CONNECT host wins. */
23
+ regions?: RouteRegion[];
24
+ /** Where hosts that match NO region go. Either "direct" (connect from this
25
+ * machine — the safe default so Google/US sites aren't black-holed through
26
+ * a foreign exit) or the name of a region to fall back to. */
27
+ defaultRegion?: string;
8
28
  listenHost?: string;
9
29
  listenPort?: number;
10
30
  mode?: "loadbalance" | "failover";
11
- routeAll?: boolean;
12
31
  log?: (msg: string) => void;
13
32
  }
33
+ export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
14
34
  /**
15
35
  * Start the multi-exit router. Returns a stop() that closes the listener.
16
36
  * Runs until stopped (the CLI command keeps the process alive).
@@ -1,31 +1,56 @@
1
1
  //
2
- // Multi-exit client router — load-balance + failover across several exit
3
- // proxy nodes, with a China/rest split tunnel. This is the CLIENT-side
4
- // counterpart to the per-node CONNECT proxy (`agentnet proxy enable`):
5
- // you run ONE of these on your machine abroad and point your browser at it,
6
- // and it spreads HTTPS (CONNECT) traffic across all your China exits.
2
+ // Multi-exit client router — region-aware domain routing across several exit
3
+ // proxy nodes, with load-balance + failover WITHIN each region. This is the
4
+ // CLIENT-side counterpart to the per-node CONNECT proxy (`agentnet proxy
5
+ // enable`): you run ONE of these on your machine and point your browser at it,
6
+ // and it sends each hostname to the right region's exits.
7
+ //
8
+ // Why this beats a single Tailscale-style exit node:
9
+ // * Tailscale gives you ONE exit for ALL traffic (all-or-nothing).
10
+ // * Here, .cn rides your China exits, .jp rides your Japan exit, US
11
+ // streaming rides your US exit, and everything else goes DIRECT — by
12
+ // hostname, automatically.
13
+ // * Each region can have SEVERAL exits; traffic load-balances across them
14
+ // for bandwidth, and fails over instantly if one drops.
7
15
  //
8
16
  // Exposed as `agentnet proxy router` so released users get it without
9
17
  // cloning the repo (it used to live in scripts/failover-proxy.mjs).
10
18
  //
11
19
  import http from "node:http";
12
20
  import net from "node:net";
13
- // Split tunnel: ONLY these hosts go through the exits; everything else
14
- // (Google, US sites, etc.) connects DIRECT. Critical because China-blocked
15
- // sites FAIL through a China exit, and US sites are faster direct anyway.
16
- const CHINA_HOSTS = [
17
- ".cn",
18
- ".cctv.com", ".cctvpic.com", ".cntv.cn", ".cctv.cn",
19
- ".bilibili.com", ".biliapi.net", ".bilivideo.com", ".bilivideo.cn", ".hdslb.com",
20
- ".youku.com", ".iqiyi.com", ".iq.com", ".mgtv.com", ".hitv.com",
21
- ".miguvideo.com", ".cmvideo.cn", ".migu.cn",
22
- ".qq.com", ".qcloud.com", ".myqcloud.com", ".qlivecdn.com", ".qpic.cn",
23
- ".volcfcdn.com", ".volccdn.com", ".byteimg.com", ".bytedance.com",
24
- ".weibo.com", ".weibocdn.com", ".sinaimg.cn", ".youku.cn",
25
- ];
26
- function isChinaHost(host) {
21
+ // Built-in domain lists, so a user can reference a region by name in
22
+ // routes.yaml without hand-listing every domain. Override by giving the
23
+ // region its own `domains` list.
24
+ export const BUILTIN_REGION_DOMAINS = {
25
+ // China-only / China-blocked-from-outside sites — must ride a China exit.
26
+ china: [
27
+ ".cn",
28
+ ".cctv.com", ".cctvpic.com", ".cntv.cn", ".cctv.cn",
29
+ ".bilibili.com", ".biliapi.net", ".bilivideo.com", ".bilivideo.cn", ".hdslb.com",
30
+ ".youku.com", ".iqiyi.com", ".iq.com", ".mgtv.com", ".hitv.com",
31
+ ".miguvideo.com", ".cmvideo.cn", ".migu.cn",
32
+ ".qq.com", ".qcloud.com", ".myqcloud.com", ".qlivecdn.com", ".qpic.cn",
33
+ ".volcfcdn.com", ".volccdn.com", ".byteimg.com", ".bytedance.com",
34
+ ".weibo.com", ".weibocdn.com", ".sinaimg.cn", ".youku.cn",
35
+ ],
36
+ // Japan-region sites / services that geo-fence to a JP IP.
37
+ japan: [
38
+ ".jp",
39
+ ".nicovideo.jp", ".nimg.jp", ".dmm.com", ".dmm.co.jp",
40
+ ".abema.tv", ".abema.io", ".tver.jp", ".unext.jp",
41
+ ".radiko.jp", ".pixiv.net", ".pximg.net",
42
+ ],
43
+ // US-region streaming / services that geo-fence to a US IP.
44
+ us: [
45
+ ".hulu.com", ".huluim.com", ".hulustream.com",
46
+ ".peacocktv.com", ".max.com", ".hbomax.com",
47
+ ".pluto.tv", ".tubi.tv", ".tubitv.com",
48
+ ".vudu.com", ".espn.com", ".nbc.com", ".cbs.com", ".fox.com",
49
+ ],
50
+ };
51
+ function hostMatches(host, suffixes) {
27
52
  host = (host || "").toLowerCase();
28
- return CHINA_HOSTS.some((s) => host === s.slice(1) || host.endsWith(s));
53
+ return suffixes.some((s) => host === (s.startsWith(".") ? s.slice(1) : s) || host.endsWith(s));
29
54
  }
30
55
  const HEALTH_INTERVAL_MS = 1000;
31
56
  const HEALTH_TIMEOUT_MS = 1500;
@@ -39,11 +64,17 @@ export function startMultiExitRouter(opts) {
39
64
  const listenHost = opts.listenHost ?? "127.0.0.1";
40
65
  const listenPort = opts.listenPort ?? 8889;
41
66
  const mode = opts.mode === "failover" ? "failover" : "loadbalance";
42
- const routeAll = opts.routeAll ?? false;
67
+ const defaultRegion = opts.defaultRegion ?? "direct";
43
68
  const ts = () => new Date().toTimeString().slice(0, 8);
44
69
  const log = opts.log ?? ((s) => console.log(`${ts()} ${s}`));
70
+ // Resolve each region's effective domain list (explicit list, else built-in).
71
+ const regions = (opts.regions ?? []).map((r) => ({
72
+ name: r.name,
73
+ domains: r.domains && r.domains.length > 0 ? r.domains : (BUILTIN_REGION_DOMAINS[r.name] ?? []),
74
+ }));
45
75
  const exits = opts.exits.map((e) => ({
46
76
  ...e,
77
+ region: e.region ?? "default",
47
78
  healthy: false,
48
79
  fails: 0,
49
80
  oks: 0,
@@ -51,10 +82,24 @@ export function startMultiExitRouter(opts) {
51
82
  served: 0,
52
83
  lastRtt: null,
53
84
  }));
85
+ // Per-host routing decision, logged once on first sight so the user can SEE
86
+ // why a hostname went DIRECT vs through a region (e.g. a CCTV video CDN that
87
+ // isn't classified as China and silently leaked direct → geo-blocked).
88
+ const seenHosts = new Set();
54
89
  let directActive = 0;
55
90
  let directTotal = 0;
56
91
  let lastPool = null;
57
92
  let stopped = false;
93
+ // ---- routing decision -----------------------------------------------------
94
+ // Returns the region name a host should use, or "direct" for a direct
95
+ // connection from this machine.
96
+ function routeFor(host) {
97
+ for (const r of regions) {
98
+ if (r.domains.length > 0 && hostMatches(host, r.domains))
99
+ return r.name;
100
+ }
101
+ return defaultRegion;
102
+ }
58
103
  // ---- health checks --------------------------------------------------------
59
104
  function probe(exit) {
60
105
  return new Promise((resolve) => {
@@ -66,10 +111,22 @@ export function startMultiExitRouter(opts) {
66
111
  });
67
112
  }
68
113
  function printPool() {
69
- const healthy = exits.filter((e) => e.healthy).map((e) => `${e.name}(${e.lastRtt}ms)`).join(", ") || "NONE";
70
- if (healthy !== lastPool) {
71
- log(`pool [${mode}]: ${healthy}`);
72
- lastPool = healthy;
114
+ // Group the healthy pool by region so the operator can see each region's
115
+ // bandwidth at a glance.
116
+ const byRegion = new Map();
117
+ for (const e of exits) {
118
+ if (!e.healthy)
119
+ continue;
120
+ const arr = byRegion.get(e.region) ?? [];
121
+ arr.push(`${e.name}(${e.lastRtt}ms)`);
122
+ byRegion.set(e.region, arr);
123
+ }
124
+ const summary = byRegion.size === 0
125
+ ? "NONE"
126
+ : [...byRegion.entries()].map(([r, ms]) => `${r}: ${ms.join(", ")}`).join(" | ");
127
+ if (summary !== lastPool) {
128
+ log(`pool [${mode}]: ${summary}`);
129
+ lastPool = summary;
73
130
  }
74
131
  }
75
132
  async function healthLoop() {
@@ -81,7 +138,7 @@ export function startMultiExitRouter(opts) {
81
138
  exit.fails = 0;
82
139
  if (!exit.healthy) {
83
140
  exit.healthy = true;
84
- log(`✓ ${exit.name} UP (${exit.lastRtt}ms)`);
141
+ log(`✓ ${exit.name} [${exit.region}] UP (${exit.lastRtt}ms)`);
85
142
  printPool();
86
143
  }
87
144
  }
@@ -90,7 +147,7 @@ export function startMultiExitRouter(opts) {
90
147
  exit.oks = 0;
91
148
  if (exit.healthy && exit.fails >= DOWN_AFTER_FAILS) {
92
149
  exit.healthy = false;
93
- log(`✗ ${exit.name} DOWN`);
150
+ log(`✗ ${exit.name} [${exit.region}] DOWN`);
94
151
  printPool();
95
152
  }
96
153
  }
@@ -98,15 +155,20 @@ export function startMultiExitRouter(opts) {
98
155
  await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
99
156
  }
100
157
  }
101
- // ---- exit selection -------------------------------------------------------
102
- function pickOrder() {
103
- const healthy = exits.filter((e) => e.healthy);
158
+ // ---- exit selection (scoped to one region) --------------------------------
159
+ function pickOrder(region) {
160
+ const healthy = exits.filter((e) => e.healthy && e.region === region);
104
161
  if (healthy.length === 0)
105
162
  return [];
106
163
  if (mode === "failover")
107
164
  return healthy; // priority order (input order)
108
165
  return [...healthy].sort((a, b) => a.active - b.active || a.served - b.served);
109
166
  }
167
+ // Whether ANY exit is configured for a region (healthy or not). Lets us tell
168
+ // "region has no exits at all → fall through" from "exits all down → 503".
169
+ function regionHasExits(region) {
170
+ return exits.some((e) => e.region === region);
171
+ }
110
172
  // ---- CONNECT through an exit ----------------------------------------------
111
173
  function forward(exit, target, client, head, onFail) {
112
174
  const up = net.connect(exit.port, exit.host);
@@ -154,8 +216,8 @@ export function startMultiExitRouter(opts) {
154
216
  up.on("data", onData);
155
217
  up.on("error", () => fail("conn error"));
156
218
  }
157
- // Direct connection from THIS machine — for non-China hosts so Google etc.
158
- // aren't black-holed through a China exit.
219
+ // Direct connection from THIS machine — for hosts that match no region so
220
+ // Google etc. aren't black-holed through a foreign exit.
159
221
  function directConnect(target, client, head) {
160
222
  const [host, portStr] = target.split(":");
161
223
  const up = net.connect(Number(portStr) || 443, host);
@@ -181,13 +243,26 @@ export function startMultiExitRouter(opts) {
181
243
  const target = req.url || "";
182
244
  const host = (target.split(":")[0] || "").toLowerCase();
183
245
  client.on("error", () => { });
184
- if (!routeAll && !isChinaHost(host)) {
246
+ let region = routeFor(host);
247
+ // If the chosen region has no exits configured, fall back to direct rather
248
+ // than 503 (a misconfigured default shouldn't black-hole everything).
249
+ if (region !== "direct" && !regionHasExits(region))
250
+ region = "direct";
251
+ if (!seenHosts.has(host)) {
252
+ seenHosts.add(host);
253
+ log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`}`);
254
+ }
255
+ if (region === "direct") {
185
256
  directConnect(target, client, head);
186
257
  return;
187
258
  }
188
- const order = pickOrder();
259
+ const order = pickOrder(region);
189
260
  if (order.length === 0) {
190
- client.end("HTTP/1.1 503 No healthy exit\r\n\r\n");
261
+ // Region matched but every exit in it is down. Do NOT silently leak to
262
+ // direct — a .cn host would just fail from abroad, and the operator
263
+ // should see the region is down.
264
+ log(` ${target}: region [${region}] has no healthy exit`);
265
+ client.end("HTTP/1.1 503 No healthy exit for region\r\n\r\n");
191
266
  return;
192
267
  }
193
268
  let i = 0;
@@ -204,16 +279,20 @@ export function startMultiExitRouter(opts) {
204
279
  });
205
280
  server.listen(listenPort, listenHost, () => {
206
281
  log(`Multi-exit router [${mode}] on http://${listenHost}:${listenPort}`);
207
- log(`Exits: ${exits.map((e) => `${e.name}@${e.host}:${e.port}`).join(", ")}`);
208
- log(routeAll
209
- ? `Routing: ALL hosts through the exits (--all)`
210
- : `Routing: China hosts → exits, everything else → DIRECT (so Google/US sites work)`);
282
+ // One line per region: which exits serve it.
283
+ for (const r of regions) {
284
+ if (!regionHasExits(r.name))
285
+ continue;
286
+ const members = exits.filter((e) => e.region === r.name).map((e) => `${e.name}@${e.host}:${e.port}`);
287
+ log(` region [${r.name}] → ${members.join(", ")}`);
288
+ }
289
+ log(` default (unmatched hosts) → ${defaultRegion === "direct" ? "DIRECT" : `region [${defaultRegion}]`}`);
211
290
  log(`Point your browser's HTTPS proxy at ${listenHost}:${listenPort}`);
212
291
  void healthLoop();
213
292
  });
214
293
  if (mode === "loadbalance") {
215
294
  const reporter = setInterval(() => {
216
- const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}: ${e.active} active / ${e.served} total`);
295
+ const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total`);
217
296
  parts.push(`direct: ${directActive} active / ${directTotal} total`);
218
297
  log(`load — ${parts.join(" | ")}`);
219
298
  }, 15000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
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",
@@ -19,6 +19,7 @@
19
19
  "dist",
20
20
  "bin",
21
21
  "config/default-doras.yaml",
22
+ "config/routes.example.yaml",
22
23
  "README.md",
23
24
  "LICENSE",
24
25
  "docs/INSTALL.md",
@@ -75,7 +76,7 @@
75
76
  },
76
77
  "dependencies": {
77
78
  "@decentnetwork/dora": "^0.1.6",
78
- "@decentnetwork/peer": "^0.1.31",
79
+ "@decentnetwork/peer": "^0.1.32",
79
80
  "js-yaml": "^4.1.0",
80
81
  "yargs": "^17.7.2"
81
82
  },