hy-app-monitor/monitor/http_app.py
2026-04-23 01:14:03 +08:00

692 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .auth import (
build_clear_session_cookie,
build_session_cookie,
create_session,
delete_session,
ensure_auth_store,
get_session,
parse_session_token,
authenticate_user,
)
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
from .dashboard import build_monitor_payload
from .nacos import (
get_nacos_config_list_payload,
get_nacos_config_payload,
save_nacos_config_payload,
validate_nacos_config_payload,
)
from .runtime_config import get_go_config_payload, save_go_config_payload, validate_go_config_payload
from .service_logs import get_service_log_export_payload, get_service_log_preview_payload
from .service_release import build_update_targets_payload
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
from .service_ops import build_restart_targets_payload, rolling_restart_payload
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
# 统一把 JSON 编码成 UTF-8 文本。
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
class MonitorHandler(BaseHTTPRequestHandler):
# 自定义服务名,便于 curl 时识别来源。
server_version = "HyAppMonitor/2.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
# 关闭默认访问日志,避免轮询刷屏。
return
def current_session_token(self) -> str:
# 同一个请求对象里只解析一次 Cookie。
if not hasattr(self, "_cached_session_token"):
self._cached_session_token = parse_session_token(self.headers.get("Cookie"))
return str(self._cached_session_token)
def current_session(self) -> Any:
# 同一个请求对象里也只查一次会话库。
if not hasattr(self, "_cached_session"):
self._cached_session = get_session(self.current_session_token())
return self._cached_session
def do_GET(self) -> None: # noqa: N802
# GET 请求需要响应头和响应体。
self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求只返回响应头。
self.dispatch(send_body=False)
def do_POST(self) -> None: # noqa: N802
# POST 请求主要用于配置保存。
self.dispatch_post()
def dispatch(self, send_body: bool) -> None:
# 只解析 URL 的 path 段。
parsed = urlparse(self.path)
# 顺手解析 query 参数,后面接口直接取。
query = parse_qs(parsed.query, keep_blank_values=True)
# 登录页对未登录用户开放,已登录时直接回总览。
if parsed.path in {"/login", "/login.html"}:
if self.current_session():
return self.redirect("/", send_body=send_body)
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
# 总览页需要先过登录校验,未登录统一跳到登录页。
if parsed.path in {"/", "/index.html"}:
if not self.current_session():
return self.redirect("/login", send_body=send_body)
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
# CSS 文件。
if parsed.path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
# 登录页脚本。
if parsed.path == "/login.js":
return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body)
# 前端业务 JS。
if parsed.path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
# 本地 Vue 运行时。
if parsed.path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
# 会话查询接口单独开放给登录页和主页面做状态判断。
if parsed.path == "/api/auth/session":
return self.handle_auth_session(send_body=send_body)
# 除鉴权接口外,其余 API 一律要求带有效会话。
if parsed.path.startswith("/api/") and not self.require_authentication(send_body=send_body):
return
# 轻量健康检查。
if parsed.path == "/api/health":
return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now()}),
"application/json; charset=utf-8",
send_body=send_body,
)
# 主监控接口。
if parsed.path == "/api/monitor/services":
return self.send_payload(
HTTPStatus.OK,
response_json(build_monitor_payload()),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前 namespace 下全部 Nacos 配置元数据。
if parsed.path == "/api/nacos/configs":
try:
payload = get_nacos_config_list_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回单个配置的原始文本内容。
if parsed.path == "/api/nacos/config":
group_name = self.query_value(query, "group")
data_id = self.query_value(query, "dataId")
if not group_name or not data_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"group and dataId are required",
send_body=send_body,
)
try:
payload = get_nacos_config_payload(group_name, data_id)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前线上 golang 的运行配置。
if parsed.path == "/api/runtime/golang-config":
try:
payload = get_go_config_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回可滚动重启的业务服务。
if parsed.path == "/api/ops/restart-targets":
try:
payload = build_restart_targets_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回可构建并发布的业务服务。
if parsed.path == "/api/ops/update-targets":
try:
payload = build_update_targets_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前服务更新任务的阶段和日志。
if parsed.path == "/api/ops/update-status":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
payload = get_update_job_payload(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "update operation not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回单个服务更新任务的完整文本日志。
if parsed.path == "/api/ops/update-log":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
content = get_update_job_log_text(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "update log not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
)
# 返回单个服务实例最近 1000 条容器日志。
if parsed.path == "/api/ops/service-log":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
payload = get_service_log_preview_payload(service_name, host_name)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 导出单个服务实例指定条数的容器日志。
if parsed.path == "/api/ops/service-log/export":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
lines = self.query_value(query, "lines")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
meta, content = get_service_log_export_payload(service_name, host_name, lines)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
filename = f"{meta['service']}-{meta['host']}-{meta['lines']}.log"
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# favicon 直接返回空。
if parsed.path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
# 其他路径统一 404。
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
def dispatch_post(self) -> None:
# 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path)
# 退出接口允许空请求体,先单独处理。
if parsed.path == "/api/auth/logout":
return self.handle_logout()
# 登录接口对未登录用户开放,但要求标准 JSON 请求体。
if parsed.path == "/api/auth/login":
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
return self.handle_login(payload)
# 其余 POST 接口都要求先通过会话校验。
if not self.require_authentication(send_body=True):
return
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
if parsed.path == "/api/nacos/config/validate":
return self.handle_nacos_validate(payload)
if parsed.path == "/api/nacos/config":
return self.handle_nacos_save(payload)
if parsed.path == "/api/runtime/golang-config/validate":
return self.handle_go_validate(payload)
if parsed.path == "/api/runtime/golang-config":
return self.handle_go_save(payload)
if parsed.path == "/api/ops/rolling-restart":
return self.handle_rolling_restart(payload)
if parsed.path == "/api/ops/update-services":
return self.handle_update_services(payload)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def handle_auth_session(self, send_body: bool) -> None:
session = self.current_session()
# 未登录时返回 401前端据此回到登录页。
if session is None:
return self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=send_body,
)
def handle_login(self, payload: dict[str, Any]) -> None:
username = str(payload.get("username") or "").strip()
password = payload.get("password")
# 登录接口要求账号密码都以字符串传入。
if not isinstance(password, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True)
canonical_username = authenticate_user(username, password)
if not canonical_username:
return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True)
# 同一浏览器重复登录时,先清掉旧 token 对应会话。
delete_session(self.current_session_token())
session = create_session(
canonical_username,
remote_addr=self.client_address[0] if self.client_address else "",
user_agent=self.headers.get("User-Agent", ""),
)
cookie_header = build_session_cookie(
session.token,
max_age=max(1, session.expires_at_ts - int(time.time())),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": cookie_header},
)
def handle_logout(self) -> None:
# 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。
delete_session(self.current_session_token())
return self.send_payload(
HTTPStatus.OK,
response_json({"ok": True}),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": build_clear_session_cookie()},
)
def session_payload(self, session: Any) -> dict[str, Any]:
# 当前只给前端暴露最小必要字段。
return {
"authenticated": True,
"username": session.username,
"expiresAt": session.expires_at,
}
def require_authentication(self, send_body: bool) -> bool:
# 查到有效会话时,当前请求可以继续执行。
if self.current_session() is not None:
return True
# token 缺失或失效时统一打回 401。
self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return False
def auth_cookie_cleanup_headers(self) -> dict[str, str] | None:
# 只有请求里真的带了旧 token才下发清 Cookie 头。
if not self.current_session_token():
return None
return {"Set-Cookie": build_clear_session_cookie()}
def redirect(self, location: str, *, send_body: bool) -> None:
# 页面跳转只依赖 Location响应体保持空即可。
self.send_payload(
HTTPStatus.FOUND,
b"",
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Location": location},
)
def handle_nacos_validate(self, payload: dict[str, Any]) -> None:
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
content = payload.get("content")
config_type = str(payload.get("type") or "").strip() or None
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = validate_nacos_config_payload(
content,
group_name=group_name,
data_id=data_id,
config_type=config_type,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_nacos_save(self, payload: dict[str, Any]) -> None:
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
content = payload.get("content")
config_type = str(payload.get("type") or "").strip() or None
# group / dataId 是保存现有配置的唯一键。
if not group_name or not data_id:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "group and dataId are required", send_body=True)
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = save_nacos_config_payload(group_name, data_id, content, config_type=config_type)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_go_validate(self, payload: dict[str, Any]) -> None:
content = payload.get("content")
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = validate_go_config_payload(content)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_go_save(self, payload: dict[str, Any]) -> None:
content = payload.get("content")
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = save_go_config_payload(content)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_rolling_restart(self, payload: dict[str, Any]) -> None:
services = payload.get("services")
if not isinstance(services, list):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "services must be a list", send_body=True)
try:
result = rolling_restart_payload([str(item or "").strip() for item in services])
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_update_services(self, payload: dict[str, Any]) -> None:
services = payload.get("services")
if not isinstance(services, list):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "services must be a list", send_body=True)
java_git_ref = str(payload.get("javaGitRef") or "").strip()
go_git_ref = str(payload.get("goGitRef") or "").strip()
image_tag = str(payload.get("imageTag") or "").strip()
skip_maven_build = bool(payload.get("skipMavenBuild"))
try:
result = start_update_job(
services,
java_git_ref=java_git_ref,
go_git_ref=go_git_ref,
image_tag=image_tag,
skip_maven_build=skip_maven_build,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.ACCEPTED,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def query_value(self, query: dict[str, list[str]], key: str) -> str:
# 统一读取单值 query 参数。
return str((query.get(key) or [""])[0]).strip()
def read_json_body(self) -> dict[str, Any]:
# Content-Length 缺失时按空请求体处理。
raw_length = self.headers.get("Content-Length", "0").strip() or "0"
try:
content_length = int(raw_length)
except ValueError as exc:
raise ValueError("invalid Content-Length") from exc
# 空请求体不符合当前 JSON 接口要求。
if content_length <= 0:
raise ValueError("request body is required")
# 读取原始请求体。
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid json") from exc
# 顶层必须是对象。
if not isinstance(payload, dict):
raise ValueError("request body must be a json object")
return payload
def serve_static(self, name: str, content_type: str, send_body: bool) -> None:
# 计算静态文件路径。
path = STATIC_DIR / name
# 文件不存在时返回 JSON 错误。
if not path.exists():
return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing", send_body=send_body)
# 文件存在时返回原始二进制内容。
self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type, send_body=send_body)
def send_error_payload(
self,
status: HTTPStatus,
message: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 错误响应也统一走 JSON。
self.send_payload(
status,
response_json({"status": int(status), "error": message}),
"application/json; charset=utf-8",
send_body=send_body,
extra_headers=extra_headers,
)
def send_payload(
self,
status: HTTPStatus,
body: bytes,
content_type: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 写状态码。
self.send_response(status)
# 写内容类型。
self.send_header("Content-Type", content_type)
# 页面和接口都禁用缓存。
self.send_header("Cache-Control", "no-store")
# 允许调用方补充下载文件名等额外头。
for key, value in (extra_headers or {}).items():
self.send_header(key, value)
# 写入长度,便于 HEAD 请求工作正常。
self.send_header("Content-Length", str(len(body)))
# 结束响应头。
self.end_headers()
# 需要响应体时再写内容。
if send_body and body:
self.wfile.write(body)
def main() -> None:
# 启动前先把账号库和默认账号准备好。
ensure_auth_store()
# 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
# 打印监听地址便于排查。
print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True)
# 进入永久监听。
server.serve_forever()