Files
intrusion-detection/app/web/routes.py
T

419 lines
16 KiB
Python

#!/usr/bin/env python3
"""Route definitions for the intrusion-detection web UI."""
from __future__ import annotations
import csv
import io
import json
import os
from flask import Flask, Response, abort, jsonify, render_template, request
from werkzeug.utils import secure_filename
from ..utils.constants import (
APP_REPORT_DATA_DIR,
BLOCKLIST_NAME,
MIKROTIK_DATA_DIR,
WEB_HOST,
WEB_PORT,
WHITELIST_NAMES,
)
from ..utils.ip_utils import ip_version
def register_routes(app: Flask,
report_sources: dict,
scan_kinds: tuple,
web_scan,
helpers) -> None:
"""Register all web UI routes on the Flask application."""
_count_lines = helpers["_count_lines"]
_read_lines = helpers["_read_lines"]
_read_json = helpers["_read_json"]
_params_from_request = helpers["_params_from_request"]
_with_router = helpers["_with_router"]
_sync_firewall = helpers["_sync_firewall"]
# --- log analysis (SQLite-backed) --------------------------------------
_analysis = helpers.get("_analysis")
@app.get("/analysis")
def analysis_page():
return render_template("analysis.html")
@app.get("/api/analysis/top-ips")
def analysis_top_ips():
if _analysis is None:
return jsonify({"error": "analysis unavailable"}), 503
since = (request.args.get("since") or "").strip() or None
limit = request.args.get("limit", 50, type=int)
return jsonify({"top_ips": _analysis["top_ips"](since=since, limit=limit)})
@app.get("/api/analysis/ip/<ip>")
def analysis_ip(ip):
if _analysis is None:
return jsonify({"error": "analysis unavailable"}), 503
return jsonify({"ip": ip,
"timeline": _analysis["ip_timeline"](ip)})
@app.get("/api/analysis/cross-source")
def analysis_cross_source():
if _analysis is None:
return jsonify({"error": "analysis unavailable"}), 503
limit = request.args.get("limit", 100, type=int)
return jsonify({"ips": _analysis["cross_source"](limit=limit)})
@app.get("/api/analysis/manifest")
def analysis_manifest():
return jsonify({"ok": _analysis is not None})
# --- pages -------------------------------------------------------------
@app.get("/")
def index():
return render_template("dashboard.html")
@app.get("/scan")
def scan_page():
return render_template("scan.html")
@app.get("/reports")
def reports_page():
return render_template("reports.html")
@app.get("/blocklist")
def blocklist_page():
return render_template("blocklist.html")
@app.get("/firewall")
def firewall_page():
return render_template("firewall.html")
@app.get("/whitelist")
def whitelist_page():
return render_template("whitelist.html", whitelist_names=WHITELIST_NAMES)
@app.get("/graph")
def graph_page():
return render_template("graph.html")
# --- status ------------------------------------------------------------
@app.get("/api/status")
def status():
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
analysis = {}
if _analysis is not None:
try:
conn = _analysis["conn"]()
analysis = {
"available": True,
"request_count": _analysis["request_count"](conn),
"scan_count": _analysis["scan_count"](conn),
}
except Exception:
analysis = {"available": False}
return jsonify({
"host": WEB_HOST,
"port": WEB_PORT,
"scan_running": web_scan.is_scan_running(),
"latest_scans": web_scan.latest_scans(5),
"blocklist_ipv4_count": _count_lines(v4_path),
"blocklist_ipv6_count": _count_lines(v6_path),
"firewall_snapshot": {
"exists": os.path.isdir(MIKROTIK_DATA_DIR),
"path": MIKROTIK_DATA_DIR,
},
"analysis": analysis,
})
# --- scans -------------------------------------------------------------
@app.post("/api/scan/<kind>")
def start_scan(kind):
if kind not in scan_kinds:
abort(404, f"unknown scan kind: {kind}")
job = web_scan.start_scan(kind, _params_from_request())
if job.get("error"):
return jsonify(job), 409
return jsonify(job), 202
@app.get("/api/scan/status")
def scan_status():
return jsonify({
"running": web_scan.is_scan_running(),
"latest_scans": web_scan.latest_scans(10),
})
@app.get("/api/scan/<job_id>")
def scan_job(job_id):
job = web_scan.get_scan(job_id)
if job is None:
abort(404, f"unknown job: {job_id}")
return jsonify(job)
# --- reports -----------------------------------------------------------
@app.get("/api/reports")
def list_report_artifacts():
source = (request.args.get("source") or "").strip() or None
if source is not None and source not in report_sources:
return jsonify({"error": "unknown report source"}), 400
limit = request.args.get("limit", default=50, type=int)
try:
from ..db import get_connection
from ..db import repositories as repo
result = repo.list_report_artifacts(
get_connection(), source=source,
report_type=(request.args.get("type") or "").strip() or None,
since=(request.args.get("since") or "").strip() or None,
until=(request.args.get("until") or "").strip() or None,
limit=limit,
cursor=(request.args.get("cursor") or "").strip() or None,
)
return jsonify(result)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
@app.get("/api/report-artifacts/<int:artifact_id>")
def get_report_artifact(artifact_id: int):
try:
from ..db import get_connection
from ..db import repositories as repo
artifact = repo.get_report_artifact(get_connection(), artifact_id)
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
if artifact is None:
abort(404, "report artifact not found")
return jsonify(artifact)
@app.get("/api/report-artifacts/<int:artifact_id>/export")
def export_report_artifact(artifact_id: int):
try:
from ..db import get_connection
from ..db import repositories as repo
artifact = repo.get_report_artifact(get_connection(), artifact_id)
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
if artifact is None:
abort(404, "report artifact not found")
payload = artifact["payload"]
export_format = (request.args.get("format") or "json").lower()
filename = secure_filename(os.path.basename(
artifact["original_path"])) or f"report-{artifact_id}"
if export_format == "json":
content = json.dumps(payload, ensure_ascii=False, indent=2)
mimetype = "application/json"
extension = ".json"
elif export_format == "txt" and payload.get("format") == "text":
content = payload.get("content", "")
mimetype = "text/plain; charset=utf-8"
extension = ".txt"
elif export_format == "csv" and payload.get("format") == "csv":
output = io.StringIO(newline="")
writer = csv.DictWriter(
output, fieldnames=payload.get("columns", []),
extrasaction="ignore")
writer.writeheader()
writer.writerows(payload.get("rows", []))
content = output.getvalue()
mimetype = "text/csv; charset=utf-8"
extension = ".csv"
else:
return jsonify({"error": "unsupported export format"}), 406
download_name = (filename if filename.lower().endswith(extension)
else f"{filename}{extension}")
response = Response(content, mimetype=mimetype)
response.headers["Content-Disposition"] = (
f'attachment; filename="{download_name}"')
return response
@app.get("/api/reports/<source>")
def list_reports(source):
base = report_sources.get(source)
if base is None:
abort(404, f"unknown report source: {source}")
names = sorted(
n for n in os.listdir(base)
if os.path.isfile(os.path.join(base, n)))
return jsonify({"source": source, "files": names})
@app.get("/api/reports/<source>/<path:name>")
def get_report(source, name):
base = report_sources.get(source)
if base is None:
abort(404, f"unknown report source: {source}")
full = os.path.realpath(os.path.join(base, name))
if not full.startswith(os.path.realpath(base)):
abort(403, "invalid report path")
if not os.path.isfile(full):
abort(404, "report not found")
with open(full, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
return jsonify({"source": source, "name": name, "content": content})
# --- blocklist ---------------------------------------------------------
@app.get("/api/blocklist")
def blocklist():
return jsonify({
"ipv4": _read_lines(
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")),
"ipv6": _read_lines(
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")),
})
@app.post("/api/blocklist/add")
def blocklist_add():
data = request.get_json(silent=True) or {}
ip = (data.get("ip") or "").strip()
version = ip_version(ip)
if not ip or version not in (4, 6):
return jsonify({"error": "missing or invalid 'ip' field"}), 400
def _add(fw):
if version == 4:
fw.write_address_list_ipv4(BLOCKLIST_NAME, [ip])
else:
fw.write_address_list_ipv6(BLOCKLIST_NAME, [ip])
return jsonify({"ok": True, "action": "add", "ip": ip})
return _with_router(_add)
@app.post("/api/blocklist/remove")
def blocklist_remove():
data = request.get_json(silent=True) or {}
ip = (data.get("ip") or "").strip()
version = ip_version(ip)
if not ip or version not in (4, 6):
return jsonify({"error": "missing or invalid 'ip' field"}), 400
def _remove(fw):
path = "/ip" if version == 4 else "/ipv6"
fw.execute_command(
f"{path} firewall address-list remove "
f"[find list={BLOCKLIST_NAME} address={ip}]")
return jsonify({"ok": True, "action": "remove", "ip": ip})
return _with_router(_remove)
# --- graph -------------------------------------------------------------
@app.get("/api/graph/ipv4")
def graph_ipv4():
blocked = _read_lines(
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt"))
addr_v4 = _read_json(
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
whitelisted = sorted({e.get("address") for e in addr_v4
if e.get("list") in WHITELIST_NAMES
and e.get("address")})
return jsonify({
"blocked": blocked,
"whitelisted": whitelisted,
"counts": {
"blocked": len(blocked),
"whitelisted": len(whitelisted),
},
"blocklist_name": BLOCKLIST_NAME,
"whitelist_names": WHITELIST_NAMES,
})
# --- firewall ----------------------------------------------------------
@app.get("/api/firewall/state")
def firewall_state():
return jsonify({
"address_lists": {
"ipv4": _read_json(
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json")),
"ipv6": _read_json(
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json")),
},
"firewall_rules": {
"ipv4": _read_json(
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv4.json")),
"ipv6": _read_json(
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv6.json")),
},
"snapshot_dir": MIKROTIK_DATA_DIR,
})
@app.get("/api/firewall/snapshots")
def firewall_snapshots():
"""Recent firewall snapshots persisted in the SQLite store (F001)."""
try:
from ..db import get_connection
from ..db import repositories as repo
conn = get_connection()
limit = request.args.get("limit", 10, type=int)
return jsonify({"snapshots": repo.recent_firewall_snapshots(
conn, limit=limit)})
except Exception as exc:
return jsonify({"error": str(exc), "snapshots": []}), 503
@app.post("/api/firewall/sync")
def firewall_sync():
return _sync_firewall(dry_run=True)
@app.post("/api/firewall/apply")
def firewall_apply():
return _sync_firewall(dry_run=False)
# --- whitelist ---------------------------------------------------------
@app.get("/api/whitelist")
def whitelist():
addr_v4 = _read_json(
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
addr_v6 = _read_json(
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json"))
result: dict[str, dict[str, list[str]]] = {}
for name in WHITELIST_NAMES:
result[name] = {
"ipv4": sorted(e.get("address") for e in addr_v4
if e.get("list") == name),
"ipv6": sorted(e.get("address") for e in addr_v6
if e.get("list") == name),
}
return jsonify({"names": WHITELIST_NAMES, "whitelists": result})
@app.post("/api/whitelist/update")
def whitelist_update():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
address = (data.get("address") or "").strip()
action = (data.get("action") or "add").strip().lower()
version = ip_version(address)
if name not in WHITELIST_NAMES:
return jsonify({"error": f"unknown whitelist: {name}"}), 400
if not address or version not in (4, 6):
return jsonify({"error": "missing or invalid 'address' field"}), 400
if action not in ("add", "remove"):
return jsonify({"error": "action must be 'add' or 'remove'"}), 400
def _update(fw):
path = "/ip" if version == 4 else "/ipv6"
if action == "add":
if version == 4:
fw.write_address_list_ipv4(name, [address])
else:
fw.write_address_list_ipv6(name, [address])
else:
fw.execute_command(
f"{path} firewall address-list remove "
f"[find list={name} address={address}]")
return jsonify({"ok": True, "action": action,
"name": name, "address": address})
return _with_router(_update)