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.string("dns_resolve_type", 5);
table.string("dns_resolve_server", 255);
table.string("dns_transport", 3);
table.string("doh_query_path", 255);
table.string("dns_last_result", 255);
table.integer("retry_interval").notNullable().defaultTo(0);
table.string("push_token", 20).defaultTo(null);

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -367,12 +367,42 @@
<template v-if="monitor.type === 'dns'">
<div class="my-3">
<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">
{{ $t("resolverserverDescription") }}
</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 -->
<div class="my-3">
<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 TagsManager from "../components/TagsManager.vue";
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 EditMonitorConditions from "../components/EditMonitorConditions.vue";
@ -1088,6 +1118,8 @@ const monitorDefaults = {
accepted_statuscodes: [ "200-299" ],
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
dns_transport: "UDP",
doh_query_path: "dns-query?dns={query}",
docker_container: "",
docker_host: null,
proxyId: null,
@ -1140,6 +1172,7 @@ export default {
},
acceptedStatusCodeOptions: [],
dnsresolvetypeOptions: [],
dnsTransportOptions: [],
kafkaSaslMechanismOptions: [],
ipOrHostnameRegexPattern: hostNameRegexPattern(),
mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true),
@ -1166,6 +1199,24 @@ export default {
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() {
let name = "Add New Monitor";
if (this.isClone) {
@ -1539,6 +1590,13 @@ message HealthCheckResponse {
"TXT",
];
let dnsTransportOptions = [
"UDP",
"TCP",
"DoH",
"DoT",
]
let kafkaSaslMechanismOptions = [
"None",
"plain",
@ -1553,6 +1611,7 @@ message HealthCheckResponse {
this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
this.dnsresolvetypeOptions = dnsresolvetypeOptions;
this.dnsTransportOptions = dnsTransportOptions;
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
},
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
* account the fact that it is an mqtt uri
* @returns {RegExp} The requested regex
@ -125,6 +125,19 @@ export function hostNameRegexPattern(mqtt = false) {
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
* Shared between components