diff --git a/db/knex_init_db.js b/db/knex_init_db.js index 46bff4bfa..b3a802b39 100644 --- a/db/knex_init_db.js +++ b/db/knex_init_db.js @@ -86,6 +86,8 @@ async function createTables() { table.text("accepted_statuscodes_json").notNullable().defaultTo("[\"200-299\"]"); table.string("dns_resolve_type", 5); table.string("dns_resolve_server", 255); + table.string("dns_transport", 3); + table.string("doh_query_path", 255); table.string("dns_last_result", 255); table.integer("retry_interval").notNullable().defaultTo(0); table.string("push_token", 20).defaultTo(null); diff --git a/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js b/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js new file mode 100644 index 000000000..0dfd7c07f --- /dev/null +++ b/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js @@ -0,0 +1,14 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.string("dns_transport"); + table.string("doh_query_path"); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("dns_transport"); + table.dropColumn("doh_query_path"); + }); +}; diff --git a/package.json b/package.json index ef6456b4a..aa5dc9959 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", + "dns2": "song940/node-dns", "dotenv": "~16.0.3", "express": "~4.21.0", "express-basic-auth": "~1.2.1", @@ -98,6 +99,7 @@ "http-proxy-agent": "~7.0.2", "https-proxy-agent": "~7.0.6", "iconv-lite": "~0.6.3", + "ip-address": "~10.0.1", "isomorphic-ws": "^5.0.0", "jsesc": "~3.0.2", "jsonata": "^2.0.3", @@ -168,7 +170,6 @@ "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/model/monitor.js b/server/model/monitor.js index 3ad8cfafc..025fa23e8 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -118,6 +118,8 @@ class Monitor extends BeanModel { accepted_statuscodes: this.getAcceptedStatuscodes(), dns_resolve_type: this.dns_resolve_type, dns_resolve_server: this.dns_resolve_server, + dns_transport: this.dns_transport, + doh_query_path: this.doh_query_path, dns_last_result: this.dns_last_result, docker_container: this.docker_container, docker_host: this.docker_host, diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index 8b87932fe..f5b4a7d47 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -24,50 +24,90 @@ class DnsMonitorType extends MonitorType { let startTime = dayjs().valueOf(); let dnsMessage = ""; - let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); + let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type, monitor.dns_transport, monitor.doh_query_path); 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": - case "TXT": + records = dnsRes.answers.map(record => record.address); + dnsMessage = `Records: ${records.join(" | ")}`; + conditionsResult = records.some(record => handleConditions({ record })); + break; + case "PTR": - dnsMessage = `Records: ${dnsRes.join(" | ")}`; - conditionsResult = dnsRes.some(record => handleConditions({ record })); + 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(" | ")}`; + conditionsResult = records.some(record => handleConditions({ record })); break; case "CNAME": - dnsMessage = dnsRes[0]; - conditionsResult = handleConditions({ record: dnsRes[0] }); + records.push(dnsRes.answers[0].domain); + dnsMessage = records[0]; + conditionsResult = handleConditions({ record: records[0] }); break; case "CAA": - dnsMessage = dnsRes[0].issue; - conditionsResult = handleConditions({ record: dnsRes[0].issue }); + // dns2 library currently has not implemented decoding CAA response + //records.push(dnsRes.answers[0].issue); + records.push("CAA issue placeholder"); + dnsMessage = records[0]; + conditionsResult = handleConditions({ record: records[0] }); break; case "MX": - dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); - conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); + 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": - dnsMessage = `Servers: ${dnsRes.join(" | ")}`; - conditionsResult = dnsRes.some(record => handleConditions({ record })); + records = dnsRes.answers.map(record => record.ns); + dnsMessage = `Servers: ${records.join(" | ")}`; + conditionsResult = records.some(record => handleConditions({ record })); break; - case "SOA": - dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; - conditionsResult = handleConditions({ record: dnsRes.nsname }); + 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, + }).map(([name, value]) => { + return `${name}: ${value}`; + }).join("; "); + conditionsResult = handleConditions({ record: records[0] }); break; + } case "SRV": - dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | "); - conditionsResult = dnsRes.some(record => handleConditions({ record: record.name })); + records = dnsRes.answers.map(record => record.target); + dnsMessage = dnsRes.answers.map((record) => { + return Object.entries({ + "Target": record.target, + "Port": record.port, + "Priority": record.priority, + "Weight": record.weight, + }).map(([name, value]) => { + return `${name}: ${value}`; + }).join("; "); + }).join(" | "); + conditionsResult = records.some(record => handleConditions({ record })); break; } diff --git a/server/server.js b/server/server.js index ec5ad49f6..d3d925d6f 100644 --- a/server/server.js +++ b/server/server.js @@ -826,6 +826,8 @@ let needSetup = false; bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_server = monitor.dns_resolve_server; + bean.dns_transport = monitor.dns_transport; + bean.doh_query_path = monitor.doh_query_path; bean.pushToken = monitor.pushToken; bean.docker_container = monitor.docker_container; bean.docker_host = monitor.docker_host; diff --git a/server/util-server.js b/server/util-server.js index 5ebc62ac5..a602f3ad9 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -3,7 +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 { Resolver } = require("dns"); +const { UDPClient, TCPClient, DOHClient } = require("dns2"); +const { isIP, isIPv4, isIPv6 } = require("node:net"); +const { Address6 } = require("ip-address"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); @@ -286,33 +288,63 @@ exports.httpNtlm = function (options, ntlmOptions) { * @param {string} resolverServer The DNS server to use * @param {string} resolverPort Port the DNS server is listening on * @param {string} rrtype The type of record to request + * @param {string} transport The transport method to use + * @param {string} dohQuery The query path used only for DoH * @returns {Promise<(string[] | object[] | object)>} DNS response */ -exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { - const resolver = new Resolver(); - // Remove brackets from IPv6 addresses so we can re-add them to - // prevent issues with ::1:5300 (::1 port 5300) +exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, transport, dohQuery) { + // Parse IPv4 and IPv6 addresses to determine address family and + // add square brackets to IPv6 addresses, following RFC 3986 syntax resolverServer = resolverServer.replace("[", "").replace("]", ""); - resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]); - return new Promise((resolve, reject) => { - if (rrtype === "PTR") { - resolver.reverse(hostname, (err, records) => { - if (err) { - reject(err); - } else { - resolve(records); - } - }); - } else { - resolver.resolve(hostname, rrtype, (err, records) => { - if (err) { - reject(err); - } else { - resolve(records); - } - }); + const addressFamily = isIP(resolverServer); + if (addressFamily === 6) { + resolverServer = `[${resolverServer}]`; + } + + // If performing reverse (PTR) record lookup, ensure hostname + // syntax follows RFC 1034 / RFC 3596 + if (rrtype === "PTR") { + if (isIPv4(hostname)) { + let octets = hostname.split("."); + octets.reverse(); + hostname = octets.join(".") + ".in-addr.arpa"; + } else if (isIPv6(hostname)) { + let address = new Address6(hostname); + hostname = address.reverseForm(); } - }); + } + + // Transport method determines which client type to use + let resolver; + switch (transport.toUpperCase()) { + case "TCP": + resolver = TCPClient({ + dns: resolverServer, + protocol: "tcp:", + port: resolverPort, + }); + break; + case "DOT": + resolver = TCPClient({ + dns: resolverServer, + protocol: "tls:", + port: resolverPort, + }); + break; + case "DOH": + resolver = DOHClient({ + dns: `https://${resolverServer}:${resolverPort}/${dohQuery}`, + }); + break; + default: + resolver = UDPClient({ + dns: resolverServer, + port: resolverPort, + socketType: "udp" + String(addressFamily), + }); + } + + return resolver(hostname, rrtype); }; /** diff --git a/src/lang/en.json b/src/lang/en.json index e215f1031..9a3a5465d 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -571,9 +571,11 @@ "deleteMonitorMsg": "Are you sure want to delete this monitor?", "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. You can change the port at any time.", + "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.", "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 \"{query}\".", "pauseMonitorMsg": "Are you sure want to pause?", "enableDefaultNotificationDescription": "This notification will be enabled by default for new monitors. You can still disable the notification separately for each monitor.", "clearEventsMsg": "Are you sure want to delete all events for this monitor?", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index a83f91cab..1c87d12bd 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -367,12 +367,42 @@