@decentnetwork/lan 0.1.47 → 0.1.49

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.
@@ -214,6 +214,25 @@ 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
+ export declare function cmdProxyRouter(args: {
229
+ exit?: string[];
230
+ port?: number;
231
+ listen?: string;
232
+ mode?: string;
233
+ all?: boolean;
234
+ configDir?: string;
235
+ }): Promise<void>;
217
236
  /**
218
237
  * Parse duration string like "1h", "24h", "30m"
219
238
  */
@@ -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 { startMultiExitRouter } from "../proxy/multi-exit-router.js";
14
15
  /**
15
16
  * Refuse to open a second Carrier peer with this identity if the
16
17
  * daemon is already running with the same keypair. Two peers sharing
@@ -995,6 +996,59 @@ export async function cmdProxyUse(args) {
995
996
  console.log(`# agentnet proxy enable`);
996
997
  console.log(`# agentnet grant --peer <your-userid> --tcp ${port}`);
997
998
  }
999
+ /**
1000
+ * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
1001
+ * load-balances / fails over across several exit nodes, sending only China
1002
+ * traffic through them and everything else direct. This is the client-side
1003
+ * counterpart to `agentnet proxy enable` (which is the per-exit server).
1004
+ *
1005
+ * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
1006
+ * If none are given we auto-discover them from the daemon's live IPAM (every
1007
+ * known peer becomes a candidate exit on its proxy port; health checks drop
1008
+ * the ones that aren't actually serving a proxy).
1009
+ */
1010
+ export async function cmdProxyRouter(args) {
1011
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1012
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1013
+ const defaultExitPort = config.proxy?.port ?? 8888;
1014
+ const parseExit = (spec) => {
1015
+ let name;
1016
+ let rest = spec;
1017
+ const eq = spec.indexOf("=");
1018
+ if (eq !== -1) {
1019
+ name = spec.slice(0, eq);
1020
+ rest = spec.slice(eq + 1);
1021
+ }
1022
+ const [host, portStr] = rest.split(":");
1023
+ return { name: name || host, host, port: Number(portStr) || defaultExitPort };
1024
+ };
1025
+ let exits = (args.exit ?? []).map(parseExit);
1026
+ if (exits.length === 0) {
1027
+ // Auto-discover from the daemon's live IPAM.
1028
+ const { peers, source } = await fetchLiveIpam(config);
1029
+ exits = peers
1030
+ .filter((p) => p.virtualIp)
1031
+ .map((p) => ({ name: p.name || p.virtualIp, host: p.virtualIp, port: defaultExitPort }));
1032
+ if (exits.length === 0) {
1033
+ console.error(`No exits given and none discovered from IPAM (${source}).`);
1034
+ console.error(`Pass exits explicitly, e.g.:`);
1035
+ console.error(` agentnet proxy router --exit 10.86.1.15 --exit 10.86.1.16 --exit 10.86.1.17`);
1036
+ process.exit(1);
1037
+ }
1038
+ console.log(`Auto-discovered ${exits.length} exit(s) from IPAM (${source}): ${exits.map((e) => e.name).join(", ")}`);
1039
+ console.log(`(health checks will drop any that aren't actually running a proxy)\n`);
1040
+ }
1041
+ const mode = args.mode === "failover" ? "failover" : "loadbalance";
1042
+ startMultiExitRouter({
1043
+ exits,
1044
+ listenHost: args.listen ?? "127.0.0.1",
1045
+ listenPort: args.port ?? 8889,
1046
+ mode,
1047
+ routeAll: args.all ?? false,
1048
+ });
1049
+ // Keep the process alive; the router runs until Ctrl-C / signal.
1050
+ await new Promise(() => { });
1051
+ }
998
1052
  /**
999
1053
  * Parse duration string like "1h", "24h", "30m"
1000
1054
  */
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, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, 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, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -258,6 +258,26 @@ async function main() {
258
258
  .option("peer", { type: "string", demandOption: true, describe: "Peer name or carrier ID" })
259
259
  .option("config-dir", { type: "string" }), async (argv) => {
260
260
  await cmdProxyUse({ peer: argv.peer, configDir: argv["config-dir"] });
261
+ })
262
+ .command("router", "Run a local multi-exit HTTPS proxy: load-balance/failover across exits, China-split tunnel", (yy) => yy
263
+ .option("exit", {
264
+ type: "string",
265
+ array: true,
266
+ describe: "Exit as 'host[:port]' or 'name=host[:port]' (repeatable). Default: auto-discover from IPAM",
267
+ })
268
+ .option("port", { type: "number", default: 8889, describe: "Local listen port" })
269
+ .option("listen", { type: "string", default: "127.0.0.1", describe: "Listen host (use 0.0.0.0 to reach from Windows → WSL)" })
270
+ .option("mode", { choices: ["loadbalance", "failover"], default: "loadbalance" })
271
+ .option("all", { type: "boolean", default: false, describe: "Route ALL hosts through exits (default: China-only split tunnel)" })
272
+ .option("config-dir", { type: "string" }), async (argv) => {
273
+ await cmdProxyRouter({
274
+ exit: argv.exit,
275
+ port: argv.port,
276
+ listen: argv.listen,
277
+ mode: argv.mode,
278
+ all: argv.all,
279
+ configDir: argv["config-dir"],
280
+ });
261
281
  })
