Add read-only OCSentinel debug dashboard
This commit is contained in:
7
infra/debug-dashboard/.env.example
Normal file
7
infra/debug-dashboard/.env.example
Normal file
@@ -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
|
||||||
15
infra/debug-dashboard/Dockerfile
Normal file
15
infra/debug-dashboard/Dockerfile
Normal file
@@ -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"]
|
||||||
109
infra/debug-dashboard/app.py
Normal file
109
infra/debug-dashboard/app.py
Normal file
@@ -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/<machine_name>")
|
||||||
|
@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)
|
||||||
22
infra/debug-dashboard/compose.yml
Normal file
22
infra/debug-dashboard/compose.yml
Normal file
@@ -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
|
||||||
3
infra/debug-dashboard/requirements.txt
Normal file
3
infra/debug-dashboard/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
10
infra/debug-dashboard/static/app.css
Normal file
10
infra/debug-dashboard/static/app.css
Normal file
@@ -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; } }
|
||||||
16
infra/debug-dashboard/templates/base.html
Normal file
16
infra/debug-dashboard/templates/base.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}OCSentinel Debug{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="masthead">
|
||||||
|
<a href="/" class="brand"><span>OC</span> Sentinel Debug</a>
|
||||||
|
<div class="badge">read-only database console</div>
|
||||||
|
</header>
|
||||||
|
<main>{% block content %}{% endblock %}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
infra/debug-dashboard/templates/device.html
Normal file
7
infra/debug-dashboard/templates/device.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ report[0] }} · OCSentinel Debug{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero compact"><p class="eyebrow">Gerätedetail</p><h1>{{ report[0] }}</h1><p><span class="state {{ report[6] }}">{{ report[6] }}</span> · Eingang {{ report[5] }}</p></section>
|
||||||
|
<section class="metrics compact-metrics"><article><span>Events</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritisch/High</span><strong class="critical">{{ report[11] }}</strong></article></section>
|
||||||
|
<section class="panel"><div class="panel-heading"><p class="eyebrow">Forensik</p><h2>Signierter Rohreport</h2></div><pre>{{ payload_pretty }}</pre></section>
|
||||||
|
{% endblock %}
|
||||||
35
infra/debug-dashboard/templates/overview.html
Normal file
35
infra/debug-dashboard/templates/overview.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">Central Ingest</p>
|
||||||
|
<h1>Endpoint Signals, ohne Rauschen.</h1>
|
||||||
|
<p>Letzte signierte OCSentinel-Berichte aus PostgreSQL.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metrics">
|
||||||
|
<article><span>Berichtende Geräte</span><strong>{{ summary[0] }}</strong></article>
|
||||||
|
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
||||||
|
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong></article>
|
||||||
|
<article><span>Events</span><strong>{{ summary[3] }}</strong></article>
|
||||||
|
<article><span>Letzter Eingang</span><strong class="timestamp">{{ summary[7] or 'noch keiner' }}</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<section class="panel alert-panel">
|
||||||
|
<div class="panel-heading"><p class="eyebrow">Priorität</p><h2>Auffällige Geräte</h2></div>
|
||||||
|
<div class="alert-grid">
|
||||||
|
{% for alert in alerts %}
|
||||||
|
<a class="alert-card {{ alert[1] }}" href="{{ url_for('device', machine_name=alert[0]) }}">
|
||||||
|
<span>{{ alert[1] }}</span><strong>{{ alert[0] }}</strong><small>{{ alert[2] }} Events · {{ alert[3] }} IPs · {{ alert[4] }}</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading"><p class="eyebrow">Live-Bestand</p><h2>Letzte Geräteberichte</h2></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Gerät</th><th>Organisation</th><th>Status</th><th>Events</th><th>IPs</th><th>Empfangen</th></tr></thead>
|
||||||
|
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row[0]) }}">{{ row[0] }}</a></td><td>{{ row[1] or '—' }}</td><td><span class="state {{ row[3] }}">{{ row[3] }}</span></td><td>{{ row[4] }}</td><td>{{ row[5] }}</td><td>{{ row[2] or '—' }}</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user