Merge remote-tracking branch 'origin/master' into feature/49-feat-metablogizer-streamlit-version-mana

# Conflicts:
#	.claude/HISTORY.md
This commit is contained in:
CyberMind-FR 2026-05-12 16:56:06 +02:00
commit 4a388994d3
53 changed files with 6263 additions and 8 deletions

View File

@ -149,6 +149,12 @@ Légende : ✅ Terminé · 🔄 En cours · ⬜ À faire · ⏸ Bloqué
---
## Addenda
**2026-05-12 — Issue #92 extension:** `secubox-metrics` gains three public live-panel endpoints (`visitor-origin`, `live-hosts`, `cert-status`) consumed by the health banner.
---
## Phases du projet
### Phase 1 — Hardware ✅

View File

@ -27,6 +27,24 @@ Expose the existing Gitea LXC at `https://gitea.gk2.secubox.in/` (web) and `ssh:
---
## ✅ Session 160: Health Banner Live Panel (Issue #92)
### Objective
Add three public banner sections (VisitorOrigin / LiveHosts / CertStatus)
sharing one polling and CORS pipeline in `secubox-metrics`.
### Completed
- Spec + plan written and committed.
- Three aggregators with unit + error-path tests (25 tests passing).
- nftables ruleset, systemd timer, postinst plumbing.
- Banner v1.3.0 with three fail-isolated fetch loops.
- README updated.
### Status
PR pending review against `master`.
---
## ✅ Session 160: secubox apt + clone — validate implementation (Issue #89)
### Objective

View File

@ -18,6 +18,16 @@ proxy_cache off;
# Passer le header Authorization pour JWT
proxy_set_header Authorization $http_authorization;
# Some upstream FastAPI services also emit CORS headers via CORSMiddleware.
# Strip them here so nginx is the sole authority — otherwise browsers see
# `Access-Control-Allow-Origin: *, *` and reject cross-origin requests.
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Max-Age;
proxy_hide_header Access-Control-Expose-Headers;
# CORS headers for cross-origin requests
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;

View File

@ -105,3 +105,53 @@ def get_board_info() -> dict:
"mem_total_mb": mem.get("MemTotal", 0) // 1024,
"mem_free_mb": mem.get("MemAvailable", 0) // 1024,
}
# ── Live-panel helpers (issue #92) ─────────────────────────────────
_VISITOR_ORIGIN_DEFAULTS = {
"enabled": False,
"window_minutes": 60,
"min_count": 5,
"top_n": 5,
"asn_db_path": "/var/lib/GeoIP/GeoLite2-ASN.mmdb",
"nft_table": "secubox_metrics",
"nft_set": "seen_src",
"nft_family": "inet",
}
_LIVE_HOSTS_DEFAULTS = {
"enabled": False,
"window_minutes": 60,
"top_n": 5,
"haproxy_socket": "/run/haproxy/admin.sock",
"frontend_filter": "*",
}
_CERT_STATUS_DEFAULTS = {
"enabled": False,
"letsencrypt_live_dir": "/etc/letsencrypt/live",
"warn_days": 30,
"critical_days": 7,
}
def _merged(defaults: dict, section: str) -> dict:
out = dict(defaults)
out.update(get_config(section))
return out
def get_visitor_origin_config() -> dict:
"""Return [visitor_origin] merged with defaults."""
return _merged(_VISITOR_ORIGIN_DEFAULTS, "visitor_origin")
def get_live_hosts_config() -> dict:
"""Return [live_hosts] merged with defaults."""
return _merged(_LIVE_HOSTS_DEFAULTS, "live_hosts")
def get_cert_status_config() -> dict:
"""Return [cert_status] merged with defaults."""
return _merged(_CERT_STATUS_DEFAULTS, "cert_status")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,432 @@
# Design — Health Banner Live Panel
*Issue: [#92](https://github.com/CyberMind-FR/secubox-deb/issues/92)*
*Date: 2026-05-12*
*Author: Gérald Kerma (via Claude, Session 160)*
> **Scope note.** Originally filed as "visitor-origin feed only". User expanded
> the scope mid-brainstorm to also include live-hosts and cert-status sections.
> All three are designed together because they share the banner module, polling
> pattern, design tokens, and CORS/cache plumbing. Splitting them later is
> always possible, but each section is small enough that combining them is
> cheaper than three round-trips of design + PR + review.
---
## 1. Goal
Add a public "live panel" to the SecuBox health banner — the floating widget
injected on every SecuBox vhost — with three independent sections:
1. **VisitorOrigin** — top source ASNs hitting the box (anonymized, last 60 min).
2. **LiveHosts** — top vhosts being served (by request rate, last 60 min).
3. **CertStatus** — Let's Encrypt / ACME state rollup across vhosts.
All three sections are visible to anonymous viewers. Hostnames and cert
metadata are already discoverable via DNS and the TLS handshake; exposing them
in the banner adds no recon surface beyond what is already public. Raw client
IPs are never exposed.
## 2. Non-goals
- Per-vhost rollup of visitor ASNs (single global rollup for v1).
- Historical charts or sparklines (current 60 min snapshot only).
- Country/city geolocation (ASN only).
- Bot/scraper classification or threat scoring.
- IPv6 source coverage for VisitorOrigin (follow-up).
- Admin-gated views (everything in v1 is public; nothing exposes IPs).
- Manual cert renewal trigger UI (CertStatus is read-only).
## 3. User-facing surface
The health banner gains one new panel containing three stacked sections, placed
after the existing SSL/health indicators:
```text
┌─ VisitorOrigin ──────────────────────────────────┐
│ AS13335 Cloudflare ████████ 142 │
│ AS16276 OVH SAS ████ 78 │
│ AS3320 Deutsche Telekom ███ 54 │
│ AS3215 Orange S.A. ██ 31 │
│ AS5089 Virgin Media █ 12 │
└─ last 60 min · top 5 ────────────────────────────┘
┌─ LiveHosts ──────────────────────────────────────┐
│ secubox.in ████████ 412 req │
│ apt.secubox.in █████ 214 req │
│ hub.secubox.in ███ 138 req │
│ auth.secubox.in ██ 67 req │
│ p2p.secubox.in █ 22 req │
└─ last 60 min · top 5 ────────────────────────────┘
┌─ CertStatus ─────────────────────────────────────┐
│ ✅ 11 valid ⚠ 2 expiring <30d 0 failed
│ next renewal: auth.secubox.in · 14d │
└──────────────────────────────────────────────────┘
```
Each section is hidden independently when:
- Its data source is unavailable (e.g. `mmdb` missing for VisitorOrigin,
HAProxy socket missing for LiveHosts, no certs found for CertStatus).
- Its `entries` list is empty after filtering.
- Its fetch fails for any reason.
The whole panel collapses gracefully — a single failing section never breaks
the others.
## 4. Architecture overview
All three sections follow the same shape: a 60 s background asyncio task in
`secubox-metrics` reads raw data, computes a sanitized rollup, persists it to
`/var/cache/secubox/metrics/<section>.json`, and exposes it via a public,
CORS-open, 5-minute-cached endpoint. The banner polls each endpoint on its
own 30 s cycle.
```text
┌──────────────────────────────────────────────────────────────────────┐
│ secubox-metrics │
│ │
│ VisitorOriginAggregator ──▶ visitor-origin.json ──▶ /visitor-origin│
│ LiveHostsAggregator ──▶ live-hosts.json ──▶ /live-hosts │
│ CertStatusAggregator ──▶ cert-status.json ──▶ /cert-status │
│ │
└──────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
nft set seen_src HAProxy stats socket /etc/letsencrypt/live
(kernel TTL'd) show stat -1 6 -1 + cryptography parse
Banner polls each endpoint independently every 30 s.
```
## 5. Components in detail
### 5.1 nftables ruleset (`/etc/nftables.d/secubox-metrics.nft`)
Idempotent include shipped by the `secubox-metrics` package. Lives in its own
table to stay clear of `secubox-firewall`'s ruleset.
```nft
table inet secubox_metrics {
set seen_src {
type ipv4_addr
flags timeout
timeout 1h
size 65536
}
chain ingress_tap {
type filter hook prerouting priority -300; policy accept;
tcp dport { 80, 443 } add @seen_src { ip saddr }
}
}
```
- Separate table → no entanglement with `secubox-firewall`.
- `priority -300` runs before standard filter; set additions happen even if a
later rule drops the packet.
- `size 65536` caps memory; on overflow the kernel evicts oldest entries.
- Per-packet cost on Armada 3720/7040: ~hundreds of nanoseconds. Negligible.
### 5.2 Config blocks (`/etc/secubox/secubox.conf`)
The codebase uses a single TOML config file `/etc/secubox/secubox.conf` read by
`secubox_core.config.get_config(section)`. Three new sections are added:
```toml
[visitor_origin]
enabled = true
window_minutes = 60
min_count = 5
top_n = 5
asn_db_path = "/var/lib/GeoIP/GeoLite2-ASN.mmdb"
nft_table = "secubox_metrics"
nft_set = "seen_src"
nft_family = "inet"
[live_hosts]
enabled = true
window_minutes = 60
top_n = 5
haproxy_socket = "/run/haproxy/admin.sock"
# Frontends to count; "*" means all that show vhost-style names.
frontend_filter = "*"
[cert_status]
enabled = true
letsencrypt_live_dir = "/etc/letsencrypt/live"
warn_days = 30 # "expiring soon" threshold
critical_days = 7 # surfaced as ⚠ vs ✗ tightening
```
Parsed by `common/secubox_core/config.py` with sensible defaults when blocks
are absent (each section defaults to `enabled=False`).
### 5.3 VisitorOrigin aggregator (`packages/secubox-metrics/api/visitor_origin.py`)
```python
class VisitorOriginAggregator:
def __init__(self, cfg: VisitorOriginConfig): ...
async def run_forever(self) -> None: ...
async def refresh_once(self) -> dict: ...
def _read_nft_set(self) -> list[IPv4Address]: ... # subprocess nft -j
def _lookup_asn(self, ip) -> tuple[int, str] | None: ... # maxminddb
def _aggregate(self, ips) -> list[dict]: ... # threshold + sort
def _persist(self, payload) -> None: ... # atomic write
def current(self) -> dict: ... # sync read
```
**Guarantees.** Raw IPs never leave the aggregator's local scope. The threshold
gate (`count >= min_count`) runs *before* cache write, so the on-disk file
cannot contain an ASN attributable to fewer than `min_count` distinct sources.
### 5.4 LiveHosts aggregator (`packages/secubox-metrics/api/live_hosts.py`)
Data source: HAProxy admin socket. The socket exposes per-frontend request
counters (`req_tot`) that are monotonic since HAProxy start, so we sample at
fixed intervals and ring-buffer 1 min deltas over 60 slots.
```python
class LiveHostsAggregator:
def __init__(self, cfg: LiveHostsConfig):
self._buckets = collections.deque(maxlen=60) # 60 × 1 min
self._prev_totals: dict[str, int] = {}
async def run_forever(self) -> None: ...
async def refresh_once(self) -> dict: ...
def _read_haproxy_stats(self) -> dict[str, int]: ... # frontend → req_tot
def _delta_and_buffer(self, totals: dict[str, int]) -> dict[str, int]: ...
def _aggregate(self) -> list[dict]: ... # sum buckets, top_n
def _persist(self, payload) -> None: ...
def current(self) -> dict: ...
```
**Why HAProxy socket, not log parsing?** The socket is structured CSV that
HAProxy guarantees stable per major version; log parsing has to track format
changes, sampling, rotation, and is heavier on the box. The socket is also
already used by `haproxyctl`.
**Frontend filtering.** Only frontends whose name looks like a hostname (or
matches `frontend_filter` when not `"*"`) are counted. Internal frontends like
`stats-https` are filtered out to avoid noise. Heuristic: contains a `.`,
doesn't start with `_`, isn't in a blocklist.
**Schema.**
```json
{
"enabled": true,
"window_minutes": 60,
"generated_at": "2026-05-12T14:23:00Z",
"entries": [
{"host": "secubox.in", "count": 412},
{"host": "apt.secubox.in", "count": 214}
]
}
```
### 5.5 CertStatus aggregator (`packages/secubox-metrics/api/cert_status.py`)
Reads every `*/cert.pem` under `/etc/letsencrypt/live/`, parses with
`cryptography.x509.load_pem_x509_certificate`, extracts `not_valid_after`.
```python
class CertStatusAggregator:
def __init__(self, cfg: CertStatusConfig): ...
async def run_forever(self) -> None: ...
async def refresh_once(self) -> dict: ...
def _scan_certs(self) -> list[CertInfo]: ...
def _classify(self, cert: CertInfo, now: datetime) -> str: ...
# → "valid" | "expiring_soon" | "expiring_critical" | "expired"
def _summarize(self, infos) -> dict: ...
def _persist(self, payload) -> None: ...
def current(self) -> dict: ...
```
**Schema.**
```json
{
"enabled": true,
"generated_at": "2026-05-12T14:23:00Z",
"summary": {
"total": 13,
"valid": 11,
"expiring_soon": 2,
"expiring_critical": 0,
"expired": 0,
"failed_renewal": 0
},
"next_renewal": {"host": "auth.secubox.in", "days": 14},
"warnings": [
{"host": "auth.secubox.in", "days": 14, "state": "expiring_soon"}
]
}
```
**Failed-renewal detection.** Optional v1 enhancement: tail
`/var/log/letsencrypt/letsencrypt.log` for the last 7 days, count failures by
host. If the log is unreadable, `failed_renewal=0` (degrade gracefully). This
can ship as a follow-up if it complicates v1.
**Permissions.** `cert.pem` is world-readable by default on Debian; the
aggregator runs as `secubox-metrics` and only reads. Private keys are never
touched.
### 5.6 FastAPI endpoints
Three GET endpoints, all unauthenticated, all CORS-open, all `Cache-Control: public, max-age=300`:
```http
GET /api/v1/metrics/visitor-origin
GET /api/v1/metrics/live-hosts
GET /api/v1/metrics/cert-status
```
Each endpoint returns its aggregator's `current()` payload or
`{"enabled": false, ...}` when the aggregator is disabled or has no data.
### 5.7 GeoIP database lifecycle (VisitorOrigin only)
- `apt install geoipupdate python3-maxminddb` (added to `debian/control`
Depends).
- License key in `/etc/secubox/secrets/maxmind.conf` (mode `0600`, owner
`secubox`). Operator-supplied; absent ⇒ updater no-op (logged once).
- `secubox-geoipupdate.timer` runs weekly, calling
`secubox-geoipupdate.service` which exec's `geoipupdate -f /etc/secubox/secrets/maxmind.conf`.
- Aggregator opens the mmdb read-only via `maxminddb.open_database(path, MODE_MMAP)`
and reopens on mtime change.
### 5.8 Health-banner integration (`packages/secubox-hub/www/shared/health-banner.js`)
Bump `VERSION` to `'1.3.0'`. Add three independent fetch loops mirroring the
existing `HealthCache` pattern. Each section:
- Has its own 30 s timer.
- Has its own cache + last-error tracker.
- Renders only when `enabled === true` and `entries.length > 0`
(or, for cert-status, `summary.total > 0`).
- A section's failure never affects the others.
```js
const VISITOR_ORIGIN_API = window.SECUBOX_VISITOR_ORIGIN_API
|| '/api/v1/metrics/visitor-origin';
const LIVE_HOSTS_API = window.SECUBOX_LIVE_HOSTS_API
|| '/api/v1/metrics/live-hosts';
const CERT_STATUS_API = window.SECUBOX_CERT_STATUS_API
|| '/api/v1/metrics/cert-status';
const LIVE_REFRESH_INTERVAL = 30000; // 30s, shared across the three
```
Section rendering uses the existing design tokens (`design-tokens.css`)
— no new colours, no new fonts. Bars use the `--gold-hermetic` accent for
VisitorOrigin, `--cyber-cyan` for LiveHosts, `--matrix-green`/`--cinnabar`
for CertStatus pass/fail icons.
## 6. Failure modes (catalogue)
| Trigger | Affected | Effect | UI |
|------------------------------------------|---------------|----------------------------------------------|--------------------------|
| `mmdb` missing | VisitorOrigin | `enabled=false` | Section hidden |
| `mmdb` lookup raises | VisitorOrigin | IP skipped, others continue | Logged |
| `nft` set missing | VisitorOrigin | `enabled=false`, logged once | Section hidden |
| `nft -j` returns malformed JSON | VisitorOrigin | Refresh skipped, prior payload kept | Logged warning |
| All ASNs below `min_count` | VisitorOrigin | `entries=[]` | Section hidden |
| HAProxy socket missing/unreadable | LiveHosts | `enabled=false`, logged once | Section hidden |
| HAProxy `show stat` parse fail | LiveHosts | Refresh skipped, prior payload kept | Logged warning |
| All frontends below 1 req in 60 min | LiveHosts | `entries=[]` | Section hidden |
| `/etc/letsencrypt/live` missing or empty | CertStatus | `enabled=false` | Section hidden |
| Cert parse error on one host | CertStatus | That host skipped, others continue | Logged |
| Endpoint 5xx | Any | Banner caches prior payload 5 min then hides | Section hidden |
| Aggregator task dies | Any | Watchdog logs, asyncio restart | Stale data until restart |
## 7. Testing strategy
### 7.1 Unit tests
`packages/secubox-metrics/tests/test_visitor_origin.py`
- `_aggregate`: synthetic IPs → expected counts.
- `_aggregate`: threshold edge — `min_count` kept, `min_count - 1` dropped.
- `_aggregate`: top-N sort, ASN-numeric tiebreak.
- `_lookup_asn`: private addresses → `None`; mmdb mock for known IPs.
- `refresh_once`: missing mmdb / empty set / subprocess raise.
- `_persist`: atomic write, `0644`, parent dir created.
`packages/secubox-metrics/tests/test_live_hosts.py`
- Ring-buffer accumulates 60 buckets, oldest evicted.
- Delta computation handles HAProxy restart (counter reset) without negatives.
- Frontend filter excludes internal names.
- Top-N + tiebreak.
- Missing socket / malformed CSV → `enabled=false` or prior payload.
`packages/secubox-metrics/tests/test_cert_status.py`
- Classifier: `now < not_valid_after - warn_days` → valid.
- Classifier: critical/expired/expiring_soon boundaries.
- Summary counts match fixtures.
- Missing live dir → `enabled=false`.
- Parse failure on one cert doesn't kill the scan.
### 7.2 Integration tests
- VisitorOrigin: populate `inet secubox_metrics seen_src` with `nft add element`
for known public IPs, run `refresh_once()` against a fixture mmdb, assert
endpoint schema.
- LiveHosts: spawn a `socat` listener mimicking the HAProxy socket reply,
drive two refreshes, assert deltas + top-N.
- CertStatus: fixture directory with hand-crafted PEMs (valid / 5d / -1d),
assert summary + `next_renewal`.
### 7.3 Manual verification
- Curl each endpoint on a live box, observe sane payloads.
- Open a public vhost in a browser, confirm all three sections render with
current data.
- Stop nft / HAProxy / move the live dir — confirm each section disappears
independently within 60 s while the others stay up.
## 8. Open questions resolved before code
1. **MaxMind license key**: operator-supplied at install. Postinst creates
`/etc/secubox/secrets/` with right perms and adds a `README.md` note
pointing at the free GeoLite2 signup. Feature ships disabled if absent —
no install breakage.
2. **nftables conflict with `secubox-firewall`**: avoided by living in a
separate table `inet secubox_metrics`, separate chain, priority `-300`.
3. **HAProxy socket reachability from `secubox-metrics`**: the `secubox`
system user (already used by the service) is added to the `haproxy` group in
postinst, matching the socket's group ownership (mode `0660`). No new
sudoers exceptions.
## 9. Acceptance criteria
- [ ] `apt install secubox-metrics` configures the nft table, deploys the
timer, joins the haproxy group, and starts the service without manual
steps.
- [ ] All three endpoints (`/visitor-origin`, `/live-hosts`, `/cert-status`)
respond 200 with documented schemas, in both enabled and disabled states.
- [ ] No raw IP appears in any cache file, the systemd journal, or any HTTP
response.
- [ ] An ASN with fewer than `min_count` distinct sources is absent from all
observable surfaces.
- [ ] Each banner section appears within 90 s of first applicable data and
disappears within 60 s of its data source being removed.
- [ ] Unit test suite passes at ≥ 80 % coverage for the three aggregator
modules.
- [ ] Integration tests pass against fixtures (mmdb, mock HAProxy socket,
fixture cert dir).
- [ ] No regression on the existing `/api/v1/metrics/health/summary` payload
or the banner's SSL section.
## 10. Out of scope (candidate follow-ups)
- IPv6 source coverage in VisitorOrigin (`type ipv6_addr` set + mmdb v6).
- Per-vhost rollup of visitor ASNs (admin-only).
- 24 h trend sparklines for any section.
- Operator-configurable port list for the ingress_tap chain.
- ASN reputation hint (flag known scraper ASNs).
- Manual cert renewal trigger UI.
- CertStatus failed-renewal log scanner (if it complicates v1).

View File

@ -0,0 +1,311 @@
---
title: WebUI Obfuscation — `admin.<HOSTNAME>.secubox.in` Only
issue: 44
date: 2026-05-12
status: design
---
## Context
GitHub issue: [#44 — WebUI Obfuscation - admin.HOSTNAME.secubox.in Only](https://github.com/CyberMind-FR/secubox-deb/issues/44).
The SecuBox WebUI currently answers on a permissive set of `server_name` values (`localhost`, `secubox.local`, `192.168.255.1`, `admin.gk2.secubox.in`, `gk2.secubox.in`, `secubox.maegia.tv`, `c3box.maegia.tv`). For ANSSI CSPN compliance, the WebUI must be served **only** on a single, well-defined canonical host: `admin.<HOSTNAME>.<DOMAIN_SUFFIX>` where `HOSTNAME` and `DOMAIN_SUFFIX` are board-identity values.
This design hardens HAProxy + nginx to enforce that constraint, while keeping LAN-direct emergency access on port `9443` untouched.
## Goals
1. **Single canonical URL** — only `https://admin.<HOSTNAME>.<DOMAIN_SUFFIX>/` returns the WebUI from the HAProxy-fronted path (port 443).
2. **Single source of truth**`/etc/default/secubox` defines `SECUBOX_HOSTNAME` and `SECUBOX_DOMAIN_SUFFIX`. Both HAProxy and nginx derive their regex from this file.
3. **Defense in depth** — strict regex at HAProxy (first filter) AND at nginx (second filter). No reliance on a single point of enforcement.
4. **Idempotent rollout** — render scripts safe to run repeatedly; nginx and HAProxy validate before reload, rollback on failure.
5. **LAN escape hatch preserved**`https://192.168.1.200:9443/` continues to serve the WebUI for emergency access without going through the obfuscation layer.
## Non-goals
- Changing the `:9443` LAN-direct listener (out of scope; remains permissive).
- Reworking the existing `mitmproxy_inspector` routing for non-WebUI vhosts (metablog, streamlit).
- Re-issuing TLS certificates for the new canonical name (`admin.gk2.secubox.in` already has a valid cert).
- Auto-detecting `SECUBOX_HOSTNAME` from network identity beyond a one-shot postinst fallback (`hostname -s` minus the `secubox-` prefix).
## Architecture
```text
┌──────────────────────────────────────────────────────────────────┐
│ /etc/default/secubox ← single source of truth │
│ SECUBOX_HOSTNAME=gk2 │
│ SECUBOX_DOMAIN_SUFFIX=secubox.in │
└──────────────────────────────────────────────────────────────────┘
│ read at API startup
┌──────────────────────────────────────────────────────────────────┐
│ secubox-haproxy FastAPI (existing service, new endpoints) │
│ GET /api/v1/haproxy/webui/admin-domain │
│ → {hostname, domain_suffix, admin_domain, regex} │
│ GET /api/v1/haproxy/webui/nginx-config │
│ → text/plain (rendered nginx vhost) │
│ POST /api/v1/haproxy/webui/refresh │
│ → invalidate cached identity (after /etc/default edit) │
└──────────────────────────────────────────────────────────────────┘
│ │
│ called by haproxyctl │ called by render script
▼ ▼
┌────────────────────────────────┐ ┌────────────────────────────┐
│ /etc/haproxy/haproxy.cfg │ │ /etc/nginx/sites-available │
│ acl is_webui_admin │ │ /secubox-local │
│ hdr(host) -m reg │ │ server_name │
│ ^admin\.gk2\.secubox\.in$ │ │ ~^admin\.gk2\.secubox │
│ use_backend webui_direct │ │ \.in$; │
│ if is_webui_admin │ │ │
└────────────────────────────────┘ └────────────────────────────┘
```
All three strict checks must agree on the same regex. The API is the canonical computation point. `haproxyctl` and `secubox-render-nginx-webui` consume it. The API itself reads `/etc/default/secubox` directly.
## Components
### 1. New package `secubox-defaults`
A minimal Debian package whose only job is to ship the source-of-truth env file.
```text
packages/secubox-defaults/
├── debian/
│ ├── control # Source + Binary, Architecture: all, no deps
│ ├── rules # dh $@
│ ├── compat # 13
│ ├── changelog
│ ├── copyright
│ ├── install # etc/default/secubox → /etc/default/secubox
│ ├── secubox-defaults.postinst
│ └── secubox-defaults.conffiles # /etc/default/secubox (preserve on upgrade)
├── etc/default/secubox # template
└── README.md
```
**`/etc/default/secubox` content (template):**
```sh
# SecuBox board identity — single source of truth.
# Read by secubox-haproxy FastAPI at startup and by render scripts.
# After editing, run:
# curl -fsS -X POST --unix-socket /run/secubox/haproxy.sock \
# http://localhost/webui/refresh
# /usr/local/bin/secubox-render-nginx-webui
# /usr/local/bin/secubox-haproxy-regen-safe
SECUBOX_HOSTNAME="gk2"
SECUBOX_DOMAIN_SUFFIX="secubox.in"
```
**Postinst behavior** (idempotent):
- If `/etc/default/secubox` is missing (purge + reinstall case), copy the template.
- If `SECUBOX_HOSTNAME` is unset or empty, attempt autodetect: `hostname -s` stripped of any `secubox-` prefix. If still empty, leave unset and emit a warning (admin must edit).
- Trigger downstream regen via `dpkg-trigger secubox-haproxy-refresh` (other packages declare `interest secubox-haproxy-refresh` in their `triggers` files).
**Dependencies:** none. `secubox-haproxy`, `secubox-hub`, `secubox-core` add `Depends: secubox-defaults` going forward.
### 2. `secubox-haproxy` API extension
New helper module + three endpoints in `packages/secubox-haproxy/api/`.
**`packages/secubox-haproxy/api/webui_identity.py`** — parse and cache the defaults file:
```python
import re, shlex
from pathlib import Path
from functools import lru_cache
DEFAULTS_FILE = Path("/etc/default/secubox")
@lru_cache(maxsize=1)
def _parse_defaults() -> dict:
out = {}
if DEFAULTS_FILE.exists():
for line in DEFAULTS_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip()] = shlex.split(v)[0] if v else ""
return out
def get_identity() -> dict:
cfg = _parse_defaults()
host = cfg.get("SECUBOX_HOSTNAME", "")
suffix = cfg.get("SECUBOX_DOMAIN_SUFFIX", "secubox.in")
if not host:
raise ValueError("SECUBOX_HOSTNAME not set in /etc/default/secubox")
admin = f"admin.{host}.{suffix}"
regex = "^" + re.escape(admin) + "$"
return {"hostname": host, "domain_suffix": suffix,
"admin_domain": admin, "regex": regex}
def invalidate_cache() -> None:
_parse_defaults.cache_clear()
```
**Endpoints** added to the existing FastAPI app in `packages/secubox-haproxy/api/main.py`:
| Method | Path | Auth | Body / Response |
| --- | --- | --- | --- |
| GET | `/webui/admin-domain` | none (info, not secret) | JSON `{hostname, domain_suffix, admin_domain, regex}` |
| GET | `/webui/nginx-config` | `Depends(require_jwt)` | `text/plain` — rendered nginx vhost |
| POST | `/webui/refresh` | `Depends(require_jwt)` | invalidate LRU cache; returns 204 |
If `get_identity()` raises (HOSTNAME unset), endpoints return `503` with `{"error": "SECUBOX_HOSTNAME not configured"}`.
**Rendered nginx vhost template (returned by `/webui/nginx-config`):**
```nginx
# SecuBox WebUI — strict-regex obfuscation (issue #44)
# Generated by /api/v1/haproxy/webui/nginx-config — do not edit by hand.
server {
listen 0.0.0.0:9080;
server_name ~^admin\.{HOSTNAME_ESCAPED}\.{SUFFIX_ESCAPED}$;
root /usr/share/secubox/www;
index index.html;
location / { try_files $uri $uri/ /index.html; }
include /etc/nginx/secubox.d/*.conf;
include /etc/nginx/snippets/api-error.conf;
location /health {
return 200 '{"status":"ok"}';
add_header Content-Type application/json;
}
}
```
`{HOSTNAME_ESCAPED}` and `{SUFFIX_ESCAPED}` are the values with literal dots escaped (nginx regex syntax).
### 3. nginx renderer script
New file: `packages/secubox-haproxy/sbin/secubox-render-nginx-webui` (installs to `/usr/local/bin/`).
Snapshot → fetch from API → atomic stage → `nginx -t` → reload, with rollback at any failure point.
```bash
#!/bin/bash
# secubox-render-nginx-webui — render strict-regex WebUI vhost from API
set -euo pipefail
TARGET=/etc/nginx/sites-available/secubox-local
TMP=$(mktemp /tmp/secubox-local.XXXXXX)
SNAP="$TARGET.bak.$(date +%s)"
SOCK=/run/secubox/haproxy.sock
log() { echo "[$(date '+%F %T')] $*" >&2; }
[[ -S "$SOCK" ]] || { log "FATAL: $SOCK missing — is secubox-haproxy.service running?"; exit 2; }
log "Fetch /webui/nginx-config from API"
if ! curl -sf --unix-socket "$SOCK" http://localhost/webui/nginx-config -o "$TMP"; then
log "API call failed"
rm -f "$TMP"; exit 3
fi
[[ -s "$TMP" ]] || { log "API returned empty body"; rm -f "$TMP"; exit 3; }
log "Snapshot $TARGET → $SNAP"
[[ -f "$TARGET" ]] && cp -p "$TARGET" "$SNAP"
mv "$TMP" "$TARGET"
if ! nginx -t 2>&1 | grep -q "syntax is ok"; then
log "nginx -t FAILED — rolling back"
[[ -f "$SNAP" ]] && cp -p "$SNAP" "$TARGET"
exit 5
fi
log "Reload nginx"
systemctl reload nginx && log "render OK (snapshot kept: $SNAP)"
```
**Triggers:**
- Postinst of `secubox-defaults`: best-effort — calls the renderer and treats exit code 2 (API socket missing) as non-fatal, since on first install `secubox-haproxy.service` may not be up yet. Other exit codes propagate.
- Postinst of `secubox-haproxy`: always renders to ensure sync; exit code is fatal here (service is starting in this transaction).
- Manual: admin after editing `/etc/default/secubox`.
- No timer: HOSTNAME changes are rare and the renderer is invoked explicitly.
### 4. `haproxyctl` regex injection
Modify `packages/secubox-haproxy/sbin/haproxyctl` `generate` command to:
1. Fetch the regex via API (with fallback to direct read of `/etc/default/secubox` if API socket unreachable).
2. Inject the strict-regex ACL/use_backend pair at the **top** of both `frontend http-in` and `frontend https-in` rule blocks.
3. Skip the original `admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}` per-vhost ACL inside the existing loop (avoid double-match).
Inserted snippet (after `mode http` of each frontend):
```haproxy
# WebUI Obfuscation (issue #44) — strict regex from /etc/default/secubox
acl is_webui_admin hdr(host) -m reg <REGEX_FROM_API>
use_backend webui_direct if is_webui_admin
```
**Symmetry:** apply the same logic in `packages/secubox-haproxy/api/main.py` `generate_config()` (Python branch) so `POST /api/v1/haproxy/generate` produces a coherent cfg.
**Why `webui_direct` not `mitmproxy_inspector`:**
- Issue #44 explicitly proposes `webui_direct`.
- `webui_direct``server webui 127.0.0.1:9080 check` — direct nginx, skips mitmproxy WAF.
- WebUI admin is protected by JWT on `/api/v1/*` endpoints; the WAF bypass is acceptable because the surface is bounded by the strict regex.
- Avoids a routing loop (mitmproxy → 9080 → mitmproxy).
### 5. Tests
**Unit tests** (pytest, run in CI):
| File | Coverage |
| --- | --- |
| `packages/secubox-haproxy/tests/test_webui_identity.py` | parse empty file; missing HOSTNAME → ValueError; custom suffix; regex escapes literal dots; cache invalidation works |
| `packages/secubox-haproxy/tests/test_webui_endpoints.py` | `/webui/admin-domain` returns expected JSON shape; `/webui/nginx-config` 401 without JWT, 200 + text/plain with JWT; `/webui/refresh` invalidates and returns 204 |
| `packages/secubox-defaults/tests/test_postinst.sh` | postinst detects hostname when SECUBOX_HOSTNAME empty; preserves admin-set value |
**Integration test** (`tests/integration/test_44_webui_obfuscation.sh`) run on board or VM:
```text
1. Snapshot haproxy.cfg + secubox-local
2. Set SECUBOX_HOSTNAME=gk2
3. /usr/local/bin/secubox-render-nginx-webui
4. /usr/local/bin/secubox-haproxy-regen-safe --no-reload
5. nginx -t && haproxy -c -f /etc/haproxy/haproxy.cfg
6. systemctl reload nginx haproxy
7. Probe positive: curl -ski https://admin.gk2.secubox.in/ → 200 + "SecuBox Control Center"
8. Probe negative: curl -ski -H "Host: gk2.secubox.in" https://192.168.1.200/ → NOT the WebUI
9. Probe LAN direct: curl -ski https://192.168.1.200:9443/ → 200 + WebUI (unchanged path)
10. Probe random admin.*: curl -ski -H "Host: admin.fake.secubox.in" https://192.168.1.200/ → reject (regex strict)
11. Regression spot-check: cpf.gk2, arm.gk2, lldh.ganimed.fr, pub.gk2, werdl.gk2, 3d.gk2 → all still HTTP 200 with expected titles
12. Restore snapshots if any probe failed
```
### 6. Rollback plan
Layered by cost / reversibility:
| Layer | Mechanism | Trigger |
| --- | --- | --- |
| `haproxy.cfg` | `secubox-haproxy-regen-safe` validates + auto-restores | regen validation fails |
| `secubox-local` nginx | `secubox-render-nginx-webui` keeps `.bak.<ts>` | `nginx -t` fails |
| `/etc/default/secubox` | `dpkg --purge secubox-defaults` reverts to legacy permissive vhost | full rollback request |
| Packages | `dpkg -i <previous_version>.deb` from APT repo | major regression |
## Acceptance criteria
1. `https://admin.gk2.secubox.in/` → HTTP 200, body contains `SecuBox Control Center`.
2. `https://gk2.secubox.in/` (no `admin.` prefix) → does NOT serve the WebUI (HAProxy + nginx both refuse).
3. `https://192.168.1.200:9443/` → still HTTP 200 with WebUI markup (LAN escape hatch intact).
4. `haproxy -c -f /etc/haproxy/haproxy.cfg` → exit 0 (config valid).
5. `nginx -t``syntax is ok`.
6. All sites verified in Sessions 153156 (cpf, arm, zkp, lldh, pub, werdl, 3d, 42, c3box, gandalf, live) remain HTTP 200 with expected titles.
7. pytest unit tests pass.
8. `systemctl restart secubox-haproxy.service` does not corrupt `haproxy.cfg` (uses the patched generators).
## Residual risks
- **Drift if admin edits `/etc/default/secubox` without re-rendering.** Mitigation: README and the comment block inside the defaults file both prescribe the refresh + render commands. Long-term, a systemd path-unit could watch the file and trigger rerender automatically (out of scope here).
- **`haproxyctl generate` still has the pre-existing TOML cert path bug** (`/srv/haproxy/certs/` vs real `/data/haproxy/certs/`) — Session 156 noted this. `secubox-haproxy-regen-safe` catches it via `haproxy -c -f` and rolls back, but the cert path mismatch should be fixed in the TOML before we expect `regen-safe` to succeed end-to-end. Tracked as a follow-up out of this issue.
- **Order-sensitive HAProxy ACL placement.** The strict-regex ACL must be emitted **before** any per-vhost ACL that could match the same hostname. The generator-side change guarantees this by emitting the regex block immediately after `mode http` of each frontend, but a future generator refactor must not break that ordering.
## Out of scope (follow-up issues)
- Fix `/etc/secubox/haproxy.toml` cert path (`/srv/haproxy/certs` → `/data/haproxy/certs`).
- systemd path-unit auto-rerender on `/etc/default/secubox` change.
- Extend WebUI dashboard to display the configured `SECUBOX_HOSTNAME` and the resulting canonical URL.

View File

@ -0,0 +1,21 @@
# secubox-defaults
Ships `/etc/default/secubox`, the single source of truth for the SecuBox
board identity:
- `SECUBOX_HOSTNAME` — short board name (e.g. `gk2`).
- `SECUBOX_DOMAIN_SUFFIX` — domain root (e.g. `secubox.in`).
Composed canonical admin URL: `https://admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}/`.
Consumers (`secubox-haproxy` API, render scripts, …) read this file at startup
and refresh on `dpkg-trigger secubox-defaults-changed`.
After hand-editing `/etc/default/secubox`:
```bash
curl -fsS -X POST --unix-socket /run/secubox/haproxy.sock \
http://localhost/webui/refresh
/usr/local/bin/secubox-render-nginx-webui
/usr/local/bin/secubox-haproxy-regen-safe
```

View File

@ -0,0 +1,6 @@
secubox-defaults (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release: ship /etc/default/secubox with SECUBOX_HOSTNAME and
SECUBOX_DOMAIN_SUFFIX. Addresses CyberMind-FR/secubox-deb#44.
-- Gerald KERMA <devel@cybermind.fr> Tue, 12 May 2026 12:00:00 +0200

View File

@ -0,0 +1 @@
13

View File

@ -0,0 +1 @@
/etc/default/secubox

View File

@ -0,0 +1,15 @@
Source: secubox-defaults
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://secubox.in
Package: secubox-defaults
Architecture: all
Depends: ${misc:Depends}
Description: SecuBox board identity defaults
Ships /etc/default/secubox, the single source of truth for SECUBOX_HOSTNAME
and SECUBOX_DOMAIN_SUFFIX. Other SecuBox packages depend on this for the
canonical admin URL pattern admin.<HOSTNAME>.<SUFFIX>.

View File

@ -0,0 +1,8 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://github.com/CyberMind-FR/secubox-deb
Upstream-Name: secubox-defaults
Files: *
Copyright: 2026 CyberMind / Gerald KERMA <devel@cybermind.fr>
License: Proprietary
SecuBox-Deb proprietary licence. ANSSI CSPN candidate.

View File

@ -0,0 +1 @@
etc/default/secubox etc/default

View File

@ -0,0 +1,37 @@
#!/bin/sh
# secubox-defaults postinst: autodetect SECUBOX_HOSTNAME if empty,
# then activate the secubox-defaults-changed trigger so consumers refresh.
set -e
DEFAULTS=/etc/default/secubox
case "$1" in
configure)
if [ ! -f "$DEFAULTS" ]; then
echo "secubox-defaults: $DEFAULTS missing — package files not installed?" >&2
exit 1
fi
# Source the existing values
# shellcheck disable=SC1090
. "$DEFAULTS" 2>/dev/null || true
if [ -z "${SECUBOX_HOSTNAME:-}" ]; then
# Autodetect: hostname -s minus any 'secubox-' prefix
DETECTED=$(hostname -s 2>/dev/null | sed 's/^secubox-//')
if [ -n "$DETECTED" ]; then
echo "secubox-defaults: setting SECUBOX_HOSTNAME=$DETECTED (autodetected)"
sed -i "s/^SECUBOX_HOSTNAME=.*/SECUBOX_HOSTNAME=\"$DETECTED\"/" "$DEFAULTS"
else
echo "secubox-defaults: WARNING — SECUBOX_HOSTNAME unset and could not autodetect" >&2
fi
fi
# Activate the trigger so other packages (secubox-haproxy etc.) refresh
dpkg-trigger secubox-defaults-changed || true
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,3 @@
#!/usr/bin/make -f
%:
dh $@

View File

@ -0,0 +1 @@
activate-noawait secubox-defaults-changed

View File

@ -0,0 +1,14 @@
# SecuBox board identity — single source of truth.
# Read by secubox-haproxy FastAPI at startup and by render scripts.
#
# Composed canonical admin URL:
# https://admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}/
#
# After editing, run:
# curl -fsS -X POST --unix-socket /run/secubox/haproxy.sock \
# http://localhost/webui/refresh
# /usr/local/bin/secubox-render-nginx-webui
# /usr/local/bin/secubox-haproxy-regen-safe
SECUBOX_HOSTNAME="gk2"
SECUBOX_DOMAIN_SUFFIX="secubox.in"

View File

@ -31,10 +31,12 @@ from datetime import datetime, timedelta
from enum import Enum
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query, BackgroundTasks
from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel, Field
from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.config import get_config
from secubox_core.logger import get_logger
from api import webui_identity as _webui_identity
app = FastAPI(title="secubox-haproxy", version="2.0.0", root_path="/api/v1/haproxy")
@ -1529,14 +1531,38 @@ async def generate_config():
" mode http",
])
# WebUI Obfuscation (issue #44) — strict regex ACL at top of frontend
try:
_ident = _webui_identity.get_identity()
config_lines.extend([
" # WebUI Obfuscation (issue #44)",
f" acl is_webui_admin hdr(host) -m reg {_ident['regex']}",
" use_backend webui_direct if is_webui_admin",
])
except ValueError:
# SECUBOX_HOSTNAME not set — skip the strict ACL (legacy behaviour)
_ident = None
# ACLs for vhosts
for vh in vhosts:
if vh.get("enabled"):
try:
_admin = _webui_identity.get_identity()["admin_domain"]
if vh.get("domain") == _admin:
continue
except ValueError:
pass
config_lines.append(f" acl host_{vh['name']} hdr(host) -i {vh['domain']}")
# Use backend rules (through WAF if enabled)
for vh in vhosts:
if vh.get("enabled"):
try:
_admin = _webui_identity.get_identity()["admin_domain"]
if vh.get("domain") == _admin:
continue
except ValueError:
pass
if cfg["waf_enabled"] and not vh.get("waf_bypass"):
config_lines.append(f" use_backend mitmproxy_inspector if host_{vh['name']}")
else:
@ -1554,12 +1580,35 @@ async def generate_config():
" mode http",
])
# WebUI Obfuscation (issue #44) — strict regex ACL at top of frontend
try:
_ident = _webui_identity.get_identity()
config_lines.extend([
" # WebUI Obfuscation (issue #44)",
f" acl is_webui_admin hdr(host) -m reg {_ident['regex']}",
" use_backend webui_direct if is_webui_admin",
])
except ValueError:
_ident = None
for vh in vhosts:
if vh.get("enabled") and vh.get("ssl"):
try:
_admin = _webui_identity.get_identity()["admin_domain"]
if vh.get("domain") == _admin:
continue
except ValueError:
pass
config_lines.append(f" acl host_{vh['name']} hdr(host) -i {vh['domain']}")
for vh in vhosts:
if vh.get("enabled") and vh.get("ssl"):
try:
_admin = _webui_identity.get_identity()["admin_domain"]
if vh.get("domain") == _admin:
continue
except ValueError:
pass
if cfg["waf_enabled"] and not vh.get("waf_bypass"):
config_lines.append(f" use_backend mitmproxy_inspector if host_{vh['name']}")
else:
@ -1583,6 +1632,18 @@ async def generate_config():
"",
])
# WebUI direct backend (issue #44 — only emitted when strict ACL is in use)
try:
_webui_identity.get_identity()
config_lines.extend([
"",
"backend webui_direct",
" mode http",
" server srv0 127.0.0.1:9080 check",
])
except ValueError:
pass
# User backends
for be in backends:
config_lines.extend([
@ -2167,4 +2228,66 @@ async def migrate(req: MigrateRequest):
return {"success": False, "error": result.stderr or "Migration failed"}
# ══════════════════════════════════════════════════════════════════
# WebUI Identity Endpoints (issue #44 — admin.<HOSTNAME>.<SUFFIX> only)
# ══════════════════════════════════════════════════════════════════
@app.get("/webui/admin-domain")
async def webui_admin_domain():
"""Return the canonical admin URL identity for this board.
Reads /etc/default/secubox. No auth required (info is not secret).
"""
try:
return _webui_identity.get_identity()
except ValueError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
def _render_nginx_vhost(ident: dict) -> str:
"""Render the secubox-local nginx vhost with strict regex server_name."""
host_esc = ident["hostname"].replace(".", r"\.")
suffix_esc = ident["domain_suffix"].replace(".", r"\.")
regex = rf"~^admin\.{host_esc}\.{suffix_esc}$"
return (
"# SecuBox WebUI — strict-regex obfuscation (issue #44)\n"
"# Generated by /api/v1/haproxy/webui/nginx-config — do not edit by hand.\n"
"server {\n"
" listen 0.0.0.0:9080;\n"
f" server_name {regex};\n"
" root /usr/share/secubox/www;\n"
" index index.html;\n"
" location / { try_files $uri $uri/ /index.html; }\n"
" include /etc/nginx/secubox.d/*.conf;\n"
" include /etc/nginx/snippets/api-error.conf;\n"
" location /health {\n"
" return 200 '{\"status\":\"ok\"}';\n"
" add_header Content-Type application/json;\n"
" }\n"
"}\n"
)
@app.get("/webui/nginx-config", response_class=PlainTextResponse)
async def webui_nginx_config():
"""Return the rendered nginx vhost for the WebUI (text/plain).
Public content is fully derivable from the public /webui/admin-domain.
Unix socket access is root-only (postinst trigger context).
"""
try:
ident = _webui_identity.get_identity()
except ValueError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return _render_nginx_vhost(ident)
@app.post("/webui/refresh", status_code=204,
dependencies=[Depends(require_jwt)])
async def webui_refresh():
"""Invalidate the cached identity. Call after editing /etc/default/secubox."""
_webui_identity.invalidate_cache()
return Response(status_code=204)
app.include_router(router)

View File

@ -0,0 +1,72 @@
"""
SecuBox-Deb :: webui_identity
CyberMind https://cybermind.fr
Author: Gerald KERMA <devel@cybermind.fr>
License: Proprietary / ANSSI CSPN candidate
Parses /etc/default/secubox and exposes the canonical admin URL + regex.
"""
import logging
import re
import shlex
from pathlib import Path
from functools import lru_cache
logger = logging.getLogger(__name__)
DEFAULTS_FILE = Path("/etc/default/secubox")
@lru_cache(maxsize=1)
def _parse_defaults() -> dict:
"""Parse /etc/default/secubox into a dict of KEY=value pairs.
Returns an empty dict on any read failure (caller surfaces the
missing-HOSTNAME case as ValueError via get_identity()).
"""
out: dict = {}
if not DEFAULTS_FILE.exists():
return out
try:
content = DEFAULTS_FILE.read_text()
except OSError as e:
logger.warning("cannot read %s: %s", DEFAULTS_FILE, e)
return out
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
# shlex.split handles quoted values; unquoted multi-word
# values keep only the first token (by design).
parts = shlex.split(v) if v else []
out[k.strip()] = parts[0] if parts else ""
return out
def get_identity() -> dict:
"""Return canonical board identity.
Raises:
ValueError: if SECUBOX_HOSTNAME is not set.
"""
cfg = _parse_defaults()
host = cfg.get("SECUBOX_HOSTNAME", "")
suffix = cfg.get("SECUBOX_DOMAIN_SUFFIX", "secubox.in")
if not host:
raise ValueError(
"SECUBOX_HOSTNAME not set in /etc/default/secubox"
)
admin = f"admin.{host}.{suffix}"
regex = "^" + re.escape(admin) + "$"
return {
"hostname": host,
"domain_suffix": suffix,
"admin_domain": admin,
"regex": regex,
}
def invalidate_cache() -> None:
"""Drop the LRU cache so the next get_identity() re-reads the file."""
_parse_defaults.cache_clear()

View File

@ -7,7 +7,7 @@ Standards-Version: 4.6.2
Package: secubox-haproxy
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, python3-httpx | python3-pip
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, python3-httpx | python3-pip, secubox-defaults
Recommends: haproxy, secubox-waf
Description: SecuBox HAProxy Dashboard
Dashboard HAProxy avec gestion des vhosts et backends.

View File

@ -12,5 +12,25 @@ case "$1" in
systemctl enable secubox-haproxy.service
systemctl start secubox-haproxy.service || true
;;
triggered)
for trig in $2; do
case "$trig" in
secubox-defaults-changed)
echo "secubox-haproxy: refreshing for $trig"
# Best-effort refresh + render
curl -fsS -X POST --unix-socket /run/secubox/haproxy.sock \
http://localhost/webui/refresh 2>/dev/null || true
if [ -x /usr/local/bin/secubox-render-nginx-webui ]; then
/usr/local/bin/secubox-render-nginx-webui || \
echo "secubox-haproxy: render failed (non-fatal)" >&2
fi
if [ -x /usr/local/bin/secubox-haproxy-regen-safe ]; then
/usr/local/bin/secubox-haproxy-regen-safe || \
echo "secubox-haproxy: regen-safe failed (non-fatal)" >&2
fi
;;
esac
done
;;
esac
#DEBHELPER#

