Merge SSL health banner feature from feature/83-multi-agent-worktree-workflow

Includes:
- SSL certificate status in health summary endpoint
- SSL status display in health banner v1.2.0
- CORS middleware for cross-origin requests
- HAProxy safe regen wrapper

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-12 11:16:50 +02:00
commit 443e375f46
10 changed files with 1327 additions and 47 deletions

View File

@ -4,6 +4,93 @@
---
## 2026-05-12
### Session 157 — SSL Certificate Health in Health Banner
**Goal:** Display SSL certificate expiration status in the Health Banner sidebar.
**Changes:**
- `packages/secubox-metrics/api/main.py`: Added `get_ssl_status()` function + endpoint enrichment
- `packages/secubox-hub/www/shared/health-banner.js`: Added SSL display (v1.2.0)
- `tests/test_ssl_status.py`: 5 unit tests
**Features:**
- Reads cert from `/etc/letsencrypt/live/{domain}/cert.pem` (with fallbacks)
- Displays after score section: 🔒 45j
- Color-coded thresholds:
- 🔒 >7j (green)
- 🔐 3-7j (yellow)
- 🔓 <3j (red)
- 🔓 EXPIRÉ (red blink)
**Commits:** `6bd4fb3e`, `9c07eda6`, `593b991f`
**Reference:** CM-SSL-BANNER-2026-05-12
---
### Session 156 — HAProxy Routing Catastrophe & Generator Fix
**Trigger:** User flagged `https://cpf.gk2.secubox.in/` returning "Wrong Domain". Investigation revealed a much wider regression that surfaced minutes after Session 154 — the entire HAProxy https-in routing for metablog/streamlit sites had silently broken.
**Symptom (when investigation started, ~10:05):**
- `arm.gk2`, `cpf.gk2`, `lldh.ganimed.fr`, etc. all returning HTTP 200 with a 6202 b `<title>Wrong Domain - SecuBox</title>` page (nginx:9080 default_server fallback).
- `/etc/haproxy/haproxy.cfg` last modified at 10:03:48 — minutes after my Session 154 nginx + mitmproxy fixes.
- HAProxy reloaded at 10:03:51 with the broken config.
- 4 `metablog_*` backends DOWN, all metablog vhosts returning 503 or Wrong Domain.
**Cause chain (multi-layered):**
1. **Stale generator on board.** `/usr/sbin/haproxyctl` (the bash script that actually writes `haproxy.cfg` from `/etc/secubox/haproxy.toml`) was an older version that emitted `use_backend waf_inspector if host_X` while `waf_enabled=1`. The repo's current `packages/secubox-haproxy/sbin/haproxyctl` already emits `use_backend mitmproxy_inspector` — but the board had a 33115 b old copy still using `waf_inspector`.
2. **`waf_inspector` backend was a dead reference.** The script also generated `backend waf_inspector { server srv0 127.0.0.1:8890 check }` but **port 8890 was not listening** — so even if the regen worked, those vhosts would have been DOWN.
3. **`waf_enabled=0` fallback was equally broken.** When `waf_enabled` evaluated to 0, the script fell back to each vhost's TOML `backend = "nginx_vhosts"` (`server 127.0.0.1:9080 check`) — but nginx:9080 has no `server_name` for individual gk2 sites (only for `admin.gk2.secubox.in`), so every other host hit the default_server "Wrong Domain" page.
4. **TOML coverage is incomplete.** `/etc/secubox/haproxy.toml` declares only 93 vhosts, while `/srv/mitmproxy/haproxy-routes.json` has 245 active routes. The 150 missing domains had no HAProxy ACL → `default_backend fallback` (deny 503).
5. **Race with the Python service.** `secubox-haproxy.service` (FastAPI on `/usr/lib/secubox/haproxy/api/main.py`) was polling/validating the config and logging "Failed to load HAProxy config: Invalid value (at line 854, column 7)" every ~10 s for >10 min before haproxyctl finally succeeded a regen that produced the broken-but-syntactically-valid output.
6. **Container routes had drifted too.** `lxc-attach -n mitmproxy -- jq .[cpf.gk2.secubox.in] routes.json` showed `[10.100.0.1, 9080]` while the host file had `[10.100.0.50, 8523]`. A previous unsuccessful regen had pushed a corrupted JSON into the container.
**Restore + harden plan (user-approved option: patch direct + freeze regen):**
1. `systemctl stop secubox-haproxy.service` — freeze any further regen attempts.
2. Backup current `haproxy.cfg`.
3. Read `/srv/mitmproxy/haproxy-routes.json` → for each of 245 domains, replace `use_backend nginx_vhosts if host_X` with `use_backend mitmproxy_inspector if host_X` in `haproxy.cfg` (180 lines patched: 90 unique × 2 frontends).
4. Replace `default_backend fallback` with `default_backend mitmproxy_inspector` in both `http-in` and `https-in` frontends — so the 150 routes that exist in mitmproxy JSON but not in `haproxy.toml` are still dispatched correctly (mitmproxy reads the Host header against its routes table).
5. `haproxy -c -f haproxy.cfg` → validate; `systemctl reload haproxy`.
6. Push host routes JSON → mitmproxy LXC; `systemctl restart mitmproxy` inside container.
7. Verify with live HTTPS probes.
**Verification (live, fresh, cache-busted):**
| Domain | HTTP | Title |
| --- | --- | --- |
| `cpf.gk2.secubox.in` | 200 | Streamlit (cineposter_fixed @ port 8523) |
| `arm.gk2.secubox.in` | 200 | SITREP ARM/ARMADA — CLASSIFIED // GANDALF-7 |
| `lldh.ganimed.fr` | 200 | La Livrée d'Hermès — Anibal Edelberto Amiot |
| `admin.gk2.secubox.in` | 200 | SecuBox Control Center |
| `pub.gk2.secubox.in` | 200 | GK² · NET — Opérateur Internet |
| `werdl.gk2.secubox.in` | 200 | Retrouver son téléphone — Pour toute la famille |
| `3d.gk2.secubox.in` | 200 | SecuBox Dice 3D |
| `42.gk2.secubox.in` | 200 | CyberMind QWIZZ — Détecteur d'Injonction Paradoxale |
| `zkp.gk2.secubox.in` | 200 | 🔐 OPORD CYBER-ZKP // SECUBOX CLASSIFIED |
**Persistent fixes committed to repo:**
- `packages/secubox-haproxy/api/main.py` — Python generator: `use_backend waf_inspector``use_backend mitmproxy_inspector` (2 occurrences, lines 1147 + 1170). Survives next package rebuild.
- `packages/secubox-haproxy/sbin/haproxyctl` — Bash generator: `default_backend fallback``default_backend mitmproxy_inspector` (2 occurrences). Repo version already used `mitmproxy_inspector` for the `use_backend` lines; the board copy was stale. Future `dpkg -i secubox-haproxy_*.deb` will replace the board's stale `/usr/sbin/haproxyctl`.
- `scripts/secubox-haproxy-regen-safe` — new wrapper: snapshot → regen → validate → atomic swap → reload, with rollback on validation failure. Prevents future broken-config-deployed-anyway incidents.
**Still on board (not in repo, infra-side only):**
- `/usr/sbin/haproxyctl` patched in place on the board to mirror repo state.
- `/usr/lib/secubox/haproxy/api/main.py` patched on board.
- `secubox-haproxy.service` left **stopped** until user confirms safe to re-enable.
**Open questions:**
- `/etc/secubox/haproxy.toml` only declares 93 vhosts — should the 150 metablog/streamlit domains be added so HAProxy has explicit ACLs and stats? Currently they work via `default_backend mitmproxy_inspector` (catch-all), which is functional but loses per-vhost stats granularity.
- Re-enabling `secubox-haproxy.service` is safe now (generators patched), but verify no other code path writes `haproxy.cfg` with the broken pattern (e.g., on-demand `/generate` API endpoint).
---
### Session 155 — Multi-Agent Worktree Workflow
**Goal:** Enable parallel multi-agent work via one-branch-per-issue isolated worktrees.

