From b0bd8eeac5212a23e37106c848f5e0ea4a53cbbc Mon Sep 17 00:00:00 2001 From: ekrekeler Date: Thu, 19 Jun 2025 03:43:05 -0500 Subject: [PATCH] Write tests, more optimizations --- server/monitor-types/dns.js | 36 ++++-- server/util-server.js | 17 ++- src/assets/app.scss | 4 + src/pages/EditMonitor.vue | 63 +++++----- test/backend-test/test-dns.js | 181 ++++++++++++++++++++++++++++ test/e2e/specs/monitor-form.spec.js | 42 ++++++- 6 files changed, 292 insertions(+), 51 deletions(-) create mode 100644 test/backend-test/test-dns.js diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index 56565e8af..8b833bd1d 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -4,7 +4,11 @@ const dayjs = require("dayjs"); const { dnsResolve } = require("../util-server"); const { R } = require("redbean-node"); const { ConditionVariable } = require("../monitor-conditions/variables"); -const { defaultStringOperators, defaultNumberOperators, defaultArrayOperators } = require("../monitor-conditions/operators"); +const { + defaultStringOperators, + defaultNumberOperators, + defaultArrayOperators +} = require("../monitor-conditions/operators"); const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); @@ -14,9 +18,9 @@ class DnsMonitorType extends MonitorType { supportsConditions = true; conditionVariables = [ - // A, AAAA, TXT, PTR, NS + // A, AAAA, TXT, NS new ConditionVariable("records", defaultArrayOperators), - // CNAME + // PTR, CNAME new ConditionVariable("hostname", defaultStringOperators), // CAA new ConditionVariable("flags", defaultStringOperators), @@ -43,10 +47,11 @@ class DnsMonitorType extends MonitorType { name: monitor.hostname, rrtype: monitor.dnsResolveType, dnssec: true, // Request DNSSEC information in the response - dnssecCheckingDisabled: monitor.skip_remote_dnssec, + dnssecCheckingDisabled: monitor.skipRemoteDnssec, }; const transportData = { type: monitor.dnsTransport, + timeout: monitor.timeout, ignoreCertErrors: monitor.ignoreTls, dohQueryPath: monitor.dohQueryPath, dohUsePost: monitor.method === "POST", @@ -62,32 +67,37 @@ class DnsMonitorType extends MonitorType { const conditions = ConditionExpressionGroup.fromMonitor(monitor); let conditionsResult = true; const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; - - const records = dnsRes.answers.reduce((results, record) => { + const checkRecord = (results, record) => { // Omit records that are not the same as the requested rrtype if (record.type === monitor.dnsResolveType) { + // Add the record to the array results.push(Buffer.isBuffer(record.data) ? record.data.toString() : record.data); } return results; - }, []); + }; + + const records = dnsRes.answers.reduce(checkRecord, []); // Return down status if no records are provided if (records.length === 0) { - rrtype = null; - dnsMessage = "No records found"; - conditionsResult = false; - + if (dnsRes.authorities.map(auth => auth.type).includes(monitor.dnsResolveType)) { + records.push(...dnsRes.authorities.reduce(checkRecord, [])); + } else { + rrtype = null; + dnsMessage = "No records found"; + conditionsResult = false; + } } switch (rrtype) { case "A": case "AAAA": case "TXT": - case "PTR": case "NS": dnsMessage = records.join(" | "); conditionsResult = handleConditions({ records: records }); break; + case "PTR": case "CNAME": dnsMessage = records[0]; conditionsResult = handleConditions({ hostname: records[0].value }); @@ -134,7 +144,7 @@ class DnsMonitorType extends MonitorType { break; } - if (monitor.dnsLastResult !== dnsMessage && dnsMessage !== undefined) { + if (monitor.dnsLastResult !== dnsMessage && dnsMessage !== undefined && monitor.id !== undefined) { await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]); } diff --git a/server/util-server.js b/server/util-server.js index 77c3ebe44..f3542b934 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -521,6 +521,8 @@ exports.dnsResolve = function (opts, resolverServer, resolverPort, transport) { client.on("error", (err) => { if (err.code === "ETIMEDOUT") { err.message = `Connection to ${socketName} timed out`; + } else if (err.code === "ECONNREFUSED") { + err.message = `Connection to ${socketName} refused`; } reject(err); }); @@ -638,7 +640,15 @@ exports.dnsResolve = function (opts, resolverServer, resolverPort, transport) { }); const req = client.request(headers); req.on("error", (err) => { - err.message = "HTTP/2: " + err.message; + if (err.cause.code === "ETIMEDOUT") { + err = err.cause; + err.message = `Connection to ${socketName} timed out`; + } else if (err.cause.code === "ECONNREFUSED") { + err = err.cause; + err.message = `Connection to ${socketName} refused`; + } else { + err.message = "HTTP/2: " + err.message; + } reject(err); }); req.on("response", (resHeaders) => { @@ -670,6 +680,11 @@ exports.dnsResolve = function (opts, resolverServer, resolverPort, transport) { client.end(); } client.on("error", (err) => { + if (err.code === "ETIMEDOUT") { + err.message = `Connection to ${socketName} timed out`; + } else if (err.code === "ECONNREFUSED") { + err.message = `Connection to ${socketName} refused`; + } reject(err); }); client.on("close", () => { diff --git a/src/assets/app.scss b/src/assets/app.scss index 6ddc99dec..5d6050b87 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -629,6 +629,10 @@ $shadow-box-padding: 20px; } } +#doh-method { + width: 90px; +} + @media (max-width: 770px) { .toast-container { margin-bottom: 100px !important; diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index d6b3d11a7..817b71f53 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -371,38 +371,16 @@ @@ -673,11 +671,11 @@
- +
- @@ -703,7 +701,7 @@
- + @@ -1516,19 +1514,18 @@ message HealthCheckResponse { }, conditionVariables() { + // For DNS monitor, the variables depend on rrtype. Conditions for + // all rrtypes are added to an array in the DnsMonitorType class in + // order to pass to Vue, then identified based on index. if (this.monitor.type === "dns") { - // When the monitor type is DNS, the conditions depend on - // record type. Condition variables are all added to a single - // array defined in server\monitor-types\dns.js in order to - // pass to Vue, then sliced below based on index. const dnsConditionVariables = this.$root.monitorTypeList["dns"]?.conditionVariables; switch (this.monitor.dnsResolveType) { case "A": case "AAAA": case "TXT": - case "PTR": case "NS": return dnsConditionVariables.slice(0, 1); + case "PTR": case "CNAME": return dnsConditionVariables.slice(1, 2); case "CAA": diff --git a/test/backend-test/test-dns.js b/test/backend-test/test-dns.js new file mode 100644 index 000000000..fa2fb998b --- /dev/null +++ b/test/backend-test/test-dns.js @@ -0,0 +1,181 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { DnsMonitorType } = require("../../server/monitor-types/dns"); +const { UP, PENDING } = require("../../src/util"); + +const queryName = "."; +const rrtype = "NS"; +const sampleRecordRegex = /a\.root-servers\.net/; +const bogusServer = "0.0.0.0"; + +/** + * Performs DNS query and checks the result + * @param {object} monitorOpts the parameters for the monitor + * @returns {Promise} the heartbeat produced by the check + */ +async function testDns(monitorOpts) { + if (!monitorOpts.hostname) { + monitorOpts.hostname = queryName; + } + if (!monitorOpts.dnsResolveType) { + monitorOpts.dnsResolveType = rrtype; + } + if (!monitorOpts.conditions) { + monitorOpts.conditions = "[]"; + } + const heartbeat = { + msg: "", + status: PENDING, + }; + const dnsMonitor = new DnsMonitorType(); + + await dnsMonitor.check(monitorOpts, heartbeat, {}); + + return heartbeat; +} + +describe("DNS monitor transport methods", { + concurrency: true +}, () => { + test("DNS (UDP)", async () => { + + const monitor = { + dnsResolveServer: "1.1.1.1", + port: 53, + dnsTransport: "UDP", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS (UDP) timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 53, + dnsTransport: "UDP", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Query to .* timed out/ + }, "Expected query timeout error"); + }); + + test("DNS (TCP)", async () => { + + const monitor = { + dnsResolveServer: "1.1.1.1", + port: 53, + dnsTransport: "TCP", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS (TCP) timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 53, + dnsTransport: "TCP", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over TLS", async () => { + + const monitor = { + dnsResolveServer: "one.one.one.one", + port: 853, + dnsTransport: "DoT", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over TLS timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 853, + dnsTransport: "DoT", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over HTTPS (GET)", async () => { + + const monitor = { + dnsResolveServer: "cloudflare-dns.com", + port: 443, + dnsTransport: "DoH", + dohQueryPath: "dns-query", + method: "GET", + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over HTTPS timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 443, + dnsTransport: "DoH", + timeout: 10000, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + + test("DNS over HTTP/2 (POST)", async () => { + + const monitor = { + dnsResolveServer: "cloudflare-dns.com", + port: 443, + dnsTransport: "DoH", + dohQueryPath: "dns-query", + method: "POST", + forceHttp2: true, + }; + + const heartbeat = await testDns(monitor); + assert.strictEqual(heartbeat.status, UP); + assert.match(heartbeat.msg, sampleRecordRegex); + }); + + test("DNS over HTTP/2 timeout", async () => { + + const monitor = { + dnsResolveServer: bogusServer, + port: 443, + dnsTransport: "DoH", + timeout: 10000, + forceHttp2: true, + }; + + await assert.rejects(testDns(monitor), { + message: /Connection to .* (timed out|refused)/ + }, "Expected connection timeout error"); + }); + +}); diff --git a/test/e2e/specs/monitor-form.spec.js b/test/e2e/specs/monitor-form.spec.js index b41f6ceb9..4936dde37 100644 --- a/test/e2e/specs/monitor-form.spec.js +++ b/test/e2e/specs/monitor-form.spec.js @@ -53,7 +53,7 @@ test.describe("Monitor Form", () => { const friendlyName = "Example DNS NS"; await page.getByTestId("friendly-name-input").fill(friendlyName); - await page.getByTestId("hostname-input").fill("example.com"); + await page.getByTestId("hostname-input").fill("."); const resolveTypeSelect = page.getByTestId("resolve-type-select"); await resolveTypeSelect.click(); @@ -65,9 +65,9 @@ test.describe("Monitor Form", () => { await page.getByTestId("add-condition-button").click(); expect(await page.getByTestId("condition").count()).toEqual(2); // 2 explicitly added - await page.getByTestId("condition-value").nth(0).fill("a.iana-servers.net"); + await page.getByTestId("condition-value").nth(0).fill("a.root-servers.net"); await page.getByTestId("condition-and-or").nth(0).selectOption("or"); - await page.getByTestId("condition-value").nth(1).fill("b.iana-servers.net"); + await page.getByTestId("condition-value").nth(1).fill("b.root-servers.net"); await screenshot(testInfo, page); await page.getByTestId("save-button").click(); @@ -86,7 +86,7 @@ test.describe("Monitor Form", () => { const friendlyName = "Example DNS NS"; await page.getByTestId("friendly-name-input").fill(friendlyName); - await page.getByTestId("hostname-input").fill("example.com"); + await page.getByTestId("hostname-input").fill("."); const resolveTypeSelect = page.getByTestId("resolve-type-select"); await resolveTypeSelect.click(); @@ -105,4 +105,38 @@ test.describe("Monitor Form", () => { await screenshot(testInfo, page); }); + + test("dns transport", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + await screenshot(testInfo, page); + await selectMonitorType(page); + + const friendlyName = "Cloudflare"; + await page.getByTestId("friendly-name-input").fill(friendlyName); + await page.getByTestId("hostname-input").fill("one.one.one.one"); + await page.getByTestId("resolve-server-input").fill("cloudflare-dns.com"); + await page.getByTestId("port-input").fill("443"); + + const resolveTypeSelect = page.getByTestId("resolve-type-select"); + await resolveTypeSelect.click(); + await resolveTypeSelect.getByRole("option", { name: "SOA" }).click(); + + const transportMethodSelect = page.getByTestId("transport-method-select"); + await transportMethodSelect.click(); + await transportMethodSelect.getByRole("option", { name: "DoH" }).click(); + + const httpMethodSelect = page.getByTestId("method-select"); + await httpMethodSelect.selectOption("POST"); + + await page.getByTestId("http2-check").check(); + + await screenshot(testInfo, page); + await page.getByTestId("save-button").click(); + await page.waitForURL("/dashboard/*"); + + expect(page.getByTestId("monitor-status")).toHaveText("up", { ignoreCase: true }); + + await screenshot(testInfo, page); + }); });