hy-app-monitor/entry/http_app.py

513 lines
19 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
import urllib.error
import urllib.parse
import urllib.request
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
from core.auth import (
build_clear_session_cookie,
build_session_cookie,
create_session,
delete_session,
ensure_auth_store,
get_session,
parse_session_token,
authenticate_user,
)
from core.config import (
APP_BIND,
APP_PORT,
GATEWAY_PROXY_TIMEOUT_SECONDS,
HAIYI_INTERNAL_HOST,
HAIYI_INTERNAL_PORT,
INTERNAL_PROXY_SHARED_SECRET,
STATIC_DIR,
YUMI_INTERNAL_HOST,
YUMI_INTERNAL_PORT,
utc_now,
)
from core.http_paths import join_base_path, login_redirect_target, 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")
APP_PROXY_TARGETS: tuple[dict[str, Any], ...] = (
{
"app": "yumi",
"basePath": "/yumi",
"upstreamBasePath": "/yumi",
"host": YUMI_INTERNAL_HOST,
"port": YUMI_INTERNAL_PORT,
},
{
"app": "common",
"basePath": "/common",
"upstreamBasePath": "/yumi",
"host": YUMI_INTERNAL_HOST,
"port": YUMI_INTERNAL_PORT,
},
{
"app": "mysql",
"basePath": "/mysql",
"upstreamBasePath": "/yumi",
"host": YUMI_INTERNAL_HOST,
"port": YUMI_INTERNAL_PORT,
},
{
"app": "haiyi",
"basePath": "/haiyi",
"upstreamBasePath": "/haiyi",
"host": HAIYI_INTERNAL_HOST,
"port": HAIYI_INTERNAL_PORT,
},
)
def match_proxy_target(path: str) -> dict[str, Any] | None:
# 网关入口固定收敛成少量前缀MySQL 复用 Yumi 内部实例但暴露独立入口。
for target in APP_PROXY_TARGETS:
base_path = str(target["basePath"])
if path == base_path or path.startswith(f"{base_path}/"):
return dict(target)
return None
def rewrite_target_path(request_path: str, base_path: str, upstream_base_path: str) -> str:
# 别名入口需要把外部前缀重写成内部实例真实挂载前缀。
parsed = urlparse(request_path)
local_path = strip_base_path(base_path, parsed.path)
if local_path is None:
return request_path
rewritten_path = join_base_path(upstream_base_path, local_path)
return urlunparse(parsed._replace(path=rewritten_path))
class GatewayHandler(BaseHTTPRequestHandler):
# 单独标出这是统一入口网关。
server_version = "HyAppMonitorGateway/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)
path = parsed.path
# 根路径只做分流,不再直接承载业务页面。
if path in {"/", "/index.html"}:
if not self.current_session():
return self.redirect("/login", send_body=send_body)
return self.redirect("/select", send_body=send_body)
# 登录页对未登录用户开放,已登录时直接回选择页或 next。
if path in {"/login", "/login.html"}:
if self.current_session():
return self.redirect(self.next_location(), send_body=send_body)
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
# 入口选择页必须先登录。
if path in {"/select", "/select.html"}:
if not self.require_page_authentication(send_body=send_body):
return
return self.serve_static("select.html", "text/html; charset=utf-8", send_body=send_body)
# 网关自有静态资源。
if path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
if path == "/login.js":
return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body)
if path == "/select.js":
return self.serve_static("select.js", "application/javascript; charset=utf-8", send_body=send_body)
# 根级会话接口给登录页和选择页使用。
if path == "/api/auth/session":
return self.handle_auth_session(send_body=send_body)
# 根级健康检查只检查网关自身。
if path == "/api/health":
return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now(), "app": "gateway"}),
"application/json; charset=utf-8",
send_body=send_body,
)
# 子应用前缀下的认证状态统一由网关代答。
if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session", "/mysql/api/auth/session", "/common/api/auth/session"}:
return self.handle_auth_session(send_body=send_body)
# favicon 直接返回空。
if path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
# 子应用其余请求统一走透明反代。
target = self.proxy_target(path)
if target is not None:
if path in {target["basePath"], f"{target['basePath']}/"}:
if not self.require_page_authentication(send_body=send_body):
return
if path == target["basePath"]:
return self.redirect(f"{target['basePath']}/", send_body=send_body)
if not self.ensure_prefixed_authentication(path, send_body=send_body):
return
return self.proxy_request(target, send_body=send_body)
# 其他路径统一 404。
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
def dispatch_post(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
# 退出接口允许空请求体,先单独处理。
if path in {
"/api/auth/logout",
"/yumi/api/auth/logout",
"/haiyi/api/auth/logout",
"/mysql/api/auth/logout",
"/common/api/auth/logout",
}:
return self.handle_logout()
# 登录接口只在根路径开放。
if 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 接口都走统一鉴权后再反代。
target = self.proxy_target(path)
if target is not None:
if not self.ensure_prefixed_authentication(path, send_body=True):
return
try:
body = self.read_raw_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
return self.proxy_request(target, send_body=True, body=body)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def proxy_target(self, path: str) -> dict[str, Any] | None:
# 按固定前缀把请求路由到对应内部实例。
return match_proxy_target(path)
def ensure_prefixed_authentication(self, path: str, *, send_body: bool) -> bool:
# 前缀下的业务页面和 API 都统一要求先登录。
if self.current_session() is not None:
return True
# API 调用返回 401页面请求走登录跳转。
if "/api/" in path:
self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return False
self.redirect(login_redirect_target(self.path), send_body=send_body)
return False
def require_page_authentication(self, *, send_body: bool) -> bool:
# 选择页这类纯页面入口,未登录时统一跳到登录页。
if self.current_session() is not None:
return True
self.redirect(login_redirect_target(self.path), send_body=send_body)
return False
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 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 next_location(self) -> str:
# 登录成功后的回跳只允许站内绝对路径,避免被注入外部地址。
parsed = urlparse(self.path)
next_value = str((parse_qs(parsed.query).get("next") or [""])[0]).strip()
if not next_value:
return "/select"
candidate = urllib.parse.unquote(next_value)
if candidate.startswith("/") and not candidate.startswith("//"):
return candidate
return "/select"
def proxy_request(self, target: dict[str, Any], *, send_body: bool, body: bytes | None = None) -> None:
# 别名入口会先重写前缀,再转发到内部实例真实挂载路径。
upstream_path = rewrite_target_path(
self.path,
str(target["basePath"]),
str(target.get("upstreamBasePath") or target["basePath"]),
)
url = f"http://{target['host']}:{target['port']}{upstream_path}"
headers = self.proxy_headers(target["basePath"])
request = urllib.request.Request(url, data=body, method=self.command, headers=headers)
try:
with urllib.request.urlopen(request, timeout=GATEWAY_PROXY_TIMEOUT_SECONDS) as response:
payload = response.read()
return self.send_payload(
HTTPStatus(response.status),
payload,
response.headers.get("Content-Type", "application/octet-stream"),
send_body=send_body,
extra_headers=self.forward_response_headers(response.headers),
)
except urllib.error.HTTPError as exc:
payload = exc.read()
return self.send_payload(
HTTPStatus(exc.code),
payload,
exc.headers.get("Content-Type", "application/octet-stream"),
send_body=send_body,
extra_headers=self.forward_response_headers(exc.headers),
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
def proxy_headers(self, base_path: str) -> dict[str, str]:
# 只把内部实例真正需要的头透传过去。
excluded = {
"host",
"connection",
"content-length",
"cookie",
"x-hy-internal-proxy-secret",
}
headers = {
key: value
for key, value in self.headers.items()
if str(key or "").strip().lower() not in excluded
}
headers["X-HY-Internal-Proxy-Secret"] = INTERNAL_PROXY_SHARED_SECRET
headers["X-Forwarded-For"] = self.client_address[0] if self.client_address else ""
headers["X-Forwarded-Prefix"] = base_path
headers["X-Forwarded-Proto"] = "http"
return headers
def forward_response_headers(self, headers: Any) -> dict[str, str]:
# 过滤掉由当前网关自己生成的响应头,其他头继续透传。
excluded = {"content-length", "connection", "server", "date", "cache-control"}
forwarded: dict[str, str] = {}
for key, value in headers.items():
if str(key or "").strip().lower() in excluded:
continue
forwarded[str(key)] = str(value)
return forwarded
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 read_raw_body(self) -> bytes | None:
# 原样读取请求体,用于透明代理 POST 请求。
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
if content_length <= 0:
return None
return self.rfile.read(content_length)
def read_json_body(self) -> dict[str, Any]:
# 根级登录接口仍按标准 JSON 读请求体。
raw_body = self.read_raw_body()
if not raw_body:
raise ValueError("request body is required")
try:
payload = json.loads(raw_body.decode("utf-8", errors="replace"))
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), GatewayHandler)
# 打印监听地址便于排查。
print(f"hy-app-monitor gateway listening on http://{APP_BIND}:{APP_PORT}", flush=True)
# 进入永久监听。
server.serve_forever()