@decentnetwork/lan 0.1.136 → 0.1.138

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.
@@ -39,6 +39,15 @@ function shellQuote(parts) {
39
39
  * whitespace (e.g. macOS's U+202F narrow no-break space before "PM" in
40
40
  * screenshot/recording names) to a plain space, so files don't need shell
41
41
  * escaping. Never empty. */
42
+ /** True for image/video/audio by extension — the kinds the chat UI previews
43
+ * inline. Mirrors dkFileMediaKind() in the desktop UI. Used to decide whether
44
+ * to keep a local copy of an OUTGOING file so the sender can play it too. */
45
+ function isMediaFileName(name) {
46
+ const ext = (name.split(".").pop() || "").toLowerCase();
47
+ return (["png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "bmp"].includes(ext) ||
48
+ ["mp4", "mov", "webm", "m4v", "mkv", "avi"].includes(ext) ||
49
+ ["mp3", "m4a", "aac", "wav", "ogg", "flac", "opus"].includes(ext));
50
+ }
42
51
  function sanitizeFileName(name) {
43
52
  const cleaned = (name || "file")
44
53
  .replace(/[/\\]/g, "_")
@@ -80,6 +89,15 @@ export class DaemonServer {
80
89
  /** Outgoing file transfers in flight: fileId → the chat message tracking it,
81
90
  * so progress/complete/cancel events can patch its status + sent bytes. */
82
91
  activeSends = new Map();
92
+ /** Directory holding queued (offline) outgoing file bytes: <configDir>/outbox.
93
+ * One file per queued message, named by its msgId. Set in start(). */
94
+ outboxDir = "";
95
+ /** Peers whose outbox is currently being drained, so overlapping
96
+ * friend-connection "connected" events don't double-send. */
97
+ flushingOutbox = new Set();
98
+ /** Largest file we'll hold in the offline outbox. Live transfers handle any
99
+ * size; the queue is meant for small things the peer can grab on reconnect. */
100
+ static OUTBOX_MAX_FILE_BYTES = 5 * 1024 * 1024;
83
101
  startedAt = 0;
84
102
  isRunning = false;
85
103
  tunRebuildInProgress = false;
@@ -271,6 +289,8 @@ export class DaemonServer {
271
289
  // On-disk chat + friend-metadata stores (survive restarts; back the UI).
272
290
  this.messageStore = new MessageStore(resolve(this.configDir, "messages.json"));
273
291
  this.friendMeta = new FriendMetaStore(resolve(this.configDir, "friends-meta.json"));
292
+ this.outboxDir = resolve(this.configDir, "outbox");
293
+ this.reconcileOutboxOnStart();
274
294
  // Start IPC as soon as the peer is up. The CLI uses it to drive
275
295
  // operations that need the daemon's Carrier identity (e.g.
276
296
  // friend-request) without spawning a competing Peer instance.
@@ -307,8 +327,23 @@ export class DaemonServer {
307
327
  chatSend: async (userid, text) => {
308
328
  if (!text)
309
329
  return;
310
- await this.peerManager.sendText(userid, text);
311
- this.logChat(userid, "out", text);
330
+ // If the friend is online, deliver immediately. If they're offline (or
331
+ // the live send throws), queue it: store as "queued" and flush on their
332
+ // next reconnect — so chat works store-and-forward, not just live.
333
+ if (this.peerManager?.isFriendOnline(userid)) {
334
+ try {
335
+ await this.peerManager.sendText(userid, text);
336
+ this.logChat(userid, "out", text);
337
+ return;
338
+ }
339
+ catch (e) {
340
+ this.logger.warn(`sendText to ${userid.slice(0, 8)} failed, queuing: ${e.message}`);
341
+ }
342
+ }
343
+ this.messageStore?.append(userid, "out", text, Date.now(), "queued");
344
+ this.friendMeta?.ensure(userid);
345
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
346
+ this.logger.info(`Queued text for offline ${userid.slice(0, 8)} (delivers on reconnect)`);
312
347
  },
313
348
  chatHistory: async (peer, before, limit) => {
314
349
  return { chats: this.messageStore?.history(peer, { before, limit }) ?? {} };
@@ -354,6 +389,16 @@ export class DaemonServer {
354
389
  friendSetAlias: async (userid, alias) => {
355
390
  this.friendMeta?.setAlias(userid, alias);
356
391
  },
392
+ friendsAutoAccept: async (enabled) => {
393
+ // Live toggle: flip in-memory (the friend-request handler reads it
394
+ // fresh) AND persist to config.yaml so it survives a restart.
395
+ this.config.friends = { ...(this.config.friends ?? {}), autoAccept: enabled };
396
+ await ConfigLoader.save(this.config, resolve(this.configDir, "config.yaml")).catch((e) => {
397
+ this.logger.warn(`Failed to persist friends.autoAccept: ${e.message}`);
398
+ });
399
+ this.logger.info(`friends.autoAccept set to ${enabled}`);
400
+ return { autoAccept: enabled };
401
+ },
357
402
  setProfile: async ({ name, description }) => {
358
403
  // Re-push to friends over Carrier (live) …
359
404
  this.peerManager?.setUserInfo({ name, description });
@@ -371,6 +416,42 @@ export class DaemonServer {
371
416
  const { basename } = await import("path");
372
417
  const data = await fs.readFile(path);
373
418
  const name = basename(path);
419
+ // Keep a local copy of outgoing MEDIA under downloads/ so the sender
420
+ // can preview/play it in chat too (the UI uploads to a temp file that's
421
+ // deleted right after send; without this the sender has nothing to
422
+ // serve back). The receiver already saves every file the same way.
423
+ if (isMediaFileName(name)) {
424
+ void (async () => {
425
+ try {
426
+ const dir = resolve(this.configDir, "downloads");
427
+ await fs.mkdir(dir, { recursive: true });
428
+ await fs.writeFile(resolve(dir, sanitizeFileName(name)), data);
429
+ }
430
+ catch (e) {
431
+ this.logger.warn(`Could not cache sent media for preview: ${e.message}`);
432
+ }
433
+ })();
434
+ }
435
+ // Friend offline → queue the file for delivery on reconnect. Bytes are
436
+ // copied into <configDir>/outbox/<msgId> (survives restart). Capped at
437
+ // OUTBOX_MAX_FILE_BYTES so the queue stays small; bigger files should
438
+ // wait until the peer is online (the live transfer handles any size).
439
+ if (!this.peerManager?.isFriendOnline(userid)) {
440
+ if (data.length > DaemonServer.OUTBOX_MAX_FILE_BYTES) {
441
+ throw new Error(`Peer offline — queued files are limited to ${(DaemonServer.OUTBOX_MAX_FILE_BYTES / 1024 / 1024) | 0} MB ` +
442
+ `(this is ${(data.length / 1024 / 1024).toFixed(1)} MB). Send it once they're online.`);
443
+ }
444
+ const safe = sanitizeFileName(name);
445
+ const msg = this.messageStore?.appendFile(userid, "out", { name: safe, size: data.length, status: "queued", sent: 0 });
446
+ if (!msg)
447
+ throw new Error("Could not queue file (no message store)");
448
+ await fs.mkdir(this.outboxDir, { recursive: true });
449
+ await fs.writeFile(resolve(this.outboxDir, msg.id), data);
450
+ this.friendMeta?.ensure(userid);
451
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
452
+ this.logger.info(`Queued file "${name}" (${data.length}B) for offline ${userid.slice(0, 8)} (delivers on reconnect)`);
453
+ return { queued: true, name: safe, size: data.length };
454
+ }
374
455
  const fileId = this.peerManager?.sendFile(userid, new Uint8Array(data), name);
375
456
  if (!fileId)
376
457
  throw new Error("No free transfer slot (or friend unknown)");
@@ -426,6 +507,33 @@ export class DaemonServer {
426
507
  }
427
508
  return { applied: true, allowHosts };
428
509
  },
510
+ proxyAccess: async (userid, allow) => {
511
+ // Live block/allow a peer's access to THIS node's exit proxy port.
512
+ // Mutates the running ACL (evaluated per-packet, so it's instant) and
513
+ // persists policy.yaml. Under the default "allow" policy, a friend can
514
+ // use the exit unless we add an explicit DENY — so "block" adds a deny
515
+ // rule on the proxy port and "allow" removes the peer's rules.
516
+ if (!this.policy)
517
+ throw new Error("ACL policy not initialised");
518
+ const port = this.config.proxy?.port ?? 8888;
519
+ // Accept a userid or a friendly name; store the rule under whatever
520
+ // identifier the ACL matches on (both are checked at evaluate time).
521
+ const peerKey = userid;
522
+ if (allow) {
523
+ this.policy.removePeer(peerKey);
524
+ }
525
+ else {
526
+ this.policy.addRule({
527
+ peer: peerKey,
528
+ direction: "both",
529
+ deny: [{ proto: "tcp", port, purpose: "exit-block" }],
530
+ audit: true,
531
+ });
532
+ }
533
+ await this.policy.save();
534
+ this.logger.info(`Exit access ${allow ? "ALLOWED" : "BLOCKED"} for ${peerKey.slice(0, 12)} (tcp:${port})`);
535
+ return { applied: true, userid: peerKey, allowed: allow, port };
536
+ },
429
537
  selfRestart: async () => {
430
538
  // Detach a child process that waits briefly and then re-execs
431
539
  // the same argv we were started with. Inherits privileges
@@ -505,6 +613,10 @@ export class DaemonServer {
505
613
  session: this.peerManager?.getSessionStatus(f.pubkey) ?? null,
506
614
  })),
507
615
  ipam: ipamPeers,
616
+ // Exit-operator view: live per-client usage of THIS node's CONNECT
617
+ // proxy (who's tunnelling through me + how much). Null when the
618
+ // proxy isn't running.
619
+ proxy: this.connectProxy?.getStats() ?? null,
508
620
  };
509
621
  },
510
622
  });
@@ -625,7 +737,6 @@ export class DaemonServer {
625
737
  // agentnet friends accept --userid <userid>
626
738
  // agentnet friends reject --userid <userid>
627
739
  // Pending requests survive daemon restarts.
628
- const autoAcceptFriends = this.config.friends?.autoAccept ?? true;
629
740
  const pendingPath = resolve(this.config.carrier.dataDir, "..", "pending-friends.json");
630
741
  this.pendingFriends = new PendingFriendsStore(pendingPath);
631
742
  const pubkeyHexToUserid = async (input) => {
@@ -652,6 +763,11 @@ export class DaemonServer {
652
763
  // transitions immediately instead of on the next poll tick.
653
764
  this.peerManager.on("friend-connection", (evt) => {
654
765
  this.ipcEvents.emit("event", { type: "presence", userid: evt?.pubkey, status: evt?.status });
766
+ // A friend just came online — drain anything we queued for them while
767
+ // they were offline (text + small files), in order.
768
+ if (evt?.status === "connected" && evt.pubkey) {
769
+ void this.flushOutbox(evt.pubkey).catch((e) => this.logger.warn(`Outbox flush for ${evt.pubkey.slice(0, 8)} failed: ${e.message}`));
770
+ }
655
771
  });
656
772
  // File transfer (toxcore-standard): auto-accept offers from friends and
657
773
  // save completed files under <configDir>/downloads/.
@@ -676,7 +792,11 @@ export class DaemonServer {
676
792
  this.logger.warn(`File send to ${p.friendId.slice(0, 8)} aborted (${p.reason ?? "cancelled"})`);
677
793
  const t = p.fileId ? this.activeSends.get(p.fileId) : undefined;
678
794
  if (t) {
679
- this.messageStore?.patchFile(t.peer, t.msgId, { status: "failed" });
795
+ // If this was a queued (offline) send and its bytes are still in the
796
+ // outbox, revert the chip to "queued" so it retries on the next
797
+ // reconnect instead of dying as "failed". Otherwise mark failed.
798
+ const requeue = !!this.outboxDir && existsSync(resolve(this.outboxDir, t.msgId));
799
+ this.messageStore?.patchFile(t.peer, t.msgId, { status: requeue ? "queued" : "failed", sent: 0 });
680
800
  this.activeSends.delete(p.fileId);
681
801
  this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
682
802
  }
@@ -693,6 +813,11 @@ export class DaemonServer {
693
813
  if (t) {
694
814
  this.messageStore?.patchFile(t.peer, t.msgId, { status: "sent", sent: p.size });
695
815
  this.activeSends.delete(p.fileId);
816
+ // If this was a queued (offline) send, its bytes lived in the
817
+ // outbox — now delivered, drop the copy.
818
+ if (this.outboxDir) {
819
+ void import("fs/promises").then((fs) => fs.unlink(resolve(this.outboxDir, t.msgId)).catch(() => undefined));
820
+ }
696
821
  this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
697
822
  }
698
823
  }
@@ -716,7 +841,9 @@ export class DaemonServer {
716
841
  const who = `${req.name || "(unnamed)"} ${userid}`;
717
842
  const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
718
843
  this.ipcEvents.emit("event", { type: "request", userid });
719
- if (autoAcceptFriends) {
844
+ // Read the flag LIVE (not captured at start) so `agentnet friends
845
+ // autoaccept off` takes effect immediately, no daemon restart.
846
+ if (this.config.friends?.autoAccept ?? true) {
720
847
  this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
721
848
  this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
722
849
  this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
@@ -948,6 +1075,84 @@ export class DaemonServer {
948
1075
  this.friendMeta?.ensure(userid);
949
1076
  this.ipcEvents.emit("event", { type: "chat", userid, dir });
950
1077
  }
1078
+ /** On startup, re-queue any offline file transfer that was mid-flight when the
1079
+ * daemon last stopped: an out chip stuck at "sending" whose bytes still sit in
1080
+ * the outbox. Flips it back to "queued" so the next reconnect redelivers it
1081
+ * (and the bytes aren't orphaned). */
1082
+ reconcileOutboxOnStart() {
1083
+ if (!this.messageStore || !this.outboxDir)
1084
+ return;
1085
+ let requeued = 0;
1086
+ const all = this.messageStore.history();
1087
+ for (const [peer, msgs] of Object.entries(all)) {
1088
+ for (const m of msgs) {
1089
+ if (m.dir === "out" && m.file?.status === "sending" && existsSync(resolve(this.outboxDir, m.id))) {
1090
+ this.messageStore.patchFile(peer, m.id, { status: "queued", sent: 0 });
1091
+ requeued++;
1092
+ }
1093
+ }
1094
+ }
1095
+ if (requeued)
1096
+ this.logger.info(`Re-queued ${requeued} interrupted offline file transfer(s)`);
1097
+ }
1098
+ /** Deliver everything queued for `userid` while they were offline — queued
1099
+ * text and queued files, oldest first. Called when a friend reconnects.
1100
+ * Stops early if the link drops again, leaving the remainder queued for the
1101
+ * next reconnect (true store-and-forward). Re-entrancy-guarded per peer. */
1102
+ async flushOutbox(userid) {
1103
+ if (this.flushingOutbox.has(userid))
1104
+ return;
1105
+ if (!this.messageStore || !this.peerManager)
1106
+ return;
1107
+ const queued = this.messageStore.queuedOutgoing(userid);
1108
+ if (!queued.length)
1109
+ return;
1110
+ this.flushingOutbox.add(userid);
1111
+ try {
1112
+ const fs = await import("fs/promises");
1113
+ this.logger.info(`Flushing ${queued.length} queued item(s) to ${userid.slice(0, 8)}`);
1114
+ for (const m of queued) {
1115
+ // Bail if the friend dropped mid-flush — keep the rest queued.
1116
+ if (!this.peerManager.isFriendOnline(userid))
1117
+ break;
1118
+ if (m.file) {
1119
+ const bytesPath = resolve(this.outboxDir, m.id);
1120
+ let data;
1121
+ try {
1122
+ data = await fs.readFile(bytesPath);
1123
+ }
1124
+ catch {
1125
+ // Bytes vanished (manual cleanup / earlier delivery) — drop the chip.
1126
+ this.messageStore.patchFile(userid, m.id, { status: "failed" });
1127
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
1128
+ continue;
1129
+ }
1130
+ const fileId = this.peerManager.sendFile(userid, new Uint8Array(data), m.file.name);
1131
+ if (!fileId)
1132
+ break; // no free transfer slot — retry on next reconnect
1133
+ // Hand off to the live progress/complete path: flip to "sending" and
1134
+ // track by fileId so file-progress/-complete patch this same chip.
1135
+ this.messageStore.patchFile(userid, m.id, { status: "sending", sent: 0 });
1136
+ this.activeSends.set(fileId, { peer: userid, msgId: m.id });
1137
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
1138
+ }
1139
+ else {
1140
+ try {
1141
+ await this.peerManager.sendText(userid, m.text);
1142
+ this.messageStore.setStatus(userid, m.id, undefined); // delivered
1143
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
1144
+ }
1145
+ catch (e) {
1146
+ this.logger.warn(`Flush sendText to ${userid.slice(0, 8)} failed: ${e.message}`);
1147
+ break; // transient — leave this and the rest queued
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ finally {
1153
+ this.flushingOutbox.delete(userid);
1154
+ }
1155
+ }
951
1156
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
952
1157
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
953
1158
  reFriendAt = new Map();
@@ -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";
@@ -300,7 +300,136 @@ export function startMultiExitRouter(opts) {
300
300
  client.on("error", () => up.destroy());
301
301
  up.on("error", () => client.destroy());
302
302
  }
303
- const server = http.createServer((_req, res) => { res.writeHead(405); res.end("CONNECT only\n"); });
303
+ // A destination is "local" when it should be dialed straight from this box on
304
+ // the normal routing table — loopback, RFC1918, the agentnet 10.86/16 vIPs
305
+ // (which ride the TUN), and *.decent/*.lan names. For these we must NOT bind
306
+ // the physical-egress source IP (that would break the TUN route to 10.86.x).
307
+ function isLocalDest(host) {
308
+ const h = host.toLowerCase();
309
+ if (h === "localhost" || h.endsWith(".localhost"))
310
+ return true;
311
+ if (h.endsWith(".lan") || h.endsWith(".decent") || h.endsWith(".agentnet") || h.endsWith(".local"))
312
+ return true;
313
+ if (h === "::1")
314
+ return true;
315
+ if (h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80"))
316
+ return true; // IPv6 ULA / link-local
317
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
318
+ const [a, b] = h.split(".").map(Number);
319
+ if (a === 127 || a === 10)
320
+ return true; // loopback / 10/8 (incl 10.86 agentnet)
321
+ if (a === 192 && b === 168)
322
+ return true; // 192.168/16
323
+ if (a === 172 && b >= 16 && b <= 31)
324
+ return true; // 172.16/12
325
+ if (a === 169 && b === 254)
326
+ return true; // link-local
327
+ }
328
+ return false;
329
+ }
330
+ // Plain-HTTP forwarding (the browser's proxy request for an `http://` URL,
331
+ // which arrives as a normal request with an absolute-form URL — NOT a CONNECT
332
+ // tunnel). Dial the origin directly from here and pipe the streams both ways.
333
+ function httpForwardDirect(host, port, path, req, res, headers) {
334
+ const bindIp = isLocalDest(host) ? undefined : opts.egressBindIp;
335
+ directTotal++;
336
+ const up = http.request({ host, port, path, method: req.method, headers, localAddress: bindIp }, (upRes) => {
337
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
338
+ upRes.pipe(res);
339
+ });
340
+ up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy(new Error("upstream timeout")));
341
+ up.on("error", (e) => { if (!res.headersSent)
342
+ res.writeHead(502, { "content-type": "text/plain" }); res.end(`direct proxy error: ${e.message}\n`); });
343
+ req.pipe(up);
344
+ }
345
+ // Plain-HTTP through an exit region: open a CONNECT tunnel to host:port via the
346
+ // exit, then ride the normal http.request over that raw socket. Fails over to
347
+ // the next exit if CONNECT is refused.
348
+ function httpForwardViaExit(order, idx, target, host, port, path, req, res, headers) {
349
+ if (idx >= order.length) {
350
+ if (!res.headersSent)
351
+ res.writeHead(502, { "content-type": "text/plain" });
352
+ res.end("all exits failed\n");
353
+ return;
354
+ }
355
+ const exit = order[idx];
356
+ const tun = net.connect(exit.port, exit.host);
357
+ tun.setTimeout(CONNECT_TIMEOUT_MS, () => tun.destroy());
358
+ tun.once("connect", () => { tun.setTimeout(0); tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`); });
359
+ let buf = Buffer.alloc(0);
360
+ const onHdr = (chunk) => {
361
+ buf = Buffer.concat([buf, chunk]);
362
+ const i = buf.indexOf("\r\n\r\n");
363
+ if (i === -1)
364
+ return;
365
+ tun.removeListener("data", onHdr);
366
+ const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
367
+ if (!/ 200/.test(statusLine)) {
368
+ tun.destroy();
369
+ httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers);
370
+ return;
371
+ }
372
+ exit.lastSuccessMs = Date.now();
373
+ exit.fails = 0;
374
+ if (!exit.healthy)
375
+ exit.healthy = true;
376
+ const leftover = buf.slice(i + 4);
377
+ if (leftover.length)
378
+ tun.unshift(leftover);
379
+ const up = http.request({ host, port, path, method: req.method, headers, createConnection: () => tun }, (upRes) => {
380
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
381
+ upRes.pipe(res);
382
+ });
383
+ up.on("error", (e) => { if (!res.headersSent)
384
+ res.writeHead(502, { "content-type": "text/plain" }); res.end(`exit proxy error: ${e.message}\n`); });
385
+ req.pipe(up);
386
+ };
387
+ tun.on("data", onHdr);
388
+ tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers); });
389
+ }
390
+ const server = http.createServer((req, res) => {
391
+ // Browsers send proxy HTTP requests in absolute-form: `GET http://h:p/path`.
392
+ // Anything else is someone hitting the proxy port directly — show a hint.
393
+ let parsed;
394
+ try {
395
+ parsed = new URL(req.url || "");
396
+ }
397
+ catch {
398
+ res.writeHead(200, { "content-type": "text/plain" });
399
+ res.end(`agentnet multi-exit proxy [${mode}] on ${listenHost}:${listenPort}\nSet this as your browser's HTTP+HTTPS proxy; http:// and https:// both work.\n`);
400
+ return;
401
+ }
402
+ if (parsed.protocol !== "http:") {
403
+ res.writeHead(400, { "content-type": "text/plain" });
404
+ res.end("only http:// here; https:// uses CONNECT\n");
405
+ return;
406
+ }
407
+ const host = parsed.hostname.toLowerCase();
408
+ const port = Number(parsed.port) || 80;
409
+ const path = (parsed.pathname || "/") + (parsed.search || "");
410
+ const target = `${host}:${port}`;
411
+ // Forward the client's headers minus hop-by-hop proxy bits.
412
+ const headers = { ...req.headers };
413
+ delete headers["proxy-connection"];
414
+ let region = routeFor(host);
415
+ if (region !== "direct" && !regionHasExits(region))
416
+ region = "direct";
417
+ if (!seenHosts.has(host)) {
418
+ seenHosts.add(host);
419
+ log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`} (http)`);
420
+ }
421
+ if (region === "direct") {
422
+ httpForwardDirect(host, port, path, req, res, headers);
423
+ return;
424
+ }
425
+ const order = pickOrder(region);
426
+ if (order.length === 0) {
427
+ res.writeHead(503, { "content-type": "text/plain" });
428
+ res.end("no healthy exit for region\n");
429
+ return;
430
+ }
431
+ httpForwardViaExit(order, 0, target, host, port, path, req, res, headers);
432
+ });
304
433
  server.on("connect", (req, client, head) => {
305
434
  const target = req.url || "";
306
435
  const host = (target.split(":")[0] || "").toLowerCase();