diff --git a/.env.example b/.env.example index 4f98a3d..6297b8a 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/README.md b/README.md index e5c2c8a..1f4ce55 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.py b/app.py index 58f7281..5fd94be 100644 --- a/app.py +++ b/app.py @@ -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() diff --git a/app/constants.py b/app/constants.py index cf43374..82a4ed5 100644 --- a/app/constants.py +++ b/app/constants.py @@ -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) # --------------------------------------------------------------------------- diff --git a/app/scan.py b/app/scan.py index 0f9e36e..785565c 100644 --- a/app/scan.py +++ b/app/scan.py @@ -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__": diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..a1d5aa8 --- /dev/null +++ b/app/static/app.js @@ -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 }); +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..02f82fe --- /dev/null +++ b/app/static/style.css @@ -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); } diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..beb3f49 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,45 @@ + + +
+ + ++ Current consolidated blocklist (from the latest scan). Adding/removing touches + the MikroTik router. +
+ +| Address | ++ |
|---|
| Address | ++ |
|---|
+ Localhost-only control panel for the intrusion detection scanner. +
+ + + +| Job | +Kind | +State | +Started | +Finished | +Blocked | +
|---|
+ Current MikroTik state (from the latest data/mikrotik snapshot).
+ Sync previews or applies the blocklist diff.
+
Browse the timestamped reports written by each scanner.
+ ++Select a report on the left to view it.+
+ Scans run in the background; only one at a time. You can leave this page and + come back. +
+ +| Job | +Kind | +State | +Started | +Finished | +Blocked | +
|---|
+ Provider whitelists (from the router snapshot). Never add a whole cloud + provider to the blocklist. +
+ +