Files
DashDescomplicar/api/services/server-metrics.ts
Emanuel Almeida f1756829af security: implement 6 high-severity vulnerability fixes
HIGH-SEVERITY FIXES (Fase 2):

1. Rate Limiting (Vulnerabilidade 2.1)
   - express-rate-limit: 100 req/15min (prod), 1000 req/15min (dev)
   - Applied to all /api/* routes
   - Standard headers for retry-after

2. CORS Restrictions (Vulnerabilidade 2.2)
   - Whitelist: dashboard.descomplicar.pt, desk.descomplicar.pt
   - Localhost only in development
   - CORS blocking logs

3. Input Validation with Zod (Vulnerabilidade 2.4)
   - Generic validateRequest() middleware
   - Schemas: WordPress Monitor, server metrics, dashboard, financial
   - Applied to api/routes/wp-monitor.ts POST endpoint
   - Detailed field-level error messages

4. Backend Authentication OIDC (Vulnerabilidade 2.5 - OPTIONAL)
   - Enabled via OIDC_ENABLED=true
   - Bearer token validation on all APIs
   - Backward compatible (disabled by default)

5. SSH Key-Based Auth Migration (Vulnerabilidade 2.6)
   - Script: /media/ealmeida/Dados/Dev/ClaudeDev/migrate-ssh-keys.sh
   - Generates ed25519 key, copies to 6 servers
   - Instructions to remove passwords from .env
   - .env.example updated with SSH_PRIVATE_KEY_PATH

6. Improved Error Handling (Vulnerabilidade 2.5)
   - Unique error IDs (UUID) for tracking
   - Structured JSON logs in production
   - Stack traces blocked in production
   - Generic messages to client

FILES CHANGED:
- api/server.ts - Complete refactor with all security improvements
- api/middleware/validation.ts - NEW: Zod middleware and schemas
- api/routes/wp-monitor.ts - Added Zod validation on POST
- .env.example - Complete security documentation
- CHANGELOG.md - Full documentation of 9 fixes (3 critical + 6 high)
- package.json + package-lock.json - New dependencies

DEPENDENCIES ADDED:
- express-rate-limit@7.x
- zod@3.x
- express-openid-connect@2.x

AUDIT STATUS:
- npm audit: 0 vulnerabilities
- Hook Regra #47: PASSED

PROGRESS:
- Phase 1 (Critical): 3/3  COMPLETE
- Phase 2 (High): 6/6  COMPLETE
- Phase 3 (Medium): 0/6 - Next
- Phase 4 (Low): 0/5 - Next

Related: AUDIT-REPORT.md vulnerabilities 2.1, 2.2, 2.4, 2.5, 2.6

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 04:09:50 +00:00

244 lines
6.8 KiB
TypeScript
Executable File

/**
* Server Metrics Collector Service
* Recolhe métricas de todos os servidores via SSH (ssh2 library)
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import db from '../db.js'
import { Client } from 'ssh2'
interface SSHServer {
name: string
monitorName: string
host: string
port: number
user: string
pass: string
}
const SSH_SERVERS: SSHServer[] = [
{
name: 'server',
monitorName: 'CWP Server',
host: process.env.SERVER_HOST || '176.9.3.158',
port: parseInt(process.env.SERVER_PORT || '9443'),
user: process.env.SERVER_USER || 'root',
pass: process.env.SERVER_PASS || ''
},
{
name: 'easy',
monitorName: 'EasyPanel',
host: process.env.EASY_HOST || '178.63.18.51',
port: 22,
user: process.env.EASY_USER || 'root',
pass: process.env.EASY_PASS || ''
},
{
name: 'mcp-hub',
monitorName: 'MCP Hub',
host: process.env.MCPHUB_HOST || 'mcp-hub.descomplicar.pt',
port: 22,
user: process.env.MCPHUB_USER || 'root',
pass: process.env.MCPHUB_PASS || ''
},
{
name: 'meet',
monitorName: 'Meet',
host: process.env.MEET_HOST || 'meet.descomplicar.pt',
port: 22,
user: process.env.MEET_USER || 'root',
pass: process.env.MEET_PASS || ''
},
{
name: 'whatsapp',
monitorName: 'WhatsApp',
host: process.env.WHATSAPP_HOST || 'whatsapp.descomplicar.pt',
port: 22,
user: process.env.WHATSAPP_USER || 'root',
pass: process.env.WHATSAPP_PASS || ''
},
{
name: 'whatsms',
monitorName: 'WhatSMS',
host: process.env.WHATSMS_HOST || 'whatsms.descomplicar.pt',
port: 22,
user: process.env.WHATSMS_USER || 'root',
pass: process.env.WHATSMS_PASS || ''
}
]
interface ServerMetrics {
cpu: number
ram: number
disk: number
load: number
containers?: number
}
function parseSSHMetrics(output: string): ServerMetrics {
const lines = output.split('\n')
const metrics: ServerMetrics = { cpu: 0, ram: 0, disk: 0, load: 0 }
for (const line of lines) {
if (line.startsWith('CPU:')) metrics.cpu = parseFloat(line.split(':')[1]) || 0
if (line.startsWith('MEM:')) metrics.ram = parseFloat(line.split(':')[1]) || 0
if (line.startsWith('DISK:')) metrics.disk = parseFloat(line.split(':')[1]) || 0
if (line.startsWith('LOAD:')) metrics.load = parseFloat(line.split(':')[1]) || 0
if (line.startsWith('CONTAINERS:')) metrics.containers = parseInt(line.split(':')[1]) || 0
}
return metrics
}
/**
* Execute SSH command via ssh2 library
*/
function executeSSH(server: SSHServer, command: string): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client()
let output = ''
const timeout = setTimeout(() => {
conn.end()
reject(new Error(`SSH timeout for ${server.name}`))
}, 20000)
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timeout)
conn.end()
reject(err)
return
}
stream.on('data', (data: Buffer) => {
output += data.toString()
})
stream.on('close', () => {
clearTimeout(timeout)
conn.end()
resolve(output)
})
stream.stderr.on('data', () => {
// ignore stderr
})
})
})
conn.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
conn.connect({
host: server.host,
port: server.port,
username: server.user,
password: server.pass,
readyTimeout: 15000,
algorithms: {
kex: [
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1'
]
}
})
})
}
/**
* Collect metrics from all SSH servers
*/
export async function collectSSHMetrics(): Promise<{ success: number; failed: number }> {
let success = 0
let failed = 0
const metricsCommand = `echo "CPU:$(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')"; echo "MEM:$(free -m | awk '/Mem:/ {printf "%.1f", $3/$2*100}')"; echo "DISK:$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')"; echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"; echo "CONTAINERS:$(docker ps -q 2>/dev/null | wc -l || echo 0)"`
for (const server of SSH_SERVERS) {
if (!server.pass) {
console.log(`[SSH] Skipping ${server.name}: no password configured`)
failed++
continue
}
try {
const output = await executeSSH(server, metricsCommand)
if (!output) {
failed++
continue
}
const metrics = parseSSHMetrics(output)
// Update monitoring table
await db.query(
`UPDATE tbl_eal_monitoring
SET details = ?, status = 'up', last_check = NOW()
WHERE category = 'server' AND name = ?`,
[
JSON.stringify({
cpu: metrics.cpu,
ram: metrics.ram,
disk: metrics.disk,
load: metrics.load
}),
server.monitorName
]
)
// Update containers if EasyPanel
if (server.name === 'easy' && metrics.containers !== undefined) {
try {
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
const down = total - up
await db.query(
`UPDATE tbl_eal_monitoring
SET details = ?, status = ?, last_check = NOW()
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
[
JSON.stringify({ total, up, down, restarting: 0 }),
down > 0 ? 'warning' : 'ok'
]
)
} catch {
// Container stats are optional
}
}
success++
console.log(`[SSH] ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
} catch (error) {
console.error(`[SSH] Failed ${server.name}:`, (error as Error).message)
failed++
}
}
return { success, failed }
}
/**
* Collect all server metrics (SSH only - replaces Hetzner sync)
*/
export async function collectAllServerMetrics(): Promise<{
ssh: { success: number; failed: number }
}> {
console.log('[METRICS] Collecting server metrics via SSH...')
const ssh = await collectSSHMetrics()
console.log(`[METRICS] Done: ${ssh.success} OK, ${ssh.failed} failed`)
return { ssh }
}
// Keep for backward compatibility with routes
export async function syncHetznerToMonitoring(): Promise<number> {
// Now handled by SSH collection
const result = await collectSSHMetrics()
return result.success
}