View File

@ -12,6 +12,14 @@ override_dh_auto_install:
# Modular nginx config
install -d debian/secubox-haproxy/etc/nginx/secubox.d
[ -f nginx/haproxy.conf ] && cp nginx/haproxy.conf debian/secubox-haproxy/etc/nginx/secubox.d/ || true
# Install control scripts
# Install control scripts (system-wide admin tools)
install -d debian/secubox-haproxy/usr/sbin
[ -d sbin ] && install -m 755 sbin/* debian/secubox-haproxy/usr/sbin/ || true
[ -d sbin ] && find sbin -maxdepth 1 -type f ! -name 'secubox-render-nginx-webui' \
! -name 'secubox-haproxy-regen-safe' \
-exec install -m 755 {} debian/secubox-haproxy/usr/sbin/ \; || true
# Install local admin tools (issue #44 — kept in /usr/local/bin for admin-only invocation)
install -d debian/secubox-haproxy/usr/local/bin
[ -f sbin/secubox-render-nginx-webui ] && \
install -m 755 sbin/secubox-render-nginx-webui debian/secubox-haproxy/usr/local/bin/ || true
[ -f sbin/secubox-haproxy-regen-safe ] && \
install -m 755 sbin/secubox-haproxy-regen-safe debian/secubox-haproxy/usr/local/bin/ || true

View File

@ -0,0 +1 @@
interest-noawait secubox-defaults-changed

View File

@ -31,6 +31,32 @@ load_config() {
fi
}
# Fetch the strict-regex pattern from the secubox-haproxy API.
# Fallback: read /etc/default/secubox directly if the API socket is unreachable.
_fetch_webui_regex() {
local sock=/run/secubox/haproxy.sock
local regex
if [[ -S "$sock" ]] && \
regex=$(curl -sf --max-time 2 --unix-socket "$sock" \
http://localhost/webui/admin-domain 2>/dev/null \
| jq -r '.regex // empty' 2>/dev/null) && \
[[ -n "$regex" ]]; then
echo "$regex"; return 0
fi
if [[ -f /etc/default/secubox ]]; then
# shellcheck source=/dev/null
. /etc/default/secubox
if [[ -n "${SECUBOX_HOSTNAME:-}" ]]; then
local suffix="${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
local admin="admin.${SECUBOX_HOSTNAME}.${suffix}"
# Escape literal dots for HAProxy regex
echo "^${admin//./\\.}\$"
return 0
fi
fi
return 1
}
# Check container status
lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; }
lxc_exists() { [ -d "$LXC_PATH/rootfs" ]; }
@ -591,14 +617,33 @@ frontend http-in
mode http
EOF
if webui_regex=$(_fetch_webui_regex); then
cat >> "$CONFIG_DIR/haproxy.cfg" << EOF
# WebUI Obfuscation (issue #44) — strict regex from /etc/default/secubox
acl is_webui_admin hdr(host) -m reg $webui_regex
use_backend webui_direct if is_webui_admin
EOF
fi
# Add ACLs and use_backend rules for vhosts
if [ -f "$CONF_PATH" ]; then
# Hoisted: source /etc/default/secubox once before the http-in loop
_admin_skip_domain=""
if [ -f /etc/default/secubox ]; then
# shellcheck source=/dev/null
. /etc/default/secubox
if [ -n "${SECUBOX_HOSTNAME:-}" ]; then
_admin_skip_domain="admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
fi
fi
grep '^\[vhosts\.' "$CONF_PATH" | while read -r line; do
local name=$(echo "$line" | sed 's/\[vhosts\.//;s/\]//')
local section=$(sed -n "/^\[vhosts\.$name\]/,/^\[/p" "$CONF_PATH" | head -n -1)
local domain=$(echo "$section" | grep -E '^domain\s*=' | cut -d'"' -f2)
local backend=$(echo "$section" | grep -E '^backend\s*=' | cut -d'"' -f2)
local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "0" || echo "1")
# WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
[ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ] && continue
[ "$enabled" = "1" ] && [ -n "$domain" ] && {
echo " acl host_$name hdr(host) -i $domain"
@ -619,8 +664,25 @@ frontend https-in
mode http
EOF
if webui_regex=$(_fetch_webui_regex); then
cat >> "$CONFIG_DIR/haproxy.cfg" << EOF
# WebUI Obfuscation (issue #44) — strict regex from /etc/default/secubox
acl is_webui_admin hdr(host) -m reg $webui_regex
use_backend webui_direct if is_webui_admin
EOF
fi
# HTTPS vhost rules (same pattern)
if [ -f "$CONF_PATH" ]; then
# Hoisted: source /etc/default/secubox once before the https-in loop
_admin_skip_domain=""
if [ -f /etc/default/secubox ]; then
# shellcheck source=/dev/null
. /etc/default/secubox
if [ -n "${SECUBOX_HOSTNAME:-}" ]; then
_admin_skip_domain="admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
fi
fi
grep '^\[vhosts\.' "$CONF_PATH" | while read -r line; do
local name=$(echo "$line" | sed 's/\[vhosts\.//;s/\]//')
local section=$(sed -n "/^\[vhosts\.$name\]/,/^\[/p" "$CONF_PATH" | head -n -1)
@ -628,6 +690,8 @@ EOF
local backend=$(echo "$section" | grep -E '^backend\s*=' | cut -d'"' -f2)
local ssl=$(echo "$section" | grep -E '^ssl\s*=' | grep -q 'true' && echo "1" || echo "0")
local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "0" || echo "1")
# WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
[ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ] && continue
[ "$enabled" = "1" ] && [ "$ssl" = "1" ] && [ -n "$domain" ] && {
echo " acl host_$name hdr(host) -i $domain"
@ -657,6 +721,16 @@ backend mitmproxy_inspector
EOF
# WebUI direct backend (issue #44 — for the strict-regex ACL above)
# Only emit if SECUBOX_HOSTNAME is set (i.e., the strict ACL was injected).
if _fetch_webui_regex >/dev/null 2>&1; then
cat >> "$CONFIG_DIR/haproxy.cfg" << 'EOF'
backend webui_direct
mode http
server srv0 127.0.0.1:9080 check
EOF
fi
# User backends
if [ -f "$CONF_PATH" ]; then
grep '^\[backends\.' "$CONF_PATH" | while read -r line; do

View File

@ -0,0 +1,58 @@
#!/bin/bash
# secubox-haproxy-regen-safe — Safe haproxy.cfg regeneration with validate + 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)
#
# CyberMind — 2026-05-12 (post Session 156 regression).
set -euo pipefail
CFG=/etc/haproxy/haproxy.cfg
TS=$(date +%s)
SNAP="$CFG.bak.$TS-pre-regen"
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; }
log "Snapshot $CFG → $SNAP"
cp -p "$CFG" "$SNAP"
log "Run: haproxyctl generate"
if ! /usr/sbin/haproxyctl generate; then
log "haproxyctl generate failed; restoring snapshot"
cp -p "$SNAP" "$CFG"
exit 3
fi
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"
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

@ -0,0 +1,45 @@
#!/bin/bash
# secubox-render-nginx-webui — render strict-regex WebUI vhost from API
#
# Flow: snapshot → fetch from API → atomic stage → nginx -t → reload,
# with rollback at any failure point.
#
# Issue #44: WebUI Obfuscation
set -euo pipefail
TARGET=/etc/nginx/sites-available/secubox-local
TMP=$(mktemp /tmp/secubox-local.XXXXXX)
SNAP="$TARGET.bak.$(date +%s)"
SOCK=/run/secubox/haproxy.sock
log() { echo "[$(date '+%F %T')] $*" >&2; }
for cmd in curl nginx systemctl; do
command -v "$cmd" >/dev/null 2>&1 || { log "FATAL: required command not found: $cmd"; exit 1; }
done
[[ -S "$SOCK" ]] || { log "FATAL: $SOCK missing — is secubox-haproxy.service running?"; exit 2; }
log "Fetch /webui/nginx-config from API"
if ! curl -sf --unix-socket "$SOCK" http://localhost/webui/nginx-config -o "$TMP"; then
log "API call failed"
rm -f "$TMP"; exit 3
fi
[[ -s "$TMP" ]] || { log "API returned empty body"; rm -f "$TMP"; exit 3; }
log "Snapshot $TARGET → $SNAP"
[[ -f "$TARGET" ]] && cp -p "$TARGET" "$SNAP"
mv "$TMP" "$TARGET"
_nginx_t_out=$(mktemp /tmp/nginx-t.XXXXXX)
if ! nginx -t >"$_nginx_t_out" 2>&1; then
log "nginx -t FAILED — rolling back"
cat "$_nginx_t_out" >&2
rm -f "$_nginx_t_out"
[[ -f "$SNAP" ]] && cp -p "$SNAP" "$TARGET"
exit 5
fi
rm -f "$_nginx_t_out"
log "Reload nginx"
systemctl reload nginx && log "render OK (snapshot kept: $SNAP)"

View File

@ -0,0 +1,10 @@
"""Add the package's api/ directory and common/ to sys.path for tests."""
import sys
from pathlib import Path
# Add the secubox-haproxy package root
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# Add the common/ directory which contains secubox_core
repo_root = Path(__file__).resolve().parents[3] # Go up from tests/ -> .. -> .. -> ..
sys.path.insert(0, str(repo_root / "common"))

View File

@ -0,0 +1,127 @@
"""
SecuBox-Deb :: webui endpoints tests
"""
import textwrap
import pytest
from fastapi.testclient import TestClient
from api import main as api_main
from api import webui_identity as wi
@pytest.fixture
def client(tmp_path, monkeypatch):
p = tmp_path / "secubox"
p.write_text(textwrap.dedent("""\
SECUBOX_HOSTNAME="gk2"
SECUBOX_DOMAIN_SUFFIX="secubox.in"
"""))
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
wi.invalidate_cache()
return TestClient(api_main.app)
def test_admin_domain_returns_canonical_identity(client):
r = client.get("/webui/admin-domain")
assert r.status_code == 200
data = r.json()
assert data == {
"hostname": "gk2",
"domain_suffix": "secubox.in",
"admin_domain": "admin.gk2.secubox.in",
"regex": r"^admin\.gk2\.secubox\.in$",
}
def test_admin_domain_503_when_unset(client, tmp_path, monkeypatch):
p = tmp_path / "secubox-empty"
p.write_text("")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
wi.invalidate_cache()
r = client.get("/webui/admin-domain")
assert r.status_code == 503
assert "SECUBOX_HOSTNAME" in r.json()["detail"]
def test_nginx_config_is_public(client):
"""Endpoint is intentionally public — content is derivable from /webui/admin-domain
and the unix socket is root-only at the filesystem level."""
r = client.get("/webui/nginx-config")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/plain")
def test_nginx_config_returns_rendered_vhost(client):
r = client.get("/webui/nginx-config")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/plain")
body = r.text
assert r"server_name ~^admin\.gk2\.secubox\.in$;" in body
assert "listen 0.0.0.0:9080;" in body
assert "root /usr/share/secubox/www;" in body
assert "include /etc/nginx/secubox.d/*.conf;" in body
def test_generate_includes_strict_webui_acl(client, tmp_path, monkeypatch):
"""When /generate runs with a configured HOSTNAME, it emits the strict ACL + webui_direct backend."""
import api.main as _mn
from api.main import app
from secubox_core.auth import require_jwt
# Redirect HAProxy cfg writes to a temp file so we don't need root
cfg_tmp = tmp_path / "haproxy.cfg"
monkeypatch.setattr(_mn, "HAPROXY_CFG", str(cfg_tmp))
# Stub out haproxy validation and WAF sync so /generate completes
import subprocess
import unittest.mock as mock
fake_result = mock.MagicMock()
fake_result.returncode = 0
fake_result.stderr = ""
fake_result.stdout = "Configuration file is valid\n"
app.dependency_overrides[require_jwt] = lambda: {"sub": "tester"}
try:
with mock.patch("subprocess.run", return_value=fake_result), \
mock.patch.object(_mn, "sync_waf_routes", return_value=None):
r = client.post("/generate")
finally:
app.dependency_overrides.clear()
assert r.status_code == 200, f"Unexpected status {r.status_code}: {r.text}"
# /generate writes the rendered config to HAPROXY_CFG — read it back
text = cfg_tmp.read_text()
assert r"acl is_webui_admin hdr(host) -m reg ^admin\.gk2\.secubox\.in$" in text, \
f"Strict ACL not found in generated config:\n{text}"
assert "use_backend webui_direct if is_webui_admin" in text, \
f"use_backend webui_direct directive missing:\n{text}"
assert "backend webui_direct" in text, \
f"backend webui_direct definition missing:\n{text}"
# Verify the ACL appears twice — once in http-in, once in https-in
assert text.count("acl is_webui_admin") == 2, \
f"Expected 2 occurrences of 'acl is_webui_admin', got {text.count('acl is_webui_admin')}"
def test_refresh_invalidates_cache(client, tmp_path, monkeypatch):
from api.main import app
from secubox_core.auth import require_jwt
app.dependency_overrides[require_jwt] = lambda: {"sub": "tester"}
try:
# First call seeds the cache via /admin-domain
r1 = client.get("/webui/admin-domain")
assert r1.json()["hostname"] == "gk2"
# Mutate the file under the API's feet
wi.DEFAULTS_FILE.write_text('SECUBOX_HOSTNAME="changed"\n')
# Without refresh, the API still sees old value
r2 = client.get("/webui/admin-domain")
assert r2.json()["hostname"] == "gk2"
# Refresh
r3 = client.post("/webui/refresh")
assert r3.status_code == 204
# Now the API sees the new value
r4 = client.get("/webui/admin-domain")
assert r4.json()["hostname"] == "changed"
finally:
app.dependency_overrides.clear()

View File

@ -0,0 +1,100 @@
"""
SecuBox-Deb :: webui_identity tests
Author: Gerald KERMA <devel@cybermind.fr>
"""
import os
import textwrap
import pytest
from pathlib import Path
from api import webui_identity as wi
@pytest.fixture(autouse=True)
def _reset_cache():
wi.invalidate_cache()
yield
wi.invalidate_cache()
def _write_defaults(tmp_path, body):
p = tmp_path / "secubox"
p.write_text(textwrap.dedent(body))
return p
def test_parse_basic(monkeypatch, tmp_path):
p = _write_defaults(tmp_path, """\
SECUBOX_HOSTNAME="gk2"
SECUBOX_DOMAIN_SUFFIX="secubox.in"
""")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
ident = wi.get_identity()
assert ident["hostname"] == "gk2"
assert ident["domain_suffix"] == "secubox.in"
assert ident["admin_domain"] == "admin.gk2.secubox.in"
assert ident["regex"] == r"^admin\.gk2\.secubox\.in$"
def test_missing_hostname_raises(monkeypatch, tmp_path):
p = _write_defaults(tmp_path, """\
SECUBOX_DOMAIN_SUFFIX="secubox.in"
""")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
with pytest.raises(ValueError, match="SECUBOX_HOSTNAME"):
wi.get_identity()
def test_custom_suffix(monkeypatch, tmp_path):
p = _write_defaults(tmp_path, """\
SECUBOX_HOSTNAME="mochabin"
SECUBOX_DOMAIN_SUFFIX="lan.local"
""")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
ident = wi.get_identity()
assert ident["admin_domain"] == "admin.mochabin.lan.local"
assert ident["regex"] == r"^admin\.mochabin\.lan\.local$"
def test_comments_and_blank_lines(monkeypatch, tmp_path):
p = _write_defaults(tmp_path, """\
# comment
SECUBOX_HOSTNAME="gk2"
# another comment
SECUBOX_DOMAIN_SUFFIX="secubox.in"
""")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
ident = wi.get_identity()
assert ident["hostname"] == "gk2"
def test_invalidate_cache(monkeypatch, tmp_path):
p = _write_defaults(tmp_path, """\
SECUBOX_HOSTNAME="gk2"
""")
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
first = wi.get_identity()
p.write_text('SECUBOX_HOSTNAME="changed"\n')
cached = wi.get_identity()
assert cached["hostname"] == "gk2" # cache hit
wi.invalidate_cache()
refreshed = wi.get_identity()
assert refreshed["hostname"] == "changed"
@pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses chmod 000")
def test_unreadable_file_returns_empty_then_raises(monkeypatch, tmp_path):
"""If the defaults file exists but is unreadable, get_identity raises ValueError."""
p = tmp_path / "secubox-unreadable"
p.write_text('SECUBOX_HOSTNAME="gk2"\n')
p.chmod(0o000) # No read permission for anyone
monkeypatch.setattr(wi, "DEFAULTS_FILE", p)
try:
# Should NOT raise OSError/PermissionError directly;
# instead, get_identity raises ValueError because HOSTNAME is unset.
with pytest.raises(ValueError, match="SECUBOX_HOSTNAME"):
wi.get_identity()
finally:
# Restore permissions so the temp file can be cleaned up
p.chmod(0o644)

View File

@ -15,7 +15,14 @@
(function() {
'use strict';
const VERSION = '1.2.1';
const VERSION = '1.3.0';
const VISITOR_ORIGIN_API = window.SECUBOX_VISITOR_ORIGIN_API
|| '/api/v1/metrics/visitor-origin';
const LIVE_HOSTS_API = window.SECUBOX_LIVE_HOSTS_API
|| '/api/v1/metrics/live-hosts';
const CERT_STATUS_API = window.SECUBOX_CERT_STATUS_API
|| '/api/v1/metrics/cert-status';
const LIVE_REFRESH_INTERVAL = 30000; // 30 s
// 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
@ -287,6 +294,93 @@
return { banner, trigger };
}
// ═══════════════════════════════════════════════════════════════════════════
// LIVE PANEL — VisitorOrigin / LiveHosts / CertStatus (issue #92)
// ═══════════════════════════════════════════════════════════════════════════
function sectionContainer(id) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.className = 'sbx-live-section';
const banner = document.getElementById('health-banner');
if (banner) banner.appendChild(el);
}
return el;
}
function hideSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function showSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = '';
}
function renderVisitorOrigin(data) {
if (!data || !data.enabled || !data.entries || !data.entries.length) {
hideSection('sbx-visitor-origin');
return;
}
const el = sectionContainer('sbx-visitor-origin');
const rows = data.entries.map(e =>
`<div class="sbx-row"><span class="sbx-asn">AS${e.asn}</span> <span class="sbx-org">${e.org}</span><span class="sbx-count">${e.count}</span></div>`
).join('');
el.innerHTML = `<div class="sbx-section-title">VisitorOrigin · ${data.window_minutes}min · top ${data.entries.length}</div>${rows}`;
showSection('sbx-visitor-origin');
}
function renderLiveHosts(data) {
if (!data || !data.enabled || !data.entries || !data.entries.length) {
hideSection('sbx-live-hosts');
return;
}
const el = sectionContainer('sbx-live-hosts');
const rows = data.entries.map(e =>
`<div class="sbx-row"><span class="sbx-host">${e.host}</span><span class="sbx-count">${e.count}</span></div>`
).join('');
el.innerHTML = `<div class="sbx-section-title">LiveHosts · ${data.window_minutes}min · top ${data.entries.length}</div>${rows}`;
showSection('sbx-live-hosts');
}
function renderCertStatus(data) {
if (!data || !data.enabled || !data.summary || !data.summary.total) {
hideSection('sbx-cert-status');
return;
}
const el = sectionContainer('sbx-cert-status');
const s = data.summary;
const next = data.next_renewal
? `<div class="sbx-row">next renewal: ${data.next_renewal.host} · ${data.next_renewal.days}d</div>`
: '';
el.innerHTML =
`<div class="sbx-section-title">CertStatus · ${s.total} total</div>` +
`<div class="sbx-row">✓ ${s.valid} valid · ⚠ ${s.expiring_soon} soon · ✗ ${s.expiring_critical + s.expired} critical</div>` +
next;
showSection('sbx-cert-status');
}
async function pollLivePanel() {
const fetchSafe = async (url) => {
try {
const r = await fetch(url, { credentials: 'omit' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
};
const [vo, lh, cs] = await Promise.all([
fetchSafe(VISITOR_ORIGIN_API),
fetchSafe(LIVE_HOSTS_API),
fetchSafe(CERT_STATUS_API),
]);
if (vo) renderVisitorOrigin(vo); else hideSection('sbx-visitor-origin');
if (lh) renderLiveHosts(lh); else hideSection('sbx-live-hosts');
if (cs) renderCertStatus(cs); else hideSection('sbx-cert-status');
}
function injectBannerStyles() {
if (document.getElementById('health-banner-styles')) return;
@ -653,6 +747,11 @@
margin-bottom: 60vh;
}
}
.sbx-live-section { padding: 6px 10px; border-top: 1px solid var(--text-muted, #6b6b7a); font-size: 11px; line-height: 1.4; }
.sbx-section-title { font-weight: 600; color: var(--gold-hermetic, #c9a84c); margin-bottom: 2px; }
.sbx-row { display: flex; justify-content: space-between; gap: 8px; }
.sbx-row .sbx-count { color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
.sbx-row .sbx-asn { color: var(--matrix-green, #00ff41); }
`;
document.head.appendChild(style);
}
@ -820,6 +919,8 @@
function init() {
// Inject styles
injectBannerStyles();
pollLivePanel();
setInterval(pollLivePanel, LIVE_REFRESH_INTERVAL);
// Create banner and trigger
const { banner, trigger } = createBannerElement();

View File

@ -34,6 +34,41 @@ Configuration file: `/etc/secubox/metrics.toml`
- `GET /api/v1/metrics/status` - Module status
- `GET /api/v1/metrics/health` - Health check
## Endpoints — live panel (issue #92)
Three public endpoints feed the health-banner live panel. All are
unauthenticated, CORS-open, and `Cache-Control: public, max-age=300`.
| Method | Path | Schema (high-level) |
|--------|-----------------------------------|------------------------------------------------------|
| GET | `/api/v1/metrics/visitor-origin` | `{enabled, window_minutes, entries:[{asn,org,count}]}`|
| GET | `/api/v1/metrics/live-hosts` | `{enabled, window_minutes, entries:[{host,count}]}` |
| GET | `/api/v1/metrics/cert-status` | `{enabled, summary, next_renewal, warnings}` |
Config blocks live in `/etc/secubox/secubox.conf`:
```toml
[visitor_origin]
enabled = true
min_count = 5
[live_hosts]
enabled = true
[cert_status]
enabled = true
warn_days = 30
```
MaxMind GeoLite2-ASN refresh: install `geoipupdate` (available in Debian
bookworm's `contrib` repository; `secubox-metrics` lists it as a
`Recommends`, not `Depends`, so `apt install secubox-metrics` succeeds
without `contrib` enabled). Drop a license file at
`/etc/secubox/secrets/maxmind.conf` (mode 0600, owner `secubox`).
The `secubox-geoipupdate.timer` runs weekly; if either the binary or the
key is absent, the unit is a silent no-op and the VisitorOrigin banner
section stays hidden.
## License
MIT License - CyberMind © 2024-2026

View File

@ -0,0 +1,138 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: CertStatus aggregator
Scans /etc/letsencrypt/live/*/cert.pem, parses each cert via cryptography,
and emits a rollup of {valid, expiring_soon, expiring_critical, expired}
plus the soonest-renewing non-expired host.
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from cryptography import x509
log = logging.getLogger("secubox.cert_status")
CACHE_PATH = Path("/var/cache/secubox/metrics/cert-status.json")
class CertStatusAggregator:
def __init__(self, cfg: dict):
self.cfg = cfg
self._payload: dict = {"enabled": False, "summary": {}, "warnings": []}
# -- public ---------------------------------------------
def current(self) -> dict:
if self._payload.get("summary"):
return dict(self._payload)
if CACHE_PATH.exists():
try:
return json.loads(CACHE_PATH.read_text())
except Exception:
pass
return {"enabled": False, "summary": {}, "warnings": []}
async def run_forever(self) -> None:
while True:
try:
self._payload = await self.refresh_once()
except Exception as e:
log.warning("refresh_once raised: %s", e)
await asyncio.sleep(60)
async def refresh_once(self) -> dict:
if not self.cfg.get("enabled"):
return self._disabled_payload()
live = Path(self.cfg["letsencrypt_live_dir"])
if not live.is_dir():
return self._disabled_payload()
infos = self._scan_certs(live)
if not infos:
return self._disabled_payload()
payload = self._summarize(infos)
self._persist(payload)
return payload
# -- helpers --------------------------------------------
def _scan_certs(self, live: Path) -> list[dict]:
out: list[dict] = []
now = datetime.now(timezone.utc)
for host_dir in sorted(live.iterdir()):
if not host_dir.is_dir():
continue
cert_path = host_dir / "cert.pem"
if not cert_path.exists():
continue
try:
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
not_after = cert.not_valid_after_utc
except Exception as e:
log.warning("parse failed for %s: %s", host_dir.name, e)
continue
days = math.ceil((not_after - now).total_seconds() / 86400)
out.append({"host": host_dir.name, "days": days, "state": self._classify(days)})
return out
def _classify(self, days: int) -> str:
if days <= 0:
return "expired"
if days <= self.cfg["critical_days"]:
return "expiring_critical"
if days <= self.cfg["warn_days"]:
return "expiring_soon"
return "valid"
def _summarize(self, infos: list[dict]) -> dict:
buckets = {"valid": 0, "expiring_soon": 0, "expiring_critical": 0, "expired": 0}
for i in infos:
buckets[i["state"]] += 1
non_expired = sorted(
(i for i in infos if i["state"] != "expired"),
key=lambda x: x["days"],
)
next_renewal = (
{"host": non_expired[0]["host"], "days": non_expired[0]["days"]}
if non_expired
else None
)
warnings = sorted(
(i for i in infos if i["state"] in ("expiring_soon", "expiring_critical", "expired")),
key=lambda x: x["days"],
)
return {
"enabled": True,
"generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {"total": len(infos), **buckets, "failed_renewal": 0},
"next_renewal": next_renewal,
"warnings": warnings,
}
def _persist(self, payload: dict) -> None:
try:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(CACHE_PATH)
except Exception as e:
log.warning("persist failed: %s", e)
def _disabled_payload(self) -> dict:
return {
"enabled": False,
"generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {},
"warnings": [],
}

