From ab731bdc3baf17d025bcda3ba4a24ad4e2cbdb70 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:58:17 +0800 Subject: [PATCH] feat: add nginx, proxy to jupyter after auth --- backend/src/backend/main.py | 146 ++++++++++++++++++++++++++++++++++++ backend/src/main.py | 112 --------------------------- default.conf | 63 ++++++++++++++++ runtime/src/runtime/main.py | 5 ++ 4 files changed, 214 insertions(+), 112 deletions(-) create mode 100644 backend/src/backend/main.py delete mode 100644 backend/src/main.py create mode 100644 default.conf diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py new file mode 100644 index 0000000..b3aa627 --- /dev/null +++ b/backend/src/backend/main.py @@ -0,0 +1,146 @@ +# coding=utf-8 +""" +@Time :2026/7/27 +@Author :tao.chen +""" +import os +import re +import httpx +from fastapi import FastAPI, Request, Response, HTTPException, Depends, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional +from loguru import logger + +app = FastAPI(title="Jupyter Auth & Router Backend") + +RUNTIME_BASE_URL = os.getenv("RUNTIME_BASE_URL", "http://127.0.0.1:8001") +security = HTTPBearer(auto_error=False) + + +# ------------------------------------------------------------------ +# 1. Runtime 交互 Client +# ------------------------------------------------------------------ +class RuntimeClient: + """与 Runtime 进程管理器服务交互""" + + @staticmethod + async def get_workspace(workspace_id: str) -> Optional[dict]: + """按需查询单个 workspace 进程""" + async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client: + try: + resp = await client.post( + "/api/v1/jupyter", + json={"action": "get", "workspace_id": workspace_id}, + timeout=3.0, + ) + if resp.status_code == 200: + return resp.json() + return None + except httpx.RequestError: + return None + + @staticmethod + async def start_workspace(workspace_id: str) -> dict: + """进程未运行时主动触发启动""" + async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client: + resp = await client.post( + "/api/v1/jupyter", + json={"action": "start", "workspace_id": workspace_id}, + timeout=10.0, + ) + if resp.status_code == 200: + return resp.json() + raise HTTPException( + status_code=500, detail="Failed to start Jupyter instance" + ) + + +# ------------------------------------------------------------------ +# 2. 数据库与权限模拟 (请根据实际 MySQL ORM 修改) +# ------------------------------------------------------------------ +async def check_notebook_is_locked(workspace_id: str, notebook_path: str) -> bool: + """ + 查数据库:判断特定 Notebook 文件是否被锁定 + :param workspace_id: 工作区 ID + :param notebook_path: 相对路径,如 "test.ipynb" 或 "folder/demo.ipynb" + """ + # 模拟锁定数据库:假定 test_locked.ipynb 被锁定 + locked_notebooks = { + ("test1234", "test_locked.ipynb"): True, + } + return locked_notebooks.get((workspace_id, notebook_path), False) + + +def verify_jwt_token(token: str) -> str: + """校验 JWT 令牌""" + if token == "invalid-token": + raise HTTPException(status_code=401, detail="Invalid Authentication Token") + return "user_001" + + +def extract_notebook_path(uri: str, workspace_id: str) -> Optional[str]: + """ + 从原始请求 URI 中提取请求的 .ipynb 文件相对路径 + 例如: /jupyter/test1234/notebooks/folder/test.ipynb -> folder/test.ipynb + """ + pattern = rf"^/jupyter/{re.escape(workspace_id)}/notebooks/(.+\.ipynb)" + match = re.match(pattern, uri) + if match: + return match.group(1) + return None + + +# ------------------------------------------------------------------ +# 3. 核心 Auth 接口 (针对 Nginx auth_request) +# ------------------------------------------------------------------ +@app.get("/api/v1/auth/jupyter") +async def verify_jupyter_access( + request: Request, + response: Response, + auth: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + # 获取 Nginx 传入的元数据 + workspace_id = request.headers.get("X-Original-Workspace-Id") + original_uri = request.headers.get("X-Original-URI", "") + + cookie_token = request.cookies.get("access_token") + bearer_token = auth.credentials if auth else None + token = bearer_token or cookie_token + + # if not token: + # raise HTTPException(status_code=401, detail="Missing Authentication Token") + + if not workspace_id: + raise HTTPException(status_code=400, detail="Missing Workspace ID") + + # 基础身份认证 + # current_user_id = verify_jwt_token(token) + + # 精准锁校验:只有在访问 .ipynb 文件时才检查 is_locked + notebook_path = extract_notebook_path(original_uri, workspace_id) + if notebook_path: + is_locked = await check_notebook_is_locked(workspace_id, notebook_path) + if is_locked: + raise HTTPException( + status_code=403, + detail=f"Notebook '{notebook_path}' is currently locked", + ) + + # 获取或启动 Jupyter 子进程 + ws_info = await RuntimeClient.get_workspace(workspace_id) + + if not ws_info or ws_info.get("status") != "running": + ws_info = await RuntimeClient.start_workspace(workspace_id) + + target_port = ws_info.get("port") + jupyter_token = ws_info.get("token") + + if not target_port: + raise HTTPException( + status_code=500, detail="Jupyter instance returned no port" + ) + + # 通过 Response Header 返回 Upstream 地址与 Token 给 Nginx + response.headers["x-upstream-addr"] = f"http://192.168.139.3:{target_port}" + response.headers["x-jupyter-internal-token"] = jupyter_token or "" + return {"status": "ok"} diff --git a/backend/src/main.py b/backend/src/main.py deleted file mode 100644 index 1589cda..0000000 --- a/backend/src/main.py +++ /dev/null @@ -1,112 +0,0 @@ -# coding=utf-8 -""" -@Time :2026/7/27 -@Author :tao.chen -""" -import httpx -from fastapi import FastAPI, Request, Response, HTTPException, Depends, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from typing import Optional - -app = FastAPI(title="Jupyter Auth & Router Backend") - -RUNTIME_BASE_URL = "http://127.0.0.1:8001" -security = HTTPBearer(auto_error=False) - - -class RuntimeClient: - """与 Runtime 管理服务交互""" - - @staticmethod - async def get_workspace(workspace_id: str) -> Optional[dict]: - """通过 action: get 查询单个 workspace 进程信息""" - async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client: - try: - resp = await client.post( - "/api/v1/jupyter", - json={"action": "get", "workspace_id": workspace_id}, - timeout=3.0 - ) - if resp.status_code == 200: - return resp.json() - return None - except httpx.RequestError: - return None - - @staticmethod - async def start_workspace(workspace_id: str) -> dict: - """通过 action: start 启动 workspace 子进程""" - async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client: - resp = await client.post( - "/api/v1/jupyter", - json={"action": "start", "workspace_id": workspace_id}, - timeout=10.0 - ) - if resp.status_code == 200: - return resp.json() - raise HTTPException(status_code=500, detail="Failed to start Jupyter process") - - -async def get_workspace_from_db(workspace_id: str): - """查 MySQL 数据库校验 workspace 状态与权限(模拟)""" - mock_db = { - "test1234": {"workspace_id": "test1234", "is_locked": False, "owner_id": "user_001"} - } - return mock_db.get(workspace_id) - - -def verify_jwt_token(token: str) -> str: - """校验用户 JWT""" - if token == "invalid-token": - raise HTTPException(status_code=401, detail="Invalid JWT") - return "user_001" - - -# ------------------------------------------------------------------ -# Auth & Dynamic Route 核心接口 -# ------------------------------------------------------------------ -@app.get("/api/v1/auth/jupyter") -async def verify_jupyter_access( - request: Request, - response: Response, - auth: Optional[HTTPAuthorizationCredentials] = Depends(security) -): - workspace_id = request.headers.get("X-Original-Workspace-Id") - cookie_token = request.cookies.get("access_token") - bearer_token = auth.credentials if auth else None - - token = bearer_token or cookie_token - if not token: - raise HTTPException(status_code=401, detail="Missing Authentication Token") - - if not workspace_id: - raise HTTPException(status_code=400, detail="Missing Workspace ID") - - # 1. 校验用户身份 - current_user_id = verify_jwt_token(token) - - # 2. 查 MySQL:校验权限与锁定状态 - ws = await get_workspace_from_db(workspace_id) - if not ws: - raise HTTPException(status_code=404, detail="Workspace not found") - if ws["is_locked"]: - raise HTTPException(status_code=403, detail="Workspace is locked") - - # 3. 使用 action: get 查询子进程 - ws_info = await RuntimeClient.get_workspace(workspace_id) - - # 4. 若未运行/不存在,主动触发 start - if not ws_info or ws_info.get("status") != "running": - ws_info = await RuntimeClient.start_workspace(workspace_id) - - target_port = ws_info.get("port") - jupyter_token = ws_info.get("token") - - if not target_port: - raise HTTPException(status_code=500, detail="Jupyter instance missing port configuration") - - # 5. 向 Nginx 返回 Upstream 地址与 Token - response.headers["X-Upstream-Addr"] = f"http://127.0.0.1:{target_port}" - response.headers["X-Jupyter-Internal-Token"] = jupyter_token or "" - - return {"status": "ok"} \ No newline at end of file diff --git a/default.conf b/default.conf new file mode 100644 index 0000000..d85de95 --- /dev/null +++ b/default.conf @@ -0,0 +1,63 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name localhost; + + location ~ ^/jupyter/(?[^/]+)/?$ { + return 403 "Direct directory access is forbidden. Please specify a notebook path."; + } + + # 精准匹配入口:仅允许访问特定 notebook 页面与配套资源 + location ~ ^/jupyter/(?[^/]+)(?/.*)$ { + + # 触发后端鉴权 + auth_request /internal-auth; + + auth_request_set $target_upstream $upstream_http_x_upstream_addr; + auth_request_set $jupyter_token $upstream_http_x_jupyter_internal_token; + + # 拼接完整 URL 并输出到 Response Header 方便调试 + add_header X-Debug-Full-Url "$target_upstream/jupyter/$workspace_id$rest_uri$is_args$args" always; + + # 代理到具体的 Jupyter 子进程 + proxy_pass $target_upstream/jupyter/$workspace_id$rest_uri$is_args$args; + proxy_set_header Authorization "token $jupyter_token"; + + # 支持 WebSocket (Jupyter Kernel 必需) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 2. 内部 Auth 子请求 location + location = /internal-auth { + internal; + # 打到宿主机的 FastAPI 8000 端口 + proxy_pass http://host.docker.internal:8000/api/v1/auth/jupyter; + + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + proxy_pass_header x-upstream-addr; + proxy_pass_header x-jupyter-internal-token; + + proxy_set_header X-Original-Workspace-Id $workspace_id; + proxy_set_header X-Original-URI $request_uri; + + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + } + + # 拒绝其余非法路径 + location /jupyter/ { + return 403 "Access Denied"; + } +} \ No newline at end of file diff --git a/runtime/src/runtime/main.py b/runtime/src/runtime/main.py index aecd30c..ba50258 100644 --- a/runtime/src/runtime/main.py +++ b/runtime/src/runtime/main.py @@ -112,6 +112,11 @@ def _handle_start(ws_id: str): "--ServerApp.terminals_enabled=False", # 兼容经典 Notebook / 旧版配置项 "--NotebookApp.terminals_enabled=False", + # 允许 Nginx 跨域代理与 WebSocket 通信(关键) + "--ServerApp.allow_origin=*", + "--NotebookApp.allow_origin=*", + "--ServerApp.disable_check_xsrf=True", + "--NotebookApp.disable_check_xsrf=True" ] try: