@opensecurity/zonzon-core 0.1.4 → 0.1.5
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/dist/audit.d.ts +4 -0
- package/dist/audit.js +116 -10
- package/dist/dns-handler.d.ts +9 -0
- package/dist/dns-handler.js +307 -35
- package/dist/dns-service.d.ts +0 -1
- package/dist/dns-service.js +8 -17
- package/dist/doh-dot.test.d.ts +1 -0
- package/dist/doh-dot.test.js +101 -0
- package/dist/firewall.d.ts +2 -0
- package/dist/firewall.js +67 -8
- package/dist/firewall.test.d.ts +1 -0
- package/dist/firewall.test.js +165 -0
- package/dist/http-body-forwarding-integration.test.js +15 -0
- package/dist/http-handler.d.ts +3 -1
- package/dist/http-handler.js +172 -132
- package/dist/http-proxy.d.ts +1 -2
- package/dist/http-proxy.js +5 -7
- package/dist/rate-limiter.d.ts +5 -0
- package/dist/rate-limiter.js +30 -11
- package/dist/schema.js +13 -6
- package/dist/sni-proxy.d.ts +1 -0
- package/dist/sni-proxy.js +61 -11
- package/dist/sni-proxy.test.d.ts +1 -0
- package/dist/sni-proxy.test.js +69 -0
- package/dist/types.d.ts +8 -0
- package/package.json +2 -2
package/dist/audit.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export declare class AuditLogger {
|
|
2
2
|
private isTestEnv;
|
|
3
|
+
private useJson;
|
|
4
|
+
private metrics;
|
|
3
5
|
private sanitize;
|
|
6
|
+
setJsonMode(enable: boolean): void;
|
|
7
|
+
getMetricsPrometheus(): string;
|
|
4
8
|
dns(ip: string, questions: any[], rcode: number, cached?: boolean): void;
|
|
5
9
|
firewall(ip: string, target: string, action: "ALLOW" | "DENY", detail?: string): void;
|
|
6
10
|
http(ip: string, method: string, host: string, path: string, status: number, target?: string): void;
|
package/dist/audit.js
CHANGED
|
@@ -2,38 +2,144 @@ import { DNS_TYPES } from "./types.js";
|
|
|
2
2
|
const REVERSE_DNS_TYPES = Object.fromEntries(Object.entries(DNS_TYPES).map(([k, v]) => [v, k]));
|
|
3
3
|
export class AuditLogger {
|
|
4
4
|
isTestEnv = process.argv.includes("--test") || process.env.NODE_ENV === "test";
|
|
5
|
+
useJson = false;
|
|
6
|
+
metrics = {
|
|
7
|
+
dns_queries: 0,
|
|
8
|
+
dns_blocked: 0,
|
|
9
|
+
http_requests: 0,
|
|
10
|
+
http_blocked: 0,
|
|
11
|
+
firewall_drops: 0,
|
|
12
|
+
system_events: 0,
|
|
13
|
+
errors: 0
|
|
14
|
+
};
|
|
5
15
|
sanitize(input) {
|
|
6
16
|
return String(input || "").replace(/[\r\n\t]/g, " ").replace(/[^\x20-\x7E]/g, "?");
|
|
7
17
|
}
|
|
18
|
+
setJsonMode(enable) {
|
|
19
|
+
this.useJson = enable;
|
|
20
|
+
}
|
|
21
|
+
getMetricsPrometheus() {
|
|
22
|
+
return [
|
|
23
|
+
`# HELP zonzon_dns_queries_total Total DNS queries processed`,
|
|
24
|
+
`# TYPE zonzon_dns_queries_total counter`,
|
|
25
|
+
`zonzon_dns_queries_total ${this.metrics.dns_queries}`,
|
|
26
|
+
`# HELP zonzon_dns_blocked_total Total DNS queries blocked by policy`,
|
|
27
|
+
`# TYPE zonzon_dns_blocked_total counter`,
|
|
28
|
+
`zonzon_dns_blocked_total ${this.metrics.dns_blocked}`,
|
|
29
|
+
`# HELP zonzon_http_requests_total Total HTTP requests routed`,
|
|
30
|
+
`# TYPE zonzon_http_requests_total counter`,
|
|
31
|
+
`zonzon_http_requests_total ${this.metrics.http_requests}`,
|
|
32
|
+
`# HELP zonzon_http_blocked_total Total HTTP requests blocked`,
|
|
33
|
+
`# TYPE zonzon_http_blocked_total counter`,
|
|
34
|
+
`zonzon_http_blocked_total ${this.metrics.http_blocked}`,
|
|
35
|
+
`# HELP zonzon_firewall_drops_total Total connection drops across L3/L4/L7`,
|
|
36
|
+
`# TYPE zonzon_firewall_drops_total counter`,
|
|
37
|
+
`zonzon_firewall_drops_total ${this.metrics.firewall_drops}`,
|
|
38
|
+
`# HELP zonzon_errors_total Total system errors encountered`,
|
|
39
|
+
`# TYPE zonzon_errors_total counter`,
|
|
40
|
+
`zonzon_errors_total ${this.metrics.errors}`
|
|
41
|
+
].join("\n") + "\n";
|
|
42
|
+
}
|
|
8
43
|
dns(ip, questions, rcode, cached = false) {
|
|
44
|
+
this.metrics.dns_queries += questions.length;
|
|
45
|
+
if (rcode === 5 || rcode === 3)
|
|
46
|
+
this.metrics.dns_blocked += questions.length;
|
|
9
47
|
if (this.isTestEnv)
|
|
10
48
|
return;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
49
|
+
if (this.useJson) {
|
|
50
|
+
console.log(JSON.stringify({
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
level: "INFO",
|
|
53
|
+
component: "DNS",
|
|
54
|
+
ip,
|
|
55
|
+
questions: questions.map(q => ({ name: q.name, type: REVERSE_DNS_TYPES[q.type] || q.type })),
|
|
56
|
+
rcode,
|
|
57
|
+
cached
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const codeMap = { 0: "Found (NOERROR)", 3: "Not Found (NXDOMAIN)", 5: "Blocked by Firewall (REFUSED)" };
|
|
62
|
+
const prefix = cached ? "[Cached] " : "";
|
|
63
|
+
questions.forEach(q => {
|
|
64
|
+
console.log(`[DNS] ${this.sanitize(ip)} | ${prefix}${REVERSE_DNS_TYPES[q.type] || q.type} ${this.sanitize(q.name)} -> ${codeMap[rcode] || rcode}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
16
67
|
}
|
|
17
68
|
firewall(ip, target, action, detail = "") {
|
|
69
|
+
if (action === "DENY")
|
|
70
|
+
this.metrics.firewall_drops++;
|
|
18
71
|
if (this.isTestEnv)
|
|
19
72
|
return;
|
|
20
|
-
|
|
21
|
-
|
|
73
|
+
if (this.useJson) {
|
|
74
|
+
console.log(JSON.stringify({
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
level: action === "DENY" ? "WARN" : "INFO",
|
|
77
|
+
component: "FIREWALL",
|
|
78
|
+
action,
|
|
79
|
+
ip,
|
|
80
|
+
target,
|
|
81
|
+
detail
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const color = action === "ALLOW" ? "\x1b[32mALLOW\x1b[0m" : "\x1b[31mDENY\x1b[0m";
|
|
86
|
+
console.log(`[FIREWALL] ${this.sanitize(ip)} | ${color} | ${this.sanitize(target)} ${detail ? `(${this.sanitize(detail)})` : ""}`);
|
|
87
|
+
}
|
|
22
88
|
}
|
|
23
89
|
http(ip, method, host, path, status, target = "") {
|
|
90
|
+
this.metrics.http_requests++;
|
|
91
|
+
if (status === 403 || status === 413 || status === 429)
|
|
92
|
+
this.metrics.http_blocked++;
|
|
24
93
|
if (this.isTestEnv)
|
|
25
94
|
return;
|
|
26
|
-
|
|
95
|
+
if (this.useJson) {
|
|
96
|
+
console.log(JSON.stringify({
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
level: status >= 400 ? "WARN" : "INFO",
|
|
99
|
+
component: "HTTP",
|
|
100
|
+
ip,
|
|
101
|
+
method,
|
|
102
|
+
host,
|
|
103
|
+
path,
|
|
104
|
+
status,
|
|
105
|
+
target
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log(`[HTTP] ${this.sanitize(ip)} | Returned Status ${status} | ${this.sanitize(method)} ${this.sanitize(host)}${this.sanitize(path)} ${target ? `-> ${this.sanitize(target)}` : ""}`);
|
|
110
|
+
}
|
|
27
111
|
}
|
|
28
112
|
system(msg) {
|
|
113
|
+
this.metrics.system_events++;
|
|
29
114
|
if (this.isTestEnv)
|
|
30
115
|
return;
|
|
31
|
-
|
|
116
|
+
if (this.useJson) {
|
|
117
|
+
console.log(JSON.stringify({
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
level: "INFO",
|
|
120
|
+
component: "SYSTEM",
|
|
121
|
+
message: msg
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
console.log(`[SYSTEM] ${this.sanitize(msg)}`);
|
|
126
|
+
}
|
|
32
127
|
}
|
|
33
128
|
error(msg) {
|
|
129
|
+
this.metrics.errors++;
|
|
34
130
|
if (this.isTestEnv)
|
|
35
131
|
return;
|
|
36
|
-
|
|
132
|
+
if (this.useJson) {
|
|
133
|
+
console.error(JSON.stringify({
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
level: "ERROR",
|
|
136
|
+
component: "SYSTEM",
|
|
137
|
+
message: msg
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
console.error(`[ERROR] \x1b[31m${this.sanitize(msg)}\x1b[0m`);
|
|
142
|
+
}
|
|
37
143
|
}
|
|
38
144
|
}
|
|
39
145
|
export const audit = new AuditLogger();
|
package/dist/dns-handler.d.ts
CHANGED
|
@@ -4,20 +4,29 @@ export declare class DnsHandler {
|
|
|
4
4
|
private server;
|
|
5
5
|
private udpServer;
|
|
6
6
|
private tcpServer;
|
|
7
|
+
private dotServer;
|
|
8
|
+
private dohServer;
|
|
7
9
|
private port;
|
|
8
10
|
private fallbackDns;
|
|
9
11
|
private config;
|
|
10
12
|
private activeTcpConnections;
|
|
11
13
|
private maxTcpConnections;
|
|
12
14
|
private tcpIdleTimeoutMs;
|
|
15
|
+
private pendingUpstreamQueries;
|
|
16
|
+
private readonly maxConcurrentUdpForwards;
|
|
13
17
|
private rateLimiter;
|
|
14
18
|
constructor(server: DevDnsServer, config: ServerConfig);
|
|
15
19
|
start(): Promise<void>;
|
|
16
20
|
stop(): Promise<void>;
|
|
17
21
|
private startUdp;
|
|
18
22
|
private startTcp;
|
|
23
|
+
private startDoT;
|
|
24
|
+
private startDoH;
|
|
19
25
|
private isRateLimited;
|
|
20
26
|
private parseResolvedIpv4s;
|
|
27
|
+
private isPrivateIp;
|
|
28
|
+
private resolveQueryAsync;
|
|
29
|
+
private handleDohRequest;
|
|
21
30
|
private forwardUdpQuery;
|
|
22
31
|
private handleUdpMessage;
|
|
23
32
|
private removeTcpConnection;
|
package/dist/dns-handler.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import dgram from "dgram";
|
|
2
2
|
import * as net from "net";
|
|
3
|
-
import
|
|
3
|
+
import * as tls from "node:tls";
|
|
4
|
+
import * as https from "node:https";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { DnsWireFormat, extractQuestions } from "./dns-service.js";
|
|
4
7
|
import { DNS_RCODE } from "./types.js";
|
|
5
8
|
import { RateLimiter } from "./rate-limiter.js";
|
|
9
|
+
import { audit } from "./audit.js";
|
|
6
10
|
import { firewallEngine } from "./firewall.js";
|
|
7
11
|
const MAX_TCP_BUFFER_SIZE = 64 * 1024;
|
|
8
12
|
export class DnsHandler {
|
|
9
13
|
server;
|
|
10
14
|
udpServer = null;
|
|
11
15
|
tcpServer = null;
|
|
16
|
+
dotServer = null;
|
|
17
|
+
dohServer = null;
|
|
12
18
|
port;
|
|
13
19
|
fallbackDns;
|
|
14
20
|
config;
|
|
15
21
|
activeTcpConnections = new Map();
|
|
16
22
|
maxTcpConnections;
|
|
17
23
|
tcpIdleTimeoutMs;
|
|
24
|
+
pendingUpstreamQueries = new Map();
|
|
25
|
+
maxConcurrentUdpForwards = 2000;
|
|
18
26
|
rateLimiter;
|
|
19
27
|
constructor(server, config) {
|
|
20
28
|
this.server = server;
|
|
@@ -36,6 +44,10 @@ export class DnsHandler {
|
|
|
36
44
|
async start() {
|
|
37
45
|
await this.startUdp();
|
|
38
46
|
await this.startTcp();
|
|
47
|
+
if (this.config.tls) {
|
|
48
|
+
await this.startDoT();
|
|
49
|
+
await this.startDoH();
|
|
50
|
+
}
|
|
39
51
|
}
|
|
40
52
|
async stop() {
|
|
41
53
|
if (this.udpServer) {
|
|
@@ -44,12 +56,32 @@ export class DnsHandler {
|
|
|
44
56
|
});
|
|
45
57
|
this.udpServer = null;
|
|
46
58
|
}
|
|
59
|
+
for (const pending of this.pendingUpstreamQueries.values()) {
|
|
60
|
+
clearTimeout(pending.timeoutId);
|
|
61
|
+
}
|
|
62
|
+
this.pendingUpstreamQueries.clear();
|
|
47
63
|
if (this.tcpServer) {
|
|
48
64
|
await new Promise((resolve) => {
|
|
49
65
|
this.tcpServer?.close(() => resolve());
|
|
50
66
|
});
|
|
51
67
|
this.tcpServer = null;
|
|
52
68
|
}
|
|
69
|
+
if (this.dotServer) {
|
|
70
|
+
await new Promise((resolve) => {
|
|
71
|
+
this.dotServer?.close(() => resolve());
|
|
72
|
+
});
|
|
73
|
+
this.dotServer = null;
|
|
74
|
+
}
|
|
75
|
+
if (this.dohServer) {
|
|
76
|
+
if ('closeIdleConnections' in this.dohServer) {
|
|
77
|
+
this.dohServer.closeIdleConnections();
|
|
78
|
+
}
|
|
79
|
+
await new Promise((resolve) => {
|
|
80
|
+
this.dohServer?.close(() => resolve());
|
|
81
|
+
});
|
|
82
|
+
this.dohServer = null;
|
|
83
|
+
}
|
|
84
|
+
this.activeTcpConnections.clear();
|
|
53
85
|
}
|
|
54
86
|
startUdp() {
|
|
55
87
|
return new Promise((resolve, reject) => {
|
|
@@ -88,13 +120,6 @@ export class DnsHandler {
|
|
|
88
120
|
});
|
|
89
121
|
this.handleTcpConnection(socket);
|
|
90
122
|
});
|
|
91
|
-
tcpServer.on("close", () => {
|
|
92
|
-
for (const [socket] of this.activeTcpConnections) {
|
|
93
|
-
if (!socket.destroyed)
|
|
94
|
-
socket.destroy();
|
|
95
|
-
}
|
|
96
|
-
this.activeTcpConnections.clear();
|
|
97
|
-
});
|
|
98
123
|
tcpServer.on("listening", () => {
|
|
99
124
|
this.tcpServer = tcpServer;
|
|
100
125
|
resolve();
|
|
@@ -102,6 +127,63 @@ export class DnsHandler {
|
|
|
102
127
|
tcpServer.listen(this.port, "0.0.0.0");
|
|
103
128
|
});
|
|
104
129
|
}
|
|
130
|
+
startDoT() {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
if (!this.config.tls) {
|
|
133
|
+
resolve();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const dotServer = tls.createServer({
|
|
137
|
+
cert: this.config.tls.cert,
|
|
138
|
+
key: this.config.tls.key,
|
|
139
|
+
minVersion: "TLSv1.2"
|
|
140
|
+
}, (socket) => {
|
|
141
|
+
if (this.activeTcpConnections.size >= this.maxTcpConnections) {
|
|
142
|
+
socket.destroy();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.activeTcpConnections.set(socket, { timeoutId: undefined });
|
|
146
|
+
socket.setTimeout(this.tcpIdleTimeoutMs);
|
|
147
|
+
socket.on("timeout", () => {
|
|
148
|
+
this.removeTcpConnection(socket);
|
|
149
|
+
socket.destroy();
|
|
150
|
+
});
|
|
151
|
+
this.handleTcpConnection(socket);
|
|
152
|
+
});
|
|
153
|
+
dotServer.on("listening", () => {
|
|
154
|
+
this.dotServer = dotServer;
|
|
155
|
+
audit.system(`DoT (DNS over TLS) isolated boundary listening on port ${this.config.dotPort || 853}`);
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
dotServer.listen(this.config.dotPort || 853, "0.0.0.0");
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
startDoH() {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
if (!this.config.tls) {
|
|
164
|
+
resolve();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const dohServer = https.createServer({
|
|
168
|
+
cert: this.config.tls.cert,
|
|
169
|
+
key: this.config.tls.key,
|
|
170
|
+
minVersion: "TLSv1.2"
|
|
171
|
+
}, (req, res) => {
|
|
172
|
+
this.handleDohRequest(req, res).catch(() => {
|
|
173
|
+
if (!res.headersSent) {
|
|
174
|
+
res.writeHead(500);
|
|
175
|
+
res.end();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
dohServer.on("listening", () => {
|
|
180
|
+
this.dohServer = dohServer;
|
|
181
|
+
audit.system(`DoH (DNS over HTTPS) isolated boundary listening on port ${this.config.dohPort || 8443}`);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
dohServer.listen(this.config.dohPort || 8443, "0.0.0.0");
|
|
185
|
+
});
|
|
186
|
+
}
|
|
105
187
|
isRateLimited(ip) {
|
|
106
188
|
if (!this.rateLimiter)
|
|
107
189
|
return false;
|
|
@@ -133,24 +215,53 @@ export class DnsHandler {
|
|
|
133
215
|
catch { }
|
|
134
216
|
return ips;
|
|
135
217
|
}
|
|
136
|
-
|
|
137
|
-
if (!
|
|
138
|
-
return;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
218
|
+
isPrivateIp(ip) {
|
|
219
|
+
if (!net.isIPv4(ip) && !net.isIPv6(ip))
|
|
220
|
+
return false;
|
|
221
|
+
if (net.isIPv6(ip)) {
|
|
222
|
+
const normalized = ip.toLowerCase();
|
|
223
|
+
return normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc00:") || normalized.startsWith("fd");
|
|
224
|
+
}
|
|
225
|
+
const parts = ip.split('.').map(Number);
|
|
226
|
+
if (parts[0] === 10)
|
|
227
|
+
return true;
|
|
228
|
+
if (parts[0] === 127)
|
|
229
|
+
return true;
|
|
230
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
231
|
+
return true;
|
|
232
|
+
if (parts[0] === 192 && parts[1] === 168)
|
|
233
|
+
return true;
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
resolveQueryAsync(query, clientIp) {
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
const response = this.server.resolve(query, clientIp);
|
|
239
|
+
if (response) {
|
|
240
|
+
if (response.length > 0)
|
|
241
|
+
resolve(response);
|
|
242
|
+
else
|
|
243
|
+
resolve(this.server.generateErrorResponse(query, DNS_RCODE.SERVFAIL));
|
|
244
|
+
return;
|
|
147
245
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
246
|
+
if (!this.fallbackDns) {
|
|
247
|
+
resolve(this.server.generateErrorResponse(query, DNS_RCODE.NXDOMAIN));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const fwdSocket = dgram.createSocket("udp4");
|
|
251
|
+
const timeoutId = setTimeout(() => {
|
|
252
|
+
try {
|
|
253
|
+
fwdSocket.close();
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
resolve(this.server.generateErrorResponse(query, DNS_RCODE.SERVFAIL));
|
|
257
|
+
}, 3000);
|
|
258
|
+
fwdSocket.on("message", (msg) => {
|
|
259
|
+
clearTimeout(timeoutId);
|
|
260
|
+
try {
|
|
261
|
+
fwdSocket.close();
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
const ips = this.parseResolvedIpv4s(msg);
|
|
154
265
|
let blockedIp = null;
|
|
155
266
|
for (const ip of ips) {
|
|
156
267
|
if (firewallEngine.evaluateIp(ip, this.config.firewall) === "DENY") {
|
|
@@ -158,26 +269,178 @@ export class DnsHandler {
|
|
|
158
269
|
break;
|
|
159
270
|
}
|
|
160
271
|
}
|
|
272
|
+
const questions = extractQuestions(query);
|
|
161
273
|
if (blockedIp) {
|
|
162
|
-
|
|
163
|
-
|
|
274
|
+
audit.firewall(clientIp, blockedIp, "DENY", "Upstream target IP blocked");
|
|
275
|
+
audit.dns(clientIp, questions, DNS_RCODE.REFUSED, false);
|
|
276
|
+
resolve(this.server.generateErrorResponse(query, DNS_RCODE.REFUSED));
|
|
164
277
|
}
|
|
165
278
|
else {
|
|
166
|
-
|
|
279
|
+
const rcode = msg.length >= 4 ? msg.readUInt16BE(2) & 0xf : 0;
|
|
280
|
+
audit.dns(clientIp, questions, rcode, false);
|
|
281
|
+
resolve(msg);
|
|
167
282
|
}
|
|
283
|
+
});
|
|
284
|
+
fwdSocket.on("error", () => {
|
|
285
|
+
clearTimeout(timeoutId);
|
|
286
|
+
try {
|
|
287
|
+
fwdSocket.close();
|
|
288
|
+
}
|
|
289
|
+
catch { }
|
|
290
|
+
resolve(this.server.generateErrorResponse(query, DNS_RCODE.SERVFAIL));
|
|
291
|
+
});
|
|
292
|
+
fwdSocket.send(query, 0, query.length, 53, this.fallbackDns);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async handleDohRequest(req, res) {
|
|
296
|
+
const clientIp = req.socket.remoteAddress || "unknown";
|
|
297
|
+
if (this.isRateLimited(clientIp)) {
|
|
298
|
+
res.writeHead(429);
|
|
299
|
+
res.end();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const method = req.method || "GET";
|
|
303
|
+
const url = new URL(req.url || "/", `https://${req.headers.host || "localhost"}`);
|
|
304
|
+
if (url.pathname !== "/dns-query") {
|
|
305
|
+
res.writeHead(404);
|
|
306
|
+
res.end();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
let query = null;
|
|
310
|
+
if (method === "GET") {
|
|
311
|
+
const dnsParam = url.searchParams.get("dns");
|
|
312
|
+
if (!dnsParam) {
|
|
313
|
+
res.writeHead(400);
|
|
314
|
+
res.end();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
query = Buffer.from(dnsParam, "base64url");
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
res.writeHead(400);
|
|
322
|
+
res.end();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (method === "POST") {
|
|
327
|
+
if (req.headers["content-type"] !== "application/dns-message") {
|
|
328
|
+
res.writeHead(415);
|
|
329
|
+
res.end();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const chunks = [];
|
|
333
|
+
let total = 0;
|
|
334
|
+
for await (const chunk of req) {
|
|
335
|
+
total += chunk.length;
|
|
336
|
+
if (total > MAX_TCP_BUFFER_SIZE) {
|
|
337
|
+
res.writeHead(413);
|
|
338
|
+
res.end();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
chunks.push(chunk);
|
|
342
|
+
}
|
|
343
|
+
query = Buffer.concat(chunks);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
res.writeHead(405);
|
|
347
|
+
res.end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!query || query.length < 12) {
|
|
351
|
+
res.writeHead(400);
|
|
352
|
+
res.end();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const response = await this.resolveQueryAsync(query, clientIp);
|
|
357
|
+
res.writeHead(200, {
|
|
358
|
+
"Content-Type": "application/dns-message",
|
|
359
|
+
"Content-Length": response.length,
|
|
360
|
+
"Cache-Control": "max-age=0"
|
|
361
|
+
});
|
|
362
|
+
res.end(response);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
res.writeHead(502);
|
|
366
|
+
res.end();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
forwardUdpQuery(data, clientInfo) {
|
|
370
|
+
if (!this.fallbackDns)
|
|
371
|
+
return;
|
|
372
|
+
if (!this.isPrivateIp(clientInfo.address)) {
|
|
373
|
+
if (this.config.firewall && (!this.config.firewall.allowlist_ips || !this.config.firewall.allowlist_ips.includes(clientInfo.address))) {
|
|
374
|
+
audit.system(`Dropped UDP forward request from untrusted WAN IP: ${clientInfo.address} (Anti-Amplification)`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (this.pendingUpstreamQueries.size >= this.maxConcurrentUdpForwards) {
|
|
379
|
+
audit.system("Dropped UDP forward request: Concurrent connection limit reached");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (data.length < 2)
|
|
383
|
+
return;
|
|
384
|
+
const originalId = data.readUInt16BE(0);
|
|
385
|
+
const ephemeralId = randomBytes(2).readUInt16BE(0);
|
|
386
|
+
const trackingId = randomBytes(4).readUInt32BE(0);
|
|
387
|
+
const fwdSocket = dgram.createSocket("udp4");
|
|
388
|
+
const timeoutId = setTimeout(() => {
|
|
389
|
+
this.pendingUpstreamQueries.delete(trackingId);
|
|
390
|
+
try {
|
|
168
391
|
fwdSocket.close();
|
|
169
392
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
393
|
+
catch { }
|
|
394
|
+
const ref = this.server.generateErrorResponse(data, DNS_RCODE.SERVFAIL);
|
|
395
|
+
this.udpServer?.send(ref, 0, ref.length, clientInfo.port, clientInfo.address);
|
|
396
|
+
}, 3000);
|
|
397
|
+
this.pendingUpstreamQueries.set(trackingId, { rinfo: clientInfo, originalId, timeoutId });
|
|
398
|
+
fwdSocket.on("message", (msg) => {
|
|
399
|
+
this.pendingUpstreamQueries.delete(trackingId);
|
|
400
|
+
clearTimeout(timeoutId);
|
|
401
|
+
try {
|
|
175
402
|
fwdSocket.close();
|
|
176
|
-
|
|
403
|
+
}
|
|
404
|
+
catch { }
|
|
405
|
+
if (msg.length < 2)
|
|
406
|
+
return;
|
|
407
|
+
const responseId = msg.readUInt16BE(0);
|
|
408
|
+
if (responseId !== ephemeralId)
|
|
409
|
+
return;
|
|
410
|
+
const restoredMsg = Buffer.from(msg);
|
|
411
|
+
restoredMsg.writeUInt16BE(originalId, 0);
|
|
412
|
+
const ips = this.parseResolvedIpv4s(restoredMsg);
|
|
413
|
+
let blockedIp = null;
|
|
414
|
+
for (const ip of ips) {
|
|
415
|
+
if (firewallEngine.evaluateIp(ip, this.config.firewall) === "DENY") {
|
|
416
|
+
blockedIp = ip;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const questions = extractQuestions(restoredMsg);
|
|
421
|
+
if (blockedIp) {
|
|
422
|
+
audit.firewall(clientInfo.address, blockedIp, "DENY", "Upstream target IP blocked");
|
|
423
|
+
audit.dns(clientInfo.address, questions, DNS_RCODE.REFUSED, false);
|
|
424
|
+
const ref = this.server.generateErrorResponse(restoredMsg, DNS_RCODE.REFUSED);
|
|
177
425
|
this.udpServer?.send(ref, 0, ref.length, clientInfo.port, clientInfo.address);
|
|
178
426
|
}
|
|
427
|
+
else {
|
|
428
|
+
const rcode = restoredMsg.length >= 4 ? restoredMsg.readUInt16BE(2) & 0xf : 0;
|
|
429
|
+
audit.dns(clientInfo.address, questions, rcode, false);
|
|
430
|
+
this.udpServer?.send(restoredMsg, 0, restoredMsg.length, clientInfo.port, clientInfo.address);
|
|
431
|
+
}
|
|
179
432
|
});
|
|
180
|
-
fwdSocket.
|
|
433
|
+
fwdSocket.on("error", () => {
|
|
434
|
+
this.pendingUpstreamQueries.delete(trackingId);
|
|
435
|
+
clearTimeout(timeoutId);
|
|
436
|
+
try {
|
|
437
|
+
fwdSocket.close();
|
|
438
|
+
}
|
|
439
|
+
catch { }
|
|
440
|
+
});
|
|
441
|
+
const queryToForward = Buffer.from(data);
|
|
442
|
+
queryToForward.writeUInt16BE(ephemeralId, 0);
|
|
443
|
+
fwdSocket.send(queryToForward, 0, queryToForward.length, 53, this.fallbackDns);
|
|
181
444
|
}
|
|
182
445
|
handleUdpMessage(data, rinfo) {
|
|
183
446
|
if (this.isRateLimited(rinfo.address)) {
|
|
@@ -218,6 +481,10 @@ export class DnsHandler {
|
|
|
218
481
|
query.copy(prefixed, 2);
|
|
219
482
|
fwd.write(prefixed);
|
|
220
483
|
});
|
|
484
|
+
fwd.setTimeout(this.tcpIdleTimeoutMs);
|
|
485
|
+
fwd.on("timeout", () => {
|
|
486
|
+
fwd.destroy();
|
|
487
|
+
});
|
|
221
488
|
fwd.on("data", (data) => {
|
|
222
489
|
if (data.length < 2)
|
|
223
490
|
return;
|
|
@@ -230,7 +497,10 @@ export class DnsHandler {
|
|
|
230
497
|
break;
|
|
231
498
|
}
|
|
232
499
|
}
|
|
500
|
+
const questions = extractQuestions(query);
|
|
233
501
|
if (blockedIp) {
|
|
502
|
+
audit.firewall(peerAddr, blockedIp, "DENY", "Upstream target IP blocked");
|
|
503
|
+
audit.dns(peerAddr, questions, DNS_RCODE.REFUSED, false);
|
|
234
504
|
if (!clientSocket.destroyed) {
|
|
235
505
|
const ref = this.server.generateErrorResponse(query, DNS_RCODE.REFUSED);
|
|
236
506
|
const p = Buffer.alloc(2 + ref.length);
|
|
@@ -241,6 +511,8 @@ export class DnsHandler {
|
|
|
241
511
|
}
|
|
242
512
|
}
|
|
243
513
|
else {
|
|
514
|
+
const rcode = resp.length >= 4 ? resp.readUInt16BE(2) & 0xf : 0;
|
|
515
|
+
audit.dns(peerAddr, questions, rcode, false);
|
|
244
516
|
if (!clientSocket.destroyed)
|
|
245
517
|
clientSocket.write(data);
|
|
246
518
|
}
|
package/dist/dns-service.d.ts
CHANGED
|
@@ -23,7 +23,6 @@ export declare function extractQuestions(query: Buffer): ParsedQuestion[];
|
|
|
23
23
|
export declare class DevDnsServer {
|
|
24
24
|
private config;
|
|
25
25
|
private cacheMap;
|
|
26
|
-
private cacheOrder;
|
|
27
26
|
private dnsCacheMaxSize;
|
|
28
27
|
private dnsCacheTtlMs;
|
|
29
28
|
constructor(config: ServerConfig);
|