Add support for DoT, DoH, and TCP DNS lookups

This commit is contained in:
ekrekeler 2025-02-24 02:03:09 -06:00
parent 2b5f57f92d
commit 8d483a8f02
No known key found for this signature in database
GPG key ID: 4C66C864B6B00854
10 changed files with 213 additions and 46 deletions

View file

@ -86,6 +86,8 @@ async function createTables() {
table.text("accepted_statuscodes_json").notNullable().defaultTo("[\"200-299\"]"); table.text("accepted_statuscodes_json").notNullable().defaultTo("[\"200-299\"]");
table.string("dns_resolve_type", 5); table.string("dns_resolve_type", 5);
table.string("dns_resolve_server", 255); table.string("dns_resolve_server", 255);
table.string("dns_transport", 3);
table.string("doh_query_path", 255);
table.string("dns_last_result", 255); table.string("dns_last_result", 255);
table.integer("retry_interval").notNullable().defaultTo(0); table.integer("retry_interval").notNullable().defaultTo(0);
table.string("push_token", 20).defaultTo(null); table.string("push_token", 20).defaultTo(null);

View file

@ -0,0 +1,14 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.string("dns_transport");
table.string("doh_query_path");
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("dns_transport");
table.dropColumn("doh_query_path");
});
};

View file

@ -85,6 +85,7 @@
"croner": "~8.1.0", "croner": "~8.1.0",
"dayjs": "~1.11.5", "dayjs": "~1.11.5",
"dev-null": "^0.1.1", "dev-null": "^0.1.1",
"dns2": "song940/node-dns",
"dotenv": "~16.0.3", "dotenv": "~16.0.3",
"express": "~4.21.0", "express": "~4.21.0",
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
@ -98,6 +99,7 @@
"http-proxy-agent": "~7.0.2", "http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6", "https-proxy-agent": "~7.0.6",
"iconv-lite": "~0.6.3", "iconv-lite": "~0.6.3",
"ip-address": "~10.0.1",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"jsesc": "~3.0.2", "jsesc": "~3.0.2",
"jsonata": "^2.0.3", "jsonata": "^2.0.3",
@ -168,7 +170,6 @@
"cronstrue": "~2.24.0", "cronstrue": "~2.24.0",
"cross-env": "~7.0.3", "cross-env": "~7.0.3",
"delay": "^5.0.0", "delay": "^5.0.0",
"dns2": "~2.0.1",
"dompurify": "~3.1.7", "dompurify": "~3.1.7",
"eslint": "~8.14.0", "eslint": "~8.14.0",
"eslint-plugin-jsdoc": "~46.4.6", "eslint-plugin-jsdoc": "~46.4.6",

View file

