180 lines
6.8 KiB
Python
Raw Permalink 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 ipaddress
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import urlparse
from core.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now
from core.http_paths import join_base_path, strip_base_path
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 HaiYiHandler(BaseHTTPRequestHandler):
# 单独标出这是 HaiYi 内部实例。
server_version = "HyAppMonitorHaiYi/2.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
# 占位实例不打默认访问日志。
return
def do_GET(self) -> None: # noqa: N802
# 只接受来自统一网关的代理请求。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求同样只允许内部网关进入。
if not self.require_internal_proxy(send_body=False):
return
self.dispatch(send_body=False)
def do_POST(self) -> None: # noqa: N802
# 第一阶段 HaiYi 没有写操作接口。
if not self.require_internal_proxy(send_body=True):
return
self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def require_internal_proxy(self, send_body: bool) -> bool:
# 内部实例除了共享密钥,还要求请求确实来自本机回环地址。
# 这样就算端口配置错误暴露到外网,也不能直接跳过统一入口。
actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip()
if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET:
return True
self.send_error_payload(
HTTPStatus.FORBIDDEN,
"internal proxy authorization required",
send_body=send_body,
)
return False
def is_loopback_client(self) -> bool:
# BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。
remote_host = str((self.client_address or ("", 0))[0]).strip()
if not remote_host:
return False
try:
# IPv6 链路本地格式可能带 zone index这里先切掉再判断。
return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback
except ValueError:
return False
def dispatch(self, send_body: bool) -> None:
# 统一先拆 URL后面都只操作 path。
parsed = urlparse(self.path)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 不属于当前应用 base path 的请求直接 404。
if local_path is None:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
# /haiyi 必须补成 /haiyi/,这样相对静态资源路径才稳定。
if APP_BASE_PATH and parsed.path == APP_BASE_PATH:
return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body)
# HaiYi 当前只提供最小占位页。
if local_path in {"/", "/index.html"}:
return self.serve_static("haiyi.html", "text/html; charset=utf-8", send_body=send_body)
if local_path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
if local_path == "/haiyi.js":
return self.serve_static("haiyi.js", "application/javascript; charset=utf-8", send_body=send_body)
if local_path == "/api/health":
return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now(), "app": "haiyi"}),
"application/json; charset=utf-8",
send_body=send_body,
)
if local_path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
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 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:
# 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), HaiYiHandler)
# 打印监听地址和挂载路径,部署排查时更直观。
print(f"hy-app-monitor haiyi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True)
# 进入永久监听。
server.serve_forever()