Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a672f7604d | ||
|
|
25a7ac4877 | ||
|
|
f74d1d53f6 |
No files matched your search
@@ -34,6 +34,17 @@ import { Typography } from "@mui/material";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
const isHttpsUrl = (url?: string): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
interface CertificateResponse {
|
||||
certificateDate: string;
|
||||
}
|
||||
@@ -80,13 +91,15 @@ const UptimeDetailsPage = () => {
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
|
||||
// Certificate fetch - only for HTTP monitors
|
||||
// Certificate fetch - only for monitors served over HTTPS. A "http" type
|
||||
// monitor may still have an http:// URL, which serves no certificate, so the
|
||||
// scheme is gated here to match the server rather than requesting a 400.
|
||||
const certificateUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http") {
|
||||
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
|
||||
return null;
|
||||
}
|
||||
return `/monitors/certificate/${monitorId}`;
|
||||
}, [monitorId, monitor?.type]);
|
||||
}, [monitorId, monitor?.type, monitor?.url]);
|
||||
|
||||
const { data: certificateData } = useGet<CertificateResponse>(
|
||||
certificateUrl,
|
||||
|
||||
@@ -7,10 +7,23 @@ import { parse as parseDomain } from "tldts";
|
||||
type SSLCheckerType = typeof sslChecker;
|
||||
type WhoisModule = typeof whoiser;
|
||||
|
||||
const DEFAULT_HTTPS_PORT = 443;
|
||||
// Ports are user-defined, so a probe can dial something that accepts the
|
||||
// connection and never completes the handshake. Bound the wait explicitly.
|
||||
const CERTIFICATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
|
||||
const monitorUrl = new URL(monitor.url);
|
||||
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
|
||||
// caller gets this instead of a raw OpenSSL handshake error from the socket.
|
||||
if (monitorUrl.protocol !== "https:") {
|
||||
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
|
||||
}
|
||||
const hostname = monitorUrl.hostname;
|
||||
const cert = await checker(hostname);
|
||||
// URL.port is "" for the scheme's default port, so fall back to 443; without
|
||||
// this the certificate of a monitor on a non-standard port was probed on 443.
|
||||
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
|
||||
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
|
||||
if (cert?.validTo === null || cert?.validTo === undefined) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -12,6 +12,9 @@ declare module "ssl-checker" {
|
||||
export interface SSLOptions {
|
||||
method?: "GET" | "HEAD";
|
||||
port?: number;
|
||||
// Inherited from https.RequestOptions; ssl-checker rejects with "Timed Out"
|
||||
// when the socket exceeds it.
|
||||
timeout?: number;
|
||||
agent?: Agent;
|
||||
rejectUnauthorized?: boolean;
|
||||
validateSubjectAltName?: boolean;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { fetchMonitorCertificate } from "../../../src/api/controllers/controllerUtils.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { SSLDetails, SSLOptions } from "ssl-checker";
|
||||
|
||||
const VALID_CERT: SSLDetails = {
|
||||
daysRemaining: 30,
|
||||
valid: true,
|
||||
validFrom: "2026-01-01T00:00:00.000Z",
|
||||
validTo: "2026-12-31T00:00:00.000Z",
|
||||
validFor: ["example.com"],
|
||||
};
|
||||
|
||||
/** Records the arguments the checker was called with. */
|
||||
const spyChecker = (cert: SSLDetails = VALID_CERT) => {
|
||||
const calls: Array<{ hostname: string; options?: SSLOptions }> = [];
|
||||
const checker = (async (hostname: string, options?: SSLOptions) => {
|
||||
calls.push({ hostname, options });
|
||||
return cert;
|
||||
}) as unknown as typeof import("ssl-checker").default;
|
||||
return { checker, calls };
|
||||
};
|
||||
|
||||
const monitorWithUrl = (url: string) => ({ url }) as Monitor;
|
||||
|
||||
describe("fetchMonitorCertificate", () => {
|
||||
it("probes the port named in the monitor URL", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].hostname).toBe("example.com");
|
||||
expect(calls[0].options?.port).toBe(54321);
|
||||
});
|
||||
|
||||
it("falls back to 443 when the URL names no port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("passes an explicit 443 through unchanged", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:443"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("bounds the probe with a timeout so a user-defined port cannot hang it", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls[0].options?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
it("never passes NaN as the port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(Number.isNaN(calls[0].options?.port)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor without opening a TLS connection", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
// Probing a non-TLS port would surface a raw OpenSSL handshake error.
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com:8080"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor on the default port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the certificate details it received", async () => {
|
||||
const { checker } = spyChecker();
|
||||
|
||||
const cert = await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"));
|
||||
|
||||
expect(cert.validTo).toBe(VALID_CERT.validTo);
|
||||
});
|
||||
|
||||
it("throws when the certificate has no expiry", async () => {
|
||||
const { checker } = spyChecker({ ...VALID_CERT, validTo: undefined as unknown as string });
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"))).rejects.toThrow("Certificate not found");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user