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",
|
||||
# 兼容经典 Notebook / 旧版配置项
|
||||
"--NotebookApp.terminals_enabled=False",
|
||||
# 允许 Nginx 跨域代理与 WebSocket 通信(关键)
|
||||
"--ServerApp.allow_origin=*",
|
||||
"--NotebookApp.allow_origin=*",
|
||||
"--ServerApp.disable_check_xsrf=True",
|
||||
"--NotebookApp.disable_check_xsrf=True"
|
||||
]
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user