@decentnetwork/lan 0.1.151 → 0.1.155
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/dist/cli/commands.d.ts +19 -0
- package/dist/cli/commands.js +63 -0
- package/dist/cli/index.js +16 -1
- package/dist/daemon/server.js +12 -1
- package/dist/proxy/connect-proxy.d.ts +41 -0
- package/dist/proxy/connect-proxy.js +76 -3
- package/dist/proxy/multi-exit-router.js +35 -2
- package/dist/tun/tun-device.js +19 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/desktop/app.js +19 -7
- package/dist/ui/server.js +21 -2
- package/package.json +2 -2
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -205,6 +205,25 @@ export declare function cmdProxyAllowHost(args: {
|
|
|
205
205
|
host: string;
|
|
206
206
|
configDir?: string;
|
|
207
207
|
}): Promise<void>;
|
|
208
|
+
/**
|
|
209
|
+
* Manage the exit's free-tier whitelist: userids that are always served, even
|
|
210
|
+
* when the exit is busy (the paid / priority set). `action` is add | remove |
|
|
211
|
+
* list. Applies live to the running proxy — no restart, no session churn.
|
|
212
|
+
*/
|
|
213
|
+
export declare function cmdProxyWhitelist(args: {
|
|
214
|
+
action: "add" | "remove" | "list";
|
|
215
|
+
userid?: string;
|
|
216
|
+
configDir?: string;
|
|
217
|
+
}): Promise<void>;
|
|
218
|
+
/**
|
|
219
|
+
* Set (or clear, with 0) the exit's busy threshold in Mbps. Above this smoothed
|
|
220
|
+
* egress rate the exit serves only whitelisted peers; 0 = free for all.
|
|
221
|
+
* Applies live to the running proxy.
|
|
222
|
+
*/
|
|
223
|
+
export declare function cmdProxyBusyMbps(args: {
|
|
224
|
+
mbps: number;
|
|
225
|
+
configDir?: string;
|
|
226
|
+
}): Promise<void>;
|
|
208
227
|
/**
|
|
209
228
|
* Remove a host glob from the proxy allowlist.
|
|
210
229
|
*/
|
package/dist/cli/commands.js
CHANGED
|
@@ -976,6 +976,69 @@ export async function cmdProxyAllowHost(args) {
|
|
|
976
976
|
console.log(`Added '${args.host}' to proxy allow-hosts.`);
|
|
977
977
|
await applyProxyReloadIfRunning(config);
|
|
978
978
|
}
|
|
979
|
+
/**
|
|
980
|
+
* Manage the exit's free-tier whitelist: userids that are always served, even
|
|
981
|
+
* when the exit is busy (the paid / priority set). `action` is add | remove |
|
|
982
|
+
* list. Applies live to the running proxy — no restart, no session churn.
|
|
983
|
+
*/
|
|
984
|
+
export async function cmdProxyWhitelist(args) {
|
|
985
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
986
|
+
const configPath = resolve(dir, "config.yaml");
|
|
987
|
+
const config = await ConfigLoader.load(configPath);
|
|
988
|
+
const proxy = config.proxy ?? { enabled: false, port: 8888 };
|
|
989
|
+
const whitelist = new Set(proxy.whitelist ?? []);
|
|
990
|
+
if (args.action === "list") {
|
|
991
|
+
if (whitelist.size === 0) {
|
|
992
|
+
console.log("Exit whitelist is empty (when busy, no one is prioritized — free users are refused).");
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
console.log(`Exit whitelist (${whitelist.size} always-served userid(s)):`);
|
|
996
|
+
for (const u of whitelist)
|
|
997
|
+
console.log(` - ${u}`);
|
|
998
|
+
}
|
|
999
|
+
const busy = proxy.busyMbps ?? 0;
|
|
1000
|
+
console.log(busy > 0
|
|
1001
|
+
? `Busy threshold: ${busy}Mbps (above this, only whitelisted peers get new tunnels).`
|
|
1002
|
+
: `Busy threshold: OFF — exit is free for all. Set one with 'agentnet proxy busy-mbps <N>'.`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (!args.userid) {
|
|
1006
|
+
console.log(`A userid is required for '${args.action}'.`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (args.action === "add") {
|
|
1010
|
+
whitelist.add(args.userid);
|
|
1011
|
+
console.log(`Added ${args.userid} to the exit whitelist (always served, even when busy).`);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
if (!whitelist.delete(args.userid)) {
|
|
1015
|
+
console.log(`${args.userid} was not on the whitelist.`);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
console.log(`Removed ${args.userid} from the exit whitelist.`);
|
|
1019
|
+
}
|
|
1020
|
+
config.proxy = { ...proxy, whitelist: [...whitelist] };
|
|
1021
|
+
await ConfigLoader.save(config, configPath);
|
|
1022
|
+
await applyProxyReloadIfRunning(config);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Set (or clear, with 0) the exit's busy threshold in Mbps. Above this smoothed
|
|
1026
|
+
* egress rate the exit serves only whitelisted peers; 0 = free for all.
|
|
1027
|
+
* Applies live to the running proxy.
|
|
1028
|
+
*/
|
|
1029
|
+
export async function cmdProxyBusyMbps(args) {
|
|
1030
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
1031
|
+
const configPath = resolve(dir, "config.yaml");
|
|
1032
|
+
const config = await ConfigLoader.load(configPath);
|
|
1033
|
+
const proxy = config.proxy ?? { enabled: false, port: 8888 };
|
|
1034
|
+
const mbps = Math.max(0, Math.floor(args.mbps));
|
|
1035
|
+
config.proxy = { ...proxy, busyMbps: mbps };
|
|
1036
|
+
await ConfigLoader.save(config, configPath);
|
|
1037
|
+
console.log(mbps > 0
|
|
1038
|
+
? `Exit busy threshold set to ${mbps}Mbps — above this, only whitelisted peers get new tunnels.`
|
|
1039
|
+
: `Exit busy threshold cleared — the exit is now free for all.`);
|
|
1040
|
+
await applyProxyReloadIfRunning(config);
|
|
1041
|
+
}
|
|
979
1042
|
/**
|
|
980
1043
|
* If the daemon is up, push the freshly-saved proxy allowlist into the
|
|
981
1044
|
* running proxy over IPC — no restart, no Carrier-session churn. Prints
|
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
|
|
|
10
10
|
// Belt-and-braces — also raise it here in case the CLI is run directly
|
|
11
11
|
// (e.g. `node dist/cli/index.js` rather than via dist/index.js).
|
|
12
12
|
EventEmitter.defaultMaxListeners = 100;
|
|
13
|
-
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
13
|
+
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
14
14
|
async function main() {
|
|
15
15
|
await yargs(hideBin(process.argv))
|
|
16
16
|
.scriptName("agentnet")
|
|
@@ -317,6 +317,21 @@ async function main() {
|
|
|
317
317
|
})
|
|
318
318
|
.command("list-hosts", "List the CONNECT host allowlist", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
|
|
319
319
|
await cmdProxyListHosts({ configDir: argv["config-dir"] });
|
|
320
|
+
})
|
|
321
|
+
.command("whitelist <action> [userid]", "Free-tier whitelist: userids always served when the exit is busy (add|remove|list)", (yy) => yy
|
|
322
|
+
.positional("action", { type: "string", demandOption: true })
|
|
323
|
+
.positional("userid", { type: "string" })
|
|
324
|
+
.option("config-dir", { type: "string" }), async (argv) => {
|
|
325
|
+
if (argv.action !== "add" && argv.action !== "remove" && argv.action !== "list") {
|
|
326
|
+
console.log("Usage: agentnet proxy whitelist <add|remove|list> [userid]");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
await cmdProxyWhitelist({ action: argv.action, userid: argv.userid, configDir: argv["config-dir"] });
|
|
330
|
+
})
|
|
331
|
+
.command("busy-mbps <mbps>", "Set the exit's busy threshold (Mbps); above it, only whitelisted peers get new tunnels. 0 = free for all", (yy) => yy
|
|
332
|
+
.positional("mbps", { type: "number", demandOption: true })
|
|
333
|
+
.option("config-dir", { type: "string" }), async (argv) => {
|
|
334
|
+
await cmdProxyBusyMbps({ mbps: argv.mbps, configDir: argv["config-dir"] });
|
|
320
335
|
})
|
|
321
336
|
.command("who", "Show who is using THIS node's exit (per-peer tunnels + bytes)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
|
|
322
337
|
await cmdProxyWho({ configDir: argv["config-dir"] });
|
package/dist/daemon/server.js
CHANGED
|
@@ -541,13 +541,21 @@ export class DaemonServer {
|
|
|
541
541
|
};
|
|
542
542
|
}
|
|
543
543
|
this.connectProxy.updateAllowlist(allowHosts, allowConnectPorts);
|
|
544
|
+
// Also refresh the free-tier admission control (busy threshold +
|
|
545
|
+
// whitelist) from the same fresh config, so `proxy whitelist add`
|
|
546
|
+
// and `proxy busy-mbps` apply live without a restart.
|
|
547
|
+
const whitelist = fresh.proxy?.whitelist ?? [];
|
|
548
|
+
const busyMbps = fresh.proxy?.busyMbps;
|
|
549
|
+
this.connectProxy.updateAdmission({ whitelist, busyMbps });
|
|
544
550
|
// Keep the in-memory config in sync so a later diag reflects it.
|
|
545
551
|
if (this.config.proxy) {
|
|
546
552
|
this.config.proxy.allowHosts = allowHosts;
|
|
547
553
|
if (allowConnectPorts)
|
|
548
554
|
this.config.proxy.allowConnectPorts = allowConnectPorts;
|
|
555
|
+
this.config.proxy.whitelist = whitelist;
|
|
556
|
+
this.config.proxy.busyMbps = busyMbps;
|
|
549
557
|
}
|
|
550
|
-
return { applied: true, allowHosts };
|
|
558
|
+
return { applied: true, allowHosts, whitelist, busyMbps };
|
|
551
559
|
},
|
|
552
560
|
proxyAccess: async (userid, allow) => {
|
|
553
561
|
// Live block/allow a peer's access to THIS node's exit proxy port.
|
|
@@ -1031,7 +1039,10 @@ export class DaemonServer {
|
|
|
1031
1039
|
allowHosts: this.config.proxy.allowHosts ?? [],
|
|
1032
1040
|
allowConnectPorts: this.config.proxy.allowConnectPorts,
|
|
1033
1041
|
egressBindIp,
|
|
1042
|
+
busyMbps: this.config.proxy.busyMbps,
|
|
1043
|
+
whitelist: this.config.proxy.whitelist ?? [],
|
|
1034
1044
|
resolvePeerName: (srcIp) => this.ipam.resolveIp(srcIp)?.name,
|
|
1045
|
+
resolvePeerUserid: (srcIp) => this.ipam.resolveIp(srcIp)?.carrierId,
|
|
1035
1046
|
// Per-tunnel audit is opt-in: on a busy exit it floods the
|
|
1036
1047
|
// log (the 13 GB audit.log bug). Off by default; ACL and
|
|
1037
1048
|
// connect events are still always audited.
|
|
@@ -37,6 +37,16 @@ export interface ConnectProxyOptions {
|
|
|
37
37
|
egressBindIp?: string;
|
|
38
38
|
/** Hook for resolving a source IP to a friendly peer name (audit/log). */
|
|
39
39
|
resolvePeerName?: (sourceIp: string) => string | undefined;
|
|
40
|
+
/** Hook for resolving a source IP to the peer's Carrier userid. Used to test
|
|
41
|
+
* the busy-tier whitelist (which is keyed by userid). */
|
|
42
|
+
resolvePeerUserid?: (sourceIp: string) => string | undefined;
|
|
43
|
+
/** Aggregate egress throughput (Mbps) at/above which the exit counts as
|
|
44
|
+
* "busy": while busy, only whitelisted peers get NEW tunnels. 0/undefined =
|
|
45
|
+
* never busy = free for all (default, current behaviour). */
|
|
46
|
+
busyMbps?: number;
|
|
47
|
+
/** Carrier userids (or virtual IPs) always served even when busy — the paid /
|
|
48
|
+
* priority set. */
|
|
49
|
+
whitelist?: string[];
|
|
40
50
|
/** Called when a tunnel opens. */
|
|
41
51
|
onTunnelOpen?: (info: {
|
|
42
52
|
src: string;
|
|
@@ -70,6 +80,15 @@ export interface ConnectProxyStats {
|
|
|
70
80
|
totalOpened: number;
|
|
71
81
|
totalRefused: number;
|
|
72
82
|
bytesTransferred: number;
|
|
83
|
+
/** Smoothed aggregate egress throughput right now (Mbps). */
|
|
84
|
+
mbps: number;
|
|
85
|
+
/** Whether the exit is currently "busy" (mbps >= busyMbps threshold). When
|
|
86
|
+
* true, only whitelisted peers get new tunnels. */
|
|
87
|
+
busy: boolean;
|
|
88
|
+
/** Configured busy threshold (Mbps); 0 = never busy (free for all). */
|
|
89
|
+
busyMbps: number;
|
|
90
|
+
/** Number of userids on the busy-tier whitelist. */
|
|
91
|
+
whitelistSize: number;
|
|
73
92
|
/** Per-client breakdown, heaviest (by bytes) first. */
|
|
74
93
|
peers: ProxyPeerUsage[];
|
|
75
94
|
}
|
|
@@ -80,12 +99,34 @@ export declare class ConnectProxy {
|
|
|
80
99
|
private stats;
|
|
81
100
|
/** Live per-client usage, keyed by source virtual IP. */
|
|
82
101
|
private perPeer;
|
|
102
|
+
/** Smoothed aggregate egress throughput (Mbps), refreshed once a second. */
|
|
103
|
+
private mbps;
|
|
104
|
+
/** Bytes seen since the last meter tick (reset every second). */
|
|
105
|
+
private windowBytes;
|
|
106
|
+
private meterTimer?;
|
|
83
107
|
constructor(opts: ConnectProxyOptions);
|
|
108
|
+
/** The exit is "busy" when its smoothed egress rate is at/above busyMbps.
|
|
109
|
+
* A 0/undefined threshold means it is never busy — free for all (default). */
|
|
110
|
+
private isBusy;
|
|
111
|
+
/** A peer is whitelisted (always served, even when busy) if its userid — or,
|
|
112
|
+
* as a fallback, its source virtual IP — is on the whitelist. */
|
|
113
|
+
private isWhitelisted;
|
|
84
114
|
/** Get-or-create the per-client usage row for a source IP. */
|
|
85
115
|
private peerRow;
|
|
86
116
|
start(): Promise<void>;
|
|
87
117
|
stop(): Promise<void>;
|
|
88
118
|
getStats(): ConnectProxyStats;
|
|
119
|
+
/**
|
|
120
|
+
* Replace the busy-tier whitelist and/or threshold on a RUNNING proxy without
|
|
121
|
+
* a restart — handleConnect reads both at CONNECT time, so this takes effect
|
|
122
|
+
* on the next tunnel. Restarting would drop every Carrier session and trigger
|
|
123
|
+
* a slow reconvergence storm; live update is what makes `agentnet proxy
|
|
124
|
+
* whitelist add <userid>` (and a future billing webhook) instant.
|
|
125
|
+
*/
|
|
126
|
+
updateAdmission(opts: {
|
|
127
|
+
whitelist?: string[];
|
|
128
|
+
busyMbps?: number;
|
|
129
|
+
}): void;
|
|
89
130
|
/**
|
|
90
131
|
* Update the host allowlist (and optionally the port allowlist) of a
|
|
91
132
|
* RUNNING proxy without restarting it. handleConnect reads
|
|
@@ -29,6 +29,11 @@ export class ConnectProxy {
|
|
|
29
29
|
stats;
|
|
30
30
|
/** Live per-client usage, keyed by source virtual IP. */
|
|
31
31
|
perPeer = new Map();
|
|
32
|
+
/** Smoothed aggregate egress throughput (Mbps), refreshed once a second. */
|
|
33
|
+
mbps = 0;
|
|
34
|
+
/** Bytes seen since the last meter tick (reset every second). */
|
|
35
|
+
windowBytes = 0;
|
|
36
|
+
meterTimer;
|
|
32
37
|
constructor(opts) {
|
|
33
38
|
this.opts = opts;
|
|
34
39
|
this.logger = new Logger({ prefix: "ConnectProxy" });
|
|
@@ -40,9 +45,30 @@ export class ConnectProxy {
|
|
|
40
45
|
totalOpened: 0,
|
|
41
46
|
totalRefused: 0,
|
|
42
47
|
bytesTransferred: 0,
|
|
48
|
+
mbps: 0,
|
|
49
|
+
busy: false,
|
|
50
|
+
busyMbps: opts.busyMbps ?? 0,
|
|
51
|
+
whitelistSize: opts.whitelist?.length ?? 0,
|
|
43
52
|
peers: [],
|
|
44
53
|
};
|
|
45
54
|
}
|
|
55
|
+
/** The exit is "busy" when its smoothed egress rate is at/above busyMbps.
|
|
56
|
+
* A 0/undefined threshold means it is never busy — free for all (default). */
|
|
57
|
+
isBusy() {
|
|
58
|
+
const limit = this.opts.busyMbps ?? 0;
|
|
59
|
+
return limit > 0 && this.mbps >= limit;
|
|
60
|
+
}
|
|
61
|
+
/** A peer is whitelisted (always served, even when busy) if its userid — or,
|
|
62
|
+
* as a fallback, its source virtual IP — is on the whitelist. */
|
|
63
|
+
isWhitelisted(src) {
|
|
64
|
+
const wl = this.opts.whitelist;
|
|
65
|
+
if (!wl || wl.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
if (wl.includes(src))
|
|
68
|
+
return true; // whitelist-by-IP convenience
|
|
69
|
+
const uid = this.opts.resolvePeerUserid?.(src);
|
|
70
|
+
return uid !== undefined && wl.includes(uid);
|
|
71
|
+
}
|
|
46
72
|
/** Get-or-create the per-client usage row for a source IP. */
|
|
47
73
|
peerRow(src, srcName) {
|
|
48
74
|
let row = this.perPeer.get(src);
|
|
@@ -85,11 +111,27 @@ export class ConnectProxy {
|
|
|
85
111
|
});
|
|
86
112
|
});
|
|
87
113
|
this.stats.startedAt = Date.now();
|
|
88
|
-
|
|
114
|
+
// Throughput meter for busy-detection: sum bytes each second → Mbps, then
|
|
115
|
+
// EWMA-smooth so a bursty video stream (or one idle second between segments)
|
|
116
|
+
// doesn't flip "busy" on/off and readmit a flood of free users mid-spike.
|
|
117
|
+
this.meterTimer = setInterval(() => {
|
|
118
|
+
const inst = (this.windowBytes * 8) / 1e6; // Mbps in the last second
|
|
119
|
+
this.windowBytes = 0;
|
|
120
|
+
this.mbps = 0.5 * this.mbps + 0.5 * inst;
|
|
121
|
+
}, 1000);
|
|
122
|
+
this.meterTimer.unref?.();
|
|
123
|
+
this.logger.info(`Listening on ${this.opts.bindIp}:${this.opts.port}` +
|
|
124
|
+
(this.opts.busyMbps
|
|
125
|
+
? ` · free-tier admission: busy>=${this.opts.busyMbps}Mbps → whitelist-only (${this.opts.whitelist?.length ?? 0} userid(s))`
|
|
126
|
+
: " · free for all (no busy threshold set)"));
|
|
89
127
|
}
|
|
90
128
|
async stop() {
|
|
91
129
|
if (!this.server)
|
|
92
130
|
return;
|
|
131
|
+
if (this.meterTimer) {
|
|
132
|
+
clearInterval(this.meterTimer);
|
|
133
|
+
this.meterTimer = undefined;
|
|
134
|
+
}
|
|
93
135
|
const srv = this.server;
|
|
94
136
|
this.server = null;
|
|
95
137
|
await new Promise((res) => {
|
|
@@ -101,7 +143,29 @@ export class ConnectProxy {
|
|
|
101
143
|
const peers = [...this.perPeer.values()]
|
|
102
144
|
.sort((a, b) => b.bytesTransferred - a.bytesTransferred || b.totalOpened - a.totalOpened)
|
|
103
145
|
.map((p) => ({ ...p }));
|
|
104
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
...this.stats,
|
|
148
|
+
mbps: Math.round(this.mbps * 10) / 10,
|
|
149
|
+
busy: this.isBusy(),
|
|
150
|
+
busyMbps: this.opts.busyMbps ?? 0,
|
|
151
|
+
whitelistSize: this.opts.whitelist?.length ?? 0,
|
|
152
|
+
peers,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Replace the busy-tier whitelist and/or threshold on a RUNNING proxy without
|
|
157
|
+
* a restart — handleConnect reads both at CONNECT time, so this takes effect
|
|
158
|
+
* on the next tunnel. Restarting would drop every Carrier session and trigger
|
|
159
|
+
* a slow reconvergence storm; live update is what makes `agentnet proxy
|
|
160
|
+
* whitelist add <userid>` (and a future billing webhook) instant.
|
|
161
|
+
*/
|
|
162
|
+
updateAdmission(opts) {
|
|
163
|
+
if (opts.whitelist !== undefined)
|
|
164
|
+
this.opts.whitelist = opts.whitelist;
|
|
165
|
+
if (opts.busyMbps !== undefined)
|
|
166
|
+
this.opts.busyMbps = opts.busyMbps;
|
|
167
|
+
this.logger.info(`Admission updated live: busy>=${this.opts.busyMbps ?? 0}Mbps, ` +
|
|
168
|
+
`whitelist=${this.opts.whitelist?.length ?? 0} userid(s)`);
|
|
105
169
|
}
|
|
106
170
|
/**
|
|
107
171
|
* Update the host allowlist (and optionally the port allowlist) of a
|
|
@@ -149,6 +213,14 @@ export class ConnectProxy {
|
|
|
149
213
|
this.refuse(clientSocket, src, srcName, `${host}:${port}`, 403, "host not in allowHosts");
|
|
150
214
|
return;
|
|
151
215
|
}
|
|
216
|
+
// Free-tier admission control. While the exit is busy (egress at/above the
|
|
217
|
+
// configured busyMbps), only whitelisted (paid/priority) peers get NEW
|
|
218
|
+
// tunnels; free users are told to retry. Existing tunnels are never cut, and
|
|
219
|
+
// an exit with no busyMbps set is never "busy" (free for all — the default).
|
|
220
|
+
if (this.isBusy() && !this.isWhitelisted(src)) {
|
|
221
|
+
this.refuse(clientSocket, src, srcName, `${host}:${port}`, 503, `exit busy (${Math.round(this.mbps)}Mbps) — free capacity full; whitelisted/paid peers only. Retry shortly.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
152
224
|
let bytes = 0;
|
|
153
225
|
let closed = false;
|
|
154
226
|
let lastBytes = -1;
|
|
@@ -199,6 +271,7 @@ export class ConnectProxy {
|
|
|
199
271
|
});
|
|
200
272
|
const onData = (chunk) => {
|
|
201
273
|
bytes += chunk.length;
|
|
274
|
+
this.windowBytes += chunk.length; // feed the busy-detection meter
|
|
202
275
|
};
|
|
203
276
|
upstream.on("data", onData);
|
|
204
277
|
clientSocket.on("data", onData);
|
|
@@ -246,7 +319,7 @@ export class ConnectProxy {
|
|
|
246
319
|
row.lastSeenMs = Date.now();
|
|
247
320
|
this.logger.info(`tunnel refused ${src}${srcName ? ` (${srcName})` : ""} -> ${target}: ${reason}`);
|
|
248
321
|
try {
|
|
249
|
-
const statusText = code === 400 ? "Bad Request" : "Forbidden";
|
|
322
|
+
const statusText = code === 400 ? "Bad Request" : code === 503 ? "Service Unavailable" : "Forbidden";
|
|
250
323
|
clientSocket.write(`HTTP/1.1 ${code} ${statusText}\r\nProxy-Agent: decentlan\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n${reason}\n`);
|
|
251
324
|
}
|
|
252
325
|
catch {
|
|
@@ -33,7 +33,15 @@ export const BUILTIN_REGION_DOMAINS = {
|
|
|
33
33
|
china: [
|
|
34
34
|
".cn",
|
|
35
35
|
".cctv.com", ".cctvpic.com", ".cntv.cn", ".cctv.cn",
|
|
36
|
-
|
|
36
|
+
// CCTV live rotates its HLS/FLV across several third-party video CDNs; the
|
|
37
|
+
// decision API hands back whichever is up, so ALL of them must ride a China
|
|
38
|
+
// exit or the segment host leaks direct → the CDN geo-blocks ("您所在的地区,
|
|
39
|
+
// 暂不支持播放该视频"). Ali is .v.myalicdn.com; Kingsoft is .v.kcdnvip.com
|
|
40
|
+
// (+ its ks-cdn/ksyuncdn CNAME targets); Tencent/Volcano are covered below.
|
|
41
|
+
// These .com CDNs aren't caught by the .cn rule and the geo-classifier misses
|
|
42
|
+
// subdomains that resolve to a non-China edge, so pin them statically.
|
|
43
|
+
".v.myalicdn.com", ".myalicdn.com",
|
|
44
|
+
".kcdnvip.com", ".ks-cdn.com", ".ksyuncdn.com",
|
|
37
45
|
".bilibili.com", ".biliapi.net", ".bilivideo.com", ".bilivideo.cn", ".hdslb.com",
|
|
38
46
|
".youku.com", ".iqiyi.com", ".iq.com", ".mgtv.com", ".hitv.com",
|
|
39
47
|
".miguvideo.com", ".cmvideo.cn", ".migu.cn",
|
|
@@ -114,6 +122,7 @@ const STALL_MIN_OPEN_MS = 4000; // only a tunnel open at least this long can cou
|
|
|
114
122
|
const STALL_MIN_BYTES = 16 * 1024; // delivered at least this = a healthy, real segment
|
|
115
123
|
const STALL_DEAD_BYTES = 1024; // relayed LESS than this = the exit is genuinely dead (only THIS counts as a stall); a small-but-real request (playlist/key/keep-alive, 1-16 KB) is NEUTRAL, so it can't flap a healthy exit out of the pool
|
|
116
124
|
const TRIP_AFTER_STALLS = 3; // consecutive DEAD tunnels before an exit is tripped out (hysteresis)
|
|
125
|
+
const TRIP_AFTER_UPSTREAM_FAILS = 5; // consecutive non-200 CONNECT responses (exit's proxy broken, e.g. 502s everything) before tripping
|
|
117
126
|
const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
|
|
118
127
|
// Measures peak short-window throughput of a byte stream. Feed it every chunk
|
|
119
128
|
// length via add(); read peakBps at the end. Peak (not average) shrugs off TCP
|
|
@@ -196,6 +205,7 @@ export function startMultiExitRouter(opts) {
|
|
|
196
205
|
served: 0,
|
|
197
206
|
lastRtt: null,
|
|
198
207
|
lastSuccessMs: null,
|
|
208
|
+
consecUpstreamFails: 0,
|
|
199
209
|
speedEwma: null,
|
|
200
210
|
bytesDown: 0,
|
|
201
211
|
stalls: 0,
|
|
@@ -458,6 +468,7 @@ export function startMultiExitRouter(opts) {
|
|
|
458
468
|
// tunnels are flowing.
|
|
459
469
|
exit.lastSuccessMs = Date.now();
|
|
460
470
|
exit.fails = 0;
|
|
471
|
+
exit.consecUpstreamFails = 0;
|
|
461
472
|
if (!exit.healthy) {
|
|
462
473
|
exit.healthy = true;
|
|
463
474
|
log(`✓ ${exit.name} [${exit.region}] UP (live traffic)`);
|
|
@@ -527,7 +538,29 @@ export function startMultiExitRouter(opts) {
|
|
|
527
538
|
up.on("error", () => client.destroy());
|
|
528
539
|
}
|
|
529
540
|
else {
|
|
530
|
-
// Exit answered
|
|
541
|
+
// Exit answered with a non-200 CONNECT status. Usually the ORIGIN's
|
|
542
|
+
// fault (a dead host 502s on EVERY exit — tripping would churn the pool),
|
|
543
|
+
// so we just fail over. BUT if THIS exit returns non-200 for many targets
|
|
544
|
+
// in a row (reset on any success), its own proxy is broken — e.g. it 502s
|
|
545
|
+
// everything (dead upstream / wrong version). Trip it so it stops eating
|
|
546
|
+
// the first pick on every request.
|
|
547
|
+
//
|
|
548
|
+
// Guard with the health probe: only trip an exit that is ALSO failing its
|
|
549
|
+
// probe (its proxy is genuinely unreachable). A 502 means the exit's proxy
|
|
550
|
+
// ANSWERED — it's alive — and the ORIGIN failed. When several DEAD origins
|
|
551
|
+
// are hit in a row (observed on CCTV: js.data.cctv.cn + p.data.cctv.cn,
|
|
552
|
+
// both unreachable even from inside China, 502 on every exit), the streak
|
|
553
|
+
// reaches the threshold and would trip a perfectly healthy exit out of the
|
|
554
|
+
// pool mid-stream (sh got tripped this way). The probe is the real
|
|
555
|
+
// proxy-liveness signal; a run of origin 502s alone must not evict an exit.
|
|
556
|
+
exit.consecUpstreamFails++;
|
|
557
|
+
const proxyAlive = exit.healthy || (exit.lastSuccessMs !== null && Date.now() - exit.lastSuccessMs < RECENT_SUCCESS_MS);
|
|
558
|
+
if (!proxyAlive && exit.consecUpstreamFails >= TRIP_AFTER_UPSTREAM_FAILS && exit.trippedUntil <= Date.now()) {
|
|
559
|
+
exit.trippedUntil = Date.now() + TRIP_COOLDOWN_MS;
|
|
560
|
+
exit.healthy = false;
|
|
561
|
+
log(`✗ ${exit.name} [${exit.region}] TRIPPED for ${TRIP_COOLDOWN_MS / 1000}s (proxy returned ${status.trim()} for ${exit.consecUpstreamFails} targets in a row) — excluding from pool`);
|
|
562
|
+
printPool();
|
|
563
|
+
}
|
|
531
564
|
fail(`upstream ${status}`, false);
|
|
532
565
|
}
|
|
533
566
|
};
|
package/dist/tun/tun-device.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 4-byte big-endian length-prefix framing.
|
|
9
9
|
*/
|
|
10
10
|
import { EventEmitter } from "events";
|
|
11
|
-
import { spawn } from "child_process";
|
|
11
|
+
import { spawn, execFileSync } from "child_process";
|
|
12
12
|
import { resolve, dirname } from "path";
|
|
13
13
|
import { existsSync } from "fs";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
@@ -181,6 +181,24 @@ export class TunDevice extends EventEmitter {
|
|
|
181
181
|
throw new Error(`TUN helper binary not found: ${helperPath || "(none)"}\n` +
|
|
182
182
|
`Build it with: cd helper/tun-helper && go build -o bin/tun-helper-linux-amd64 .`);
|
|
183
183
|
}
|
|
184
|
+
// On Linux the helper creates a FIXED-name interface (agentnet0). If a
|
|
185
|
+
// prior helper died uncleanly — e.g. "read tun: file descriptor in bad
|
|
186
|
+
// state" during a rebuild, or a hard kill — the interface lingers, and the
|
|
187
|
+
// new helper fails to create it (EEXIST) → the daemon loops forever on
|
|
188
|
+
// "TUN rebuild failed (code=1)" until an operator manually runs
|
|
189
|
+
// `ip link delete agentnet0`. Pre-delete any stale interface so the
|
|
190
|
+
// (re)build is idempotent and self-heals. Best-effort: on a clean first
|
|
191
|
+
// start the interface is absent and this no-ops; macOS uses a dynamic
|
|
192
|
+
// utun so there is no fixed-name collision and we skip it.
|
|
193
|
+
if (process.platform === "linux") {
|
|
194
|
+
try {
|
|
195
|
+
execFileSync("ip", ["link", "delete", this.config.name], { stdio: "ignore" });
|
|
196
|
+
this.logger.info(`Removed stale interface ${this.config.name} before (re)build`);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Interface didn't exist — normal on a clean start; nothing to clean up.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
184
202
|
this.logger.info(`Spawning TUN helper: ${helperPath}`);
|
|
185
203
|
this.helper = spawn(helperPath, ["--name", this.config.name], {
|
|
186
204
|
stdio: ["pipe", "pipe", "pipe"],
|
package/dist/types.d.ts
CHANGED
|
@@ -110,6 +110,10 @@ export interface BootstrapNode {
|
|
|
110
110
|
port: number;
|
|
111
111
|
pk: string;
|
|
112
112
|
isTcp?: boolean;
|
|
113
|
+
/** Express relays only: set `false` to reach the relay over plain HTTP
|
|
114
|
+
* instead of HTTPS (the payload is already end-to-end encrypted). Lets a
|
|
115
|
+
* relay run on a raw IP with no TLS cert / domain / ICP filing. */
|
|
116
|
+
tls?: boolean;
|
|
113
117
|
}
|
|
114
118
|
export interface CarrierConfig {
|
|
115
119
|
dataDir: string;
|
|
@@ -135,6 +139,8 @@ export interface ProxyConfig {
|
|
|
135
139
|
allowConnectPorts?: number[];
|
|
136
140
|
auditTunnels?: boolean;
|
|
137
141
|
egress?: string;
|
|
142
|
+
busyMbps?: number;
|
|
143
|
+
whitelist?: string[];
|
|
138
144
|
}
|
|
139
145
|
export interface FriendsConfig {
|
|
140
146
|
/** When true (default), the running daemon auto-accepts incoming
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -173,15 +173,24 @@ async function dkGet(path) {
|
|
|
173
173
|
return r.json();
|
|
174
174
|
}
|
|
175
175
|
async function dkPost(path, body) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
headers: { "content-type": "application/json" },
|
|
179
|
-
body: JSON.stringify(body || {})
|
|
180
|
-
});
|
|
176
|
+
const ctrl = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => ctrl.abort(), 15e3);
|
|
181
178
|
try {
|
|
182
|
-
|
|
179
|
+
const r = await fetch(path, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify(body || {}),
|
|
183
|
+
signal: ctrl.signal
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
return await r.json();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { ok: r.ok };
|
|
189
|
+
}
|
|
183
190
|
} catch (e) {
|
|
184
|
-
return { ok:
|
|
191
|
+
return { ok: false, error: e && e.name === "AbortError" ? "timed out" : e && e.message || "network error" };
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timer);
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
function dkCopy(text) {
|
|
@@ -1012,6 +1021,9 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
|
|
|
1012
1021
|
} else {
|
|
1013
1022
|
setAddState({ kind: "err", msg: r && r.error ? `${T.addFailed}: ${r.error}` : T.addFailed });
|
|
1014
1023
|
}
|
|
1024
|
+
}).catch((e) => {
|
|
1025
|
+
setAddState({ kind: "err", msg: `${T.addFailed}: ${e && e.message || e}` });
|
|
1026
|
+
}).finally(() => {
|
|
1015
1027
|
setTimeout(() => setAddState((s) => s && s.kind !== "sending" ? null : s), 6e3);
|
|
1016
1028
|
});
|
|
1017
1029
|
};
|
package/dist/ui/server.js
CHANGED
|
@@ -665,8 +665,27 @@ export function startFriendUi(opts) {
|
|
|
665
665
|
}
|
|
666
666
|
if (req.method === "POST" && url === "/api/add") {
|
|
667
667
|
const { address } = await readBody(req);
|
|
668
|
-
|
|
669
|
-
|
|
668
|
+
// A friend-request is fire-and-forget: it's delivered when the peer is
|
|
669
|
+
// online and only becomes a friend once they accept. Don't let the UI
|
|
670
|
+
// hang on the network round-trip (DHT lookup / unreachable peer) — kick
|
|
671
|
+
// the send off, but respond within a short window. A fast failure (e.g.
|
|
672
|
+
// a malformed address) still surfaces its real error; a slow send
|
|
673
|
+
// returns optimistic success and continues in the background.
|
|
674
|
+
const sent = opts
|
|
675
|
+
.call({ op: "friend-request", address })
|
|
676
|
+
.then((r) => ({ done: true, r }))
|
|
677
|
+
.catch((e) => ({ done: true, r: { ok: false, error: e instanceof Error ? e.message : String(e) } }));
|
|
678
|
+
const settled = await Promise.race([
|
|
679
|
+
sent,
|
|
680
|
+
new Promise((r) => setTimeout(() => r({ done: false }), 2500)),
|
|
681
|
+
]);
|
|
682
|
+
if (settled.done) {
|
|
683
|
+
sendJson(res, settled.r.ok ? 200 : 400, settled.r);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
void sent.catch(() => { }); // let it finish in the background, no unhandled rejection
|
|
687
|
+
sendJson(res, 200, { ok: true, queued: true });
|
|
688
|
+
}
|
|
670
689
|
return;
|
|
671
690
|
}
|
|
672
691
|
res.writeHead(404);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.155",
|
|
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",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"build:console": "node scripts/build-console.mjs"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@decentnetwork/dora": "^0.1.
|
|
81
|
+
"@decentnetwork/dora": "^0.1.13",
|
|
82
82
|
"@decentnetwork/peer": "^0.1.79",
|
|
83
83
|
"ink": "^5.2.1",
|
|
84
84
|
"js-yaml": "^4.1.0",
|