mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-30 06:05:01 +00:00
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:
commit
443e375f46
|
|
@ -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.
|
||||
|
|
|
|||
622
docs/superpowers/plans/2026-05-12-ssl-health-banner.md
Normal file
622
docs/superpowers/plans/2026-05-12-ssl-health-banner.md
Normal 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
|
||||
351
docs/superpowers/specs/2026-05-12-ssl-health-banner-design.md
Normal file
351
docs/superpowers/specs/2026-05-12-ssl-health-banner-design.md
Normal 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
|
||||
|
|
@ -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']}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
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