@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.
Binary file
Binary file
Binary file
Binary file
@@ -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
  */
@@ -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"] });
@@ -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
- this.logger.info(`Listening on ${this.opts.bindIp}:${this.opts.port}`);
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 { ...this.stats, peers };
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
- ".v.myalicdn.com", // CCTV live HLS rides Ali CDN video subdomains (piccpndali.v.myalicdn.com etc.)
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 but the ORIGIN errored (502/403/…) not an exit fault.
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
  };
@@ -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
@@ -173,15 +173,24 @@ async function dkGet(path) {
173
173
  return r.json();
174
174
  }
175
175
  async function dkPost(path, body) {
176
- const r = await fetch(path, {
177
- method: "POST",
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
- return await r.json();
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: r.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
- const r = await opts.call({ op: "friend-request", address });
669
- sendJson(res, r.ok ? 200 : 400, r);
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.151",
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.11",
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",