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>
266 lines
7.9 KiB
TypeScript
Executable File
266 lines
7.9 KiB
TypeScript
Executable File
/**
|
|
* Hetzner Cloud API Service
|
|
* Recolhe métricas dos VPS via API Hetzner Cloud
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
import db from '../db.js'
|
|
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
|
|
|
// Hetzner API Configuration
|
|
const HETZNER_API_URL = 'https://api.hetzner.cloud/v1'
|
|
const HETZNER_TOKEN = process.env.HETZNER_TOKEN || ''
|
|
|
|
interface HetznerServer {
|
|
id: number
|
|
name: string
|
|
status: string
|
|
server_type: { name: string }
|
|
datacenter: { name: string }
|
|
public_net: {
|
|
ipv4: { ip: string }
|
|
ipv6: { ip: string }
|
|
}
|
|
private_net: Array<{ ip: string }>
|
|
labels: Record<string, string>
|
|
created: string
|
|
}
|
|
|
|
interface HetznerMetrics {
|
|
metrics: {
|
|
time_series: {
|
|
[key: string]: {
|
|
values: Array<[number, string]>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
interface ServerWithMetrics {
|
|
id: number
|
|
hetzner_id: number
|
|
name: string
|
|
status: string
|
|
server_type: string
|
|
datacenter: string
|
|
public_ipv4: string
|
|
collected_at: string | null
|
|
cpu_percent: number | null
|
|
disk_read_iops: number | null
|
|
disk_write_iops: number | null
|
|
network_in_bps: number | null
|
|
network_out_bps: number | null
|
|
}
|
|
|
|
// Helper para requests à API Hetzner
|
|
async function hetznerRequest<T>(endpoint: string): Promise<T> {
|
|
const response = await fetch(`${HETZNER_API_URL}${endpoint}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${HETZNER_TOKEN}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Hetzner API error: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
return response.json() as Promise<T>
|
|
}
|
|
|
|
// Sincronizar lista de servidores
|
|
export async function syncServers(): Promise<number> {
|
|
const data = await hetznerRequest<{ servers: HetznerServer[] }>('/servers')
|
|
let synced = 0
|
|
|
|
for (const server of data.servers) {
|
|
const [existing] = await db.query<RowDataPacket[]>(
|
|
'SELECT id FROM tbl_eal_hetzner_servers WHERE hetzner_id = ?',
|
|
[server.id]
|
|
)
|
|
|
|
const serverData = {
|
|
hetzner_id: server.id,
|
|
name: server.name,
|
|
status: server.status,
|
|
server_type: server.server_type.name,
|
|
datacenter: server.datacenter.name,
|
|
public_ipv4: server.public_net.ipv4?.ip || null,
|
|
public_ipv6: server.public_net.ipv6?.ip || null,
|
|
private_ip: server.private_net?.[0]?.ip || null,
|
|
labels: JSON.stringify(server.labels),
|
|
created_hetzner: new Date(server.created)
|
|
}
|
|
|
|
if (existing.length > 0) {
|
|
// Update existing
|
|
await db.query(
|
|
`UPDATE tbl_eal_hetzner_servers SET
|
|
name = ?, status = ?, server_type = ?, datacenter = ?,
|
|
public_ipv4 = ?, public_ipv6 = ?, private_ip = ?, labels = ?
|
|
WHERE hetzner_id = ?`,
|
|
[
|
|
serverData.name, serverData.status, serverData.server_type,
|
|
serverData.datacenter, serverData.public_ipv4, serverData.public_ipv6,
|
|
serverData.private_ip, serverData.labels, server.id
|
|
]
|
|
)
|
|
} else {
|
|
// Insert new
|
|
await db.query(
|
|
`INSERT INTO tbl_eal_hetzner_servers
|
|
(hetzner_id, name, status, server_type, datacenter, public_ipv4, public_ipv6, private_ip, labels, created_hetzner)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
serverData.hetzner_id, serverData.name, serverData.status,
|
|
serverData.server_type, serverData.datacenter, serverData.public_ipv4,
|
|
serverData.public_ipv6, serverData.private_ip, serverData.labels,
|
|
serverData.created_hetzner
|
|
]
|
|
)
|
|
}
|
|
synced++
|
|
}
|
|
|
|
return synced
|
|
}
|
|
|
|
// Recolher métricas de um servidor
|
|
export async function collectMetrics(hetzner_id: number): Promise<boolean> {
|
|
// Obter server_id local
|
|
const [servers] = await db.query<RowDataPacket[]>(
|
|
'SELECT id FROM tbl_eal_hetzner_servers WHERE hetzner_id = ?',
|
|
[hetzner_id]
|
|
)
|
|
|
|
if (servers.length === 0) {
|
|
console.error(`Server ${hetzner_id} not found in database`)
|
|
return false
|
|
}
|
|
|
|
const server_id = servers[0].id
|
|
const now = new Date()
|
|
const start = new Date(now.getTime() - 5 * 60 * 1000) // 5 minutos atrás
|
|
|
|
try {
|
|
// Obter métricas da API
|
|
const metricsUrl = `/servers/${hetzner_id}/metrics?type=cpu,disk,network&start=${start.toISOString()}&end=${now.toISOString()}`
|
|
const data = await hetznerRequest<HetznerMetrics>(metricsUrl)
|
|
|
|
// Extrair valores mais recentes
|
|
const getLatestValue = (series: string): number | null => {
|
|
const values = data.metrics.time_series[series]?.values
|
|
if (!values || values.length === 0) return null
|
|
return parseFloat(values[values.length - 1][1])
|
|
}
|
|
|
|
const metrics = {
|
|
cpu_percent: getLatestValue('cpu'),
|
|
disk_read_iops: getLatestValue('disk.0.iops.read'),
|
|
disk_write_iops: getLatestValue('disk.0.iops.write'),
|
|
disk_read_bps: getLatestValue('disk.0.bandwidth.read'),
|
|
disk_write_bps: getLatestValue('disk.0.bandwidth.write'),
|
|
network_in_bps: getLatestValue('network.0.bandwidth.in'),
|
|
network_out_bps: getLatestValue('network.0.bandwidth.out'),
|
|
network_in_pps: getLatestValue('network.0.pps.in'),
|
|
network_out_pps: getLatestValue('network.0.pps.out')
|
|
}
|
|
|
|
// Inserir métricas
|
|
await db.query(
|
|
`INSERT INTO tbl_eal_hetzner_metrics
|
|
(server_id, collected_at, cpu_percent, disk_read_iops, disk_write_iops,
|
|
disk_read_bps, disk_write_bps, network_in_bps, network_out_bps,
|
|
network_in_pps, network_out_pps)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
server_id, now, metrics.cpu_percent,
|
|
metrics.disk_read_iops, metrics.disk_write_iops,
|
|
metrics.disk_read_bps, metrics.disk_write_bps,
|
|
metrics.network_in_bps, metrics.network_out_bps,
|
|
metrics.network_in_pps, metrics.network_out_pps
|
|
]
|
|
)
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error(`Error collecting metrics for server ${hetzner_id}:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Recolher métricas de todos os servidores
|
|
export async function collectAllMetrics(): Promise<{ success: number; failed: number }> {
|
|
const [servers] = await db.query<RowDataPacket[]>(
|
|
'SELECT hetzner_id FROM tbl_eal_hetzner_servers WHERE status = "running"'
|
|
)
|
|
|
|
let success = 0
|
|
let failed = 0
|
|
|
|
for (const server of servers) {
|
|
const result = await collectMetrics(server.hetzner_id)
|
|
if (result) success++
|
|
else failed++
|
|
}
|
|
|
|
return { success, failed }
|
|
}
|
|
|
|
// Obter dados para o dashboard
|
|
export async function getHetznerDashboard(): Promise<{
|
|
servers: ServerWithMetrics[]
|
|
summary: { total: number; running: number; off: number }
|
|
}> {
|
|
// Usar a view para obter últimas métricas
|
|
const [servers] = await db.query<RowDataPacket[]>(`
|
|
SELECT * FROM v_eal_hetzner_latest
|
|
ORDER BY name
|
|
`)
|
|
|
|
// Calcular sumário
|
|
const [summary] = await db.query<RowDataPacket[]>(`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running,
|
|
SUM(CASE WHEN status != 'running' THEN 1 ELSE 0 END) as off
|
|
FROM tbl_eal_hetzner_servers
|
|
`)
|
|
|
|
return {
|
|
servers: servers as ServerWithMetrics[],
|
|
summary: summary[0] as { total: number; running: number; off: number }
|
|
}
|
|
}
|
|
|
|
// Limpar métricas antigas (manter últimos 7 dias)
|
|
export async function cleanupOldMetrics(days: number = 7): Promise<number> {
|
|
const [result] = await db.query<ResultSetHeader>(
|
|
`DELETE FROM tbl_eal_hetzner_metrics
|
|
WHERE collected_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
|
|
[days]
|
|
)
|
|
return result.affectedRows
|
|
}
|
|
|
|
// Obter histórico de métricas para gráficos
|
|
export async function getMetricsHistory(
|
|
server_id: number,
|
|
hours: number = 24
|
|
): Promise<RowDataPacket[]> {
|
|
const [metrics] = await db.query<RowDataPacket[]>(`
|
|
SELECT
|
|
collected_at,
|
|
cpu_percent,
|
|
network_in_bps,
|
|
network_out_bps,
|
|
disk_read_iops,
|
|
disk_write_iops
|
|
FROM tbl_eal_hetzner_metrics
|
|
WHERE server_id = ?
|
|
AND collected_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
|
|
ORDER BY collected_at ASC
|
|
`, [server_id, hours])
|
|
|
|
return metrics
|
|
}
|