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", () => {