From e5959ca91daf38da9461417a88d1ca7079aca499 Mon Sep 17 00:00:00 2001 From: colinjtsen2010 Date: Tue, 11 Aug 2026 00:34:21 +0800 Subject: [PATCH 1/3] feat: add domain registration expiry monitoring Adds a GET /monitors/domain/:monitorId endpoint that looks up the registrable domain's WHOIS expiry date (walking up from subdomains), and displays it next to the certificate expiry on the uptime details page. Fixes #2094 --- .../Components/monitors/MonitorStatBoxes.tsx | 16 ++- client/src/Pages/Uptime/Details/index.tsx | 29 ++++ client/src/locales/en.json | 1 + client/src/locales/zh-CN.json | 1 + server/openapi.json | 125 ++++++++++++++++++ server/openapi/routes/monitor.ts | 11 ++ server/package-lock.json | 13 ++ server/package.json | 1 + server/src/api/controllers/controllerUtils.ts | 61 +++++++++ .../src/api/controllers/monitorController.ts | 21 ++- server/src/api/routes/monitorRoutes.ts | 3 + .../src/api/validation/monitorValidation.ts | 4 + server/test/unit/utils/domainExpiry.test.ts | 52 ++++++++ 13 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 server/test/unit/utils/domainExpiry.test.ts 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} /> = 8" } }, + "node_modules/whoiser": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/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..f30495c5c 100755 --- a/server/package.json +++ b/server/package.json @@ -63,6 +63,7 @@ "sharp": "0.33.5", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", + "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..7b014e656 100755 --- a/server/src/api/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -2,7 +2,9 @@ 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"; 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 +15,65 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: } return cert; }; + +const WHOIS_TIMEOUT_MS = 10_000; +const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i; + +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): Promise<{ domain: string; expiryDate: string }> => { + const monitorUrl = new URL(monitor.url); + const labels = monitorUrl.hostname.split("."); + + // WHOIS registries only answer for the registrable domain, so try the full + // hostname first and walk up to the parent domain (www.example.com -> example.com). + for (let index = 0; index < labels.length - 1; index++) { + const candidate = labels.slice(index).join("."); + const result = await client.domain(candidate, { timeout: WHOIS_TIMEOUT_MS }); + const firstServerData = Object.values(result)[0]; + const expiryDate = extractDomainExpiryDate( + typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} + ); + if (expiryDate) { + return { domain: candidate, expiryDate }; + } + } + throw new Error("Domain expiry not found"); +}; + 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..4bd1a4fcb 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: 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..7d6e02e49 --- /dev/null +++ b/server/test/unit/utils/domainExpiry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@jest/globals"; +import { 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("walks up from the hostname to the registrable domain", async () => { + const queried: string[] = []; + const client = { + domain: async (domain: string) => { + queried.push(domain); + return domain === "example.com" ? { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } } : {}; + }, + }; + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.com")); + + expect(queried).toEqual(["www.example.com", "example.com"]); + expect(result).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("throws when no candidate has an expiry date", async () => { + const client = { + domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }), + }; + + await expect(fetchMonitorDomain(client as never, makeMonitor("https://example.com"))).rejects.toThrow("Domain expiry not found"); + }); +}); From 75516a220d308a5a2d3a419f3d2c0900f14bb49a Mon Sep 17 00:00:00 2001 From: colinjtsen2010 Date: Thu, 13 Aug 2026 12:02:49 +0800 Subject: [PATCH 2/3] fix: handle IPs, avoid wrong parent-domain lookups, cache whois results - Bare IP addresses and non-domain hosts (localhost, IPv6) now return a null expiry instead of throwing, so they no longer spam error logs. - Extract the registrable domain with tldts instead of walking up hostname labels, so a subdomain can never accidentally return its parent domain's expiry date. - Cache WHOIS lookups per registrable domain (1h positive, 10min negative) so visiting details pages does not re-query the registry every time. Addresses review feedback on #3834. --- server/openapi.json | 6 +- server/openapi/routes/monitor.ts | 5 +- server/package-lock.json | 51 ++++++------ server/package.json | 1 + server/src/api/controllers/controllerUtils.ts | 82 +++++++++++++++---- .../src/api/controllers/monitorController.ts | 2 +- server/test/unit/utils/domainExpiry.test.ts | 66 +++++++++++++-- 7 files changed, 160 insertions(+), 53 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 0a57093ea..e95617426 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7191,10 +7191,12 @@ "type": "object", "properties": { "domain": { - "type": "string" + "type": "string", + "nullable": true }, "expiryDate": { - "type": "string" + "type": "string", + "nullable": true } }, "required": ["domain", "expiryDate"] diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index 02626d8ab..ddb207eb8 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -152,7 +152,10 @@ registry.registerPath({ summary: "Get domain registration expiry for a monitor", security: bearer, request: { params: getDomainParamValidation }, - responses: { "200": okJson(z.object({ domain: z.string(), expiryDate: z.string() })), ...standardErrors }, + responses: { + "200": okJson(z.object({ domain: z.string().nullable(), expiryDate: z.string().nullable() })), + ...standardErrors, + }, }); registry.registerPath({ diff --git a/server/package-lock.json b/server/package-lock.json index 548f99575..a581bd18b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -40,6 +40,7 @@ "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", @@ -9670,24 +9671,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/isomorphic-dompurify/node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/isomorphic-dompurify/node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "license": "MIT" - }, "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -15258,21 +15241,21 @@ } }, "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==", + "version": "7.4.10", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "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==", + "version": "7.4.10", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, "node_modules/tmpl": { @@ -15326,6 +15309,24 @@ "node": ">=16" } }, + "node_modules/tough-cookie/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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", diff --git a/server/package.json b/server/package.json index f30495c5c..a43871d78 100755 --- a/server/package.json +++ b/server/package.json @@ -63,6 +63,7 @@ "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", diff --git a/server/src/api/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts index 7b014e656..608c39d57 100755 --- a/server/src/api/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -3,6 +3,7 @@ import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monit 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; @@ -17,8 +18,50 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: }; 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) { @@ -54,24 +97,31 @@ const parseDomainExpiryValue = (raw: string): Date | null => { return null; }; -export const fetchMonitorDomain = async (client: WhoisModule, monitor: Monitor): Promise<{ domain: string; expiryDate: string }> => { +export const fetchMonitorDomain = async ( + client: WhoisModule, + monitor: Monitor, + cache: DomainExpiryCache = domainExpiryCache +): Promise => { const monitorUrl = new URL(monitor.url); - const labels = monitorUrl.hostname.split("."); - - // WHOIS registries only answer for the registrable domain, so try the full - // hostname first and walk up to the parent domain (www.example.com -> example.com). - for (let index = 0; index < labels.length - 1; index++) { - const candidate = labels.slice(index).join("."); - const result = await client.domain(candidate, { timeout: WHOIS_TIMEOUT_MS }); - const firstServerData = Object.values(result)[0]; - const expiryDate = extractDomainExpiryDate( - typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} - ); - if (expiryDate) { - return { domain: candidate, expiryDate }; - } + 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 }; } - throw new Error("Domain expiry not found"); + + 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 => { diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 4bd1a4fcb..f05e0df5c 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -82,7 +82,7 @@ class MonitorController implements IMonitorController { msg: "Domain expiry retrieved successfully", data: { domain: domainExpiry.domain, - expiryDate: new Date(domainExpiry.expiryDate), + expiryDate: domainExpiry.expiryDate === null ? null : new Date(domainExpiry.expiryDate), }, }); }); diff --git a/server/test/unit/utils/domainExpiry.test.ts b/server/test/unit/utils/domainExpiry.test.ts index 7d6e02e49..185a759ee 100644 --- a/server/test/unit/utils/domainExpiry.test.ts +++ b/server/test/unit/utils/domainExpiry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts"; +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 => @@ -27,26 +27,76 @@ describe("extractDomainExpiryDate", () => { }); describe("fetchMonitorDomain", () => { - it("walks up from the hostname to the registrable domain", async () => { + it("queries the registrable domain for subdomains", async () => { const queried: string[] = []; const client = { domain: async (domain: string) => { queried.push(domain); - return domain === "example.com" ? { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } } : {}; + 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://www.example.com")); + const result = await fetchMonitorDomain(client as never, makeMonitor("https://1.2.3.4")); - expect(queried).toEqual(["www.example.com", "example.com"]); - expect(result).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + expect(result).toEqual({ domain: null, expiryDate: null }); }); - it("throws when no candidate has an expiry date", async () => { + it("returns null expiry when the registry has no expiry record", async () => { const client = { domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }), }; - await expect(fetchMonitorDomain(client as never, makeMonitor("https://example.com"))).rejects.toThrow("Domain expiry not found"); + 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); }); }); From 0cbd9ab8a23f345c53071c3343bb433cc27b0ec9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 10:49:39 -0700 Subject: [PATCH 3/3] point lockfile at official npm registry --- server/package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index a581bd18b..b9a733664 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15242,7 +15242,7 @@ }, "node_modules/tldts": { "version": "7.4.10", - "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.10.tgz", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "license": "MIT", "dependencies": { @@ -15254,7 +15254,7 @@ }, "node_modules/tldts-core": { "version": "7.4.10", - "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.10.tgz", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, @@ -15311,7 +15311,7 @@ }, "node_modules/tough-cookie/node_modules/tldts": { "version": "6.1.86", - "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "license": "MIT", "dependencies": { @@ -15323,7 +15323,7 @@ }, "node_modules/tough-cookie/node_modules/tldts-core": { "version": "6.1.86", - "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "license": "MIT" }, @@ -15970,7 +15970,7 @@ }, "node_modules/whoiser": { "version": "1.18.0", - "resolved": "https://registry.npmmirror.com/whoiser/-/whoiser-1.18.0.tgz", + "resolved": "https://registry.npmjs.org/whoiser/-/whoiser-1.18.0.tgz", "integrity": "sha512-QRIGreBuouc8d9i+UVMFqYJSiG7gaoaGX8nKugYDGqnuNLLgjDBwmlKODOIGHveBawza3Kfkk/OuM9VsTUYwaA==", "license": "MIT", "dependencies": {