feat: alerts view + created-at timestamps on decisions
All checks were successful
Deploy / deploy (push) Successful in 22s
All checks were successful
Deploy / deploy (push) Successful in 22s
Mirror cscli alerts list: new /alerts endpoint hits LAPI machine auth with since/ip/scenario/origin filters, renders ID, scope:value, reason, country, AS, events, decisions, created_at. Decisions table gains a Created column derived from until - duration (LAPI does not expose created_at on /v1/decisions). Both views format timestamps locally and append a relative "x ago" via Intl.RelativeTimeFormat, refreshed every 30s on the decisions view.
This commit is contained in:
84
app/app.py
84
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()
|
||||
|
||||
Reference in New Issue
Block a user