diff --git a/db/knex_init_db.js b/db/knex_init_db.js index 46bff4bfa..897009797 100644 --- a/db/knex_init_db.js +++ b/db/knex_init_db.js @@ -86,6 +86,10 @@ 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.boolean("doh_query_path", 255).defaultTo("dns-query"); + table.boolean("force_http2").notNullable().defaultTo(false); + table.boolean("skip_remote_dnssec").notNullable().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 new file mode 100644 index 000000000..c076fbd8e --- /dev/null +++ b/db/knex_migrations/2025-22-02-0000-dns-trasnsport.js @@ -0,0 +1,18 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.string("dns_transport", 3).notNullable().defaultTo("UDP"); + table.string("doh_query_path", 255).defaultTo("dns-query"); + table.boolean("force_http2").notNullable().defaultTo(false); + table.boolean("skip_remote_dnssec").notNullable().defaultTo(false); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("dns_transport"); + table.dropColumn("doh_query_path"); + table.dropColumn("force_http2"); + table.dropColumn("skip_remote_dnssec"); + }); +}; diff --git a/package-lock.json b/package-lock.json index ccb72dee3..4dc9a1199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,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", @@ -41,6 +42,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", @@ -2706,6 +2708,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", @@ -7798,6 +7806,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", @@ -10541,14 +10561,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" } @@ -15533,6 +15549,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/package.json b/package.json index 97b7bc339..02f596b9e 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,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", @@ -99,6 +100,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", diff --git a/server/model/monitor.js b/server/model/monitor.js index c9844a55d..7e63cad18 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -120,9 +120,13 @@ class Monitor extends BeanModel { packetSize: this.packetSize, maxredirects: this.maxredirects, accepted_statuscodes: this.getAcceptedStatuscodes(), - dns_resolve_type: this.dns_resolve_type, - dns_resolve_server: this.dns_resolve_server, - dns_last_result: this.dns_last_result, + dnsResolveType: this.dnsResolveType, + dnsResolveServer: this.dnsResolveServer, + dnsTransport: this.dnsTransport, + dohQueryPath: this.dohQueryPath, + forceHttp2: Boolean(this.forceHttp2), + skipRemoteDnssec: Boolean(this.skipRemoteDnssec), + dnsLastResult: this.dnsLastResult, docker_container: this.docker_container, docker_host: this.docker_host, proxyId: this.proxy_id, 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 5a47e4591..b50ef42d5 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -4,7 +4,11 @@ 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,68 +18,140 @@ class DnsMonitorType extends MonitorType { supportsConditions = true; conditionVariables = [ - new ConditionVariable("record", defaultStringOperators ), + // A, AAAA, NS + new ConditionVariable("records", defaultArrayOperators), + // PTR, CNAME + new ConditionVariable("hostname", defaultStringOperators), + // CAA + new ConditionVariable("flags", defaultStringOperators), + new ConditionVariable("tag", defaultStringOperators), + // CAA, TXT + 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("servers", defaultArrayOperators), ]; /** * @inheritdoc */ async check(monitor, heartbeat, _server) { - let startTime = dayjs().valueOf(); - let dnsMessage = ""; + const requestData = { + name: monitor.hostname, + rrtype: monitor.dnsResolveType, + dnssec: true, // Request DNSSEC information in the response + dnssecCheckingDisabled: monitor.skipRemoteDnssec, + }; + const transportData = { + type: monitor.dnsTransport, + timeout: monitor.timeout, + ignoreCertErrors: monitor.ignoreTls, + dohQueryPath: monitor.dohQueryPath, + dohUsePost: monitor.method === "POST", + dohUseHttp2: monitor.forceHttp2, + }; - let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); + let startTime = dayjs().valueOf(); + let dnsRes = await dnsResolve(requestData, monitor.dnsResolveServer, monitor.port, transportData); heartbeat.ping = dayjs().valueOf() - startTime; + let dnsMessage = ""; + let rrtype = monitor.dnsResolveType; const conditions = ConditionExpressionGroup.fromMonitor(monitor); let conditionsResult = true; const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; + const checkRecord = (results, record) => { + // Omit records that are not the same as the requested rrtype + if (record.type === monitor.dnsResolveType) { + // Concat TXT records with values larger than 255 characters + results.push(Array.isArray(record.data) ? record.data.join("") : record.data); + } + return results; + }; - switch (monitor.dns_resolve_type) { + const records = dnsRes.answers.reduce(checkRecord, []); + // Return down status if no records are provided + if (records.length === 0) { + // Some DNS servers place SOA record in the authorities section + if (dnsRes.authorities.map(auth => auth.type).includes(monitor.dnsResolveType)) { + records.push(...dnsRes.authorities.reduce(checkRecord, [])); + } else { + rrtype = null; + dnsMessage = "No records found"; + conditionsResult = false; + } + } + + switch (rrtype) { case "A": case "AAAA": - case "PTR": - dnsMessage = `Records: ${dnsRes.join(" | ")}`; - conditionsResult = dnsRes.some(record => handleConditions({ record })); + case "NS": + dnsMessage = records.join(" | "); + conditionsResult = handleConditions({ records: records }); break; case "TXT": - dnsMessage = `Records: ${dnsRes.join(" | ")}`; - conditionsResult = dnsRes.flat().some(record => handleConditions({ record })); + dnsMessage = records.join(" | "); + // Combine records to enable string operators for conditions + conditionsResult = handleConditions({ value: records.join("|") }); break; + // While PTR can have multiple records in DNS, it's not recommended + case "PTR": case "CNAME": - dnsMessage = dnsRes[0]; - conditionsResult = handleConditions({ record: dnsRes[0] }); + dnsMessage = records[0]; + conditionsResult = handleConditions({ hostname: records[0].value }); break; case "CAA": - dnsMessage = dnsRes[0].issue; - conditionsResult = handleConditions({ record: dnsRes[0].issue }); + dnsMessage = `${records[0].flags} ${records[0].tag} "${records[0].value}"`; + conditionsResult = handleConditions(records[0]); break; case "MX": - dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); - conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); + dnsMessage = records.map(record => `Hostname: ${record.exchange}; Priority: ${record.priority}`).join(" | "); + conditionsResult = handleConditions({ hostnames: records.map(record => record.exchange) }); break; - case "NS": - dnsMessage = `Servers: ${dnsRes.join(" | ")}`; - conditionsResult = dnsRes.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": { + dnsMessage = Object.entries({ + "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("; "); + conditionsResult = handleConditions(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 })); + dnsMessage = records.map((record) => { + return Object.entries({ + "Server": `${record.target}:${record.port}`, + "Priority": record.priority, + "Weight": record.weight, + }).map(([ name, value ]) => { + return `${name}: ${value}`; + }).join("; "); + }).join(" | "); + conditionsResult = handleConditions({ servers: records.map(record => `${record.target}:${record.port}`) }); break; } - if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { + if (monitor.dnsLastResult !== dnsMessage && dnsMessage !== undefined && monitor.id !== undefined) { await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]); } diff --git a/server/server.js b/server/server.js index 5b2f41a2e..405d75a85 100644 --- a/server/server.js +++ b/server/server.js @@ -825,8 +825,12 @@ let needSetup = false; bean.packetSize = monitor.packetSize; bean.maxredirects = monitor.maxredirects; 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.dnsResolveType = monitor.dnsResolveType; + bean.dnsResolveServer = monitor.dnsResolveServer; + bean.dnsTransport = monitor.dnsTransport; + bean.dohQueryPath = monitor.dohQueryPath; + bean.forceHttp2 = monitor.forceHttp2; + bean.skipRemoteDnssec = monitor.skipRemoteDnssec; 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 4cc833330..d157f8fc5 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -7,7 +7,11 @@ const { PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../src/util"); const passwordHash = require("./password-hash"); -const { Resolver } = require("dns"); +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"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); @@ -23,6 +27,19 @@ const radiusClient = require("node-radius-client"); const redis = require("redis"); const oidc = require("openid-client"); const tls = require("tls"); +const https = require("https"); +const http2 = require("http2"); +const url = require("url"); + +const { + HTTP2_HEADER_PATH, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_AUTHORITY, + HTTP2_HEADER_ACCEPT, + HTTP2_HEADER_CONTENT_LENGTH, + HTTP2_HEADER_CONTENT_TYPE, + HTTP2_HEADER_STATUS, +} = http2.constants; const { dictionaries: { @@ -314,39 +331,439 @@ exports.httpNtlm = function (options, ntlmOptions) { }); }; +/** + * Encode DNS query packet data to a buffer. Adapted from + * https://github.com/hildjj/dohdec/blob/v7.0.2/pkg/dohdec/lib/dnsUtils.js + * @param {object} opts Options for the query. + * @param {string} opts.name The name to look up. + * @param {number} opts.id ID for the query. SHOULD be 0 for DOH. + * @param {packet.RecordType} opts.rrtype The record type to look up. + * @param {boolean} opts.dnssec Request DNSSec information? + * @param {boolean} opts.dnssecCheckingDisabled Disable DNSSec validation? + * @param {string} opts.ecsSubnet Subnet to use for ECS. + * @param {number} opts.ecs Number of ECS bits. Defaults to 24 (IPv4) or 56 + * (IPv6). + * @param {boolean} opts.stream Encode for streaming, with the packet prefixed + * by a 2-byte big-endian integer of the number of bytes in the packet. + * @param {number} opts.udpPayloadSize Set a custom UDP payload size (EDNS). + * @returns {Buffer} The encoded packet. + * @throws {TypeError} opts does not contain a name attribute. + */ +exports.makeDnsPacket = function (opts) { + const PAD_SIZE = 128; + + if (!opts?.name) { + throw new TypeError("Name is required"); + } + + /** @type {dnsPacket.OptAnswer} */ + const opt = { + name: ".", + type: "OPT", + udpPayloadSize: opts.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); +}; + +/** + * Decodes DNS packet response data with error handling + * @param {Buffer} data DNS packet data to decode + * @param {boolean} isStream If the data is encoded as a stream + * @param {Function} callback function to call if error is encountered + * Passes error object as a parameter to the function + * @returns {dnsPacket.Packet} DNS packet data in an object + */ +exports.decodeDnsPacket = function (data, isStream = false, callback) { + let decodedData; + try { + decodedData = isStream ? dnsPacket.streamDecode(data) : dnsPacket.decode(data); + log.debug("dns", "Response decoded"); + // If the truncated bit is set, the answers section was too large + if (decodedData.flag_tc) { + callback({ message: "Response is truncated." }); + } + } catch (err) { + err.message = `Error decoding DNS response data: ${err.message}`; + log.warn("dns", err.message); + if (callback) { + callback(err); + } + } + return decodedData; +}; + /** * Resolves a given record using the specified DNS server - * @param {string} hostname The hostname of the record to lookup + * @param {object} opts Options for the query, used to generate DNS packet + * @param {string} opts.name The name of the record to query + * @param {string} opts.rrtype The resource record type + * @param {number} opts.id Set a specific ID number to use on the query + * @param {number} opts.udpPayloadSize Set a custom UDP payload size (EDNS). + * Defaults to safe values, 1432 bytes (IPv4) or 1232 bytes (IPv6). * @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 {number} resolverPort Port the DNS server is listening on + * @param {object} transport The transport method and options + * @param {string} transport.type Transport method, default is UDP + * @param {number} transport.timeout Timeout to use for queries + * @param {boolean} transport.ignoreCertErrors Proceed with secure connections + * even if the server presents an untrusted or expired certificate + * @param {string} transport.dohQueryPath Query path to use for DoH requests + * @param {boolean} transport.dohUsePost If true, DNS query will be sent using + * HTTP POST method for DoH requests, otherwise use HTTP GET method + * @param {boolean} transport.dohUseHttp2 If true, DNS query will be made with + * HTTP/2 session for DOH requests, otherwise use HTTP/1.1 * @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 (opts, resolverServer, resolverPort, transport) { + // Set transport variables to defaults if not defined + const method = ("type" in transport) ? transport.type.toUpperCase() : "UDP"; + const isSecure = [ "DOH", "DOT", "DOQ" ].includes(method); + const timeout = transport.timeout ?? 30000; // 30 seconds + const skipCertCheck = transport.ignoreCertErrors ?? false; + const dohQuery = transport.dohQueryPath ?? "dns-query"; + const dohUsePost = transport.dohUsePost ?? false; + const dohUseHttp2 = transport.dohUseHttp2 ?? false; + + // 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 (opts.rrtype === "PTR") { + if (isIPv4(opts.name)) { + let octets = opts.name.split("."); + octets.reverse(); + opts.name = octets.join(".") + ".in-addr.arpa"; + } else if (isIPv6(opts.name)) { + let address = new Address6(opts.name); + opts.name = address.reverseForm(); } - }); + } + + // 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 = (method === "DOH") ? 0 : Math.floor(Math.random() * 65534) + 1; + } + + // Set UDP payload size to safe levels for transmission over 1500 MTU + if (!opts.udpPayloadSize) { + opts.udpPayloadSize = (addressFamily === 4) ? 1432 : 1232; + } + + // Enable stream encoding for TCP and DOT transport methods + if ([ "TCP", "DOT" ].includes(method)) { + opts.stream = true; + } + // Generate buffer with encoded DNS query + const buf = exports.makeDnsPacket(opts); + + let client; + let resolver; + const socketName = `${resolverServer}:${resolverPort}`; + // Transport method determines which client type to use + switch (method) { + + case "TCP": + case "DOT": { + if (isSecure) { + const options = { + port: resolverPort, + host: resolverServer, + rejectUnauthorized: !skipCertCheck, + secureContext: tls.createSecureContext({ + minVersion: "TLSv1.2", + }), + }; + // Set TLS ServerName only if server is not an IP address per + // Section 3 of RFC 6066 + if (addressFamily === 0) { + options.servername = resolverServer; + } + client = tls.connect(options, () => { + log.debug("dns", `Connected to ${socketName}`); + client.write(buf); + }); + } else { + client = new Socket(); + client.connect(resolverPort, resolverServer, () => { + log.debug("dns", `Connected to ${socketName}`); + client.write(buf); + }); + } + resolver = new Promise((resolve, reject) => { + // The below message is used when the response received does + // not follow Section 4.2.2 of RFC 1035 + const lenErrMsg = "Resolver returned invalid DNS response"; + let data = Buffer.alloc(0); + let expectedLength = 0; + let isValidLength = false; + client.on("error", (err) => { + if (err.code === "ETIMEDOUT") { + err.message = `Connection to ${socketName} timed out`; + } else if (err.code === "ECONNREFUSED") { + err.message = `Connection to ${socketName} refused`; + } + reject(err); + }); + client.setTimeout(timeout, () => { + client.destroy(); + reject({ message: `Request to ${socketName} timed out` }); + }); + client.on("data", (chunk) => { + if (data.length === 0) { + if (chunk.byteLength > 1) { + expectedLength = chunk.readUInt16BE(0); + if (expectedLength < 12) { + reject({ message: lenErrMsg }); + } + } + } + data = Buffer.concat([ data, chunk ]); + if (data.byteLength - 2 === expectedLength) { + isValidLength = true; + client.destroy(); + const response = exports.decodeDnsPacket(data, true, reject); + resolve(response); + } + }); + client.on("close", () => { + log.debug("dns", `Connection to ${socketName} closed`); + if (!isValidLength) { + reject({ message: lenErrMsg }); + } + }); + }); + break; + } + + case "DOH": { + const queryPath = dohUsePost ? dohQuery : `${dohQuery}?dns=${buf.toString("base64url")}`; + const requestURL = url.parse(`https://${socketName}/${queryPath}`, true); + const mimeType = "application/dns-message"; + const options = { + hostname: requestURL.hostname, + port: requestURL.port, + path: requestURL.path, + method: "GET", + headers: { + "accept": mimeType, + }, + rejectUnauthorized: !skipCertCheck, + }; + if (dohUsePost) { + options.method = "POST"; + // Setting Content-Length header is required for some resolvers + options.headers["content-length"] = buf.byteLength; + options.headers["content-type"] = mimeType; + } + resolver = new Promise((resolve, reject) => { + /** + * Helper function to validate HTTP response + * @param {IncomingMessage|ClientHttp2Stream} httpResponse + * The response from https or http2 client + * @param {object} http2Headers Response headers from http2 + * @returns {void} + * @throws missing one or more headers for HTTP/2 response + */ + const handleResponse = (httpResponse, http2Headers) => { + // Determine status code and content type + let statusCode; + let contentType; + if (dohUseHttp2) { + if (!http2Headers) { + throw new Error("No headers passed for HTTP/2 response"); + } + statusCode = http2Headers[HTTP2_HEADER_STATUS]; + contentType = http2Headers[HTTP2_HEADER_CONTENT_TYPE]; + } else { + statusCode = httpResponse.statusCode; + contentType = httpResponse.headers["content-type"]; + } + // Validate response from resolver + if (statusCode !== 200) { + reject({ message: `Request to ${socketName} failed with status code ${statusCode}` }); + return; + } else if (contentType !== mimeType) { + reject({ message: `Response from ${socketName} Content-Type was "${contentType}", expected ${mimeType}` }); + return; + } + // Read the response body into a buffer + let data = Buffer.alloc(0); + httpResponse.on("data", (chunk) => { + data = Buffer.concat([ data, chunk ]); + }); + httpResponse.on("end", () => { + const response = exports.decodeDnsPacket(data, false, reject); + resolve(response); + }); + }; + if (dohUseHttp2) { + const headers = {}; + headers[HTTP2_HEADER_AUTHORITY] = options.hostname; + headers[HTTP2_HEADER_PATH] = options.path; + headers[HTTP2_HEADER_METHOD] = options.method; + headers[HTTP2_HEADER_ACCEPT] = options.headers["accept"]; + if (dohUsePost) { + headers[HTTP2_HEADER_CONTENT_LENGTH] = options.headers["content-length"]; + headers[HTTP2_HEADER_CONTENT_TYPE] = options.headers["content-type"]; + } + client = http2.connect(`https://${options.hostname}:${options.port}`, { + rejectUnauthorized: options.rejectUnauthorized, + }); + client.setTimeout(timeout, () => { + client.destroy(); + reject({ message: `Request to ${socketName} timed out` }); + }); + client.on("connect", () => { + log.debug("dns", `Connected to ${socketName}`); + }); + const req = client.request(headers); + req.on("error", (err) => { + if (err.cause.code === "ETIMEDOUT") { + err = err.cause; + err.message = `Connection to ${socketName} timed out`; + } else if (err.cause.code === "ECONNREFUSED") { + err = err.cause; + err.message = `Connection to ${socketName} refused`; + } else { + err.message = "HTTP/2: " + err.message; + } + reject(err); + }); + req.on("response", (resHeaders) => { + handleResponse(req, resHeaders); + }); + req.on("end", () => { + client.close(); + }); + if (dohUsePost) { + req.write(buf); + } + req.end(); + } else { + client = https.request(options, (httpResponse) => { + handleResponse(httpResponse); + }); + client.setTimeout(timeout, () => { + client.destroy(); + reject({ message: `Request to ${socketName} timed out` }); + }); + client.on("socket", (socket) => { + socket.on("secureConnect", () => { + log.debug("dns", `Connected to ${socketName}`); + }); + }); + if (dohUsePost) { + client.write(buf); + } + client.end(); + } + client.on("error", (err) => { + if (err.code === "ETIMEDOUT") { + err.message = `Connection to ${socketName} timed out`; + } else if (err.code === "ECONNREFUSED") { + err.message = `Connection to ${socketName} refused`; + } + reject(err); + }); + client.on("close", () => { + log.debug("dns", `Connection to ${socketName} closed`); + }); + }); + break; + } + + case "UDP": + default: { + if (addressFamily === 0) { + return new Promise((resolve, reject) => { + reject({ message: "Resolver server must be IP address for UDP transport method" }); + }); + } + client = dgram.createSocket(`udp${addressFamily}`); + resolver = new Promise((resolve, reject) => { + let timer; + client.on("message", (rdata, rinfo) => { + client.close(); + const response = exports.decodeDnsPacket(rdata, false, reject); + resolve(response); + }); + client.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + client.on("listening", () => { + log.debug("dns", `Connected to ${socketName}`); + timer = setTimeout(() => { + reject({ message: `Query to ${socketName} timed out` }); + client.close(); + }, timeout); + }); + client.on("close", () => { + clearTimeout(timer); + log.debug("dns", `Connection to ${socketName} closed`); + }); + }); + client.send(buf, 0, buf.length, resolverPort, resolverServer); + } + } + + return resolver; }; /** diff --git a/src/assets/app.scss b/src/assets/app.scss index fd43a7bee..92c04e164 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -629,6 +629,10 @@ $shadow-box-padding: 20px; } } +#doh-method { + width: 90px; +} + @media (max-width: 770px) { .toast-container { margin-bottom: 100px !important; diff --git a/src/assets/multiselect.scss b/src/assets/multiselect.scss index bb24db76a..c4672dac6 100644 --- a/src/assets/multiselect.scss +++ b/src/assets/multiselect.scss @@ -48,6 +48,7 @@ .multiselect__single { line-height: 14px; margin-bottom: 0; + vertical-align: middle; } .dark { diff --git a/src/lang/en.json b/src/lang/en.json index a979edcc2..87512d5c8 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -85,7 +85,7 @@ "retriesDescription": "Maximum retries before the service is marked as down and a notification is sent", "ignoredTLSError": "TLS/SSL errors have been ignored", "ignoreTLSError": "Ignore TLS/SSL errors for HTTPS websites", - "ignoreTLSErrorGeneral": "Ignore TLS/SSL error for connection", + "ignoreTLSErrorGeneral": "Ignore TLS/SSL errors for secure connections", "upsideDownModeDescription": "Flip the status upside down. If the service is reachable, it is DOWN.", "maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.", "Upside Down Mode": "Upside Down Mode", @@ -144,7 +144,11 @@ "Test": "Test", "Certificate Info": "Certificate Info", "Resolver Server": "Resolver Server", + "Transport Method": "Transport Method", + "Query Path": "Query Path", + "Force HTTP2": "HTTP/2", "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", @@ -277,6 +281,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", @@ -582,9 +587,14 @@ "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.", - "resolverserverDescription": "Cloudflare is the default server. You can change the resolver server anytime.", + "dnsPortDescription": "DNS server port. Defaults to 53. Alternative ports are 443 for DoH and 853 for DoT.", + "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.", + "dohHttpMethodDescription": "Set the HTTP method to use for DoH query.", + "forceHttp2": "Send the request using HTTP/2. Fails if the server does not support HTTP/2.", + "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/Details.vue b/src/pages/Details.vue index 1d068b92e..c3114aa2b 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -32,9 +32,9 @@ {{ $t("Expected Value") }}: {{ monitor.expectedValue }} - [{{ monitor.dns_resolve_type }}] {{ monitor.hostname }} + [{{ monitor.dnsResolveType }}] {{ monitor.hostname }} - {{ $t("Last Result") }}: {{ monitor.dns_last_result }} + {{ $t("Last Result") }}: {{ monitor.dnsLastResult }} Docker container: {{ monitor.docker_container }} Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }} diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 0d628895d..dda5b5c0d 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -307,7 +307,11 @@ v-model="monitor.hostname" type="text" class="form-control" - :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" + :pattern="`${ + monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern.source : + monitor.type === 'dns' ? ipOrDnsNameRegexPattern.source : + ipOrHostnameRegexPattern.source + }`" required data-testid="hostname-input" > @@ -396,8 +400,8 @@ - {{ $t("Resolver Server") }} - + {{ $t("Resolver Server") }} + {{ $t("resolverserverDescription") }} @@ -406,19 +410,19 @@ {{ $t("Port") }} - + {{ $t("dnsPortDescription") }} - {{ $t("Resource Record Type") }} + {{ $t("Resource Record Type") }} + + + {{ $t("Transport Method") }} + + + {{ $t("dnsTransportDescription") }} + + @@ -655,10 +680,10 @@ - + - {{ monitor.type === "redis" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }} + {{ monitor.type === "redis" || monitor.type === "dns" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }} @@ -674,6 +699,63 @@ + + + + + + + {{ $t("Method") }} + + + + + + GET + + + POST + + + + + + + + + {{ $t("dohHttpMethodDescription") }} + + + + {{ $t("Query Path") }} + + + {{ $t("dohQueryPathDescription") }} + + + + + + + {{ $t("Force HTTP2") }} + + + {{ $t("forceHttp2") }} + + + + + + + + {{ $t("Skip Remote DNSSEC Verification") }} + + + {{ $t("skipRemoteDnssecDescription") }} + + + + @@ -1144,7 +1226,11 @@ import { MIN_INTERVAL_SECOND, sleep, } from "../util.ts"; -import { hostNameRegexPattern } from "../util-frontend"; +import { + hostNameRegexPattern, + dnsNameRegexPattern, + urlPathRegexPattern +} from "../util-frontend"; import HiddenInput from "../components/HiddenInput.vue"; import EditMonitorConditions from "../components/EditMonitorConditions.vue"; @@ -1169,8 +1255,12 @@ const monitorDefaults = { expiryNotification: false, maxredirects: 10, accepted_statuscodes: [ "200-299" ], - dns_resolve_type: "A", - dns_resolve_server: "1.1.1.1", + dnsResolveType: "A", + dnsResolveServer: "1.1.1.1", + dnsTransport: "UDP", + dohQueryPath: "dns-query", + forceHttp2: false, + skip_remote_dnssec: false, docker_container: "", docker_host: null, proxyId: null, @@ -1223,9 +1313,14 @@ export default { }, acceptedStatusCodeOptions: [], dnsresolvetypeOptions: [], + dnsTransportOptions: [], kafkaSaslMechanismOptions: [], + ipRegexPattern: hostNameRegexPattern(false, true, false), + hostnameRegexPattern: hostNameRegexPattern(false, false, true), ipOrHostnameRegexPattern: hostNameRegexPattern(), mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true), + ipOrDnsNameRegexPattern: dnsNameRegexPattern(), + queryRegexPattern: urlPathRegexPattern(), gameList: null, connectionStringTemplates: { "sqlserver": "Server=,;Database=;User Id=;Password=;Encrypt=;TrustServerCertificate=;Connection Timeout=", @@ -1286,7 +1381,43 @@ export default { // Allow to test with simple dns server with port (127.0.0.1:5300) if (! isDev) { - return this.ipRegexPattern; + return this.ipRegexPattern.source; + } + return null; + }, + + ipOrHostnameRegex() { + + // Permit either IP address or hostname (127.0.0.1, dns.example.com) + if (! isDev) { + return this.ipOrHostnameRegexPattern.source; + } + return null; + }, + + dnsResolverRegex() { + + // Permit IP address for TCP/UDP resolvers, hostname for DoH/DoT + if (! isDev) { + switch (this.monitor.dnsTransport) { + case "UDP": + case "TCP": + return this.ipRegexPattern.source; + case "DoH": + return this.hostnameRegexPattern.source; + case "DoT": + case "DoQ": + return this.ipOrHostnameRegexPattern.source; + } + } + return null; + }, + + urlQueryRegex() { + + // Permit only URL paths with a query parameter ( {query} ) + if (! isDev) { + return this.queryRegexPattern.source; } return null; }, @@ -1516,8 +1647,48 @@ message HealthCheckResponse { }, conditionVariables() { + // For DNS monitor, the variables depend on rrtype. Conditions for + // all rrtypes are added to an array in the DnsMonitorType class in + // order to pass to Vue, then identified based on index. + if (this.monitor.type === "dns") { + const dnsConditionVariables = this.$root.monitorTypeList["dns"]?.conditionVariables; + switch (this.monitor.dnsResolveType) { + case "A": + case "AAAA": + case "NS": + return dnsConditionVariables.slice(0, 1); + case "PTR": + case "CNAME": + return dnsConditionVariables.slice(1, 2); + case "CAA": + return dnsConditionVariables.slice(2, 5); + case "TXT": + return dnsConditionVariables.slice(4, 5); + case "MX": + return dnsConditionVariables.slice(5, 6); + case "SOA": + return dnsConditionVariables.slice(6, 12); + case "SRV": + return dnsConditionVariables.slice(12, 13); + } + return []; + } return this.$root.monitorTypeList[this.monitor.type]?.conditionVariables || []; }, + + dohSelected() { + return this.monitor.dnsTransport === "DoH"; + }, + + dohDisplayUrl() { + const port = (this.monitor.port !== 443) ? `:${this.monitor.port}` : ""; + return `https://${this.monitor.dnsResolveServer}${port}/`; + }, + + isSecureDnsTransport() { + return [ "DoH", "DoT", "DoQ" ].includes(this.monitor.dnsTransport); + }, + }, watch: { "$root.proxyList"() { @@ -1686,6 +1857,13 @@ message HealthCheckResponse { "TXT", ]; + let dnsTransportOptions = [ + "UDP", + "TCP", + "DoH", + "DoT", + ]; + let kafkaSaslMechanismOptions = [ "None", "plain", @@ -1700,6 +1878,7 @@ message HealthCheckResponse { this.acceptedStatusCodeOptions = acceptedStatusCodeOptions; this.dnsresolvetypeOptions = dnsresolvetypeOptions; + this.dnsTransportOptions = dnsTransportOptions; this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions; }, methods: { @@ -2045,6 +2224,11 @@ message HealthCheckResponse { } }, + focusElement(refId) { + // Focus the element that has a defined reference + this.$refs[refId].focus(); + }, + }, }; diff --git a/src/util-frontend.js b/src/util-frontend.js index d9bf378e5..a4ad0f9d5 100644 --- a/src/util-frontend.js +++ b/src/util-frontend.js @@ -109,20 +109,80 @@ export function getDevContainerServerHostname() { } /** - * Regex pattern fr identifying hostnames and IP addresses + * Regex pattern for identifying hostnames and IP addresses * @param {boolean} mqtt whether or not the regex should take into * account the fact that it is an mqtt uri + * @param {boolean} ip whether the regex should match IP addresses + * @param {boolean} hostname whether the regex should match hostnames * @returns {RegExp} The requested regex */ -export function hostNameRegexPattern(mqtt = false) { +export function hostNameRegexPattern(mqtt = false, ip = true, hostname = true) { // mqtt, mqtts, ws and wss schemes accepted by mqtt.js (https://github.com/mqttjs/MQTT.js/#connect) - const mqttSchemeRegexPattern = "((mqtt|ws)s?:\\/\\/)?"; + const mqttSchemeRegexPattern = /((mqtt|ws)s?:\/\/)?/; // Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/ - const ipRegexPattern = `((^${mqtt ? mqttSchemeRegexPattern : ""}((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))$)|(^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?$))`; - // Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address - const hostNameRegexPattern = `^${mqtt ? mqttSchemeRegexPattern : ""}([a-zA-Z0-9])?(([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9\\-_]*[a-zA-Z0-9_])\\.)*([A-Za-z0-9_]|[A-Za-z0-9_][A-Za-z0-9\\-_]*[A-Za-z0-9_])(\\.)?$`; + const ipv4RegexPattern = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; + const ipv6RegexPattern = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; + // See this answer for detailed a explanation: https://stackoverflow.com/a/53875771/1854468 + /* eslint-disable-next-line no-useless-escape */ + const hostNameRegexPattern = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}\.([a-zA-Z0-9][a-zA-Z0-9\-]{0,62}\.)*([a-zA-Z]{2,63}|[xX][nN]--[a-zA-Z0-9]{0,59})$/; + /* eslint-disable-next-line no-useless-escape */ + const localNameRegexPattern = /^[a-zA-Z0-9][a-zA-Z0-9\-]{0,127}$/; - return `${ipRegexPattern}|${hostNameRegexPattern}`; + let patterns = []; + if (ip) { + patterns.push(ipv4RegexPattern, ipv6RegexPattern); + } + if (hostname) { + patterns.push(hostNameRegexPattern, localNameRegexPattern); + } + if (mqtt) { + // all modified patterns must start with "^" for this to work + patterns = patterns.map(pattern => { + return new RegExp(`^${mqttSchemeRegexPattern.source}${pattern.source.slice(1)}`); + }); + } + + return new RegExp(patterns.map(pattern => `(${pattern.source})`).join("|")); +} + +/** + * Regex pattern for DNS queries + * @returns {RegExp} The requested regex + */ +export function dnsNameRegexPattern() { + // This borrows ipRegexPattern from hostNameRegexPattern above + const ipRegexPattern = hostNameRegexPattern(false, true, false); + // Similar to hostNameRegexPattern, except the hostname pattern + // can also match root (.) and top-level domains (.com, .org), + // and may contain underscores (_) + const dnsNamePattern = /^(\.|(\.?[a-zA-Z0-9\-_]+)+)$/; + + return new RegExp(`(${ipRegexPattern.source})|(${dnsNamePattern.source})`); +} + +/** + * Regex patterns for validating URL paths + * @param {boolean} qstr True if the url should contain a query string + * @param {boolean} tmpl True if the url should contain templating, `{query}` + * Takes precedence over qstr if both are true. + * @returns {RegExp} The requested regex + */ +export function urlPathRegexPattern(qstr = false, tmpl = false) { + // Matches any URL path, including empty string + const pathRegexPattern = /^\/?(([a-zA-Z0-9\-_%])+\/)*[a-zA-Z0-9\-_%]*(\?([a-zA-Z0-9\-_%]+=[a-zA-Z0-9\-_%]*&?)+)?$/; + // Ensures a URL path follows query string format + const queryRegexPattern = /^\/?(([a-zA-Z0-9\-_%])+\/)*[a-zA-Z0-9\-_%]*\?([a-zA-Z0-9\-_%]+=[a-zA-Z0-9\-_%]*&?)+$/; + // Only checks for valid URL path containing "{query}" + /* eslint-disable-next-line no-useless-escape */ + const queryTemplateRegexPattern = /^[a-zA-Z0-9\-._~:\/?#\[\]@!$&'\(\)*+,;=]*\{query\}[a-zA-Z0-9\-._~:\/?#\[\]@!$&'\(\)*+,;=]*$/; + + if (tmpl) { + return queryTemplateRegexPattern; + } + if (qstr) { + return queryRegexPattern; + } + return pathRegexPattern; } /** diff --git a/test/backend-test/test-dns.js b/test/backend-test/test-dns.js new file mode 100644 index 000000000..fa2fb998b --- /dev/null +++ b/test/backend-test/test-dns.js @@ -0,0 +1,181 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { DnsMonitorType } = require("../../server/monitor-types/dns"); +const { UP, PENDING } = require("../../src/util"); + +const queryName = "."; +const rrtype = "NS"; +const sampleRecordRegex = /a\.root-servers\.net/; +const bogusServer = "0.0.0.0"; + +/** + * Performs DNS query and checks the result + * @param {object} monitorOpts the parameters for the monitor + * @returns {Promise} the heartbeat produced by the check + */ +async function testDns(monitorOpts) { + if (!monitorOpts.hostname) { + monitorOpts.hostname = queryName; + } + if (!monitorOpts.dnsResolveType) { + monitorOpts.dnsResolveType = rrtype; + } + if (!monitorOpts.conditions) { + monitorOpts.conditions = "[]"; + } + const heartbeat = { + msg: "", + status: PENDING, + }; + const dnsMonitor = new DnsMonitorType(); + + await dnsMonitor.check(monitorOpts, heartbeat, {}); + + return heartbeat; +} + +describe("DNS monitor transport methods", { + concurrency: true +}, () => { + test("DNS (UDP)", async () => { + + const monitor = { + dnsResolveServer: "1.1.1.1", + port: 53, + dnsTransport: "UDP", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS (UDP) timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 53, + dnsTransport: "UDP", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Query to .* timed out/ + }, "Expected query timeout error"); + }); + + test("DNS (TCP)", async () => { + + const monitor = { + dnsResolveServer: "1.1.1.1", + port: 53, + dnsTransport: "TCP", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS (TCP) timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 53, + dnsTransport: "TCP", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over TLS", async () => { + + const monitor = { + dnsResolveServer: "one.one.one.one", + port: 853, + dnsTransport: "DoT", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over TLS timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 853, + dnsTransport: "DoT", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over HTTPS (GET)", async () => { + + const monitor = { + dnsResolveServer: "cloudflare-dns.com", + port: 443, + dnsTransport: "DoH", + dohQueryPath: "dns-query", + method: "GET", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over HTTPS timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 443, + dnsTransport: "DoH", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over HTTP/2 (POST)", async () => { + + const monitor = { + dnsResolveServer: "cloudflare-dns.com", + port: 443, + dnsTransport: "DoH", + dohQueryPath: "dns-query", + method: "POST", + forceHttp2: true, + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over HTTP/2 timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 443, + dnsTransport: "DoH", + timeout: 10000, + forceHttp2: true, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + +}); diff --git a/test/e2e/specs/monitor-form.spec.js b/test/e2e/specs/monitor-form.spec.js index b41f6ceb9..4936dde37 100644 --- a/test/e2e/specs/monitor-form.spec.js +++ b/test/e2e/specs/monitor-form.spec.js @@ -53,7 +53,7 @@ test.describe("Monitor Form", () => { const friendlyName = "Example DNS NS"; await page.getByTestId("friendly-name-input").fill(friendlyName); - await page.getByTestId("hostname-input").fill("example.com"); + await page.getByTestId("hostname-input").fill("."); const resolveTypeSelect = page.getByTestId("resolve-type-select"); await resolveTypeSelect.click(); @@ -65,9 +65,9 @@ test.describe("Monitor Form", () => { await page.getByTestId("add-condition-button").click(); expect(await page.getByTestId("condition").count()).toEqual(2); // 2 explicitly added - await page.getByTestId("condition-value").nth(0).fill("a.iana-servers.net"); + await page.getByTestId("condition-value").nth(0).fill("a.root-servers.net"); await page.getByTestId("condition-and-or").nth(0).selectOption("or"); - await page.getByTestId("condition-value").nth(1).fill("b.iana-servers.net"); + await page.getByTestId("condition-value").nth(1).fill("b.root-servers.net"); await screenshot(testInfo, page); await page.getByTestId("save-button").click(); @@ -86,7 +86,7 @@ test.describe("Monitor Form", () => { const friendlyName = "Example DNS NS"; await page.getByTestId("friendly-name-input").fill(friendlyName); - await page.getByTestId("hostname-input").fill("example.com"); + await page.getByTestId("hostname-input").fill("."); const resolveTypeSelect = page.getByTestId("resolve-type-select"); await resolveTypeSelect.click(); @@ -105,4 +105,38 @@ test.describe("Monitor Form", () => { await screenshot(testInfo, page); }); + + test("dns transport", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + await screenshot(testInfo, page); + await selectMonitorType(page); + + const friendlyName = "Cloudflare"; + await page.getByTestId("friendly-name-input").fill(friendlyName); + await page.getByTestId("hostname-input").fill("one.one.one.one"); + await page.getByTestId("resolve-server-input").fill("cloudflare-dns.com"); + await page.getByTestId("port-input").fill("443"); + + const resolveTypeSelect = page.getByTestId("resolve-type-select"); + await resolveTypeSelect.click(); + await resolveTypeSelect.getByRole("option", { name: "SOA" }).click(); + + const transportMethodSelect = page.getByTestId("transport-method-select"); + await transportMethodSelect.click(); + await transportMethodSelect.getByRole("option", { name: "DoH" }).click(); + + const httpMethodSelect = page.getByTestId("method-select"); + await httpMethodSelect.selectOption("POST"); + + await page.getByTestId("http2-check").check(); + + await screenshot(testInfo, page); + await page.getByTestId("save-button").click(); + await page.waitForURL("/dashboard/*"); + + expect(page.getByTestId("monitor-status")).toHaveText("up", { ignoreCase: true }); + + await screenshot(testInfo, page); + }); });