"""Small, dependency-free HTTP server for the dashboard and its SSE stream.""" from __future__ import annotations import base64 import hmac import json import logging import queue import socket import threading from pathlib import Path from typing import Any, Dict, Optional, Tuple from urllib.parse import unquote, urlsplit from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import __version__ from .service import MonitorService logger = logging.getLogger("servermon.web") DEFAULT_WEB_DIR = Path(__file__).resolve().parent.parent / "web" STATIC_FILES: Dict[str, Tuple[str, str]] = { "/": ("index.html", "text/html; charset=utf-8"), "/index.html": ("index.html", "text/html; charset=utf-8"), "/app.js": ("app.js", "text/javascript; charset=utf-8"), "/style.css": ("style.css", "text/css; charset=utf-8"), "/favicon.svg": ("favicon.svg", "image/svg+xml"), "/favicon.ico": ("favicon.svg", "image/svg+xml"), "/robots.txt": ("robots.txt", "text/plain; charset=utf-8"), } SECURITY_HEADERS = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "X-Frame-Options": "SAMEORIGIN", } CSP = ( "default-src 'self'; " "script-src 'self'; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "font-src 'self' data:; " "connect-src 'self'; " "base-uri 'none'; " "frame-ancestors 'self'" ) class AuthConfig: """HTTP Basic authentication credentials, if configured.""" def __init__(self, username: str, password: str) -> None: self.username = username self.password = password @classmethod def parse(cls, raw: Optional[str]) -> Optional["AuthConfig"]: if not raw: return None if ":" not in raw: raise ValueError("认证信息格式应为 USER:PASSWORD") username, password = raw.split(":", 1) if not username or not password: raise ValueError("认证用户名和密码不能为空") return cls(username, password) def check_header(self, header: str) -> bool: if not header: return False scheme, _, encoded = header.partition(" ") if scheme.lower() != "basic" or not encoded: return False try: decoded = base64.b64decode(encoded, validate=True).decode("utf-8") except (ValueError, UnicodeDecodeError): return False expected = f"{self.username}:{self.password}" return hmac.compare_digest(decoded, expected) class MonitorHTTPServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True def __init__( self, server_address: Tuple[str, int], handler_class: type, service: MonitorService, auth: Optional[AuthConfig], web_dir: Path, stop_event: threading.Event, verbose: bool = False, ) -> None: self.service = service self.auth = auth self.web_dir = web_dir self.stop_event = stop_event self.verbose = verbose super().__init__(server_address, handler_class) class MonitorHandler(BaseHTTPRequestHandler): server_version = f"ServerMonitor/{__version__}" sys_version = "" protocol_version = "HTTP/1.0" # ------------------------------------------------------------------ # Plumbing # ------------------------------------------------------------------ def log_message(self, fmt: str, *args: Any) -> None: if getattr(self.server, "verbose", False): logger.info("%s - %s", self.address_string(), fmt % args) def _base_headers(self, content_type: str, length: int, cache: str = "no-store") -> None: self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(length)) self.send_header("Cache-Control", cache) for key, value in SECURITY_HEADERS.items(): self.send_header(key, value) def _send_bytes( self, status: int, body: bytes, content_type: str, extra: Optional[Dict[str, str]] = None, cache: str = "no-store", head_only: bool = False, ) -> None: self.send_response(status) self._base_headers(content_type, len(body), cache) if extra: for key, value in extra.items(): self.send_header(key, value) self.end_headers() if not head_only: try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): pass def _send_json(self, status: int, payload: Any, head_only: bool = False) -> None: body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") self._send_bytes(status, body, "application/json; charset=utf-8", head_only=head_only) def _authorized(self) -> bool: auth = getattr(self.server, "auth", None) if auth is None: return True if auth.check_header(self.headers.get("Authorization", "")): return True self._send_bytes( 401, "Authentication required\n".encode("utf-8"), "text/plain; charset=utf-8", extra={"WWW-Authenticate": 'Basic realm="Server Monitor", charset="UTF-8"'}, ) return False # ------------------------------------------------------------------ # Routes # ------------------------------------------------------------------ def do_GET(self) -> None: # noqa: N802 parsed = urlsplit(self.path) path = unquote(parsed.path) if path == "/healthz": health = self.server.service.health() self._send_json(200 if health["ok"] else 503, { "ok": health["ok"], "samples": health["samples"], "interval": health["interval"], "uptime_seconds": health["uptime_seconds"], }) return if not self._authorized(): return if path == "/api/snapshot": encoded = self.server.service.snapshot_json() if encoded == "{}": self._send_json(503, {"error": "sampling is warming up"}) return body = encoded.encode("utf-8") self._send_bytes(200, body, "application/json; charset=utf-8") return if path == "/api/stream": self._stream() return static = STATIC_FILES.get(path) if static: filename, content_type = static file_path = (self.server.web_dir / filename).resolve() try: body = file_path.read_bytes() except OSError: self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8") return extra = {"Content-Security-Policy": CSP} if content_type.startswith("text/html") else None self._send_bytes(200, body, content_type, extra=extra, cache="no-cache") return self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8") def do_HEAD(self) -> None: # noqa: N802 parsed = urlsplit(self.path) path = unquote(parsed.path) if path == "/healthz": self._send_json(200, {"ok": True}, head_only=True) return if not self._authorized(): return if path == "/api/snapshot": body = self.server.service.snapshot_json().encode("utf-8") self._send_bytes(200, body, "application/json; charset=utf-8", head_only=True) return static = STATIC_FILES.get(path) if static: filename, content_type = static try: body = (self.server.web_dir / filename).read_bytes() except OSError: self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8", head_only=True) return extra = {"Content-Security-Policy": CSP} if content_type.startswith("text/html") else None self._send_bytes(200, body, content_type, extra=extra, cache="no-cache", head_only=True) return self._send_bytes(404, b"Not found\n", "text/plain; charset=utf-8", head_only=True) def do_POST(self) -> None: # noqa: N802 self._send_bytes(405, b"Method not allowed\n", "text/plain; charset=utf-8", extra={"Allow": "GET, HEAD"}) # ------------------------------------------------------------------ # Server-sent events # ------------------------------------------------------------------ def _stream(self) -> None: service: MonitorService = self.server.service self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-cache, no-transform") self.send_header("X-Accel-Buffering", "no") self.send_header("Connection", "close") for key, value in SECURITY_HEADERS.items(): self.send_header(key, value) self.end_headers() subscriber = service.subscribe() try: self.wfile.write(b"retry: 3000\n\n") encoded = service.snapshot_json() if encoded and encoded != "{}": self.wfile.write(f"data: {encoded}\n\n".encode("utf-8")) self.wfile.flush() while not self.server.stop_event.is_set(): try: payload = subscriber.get(timeout=15.0) except queue.Empty: self.wfile.write(b": ping\n\n") self.wfile.flush() continue self.wfile.write(f"data: {payload}\n\n".encode("utf-8")) self.wfile.flush() except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError): pass finally: service.unsubscribe(subscriber) class IPv6MonitorHTTPServer(MonitorHTTPServer): address_family = socket.AF_INET6 def build_server( bind: str, port: int, service: MonitorService, auth: Optional[AuthConfig] = None, web_dir: Optional[Path] = None, stop_event: Optional[threading.Event] = None, verbose: bool = False, ) -> MonitorHTTPServer: if not 1 <= int(port) <= 65535: raise ValueError("端口必须在 1-65535 之间") web_dir = Path(web_dir) if web_dir else DEFAULT_WEB_DIR if not (web_dir / "index.html").exists(): raise FileNotFoundError(f"找不到前端文件: {web_dir / 'index.html'}") event = stop_event or threading.Event() server_class = IPv6MonitorHTTPServer if ":" in bind else MonitorHTTPServer return server_class((bind, int(port)), MonitorHandler, service, auth, web_dir, event, verbose)