HTTP/2 support, error handling, rename variables, and various bugfixes

This commit is contained in:
ekrekeler 2025-06-15 16:39:21 -05:00
parent 3ed4a2a2cb
commit 47cf7f9d13
No known key found for this signature in database
GPG key ID: 4C66C864B6B00854
11 changed files with 1436 additions and 1181 deletions

View file

@ -87,8 +87,9 @@ async function createTables() {
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("dns_transport", 3);
table.string("doh_query_path", 255); table.boolean("doh_query_path", 255).defaultTo("dns-query");
table.boolean("skip_remote_dnssec").defaultTo(false); table.boolean("force_http2").notNullable().defaultTo(false);
table.boolean("skip_remote_dnssec").notNullable().defaultTo(false);
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

@ -1,9 +1,10 @@
exports.up = function (knex) { exports.up = function (knex) {
return knex.schema return knex.schema
.alterTable("monitor", function (table) { .alterTable("monitor", function (table) {
table.string("dns_transport").notNullable().defaultTo("UDP"); table.string("dns_transport", 3).notNullable().defaultTo("UDP");
table.string("doh_query_path"); table.string("doh_query_path", 255).defaultTo("dns-query");
table.boolean("skip_remote_dnssec").defaultTo(false); table.boolean("force_http2").notNullable().defaultTo(false);
table.boolean("skip_remote_dnssec").notNullable().defaultTo(false);
}); });
}; };
@ -11,6 +12,7 @@ exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) { return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("dns_transport"); table.dropColumn("dns_transport");
table.dropColumn("doh_query_path"); table.dropColumn("doh_query_path");
table.dropColumn("force_http2");
table.dropColumn("skip_remote_dnssec"); table.dropColumn("skip_remote_dnssec");
}); });
}; };

View file

@ -116,12 +116,13 @@ class Monitor extends BeanModel {
packetSize: this.packetSize, packetSize: this.packetSize,
maxredirects: this.maxredirects, maxredirects: this.maxredirects,
accepted_statuscodes: this.getAcceptedStatuscodes(), accepted_statuscodes: this.getAcceptedStatuscodes(),
dns_resolve_type: this.dns_resolve_type, dnsResolveType: this.dnsResolveType,
dns_resolve_server: this.dns_resolve_server, dnsResolveServer: this.dnsResolveServer,
dns_transport: this.dns_transport, dnsTransport: this.dnsTransport,
doh_query_path: this.doh_query_path, dohQueryPath: this.dohQueryPath,
skip_remote_dnssec: this.skip_remote_dnssec, forceHttp2: Boolean(this.forceHttp2),
dns_last_result: this.dns_last_result, skipRemoteDnssec: Boolean(this.skipRemoteDnssec),
dnsLastResult: this.dnsLastResult,
docker_container: this.docker_container, docker_container: this.docker_container,
docker_host: this.docker_host, docker_host: this.docker_host,
proxyId: this.proxy_id, proxyId: this.proxy_id,

View file

@ -39,26 +39,46 @@ class DnsMonitorType extends MonitorType {
* @inheritdoc * @inheritdoc
*/ */
async check(monitor, heartbeat, _server) { async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let dnsMessage = "";
const requestData = { const requestData = {
name: monitor.hostname, name: monitor.hostname,
rrtype: monitor.dns_resolve_type, rrtype: monitor.dnsResolveType,
dnssec: true, // Request DNSSEC information in the response dnssec: true, // Request DNSSEC information in the response
dnssecCheckingDisabled: monitor.skip_remote_dnssec, dnssecCheckingDisabled: monitor.skip_remote_dnssec,
}; };
let dnsRes = await dnsResolve(requestData, monitor.dns_resolve_server, monitor.port, monitor.dns_transport, monitor.doh_query_path); const transportData = {
const records = dnsRes.answers.map(record => { type: monitor.dnsTransport,
return Buffer.isBuffer(record.data) ? record.data.toString() : record.data; ignoreCertErrors: monitor.ignoreTls,
}); dohQueryPath: monitor.dohQueryPath,
dohUsePost: monitor.method === "POST",
dohUseHttp2: monitor.forceHttp2,
};
let startTime = dayjs().valueOf();
let dnsRes = await dnsResolve(requestData, monitor.dnsResolveServer, monitor.port, transportData);
heartbeat.ping = dayjs().valueOf() - startTime; heartbeat.ping = dayjs().valueOf() - startTime;
let dnsMessage = "";
let rrtype = monitor.dnsResolveType;
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;
switch (monitor.dns_resolve_type) { const records = dnsRes.answers.reduce((results, record) => {
// Omit records that are not the same as the requested rrtype
if (record.type === monitor.dnsResolveType) {
results.push(Buffer.isBuffer(record.data) ? record.data.toString() : record.data);
}
return results;
}, []);
// Return down status if no records are provided
if (records.length === 0) {
rrtype = null;
dnsMessage = "No records found";
conditionsResult = false;
}
switch (rrtype) {
case "A": case "A":
case "AAAA": case "AAAA":
case "TXT": case "TXT":
@ -114,7 +134,7 @@ class DnsMonitorType extends MonitorType {
break; break;
} }
if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { if (monitor.dnsLastResult !== dnsMessage && dnsMessage !== undefined) {
await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]); await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]);
} }