View File

@ -0,0 +1,181 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: LiveHosts aggregator
Polls the HAProxy admin socket once per minute, ring-buffers per-frontend
request deltas over 60 minutes, and emits a sanitized top-N rollup of the
hostnames being served.
"""
from __future__ import annotations
import asyncio
import collections
import json
import logging
import socket
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
log = logging.getLogger("secubox.live_hosts")
CACHE_PATH = Path("/var/cache/secubox/metrics/live-hosts.json")
class LiveHostsAggregator:
def __init__(self, cfg: dict):
self.cfg = cfg
self._buckets: collections.deque[dict[str, int]] = collections.deque(maxlen=60)
self._prev_totals: dict[str, int] = {}
self._payload: dict = {"enabled": False, "entries": []}
self._refreshed = False
# -- public ---------------------------------------------
def current(self) -> dict:
if self._refreshed:
return dict(self._payload)
if CACHE_PATH.exists():
try:
return json.loads(CACHE_PATH.read_text())
except Exception:
pass
return {"enabled": False, "window_minutes": self.cfg["window_minutes"], "entries": []}
async def run_forever(self) -> None:
while True:
try:
self._payload = await self.refresh_once()
except Exception as e:
log.warning("refresh_once raised: %s", e)
await asyncio.sleep(60)
async def refresh_once(self) -> dict:
if not self.cfg.get("enabled"):
self._refreshed = True
return self._disabled_payload()
totals = await asyncio.to_thread(self._read_haproxy_stats)
if totals is None:
self._refreshed = True
return self._disabled_payload()
kept = self._filter_frontends(totals)
self._delta_and_buffer(kept)
entries = self._aggregate()
payload = {
"enabled": True,
"window_minutes": self.cfg["window_minutes"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": entries,
}
self._persist(payload)
self._refreshed = True
return payload
# -- helpers --------------------------------------------
def _filter_frontends(self, totals: dict[str, int]) -> dict[str, int]:
flt = self.cfg.get("frontend_filter", "*")
out: dict[str, int] = {}
for name, n in totals.items():
if name.startswith("_"):
continue
if "." not in name:
continue
if flt != "*" and flt not in name:
continue
out[name] = n
return out
def _delta_and_buffer(self, totals: dict[str, int]) -> None:
if not self._prev_totals:
self._buckets.append({k: 0 for k in totals})
self._prev_totals = dict(totals)
return
bucket: dict[str, int] = {}
for host, cur in totals.items():
prev = self._prev_totals.get(host)
if prev is None or cur < prev:
bucket[host] = 0
else:
bucket[host] = cur - prev
self._buckets.append(bucket)
self._prev_totals = dict(totals)
def _aggregate(self) -> list[dict]:
totals: dict[str, int] = collections.Counter()
for bucket in self._buckets:
for host, n in bucket.items():
totals[host] += n
entries = [{"host": h, "count": c} for h, c in totals.items() if c > 0]
entries.sort(key=lambda e: (-e["count"], e["host"]))
return entries[: self.cfg["top_n"]]
def _read_haproxy_stats(self) -> Optional[dict[str, int]]:
sock_path = self.cfg["haproxy_socket"]
if not Path(sock_path).exists():
return None
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(2.0)
s.connect(sock_path)
s.sendall(b"show stat\n")
chunks = []
while True:
data = s.recv(8192)
if not data:
break
chunks.append(data)
blob = b"".join(chunks).decode("utf-8", errors="replace")
except Exception as e:
log.warning("haproxy socket read failed: %s", e)
return None
return self._parse_show_stat(blob)
@staticmethod
def _parse_show_stat(blob: str) -> dict[str, int]:
"""Extract {frontend_name: req_tot} from `show stat` CSV output."""
out: dict[str, int] = {}
lines = blob.splitlines()
if not lines:
return out
header = lines[0].lstrip("# ").split(",")
try:
pxname_i = header.index("pxname")
svname_i = header.index("svname")
req_tot_i = header.index("req_tot")
except ValueError:
return out
for line in lines[1:]:
if not line or line.startswith("#"):
continue
cols = line.split(",")
if len(cols) <= max(pxname_i, svname_i, req_tot_i):
continue
if cols[svname_i] != "FRONTEND":
continue
try:
out[cols[pxname_i]] = int(cols[req_tot_i] or "0")
except ValueError:
continue
return out
def _persist(self, payload: dict) -> None:
try:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(CACHE_PATH)
except Exception as e:
log.warning("persist failed: %s", e)
def _disabled_payload(self) -> dict:
return {
"enabled": False,
"window_minutes": self.cfg["window_minutes"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": [],
}

View File

@ -9,7 +9,9 @@ import asyncio
import json
import os
import subprocess
import sys
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@ -29,10 +31,51 @@ except ImportError:
async def require_jwt():
pass
# When uvicorn loads us as `api.main`, sibling modules in this directory
# are not on sys.path. Mirror what tests/conftest.py does at test time so
# the bare imports below resolve in both environments.
sys.path.insert(0, os.path.dirname(__file__))
from visitor_origin import VisitorOriginAggregator
from live_hosts import LiveHostsAggregator
from cert_status import CertStatusAggregator
try:
from secubox_core.config import (
get_visitor_origin_config,
get_live_hosts_config,
get_cert_status_config,
)
except ImportError: # dev fallback
def get_visitor_origin_config(): return {"enabled": False, "window_minutes": 60, "min_count": 5, "top_n": 5, "asn_db_path": "/var/lib/GeoIP/GeoLite2-ASN.mmdb", "nft_table": "secubox_metrics", "nft_set": "seen_src", "nft_family": "inet"}
def get_live_hosts_config(): return {"enabled": False, "window_minutes": 60, "top_n": 5, "haproxy_socket": "/run/haproxy/admin.sock", "frontend_filter": "*"}
def get_cert_status_config(): return {"enabled": False, "letsencrypt_live_dir": "/etc/letsencrypt/live", "warn_days": 30, "critical_days": 7}
visitor_origin_agg = VisitorOriginAggregator(get_visitor_origin_config())
live_hosts_agg = LiveHostsAggregator(get_live_hosts_config())
cert_status_agg = CertStatusAggregator(get_cert_status_config())
@asynccontextmanager
async def lifespan(_app):
tasks = [
asyncio.create_task(visitor_origin_agg.run_forever()),
asyncio.create_task(live_hosts_agg.run_forever()),
asyncio.create_task(cert_status_agg.run_forever()),
]
try:
yield
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
app = FastAPI(
title="SecuBox Metrics Dashboard",
description="Real-time system metrics with caching",
version="1.0.0"
version="1.0.0",
lifespan=lifespan,
)
# CORS middleware for cross-origin health banner requests
@ -705,6 +748,30 @@ async def get_health_summary(request: Request, domain: Optional[str] = None):
return summary
@app.get("/api/v1/metrics/visitor-origin")
async def visitor_origin_endpoint():
return JSONResponse(
content=visitor_origin_agg.current(),
headers={"Cache-Control": "public, max-age=300"},
)
@app.get("/api/v1/metrics/live-hosts")
async def live_hosts_endpoint():
return JSONResponse(
content=live_hosts_agg.current(),
headers={"Cache-Control": "public, max-age=300"},
)
@app.get("/api/v1/metrics/cert-status")
async def cert_status_endpoint():
return JSONResponse(
content=cert_status_agg.current(),
headers={"Cache-Control": "public, max-age=300"},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)

View File

@ -0,0 +1,182 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: VisitorOrigin aggregator
Polls the secubox_metrics seen_src nft set, resolves ASNs via MaxMind GeoLite2,
threshold-gates the rollup, and exposes a sanitized payload. Raw IPs never
leave the local scope.
"""
from __future__ import annotations
import asyncio
import json
import logging
import subprocess
from collections import Counter
from datetime import datetime, timezone
from ipaddress import IPv4Address
from pathlib import Path
from typing import Optional
try:
import maxminddb
except ImportError:
maxminddb = None # type: ignore[assignment]
log = logging.getLogger("secubox.visitor_origin")
CACHE_PATH = Path("/var/cache/secubox/metrics/visitor-origin.json")
class VisitorOriginAggregator:
def __init__(self, cfg: dict):
self.cfg = cfg
self._payload: dict = {"enabled": False, "entries": []}
self._refreshed = False
self._mmdb = None
self._mmdb_mtime: float = 0.0
# -- public ---------------------------------------------
def current(self) -> dict:
if self._refreshed:
return dict(self._payload)
if CACHE_PATH.exists():
try:
return json.loads(CACHE_PATH.read_text())
except Exception:
pass
return {"enabled": False, "window_minutes": self.cfg["window_minutes"], "entries": []}
async def run_forever(self) -> None:
while True:
try:
self._payload = await self.refresh_once()
except Exception as e:
log.warning("refresh_once raised: %s", e)
await asyncio.sleep(60)
async def refresh_once(self) -> dict:
if not self.cfg.get("enabled"):
self._refreshed = True
return self._disabled_payload()
if not Path(self.cfg["asn_db_path"]).exists() or maxminddb is None:
self._refreshed = True
return self._disabled_payload()
ips = await asyncio.to_thread(self._read_nft_set)
entries = self._aggregate(ips)
payload = {
"enabled": True,
"window_minutes": self.cfg["window_minutes"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": entries,
}
self._persist(payload)
self._refreshed = True
return payload
# -- pure helpers ---------------------------------------
def _aggregate(self, ips: list[IPv4Address]) -> list[dict]:
counter: Counter[tuple[int, str]] = Counter()
seen: set[IPv4Address] = set()
for ip in ips:
if ip in seen:
continue
seen.add(ip)
asn = self._lookup_asn(ip)
if asn is None:
continue
counter[asn] += 1
groups = [
{"asn": asn, "org": org, "count": cnt}
for (asn, org), cnt in counter.items()
if cnt >= self.cfg["min_count"]
]
groups.sort(key=lambda e: (-e["count"], e["asn"]))
return groups[: self.cfg["top_n"]]
def _lookup_asn(self, ip: IPv4Address) -> Optional[tuple[int, str]]:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
return None
try:
current_mtime = Path(self.cfg["asn_db_path"]).stat().st_mtime
except OSError:
current_mtime = 0.0
if self._mmdb is not None and current_mtime != self._mmdb_mtime:
try:
self._mmdb.close()
except Exception:
pass
self._mmdb = None
if self._mmdb is None:
try:
self._mmdb = maxminddb.open_database(self.cfg["asn_db_path"])
self._mmdb_mtime = current_mtime
except Exception as e:
log.warning("mmdb open failed: %s", e)
return None
try:
rec = self._mmdb.get(str(ip))
except Exception:
return None
if not rec:
return None
asn = rec.get("autonomous_system_number")
org = rec.get("autonomous_system_organization") or ""
if asn is None:
return None
return (int(asn), str(org))
def _read_nft_set(self) -> list[IPv4Address]:
cmd = [
"nft", "-j", "list", "set",
self.cfg["nft_family"], self.cfg["nft_table"], self.cfg["nft_set"],
]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if res.returncode != 0:
return []
doc = json.loads(res.stdout)
except Exception:
return []
ips: list[IPv4Address] = []
for obj in doc.get("nftables", []):
elem = obj.get("set", {}).get("elem")
if not elem:
continue
for e in elem:
# nftables JSON can wrap addresses in dicts when flags are set
raw = (
e["elem"]["val"]
if isinstance(e, dict) and isinstance(e.get("elem"), dict)
else e
)
if isinstance(raw, dict):
raw = raw.get("val", "")
try:
ips.append(IPv4Address(str(raw)))
except Exception:
continue
return ips
def _persist(self, payload: dict) -> None:
try:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(CACHE_PATH)
except Exception as e:
log.warning("persist failed: %s", e)
def _disabled_payload(self) -> dict:
return {
"enabled": False,
"window_minutes": self.cfg["window_minutes"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": [],
}

View File

@ -12,7 +12,11 @@ Depends: ${misc:Depends},
secubox-core,
python3,
python3-fastapi | python3-pip,
python3-uvicorn | python3-pip
python3-uvicorn | python3-pip,
python3-maxminddb | python3-pip,
python3-cryptography,
nftables
Recommends: geoipupdate
Description: SecuBox Metrics Dashboard
Real-time system metrics dashboard with caching.
Provides overview stats, WAF metrics, connection counts,

View File

@ -16,10 +16,36 @@ case "$1" in
mkdir -p /tmp/secubox
chown secubox:secubox /tmp/secubox
# Add secubox to haproxy group for admin-socket access (issue #92)
if getent group haproxy >/dev/null; then
usermod -aG haproxy secubox || true
fi
# Persistent cache dir for live-panel rollups
mkdir -p /var/cache/secubox/metrics
chown -R secubox:secubox /var/cache/secubox
# Secrets dir for MaxMind license key
mkdir -p /etc/secubox/secrets
chmod 0700 /etc/secubox/secrets
chown secubox:secubox /etc/secubox/secrets
# GeoIP db dir
mkdir -p /var/lib/GeoIP
chown secubox:secubox /var/lib/GeoIP
# Reload nftables to pick up /etc/nftables.d/secubox-metrics.nft
if systemctl is-active --quiet nftables; then
systemctl reload nftables || true
fi
# Enable the geoipupdate timer (no-op if user hasn't supplied a key)
systemctl enable --now secubox-geoipupdate.timer || true
# Enable and start service
systemctl daemon-reload
systemctl enable secubox-metrics.service
systemctl start secubox-metrics.service || true
systemctl restart secubox-metrics.service || true
# Reload nginx if running
if systemctl is-active --quiet nginx; then

View File

@ -3,6 +3,9 @@ set -e
case "$1" in
remove|upgrade|deconfigure)
systemctl stop secubox-geoipupdate.timer 2>/dev/null || true
systemctl stop secubox-geoipupdate.service 2>/dev/null || true
systemctl disable secubox-geoipupdate.timer 2>/dev/null || true
systemctl stop secubox-metrics.service || true
systemctl disable secubox-metrics.service || true
;;

View File

@ -30,3 +30,12 @@ override_dh_auto_install:
echo ' proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;' >> $(CURDIR)/debian/secubox-metrics/etc/nginx/secubox.d/metrics.conf
echo ' proxy_set_header X-Forwarded-Proto $$scheme;' >> $(CURDIR)/debian/secubox-metrics/etc/nginx/secubox.d/metrics.conf
echo '}' >> $(CURDIR)/debian/secubox-metrics/etc/nginx/secubox.d/metrics.conf
override_dh_install:
dh_install
install -D -m 0644 nftables/secubox-metrics.nft \
debian/secubox-metrics/etc/nftables.d/secubox-metrics.nft
install -D -m 0644 systemd/secubox-geoipupdate.service \
debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.service
install -D -m 0644 systemd/secubox-geoipupdate.timer \
debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.timer

View File

@ -20,7 +20,7 @@ RestartSec=5
# PrivateTmp=true
# ProtectSystem=full
NoNewPrivileges=true
ReadWritePaths=/run/secubox /tmp/secubox
ReadWritePaths=/run/secubox /tmp/secubox /var/cache/secubox
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,17 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-metrics ingress tap (issue #92)
# Owns a private table; never touches secubox-firewall's chains.
table inet secubox_metrics {
set seen_src {
type ipv4_addr
flags timeout
timeout 1h
size 65536
}
chain ingress_tap {
type filter hook prerouting priority -300; policy accept;
tcp dport { 80, 443 } add @seen_src { ip saddr }
}
}

View File

@ -0,0 +1,14 @@
[Unit]
Description=SecuBox — refresh GeoLite2 ASN database
ConditionPathExists=/etc/secubox/secrets/maxmind.conf
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=secubox
Group=secubox
ExecStart=/usr/bin/geoipupdate -f /etc/secubox/secrets/maxmind.conf -d /var/lib/GeoIP
NoNewPrivileges=true
ProtectSystem=full
ReadWritePaths=/var/lib/GeoIP

View File

@ -0,0 +1,10 @@
[Unit]
Description=SecuBox — weekly GeoLite2 ASN refresh
[Timer]
OnCalendar=weekly
RandomizedDelaySec=4h
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,11 @@
"""Add the package's api/ and the repo-wide common/ to sys.path for tests."""
import sys
from pathlib import Path
# packages/secubox-metrics/
_pkg_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_pkg_root / "api"))
# repo root → common/
_repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_repo_root / "common"))

