@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.
- 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/config/default-doras.yaml +0 -4
- package/config/default-exits.yaml +0 -6
- package/dist/cli/commands.d.ts +32 -0
- package/dist/cli/commands.js +139 -0
- package/dist/cli/index.js +19 -1
- package/dist/config/loader.js +0 -1
- package/dist/console/console.js +0 -2
- package/dist/daemon/ipc.d.ts +12 -1
- package/dist/daemon/ipc.js +9 -0
- package/dist/daemon/message-store.d.ts +22 -6
- package/dist/daemon/message-store.js +30 -3
- package/dist/daemon/server.d.ts +19 -0
- package/dist/daemon/server.js +210 -5
- package/dist/proxy/connect-proxy.d.ts +17 -0
- package/dist/proxy/connect-proxy.js +29 -1
- package/dist/proxy/multi-exit-router.js +130 -1
- package/dist/ui/desktop/app.js +67 -39
- package/dist/ui/desktop/index.html +1 -1
- package/dist/ui/server.js +22 -4
- package/docs/INSTALL.md +20 -6
- package/package.json +2 -2
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
|
|
311
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|