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
This commit is contained in:
1 parent
4b921f4986
commit
e5959ca91d
13 files changed
+333
-5
No files matched your search
@@ -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" && (
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||
subtitle={certificateExpiry || "N/A"}
|
||||
/>
|
||||
<>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||
subtitle={certificateExpiry || "N/A"}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.domainExpiry")}
|
||||
subtitle={domainExpiry || "N/A"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -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<DomainResponse>(
|
||||
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}
|
||||
/>
|
||||
<HeaderTimeRange
|
||||
isLoading={monitorIsLoading || checksIsLoading}
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
"statBoxes": {
|
||||
"activeFor": "Active for",
|
||||
"certificateExpiry": "Certificate expiry",
|
||||
"domainExpiry": "Domain expiry",
|
||||
"lastCheck": "Last check",
|
||||
"lastResponseTime": "Last response time",
|
||||
"serviceIsDown": "Service is down"
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"statBoxes": {
|
||||
"activeFor": "已活跃",
|
||||
"certificateExpiry": "证书到期",
|
||||
"domainExpiry": "域名到期",
|
||||
"lastCheck": "上次检查",
|
||||
"lastResponseTime": "上次响应时间",
|
||||
"serviceIsDown": "服务已停止"
|
||||
|
||||
@@ -7152,6 +7152,131 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/monitors/domain/{monitorId}": {
|
||||
"get": {
|
||||
"tags": ["monitors"],
|
||||
"summary": "Get domain registration expiry for a monitor",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"required": true,
|
||||
"name": "monitorId",
|
||||
"in": "path"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"expiryDate": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain", "expiryDate"]
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg", "data"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/monitors/notifications": {
|
||||
"patch": {
|
||||
"tags": ["monitors"],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMonitorsByTeamIdQueryValidation,
|
||||
getMonitorsWithChecksQueryValidation,
|
||||
getCertificateParamValidation,
|
||||
getDomainParamValidation,
|
||||
createMonitorBodyValidation,
|
||||
editMonitorBodyValidation,
|
||||
pauseMonitorParamValidation,
|
||||
@@ -144,6 +145,16 @@ registry.registerPath({
|
||||
responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/domain/{monitorId}",
|
||||
tags,
|
||||
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 },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/monitors/notifications",
|
||||
|
||||
Generated
+13
@@ -40,6 +40,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"
|
||||
@@ -15966,6 +15967,18 @@
|
||||
"node": ">= 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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<SSLDetails> => {
|
||||
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<string, unknown>)[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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user