mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 12:34:38 +00:00
Merge remote-tracking branch 'origin/master' into feature/49-feat-metablogizer-streamlit-version-mana
# Conflicts: # .claude/HISTORY.md
This commit is contained in:
commit
4a388994d3
|
|
@ -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 ✅
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
1993
docs/superpowers/plans/2026-05-12-visitor-origin-feed.md
Normal file
1993
docs/superpowers/plans/2026-05-12-visitor-origin-feed.md
Normal file
File diff suppressed because it is too large
Load Diff
1404
docs/superpowers/plans/2026-05-12-webui-obfuscation.md
Normal file
1404
docs/superpowers/plans/2026-05-12-webui-obfuscation.md
Normal file
File diff suppressed because it is too large
Load Diff
432
docs/superpowers/specs/2026-05-12-visitor-origin-feed-design.md
Normal file
432
docs/superpowers/specs/2026-05-12-visitor-origin-feed-design.md
Normal 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).
|
||||
311
docs/superpowers/specs/2026-05-12-webui-obfuscation-design.md
Normal file
311
docs/superpowers/specs/2026-05-12-webui-obfuscation-design.md
Normal 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 153–156 (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.
|
||||
21
packages/secubox-defaults/README.md
Normal file
21
packages/secubox-defaults/README.md
Normal 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
|
||||
```
|
||||
6
packages/secubox-defaults/debian/changelog
Normal file
6
packages/secubox-defaults/debian/changelog
Normal 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
|
||||
1
packages/secubox-defaults/debian/compat
Normal file
1
packages/secubox-defaults/debian/compat
Normal file
|
|
@ -0,0 +1 @@
|
|||
13
|
||||
1
packages/secubox-defaults/debian/conffiles
Normal file
1
packages/secubox-defaults/debian/conffiles
Normal file
|
|
@ -0,0 +1 @@
|
|||
/etc/default/secubox
|
||||
15
packages/secubox-defaults/debian/control
Normal file
15
packages/secubox-defaults/debian/control
Normal 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>.
|
||||
8
packages/secubox-defaults/debian/copyright
Normal file
8
packages/secubox-defaults/debian/copyright
Normal 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.
|
||||
1
packages/secubox-defaults/debian/install
Normal file
1
packages/secubox-defaults/debian/install
Normal file
|
|
@ -0,0 +1 @@
|
|||
etc/default/secubox etc/default
|
||||
37
packages/secubox-defaults/debian/postinst
Executable file
37
packages/secubox-defaults/debian/postinst
Executable 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
|
||||
3
packages/secubox-defaults/debian/rules
Executable file
3
packages/secubox-defaults/debian/rules
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@
|
||||
1
packages/secubox-defaults/debian/triggers
Normal file
1
packages/secubox-defaults/debian/triggers
Normal file
|
|
@ -0,0 +1 @@
|
|||
activate-noawait secubox-defaults-changed
|
||||
14
packages/secubox-defaults/etc/default/secubox
Normal file
14
packages/secubox-defaults/etc/default/secubox
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
72
packages/secubox-haproxy/api/webui_identity.py
Normal file
72
packages/secubox-haproxy/api/webui_identity.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
packages/secubox-haproxy/debian/triggers
Normal file
1
packages/secubox-haproxy/debian/triggers
Normal file
|
|
@ -0,0 +1 @@
|
|||
interest-noawait secubox-defaults-changed
|
||||
|
|
@ -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
|
||||
|
|
|
|||
58
packages/secubox-haproxy/sbin/secubox-haproxy-regen-safe
Executable file
58
packages/secubox-haproxy/sbin/secubox-haproxy-regen-safe
Executable 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)"
|
||||
45
packages/secubox-haproxy/sbin/secubox-render-nginx-webui
Executable file
45
packages/secubox-haproxy/sbin/secubox-render-nginx-webui
Executable 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)"
|
||||
0
packages/secubox-haproxy/tests/__init__.py
Normal file
0
packages/secubox-haproxy/tests/__init__.py
Normal file
10
packages/secubox-haproxy/tests/conftest.py
Normal file
10
packages/secubox-haproxy/tests/conftest.py
Normal 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"))
|
||||
127
packages/secubox-haproxy/tests/test_webui_endpoints.py
Normal file
127
packages/secubox-haproxy/tests/test_webui_endpoints.py
Normal 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()
|
||||
100
packages/secubox-haproxy/tests/test_webui_identity.py
Normal file
100
packages/secubox-haproxy/tests/test_webui_identity.py
Normal 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)
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
packages/secubox-metrics/api/cert_status.py
Normal file
138
packages/secubox-metrics/api/cert_status.py
Normal 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": [],
|
||||
}
|
||||
181
packages/secubox-metrics/api/live_hosts.py
Normal file
181
packages/secubox-metrics/api/live_hosts.py
Normal 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": [],
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
182
packages/secubox-metrics/api/visitor_origin.py
Normal file
182
packages/secubox-metrics/api/visitor_origin.py
Normal 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": [],
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
packages/secubox-metrics/nftables/secubox-metrics.nft
Normal file
17
packages/secubox-metrics/nftables/secubox-metrics.nft
Normal 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 }
|
||||
}
|
||||
}
|
||||
14
packages/secubox-metrics/systemd/secubox-geoipupdate.service
Normal file
14
packages/secubox-metrics/systemd/secubox-geoipupdate.service
Normal 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
|
||||
10
packages/secubox-metrics/systemd/secubox-geoipupdate.timer
Normal file
10
packages/secubox-metrics/systemd/secubox-geoipupdate.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=SecuBox — weekly GeoLite2 ASN refresh
|
||||
|
||||
[Timer]
|
||||
OnCalendar=weekly
|
||||
RandomizedDelaySec=4h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
0
packages/secubox-metrics/tests/__init__.py
Normal file
0
packages/secubox-metrics/tests/__init__.py
Normal file
11
packages/secubox-metrics/tests/conftest.py
Normal file
11
packages/secubox-metrics/tests/conftest.py
Normal 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"))
|
||||
88
packages/secubox-metrics/tests/test_cert_status.py
Normal file
88
packages/secubox-metrics/tests/test_cert_status.py
Normal 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
|
||||
56
packages/secubox-metrics/tests/test_config_helpers.py
Normal file
56
packages/secubox-metrics/tests/test_config_helpers.py
Normal 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
|
||||
110
packages/secubox-metrics/tests/test_live_hosts.py
Normal file
110
packages/secubox-metrics/tests/test_live_hosts.py
Normal 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"] == []
|
||||
158
packages/secubox-metrics/tests/test_visitor_origin.py
Normal file
158
packages/secubox-metrics/tests/test_visitor_origin.py
Normal 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"] == []
|
||||
75
tests/integration/test_44_webui_obfuscation.sh
Executable file
75
tests/integration/test_44_webui_obfuscation.sh
Executable 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"
|
||||
Loading…
Reference in New Issue
Block a user