View File

@ -0,0 +1,88 @@
"""Unit tests for CertStatusAggregator."""
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from cert_status import CertStatusAggregator
CFG_BASE = {
"enabled": True,
"warn_days": 30,
"critical_days": 7,
}
def _make_cert(host: str, days_left: int, out_dir: Path) -> Path:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])
now = datetime.now(timezone.utc)
# For already-expired certs (days_left < 0), not_valid_before must be
# earlier than not_valid_after, so push it back far enough.
not_before_offset = max(1, abs(days_left) + 1) if days_left < 0 else 1
cert = (
x509.CertificateBuilder()
.subject_name(subject).issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=not_before_offset))
.not_valid_after(now + timedelta(days=days_left))
.sign(key, hashes.SHA256())
)
host_dir = out_dir / host
host_dir.mkdir(parents=True, exist_ok=True)
pem_path = host_dir / "cert.pem"
pem_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return pem_path
@pytest.fixture
def live_dir(tmp_path):
_make_cert("good.example.com", 60, tmp_path)
_make_cert("soon.example.com", 14, tmp_path)
_make_cert("crit.example.com", 3, tmp_path)
_make_cert("dead.example.com", -2, tmp_path)
return tmp_path
def test_summary_counts_by_state(live_dir):
agg = CertStatusAggregator(dict(CFG_BASE, letsencrypt_live_dir=str(live_dir)))
out = asyncio.run(agg.refresh_once())
s = out["summary"]
assert s["total"] == 4
assert s["valid"] == 1
assert s["expiring_soon"] == 1
assert s["expiring_critical"] == 1
assert s["expired"] == 1
def test_next_renewal_is_soonest_non_expired(live_dir):
agg = CertStatusAggregator(dict(CFG_BASE, letsencrypt_live_dir=str(live_dir)))
out = asyncio.run(agg.refresh_once())
# Soonest non-expired is crit.example.com at 3d
assert out["next_renewal"]["host"] == "crit.example.com"
assert out["next_renewal"]["days"] == 3
def test_missing_live_dir_disabled(tmp_path):
agg = CertStatusAggregator(
dict(CFG_BASE, letsencrypt_live_dir=str(tmp_path / "missing"))
)
out = asyncio.run(agg.refresh_once())
assert out["enabled"] is False
def test_corrupt_cert_doesnt_kill_scan(live_dir):
bad_dir = live_dir / "bad.example.com"
bad_dir.mkdir()
(bad_dir / "cert.pem").write_bytes(b"not a real cert")
agg = CertStatusAggregator(dict(CFG_BASE, letsencrypt_live_dir=str(live_dir)))
out = asyncio.run(agg.refresh_once())
# Still surfaces the other four
assert out["summary"]["total"] == 4