View file

@ -824,11 +824,12 @@ let needSetup = false;
bean.packetSize = monitor.packetSize; bean.packetSize = monitor.packetSize;
bean.maxredirects = monitor.maxredirects; bean.maxredirects = monitor.maxredirects;
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.dnsResolveType = monitor.dnsResolveType;
bean.dns_resolve_server = monitor.dns_resolve_server; bean.dnsResolveServer = monitor.dnsResolveServer;
bean.dns_transport = monitor.dns_transport; bean.dnsTransport = monitor.dnsTransport;
bean.doh_query_path = monitor.doh_query_path; bean.dohQueryPath = monitor.dohQueryPath;
bean.skip_remote_dnssec = monitor.skip_remote_dnssec; bean.forceHttp2 = monitor.forceHttp2;
bean.skipRemoteDnssec = monitor.skipRemoteDnssec;
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

@ -24,8 +24,19 @@ const redis = require("redis");
const oidc = require("openid-client"); const oidc = require("openid-client");
const tls = require("tls"); const tls = require("tls");
const https = require("https"); const https = require("https");
const http2 = require("http2");
const url = require("url"); 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 { const {
dictionaries: { dictionaries: {
rfc2865: { file, attributes }, rfc2865: { file, attributes },
@ -287,25 +298,24 @@ exports.httpNtlm = function (options, ntlmOptions) {
}; };
/** /**
* Adapted from https://github.com/hildjj/dohdec/blob/v7.0.2/pkg/dohdec/lib/dnsUtils.js * Encode DNS query packet data to a buffer. Adapted from
* Encode a DNS query packet to a buffer. * https://github.com/hildjj/dohdec/blob/v7.0.2/pkg/dohdec/lib/dnsUtils.js
* @param {object} opts Options for the query. * @param {object} opts Options for the query.
* @param {string} opts.name The name to look up. * @param {string} opts.name The name to look up.
* @param {number} [opts.id=0] ID for the query. SHOULD be 0 for DOH. * @param {number} opts.id ID for the query. SHOULD be 0 for DOH.
* @param {packet.RecordType} [opts.rrtype="A"] The record type to look up. * @param {packet.RecordType} opts.rrtype The record type to look up.
* @param {boolean} [opts.dnssec=false] Request DNSSec information? * @param {boolean} opts.dnssec Request DNSSec information?
* @param {boolean} [opts.dnssecCheckingDisabled=false] Disable DNSSec * @param {boolean} opts.dnssecCheckingDisabled Disable DNSSec validation?
* validation? * @param {string} opts.ecsSubnet Subnet to use for ECS.
* @param {string} [opts.ecsSubnet] Subnet to use for ECS. * @param {number} opts.ecs Number of ECS bits. Defaults to 24 (IPv4) or 56
* @param {number} [opts.ecs] Number of ECS bits. Defaults to 24 or 56 * (IPv6).
* (IPv4/IPv6). * @param {boolean} opts.stream Encode for streaming, with the packet prefixed
* @param {boolean} [opts.stream=false] Encode for streaming, with the packet * by a 2-byte big-endian integer of the number of bytes in the packet.
* prefixed by a 2-byte big-endian integer of the number of bytes in the * @param {number} opts.udpPayloadSize Set a custom UDP payload size (EDNS).
* packet.
* @returns {Buffer} The encoded packet. * @returns {Buffer} The encoded packet.
* @throws {TypeError} opts does not contain a name attribute. * @throws {TypeError} opts does not contain a name attribute.
*/ */
exports.makePacket = function (opts) { exports.makeDnsPacket = function (opts) {
const PAD_SIZE = 128; const PAD_SIZE = 128;
if (!opts?.name) { if (!opts?.name) {
@ -316,7 +326,7 @@ exports.makePacket = function (opts) {
const opt = { const opt = {
name: ".", name: ".",
type: "OPT", type: "OPT",
udpPayloadSize: 4096, udpPayloadSize: opts.udpPayloadSize || 4096,
extendedRcode: 0, extendedRcode: 0,
flags: 0, flags: 0,
flag_do: false, // Setting here has no effect flag_do: false, // Setting here has no effect
@ -369,16 +379,65 @@ exports.makePacket = function (opts) {
return dnsPacket.encode(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 * Resolves a given record using the specified DNS server
* @param {string} opts Options for the query * @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} resolverServer The DNS server to use
* @param {string} resolverPort Port the DNS server is listening on * @param {number} resolverPort Port the DNS server is listening on
* @param {string} transport The transport method to use * @param {object} transport The transport method and options
* @param {string} dohQuery The query path used only for DoH * @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 * @returns {Promise<(string[] | object[] | object)>} DNS response
*/ */
exports.dnsResolve = function (opts, resolverServer, resolverPort, transport, dohQuery) { 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 // Parse IPv4 and IPv6 addresses to determine address family and
// add square brackets to IPv6 addresses, following RFC 3986 syntax // add square brackets to IPv6 addresses, following RFC 3986 syntax
resolverServer = resolverServer.replace("[", "").replace("]", ""); resolverServer = resolverServer.replace("[", "").replace("]", "");
@ -404,29 +463,42 @@ exports.dnsResolve = function (opts, resolverServer, resolverPort, transport, do
if (opts.id == null) { if (opts.id == null) {
// Set query ID to "0" for HTTP cache friendlyness on DoH requests. // Set query ID to "0" for HTTP cache friendlyness on DoH requests.
// See https://github.com/mafintosh/dns-packet/issues/77 // See https://github.com/mafintosh/dns-packet/issues/77
opts.id = (transport.toUpperCase() === "DOH") ? 0 : Math.floor(Math.random() * 65534) + 1; 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 client;
let resolver = null; let resolver;
// Transport method determines which client type to use // Transport method determines which client type to use
const isSecure = [ "DOH", "DOT" ].includes(transport.toUpperCase()); switch (method) {
switch (transport.toUpperCase()) {
case "TCP": case "TCP":
case "DOT": { case "DOT": {
opts.stream = true;
const buf = exports.makePacket(opts);
if (isSecure) { if (isSecure) {
const options = { const options = {
port: resolverPort, port: resolverPort,
host: resolverServer, host: resolverServer,
// TODO: Option for relaxing certificate validation rejectUnauthorized: !skipCertCheck,
secureContext: tls.createSecureContext({ secureContext: tls.createSecureContext({
secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.2",
}), }),
}; };
// TODO: Error handling for untrusted or expired cert // 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, () => { client = tls.connect(options, () => {
log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`);
client.write(buf); client.write(buf);
@ -438,99 +510,201 @@ exports.dnsResolve = function (opts, resolverServer, resolverPort, transport, do
client.write(buf); client.write(buf);
}); });
} }
client.on("close", () => {
log.debug("dns", "Connection closed");
});
resolver = new Promise((resolve, reject) => { 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 data = Buffer.alloc(0);
let expectedLength = 0; let expectedLength = 0;
let isValidLength = false;
client.on("error", (err) => {
reject(err);
});
client.setTimeout(timeout, () => {
client.destroy();
reject({ message: "Connection timed out" });
});
client.on("data", (chunk) => { client.on("data", (chunk) => {
if (data.length === 0) { if (data.length === 0) {
if (chunk.byteLength > 1) { if (chunk.byteLength > 1) {
const plen = chunk.readUInt16BE(0); expectedLength = chunk.readUInt16BE(0);
expectedLength = plen; if (expectedLength < 12) {
if (plen < 12) { reject({ message: lenErrMsg });
reject("Response received is below DNS minimum packet length");
} }
} }
} }
data = Buffer.concat([ data, chunk ]); data = Buffer.concat([ data, chunk ]);
if (data.byteLength >= expectedLength) { if (data.byteLength - 2 === expectedLength) {
isValidLength = true;
client.destroy(); client.destroy();
const response = dnsPacket.streamDecode(data); const response = exports.decodeDnsPacket(data, true, reject);
log.debug("dns", "Response decoded");
resolve(response); resolve(response);
} }
}); });
client.on("close", () => {
log.debug("dns", "Connection closed");
if (!isValidLength) {
reject({ message: lenErrMsg });
}
});
}); });
break; break;
} }
case "DOH": { case "DOH": {
const buf = exports.makePacket(opts); const queryPath = dohUsePost ? dohQuery : `${dohQuery}?dns=${buf.toString("base64url")}`;
// TODO: implement POST requests for wireformat and JSON const requestURL = url.parse(`https://${resolverServer}:${resolverPort}/${queryPath}`, true);
dohQuery = dohQuery || "dns-query?dns={query}"; const mimeType = "application/dns-message";
dohQuery = dohQuery.replace("{query}", buf.toString("base64url"));
const requestURL = url.parse(`https://${resolverServer}:${resolverPort}/${dohQuery}`, true);
const options = { const options = {
hostname: requestURL.hostname, hostname: requestURL.hostname,
port: requestURL.port, port: requestURL.port,
path: requestURL.path, path: requestURL.path,
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/dns-message", "accept": mimeType,
}, },
// TODO: Option for relaxing certificate validation 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) => { resolver = new Promise((resolve, reject) => {
client = https.request(options, (response) => { /**
* 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 failed with status code ${statusCode}` });
return;
} else if (contentType !== mimeType) {
reject({ message: `Content-Type was "${contentType}", expected ${mimeType}` });
return;
}
// Read the response body into a buffer
let data = Buffer.alloc(0); let data = Buffer.alloc(0);
response.on("data", (chunk) => { httpResponse.on("data", (chunk) => {
data = Buffer.concat([ data, chunk ]); data = Buffer.concat([ data, chunk ]);
}); });
response.on("end", () => { httpResponse.on("end", () => {
const response = dnsPacket.decode(data); const response = exports.decodeDnsPacket(data, false, reject);
log.debug("dns", "Response decoded");
resolve(response); resolve(response);
}); });
}); };
client.on("socket", (socket) => { if (dohUseHttp2) {
socket.on("secureConnect", () => { 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 timed out" });
});
client.on("connect", () => {
log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`);
}); });
}); const req = client.request(headers);
req.on("error", (err) => {
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 timed out" });
});
client.on("socket", (socket) => {
socket.on("secureConnect", () => {
log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`);
});
});
if (dohUsePost) {
client.write(buf);
}
client.end();
}
client.on("error", (err) => { client.on("error", (err) => {
reject(err); reject(err);
}); });
client.on("close", () => { client.on("close", () => {
log.debug("dns", "Connection closed"); log.debug("dns", "Connection closed");
}); });
client.write(buf);
client.end();
}); });
break; break;
} }
//case "UDP": case "UDP":
default: { default: {
const buf = exports.makePacket(opts); if (addressFamily === 0) {
client = dgram.createSocket("udp" + String(addressFamily)); return new Promise((resolve, reject) => {
client.on("connect", () => { reject({ message: "Resolver server must be IP address for UDP transport method" });
log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`); });
}); }
client.on("close", () => { client = dgram.createSocket(`udp${addressFamily}`);
log.debug("dns", "Connection closed");
});
resolver = new Promise((resolve, reject) => { resolver = new Promise((resolve, reject) => {
let timer;
client.on("message", (rdata, rinfo) => { client.on("message", (rdata, rinfo) => {
client.close(); client.close();
const response = dnsPacket.decode(rdata); const response = exports.decodeDnsPacket(rdata, false, reject);
log.debug("dns", "Response decoded");
resolve(response); resolve(response);
}); });
client.on("error", (err) => { client.on("error", (err) => {
clearTimeout(timer);
reject(err); reject(err);
}); });
client.on("listening", () => {
log.debug("dns", `Connected to ${resolverServer}:${resolverPort}`);
timer = setTimeout(() => {
reject({ message: "Query timed out" });
client.close();
}, timeout);
});
client.on("close", () => {
clearTimeout(timer);
log.debug("dns", "Connection closed");
});
}); });
client.send(buf, 0, buf.length, resolverPort, resolverServer); client.send(buf, 0, buf.length, resolverPort, resolverServer);
} }

View file

@ -48,6 +48,7 @@
.multiselect__single { .multiselect__single {
line-height: 14px; line-height: 14px;
margin-bottom: 0; margin-bottom: 0;
vertical-align: middle;
} }
.dark { .dark {

File diff suppressed because it is too large Load diff

View file

@ -31,9 +31,9 @@
<br> <br>
<span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span> <span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span>
</span> </span>
<span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }} <span v-if="monitor.type === 'dns'">[{{ monitor.dnsResolveType }}] {{ monitor.hostname }}
<br> <br>
<span>{{ $t("Last Result") }}:</span> <span class="keyword">{{ monitor.dns_last_result }}</span> <span>{{ $t("Last Result") }}:</span> <span class="keyword">{{ monitor.dnsLastResult }}</span>
</span> </span>
<span v-if="monitor.type === 'docker'">Docker container: {{ monitor.docker_container }}</span> <span v-if="monitor.type === 'docker'">Docker container: {{ monitor.docker_container }}</span>
<span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }}</span> <span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }}</span>

View file

@ -370,18 +370,19 @@
<!-- For DNS Type --> <!-- For DNS Type -->
<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="dnsResolverRegex" required> <input id="dns-resolve-server" ref="dns-resolve-server" v-model="monitor.dnsResolveServer" type="text" class="form-control" :pattern="dnsResolverRegex" required>
<div class="form-text"> <div class="form-text">
{{ $t("resolverserverDescription") }} {{ $t("resolverserverDescription") }}
</div> </div>
</div> </div>
<!-- TODO center selected option text -->
<div class="my-3"> <div class="my-3">
<label for="dns_transport" class="form-label">{{ $t("Transport Method") }}</label> <label for="dns-transport" class="form-label">{{ $t("Transport Method") }}</label>
<VueMultiselect <VueMultiselect
id="dns_transport" id="dns-transport"
v-model="monitor.dns_transport" v-model="monitor.dnsTransport"
:options="dnsTransportOptions" :options="dnsTransportOptions"
:multiple="false" :multiple="false"
:close-on-select="true" :close-on-select="true"
@ -408,12 +409,13 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="dns_resolve_type" class="form-label">{{ $t("Resource Record Type") }}</label> <label for="dns-resolve-type" class="form-label">{{ $t("Resource Record Type") }}</label>
<!-- :allow-empty="false" is not working, set a default value instead https://github.com/shentao/vue-multiselect/issues/336 --> <!-- :allow-empty="false" is not working, set a default value instead https://github.com/shentao/vue-multiselect/issues/336 -->
<!-- TODO center selected option text -->
<VueMultiselect <VueMultiselect
id="dns_resolve_type" id="dns-resolve-type"
v-model="monitor.dns_resolve_type" v-model="monitor.dnsResolveType"
:options="dnsresolvetypeOptions" :options="dnsresolvetypeOptions"
:multiple="false" :multiple="false"
:close-on-select="true" :close-on-select="true"
@ -646,10 +648,10 @@
</div> </div>
</div> </div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'redis' " class="my-3 form-check"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'redis' || (monitor.type === 'dns' && isSecureDnsTransport)" class="my-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value=""> <input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls"> <label class="form-check-label" for="ignore-tls">
{{ monitor.type === "redis" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }} {{ monitor.type === "redis" || monitor.type === "dns" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }}
</label> </label>
</div> </div>
@ -668,18 +670,51 @@
<!-- Advanced DNS monitor settings --> <!-- Advanced DNS monitor settings -->
<div v-if="monitor.type === 'dns'" class="my-3"> <div v-if="monitor.type === 'dns'" class="my-3">
<div v-if="dohSelected"> <div v-if="dohSelected">
<label for="doh_query_path" class="form-label">{{ $t("Query Path") }}</label>
<div class="d-flex"> <div class="d-flex">
<label for="doh_query_path" class="px-2 fs-5">/</label> <div class="my-3 flex-column flex-fill">
<input id="doh_query_path" v-model="monitor.doh_query_path" type="text" class="form-control" :pattern="urlQueryRegex" placeholder="dns-query?dns={query}"> <div>
<label for="method" class="form-label">{{ $t("Method") }}</label>
</div>
<div class="d-flex flex-row">
<div class="d-inline-flex">
<select id="method" v-model="monitor.method" class="form-select">
<option value="GET">
GET
</option>
<option value="POST">
POST
</option>
</select>
</div>
<div class="mx-3 flex-fill">
<input :value="dohDisplayUrl" type="button" class="form-control text-center" @click="focusElement('dns-resolve-server')">
</div>
</div>
<div class="form-text">
{{ $t("dohHttpMethodDescription") }}
</div>
</div>
<div class="my-3 flex-column flex-fill">
<label for="doh-query-path" class="form-label">{{ $t("Query Path") }}</label>
<input id="doh-query-path" v-model="monitor.dohQueryPath" type="text" class="form-control" :pattern="urlQueryRegex" placeholder="dns-query">
<div class="form-text">
{{ $t("dohQueryPathDescription") }}
</div>
</div>
</div> </div>
<div class="form-text"> <div class="my-3 form-check">
{{ $t("dohQueryPathDescription") + ' "{query}".' }} <input id="force-http2" v-model="monitor.forceHttp2" class="form-check-input" type="checkbox">
<label class="form-check-label" for="force-http2">
{{ $t("Force HTTP2") }}
</label>
<div class="form-text">
{{ $t("forceHttp2") }}
</div>
</div> </div>
</div> </div>
<div class="form-check"> <div class="my-3 form-check">
<input id="skip_remote_dnssec" v-model="monitor.skip_remote_dnssec" class="form-check-input" type="checkbox" value=""> <input id="skip_remote_dnssec" v-model="monitor.skip_remote_dnssec" class="form-check-input" type="checkbox">
<label class="form-check-label" for="skip_remote_dnssec"> <label class="form-check-label" for="skip_remote_dnssec">
{{ $t("Skip Remote DNSSEC Verification") }} {{ $t("Skip Remote DNSSEC Verification") }}
</label> </label>
@ -1135,9 +1170,11 @@ const monitorDefaults = {
expiryNotification: false, expiryNotification: false,
maxredirects: 10, maxredirects: 10,
accepted_statuscodes: [ "200-299" ], accepted_statuscodes: [ "200-299" ],
dns_resolve_type: "A", dnsResolveType: "A",
dns_resolve_server: "1.1.1.1", dnsResolveServer: "1.1.1.1",
dns_transport: "UDP", dnsTransport: "UDP",
dohQueryPath: "dns-query",
forceHttp2: false,
skip_remote_dnssec: false, skip_remote_dnssec: false,
docker_container: "", docker_container: "",
docker_host: null, docker_host: null,
@ -1235,7 +1272,7 @@ export default {
// Permit IP address for TCP/UDP resolvers, hostname for DoH/DoT // Permit IP address for TCP/UDP resolvers, hostname for DoH/DoT
if (! isDev) { if (! isDev) {
switch (this.monitor.dns_transport) { switch (this.monitor.dnsTransport) {
case "UDP": case "UDP":
case "TCP": case "TCP":
return this.ipRegexPattern.source; return this.ipRegexPattern.source;
@ -1484,7 +1521,7 @@ message HealthCheckResponse {
// array defined in server\monitor-types\dns.js in order to // array defined in server\monitor-types\dns.js in order to
// pass to Vue, then sliced below based on index. // pass to Vue, then sliced below based on index.
const dnsConditionVariables = this.$root.monitorTypeList["dns"]?.conditionVariables; const dnsConditionVariables = this.$root.monitorTypeList["dns"]?.conditionVariables;
switch (this.monitor.dns_resolve_type) { switch (this.monitor.dnsResolveType) {
case "A": case "A":
case "AAAA": case "AAAA":
case "TXT": case "TXT":
@ -1508,8 +1545,18 @@ message HealthCheckResponse {
}, },
dohSelected() { dohSelected() {
return this.monitor.dns_transport === "DoH"; return this.monitor.dnsTransport === "DoH";
} },
dohDisplayUrl() {
const port = (this.monitor.port !== 443) ? `:${this.monitor.port}` : "";
return `https://${this.monitor.dnsResolveServer}${port}/`;
},
isSecureDnsTransport() {
return [ "DoH", "DoT", "DoQ" ].includes(this.monitor.dnsTransport);
},
}, },
watch: { watch: {
"$root.proxyList"() { "$root.proxyList"() {
@ -1977,6 +2024,11 @@ message HealthCheckResponse {
} }
}, },
focusElement(refId) {
// Focus the element that has a defined reference
this.$refs[refId].focus();
},
}, },
}; };
</script> </script>

View file

@ -165,7 +165,7 @@ export function dnsNameRegexPattern() {
* @param {boolean} qstr whether or not the url follows query string format * @param {boolean} qstr whether or not the url follows query string format
* @returns {RegExp} The requested regex * @returns {RegExp} The requested regex
*/ */
export function urlPathRegexPattern(qstr = false) { export function urlPathRegexPattern(qstr = true) {
// Ensures a URL path follows query string format // 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\-_%]*&?)+$/; 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}" // Only checks for valid URL path containing "{query}"