mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
feat(metrics): Add get_ssl_status() for certificate health
- Reads cert from /etc/letsencrypt/live/{domain}/cert.pem
- Thresholds: >7j ok, 3-7j warn, <3j error, ≤0 expired
- 5 unit tests with mocked certificates
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c911e8192f
commit
6bd4fb3e11
|
|
@ -10,12 +10,14 @@ import json
|
|||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Try to import auth, fallback to no-auth for development
|
||||
try:
|
||||
|
|
@ -610,6 +612,61 @@ def build_health_summary() -> dict:
|
|||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SSL CERTIFICATE STATUS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def get_ssl_status(domain: str) -> dict:
|
||||
"""
|
||||
Get SSL certificate status for a domain.
|
||||
|
||||
Thresholds (aggressive for Let's Encrypt 90-day certs):
|
||||
- ok: > 7 days remaining
|
||||
- warn: 3-7 days remaining
|
||||
- error: < 3 days remaining
|
||||
- expired: <= 0 days remaining
|
||||
"""
|
||||
if not domain:
|
||||
return {"domain": None, "days_remaining": None, "status": "unknown", "expiry": None}
|
||||
|
||||
parent_domain = '.'.join(domain.split('.')[1:]) if '.' in domain else domain
|
||||
cert_paths = [
|
||||
Path(f"/etc/letsencrypt/live/{domain}/cert.pem"),
|
||||
Path(f"/etc/letsencrypt/live/{parent_domain}/cert.pem"),
|
||||
Path(f"/etc/haproxy/certs/{domain}.pem"),
|
||||
Path(f"/etc/nginx/ssl/{domain}.crt"),
|
||||
]
|
||||
|
||||
cert_path = None
|
||||
for p in cert_paths:
|
||||
if p.exists():
|
||||
cert_path = p
|
||||
break
|
||||
|
||||
if not cert_path:
|
||||
return {"domain": domain, "days_remaining": None, "status": "unknown", "expiry": None}
|
||||
|
||||
try:
|
||||
cert_data = cert_path.read_bytes()
|
||||
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
|
||||
now = datetime.now(timezone.utc)
|
||||
expiry = cert.not_valid_after_utc
|
||||
days_remaining = (expiry - now).days
|
||||
|
||||
if days_remaining <= 0:
|
||||
status = "expired"
|
||||
elif days_remaining < 3:
|
||||
status = "error"
|
||||
elif days_remaining <= 7:
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return {"domain": domain, "days_remaining": days_remaining, "status": status, "expiry": expiry.isoformat()}
|
||||
except Exception as e:
|
||||
return {"domain": domain, "days_remaining": None, "status": "unknown", "expiry": None, "error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/v1/metrics/health/summary")
|
||||
async def get_health_summary():
|
||||
"""
|
||||
|
|
|
|||
87
tests/test_ssl_status.py
Normal file
87
tests/test_ssl_status.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
SecuBox-Deb :: SSL Status Tests
|
||||
CyberMind — https://cybermind.fr
|
||||
Author: Gérald Kerma <gandalf@gk2.net>
|
||||
License: Proprietary / ANSSI CSPN candidate
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "packages/secubox-metrics/api"))
|
||||
from main import get_ssl_status
|
||||
|
||||
|
||||
def test_ssl_status_ok():
|
||||
"""Cert with 45 days remaining returns status ok."""
|
||||
now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mock_cert = MagicMock()
|
||||
mock_cert.not_valid_after_utc = now + timedelta(days=45)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True), \
|
||||
patch.object(Path, 'read_bytes', return_value=b'cert data'), \
|
||||
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \
|
||||
patch('main.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = get_ssl_status("example.com")
|
||||
assert result["status"] == "ok"
|
||||
assert result["days_remaining"] == 45
|
||||
|
||||
|
||||
def test_ssl_status_warn():
|
||||
"""Cert with 5 days remaining returns status warn."""
|
||||
now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mock_cert = MagicMock()
|
||||
mock_cert.not_valid_after_utc = now + timedelta(days=5)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True), \
|
||||
patch.object(Path, 'read_bytes', return_value=b'cert data'), \
|
||||
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \
|
||||
patch('main.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = get_ssl_status("example.com")
|
||||
assert result["status"] == "warn"
|
||||
assert result["days_remaining"] == 5
|
||||
|
||||
|
||||
def test_ssl_status_error():
|
||||
"""Cert with 2 days remaining returns status error."""
|
||||
now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mock_cert = MagicMock()
|
||||
mock_cert.not_valid_after_utc = now + timedelta(days=2)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True), \
|
||||
patch.object(Path, 'read_bytes', return_value=b'cert data'), \
|
||||
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \
|
||||
patch('main.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = get_ssl_status("example.com")
|
||||
assert result["status"] == "error"
|
||||
assert result["days_remaining"] == 2
|
||||
|
||||
|
||||
def test_ssl_status_expired():
|
||||
"""Cert with -3 days (expired) returns status expired."""
|
||||
now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mock_cert = MagicMock()
|
||||
mock_cert.not_valid_after_utc = now - timedelta(days=3)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True), \
|
||||
patch.object(Path, 'read_bytes', return_value=b'cert data'), \
|
||||
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \
|
||||
patch('main.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = get_ssl_status("example.com")
|
||||
assert result["status"] == "expired"
|
||||
assert result["days_remaining"] == -3
|
||||
|
||||
|
||||
def test_ssl_status_not_found():
|
||||
"""Missing cert file returns status unknown."""
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
result = get_ssl_status("nonexistent.com")
|
||||
assert result["status"] == "unknown"
|
||||
assert result["days_remaining"] is None
|
||||
Loading…
Reference in New Issue
Block a user