@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.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/config/routes.example.yaml +34 -0
- package/dist/carrier/peer-manager.d.ts +2 -0
- package/dist/carrier/peer-manager.js +1 -0
- package/dist/cli/commands.d.ts +11 -11
- package/dist/cli/commands.js +142 -32
- package/dist/cli/index.js +7 -2
- package/dist/daemon/server.js +20 -16
- package/dist/proxy/multi-exit-router.d.ts +21 -1
- package/dist/proxy/multi-exit-router.js +119 -40
- package/package.json +3 -2
|
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)");
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1053
|
-
rest =
|
|
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
|
-
|
|
1056
|
+
if (!host)
|
|
1057
|
+
return null;
|
|
1058
|
+
return { name: label || host, host, port: Number(portStr) || defaultExitPort, region };
|
|
1057
1059
|
};
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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) => (
|
|
1065
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1561
|
-
dir
|
|
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
|
-
|
|
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 '
|
|
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,
|
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
//
|
|
112
|
-
//
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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 —
|
|
3
|
-
// proxy nodes, with
|
|
4
|
-
// counterpart to the per-node CONNECT proxy (`agentnet proxy
|
|
5
|
-
// you run ONE of these on your machine
|
|
6
|
-
// and it
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
158
|
-
// aren't black-holed through a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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.
|
|
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.
|
|
79
|
+
"@decentnetwork/peer": "^0.1.32",
|
|
79
80
|
"js-yaml": "^4.1.0",
|
|
80
81
|
"yargs": "^17.7.2"
|
|
81
82
|
},
|