feat: add nginx, proxy to jupyter after auth
This commit is contained in:
@@ -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"}
|
||||||
@@ -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"}
|
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,11 @@ def _handle_start(ws_id: str):
|
|||||||
"--ServerApp.terminals_enabled=False",
|
"--ServerApp.terminals_enabled=False",
|
||||||
# 兼容经典 Notebook / 旧版配置项
|
# 兼容经典 Notebook / 旧版配置项
|
||||||
"--NotebookApp.terminals_enabled=False",
|
"--NotebookApp.terminals_enabled=False",
|
||||||
|
# 允许 Nginx 跨域代理与 WebSocket 通信(关键)
|
||||||
|
"--ServerApp.allow_origin=*",
|
||||||
|
"--NotebookApp.allow_origin=*",
|
||||||
|
"--ServerApp.disable_check_xsrf=True",
|
||||||
|
"--NotebookApp.disable_check_xsrf=True"
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user