@ -118,6 +118,8 @@ class Monitor extends BeanModel {
accepted_statuscodes: this.getAcceptedStatuscodes(), accepted_statuscodes: this.getAcceptedStatuscodes(),
dns_resolve_type: this.dns_resolve_type, dns_resolve_type: this.dns_resolve_type,
dns_resolve_server: this.dns_resolve_server, dns_resolve_server: this.dns_resolve_server,
dns_transport: this.dns_transport,
doh_query_path: this.doh_query_path,
dns_last_result: this.dns_last_result, dns_last_result: this.dns_last_result,
docker_container: this.docker_container, docker_container: this.docker_container,
docker_host: this.docker_host, docker_host: this.docker_host,

View file

@ -24,50 +24,90 @@ class DnsMonitorType extends MonitorType {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
let dnsMessage = ""; let dnsMessage = "";
let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type, monitor.dns_transport, monitor.doh_query_path);
heartbeat.ping = dayjs().valueOf() - startTime; heartbeat.ping = dayjs().valueOf() - startTime;
const conditions = ConditionExpressionGroup.fromMonitor(monitor); const conditions = ConditionExpressionGroup.fromMonitor(monitor);
let conditionsResult = true; let conditionsResult = true;
const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true;
let records = [];
switch (monitor.dns_resolve_type) { switch (monitor.dns_resolve_type) {
case "A": case "A":
case "AAAA": case "AAAA":
case "TXT": records = dnsRes.answers.map(record => record.address);
dnsMessage = `Records: ${records.join(" | ")}`;
conditionsResult = records.some(record => handleConditions({ record }));
break;
case "PTR": case "PTR":
dnsMessage = `Records: ${dnsRes.join(" | ")}`; records = dnsRes.answers.map(record => record.domain);
conditionsResult = dnsRes.some(record => handleConditions({ record })); dnsMessage = `Records: ${records.join(" | ")}`;
conditionsResult = records.some(record => handleConditions({ record }));
break;
case "TXT":
records = dnsRes.answers.map(record => record.data);
dnsMessage = `Records: ${records.join(" | ")}`;
conditionsResult = records.some(record => handleConditions({ record }));
break; break;
case "CNAME": case "CNAME":
dnsMessage = dnsRes[0]; records.push(dnsRes.answers[0].domain);
conditionsResult = handleConditions({ record: dnsRes[0] }); dnsMessage = records[0];
conditionsResult = handleConditions({ record: records[0] });
break; break;
case "CAA": case "CAA":
dnsMessage = dnsRes[0].issue; // dns2 library currently has not implemented decoding CAA response
conditionsResult = handleConditions({ record: dnsRes[0].issue }); //records.push(dnsRes.answers[0].issue);
records.push("CAA issue placeholder");
dnsMessage = records[0];
conditionsResult = handleConditions({ record: records[0] });
break; break;
case "MX": case "MX":
dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); records = dnsRes.answers.map(record => record.exchange);
conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); dnsMessage = dnsRes.answers.map(record => `Hostname: ${record.exchange} ; Priority: ${record.priority}`).join(" | ");
conditionsResult = records.some(record => handleConditions({ record }));
break; break;
case "NS": case "NS":
dnsMessage = `Servers: ${dnsRes.join(" | ")}`; records = dnsRes.answers.map(record => record.ns);
conditionsResult = dnsRes.some(record => handleConditions({ record })); dnsMessage = `Servers: ${records.join(" | ")}`;
conditionsResult = records.some(record => handleConditions({ record }));
break; break;
case "SOA": 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}`; records.push(dnsRes.answers[0].primary);
conditionsResult = handleConditions({ record: dnsRes.nsname }); dnsMessage = Object.entries({
"Primary-NS": dnsRes.answers[0].primary,
"Hostmaster": dnsRes.answers[0].admin,
"Serial": dnsRes.answers[0].serial,
"Refresh": dnsRes.answers[0].refresh,
"Retry": dnsRes.answers[0].retry,
"Expire": dnsRes.answers[0].expiration,
"MinTTL": dnsRes.answers[0].minimum,
}).map(([name, value]) => {
return `${name}: ${value}`;
}).join("; ");
conditionsResult = handleConditions({ record: records[0] });
break; break;
}
case "SRV": case "SRV":
dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | "); records = dnsRes.answers.map(record => record.target);
conditionsResult = dnsRes.some(record => handleConditions({ record: record.name })); dnsMessage = dnsRes.answers.map((record) => {
return Object.entries({
"Target": record.target,
"Port": record.port,
"Priority": record.priority,
"Weight": record.weight,
}).map(([name, value]) => {
return `${name}: ${value}`;
}).join("; ");
}).join(" | ");
conditionsResult = records.some(record => handleConditions({ record }));
break; break;
} }

View file

@ -826,6 +826,8 @@ let needSetup = false;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server; bean.dns_resolve_server = monitor.dns_resolve_server;
bean.dns_transport = monitor.dns_transport;
bean.doh_query_path = monitor.doh_query_path;
bean.pushToken = monitor.pushToken; bean.pushToken = monitor.pushToken;
bean.docker_container = monitor.docker_container; bean.docker_container = monitor.docker_container;
bean.docker_host = monitor.docker_host; bean.docker_host = monitor.docker_host;

View file

@ -3,7 +3,9 @@ const ping = require("@louislam/ping");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { log, genSecret, badgeConstants } = require("../src/util"); const { log, genSecret, badgeConstants } = require("../src/util");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
const { Resolver } = require("dns"); const { UDPClient, TCPClient, DOHClient } = require("dns2");
const { isIP, isIPv4, isIPv6 } = require("node:net");
const { Address6 } = require("ip-address");
const iconv = require("iconv-lite"); const iconv = require("iconv-lite");
const chardet = require("chardet"); const chardet = require("chardet");
const chroma = require("chroma-js"); const chroma = require("chroma-js");
@ -286,33 +288,63 @@ exports.httpNtlm = function (options, ntlmOptions) {
* @param {string} resolverServer The DNS server to use * @param {string} resolverServer The DNS server to use
* @param {string} resolverPort Port the DNS server is listening on * @param {string} resolverPort Port the DNS server is listening on
* @param {string} rrtype The type of record to request * @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 * @returns {Promise<(string[] | object[] | object)>} DNS response
*/ */
exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype, transport, dohQuery) {
const resolver = new Resolver(); // Parse IPv4 and IPv6 addresses to determine address family and
// Remove brackets from IPv6 addresses so we can re-add them to // add square brackets to IPv6 addresses, following RFC 3986 syntax
// prevent issues with ::1:5300 (::1 port 5300)
resolverServer = resolverServer.replace("[", "").replace("]", ""); resolverServer = resolverServer.replace("[", "").replace("]", "");
resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]); const addressFamily = isIP(resolverServer);
return new Promise((resolve, reject) => { if (addressFamily === 6) {
resolverServer = `[${resolverServer}]`;
}
// If performing reverse (PTR) record lookup, ensure hostname
// syntax follows RFC 1034 / RFC 3596
if (rrtype === "PTR") { if (rrtype === "PTR") {
resolver.reverse(hostname, (err, records) => { if (isIPv4(hostname)) {
if (err) { let octets = hostname.split(".");
reject(err); octets.reverse();
} else { hostname = octets.join(".") + ".in-addr.arpa";
resolve(records); } else if (isIPv6(hostname)) {
let address = new Address6(hostname);
hostname = address.reverseForm();
} }
}
// Transport method determines which client type to use
let resolver;
switch (transport.toUpperCase()) {
case "TCP":
resolver = TCPClient({
dns: resolverServer,
protocol: "tcp:",
port: resolverPort,
}); });
} else { break;
resolver.resolve(hostname, rrtype, (err, records) => { case "DOT":
if (err) { resolver = TCPClient({
reject(err); dns: resolverServer,
} else { protocol: "tls:",
resolve(records); port: resolverPort,
} });
break;
case "DOH":
resolver = DOHClient({
dns: `https://${resolverServer}:${resolverPort}/${dohQuery}`,
});
break;
default:
resolver = UDPClient({
dns: resolverServer,
port: resolverPort,
socketType: "udp" + String(addressFamily),
}); });
} }
});
return resolver(hostname, rrtype);
}; };
/** /**

View file

@ -571,9 +571,11 @@
"deleteMonitorMsg": "Are you sure want to delete this monitor?", "deleteMonitorMsg": "Are you sure want to delete this monitor?",
"deleteMaintenanceMsg": "Are you sure want to delete this maintenance?", "deleteMaintenanceMsg": "Are you sure want to delete this maintenance?",
"deleteNotificationMsg": "Are you sure want to delete this notification for all monitors?", "deleteNotificationMsg": "Are you sure want to delete this notification for all monitors?",
"dnsPortDescription": "DNS server port. Defaults to 53. You can change the port at any time.", "dnsPortDescription": "DNS server port. Defaults to 53. Alternative ports are 443 for DoH and 853 for DoT.",
"resolverserverDescription": "Cloudflare is the default server. You can change the resolver server anytime.", "resolverserverDescription": "Cloudflare is the default server. You can change the resolver server anytime.",
"rrtypeDescription": "Select the RR type you want to monitor", "rrtypeDescription": "Select the RR type you want to monitor",
"dnsTransportDescription": "Select the transport method for querying the DNS server.",
"dohQueryPathDescription": "Set the query path to use for DNS wireformat. Must contain \"{query}\".",
"pauseMonitorMsg": "Are you sure want to pause?", "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.", "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?", "clearEventsMsg": "Are you sure want to delete all events for this monitor?",

View file

@ -367,12 +367,42 @@
<template v-if="monitor.type === 'dns'"> <template v-if="monitor.type === 'dns'">
<div class="my-3"> <div class="my-3">
<label for="dns_resolve_server" class="form-label">{{ $t("Resolver Server") }}</label> <label for="dns_resolve_server" class="form-label">{{ $t("Resolver Server") }}</label>
<input id="dns_resolve_server" v-model="monitor.dns_resolve_server" type="text" class="form-control" :pattern="ipRegex" required> <input id="dns_resolve_server" v-model="monitor.dns_resolve_server" type="text" class="form-control" :pattern="ipOrHostnameRegex" required>
<div class="form-text"> <div class="form-text">
{{ $t("resolverserverDescription") }} {{ $t("resolverserverDescription") }}
</div> </div>
</div> </div>
<div class="my-3">
<label for="doh_query_path" class="form-label">{{ $t("Query Path") }}</label>
<input id="doh_query_path" v-model="monitor.doh_query_path" type="text" class="form-control" :pattern="urlQueryRegex">
<div class="form-text">
{{ $t("dohQueryPathDescription") }}
</div>
</div>
<div class="my-3">
<label for="dns_transport" class="form-label">{{ $t("Transport Method") }}</label>
<!-- :allow-empty="false" is not working, set a default value instead https://github.com/shentao/vue-multiselect/issues/336 -->
<VueMultiselect
id="dns_transport"
v-model="monitor.dns_transport"
:options="dnsTransportOptions"
:multiple="false"
:close-on-select="true"
:clear-on-select="false"
:preserve-search="false"
:placeholder="$t('Select the transport method')"
:preselect-first="false"
:max-height="500"
:taggable="false"
data-testid="resolve-type-select"
></VueMultiselect>
<div class="form-text">
{{ $t("dnsTransportDescription") }}
</div>
</div>
<!-- Port --> <!-- Port -->
<div class="my-3"> <div class="my-3">
<label for="port" class="form-label">{{ $t("Port") }}</label> <label for="port" class="form-label">{{ $t("Port") }}</label>
@ -1061,7 +1091,7 @@ import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue"; import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue"; import TagsManager from "../components/TagsManager.vue";
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts"; import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
import { hostNameRegexPattern } from "../util-frontend"; import { hostNameRegexPattern, urlPathRegexPattern } from "../util-frontend";
import HiddenInput from "../components/HiddenInput.vue"; import HiddenInput from "../components/HiddenInput.vue";
import EditMonitorConditions from "../components/EditMonitorConditions.vue"; import EditMonitorConditions from "../components/EditMonitorConditions.vue";
@ -1088,6 +1118,8 @@ const monitorDefaults = {
accepted_statuscodes: [ "200-299" ], accepted_statuscodes: [ "200-299" ],
dns_resolve_type: "A", dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1", dns_resolve_server: "1.1.1.1",
dns_transport: "UDP",
doh_query_path: "dns-query?dns={query}",
docker_container: "", docker_container: "",
docker_host: null, docker_host: null,
proxyId: null, proxyId: null,
@ -1140,6 +1172,7 @@ export default {
}, },
acceptedStatusCodeOptions: [], acceptedStatusCodeOptions: [],
dnsresolvetypeOptions: [], dnsresolvetypeOptions: [],
dnsTransportOptions: [],
kafkaSaslMechanismOptions: [], kafkaSaslMechanismOptions: [],
ipOrHostnameRegexPattern: hostNameRegexPattern(), ipOrHostnameRegexPattern: hostNameRegexPattern(),
mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true), mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true),
@ -1166,6 +1199,24 @@ export default {
return null; return null;
}, },
ipOrHostnameRegex() {
// Permit either IP address or hostname (127.0.0.1, dns.example.com)
if (! isDev) {
return this.ipOrHostnameRegexPattern;
}
return null;
},
urlQueryRegex() {
// Permit only URL paths with a query parameter ( {query} )
if (! isDev) {
return this.queryRegexPattern;
}
return null;
},
pageName() { pageName() {
let name = "Add New Monitor"; let name = "Add New Monitor";
if (this.isClone) { if (this.isClone) {
@ -1539,6 +1590,13 @@ message HealthCheckResponse {
"TXT", "TXT",
]; ];
let dnsTransportOptions = [
"UDP",
"TCP",
"DoH",
"DoT",
]
let kafkaSaslMechanismOptions = [ let kafkaSaslMechanismOptions = [
"None", "None",
"plain", "plain",
@ -1553,6 +1611,7 @@ message HealthCheckResponse {
this.acceptedStatusCodeOptions = acceptedStatusCodeOptions; this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
this.dnsresolvetypeOptions = dnsresolvetypeOptions; this.dnsresolvetypeOptions = dnsresolvetypeOptions;
this.dnsTransportOptions = dnsTransportOptions;
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions; this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
}, },
methods: { methods: {

View file

@ -109,7 +109,7 @@ 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 * @param {boolean} mqtt whether or not the regex should take into
* account the fact that it is an mqtt uri * account the fact that it is an mqtt uri
* @returns {RegExp} The requested regex * @returns {RegExp} The requested regex
@ -125,6 +125,19 @@ export function hostNameRegexPattern(mqtt = false) {
return `${ipRegexPattern}|${hostNameRegexPattern}`; return `${ipRegexPattern}|${hostNameRegexPattern}`;
} }
/**
* Regex patterns for validating URL paths
* @returns {RegExp} The requested regex
*/
export function urlPathRegexPattern() {
// Ensures a URL path follows query string format
const queryStringRegexPattern = "^/?(([a-zA-Z0-9\\-_%])+/)*[a-zA-Z0-9\\-_%]*\\?([a-zA-Z0-9\\-_%]+=[a-zA-Z0-9\\-_%]*&?)+$";
// Only checks for valid URL path containing "{query}"
const queryRegexPattern = "^[a-zA-Z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=]*{query}[a-zA-Z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=]*$";
return `${queryStringRegexPattern}|${queryRegexPattern}`;
}
/** /**
* Get the tag color options * Get the tag color options
* Shared between components * Shared between components