Write tests, more optimizations

This commit is contained in:
ekrekeler 2025-06-19 03:43:05 -05:00
parent a670386281
commit b0bd8eeac5
No known key found for this signature in database
GPG key ID: 4C66C864B6B00854
6 changed files with 292 additions and 51 deletions

View file

@ -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 ]);
}

View file

@ -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", () => {

View file

@ -629,6 +629,10 @@ $shadow-box-padding: 20px;
}
}
#doh-method {
width: 90px;
}
@media (max-width: 770px) {
.toast-container {
margin-bottom: 100px !important;

View file

@ -371,38 +371,16 @@
<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" ref="dns-resolve-server" v-model="monitor.dnsResolveServer" 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 data-testid="resolve-server-input">
<div class="form-text">
{{ $t("resolverserverDescription") }}
</div>
</div>
<!-- TODO center selected option text -->
<div class="my-3">
<label for="dns-transport" class="form-label">{{ $t("Transport Method") }}</label>
<VueMultiselect
id="dns-transport"
v-model="monitor.dnsTransport"
: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>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1" data-testid="port-input">
<div class="form-text">
{{ $t("dnsPortDescription") }}
</div>
@ -412,7 +390,6 @@
<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 -->
<!-- TODO center selected option text -->
<VueMultiselect
id="dns-resolve-type"
v-model="monitor.dnsResolveType"
@ -432,6 +409,27 @@
{{ $t("rrtypeDescription") }}
</div>
</div>
<div class="my-3">
<label for="dns-transport" class="form-label">{{ $t("Transport Method") }}</label>
<VueMultiselect
id="dns-transport"
v-model="monitor.dnsTransport"
: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="transport-method-select"
></VueMultiselect>
<div class="form-text">
{{ $t("dnsTransportDescription") }}
</div>
</div>
</template>
<!-- Docker Container Name / ID -->
@ -673,11 +671,11 @@
<div class="d-flex">
<div class="my-3 flex-column flex-fill">
<div>
<label for="method" class="form-label">{{ $t("Method") }}</label>
<label for="doh-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">
<select id="doh-method" v-model="monitor.method" class="form-select" data-testid="method-select">
<option value="GET">
GET
</option>
@ -703,7 +701,7 @@
</div>
</div>
<div class="my-3 form-check">
<input id="force-http2" v-model="monitor.forceHttp2" class="form-check-input" type="checkbox">
<input id="force-http2" v-model="monitor.forceHttp2" class="form-check-input" type="checkbox" data-testid="http2-check">
<label class="form-check-label" for="force-http2">
{{ $t("Force HTTP2") }}
</label>
@ -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":

View file

@ -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<Heartbeat>} 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");
});
});

View file

@ -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);
});
});