"""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
\U0001f6e1\ufe0f
AYN ANTIVIRUS
Security Operations Dashboard
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()