feat: add nginx, proxy to jupyter after auth

This commit is contained in:
tao.chen
2026-07-27 19:58:17 +08:00
parent 1c03a32298
commit ab731bdc3b
4 changed files with 214 additions and 112 deletions
+146
View File
@@ -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"}
-112
View File
@@ -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"}
+63
View File
@@ -0,0 +1,63 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name localhost;
location ~ ^/jupyter/(?<workspace_id>[^/]+)/?$ {
return 403 "Direct directory access is forbidden. Please specify a notebook path.";
}
# 精准匹配入口:仅允许访问特定 notebook 页面与配套资源
location ~ ^/jupyter/(?<workspace_id>[^/]+)(?<rest_uri>/.*)$ {
# 触发后端鉴权
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";
}
}
+5
View File
@@ -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: