diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index 963409fc8..f202b355e 100644
--- a/client/src/Hooks/useMonitorForm.ts
+++ b/client/src/Hooks/useMonitorForm.ts
@@ -111,6 +111,15 @@ export const useMonitorForm = ({
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
+ case "dns":
+ defaults = {
+ ...base,
+ type: "dns",
+ url: data?.url || "",
+ dnsServer: data?.dnsServer || "",
+ dnsRecordType: data?.dnsRecordType || "A",
+ };
+ break;
default:
defaults = {
...base,
diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx
index 4ae518caa..036ef929e 100644
--- a/client/src/Pages/CreateMonitor/index.tsx
+++ b/client/src/Pages/CreateMonitor/index.tsx
@@ -51,6 +51,8 @@ interface GeneralSettingsConfig {
showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
+ showDnsServer: boolean;
+ showDnsRecordType: boolean;
}
const getGeneralSettingsConfig = (
@@ -68,6 +70,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -79,6 +83,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -90,6 +96,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -101,6 +109,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -112,6 +122,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -123,6 +135,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: true,
showIgnoreTls: true,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -134,6 +148,8 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
@@ -145,6 +161,8 @@ const getGeneralSettingsConfig = (
showSecret: true,
showGrpcServiceName: false,
showIgnoreTls: false,
+ showDnsServer: false,
+ showDnsRecordType: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
@@ -156,6 +174,21 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: true,
+ showDnsServer: false,
+ showDnsRecordType: false,
+ },
+ dns: {
+ urlLabel: t("pages.createMonitor.form.general.option.url.label"),
+ urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
+ namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
+ showUrl: true,
+ showPort: false,
+ showGameSelect: false,
+ showSecret: false,
+ showGrpcServiceName: false,
+ showIgnoreTls: false,
+ showDnsServer: true,
+ showDnsRecordType: true,
},
};
return configs[type] || configs.http;
@@ -349,6 +382,13 @@ const CreateMonitorPage = () => {
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
+
)}
@@ -495,6 +535,74 @@ const CreateMonitorPage = () => {
)}
/>
)}
+ {generalSettingsConfig.showDnsServer && (
+ (
+
+ )}
+ />
+ )}
+ {generalSettingsConfig.showDnsRecordType && (
+ (
+
+ )}
+ />
+ )}
{
/>
{
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
- : ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
+ : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index 3dced0a6c..a4317e1ff 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -15,10 +15,14 @@ export const MonitorTypes = [
"game",
"grpc",
"websocket",
+ "dns",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
+export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
+export type DnsRecordType = (typeof DnsRecordTypes)[number];
+
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
@@ -78,6 +82,8 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
+ dnsServer?: string;
+ dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index d0ede67e1..256d523de 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => {
game: "uptime",
grpc: "uptime",
websocket: "uptime",
+ dns: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts
index 9acffe6fe..108aa729e 100644
--- a/client/src/Validation/monitor.ts
+++ b/client/src/Validation/monitor.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
+import { DnsRecordTypes } from "@/Types/Monitor";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -122,6 +123,29 @@ const websocketSchema = baseSchema.extend({
ignoreTlsErrors: z.boolean(),
});
+// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
+// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
+// No scheme, port, path, or whitespace. Total length ≤ 253.
+const hostnameRegex =
+ /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
+
+// DNS monitor schema
+const dnsSchema = baseSchema.extend({
+ type: z.literal("dns"),
+ url: z
+ .string()
+ .min(1, "Domain is required")
+ .regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"),
+ dnsServer: z
+ .string()
+ .min(1, "DNS server is required")
+ .refine(
+ (v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success,
+ "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"
+ ),
+ dnsRecordType: z.enum(DnsRecordTypes),
+});
+
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
@@ -133,6 +157,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
pagespeedSchema,
hardwareSchema,
websocketSchema,
+ dnsSchema,
]);
export type MonitorFormData = z.infer;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 587e25df1..6d0d37eb5 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -475,7 +475,8 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
- "optionWebSocket": "WebSocket"
+ "optionWebSocket": "WebSocket",
+ "optionDns": "DNS"
}
}
},
@@ -565,6 +566,21 @@
"captureUrl": {
"label": "Capture endpoint",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
+ },
+ "dnsServer": {
+ "label": "DNS server",
+ "placeholder": "8.8.8.8"
+ },
+ "dnsRecordType": {
+ "label": "DNS record type",
+ "value": {
+ "A": "A",
+ "AAAA": "AAAA",
+ "CNAME": "CNAME",
+ "MX": "MX",
+ "NS": "NS",
+ "TXT": "TXT"
+ }
}
},
"title": "General settings",
@@ -577,7 +593,8 @@
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
- "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'."
+ "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.",
+ "dns": "Monitor if a specific DNS server is responding."
}
},
"ignoreTls": {
@@ -614,6 +631,7 @@
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPortDescription": "Monitor if a specific port on a server is open.",
+ "optionDnsDescription": "Monitor if a specific DNS server is responding.",
"title": "Type"
},
"thresholds": {
diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts
index 742e0200a..72c8f1fe8 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -9,7 +9,8 @@ import type {
GamesMap,
GroupedGeoCheckResult,
} from "@/types/monitor.js";
-import { supportsGeoCheck } from "@/types/monitor.js";
+import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js";
+import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js";
import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
@@ -28,6 +29,9 @@ import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
+const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
+ supportsUptimeDetails(result.monitorType);
+
export interface IMonitorService {
readonly serviceName: string;
@@ -216,15 +220,7 @@ export class MonitorService implements IMonitorService {
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
- if (
- checksData.monitorType !== "http" &&
- checksData.monitorType !== "ping" &&
- checksData.monitorType !== "docker" &&
- checksData.monitorType !== "port" &&
- checksData.monitorType !== "game" &&
- checksData.monitorType !== "grpc" &&
- checksData.monitorType !== "websocket"
- ) {
+ if (!isUptimeChecksResult(checksData)) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts
index 64e473e88..92b4eb333 100644
--- a/server/src/service/infrastructure/network/DNSProvider.ts
+++ b/server/src/service/infrastructure/network/DNSProvider.ts
@@ -16,11 +16,19 @@ export class DNSProvider implements IStatusProvider {
async handle(monitor: Monitor): Promise> {
try {
- const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor;
+ const { url, dnsServer, dnsRecordType = "A" } = monitor;
if (!dnsServer) {
throw new Error("DNS server is required for DNS monitoring");
}
+ let hostname: string;
+ try {
+ const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing
+ hostname = new URL(withScheme).hostname;
+ } catch {
+ hostname = url;
+ }
+
const resolver = this.createResolver();
resolver.setServers([dnsServer]);
diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts
index 09797f2ca..62b3a367e 100644
--- a/server/src/types/monitor.ts
+++ b/server/src/types/monitor.ts
@@ -9,6 +9,19 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
+export const UptimeDetailsSupportedTypes = [
+ "http",
+ "ping",
+ "docker",
+ "port",
+ "game",
+ "grpc",
+ "websocket",
+ "dns",
+] as const satisfies readonly MonitorType[];
+export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
+export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
+
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number];
diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts
index e511118e6..0c58b9df1 100644
--- a/server/src/validation/checkValidation.ts
+++ b/server/src/validation/checkValidation.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
+import { MonitorTypes } from "@/types/monitor.js";
//****************************************
// Check Validations
@@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({
});
export const getChecksQueryValidation = z.object({
- type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(),
+ type: z.enum(MonitorTypes).optional(),
sortOrder: z.enum(["asc", "desc"]),
limit: z.coerce.number().optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts
index 5065a5aa1..13c65cd4c 100644
--- a/server/src/validation/monitorValidation.ts
+++ b/server/src/validation/monitorValidation.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { booleanCoercion } from "./shared.js";
+import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
@@ -39,69 +39,83 @@ export const getCertificateParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
-export const createMonitorBodyValidation = z.object({
- _id: z.string().optional(),
- name: z.string().min(1, "Name is required"),
- description: z.union([z.string(), z.literal("")]).optional(),
- type: z.enum(MonitorTypes, "Invalid monitor type"),
- statusWindowSize: z.number().min(1).max(20).default(5),
- statusWindowThreshold: z.number().min(1).max(100).default(60),
- url: z.string().min(1, "URL is required"),
- ignoreTlsErrors: z.boolean().default(false),
- useAdvancedMatching: z.boolean().default(false),
- port: z.number().optional(),
- isActive: z.boolean().optional(),
- interval: z.number().optional(),
- cpuAlertThreshold: z.number().optional(),
- memoryAlertThreshold: z.number().optional(),
- diskAlertThreshold: z.number().optional(),
- tempAlertThreshold: z.number().optional(),
- notifications: z.array(z.string()).optional(),
- secret: z.string().optional(),
- jsonPath: z.union([z.string(), z.literal("")]).optional(),
- expectedValue: z.union([z.string(), z.literal("")]).optional(),
- matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
- gameId: z.union([z.string(), z.literal("")]).optional(),
- grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
- selectedDisks: z.array(z.string()).optional(),
- group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
- geoCheckEnabled: z.boolean().optional(),
- geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
- geoCheckInterval: z.number().min(300000).optional(),
- dnsServer: z.string().optional(),
- dnsRecordType: z.enum(DnsRecordTypes).optional(),
-});
+const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
+ if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["url"],
+ message: "Enter a valid domain (e.g. www.example.com)",
+ });
+ }
+};
-export const editMonitorBodyValidation = z.object({
- name: z.string().optional(),
- type: z.enum(MonitorTypes).optional(),
- url: z.string().optional(),
- statusWindowSize: z.number().min(1).max(20).default(5),
- statusWindowThreshold: z.number().min(1).max(100).default(60),
- description: z.union([z.string(), z.literal("")]).optional(),
- interval: z.number().optional(),
- notifications: z.array(z.string()).optional(),
- secret: z.string().optional(),
- ignoreTlsErrors: z.boolean().optional(),
- useAdvancedMatching: z.boolean().optional(),
- jsonPath: z.union([z.string(), z.literal("")]).optional(),
- expectedValue: z.union([z.string(), z.literal("")]).optional(),
- matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
- port: z.number().min(1).max(65535).optional(),
- cpuAlertThreshold: z.number().optional(),
- memoryAlertThreshold: z.number().optional(),
- diskAlertThreshold: z.number().optional(),
- tempAlertThreshold: z.number().optional(),
- gameId: z.union([z.string(), z.literal("")]).optional(),
- grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
- selectedDisks: z.array(z.string()).optional(),
- group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
- geoCheckEnabled: z.boolean().optional(),
- geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
- geoCheckInterval: z.number().min(300000).optional(),
- dnsServer: z.string().optional(),
- dnsRecordType: z.enum(DnsRecordTypes).optional(),
-});
+export const createMonitorBodyValidation = z
+ .object({
+ _id: z.string().optional(),
+ name: z.string().min(1, "Name is required"),
+ description: z.union([z.string(), z.literal("")]).optional(),
+ type: z.enum(MonitorTypes, "Invalid monitor type"),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
+ url: z.string().min(1, "URL is required"),
+ ignoreTlsErrors: z.boolean().default(false),
+ useAdvancedMatching: z.boolean().default(false),
+ port: z.number().optional(),
+ isActive: z.boolean().optional(),
+ interval: z.number().optional(),
+ cpuAlertThreshold: z.number().optional(),
+ memoryAlertThreshold: z.number().optional(),
+ diskAlertThreshold: z.number().optional(),
+ tempAlertThreshold: z.number().optional(),
+ notifications: z.array(z.string()).optional(),
+ secret: z.string().optional(),
+ jsonPath: z.union([z.string(), z.literal("")]).optional(),
+ expectedValue: z.union([z.string(), z.literal("")]).optional(),
+ matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
+ gameId: z.union([z.string(), z.literal("")]).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
+ selectedDisks: z.array(z.string()).optional(),
+ group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
+ geoCheckEnabled: z.boolean().optional(),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
+ geoCheckInterval: z.number().min(300000).optional(),
+ dnsServer: dnsServerValidation.optional(),
+ dnsRecordType: z.enum(DnsRecordTypes).optional(),
+ })
+ .superRefine(refineDnsHostname);
+
+export const editMonitorBodyValidation = z
+ .object({
+ name: z.string().optional(),
+ type: z.enum(MonitorTypes).optional(),
+ url: z.string().optional(),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
+ description: z.union([z.string(), z.literal("")]).optional(),
+ interval: z.number().optional(),
+ notifications: z.array(z.string()).optional(),
+ secret: z.string().optional(),
+ ignoreTlsErrors: z.boolean().optional(),
+ useAdvancedMatching: z.boolean().optional(),
+ jsonPath: z.union([z.string(), z.literal("")]).optional(),
+ expectedValue: z.union([z.string(), z.literal("")]).optional(),
+ matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
+ port: z.number().min(1).max(65535).optional(),
+ cpuAlertThreshold: z.number().optional(),
+ memoryAlertThreshold: z.number().optional(),
+ diskAlertThreshold: z.number().optional(),
+ tempAlertThreshold: z.number().optional(),
+ gameId: z.union([z.string(), z.literal("")]).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
+ selectedDisks: z.array(z.string()).optional(),
+ group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
+ geoCheckEnabled: z.boolean().optional(),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
+ geoCheckInterval: z.number().min(300000).optional(),
+ dnsServer: dnsServerValidation.optional(),
+ dnsRecordType: z.enum(DnsRecordTypes).optional(),
+ })
+ .superRefine(refineDnsHostname);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -124,49 +138,51 @@ export const getUptimeDetailsByIdQueryValidation = z.object({
normalize: booleanCoercion.optional(),
});
-const importedMonitorSchema = z.object({
- id: z.string().optional(),
- userId: z.string().optional(),
- teamId: z.string().optional(),
- name: z.string().min(1, "Name is required"),
- description: z.union([z.string(), z.literal("")]).optional(),
- status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
- statusWindow: z.array(z.boolean()).default([]),
- statusWindowSize: z.number().min(1).max(20).default(5),
- statusWindowThreshold: z.number().min(1).max(100).default(60),
- type: z.enum(MonitorTypes, "Invalid monitor type"),
- ignoreTlsErrors: z.boolean().default(false),
- useAdvancedMatching: z.boolean().default(false),
- jsonPath: z.union([z.string(), z.literal("")]).optional(),
- expectedValue: z.union([z.string(), z.literal("")]).optional(),
- matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
- url: z.string().min(1, "URL is required"),
- port: z.number().optional(),
- isActive: z.boolean().default(true),
- interval: z.number().default(60000),
- uptimePercentage: z.number().optional(),
- notifications: z.array(z.string()).default([]),
- secret: z.string().optional(),
- cpuAlertThreshold: z.number().default(100),
- cpuAlertCounter: z.number().default(5),
- memoryAlertThreshold: z.number().default(100),
- memoryAlertCounter: z.number().default(5),
- diskAlertThreshold: z.number().default(100),
- diskAlertCounter: z.number().default(5),
- tempAlertThreshold: z.number().default(100),
- tempAlertCounter: z.number().default(5),
- selectedDisks: z.array(z.string()).default([]),
- gameId: z.union([z.string(), z.literal("")]).optional(),
- grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
- group: z.union([z.string().max(50).trim(), z.null()]).default(null),
- geoCheckEnabled: z.boolean().default(false),
- geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
- geoCheckInterval: z.number().min(300000).default(300000),
- dnsServer: z.string().optional(),
- dnsRecordType: z.enum(DnsRecordTypes).optional(),
- createdAt: z.string().optional(),
- updatedAt: z.string().optional(),
-});
+const importedMonitorSchema = z
+ .object({
+ id: z.string().optional(),
+ userId: z.string().optional(),
+ teamId: z.string().optional(),
+ name: z.string().min(1, "Name is required"),
+ description: z.union([z.string(), z.literal("")]).optional(),
+ status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
+ statusWindow: z.array(z.boolean()).default([]),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
+ type: z.enum(MonitorTypes, "Invalid monitor type"),
+ ignoreTlsErrors: z.boolean().default(false),
+ useAdvancedMatching: z.boolean().default(false),
+ jsonPath: z.union([z.string(), z.literal("")]).optional(),
+ expectedValue: z.union([z.string(), z.literal("")]).optional(),
+ matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
+ url: z.string().min(1, "URL is required"),
+ port: z.number().optional(),
+ isActive: z.boolean().default(true),
+ interval: z.number().default(60000),
+ uptimePercentage: z.number().optional(),
+ notifications: z.array(z.string()).default([]),
+ secret: z.string().optional(),
+ cpuAlertThreshold: z.number().default(100),
+ cpuAlertCounter: z.number().default(5),
+ memoryAlertThreshold: z.number().default(100),
+ memoryAlertCounter: z.number().default(5),
+ diskAlertThreshold: z.number().default(100),
+ diskAlertCounter: z.number().default(5),
+ tempAlertThreshold: z.number().default(100),
+ tempAlertCounter: z.number().default(5),
+ selectedDisks: z.array(z.string()).default([]),
+ gameId: z.union([z.string(), z.literal("")]).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
+ group: z.union([z.string().max(50).trim(), z.null()]).default(null),
+ geoCheckEnabled: z.boolean().default(false),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
+ geoCheckInterval: z.number().min(300000).default(300000),
+ dnsServer: dnsServerValidation.optional(),
+ dnsRecordType: z.enum(DnsRecordTypes).optional(),
+ createdAt: z.string().optional(),
+ updatedAt: z.string().optional(),
+ })
+ .superRefine(refineDnsHostname);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts
index 28fd13c82..99f92f250 100644
--- a/server/src/validation/shared.ts
+++ b/server/src/validation/shared.ts
@@ -15,6 +15,17 @@ export const nameValidation = z
export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase());
+export const dnsServerValidation = z
+ .string()
+ .min(1, "DNS server is required")
+ .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)");
+
+// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
+// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
+// No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with
+// the client-side regex in client/src/Validation/monitor.ts.
+export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
+
export const booleanCoercion = z.preprocess((val) => {
if (val === "true" || val === true) return true;
if (val === "false" || val === false) return false;
diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts
index 3fcea5113..a96731be5 100644
--- a/server/test/unit/providers/network/dnsProvider.test.ts
+++ b/server/test/unit/providers/network/dnsProvider.test.ts
@@ -187,6 +187,28 @@ describe("DNSProvider", () => {
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
});
+ it("strips scheme from url before querying the resolver", async () => {
+ const provider = new DNSProvider(createResolver);
+ const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" }));
+
+ expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca");
+ expect(result.payload?.hostname).toBe("www.google.ca");
+ });
+
+ it("strips port and path from url before querying the resolver", async () => {
+ const provider = new DNSProvider(createResolver);
+ await provider.handle(makeMonitor({ url: "example.com:53/some/path" }));
+
+ expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
+ });
+
+ it("passes a bare hostname through unchanged", async () => {
+ const provider = new DNSProvider(createResolver);
+ await provider.handle(makeMonitor({ url: "example.com" }));
+
+ expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
+ });
+
it("stringifies non-Error throws from outside timeRequest", async () => {
mockResolverInstance.setServers.mockImplementationOnce(() => {
throw 42;
diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts
index 5bd178f51..62fa91625 100644
--- a/server/test/unit/validation/monitorValidation.test.ts
+++ b/server/test/unit/validation/monitorValidation.test.ts
@@ -51,6 +51,51 @@ describe("monitorValidation — DNS fields", () => {
expect(parsed.dnsServer).toBeUndefined();
expect(parsed.dnsRecordType).toBeUndefined();
});
+
+ it("rejects a DNS monitor whose url has a scheme or path", () => {
+ for (const badUrl of ["https://example.com", "example.com/path", "example.com:53", " example.com"]) {
+ expect(() =>
+ createMonitorBodyValidation.parse({
+ ...baseDnsBody,
+ url: badUrl,
+ dnsServer: "8.8.8.8",
+ dnsRecordType: "A",
+ })
+ ).toThrow();
+ }
+ });
+
+ it("rejects a DNS monitor with an invalid dnsServer", () => {
+ expect(() =>
+ createMonitorBodyValidation.parse({
+ ...baseDnsBody,
+ dnsServer: "not-an-ip",
+ dnsRecordType: "A",
+ })
+ ).toThrow();
+ });
+
+ it("accepts service labels with leading underscore (DMARC, SRV, ACME)", () => {
+ for (const url of ["_dmarc.example.com", "_imaps._tcp.example.com", "_acme-challenge.example.com"]) {
+ const parsed = createMonitorBodyValidation.parse({
+ ...baseDnsBody,
+ url,
+ dnsServer: "8.8.8.8",
+ dnsRecordType: "TXT",
+ });
+ expect(parsed.url).toBe(url);
+ }
+ });
+
+ it("does not enforce the hostname rule for non-DNS monitors", () => {
+ const parsed = createMonitorBodyValidation.parse({
+ name: "HTTP check",
+ type: "http",
+ url: "https://example.com/some/path",
+ });
+
+ expect(parsed.url).toBe("https://example.com/some/path");
+ });
});
describe("editMonitorBodyValidation", () => {