Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft, siehe Handbuch „Sicherheitslage“ und „Automatisierung“. - **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten, Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers. Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups, Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle `api_tokens` (Migration `0008`). - **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle, offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls. - **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse, Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens. - **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP). - **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert 0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`). - Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der Anfrage neu geladen.
105 lines
4.5 KiB
Python
105 lines
4.5 KiB
Python
"""Prometheus endpoint: format, content and that it never calls a firewall."""
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.carp_event import CarpEvent
|
|
from app.models.notification import NotificationProblem
|
|
from app.models.resource_sample import HostResourceSample
|
|
from tests.test_api_tokens import _bearer, _make
|
|
from tests.test_rules import _create_host
|
|
|
|
_SAMPLE_LINE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*(\{([a-zA-Z_][a-zA-Z0-9_]*="([^"\\]|\\.)*"(,|(?=\})))*\})? -?\d+(\.\d+)?(e[+-]?\d+)?$')
|
|
|
|
|
|
def _parse(text: str) -> dict[str, float]:
|
|
"""name{labels} -> value, asserting the exposition grammar line by line."""
|
|
values = {}
|
|
for line in text.splitlines():
|
|
if not line or line.startswith("# HELP ") or line.startswith("# TYPE "):
|
|
continue
|
|
assert _SAMPLE_LINE.match(line), f"not valid exposition: {line!r}"
|
|
key, _, value = line.rpartition(" ")
|
|
values[key] = float(value)
|
|
return values
|
|
|
|
|
|
async def _seed(host_id: str, *, age_s: int, carp: str | None):
|
|
async with SessionLocal() as db:
|
|
db.add(HostResourceSample(
|
|
host_id=host_id, sampled_at=datetime.now(timezone.utc) - timedelta(seconds=age_s),
|
|
mem_used_pct=25.0, load_avg_1m=0.42, disk_used_pct=12.0, raw={},
|
|
))
|
|
if carp:
|
|
db.add(CarpEvent(host_id=host_id, host_name="fw1", cluster_name="ha", from_status=None, to_status=carp))
|
|
await db.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metrics_requires_auth_and_is_prometheus_text(client, anon_client):
|
|
assert (await anon_client.get("/api/metrics")).status_code == 401
|
|
resp = await client.get("/api/metrics")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("text/plain; version=0.0.4")
|
|
values = _parse(resp.text)
|
|
assert values["opnexus_hosts"] == 0
|
|
assert any(key.startswith('opnexus_info{version="') for key in values)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_host_metrics_from_stored_samples_without_calling_the_firewall(client, respx_mock):
|
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
|
await _seed(host["id"], age_s=30, carp="MASTER")
|
|
before = len(respx_mock.calls)
|
|
values = _parse((await client.get("/api/metrics")).text)
|
|
# The scrape is DB-only: not a single request went to a firewall.
|
|
assert len(respx_mock.calls) == before
|
|
assert values["opnexus_hosts"] == 1
|
|
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 1
|
|
assert values['opnexus_host_memory_used_percent{host="fw1",cluster=""}'] == 25
|
|
assert values['opnexus_host_load1{host="fw1",cluster=""}'] == 0.42
|
|
assert values['opnexus_host_disk_used_percent{host="fw1",cluster=""}'] == 12
|
|
assert values['opnexus_host_carp_master{host="fw1",cluster=""}'] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_sample_means_host_down_and_unknown_carp_is_omitted(client):
|
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
|
await _seed(host["id"], age_s=600, carp=None)
|
|
text = (await client.get("/api/metrics")).text
|
|
values = _parse(text)
|
|
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 0
|
|
assert "opnexus_host_carp_master{" not in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_problems_are_counted_and_resolved_ones_are_not(client):
|
|
async with SessionLocal() as db:
|
|
db.add(NotificationProblem(problem_type="host_offline", dedup_key="a", status="open"))
|
|
db.add(NotificationProblem(problem_type="host_offline", dedup_key="b", status="open"))
|
|
db.add(NotificationProblem(problem_type="cert_expiry", dedup_key="c", status="resolved"))
|
|
await db.commit()
|
|
values = _parse((await client.get("/api/metrics")).text)
|
|
assert values['opnexus_problems_open{type="host_offline"}'] == 2
|
|
assert not any("cert_expiry" in key for key in values)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_label_values_are_escaped(client):
|
|
await _create_host(client, 'we"ird\\name', "https://fw1.test")
|
|
text = (await client.get("/api/metrics")).text
|
|
assert 'host="we\\"ird\\\\name"' in text
|
|
_parse(text)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_token_can_scrape(client):
|
|
created = await _make(client, "read", name="prometheus")
|
|
async with _bearer(created["token"]) as api:
|
|
resp = await api.get("/api/metrics")
|
|
assert resp.status_code == 200 and "opnexus_info" in resp.text
|