"""AYN Antivirus Dashboard — Web Server with Password Auth. Lightweight aiohttp server that serves the dashboard SPA and REST API. Non-localhost access requires username/password authentication via a session cookie obtained through ``POST /login``. """ from __future__ import annotations import logging import secrets import time from typing import Dict, Optional from urllib.parse import urlparse from aiohttp import web from ayn_antivirus.config import Config from ayn_antivirus.constants import QUARANTINE_ENCRYPTION_KEY_FILE from ayn_antivirus.dashboard.api import setup_routes from ayn_antivirus.dashboard.collector import MetricsCollector from ayn_antivirus.dashboard.store import DashboardStore from ayn_antivirus.dashboard.templates import get_dashboard_html logger = logging.getLogger("ayn_antivirus.dashboard.server") # ------------------------------------------------------------------ # JSON error handler — prevent aiohttp returning HTML on /api/* routes # ------------------------------------------------------------------ @web.middleware async def json_error_middleware( request: web.Request, handler, ) -> web.StreamResponse: """Catch unhandled exceptions and return JSON for API routes. Without this, aiohttp's default error handler returns HTML error pages, which break frontend ``fetch().json()`` calls. """ try: return await handler(request) except web.HTTPException as exc: if request.path.startswith("/api/"): return web.json_response( {"error": exc.reason or "Request failed"}, status=exc.status, ) raise except Exception as exc: logger.exception("Unhandled error on %s %s", request.method, request.path) if request.path.startswith("/api/"): return web.json_response( {"error": f"Internal server error: {exc}"}, status=500, ) return web.Response( text="

500 Internal Server Error

