feat: add localhost web UI for scans, blocklist, and firewall management

This commit is contained in:
zwitschi committed 2026-08-05 20:34:34 +02:00
1 parent a9dc560f80
commit 4ef6892916
19 files changed
+1934 -117

No files matched your search

+8
View File
@@ -9,6 +9,14 @@
# so production deployments can set these via the OS and skip `.env`.
# `.env` is git-ignored — never commit real secrets to it.
# --- Web server (localhost only) -------------------------------------------------
# The Flask web UI binds to localhost so it is only reachable from this machine.
# Do not expose WEB_HOST beyond 127.0.0.1!
WEB_HOST=127.0.0.1
# Please choose a very high port number (e.g. 54666) to avoid conflicts with other services.
WEB_PORT=54666
# --- Log sources -----------------------------------------------------------
# Directory containing copyparty access logs (YYYY-MM-DD.txt files).
+49 -10
View File
@@ -55,7 +55,11 @@ intrusion-detection/
│ ├── whois.py query_whois_subnet, consolidate_by_subnet
│ ├── mikrotik.py RouterOS .rsc script generation
│ ├── files.py read_lines, find_logs, write_csv/json/lines
│ └── reporting.py Column, render_table, render_kv, timestamp_name
│ ├── reporting.py Column, render_table, render_kv, timestamp_name
│ ├── web.py Flask web UI (app factory, JSON API, pages)
│ ├── web_scan.py background scan runner + in-memory job store
│ ├── templates/ Jinja2 page templates (dashboard, scan, reports, …)
│ └── static/ style.css + app.js (dark-theme UI)
├── data/ whitelist source files (cached locally)
│ ├── openfactory/
│ │ └── geofeed.csv
@@ -88,6 +92,8 @@ Core responsibilities by module:
| `app/whois.py` | `query_whois_subnet`, `consolidate_by_subnet` |
| `app/mikrotik.py` | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` |
| `app/firewall.py` | `MikrotikFirewall` — SSH connect, sync blocklist, audit state |
| `app/web.py` | Flask app factory, JSON API routes, page rendering |
| `app/web_scan.py` | background scan runner, in-memory job store, single-scan busy lock |
| `app/files.py` | `read_lines`, `find_logs`, `validate_dir`, `ensure_dirs`, `write_csv/json/lines` |
| `app/reporting.py` | `Column`, `render_table`, `render_kv`, `timestamp_name` |
@@ -96,7 +102,7 @@ Console output and saved reports are the **same string** (`Analyzer.text_report(
## Requirements
- Python 3.10+
- `paramiko` (MikroTik SSH), `python-dotenv` (`.env` loading)
- `paramiko` (MikroTik SSH), `python-dotenv` (`.env` loading), `flask` (web UI)
```powershell
pip install -r requirements.txt
@@ -126,6 +132,8 @@ production can set the same variables via the OS and skip `.env` entirely.
| `ROUTER_PASSWORD` | _(set in .env)_ | MikroTik connection |
| `BLOCKLIST_NAME` | `Blocked` | MikroTik address list |
| `WHITELIST_NAMES` | `Cloudflare,Openfactory` | MikroTik whitelists |
| `WEB_HOST` | `127.0.0.1` | web UI bind host |
| `WEB_PORT` | `54666` | web UI port |
See `.env.example` for the complete list of supported variables (WHOIS
tuning, scan thresholds, whitelist source URLs, and more).
@@ -133,11 +141,15 @@ tuning, scan thresholds, whitelist source URLs, and more).
## Usage
```powershell
# unified entry point (both sources, optional firewall sync)
# web UI (default entry point)
python app.py
python app.py --quiet
python app.py --update-firewall --dry-run
python app.py --update-firewall
python app.py --web-port 54666
# consolidated CLI scan (scan subcommand)
python app.py scan
python app.py scan --quiet
python app.py scan --update-firewall --dry-run
python app.py scan --update-firewall
# individual scanners (still runnable directly)
python -m app.copyparty
@@ -157,19 +169,46 @@ All scripts accept CLI overrides:
| `--min-denied N` | `1` (`copyparty.py`) | denied-hit threshold |
| `--update-firewall` | off | sync blocklist to MikroTik via SSH |
| `--dry-run` | off | preview firewall changes, no apply |
| `--web-host` | `127.0.0.1` | web UI bind host |
| `--web-port` | `54666` | web UI port |
`app.py` accepts all flags from both scanners plus `--copyparty-log-path`,
`--copyparty-report-dir`, `--npm-log-path`, `--npm-report-dir`, and
`--report-dir` for the consolidated output.
`python app.py scan` accepts all flags from both scanners plus
`--copyparty-log-path`, `--copyparty-report-dir`, `--npm-log-path`,
`--npm-report-dir`, and `--report-dir` for the consolidated output.
Examples:
```powershell
python -m app.npm --log-path D:\logs\npm --min-errors 10 --error-ratio 0.8
python -m app.copyparty --top 100
python app.py --quiet --update-firewall --dry-run
python app.py scan --quiet --update-firewall --dry-run
```
## Web UI
`python app.py` starts a **localhost-only** Flask web UI (default
`http://127.0.0.1:54666`) for running scans and managing the MikroTik
blocklist / firewall. It is bound to `127.0.0.1` — do **not** expose
`WEB_HOST` beyond localhost.
Pages:
| Route | Purpose |
| ------------ | ----------------------------------------------------- |
| `/` | dashboard — status cards + latest scans |
| `/scan` | run copyparty / npm / consolidated scans (background) |
| `/reports` | browse reports per source |
| `/blocklist` | view the consolidated blocklist, add/remove entries |
| `/firewall` | view address lists + rules, preview/apply a sync |
| `/whitelist` | view/manage provider whitelist CIDRs |
Scans run in a background thread (only one at a time) and report progress via
the JSON API, so the UI never blocks. Firewall sync is **dry-run by default**
from the UI — applying changes is an explicit action. The main JSON endpoints
(`/api/status`, `/api/scan/*`, `/api/reports/*`, `/api/blocklist`,
`/api/firewall/*`, `/api/whitelist`) are also usable directly from a REST
client.
## Output
Each script writes to its own report directory. `app.py` also writes
+43 -6
View File
@@ -1,18 +1,55 @@
#!/usr/bin/env python3
"""Intrusion Detection System — unified entry point.
Runs both copyparty and NPM log analyzers, consolidates blocklist
candidates, and optionally synchronizes the MikroTik RouterOS firewall.
By default this starts the localhost-only Flask web UI. Pass ``scan`` to
run the consolidated CLI scan instead.
Usage:
python app.py --quiet
python app.py --update-firewall --dry-run
python app.py --update-firewall
python app.py # start the web UI (default)
python app.py --web-port 54666 # web UI with a custom port
python app.py scan [options] # run the consolidated CLI scan
"""
from __future__ import annotations
from app.scan import main
import argparse
import sys
from app.constants import WEB_HOST, WEB_PORT
def _web_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="app.py",
description="Intrusion Detection System: web UI (default) or 'scan' subcommand.",
)
parser.add_argument("--web-host", default=WEB_HOST,
help=f"web UI bind host (default: {WEB_HOST})")
parser.add_argument("--web-port", type=int, default=WEB_PORT,
help=f"web UI port (default: {WEB_PORT})")
return parser
def _run_web(host: str, port: int) -> None:
from app.web import serve
serve(host=host, port=port)
def _run_scan(argv: list[str]) -> None:
from app.scan import main as scan_main
scan_main(argv)
def main() -> None:
# `python app.py scan ...` runs the consolidated CLI scan.
if len(sys.argv) > 1 and sys.argv[1] == "scan":
_run_scan(sys.argv[2:])
return
# Default: start the web UI (parse_known_args tolerates stray flags).
args, _ = _web_parser().parse_known_args()
_run_web(args.web_host, args.web_port)
if __name__ == "__main__":
main()
+9
View File
@@ -102,6 +102,15 @@ DEFAULT_ERROR_RATIO = float(os.environ.get(
"DEFAULT_ERROR_RATIO", "0.5")) # npm
DEFAULT_TOP_PATHS = int(os.environ.get("DEFAULT_TOP_PATHS", "20"))
# ---------------------------------------------------------------------------
# Web server (localhost only)
# ---------------------------------------------------------------------------
# The Flask web UI binds to localhost on a very high port so it is only
# reachable from this machine. Do not expose WEB_HOST beyond 127.0.0.1.
WEB_HOST = os.environ.get("WEB_HOST", "127.0.0.1")
WEB_PORT = int(os.environ.get("WEB_PORT", "54666"))
# ---------------------------------------------------------------------------
# Whitelist sources (configurable via environment)
# ---------------------------------------------------------------------------
+194 -99
View File
@@ -2,12 +2,13 @@
"""Consolidated intrusion detection log scanner.
Runs both copyparty and Nginx Proxy Manager (NPM) log analyzers,
and outputs a single consolidated MikroTik RouterOS blocklist script.
and outputs a single consolidated MikroTik RouterOS blocklist.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass, field
from .constants import (
COPYPARTY_LOG_PATH,
@@ -29,7 +30,184 @@ from .mikrotik import split_cidr_versions
from .whois import consolidate_by_subnet
def main() -> None:
@dataclass
class ScanParams:
"""Configuration for a consolidated scan.
Shared by the CLI entry point and the web interface so both run the
exact same pipeline.
"""
copyparty_log_path: str = COPYPARTY_LOG_PATH
copyparty_report_dir: str = COPYPARTY_REPORT_DIR
min_denied: int = DEFAULT_MIN_DENIED
npm_log_path: str = NPM_LOG_PATH
npm_report_dir: str = NPM_REPORT_DIR
min_errors: int = DEFAULT_MIN_ERRORS
error_ratio: float = DEFAULT_ERROR_RATIO
report_dir: str = REPORT_DIR
top: int = DEFAULT_TOP
quiet: bool = False
update_firewall: bool = False
dry_run: bool = False
@dataclass
class ScanResult:
"""Structured outcome of a consolidated scan."""
ok: bool
cp_exists: bool = False
npm_exists: bool = False
cp_candidates: int = 0
npm_candidates: int = 0
blocked_ips: int = 0
blocked_targets: int = 0
blocked_ipv4: list[str] = field(default_factory=list)
blocked_ipv6: list[str] = field(default_factory=list)
blocklist_v4_path: str | None = None
blocklist_v6_path: str | None = None
firewall_connected: bool = False
firewall_changes: dict | None = None
errors: list[str] = field(default_factory=list)
def run_scan(params: ScanParams) -> ScanResult:
"""Run the full consolidated scan pipeline and return structured results.
Steps: connect to MikroTik (optional) -> snapshot state -> run copyparty
+ NPM analyzers -> consolidate candidates -> write blocklists -> compare
and apply firewall changes (optional).
"""
errors: list[str] = []
# --- Step 1-3: connect to MikroTik RouterOS and capture current state ---
# If the router is unreachable we fall back to scan-only mode: still
# generate reports and blocklists, but skip the firewall sync.
firewall: MikrotikFirewall | None = None
if params.update_firewall:
firewall = MikrotikFirewall()
try:
firewall.connect()
# Read current firewall rules + address lists and snapshot them to
# reports/mikrotik/data/ and the git-diffable data/mikrotik/.
firewall.save_state_report(params.report_dir)
except Exception as e:
message = f"could not read MikroTik router ({firewall.router_ip}): {e}"
errors.append(message)
print(f"\nWARNING: {message}")
firewall.disconnect()
firewall = None
# --- Step 4: run copyparty + NPM analyzers -> blocklist candidates ------
cp_analyzer = CopypartyAnalyzer(
log_path=params.copyparty_log_path,
report_dir=params.copyparty_report_dir,
top=params.top,
quiet=params.quiet,
min_denied=params.min_denied,
)
npm_analyzer = NpmAnalyzer(
log_path=params.npm_log_path,
report_dir=params.npm_report_dir,
top=params.top,
quiet=params.quiet,
min_errors=params.min_errors,
error_ratio=params.error_ratio,
)
print("=== Running copyparty scan ===")
cp_exists = True
try:
cp_analyzer.run()
except Exception as e:
errors.append(f"copyparty scan failed: {e}")
print(f"Error running copyparty scan: {e}")
cp_exists = False
print("\n=== Running NPM scan ===")
npm_exists = True
try:
npm_analyzer.run()
except Exception as e:
errors.append(f"NPM scan failed: {e}")
print(f"Error running NPM scan: {e}")
npm_exists = False
if not cp_exists and not npm_exists:
message = "Both scans failed. Unable to generate consolidated blocklist."
errors.append(message)
print(f"\n{message}")
if firewall is not None:
firewall.disconnect()
return ScanResult(ok=False, errors=errors)
# Consolidate block candidates
consolidated_candidates: set[str] = set()
if cp_exists:
consolidated_candidates.update(cp_analyzer.block_candidates())
if npm_exists:
consolidated_candidates.update(npm_analyzer.block_candidates())
# Exclude whitelisted IPs
final_blocked_ips = {
ip for ip in consolidated_candidates if not is_whitelisted(ip)}
# Group into subnets via WHOIS if multiple malicious IPs in same subnet
print("\nGrouping blocked IPs into subnets using WHOIS...")
final_blocked_targets = consolidate_by_subnet(
final_blocked_ips, threshold=SUBNET_THRESHOLD)
blocked_ipv4, blocked_ipv6 = split_cidr_versions(final_blocked_targets)
cp_candidates = len(cp_analyzer.block_candidates()) if cp_exists else 0
npm_candidates = len(npm_analyzer.block_candidates()) if npm_exists else 0
print("\n=== Consolidated Summary ===")
print(f"copyparty block candidates: {cp_candidates}")
print(f"NPM block candidates: {npm_candidates}")
print(f"Total unified blocked IPs: {len(final_blocked_ips)}")
print(f"Total target blocks/subnets: {len(final_blocked_targets)}")
# --- Step 6: write consolidated blocklists to reports/app/data ----------
blocklist_v4_path, blocklist_v6_path = write_blocklist_files(
blocked_ipv4, blocked_ipv6, params.report_dir)
print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}")
# --- Steps 5+7: compare current state with candidates and apply ---------
firewall_changes: dict | None = None
if firewall is not None:
try:
firewall_changes = firewall.sync_blocklist(
blocked_ipv4, blocked_ipv6, dry_run=params.dry_run,
report_dir=params.report_dir)
finally:
firewall.disconnect()
action = "Would" if params.dry_run else ""
print(f"{action} add {len(firewall_changes['added_ipv4'])} IPv4 and "
f"{len(firewall_changes['added_ipv6'])} IPv6 addresses; "
f"remove {len(firewall_changes['removed_ipv4'])} IPv4 and "
f"{len(firewall_changes['removed_ipv6'])} IPv6 addresses.")
return ScanResult(
ok=True,
cp_exists=cp_exists,
npm_exists=npm_exists,
cp_candidates=cp_candidates,
npm_candidates=npm_candidates,
blocked_ips=len(final_blocked_ips),
blocked_targets=len(final_blocked_targets),
blocked_ipv4=blocked_ipv4,
blocked_ipv6=blocked_ipv6,
blocklist_v4_path=blocklist_v4_path,
blocklist_v6_path=blocklist_v6_path,
firewall_connected=firewall is not None,
firewall_changes=firewall_changes,
errors=errors,
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Consolidated IDS Scanner: run copyparty + NPM analyzers and generate unified blocklist."
)
@@ -63,109 +241,26 @@ def main() -> None:
help="synchronize the MikroTik Blocked list after scanning")
parser.add_argument("--dry-run", action="store_true",
help="preview firewall changes without applying them")
return parser
args = parser.parse_args()
# --- Step 1-3: connect to MikroTik RouterOS and capture current state ---
# If the router is unreachable we fall back to scan-only mode: still
# generate reports and blocklists, but skip the firewall sync.
firewall: MikrotikFirewall | None = None
if args.update_firewall:
firewall = MikrotikFirewall()
try:
firewall.connect()
# Read current firewall rules + address lists and snapshot them to
# reports/mikrotik/data/ and the git-diffable data/mikrotik/.
firewall.save_state_report(args.report_dir)
except Exception as e:
print(f"\nWARNING: could not read MikroTik router "
f"({firewall.router_ip}): {e}")
firewall.disconnect()
firewall = None
# --- Step 4: run copyparty + NPM analyzers -> blocklist candidates ------
cp_analyzer = CopypartyAnalyzer(
log_path=args.copyparty_log_path,
report_dir=args.copyparty_report_dir,
top=args.top,
quiet=args.quiet,
def main(argv: list[str] | None = None) -> None:
args = _build_parser().parse_args(argv)
params = ScanParams(
copyparty_log_path=args.copyparty_log_path,
copyparty_report_dir=args.copyparty_report_dir,
min_denied=args.min_denied,
)
npm_analyzer = NpmAnalyzer(
log_path=args.npm_log_path,
report_dir=args.npm_report_dir,
top=args.top,
quiet=args.quiet,
npm_log_path=args.npm_log_path,
npm_report_dir=args.npm_report_dir,
min_errors=args.min_errors,
error_ratio=args.error_ratio,
report_dir=args.report_dir,
top=args.top,
quiet=args.quiet,
update_firewall=args.update_firewall,
dry_run=args.dry_run,
)
print("=== Running copyparty scan ===")
cp_exists = True
try:
cp_analyzer.run()
except Exception as e:
print(f"Error running copyparty scan: {e}")
cp_exists = False
print("\n=== Running NPM scan ===")
npm_exists = True
try:
npm_analyzer.run()
except Exception as e:
print(f"Error running NPM scan: {e}")
npm_exists = False
if not cp_exists and not npm_exists:
print("\nBoth scans failed. Unable to generate consolidated blocklist.")
if firewall is not None:
firewall.disconnect()
return
# Consolidate block candidates
consolidated_candidates: set[str] = set()
if cp_exists:
consolidated_candidates.update(cp_analyzer.block_candidates())
if npm_exists:
consolidated_candidates.update(npm_analyzer.block_candidates())
# Exclude whitelisted IPs
final_blocked_ips = {
ip for ip in consolidated_candidates if not is_whitelisted(ip)}
# Group into subnets via WHOIS if multiple malicious IPs in same subnet
print("\nGrouping blocked IPs into subnets using WHOIS...")
final_blocked_targets = consolidate_by_subnet(
final_blocked_ips, threshold=SUBNET_THRESHOLD)
blocked_ipv4, blocked_ipv6 = split_cidr_versions(final_blocked_targets)
print("\n=== Consolidated Summary ===")
print(
f"copyparty block candidates: {len(cp_analyzer.block_candidates()) if cp_exists else 0}")
print(
f"NPM block candidates: {len(npm_analyzer.block_candidates()) if npm_exists else 0}")
print(f"Total unified blocked IPs: {len(final_blocked_ips)}")
print(f"Total target blocks/subnets: {len(final_blocked_targets)}")
# --- Step 6: write consolidated blocklists to reports/app/data ----------
blocklist_v4_path, blocklist_v6_path = write_blocklist_files(
blocked_ipv4, blocked_ipv6, args.report_dir)
print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}")
# --- Steps 5+7: compare current state with candidates and apply ---------
if firewall is not None:
try:
changes = firewall.sync_blocklist(
blocked_ipv4, blocked_ipv6, dry_run=args.dry_run,
report_dir=args.report_dir)
finally:
firewall.disconnect()
action = "Would" if args.dry_run else ""
print(f"{action} add {len(changes['added_ipv4'])} IPv4 and "
f"{len(changes['added_ipv6'])} IPv6 addresses; "
f"remove {len(changes['removed_ipv4'])} IPv4 and "
f"{len(changes['removed_ipv6'])} IPv6 addresses.")
run_scan(params)
if __name__ == "__main__":
+77
View File
@@ -0,0 +1,77 @@
// Shared helpers for the Intrusion Detection web UI.
async function api(path, options = {}) {
const opts = { headers: {}, ...options };
if (opts.body && typeof opts.body !== "string") {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(path, opts);
let data = null;
try {
data = await res.json();
} catch (e) {
/* non-JSON */
}
if (!res.ok) {
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
function escapeHtml(s) {
return String(s ?? "").replace(
/[&<>"']/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c],
);
}
function toast(message, type = "info") {
const box = document.getElementById("toasts");
if (!box) return;
const node = document.createElement("div");
node.className = `toast toast-${type}`;
node.textContent = message;
box.appendChild(node);
setTimeout(() => node.remove(), 4000);
}
function fmtTime(s) {
if (!s) return "—";
const d = new Date(s);
return isNaN(d) ? s : d.toLocaleString();
}
function badge(state) {
const node = document.createElement("span");
node.className = `badge ${state}`;
node.textContent = state;
return node;
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") node.className = v;
else if (k === "text") node.textContent = v;
else if (k === "html") node.innerHTML = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const c of children) {
node.append(c == null ? "" : c);
}
return node;
}
async function startScan(kind, params) {
return api(`/api/scan/${kind}`, { method: "POST", body: params });
}
+199
View File
@@ -0,0 +1,199 @@
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #263449;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: .75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-weight: 700; letter-spacing: .02em; white-space: nowrap; }
.topbar nav { display: flex; gap: .25rem; flex-wrap: wrap; }
.topbar nav a {
color: var(--muted);
text-decoration: none;
padding: .4rem .75rem;
border-radius: 8px;
font-size: .9rem;
}
.topbar nav a:hover { color: var(--text); background: var(--surface-2); }
.topbar nav a.active { color: var(--accent); background: var(--surface-2); }
.container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
h2 { font-size: 1.05rem; margin: 0; }
.muted { color: var(--muted); }
.mono { font-family: var(--mono); font-size: .85rem; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
margin: 1rem 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: .75rem;
}
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
}
.card-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.card-value { font-size: 1.5rem; font-weight: 700; margin-top: .25rem; }
.card-value.ok { color: var(--ok); }
.card-value.warn { color: var(--warn); }
.card-value.danger { color: var(--danger); }
.card-value.muted { color: var(--muted); }
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.table th {
text-align: left;
color: var(--muted);
font-weight: 600;
border-bottom: 1px solid var(--border);
padding: .5rem .6rem;
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .04em;
}
.table td { padding: .5rem .6rem; border-bottom: 1px solid var(--border); }
.table tr:last-child td { border-bottom: none; }
.table .num { text-align: right; font-variant-numeric: tabular-nums; }
.badge {
display: inline-block;
padding: .1rem .5rem;
border-radius: 999px;
font-size: .72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .03em;
background: var(--surface-2);
color: var(--muted);
}
.badge.ok, .badge.done { background: rgba(52, 211, 153, .15); color: var(--ok); }
.badge.warn, .badge.running, .badge.queued { background: rgba(251, 191, 36, .15); color: var(--warn); }
.badge.danger, .badge.error { background: rgba(248, 113, 113, .15); color: var(--danger); }
.btn {
display: inline-flex;
align-items: center;
gap: .4rem;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
padding: .45rem .9rem;
border-radius: 8px;
font-size: .88rem;
cursor: pointer;
text-decoration: none;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #062a35; font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { border-color: rgba(248, 113, 113, .5); color: var(--danger); }
.btn.danger:hover { background: rgba(248, 113, 113, .12); }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.panel-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; flex-wrap: wrap; }
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
label { display: flex; flex-direction: column; gap: .3rem; font-size: .82rem; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: .5rem; font-size: .88rem; color: var(--text); margin-top: 1.4rem; }
input, select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: .45rem .6rem;
font-size: .88rem;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.job-status { margin-top: 1rem; font-family: var(--mono); font-size: .85rem; }
#toasts { position: fixed; bottom: 1rem; right: 1rem; display: flex; flex-direction: column; gap: .5rem; z-index: 100; }
.toast {
padding: .6rem 1rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: 0 6px 20px rgba(0, 0, 0, .4);
font-size: .88rem;
max-width: 360px;
}
.toast-error { border-color: var(--danger); color: var(--danger); }
.toast-ok { border-color: var(--ok); color: var(--ok); }
.toast-warn { border-color: var(--warn); color: var(--warn); }
pre.content {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
max-height: 480px;
font-family: var(--mono);
font-size: .8rem;
white-space: pre-wrap;
}
.list { display: flex; flex-direction: column; gap: .25rem; max-height: 300px; overflow: auto; padding: .25rem; }
.list a { color: var(--accent); text-decoration: none; font-family: var(--mono); font-size: .85rem; padding: .2rem .4rem; border-radius: 6px; }
.list a:hover { background: var(--surface-2); }
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Intrusion Detection{% endblock %}</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='style.css') }}"
/>
</head>
<body>
<header class="topbar">
<div class="brand">🛡 Intrusion Detection</div>
<nav>
<a href="/" class="{{ 'active' if request.path == '/' }}">Dashboard</a>
<a href="/scan" class="{{ 'active' if request.path == '/scan' }}"
>Scan</a
>
<a href="/reports" class="{{ 'active' if request.path == '/reports' }}"
>Reports</a
>
<a
href="/blocklist"
class="{{ 'active' if request.path == '/blocklist' }}"
>Blocklist</a
>
<a
href="/firewall"
class="{{ 'active' if request.path == '/firewall' }}"
>Firewall</a
>
<a
href="/whitelist"
class="{{ 'active' if request.path == '/whitelist' }}"
>Whitelist</a
>
</nav>
</header>
<main class="container">{% block content %}{% endblock %}</main>
<div id="toasts"></div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+134
View File
@@ -0,0 +1,134 @@
{% extends "base.html" %} {% block title %}Blocklist — Intrusion Detection{%
endblock %} {% block content %}
<h1>Blocklist</h1>
<p class="muted">
Current consolidated blocklist (from the latest scan). Adding/removing touches
the MikroTik router.
</p>
<div class="panel">
<div class="panel-head"><h2>Add / remove entry</h2></div>
<div class="panel-actions" style="margin-top: 0">
<input
id="ip"
placeholder="IP or CIDR, e.g. 1.2.3.4 or 1.2.0.0/16"
style="min-width: 260px"
/>
<button class="btn primary" id="add-btn">Add to Blocked</button>
<button class="btn danger" id="remove-btn">Remove from Blocked</button>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>IPv4 <span id="v4-count"></span></h2>
</div>
<table class="table">
<thead>
<tr>
<th>Address</th>
<th></th>
</tr>
</thead>
<tbody id="v4"></tbody>
</table>
</div>
<div class="panel">
<div class="panel-head">
<h2>IPv6 <span id="v6-count"></span></h2>
</div>
<table class="table">
<thead>
<tr>
<th>Address</th>
<th></th>
</tr>
</thead>
<tbody id="v6"></tbody>
</table>
</div>
{% endblock %} {% block scripts %}
<script>
async function loadBlocklist() {
try {
const data = await api("/api/blocklist");
render("v4", data.ipv4 || [], "v4-count");
render("v6", data.ipv6 || [], "v6-count");
} catch (e) {
toast(e.message, "error");
}
}
function render(tbodyId, entries, countId) {
const body = document.getElementById(tbodyId);
body.innerHTML = "";
document.getElementById(countId).textContent = `(${entries.length})`;
if (!entries.length) {
body.innerHTML =
'<tr><td colspan="2" class="muted">No entries.</td></tr>';
return;
}
for (const ip of entries) {
body.append(
el(
"tr",
el("td", { class: "mono", text: ip }),
el(
"td",
{ style: "text-align:right" },
el("button", {
class: "btn danger",
text: "Remove",
onclick: () => remove(ip),
}),
),
),
);
}
}
async function act(action) {
const ip = document.getElementById("ip").value.trim();
if (!ip) {
toast("Enter an IP or CIDR", "warn");
return;
}
try {
const data = await api(`/api/blocklist/${action}`, {
method: "POST",
body: { ip },
});
toast(
`Removed ${ip}`.replace(
"Removed",
action === "add" ? "Added" : "Removed",
),
"ok",
);
document.getElementById("ip").value = "";
loadBlocklist();
} catch (e) {
toast(e.message, "error");
}
}
async function remove(ip) {
try {
await api("/api/blocklist/remove", { method: "POST", body: { ip } });
toast(`Removed ${ip}`, "ok");
loadBlocklist();
} catch (e) {
toast(e.message, "error");
}
}
document
.getElementById("add-btn")
.addEventListener("click", () => act("add"));
document
.getElementById("remove-btn")
.addEventListener("click", () => act("remove"));
loadBlocklist();
</script>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %} {% block title %}Dashboard — Intrusion Detection{%
endblock %} {% block content %}
<h1>Dashboard</h1>
<p class="muted">
Localhost-only control panel for the intrusion detection scanner.
</p>
<div class="cards" id="status-cards"></div>
<div class="panel">
<div class="panel-head">
<h2>Latest scans</h2>
<a class="btn ghost" href="/scan">Run a scan →</a>
</div>
<table class="table">
<thead>
<tr>
<th>Job</th>
<th>Kind</th>
<th>State</th>
<th>Started</th>
<th>Finished</th>
<th class="num">Blocked</th>
</tr>
</thead>
<tbody id="latest-scans"></tbody>
</table>
</div>
{% endblock %} {% block scripts %}
<script>
async function loadDashboard() {
try {
const s = await api("/api/status");
renderCards(s);
renderScans(s.latest_scans || []);
} catch (e) {
toast(e.message, "error");
}
}
function renderCards(s) {
const cards = [
{
label: "Scan running",
value: s.scan_running ? "Yes" : "No",
tone: s.scan_running ? "warn" : "ok",
},
{ label: "Blocklist IPv4", value: s.blocklist_ipv4_count ?? "—" },
{ label: "Blocklist IPv6", value: s.blocklist_ipv6_count ?? "—" },
{
label: "Firewall snapshot",
value:
s.firewall_snapshot && s.firewall_snapshot.exists
? "Present"
: "None",
tone:
s.firewall_snapshot && s.firewall_snapshot.exists ? "ok" : "muted",
},
];
const box = document.getElementById("status-cards");
box.innerHTML = "";
for (const c of cards) {
box.append(
el(
"div",
{ class: "card" },
el("div", { class: "card-label", text: c.label }),
el("div", { class: `card-value ${c.tone || ""}`, text: c.value }),
),
);
}
}
function renderScans(scans) {
const body = document.getElementById("latest-scans");
body.innerHTML = "";
if (!scans.length) {
body.innerHTML =
'<tr><td colspan="6" class="muted">No scans yet — run one from the Scan page.</td></tr>';
return;
}
for (const j of scans) {
const r = j.result || {};
body.append(
el(
"tr",
el("td", { class: "mono", text: j.job_id }),
el("td", { text: j.kind }),
el("td", {}, badge(j.state)),
el("td", { text: fmtTime(j.started) }),
el("td", { text: fmtTime(j.finished) }),
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
),
);
}
}
loadDashboard();
</script>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %} {% block title %}Firewall — Intrusion Detection{%
endblock %} {% block content %}
<h1>Firewall</h1>
<p class="muted">
Current MikroTik state (from the latest <code>data/mikrotik</code> snapshot).
Sync previews or applies the blocklist diff.
</p>
<div class="panel">
<div class="panel-actions" style="margin-top: 0">
<button class="btn" id="preview-btn">Preview sync (dry-run)</button>
<button class="btn danger" id="apply-btn">Apply sync</button>
<span class="muted" id="fw-result"></span>
</div>
</div>
<div class="panel">
<div class="panel-head"><h2>Address lists</h2></div>
<div id="address-lists"></div>
</div>
<div class="panel">
<div class="panel-head"><h2>Firewall rules</h2></div>
<div id="firewall-rules"></div>
</div>
{% endblock %} {% block scripts %}
<script>
async function loadState() {
try {
const s = await api("/api/firewall/state");
renderAddressLists(s.address_lists || {});
renderRules("firewall-rules", s.firewall_rules || {});
} catch (e) {
toast(e.message, "error");
}
}
function renderAddressLists(addr) {
const box = document.getElementById("address-lists");
box.innerHTML = "";
const byList = {};
for (const version of ["ipv4", "ipv6"]) {
for (const e of addr[version] || []) {
const name = e.list || "?";
(byList[name] = byList[name] || { ipv4: [], ipv6: [] })[version].push(
e.address,
);
}
}
const names = Object.keys(byList);
if (!names.length) {
box.innerHTML =
'<p class="muted">No address-list snapshot available. Run <code>app.py --update-firewall</code> first.</p>';
return;
}
for (const name of names) {
const rows = [];
for (const version of ["ipv4", "ipv6"]) {
const entries = (byList[name][version] || []).sort();
rows.push(
el(
"tr",
el("td", { text: version }),
el("td", { class: "mono", text: entries.join(", ") || "—" }),
),
);
}
box.append(
el(
"div",
{ style: "margin-bottom:1rem" },
el("div", {
style: "font-weight:600;margin-bottom:.4rem",
text: name,
}),
el(
"table",
{ class: "table" },
el(
"thead",
{},
el(
"tr",
{},
el("th", { text: "Version" }),
el("th", { text: "Addresses" }),
),
),
el("tbody", {}, ...rows),
),
),
);
}
}
function renderRules(boxId, rules) {
const box = document.getElementById(boxId);
box.innerHTML = "";
for (const version of ["ipv4", "ipv6"]) {
const list = rules[version] || [];
const rows = list.map((r) =>
el(
"tr",
el("td", {
class: "mono",
text: r.address ? r.address : r["src-address-list"] || "",
}),
el("td", { text: r.chain || "—" }),
el("td", { text: r.action || "—" }),
el("td", { text: r.comment || "" }),
),
);
box.append(
el(
"div",
{ style: "margin-bottom:1rem" },
el("div", {
style: "font-weight:600;margin-bottom:.4rem",
text: `IPv${version} (${list.length})`,
}),
rows.length
? el(
"table",
{ class: "table" },
el(
"thead",
{},
el(
"tr",
{},
el("th", { text: "Address / List" }),
el("th", { text: "Chain" }),
el("th", { text: "Action" }),
el("th", { text: "Comment" }),
),
),
el("tbody", {}, ...rows),
)
: el("p", { class: "muted", text: "No rules in snapshot." }),
),
);
}
}
async function sync(dryRun) {
const out = document.getElementById("fw-result");
out.textContent = "Working…";
try {
const changes = await api(`/api/firewall/${dryRun ? "sync" : "apply"}`, {
method: "POST",
body: {},
});
const verb = dryRun ? "Would" : "";
out.textContent =
`${verb} add ${(changes.added_ipv4 || []).length} IPv4 + ${(changes.added_ipv6 || []).length} IPv6; ` +
`remove ${(changes.removed_ipv4 || []).length} IPv4 + ${(changes.removed_ipv6 || []).length} IPv6.`;
toast(
(dryRun ? "Preview" : "Applied") + " firewall sync",
dryRun ? "warn" : "ok",
);
if (!dryRun) loadState();
} catch (e) {
out.textContent = "";
toast(e.message, "error");
}
}
document
.getElementById("preview-btn")
.addEventListener("click", () => sync(true));
document
.getElementById("apply-btn")
.addEventListener("click", () => sync(false));
loadState();
</script>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{%
endblock %} {% block content %}
<h1>Reports</h1>
<p class="muted">Browse the timestamped reports written by each scanner.</p>
<div class="panel">
<div class="panel-actions" style="margin-top: 0">
<label style="flex-direction: row; align-items: center; gap: 0.5rem"
>Source
<select id="source">
<option value="copyparty">copyparty</option>
<option value="npm">npm</option>
<option value="app">app</option>
<option value="mikrotik">mikrotik</option>
</select>
</label>
<button class="btn" id="refresh-btn">Refresh</button>
</div>
<div class="list" id="files"></div>
</div>
<div class="panel">
<div class="panel-head"><h2 id="content-title">Report content</h2></div>
<pre class="content" id="content">
Select a report on the left to view it.</pre
>
</div>
{% endblock %} {% block scripts %}
<script>
async function loadFiles() {
const source = document.getElementById("source").value;
const list = document.getElementById("files");
list.innerHTML = '<div class="muted">Loading…</div>';
try {
const data = await api(`/api/reports/${source}`);
list.innerHTML = "";
const files = data.files || [];
if (!files.length) {
list.innerHTML = '<div class="muted">No reports in this source.</div>';
return;
}
for (const name of files) {
list.append(
el("a", {
href: "#",
text: name,
onclick: (ev) => {
ev.preventDefault();
openReport(name);
},
}),
);
}
} catch (e) {
list.innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`;
}
}
async function openReport(name) {
const source = document.getElementById("source").value;
document.getElementById("content-title").textContent = name;
const pre = document.getElementById("content");
pre.textContent = "Loading…";
try {
const data = await api(`/api/reports/${source}/${name}`);
pre.textContent = data.content;
} catch (e) {
pre.textContent = `Error: ${e.message}`;
}
}
document.getElementById("refresh-btn").addEventListener("click", loadFiles);
document.getElementById("source").addEventListener("change", loadFiles);
loadFiles();
</script>
{% endblock %}
+178
View File
@@ -0,0 +1,178 @@
{% extends "base.html" %} {% block title %}Scan — Intrusion Detection{% endblock
%} {% block content %}
<h1>Run a scan</h1>
<p class="muted">
Scans run in the background; only one at a time. You can leave this page and
come back.
</p>
<div class="panel">
<form id="scan-form" class="form-grid">
<label
>Type
<select id="kind">
<option value="consolidated">Consolidated (copyparty + npm)</option>
<option value="copyparty">copyparty only</option>
<option value="npm">npm only</option>
</select>
</label>
<label>Top IPs <input id="top" type="number" value="30" min="1" /></label>
<label
>Min denied (copyparty)
<input id="min_denied" type="number" value="1" min="0"
/></label>
<label
>Min errors (npm) <input id="min_errors" type="number" value="5" min="0"
/></label>
<label
>Error ratio (npm)
<input
id="error_ratio"
type="number"
step="0.05"
value="0.5"
min="0"
max="1"
/></label>
<label class="check"
><input id="update_firewall" type="checkbox" /> Update firewall after
scan</label
>
<label class="check"
><input id="dry_run" type="checkbox" /> Dry-run (preview, no
changes)</label
>
</form>
<div class="panel-actions">
<button class="btn primary" id="run-btn">Run scan</button>
<span class="muted" id="busy-hint"></span>
</div>
<div id="job-status" class="job-status"></div>
</div>
<div class="panel">
<div class="panel-head"><h2>Recent jobs</h2></div>
<table class="table">
<thead>
<tr>
<th>Job</th>
<th>Kind</th>
<th>State</th>
<th>Started</th>
<th>Finished</th>
<th class="num">Blocked</th>
</tr>
</thead>
<tbody id="jobs"></tbody>
</table>
</div>
{% endblock %} {% block scripts %}
<script>
let pollTimer = null;
const form = document.getElementById("scan-form");
const runBtn = document.getElementById("run-btn");
const hint = document.getElementById("busy-hint");
async function runScan() {
const kind = document.getElementById("kind").value;
const params = {
top: parseInt(document.getElementById("top").value, 10) || 30,
min_denied:
parseInt(document.getElementById("min_denied").value, 10) || 1,
min_errors:
parseInt(document.getElementById("min_errors").value, 10) || 5,
error_ratio:
parseFloat(document.getElementById("error_ratio").value) || 0.5,
update_firewall: document.getElementById("update_firewall").checked,
dry_run: document.getElementById("dry_run").checked,
quiet: true,
};
runBtn.disabled = true;
try {
const job = await startScan(kind, params);
setStatus(`Job ${job.job_id} started (${job.state}).`, "ok");
poll(job.job_id);
} catch (e) {
setStatus(e.message, "error");
runBtn.disabled = false;
}
}
function setStatus(msg, type) {
const box = document.getElementById("job-status");
box.textContent = msg;
box.style.color =
type === "error"
? "var(--danger)"
: type === "ok"
? "var(--ok)"
: "inherit";
}
async function poll(jobId) {
clearTimeout(pollTimer);
let job;
try {
job = await api(`/api/scan/${jobId}`);
} catch (e) {
setStatus(e.message, "error");
runBtn.disabled = false;
return;
}
const r = job.result || {};
setStatus(
`[${job.state}] ${job.kind} — started ${fmtTime(job.started)}` +
(job.finished ? `, finished ${fmtTime(job.finished)}` : "") +
(r.blocked_targets != null
? `, blocked targets: ${r.blocked_targets}`
: "") +
(job.error ? `, error: ${job.error}` : ""),
job.state === "error" ? "error" : job.state === "done" ? "ok" : "",
);
if (job.state === "running" || job.state === "queued") {
pollTimer = setTimeout(() => poll(jobId), 2000);
} else {
runBtn.disabled = false;
refreshJobs();
const running = (await api("/api/scan/status")).running;
hint.textContent = running ? "" : "";
}
}
async function refreshJobs() {
try {
const s = await api("/api/scan/status");
hint.textContent = s.running ? "A scan is currently running…" : "";
const body = document.getElementById("jobs");
body.innerHTML = "";
const jobs = s.latest_scans || [];
if (!jobs.length) {
body.innerHTML =
'<tr><td colspan="6" class="muted">No jobs yet.</td></tr>';
return;
}
for (const j of jobs) {
const r = j.result || {};
body.append(
el(
"tr",
el("td", { class: "mono", text: j.job_id }),
el("td", { text: j.kind }),
el("td", {}, badge(j.state)),
el("td", { text: fmtTime(j.started) }),
el("td", { text: fmtTime(j.finished) }),
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
),
);
}
} catch (e) {
toast(e.message, "error");
}
}
runBtn.addEventListener("click", runScan);
refreshJobs();
setInterval(refreshJobs, 5000);
</script>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %} {% block title %}Whitelist — Intrusion Detection{%
endblock %} {% block content %}
<h1>Whitelist</h1>
<p class="muted">
Provider whitelists (from the router snapshot). Never add a whole cloud
provider to the blocklist.
</p>
<div class="panel">
<div class="panel-head"><h2>Add / remove CIDR</h2></div>
<div class="panel-actions" style="margin-top: 0">
<select id="wl-name">
{% for name in whitelist_names %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<input
id="wl-address"
placeholder="CIDR, e.g. 1.2.3.0/24"
style="min-width: 220px"
/>
<button class="btn primary" id="wl-add-btn">Add</button>
<button class="btn danger" id="wl-remove-btn">Remove</button>
</div>
</div>
<div id="whitelists"></div>
{% endblock %} {% block scripts %}
<script>
async function loadWhitelists() {
try {
const data = await api("/api/whitelist");
const box = document.getElementById("whitelists");
box.innerHTML = "";
for (const name of data.names || []) {
const w = data.whitelists[name] || {};
const rows = [];
for (const version of ["ipv4", "ipv6"]) {
const entries = (w[version] || []).sort();
rows.push(
el(
"tr",
el("td", { text: version }),
el("td", { class: "mono", text: entries.join(", ") || "—" }),
),
);
}
box.append(
el(
"div",
{ class: "panel" },
el(
"div",
{ class: "panel-head" },
el("h2", {
text: `${name} (${(w.ipv4 || []).length + (w.ipv6 || []).length})`,
}),
),
el(
"table",
{ class: "table" },
el(
"thead",
{},
el(
"tr",
{},
el("th", { text: "Version" }),
el("th", { text: "Addresses" }),
),
),
el("tbody", {}, ...rows),
),
),
);
}
} catch (e) {
toast(e.message, "error");
}
}
async function act(action) {
const name = document.getElementById("wl-name").value;
const address = document.getElementById("wl-address").value.trim();
if (!address) {
toast("Enter a CIDR", "warn");
return;
}
try {
await api("/api/whitelist/update", {
method: "POST",
body: { name, address, action },
});
toast(`${action} ${address} on ${name}`, "ok");
document.getElementById("wl-address").value = "";
loadWhitelists();
} catch (e) {
toast(e.message, "error");
}
}
document
.getElementById("wl-add-btn")
.addEventListener("click", () => act("add"));
document
.getElementById("wl-remove-btn")
.addEventListener("click", () => act("remove"));
loadWhitelists();
</script>
{% endblock %}
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Flask web UI for the intrusion-detection system.
A localhost-only web server (``127.0.0.1:<high port>``) for running scans,
browsing reports, and managing the MikroTik blocklist / firewall.
Run directly: ``python -m app.web``
Or via the entry point: ``python app.py --web`` (see ``serve()``).
"""
from __future__ import annotations
import json
import os
from flask import Flask, abort, jsonify, render_template, request
from .constants import (
APP_REPORT_DATA_DIR,
APP_REPORT_DIR,
BLOCKLIST_NAME,
COPYPARTY_LOG_PATH,
COPYPARTY_REPORT_DIR,
DEFAULT_ERROR_RATIO,
DEFAULT_MIN_DENIED,
DEFAULT_MIN_ERRORS,
DEFAULT_TOP,
MIKROTIK_DATA_DIR,
MIKROTIK_REPORT_DIR,
NPM_LOG_PATH,
NPM_REPORT_DIR,
REPORT_DIR,
WEB_HOST,
WEB_PORT,
WHITELIST_NAMES,
)
from .firewall import MikrotikFirewall
from .ip_utils import ip_version
from .scan import ScanParams
from . import web_scan
#: report sources exposed by the API -> report directory
REPORT_SOURCES = {
"copyparty": COPYPARTY_REPORT_DIR,
"npm": NPM_REPORT_DIR,
"app": APP_REPORT_DIR,
"mikrotik": MIKROTIK_REPORT_DIR,
}
SCAN_KINDS = ("copyparty", "npm", "consolidated")
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _count_lines(path: str) -> int | None:
"""Return the line count of a file, or None if it cannot be read."""
try:
with open(path, "r", encoding="utf-8") as f:
return sum(1 for _ in f)
except OSError:
return None
def _read_lines(path: str) -> list[str]:
"""Return non-empty lines of a file, or [] if it cannot be read."""
try:
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
except OSError:
return []
def _read_json(path: str, default: list | dict | None = None) -> list | dict:
"""Read a JSON file, returning ``default`` (or []) on failure."""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return default if default is not None else []
def _params_from_request() -> ScanParams:
"""Build ScanParams from the JSON request body, falling back to defaults."""
data = request.get_json(silent=True) or {}
def _pick(key: str, default):
value = data.get(key)
return default if value is None or value == "" else value
return ScanParams(
copyparty_log_path=str(
_pick("copyparty_log_path", COPYPARTY_LOG_PATH)),
copyparty_report_dir=str(
_pick("copyparty_report_dir", COPYPARTY_REPORT_DIR)),
min_denied=int(_pick("min_denied", DEFAULT_MIN_DENIED)),
npm_log_path=str(_pick("npm_log_path", NPM_LOG_PATH)),
npm_report_dir=str(_pick("npm_report_dir", NPM_REPORT_DIR)),
min_errors=int(_pick("min_errors", DEFAULT_MIN_ERRORS)),
error_ratio=float(_pick("error_ratio", DEFAULT_ERROR_RATIO)),
report_dir=str(_pick("report_dir", REPORT_DIR)),
top=int(_pick("top", DEFAULT_TOP)),
quiet=bool(data.get("quiet", False)),
update_firewall=bool(data.get("update_firewall", False)),
dry_run=bool(data.get("dry_run", False)),
)
def _with_router(fn):
"""Connect to the router, run ``fn(fw)``, always disconnect.
Returns a JSON error response (502) if the router is unreachable.
"""
fw = MikrotikFirewall()
try:
fw.connect()
return fn(fw)
except Exception as e:
return jsonify({"error": str(e)}), 502
finally:
fw.disconnect()
def _sync_firewall(dry_run: bool):
"""Sync the latest consolidated blocklist to the router (preview or apply)."""
v4 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt"))
v6 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt"))
def _sync(fw):
changes = fw.sync_blocklist(v4, v6, dry_run=dry_run)
return jsonify({**changes, "dry_run": dry_run})
return _with_router(_sync)
# ---------------------------------------------------------------------------
# app factory
# ---------------------------------------------------------------------------
def create_app() -> Flask:
"""Build and configure the Flask application."""
app = Flask(__name__)
@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)
# --- 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")
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,
},
})
# --- 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())
# A busy-lock rejection is a bare {"error": ...}; a started job has
# "error" == None, so test for a truthy value, not key presence.
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/<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)
# --- 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.post("/api/firewall/sync")
def firewall_sync():
"""Preview the blocklist sync (dry-run, no changes applied)."""
return _sync_firewall(dry_run=True)
@app.post("/api/firewall/apply")
def firewall_apply():
"""Apply the blocklist sync (add new / remove stale entries)."""
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)
return app
def serve(host: str = WEB_HOST, port: int = WEB_PORT) -> None:
"""Run the web server on localhost at the configured high port."""
app = create_app()
print(f"Intrusion Detection web UI running at http://{host}:{port}")
app.run(host=host, port=port, use_reloader=False)
if __name__ == "__main__":
serve()
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Background scan runner for the web UI.
Runs scans in a daemon thread so HTTP requests never block, keeps an
in-memory job store, and serializes scans with a busy lock (WHOIS and
whitelist refresh must not run concurrently).
"""
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Literal
from .copyparty import CopypartyAnalyzer
from .npm import NpmAnalyzer
from .scan import ScanParams, ScanResult, run_scan
ScanKind = Literal["copyparty", "npm", "consolidated"]
#: serializes scans — only one runs at a time
_scan_lock = threading.Lock()
#: in-memory job store (job_id -> ScanJob)
_jobs: dict[str, "ScanJob"] = {}
_jobs_lock = threading.Lock()
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
@dataclass
class ScanJob:
"""One background scan job."""
job_id: str
kind: ScanKind
params: ScanParams
state: str = "queued" # queued | running | done | error
started: str | None = None
finished: str | None = None
result: ScanResult | None = None
error: str | None = None
busy: bool = False
details: dict = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
result = self.result
return {
"job_id": self.job_id,
"kind": self.kind,
"state": self.state,
"started": self.started,
"finished": self.finished,
"error": self.error,
"busy": self.busy,
"details": self.details,
"result": None if result is None else {
"ok": result.ok,
"cp_exists": result.cp_exists,
"npm_exists": result.npm_exists,
"cp_candidates": result.cp_candidates,
"npm_candidates": result.npm_candidates,
"blocked_ips": result.blocked_ips,
"blocked_targets": result.blocked_targets,
"blocklist_v4_path": result.blocklist_v4_path,
"blocklist_v6_path": result.blocklist_v6_path,
"firewall_connected": result.firewall_connected,
"firewall_changes": result.firewall_changes,
"errors": result.errors,
},
}
def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
"""Start a scan in the background and return its job dict.
If another scan is already running, returns a dict with an ``error`` key.
"""
if not _scan_lock.acquire(blocking=False):
return {"error": "a scan is already running; try again when it finishes"}
job = ScanJob(job_id=uuid.uuid4().hex[:12], kind=kind,
params=params, busy=True)
with _jobs_lock:
_jobs[job.job_id] = job
def _run() -> None:
job.started = _now()
job.state = "running"
try:
if kind == "consolidated":
job.result = run_scan(params)
elif kind == "copyparty":
info = CopypartyAnalyzer(
log_path=params.copyparty_log_path,
report_dir=params.copyparty_report_dir,
top=params.top,
quiet=params.quiet,
min_denied=params.min_denied,
).run()
job.result = ScanResult(ok=True)
job.details = {"report": info.get("report"),
"data_dir": info.get("data_dir")}
elif kind == "npm":
info = NpmAnalyzer(
log_path=params.npm_log_path,
report_dir=params.npm_report_dir,
top=params.top,
quiet=params.quiet,
min_errors=params.min_errors,
error_ratio=params.error_ratio,
).run()
job.result = ScanResult(ok=True)
job.details = {"report": info.get("report"),
"data_dir": info.get("data_dir")}
else: # pragma: no cover - guarded by type hint
raise ValueError(f"unknown scan kind: {kind}")
job.state = "done"
except Exception as e:
job.error = str(e)
job.state = "error"
finally:
job.finished = _now()
job.busy = False
_scan_lock.release()
threading.Thread(target=_run, daemon=True,
name=f"scan-{job.job_id}").start()
return job.to_dict()
def get_scan(job_id: str) -> dict[str, Any] | None:
"""Return a single job as a dict, or None if unknown."""
with _jobs_lock:
job = _jobs.get(job_id)
return job.to_dict() if job else None
def latest_scans(limit: int = 10) -> list[dict[str, Any]]:
"""Return the most recently started jobs, newest first."""
with _jobs_lock:
jobs = sorted(_jobs.values(),
key=lambda j: j.started or "", reverse=True)
return [j.to_dict() for j in jobs[:limit]]
def is_scan_running() -> bool:
"""True if any job currently holds the scan lock."""
return _scan_lock.locked()
+3 -1
View File
@@ -964,5 +964,7 @@
"74.7.241.46": "74.7.0.0/16",
"216.73.216.67": "216.73.216.0/22",
"185.198.240.238": "185.198.240.0/24",
"34.125.213.146": "34.125.208.0/20"
"34.125.213.146": "34.125.208.0/20",
"222.176.200.21": "222.176.0.0/13",
"106.117.109.102": "106.117.0.0/16"
}
+2
View File
@@ -24,6 +24,7 @@
104.28.247.220/32
104.28.254.131/32
104.28.254.134/32
106.117.109.102/32
106.63.0.0/17
107.170.9.103/32
108.130.204.104/32
@@ -298,6 +299,7 @@
217.217.252.197/32
220.202.112.100/32
221.13.93.71/32
222.176.200.21/32
222.176.200.251/32
223.199.187.171/32
23.102.123.231/32
+2 -1
View File
@@ -1,2 +1,3 @@
paramiko
python-dotenv
python-dotenv
flask