diff --git a/client/src/Components/monitors/MonitorStatBoxes.tsx b/client/src/Components/monitors/MonitorStatBoxes.tsx
index 58176dc04..8b201b474 100644
--- a/client/src/Components/monitors/MonitorStatBoxes.tsx
+++ b/client/src/Components/monitors/MonitorStatBoxes.tsx
@@ -11,12 +11,14 @@ interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
+ domainExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
+ domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -66,10 +68,16 @@ export const MonitorStatBoxes = ({
/>
{monitor?.type === "http" && (
-
+ <>
+
+
+ >
)}
);
diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx
index 56724259a..bcf7177ac 100644
--- a/client/src/Pages/Uptime/Details/index.tsx
+++ b/client/src/Pages/Uptime/Details/index.tsx
@@ -38,6 +38,11 @@ interface CertificateResponse {
certificateDate: string;
}
+interface DomainResponse {
+ domain: string;
+ expiryDate: string;
+}
+
const UptimeDetailsPage = () => {
const theme = useTheme();
const isAdmin = useIsAdmin();
@@ -102,6 +107,29 @@ const UptimeDetailsPage = () => {
);
}, [certificateData, uiTimezone]);
+ // Domain expiry fetch - only for HTTP monitors
+ const domainUrl = useMemo(() => {
+ if (!monitorId || monitor?.type !== "http") {
+ return null;
+ }
+ return `/monitors/domain/${monitorId}`;
+ }, [monitorId, monitor?.type]);
+
+ const { data: domainData } = useGet(
+ domainUrl,
+ {},
+ { revalidateOnFocus: false }
+ );
+
+ const domainExpiry = useMemo(() => {
+ if (!domainData?.expiryDate) {
+ return undefined;
+ }
+ return (
+ formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A"
+ );
+ }, [domainData, uiTimezone]);
+
const checksUrl = useMemo(() => {
if (!monitorId || !monitor?.type) {
return null;
@@ -193,6 +221,7 @@ const UptimeDetailsPage = () => {
monitor={monitor}
monitorStats={monitorStats}
certificateExpiry={certificateExpiry}
+ domainExpiry={domainExpiry}
/>
=16"
}
},
+ "node_modules/tough-cookie/node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tough-cookie/node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "license": "MIT"
+ },
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
@@ -15966,6 +15968,18 @@
"node": ">= 8"
}
},
+ "node_modules/whoiser": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/whoiser/-/whoiser-1.18.0.tgz",
+ "integrity": "sha512-QRIGreBuouc8d9i+UVMFqYJSiG7gaoaGX8nKugYDGqnuNLLgjDBwmlKODOIGHveBawza3Kfkk/OuM9VsTUYwaA==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=15.0.0"
+ }
+ },
"node_modules/winston": {
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz",
diff --git a/server/package.json b/server/package.json
index e67bcdb78..a43871d78 100755
--- a/server/package.json
+++ b/server/package.json
@@ -63,6 +63,8 @@
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"swagger-ui-express": "5.0.1",
+ "tldts": "^7.4.10",
+ "whoiser": "^1.18.0",
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
diff --git a/server/src/api/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts
index 1980ec0b4..608c39d57 100755
--- a/server/src/api/controllers/controllerUtils.ts
+++ b/server/src/api/controllers/controllerUtils.ts
@@ -2,7 +2,10 @@ import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
+import * as whoiser from "whoiser";
+import { parse as parseDomain } from "tldts";
type SSLCheckerType = typeof sslChecker;
+type WhoisModule = typeof whoiser;
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise => {
const monitorUrl = new URL(monitor.url);
@@ -13,6 +16,114 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor:
}
return cert;
};
+
+const WHOIS_TIMEOUT_MS = 10_000;
+const DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS = 60 * 60 * 1000; // 1 hour
+const DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS = 10 * 60 * 1000; // 10 minutes
+const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i;
+
+export interface DomainExpiryResult {
+ domain: string | null;
+ expiryDate: string | null;
+}
+
+interface DomainExpiryCacheEntry extends DomainExpiryResult {
+ expiresAt: number;
+}
+
+interface DomainExpiryCache {
+ get(domain: string): DomainExpiryResult | undefined;
+ set(domain: string, result: DomainExpiryResult): void;
+}
+
+export const createDomainExpiryCache = (positiveTtlMs: number, negativeTtlMs: number): DomainExpiryCache => {
+ const entries = new Map();
+
+ return {
+ get(domain: string): DomainExpiryResult | undefined {
+ const entry = entries.get(domain);
+ if (entry === undefined) {
+ return undefined;
+ }
+ if (entry.expiresAt <= Date.now()) {
+ entries.delete(domain);
+ return undefined;
+ }
+ return { domain: entry.domain, expiryDate: entry.expiryDate };
+ },
+ set(domain: string, result: DomainExpiryResult): void {
+ entries.set(domain, {
+ ...result,
+ expiresAt: Date.now() + (result.expiryDate === null ? negativeTtlMs : positiveTtlMs),
+ });
+ },
+ };
+};
+
+const domainExpiryCache = createDomainExpiryCache(DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS, DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS);
+
+export const extractDomainExpiryDate = (whoisData: object): string | null => {
+ const matchingKey = Object.keys(whoisData).find((key) => EXPIRY_DATE_KEY_PATTERN.test(key));
+ if (!matchingKey) {
+ return null;
+ }
+ const raw = extractString((whoisData as Record)[matchingKey] ?? null);
+ if (!raw) {
+ return null;
+ }
+
+ const parsed = parseDomainExpiryValue(raw);
+ return parsed === null ? null : parsed.toISOString();
+};
+
+const parseDomainExpiryValue = (raw: string): Date | null => {
+ // Some registries (e.g. Nominet for .uk) return "14-Feb-2027" style values.
+ const monthMatch = /^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/.exec(raw.trim());
+ if (monthMatch) {
+ const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
+ const monthIndex = months.indexOf((monthMatch[2] ?? "").toLowerCase());
+ if (monthIndex !== -1) {
+ const parsed = new Date(Date.UTC(Number(monthMatch[3]), monthIndex, Number(monthMatch[1])));
+ if (!Number.isNaN(parsed.getTime())) {
+ return parsed;
+ }
+ }
+ }
+
+ const date = new Date(raw);
+ if (!Number.isNaN(date.getTime())) {
+ return date;
+ }
+ return null;
+};
+
+export const fetchMonitorDomain = async (
+ client: WhoisModule,
+ monitor: Monitor,
+ cache: DomainExpiryCache = domainExpiryCache
+): Promise => {
+ const monitorUrl = new URL(monitor.url);
+ const registrableDomain = parseDomain(monitorUrl.hostname).domain;
+ if (registrableDomain === null) {
+ // Bare IP addresses, localhost, and other non-domain hosts have no WHOIS expiry.
+ return { domain: null, expiryDate: null };
+ }
+
+ const cached = cache.get(registrableDomain);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const result = await client.domain(registrableDomain, { timeout: WHOIS_TIMEOUT_MS });
+ const firstServerData = Object.values(result)[0];
+ const expiryDate = extractDomainExpiryDate(
+ typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {}
+ );
+ const domainExpiry: DomainExpiryResult = { domain: registrableDomain, expiryDate };
+ cache.set(registrableDomain, domainExpiry);
+ return domainExpiry;
+};
+
export const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts
index 664a78f45..f05e0df5c 100644
--- a/server/src/api/controllers/monitorController.ts
+++ b/server/src/api/controllers/monitorController.ts
@@ -10,6 +10,7 @@ import {
editMonitorBodyValidation,
pauseMonitorParamValidation,
getCertificateParamValidation,
+ getDomainParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getUptimeDetailsByIdParamValidation,
@@ -18,13 +19,15 @@ import {
bulkPauseMonitorBodyValidation,
} from "@/api/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
-import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
+import * as whoiser from "whoiser";
+import { fetchMonitorCertificate, fetchMonitorDomain, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js";
export interface IMonitorController {
getMonitorCertificate: RequestHandler;
+ getMonitorDomain: RequestHandler;
getUptimeDetailsById: RequestHandler;
getHardwareDetailsById: RequestHandler;
getPageSpeedDetailsById: RequestHandler;
@@ -68,6 +71,22 @@ class MonitorController implements IMonitorController {
});
});
+ getMonitorDomain = catchAsync(async (req: Request, res: Response) => {
+ const validatedParams = getDomainParamValidation.parse(req.params);
+ const teamId = requireTeamId(req.user?.teamId);
+ const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId });
+ const domainExpiry = await fetchMonitorDomain(whoiser, monitor);
+
+ return res.status(200).json({
+ success: true,
+ msg: "Domain expiry retrieved successfully",
+ data: {
+ domain: domainExpiry.domain,
+ expiryDate: domainExpiry.expiryDate === null ? null : new Date(domainExpiry.expiryDate),
+ },
+ });
+ });
+
getUptimeDetailsById = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query);
diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts
index 43a94b5c4..4cd90a9a0 100644
--- a/server/src/api/routes/monitorRoutes.ts
+++ b/server/src/api/routes/monitorRoutes.ts
@@ -29,6 +29,9 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
router.get("/certificate/:monitorId", (req, res, next) => {
monitorController.getMonitorCertificate(req, res, next);
});
+ router.get("/domain/:monitorId", (req, res, next) => {
+ monitorController.getMonitorDomain(req, res, next);
+ });
// General monitor CRUD routes
router.patch("/notifications", isAllowed(["admin", "superadmin"]), monitorController.updateNotifications);
diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts
index 441809c42..d8b23a8e3 100644
--- a/server/src/api/validation/monitorValidation.ts
+++ b/server/src/api/validation/monitorValidation.ts
@@ -52,6 +52,10 @@ export const getCertificateParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
+export const getDomainParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) {
ctx.addIssue({
diff --git a/server/test/unit/utils/domainExpiry.test.ts b/server/test/unit/utils/domainExpiry.test.ts
new file mode 100644
index 000000000..185a759ee
--- /dev/null
+++ b/server/test/unit/utils/domainExpiry.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "@jest/globals";
+import { createDomainExpiryCache, extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts";
+import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
+
+const makeMonitor = (url: string): Monitor =>
+ ({
+ url,
+ type: "http",
+ }) as unknown as Monitor;
+
+describe("extractDomainExpiryDate", () => {
+ it("returns an ISO date for ISO registry expiry values", () => {
+ expect(extractDomainExpiryDate({ "Registry Expiry Date": "2027-08-13T04:00:00Z" })).toBe("2027-08-13T04:00:00.000Z");
+ });
+
+ it("returns an ISO date for dd-MMM-yyyy expiry values", () => {
+ expect(extractDomainExpiryDate({ "Expiry Date": "14-Feb-2027" })).toBe("2027-02-14T00:00:00.000Z");
+ });
+
+ it("returns null when no expiry key exists", () => {
+ expect(extractDomainExpiryDate({ "Domain Name": "example.com" })).toBeNull();
+ });
+
+ it("returns null when the expiry value cannot be parsed", () => {
+ expect(extractDomainExpiryDate({ "Expiry Date": "not-a-date" })).toBeNull();
+ });
+});
+
+describe("fetchMonitorDomain", () => {
+ it("queries the registrable domain for subdomains", async () => {
+ const queried: string[] = [];
+ const client = {
+ domain: async (domain: string) => {
+ queried.push(domain);
+ return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } };
+ },
+ };
+ const cache = createDomainExpiryCache(60_000, 10_000);
+
+ const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.co.uk"), cache);
+
+ expect(queried).toEqual(["example.co.uk"]);
+ expect(result).toEqual({ domain: "example.co.uk", expiryDate: "2027-08-13T04:00:00.000Z" });
+ });
+
+ it("returns null expiry for bare IP addresses without querying whois", async () => {
+ const client = {
+ domain: async () => {
+ throw new Error("should not be called");
+ },
+ };
+
+ const result = await fetchMonitorDomain(client as never, makeMonitor("https://1.2.3.4"));
+
+ expect(result).toEqual({ domain: null, expiryDate: null });
+ });
+
+ it("returns null expiry when the registry has no expiry record", async () => {
+ const client = {
+ domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }),
+ };
+
+ const result = await fetchMonitorDomain(client as never, makeMonitor("https://example.com"));
+
+ expect(result).toEqual({ domain: "example.com", expiryDate: null });
+ });
+
+ it("serves repeat lookups from cache", async () => {
+ let calls = 0;
+ const client = {
+ domain: async () => {
+ calls++;
+ return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } };
+ },
+ };
+ const cache = createDomainExpiryCache(60_000, 10_000);
+ const monitor = makeMonitor("https://example.com");
+
+ await fetchMonitorDomain(client as never, monitor, cache);
+ const second = await fetchMonitorDomain(client as never, monitor, cache);
+
+ expect(calls).toBe(1);
+ expect(second).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" });
+ });
+
+ it("re-queries after the cache expires", async () => {
+ let calls = 0;
+ const client = {
+ domain: async () => {
+ calls++;
+ return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } };
+ },
+ };
+ const cache = createDomainExpiryCache(-1, -1);
+ const monitor = makeMonitor("https://example.com");
+
+ await fetchMonitorDomain(client as never, monitor, cache);
+ await fetchMonitorDomain(client as never, monitor, cache);
+
+ expect(calls).toBe(2);
+ });
+});