diff --git a/app/app.py b/app/app.py index 6b9b3bf..e2a550d 100644 --- a/app/app.py +++ b/app/app.py @@ -1,7 +1,9 @@ import os +import re import time import ipaddress import logging +from datetime import datetime, timedelta, timezone import requests from flask import Flask, render_template, request, jsonify, abort @@ -42,6 +44,52 @@ def _jwt(): return _token["jwt"] +_GO_DURATION_RE = re.compile( + r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?(?:(\d+(?:\.\d+)?)(?:us|µs))?(?:(\d+)ns)?$" +) + + +def _parse_go_duration(s): + if not s: + return None + m = _GO_DURATION_RE.match(s.strip()) + if not m or not any(m.groups()): + return None + h, mn, sc, ms, us, ns = m.groups() + return timedelta( + hours=int(h or 0), + minutes=int(mn or 0), + seconds=float(sc or 0), + milliseconds=float(ms or 0), + microseconds=float(us or 0) + (float(ns or 0) / 1000.0), + ) + + +def _parse_iso(s): + if not s: + return None + # CrowdSec emits RFC3339 w/ nanos; Python fromisoformat tolerates up to micros only. + s = s.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + # Trim fractional seconds to 6 digits. + s = re.sub(r"(\.\d{6})\d+", r"\1", s) + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def _enrich(decisions): + for d in decisions: + until = _parse_iso(d.get("until")) + dur = _parse_go_duration(d.get("duration")) + if until and dur: + created = until - dur + d["created_at"] = created.astimezone(timezone.utc).isoformat() + return decisions + + def _bouncer(method, path, **kw): headers = kw.pop("headers", {}) headers["X-Api-Key"] = BOUNCER_KEY @@ -114,10 +162,44 @@ def list_decisions(): r = _bouncer("GET", "/v1/decisions", params=params) if r.status_code != 200: return render_template("_decisions.html", error=f"LAPI {r.status_code}: {r.text[:200]}", decisions=[], total=0, limit=limit), 502 - decisions = r.json() or [] + decisions = _enrich(r.json() or []) return render_template("_decisions.html", decisions=decisions, error=None, total=len(decisions), limit=limit) +ALERT_SINCE_DEFAULT = "1h" +ALERT_SINCE_ALLOWED = {"15m", "1h", "6h", "24h", "168h", "720h"} + + +@app.get("/alerts") +def list_alerts(): + since = request.args.get("since", ALERT_SINCE_DEFAULT).strip() + if since not in ALERT_SINCE_ALLOWED: + since = ALERT_SINCE_DEFAULT + ip = request.args.get("ip", "").strip() + scenario = request.args.get("scenario", "").strip() + origin = request.args.get("origin", "").strip() + try: + limit = max(1, min(MAX_LIMIT, int(request.args.get("limit", DEFAULT_LIMIT)))) + except ValueError: + limit = DEFAULT_LIMIT + + params = {"limit": limit, "since": since} + if ip: + if not valid_ip(ip): + return render_template("_alerts.html", error="invalid IP", alerts=[], total=0, limit=limit, since=since), 400 + params["ip"] = ip + if scenario: + params["scenario"] = scenario + if origin: + params["origin"] = origin + + r = _machine("GET", "/v1/alerts", params=params) + if r.status_code != 200: + return render_template("_alerts.html", error=f"LAPI {r.status_code}: {r.text[:200]}", alerts=[], total=0, limit=limit, since=since), 502 + alerts = r.json() or [] + return render_template("_alerts.html", alerts=alerts, error=None, total=len(alerts), limit=limit, since=since) + + @app.post("/unban") def unban(): ip = request.form.get("ip", "").strip() diff --git a/app/templates/_alerts.html b/app/templates/_alerts.html new file mode 100644 index 0000000..6318ec5 --- /dev/null +++ b/app/templates/_alerts.html @@ -0,0 +1,84 @@ +{% if error %} +

{{ error }}

+{% endif %} +{% if alerts %} +

+ Showing {{ total }} alert{{ '' if total == 1 else 's' }} + since {{ since }} + (server limit {{ limit }}{% if total == limit %} — at cap, raise limit if more expected{% endif %}). +

+ + + + + + + + + + + + + + + {% for a in alerts %} + {% set src = a.source or {} %} + + + + + + + + + + + {% endfor %} + +
IDScope:ValueReasonCountryASEventsDecisionsCreated
{{ a.id }}{{ src.scope or a.scope or '?' }}:{{ src.value or a.value or '?' }} + {{ a.scenario }} + {% if a.events_count and a.events_count > 1 %}×{{ a.events_count }}{% endif %} + {{ src.cn or '—' }}{% if src.as_number %}{{ src.as_number }}{% if src.as_name %} {{ src.as_name }}{% endif %}{% else %}—{% endif %}{{ a.events_count or 0 }} + {% if a.decisions %} + {% for dec in a.decisions %} + {{ dec.type }} {{ dec.duration }}{% if dec.simulated %} sim{% endif %} + {% endfor %} + {% else %} + — + {% endif %} +
+ +{% else %} + {% if not error %}

No alerts in window.

{% endif %} +{% endif %} diff --git a/app/templates/_decisions.html b/app/templates/_decisions.html index 58e10b6..0e10f14 100644 --- a/app/templates/_decisions.html +++ b/app/templates/_decisions.html @@ -16,7 +16,7 @@ - IDIP / valueScopeTypeReasonUntilOrigin + IDIP / valueScopeTypeReasonCreatedUntilOrigin @@ -28,7 +28,8 @@ {{ d.scope }} {{ d.type }} {{ d.scenario }} - {{ d.until }} + + {{ d.origin }}