@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.
Files changed (79) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +296 -0
  3. package/bin/tun-helper-darwin-amd64 +0 -0
  4. package/bin/tun-helper-darwin-arm64 +0 -0
  5. package/bin/tun-helper-linux-amd64 +0 -0
  6. package/bin/tun-helper-linux-arm64 +0 -0
  7. package/dist/acl/acl-engine.d.ts +43 -0
  8. package/dist/acl/acl-engine.js +189 -0
  9. package/dist/acl/audit.d.ts +70 -0
  10. package/dist/acl/audit.js +144 -0
  11. package/dist/acl/index.d.ts +4 -0
  12. package/dist/acl/index.js +3 -0
  13. package/dist/acl/policy.d.ts +31 -0
  14. package/dist/acl/policy.js +102 -0
  15. package/dist/acl/types.d.ts +18 -0
  16. package/dist/acl/types.js +4 -0
  17. package/dist/carrier/frame.d.ts +18 -0
  18. package/dist/carrier/frame.js +66 -0
  19. package/dist/carrier/index.d.ts +5 -0
  20. package/dist/carrier/index.js +4 -0
  21. package/dist/carrier/packet-session.d.ts +32 -0
  22. package/dist/carrier/packet-session.js +151 -0
  23. package/dist/carrier/peer-manager.d.ts +113 -0
  24. package/dist/carrier/peer-manager.js +392 -0
  25. package/dist/carrier/types.d.ts +10 -0
  26. package/dist/carrier/types.js +11 -0
  27. package/dist/cli/commands.d.ts +223 -0
  28. package/dist/cli/commands.js +932 -0
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +196 -0
  31. package/dist/config/loader.d.ts +10 -0
  32. package/dist/config/loader.js +152 -0
  33. package/dist/daemon/index.d.ts +1 -0
  34. package/dist/daemon/index.js +1 -0
  35. package/dist/daemon/ipc.d.ts +60 -0
  36. package/dist/daemon/ipc.js +144 -0
  37. package/dist/daemon/server.d.ts +63 -0
  38. package/dist/daemon/server.js +510 -0
  39. package/dist/dns/index.d.ts +1 -0
  40. package/dist/dns/index.js +1 -0
  41. package/dist/dns/resolver.d.ts +44 -0
  42. package/dist/dns/resolver.js +82 -0
  43. package/dist/dns/server.d.ts +70 -0
  44. package/dist/dns/server.js +393 -0
  45. package/dist/dora/dora-integration.d.ts +90 -0
  46. package/dist/dora/dora-integration.js +325 -0
  47. package/dist/index.d.ts +13 -0
  48. package/dist/index.js +15 -0
  49. package/dist/ipam/index.d.ts +1 -0
  50. package/dist/ipam/index.js +1 -0
  51. package/dist/ipam/ipam.d.ts +99 -0
  52. package/dist/ipam/ipam.js +254 -0
  53. package/dist/proxy/connect-proxy.d.ts +78 -0
  54. package/dist/proxy/connect-proxy.js +204 -0
  55. package/dist/router/index.d.ts +5 -0
  56. package/dist/router/index.js +4 -0
  57. package/dist/router/ip-parser.d.ts +36 -0
  58. package/dist/router/ip-parser.js +127 -0
  59. package/dist/router/packet-router.d.ts +49 -0
  60. package/dist/router/packet-router.js +251 -0
  61. package/dist/router/session-manager.d.ts +50 -0
  62. package/dist/router/session-manager.js +138 -0
  63. package/dist/router/types.d.ts +21 -0
  64. package/dist/router/types.js +6 -0
  65. package/dist/tun/index.d.ts +3 -0
  66. package/dist/tun/index.js +2 -0
  67. package/dist/tun/route-manager.d.ts +59 -0
  68. package/dist/tun/route-manager.js +353 -0
  69. package/dist/tun/tun-device.d.ts +45 -0
  70. package/dist/tun/tun-device.js +265 -0
  71. package/dist/tun/types.d.ts +28 -0
  72. package/dist/tun/types.js +4 -0
  73. package/dist/types.d.ts +176 -0
  74. package/dist/types.js +4 -0
  75. package/dist/utils/logger.d.ts +20 -0
  76. package/dist/utils/logger.js +43 -0
  77. package/docs/CONFIGURATION.md +197 -0
  78. package/docs/INSTALL.md +145 -0
  79. 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,4 @@
1
+ export { PacketRouter } from "./packet-router.js";
2
+ export { SessionManager } from "./session-manager.js";
3
+ export { IpParser } from "./ip-parser.js";
4
+ 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
+ }