View File

@ -0,0 +1,622 @@
# SSL Certificate Health in Health Banner — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Display SSL certificate expiration status for the current domain in the Health Banner sidebar.
**Architecture:** Backend (`secubox-metrics/api/main.py`) extracts domain from `Host` header, reads cert from `/etc/letsencrypt/live/{domain}/cert.pem`, returns `ssl` object in health summary. Frontend (`health-banner.js`) renders SSL line after score section.
**Tech Stack:** Python 3.11, cryptography, FastAPI, vanilla JS
---
## File Structure
| File | Responsibility |
|------|----------------|
| `packages/secubox-metrics/api/main.py` | Add `get_ssl_status()` function, enrich `/api/v1/metrics/health/summary` |
| `packages/secubox-hub/www/shared/health-banner.js` | Add SSL display after score section |
| `tests/test_ssl_status.py` | Unit tests for SSL status logic |
---
### Task 1: Add SSL Status Function to Metrics API
**Files:**
- Modify: `packages/secubox-metrics/api/main.py:1-50` (imports)
- Modify: `packages/secubox-metrics/api/main.py:430-620` (health summary section)
- Create: `tests/test_ssl_status.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_ssl_status.py`:
```python
"""
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
# We'll import the function after creating it
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "packages/secubox-metrics/api"))
def test_ssl_status_ok():
"""Cert with 45 days remaining returns status ok."""
from main import get_ssl_status
# Mock cert with 45 days remaining
mock_cert = MagicMock()
mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=45)
with patch('main.Path.exists', return_value=True), \
patch('main.Path.read_bytes', return_value=b'cert data'), \
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):
result = get_ssl_status("example.com")
assert result["status"] == "ok"
assert result["days_remaining"] == 45
assert result["domain"] == "example.com"
def test_ssl_status_warn():
"""Cert with 5 days remaining returns status warn."""
from main import get_ssl_status
mock_cert = MagicMock()
mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=5)
with patch('main.Path.exists', return_value=True), \
patch('main.Path.read_bytes', return_value=b'cert data'), \
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):
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."""
from main import get_ssl_status
mock_cert = MagicMock()
mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=2)
with patch('main.Path.exists', return_value=True), \
patch('main.Path.read_bytes', return_value=b'cert data'), \
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):
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."""
from main import get_ssl_status
mock_cert = MagicMock()
mock_cert.not_valid_after_utc = datetime.now(timezone.utc) - timedelta(days=3)
with patch('main.Path.exists', return_value=True), \
patch('main.Path.read_bytes', return_value=b'cert data'), \
patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):
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."""
from main import get_ssl_status
with patch.object(Path, 'exists', return_value=False):
result = get_ssl_status("nonexistent.com")
assert result["status"] == "unknown"
assert result["days_remaining"] is None
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v`
Expected: FAIL with `ImportError: cannot import name 'get_ssl_status' from 'main'`
- [ ] **Step 3: Add imports to main.py**
At the top of `packages/secubox-metrics/api/main.py`, after existing imports (around line 17), add:
```python
from cryptography import x509
from cryptography.hazmat.backends import default_backend
```
- [ ] **Step 4: Add get_ssl_status function**
In `packages/secubox-metrics/api/main.py`, after the `build_health_summary()` function (around line 610), add:
```python
# ═══════════════════════════════════════════════════════════════════════════════
# 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
Returns:
dict with domain, days_remaining, status, expiry
"""
if not domain:
return {
"domain": None,
"days_remaining": None,
"status": "unknown",
"expiry": None
}
# Certificate search paths (in order of priority)
# 1. Direct domain match
# 2. Wildcard (parent domain)
# 3. HAProxy combined cert
# 4. Nginx cert
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
# Thresholds: >7j ok, 3-7j warn, <3j error, 0 expired
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)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v`
Expected: All 5 tests PASS
- [ ] **Step 6: Commit**
```bash
git add packages/secubox-metrics/api/main.py tests/test_ssl_status.py
git commit -m "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>"
```
---
### Task 2: Enrich Health Summary Endpoint with SSL Status
**Files:**
- Modify: `packages/secubox-metrics/api/main.py:613-620` (get_health_summary endpoint)
- [ ] **Step 1: Write the failing test**
Add to `tests/test_ssl_status.py`:
```python
def test_health_summary_includes_ssl():
"""Health summary endpoint includes ssl field."""
from main import build_health_summary
from fastapi import Request
from unittest.mock import AsyncMock
# Mock the request with Host header
mock_request = MagicMock()
mock_request.headers = {"host": "admin.gk2.secubox.in"}
# We can't easily test the full endpoint, but we can verify
# build_health_summary returns the expected structure
result = build_health_summary()
# The function should have these keys
assert "score" in result
assert "modules" in result
# SSL will be added in the endpoint, not build_health_summary
```
- [ ] **Step 2: Modify the endpoint to include SSL**
Replace the `get_health_summary` endpoint in `packages/secubox-metrics/api/main.py` (around line 613-620):
```python
@app.get("/api/v1/metrics/health/summary")
async def get_health_summary(request: Request):
"""
Health summary for the global health banner.
Returns aggregated health score and module statuses.
No auth required for banner display.
"""
summary = build_health_summary()
# Add SSL certificate status for the current domain
host = request.headers.get("host", "").split(":")[0] # Remove port if present
if host:
summary["ssl"] = get_ssl_status(host)
else:
summary["ssl"] = None
return summary
```
- [ ] **Step 3: Add Request import**
At the top of the file, update the FastAPI import (around line 17):
```python
from fastapi import FastAPI, Depends, HTTPException, Request
```
- [ ] **Step 4: Run tests to verify**
Run: `cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v`
Expected: All tests PASS
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-metrics/api/main.py tests/test_ssl_status.py
git commit -m "feat(metrics): Add SSL status to health summary endpoint
- Extract domain from Host header
- Call get_ssl_status() and include in response
- Frontend can now display certificate health
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
```
---
### Task 3: Add SSL Display to Health Banner Frontend
**Files:**
- Modify: `packages/secubox-hub/www/shared/health-banner.js`
- [ ] **Step 1: Add SSL CSS styles**
In `packages/secubox-hub/www/shared/health-banner.js`, find the style block (around line 269-590) and add after the `.hb-pct` style (around line 390):
```css
/* SSL Certificate Status */
.hb-ssl {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08);
font-size: 11px;
}
.hb-ssl-icon {
font-size: 14px;
}
.hb-ssl-days {
font-weight: 600;
}
.hb-ssl.ssl-ok {
border-color: rgba(0, 255, 65, 0.3);
}
.hb-ssl.ssl-ok .hb-ssl-days {
color: #00ff41;
}
.hb-ssl.ssl-warn {
border-color: rgba(255, 193, 7, 0.3);
}
.hb-ssl.ssl-warn .hb-ssl-days {
color: #ffc107;
}
.hb-ssl.ssl-error {
border-color: rgba(230, 57, 70, 0.3);
}
.hb-ssl.ssl-error .hb-ssl-days {
color: #e63946;
}
.hb-ssl.ssl-expired {
border-color: rgba(230, 57, 70, 0.5);
background: rgba(230, 57, 70, 0.1);
animation: ssl-blink 1s infinite;
}
.hb-ssl.ssl-expired .hb-ssl-days {
color: #e63946;
}
@keyframes ssl-blink {
50% { opacity: 0.6; }
}
.hb-ssl.ssl-unknown {
border-color: rgba(107, 107, 122, 0.3);
}
.hb-ssl.ssl-unknown .hb-ssl-days {
color: #6b6b7a;
}
```
- [ ] **Step 2: Add SSL rendering function**
In the same file, after the `getScoreVibe` function (around line 226), add:
```javascript
function renderSslStatus(ssl) {
if (!ssl) return '';
const statusConfig = {
ok: { emoji: '🔒', label: 'Certificate OK' },
warn: { emoji: '🔐', label: 'Certificate expiring soon' },
error: { emoji: '🔓', label: 'Certificate critical' },
expired: { emoji: '🔓', label: 'Certificate EXPIRED' },
unknown: { emoji: '❓', label: 'Certificate unknown' }
};
const config = statusConfig[ssl.status] || statusConfig.unknown;
const days = ssl.days_remaining;
const daysText = ssl.status === 'expired' ? 'EXPIRÉ' :
days !== null ? `${days}j` : '--';
return `
<div class="hb-ssl ssl-${ssl.status}" title="${config.label} — ${ssl.domain || 'unknown'}">
<span class="hb-ssl-icon">${config.emoji}</span>
<span class="hb-ssl-days">${daysText}</span>
</div>
`;
}
```
- [ ] **Step 3: Add SSL section to banner HTML structure**
Find the `createBannerElement` function (around line 228) and add an SSL placeholder div after the score div:
```javascript
banner.innerHTML = `
<div class="hb-content">
<div class="hb-score">
<span class="hb-icon">💖</span>
<div class="hb-score-info">
<span class="hb-label">VIBING</span>
<div class="hb-bar"><div class="hb-fill"></div></div>
</div>
<span class="hb-pct">--</span>
</div>
<div class="hb-ssl-container"></div>
<div class="hb-modules"></div>
<div class="hb-alerts"></div>
<div class="hb-sparkle"></div>
<div class="hb-details">
<div class="hb-stats-grid"></div>
</div>
</div>
`;
```
- [ ] **Step 4: Update the render function to display SSL**
Find the `updateBanner` function (search for `function updateBanner`) and add SSL rendering after the score update:
```javascript
// Update SSL status (after score section)
const sslContainer = banner.querySelector('.hb-ssl-container');
if (sslContainer && data.ssl) {
sslContainer.innerHTML = renderSslStatus(data.ssl);
}
```
- [ ] **Step 5: Update version number**
At the top of the file (around line 18), update:
```javascript
const VERSION = '1.2.0';
```
- [ ] **Step 6: Test manually in browser**
Open: `https://admin.gk2.secubox.in/`
Expected: Health banner sidebar shows SSL status line (🔒 XXj) after the score
- [ ] **Step 7: Commit**
```bash
git add packages/secubox-hub/www/shared/health-banner.js
git commit -m "feat(health-banner): Add SSL certificate status display v1.2.0
- Show cert expiry days after score section
- Color-coded: green (>7j), yellow (3-7j), red (<3j)
- Blinking animation for expired certs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
```
---
### Task 4: Copy Updated Files to Debian Package
**Files:**
- Sync: `packages/secubox-metrics/debian/secubox-metrics/usr/lib/secubox/metrics/api/main.py`
- Sync: `packages/secubox-hub/debian/secubox-hub/usr/share/secubox/www/shared/health-banner.js`
- [ ] **Step 1: Copy metrics API**
```bash
cp packages/secubox-metrics/api/main.py \
packages/secubox-metrics/debian/secubox-metrics/usr/lib/secubox/metrics/api/main.py
```
- [ ] **Step 2: Copy health banner JS**
```bash
cp packages/secubox-hub/www/shared/health-banner.js \
packages/secubox-hub/debian/secubox-hub/usr/share/secubox/www/shared/health-banner.js
```
- [ ] **Step 3: Commit**
```bash
git add packages/secubox-metrics/debian packages/secubox-hub/debian
git commit -m "chore: Sync debian package files for SSL health feature
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
```
---
### Task 5: Deploy and Validate
**Files:** None (deployment task)
- [ ] **Step 1: Deploy to MOCHAbin**
```bash
# Deploy metrics API
bash scripts/deploy.sh secubox-metrics root@192.168.1.200
# Deploy hub (health-banner.js)
bash scripts/deploy.sh secubox-hub root@192.168.1.200
```
- [ ] **Step 2: Restart metrics service**
```bash
ssh root@192.168.1.200 'systemctl restart secubox-metrics'
```
- [ ] **Step 3: Validate API response**
```bash
curl -s https://admin.gk2.secubox.in/api/v1/metrics/health/summary | jq '.ssl'
```
Expected output:
```json
{
"domain": "admin.gk2.secubox.in",
"days_remaining": 45,
"status": "ok",
"expiry": "2026-06-26T12:00:00+00:00"
}
```
- [ ] **Step 4: Validate frontend display**
1. Open: `https://admin.gk2.secubox.in/`
2. Click the health banner trigger (◀) on the right edge
3. Verify SSL line shows: 🔒 XXj (green)
- [ ] **Step 5: Update HISTORY.md**
Add session entry to `.claude/HISTORY.md`:
```markdown
### Session 153 — SSL Certificate Health in Health Banner
**Goal:** Display SSL certificate expiration status in the Health Banner sidebar.
**Changes:**
- `packages/secubox-metrics/api/main.py`: Added `get_ssl_status()` function
- `packages/secubox-hub/www/shared/health-banner.js`: Added SSL display (v1.2.0)
- `tests/test_ssl_status.py`: 5 unit tests
**Thresholds:**
- 🔒 >7j (green)
- 🔐 3-7j (yellow)
- 🔓 <3j (red)
- 🔓 EXPIRÉ (red blink)
**Reference:** CM-SSL-BANNER-2026-05-12
```
- [ ] **Step 6: Final commit**
```bash
git add .claude/HISTORY.md
git commit -m "docs: Add Session 153 - SSL certificate health in Health Banner
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
```
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | Add `get_ssl_status()` function | `main.py`, `test_ssl_status.py` |
| 2 | Enrich health summary endpoint | `main.py` |
| 3 | Add SSL display to frontend | `health-banner.js` |
| 4 | Sync debian package files | `debian/` dirs |
| 5 | Deploy and validate | deployment |
**Total estimated steps:** 25
**Tests:** 5 unit tests

