Add SSL certificate checker and tests

This commit is contained in:
Keith Casko
2026-07-23 03:53:19 -04:00
parent 28ccd40686
commit 820f271195
7 changed files with 700 additions and 2 deletions
+40
View File
@@ -0,0 +1,40 @@
// Import Express so we can create the SSL route.
const express = require("express");
// Import hostname validation.
const {
validateHostname,
} = require("../middleware/validateHostname");
// Import the SSL certificate service.
const {
checkSslCertificate,
} = require("../services/sslService");
// Reuse the existing request rate limiter.
const {
uptimeRateLimiter,
} = require("../middleware/rateLimit");
const router = express.Router();
// POST /api/ssl
router.post("/", uptimeRateLimiter, async (req, res) => {
try {
// Validate and normalize the hostname or pasted URL.
const hostname = validateHostname(req.body.hostname);
// Inspect the public TLS certificate.
const result = await checkSslCertificate(hostname);
return res.json(result);
} catch (error) {
return res.status(400).json({
error:
error.message ||
"The SSL certificate could not be checked.",
});
}
});
module.exports = router;
+6
View File
@@ -17,6 +17,9 @@ const app = express();
// Otherwise, use port 3001 for local development.
const PORT = process.env.PORT || 3001;
// Import the SSL certificate API route.
const sslRouter = require("./routes/ssl");
// Allow the server to read JSON request bodies.
app.use(express.json());
@@ -29,6 +32,9 @@ app.use("/api/uptime", uptimeRouter);
// Handle DNS lookup requests.
app.use("/api/dns", dnsRouter);
// Handle SSL certificate checks.
app.use("/api/ssl", sslRouter);
// Simple health-check route.
app.get("/health", (req, res) => {
res.json({
+177
View File
@@ -0,0 +1,177 @@
// Import Node's built-in TLS module for secure connections.
const tls = require("tls");
// Import DNS promises so we can resolve the hostname ourselves.
const dns = require("dns").promises;
// Reuse the IP security check from the website status checker.
const { isPrivateIp } = require("./urlSecurityService");
// Maximum amount of time allowed for the TLS connection.
const TLS_TIMEOUT_MS = 5000;
// Convert certificate subject or issuer objects into readable text.
function formatCertificateName(name) {
// Some servers may not provide all certificate fields.
if (!name || typeof name !== "object") {
return "Unknown";
}
// Keep the most useful certificate identity fields.
const fields = [
["CN", name.CN],
["O", name.O],
["OU", name.OU],
["C", name.C],
["ST", name.ST],
["L", name.L],
];
return fields
.filter(([, value]) => value)
.map(([label, value]) => `${label}=${value}`)
.join(", ");
}
// Resolve a hostname and choose a public address.
async function resolvePublicAddress(hostname) {
let addresses;
try {
// Resolve every IPv4 and IPv6 address for the hostname.
addresses = await dns.lookup(hostname, {
all: true,
verbatim: true,
});
} catch {
throw new Error("The hostname could not be resolved.");
}
// Keep only addresses that are safe to contact.
const publicAddresses = addresses.filter(
(result) => !isPrivateIp(result.address)
);
if (publicAddresses.length === 0) {
throw new Error(
"The hostname does not resolve to a public IP address."
);
}
// Use the first safe resolved address.
return publicAddresses[0];
}
// Calculate the number of whole days until certificate expiration.
function calculateDaysRemaining(validTo) {
const expirationTime = new Date(validTo).getTime();
if (Number.isNaN(expirationTime)) {
return null;
}
const differenceMs = expirationTime - Date.now();
return Math.ceil(differenceMs / (1000 * 60 * 60 * 24));
}
// Inspect the TLS certificate for a public hostname.
async function checkSslCertificate(hostname) {
const resolvedAddress = await resolvePublicAddress(hostname);
return new Promise((resolve, reject) => {
let finished = false;
// Finish once and always close the socket.
function finish(callback, value, socket) {
if (finished) {
return;
}
finished = true;
socket.destroy();
callback(value);
}
// Connect directly to the resolved public IP.
// servername preserves SNI and hostname certificate checking.
const socket = tls.connect({
host: resolvedAddress.address,
port: 443,
servername: hostname,
// Keep the connection available even when the certificate is invalid.
// We report the authorization error instead of hiding the certificate.
rejectUnauthorized: false,
});
socket.setTimeout(TLS_TIMEOUT_MS);
socket.once("secureConnect", () => {
const certificate = socket.getPeerCertificate(true);
if (!certificate || Object.keys(certificate).length === 0) {
finish(
reject,
new Error("The server did not provide a certificate."),
socket
);
return;
}
const cipher = socket.getCipher();
const daysRemaining = calculateDaysRemaining(certificate.valid_to);
const result = {
hostname,
resolvedAddress: resolvedAddress.address,
addressFamily: resolvedAddress.family,
authorized: socket.authorized,
authorizationError: socket.authorizationError || null,
subject: formatCertificateName(certificate.subject),
issuer: formatCertificateName(certificate.issuer),
validFrom: certificate.valid_from,
validTo: certificate.valid_to,
daysRemaining,
serialNumber: certificate.serialNumber || null,
fingerprint256: certificate.fingerprint256 || null,
protocol: socket.getProtocol() || null,
cipher: cipher ? cipher.name : null,
checkedAt: new Date().toISOString(),
};
finish(resolve, result, socket);
});
socket.once("timeout", () => {
finish(
reject,
new Error("The TLS connection timed out."),
socket
);
});
socket.once("error", (error) => {
const safeMessage =
error.code === "ECONNREFUSED"
? "The server refused the TLS connection."
: "The TLS connection could not be completed.";
finish(reject, new Error(safeMessage), socket);
});
});
}
module.exports = {
checkSslCertificate,
resolvePublicAddress,
calculateDaysRemaining,
formatCertificateName,
};