180 lines
6.8 KiB
Python
180 lines
6.8 KiB
Python
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()
|