Add DNS lookup tool and tests

This commit is contained in:
Keith Casko
2026-07-23 02:24:31 -04:00
parent dd5092d352
commit 28ccd40686
9 changed files with 666 additions and 30 deletions
+139 -14
View File
@@ -1,5 +1,32 @@
// Get references to the page elements we need to update. // ---------------------------------------------------------
const form = document.getElementById("uptime-form"); // Tool tab navigation
// ---------------------------------------------------------
const toolTabs = document.querySelectorAll(".tool-tab");
const toolPanels = document.querySelectorAll(".tool-panel");
toolTabs.forEach((tab) => {
tab.addEventListener("click", () => {
const selectedToolId = tab.dataset.tool;
// Update the active tab.
toolTabs.forEach((currentTab) => {
currentTab.classList.toggle("active", currentTab === tab);
});
// Show only the selected tool panel.
toolPanels.forEach((panel) => {
panel.hidden = panel.id !== selectedToolId;
});
});
});
// ---------------------------------------------------------
// Website Status Checker
// ---------------------------------------------------------
const uptimeForm = document.getElementById("uptime-form");
const urlInput = document.getElementById("url-input"); const urlInput = document.getElementById("url-input");
const checkButton = document.getElementById("check-button"); const checkButton = document.getElementById("check-button");
const statusMessage = document.getElementById("status-message"); const statusMessage = document.getElementById("status-message");
@@ -12,14 +39,11 @@ const redirectCount = document.getElementById("redirect-count");
const checkedAt = document.getElementById("checked-at"); const checkedAt = document.getElementById("checked-at");
const finalUrl = document.getElementById("final-url"); const finalUrl = document.getElementById("final-url");
// Listen for the form submission. uptimeForm.addEventListener("submit", async (event) => {
form.addEventListener("submit", async (event) => {
// Prevent the browser from reloading the page.
event.preventDefault(); event.preventDefault();
const url = urlInput.value.trim(); const url = urlInput.value.trim();
// Reset the interface before starting a new check.
statusMessage.textContent = "Checking website..."; statusMessage.textContent = "Checking website...";
statusMessage.className = "status-message"; statusMessage.className = "status-message";
resultPanel.hidden = true; resultPanel.hidden = true;
@@ -37,17 +61,14 @@ form.addEventListener("submit", async (event) => {
const result = await response.json(); const result = await response.json();
// Handle API-level errors.
if (!response.ok) { if (!response.ok) {
throw new Error(result.error || "The request failed."); throw new Error(result.error || "The request failed.");
} }
// Handle unreachable or blocked destinations.
if (!result.online) { if (!result.online) {
throw new Error(result.error || "The website could not be reached."); throw new Error(result.error || "The website could not be reached.");
} }
// Fill in the successful result.
onlineBadge.textContent = "Online"; onlineBadge.textContent = "Online";
onlineBadge.className = "status-badge online"; onlineBadge.className = "status-badge online";
@@ -62,15 +83,119 @@ form.addEventListener("submit", async (event) => {
statusMessage.textContent = "Website check completed."; statusMessage.textContent = "Website check completed.";
resultPanel.hidden = false; resultPanel.hidden = false;
} catch (error) { } catch (error) {
// Show a safe, readable message to the user.
statusMessage.textContent = error.message; statusMessage.textContent = error.message;
statusMessage.className = "status-message error"; statusMessage.className = "status-message error";
onlineBadge.textContent = "Offline";
onlineBadge.className = "status-badge offline";
} finally { } finally {
// Restore the button whether the request succeeded or failed.
checkButton.disabled = false; checkButton.disabled = false;
checkButton.textContent = "Check Website"; checkButton.textContent = "Check Website";
} }
}); });
// ---------------------------------------------------------
// DNS Lookup
// ---------------------------------------------------------
const dnsForm = document.getElementById("dns-form");
const hostnameInput = document.getElementById("hostname-input");
const dnsButton = document.getElementById("dns-button");
const dnsMessage = document.getElementById("dns-message");
const dnsResultPanel = document.getElementById("dns-result-panel");
const dnsHostname = document.getElementById("dns-hostname");
const dnsRecords = document.getElementById("dns-records");
const dnsCheckedAt = document.getElementById("dns-checked-at");
// Convert one DNS record into readable text.
function formatDnsRecord(recordType, record) {
if (recordType === "MX" && typeof record === "object") {
return `${record.exchange} (priority ${record.priority})`;
}
return String(record);
}
// Build one visible record group.
function createDnsRecordGroup(recordType, records) {
const group = document.createElement("section");
group.className = "dns-record-group";
const heading = document.createElement("h4");
heading.textContent = recordType;
group.appendChild(heading);
if (!Array.isArray(records) || records.length === 0) {
const emptyMessage = document.createElement("p");
emptyMessage.className = "empty-record";
emptyMessage.textContent = "No records found.";
group.appendChild(emptyMessage);
return group;
}
const list = document.createElement("ul");
list.className = "dns-record-list";
records.forEach((record) => {
const item = document.createElement("li");
item.textContent = formatDnsRecord(recordType, record);
list.appendChild(item);
});
group.appendChild(list);
return group;
}
dnsForm.addEventListener("submit", async (event) => {
event.preventDefault();
const hostname = hostnameInput.value.trim();
dnsMessage.textContent = "Looking up DNS records...";
dnsMessage.className = "status-message";
dnsResultPanel.hidden = true;
dnsButton.disabled = true;
dnsButton.textContent = "Looking Up...";
try {
const response = await fetch("/api/dns", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ hostname }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "The DNS lookup failed.");
}
dnsHostname.textContent = result.hostname;
dnsRecords.replaceChildren();
const recordOrder = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"];
recordOrder.forEach((recordType) => {
const group = createDnsRecordGroup(
recordType,
result.records[recordType]
);
dnsRecords.appendChild(group);
});
dnsCheckedAt.textContent =
`Checked ${new Date(result.checkedAt).toLocaleString()}`;
dnsMessage.textContent = "DNS lookup completed.";
dnsResultPanel.hidden = false;
} catch (error) {
dnsMessage.textContent = error.message;
dnsMessage.className = "status-message error";
} finally {
dnsButton.disabled = false;
dnsButton.textContent = "Look Up DNS";
}
});
+81 -13
View File
@@ -3,27 +3,55 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<!-- Makes the page scale correctly on phones and tablets. --> <!-- Makes the layout work correctly on phones and tablets. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ByteGeist Toolbox</title> <title>ByteGeist Toolbox</title>
<!-- Load the stylesheet from the public folder. --> <!-- Load the site stylesheet. -->
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<main class="page-shell"> <main class="page-shell">
<section class="tool-panel"> <header class="site-header">
<p class="eyebrow">BYTEGEIST TOOLBOX</p>
<h1>Practical Website and Network Tools</h1>
<p class="description">
Check a website's current status or inspect its public DNS records.
</p>
</header>
<nav class="tool-tabs" aria-label="Tool selection">
<button
id="status-tab"
class="tool-tab active"
type="button"
data-tool="status-tool"
>
Website Status
</button>
<button
id="dns-tab"
class="tool-tab"
type="button"
data-tool="dns-tool"
>
DNS Lookup
</button>
</nav>
<!-- Website Status Checker -->
<section id="status-tool" class="tool-panel">
<header class="tool-header"> <header class="tool-header">
<p class="eyebrow">BYTEGEIST TOOLBOX</p> <h2>Website Status Checker</h2>
<h1>Website Status Checker</h1> <p class="tool-description">
<p class="description"> Check whether a public website is responding right now and view its
Check whether a public website is responding right now and view its status code, status code, response time, redirects, and final destination.
response time, redirects, and final destination.
</p> </p>
</header> </header>
<form id="uptime-form" class="uptime-form"> <form id="uptime-form" class="tool-form">
<label for="url-input">Website URL</label> <label for="url-input">Website URL</label>
<div class="input-row"> <div class="input-row">
@@ -42,13 +70,11 @@
</div> </div>
</form> </form>
<!-- JavaScript updates this message during loading and errors. -->
<p id="status-message" class="status-message" aria-live="polite"></p> <p id="status-message" class="status-message" aria-live="polite"></p>
<!-- Hidden until a result is returned. -->
<section id="result-panel" class="result-panel" hidden> <section id="result-panel" class="result-panel" hidden>
<div class="result-heading"> <div class="result-heading">
<h2>Current Status</h2> <h3>Current Status</h3>
<span id="online-badge" class="status-badge"></span> <span id="online-badge" class="status-badge"></span>
</div> </div>
@@ -85,9 +111,51 @@
</div> </div>
</section> </section>
</section> </section>
<!-- DNS Lookup -->
<section id="dns-tool" class="tool-panel" hidden>
<header class="tool-header">
<h2>DNS Lookup</h2>
<p class="tool-description">
Look up public A, AAAA, CNAME, MX, TXT, and NS records for a domain.
</p>
</header>
<form id="dns-form" class="tool-form">
<label for="hostname-input">Hostname or website URL</label>
<div class="input-row">
<input
id="hostname-input"
name="hostname"
type="text"
placeholder="example.com"
autocomplete="off"
required
>
<button id="dns-button" type="submit">
Look Up DNS
</button>
</div>
</form>
<p id="dns-message" class="status-message" aria-live="polite"></p>
<section id="dns-result-panel" class="result-panel" hidden>
<div class="result-heading">
<h3>DNS Records</h3>
<span id="dns-hostname" class="hostname-badge"></span>
</div>
<div id="dns-records" class="dns-records"></div>
<p id="dns-checked-at" class="dns-checked-at"></p>
</section>
</section>
</main> </main>
<!-- Load the browser JavaScript after the HTML is available. --> <!-- Load browser JavaScript after the page elements exist. -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+108
View File
@@ -234,3 +234,111 @@ button:disabled {
width: 100%; width: 100%;
} }
} }
.site-header {
width: min(760px, 100%);
margin-bottom: 18px;
}
.site-header h1 {
margin-bottom: 10px;
}
.tool-tabs {
display: flex;
width: min(760px, 100%);
gap: 8px;
margin-bottom: 14px;
}
.tool-tab {
padding: 10px 14px;
border: 1px solid var(--border);
background: #0d1523;
color: var(--muted);
}
.tool-tab:hover {
background: var(--surface-light);
color: var(--text);
}
.tool-tab.active {
border-color: var(--accent);
background: rgba(76, 166, 255, 0.12);
color: var(--accent);
}
.tool-header h2 {
margin-bottom: 8px;
font-size: 1.6rem;
}
.tool-description {
margin-bottom: 24px;
color: var(--muted);
line-height: 1.6;
}
.hostname-badge {
padding: 5px 10px;
border-radius: 999px;
background: rgba(76, 166, 255, 0.12);
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
overflow-wrap: anywhere;
}
.dns-records {
display: grid;
gap: 12px;
}
.dns-record-group {
border: 1px solid var(--border);
border-radius: 8px;
background: #0d1523;
overflow: hidden;
}
.dns-record-group h4 {
margin: 0;
padding: 11px 14px;
border-bottom: 1px solid var(--border);
color: var(--accent);
font-size: 0.9rem;
letter-spacing: 0.08em;
}
.dns-record-list {
margin: 0;
padding: 12px 14px 12px 34px;
}
.dns-record-list li {
margin: 5px 0;
overflow-wrap: anywhere;
}
.empty-record {
margin: 0;
padding: 12px 14px;
color: var(--muted);
}
.dns-checked-at {
margin: 14px 0 0;
color: var(--muted);
font-size: 0.84rem;
}
@media (max-width: 640px) {
.tool-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.tool-tab {
width: 100%;
}
}
+58
View File
@@ -0,0 +1,58 @@
// Maximum length allowed by the DNS hostname standard.
const MAX_HOSTNAME_LENGTH = 253;
// Validate and normalize a submitted hostname.
function validateHostname(value) {
// Make sure the submitted value is text and is not empty.
if (typeof value !== "string" || value.trim() === "") {
throw new Error("A hostname is required.");
}
// Remove surrounding spaces and normalize uppercase letters.
let hostname = value.trim().toLowerCase();
// Allow users to paste a complete website URL.
// Example: https://example.com/page becomes example.com.
if (hostname.startsWith("http://") || hostname.startsWith("https://")) {
try {
hostname = new URL(hostname).hostname.toLowerCase();
} catch {
throw new Error("Enter a valid hostname or website URL.");
}
}
// Remove a trailing period from a fully qualified hostname.
// Example: example.com. becomes example.com.
if (hostname.endsWith(".")) {
hostname = hostname.slice(0, -1);
}
// DNS hostnames cannot exceed 253 characters.
if (hostname.length > MAX_HOSTNAME_LENGTH) {
throw new Error("The hostname is too long.");
}
// Require normal DNS labels separated by periods.
// Letters, numbers, and interior hyphens are allowed.
const hostnamePattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
if (!hostnamePattern.test(hostname)) {
throw new Error("Enter a valid public hostname, such as example.com.");
}
// Block local-only hostnames.
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local")
) {
throw new Error("Local hostnames are not allowed.");
}
return hostname;
}
module.exports = {
validateHostname,
};
+26
View File
@@ -0,0 +1,26 @@
const express = require("express");
const { lookupDns } = require("../services/dnsService");
const { validateHostname } = require("../middleware/validateHostname");
const { uptimeRateLimiter } = require("../middleware/rateLimit");
const router = express.Router();
// POST /api/dns
router.post("/", uptimeRateLimiter, async (req, res) => {
try {
// Validate and normalize the submitted hostname.
const hostname = validateHostname(req.body.hostname);
// Perform the DNS lookup.
const result = await lookupDns(hostname);
return res.json(result);
} catch (error) {
return res.status(400).json({
error: error.message || "The DNS lookup failed.",
});
}
});
module.exports = router;
+6
View File
@@ -7,6 +7,9 @@ const path = require("path");
// Import the uptime API route. // Import the uptime API route.
const uptimeRouter = require("./routes/uptime"); const uptimeRouter = require("./routes/uptime");
// Import the DNS lookup API route.
const dnsRouter = require("./routes/dns");
// Create the Express application. // Create the Express application.
const app = express(); const app = express();
@@ -23,6 +26,9 @@ app.use(express.static(path.join(__dirname, "..", "public")));
// Handle website uptime checks. // Handle website uptime checks.
app.use("/api/uptime", uptimeRouter); app.use("/api/uptime", uptimeRouter);
// Handle DNS lookup requests.
app.use("/api/dns", dnsRouter);
// Simple health-check route. // Simple health-check route.
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.json({ res.json({
+65
View File
@@ -0,0 +1,65 @@
// Import Node's built-in DNS promise API.
const dns = require("dns").promises;
// Record types supported by the first DNS Lookup version.
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"];
// Errors that usually mean a record type simply does not exist.
const EMPTY_RECORD_ERRORS = new Set([
"ENODATA",
"ENOTFOUND",
"ENOENT",
"ENODOMAIN",
]);
// Look up one DNS record type without crashing the entire request.
async function resolveRecord(hostname, recordType) {
try {
const records = await dns.resolve(hostname, recordType);
// TXT records arrive as arrays of text fragments.
// Join each record into one readable string.
if (recordType === "TXT") {
return records.map((parts) => parts.join(""));
}
return records;
} catch (error) {
// A domain may have an A record but no MX, TXT, or CNAME record.
// That is normal, so return an empty array.
if (EMPTY_RECORD_ERRORS.has(error.code)) {
return [];
}
throw error;
}
}
// Look up all supported DNS record types.
async function lookupDns(hostname) {
const results = {};
// Run all record lookups at the same time.
const lookups = RECORD_TYPES.map(async (recordType) => {
try {
results[recordType] = await resolveRecord(hostname, recordType);
} catch {
// One failed record type should not erase successful results.
results[recordType] = [];
}
});
await Promise.all(lookups);
return {
hostname,
records: results,
checkedAt: new Date().toISOString(),
};
}
module.exports = {
lookupDns,
resolveRecord,
RECORD_TYPES,
};
+65
View File
@@ -0,0 +1,65 @@
// Import Node's built-in test tools.
const test = require("node:test");
const assert = require("node:assert/strict");
// Import the DNS service.
const {
lookupDns,
resolveRecord,
RECORD_TYPES,
} = require("../src/services/dnsService");
test("supports the expected DNS record types", () => {
assert.deepEqual(
RECORD_TYPES,
["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
);
});
test("resolves A records for example.com", async () => {
const records = await resolveRecord("example.com", "A");
assert.equal(Array.isArray(records), true);
assert.equal(records.length > 0, true);
});
test("returns an array for a missing optional record type", async () => {
const records = await resolveRecord(
"example.com",
"CNAME"
);
assert.equal(Array.isArray(records), true);
});
test("joins TXT record fragments into strings", async () => {
const records = await resolveRecord("google.com", "TXT");
assert.equal(Array.isArray(records), true);
records.forEach((record) => {
assert.equal(typeof record, "string");
});
});
test("returns all supported record groups", async () => {
const result = await lookupDns("example.com");
assert.equal(result.hostname, "example.com");
assert.equal(typeof result.checkedAt, "string");
assert.equal(typeof result.records, "object");
RECORD_TYPES.forEach((recordType) => {
assert.equal(
Array.isArray(result.records[recordType]),
true
);
});
});
test("returns a valid ISO timestamp", async () => {
const result = await lookupDns("example.com");
const timestamp = Date.parse(result.checkedAt);
assert.equal(Number.isNaN(timestamp), false);
});
+115
View File
@@ -0,0 +1,115 @@
// Import Node's built-in test tools.
const test = require("node:test");
const assert = require("node:assert/strict");
// Import the function we want to test.
const {
validateHostname,
} = require("../src/middleware/validateHostname");
test("accepts a normal hostname", () => {
assert.equal(validateHostname("example.com"), "example.com");
});
test("normalizes uppercase letters", () => {
assert.equal(validateHostname("EXAMPLE.COM"), "example.com");
});
test("trims surrounding spaces", () => {
assert.equal(validateHostname(" example.com "), "example.com");
});
test("extracts the hostname from an HTTPS URL", () => {
assert.equal(
validateHostname("https://example.com/some/page"),
"example.com"
);
});
test("extracts the hostname from an HTTP URL", () => {
assert.equal(
validateHostname("http://sub.example.com/path"),
"sub.example.com"
);
});
test("removes a trailing period", () => {
assert.equal(validateHostname("example.com."), "example.com");
});
test("accepts subdomains", () => {
assert.equal(
validateHostname("api.tools.example.com"),
"api.tools.example.com"
);
});
test("rejects an empty hostname", () => {
assert.throws(
() => validateHostname(""),
/A hostname is required/
);
});
test("rejects whitespace-only input", () => {
assert.throws(
() => validateHostname(" "),
/A hostname is required/
);
});
test("rejects non-string input", () => {
assert.throws(
() => validateHostname(null),
/A hostname is required/
);
});
test("rejects malformed hostnames", () => {
assert.throws(
() => validateHostname("not a real hostname"),
/Enter a valid public hostname/
);
});
test("rejects hostnames beginning with a hyphen", () => {
assert.throws(
() => validateHostname("-example.com"),
/Enter a valid public hostname/
);
});
test("rejects hostnames ending with a hyphen", () => {
assert.throws(
() => validateHostname("example-.com"),
/Enter a valid public hostname/
);
});
test("rejects localhost", () => {
assert.throws(
() => validateHostname("localhost"),
/Enter a valid public hostname/
);
});
test("rejects localhost subdomains", () => {
assert.throws(
() => validateHostname("test.localhost"),
/Local hostnames are not allowed/
);
});
test("rejects .local hostnames", () => {
assert.throws(
() => validateHostname("printer.local"),
/Local hostnames are not allowed/
);
});
test("rejects an invalid URL", () => {
assert.throws(
() => validateHostname("https://"),
/Enter a valid hostname or website URL/
);
});