513 lines
19 KiB
Python
513 lines
19 KiB
Python
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()
|