diff --git a/server/package-lock.json b/server/package-lock.json index 42c2388dc..c6f7fc936 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -43,6 +43,7 @@ "super-simple-scheduler": "1.4.5", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", + "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { @@ -63,6 +64,7 @@ "@types/papaparse": "^5.5.2", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", + "@types/ws": "^8.18.1", "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", @@ -4670,6 +4672,16 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -15172,9 +15184,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/server/package.json b/server/package.json index ee30cccee..40a3d8494 100755 --- a/server/package.json +++ b/server/package.json @@ -58,6 +58,7 @@ "super-simple-scheduler": "1.4.5", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", + "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { @@ -78,6 +79,7 @@ "@types/papaparse": "^5.5.2", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", + "@types/ws": "^8.18.1", "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index d7648803f..1e08d0950 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -45,6 +45,7 @@ import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; +import WebSocket from "ws"; // repositories import { @@ -145,7 +146,21 @@ export const initializeServices = async ({ const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - const networkService = new NetworkService(axios, got, https, jmespath, GameDig, ping, logger, Docker, net, settingsService, grpc, protoLoader); + const networkService = new NetworkService( + axios, + got, + https, + jmespath, + GameDig, + ping, + logger, + Docker, + net, + settingsService, + grpc, + protoLoader, + WebSocket + ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); const notificationMessageBuilder = new NotificationMessageBuilder(); diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 3cf24ccf6..1dcb82853 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -41,14 +41,7 @@ export interface INetworkService { class NetworkService implements INetworkService { static SERVICE_NAME = SERVICE_NAME; - private TYPE_PING: string; - private TYPE_HTTP: string; - private TYPE_PAGESPEED: string; - private TYPE_HARDWARE: string; - private TYPE_DOCKER: string; - private TYPE_PORT: string; - private TYPE_GAME: string; - private TYPE_GRPC: string; + private typeHandlers!: Record Promise>; private SERVICE_NAME: string; private NETWORK_ERROR: number; private PING_ERROR: number; @@ -65,6 +58,7 @@ class NetworkService implements INetworkService { private settingsService: ISettingsService; private grpc: any; private protoLoader: any; + private WebSocket: any; private buildStatusResponse = ({ monitor, @@ -132,16 +126,20 @@ class NetworkService implements INetworkService { net: any, settingsService: ISettingsService, grpc: any, - protoLoader: any + protoLoader: any, + WebSocket: any ) { - this.TYPE_PING = "ping"; - this.TYPE_HTTP = "http"; - this.TYPE_PAGESPEED = "pagespeed"; - this.TYPE_HARDWARE = "hardware"; - this.TYPE_DOCKER = "docker"; - this.TYPE_PORT = "port"; - this.TYPE_GAME = "game"; - this.TYPE_GRPC = "grpc"; + this.typeHandlers = { + ping: (m) => this.requestPing(m), + http: (m) => this.requestHttp(m), + pagespeed: (m) => this.requestPageSpeed(m), + hardware: (m) => this.requestHardware(m), + docker: (m) => this.requestDocker(m), + port: (m) => this.requestPort(m), + game: (m) => this.requestGame(m), + grpc: (m) => this.requestGrpc(m), + websocket: (m) => this.requestWebSocket(m), + }; this.SERVICE_NAME = SERVICE_NAME; this.NETWORK_ERROR = 5000; this.PING_ERROR = 5001; @@ -156,6 +154,7 @@ class NetworkService implements INetworkService { this.settingsService = settingsService; this.grpc = grpc; this.protoLoader = protoLoader; + this.WebSocket = WebSocket; const cacheable = new CacheableLookup(); @@ -188,26 +187,8 @@ class NetworkService implements INetworkService { // Main entry point async requestStatus(monitor: Monitor): Promise { const type = monitor?.type || "unknown"; - switch (type) { - case this.TYPE_PING: - return await this.requestPing(monitor); - case this.TYPE_HTTP: - return await this.requestHttp(monitor); - case this.TYPE_PAGESPEED: - return await this.requestPageSpeed(monitor); - case this.TYPE_HARDWARE: - return await this.requestHardware(monitor); - case this.TYPE_DOCKER: - return await this.requestDocker(monitor); - case this.TYPE_PORT: - return await this.requestPort(monitor); - case this.TYPE_GAME: - return await this.requestGame(monitor); - case this.TYPE_GRPC: - return await this.requestGrpc(monitor); - default: - return this.handleUnsupportedType(type); - } + const handler = this.typeHandlers[type]; + return handler ? handler(monitor) : this.handleUnsupportedType(type); } private async requestPing(monitor: Monitor): Promise { @@ -763,6 +744,75 @@ class NetworkService implements INetworkService { return statusNames[code] || "UNKNOWN"; } + private async requestWebSocket(monitor: Monitor): Promise { + try { + const { url, ignoreTlsErrors } = monitor; + + if (!url) { + throw new Error("Monitor URL is required"); + } + + const TIMEOUT_MS = 10000; + + const wsResponse = this.buildStatusResponse({ + monitor, + overrides: { + status: false, + code: this.NETWORK_ERROR, + message: "WebSocket connection not established", + }, + }); + + const { response, responseTime, error } = await this.timeRequest<{ connected: boolean }>(() => { + return new Promise<{ connected: boolean }>((resolve, reject) => { + const wsOptions: Record = {}; + if (ignoreTlsErrors) { + wsOptions.rejectUnauthorized = false; + } + + const ws = new this.WebSocket(url, wsOptions); + + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("WebSocket connection timeout")); + }, TIMEOUT_MS); + + ws.on("open", () => { + clearTimeout(timeout); + ws.close(); + resolve({ connected: true }); + }); + + ws.on("error", (err: Error) => { + clearTimeout(timeout); + ws.close(); + reject(err); + }); + }); + }); + + if (error) { + wsResponse.status = false; + wsResponse.code = this.NETWORK_ERROR; + wsResponse.message = (error as Error).message || "WebSocket connection failed"; + wsResponse.responseTime = responseTime; + return wsResponse; + } + + wsResponse.status = true; + wsResponse.code = 200; + wsResponse.message = "WebSocket connection successful"; + wsResponse.responseTime = responseTime; + wsResponse.payload = response; + + return wsResponse; + } catch (err: any) { + err.service = this.SERVICE_NAME; + err.method = "requestWebSocket"; + throw err; + } + } + private async handleUnsupportedType(type: string): Promise { return { monitorId: "unknown", diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 547281d68..d4f75f3ac 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -11,7 +11,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(), + type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 4694143ac..75ffccd19 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -21,8 +21,8 @@ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ type: z .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])), + z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), + z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), ]) .optional(), filter: z.union([z.string(), z.literal(""), z.null()]).optional(), @@ -37,8 +37,8 @@ export const getMonitorsWithChecksQueryValidation = z.object({ order: z.enum(["asc", "desc"]).optional(), type: z .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])), + z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), + z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), ]) .optional(), explain: booleanCoercion.optional(),