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 @@ @@ -655,10 +680,10 @@ -
+
@@ -674,6 +699,63 @@
+ +
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+ {{ $t("dohHttpMethodDescription") }} +
+
+
+ + +
+ {{ $t("dohQueryPathDescription") }} +
+
+
+
+ + +
+ {{ $t("forceHttp2") }} +
+
+
+ +
+ + +
+ {{ $t("skipRemoteDnssecDescription") }} +
+
+
+