@decentnetwork/lan 0.1.137 → 0.1.139

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.
Files changed (39) hide show
  1. package/bin/tun-helper-darwin-amd64 +0 -0
  2. package/bin/tun-helper-darwin-arm64 +0 -0
  3. package/bin/tun-helper-linux-amd64 +0 -0
  4. package/bin/tun-helper-linux-arm64 +0 -0
  5. package/dist/carrier/peer-manager.d.ts +10 -0
  6. package/dist/carrier/peer-manager.js +25 -1
  7. package/dist/carrier/types.d.ts +1 -0
  8. package/dist/carrier/types.js +6 -0
  9. package/dist/cli/commands.d.ts +22 -0
  10. package/dist/cli/commands.js +99 -0
  11. package/dist/cli/index.js +16 -1
  12. package/dist/daemon/ipc.d.ts +7 -1
  13. package/dist/daemon/ipc.js +4 -0
  14. package/dist/daemon/server.js +38 -0
  15. package/dist/proxy/connect-proxy.d.ts +17 -0
  16. package/dist/proxy/connect-proxy.js +29 -1
  17. package/dist/proxy/data/chnroutes-cn.d.ts +1 -0
  18. package/dist/proxy/data/chnroutes-cn.js +691 -0
  19. package/dist/proxy/geo-cn.d.ts +25 -0
  20. package/dist/proxy/geo-cn.js +144 -0
  21. package/dist/proxy/hls-prefetch.d.ts +67 -0
  22. package/dist/proxy/hls-prefetch.js +195 -0
  23. package/dist/proxy/mitm-ca.d.ts +27 -0
  24. package/dist/proxy/mitm-ca.js +120 -0
  25. package/dist/proxy/mitm-gateway.d.ts +22 -0
  26. package/dist/proxy/mitm-gateway.js +76 -0
  27. package/dist/proxy/multi-exit-router.d.ts +13 -0
  28. package/dist/proxy/multi-exit-router.js +693 -78
  29. package/dist/proxy/route-learner.d.ts +33 -0
  30. package/dist/proxy/route-learner.js +129 -0
  31. package/dist/router/paced-forwarder.d.ts +45 -0
  32. package/dist/router/paced-forwarder.js +188 -0
  33. package/dist/router/packet-router.d.ts +10 -0
  34. package/dist/router/packet-router.js +74 -1
  35. package/dist/types.d.ts +7 -0
  36. package/dist/ui/desktop/app.js +1 -1
  37. package/dist/ui/desktop/index.html +1 -1
  38. package/dist/ui/server.js +1 -1
  39. package/package.json +4 -2
Binary file
Binary file
Binary file
Binary file
@@ -18,6 +18,9 @@ export interface PeerManagerOptions {
18
18
  nickname?: string;
19
19
  /** Status-message / short bio advertised to friends (Carrier USERINFO descr). */
20
20
  statusMessage?: string;
21
+ /** Directory for persisting partially-received files so a dropped/restarted
22
+ * transfer resumes instead of restarting (断点续传). */
23
+ fileResumeDir?: string;
21
24
  }
