From 9c344ad3710cebacec1de57de1a238e8deb06ece Mon Sep 17 00:00:00 2001 From: ekrekeler Date: Fri, 28 Feb 2025 02:54:56 -0600 Subject: [PATCH] Replace dns2 module with dns-packet --- package.json | 3 +- server/monitor-types/dns.js | 61 ++++---------- server/util-server.js | 160 ++++++++++++++++++++++++++++++------ src/lang/en.json | 2 +- src/pages/EditMonitor.vue | 4 +- 5 files changed, 158 insertions(+), 72 deletions(-) diff --git a/package.json b/package.json index aa5dc9959..1184ced84 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", - "dns2": "song940/node-dns", + "dns-packet": "~5.6.1", "dotenv": "~16.0.3", "express": "~4.21.0", "express-basic-auth": "~1.2.1", @@ -170,6 +170,7 @@ "cronstrue": "~2.24.0", "cross-env": "~7.0.3", "delay": "^5.0.0", + "dns2": "~2.0.1", "dompurify": "~3.1.7", "eslint": "~8.14.0", "eslint-plugin-jsdoc": "~46.4.6", diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index cf6f12410..e6ab61c2b 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -25,77 +25,49 @@ class DnsMonitorType extends MonitorType { let dnsMessage = ""; let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type, monitor.dns_transport, monitor.doh_query_path); + const records = dnsRes.answers.map(record => { + return Buffer.isBuffer(record.data) ? record.data.toString() : record.data; + }); heartbeat.ping = dayjs().valueOf() - startTime; const conditions = ConditionExpressionGroup.fromMonitor(monitor); let conditionsResult = true; const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; - let records = []; switch (monitor.dns_resolve_type) { case "A": case "AAAA": - records = dnsRes.answers.map(record => { - switch (record.type) { - case 1: // A - case 28: // AAAA - return record.address; - case 5: // CNAME - return record.domain; - } - }); - dnsMessage = `Records: ${records.join(" | ")}`; - conditionsResult = records.some(record => handleConditions({ record })); - break; - - case "PTR": - records = dnsRes.answers.map(record => record.domain); - dnsMessage = `Records: ${records.join(" | ")}`; - conditionsResult = records.some(record => handleConditions({ record })); - break; - case "TXT": - records = dnsRes.answers.map(record => record.data); - dnsMessage = `Records: ${records.join(" | ")}`; + case "PTR": + case "NS": + dnsMessage = records.join(" | "); conditionsResult = records.some(record => handleConditions({ record })); break; case "CNAME": - records.push(dnsRes.answers[0].domain); dnsMessage = records[0]; conditionsResult = handleConditions({ record: records[0] }); break; case "CAA": - // dns2 library currently has not implemented decoding CAA response - //records.push(dnsRes.answers[0].issue); - records.push("CAA issue placeholder"); - dnsMessage = records[0]; + dnsMessage = records.map(record => `${record.flags} ${record.tag} "${record.value}"`).join(" | "); conditionsResult = handleConditions({ record: records[0] }); break; case "MX": - records = dnsRes.answers.map(record => record.exchange); - dnsMessage = dnsRes.answers.map(record => `Hostname: ${record.exchange} ; Priority: ${record.priority}`).join(" | "); - conditionsResult = records.some(record => handleConditions({ record })); - break; - - case "NS": - records = dnsRes.answers.map(record => record.ns); - dnsMessage = `Servers: ${records.join(" | ")}`; + dnsMessage = records.map(record => `Hostname: ${record.exchange}; Priority: ${record.priority}`).join(" | "); conditionsResult = records.some(record => handleConditions({ record })); break; case "SOA": { - records.push(dnsRes.answers[0].primary); dnsMessage = Object.entries({ - "Primary-NS": dnsRes.answers[0].primary, - "Hostmaster": dnsRes.answers[0].admin, - "Serial": dnsRes.answers[0].serial, - "Refresh": dnsRes.answers[0].refresh, - "Retry": dnsRes.answers[0].retry, - "Expire": dnsRes.answers[0].expiration, - "MinTTL": dnsRes.answers[0].minimum, + "Primary-NS": records[0].mname, + "Hostmaster": records[0].rname, + "Serial": records[0].serial, + "Refresh": records[0].refresh, + "Retry": records[0].retry, + "Expire": records[0].expire, + "MinTTL": records[0].minimum, }).map(([ name, value ]) => { return `${name}: ${value}`; }).join("; "); @@ -104,8 +76,7 @@ class DnsMonitorType extends MonitorType { } case "SRV": - records = dnsRes.answers.map(record => record.target); - dnsMessage = dnsRes.answers.map((record) => { + dnsMessage = records.map((record) => { return Object.entries({ "Target": record.target, "Port": record.port, diff --git a/server/util-server.js b/server/util-server.js index 5abfd5e9e..84dcd7bb5 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -3,8 +3,9 @@ const ping = require("@louislam/ping"); const { R } = require("redbean-node"); const { log, genSecret, badgeConstants } = require("../src/util"); const passwordHash = require("./password-hash"); -const { UDPClient, TCPClient, DOHClient } = require("dns2"); -const { isIP, isIPv4, isIPv6 } = require("node:net"); +const dnsPacket = require("dns-packet"); +const dgram = require("dgram"); +const { Socket, isIP, isIPv4, isIPv6 } = require("net"); const { Address6 } = require("ip-address"); const iconv = require("iconv-lite"); const chardet = require("chardet"); @@ -21,6 +22,8 @@ const radiusClient = require("node-radius-client"); const redis = require("redis"); const oidc = require("openid-client"); const tls = require("tls"); +const https = require("https"); +const url = require("url"); const { dictionaries: { @@ -314,38 +317,149 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, t } } + // This is the DNS request data that will get encoded later + const requestData = { + type: "query", + id: Math.floor(Math.random() * 65534) + 1, + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: rrtype, + name: hostname, + }], + }; + + let client; + let resolver = null; // Transport method determines which client type to use - let resolver; + const isSecure = [ "DOH", "DOT" ].includes(transport.toUpperCase()); switch (transport.toUpperCase()) { + case "TCP": - resolver = TCPClient({ - dns: resolverServer, - protocol: "tcp:", - port: resolverPort, + case "DOT": { + const buf = dnsPacket.streamEncode(requestData); + if (isSecure) { + const options = { + port: resolverPort, + host: resolverServer, + // TODO: Option for relaxing certificate validation + secureContext: tls.createSecureContext({ + secureProtocol: "TLSv1_2_method", + }), + }; + // TODO: Error handling for untrusted or expired cert + client = tls.connect(options, () => { + log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); + client.write(buf); + }); + } else { + client = new Socket(); + client.connect(resolverPort, resolverServer, () => { + log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); + client.write(buf); + }); + } + client.on("close", () => { + log.debug("dns", "Connection closed"); + }); + resolver = new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + let expectedLength = 0; + client.on("data", (chunk) => { + if (data.length === 0) { + if (chunk.byteLength > 1) { + const plen = chunk.readUInt16BE(0); + expectedLength = plen; + if (plen < 12) { + reject("Response received is below DNS minimum packet length"); + } + } + } + data = Buffer.concat([ data, chunk ]); + if (data.byteLength >= expectedLength) { + client.destroy(); + const response = dnsPacket.streamDecode(data); + log.debug("dns", "Response decoded"); + resolve(response); + } + }); }); break; - case "DOT": - resolver = TCPClient({ - dns: resolverServer, - protocol: "tls:", - port: resolverPort, - }); - break; - case "DOH": + } + + case "DOH": { + // Set query ID to "0" for HTTP cache friendlyness. See + // https://github.com/mafintosh/dns-packet/issues/77 + requestData.id = 0; + const buf = dnsPacket.encode(requestData); + // TODO: implement POST requests for wireformat and JSON dohQuery = dohQuery || "dns-query?dns={query}"; - resolver = DOHClient({ - dns: `https://${resolverServer}:${resolverPort}/${dohQuery}`, + dohQuery = dohQuery.replace("{query}", buf.toString("base64url")); + const requestURL = url.parse(`https://${resolverServer}:${resolverPort}/${dohQuery}`, true); + const options = { + hostname: requestURL.hostname, + port: requestURL.port, + path: requestURL.path, + method: "GET", + headers: { + "Content-Type": "application/dns-message", + }, + // TODO: Option for relaxing certificate validation + }; + resolver = new Promise((resolve, reject) => { + client = https.request(options, (response) => { + let data = Buffer.alloc(0); + response.on("data", (chunk) => { + data = Buffer.concat([ data, chunk ]); + }); + response.on("end", () => { + const response = dnsPacket.decode(data); + log.debug("dns", "Response decoded"); + resolve(response); + }); + }); + client.on("socket", (socket) => { + socket.on("secureConnect", () => { + log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); + }); + }); + client.on("error", (err) => { + reject(err); + }); + client.on("close", () => { + log.debug("dns", "Connection closed"); + }); + client.write(buf); + client.end(); }); break; - default: - resolver = UDPClient({ - dns: resolverServer, - port: resolverPort, - socketType: "udp" + String(addressFamily), + } + + //case "UDP": + default: { + const buf = dnsPacket.encode(requestData); + client = dgram.createSocket("udp" + String(addressFamily)); + client.on("connect", () => { + log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); }); + client.on("close", () => { + log.debug("dns", "Connection closed"); + }); + resolver = new Promise((resolve, reject) => { + client.on("message", (rdata, rinfo) => { + client.close(); + const response = dnsPacket.decode(rdata); + log.debug("dns", "Response decoded"); + resolve(response); + }); + client.on("error", (err) => { + reject(err); + }); + }); + client.send(buf, 0, buf.length, resolverPort, resolverServer); + } } - return resolver(hostname, rrtype); + return resolver; }; /** diff --git a/src/lang/en.json b/src/lang/en.json index eb39081df..8f7e75bb3 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -572,7 +572,7 @@ "deleteMaintenanceMsg": "Are you sure want to delete this maintenance?", "deleteNotificationMsg": "Are you sure want to delete this notification for all monitors?", "dnsPortDescription": "DNS server port. Defaults to 53. Alternative ports are 443 for DoH and 853 for DoT.", - "resolverserverDescription": "Cloudflare is the default server. You can change the resolver server anytime.", + "resolverserverDescription": "Cloudflare is the default server. Use IP address for UDP/TCP/DoT, and domain name for DoH/DoT.", "rrtypeDescription": "Select the RR type you want to monitor", "dnsTransportDescription": "Select the transport method for querying the DNS server.", "dohQueryPathDescription": "Set the query path to use for DNS wireformat. Must contain", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 7a9a170f6..dca8bcc2a 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -1225,10 +1225,10 @@ export default { case "UDP": case "TCP": return this.ipRegexPattern.source; - case "DoH": - case "DoT": return this.hostnameRegexPattern.source; + case "DoT": + return this.ipOrHostnameRegexPattern.source; } } return null;