mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-21 00:24:04 +02:00
Add support for DoT, DoH, and TCP DNS lookups
This commit is contained in:
parent
2b5f57f92d
commit
8d483a8f02
10 changed files with 213 additions and 46 deletions
|
@ -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);
|
||||
|
|
14
db/knex_migrations/2025-22-02-0000-dns-trasnsport.js
Normal file
14
db/knex_migrations/2025-22-02-0000-dns-trasnsport.js
Normal 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");
|
||||
});
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue