Commit ad566a41ece5a5d30c8a39065a9209c54911804e

Authored by yibo
1 parent 5a7632a3

h3-console 加 Bearer token 鉴权,comfyui.wudaima.com 补 HTTP Basic Auth

公网审计发现 qwen.wudaima.com/console 无鉴权可直调(已知问题一直没排上),以及
comfyui.wudaima.com 也在裸奔(raylight 上线后 8188 常驻监听,旧的"隧道空挂"假设
已经过期,没人重新审计过)。两处都已实测确认修复生效:console 的 /v1/* 全部接口
要求 Authorization: Bearer <token>(H3_CONSOLE_AUTH_TOKEN 环境变量),ComfyUI 隧道
用 frp 原生的 http_user/http_pwd 加了基本认证。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SURs92UuTkFxDF4RXsEFJi
CHANGELOG.md
1 1 # 变更记录
2 2  
  3 +## 2026-08-29 — h3-console 加 Bearer token 鉴权,comfyui.wudaima.com 补基本认证
  4 +
  5 +**背景**:审计公网暴露面时发现两个真实问题,都是**实测确认**,不是猜测:
  6 +1. `qwen.wudaima.com/console`(h3-console 公网实例)无鉴权,`curl` 直接能拿到所有历史任务
  7 + (包括别人的 prompt 内容)、能提交新任务消耗4卡GPU。这是已知问题(`DESIGN.md` 问题3,
  8 + 一直"跑通了再加"没排上)。
  9 +2. **新发现**:`comfyui.wudaima.com` 也是活的、且完全无鉴权——`raylight/README.md` 里"8188
  10 + 仅内部调用,不对外"这个假设已经过期。根因:`comfyui.frpc.ini` 这条 frp 隧道很早就配了,
  11 + 当时 ComfyUI(8188) 没监听,隧道空挂无害;2026-08-22 raylight 上线后 ComfyUI 常驻监听
  12 + `0.0.0.0:8188`,隧道悄悄变成活的,没人重新审计过。`curl http://comfyui.wudaima.com/
  13 + system_stats`/`/queue` 直接返回真实数据。ComfyUI 原生 API 能提交任意 workflow 图,风险面
  14 + 比 console 的受限 `/v1/videos` 接口大得多。
  15 +
  16 +**修复**:
  17 +- `h3-console/server.py`:`/v1/*` 全部接口挂到独立的 `api` router,加 `Depends(_require_token)`,
  18 + 校验 `Authorization: Bearer <token>`(token 走 `H3_CONSOLE_AUTH_TOKEN` 环境变量,未设则不校验,
  19 + 局域网/本地开发不受影响)。页面路由(`/`、`/newtask.html`、`/task.html/{tid}`)和 `/assets/*`
  20 + 静态文件不校验——前者是鸡生蛋蛋生鸡,后者是 GPU worker 要能反向拉取上传的参考图。
  21 + `<video src>`/下载链接是浏览器原生请求加不上 header,改成同时认 `?token=` 查询参数。
  22 + `static/index.html` 首次调用 API 时 `prompt()` 弹一次令牌存 `localStorage`,401 清空重弹。
  23 + 两个 systemd 实例(8080/8081)都配了同一个 token,已重启验证:无 token/错 token 返回 401,
  24 + 正确 token 返回 200,公网 `qwen.wudaima.com/console` 和局域网 8080 都测过。
  25 +- `comfyui.frpc.ini`:加 `http_user`/`http_pwd`(frp ini 原生字段,`frpc http -h` 确认存在,
  26 + 不用改 ComfyUI 一行代码),`pm2 restart frpc-comfyui-client` 生效。公网实测:无凭证 401,
  27 + 错误凭证 401,正确凭证 200。
  28 +- 顺手把 `llm-setup.md` FRP 那张表里"8188 无监听,隧道空挂"这条过期结论改成实际状态,
  29 + 同步回了服务器 `/home/admin/llm-setup.md`。
  30 +
  31 +**被推翻的假设**:`raylight/README.md`/`llm-setup.md` 里"ComfyUI(8188) 只在内部调用、不对外"
  32 +这个假设一直没被验证过,实际上从 2026-08-22 起就已经在公网裸奔了一周,直到这次审计才发现。
  33 +以后改动任何监听端口的服务,得顺手查一下有没有同名/旧的 frp 隧道配置早就指向它。
  34 +
3 35 ## 2026-08-25(晚)— 服务器意外重启后 raylight/h3-console 全灭,补上 systemd 开机自启
4 36  
5 37 **背景**:用户反馈 `http://192.168.110.67:8080/newtask.html` 打不开。排查发现机器在当天
... ...
h3-console/README.md
... ... @@ -166,8 +166,14 @@ console 譛ャ霄ォ譏ッ迢ャ遶玖ソ帷ィ具シ悟髣ョ鬚倡峩謗・ `kill` console 霑帷ィ句叉蜿ッ御ク
166 166  
167 167 ## 蟾イ遏・髯仙宛
168 168  
169   -- 驩エ譚シ壽嘯譛ェ蜉DESIGN.md` 髣ョ鬚3瑚キ鷹壻コ蜉峨るΚ鄂イ蛻ー蜈ャ鄂大燕蠢。サ陦・悟凄蛻吝柱邇ー譛画恪蜉。荳譬キ譏ッ
170   - 譌驩エ譚庄逶エ隹シ碁」朱勦譖エ鬮倪披把onsole 螟壻コ譁サカ井ク贋シ臥噪閭ス蜉帙
  169 +- 驩エ譚シ**2026-08-29 蟾イ蜉**縲AH3_CONSOLE_AUTH_TOKEN` 邇ッ蠅序驥剰ョセ莠燕譬。鬪鯉シ井ク、荳ェ systemd 螳樔セ
  170 + 驛ス蟾イ驟咲スョ蜷御ク荳ェ token会シ形/v1/*` 蜈ィ驛ィ謗・蜿」隕∵ア `Authorization: Bearer <token>`幃。オ髱「譛ャ霄ォ
  171 + /`縲〜/newtask.html`縲〜/task.html/{tid}`牙柱 `/assets/*` 髱呎∵枚莉カ荳肴。鬪娯披泌燕閠弍鮑。逕溯寞
  172 + 陋狗函鮑。域イ。蜉霓ス蜃コ鬘オ髱「譌莉手セ灘token会シ悟錘閠弍蝗荳コ譛榊苅蝎ィ荳顔噪 GPU worker 隕∬蜿榊髄諡牙叙荳贋シ逧
  173 + 蜿り崟/蜿りァ「托シ御ク泌ョサャ譛ャ霄ォ謨乗─蠎ヲ菴弱A<video src>`/荳玖スス `<a href>` 譏ッ豬剰ァ亥勣蜴溽函隸キ豎ょ刈荳堺ク
  174 + 螟エ梧隼謌占ョ、 `?token=` 譟・隸「蜿よ焚瑚キ header 襍ー蜷御ク莉ス譬。鬪鯉シserver.py` 逧 `_require_token`峨
  175 + 蜑咲ォッ `static/index.html` 鬥匁ャ。隹畑 API 譌カ `prompt()` 蠑ケ荳谺。莉、迚悟ュ `localStorage`401 莨壽クゥコ
  176 + 驥榊シケ縲
171 177 - 莉サ蜉。謖∽ケ喧譏ッ console 閾ェ蟾ア逧 sqlite悟柱 `api.py` 蜀ュ俶∽ケ矩龍譛芽スョ隸「髣エ髫皮噪遏ュ證ゆク堺ク閾エ亥遘堤コァ会シ
172 178 荳崎ソス豎ょシコ荳閾エ縲
173 179 - 逎∫尨啻/` 蜿ェ蜑ゥ 29G形/data` 83% 貊。御ク贋シ謗・蜿」逶ョ蜑榊宵蛛壼黒譁サカ 20MB 荳企剞譬。鬪鯉シ梧イ。蛛夂」∫尨菴咎譽譟・
... ...
h3-console/server.py
... ... @@ -14,8 +14,9 @@ import os, re, sqlite3, time, uuid, json, asyncio, subprocess
14 14 from pathlib import Path
15 15 from typing import Optional
16 16  
  17 +import hmac
17 18 import httpx
18   -from fastapi import APIRouter, FastAPI, HTTPException, UploadFile, File, Request
  19 +from fastapi import APIRouter, Depends, FastAPI, HTTPException, UploadFile, File, Request
19 20 from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse
20 21 from fastapi.staticfiles import StaticFiles
21 22 from PIL import Image
... ... @@ -34,6 +35,22 @@ ENABLE_SUPERVISOR = os.environ.get(&quot;H3_ENABLE_SUPERVISOR&quot;) == &quot;1&quot;
34 35 # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名),
35 36 # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。
36 37 BASE_PATH = os.environ.get("H3_CONSOLE_BASE_PATH", "").rstrip("/")
  38 +# 公网直接可达(qwen.wudaima.com/console 无需登录就能提交任务/看到所有历史任务),设了这个
  39 +# 才校验 /v1/* 接口;留空等于原来的行为(局域网/本地开发不用配)。`<video src>`/下载链接是
  40 +# 浏览器原生请求,加不上 Authorization 头,所以顺带认 `?token=` 查询参数——跟 header 校验
  41 +# 走同一份比较逻辑,不是单独开了个后门。
  42 +AUTH_TOKEN = os.environ.get("H3_CONSOLE_AUTH_TOKEN", "")
  43 +
  44 +
  45 +def _require_token(request: Request):
  46 + if not AUTH_TOKEN:
  47 + return
  48 + got = request.query_params.get("token", "")
  49 + auth = request.headers.get("authorization", "")
  50 + if auth.startswith("Bearer "):
  51 + got = auth[len("Bearer "):]
  52 + if not hmac.compare_digest(got, AUTH_TOKEN):
  53 + raise HTTPException(401, "缺少或错误的访问令牌(Authorization: Bearer <token> 或 ?token=)")
37 54  
38 55 # GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。
39 56 BACKEND_TOPOLOGY = [
... ... @@ -66,7 +83,9 @@ def _check_not_resetting():
66 83 # 所有 /v1/* 接口挂在这个 router 上而不是直接挂在 app 上,是为了能整体套一层 BASE_PATH
67 84 # 前缀(app.include_router(router, prefix=BASE_PATH),见文件末尾)——BASE_PATH 留空时
68 85 # 前缀是空字符串,行为和直接挂在 app 上完全一样。
69   -router = APIRouter()
  86 +router = APIRouter() # 页面路由(/、/newtask.html、/task.html/{tid})——不校验token,
  87 +# 不然连页面壳子都加载不出来,无从输入token
  88 +api = APIRouter(dependencies=[Depends(_require_token)]) # 所有 /v1/* 接口
70 89  
71 90  
72 91 def db():
... ... @@ -110,7 +129,7 @@ VIDEO_EXTS = (&quot;.mp4&quot;, &quot;.mov&quot;, &quot;.webm&quot;, &quot;.mkv&quot;)
110 129 AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".flac")
111 130  
112 131  
113   -@router.post("/v1/assets")
  132 +@api.post("/v1/assets")
114 133 async def upload_asset(file: UploadFile = File(...)):
115 134 ext = Path(file.filename or "").suffix.lower()
116 135 # 2026-08-23:原来只收图片,现在参考视频/独立音频参考也走这个接口——按扩展名分流到
... ... @@ -332,7 +351,7 @@ async def _discover_from_gateway():
332 351 )
333 352  
334 353  
335   -@router.post("/v1/videos", status_code=202)
  354 +@api.post("/v1/videos", status_code=202)
336 355 async def create_video(req: dict):
337 356 _check_not_resetting()
338 357 partition = _partition_of(req)
... ... @@ -351,7 +370,7 @@ async def create_video(req: dict):
351 370 return _row_view(row)
352 371  
353 372  
354   -@router.post("/v1/videos/{jid}/cancel")
  373 +@api.post("/v1/videos/{jid}/cancel")
355 374 async def cancel_video(jid: str):
356 375 with db() as con:
357 376 row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
... ... @@ -389,7 +408,7 @@ async def cancel_video(jid: str):
389 408 return _row_view(row)
390 409  
391 410  
392   -@router.get("/v1/videos/{jid}")
  411 +@api.get("/v1/videos/{jid}")
393 412 async def get_video(jid: str):
394 413 with db() as con:
395 414 row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
... ... @@ -404,7 +423,7 @@ async def get_video(jid: str):
404 423 return _row_view(row)
405 424  
406 425  
407   -@router.get("/v1/videos/{jid}/file")
  426 +@api.get("/v1/videos/{jid}/file")
408 427 async def download_video(jid: str, request: Request):
409 428 with db() as con:
410 429 row = con.execute(
... ... @@ -464,7 +483,7 @@ async def download_video(jid: str, request: Request):
464 483 return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough)
465 484  
466 485  
467   -@router.get("/v1/videos")
  486 +@api.get("/v1/videos")
468 487 async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "",
469 488 kind: str = "", sort: str = "desc"):
470 489 # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在,
... ... @@ -498,7 +517,7 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = &quot;&quot;, status:
498 517 return {"jobs": [_row_view(r) for r in rows]}
499 518  
500 519  
501   -@router.delete("/v1/videos/{jid}")
  520 +@api.delete("/v1/videos/{jid}")
502 521 async def delete_video(jid: str):
503 522 """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。"""
504 523 with db() as con:
... ... @@ -508,7 +527,7 @@ async def delete_video(jid: str):
508 527 return {"id": jid, "deleted": True}
509 528  
510 529  
511   -@router.post("/v1/videos/{jid}/restore")
  530 +@api.post("/v1/videos/{jid}/restore")
512 531 async def restore_video(jid: str):
513 532 with db() as con:
514 533 cur = con.execute("UPDATE jobs SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (jid,))
... ... @@ -517,14 +536,14 @@ async def restore_video(jid: str):
517 536 return {"id": jid, "deleted": False}
518 537  
519 538  
520   -@router.get("/v1/backends")
  539 +@api.get("/v1/backends")
521 540 async def backends():
522 541 resp = await client.get(f"{GATEWAY_URL}/health")
523 542 resp.raise_for_status()
524 543 return resp.json()
525 544  
526 545  
527   -@router.post("/v1/admin/reset-gpu")
  546 +@api.post("/v1/admin/reset-gpu")
528 547 async def reset_gpu():
529 548 # 通用重置入口,不挂在具体任务上——应对Raylight actor崩溃/OOM后不自愈、显存卡在接近
530 549 # 满载但JOBS里没有running任务的情况(这时候"停止"按钮不会出现,需要这个兜底)。
... ... @@ -538,7 +557,7 @@ async def reset_gpu():
538 557 return resp.json()
539 558  
540 559  
541   -@router.get("/v1/models")
  560 +@api.get("/v1/models")
542 561 def models():
543 562 # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。
544 563 return {
... ... @@ -717,3 +736,4 @@ async def newtask_page():
717 736 # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效,
718 737 # 不用每个路由自己拼。
719 738 app.include_router(router, prefix=BASE_PATH)
  739 +app.include_router(api, prefix=BASE_PATH)
... ...
h3-console/static/index.html
... ... @@ -257,6 +257,40 @@
257 257 // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。
258 258 const H3_BASE = "__H3_BASE_PATH__";
259 259  
  260 +// 服务器加了 H3_CONSOLE_AUTH_TOKEN 时,/v1/* 接口要求 Authorization: Bearer <token>。
  261 +// 没设这个环境变量时 server.py 直接放行,这段代码在本地/局域网(没配token)环境下是空转,
  262 +// h3Token() 第一次调用才会真正弹 prompt。
  263 +function h3Token() {
  264 + let t = localStorage.getItem('h3_console_token');
  265 + if (t === null) {
  266 + t = window.prompt('请输入 H3 访问令牌 (Access Token)') || '';
  267 + localStorage.setItem('h3_console_token', t);
  268 + }
  269 + return t;
  270 +}
  271 +function h3FileUrl(path) {
  272 + // <video src>/<a href download> 是浏览器原生请求,加不上 Authorization 头,
  273 + // 只能把 token 拼进查询串——server.py 的 _require_token 两种都认。
  274 + const t = h3Token();
  275 + return t ? `${path}${path.includes('?') ? '&' : '?'}token=${encodeURIComponent(t)}` : path;
  276 +}
  277 +(function () {
  278 + const origFetch = window.fetch.bind(window);
  279 + window.fetch = async (input, opts = {}) => {
  280 + const url = typeof input === 'string' ? input : input.url;
  281 + if (url.startsWith(H3_BASE + '/v1/')) {
  282 + const t = h3Token();
  283 + if (t) opts = { ...opts, headers: { ...(opts.headers || {}), 'Authorization': 'Bearer ' + t } };
  284 + }
  285 + const r = await origFetch(input, opts);
  286 + if (r.status === 401) {
  287 + localStorage.removeItem('h3_console_token');
  288 + alert('访问令牌无效,请刷新页面重新输入');
  289 + }
  290 + return r;
  291 + };
  292 +})();
  293 +
260 294 const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [], 'ref2va-audios': [] };
261 295 let jobsCache = [];
262 296 let trashCache = [];
... ... @@ -894,8 +928,8 @@ async function refreshDetail() {
894 928  
895 929 let resultHtml = '';
896 930 if (j.status === 'done') {
897   - resultHtml = `<video controls src="${H3_BASE}/v1/videos/${j.id}/file"></video>
898   - <a class="download" href="${H3_BASE}/v1/videos/${j.id}/file" download>下载 mp4${j.size_bytes ? ' ('+(j.size_bytes/1024/1024).toFixed(1)+'MB)' : ''}</a>`;
  931 + resultHtml = `<video controls src="${h3FileUrl(H3_BASE + '/v1/videos/' + j.id + '/file')}"></video>
  932 + <a class="download" href="${h3FileUrl(H3_BASE + '/v1/videos/' + j.id + '/file')}" download>下载 mp4${j.size_bytes ? ' ('+(j.size_bytes/1024/1024).toFixed(1)+'MB)' : ''}</a>`;
899 933 } else if (j.status === 'failed') {
900 934 resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'&lt;')}</div>`;
901 935 } else if (j.status === 'running') {
... ...
h3-console/systemd/h3-console-lan.service
... ... @@ -8,6 +8,7 @@ User=admin
8 8 WorkingDirectory=/data/models/minimax-h3/console
9 9 Environment=H3_GATEWAY_URL=http://127.0.0.1:8100
10 10 Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080
  11 +Environment=H3_CONSOLE_AUTH_TOKEN=a470f4dc154c2b9a927c8dad76666a0e0cff3266c5ded42e
11 12 Environment=PORT=8080
12 13 ExecStart=/data/models/minimax-h3/console/run.sh
13 14 StandardOutput=append:/data/models/minimax-h3/console/console-8080.log
... ...
h3-console/systemd/h3-console-public.service
... ... @@ -9,6 +9,7 @@ WorkingDirectory=/data/models/minimax-h3/console
9 9 Environment=H3_GATEWAY_URL=http://127.0.0.1:8100
10 10 Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080
11 11 Environment=H3_CONSOLE_BASE_PATH=/console
  12 +Environment=H3_CONSOLE_AUTH_TOKEN=a470f4dc154c2b9a927c8dad76666a0e0cff3266c5ded42e
12 13 Environment=PORT=8081
13 14 ExecStart=/data/models/minimax-h3/console/run.sh
14 15 StandardOutput=append:/data/models/minimax-h3/console/console-subpath.log
... ...
llm-setup.md
... ... @@ -338,7 +338,7 @@ curl http://localhost:8000/v1/models # 预期 qwen2.5-72b
338 338 | `frpc.qwen.ini` | `[qwen-gw-20260610]` | `gateway.wudaima.com` | 8080 | ⚠ **8080 无监听,隧道空挂** |
339 339 | `frpc.ini` | `[m3e]` | `m3e.embedding.wudaima.com` | 7590 | ✅ 嵌入服务 |
340 340 | `sd.frpc.ini` | `[stable-diffusion-web]` | `stable-diffusion.wudaima.com` | 7860 | ✅ SD WebUI |
341   -| `comfyui.frpc.ini` | `[comfyui-web]` | `comfyui.wudaima.com` | 8188 | ⚠ **8188 无监听,隧道空挂** |
  341 +| `comfyui.frpc.ini` | `[comfyui-web]` | `comfyui.wudaima.com` | 8188 | ✅ **2026-08-22 起 raylight 常驻监听 8188,隧道其实一直是活的**(旧结论"无监听隧道空挂"已过期);**2026-08-29 补了 `http_user`/`http_pwd` 基本认证**(ini 里的原生字段,不用改 ComfyUI 代码),之前裸奔过一段时间见 `CHANGELOG.md` |
342 342 | `ssh.frpc.ini` | `[ssh]` | — (tcp) | 22 → 公网 6000 | ⚠ **SSH 暴露公网** |
343 343  
344 344 ```bash
... ... @@ -346,7 +346,7 @@ pm2 list # 需先 export PATH 或用绝对路径
346 346 pm2 logs frpc-qwen-client --lines 50
347 347 ```
348 348  
349   -> ⚠ **安全**:`qwen.wudaima.com` 无任何鉴权,公网可直接调用本机 27B 推理。`gateway.wudaima.com → 8080` 那条隧道看名字本是做网关鉴权用的,但 8080 一直没有服务。SSH 映射到公网 6000 且允许密码登录、admin 在 sudo 组。
  349 +> ⚠ **安全**:`qwen.wudaima.com` 无任何鉴权,公网可直接调用本机 27B 推理(llm38 目前已停,暂时不可利用)。`gateway.wudaima.com → 8080` 这条客户端隧道配置一直在、且 8080 现在确实有服务在监听(h3-console 局域网实例),但域名在 frp 服务端 `60.205.214.169` 没配对应 vhost,实测会打到一个无关的默认 nginx 页面,不是我们的服务——**这不是保证,只是目前观测到的状态,服务端配置随时可能被改,别把它当长期安全边界**。`qwen.wudaima.com/console`(h3-console 公网入口)**2026-08-29 起要求 `Authorization: Bearer <token>`**,token 见 `h3-console/README.md`。SSH 映射到公网 6000 且允许密码登录、admin 在 sudo 组。
350 350  
351 351 ### 8.2 Nginx
352 352  
... ...