@decentnetwork/lan 0.1.70 → 0.1.72
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/config/default-exits.yaml +55 -0
- package/config/routes.example.yaml +34 -0
- package/dist/cli/commands.d.ts +11 -11
- package/dist/cli/commands.js +185 -36
- package/dist/cli/index.js +7 -2
- package/dist/config/loader.d.ts +22 -0
- package/dist/config/loader.js +30 -7
- package/dist/proxy/multi-exit-router.d.ts +21 -1
- package/dist/proxy/multi-exit-router.js +137 -40
- package/package.json +3 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Official exit / infrastructure nodes shipped with @decentnetwork/lan.
|
|
2
|
+
#
|
|
3
|
+
# A fresh install auto-friends ONLY the dora registries (config/default-doras.yaml)
|
|
4
|
+
# and the exits listed here — NOT every machine that happens to be in the dora
|
|
5
|
+
# roster. This keeps a new client's friend list to infrastructure (doras + exits)
|
|
6
|
+
# instead of filling up with other people's personal/compute boxes.
|
|
7
|
+
#
|
|
8
|
+
# Each exit carries a `region`, so `agentnet proxy router` (with no routes.yaml)
|
|
9
|
+
# auto-builds the routing table: China sites → china exits, Japan/Binance →
|
|
10
|
+
# japan exit, Google/YouTube/GitHub/etc → us exits, everything else direct.
|
|
11
|
+
#
|
|
12
|
+
# Mechanism: these userids (plus the dora userids) become the default
|
|
13
|
+
# `dora.autoFriend` whitelist for a freshly-initialised config. The client
|
|
14
|
+
# still LEARNS every roster entry into IPAM (so names/IPs resolve), but only
|
|
15
|
+
# proactively friends infrastructure. Exits still serve arbitrary clients
|
|
16
|
+
# because the daemon auto-ACCEPTS incoming friend-requests — so hub-and-spoke
|
|
17
|
+
# works without meshing every personal machine.
|
|
18
|
+
#
|
|
19
|
+
# To run a full mesh instead (friend everyone in the roster):
|
|
20
|
+
# agentnet dora autofriend all
|
|
21
|
+
# To add/replace an exit: edit this file (data, not code) and republish, or
|
|
22
|
+
# locally: agentnet dora autofriend allow <name|userid> ...
|
|
23
|
+
#
|
|
24
|
+
# `region` is one of: china | japan | us (matches the router's built-in
|
|
25
|
+
# domain lists; see BUILTIN_REGION_DOMAINS in src/proxy/multi-exit-router.ts).
|
|
26
|
+
exits:
|
|
27
|
+
# --- China (China-only / GFW-blocked-from-outside sites) ---
|
|
28
|
+
- name: cn
|
|
29
|
+
userid: 5Aj6uQMd1cNRb9cGT4AwHCN4RdVZjfaGLtgKGhdP1LzN
|
|
30
|
+
virtual_ip: 10.86.1.15
|
|
31
|
+
region: china
|
|
32
|
+
- name: sh
|
|
33
|
+
userid: 6D1PLSVqbSpcxnDMAd8ZXLWycYveeP2hU4g3igDQEqA7
|
|
34
|
+
virtual_ip: 10.86.1.16
|
|
35
|
+
region: china
|
|
36
|
+
- name: callpass
|
|
37
|
+
userid: DZN2L9RV1YkHjqGHMTA6juZhskKA65AD2RyPt4umZVc7
|
|
38
|
+
virtual_ip: 10.86.1.17
|
|
39
|
+
region: china
|
|
40
|
+
# --- Japan (host "lico", dora name node-91) — Binance routes here ---
|
|
41
|
+
- name: tokyo
|
|
42
|
+
userid: 9aZUSoLssVXyYbbVQqqjsydh5Ejv7SugqV53MJiMknd6PtLja7Ka
|
|
43
|
+
virtual_ip: 10.86.68.90
|
|
44
|
+
region: japan
|
|
45
|
+
# --- US (Google, YouTube, GitHub, X/Twitter, LinkedIn, OpenAI) ---
|
|
46
|
+
- name: gojipower
|
|
47
|
+
userid: 2wErj1XreXt1UchE3FGhuvkZ4GoBpo8JGMn8X49nm2ec
|
|
48
|
+
virtual_ip: 10.86.166.16
|
|
49
|
+
region: us
|
|
50
|
+
# snoopy has NO public IP (LAN 10.0.0.115) — proves a NAT'd node can serve
|
|
51
|
+
# as an exit purely over the Carrier tunnel.
|
|
52
|
+
- name: snoopy
|
|
53
|
+
userid: BeMbuKf1poh3U4rjw3eeUAKZsbN2qBSjVZdPSvehmHsw
|
|
54
|
+
virtual_ip: 10.86.156.164
|
|
55
|
+
region: us
|
|
@@ -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
|
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
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
import { resolve, dirname } from "path";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
6
6
|
import { createConnection } from "net";
|
|
7
|
-
import { ConfigLoader, DEFAULT_DORAS } from "../config/loader.js";
|
|
7
|
+
import { ConfigLoader, DEFAULT_DORAS, DEFAULT_EXITS } from "../config/loader.js";
|
|
8
8
|
import { ipcSocketPath } from "../daemon/ipc.js";
|
|
9
9
|
import { DaemonServer } from "../daemon/server.js";
|
|
10
10
|
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,145 @@ 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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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 if (args.all) {
|
|
1108
|
+
// ---- --all: every discovered peer through exits, single region --------
|
|
1109
|
+
const discovered = peers.filter((p) => p.virtualIp);
|
|
1110
|
+
for (const p of discovered) {
|
|
1111
|
+
exits.push({ name: p.name || p.virtualIp, host: p.virtualIp, port: defaultExitPort, region: "all" });
|
|
1112
|
+
}
|
|
1065
1113
|
if (exits.length === 0) {
|
|
1066
|
-
console.error(`No exits
|
|
1067
|
-
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`);
|
|
1114
|
+
console.error(`No exits discovered from IPAM (${source}). Is the daemon up?`);
|
|
1069
1115
|
process.exit(1);
|
|
1070
1116
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1117
|
+
regions.push({ name: "all", domains: [] });
|
|
1118
|
+
defaultRegion = "all";
|
|
1119
|
+
console.log(`Auto-discovered ${exits.length} exit(s) from IPAM (${source}); routing ALL hosts through them.\n`);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
// ---- Auto-discovery: curated exits, each in its OWN region -----------
|
|
1123
|
+
// Match live IPAM peers against the shipped exit list (by userid, then
|
|
1124
|
+
// name) and assign each its region (china/japan/us). Non-exit peers
|
|
1125
|
+
// (personal machines) are skipped, so the router never sends traffic
|
|
1126
|
+
// through someone's laptop.
|
|
1127
|
+
const exitByUserid = new Map(DEFAULT_EXITS.map((e) => [e.userid, e]));
|
|
1128
|
+
const exitByName = new Map(DEFAULT_EXITS.map((e) => [e.name, e]));
|
|
1129
|
+
const regionsSeen = new Set();
|
|
1130
|
+
for (const p of peers) {
|
|
1131
|
+
if (!p.virtualIp)
|
|
1132
|
+
continue;
|
|
1133
|
+
const curated = exitByUserid.get(p.carrierId) ?? exitByName.get(p.name);
|
|
1134
|
+
if (!curated)
|
|
1135
|
+
continue;
|
|
1136
|
+
const region = curated.region ?? "china";
|
|
1137
|
+
exits.push({ name: curated.name, host: p.virtualIp, port: defaultExitPort, region });
|
|
1138
|
+
regionsSeen.add(region);
|
|
1139
|
+
}
|
|
1140
|
+
if (exits.length > 0) {
|
|
1141
|
+
for (const r of regionsSeen)
|
|
1142
|
+
regions.push({ name: r, domains: [] });
|
|
1143
|
+
console.log(`Auto-discovered ${exits.length} curated exit(s) from IPAM (${source}): ` +
|
|
1144
|
+
[...regionsSeen].map((r) => `${r}=[${exits.filter((e) => e.region === r).map((e) => e.name).join(",")}]`).join(" "));
|
|
1145
|
+
console.log(`(China→china exits, Japan/Binance→japan, Google/YouTube/etc→us, rest→direct)\n`);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
// No curated exits in this roster (a different/private network) — fall
|
|
1149
|
+
// back to treating every discovered peer as a China exit.
|
|
1150
|
+
const discovered = peers
|
|
1151
|
+
.filter((p) => p.virtualIp)
|
|
1152
|
+
.map((p) => ({ name: p.name || p.virtualIp, host: p.virtualIp, port: defaultExitPort, region: "china" }));
|
|
1153
|
+
if (discovered.length === 0) {
|
|
1154
|
+
console.error(`No exits given and none discovered from IPAM (${source}).`);
|
|
1155
|
+
console.error(`Pass exits explicitly, e.g.: agentnet proxy router --exit cn --exit sh`);
|
|
1156
|
+
console.error(`Or define regions in ${routesPath} (see docs/INSTALL-NODES.md).`);
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
exits.push(...discovered);
|
|
1160
|
+
regions.push({ name: "china", domains: [] });
|
|
1161
|
+
console.log(`Auto-discovered ${discovered.length} exit(s) from IPAM (${source}); none curated, routing China-split.\n`);
|
|
1162
|
+
}
|
|
1163
|
+
defaultRegion = "direct";
|
|
1073
1164
|
}
|
|
1074
|
-
const mode = args.mode === "failover" ? "failover" : "loadbalance";
|
|
1075
1165
|
startMultiExitRouter({
|
|
1076
1166
|
exits,
|
|
1167
|
+
regions,
|
|
1168
|
+
defaultRegion,
|
|
1077
1169
|
listenHost: args.listen ?? "127.0.0.1",
|
|
1078
1170
|
listenPort: args.port ?? 8889,
|
|
1079
1171
|
mode,
|
|
1080
|
-
routeAll: args.all ?? false,
|
|
1081
1172
|
});
|
|
1082
1173
|
// Keep the process alive; the router runs until Ctrl-C / signal.
|
|
1083
1174
|
await new Promise(() => { });
|
|
@@ -1557,8 +1648,10 @@ export async function cmdServiceInstall(args) {
|
|
|
1557
1648
|
else if (process.env.SUDO_USER && process.env.SUDO_USER !== "root") {
|
|
1558
1649
|
const { execSync } = await import("child_process");
|
|
1559
1650
|
try {
|
|
1560
|
-
|
|
1561
|
-
dir
|
|
1651
|
+
// Resolve SUDO_USER's home without getent (absent on macOS). `echo ~user`
|
|
1652
|
+
// tilde-expands to the home dir in sh on both Linux and macOS.
|
|
1653
|
+
const sudoHome = execSync(`echo ~${process.env.SUDO_USER}`, { encoding: "utf-8", shell: "/bin/sh" }).trim();
|
|
1654
|
+
dir = sudoHome && sudoHome.startsWith("/") && !sudoHome.startsWith("~")
|
|
1562
1655
|
? `${sudoHome}/.agentnet`
|
|
1563
1656
|
: ConfigLoader.defaultConfigDir();
|
|
1564
1657
|
console.log(`[service install] sudo detected — using ${dir} (override with --config-dir)`);
|
|
@@ -1571,6 +1664,16 @@ export async function cmdServiceInstall(args) {
|
|
|
1571
1664
|
dir = ConfigLoader.defaultConfigDir();
|
|
1572
1665
|
}
|
|
1573
1666
|
const { spawnSync, execSync } = await import("child_process");
|
|
1667
|
+
// System services (systemd/launchd) run with a minimal PATH that does NOT
|
|
1668
|
+
// include a node installed via nvm/homebrew/an unofficial build. Relying on
|
|
1669
|
+
// the `agentnet` shebang (#!/usr/bin/env node) then fails with
|
|
1670
|
+
// "env: node: No such file or directory" and the daemon never starts.
|
|
1671
|
+
// Invoke node by ABSOLUTE path — process.execPath is the node running this
|
|
1672
|
+
// very install, so it's guaranteed to exist — and add its dir to the
|
|
1673
|
+
// service's PATH for any child tools (ifconfig/route/etc.).
|
|
1674
|
+
const nodeBin = process.execPath;
|
|
1675
|
+
const nodeDir = dirname(nodeBin);
|
|
1676
|
+
const servicePath = `${nodeDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
|
|
1574
1677
|
if (process.platform === "linux") {
|
|
1575
1678
|
const unitPath = "/etc/systemd/system/agentnet.service";
|
|
1576
1679
|
if (args.uninstall) {
|
|
@@ -1609,7 +1712,8 @@ Wants=network-online.target
|
|
|
1609
1712
|
[Service]
|
|
1610
1713
|
Type=simple
|
|
1611
1714
|
User=root
|
|
1612
|
-
|
|
1715
|
+
Environment=PATH=${servicePath}
|
|
1716
|
+
ExecStart=${nodeBin} ${agentnetBin} up --real-tun --config-dir ${dir}
|
|
1613
1717
|
Restart=on-failure
|
|
1614
1718
|
RestartSec=5
|
|
1615
1719
|
# Log to the journal so the intuitive 'journalctl -u agentnet -f' just works.
|
|
@@ -1667,12 +1771,16 @@ WantedBy=multi-user.target
|
|
|
1667
1771
|
<key>Label</key><string>com.decentlan.agentnet</string>
|
|
1668
1772
|
<key>UserName</key><string>root</string>
|
|
1669
1773
|
<key>ProgramArguments</key><array>
|
|
1774
|
+
<string>${nodeBin}</string>
|
|
1670
1775
|
<string>${agentnetBin}</string>
|
|
1671
1776
|
<string>up</string>
|
|
1672
1777
|
<string>--real-tun</string>
|
|
1673
1778
|
<string>--config-dir</string>
|
|
1674
1779
|
<string>${dir}</string>
|
|
1675
1780
|
</array>
|
|
1781
|
+
<key>EnvironmentVariables</key><dict>
|
|
1782
|
+
<key>PATH</key><string>${servicePath}</string>
|
|
1783
|
+
</dict>
|
|
1676
1784
|
<key>RunAtLoad</key><true/>
|
|
1677
1785
|
<key>KeepAlive</key><true/>
|
|
1678
1786
|
<key>StandardOutPath</key><string>/var/log/agentnet.log</string>
|
|
@@ -1719,6 +1827,47 @@ export async function cmdServiceStatus(_args) {
|
|
|
1719
1827
|
}
|
|
1720
1828
|
console.log(`'service status' isn't wired up for ${process.platform}.`);
|
|
1721
1829
|
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Restart the system-service-managed daemon (systemd on Linux, launchd on
|
|
1832
|
+
* macOS). Needs root, same as install. Distinct from the top-level
|
|
1833
|
+
* `agentnet restart`, which re-execs an already-running daemon over IPC with
|
|
1834
|
+
* no sudo — use that for picking up an upgrade; use this when the service
|
|
1835
|
+
* itself is wedged/stopped and you want launchd/systemd to bring it back.
|
|
1836
|
+
*/
|
|
1837
|
+
export async function cmdServiceRestart(_args) {
|
|
1838
|
+
const { spawnSync } = await import("child_process");
|
|
1839
|
+
if (process.platform === "linux") {
|
|
1840
|
+
const r = spawnSync("systemctl", ["restart", "agentnet"], { stdio: "inherit" });
|
|
1841
|
+
if ((r.status ?? 0) !== 0) {
|
|
1842
|
+
throw new Error(`'systemctl restart agentnet' failed (need root? try 'sudo agentnet service restart'). ` +
|
|
1843
|
+
`If the service isn't installed, run 'sudo agentnet service install' first.`);
|
|
1844
|
+
}
|
|
1845
|
+
console.log("Restarted agentnet.service. Logs: journalctl -u agentnet -f");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (process.platform === "darwin") {
|
|
1849
|
+
const label = "com.decentlan.agentnet";
|
|
1850
|
+
// kickstart -k restarts the (loaded) daemon in one call. Falls back to
|
|
1851
|
+
// unload+load if kickstart isn't available (older launchctl).
|
|
1852
|
+
const kick = spawnSync("launchctl", ["kickstart", "-k", `system/${label}`], { stdio: "inherit" });
|
|
1853
|
+
if ((kick.status ?? 0) === 0) {
|
|
1854
|
+
console.log(`Restarted ${label}. Logs: tail -f /var/log/agentnet.log`);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const plistPath = "/Library/LaunchDaemons/com.decentlan.agentnet.plist";
|
|
1858
|
+
if (!existsSync(plistPath)) {
|
|
1859
|
+
throw new Error(`${plistPath} not found — run 'sudo agentnet service install' first.`);
|
|
1860
|
+
}
|
|
1861
|
+
spawnSync("launchctl", ["unload", plistPath], { stdio: "inherit" });
|
|
1862
|
+
const load = spawnSync("launchctl", ["load", plistPath], { stdio: "inherit" });
|
|
1863
|
+
if ((load.status ?? 0) !== 0) {
|
|
1864
|
+
throw new Error(`launchctl reload failed (need root? try 'sudo agentnet service restart').`);
|
|
1865
|
+
}
|
|
1866
|
+
console.log(`Restarted ${label}. Logs: tail -f /var/log/agentnet.log`);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
throw new Error(`'service restart' isn't wired up for ${process.platform}.`);
|
|
1870
|
+
}
|
|
1722
1871
|
/**
|
|
1723
1872
|
* Ask the running daemon to re-exec itself. Used after
|
|
1724
1873
|
* `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/config/loader.d.ts
CHANGED
|
@@ -26,6 +26,28 @@ export interface DefaultDora {
|
|
|
26
26
|
export declare const DEFAULT_DORAS: DefaultDora[];
|
|
27
27
|
export declare const DEFAULT_DORA_USERID: string;
|
|
28
28
|
export declare const DEFAULT_DORA_ADDRESS: string;
|
|
29
|
+
/**
|
|
30
|
+
* Official exit / infrastructure nodes (config/default-exits.yaml). These,
|
|
31
|
+
* together with the dora userids, form the default `autoFriend` whitelist so
|
|
32
|
+
* a fresh install only friends infrastructure — not every personal/compute
|
|
33
|
+
* box that shows up in the dora roster. Editable data, not code.
|
|
34
|
+
*/
|
|
35
|
+
export interface DefaultExit {
|
|
36
|
+
name: string;
|
|
37
|
+
userid: string;
|
|
38
|
+
virtual_ip?: string;
|
|
39
|
+
/** Routing region: china | japan | us. Used by `agentnet proxy router` to
|
|
40
|
+
* auto-build the domain→region table with zero config. */
|
|
41
|
+
region?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_EXITS: DefaultExit[];
|
|
44
|
+
/**
|
|
45
|
+
* The default `dora.autoFriend` whitelist for a fresh config: every dora plus
|
|
46
|
+
* every official exit, by userid. A new client friends only these (the dora
|
|
47
|
+
* roster is still fully learned into IPAM for name/IP resolution). Override
|
|
48
|
+
* with `agentnet dora autofriend all` for a full mesh.
|
|
49
|
+
*/
|
|
50
|
+
export declare const DEFAULT_AUTOFRIEND: string[];
|
|
29
51
|
export declare class ConfigLoader {
|
|
30
52
|
static defaultConfigPath(): string;
|
|
31
53
|
static defaultConfigDir(): string;
|
package/dist/config/loader.js
CHANGED
|
@@ -72,6 +72,27 @@ export const DEFAULT_DORAS = loadDefaultDoras();
|
|
|
72
72
|
// Back-compat single-value exports (first/primary dora).
|
|
73
73
|
export const DEFAULT_DORA_USERID = DEFAULT_DORAS[0].userid;
|
|
74
74
|
export const DEFAULT_DORA_ADDRESS = DEFAULT_DORAS[0].address;
|
|
75
|
+
function loadDefaultExits() {
|
|
76
|
+
try {
|
|
77
|
+
const file = resolve(dirname(new URL(import.meta.url).pathname), "../../config/default-exits.yaml");
|
|
78
|
+
const parsed = yaml.load(readFileSync(file, "utf-8"));
|
|
79
|
+
return (parsed?.exits ?? []).filter((e) => e && e.userid);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export const DEFAULT_EXITS = loadDefaultExits();
|
|
86
|
+
/**
|
|
87
|
+
* The default `dora.autoFriend` whitelist for a fresh config: every dora plus
|
|
88
|
+
* every official exit, by userid. A new client friends only these (the dora
|
|
89
|
+
* roster is still fully learned into IPAM for name/IP resolution). Override
|
|
90
|
+
* with `agentnet dora autofriend all` for a full mesh.
|
|
91
|
+
*/
|
|
92
|
+
export const DEFAULT_AUTOFRIEND = [
|
|
93
|
+
...DEFAULT_DORAS.map((d) => d.userid),
|
|
94
|
+
...DEFAULT_EXITS.map((e) => e.userid),
|
|
95
|
+
];
|
|
75
96
|
export class ConfigLoader {
|
|
76
97
|
static defaultConfigPath() {
|
|
77
98
|
return resolve(this.defaultConfigDir(), "config.yaml");
|
|
@@ -174,13 +195,15 @@ export class ConfigLoader {
|
|
|
174
195
|
enabled: true,
|
|
175
196
|
userids: DEFAULT_DORAS.map((d) => d.userid),
|
|
176
197
|
refreshIntervalMs: 60_000,
|
|
177
|
-
// Default: auto-friend
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
198
|
+
// Default: auto-friend ONLY infrastructure — the dora registries and
|
|
199
|
+
// the official exit nodes (config/default-exits.yaml) — not every
|
|
200
|
+
// personal/compute box in the roster. Hub-and-spoke: a client connects
|
|
201
|
+
// to doras + exits; exits still serve arbitrary clients because the
|
|
202
|
+
// daemon auto-ACCEPTS incoming friend-requests. The full roster is
|
|
203
|
+
// still learned into IPAM for name/IP resolution. Run a full mesh with
|
|
204
|
+
// `agentnet dora autofriend all`, lock down with `... none`, or curate
|
|
205
|
+
// with `agentnet dora autofriend allow <peer>...`.
|
|
206
|
+
autoFriend: DEFAULT_AUTOFRIEND,
|
|
184
207
|
},
|
|
185
208
|
};
|
|
186
209
|
}
|
|
@@ -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,74 @@
|
|
|
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, plus Binance
|
|
37
|
+
// (routed through the Japan exit — it's blocked/geo-restricted on many
|
|
38
|
+
// other paths). .bnbstatic.com is Binance's asset CDN; without it the
|
|
39
|
+
// site loads blank.
|
|
40
|
+
japan: [
|
|
41
|
+
".jp",
|
|
42
|
+
".nicovideo.jp", ".nimg.jp", ".dmm.com", ".dmm.co.jp",
|
|
43
|
+
".abema.tv", ".abema.io", ".tver.jp", ".unext.jp",
|
|
44
|
+
".radiko.jp", ".pixiv.net", ".pximg.net",
|
|
45
|
+
".binance.com", ".binance.org", ".bnbstatic.com", ".binancecnt.com",
|
|
46
|
+
],
|
|
47
|
+
// US region: Western sites commonly blocked/throttled from China (route via
|
|
48
|
+
// a US exit), plus US-geo-fenced streaming.
|
|
49
|
+
us: [
|
|
50
|
+
// Google / YouTube
|
|
51
|
+
".google.com", ".google.com.hk", ".gstatic.com", ".googleapis.com",
|
|
52
|
+
".googleusercontent.com", ".googlevideo.com", ".ggpht.com", ".withgoogle.com", ".goo.gl",
|
|
53
|
+
".youtube.com", ".youtu.be", ".ytimg.com",
|
|
54
|
+
// GitHub
|
|
55
|
+
".github.com", ".github.io", ".githubusercontent.com", ".githubassets.com", ".ghcr.io",
|
|
56
|
+
// X / Twitter
|
|
57
|
+
".twitter.com", ".x.com", ".twimg.com", ".t.co",
|
|
58
|
+
// LinkedIn
|
|
59
|
+
".linkedin.com", ".licdn.com",
|
|
60
|
+
// OpenAI / ChatGPT
|
|
61
|
+
".openai.com", ".chatgpt.com", ".oaistatic.com", ".oaiusercontent.com",
|
|
62
|
+
// US-geo-fenced streaming
|
|
63
|
+
".hulu.com", ".huluim.com", ".hulustream.com",
|
|
64
|
+
".peacocktv.com", ".max.com", ".hbomax.com",
|
|
65
|
+
".pluto.tv", ".tubi.tv", ".tubitv.com",
|
|
66
|
+
".vudu.com", ".espn.com", ".nbc.com", ".cbs.com", ".fox.com",
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
function hostMatches(host, suffixes) {
|
|
27
70
|
host = (host || "").toLowerCase();
|
|
28
|
-
return
|
|
71
|
+
return suffixes.some((s) => host === (s.startsWith(".") ? s.slice(1) : s) || host.endsWith(s));
|
|
29
72
|
}
|
|
30
73
|
const HEALTH_INTERVAL_MS = 1000;
|
|
31
74
|
const HEALTH_TIMEOUT_MS = 1500;
|
|
@@ -39,11 +82,17 @@ export function startMultiExitRouter(opts) {
|
|
|
39
82
|
const listenHost = opts.listenHost ?? "127.0.0.1";
|
|
40
83
|
const listenPort = opts.listenPort ?? 8889;
|
|
41
84
|
const mode = opts.mode === "failover" ? "failover" : "loadbalance";
|
|
42
|
-
const
|
|
85
|
+
const defaultRegion = opts.defaultRegion ?? "direct";
|
|
43
86
|
const ts = () => new Date().toTimeString().slice(0, 8);
|
|
44
87
|
const log = opts.log ?? ((s) => console.log(`${ts()} ${s}`));
|
|
88
|
+
// Resolve each region's effective domain list (explicit list, else built-in).
|
|
89
|
+
const regions = (opts.regions ?? []).map((r) => ({
|
|
90
|
+
name: r.name,
|
|
91
|
+
domains: r.domains && r.domains.length > 0 ? r.domains : (BUILTIN_REGION_DOMAINS[r.name] ?? []),
|
|
92
|
+
}));
|
|
45
93
|
const exits = opts.exits.map((e) => ({
|
|
46
94
|
...e,
|
|
95
|
+
region: e.region ?? "default",
|
|
47
96
|
healthy: false,
|
|
48
97
|
fails: 0,
|
|
49
98
|
oks: 0,
|
|
@@ -51,10 +100,24 @@ export function startMultiExitRouter(opts) {
|
|
|
51
100
|
served: 0,
|
|
52
101
|
lastRtt: null,
|
|
53
102
|
}));
|
|
103
|
+
// Per-host routing decision, logged once on first sight so the user can SEE
|
|
104
|
+
// why a hostname went DIRECT vs through a region (e.g. a CCTV video CDN that
|
|
105
|
+
// isn't classified as China and silently leaked direct → geo-blocked).
|
|
106
|
+
const seenHosts = new Set();
|
|
54
107
|
let directActive = 0;
|
|
55
108
|
let directTotal = 0;
|
|
56
109
|
let lastPool = null;
|
|
57
110
|
let stopped = false;
|
|
111
|
+
// ---- routing decision -----------------------------------------------------
|
|
112
|
+
// Returns the region name a host should use, or "direct" for a direct
|
|
113
|
+
// connection from this machine.
|
|
114
|
+
function routeFor(host) {
|
|
115
|
+
for (const r of regions) {
|
|
116
|
+
if (r.domains.length > 0 && hostMatches(host, r.domains))
|
|
117
|
+
return r.name;
|
|
118
|
+
}
|
|
119
|
+
return defaultRegion;
|
|
120
|
+
}
|
|
58
121
|
// ---- health checks --------------------------------------------------------
|
|
59
122
|
function probe(exit) {
|
|
60
123
|
return new Promise((resolve) => {
|
|
@@ -66,10 +129,22 @@ export function startMultiExitRouter(opts) {
|
|
|
66
129
|
});
|
|
67
130
|
}
|
|
68
131
|
function printPool() {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
// Group the healthy pool by region so the operator can see each region's
|
|
133
|
+
// bandwidth at a glance.
|
|
134
|
+
const byRegion = new Map();
|
|
135
|
+
for (const e of exits) {
|
|
136
|
+
if (!e.healthy)
|
|
137
|
+
continue;
|
|
138
|
+
const arr = byRegion.get(e.region) ?? [];
|
|
139
|
+
arr.push(`${e.name}(${e.lastRtt}ms)`);
|
|
140
|
+
byRegion.set(e.region, arr);
|
|
141
|
+
}
|
|
142
|
+
const summary = byRegion.size === 0
|
|
143
|
+
? "NONE"
|
|
144
|
+
: [...byRegion.entries()].map(([r, ms]) => `${r}: ${ms.join(", ")}`).join(" | ");
|
|
145
|
+
if (summary !== lastPool) {
|
|
146
|
+
log(`pool [${mode}]: ${summary}`);
|
|
147
|
+
lastPool = summary;
|
|
73
148
|
}
|
|
74
149
|
}
|
|
75
150
|
async function healthLoop() {
|
|
@@ -81,7 +156,7 @@ export function startMultiExitRouter(opts) {
|
|
|
81
156
|
exit.fails = 0;
|
|
82
157
|
if (!exit.healthy) {
|
|
83
158
|
exit.healthy = true;
|
|
84
|
-
log(`✓ ${exit.name} UP (${exit.lastRtt}ms)`);
|
|
159
|
+
log(`✓ ${exit.name} [${exit.region}] UP (${exit.lastRtt}ms)`);
|
|
85
160
|
printPool();
|
|
86
161
|
}
|
|
87
162
|
}
|
|
@@ -90,7 +165,7 @@ export function startMultiExitRouter(opts) {
|
|
|
90
165
|
exit.oks = 0;
|
|
91
166
|
if (exit.healthy && exit.fails >= DOWN_AFTER_FAILS) {
|
|
92
167
|
exit.healthy = false;
|
|
93
|
-
log(`✗ ${exit.name} DOWN`);
|
|
168
|
+
log(`✗ ${exit.name} [${exit.region}] DOWN`);
|
|
94
169
|
printPool();
|
|
95
170
|
}
|
|
96
171
|
}
|
|
@@ -98,15 +173,20 @@ export function startMultiExitRouter(opts) {
|
|
|
98
173
|
await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
|
|
99
174
|
}
|
|
100
175
|
}
|
|
101
|
-
// ---- exit selection
|
|
102
|
-
function pickOrder() {
|
|
103
|
-
const healthy = exits.filter((e) => e.healthy);
|
|
176
|
+
// ---- exit selection (scoped to one region) --------------------------------
|
|
177
|
+
function pickOrder(region) {
|
|
178
|
+
const healthy = exits.filter((e) => e.healthy && e.region === region);
|
|
104
179
|
if (healthy.length === 0)
|
|
105
180
|
return [];
|
|
106
181
|
if (mode === "failover")
|
|
107
182
|
return healthy; // priority order (input order)
|
|
108
183
|
return [...healthy].sort((a, b) => a.active - b.active || a.served - b.served);
|
|
109
184
|
}
|
|
185
|
+
// Whether ANY exit is configured for a region (healthy or not). Lets us tell
|
|
186
|
+
// "region has no exits at all → fall through" from "exits all down → 503".
|
|
187
|
+
function regionHasExits(region) {
|
|
188
|
+
return exits.some((e) => e.region === region);
|
|
189
|
+
}
|
|
110
190
|
// ---- CONNECT through an exit ----------------------------------------------
|
|
111
191
|
function forward(exit, target, client, head, onFail) {
|
|
112
192
|
const up = net.connect(exit.port, exit.host);
|
|
@@ -154,8 +234,8 @@ export function startMultiExitRouter(opts) {
|
|
|
154
234
|
up.on("data", onData);
|
|
155
235
|
up.on("error", () => fail("conn error"));
|
|
156
236
|
}
|
|
157
|
-
// Direct connection from THIS machine — for
|
|
158
|
-
// aren't black-holed through a
|
|
237
|
+
// Direct connection from THIS machine — for hosts that match no region so
|
|
238
|
+
// Google etc. aren't black-holed through a foreign exit.
|
|
159
239
|
function directConnect(target, client, head) {
|
|
160
240
|
const [host, portStr] = target.split(":");
|
|
161
241
|
const up = net.connect(Number(portStr) || 443, host);
|
|
@@ -181,13 +261,26 @@ export function startMultiExitRouter(opts) {
|
|
|
181
261
|
const target = req.url || "";
|
|
182
262
|
const host = (target.split(":")[0] || "").toLowerCase();
|
|
183
263
|
client.on("error", () => { });
|
|
184
|
-
|
|
264
|
+
let region = routeFor(host);
|
|
265
|
+
// If the chosen region has no exits configured, fall back to direct rather
|
|
266
|
+
// than 503 (a misconfigured default shouldn't black-hole everything).
|
|
267
|
+
if (region !== "direct" && !regionHasExits(region))
|
|
268
|
+
region = "direct";
|
|
269
|
+
if (!seenHosts.has(host)) {
|
|
270
|
+
seenHosts.add(host);
|
|
271
|
+
log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`}`);
|
|
272
|
+
}
|
|
273
|
+
if (region === "direct") {
|
|
185
274
|
directConnect(target, client, head);
|
|
186
275
|
return;
|
|
187
276
|
}
|
|
188
|
-
const order = pickOrder();
|
|
277
|
+
const order = pickOrder(region);
|
|
189
278
|
if (order.length === 0) {
|
|
190
|
-
|
|
279
|
+
// Region matched but every exit in it is down. Do NOT silently leak to
|
|
280
|
+
// direct — a .cn host would just fail from abroad, and the operator
|
|
281
|
+
// should see the region is down.
|
|
282
|
+
log(` ${target}: region [${region}] has no healthy exit`);
|
|
283
|
+
client.end("HTTP/1.1 503 No healthy exit for region\r\n\r\n");
|
|
191
284
|
return;
|
|
192
285
|
}
|
|
193
286
|
let i = 0;
|
|
@@ -204,16 +297,20 @@ export function startMultiExitRouter(opts) {
|
|
|
204
297
|
});
|
|
205
298
|
server.listen(listenPort, listenHost, () => {
|
|
206
299
|
log(`Multi-exit router [${mode}] on http://${listenHost}:${listenPort}`);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
300
|
+
// One line per region: which exits serve it.
|
|
301
|
+
for (const r of regions) {
|
|
302
|
+
if (!regionHasExits(r.name))
|
|
303
|
+
continue;
|
|
304
|
+
const members = exits.filter((e) => e.region === r.name).map((e) => `${e.name}@${e.host}:${e.port}`);
|
|
305
|
+
log(` region [${r.name}] → ${members.join(", ")}`);
|
|
306
|
+
}
|
|
307
|
+
log(` default (unmatched hosts) → ${defaultRegion === "direct" ? "DIRECT" : `region [${defaultRegion}]`}`);
|
|
211
308
|
log(`Point your browser's HTTPS proxy at ${listenHost}:${listenPort}`);
|
|
212
309
|
void healthLoop();
|
|
213
310
|
});
|
|
214
311
|
if (mode === "loadbalance") {
|
|
215
312
|
const reporter = setInterval(() => {
|
|
216
|
-
const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}: ${e.active} active / ${e.served} total`);
|
|
313
|
+
const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total`);
|
|
217
314
|
parts.push(`direct: ${directActive} active / ${directTotal} total`);
|
|
218
315
|
log(`load — ${parts.join(" | ")}`);
|
|
219
316
|
}, 15000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.72",
|
|
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,8 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"bin",
|
|
21
21
|
"config/default-doras.yaml",
|
|
22
|
+
"config/default-exits.yaml",
|
|
23
|
+
"config/routes.example.yaml",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE",
|
|
24
26
|
"docs/INSTALL.md",
|