feat: add localhost web UI for scans, blocklist, and firewall management
This commit is contained in:
1 parent
a9dc560f80
commit
4ef6892916
19 files changed
+1934
-117
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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 });
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -1,2 +1,3 @@
|
||||
paramiko
|
||||
python-dotenv
|
||||
python-dotenv
|
||||
flask
|
||||
Reference in new issue
Block a user