22
25
  export declare class PeerManager extends EventEmitter {
23
26
  private peer;
@@ -185,6 +188,13 @@ export declare class PeerManager extends EventEmitter {
185
188
  * (162, lossless). Replaces the old `DORA:`-prefixed text on packet 64.
186
189
  */
187
190
  sendDora(userid: string, text: string): Promise<void>;
191
+ /**
192
+ * Report to `pubkey` the delivered rate (bytes/sec) we're measuring on their
193
+ * inbound IP flow, so they can pace their sends to us toward it. Lossless +
194
+ * tiny (4 bytes). Best-effort: a failed report just means the sender keeps
195
+ * its previous pace estimate.
196
+ */
197
+ sendRateReport(pubkey: string, bytesPerSec: number): Promise<void>;
188
198
  /**
189
199
  * Route a decoded decentlan frame to its packet-session, creating or
190
200
  * replacing the session on a HANDSHAKE_REQ (handles the Carrier re-pair
@@ -6,7 +6,7 @@ import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
7
  import { PacketSession } from "./packet-session.js";
8
8
  import { FrameCodec } from "./frame.js";
9
- import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, } from "./types.js";
9
+ import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, } from "./types.js";
10
10
  import { Logger } from "../utils/logger.js";
11
11
  export class PeerManager extends EventEmitter {
12
12
  peer = null;
@@ -32,6 +32,7 @@ export class PeerManager extends EventEmitter {
32
32
  // rides a single transport instead of fanning out over UDP + relay +
33
33
  // TCP relay (which delivered 3-4 duplicates of every IP packet).
34
34
  bulkDataPacketId: PACKET_ID_DL_IP,
35
+ fileResumeDir: opts.fileResumeDir,
35
36
  });
36
37
  this.setupEventHandlers();
37
38
  this.logger.info("Peer instance created (not yet started)");
@@ -421,6 +422,22 @@ export class PeerManager extends EventEmitter {
421
422
  }
422
423
  await this.peer.sendCustomPacket(userid, PACKET_ID_DL_DORA, Buffer.from(text, "utf-8"));
423
424
  }
425
+ /**
426
+ * Report to `pubkey` the delivered rate (bytes/sec) we're measuring on their
427
+ * inbound IP flow, so they can pace their sends to us toward it. Lossless +
428
+ * tiny (4 bytes). Best-effort: a failed report just means the sender keeps
429
+ * its previous pace estimate.
430
+ */
431
+ async sendRateReport(pubkey, bytesPerSec) {
432
+ if (!this.peer)
433
+ return;
434
+ const buf = Buffer.alloc(4);
435
+ buf.writeUInt32BE(Math.max(0, Math.min(0xffffffff, Math.round(bytesPerSec))), 0);
436
+ try {
437
+ await this.peer.sendCustomPacket(pubkey, PACKET_ID_DL_RATE, buf);
438
+ }
439
+ catch { /* best-effort */ }
440
+ }
424
441
  /**
425
442
  * Route a decoded decentlan frame to its packet-session, creating or
426
443
  * replacing the session on a HANDSHAKE_REQ (handles the Carrier re-pair
@@ -511,6 +528,13 @@ export class PeerManager extends EventEmitter {
511
528
  this.emit("dora-message", pubkey, Buffer.from(data).toString("utf-8"));
512
529
  return;
513
530
  }
531
+ if (id === PACKET_ID_DL_RATE) {
532
+ if (data.length >= 4) {
533
+ const bytesPerSec = Buffer.from(data.buffer, data.byteOffset, 4).readUInt32BE(0);
534
+ this.emit("rate-report", pubkey, bytesPerSec);
535
+ }
536
+ return;
537
+ }
514
538
  if (id === PACKET_ID_DL_IP || id === PACKET_ID_DL_SESSION) {
515
539
  const frame = FrameCodec.decode(data);
516
540
  if (!frame) {
@@ -11,3 +11,4 @@ export declare const FRAME_HEADER_SIZE = 6;
11
11
  export declare const PACKET_ID_DL_SESSION = 161;
12
12
  export declare const PACKET_ID_DL_DORA = 162;
13
13
  export declare const PACKET_ID_DL_IP = 163;
14
+ export declare const PACKET_ID_DL_RATE = 164;
@@ -25,3 +25,9 @@ export const PACKET_ID_DL_DORA = 162; // lossless: dora control (must arrive)
25
25
  // The TCP-over-TCP cost on a direct path is the accepted trade — relay delivery
26
26
  // is non-negotiable. (The handshake at 161 already exempts IP from chat-64.)
27
27
  export const PACKET_ID_DL_IP = 163; // lossless: IP data frames (must traverse relays)
28
+ // lossless: egress pacing feedback. The RECEIVER of an IP flow measures how many
29
+ // bytes/sec it actually received from a peer and reports that number back; the
30
+ // SENDER paces its outbound to that peer toward the reported (delivered) rate,
31
+ // so it stops overrunning the path and inducing congestion drops. Payload: a
32
+ // single uint32be = delivered bytes/sec. Rare + tiny, so lossless is free.
33
+ export const PACKET_ID_DL_RATE = 164;
@@ -218,6 +218,27 @@ export declare function cmdProxyRevokeHost(args: {
218
218
  export declare function cmdProxyListHosts(args: {
219
219
  configDir?: string;
220
220
  }): Promise<void>;
221
+ /**
222
+ * `agentnet proxy who` — show who is using THIS node's exit right now:
223
+ * per-client active tunnels, total tunnels opened, bytes moved, and refusals.
224
+ * Reads live stats from the running daemon (diag → proxy). Lets an exit
225
+ * operator spot a heavy user before deciding to `agentnet revoke` them.
226
+ */
227
+ export declare function cmdProxyWho(args: {
228
+ configDir?: string;
229
+ }): Promise<void>;
230
+ /**
231
+ * `agentnet proxy block|allow <peer>` — live per-peer exit access control.
232
+ * Under the default "allow" policy, every friend can tunnel through this exit;
233
+ * `block` adds a deny rule on the proxy port (applied to the running daemon
234
+ * instantly, no restart), `allow` removes it. Accepts a userid or a friendly
235
+ * name (resolved via the live roster).
236
+ */
237
+ export declare function cmdProxyAccess(args: {
238
+ peer: string;
239
+ allow: boolean;
240
+ configDir?: string;
241
+ }): Promise<void>;
221
242
  /**
222
243
  * Print the env-var line for a peer to use this node's proxy.
223
244
  */
@@ -309,6 +330,7 @@ export declare function cmdProxyRouter(args: {
309
330
  all?: boolean;
310
331
  routes?: string;
311
332
  egress?: string;
333
+ hlsAccel?: boolean;
312
334
  configDir?: string;
313
335
  }): Promise<void>;
314
336
  /**
@@ -1040,6 +1040,102 @@ export async function cmdProxyListHosts(args) {
1040
1040
  for (const h of allowHosts)
1041
1041
  console.log(` ${h}`);
1042
1042
  }
1043
+ /**
1044
+ * `agentnet proxy who` — show who is using THIS node's exit right now:
1045
+ * per-client active tunnels, total tunnels opened, bytes moved, and refusals.
1046
+ * Reads live stats from the running daemon (diag → proxy). Lets an exit
1047
+ * operator spot a heavy user before deciding to `agentnet revoke` them.
1048
+ */
1049
+ export async function cmdProxyWho(args) {
1050
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1051
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1052
+ const pid = daemonPid(config);
1053
+ if (pid === null) {
1054
+ console.error("No running daemon found for this config — start it with 'agentnet up'.");
1055
+ process.exit(1);
1056
+ }
1057
+ const res = await ipcCall(config, { op: "diag" });
1058
+ if (!res.ok)
1059
+ throw new Error(`Daemon diag failed: ${res.error}`);
1060
+ const proxy = res.data?.proxy;
1061
+ if (!proxy) {
1062
+ console.log("The CONNECT proxy isn't running on this node (nobody can use it as an exit).");
1063
+ console.log("Enable it with: agentnet proxy enable");
1064
+ return;
1065
+ }
1066
+ const fmtBytes = (n) => {
1067
+ if (n >= 1024 ** 3)
1068
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
1069
+ if (n >= 1024 ** 2)
1070
+ return `${(n / 1024 ** 2).toFixed(1)} MB`;
1071
+ if (n >= 1024)
1072
+ return `${(n / 1024).toFixed(0)} KB`;
1073
+ return `${n} B`;
1074
+ };
1075
+ const ago = (ms) => {
1076
+ if (!ms)
1077
+ return "—";
1078
+ const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
1079
+ if (s < 60)
1080
+ return `${s}s ago`;
1081
+ if (s < 3600)
1082
+ return `${Math.floor(s / 60)}m ago`;
1083
+ return `${Math.floor(s / 3600)}h ago`;
1084
+ };
1085
+ console.log(`Exit proxy: ${proxy.active} active tunnel(s), ${proxy.totalOpened} opened total, ` +
1086
+ `${fmtBytes(proxy.bytesTransferred)} moved, ${proxy.totalRefused} refused.`);
1087
+ if (!proxy.peers.length) {
1088
+ console.log("No clients have used this exit yet.");
1089
+ return;
1090
+ }
1091
+ console.log("");
1092
+ console.log(" CLIENT ACTIVE OPENED BYTES REFUSED LAST");
1093
+ for (const p of proxy.peers) {
1094
+ const who = (p.srcName ? `${p.srcName} (${p.src})` : p.src).padEnd(22).slice(0, 22);
1095
+ console.log(` ${who} ${String(p.active).padStart(6)} ${String(p.totalOpened).padStart(6)} ` +
1096
+ `${fmtBytes(p.bytesTransferred).padEnd(11)} ${String(p.totalRefused).padStart(7)} ${ago(p.lastSeenMs)}`);
1097
+ }
1098
+ console.log("");
1099
+ console.log("Cut off a heavy user: agentnet proxy block <userid-or-name>");
1100
+ }
1101
+ /**
1102
+ * `agentnet proxy block|allow <peer>` — live per-peer exit access control.
1103
+ * Under the default "allow" policy, every friend can tunnel through this exit;
1104
+ * `block` adds a deny rule on the proxy port (applied to the running daemon
1105
+ * instantly, no restart), `allow` removes it. Accepts a userid or a friendly
1106
+ * name (resolved via the live roster).
1107
+ */
1108
+ export async function cmdProxyAccess(args) {
1109
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1110
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1111
+ const pid = daemonPid(config);
1112
+ if (pid === null) {
1113
+ console.error("No running daemon found for this config — start it with 'agentnet up'.");
1114
+ process.exit(1);
1115
+ }
1116
+ // Resolve a friendly name to its userid so the deny rule keys on the stable id.
1117
+ let userid = args.peer;
1118
+ try {
1119
+ const { peers } = await fetchLiveIpam(config);
1120
+ const stripDomain = (s) => s.replace(/\.[A-Za-z][A-Za-z0-9-]*$/, "");
1121
+ const hit = peers.find((p) => p.carrierId === args.peer || p.name === args.peer || p.name === stripDomain(args.peer));
1122
+ if (hit?.carrierId)
1123
+ userid = hit.carrierId;
1124
+ }
1125
+ catch {
1126
+ // fall back to the raw arg — the daemon matches on userid OR name
1127
+ }
1128
+ const res = await ipcCall(config, { op: "proxy-access", userid, enabled: args.allow });
1129
+ if (!res.ok)
1130
+ throw new Error(`Daemon refused: ${res.error}`);
1131
+ if (args.allow) {
1132
+ console.log(`✓ ${args.peer} may use this exit again (block removed, live).`);
1133
+ }
1134
+ else {
1135
+ console.log(`✓ ${args.peer} is now BLOCKED from this exit (live — existing tunnels drop on next reconnect).`);
1136
+ console.log(" Re-allow with: agentnet proxy allow " + args.peer);
1137
+ }
1138
+ }
1043
1139
  /**
1044
1140
  * Print the env-var line for a peer to use this node's proxy.
1045
1141
  */
@@ -1384,6 +1480,9 @@ export async function cmdProxyRouter(args) {
1384
1480
  listenPort: args.port ?? 8889,
1385
1481
  mode,
1386
1482
  egressBindIp,
1483
+ learnedRoutesPath: resolve(dir, "learned-routes.json"),
1484
+ hlsAccel: args.hlsAccel ?? false,
1485
+ configDir: dir,
1387
1486
  });
1388
1487
  // Keep the process alive; the router runs until Ctrl-C / signal.
1389
1488
  await new Promise(() => { });
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, 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, 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,19 @@ 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("who", "Show who is using THIS node's exit (per-peer tunnels + bytes)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
322
+ await cmdProxyWho({ configDir: argv["config-dir"] });
323
+ })
324
+ .command("block <peer>", "Block a peer from using THIS node's exit (live, no restart)", (yy) => yy
325
+ .positional("peer", { type: "string", demandOption: true })
326
+ .option("config-dir", { type: "string" }), async (argv) => {
327
+ await cmdProxyAccess({ peer: argv.peer, allow: false, configDir: argv["config-dir"] });
328
+ })
329
+ .command("allow <peer>", "Re-allow a previously-blocked peer to use THIS node's exit (live)", (yy) => yy
330
+ .positional("peer", { type: "string", demandOption: true })
331
+ .option("config-dir", { type: "string" }), async (argv) => {
332
+ await cmdProxyAccess({ peer: argv.peer, allow: true, configDir: argv["config-dir"] });
320
333
  })
