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 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 login_redirect_target 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 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"}: 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']}/"} and not self.require_page_authentication(send_body=send_body): return 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"}: 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: # 按固定前缀把请求路由到对应内部实例。 if path == "/yumi" or path.startswith("/yumi/"): return { "app": "yumi", "basePath": "/yumi", "host": YUMI_INTERNAL_HOST, "port": YUMI_INTERNAL_PORT, } if path == "/haiyi" or path.startswith("/haiyi/"): return { "app": "haiyi", "basePath": "/haiyi", "host": HAIYI_INTERNAL_HOST, "port": HAIYI_INTERNAL_PORT, } return None 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: # 透传完整 path + query,保持前缀路径和下载地址都不变。 url = f"http://{target['host']}:{target['port']}{self.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()