Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor

- FastAPI + htmx + Jinja2 web frontend, started with --web flag
- JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) +
  refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db
- RBAC: superadmin > admin > reader enforced per route
- Live SSE dashboard fed by tui/events broadcast queue
- Config editor: keyword groups and channel list saved to data/runtime_config.json
  and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed)
- config.py migrated to load groups/channels from runtime_config.json;
  falls back to hardcoded defaults when file absent
- tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed
- utils/scorer.py: import config as _config (fixes local binding); reload_from_config()
- utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains
- 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement,
  config round-trip); total 141 passing
This commit is contained in:
2026-04-02 11:41:46 -03:00
parent b28168c846
commit 4c104cddd2
32 changed files with 2093 additions and 47 deletions

56
main.py
View File

@@ -2,11 +2,10 @@
main.py — Entry point for the ULP credential monitor.
Usage:
python main.py # TUI mode (default, requires textual)
python main.py --no-tui # Plain CLI mode
First run will prompt for your Telegram phone number and 2FA code
to create a session file. Subsequent runs are fully automatic.
python main.py # TUI mode (default)
python main.py --no-tui # Plain CLI mode
python main.py --web # TUI + web frontend (port 8080)
python main.py --no-tui --web # CLI + web frontend
"""
import asyncio
@@ -14,6 +13,7 @@ import logging
import sys
import shutil
import argparse
import threading
import config
from utils.database import init_db
@@ -36,6 +36,22 @@ log = logging.getLogger(__name__)
init_db()
# ─── Web thread ───────────────────────────────────────────────────────────────
def _run_web(host: str, port: int) -> None:
"""Start uvicorn in its own thread with its own asyncio loop."""
import uvicorn
from web.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="warning")
def _start_web_thread(host: str, port: int) -> threading.Thread:
t = threading.Thread(target=_run_web, args=(host, port), daemon=True, name="web")
t.start()
log.info(f"Web frontend started at http://{host}:{port}")
return t
# ─── Plain CLI mode ───────────────────────────────────────────────────────────
async def _cli_main():
@@ -96,24 +112,29 @@ async def _cli_main():
def main():
parser = argparse.ArgumentParser(description="ULP Credential Monitor")
parser.add_argument(
"--no-tui",
action="store_true",
help="Run in plain CLI mode (no Textual TUI)",
)
parser.add_argument("--no-tui", action="store_true", help="Run in plain CLI mode (no Textual TUI)")
parser.add_argument("--web", action="store_true", help="Start web frontend")
parser.add_argument("--web-host", default="127.0.0.1", help="Web frontend bind host (default: 127.0.0.1)")
parser.add_argument("--web-port", type=int, default=8080, help="Web frontend port (default: 8080)")
args = parser.parse_args()
if args.web:
_start_web_thread(args.web_host, args.web_port)
def _cleanup():
log.info("Cleaning up tmp/...")
if config.TEMP_DIR.exists():
shutil.rmtree(config.TEMP_DIR, ignore_errors=True)
config.TEMP_DIR.mkdir()
log.info("Done.")
if args.no_tui:
try:
asyncio.run(_cli_main())
except KeyboardInterrupt:
log.info("Interrupted by user.")
finally:
log.info("Cleaning up tmp/...")
if config.TEMP_DIR.exists():
shutil.rmtree(config.TEMP_DIR, ignore_errors=True)
config.TEMP_DIR.mkdir()
log.info("Done.")
_cleanup()
else:
try:
from tui.app import run_tui
@@ -132,10 +153,7 @@ def main():
except KeyboardInterrupt:
pass
finally:
log.info("Cleaning up tmp/...")
if config.TEMP_DIR.exists():
shutil.rmtree(config.TEMP_DIR, ignore_errors=True)
config.TEMP_DIR.mkdir()
_cleanup()
if __name__ == "__main__":