mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-23 02:53:26 +00:00
520b69a1c6
Addresses three reported advisories, all verified against the code. Fixes are
entirely server-side — deployed agents already send a valid X-API-Key on every
call, so enforcing it does not require any agent-script change or upgrade.
GHSA-7rhv-c5pc-69r8 (CRITICAL RCE — agent script-template poisoning):
- POST/GET /api/agents/script-templates/{platform} now require the agents.version
permission (was authentication-only), matching POST /versions. Blocks a viewer
JWT from overwriting the root install/upgrade script.
GHSA-3p5c-m5m4-mjpx (missing authentication):
- Agent data-plane endpoints now REQUIRE a valid X-API-Key (was optional/skipped
when the header was absent), checked before any DB access: config,
ssl-certificates (private keys!), upgrade-status, heartbeat (by-name and the
previously auth-less by-id), configuration pending-requests. Removes keyless
heartbeat spoofing and keyless rogue-agent auto-registration.
- Operator/UI endpoints now require a JWT: GET /api/agents, the entire
/api/dashboard-stats router, /api/health/{deep,agents,clusters}, and
/api/ssl/certificates/{id}/config-versions. The simple /api/health liveness
probe stays public. Adds shared auth_middleware.require_authenticated_user.
GHSA-3vh4-gvxx-wm2p (SSRF via ACME directory_url):
- New utils/ssrf_guard.py (https-only + public-IP-only, IPv4-pinned, no redirects),
applied to settings test-connection, acme_service.get_directory and
_signed_request, and validated at Let's Encrypt account creation. The
test-connection response no longer reflects arbitrary upstream JSON keys
(information-disclosure oracle) — only fixed ACME field names.
Verified: full pytest tests/ (1128 passed, 0 failed) + live localtest stack smoke
(valid JWT/key paths return 200/404 as expected; anonymous requests 401; SSRF to
metadata/private/loopback refused). No changes to backend/utils/agent_scripts/*.
493 lines
17 KiB
Python
493 lines
17 KiB
Python
"""
|
|
Dashboard Stats Router
|
|
API endpoints for HAProxy statistics dashboard
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
|
from typing import Optional, List
|
|
import logging
|
|
|
|
from services.dashboard_stats_service import dashboard_stats_service
|
|
from auth_middleware import require_authenticated_user
|
|
|
|
# SECURITY (GHSA-3p5c-m5m4-mjpx): this entire router (traffic metrics, backend
|
|
# health, cluster topology, agent status) was mounted without authentication.
|
|
# Require a valid JWT on every route. The frontend Dashboard already sends the
|
|
# operator JWT on these calls, so this is transparent to the UI.
|
|
router = APIRouter(
|
|
prefix="/api/dashboard-stats",
|
|
tags=["dashboard-stats"],
|
|
dependencies=[Depends(require_authenticated_user)],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_dashboard_stats(
|
|
cluster_id: int = Query(..., description="Cluster ID to fetch stats for"),
|
|
frontends: Optional[str] = Query(None, description="Comma-separated list of frontend names to filter"),
|
|
backends: Optional[str] = Query(None, description="Comma-separated list of backend names to filter")
|
|
):
|
|
"""
|
|
Get comprehensive HAProxy statistics for dashboard
|
|
|
|
This endpoint provides real-time statistics from Redis cache including:
|
|
- Frontend metrics (requests, sessions, response codes)
|
|
- Backend metrics (health, response times, queues)
|
|
- Server status and health
|
|
- Aggregated KPIs
|
|
"""
|
|
try:
|
|
# Parse filters
|
|
frontend_filter = frontends.split(',') if frontends else None
|
|
backend_filter = backends.split(',') if backends else None
|
|
|
|
# Get stats from service
|
|
stats = await dashboard_stats_service.get_cluster_stats(
|
|
cluster_id=cluster_id,
|
|
frontend_filter=frontend_filter,
|
|
backend_filter=backend_filter
|
|
)
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get dashboard stats: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/frontends")
|
|
async def get_frontend_list(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""Get list of available frontends for a cluster"""
|
|
try:
|
|
frontends = await dashboard_stats_service.get_frontend_list(cluster_id)
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"frontends": frontends,
|
|
"count": len(frontends)
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get frontend list: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/backends")
|
|
async def get_backend_list(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""Get list of available backends for a cluster"""
|
|
try:
|
|
backends = await dashboard_stats_service.get_backend_list(cluster_id)
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"backends": backends,
|
|
"count": len(backends)
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get backend list: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/backend-health")
|
|
async def get_backend_health(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""Get backend health matrix for visualization"""
|
|
try:
|
|
health_matrix = await dashboard_stats_service.get_backend_health_matrix(cluster_id)
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"backends": health_matrix,
|
|
"count": len(health_matrix)
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get backend health: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/slowest-backends")
|
|
async def get_slowest_backends(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
limit: int = Query(5, description="Number of results to return", ge=1, le=20)
|
|
):
|
|
"""Get the slowest backends by response time"""
|
|
try:
|
|
slowest = await dashboard_stats_service.get_slowest_backends(cluster_id, limit)
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"slowest_backends": slowest,
|
|
"count": len(slowest)
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get slowest backends: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/timeseries")
|
|
async def get_timeseries_data(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
metric: str = Query(..., description="Metric name (e.g., 'frontend:web:requests')"),
|
|
hours: int = Query(24, description="Number of hours to look back", ge=1, le=168)
|
|
):
|
|
"""
|
|
Get time series data for a specific metric
|
|
|
|
Metric naming convention:
|
|
- Frontend requests: "frontend:{name}:requests"
|
|
- Backend requests: "backend:{name}:requests"
|
|
- Backend response time: "backend:{name}:response_time"
|
|
"""
|
|
try:
|
|
data = await dashboard_stats_service.get_timeseries_data(
|
|
cluster_id=cluster_id,
|
|
metric_name=metric,
|
|
hours=hours
|
|
)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"metric": metric,
|
|
"hours": hours,
|
|
"data_points": len(data),
|
|
"data": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get timeseries data: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/summary")
|
|
async def get_summary_stats(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""Get summary statistics for quick overview"""
|
|
try:
|
|
from utils.haproxy_stats_cache import haproxy_stats_cache
|
|
|
|
summary = haproxy_stats_cache.get_summary_stats(cluster_id)
|
|
|
|
if not summary:
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"message": "No stats available yet. Waiting for agent data...",
|
|
"frontends": {"count": 0},
|
|
"backends": {"count": 0},
|
|
"servers": {"total": 0, "up": 0, "down": 0}
|
|
}
|
|
|
|
return summary
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get summary stats: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/overview")
|
|
async def get_overview_metrics(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""
|
|
Get overview metrics for the dashboard header
|
|
Returns: frontends_count, backends_count, servers_total, servers_up, ssl_certificates, waf_rules
|
|
"""
|
|
try:
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from utils.haproxy_stats_cache import haproxy_stats_cache
|
|
|
|
# Get cached server stats
|
|
all_servers = haproxy_stats_cache.get_all_server_stats(cluster_id)
|
|
servers_total = len(all_servers)
|
|
servers_up = len([s for s in all_servers if s.get('status') == 'UP'])
|
|
|
|
# Get frontend/backend counts from cache
|
|
frontends = haproxy_stats_cache.get_all_frontend_stats(cluster_id)
|
|
backends = haproxy_stats_cache.get_all_backend_stats(cluster_id)
|
|
|
|
# Get SSL and WAF counts from database
|
|
conn = await get_database_connection()
|
|
|
|
# Count SSL certificates for this cluster
|
|
# Include: Global SSL (cluster_id IS NULL) + Cluster-specific SSL (junction table)
|
|
ssl_count = await conn.fetchval("""
|
|
SELECT COUNT(DISTINCT s.id)
|
|
FROM ssl_certificates s
|
|
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
|
|
WHERE s.is_active = TRUE
|
|
AND (s.cluster_id IS NULL OR scc.cluster_id = $1)
|
|
""", cluster_id) or 0
|
|
|
|
# Count WAF rules for this cluster
|
|
# Include: Global WAF (cluster_id IS NULL) + Cluster-specific WAF
|
|
waf_count = await conn.fetchval("""
|
|
SELECT COUNT(*)
|
|
FROM waf_rules
|
|
WHERE is_active = TRUE
|
|
AND (cluster_id IS NULL OR cluster_id = $1)
|
|
""", cluster_id) or 0
|
|
|
|
await close_database_connection(conn)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"frontends_count": len(frontends),
|
|
"backends_count": len(backends),
|
|
"servers_total": servers_total,
|
|
"servers_up": servers_up,
|
|
"servers_down": servers_total - servers_up,
|
|
"ssl_certificates": ssl_count,
|
|
"waf_rules": waf_count
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get overview metrics: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/timeseries/requests")
|
|
async def get_requests_timeseries(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
hours: int = Query(24, description="Number of hours to look back", ge=1, le=168),
|
|
frontends: Optional[str] = Query(None, description="Comma-separated list of frontend names"),
|
|
limit: Optional[int] = Query(None, description="Max number of data points to return", ge=10, le=500)
|
|
):
|
|
"""
|
|
Get request rate time series data for frontends (optimized with limit)
|
|
Returns data points with timestamp and request counts
|
|
"""
|
|
try:
|
|
frontend_list = frontends.split(',') if frontends else None
|
|
data = await dashboard_stats_service.get_requests_timeseries(
|
|
cluster_id=cluster_id,
|
|
hours=hours,
|
|
frontend_filter=frontend_list,
|
|
limit=limit
|
|
)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"hours": hours,
|
|
"frontends": frontend_list,
|
|
"limit": limit,
|
|
"data_points": len(data),
|
|
"data": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get requests timeseries: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/timeseries/response-time")
|
|
async def get_response_time_timeseries(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
hours: int = Query(24, description="Number of hours to look back", ge=1, le=168),
|
|
backends: Optional[str] = Query(None, description="Comma-separated list of backend names"),
|
|
limit: Optional[int] = Query(None, description="Max number of data points to return", ge=10, le=10000)
|
|
):
|
|
"""
|
|
Get response time trends (P50, P95, P99) for backends (optimized with limit)
|
|
Returns percentile data over time
|
|
"""
|
|
try:
|
|
backend_list = backends.split(',') if backends else None
|
|
data = await dashboard_stats_service.get_response_time_timeseries(
|
|
cluster_id=cluster_id,
|
|
hours=hours,
|
|
backend_filter=backend_list,
|
|
limit=limit
|
|
)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"hours": hours,
|
|
"backends": backend_list,
|
|
"limit": limit,
|
|
"data_points": len(data),
|
|
"data": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get response time timeseries: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/timeseries/errors")
|
|
async def get_error_timeseries(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
hours: int = Query(24, description="Number of hours to look back", ge=1, le=168),
|
|
limit: Optional[int] = Query(None, description="Max number of data points to return", ge=10, le=500)
|
|
):
|
|
"""
|
|
Get error rate trends (4xx and 5xx) over time (optimized with limit)
|
|
Returns HTTP error code distribution
|
|
"""
|
|
try:
|
|
data = await dashboard_stats_service.get_error_timeseries(
|
|
cluster_id=cluster_id,
|
|
hours=hours,
|
|
limit=limit
|
|
)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"hours": hours,
|
|
"limit": limit,
|
|
"data_points": len(data),
|
|
"data": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get error timeseries: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/realtime/throughput")
|
|
async def get_realtime_throughput(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""
|
|
Get current bytes in/out rates and totals
|
|
Returns real-time throughput metrics
|
|
"""
|
|
try:
|
|
data = await dashboard_stats_service.get_realtime_throughput(cluster_id)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"throughput": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get realtime throughput: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/metrics/timeseries/sessions")
|
|
async def get_sessions_timeseries(
|
|
cluster_id: int = Query(..., description="Cluster ID"),
|
|
hours: int = Query(24, description="Number of hours to look back", ge=1, le=168),
|
|
limit: Optional[int] = Query(None, description="Max number of data points to return", ge=10, le=500)
|
|
):
|
|
"""
|
|
Get active sessions over time (optimized with limit)
|
|
Returns session count trends
|
|
"""
|
|
try:
|
|
data = await dashboard_stats_service.get_sessions_timeseries(
|
|
cluster_id=cluster_id,
|
|
hours=hours,
|
|
limit=limit
|
|
)
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"hours": hours,
|
|
"data_points": len(data),
|
|
"data": data
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get sessions timeseries: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/agents/status")
|
|
async def get_agents_status(
|
|
cluster_id: int = Query(..., description="Cluster ID")
|
|
):
|
|
"""
|
|
Get real-time agent health status for cluster
|
|
Returns agent names, last seen, health status
|
|
"""
|
|
try:
|
|
from database.connection import get_database_connection, close_database_connection, redis_client
|
|
import time
|
|
import json
|
|
from datetime import datetime
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Get agents for this cluster (include keepalive columns)
|
|
agents = await conn.fetch("""
|
|
SELECT
|
|
a.id, a.name, a.platform, a.version, a.status,
|
|
a.last_seen, a.enabled, a.pool_id,
|
|
a.keepalive_state, a.keepalive_ip
|
|
FROM agents a
|
|
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
|
WHERE hc.id = $1
|
|
ORDER BY a.name
|
|
""", cluster_id)
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Calculate health for each agent
|
|
agent_status_list = []
|
|
for agent in agents:
|
|
last_seen = agent['last_seen']
|
|
status = agent['status']
|
|
|
|
# Calculate time difference
|
|
if last_seen:
|
|
time_diff = time.time() - last_seen.timestamp()
|
|
seconds_ago = int(time_diff)
|
|
|
|
# Determine health status
|
|
if status == "online" and time_diff < 30:
|
|
health = "healthy"
|
|
health_percentage = 100
|
|
elif status == "online" and time_diff < 60:
|
|
health = "warning"
|
|
health_percentage = 75
|
|
elif status == "online" and time_diff < 120:
|
|
health = "degraded"
|
|
health_percentage = 50
|
|
else:
|
|
health = "offline"
|
|
health_percentage = 0
|
|
else:
|
|
health = "unknown"
|
|
health_percentage = 0
|
|
seconds_ago = None
|
|
|
|
# Get keepalive state: try Redis first (fresh), fall back to DB
|
|
keepalive_state = None
|
|
keepalive_ip = None
|
|
try:
|
|
cached = redis_client.get(f"haproxy:agent:{agent['id']}:keepalive")
|
|
if cached:
|
|
keepalive_data = json.loads(cached)
|
|
keepalive_state = keepalive_data.get("state") or None
|
|
keepalive_ip = keepalive_data.get("ip") or None
|
|
except Exception:
|
|
pass
|
|
if not keepalive_state:
|
|
keepalive_state = agent['keepalive_state'] or None
|
|
keepalive_ip = agent['keepalive_ip'] or None
|
|
|
|
agent_status_list.append({
|
|
"id": agent['id'],
|
|
"name": agent['name'],
|
|
"platform": agent['platform'],
|
|
"version": agent['version'],
|
|
"status": status,
|
|
"health": health,
|
|
"health_percentage": health_percentage,
|
|
"last_seen": last_seen.isoformat() if last_seen else None,
|
|
"seconds_ago": seconds_ago,
|
|
"enabled": agent['enabled'],
|
|
"keepalive_state": keepalive_state,
|
|
"keepalive_ip": keepalive_ip
|
|
})
|
|
|
|
return {
|
|
"cluster_id": cluster_id,
|
|
"agents": agent_status_list,
|
|
"total_agents": len(agent_status_list),
|
|
"healthy_agents": len([a for a in agent_status_list if a['health'] == 'healthy']),
|
|
"warning_agents": len([a for a in agent_status_list if a['health'] == 'warning']),
|
|
"offline_agents": len([a for a in agent_status_list if a['health'] == 'offline'])
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get agent status: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|