Add DNS lookup tool and tests
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -7,6 +7,9 @@ const path = require("path");
|
||||
// Import the uptime API route.
|
||||
const uptimeRouter = require("./routes/uptime");
|
||||
|
||||
// Import the DNS lookup API route.
|
||||
const dnsRouter = require("./routes/dns");
|
||||
|
||||
// Create the Express application.
|
||||
const app = express();
|
||||
|
||||
@@ -23,6 +26,9 @@ app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
// Handle website uptime checks.
|
||||
app.use("/api/uptime", uptimeRouter);
|
||||
|
||||
// Handle DNS lookup requests.
|
||||
app.use("/api/dns", dnsRouter);
|
||||
|
||||
// Simple health-check route.
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user