From 3ed4a2a2cb311e25a9b589f4392839e1b9bf8548 Mon Sep 17 00:00:00 2001 From: ekrekeler Date: Mon, 9 Jun 2025 00:13:24 -0500 Subject: [PATCH] DNSSEC support and fixed multiple conditions for DNS monitor --- db/knex_init_db.js | 1 + .../2025-22-02-0000-dns-trasnsport.js | 4 +- package-lock.json | 43 +++++- server/model/monitor.js | 1 + server/monitor-conditions/operators.js | 6 + server/monitor-types/dns.js | 42 ++++-- server/server.js | 1 + server/util-server.js | 129 ++++++++++++++---- src/lang/en.json | 5 + src/pages/EditMonitor.vue | 64 +++++++-- 10 files changed, 241 insertions(+), 55 deletions(-) diff --git a/db/knex_init_db.js b/db/knex_init_db.js index b3a802b39..95f1c304e 100644 --- a/db/knex_init_db.js +++ b/db/knex_init_db.js @@ -88,6 +88,7 @@ async function createTables() { table.string("dns_resolve_server", 255); table.string("dns_transport", 3); table.string("doh_query_path", 255); + table.boolean("skip_remote_dnssec").defaultTo(false); 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 index 0dfd7c07f..66a51031a 100644 --- a/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js +++ b/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js @@ -1,8 +1,9 @@ exports.up = function (knex) { return knex.schema .alterTable("monitor", function (table) { - table.string("dns_transport"); + table.string("dns_transport").notNullable().defaultTo("UDP"); table.string("doh_query_path"); + table.boolean("skip_remote_dnssec").defaultTo(false); }); }; @@ -10,5 +11,6 @@ exports.down = function (knex) { return knex.schema.alterTable("monitor", function (table) { table.dropColumn("dns_transport"); table.dropColumn("doh_query_path"); + table.dropColumn("skip_remote_dnssec"); }); }; diff --git a/package-lock.json b/package-lock.json index 586b0d6a4..4028961dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", + "dns-packet": "~5.6.1", "dotenv": "~16.0.3", "express": "~4.21.0", "express-basic-auth": "~1.2.1", @@ -40,6 +41,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", @@ -2745,6 +2747,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@louislam/ping": { "version": "0.4.4-mod.1", "resolved": "https://registry.npmjs.org/@louislam/ping/-/ping-0.4.4-mod.1.tgz", @@ -7671,6 +7679,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dns2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/dns2/-/dns2-2.0.5.tgz", @@ -10318,14 +10338,10 @@ } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -15261,6 +15277,19 @@ "node": ">= 14" } }, + "node_modules/socks/node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/sortablejs": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", diff --git a/server/model/monitor.js b/server/model/monitor.js index 025fa23e8..0fdf91322 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -120,6 +120,7 @@ class Monitor extends BeanModel { dns_resolve_server: this.dns_resolve_server, dns_transport: this.dns_transport, doh_query_path: this.doh_query_path, + skip_remote_dnssec: this.skip_remote_dnssec, dns_last_result: this.dns_last_result, docker_container: this.docker_container, docker_host: this.docker_host, diff --git a/server/monitor-conditions/operators.js b/server/monitor-conditions/operators.js index d900dff9d..1beab19b1 100644 --- a/server/monitor-conditions/operators.js +++ b/server/monitor-conditions/operators.js @@ -296,6 +296,11 @@ const defaultNumberOperators = [ operatorMap.get(OP_GTE) ]; +const defaultArrayOperators = [ + operatorMap.get(OP_CONTAINS), + operatorMap.get(OP_NOT_CONTAINS) +]; + module.exports = { OP_STR_EQUALS, OP_STR_NOT_EQUALS, @@ -314,5 +319,6 @@ module.exports = { operatorMap, defaultStringOperators, defaultNumberOperators, + defaultArrayOperators, ConditionOperator, }; diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index e6ab61c2b..2d207558b 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -4,7 +4,7 @@ const dayjs = require("dayjs"); const { dnsResolve } = require("../util-server"); const { R } = require("redbean-node"); const { ConditionVariable } = require("../monitor-conditions/variables"); -const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { defaultStringOperators, defaultNumberOperators, defaultArrayOperators } = require("../monitor-conditions/operators"); const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); @@ -14,7 +14,25 @@ class DnsMonitorType extends MonitorType { supportsConditions = true; conditionVariables = [ - new ConditionVariable("record", defaultStringOperators ), + // A, AAAA, TXT, PTR, NS + new ConditionVariable("records", defaultArrayOperators), + // CNAME + new ConditionVariable("hostname", defaultStringOperators), + // CAA + new ConditionVariable("flags", defaultStringOperators), + new ConditionVariable("tag", defaultStringOperators), + new ConditionVariable("value", defaultStringOperators), + // MX + new ConditionVariable("hostnames", defaultArrayOperators), + // SOA + new ConditionVariable("mname", defaultStringOperators), + new ConditionVariable("rname", defaultStringOperators), + new ConditionVariable("serial", defaultStringOperators), + new ConditionVariable("refresh", defaultNumberOperators), + new ConditionVariable("retry", defaultNumberOperators), + new ConditionVariable("minimum", defaultNumberOperators), + // SRV + new ConditionVariable("targets", defaultArrayOperators), ]; /** @@ -24,7 +42,13 @@ 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, monitor.dns_transport, monitor.doh_query_path); + const requestData = { + name: monitor.hostname, + rrtype: monitor.dns_resolve_type, + dnssec: true, // Request DNSSEC information in the response + dnssecCheckingDisabled: monitor.skip_remote_dnssec, + }; + let dnsRes = await dnsResolve(requestData, monitor.dns_resolve_server, monitor.port, monitor.dns_transport, monitor.doh_query_path); const records = dnsRes.answers.map(record => { return Buffer.isBuffer(record.data) ? record.data.toString() : record.data; }); @@ -41,22 +65,22 @@ class DnsMonitorType extends MonitorType { case "PTR": case "NS": dnsMessage = records.join(" | "); - conditionsResult = records.some(record => handleConditions({ record })); + conditionsResult = handleConditions({ records: records }); break; case "CNAME": dnsMessage = records[0]; - conditionsResult = handleConditions({ record: records[0] }); + conditionsResult = handleConditions({ hostname: records[0].value }); break; case "CAA": dnsMessage = records.map(record => `${record.flags} ${record.tag} "${record.value}"`).join(" | "); - conditionsResult = handleConditions({ record: records[0] }); + conditionsResult = handleConditions(records[0]); break; case "MX": dnsMessage = records.map(record => `Hostname: ${record.exchange}; Priority: ${record.priority}`).join(" | "); - conditionsResult = records.some(record => handleConditions({ record })); + conditionsResult = handleConditions({ hostnames: records.map(record => record.exchange) }); break; case "SOA": { @@ -71,7 +95,7 @@ class DnsMonitorType extends MonitorType { }).map(([ name, value ]) => { return `${name}: ${value}`; }).join("; "); - conditionsResult = handleConditions({ record: records[0] }); + conditionsResult = handleConditions(records[0]); break; } @@ -86,7 +110,7 @@ class DnsMonitorType extends MonitorType { return `${name}: ${value}`; }).join("; "); }).join(" | "); - conditionsResult = records.some(record => handleConditions({ record })); + conditionsResult = handleConditions({ targets: records.map(record => record.target) }); break; } diff --git a/server/server.js b/server/server.js index d3d925d6f..28666889b 100644 --- a/server/server.js +++ b/server/server.js @@ -828,6 +828,7 @@ let needSetup = false; bean.dns_resolve_server = monitor.dns_resolve_server; bean.dns_transport = monitor.dns_transport; bean.doh_query_path = monitor.doh_query_path; + bean.skip_remote_dnssec = monitor.skip_remote_dnssec; 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 84dcd7bb5..490541463 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -4,6 +4,7 @@ const { R } = require("redbean-node"); const { log, genSecret, badgeConstants } = require("../src/util"); const passwordHash = require("./password-hash"); const dnsPacket = require("dns-packet"); +const optioncodes = require("dns-packet/optioncodes.js"); const dgram = require("dgram"); const { Socket, isIP, isIPv4, isIPv6 } = require("net"); const { Address6 } = require("ip-address"); @@ -285,17 +286,99 @@ exports.httpNtlm = function (options, ntlmOptions) { }); }; +/** + * Adapted from https://github.com/hildjj/dohdec/blob/v7.0.2/pkg/dohdec/lib/dnsUtils.js + * Encode a DNS query packet to a buffer. + * @param {object} opts Options for the query. + * @param {string} opts.name The name to look up. + * @param {number} [opts.id=0] ID for the query. SHOULD be 0 for DOH. + * @param {packet.RecordType} [opts.rrtype="A"] The record type to look up. + * @param {boolean} [opts.dnssec=false] Request DNSSec information? + * @param {boolean} [opts.dnssecCheckingDisabled=false] Disable DNSSec + * validation? + * @param {string} [opts.ecsSubnet] Subnet to use for ECS. + * @param {number} [opts.ecs] Number of ECS bits. Defaults to 24 or 56 + * (IPv4/IPv6). + * @param {boolean} [opts.stream=false] Encode for streaming, with the packet + * prefixed by a 2-byte big-endian integer of the number of bytes in the + * packet. + * @returns {Buffer} The encoded packet. + * @throws {TypeError} opts does not contain a name attribute. + */ +exports.makePacket = function (opts) { + const PAD_SIZE = 128; + + if (!opts?.name) { + throw new TypeError("Name is required"); + } + + /** @type {dnsPacket.OptAnswer} */ + const opt = { + name: ".", + type: "OPT", + udpPayloadSize: 4096, + extendedRcode: 0, + flags: 0, + flag_do: false, // Setting here has no effect + ednsVersion: 0, + options: [], + }; + + /** @type {dnsPacket.Packet} */ + const dns = { + type: "query", + id: opts.id || 0, + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: opts.rrtype || "A", + class: "IN", + name: opts.name, + }], + additionals: [ opt ], + }; + //assert(dns.flags !== undefined); + if (opts.dnssec) { + dns.flags |= dnsPacket.AUTHENTIC_DATA; + opt.flags |= dnsPacket.DNSSEC_OK; + } + if (opts.dnssecCheckingDisabled) { + dns.flags |= dnsPacket.CHECKING_DISABLED; + } + if ( + (opts.ecs != null) || + (opts.ecsSubnet && (isIP(opts.ecsSubnet) !== 0)) + ) { + // https://tools.ietf.org/html/rfc7871#section-11.1 + const prefix = (opts.ecsSubnet && isIPv4(opts.ecsSubnet)) ? 24 : 56; + opt.options.push({ + code: optioncodes.toCode("CLIENT_SUBNET"), + ip: opts.ecsSubnet || "0.0.0.0", + sourcePrefixLength: (opts.ecs == null) ? prefix : opts.ecs, + }); + } + const unpadded = dnsPacket.encodingLength(dns); + opt.options.push({ + code: optioncodes.toCode("PADDING"), + // Next pad size, minus what we already have, minus another 4 bytes for + // the option header + length: (Math.ceil(unpadded / PAD_SIZE) * PAD_SIZE) - unpadded - 4, + }); + if (opts.stream) { + return dnsPacket.streamEncode(dns); + } + return dnsPacket.encode(dns); +}; + /** * Resolves a given record using the specified DNS server - * @param {string} hostname The hostname of the record to lookup + * @param {string} opts Options for the query * @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, transport, dohQuery) { +exports.dnsResolve = function (opts, resolverServer, resolverPort, 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("]", ""); @@ -306,27 +389,23 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, t // If performing reverse (PTR) record lookup, ensure hostname // syntax follows RFC 1034 / RFC 3596 - if (rrtype === "PTR") { - if (isIPv4(hostname)) { - let octets = hostname.split("."); + if (opts.rrtype === "PTR") { + if (isIPv4(opts.name)) { + let octets = opts.name.split("."); octets.reverse(); - hostname = octets.join(".") + ".in-addr.arpa"; - } else if (isIPv6(hostname)) { - let address = new Address6(hostname); - hostname = address.reverseForm(); + opts.name = octets.join(".") + ".in-addr.arpa"; + } else if (isIPv6(opts.name)) { + let address = new Address6(opts.name); + opts.name = address.reverseForm(); } } - // 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, - }], - }; + // Set request ID + if (opts.id == null) { + // Set query ID to "0" for HTTP cache friendlyness on DoH requests. + // See https://github.com/mafintosh/dns-packet/issues/77 + opts.id = (transport.toUpperCase() === "DOH") ? 0 : Math.floor(Math.random() * 65534) + 1; + } let client; let resolver = null; @@ -336,7 +415,8 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, t case "TCP": case "DOT": { - const buf = dnsPacket.streamEncode(requestData); + opts.stream = true; + const buf = exports.makePacket(opts); if (isSecure) { const options = { port: resolverPort, @@ -387,10 +467,7 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, t } 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); + const buf = exports.makePacket(opts); // TODO: implement POST requests for wireformat and JSON dohQuery = dohQuery || "dns-query?dns={query}"; dohQuery = dohQuery.replace("{query}", buf.toString("base64url")); @@ -436,7 +513,7 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, t //case "UDP": default: { - const buf = dnsPacket.encode(requestData); + const buf = exports.makePacket(opts); client = dgram.createSocket("udp" + String(addressFamily)); client.on("connect", () => { log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); diff --git a/src/lang/en.json b/src/lang/en.json index 8f7e75bb3..4d5bc5bd9 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -143,7 +143,10 @@ "Test": "Test", "Certificate Info": "Certificate Info", "Resolver Server": "Resolver Server", + "Transport Method": "Transport Method", + "Query Path": "Query Path", "Resource Record Type": "Resource Record Type", + "Skip Remote DNSSEC Verification": "Skip Remote DNSSEC Verification", "Last Result": "Last Result", "Create your admin account": "Create your admin account", "Repeat Password": "Repeat Password", @@ -269,6 +272,7 @@ "Shrink Database": "Shrink Database", "shrinkDatabaseDescriptionSqlite": "Trigger database {vacuum} for SQLite. {auto_vacuum} is already enabled but this does not defragment the database nor repack individual database pages the way that the {vacuum} command does.", "Pick a RR-Type...": "Pick a RR-Type…", + "Select the transport method...": "Select the transport method…", "Pick Accepted Status Codes...": "Pick Accepted Status Codes…", "Default": "Default", "HTTP Options": "HTTP Options", @@ -576,6 +580,7 @@ "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", + "skipRemoteDnssecDescription": "Requests the resolver server not to perform DNSSEC verification against queried records.", "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 dca8bcc2a..ac8f573ba 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -377,17 +377,6 @@ -
- -
- - -
-
- {{ $t("dohQueryPathDescription") + " " }}{{ '"{query}".' }} -
-
-
+ +
+
+ +
+ + +
+
+ {{ $t("dohQueryPathDescription") + ' "{query}".' }} +
+
+ +
+ + +
+ {{ $t("skipRemoteDnssecDescription") }} +
+
+
+