", status=500, content_type="text/html", ) # ------------------------------------------------------------------ # Rate limiting state # ------------------------------------------------------------------ _action_timestamps: Dict[str, float] = {} _RATE_LIMIT_SECONDS = 10 # ------------------------------------------------------------------ # Authentication middleware # ------------------------------------------------------------------ @web.middleware async def auth_middleware( request: web.Request, handler, ) -> web.StreamResponse: """Authenticate all requests. * ``/login`` and ``/favicon.ico`` are always allowed. * All other routes require a valid session cookie. * Unauthenticated HTML routes serve the login page. * Unauthenticated ``/api/*`` returns 401. * POST ``/api/actions/*`` enforces CSRF and rate limiting. """ # Login route is always open. if request.path in ("/login", "/favicon.ico"): return await handler(request) # All requests require auth (no localhost bypass — behind reverse proxy). # Check session cookie. session_token = request.app.get("_session_token", "") cookie = request.cookies.get("ayn_session", "") authenticated = ( cookie and session_token and secrets.compare_digest(cookie, session_token) ) if not authenticated: if request.path.startswith("/api/"): return web.json_response( {"error": "Unauthorized. Please login."}, status=401, ) # Serve login page for HTML routes. return web.Response( text=request.app["_login_html"], content_type="text/html", ) # CSRF + rate-limiting for POST action endpoints. if request.method == "POST" and request.path.startswith("/api/actions/"): origin = request.headers.get("Origin", "") if origin: parsed = urlparse(origin) origin_host = parsed.hostname or "" host = request.headers.get("Host", "") expected = host.split(":")[0] if host else "" allowed = {expected, "localhost", "127.0.0.1", "::1"} allowed.discard("") if origin_host not in allowed: return web.json_response( {"error": "CSRF: Origin mismatch"}, status=403, ) now = time.time() last = _action_timestamps.get(request.path, 0) if now - last < _RATE_LIMIT_SECONDS: return web.json_response( {"error": "Rate limited. Try again in a few seconds."}, status=429, ) _action_timestamps[request.path] = now return await handler(request) # ------------------------------------------------------------------ # Dashboard server # ------------------------------------------------------------------ class DashboardServer: """AYN Antivirus dashboard with username/password authentication.""" def __init__(self, config: Optional[Config] = None) -> None: self.config = config or Config() self.store = DashboardStore(self.config.dashboard_db_path) self.collector = MetricsCollector(self.store) self.app = web.Application(middlewares=[json_error_middleware, auth_middleware]) self._session_token: str = secrets.token_urlsafe(32) self._runner: Optional[web.AppRunner] = None self._site: Optional[web.TCPSite] = None self._setup() # ------------------------------------------------------------------ # Setup # ------------------------------------------------------------------ def _setup(self) -> None: """Configure the aiohttp application.""" self.app["_session_token"] = self._session_token self.app["_login_html"] = self._build_login_page() self.app["store"] = self.store self.app["collector"] = self.collector self.app["config"] = self.config # Quarantine vault (best-effort). try: from ayn_antivirus.quarantine.vault import QuarantineVault self.app["vault"] = QuarantineVault( quarantine_dir=self.config.quarantine_path, key_file_path=QUARANTINE_ENCRYPTION_KEY_FILE, ) except Exception as exc: logger.warning("Quarantine vault not available: %s", exc) # API routes (``/api/*``). setup_routes(self.app) # HTML routes. self.app.router.add_get("/", self._serve_dashboard) self.app.router.add_get("/dashboard", self._serve_dashboard) self.app.router.add_get("/login", self._serve_login) self.app.router.add_post("/login", self._handle_login) # Lifecycle hooks. self.app.on_startup.append(self._on_startup) self.app.on_shutdown.append(self._on_shutdown) # ------------------------------------------------------------------ # Request handlers # ------------------------------------------------------------------ async def _serve_login(self, request: web.Request) -> web.Response: """``GET /login`` — render the login page.""" return web.Response( text=self.app["_login_html"], content_type="text/html", ) async def _serve_dashboard(self, request: web.Request) -> web.Response: """``GET /`` or ``GET /dashboard`` — render the SPA. The middleware already enforces auth for non-localhost, so if we reach here the client is authenticated (or local). """ html = get_dashboard_html() return web.Response(text=html, content_type="text/html") async def _handle_login(self, request: web.Request) -> web.Response: """``POST /login`` — validate username/password, set session cookie.""" try: body = await request.json() username = body.get("username", "").strip() password = body.get("password", "").strip() except Exception: return web.json_response({"error": "Invalid request"}, status=400) if not username or not password: return web.json_response( {"error": "Username and password required"}, status=400, ) valid_user = secrets.compare_digest( username, self.config.dashboard_username, ) valid_pass = secrets.compare_digest( password, self.config.dashboard_password, ) if not (valid_user and valid_pass): self.store.log_activity( f"Failed login attempt from {request.remote}: user={username}", "WARNING", "auth", ) return web.json_response( {"error": "Invalid username or password"}, status=401, ) self.store.log_activity( f"Successful login from {request.remote}: user={username}", "INFO", "auth", ) response = web.json_response( {"status": "ok", "message": "Welcome to AYN Antivirus"}, ) response.set_cookie( "ayn_session", self._session_token, httponly=True, max_age=86400, samesite="Strict", ) return response # ------------------------------------------------------------------ # Login page # ------------------------------------------------------------------ @staticmethod def _build_login_page() -> str: """Return a polished HTML login form with username + password fields.""" return ''' AYN Antivirus \u2014 Login
Invalid credentials
''' # ------------------------------------------------------------------ # Lifecycle hooks # ------------------------------------------------------------------ async def _on_startup(self, app: web.Application) -> None: await self.collector.start() self.store.log_activity("Dashboard server started", "INFO", "server") logger.info( "Dashboard on http://%s:%d", self.config.dashboard_host, self.config.dashboard_port, ) async def _on_shutdown(self, app: web.Application) -> None: await self.collector.stop() self.store.log_activity("Dashboard server stopped", "INFO", "server") self.store.close() # ------------------------------------------------------------------ # Blocking run # ------------------------------------------------------------------ def run(self) -> None: """Run the dashboard server (blocking).""" host = self.config.dashboard_host port = self.config.dashboard_port print(f"\n \U0001f6e1\ufe0f AYN Antivirus Dashboard") print(f" \U0001f310 http://{host}:{port}") print(f" \U0001f464 Username: {self.config.dashboard_username}") print(f" \U0001f511 Password: {self.config.dashboard_password}") print(f" Press Ctrl+C to stop\n") web.run_app(self.app, host=host, port=port, print=None) # ------------------------------------------------------------------ # Async start / stop (non-blocking) # ------------------------------------------------------------------ async def start_async(self) -> None: """Start the server without blocking.""" self._runner = web.AppRunner(self.app) await self._runner.setup() self._site = web.TCPSite( self._runner, self.config.dashboard_host, self.config.dashboard_port, ) await self._site.start() self.store.log_activity( "Dashboard server started (async)", "INFO", "server", ) async def stop_async(self) -> None: """Stop a server previously started with :meth:`start_async`.""" if self._site: await self._site.stop() if self._runner: await self._runner.cleanup() await self.collector.stop() self.store.close() # ------------------------------------------------------------------ # Convenience entry point # ------------------------------------------------------------------ def run_dashboard(config: Optional[Config] = None) -> None: """Create a :class:`DashboardServer` and run it (blocking).""" DashboardServer(config).run()