diff --git a/infra/debug-dashboard/.env.example b/infra/debug-dashboard/.env.example new file mode 100644 index 0000000..da405ab --- /dev/null +++ b/infra/debug-dashboard/.env.example @@ -0,0 +1,7 @@ +DB_HOST=ocsentinel-postgres +DB_PORT=5432 +DB_NAME=ocsentinel +DB_USER=ocsentinel_debug +DB_PASSWORD=replace-with-server-generated-password +DASHBOARD_USER=ocsentinel-debug +DASHBOARD_PASSWORD=replace-with-server-generated-password diff --git a/infra/debug-dashboard/Dockerfile b/infra/debug-dashboard/Dockerfile new file mode 100644 index 0000000..4eff042 --- /dev/null +++ b/infra/debug-dashboard/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-alpine + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates ./templates +COPY static ./static + +RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel +USER ocsentinel + +EXPOSE 8080 +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "--timeout", "30", "app:app"] diff --git a/infra/debug-dashboard/app.py b/infra/debug-dashboard/app.py new file mode 100644 index 0000000..12ac60f --- /dev/null +++ b/infra/debug-dashboard/app.py @@ -0,0 +1,109 @@ +import hmac +import json +import os +from functools import wraps + +import psycopg +from flask import Flask, Response, abort, render_template + + +app = Flask(__name__) + + +def db_connection(): + return psycopg.connect( + host=os.environ["DB_HOST"], + port=os.getenv("DB_PORT", "5432"), + dbname=os.environ["DB_NAME"], + user=os.environ["DB_USER"], + password=os.environ["DB_PASSWORD"], + connect_timeout=5, + ) + + +def requires_auth(view): + @wraps(view) + def wrapped(*args, **kwargs): + auth = __import__("flask").request.authorization + expected_user = os.environ["DASHBOARD_USER"] + expected_password = os.environ["DASHBOARD_PASSWORD"] + valid = auth and hmac.compare_digest(auth.username or "", expected_user) and hmac.compare_digest(auth.password or "", expected_password) + if not valid: + return Response("Authentication required", 401, {"WWW-Authenticate": 'Basic realm="OCSentinel Debug"'}) + return view(*args, **kwargs) + + return wrapped + + +@app.get("/") +@requires_auth +def overview(): + with db_connection() as connection, connection.cursor() as cursor: + cursor.execute("SELECT * FROM ocsentinel.organization_summary") + summary = cursor.fetchone() + + cursor.execute( + """ + SELECT machine_name, organization_name, received_at, alert_state, + total_events, unique_ip_count, cve_total, cve_critical + FROM ( + SELECT machine_name, received_at, alert_state, total_events, + unique_ip_count, cve_total, cve_critical, + payload #>> '{NinjaOne,OrganizationName}' AS organization_name + FROM ocsentinel.current_device_status + ) AS status + ORDER BY received_at DESC NULLS LAST + LIMIT 100 + """ + ) + reports = cursor.fetchall() + + cursor.execute( + """ + SELECT machine_name, alert_state, total_events, unique_ip_count, + received_at, payload + FROM ocsentinel.current_device_status + WHERE alert_state IN ('warning', 'critical') + ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC + """ + ) + alerts = cursor.fetchall() + + return render_template("overview.html", summary=summary, reports=reports, alerts=alerts) + + +@app.get("/device/") +@requires_auth +def device(machine_name): + with db_connection() as connection, connection.cursor() as cursor: + cursor.execute( + """ + SELECT machine_name, first_seen_at, last_seen_at, last_client_version, + generated_at_utc, received_at, alert_state, base_alert_state, + total_events, unique_ip_count, cve_total, cve_critical, payload + FROM ocsentinel.current_device_status + WHERE machine_name = %s + """, + (machine_name,), + ) + report = cursor.fetchone() + + if report is None: + abort(404) + + payload = report[12] + return render_template("device.html", report=report, payload=payload, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False)) + + +@app.get("/healthz") +def healthz(): + try: + with db_connection() as connection, connection.cursor() as cursor: + cursor.execute("SELECT 1") + return {"status": "ok"} + except Exception: + return {"status": "unavailable"}, 503 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/infra/debug-dashboard/compose.yml b/infra/debug-dashboard/compose.yml new file mode 100644 index 0000000..f0ad4f9 --- /dev/null +++ b/infra/debug-dashboard/compose.yml @@ -0,0 +1,22 @@ +services: + ocsentinel-debug: + build: . + container_name: ocsentinel-debug + restart: unless-stopped + env_file: .env + ports: + - "172.16.41.197:8090:8080" + networks: + - ocsentinel-network + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +networks: + ocsentinel-network: + external: true + name: n8n_n8n-network diff --git a/infra/debug-dashboard/requirements.txt b/infra/debug-dashboard/requirements.txt new file mode 100644 index 0000000..ea78844 --- /dev/null +++ b/infra/debug-dashboard/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.1 +gunicorn==23.0.0 +psycopg[binary]==3.2.9 diff --git a/infra/debug-dashboard/static/app.css b/infra/debug-dashboard/static/app.css new file mode 100644 index 0000000..e17010f --- /dev/null +++ b/infra/debug-dashboard/static/app.css @@ -0,0 +1,10 @@ +:root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; } +* { box-sizing:border-box; } +body { margin:0; color:var(--ink); background:radial-gradient(circle at 86% -10%, #d6efad 0, transparent 28rem), var(--paper); font-family:Georgia, 'Times New Roman', serif; } +.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(255,253,247,.82); backdrop-filter:blur(10px); } +.brand { color:var(--ink); font:700 20px/1 Arial,sans-serif; text-decoration:none; letter-spacing:-.04em; }.brand span { display:inline-grid; place-items:center; margin-right:7px; width:28px; height:28px; background:var(--green); color:#fff; border-radius:50%; font-size:11px; letter-spacing:0; }.badge,.eyebrow { color:var(--muted); font:700 10px/1 Arial,sans-serif; text-transform:uppercase; letter-spacing:.12em; }.badge { border:1px solid var(--line); padding:6px 8px; border-radius:20px; } +main { max-width:1280px; margin:auto; padding:58px 6vw 80px; }.hero { max-width:650px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; } +.metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:1px; margin:25px 0 46px; border:1px solid var(--line); background:var(--line); }.metrics article { min-height:130px; padding:20px; background:var(--panel); }.metrics span { display:block; color:var(--muted); font:700 10px Arial,sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px Arial,sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); } +.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); background:#fff7e9; color:var(--ink); text-decoration:none; }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px Arial,sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px Arial,sans-serif; letter-spacing:-.04em; } +table { width:100%; border-collapse:collapse; font-family:Arial,sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px Arial,sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; } +@media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { padding:0 5vw; }.badge { display:none; } main { padding:38px 5vw; } } diff --git a/infra/debug-dashboard/templates/base.html b/infra/debug-dashboard/templates/base.html new file mode 100644 index 0000000..e7e4f81 --- /dev/null +++ b/infra/debug-dashboard/templates/base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}OCSentinel Debug{% endblock %} + + + +
+ OC Sentinel Debug +
read-only database console
+
+
{% block content %}{% endblock %}
+ + diff --git a/infra/debug-dashboard/templates/device.html b/infra/debug-dashboard/templates/device.html new file mode 100644 index 0000000..f5a5ede --- /dev/null +++ b/infra/debug-dashboard/templates/device.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}{{ report[0] }} · OCSentinel Debug{% endblock %} +{% block content %} +

Gerätedetail

{{ report[0] }}

{{ report[6] }} · Eingang {{ report[5] }}

+
Events{{ report[8] }}
Quell-IPs{{ report[9] }}
CVEs{{ report[10] }}
Kritisch/High{{ report[11] }}
+

Forensik

Signierter Rohreport

{{ payload_pretty }}
+{% endblock %} diff --git a/infra/debug-dashboard/templates/overview.html b/infra/debug-dashboard/templates/overview.html new file mode 100644 index 0000000..1cd6d5f --- /dev/null +++ b/infra/debug-dashboard/templates/overview.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block content %} +
+

Central Ingest

+

Endpoint Signals, ohne Rauschen.

+

Letzte signierte OCSentinel-Berichte aus PostgreSQL.

+
+ +
+
Berichtende Geräte{{ summary[0] }}
+
Warnungen{{ summary[1] }}
+
Kritisch{{ summary[2] }}
+
Events{{ summary[3] }}
+
Letzter Eingang{{ summary[7] or 'noch keiner' }}
+
+ +{% if alerts %} +
+

Priorität

Auffällige Geräte

+ +
+{% endif %} + +
+

Live-Bestand

Letzte Geräteberichte

+
+ {% for row in reports %}{% endfor %}
GerätOrganisationStatusEventsIPsEmpfangen
{{ row[0] }}{{ row[1] or '—' }}{{ row[3] }}{{ row[4] }}{{ row[5] }}{{ row[2] or '—' }}
+
+{% endblock %}