View File

@ -0,0 +1,56 @@
"""Tests for the three new config helpers in secubox_core.config."""
from pathlib import Path
import secubox_core.config as cfg
def _reload_with(monkeypatch, tmp_path: Path, toml_text: str):
monkeypatch.setattr(cfg, "_CONFIG", None)
fake = tmp_path / "_secubox_test_conf.toml"
fake.write_text(toml_text)
monkeypatch.setattr(cfg, "_CONF_PATHS", [fake])
def test_visitor_origin_defaults_when_absent(monkeypatch, tmp_path):
_reload_with(monkeypatch, tmp_path, "[global]\nhostname='test'\n")
out = cfg.get_visitor_origin_config()
assert out["enabled"] is False
assert out["window_minutes"] == 60
assert out["min_count"] == 5
assert out["top_n"] == 5
assert out["asn_db_path"] == "/var/lib/GeoIP/GeoLite2-ASN.mmdb"
assert out["nft_table"] == "secubox_metrics"
assert out["nft_set"] == "seen_src"
assert out["nft_family"] == "inet"
def test_visitor_origin_overrides(monkeypatch, tmp_path):
_reload_with(
monkeypatch,
tmp_path,
"[visitor_origin]\nenabled=true\nmin_count=2\ntop_n=3\n",
)
out = cfg.get_visitor_origin_config()
assert out["enabled"] is True
assert out["min_count"] == 2
assert out["top_n"] == 3
assert out["window_minutes"] == 60
def test_live_hosts_defaults(monkeypatch, tmp_path):
_reload_with(monkeypatch, tmp_path, "[global]\nhostname='test'\n")
out = cfg.get_live_hosts_config()
assert out["enabled"] is False
assert out["window_minutes"] == 60
assert out["top_n"] == 5
assert out["haproxy_socket"] == "/run/haproxy/admin.sock"
assert out["frontend_filter"] == "*"
def test_cert_status_defaults(monkeypatch, tmp_path):
_reload_with(monkeypatch, tmp_path, "[global]\nhostname='test'\n")
out = cfg.get_cert_status_config()
assert out["enabled"] is False
assert out["letsencrypt_live_dir"] == "/etc/letsencrypt/live"
assert out["warn_days"] == 30
assert out["critical_days"] == 7

