diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts new file mode 100644 index 000000000..4bf127785 --- /dev/null +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -0,0 +1,187 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; +import { ILogger } from "@/utils/logger.js"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; + +import path from "path"; +import { fileURLToPath } from "url"; +import { Network } from "inspector/promises"; + +type GrpcType = typeof grpc; +type ProtoLoaderType = typeof protoLoader; + +const SERVICE_NAME = "GrpcProvider"; + +export interface GrpcError extends Error { + code: number; + details?: string; +} + +export class GrpcProvider implements IStatusProvider { + readonly type = "grpc"; + constructor( + private logger: ILogger, + private grpc: GrpcType, + private protoLoader: ProtoLoaderType + ) {} + + supports(type: MonitorType): boolean { + return type === "grpc"; + } + + private isGrpcError(error: unknown): error is GrpcError { + return error instanceof Error && "code" in error && typeof (error as Record).code === "number"; + } + + private getGrpcStatusName(code: number): string { + const statusNames: Record = { + 0: "OK", + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", + }; + return statusNames[code] || "UNKNOWN"; + } + + async handle(monitor: Monitor): Promise> { + const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; + const grpcServiceName = monitor.grpcServiceName || ""; + const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; + + try { + if (!host || !port) { + throw new Error("Valid host and port are required"); + } + const target = `${host}:${port}`; + const currentFilePath = fileURLToPath(import.meta.url); + const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto"); + const packageDefinition = this.protoLoader.loadSync(protoPath, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as any; + const HealthClient = grpcObject.grpc.health.v1.Health; + let credentials; + if (ignoreTlsErrors) { + credentials = this.grpc.credentials.createSsl(null, null, null, { + checkServerIdentity: () => undefined, + }); + } else { + credentials = this.grpc.credentials.createInsecure(); + } + const client = new HealthClient(target, credentials); + const deadline = new Date(Date.now() + 10000); // 10s timeout + const { response, responseTime, error } = await timeRequest(() => { + return new Promise((resolve, reject) => { + client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => { + client.close(); + + if (err) { + const grpcErr = err as { code?: number; details?: string; message?: string }; + const payload: GrpcStatusPayload = { + grpcStatusCode: grpcErr.code ?? -1, + grpcStatusName: this.getGrpcStatusName(grpcErr.code ?? -1), + serviceName: grpcServiceName, + servingStatus: "UNKNOWN", + }; + const grpcError = new AppError({ + message: grpcErr.details || grpcErr.message || "gRPC error", + service: SERVICE_NAME, + method: "handle", + }) as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; + grpcError.grpcPayload = payload; + grpcError.grpcCode = grpcErr.code; + reject(grpcError); + return; + } + + const resp = response as { status?: string } | undefined; + const servingStatus = resp?.status ?? "UNKNOWN"; + resolve({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: grpcServiceName, + servingStatus, + }); + }); + }); + }); + + if (error) { + let grpcCode = -1; + let message = error instanceof Error ? error.message : "gRPC health check failed"; + + if (this.isGrpcError(error)) { + grpcCode = error.code; + message = error.details || error.message; + } + + return { + monitorId: id, + teamId: teamId, + type: type, + status: false, + code: 500, + message: message, + responseTime, + payload: { + grpcStatusCode: grpcCode, + grpcStatusName: this.getGrpcStatusName(grpcCode), + serviceName: grpcServiceName, + servingStatus: "UNKNOWN", + }, + }; + } + + if (!response) { + throw new Error("No response received from gRPC health check"); + } + + const isServing = response.servingStatus === "SERVING"; + + return { + monitorId: id, + teamId: teamId, + type: type, + status: isServing, + code: isServing ? 200 : NETWORK_ERROR, + message: isServing ? "gRPC health check successful" : "gRPC health check failed", + responseTime, + payload: { + grpcStatusCode: isServing ? 200 : NETWORK_ERROR, + grpcStatusName: response.grpcStatusName, + serviceName: response.serviceName, + servingStatus: response.servingStatus || "UNKNOWN", + }, + }; + } catch (err: unknown) { + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing gRPC health check", + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName }, + }); + } + } +} diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index d727345da..1a0d71553 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -277,360 +277,6 @@ class NetworkService implements INetworkService { } } - private async requestHttp(monitor: Monitor): Promise> { - const { url, secret, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; - const httpResponse = this.buildStatusResponse({ - monitor, - overrides: { - status: false, - code: this.NETWORK_ERROR, - message: "Request not executed", - }, - }); - - try { - if (!url) { - throw new Error("Monitor URL is required"); - } - const config: Record = { - headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, - }; - - if (ignoreTlsErrors) { - config.agent = { - https: new this.https.Agent({ - rejectUnauthorized: false, - }), - }; - } - - const response = await this.got(url, config); - - let payload: T | string | undefined; - const contentType = response.headers["content-type"]; - - if (contentType && contentType.includes("application/json")) { - try { - payload = JSON.parse(response.body); - } catch { - payload = response.body; - } - } else { - payload = response.body; - } - - Object.assign(httpResponse, { - code: response.statusCode, - status: response.ok, - message: response.statusMessage ?? "", - responseTime: response.timings.phases.total || 0, - payload, - timings: response.timings, - }); - - if (!useAdvancedMatching) { - return httpResponse; - } - - if (expectedValue && !jsonPath) { - let ok = false; - if (matchMethod === "equal") ok = payload === expectedValue; - if (matchMethod === "include" && typeof payload === "string") ok = payload.includes(expectedValue); - if (matchMethod === "regex" && typeof payload === "string") ok = new RegExp(expectedValue).test(payload); - - if (ok === true) { - return httpResponse; - } else { - httpResponse.code = 500; - httpResponse.status = false; - httpResponse.message = "Expected value did not match"; - return httpResponse; - } - } - - if (jsonPath) { - const contentType = response.headers["content-type"]; - const isJson = contentType?.includes("application/json"); - if (!isJson) { - httpResponse.status = false; - httpResponse.message = "Response is not JSON"; - return httpResponse; - } - try { - const extracted = this.jmespath.search(payload, jsonPath); - if (expectedValue) { - let ok = false; - if (matchMethod === "equal") ok = extracted === expectedValue; - if (matchMethod === "include" && typeof extracted === "string") ok = extracted.includes(expectedValue); - if (matchMethod === "regex" && typeof extracted === "string") ok = new RegExp(expectedValue).test(extracted); - - if (ok) { - httpResponse.extracted = extracted; - return httpResponse; - } else { - httpResponse.status = false; - httpResponse.code = 500; - httpResponse.message = "Expected value did not match"; - httpResponse.extracted = extracted; - return httpResponse; - } - } else { - const isFalsey = extracted === false || extracted === "false" || extracted === undefined || extracted === null; - if (!isFalsey) { - httpResponse.extracted = extracted; - return httpResponse; - } else { - httpResponse.status = false; - httpResponse.code = 500; - httpResponse.message = "Expected value did not match"; - httpResponse.extracted = extracted; - return httpResponse; - } - } - } catch { - httpResponse.status = false; - httpResponse.message = "Error evaluating JSON path"; - return httpResponse; - } - } - return httpResponse; - } catch (err: unknown) { - if (err instanceof HTTPError || err instanceof RequestError) { - httpResponse.code = err.response?.statusCode || this.NETWORK_ERROR; - httpResponse.status = false; - httpResponse.message = String(err.response?.statusCode) || err.message; - httpResponse.responseTime = err.timings?.phases?.total || 0; - httpResponse.payload = null; - httpResponse.timings = err.timings || undefined; - return httpResponse; - } - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing HTTP request", - service: this.SERVICE_NAME, - method: "requestHttp", - details: { url: monitor.url }, - }); - } - } - - private async requestPageSpeed(monitor: Monitor): Promise> { - try { - const url = monitor.url; - if (!url) { - throw new Error("Monitor URL is required"); - } - let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`; - const dbSettings = await this.settingsService.getDBSettings(); - if (dbSettings?.pagespeedApiKey) { - pageSpeedUrl += `&key=${dbSettings.pagespeedApiKey}`; - } else { - this.logger.warn({ - message: "PageSpeed API key not found, job not executed", - service: this.SERVICE_NAME, - method: "requestPagespeed", - details: { url }, - }); - } - return await this.requestHttp({ - ...monitor, - url: pageSpeedUrl, - }); - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing PageSpeed request", - service: this.SERVICE_NAME, - method: "requestPageSpeed", - details: { url: monitor.url }, - }); - } - } - - private async requestHardware(monitor: Monitor): Promise> { - try { - return await this.requestHttp(monitor); - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing hardware request", - service: this.SERVICE_NAME, - method: "requestHardware", - details: { url: monitor.url }, - }); - } - } - - private async requestDocker(monitor: Monitor): Promise> { - try { - if (!monitor.url) { - throw new Error("Monitor URL is required"); - } - - const docker = new this.Docker({ - socketPath: "/var/run/docker.sock", - }); - - const dockerResponse = this.buildStatusResponse({ - monitor, - overrides: { - status: false, - code: this.NETWORK_ERROR, - message: "No response", - }, - }); - - const containers = await docker.listContainers({ all: true }); - - // Normalize input: strip leading slashes and convert to lowercase for comparison - const normalizedInput = monitor.url.replace(/^\/+/, "").toLowerCase(); - - // Priority-based matching to avoid ambiguity: - // 1. Exact full ID match (64-char) - const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); - - // 2. Exact container name match (case-insensitive) - const exactNameMatch = containers.find((c) => - c.Names.some((name: string) => { - const cleanName = name.replace(/^\/+/, "").toLowerCase(); - return cleanName === normalizedInput; - }) - ); - - // 3. Partial ID match (fallback for backwards compatibility) - const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); - - // Select container based on priority - const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; - - // Return negative response if no container - if (!targetContainer) { - this.logger.warn({ - message: `No container found for "${monitor.url}".`, - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - - dockerResponse.code = 404; - dockerResponse.status = false; - dockerResponse.message = "Docker container not found"; - return dockerResponse; - } - - // Return negative response if ambiguous matches exist - const matchTypes = []; - if (exactIdMatch) matchTypes.push("exact ID"); - if (exactNameMatch) matchTypes.push("exact name"); - if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); - - if (matchTypes.length > 1) { - this.logger.warn({ - message: `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`, - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - dockerResponse.status = false; - dockerResponse.message = `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; - return dockerResponse; - } - - const container = docker.getContainer(targetContainer.Id); - const { response, responseTime, error }: { response?: { State: { Status: string } } | null; responseTime: number; error: unknown } = - await this.timeRequest(() => container.inspect()); - - dockerResponse.responseTime = responseTime; - dockerResponse.status = response?.State?.Status === "running" ? true : false; - dockerResponse.code = 200; - dockerResponse.message = "Docker container status fetched successfully"; - - if (error) { - dockerResponse.status = false; - dockerResponse.code = - (error && typeof error === "object" && "statusCode" in error ? (error as { statusCode?: number }).statusCode : undefined) || - this.NETWORK_ERROR; - dockerResponse.message = - (error && typeof error === "object" && "reason" in error ? (error as { reason?: string }).reason : undefined) || - "Failed to fetch Docker container information"; - return dockerResponse; - } - - return dockerResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing Docker request", - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - } - } - - private async requestPort(monitor: Monitor): Promise> { - try { - const { url, port } = monitor; - if (!port) { - throw new Error("Port is required for port monitor"); - } - - const { response, responseTime, error } = await this.timeRequest(async () => { - return new Promise((resolve, reject) => { - const socket = this.net.createConnection( - { - host: url, - port, - }, - () => { - socket.end(); - socket.destroy(); - resolve({ success: true }); - } - ); - - socket.setTimeout(5000); - socket.on("timeout", () => { - socket.destroy(); - reject(new Error("Connection timeout")); - }); - - socket.on("error", (err: unknown) => { - socket.destroy(); - reject(err); - }); - }); - }); - - const portResponse = this.buildStatusResponse({ - monitor, - overrides: { - code: 200, - status: (response as { success?: boolean })?.success ?? false, - message: "Port check successful", - responseTime, - }, - }); - - if (error) { - portResponse.code = this.NETWORK_ERROR; - portResponse.status = false; - portResponse.message = "Port check failed"; - return portResponse; - } - - return portResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing port check", - service: this.SERVICE_NAME, - method: "requestPort", - details: { url: monitor.url, port: monitor.port }, - }); - } - } - private async requestGrpc(monitor: Monitor): Promise> { try { const { url, port, ignoreTlsErrors } = monitor;