321
334
  .command("use", "Print env vars to route HTTPS via a peer's proxy node", (yy) => yy
322
335
  .option("peer", { type: "string", demandOption: true, describe: "Peer name or carrier ID" })
@@ -335,6 +348,7 @@ async function main() {
335
348
  .option("mode", { choices: ["loadbalance", "failover"], default: "loadbalance" })
336
349
  .option("all", { type: "boolean", default: false, describe: "Route ALL hosts through exits (default: China-only split tunnel)" })
337
350
  .option("egress", { type: "string", describe: "Bind the DIRECT internet dial to the physical NIC: 'physical' (auto) or an explicit source IPv4 — keeps egress off Tailscale/overlay" })
351
+ .option("hls-accel", { type: "boolean", default: false, describe: "Accelerate China video streams: selectively MITM video-CDN HTTPS to parallel-prefetch HLS segments across all exits (prints a CA cert to trust once)" })
338
352
  .option("config-dir", { type: "string" }), async (argv) => {
339
353
  await cmdProxyRouter({
340
354
  exit: argv.exit,
@@ -344,6 +358,7 @@ async function main() {
344
358
  mode: argv.mode,
345
359
  all: argv.all,
346
360
  egress: argv.egress,
361
+ hlsAccel: argv["hls-accel"],
347
362
  configDir: argv["config-dir"],
348
363
  });
349
364
  })
@@ -84,6 +84,12 @@ export interface IpcHandlers {
84
84
  * restart (which drops every Carrier session). Returns the applied
85
85
  * allowlist for the CLI to echo. */
86
86
  proxyReload: () => Promise<Record<string, unknown>>;
87
+ /** Block or allow a specific peer's access to THIS node's exit proxy port,
88
+ * LIVE (mutates the running ACL + persists policy.yaml). `allow=true`
89
+ * removes the block; `allow=false` adds a deny rule so the peer can no
90
+ * longer tunnel through this exit. Lets an operator cut off a heavy user
91
+ * without restarting the daemon. */
92
+ proxyAccess: (userid: string, allow: boolean) => Promise<Record<string, unknown>>;
87
93
  /** Re-exec the running daemon with its original argv. The new
88
94
  * process inherits the current process's privileges (so a
89
95
  * root-owned daemon re-execs as root with no sudo prompt),
@@ -94,7 +100,7 @@ export interface IpcHandlers {
94
100
  selfRestart: () => Promise<Record<string, unknown>>;
95
101
  }
96
102
  export interface IpcRequest {
97
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "self-restart";
103
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "proxy-access" | "self-restart";
98
104
  address?: string;
99
105
  hello?: string;
100
106
  userid?: string;
@@ -233,6 +233,10 @@ export class IpcServer {
233
233
  }
234
234
  case "proxy-reload":
235
235
  return await this.handlers.proxyReload();
236
+ case "proxy-access":
237
+ if (!req.userid)
238
+ throw new Error("proxy-access requires a userid");
239
+ return await this.handlers.proxyAccess(req.userid, req.enabled !== false);
236
240
  case "self-restart":
237
241
  return await this.handlers.selfRestart();
238
242
  default:
@@ -283,6 +283,9 @@ export class DaemonServer {
283
283
  // instead of the generic "@decentnetwork/peer".
284
284
  nickname: this.config.node.name,
285
285
  statusMessage: this.config.node.statusMessage,
286
+ // Persist partially-received files so a dropped/restarted transfer
287
+ // resumes instead of restarting from 0 (断点续传).
288
+ fileResumeDir: resolve(this.configDir, "downloads", ".partial"),
286
289
  });
287
290
  await this.peerManager.start();
288
291
  this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
@@ -507,6 +510,33 @@ export class DaemonServer {
507
510
  }
508
511
  return { applied: true, allowHosts };
509
512
  },
513
+ proxyAccess: async (userid, allow) => {
514
+ // Live block/allow a peer's access to THIS node's exit proxy port.
515
+ // Mutates the running ACL (evaluated per-packet, so it's instant) and
516
+ // persists policy.yaml. Under the default "allow" policy, a friend can
517
+ // use the exit unless we add an explicit DENY — so "block" adds a deny
518
+ // rule on the proxy port and "allow" removes the peer's rules.
519
+ if (!this.policy)
520
+ throw new Error("ACL policy not initialised");
521
+ const port = this.config.proxy?.port ?? 8888;
522
+ // Accept a userid or a friendly name; store the rule under whatever
523
+ // identifier the ACL matches on (both are checked at evaluate time).
524
+ const peerKey = userid;
525
+ if (allow) {
526
+ this.policy.removePeer(peerKey);
527
+ }
528
+ else {
529
+ this.policy.addRule({
530
+ peer: peerKey,
531
+ direction: "both",
532
+ deny: [{ proto: "tcp", port, purpose: "exit-block" }],
533
+ audit: true,
534
+ });
535
+ }
536
+ await this.policy.save();
537
+ this.logger.info(`Exit access ${allow ? "ALLOWED" : "BLOCKED"} for ${peerKey.slice(0, 12)} (tcp:${port})`);
538
+ return { applied: true, userid: peerKey, allowed: allow, port };
539
+ },
510
540
  selfRestart: async () => {
511
541
  // Detach a child process that waits briefly and then re-execs
512
542
  // the same argv we were started with. Inherits privileges
@@ -586,6 +616,10 @@ export class DaemonServer {
586
616
  session: this.peerManager?.getSessionStatus(f.pubkey) ?? null,
587
617
  })),
588
618
  ipam: ipamPeers,
619
+ // Exit-operator view: live per-client usage of THIS node's CONNECT
620
+ // proxy (who's tunnelling through me + how much). Null when the
621
+ // proxy isn't running.
622
+ proxy: this.connectProxy?.getStats() ?? null,
589
623
  };
590
624
  },
591
625
  });
@@ -877,6 +911,10 @@ export class DaemonServer {
877
911
  peerManager: this.peerManager,
878
912
  ipam: this.ipam,
879
913
  acl: this.aclEngine,
914
+ // Pace + fair-queue egress IP data (default on). Disable with
915
+ // `forwarding: { paced: false }` in config.yaml to restore the old
916
+ // fire-immediately behavior.
917
+ pacedForwarding: this.config.forwarding?.paced !== false,
880
918
  });
881
919
  await this.packetRouter.start();
882
920
  // 7b. In-process DNS server. Answers A/PTR for *.{dnsDomain}
@@ -51,6 +51,17 @@ export interface ConnectProxyOptions {
51
51
  bytesTransferred: number;
52
52
  }) => void;
53
53
  }
54
+ /** Per-client (per source virtual-IP) usage of the exit, so the operator can
55
+ * see WHO is using their exit and HOW MUCH — spot heavy/abusive users. */
56
+ export interface ProxyPeerUsage {
57
+ src: string;
58
+ srcName?: string;
59
+ active: number;
60
+ totalOpened: number;
61
+ totalRefused: number;
62
+ bytesTransferred: number;
63
+ lastSeenMs: number;
64
+ }
54
65
  export interface ConnectProxyStats {
55
66
  bindIp: string;
56
67
  port: number;
@@ -59,13 +70,19 @@ export interface ConnectProxyStats {
59
70
  totalOpened: number;
60
71
  totalRefused: number;
61
72
  bytesTransferred: number;
73
+ /** Per-client breakdown, heaviest (by bytes) first. */
74
+ peers: ProxyPeerUsage[];
62
75
  }
63
76
  export declare class ConnectProxy {
64
77
  private opts;
65
78
  private server;
66
79
  private logger;
67
80
  private stats;
81
+ /** Live per-client usage, keyed by source virtual IP. */
82
+ private perPeer;
68
83
  constructor(opts: ConnectProxyOptions);
84
+ /** Get-or-create the per-client usage row for a source IP. */
85
+ private peerRow;
69
86
  start(): Promise<void>;
70
87
  stop(): Promise<void>;
71
88
  getStats(): ConnectProxyStats;
@@ -27,6 +27,8 @@ export class ConnectProxy {
27
27
  server = null;
28
28
  logger;
29
29
  stats;
30
+ /** Live per-client usage, keyed by source virtual IP. */
31
+ perPeer = new Map();
30
32
  constructor(opts) {
31
33
  this.opts = opts;
32
34
  this.logger = new Logger({ prefix: "ConnectProxy" });
@@ -38,8 +40,20 @@ export class ConnectProxy {
38
40
  totalOpened: 0,
39
41
  totalRefused: 0,
40
42
  bytesTransferred: 0,
43
+ peers: [],
41
44
  };
42
45
  }
46
+ /** Get-or-create the per-client usage row for a source IP. */
47
+ peerRow(src, srcName) {
48
+ let row = this.perPeer.get(src);
49
+ if (!row) {
50
+ row = { src, srcName, active: 0, totalOpened: 0, totalRefused: 0, bytesTransferred: 0, lastSeenMs: 0 };
51
+ this.perPeer.set(src, row);
52
+ }
53
+ if (srcName && !row.srcName)
54
+ row.srcName = srcName;
55
+ return row;
56
+ }
43
57
  async start() {
44
58
  if (this.server) {
45
59
  throw new Error("ConnectProxy already started");
@@ -84,7 +98,10 @@ export class ConnectProxy {
84
98
  this.logger.info("Stopped");
85
99
  }
86
100
  getStats() {
87
- return { ...this.stats };
101
+ const peers = [...this.perPeer.values()]
102
+ .sort((a, b) => b.bytesTransferred - a.bytesTransferred || b.totalOpened - a.totalOpened)
103
+ .map((p) => ({ ...p }));
104
+ return { ...this.stats, peers };
88
105
  }
89
106
  /**
90
107
  * Update the host allowlist (and optionally the port allowlist) of a
@@ -139,6 +156,10 @@ export class ConnectProxy {
139
156
  clientSocket.destroy();
140
157
  this.stats.active = Math.max(0, this.stats.active - 1);
141
158
  this.stats.bytesTransferred += bytes;
159
+ const row = this.peerRow(src, srcName);
160
+ row.active = Math.max(0, row.active - 1);
161
+ row.bytesTransferred += bytes;
162
+ row.lastSeenMs = Date.now();
142
163
  this.opts.onTunnelClose?.({
143
164
  src,
144
165
  srcName,
@@ -161,6 +182,10 @@ export class ConnectProxy {
161
182
  }
162
183
  this.stats.active += 1;
163
184
  this.stats.totalOpened += 1;
185
+ const row = this.peerRow(src, srcName);
186
+ row.active += 1;
187
+ row.totalOpened += 1;
188
+ row.lastSeenMs = Date.now();
164
189
  this.opts.onTunnelOpen?.({ src, srcName, target: `${host}:${port}` });
165
190
  this.logger.info(`tunnel open ${src}${srcName ? ` (${srcName})` : ""} -> ${host}:${port}`);
166
191
  });
@@ -208,6 +233,9 @@ export class ConnectProxy {
208
233
  }
209
234
  refuse(clientSocket, src, srcName, target, code, reason) {
210
235
  this.stats.totalRefused += 1;
236
+ const row = this.peerRow(src, srcName);
237
+ row.totalRefused += 1;
238
+ row.lastSeenMs = Date.now();
211
239
  this.logger.info(`tunnel refused ${src}${srcName ? ` (${srcName})` : ""} -> ${target}: ${reason}`);
212
240
  try {
213
241
  const statusText = code === 400 ? "Bad Request" : "Forbidden";
@@ -0,0 +1 @@
1
+ export declare const CN_IPV4_RANGES: readonly number[];