Add Linux server monitor dashboard
This commit is contained in:
1 parent
0446648ed1
commit
315cb36a21
19 files changed
+5997
-1
No files matched your search
@@ -0,0 +1,298 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user