diff --git a/client/package.json b/client/package.json index 7b1e0e50a..29d507b64 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "i18n:display": "i18n-unused display-unused > test.log", - "dev": "vite --mode development", + "dev": "vite --host 127.0.0.1 --mode development", "build": "tsc -b && vite build --mode production", "lint": "eslint .", "preview": "vite preview" diff --git a/client/src/hooks/forms/UseInitMonitorFrom.tsx b/client/src/hooks/forms/UseInitMonitorFrom.tsx index 65e06edec..883d65857 100644 --- a/client/src/hooks/forms/UseInitMonitorFrom.tsx +++ b/client/src/hooks/forms/UseInitMonitorFrom.tsx @@ -20,6 +20,7 @@ export const useInitForm = ({ const defaults: z.infer = { type: initialData?.type || "https", url: initialData?.url || "", + port: initialData?.port || 80, n: initialData?.n || 3, notificationChannels: initialData?.notificationChannels || [], name: initialData?.name || "", diff --git a/client/src/pages/uptime/UptimeForm.tsx b/client/src/pages/uptime/UptimeForm.tsx index 18dcdd3e6..20719c54d 100644 --- a/client/src/pages/uptime/UptimeForm.tsx +++ b/client/src/pages/uptime/UptimeForm.tsx @@ -132,6 +132,11 @@ export const UptimeForm = ({ "Use HTTP to monitor your website or API endpoint." } /> + )} /> + {selectedType === "port" && ( + ( + + )} + /> + )} (schema: T) => schema.optional() ); -export const monitorSchema = z.object({ - type: z.string().min(1, "You must select an option"), - url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), - n: z.coerce - .number({ message: "Number required" }) - .min(1, "Minimum value is 1") - .max(25, "Maximum value is 25"), - notificationChannels: z.array(z.string()).optional().default([]), - name: z.string().min(1, "Display name is required"), - interval: durationSchema, -}); +export const monitorSchema = z + .object({ + type: z.string().min(1, "You must select an option"), + url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), + n: z.coerce + .number({ message: "Number required" }) + .min(1, "Minimum value is 1") + .max(25, "Maximum value is 25"), + port: z.coerce.number().optional(), + notificationChannels: z.array(z.string()).optional().default([]), + name: z.string().min(1, "Display name is required"), + interval: durationSchema, + }) + .superRefine((data, ctx) => { + if (data.type === "port") { + if (!data.port) { + ctx.addIssue({ + code: "custom", + message: "Port is required for port monitoring", + path: ["port"], + }); + } + if (isNaN(Number(data.port))) { + ctx.addIssue({ + code: "custom", + message: "Port must be a number", + path: ["port"], + }); + } + if (Number(data.port) < 1 || Number(data.port) > 65535) { + ctx.addIssue({ + code: "custom", + message: "Port must be between 1 and 65535", + path: ["port"], + }); + } + } + }) + .transform((data) => { + if (data.type !== "port") { + return { ...data, port: undefined }; + } + return data; + }); export const monitorSchemaPageSpeed = z.object({ type: z.string().min(1, "You must select an option"), @@ -212,13 +245,9 @@ export const maintenanceSchema = z export const systemSettingsSchema = z.object({ systemEmailHost: optionalString( - z - .string() - .trim() - .min(1, { message: "SMTP host is required" }) - .max(255, { - message: "SMTP host must be 255 characters or fewer", - }) + z.string().trim().min(1, { message: "SMTP host is required" }).max(255, { + message: "SMTP host must be 255 characters or fewer", + }) ), systemEmailPort: optionalString( z @@ -237,35 +266,24 @@ export const systemSettingsSchema = z.object({ z.string().email({ message: "SMTP email must be valid" }) ), systemEmailPassword: optionalString( - z - .string() - .max(255, { - message: "SMTP password must be 255 characters or fewer", - }) + z.string().max(255, { + message: "SMTP password must be 255 characters or fewer", + }) ), systemEmailUser: optionalString( - z - .string() - .trim() - .max(255, { - message: "SMTP username must be 255 characters or fewer", - }) + z.string().trim().max(255, { + message: "SMTP username must be 255 characters or fewer", + }) ), systemEmailConnectionHost: optionalString( - z - .string() - .trim() - .max(255, { - message: "Connection host must be 255 characters or fewer", - }) + z.string().trim().max(255, { + message: "Connection host must be 255 characters or fewer", + }) ), systemEmailTLSServername: optionalString( - z - .string() - .trim() - .max(255, { - message: "TLS server name must be 255 characters or fewer", - }) + z.string().trim().max(255, { + message: "TLS server name must be 255 characters or fewer", + }) ), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), diff --git a/server/src/db/models/monitors/Monitor.ts b/server/src/db/models/monitors/Monitor.ts index 119fcbfca..b190647dd 100644 --- a/server/src/db/models/monitors/Monitor.ts +++ b/server/src/db/models/monitors/Monitor.ts @@ -4,6 +4,7 @@ import { Check, MonitorStats } from "@/db/models/index.js"; export const MonitorTypes = [ "http", "https", + "port", "ping", "infrastructure", "pagespeed", @@ -17,12 +18,14 @@ export const MonitorStatuses = [ "initializing", ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; + export interface IMonitor extends Document { _id: Types.ObjectId; orgId: Types.ObjectId; teamId: Types.ObjectId; name: string; url: string; + port?: number; secret?: string; type: MonitorType; interval: number; // in ms @@ -47,6 +50,7 @@ const MonitorSchema = new Schema( teamId: { type: Schema.Types.ObjectId, ref: "Team", required: true }, name: { type: String, required: true, trim: true, maxlength: 100 }, url: { type: String, required: true, trim: true }, + port: { type: Number, required: false }, secret: { type: String, required: false }, type: { type: String, diff --git a/server/src/services/business/CheckService.ts b/server/src/services/business/CheckService.ts index 50f36fa8e..6bbd6a97c 100644 --- a/server/src/services/business/CheckService.ts +++ b/server/src/services/business/CheckService.ts @@ -188,6 +188,8 @@ class CheckService implements ICheckService { case "ping": return this.buildBaseCheck(statusResponse); + case "port": + return this.buildBaseCheck(statusResponse); default: throw new Error(`Unsupported monitor type: ${type}`); } diff --git a/server/src/services/business/MonitorService.ts b/server/src/services/business/MonitorService.ts index 72fc33a04..0c146a31b 100644 --- a/server/src/services/business/MonitorService.ts +++ b/server/src/services/business/MonitorService.ts @@ -657,7 +657,7 @@ class MonitorService implements IMonitorService { export = async (teamId: string) => { return Monitor.find({ teamId }) - .select("name url type interval n secret -_id") + .select("name url port type interval n secret -_id") .lean(); }; diff --git a/server/src/services/infrastructure/NetworkService.ts b/server/src/services/infrastructure/NetworkService.ts index 57d73e7aa..1382c25db 100644 --- a/server/src/services/infrastructure/NetworkService.ts +++ b/server/src/services/infrastructure/NetworkService.ts @@ -1,5 +1,6 @@ import { Got, HTTPError } from "got"; import ping from "ping"; +import net from "net"; import { IMonitor } from "@/db/models/index.js"; import { GotTimings } from "@/db/models/checks/Check.js"; import type { Response } from "got"; @@ -12,16 +13,16 @@ import { MonitorType, MonitorStatus } from "@/db/models/monitors/Monitor.js"; import ApiError from "@/utils/ApiError.js"; import { config } from "@/config/index.js"; import CacheableLookup from "cacheable-lookup"; -import http from "http"; -import https from "https"; - +import { getChildLogger } from "@/logger/Logger.js"; const SERVICE_NAME = "NetworkService"; +const logger = getChildLogger(SERVICE_NAME); export interface INetworkService { requestHttp: (monitor: IMonitor) => Promise; requestInfrastructure: (monitor: IMonitor) => Promise; requestStatus: (monitor: IMonitor) => Promise; requestPagespeed: (monitor: IMonitor) => Promise; requestPing: (monitor: IMonitor) => Promise; + requestPort: (monitor: IMonitor) => Promise; } export interface ICapturePayload { @@ -216,6 +217,78 @@ class NetworkService implements INetworkService { }; }; + requestPort = async (monitor: IMonitor) => { + const response = { + monitorId: monitor._id.toString(), + teamId: monitor.teamId.toString(), + type: monitor.type, + status: "down" as MonitorStatus, + message: "Port check failed", + responseTime: 0, + timings: { phases: {} } as GotTimings, + }; + + try { + const host = monitor.url; + const port = monitor.port; + if (!port) { + throw new Error("No port provided for port monitor"); + } + + const { status, responseTime, message } = await new Promise<{ + status: MonitorStatus; + responseTime: number; + message: string; + }>((resolve) => { + const start = performance.now(); + const socket = net.createConnection({ + host, + port, + }); + + socket.setTimeout(5000); + + socket.on("connect", () => { + socket.destroy(); + const duration = performance.now() - start; + resolve({ + status: "up", + responseTime: duration, + message: "Port is open", + }); + }); + + socket.on("error", (err) => { + socket.destroy(); + const duration = performance.now() - start; + resolve({ + status: "down", + responseTime: duration, + message: err.message, + }); + }); + + socket.on("timeout", () => { + socket.destroy(); + const duration = performance.now() - start; + resolve({ + status: "down", + responseTime: duration, + message: "Port check timed out", + }); + }); + }); + + response.status = status; + response.responseTime = responseTime; + response.message = message; + } catch (error) { + console.error(error); + } finally { + return response; + } + }; + requestStatus = async (monitor: IMonitor) => { switch (monitor?.type) { case "http": @@ -228,6 +301,8 @@ class NetworkService implements INetworkService { return await this.requestPagespeed(monitor); // uses GOT case "ping": return await this.requestPing(monitor); // uses PING + case "port": + return await this.requestPort(monitor); default: throw new Error("Not implemented"); } diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index 95ce1e428..8069a01a5 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -52,6 +52,7 @@ export const monitorSchema = z type: z.string().min(1, "You must select an option"), url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), name: z.string().min(1, "Display name is required"), + port: z.coerce.number().optional(), n: z .number({ message: "Number required" }) .min(1, "Minimum value is 1") @@ -81,6 +82,30 @@ export const monitorSchema = z message: `Secret is required for infrastructure monitors`, }); } + + if (data.type === "port") { + if (!data.port) { + ctx.addIssue({ + code: "custom", + message: "Port is required for port monitoring", + path: ["port"], + }); + } + if (isNaN(Number(data.port))) { + ctx.addIssue({ + code: "custom", + message: "Port must be a number", + path: ["port"], + }); + } + if (Number(data.port) < 1 || Number(data.port) > 65535) { + ctx.addIssue({ + code: "custom", + message: "Port must be between 1 and 65535", + path: ["port"], + }); + } + } }); export const monitorImportSchema = z.object({ @@ -90,6 +115,7 @@ export const monitorImportSchema = z.object({ .object({ name: z.string().trim().min(1, "Monitor name is required"), url: z.string().trim().regex(urlRegex, "Invalid URL"), + port: z.number().min(1).max(65535).optional(), secret: z.string().trim().optional(), type: z.enum(MonitorTypes), interval: z.number().min(1, "Interval must be greater than 0"),