BirkhoffLee commited on
Commit
b6079be
·
unverified ·
1 Parent(s): 83d3f97

feat: 切换到 FastAPI 门控

Browse files
Files changed (4) hide show
  1. Caddyfile +0 -15
  2. Dockerfile +8 -4
  3. entrypoint.sh +9 -40
  4. gateway.py +368 -0
Caddyfile DELETED
@@ -1,15 +0,0 @@
1
- :7860 {
2
- # 健康检查不需要认证
3
- @health path /healthz
4
- handle @health {
5
- respond "ok" 200
6
- }
7
-
8
- # 其他请求走基础认证后转发到 pdf2zh_next
9
- handle {
10
- basic_auth {
11
- import /etc/caddy/users.auth
12
- }
13
- reverse_proxy 127.0.0.1:7861
14
- }
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -14,8 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
14
  libgomp1 \
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
- # 从官方镜像直接复制 caddy 和 uv 二进制
18
- COPY --from=caddy:latest /usr/bin/caddy /usr/local/bin/caddy
19
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
20
 
21
  ENV PATH="/root/.local/bin:${PATH}" \
@@ -27,8 +26,13 @@ ENV PATH="/root/.local/bin:${PATH}" \
27
  # 安装 pdf2zh-next
28
  RUN uv tool install --python 3.12 pdf2zh-next
29
 
30
- # 复制并启用配置
31
- COPY Caddyfile /etc/caddy/Caddyfile
 
 
 
 
 
32
  COPY entrypoint.sh /entrypoint.sh
33
  RUN chmod +x /entrypoint.sh
34
 
 
14
  libgomp1 \
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
+ # 从官方镜像复制 uv 二进制
 
18
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
19
 
20
  ENV PATH="/root/.local/bin:${PATH}" \
 
26
  # 安装 pdf2zh-next
27
  RUN uv tool install --python 3.12 pdf2zh-next
28
 
29
+ # 安装网关依赖
30
+ RUN uv pip install --python 3.12 \
31
+ "fastapi>=0.115" "uvicorn[standard]>=0.32" "httpx>=0.28" \
32
+ python-multipart bcrypt itsdangerous websockets
33
+
34
+ # 复制网关和启动脚本
35
+ COPY gateway.py /gateway.py
36
  COPY entrypoint.sh /entrypoint.sh
37
  RUN chmod +x /entrypoint.sh
38
 
entrypoint.sh CHANGED
@@ -1,67 +1,36 @@
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
4
- # 解析 HF Secret,支持多行或 \n 形式
5
  RAW_USERS="${BASIC_AUTH_USERS:-}"
6
  if [[ -z "${RAW_USERS}" ]]; then
7
  echo "[ERROR] BASIC_AUTH_USERS is required. Use one 'username:password' per line." >&2
8
  exit 1
9
  fi
10
- RAW_USERS="${RAW_USERS//\\n/$'\n'}"
11
 
12
- AUTH_FILE="/etc/caddy/users.auth"
13
- > "${AUTH_FILE}"
14
- chmod 600 "${AUTH_FILE}"
15
-
16
- # 逐行生成 caddy 可用的哈希密码
17
- while IFS= read -r line || [[ -n "${line}" ]]; do
18
- line="${line%$'\r'}"
19
- [[ -z "${line}" ]] && continue
20
- [[ "${line}" == \#* ]] && continue
21
-
22
- if [[ "${line}" != *:* ]]; then
23
- echo "[ERROR] Invalid BASIC_AUTH_USERS line: '${line}'" >&2
24
- exit 1
25
- fi
26
-
27
- user="${line%%:*}"
28
- pass="${line#*:}"
29
- if [[ -z "${user}" || -z "${pass}" ]]; then
30
- echo "[ERROR] Empty username or password is not allowed: '${line}'" >&2
31
- exit 1
32
- fi
33
-
34
- hash="$(caddy hash-password --plaintext "${pass}")"
35
- printf "%s %s\n" "${user}" "${hash}" >> "${AUTH_FILE}"
36
- done <<< "${RAW_USERS}"
37
-
38
- if [[ ! -s "${AUTH_FILE}" ]]; then
39
- echo "[ERROR] No valid user entries found in BASIC_AUTH_USERS." >&2
40
- exit 1
41
- fi
42
-
43
- # 固定内部监听,确保只能经由 caddy 访问
44
  export GRADIO_SERVER_NAME="${GRADIO_SERVER_NAME:-127.0.0.1}"
45
  export GRADIO_SERVER_PORT="${GRADIO_SERVER_PORT:-7861}"
46
- # 一些 gradio 应用优先读取 PORT;这里强制覆盖避免与 caddy 的 7860 冲突
47
  export PORT="${GRADIO_SERVER_PORT}"
48
 
49
  echo "[INFO] Starting pdf2zh_next on ${GRADIO_SERVER_NAME}:${GRADIO_SERVER_PORT}"
50
  pdf2zh_next --gui --server-port "${GRADIO_SERVER_PORT}" &
51
  PDF_PID=$!
52
 
53
- echo "[INFO] Starting caddy on :7860"
54
- caddy run --config /etc/caddy/Caddyfile &
55
- CADDY_PID=$!
 
56
 
57
  cleanup() {
58
- kill "${PDF_PID}" "${CADDY_PID}" 2>/dev/null || true
59
  }
60
  trap cleanup INT TERM EXIT
61
 
62
  # 任一进程退出都结束容器
63
  set +e
64
- wait -n "${PDF_PID}" "${CADDY_PID}"
65
  EXIT_CODE=$?
66
  set -e
67
  echo "[ERROR] A service exited unexpectedly. Shutting down."
 
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
4
+ # 验证必需的环境变量
5
  RAW_USERS="${BASIC_AUTH_USERS:-}"
6
  if [[ -z "${RAW_USERS}" ]]; then
7
  echo "[ERROR] BASIC_AUTH_USERS is required. Use one 'username:password' per line." >&2
8
  exit 1
9
  fi
 
10
 
11
+ # 固定内部监听,确保 pdf2zh_next 只能经由网关访问
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  export GRADIO_SERVER_NAME="${GRADIO_SERVER_NAME:-127.0.0.1}"
13
  export GRADIO_SERVER_PORT="${GRADIO_SERVER_PORT:-7861}"
14
+ # 部分 Gradio 应用优先读取 PORT;强制覆盖避免与网关的 7860 冲突
15
  export PORT="${GRADIO_SERVER_PORT}"
16
 
17
  echo "[INFO] Starting pdf2zh_next on ${GRADIO_SERVER_NAME}:${GRADIO_SERVER_PORT}"
18
  pdf2zh_next --gui --server-port "${GRADIO_SERVER_PORT}" &
19
  PDF_PID=$!
20
 
21
+ echo "[INFO] Starting gateway on :7860"
22
+ cd / && uv run --python 3.12 --no-project python -m uvicorn gateway:app \
23
+ --host 0.0.0.0 --port 7860 --log-level info &
24
+ GW_PID=$!
25
 
26
  cleanup() {
27
+ kill "${PDF_PID}" "${GW_PID}" 2>/dev/null || true
28
  }
29
  trap cleanup INT TERM EXIT
30
 
31
  # 任一进程退出都结束容器
32
  set +e
33
+ wait -n "${PDF_PID}" "${GW_PID}"
34
  EXIT_CODE=$?
35
  set -e
36
  echo "[ERROR] A service exited unexpectedly. Shutting down."
gateway.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """FastAPI 认证网关:Session 登录 + 反向代理到 pdf2zh_next(:7861)。
3
+
4
+ 架构:用户 → Gateway(:7860) [Session Auth] → pdf2zh_next(:7861)
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import secrets
11
+ from typing import Optional
12
+
13
+ import bcrypt
14
+ import httpx
15
+ import websockets
16
+ import websockets.exceptions
17
+ from fastapi import FastAPI, Form, Request, WebSocket
18
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse
19
+ from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
20
+ from starlette.background import BackgroundTask
21
+
22
+ # ── 配置 ──────────────────────────────────────────────────────────────────────
23
+ UPSTREAM_HTTP = "http://127.0.0.1:7861"
24
+ UPSTREAM_WS = "ws://127.0.0.1:7861"
25
+ SESSION_COOKIE = "gw_session"
26
+ SESSION_MAX_AGE = 86400 # 24 小时
27
+
28
+ SECRET_KEY = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
29
+ signer = TimestampSigner(SECRET_KEY)
30
+
31
+ # hop-by-hop headers(不透传给上游或客户端)
32
+ HOP_BY_HOP = frozenset(
33
+ [
34
+ "connection",
35
+ "keep-alive",
36
+ "proxy-authenticate",
37
+ "proxy-authorization",
38
+ "te",
39
+ "trailers",
40
+ "transfer-encoding",
41
+ "upgrade",
42
+ ]
43
+ )
44
+
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format="%(asctime)s %(levelname)s %(name)s - %(message)s",
48
+ )
49
+ logger = logging.getLogger("gateway")
50
+
51
+
52
+ # ── 用户加载与认证 ────────────────────────────────────────────────────────────
53
+ def _load_users() -> dict[str, str]:
54
+ """从 BASIC_AUTH_USERS 环境变量加载用户名和明文密码。
55
+
56
+ 支持格式:每行 `username:password`,支持 \\n 转义的单行形式。
57
+ """
58
+ raw = os.environ.get("BASIC_AUTH_USERS", "").replace("\\n", "\n")
59
+ users: dict[str, str] = {}
60
+ for line in raw.splitlines():
61
+ line = line.strip()
62
+ if not line or line.startswith("#"):
63
+ continue
64
+ if ":" not in line:
65
+ logger.warning("Skipping invalid BASIC_AUTH_USERS line (no colon)")
66
+ continue
67
+ username, password = line.split(":", 1)
68
+ if username and password:
69
+ users[username] = password
70
+ if not users:
71
+ logger.error("No valid users found — authentication will always fail")
72
+ return users
73
+
74
+
75
+ USERS = _load_users()
76
+
77
+
78
+ def _verify_credentials(username: str, password: str) -> bool:
79
+ """验证用户名密码,使用 secrets.compare_digest 防止时序攻击。
80
+
81
+ 支持明文密码和 bcrypt 哈希(以 $2b$ 开头)。
82
+ """
83
+ stored = USERS.get(username)
84
+ if stored is None:
85
+ return False
86
+ if stored.startswith("$2"):
87
+ # bcrypt 哈希
88
+ return bcrypt.checkpw(password.encode(), stored.encode())
89
+ # 明文对比
90
+ return secrets.compare_digest(stored, password)
91
+
92
+
93
+ # ── Session 签名 ──────────────────────────────────────────────────────────────
94
+ def _make_session(username: str) -> str:
95
+ return signer.sign(username).decode()
96
+
97
+
98
+ def _verify_session(token: str) -> Optional[str]:
99
+ try:
100
+ return signer.unsign(token, max_age=SESSION_MAX_AGE).decode()
101
+ except (BadSignature, SignatureExpired):
102
+ return None
103
+
104
+
105
+ def _get_session(request: Request) -> Optional[str]:
106
+ token = request.cookies.get(SESSION_COOKIE)
107
+ return _verify_session(token) if token else None
108
+
109
+
110
+ # ── 登录页 HTML ───────────────────────────────────────────────────────────────
111
+ _LOGIN_HTML = """\
112
+ <!DOCTYPE html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="UTF-8">
116
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
117
+ <title>Sign In</title>
118
+ <style>
119
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
120
+ body {{
121
+ min-height: 100vh;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ background: linear-gradient(135deg, #f0f2f5 0%, #e4e8f0 100%);
126
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
127
+ }}
128
+ .card {{
129
+ background: #fff;
130
+ border-radius: 14px;
131
+ box-shadow: 0 6px 32px rgba(0, 0, 0, 0.10);
132
+ padding: 44px 40px;
133
+ width: 100%;
134
+ max-width: 400px;
135
+ }}
136
+ h1 {{ font-size: 1.5rem; font-weight: 700; color: #111827; margin-bottom: 6px; }}
137
+ p.sub {{ font-size: 0.875rem; color: #6b7280; margin-bottom: 30px; }}
138
+ label {{ display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 6px; }}
139
+ input[type=text], input[type=password] {{
140
+ width: 100%;
141
+ padding: 11px 14px;
142
+ border: 1.5px solid #e5e7eb;
143
+ border-radius: 8px;
144
+ font-size: 0.95rem;
145
+ outline: none;
146
+ transition: border-color 0.15s;
147
+ margin-bottom: 20px;
148
+ color: #111827;
149
+ }}
150
+ input:focus {{ border-color: #4f6ef7; box-shadow: 0 0 0 3px rgba(79,110,247,0.12); }}
151
+ button {{
152
+ width: 100%;
153
+ padding: 12px;
154
+ background: linear-gradient(135deg, #4f6ef7 0%, #3b5bdb 100%);
155
+ color: #fff;
156
+ border: none;
157
+ border-radius: 8px;
158
+ font-size: 1rem;
159
+ font-weight: 600;
160
+ cursor: pointer;
161
+ transition: opacity 0.15s;
162
+ letter-spacing: 0.01em;
163
+ }}
164
+ button:hover {{ opacity: 0.88; }}
165
+ .error {{
166
+ background: #fef2f2;
167
+ border: 1.5px solid #fecaca;
168
+ border-radius: 8px;
169
+ padding: 10px 14px;
170
+ font-size: 0.875rem;
171
+ color: #dc2626;
172
+ margin-bottom: 20px;
173
+ }}
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <div class="card">
178
+ <h1>Welcome back</h1>
179
+ <p class="sub">Sign in to continue</p>
180
+ {error_block}
181
+ <form method="post" action="/login">
182
+ <label for="u">Username</label>
183
+ <input id="u" type="text" name="username" autocomplete="username" required autofocus>
184
+ <label for="p">Password</label>
185
+ <input id="p" type="password" name="password" autocomplete="current-password" required>
186
+ <button type="submit">Sign in</button>
187
+ </form>
188
+ </div>
189
+ </body>
190
+ </html>
191
+ """
192
+
193
+
194
+ def _login_page(error: str = "") -> str:
195
+ error_block = f'<div class="error">{error}</div>' if error else ""
196
+ return _LOGIN_HTML.format(error_block=error_block)
197
+
198
+
199
+ # ── FastAPI App ───────────────────────────────────────────────────────────────
200
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
201
+
202
+ _http_client: Optional[httpx.AsyncClient] = None
203
+
204
+
205
+ @app.on_event("startup")
206
+ async def _startup() -> None:
207
+ global _http_client
208
+ _http_client = httpx.AsyncClient(
209
+ base_url=UPSTREAM_HTTP,
210
+ follow_redirects=False,
211
+ timeout=httpx.Timeout(60.0),
212
+ )
213
+ logger.info("Gateway started. Upstream: %s", UPSTREAM_HTTP)
214
+
215
+
216
+ @app.on_event("shutdown")
217
+ async def _shutdown() -> None:
218
+ if _http_client:
219
+ await _http_client.aclose()
220
+
221
+
222
+ # ── 路由:无需认证 ─────────────────────────────────────────────────────────────
223
+ @app.get("/healthz")
224
+ async def healthz() -> Response:
225
+ return Response("ok", media_type="text/plain")
226
+
227
+
228
+ @app.get("/login", response_class=HTMLResponse)
229
+ async def login_page(request: Request) -> HTMLResponse:
230
+ if _get_session(request):
231
+ return RedirectResponse("/", status_code=302)
232
+ return HTMLResponse(_login_page())
233
+
234
+
235
+ @app.post("/login")
236
+ async def login(
237
+ request: Request,
238
+ username: str = Form(...),
239
+ password: str = Form(...),
240
+ ) -> Response:
241
+ next_url = request.query_params.get("next", "/")
242
+ if _verify_credentials(username, password):
243
+ token = _make_session(username)
244
+ resp = RedirectResponse(next_url, status_code=303)
245
+ resp.set_cookie(
246
+ SESSION_COOKIE,
247
+ token,
248
+ max_age=SESSION_MAX_AGE,
249
+ httponly=True,
250
+ samesite="lax",
251
+ )
252
+ logger.info("Login successful: %s", username)
253
+ return resp
254
+ logger.warning("Login failed: %s", username)
255
+ return HTMLResponse(_login_page("Invalid username or password."), status_code=401)
256
+
257
+
258
+ @app.get("/logout")
259
+ async def logout() -> Response:
260
+ resp = RedirectResponse("/login", status_code=302)
261
+ resp.delete_cookie(SESSION_COOKIE)
262
+ return resp
263
+
264
+
265
+ # ── 路由:WebSocket 透传(Gradio 依赖) ────────────────────────────────────────
266
+ @app.websocket("/{path:path}")
267
+ async def ws_proxy(websocket: WebSocket, path: str) -> None:
268
+ """WebSocket 透传:验证 session 后桥接到上游。"""
269
+ token = websocket.cookies.get(SESSION_COOKIE)
270
+ if not token or not _verify_session(token):
271
+ await websocket.close(code=1008) # Policy Violation
272
+ return
273
+
274
+ await websocket.accept()
275
+
276
+ qs = websocket.scope.get("query_string", b"").decode()
277
+ upstream_url = f"{UPSTREAM_WS}/{path}"
278
+ if qs:
279
+ upstream_url += f"?{qs}"
280
+
281
+ try:
282
+ async with websockets.connect(upstream_url) as upstream:
283
+
284
+ async def _client_to_upstream() -> None:
285
+ try:
286
+ while True:
287
+ data = await websocket.receive()
288
+ if data["type"] == "websocket.disconnect":
289
+ break
290
+ msg = data.get("bytes") or data.get("text")
291
+ if msg is not None:
292
+ await upstream.send(msg)
293
+ except Exception:
294
+ pass
295
+ finally:
296
+ await upstream.close()
297
+
298
+ async def _upstream_to_client() -> None:
299
+ try:
300
+ async for msg in upstream:
301
+ if isinstance(msg, bytes):
302
+ await websocket.send_bytes(msg)
303
+ else:
304
+ await websocket.send_text(msg)
305
+ except Exception:
306
+ pass
307
+
308
+ tasks = [
309
+ asyncio.create_task(_client_to_upstream()),
310
+ asyncio.create_task(_upstream_to_client()),
311
+ ]
312
+ _done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
313
+ for t in pending:
314
+ t.cancel()
315
+
316
+ except websockets.exceptions.WebSocketException as exc:
317
+ logger.debug("WS proxy closed: %s", exc)
318
+ except Exception as exc:
319
+ logger.debug("WS proxy error: %s", exc)
320
+
321
+
322
+ # ── 路由:HTTP 反向代理 ────────────────────────────────────────────────────────
323
+ @app.api_route(
324
+ "/{path:path}",
325
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
326
+ )
327
+ async def http_proxy(request: Request, path: str) -> Response:
328
+ """HTTP 反向代理:需要 session,流式转发请求和响应。"""
329
+ if not _get_session(request):
330
+ if request.method == "GET":
331
+ dest = f"/{path}" if path else "/"
332
+ return RedirectResponse(f"/login?next={dest}", status_code=302)
333
+ return Response("Unauthorized", status_code=401)
334
+
335
+ # 构建上游请求头
336
+ headers = {
337
+ k: v
338
+ for k, v in request.headers.items()
339
+ if k.lower() not in HOP_BY_HOP and k.lower() != "host"
340
+ }
341
+
342
+ url = f"/{path}"
343
+ if request.url.query:
344
+ url += f"?{request.url.query}"
345
+
346
+ # 读取请求体(对于大文件可换成流式,但原型够用)
347
+ body = await request.body()
348
+
349
+ upstream_req = _http_client.build_request(
350
+ request.method,
351
+ url,
352
+ headers=headers,
353
+ content=body,
354
+ )
355
+ upstream_resp = await _http_client.send(upstream_req, stream=True)
356
+
357
+ resp_headers = {
358
+ k: v
359
+ for k, v in upstream_resp.headers.items()
360
+ if k.lower() not in HOP_BY_HOP
361
+ }
362
+
363
+ return StreamingResponse(
364
+ upstream_resp.aiter_bytes(),
365
+ status_code=upstream_resp.status_code,
366
+ headers=resp_headers,
367
+ background=BackgroundTask(upstream_resp.aclose),
368
+ )