View File

@ -0,0 +1,110 @@
"""Unit tests for LiveHosts ring buffer + parsing + filter + aggregation."""
import asyncio
from unittest.mock import patch, MagicMock
from live_hosts import LiveHostsAggregator
CFG = {
"enabled": True,
"window_minutes": 60,
"top_n": 5,
"haproxy_socket": "/nonexistent.sock",
"frontend_filter": "*",
}
def _drive(agg, totals_sequence):
"""Feed a sequence of {frontend: req_tot} dicts to _delta_and_buffer."""
for totals in totals_sequence:
agg._delta_and_buffer(totals)
def test_delta_buffers_first_sample_as_zeros():
agg = LiveHostsAggregator(CFG)
_drive(agg, [{"foo.com": 100, "bar.com": 50}])
assert len(agg._buckets) == 1
assert agg._buckets[0] == {"foo.com": 0, "bar.com": 0}
def test_delta_computes_increment():
agg = LiveHostsAggregator(CFG)
_drive(agg, [
{"foo.com": 100}, # init
{"foo.com": 142}, # +42
{"foo.com": 150}, # +8
])
assert agg._buckets[-1] == {"foo.com": 8}
assert agg._buckets[-2] == {"foo.com": 42}
def test_delta_handles_haproxy_restart_no_negatives():
agg = LiveHostsAggregator(CFG)
_drive(agg, [
{"foo.com": 1000},
{"foo.com": 1050}, # +50
{"foo.com": 12}, # counter reset -> treat as fresh
])
# After reset the bucket value should be 0 (fresh baseline), never -1038.
assert agg._buckets[-1].get("foo.com", 0) == 0
def test_ring_buffer_caps_at_60():
agg = LiveHostsAggregator(CFG)
for i in range(65):
agg._delta_and_buffer({"foo.com": i * 10})
assert len(agg._buckets) == 60
def test_aggregate_sums_buckets_top_n():
agg = LiveHostsAggregator(dict(CFG, top_n=2))
_drive(agg, [
{"a.com": 0, "b.com": 0, "c.com": 0}, # baseline
{"a.com": 30, "b.com": 10, "c.com": 5}, # deltas 30/10/5
{"a.com": 40, "b.com": 25, "c.com": 6}, # deltas 10/15/1
])
entries = agg._aggregate()
hosts = [e["host"] for e in entries]
assert hosts == ["a.com", "b.com"]
counts = {e["host"]: e["count"] for e in entries}
assert counts["a.com"] == 40
assert counts["b.com"] == 25
def test_frontend_filter_strips_internal_names():
agg = LiveHostsAggregator(CFG)
raw = {
"foo.com": 10,
"_stats": 999, # leading underscore -> drop
"stats-https": 5, # no dot -> drop
"secubox.in": 50,
}
kept = agg._filter_frontends(raw)
assert "foo.com" in kept
assert "secubox.in" in kept
assert "_stats" not in kept
assert "stats-https" not in kept
def test_parse_show_stat_csv_extracts_frontends():
csv = (
"# pxname,svname,qcur,scur,smax,slim,stot,req_tot,extra\n"
"stats-https,FRONTEND,0,0,0,0,0,5,\n"
"secubox.in,FRONTEND,0,1,1,0,42,142,\n"
"secubox.in,backend-a,0,0,0,0,0,0,\n"
"apt.secubox.in,FRONTEND,0,0,0,0,0,9,\n"
)
out = LiveHostsAggregator._parse_show_stat(csv)
assert out == {"stats-https": 5, "secubox.in": 142, "apt.secubox.in": 9}
def test_parse_show_stat_empty_or_malformed_returns_empty():
assert LiveHostsAggregator._parse_show_stat("") == {}
assert LiveHostsAggregator._parse_show_stat("garbage\n") == {}
def test_refresh_missing_socket_returns_disabled(tmp_path):
agg = LiveHostsAggregator(dict(CFG, haproxy_socket=str(tmp_path / "missing.sock")))
out = asyncio.run(agg.refresh_once())
assert out["enabled"] is False
assert out["entries"] == []