View File

@ -0,0 +1,351 @@
# SSL Certificate Health in Health Banner — Design Spec
> **Reference:** CM-SSL-BANNER-2026-05-12
> **Status:** Approved
> **Author:** Gerald Kerma / Claude
---
## Goal
Display SSL certificate expiration status for the current domain in the SecuBox Health Banner, providing at-a-glance visibility into certificate health for all proxied websites.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Browser (proxied website) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Health Banner (injected by mitmproxy) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ 🏥 87% ████████░░ │ │ │
│ │ │ 🔒 45j ← NEW: SSL status │ │ │
│ │ │ ┌───┬───┬───┬───┬───┐ │ │ │
│ │ │ │WAF│CS │HAP│NGX│SYS│ │ │ │
│ │ │ └───┴───┴───┴───┴───┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ GET /api/v1/metrics/health/summary
│ Host: example.gk2.secubox.in
┌─────────────────────────────────────────────────────────────────┐
│ SecuBox Hub API │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ get_ssl_status(domain) │ │
│ │ - Read /etc/letsencrypt/live/{domain}/cert.pem │ │
│ │ - Parse with cryptography.x509 │ │
│ │ - Calculate days_remaining │ │
│ │ - Return status: ok | warn | error | expired | unknown │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Scope
### In Scope
- SSL certificate status for the **current domain** (from `Host` header)
- Minimal display: icon + days remaining
- Aggressive thresholds for Let's Encrypt 90-day certs
- Integration with existing Health Banner double-buffer cache
### Out of Scope
- Multi-domain monitoring dashboard
- Email/webhook alerts on expiration
- Certificate renewal automation (handled by certbot)
- Full certificate details (issuer, SAN list, chain)
---
## Backend Changes
### File: `packages/secubox-hub/api/main.py`
#### New Function: `get_ssl_status(domain: str)`
```python
from pathlib import Path
from datetime import datetime, timezone
from cryptography import x509
from cryptography.hazmat.backends import default_backend
def get_ssl_status(domain: str) -> dict:
"""
Get SSL certificate status for a domain.
Returns:
{
"domain": "example.gk2.secubox.in",
"days_remaining": 45,
"status": "ok", # ok | warn | error | expired | unknown | none
"expiry": "2026-06-26T12:00:00Z"
}
"""
# Certificate search paths (in order of priority)
cert_paths = [
Path(f"/etc/letsencrypt/live/{domain}/cert.pem"),
Path(f"/etc/letsencrypt/live/{domain.split('.', 1)[-1]}/cert.pem"), # wildcard
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
# Thresholds: >7j ok, 3-7j warn, <3j error, 0 expired
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)
}
```
#### Modify: Health Summary Endpoint
Add SSL status to the existing `/api/v1/metrics/health/summary` response:
```python
@app.get("/api/v1/metrics/health/summary")
async def health_summary(request: Request):
# ... existing code ...
# Extract domain from Host header
host = request.headers.get("host", "").split(":")[0]
ssl_status = get_ssl_status(host) if host else None
return {
"score": score,
"modules": modules,
"stats": stats,
"alerts": alerts,
"ssl": ssl_status # NEW
}
```
---
## Frontend Changes
### File: `packages/secubox-hub/www/shared/health-banner.js`
#### CSS Addition (in style block)
```css
/* SSL Certificate Status */
.ssl-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
margin: 4px 0;
}
.ssl-ok {
color: #00ff41;
background: rgba(0, 255, 65, 0.1);
}
.ssl-warn {
color: #ffc107;
background: rgba(255, 193, 7, 0.1);
}
.ssl-error {
color: #e63946;
background: rgba(230, 57, 70, 0.1);
}
.ssl-expired {
color: #e63946;
background: rgba(230, 57, 70, 0.2);
animation: blink 1s infinite;
}
.ssl-unknown {
color: #6b6b7a;
background: rgba(107, 107, 122, 0.1);
}
@keyframes blink {
50% { opacity: 0.5; }
}
```
#### JavaScript Addition (in renderBanner function)
```javascript
// SSL Certificate Status (after score, before modules)
function renderSslStatus(ssl) {
if (!ssl) return '';
const statusMap = {
ok: { emoji: '🔒', label: 'Cert OK' },
warn: { emoji: '🔐', label: 'Cert expiring soon' },
error: { emoji: '🔓', label: 'Cert critical' },
expired: { emoji: '🔓', label: 'Cert EXPIRED' },
unknown: { emoji: '❓', label: 'Cert unknown' },
none: { emoji: '🔓', label: 'No HTTPS' }
};
const info = statusMap[ssl.status] || statusMap.unknown;
const days = ssl.days_remaining;
const daysText = days !== null ? `${days}j` : '--';
const displayText = ssl.status === 'expired' ? 'EXPIRÉ' :
ssl.status === 'none' ? 'HTTP' : daysText;
return `
<div class="ssl-status ssl-${ssl.status}" title="${info.label} - ${ssl.domain}">
${info.emoji} ${displayText}
</div>
`;
}
// In renderBanner(), after score section:
const sslHtml = renderSslStatus(data.ssl);
// Insert sslHtml after scoreHtml, before modulesHtml
```
---
## Error Handling
| Scenario | Backend Response | Frontend Display |
|----------|------------------|------------------|
| Cert found, valid | `status: "ok"`, `days_remaining: 45` | 🔒 45j (green) |
| Cert expiring 3-7 days | `status: "warn"`, `days_remaining: 5` | 🔐 5j (yellow) |
| Cert expiring <3 days | `status: "error"`, `days_remaining: 2` | 🔓 2j (red) |
| Cert expired | `status: "expired"`, `days_remaining: -3` | 🔓 EXPIRÉ (red blink) |
| Cert file not found | `status: "unknown"` | ❓ -- (gray) |
| No Host header | `ssl: null` | (no SSL line shown) |
| HTTP-only domain | `status: "none"` | 🔓 HTTP (gray) |
| Parse error | `status: "unknown"`, `error: "..."` | ❓ -- (gray) |
---
## Caching
SSL status is included in the existing Health Banner cache:
- **Refresh interval:** 30 seconds (existing `REFRESH_INTERVAL`)
- **localStorage TTL:** 5 minutes (existing `CACHE_KEY` with 300000ms TTL)
- **Double-buffer:** Uses existing `HealthCache.write()``swap()` pattern
No additional network requests required.
---
## File Permissions
The `secubox-hub` service user needs read access to certificate files:
```bash
# Already handled by secubox-core postinst
usermod -aG ssl-cert secubox-hub
chmod 640 /etc/letsencrypt/live/*/cert.pem
chown root:ssl-cert /etc/letsencrypt/live/*/cert.pem
```
---
## Testing
### Unit Tests: `tests/test_ssl_status.py`
```python
def test_ssl_status_ok():
"""Cert with 45 days remaining → status ok"""
def test_ssl_status_warn():
"""Cert with 5 days remaining → status warn"""
def test_ssl_status_error():
"""Cert with 2 days remaining → status error"""
def test_ssl_status_expired():
"""Cert with -3 days remaining → status expired"""
def test_ssl_status_not_found():
"""Missing cert file → status unknown"""
def test_ssl_status_wildcard():
"""Wildcard cert matches subdomain"""
```
### Integration Test
1. Deploy to MOCHAbin
2. Visit `https://admin.gk2.secubox.in/`
3. Open Health Banner sidebar
4. Verify SSL status line shows: 🔒 XXj (green)
---
## Thresholds
| Days Remaining | Status | Color | Icon |
|----------------|--------|-------|------|
| > 7 | `ok` | Green (#00ff41) | 🔒 |
| 3 - 7 | `warn` | Yellow (#ffc107) | 🔐 |
| 1 - 2 | `error` | Red (#e63946) | 🔓 |
| ≤ 0 | `expired` | Red (blinking) | 🔓 |
These aggressive thresholds assume Let's Encrypt 90-day certificates with certbot auto-renewal at 30 days. Anything under 7 days indicates renewal failure.
---
## Dependencies
- `cryptography` — Already in `common/secubox_core/requirements.txt`
- No new packages required
---
## Version
Increment Health Banner version:
```javascript
const VERSION = '1.2.0'; // was 1.1.0
```
Changelog:
- **1.2.0** — Add SSL certificate health status display

View File

@ -1538,7 +1538,7 @@ async def generate_config():
for vh in vhosts:
if vh.get("enabled"):
if cfg["waf_enabled"] and not vh.get("waf_bypass"):
config_lines.append(f" use_backend waf_inspector if host_{vh['name']}")
config_lines.append(f" use_backend mitmproxy_inspector if host_{vh['name']}")
else:
config_lines.append(f" use_backend {vh['backend']} if host_{vh['name']}")
@ -1561,7 +1561,7 @@ async def generate_config():
for vh in vhosts:
if vh.get("enabled") and vh.get("ssl"):
if cfg["waf_enabled"] and not vh.get("waf_bypass"):
config_lines.append(f" use_backend waf_inspector if host_{vh['name']}")
config_lines.append(f" use_backend mitmproxy_inspector if host_{vh['name']}")
else:
config_lines.append(f" use_backend {vh['backend']} if host_{vh['name']}")

View File

@ -612,7 +612,7 @@ EOF
fi
cat >> "$CONFIG_DIR/haproxy.cfg" << EOF
default_backend fallback
default_backend mitmproxy_inspector
frontend https-in
bind *:${https_port} ssl crt $CERTS_DIR/
@ -641,7 +641,7 @@ EOF
fi
cat >> "$CONFIG_DIR/haproxy.cfg" << EOF
default_backend fallback
default_backend mitmproxy_inspector
EOF

View File

@ -15,7 +15,7 @@
(function() {
'use strict';
const VERSION = '1.1.0';
const VERSION = '1.2.0';
// Use global config if injected by CDN/WAF, otherwise use relative path
const HEALTH_API = window.SECUBOX_HEALTH_API || '/api/v1/metrics/health/summary';
const REFRESH_INTERVAL = 30000; // 30s
@ -225,6 +225,30 @@
return 'YIKES';
}
function renderSslStatus(ssl) {
if (!ssl) return '';
const statusConfig = {
ok: { emoji: '🔒', label: 'Certificate OK' },
warn: { emoji: '🔐', label: 'Certificate expiring soon' },
error: { emoji: '🔓', label: 'Certificate critical' },
expired: { emoji: '🔓', label: 'Certificate EXPIRED' },
unknown: { emoji: '❓', label: 'Certificate unknown' }
};
const config = statusConfig[ssl.status] || statusConfig.unknown;
const days = ssl.days_remaining;
const daysText = ssl.status === 'expired' ? 'EXPIRÉ' :
days !== null ? `${days}j` : '--';
return `
<div class="hb-ssl ssl-${ssl.status}" title="${config.label} — ${ssl.domain || 'unknown'}">
<span class="hb-ssl-icon">${config.emoji}</span>
<span class="hb-ssl-days">${daysText}</span>
</div>
`;
}
function createBannerElement() {
// Create trigger button (always visible)
const trigger = document.createElement('div');
@ -249,6 +273,7 @@
</div>
<span class="hb-pct">--</span>
</div>
<div class="hb-ssl-container"></div>
<div class="hb-modules"></div>
<div class="hb-alerts"></div>
<div class="hb-sparkle"></div>
@ -388,6 +413,35 @@
color: var(--text-primary, #e8e6d9);
}
/* SSL Certificate Status */
.hb-ssl {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08);
font-size: 11px;
}
.hb-ssl-icon { font-size: 14px; }
.hb-ssl-days { font-weight: 600; }
.hb-ssl.ssl-ok { border-color: rgba(0, 255, 65, 0.3); }
.hb-ssl.ssl-ok .hb-ssl-days { color: #00ff41; }
.hb-ssl.ssl-warn { border-color: rgba(255, 193, 7, 0.3); }
.hb-ssl.ssl-warn .hb-ssl-days { color: #ffc107; }
.hb-ssl.ssl-error { border-color: rgba(230, 57, 70, 0.3); }
.hb-ssl.ssl-error .hb-ssl-days { color: #e63946; }
.hb-ssl.ssl-expired {
border-color: rgba(230, 57, 70, 0.5);
background: rgba(230, 57, 70, 0.1);
animation: ssl-blink 1s infinite;
}
.hb-ssl.ssl-expired .hb-ssl-days { color: #e63946; }
@keyframes ssl-blink { 50% { opacity: 0.6; } }
.hb-ssl.ssl-unknown { border-color: rgba(107, 107, 122, 0.3); }
.hb-ssl.ssl-unknown .hb-ssl-days { color: #6b6b7a; }
/* Modules grid */
.hb-modules {
display: grid;
@ -628,6 +682,12 @@
sparkleEl.style.display = score >= 90 ? 'block' : 'none';
}
// Update SSL status
const sslContainer = banner.querySelector('.hb-ssl-container');
if (sslContainer && health.ssl) {
sslContainer.innerHTML = renderSslStatus(health.ssl);
}
// Render module cards
const modsEl = banner.querySelector('.hb-modules');
if (modsEl && health?.modules) {

View File

@ -10,12 +10,15 @@ 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 import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
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:
@ -32,6 +35,15 @@ app = FastAPI(
version="1.0.0"
)
# CORS middleware for cross-origin health banner requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Health banner injected on any domain
allow_credentials=False,
allow_methods=["GET"],
allow_headers=["*"],
)
# Cache configuration
CACHE_DIR = Path("/tmp/secubox")
CACHE_FILE = CACHE_DIR / "metrics-cache.json"
@ -610,14 +622,78 @@ 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():
async def get_health_summary(request: Request):
"""
Health summary for the global health banner.
Returns aggregated health score and module statuses.
No auth required for banner display.
"""
return build_health_summary()
summary = build_health_summary()
# Add SSL certificate status for the current domain
host = request.headers.get("host", "").split(":")[0] # Remove port if present
if host:
summary["ssl"] = get_ssl_status(host)
else:
summary["ssl"] = None
return summary
if __name__ == "__main__":
import uvicorn

View File

@ -1,58 +1,55 @@
#!/bin/bash
# secubox-haproxy-regen-safe — Safe haproxy.cfg regeneration with validate + rollback
# secubox-haproxy-regen-safe — Safe haproxy.cfg regeneration with validate + atomic swap + rollback
#
# Usage: secubox-haproxy-regen-safe [--no-reload]
#
# Flow:
# 1. Snapshot /etc/haproxy/haproxy.cfg
# 2. Run `haproxyctl generate` (writes new cfg in place)
# 3. Validate with `haproxy -c -f` — if invalid, restore snapshot
# 4. Reload haproxy on success (skip with --no-reload)
# 1. Snapshot current /etc/haproxy/haproxy.cfg
# 2. Generate new cfg via haproxyctl regen (output to .new)
# 3. Validate new cfg with haproxy -c -f
# 4. On success: atomic swap + reload haproxy
# 5. On failure: keep current cfg, exit non-zero
#
# CyberMind — 2026-05-12 (post Session 156 regression).
# CyberMind — added 2026-05-12 after Session 155 haproxy regression incident.
set -euo pipefail
CFG=/etc/haproxy/haproxy.cfg
TS=$(date +%s)
SNAP="$CFG.bak.$TS-pre-regen"
NEW="$CFG.new.$TS"
RELOAD=1
[[ "${1:-}" == "--no-reload" ]] && RELOAD=0
log() { echo "[$(date "+%F %T")] $*" >&2; }
[[ -x /usr/sbin/haproxyctl ]] || { log "FATAL: /usr/sbin/haproxyctl missing"; exit 2; }
[[ -r "$CFG" ]] || { log "FATAL: $CFG missing or unreadable"; exit 2; }
[[ -x /usr/sbin/haproxyctl ]] || { log "FATAL: haproxyctl missing"; exit 2; }
log "Snapshot $CFG → $SNAP"
cp -p "$CFG" "$SNAP"
log "Snapshot current cfg -> $SNAP"
cp "$CFG" "$SNAP"
log "Run: haproxyctl generate"
if ! /usr/sbin/haproxyctl generate; then
log "haproxyctl generate failed; restoring snapshot"
cp -p "$SNAP" "$CFG"
log "Generate new cfg via haproxyctl"
# haproxyctl writes to $CFG directly; redirect by temporarily moving
cp "$CFG" "$NEW"
if ! /usr/sbin/haproxyctl regen 2>&1 | tail -5; then
log "regen failed; restoring snapshot"
mv "$SNAP" "$CFG"
rm -f "$NEW"
exit 3
fi
mv "$CFG" "$NEW"
mv "$SNAP" "$CFG" # restore current while we validate the new
log "Validate new $CFG"
if ! haproxy -c -f "$CFG" >/dev/null 2>&1; then
log "validation FAILED; saving broken candidate and restoring snapshot"
cp -p "$CFG" "$CFG.broken.$TS"
cp -p "$SNAP" "$CFG"
log "broken candidate kept at $CFG.broken.$TS for forensics"
log "Validate new cfg"
if haproxy -c -f "$NEW" >/dev/null 2>&1; then
log "validation OK"
mv "$NEW" "$CFG"
if [[ $RELOAD -eq 1 ]]; then
log "reload haproxy"
systemctl reload haproxy && log "reload OK" || { log "reload FAILED"; exit 4; }
fi
log "regen-safe complete"
else
log "validation FAILED; cfg untouched; broken candidate saved as $NEW.broken"
mv "$NEW" "$NEW.broken"
exit 5
fi
log "validation OK"
if [[ $RELOAD -eq 1 ]]; then
log "Reload haproxy"
if systemctl reload haproxy; then
log "reload OK"
else
log "reload failed; rolling back to snapshot"
cp -p "$SNAP" "$CFG"
systemctl reload haproxy && log "rollback reload OK" || log "rollback reload ALSO failed — manual intervention needed"
exit 4
fi
fi
log "regen-safe complete (snapshot kept: $SNAP)"

View File

@ -26,7 +26,7 @@ METABLOG_PORT=8900
BACKEND_IP="10.100.0.1"
# Dead container IPs to fix (not running LXC containers)
DEAD_CONTAINER_IPS="10.100.0.10 10.100.0.20 10.100.0.30 10.100.0.40 10.100.0.50"
DEAD_CONTAINER_IPS="10.100.0.10 10.100.0.20 10.100.0.30 10.100.0.40"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
@ -110,7 +110,7 @@ main() {
# Check if route exists with correct port
existing=$(echo "$routes_json" | jq -r --arg d "$domain" '.[$d] // empty' 2>/dev/null || true)
if [[ -z "$existing" ]] || ! echo "$existing" | jq -e ".[1] == $port" >/dev/null 2>&1; then
if [[ -z "$existing" ]]; then
log "Updating route: $domain -> $BACKEND_IP:$port"
new_rj=$(echo "$routes_json" | jq --arg d "$domain" --arg ip "$BACKEND_IP" --argjson p "$port" '.[$d] = [$ip, $p]' 2>/dev/null || true)
if [[ -n "$new_rj" ]]; then routes_json="$new_rj"; fi
@ -124,7 +124,7 @@ main() {
existing=$(echo "$routes_json" | jq -r --arg d "$domain" '.[$d] // empty' 2>/dev/null || true)
if [[ -z "$existing" ]] || ! echo "$existing" | jq -e ".[1] == $METABLOG_PORT" >/dev/null 2>&1; then
if [[ -z "$existing" ]]; then
log "Updating metablog route: $domain -> $BACKEND_IP:$METABLOG_PORT"
new_rj=$(echo "$routes_json" | jq --arg d "$domain" --arg ip "$BACKEND_IP" --argjson p "$METABLOG_PORT" '.[$d] = [$ip, $p]' 2>/dev/null || true)
if [[ -n "$new_rj" ]]; then routes_json="$new_rj"; fi

87
tests/test_ssl_status.py Normal file
View 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