Add SSL certificate checker and tests
This commit is contained in:
+164
@@ -199,3 +199,167 @@ dnsForm.addEventListener("submit", async (event) => {
|
|||||||
dnsButton.textContent = "Look Up DNS";
|
dnsButton.textContent = "Look Up DNS";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// SSL Certificate Checker
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Get references to the SSL form and result elements.
|
||||||
|
const sslForm = document.getElementById("ssl-form");
|
||||||
|
const sslHostnameInput = document.getElementById(
|
||||||
|
"ssl-hostname-input"
|
||||||
|
);
|
||||||
|
const sslButton = document.getElementById("ssl-button");
|
||||||
|
const sslMessage = document.getElementById("ssl-message");
|
||||||
|
const sslResultPanel = document.getElementById(
|
||||||
|
"ssl-result-panel"
|
||||||
|
);
|
||||||
|
|
||||||
|
const sslStatusBadge = document.getElementById(
|
||||||
|
"ssl-status-badge"
|
||||||
|
);
|
||||||
|
const sslHostname = document.getElementById("ssl-hostname");
|
||||||
|
const sslDaysRemaining = document.getElementById(
|
||||||
|
"ssl-days-remaining"
|
||||||
|
);
|
||||||
|
const sslValidFrom = document.getElementById("ssl-valid-from");
|
||||||
|
const sslValidTo = document.getElementById("ssl-valid-to");
|
||||||
|
const sslProtocol = document.getElementById("ssl-protocol");
|
||||||
|
const sslCipher = document.getElementById("ssl-cipher");
|
||||||
|
const sslAddress = document.getElementById("ssl-address");
|
||||||
|
const sslCheckedAt = document.getElementById("ssl-checked-at");
|
||||||
|
|
||||||
|
const sslSubject = document.getElementById("ssl-subject");
|
||||||
|
const sslIssuer = document.getElementById("ssl-issuer");
|
||||||
|
const sslFingerprint = document.getElementById(
|
||||||
|
"ssl-fingerprint"
|
||||||
|
);
|
||||||
|
|
||||||
|
const sslAuthorizationErrorBlock = document.getElementById(
|
||||||
|
"ssl-authorization-error-block"
|
||||||
|
);
|
||||||
|
const sslAuthorizationError = document.getElementById(
|
||||||
|
"ssl-authorization-error"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert certificate date strings into a readable local date.
|
||||||
|
function formatCertificateDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose readable text for the certificate expiration state.
|
||||||
|
function getExpirationText(daysRemaining) {
|
||||||
|
if (daysRemaining === null || daysRemaining === undefined) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysRemaining < 0) {
|
||||||
|
return `${Math.abs(daysRemaining)} days expired`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysRemaining === 0) {
|
||||||
|
return "Expires today";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${daysRemaining} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSL certificate form submissions.
|
||||||
|
sslForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const hostname = sslHostnameInput.value.trim();
|
||||||
|
|
||||||
|
// Reset the SSL interface before beginning the request.
|
||||||
|
sslMessage.textContent = "Checking certificate...";
|
||||||
|
sslMessage.className = "status-message";
|
||||||
|
sslResultPanel.hidden = true;
|
||||||
|
sslButton.disabled = true;
|
||||||
|
sslButton.textContent = "Checking...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ssl", {
|
||||||
|
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 SSL certificate check failed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show whether Node trusted the certificate chain and hostname.
|
||||||
|
if (result.authorized) {
|
||||||
|
sslStatusBadge.textContent = "Valid";
|
||||||
|
sslStatusBadge.className = "status-badge online";
|
||||||
|
} else {
|
||||||
|
sslStatusBadge.textContent = "Not trusted";
|
||||||
|
sslStatusBadge.className = "status-badge offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the certificate summary.
|
||||||
|
sslHostname.textContent = result.hostname;
|
||||||
|
sslDaysRemaining.textContent = getExpirationText(
|
||||||
|
result.daysRemaining
|
||||||
|
);
|
||||||
|
|
||||||
|
sslValidFrom.textContent = formatCertificateDate(
|
||||||
|
result.validFrom
|
||||||
|
);
|
||||||
|
sslValidTo.textContent = formatCertificateDate(
|
||||||
|
result.validTo
|
||||||
|
);
|
||||||
|
|
||||||
|
sslProtocol.textContent = result.protocol || "Unknown";
|
||||||
|
sslCipher.textContent = result.cipher || "Unknown";
|
||||||
|
|
||||||
|
sslAddress.textContent = result.resolvedAddress
|
||||||
|
? `${result.resolvedAddress} (IPv${result.addressFamily})`
|
||||||
|
: "Unknown";
|
||||||
|
|
||||||
|
sslCheckedAt.textContent = formatCertificateDate(
|
||||||
|
result.checkedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Populate the longer certificate values.
|
||||||
|
sslSubject.textContent = result.subject || "Unknown";
|
||||||
|
sslIssuer.textContent = result.issuer || "Unknown";
|
||||||
|
sslFingerprint.textContent =
|
||||||
|
result.fingerprint256 || "Unavailable";
|
||||||
|
|
||||||
|
// Show the authorization problem only when one exists.
|
||||||
|
if (result.authorizationError) {
|
||||||
|
sslAuthorizationError.textContent =
|
||||||
|
result.authorizationError;
|
||||||
|
sslAuthorizationErrorBlock.hidden = false;
|
||||||
|
} else {
|
||||||
|
sslAuthorizationError.textContent = "";
|
||||||
|
sslAuthorizationErrorBlock.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sslMessage.textContent =
|
||||||
|
"Certificate check completed.";
|
||||||
|
sslResultPanel.hidden = false;
|
||||||
|
} catch (error) {
|
||||||
|
sslMessage.textContent = error.message;
|
||||||
|
sslMessage.className = "status-message error";
|
||||||
|
} finally {
|
||||||
|
sslButton.disabled = false;
|
||||||
|
sslButton.textContent = "Check Certificate";
|
||||||
|
}
|
||||||
|
});
|
||||||
+126
-2
@@ -17,7 +17,8 @@
|
|||||||
<p class="eyebrow">BYTEGEIST TOOLBOX</p>
|
<p class="eyebrow">BYTEGEIST TOOLBOX</p>
|
||||||
<h1>Practical Website and Network Tools</h1>
|
<h1>Practical Website and Network Tools</h1>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
Check a website's current status or inspect its public DNS records.
|
Check a website's current status, inspect its public DNS records,
|
||||||
|
or review its SSL certificate.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -39,6 +40,15 @@
|
|||||||
>
|
>
|
||||||
DNS Lookup
|
DNS Lookup
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="ssl-tab"
|
||||||
|
class="tool-tab"
|
||||||
|
type="button"
|
||||||
|
data-tool="ssl-tool"
|
||||||
|
>
|
||||||
|
SSL Certificate
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Website Status Checker -->
|
<!-- Website Status Checker -->
|
||||||
@@ -153,9 +163,123 @@
|
|||||||
<p id="dns-checked-at" class="dns-checked-at"></p>
|
<p id="dns-checked-at" class="dns-checked-at"></p>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- SSL Certificate Checker -->
|
||||||
|
<section id="ssl-tool" class="tool-panel" hidden>
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2>SSL Certificate Checker</h2>
|
||||||
|
|
||||||
|
<p class="tool-description">
|
||||||
|
Inspect the public TLS certificate for a website and view its issuer,
|
||||||
|
expiration date, authorization status, protocol, cipher, and fingerprint.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="ssl-form" class="tool-form">
|
||||||
|
<label for="ssl-hostname-input">Hostname or website URL</label>
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<input
|
||||||
|
id="ssl-hostname-input"
|
||||||
|
name="hostname"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<button id="ssl-button" type="submit">
|
||||||
|
Check Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p
|
||||||
|
id="ssl-message"
|
||||||
|
class="status-message"
|
||||||
|
aria-live="polite"
|
||||||
|
></p>
|
||||||
|
|
||||||
|
<section id="ssl-result-panel" class="result-panel" hidden>
|
||||||
|
<div class="result-heading">
|
||||||
|
<h3>Certificate Details</h3>
|
||||||
|
|
||||||
|
<span
|
||||||
|
id="ssl-status-badge"
|
||||||
|
class="status-badge"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="result-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Hostname</dt>
|
||||||
|
<dd id="ssl-hostname">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Days remaining</dt>
|
||||||
|
<dd id="ssl-days-remaining">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Valid from</dt>
|
||||||
|
<dd id="ssl-valid-from">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Expires</dt>
|
||||||
|
<dd id="ssl-valid-to">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>TLS protocol</dt>
|
||||||
|
<dd id="ssl-protocol">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Cipher</dt>
|
||||||
|
<dd id="ssl-cipher">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Resolved address</dt>
|
||||||
|
<dd id="ssl-address">-</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>Checked at</dt>
|
||||||
|
<dd id="ssl-checked-at">-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="certificate-detail-block">
|
||||||
|
<span>Subject</span>
|
||||||
|
<p id="ssl-subject">-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certificate-detail-block">
|
||||||
|
<span>Issuer</span>
|
||||||
|
<p id="ssl-issuer">-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certificate-detail-block">
|
||||||
|
<span>SHA-256 fingerprint</span>
|
||||||
|
<code id="ssl-fingerprint">-</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="ssl-authorization-error-block"
|
||||||
|
class="certificate-detail-block certificate-error"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<span>Authorization error</span>
|
||||||
|
<p id="ssl-authorization-error">-</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Load browser JavaScript after the page elements exist. -->
|
<!-- Load browser JavaScript after the page elements exist. -->
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -342,3 +342,49 @@ button:disabled {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.certificate-detail-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0d1523;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-detail-block span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-detail-block p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-detail-block code {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family:
|
||||||
|
"Cascadia Code",
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-error {
|
||||||
|
border-color: rgba(255, 107, 115, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tool-tabs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Always hide elements that use the HTML hidden attribute. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -17,6 +17,9 @@ const app = express();
|
|||||||
// Otherwise, use port 3001 for local development.
|
// Otherwise, use port 3001 for local development.
|
||||||
const PORT = process.env.PORT || 3001;
|
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.
|
// Allow the server to read JSON request bodies.
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
@@ -29,6 +32,9 @@ app.use("/api/uptime", uptimeRouter);
|
|||||||
// Handle DNS lookup requests.
|
// Handle DNS lookup requests.
|
||||||
app.use("/api/dns", dnsRouter);
|
app.use("/api/dns", dnsRouter);
|
||||||
|
|
||||||
|
// Handle SSL certificate checks.
|
||||||
|
app.use("/api/ssl", sslRouter);
|
||||||
|
|
||||||
// Simple health-check route.
|
// Simple health-check route.
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Import Node's built-in test tools.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
// Import the SSL service functions we want to test.
|
||||||
|
const {
|
||||||
|
checkSslCertificate,
|
||||||
|
resolvePublicAddress,
|
||||||
|
calculateDaysRemaining,
|
||||||
|
formatCertificateName,
|
||||||
|
} = require("../src/services/sslService");
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// formatCertificateName() tests
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
test("formats a complete certificate name", () => {
|
||||||
|
const result = formatCertificateName({
|
||||||
|
CN: "example.com",
|
||||||
|
O: "Example Organization",
|
||||||
|
OU: "Technology",
|
||||||
|
C: "US",
|
||||||
|
ST: "Michigan",
|
||||||
|
L: "Kalamazoo",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
"CN=example.com, O=Example Organization, OU=Technology, C=US, ST=Michigan, L=Kalamazoo"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats only certificate fields that exist", () => {
|
||||||
|
const result = formatCertificateName({
|
||||||
|
CN: "example.com",
|
||||||
|
O: "Example Organization",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
"CN=example.com, O=Example Organization"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns Unknown for missing certificate name data", () => {
|
||||||
|
assert.equal(formatCertificateName(null), "Unknown");
|
||||||
|
assert.equal(formatCertificateName(undefined), "Unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// calculateDaysRemaining() tests
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
test("returns a positive number for a future expiration date", () => {
|
||||||
|
const futureDate = new Date(
|
||||||
|
Date.now() + 10 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const result = calculateDaysRemaining(futureDate);
|
||||||
|
|
||||||
|
assert.equal(result >= 9 && result <= 10, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a negative number for an expired certificate date", () => {
|
||||||
|
const pastDate = new Date(
|
||||||
|
Date.now() - 5 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const result = calculateDaysRemaining(pastDate);
|
||||||
|
|
||||||
|
assert.equal(result < 0, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for an invalid certificate date", () => {
|
||||||
|
assert.equal(
|
||||||
|
calculateDaysRemaining("not-a-real-date"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// resolvePublicAddress() tests
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
test("resolves a public hostname to a public IP address", async () => {
|
||||||
|
const result = await resolvePublicAddress("example.com");
|
||||||
|
|
||||||
|
assert.equal(typeof result.address, "string");
|
||||||
|
assert.equal([4, 6].includes(result.family), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a hostname that cannot be resolved", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
resolvePublicAddress(
|
||||||
|
"this-domain-should-not-exist-bytegeist-12345.com"
|
||||||
|
),
|
||||||
|
/hostname could not be resolved/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// checkSslCertificate() integration test
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
test("checks a valid public SSL certificate", async () => {
|
||||||
|
const result = await checkSslCertificate("example.com");
|
||||||
|
|
||||||
|
assert.equal(result.hostname, "example.com");
|
||||||
|
assert.equal(typeof result.resolvedAddress, "string");
|
||||||
|
assert.equal([4, 6].includes(result.addressFamily), true);
|
||||||
|
|
||||||
|
assert.equal(typeof result.authorized, "boolean");
|
||||||
|
assert.equal(typeof result.subject, "string");
|
||||||
|
assert.equal(typeof result.issuer, "string");
|
||||||
|
|
||||||
|
assert.equal(typeof result.validFrom, "string");
|
||||||
|
assert.equal(typeof result.validTo, "string");
|
||||||
|
assert.equal(typeof result.checkedAt, "string");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result.daysRemaining === null ||
|
||||||
|
typeof result.daysRemaining === "number",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result.protocol === null ||
|
||||||
|
typeof result.protocol === "string",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result.cipher === null ||
|
||||||
|
typeof result.cipher === "string",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user