feat: DNS monitor multi IP address and hostname support for Resolver Servers (#6524)

Co-authored-by: PoleTransformer <you@example.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
PoleTransformer 2026-01-08 10:11:49 +00:00 committed by GitHub
parent 79b3274441
commit 5d9a570c77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 85 additions and 53 deletions

View File

@ -1,12 +1,13 @@
const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util");
const { UP, log } = require("../../src/util");
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 { ConditionExpressionGroup } = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
const { Resolver } = require("node:dns/promises");
const net = require("node:net");
class DnsMonitorType extends MonitorType {
name = "dns";
@ -24,7 +25,8 @@ 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);
const resolverServers = await this.resolveDnsResolverServers(monitor.dns_resolve_server);
let dnsRes = await this.dnsResolve(monitor.hostname, resolverServers, monitor.port, monitor.dns_resolve_type);
heartbeat.ping = dayjs().valueOf() - startTime;
const conditions = ConditionExpressionGroup.fromMonitor(monitor);
@ -88,6 +90,82 @@ class DnsMonitorType extends MonitorType {
heartbeat.msg = dnsMessage;
heartbeat.status = UP;
}
/**
* Parses a comma-separated list of DNS resolver servers and resolves any hostnames
* to their corresponding IPv4 and/or IPv6 addresses.
*
* We are primarily doing this to support hostnames of docker containers like adguard.
*
* - Whitespace is removed from the input string
* - Empty entries are ignored
* - IP literals (IPv4 / IPv6) are accepted as-is
* - Hostnames are resolved to both A and AAAA records in parallel
* - Invalid or unresolvable entries are logged and skipped
* @param {string} dnsResolveServer - Comma-separated list of resolver servers (IPs or hostnames)
* @returns {Promise<Array<string>>} Array of resolved IP addresses
* @throws {Error} If no valid resolver servers could be parsed or resolved
*/
async resolveDnsResolverServers(dnsResolveServer) {
// Remove all spaces, split into array, remove all elements that are empty
const addresses = dnsResolveServer.replace(/\s/g, "").split(",").filter((x) => x !== "");
if (!addresses.length) {
throw new Error("No Resolver Servers specified. Please specifiy at least one resolver server like 1.1.1.1 or a hostname");
}
const resolver = new Resolver();
// Make promises to be resolved concurrently
const promises = addresses.map(async (e) => {
if (net.isIP(e)) { // If IPv4 or IPv6 addr, immediately return
return [ e ];
}
// Otherwise, attempt to resolve hostname
const [ v4, v6 ] = await Promise.allSettled([
resolver.resolve4(e),
resolver.resolve6(e),
]);
const addrs = [
...(v4.status === "fulfilled" ? v4.value : []),
...(v6.status === "fulfilled" ? v6.value : []),
];
if (!addrs.length) {
log.error("DNS", `Invalid resolver server ${e}`);
}
return addrs;
});
// [[ips of hostname1],[ips hostname2],...]
const ips = await Promise.all(promises);
// Append all the ips in [[]] to a single []
const parsed = ips.flat();
// only the resolver resolution can discard an address
// -> no special error message for only the net.isIP case is necessary
if (!parsed.length) {
throw new Error("None of the configured resolver servers could be resolved to an IP address. Please provide a comma-separated list of valid resolver hostnames or IP addresses.");
}
return parsed;
}
/**
* Resolves a given record using the specified DNS server
* @param {string} hostname The hostname of the record to lookup
* @param {string[]} resolverServer Array of DNS server IP addresses to use
* @param {string} resolverPort Port the DNS server is listening on
* @param {string} rrtype The type of record to request
* @returns {Promise<(string[] | object[] | object)>} DNS response
*/
async dnsResolve(hostname, resolverServer, resolverPort, rrtype) {
const resolver = new Resolver();
resolver.setServers(resolverServer.map(server => `[${server}]:${resolverPort}`));
if (rrtype === "PTR") {
return await resolver.reverse(hostname);
}
return await resolver.resolve(hostname, rrtype);
}
}
module.exports = {

View File

@ -6,7 +6,6 @@ const {
PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../src/util");
const passwordHash = require("./password-hash");
const { Resolver } = require("dns");
const iconv = require("iconv-lite");
const chardet = require("chardet");
const chroma = require("chroma-js");
@ -285,41 +284,6 @@ exports.httpNtlm = function (options, ntlmOptions) {
});
};
/**
* Resolves a given record using the specified DNS server
* @param {string} hostname The hostname of the record to lookup
* @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
* @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)
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);
}
});
}
});
};
/**
* Query radius server
* @param {string} hostname Hostname of radius server

View File

@ -147,7 +147,7 @@
"Email": "Email",
"Test": "Test",
"Certificate Info": "Certificate Info",
"Resolver Server": "Resolver Server",
"Resolver Server(s)": "Resolver Server(s)",
"Resource Record Type": "Resource Record Type",
"Last Result": "Last Result",
"Create your admin account": "Create your admin account",
@ -625,7 +625,7 @@
"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.",
"resolverserverDescription": "Cloudflare is the default server. You can specify a comma delimited list of IP addresses or hostnames.",
"rrtypeDescription": "Select the RR type you want to monitor",
"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.",

View File

@ -466,8 +466,8 @@
<!-- For DNS Type -->
<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>
<label for="dns_resolve_server" class="form-label">{{ $t("Resolver Server(s)") }}</label>
<input id="dns_resolve_server" v-model="monitor.dns_resolve_server" type="text" class="form-control" required>
<div class="form-text">
{{ $t("resolverserverDescription") }}
</div>
@ -1352,7 +1352,6 @@ import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import {
genSecret,
isDev,
MAX_INTERVAL_SECOND,
MIN_INTERVAL_SECOND,
sleep,
@ -1502,15 +1501,6 @@ export default {
return this.$t("defaultFriendlyName");
},
ipRegex() {
// Allow to test with simple dns server with port (127.0.0.1:5300)
if (! isDev) {
return this.ipRegexPattern;
}
return null;
},
monitorTypeUrlHost() {
const { type, url, hostname, grpcUrl } = this.monitor;
return {