View File

@ -0,0 +1,158 @@
"""Unit tests for the VisitorOrigin aggregator pure functions."""
from ipaddress import IPv4Address
from unittest.mock import MagicMock
import pytest
from visitor_origin import VisitorOriginAggregator
CFG = {
"enabled": True,
"window_minutes": 60,
"min_count": 5,
"top_n": 5,
"asn_db_path": "/nonexistent/asn.mmdb",
"nft_table": "secubox_metrics",
"nft_set": "seen_src",
"nft_family": "inet",
}
def _agg_with_mock_asn(mapping: dict[str, tuple[int, str]]):
agg = VisitorOriginAggregator(CFG)
def fake_lookup(ip):
return mapping.get(str(ip))
agg._lookup_asn = fake_lookup # type: ignore[assignment]
return agg
def test_aggregate_counts_unique_ips_per_asn():
agg = _agg_with_mock_asn({
"1.1.1.1": (13335, "Cloudflare, Inc."),
"1.0.0.1": (13335, "Cloudflare, Inc."),
"8.8.8.8": (15169, "Google LLC"),
"9.9.9.9": (19281, "Quad9"),
})
# Force min_count=1 for this test so all groups survive
agg.cfg = dict(CFG, min_count=1)
entries = agg._aggregate([IPv4Address("1.1.1.1"),
IPv4Address("1.0.0.1"),
IPv4Address("8.8.8.8"),
IPv4Address("9.9.9.9")])
counts = {e["asn"]: e["count"] for e in entries}
assert counts == {13335: 2, 15169: 1, 19281: 1}
def test_aggregate_threshold_keeps_equal_drops_below():
agg = _agg_with_mock_asn({
"1.1.1.1": (13335, "Cloudflare"),
"1.0.0.1": (13335, "Cloudflare"),
"1.0.0.2": (13335, "Cloudflare"),
"1.0.0.3": (13335, "Cloudflare"),
"1.0.0.4": (13335, "Cloudflare"), # 5 distinct -> kept
"8.8.8.8": (15169, "Google"),
"8.8.4.4": (15169, "Google"),
"8.8.0.1": (15169, "Google"),
"8.8.0.2": (15169, "Google"), # 4 distinct -> dropped
})
entries = agg._aggregate([IPv4Address(s) for s in [
"1.1.1.1", "1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4",
"8.8.8.8", "8.8.4.4", "8.8.0.1", "8.8.0.2",
]])
asns = {e["asn"] for e in entries}
assert 13335 in asns
assert 15169 not in asns
def test_aggregate_top_n_and_tiebreak_by_asn():
agg = _agg_with_mock_asn({
f"10.0.0.{i}": (100 + (i % 3), f"org{i % 3}") for i in range(1, 16)
})
agg.cfg = dict(CFG, top_n=2, min_count=1)
entries = agg._aggregate([IPv4Address(f"10.0.0.{i}") for i in range(1, 16)])
assert len(entries) == 2
# All three ASNs (100, 101, 102) have count=5; tie must resolve to smallest ASN first.
assert entries[0]["asn"] == 100
assert entries[1]["asn"] == 101
assert entries[0]["count"] == 5
assert entries[1]["count"] == 5
def test_lookup_asn_returns_none_for_private():
agg = VisitorOriginAggregator(CFG)
assert agg._lookup_asn(IPv4Address("10.0.0.1")) is None
assert agg._lookup_asn(IPv4Address("192.168.1.1")) is None
assert agg._lookup_asn(IPv4Address("127.0.0.1")) is None
def test_lookup_asn_reopens_on_mtime_change(monkeypatch, tmp_path):
"""If the mmdb file's mtime changes between calls, the handle is reopened."""
db_path = tmp_path / "asn.mmdb"
db_path.write_bytes(b"v1")
cfg = dict(CFG, asn_db_path=str(db_path))
agg = VisitorOriginAggregator(cfg)
open_calls = []
class FakeMmdb:
def __init__(self, n): self.n = n
def get(self, _): return None
def close(self): pass
def fake_open(path):
open_calls.append(path)
return FakeMmdb(len(open_calls))
monkeypatch.setattr("visitor_origin.maxminddb",
type("M", (), {"open_database": staticmethod(fake_open)}))
# First lookup opens v1
agg._lookup_asn(IPv4Address("1.1.1.1"))
assert len(open_calls) == 1
# No file change → no reopen
agg._lookup_asn(IPv4Address("1.1.1.2"))
assert len(open_calls) == 1
# Bump mtime → reopen on next call
import os
new_mtime = db_path.stat().st_mtime + 100
os.utime(db_path, (new_mtime, new_mtime))
agg._lookup_asn(IPv4Address("1.1.1.3"))
assert len(open_calls) == 2
def test_refresh_disabled_returns_disabled():
agg = VisitorOriginAggregator(dict(CFG, enabled=False))
import asyncio
out = asyncio.run(agg.refresh_once())
assert out["enabled"] is False
assert out["entries"] == []
def test_refresh_missing_mmdb_returns_disabled(tmp_path):
agg = VisitorOriginAggregator(dict(CFG, asn_db_path=str(tmp_path / "missing.mmdb")))
import asyncio
out = asyncio.run(agg.refresh_once())
assert out["enabled"] is False
def test_refresh_handles_nft_failure(monkeypatch, tmp_path):
# Pretend mmdb exists so we reach _read_nft_set
fake_db = tmp_path / "asn.mmdb"
fake_db.write_bytes(b"\x00") # truthy existence
agg = VisitorOriginAggregator(dict(CFG, asn_db_path=str(fake_db)))
# Force maxminddb import path to succeed without a real db by mocking _lookup_asn
agg._lookup_asn = lambda ip: None # type: ignore[assignment]
# Ensure module-level maxminddb guard passes even if package is not installed
monkeypatch.setattr("visitor_origin.maxminddb", object())
monkeypatch.setattr(
"visitor_origin.subprocess.run",
lambda *a, **kw: MagicMock(returncode=1, stdout=""),
)
import asyncio
out = asyncio.run(agg.refresh_once())
assert out["enabled"] is True
assert out["entries"] == []

