Skip to main content
Glama
nginxMonitoring.ts8.63 kB
import type { CommandRunner } from "../utils/commandRunner.js"; import { readFile } from "node:fs/promises"; import { logger } from "../utils/logger.js"; import { validateFilePath, validateNumericRange, sanitizeShellArg, ALLOWED_LOG_DIRS } from "../utils/validators.js"; export interface AccessStats { readonly totalRequests: number; readonly byStatusCode: Record<string, number>; readonly byMethod: Record<string, number>; readonly topPaths: Array<{ path: string; count: number }>; readonly topIPs: Array<{ ip: string; count: number }>; readonly avgResponseSize: number; } export interface ErrorEntry { readonly timestamp: string; readonly level: string; readonly message: string; readonly client?: string; } export interface ConfigTestResult { readonly success: boolean; readonly output: string; readonly errors: string[]; } export interface UpstreamStatus { readonly name: string; readonly status: "up" | "down" | "unknown"; readonly lastCheck: string; } export class NginxMonitoringService { public constructor(private readonly runner: CommandRunner) {} public async getAccessLogStats(logPath?: string, lastMinutes?: number, maxLines?: number): Promise<AccessStats> { const accessLog = logPath ?? process.env.NGINX_ACCESS_LOG ?? "/var/log/nginx/access.log"; // Validate log path to prevent path traversal if (!validateFilePath(accessLog, ALLOWED_LOG_DIRS)) { throw new Error(`Invalid log path: ${accessLog}. Must be in allowed directories.`); } if (typeof maxLines === "number") { if (!validateNumericRange(maxLines, 1, 50000, "maxLines")) { throw new Error(`Invalid maxLines parameter: ${maxLines}. Must be between 1 and 50000.`); } } try { const content = await readFile(accessLog, "utf8"); const lines = content.split("\n").filter((line) => line.trim()); const boundedLines = typeof maxLines === "number" ? lines.slice(-maxLines) : lines; // Filter by time if requested let filteredLines = boundedLines; if (lastMinutes) { const cutoffTime = Date.now() - lastMinutes * 60 * 1000; filteredLines = boundedLines.filter((line) => { const timeMatch = line.match(/\[([^\]]+)\]/); if (!timeMatch) { return false; } const timestamp = new Date(timeMatch[1].replace(":", " ")).getTime(); return timestamp >= cutoffTime; }); } const byStatusCode: Record<string, number> = {}; const byMethod: Record<string, number> = {}; const pathCounts = new Map<string, number>(); const ipCounts = new Map<string, number>(); let totalResponseSize = 0; for (const line of filteredLines) { // Parse NGINX combined log format // Example: 192.168.1.1 - - [01/Nov/2025:10:30:00 +0000] "GET /api/users HTTP/1.1" 200 1234 "-" "Mozilla/5.0" const parts = line.match(/^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+) (\d+)/); if (!parts) { continue; } const [, ip, , method, path, statusCode, responseSize] = parts; // Count by status code byStatusCode[statusCode] = (byStatusCode[statusCode] ?? 0) + 1; // Count by method byMethod[method] = (byMethod[method] ?? 0) + 1; // Count paths pathCounts.set(path, (pathCounts.get(path) ?? 0) + 1); // Count IPs ipCounts.set(ip, (ipCounts.get(ip) ?? 0) + 1); // Sum response sizes totalResponseSize += Number(responseSize) || 0; } const topPaths = Array.from(pathCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([path, count]) => ({ path, count })); const topIPs = Array.from(ipCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([ip, count]) => ({ ip, count })); return { totalRequests: filteredLines.length, byStatusCode, byMethod, topPaths, topIPs, avgResponseSize: filteredLines.length > 0 ? Math.round(totalResponseSize / filteredLines.length) : 0, }; } catch (error) { logger.error("Failed to parse access log", { logPath: accessLog, error }); throw error; } } public async getErrorLogRecent(logPath?: string, lines: number = 100): Promise<ErrorEntry[]> { const errorLog = logPath ?? process.env.NGINX_ERROR_LOG ?? "/var/log/nginx/error.log"; // Validate log path to prevent path traversal if (!validateFilePath(errorLog, ALLOWED_LOG_DIRS)) { throw new Error(`Invalid log path: ${errorLog}. Must be in allowed directories.`); } // Validate lines parameter if (!validateNumericRange(lines, 1, 10000, "lines")) { throw new Error(`Invalid lines parameter: ${lines}. Must be between 1 and 10000.`); } try { // Sanitize parameters for shell command const safeLines = sanitizeShellArg(String(lines)); const safeErrorLog = sanitizeShellArg(errorLog); const result = await this.runner.run(`tail -n ${safeLines} ${safeErrorLog}`, { requiresSudo: false, timeoutMs: 10000, }); const entries: ErrorEntry[] = []; const logLines = result.stdout.split("\n").filter((line) => line.trim()); for (const line of logLines) { // Parse NGINX error log format // Example: 2025/11/01 10:30:00 [error] 12345#0: *1 connect() failed (111: Connection refused) const match = line.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] \d+#\d+: (.+)$/); if (!match) { continue; } const [, timestamp, level, message] = match; // Extract client IP if present const clientMatch = message.match(/client: ([^,]+)/); entries.push({ timestamp, level, message: message.trim(), client: clientMatch ? clientMatch[1] : undefined, }); } return entries; } catch (error) { logger.error("Failed to read error log", { logPath: errorLog, error }); throw error; } } public async testConfiguration(): Promise<ConfigTestResult> { try { const result = await this.runner.run("nginx -t", { requiresSudo: true, timeoutMs: 10000, }); const output = result.stderr + result.stdout; const errors: string[] = []; // Check for error markers if (output.includes("test failed")) { const errorLines = output.split("\n").filter((line) => line.includes("error")); errors.push(...errorLines); } return { success: output.includes("test is successful"), output, errors, }; } catch (error) { logger.error("NGINX config test failed", { error }); return { success: false, output: error instanceof Error ? error.message : String(error), errors: ["Configuration test failed to run"], }; } } public async getUpstreamHealth(): Promise<UpstreamStatus[]> { // Note: This requires NGINX Plus or custom status module // For open-source NGINX, we can check if upstream hosts are reachable const configPath = process.env.NGINX_CONFIG_PATH ?? "/etc/nginx/nginx.conf"; try { const content = await readFile(configPath, "utf8"); // Extract upstream definitions const upstreamBlocks = content.match(/upstream\s+(\w+)\s*\{[^}]+\}/g) || []; const upstreams: UpstreamStatus[] = []; for (const block of upstreamBlocks) { const nameMatch = block.match(/upstream\s+(\w+)/); if (!nameMatch) { continue; } const name = nameMatch[1]; // For now, return unknown status (would need NGINX Plus API for real status) upstreams.push({ name, status: "unknown", lastCheck: new Date().toISOString(), }); } return upstreams; } catch (error) { logger.warn("Failed to parse NGINX config for upstreams", { configPath, error }); return []; } } public async parseAccessLog(logPath: string, maxLines: number = 1000): Promise<AccessStats> { return this.getAccessLogStats(logPath, undefined, maxLines); } public async reloadConfig(): Promise<boolean> { try { await this.runner.run("systemctl reload nginx", { requiresSudo: true, timeoutMs: 10000, }); logger.info("NGINX config reloaded successfully"); return true; } catch (error) { logger.error("Failed to reload NGINX", { error }); return false; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/acampkin95/MCPCentralManager'

If you have feedback or need assistance with the MCP directory API, please join our Discord server