262
282
  .demandCommand(1, "Specify a proxy subcommand (run 'agentnet proxy --help')"), () => {
263
283
  // parent handler — never invoked because demandCommand above
@@ -0,0 +1,20 @@
1
+ export interface ExitTarget {
2
+ name: string;
3
+ host: string;
4
+ port: number;
5
+ }
6
+ export interface MultiExitRouterOptions {
7
+ exits: ExitTarget[];
8
+ listenHost?: string;
9
+ listenPort?: number;
10
+ mode?: "loadbalance" | "failover";
11
+ routeAll?: boolean;
12
+ log?: (msg: string) => void;
13
+ }
14
+ /**
15
+ * Start the multi-exit router. Returns a stop() that closes the listener.
16
+ * Runs until stopped (the CLI command keeps the process alive).
17
+ */
18
+ export declare function startMultiExitRouter(opts: MultiExitRouterOptions): {
19
+ stop: () => void;
20
+ };
@@ -0,0 +1,225 @@
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.
7
+ //
8
+ // Exposed as `agentnet proxy router` so released users get it without
9
+ // cloning the repo (it used to live in scripts/failover-proxy.mjs).
10
+ //
11
+ import http from "node:http";
12
+ 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) {
27
+ host = (host || "").toLowerCase();
28
+ return CHINA_HOSTS.some((s) => host === s.slice(1) || host.endsWith(s));
29
+ }
30
+ const HEALTH_INTERVAL_MS = 1000;
31
+ const HEALTH_TIMEOUT_MS = 1500;
32
+ const DOWN_AFTER_FAILS = 2;
33
+ const CONNECT_TIMEOUT_MS = 8000;
34
+ /**
35
+ * Start the multi-exit router. Returns a stop() that closes the listener.
36
+ * Runs until stopped (the CLI command keeps the process alive).
37
+ */
38
+ export function startMultiExitRouter(opts) {
39
+ const listenHost = opts.listenHost ?? "127.0.0.1";
40
+ const listenPort = opts.listenPort ?? 8889;
41
+ const mode = opts.mode === "failover" ? "failover" : "loadbalance";
42
+ const routeAll = opts.routeAll ?? false;
43
+ const ts = () => new Date().toTimeString().slice(0, 8);
44
+ const log = opts.log ?? ((s) => console.log(`${ts()} ${s}`));
45
+ const exits = opts.exits.map((e) => ({
46
+ ...e,
47
+ healthy: false,
48
+ fails: 0,
49
+ oks: 0,
50
+ active: 0,
51
+ served: 0,
52
+ lastRtt: null,
53
+ }));
54
+ let directActive = 0;
55
+ let directTotal = 0;
56
+ let lastPool = null;
57
+ let stopped = false;
58
+ // ---- health checks --------------------------------------------------------
59
+ function probe(exit) {
60
+ return new Promise((resolve) => {
61
+ const t0 = Date.now();
62
+ const s = net.connect(exit.port, exit.host);
63
+ const timer = setTimeout(() => { s.destroy(); resolve(false); }, HEALTH_TIMEOUT_MS);
64
+ s.once("connect", () => { clearTimeout(timer); s.destroy(); exit.lastRtt = Date.now() - t0; resolve(true); });
65
+ s.once("error", () => { clearTimeout(timer); resolve(false); });
66
+ });
67
+ }
68
+ 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;
73
+ }
74
+ }
75
+ async function healthLoop() {
76
+ while (!stopped) {
77
+ for (const exit of exits) {
78
+ const ok = await probe(exit);
79
+ if (ok) {
80
+ exit.oks++;
81
+ exit.fails = 0;
82
+ if (!exit.healthy) {
83
+ exit.healthy = true;
84
+ log(`✓ ${exit.name} UP (${exit.lastRtt}ms)`);
85
+ printPool();
86
+ }
87
+ }
88
+ else {
89
+ exit.fails++;
90
+ exit.oks = 0;
91
+ if (exit.healthy && exit.fails >= DOWN_AFTER_FAILS) {
92
+ exit.healthy = false;
93
+ log(`✗ ${exit.name} DOWN`);
94
+ printPool();
95
+ }
96
+ }
97
+ }
98
+ await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
99
+ }
100
+ }
101
+ // ---- exit selection -------------------------------------------------------
102
+ function pickOrder() {
103
+ const healthy = exits.filter((e) => e.healthy);
104
+ if (healthy.length === 0)
105
+ return [];
106
+ if (mode === "failover")
107
+ return healthy; // priority order (input order)
108
+ return [...healthy].sort((a, b) => a.active - b.active || a.served - b.served);
109
+ }
110
+ // ---- CONNECT through an exit ----------------------------------------------
111
+ function forward(exit, target, client, head, onFail) {
112
+ const up = net.connect(exit.port, exit.host);
113
+ let established = false;
114
+ const fail = (why) => { if (!established) {
115
+ up.destroy();
116
+ onFail(why);
117
+ }
118
+ else {
119
+ client.destroy();
120
+ } };
121
+ up.once("connect", () => up.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
122
+ up.setTimeout(CONNECT_TIMEOUT_MS, () => fail("timeout"));
123
+ let buf = Buffer.alloc(0);
124
+ const onData = (d) => {
125
+ buf = Buffer.concat([buf, d]);
126
+ const idx = buf.indexOf("\r\n\r\n");
127
+ if (idx === -1)
128
+ return;
129
+ const status = buf.slice(0, buf.indexOf("\r\n")).toString();
130
+ up.removeListener("data", onData);
131
+ if (status.includes(" 200 ")) {
132
+ established = true;
133
+ exit.active++;
134
+ exit.served++;
135
+ up.setTimeout(0);
136
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
137
+ if (head && head.length)
138
+ up.write(head);
139
+ const rest = buf.slice(idx + 4);
140
+ if (rest.length)
141
+ client.write(rest);
142
+ client.pipe(up);
143
+ up.pipe(client);
144
+ const done = () => { exit.active = Math.max(0, exit.active - 1); };
145
+ up.once("close", done);
146
+ client.once("close", () => up.destroy());
147
+ client.on("error", () => up.destroy());
148
+ up.on("error", () => client.destroy());
149
+ }
150
+ else {
151
+ fail(`upstream ${status}`);
152
+ }
153
+ };
154
+ up.on("data", onData);
155
+ up.on("error", () => fail("conn error"));
156
+ }
157
+ // Direct connection from THIS machine — for non-China hosts so Google etc.
158
+ // aren't black-holed through a China exit.
159
+ function directConnect(target, client, head) {
160
+ const [host, portStr] = target.split(":");
161
+ const up = net.connect(Number(portStr) || 443, host);
162
+ up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy());
163
+ up.once("connect", () => {
164
+ up.setTimeout(0);
165
+ directActive++;
166
+ directTotal++;
167
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
168
+ if (head && head.length)
169
+ up.write(head);
170
+ client.pipe(up);
171
+ up.pipe(client);
172
+ const done = () => { directActive = Math.max(0, directActive - 1); };
173
+ up.once("close", done);
174
+ client.once("close", () => up.destroy());
175
+ });
176
+ client.on("error", () => up.destroy());
177
+ up.on("error", () => client.destroy());
178
+ }
179
+ const server = http.createServer((_req, res) => { res.writeHead(405); res.end("CONNECT only\n"); });
180
+ server.on("connect", (req, client, head) => {
181
+ const target = req.url || "";
182
+ const host = (target.split(":")[0] || "").toLowerCase();
183
+ client.on("error", () => { });
184
+ if (!routeAll && !isChinaHost(host)) {
185
+ directConnect(target, client, head);
186
+ return;
187
+ }
188
+ const order = pickOrder();
189
+ if (order.length === 0) {
190
+ client.end("HTTP/1.1 503 No healthy exit\r\n\r\n");
191
+ return;
192
+ }
193
+ let i = 0;
194
+ const tryNext = (why) => {
195
+ if (why)
196
+ log(` ${target}: ${order[i - 1]?.name} failed (${why}), retrying`);
197
+ if (i >= order.length) {
198
+ client.end("HTTP/1.1 502 All exits failed\r\n\r\n");
199
+ return;
200
+ }
201
+ forward(order[i++], target, client, head, tryNext);
202
+ };
203
+ tryNext();
204
+ });
205
+ server.listen(listenPort, listenHost, () => {
206
+ 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)`);
211
+ log(`Point your browser's HTTPS proxy at ${listenHost}:${listenPort}`);
212
+ void healthLoop();
213
+ });
214
+ if (mode === "loadbalance") {
215
+ const reporter = setInterval(() => {
216
+ const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}: ${e.active} active / ${e.served} total`);
217
+ parts.push(`direct: ${directActive} active / ${directTotal} total`);
218
+ log(`load — ${parts.join(" | ")}`);
219
+ }, 15000);
220
+ server.on("close", () => clearInterval(reporter));
221
+ }
222
+ return {
223
+ stop: () => { stopped = true; server.close(); },
224
+ };
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
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",
@@ -74,7 +74,7 @@
74
74
  "prepublishOnly": "rm -rf dist && npm run build && npm run typecheck && npm run build:helpers:all"
75
75
  },
76
76
  "dependencies": {
77
- "@decentnetwork/dora": "^0.1.0",
77
+ "@decentnetwork/dora": "^0.1.6",
78
78
  "@decentnetwork/peer": "^0.1.20",
79
79
  "js-yaml": "^4.1.0",
80
80
  "yargs": "^17.7.2"