View File

@ -0,0 +1,75 @@
#!/bin/bash
# tests/integration/test_44_webui_obfuscation.sh
# End-to-end test for issue #44 — run on the board (or a VM that mirrors it).
#
# Pre-requisites:
# - secubox-defaults, secubox-haproxy installed
# - /etc/default/secubox with SECUBOX_HOSTNAME and SECUBOX_DOMAIN_SUFFIX set
# - secubox-haproxy.service running
#
# Exit codes:
# 0 all probes succeeded
# 1 one or more probes failed (rollback performed)
# 2 pre-flight check failed (no changes made)
set -euo pipefail
LOG() { echo "[$(date '+%F %T')] $*" >&2; }
FAIL() { LOG "FAIL: $*"; exit 1; }
# Pre-flight
[[ -f /etc/default/secubox ]] || { LOG "missing /etc/default/secubox"; exit 2; }
# shellcheck source=/dev/null
. /etc/default/secubox
[[ -n "${SECUBOX_HOSTNAME:-}" ]] || { LOG "SECUBOX_HOSTNAME unset"; exit 2; }
ADMIN="admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
LOG "Canonical admin URL: https://$ADMIN/"
# Snapshot
TS=$(date +%s)
SNAP_HA="/etc/haproxy/haproxy.cfg.bak.$TS-test44"
SNAP_NX="/etc/nginx/sites-available/secubox-local.bak.$TS-test44"
cp -p /etc/haproxy/haproxy.cfg "$SNAP_HA"
cp -p /etc/nginx/sites-available/secubox-local "$SNAP_NX" 2>/dev/null || true
restore() {
LOG "Restoring snapshots"
cp -p "$SNAP_HA" /etc/haproxy/haproxy.cfg
[[ -f "$SNAP_NX" ]] && cp -p "$SNAP_NX" /etc/nginx/sites-available/secubox-local
systemctl reload haproxy nginx || true
}
trap 'restore' ERR
# 1. Render nginx + regen HAProxy
LOG "Render + regen"
/usr/local/bin/secubox-render-nginx-webui
/usr/local/bin/secubox-haproxy-regen-safe
# 2. Positive probe
LOG "Probe https://$ADMIN/"
TITLE=$(curl -ski "https://$ADMIN/?cb=$RANDOM" | grep -oE '<title>[^<]+</title>' | head -1)
[[ "$TITLE" == *"SecuBox Control Center"* ]] || FAIL "admin URL not serving WebUI ($TITLE)"
# 3. Negative probe: gk2.secubox.in (no admin.) should NOT be WebUI
LOG "Probe https://${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}/ (should NOT serve WebUI)"
BODY=$(curl -sk "https://${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}/" || true)
echo "$BODY" | grep -q "SecuBox Control Center" && FAIL "non-admin host served WebUI"
# 4. LAN-direct still works
LOG "Probe LAN direct https://192.168.1.200:9443/"
TITLE=$(curl -ski "https://192.168.1.200:9443/?cb=$RANDOM" | grep -oE '<title>[^<]+</title>' | head -1)
[[ "$TITLE" == *"SecuBox Control Center"* ]] || FAIL "LAN-direct broken ($TITLE)"
# 5. Random admin.* should be rejected
LOG "Probe https://admin.fake.secubox.in/ (should NOT serve WebUI)"
BODY=$(curl -sk -H "Host: admin.fake.secubox.in" "https://192.168.1.200/" || true)
echo "$BODY" | grep -q "SecuBox Control Center" && FAIL "random admin.X served WebUI"
# 6. Regression spot-checks
LOG "Regression spot-checks"
for d in cpf.gk2.secubox.in arm.gk2.secubox.in lldh.ganimed.fr pub.gk2.secubox.in werdl.gk2.secubox.in 3d.gk2.secubox.in; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$d/?cb=$RANDOM")
[[ "$code" == "200" ]] || FAIL "regression on $d (HTTP $code)"
done
trap - ERR
LOG "ALL TESTS PASSED — snapshots kept at $SNAP_HA and $SNAP_NX for forensics"