@decentnetwork/lan 0.1.0
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/LICENSE +31 -0
- package/README.md +296 -0
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/acl/acl-engine.d.ts +43 -0
- package/dist/acl/acl-engine.js +189 -0
- package/dist/acl/audit.d.ts +70 -0
- package/dist/acl/audit.js +144 -0
- package/dist/acl/index.d.ts +4 -0
- package/dist/acl/index.js +3 -0
- package/dist/acl/policy.d.ts +31 -0
- package/dist/acl/policy.js +102 -0
- package/dist/acl/types.d.ts +18 -0
- package/dist/acl/types.js +4 -0
- package/dist/carrier/frame.d.ts +18 -0
- package/dist/carrier/frame.js +66 -0
- package/dist/carrier/index.d.ts +5 -0
- package/dist/carrier/index.js +4 -0
- package/dist/carrier/packet-session.d.ts +32 -0
- package/dist/carrier/packet-session.js +151 -0
- package/dist/carrier/peer-manager.d.ts +113 -0
- package/dist/carrier/peer-manager.js +392 -0
- package/dist/carrier/types.d.ts +10 -0
- package/dist/carrier/types.js +11 -0
- package/dist/cli/commands.d.ts +223 -0
- package/dist/cli/commands.js +932 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +196 -0
- package/dist/config/loader.d.ts +10 -0
- package/dist/config/loader.js +152 -0
- package/dist/daemon/index.d.ts +1 -0
- package/dist/daemon/index.js +1 -0
- package/dist/daemon/ipc.d.ts +60 -0
- package/dist/daemon/ipc.js +144 -0
- package/dist/daemon/server.d.ts +63 -0
- package/dist/daemon/server.js +510 -0
- package/dist/dns/index.d.ts +1 -0
- package/dist/dns/index.js +1 -0
- package/dist/dns/resolver.d.ts +44 -0
- package/dist/dns/resolver.js +82 -0
- package/dist/dns/server.d.ts +70 -0
- package/dist/dns/server.js +393 -0
- package/dist/dora/dora-integration.d.ts +90 -0
- package/dist/dora/dora-integration.js +325 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/ipam/index.d.ts +1 -0
- package/dist/ipam/index.js +1 -0
- package/dist/ipam/ipam.d.ts +99 -0
- package/dist/ipam/ipam.js +254 -0
- package/dist/proxy/connect-proxy.d.ts +78 -0
- package/dist/proxy/connect-proxy.js +204 -0
- package/dist/router/index.d.ts +5 -0
- package/dist/router/index.js +4 -0
- package/dist/router/ip-parser.d.ts +36 -0
- package/dist/router/ip-parser.js +127 -0
- package/dist/router/packet-router.d.ts +49 -0
- package/dist/router/packet-router.js +251 -0
- package/dist/router/session-manager.d.ts +50 -0
- package/dist/router/session-manager.js +138 -0
- package/dist/router/types.d.ts +21 -0
- package/dist/router/types.js +6 -0
- package/dist/tun/index.d.ts +3 -0
- package/dist/tun/index.js +2 -0
- package/dist/tun/route-manager.d.ts +59 -0
- package/dist/tun/route-manager.js +353 -0
- package/dist/tun/tun-device.d.ts +45 -0
- package/dist/tun/tun-device.js +265 -0
- package/dist/tun/types.d.ts +28 -0
- package/dist/tun/types.js +4 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.js +4 -0
- package/dist/utils/logger.d.ts +20 -0
- package/dist/utils/logger.js +43 -0
- package/docs/CONFIGURATION.md +197 -0
- package/docs/INSTALL.md +145 -0
- package/package.json +93 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPAM - IP Address Management
|
|
3
|
+
* Maps Carrier IDs to virtual IPs, hostnames, and services
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
6
|
+
import { dirname } from "path";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import yaml from "js-yaml";
|
|
9
|
+
import { Logger } from "../utils/logger.js";
|
|
10
|
+
export class Ipam {
|
|
11
|
+
config;
|
|
12
|
+
filePath;
|
|
13
|
+
logger;
|
|
14
|
+
ipCache = new Map(); // IP -> Record
|
|
15
|
+
idCache = new Map(); // Carrier ID -> Record
|
|
16
|
+
nameCache = new Map(); // Name -> Record
|
|
17
|
+
constructor(config, filePath) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.filePath = filePath;
|
|
20
|
+
this.logger = new Logger({ prefix: "IPAM" });
|
|
21
|
+
this.rebuildCache();
|
|
22
|
+
}
|
|
23
|
+
static async load(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
const content = readFileSync(filePath, "utf-8");
|
|
26
|
+
const config = yaml.load(content);
|
|
27
|
+
return new Ipam(config, filePath);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
31
|
+
throw new Error(`IPAM file not found: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
static async loadOrCreate(filePath, namespace = "agentnet-main") {
|
|
37
|
+
try {
|
|
38
|
+
return await Ipam.load(filePath);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const logger = new Logger({ prefix: "IPAM" });
|
|
42
|
+
logger.info(`Creating new IPAM file: ${filePath}`);
|
|
43
|
+
const config = {
|
|
44
|
+
namespace,
|
|
45
|
+
peers: [],
|
|
46
|
+
};
|
|
47
|
+
const ipam = new Ipam(config, filePath);
|
|
48
|
+
await ipam.save();
|
|
49
|
+
return ipam;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve hostname to virtual IP
|
|
54
|
+
*/
|
|
55
|
+
resolveName(name) {
|
|
56
|
+
const record = this.nameCache.get(name);
|
|
57
|
+
return record ? record.virtualIp : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve virtual IP to IpamRecord
|
|
61
|
+
*/
|
|
62
|
+
resolveIp(ip) {
|
|
63
|
+
return this.ipCache.get(ip) || null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve Carrier ID to IpamRecord
|
|
67
|
+
*/
|
|
68
|
+
resolveCarrierId(carrierId) {
|
|
69
|
+
return this.idCache.get(carrierId) || null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get all peers
|
|
73
|
+
*/
|
|
74
|
+
getPeers() {
|
|
75
|
+
return this.config.peers;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Add or update a peer
|
|
79
|
+
*/
|
|
80
|
+
assignPeer(record) {
|
|
81
|
+
// Remove existing peer with same name or ID
|
|
82
|
+
this.config.peers = this.config.peers.filter((p) => p.name !== record.name && p.carrierId !== record.carrierId);
|
|
83
|
+
// Add new record
|
|
84
|
+
this.config.peers.push(record);
|
|
85
|
+
this.rebuildCache();
|
|
86
|
+
this.logger.info(`Assigned peer: ${record.name} (${record.virtualIp}) -> ${record.carrierId}`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Remove a peer by name or Carrier ID
|
|
90
|
+
*/
|
|
91
|
+
removePeer(identifier) {
|
|
92
|
+
const initialLength = this.config.peers.length;
|
|
93
|
+
this.config.peers = this.config.peers.filter((p) => p.name !== identifier && p.carrierId !== identifier);
|
|
94
|
+
if (this.config.peers.length < initialLength) {
|
|
95
|
+
this.rebuildCache();
|
|
96
|
+
this.logger.info(`Removed peer: ${identifier}`);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check if IP is allocated
|
|
103
|
+
*/
|
|
104
|
+
isIpAllocated(ip) {
|
|
105
|
+
return this.ipCache.has(ip);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Find next available IP in subnet
|
|
109
|
+
* Assumes subnet like "10.86.0.0/16"
|
|
110
|
+
*/
|
|
111
|
+
findNextAvailableIp(subnet, start = "10.86.1.0") {
|
|
112
|
+
const allocated = new Set(Array.from(this.ipCache.keys()));
|
|
113
|
+
// Parse start IP
|
|
114
|
+
const parts = start.split(".");
|
|
115
|
+
let octet4 = parseInt(parts[3], 10);
|
|
116
|
+
let octet3 = parseInt(parts[2], 10);
|
|
117
|
+
while (octet3 < 255) {
|
|
118
|
+
while (octet4 < 255) {
|
|
119
|
+
octet4++;
|
|
120
|
+
const candidate = `${parts[0]}.${parts[1]}.${octet3}.${octet4}`;
|
|
121
|
+
if (!allocated.has(candidate)) {
|
|
122
|
+
return candidate;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
octet3++;
|
|
126
|
+
octet4 = 0;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`No available IPs in subnet ${subnet}`);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get service port for peer
|
|
132
|
+
*/
|
|
133
|
+
getServicePort(peerName, serviceName) {
|
|
134
|
+
const record = this.nameCache.get(peerName);
|
|
135
|
+
if (!record) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const service = record.services.find((s) => s.name === serviceName);
|
|
139
|
+
return service ? service.port : null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get all services for peer
|
|
143
|
+
*/
|
|
144
|
+
getServices(peerName) {
|
|
145
|
+
const record = this.nameCache.get(peerName);
|
|
146
|
+
return record ? record.services : [];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Save IPAM config to file
|
|
150
|
+
*/
|
|
151
|
+
async save() {
|
|
152
|
+
try {
|
|
153
|
+
const dir = dirname(this.filePath);
|
|
154
|
+
mkdirSync(dir, { recursive: true });
|
|
155
|
+
const content = yaml.dump(this.config, { lineWidth: -1 });
|
|
156
|
+
writeFileSync(this.filePath, content, "utf-8");
|
|
157
|
+
this.logger.info(`IPAM saved to ${this.filePath}`);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.logger.error(`Failed to save IPAM: ${error}`);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Check if peer is expired
|
|
166
|
+
*/
|
|
167
|
+
isExpired(peerName) {
|
|
168
|
+
const record = this.nameCache.get(peerName);
|
|
169
|
+
if (!record || !record.expiresAt) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return Date.now() > record.expiresAt;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Deterministically derive a virtual IP from a Carrier userid, in the
|
|
176
|
+
* same style as ARP/DHCP-by-MAC: every peer computes the same IP for the
|
|
177
|
+
* same userid, so the network is consistent without manual coordination.
|
|
178
|
+
*
|
|
179
|
+
* Hash(userid)[0..1] → last two octets of 10.86.X.Y. Avoids x.0 and x.255.
|
|
180
|
+
* Returns the IP regardless of whether it's already in the IPAM.
|
|
181
|
+
*/
|
|
182
|
+
static deterministicIpForUserid(userid) {
|
|
183
|
+
const hash = createHash("sha256").update(userid).digest();
|
|
184
|
+
// Avoid the .0 and .255 endpoints in each octet.
|
|
185
|
+
const octet3 = (hash[0] % 254) + 1; // 1..254
|
|
186
|
+
const octet4 = (hash[1] % 254) + 1; // 1..254
|
|
187
|
+
return `10.86.${octet3}.${octet4}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Ensure each friend has an IPAM record. Returns the number of new
|
|
191
|
+
* records added. Existing entries (manual or previously auto-assigned)
|
|
192
|
+
* are preserved.
|
|
193
|
+
*
|
|
194
|
+
* On collision (different userid hashes to an already-taken IP), walks
|
|
195
|
+
* the 4th octet until a free slot is found.
|
|
196
|
+
*/
|
|
197
|
+
autoAssignFriends(friends, selfUserid) {
|
|
198
|
+
let added = 0;
|
|
199
|
+
for (const friend of friends) {
|
|
200
|
+
if (friend.pubkey === selfUserid)
|
|
201
|
+
continue; // skip ourselves
|
|
202
|
+
if (this.idCache.has(friend.pubkey))
|
|
203
|
+
continue; // already in IPAM
|
|
204
|
+
const baseIp = Ipam.deterministicIpForUserid(friend.pubkey);
|
|
205
|
+
let ip = baseIp;
|
|
206
|
+
const [, , o3str] = baseIp.split(".");
|
|
207
|
+
const o3 = parseInt(o3str, 10);
|
|
208
|
+
for (let attempt = 0; attempt < 254 && this.ipCache.has(ip); attempt++) {
|
|
209
|
+
const o4 = ((parseInt(baseIp.split(".")[3], 10) - 1 + attempt + 1) % 254) + 1;
|
|
210
|
+
ip = `10.86.${o3}.${o4}`;
|
|
211
|
+
}
|
|
212
|
+
this.config.peers.push({
|
|
213
|
+
// Use the short userid prefix as a stable, human-recognizable name.
|
|
214
|
+
// The operator can rename later via ipam assign.
|
|
215
|
+
name: friend.name && friend.name.trim() ? friend.name : `peer-${friend.pubkey.slice(0, 8)}`,
|
|
216
|
+
carrierId: friend.pubkey,
|
|
217
|
+
virtualIp: ip,
|
|
218
|
+
services: [],
|
|
219
|
+
});
|
|
220
|
+
added += 1;
|
|
221
|
+
this.logger.info(`Auto-assigned ${friend.pubkey.slice(0, 16)}... -> ${ip}`);
|
|
222
|
+
}
|
|
223
|
+
if (added > 0)
|
|
224
|
+
this.rebuildCache();
|
|
225
|
+
return added;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Get config
|
|
229
|
+
*/
|
|
230
|
+
getConfig() {
|
|
231
|
+
return this.config;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get namespace
|
|
235
|
+
*/
|
|
236
|
+
getNamespace() {
|
|
237
|
+
return this.config.namespace;
|
|
238
|
+
}
|
|
239
|
+
rebuildCache() {
|
|
240
|
+
this.ipCache.clear();
|
|
241
|
+
this.idCache.clear();
|
|
242
|
+
this.nameCache.clear();
|
|
243
|
+
for (const peer of this.config.peers) {
|
|
244
|
+
// Skip expired peers
|
|
245
|
+
if (peer.expiresAt && Date.now() > peer.expiresAt) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
this.ipCache.set(peer.virtualIp, peer);
|
|
249
|
+
this.idCache.set(peer.carrierId, peer);
|
|
250
|
+
this.nameCache.set(peer.name, peer);
|
|
251
|
+
}
|
|
252
|
+
this.logger.debug(`IPAM cache rebuilt: ${this.config.peers.length} peers`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP CONNECT proxy server.
|
|
3
|
+
*
|
|
4
|
+
* Folded into the decentlan daemon so a peer can act as an egress proxy for
|
|
5
|
+
* other peers without depending on tinyproxy + Homebrew + launchd. Listens on
|
|
6
|
+
* the daemon's virtual IP (or any address the caller provides), accepts only
|
|
7
|
+
* the CONNECT method, and pipes bytes between client and upstream.
|
|
8
|
+
*
|
|
9
|
+
* What this is NOT:
|
|
10
|
+
* - Not a forward proxy for plain HTTP. CONNECT only — TLS is end-to-end past
|
|
11
|
+
* the proxy, which keeps us out of the request-rewriting / cert-bumping
|
|
12
|
+
* business.
|
|
13
|
+
* - Not authenticated at the HTTP layer. Auth is "you reached the listener,
|
|
14
|
+
* so the decentlan ACL already let you through."
|
|
15
|
+
* - Not load-balanced or HA. One proxy node, one upstream connection per
|
|
16
|
+
* CONNECT.
|
|
17
|
+
*
|
|
18
|
+
* Audit and host-allowlist live here; ACL gating happens upstream in
|
|
19
|
+
* PacketRouter (the TCP connection cannot even reach this listener unless the
|
|
20
|
+
* peer is granted port 8888).
|
|
21
|
+
*/
|
|
22
|
+
export interface ConnectProxyOptions {
|
|
23
|
+
/** IP to bind on. Pass the daemon's virtual IP (e.g. "10.86.1.20"). */
|
|
24
|
+
bindIp: string;
|
|
25
|
+
/** TCP port to bind. */
|
|
26
|
+
port: number;
|
|
27
|
+
/**
|
|
28
|
+
* Globs allowed as CONNECT targets. `*` matches any segment. Empty list
|
|
29
|
+
* means "allow any host." Examples: `["*.binance.com", "api.coingecko.com"]`.
|
|
30
|
+
*/
|
|
31
|
+
allowHosts?: string[];
|
|
32
|
+
/** TCP ports the proxy will dial upstream. Defaults to [443, 80]. */
|
|
33
|
+
allowConnectPorts?: number[];
|
|
34
|
+
/** Hook for resolving a source IP to a friendly peer name (audit/log). */
|
|
35
|
+
resolvePeerName?: (sourceIp: string) => string | undefined;
|
|
36
|
+
/** Called when a tunnel opens. */
|
|
37
|
+
onTunnelOpen?: (info: {
|
|
38
|
+
src: string;
|
|
39
|
+
srcName?: string;
|
|
40
|
+
target: string;
|
|
41
|
+
}) => void;
|
|
42
|
+
/** Called when a tunnel closes. */
|
|
43
|
+
onTunnelClose?: (info: {
|
|
44
|
+
src: string;
|
|
45
|
+
srcName?: string;
|
|
46
|
+
target: string;
|
|
47
|
+
bytesTransferred: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
}
|
|
50
|
+
export interface ConnectProxyStats {
|
|
51
|
+
bindIp: string;
|
|
52
|
+
port: number;
|
|
53
|
+
startedAt: number;
|
|
54
|
+
active: number;
|
|
55
|
+
totalOpened: number;
|
|
56
|
+
totalRefused: number;
|
|
57
|
+
bytesTransferred: number;
|
|
58
|
+
}
|
|
59
|
+
export declare class ConnectProxy {
|
|
60
|
+
private opts;
|
|
61
|
+
private server;
|
|
62
|
+
private logger;
|
|
63
|
+
private stats;
|
|
64
|
+
constructor(opts: ConnectProxyOptions);
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
stop(): Promise<void>;
|
|
67
|
+
getStats(): ConnectProxyStats;
|
|
68
|
+
private handleConnect;
|
|
69
|
+
private refuse;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Glob match where `*` matches any sequence of characters (including dots).
|
|
73
|
+
* `*.binance.com` matches `api.binance.com` and `data-api.binance.com` but
|
|
74
|
+
* NOT `binance.com` (literal — must include the leading subdomain). To allow
|
|
75
|
+
* the bare domain too, use a second glob `binance.com`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function matchesGlob(glob: string, host: string): boolean;
|
|
78
|
+
export declare function matchesAnyGlob(globs: string[], host: string): boolean;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP CONNECT proxy server.
|
|
3
|
+
*
|
|
4
|
+
* Folded into the decentlan daemon so a peer can act as an egress proxy for
|
|
5
|
+
* other peers without depending on tinyproxy + Homebrew + launchd. Listens on
|
|
6
|
+
* the daemon's virtual IP (or any address the caller provides), accepts only
|
|
7
|
+
* the CONNECT method, and pipes bytes between client and upstream.
|
|
8
|
+
*
|
|
9
|
+
* What this is NOT:
|
|
10
|
+
* - Not a forward proxy for plain HTTP. CONNECT only — TLS is end-to-end past
|
|
11
|
+
* the proxy, which keeps us out of the request-rewriting / cert-bumping
|
|
12
|
+
* business.
|
|
13
|
+
* - Not authenticated at the HTTP layer. Auth is "you reached the listener,
|
|
14
|
+
* so the decentlan ACL already let you through."
|
|
15
|
+
* - Not load-balanced or HA. One proxy node, one upstream connection per
|
|
16
|
+
* CONNECT.
|
|
17
|
+
*
|
|
18
|
+
* Audit and host-allowlist live here; ACL gating happens upstream in
|
|
19
|
+
* PacketRouter (the TCP connection cannot even reach this listener unless the
|
|
20
|
+
* peer is granted port 8888).
|
|
21
|
+
*/
|
|
22
|
+
import http from "http";
|
|
23
|
+
import net from "net";
|
|
24
|
+
import { Logger } from "../utils/logger.js";
|
|
25
|
+
export class ConnectProxy {
|
|
26
|
+
opts;
|
|
27
|
+
server = null;
|
|
28
|
+
logger;
|
|
29
|
+
stats;
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
this.opts = opts;
|
|
32
|
+
this.logger = new Logger({ prefix: "ConnectProxy" });
|
|
33
|
+
this.stats = {
|
|
34
|
+
bindIp: opts.bindIp,
|
|
35
|
+
port: opts.port,
|
|
36
|
+
startedAt: 0,
|
|
37
|
+
active: 0,
|
|
38
|
+
totalOpened: 0,
|
|
39
|
+
totalRefused: 0,
|
|
40
|
+
bytesTransferred: 0,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async start() {
|
|
44
|
+
if (this.server) {
|
|
45
|
+
throw new Error("ConnectProxy already started");
|
|
46
|
+
}
|
|
47
|
+
this.server = http.createServer();
|
|
48
|
+
this.server.on("connect", (req, clientSocket, head) => {
|
|
49
|
+
this.handleConnect(req, clientSocket, head);
|
|
50
|
+
});
|
|
51
|
+
// Reject anything that isn't CONNECT — we don't forward HTTP/1.1.
|
|
52
|
+
this.server.on("request", (_req, res) => {
|
|
53
|
+
res.writeHead(405, { Allow: "CONNECT", "Content-Type": "text/plain" });
|
|
54
|
+
res.end("Only HTTP CONNECT is supported.\n");
|
|
55
|
+
});
|
|
56
|
+
this.server.on("clientError", (err, socket) => {
|
|
57
|
+
this.logger.debug(`clientError: ${err.message}`);
|
|
58
|
+
try {
|
|
59
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// socket already gone
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
await new Promise((res, rej) => {
|
|
66
|
+
const onErr = (err) => rej(err);
|
|
67
|
+
this.server.once("error", onErr);
|
|
68
|
+
this.server.listen(this.opts.port, this.opts.bindIp, () => {
|
|
69
|
+
this.server.removeListener("error", onErr);
|
|
70
|
+
res();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
this.stats.startedAt = Date.now();
|
|
74
|
+
this.logger.info(`Listening on ${this.opts.bindIp}:${this.opts.port}`);
|
|
75
|
+
}
|
|
76
|
+
async stop() {
|
|
77
|
+
if (!this.server)
|
|
78
|
+
return;
|
|
79
|
+
const srv = this.server;
|
|
80
|
+
this.server = null;
|
|
81
|
+
await new Promise((res) => {
|
|
82
|
+
srv.close(() => res());
|
|
83
|
+
});
|
|
84
|
+
this.logger.info("Stopped");
|
|
85
|
+
}
|
|
86
|
+
getStats() {
|
|
87
|
+
return { ...this.stats };
|
|
88
|
+
}
|
|
89
|
+
handleConnect(req, clientSocket, head) {
|
|
90
|
+
const src = clientSocket.remoteAddress ?? "?";
|
|
91
|
+
const srcName = this.opts.resolvePeerName?.(src);
|
|
92
|
+
const targetSpec = req.url ?? "";
|
|
93
|
+
const m = targetSpec.match(/^([^:]+):(\d+)$/);
|
|
94
|
+
if (!m) {
|
|
95
|
+
this.refuse(clientSocket, src, srcName, targetSpec, 400, "malformed CONNECT target");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const host = m[1];
|
|
99
|
+
const port = Number(m[2]);
|
|
100
|
+
const allowedPorts = this.opts.allowConnectPorts ?? [443, 80];
|
|
101
|
+
if (!allowedPorts.includes(port)) {
|
|
102
|
+
this.refuse(clientSocket, src, srcName, `${host}:${port}`, 403, `port ${port} not in allowConnectPorts`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const allowHosts = this.opts.allowHosts ?? [];
|
|
106
|
+
if (allowHosts.length > 0 && !matchesAnyGlob(allowHosts, host)) {
|
|
107
|
+
this.refuse(clientSocket, src, srcName, `${host}:${port}`, 403, "host not in allowHosts");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
let closed = false;
|
|
112
|
+
const upstream = net.connect(port, host);
|
|
113
|
+
const tearDown = (reason) => {
|
|
114
|
+
if (closed)
|
|
115
|
+
return;
|
|
116
|
+
closed = true;
|
|
117
|
+
upstream.destroy();
|
|
118
|
+
clientSocket.destroy();
|
|
119
|
+
this.stats.active = Math.max(0, this.stats.active - 1);
|
|
120
|
+
this.stats.bytesTransferred += bytes;
|
|
121
|
+
this.opts.onTunnelClose?.({
|
|
122
|
+
src,
|
|
123
|
+
srcName,
|
|
124
|
+
target: `${host}:${port}`,
|
|
125
|
+
bytesTransferred: bytes,
|
|
126
|
+
});
|
|
127
|
+
this.logger.debug(`tunnel closed ${src} -> ${host}:${port} bytes=${bytes} (${reason})`);
|
|
128
|
+
};
|
|
129
|
+
upstream.on("connect", () => {
|
|
130
|
+
try {
|
|
131
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: decentlan\r\n\r\n");
|
|
132
|
+
if (head.length > 0)
|
|
133
|
+
upstream.write(head);
|
|
134
|
+
clientSocket.pipe(upstream);
|
|
135
|
+
upstream.pipe(clientSocket);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
tearDown(`pipe-setup failed: ${err.message}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.stats.active += 1;
|
|
142
|
+
this.stats.totalOpened += 1;
|
|
143
|
+
this.opts.onTunnelOpen?.({ src, srcName, target: `${host}:${port}` });
|
|
144
|
+
this.logger.info(`tunnel open ${src}${srcName ? ` (${srcName})` : ""} -> ${host}:${port}`);
|
|
145
|
+
});
|
|
146
|
+
const onData = (chunk) => {
|
|
147
|
+
bytes += chunk.length;
|
|
148
|
+
};
|
|
149
|
+
upstream.on("data", onData);
|
|
150
|
+
clientSocket.on("data", onData);
|
|
151
|
+
upstream.on("error", (err) => {
|
|
152
|
+
// Pre-tunnel: send an HTTP error back so the client gets a useful message.
|
|
153
|
+
// Post-tunnel: just tear down silently.
|
|
154
|
+
if (!closed && !clientSocket.destroyed) {
|
|
155
|
+
try {
|
|
156
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\nProxy-Agent: decentlan\r\n\r\nupstream error: ${err.message}\n`);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// ignore
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
tearDown(`upstream error: ${err.message}`);
|
|
163
|
+
});
|
|
164
|
+
upstream.on("end", () => tearDown("upstream end"));
|
|
165
|
+
clientSocket.on("error", (err) => tearDown(`client error: ${err.message}`));
|
|
166
|
+
clientSocket.on("end", () => tearDown("client end"));
|
|
167
|
+
}
|
|
168
|
+
refuse(clientSocket, src, srcName, target, code, reason) {
|
|
169
|
+
this.stats.totalRefused += 1;
|
|
170
|
+
this.logger.info(`tunnel refused ${src}${srcName ? ` (${srcName})` : ""} -> ${target}: ${reason}`);
|
|
171
|
+
try {
|
|
172
|
+
const statusText = code === 400 ? "Bad Request" : "Forbidden";
|
|
173
|
+
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`);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// ignore
|
|
177
|
+
}
|
|
178
|
+
clientSocket.end();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Glob match where `*` matches any sequence of characters (including dots).
|
|
183
|
+
* `*.binance.com` matches `api.binance.com` and `data-api.binance.com` but
|
|
184
|
+
* NOT `binance.com` (literal — must include the leading subdomain). To allow
|
|
185
|
+
* the bare domain too, use a second glob `binance.com`.
|
|
186
|
+
*/
|
|
187
|
+
export function matchesGlob(glob, host) {
|
|
188
|
+
const re = new RegExp("^" +
|
|
189
|
+
glob
|
|
190
|
+
.split("")
|
|
191
|
+
.map((ch) => {
|
|
192
|
+
if (ch === "*")
|
|
193
|
+
return ".*";
|
|
194
|
+
if (/[.+?^${}()|[\]\\]/.test(ch))
|
|
195
|
+
return "\\" + ch;
|
|
196
|
+
return ch;
|
|
197
|
+
})
|
|
198
|
+
.join("") +
|
|
199
|
+
"$", "i");
|
|
200
|
+
return re.test(host);
|
|
201
|
+
}
|
|
202
|
+
export function matchesAnyGlob(globs, host) {
|
|
203
|
+
return globs.some((g) => matchesGlob(g, host));
|
|
204
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PacketRouter, type PacketRouterOptions } from "./packet-router.js";
|
|
2
|
+
export { SessionManager } from "./session-manager.js";
|
|
3
|
+
export { IpParser } from "./ip-parser.js";
|
|
4
|
+
export type { ParsedPacket, ForwardingStats } from "./types.js";
|
|
5
|
+
export { IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_ICMP } from "./types.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv4 packet parser
|
|
3
|
+
* Extracts source/dest IP, protocol, and (for TCP/UDP) ports
|
|
4
|
+
*/
|
|
5
|
+
import type { ParsedPacket } from "./types.js";
|
|
6
|
+
export declare class IpParser {
|
|
7
|
+
/**
|
|
8
|
+
* Parse an IPv4 packet.
|
|
9
|
+
* Returns null if not a valid IPv4 packet.
|
|
10
|
+
*/
|
|
11
|
+
static parse(packet: Uint8Array): ParsedPacket | null;
|
|
12
|
+
/**
|
|
13
|
+
* Get the protocol name for logging.
|
|
14
|
+
*/
|
|
15
|
+
static protoName(protocol: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Check if protocol is TCP.
|
|
18
|
+
*/
|
|
19
|
+
static isTcp(protocol: number): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Check if protocol is UDP.
|
|
22
|
+
*/
|
|
23
|
+
static isUdp(protocol: number): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Build a minimal IPv4 packet header for testing.
|
|
26
|
+
* Returns a 20-byte header plus optional payload.
|
|
27
|
+
*/
|
|
28
|
+
static buildIpv4Header(opts: {
|
|
29
|
+
srcIp: string;
|
|
30
|
+
dstIp: string;
|
|
31
|
+
protocol: number;
|
|
32
|
+
payload?: Uint8Array;
|
|
33
|
+
srcPort?: number;
|
|
34
|
+
dstPort?: number;
|
|
35
|
+
}): Uint8Array;
|
|
36
|
+
}
|