Commit ad566a41ece5a5d30c8a39065a9209c54911804e
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
Showing
7 changed files
with
113 additions
and
19 deletions
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 | ## 2026-08-25(晚)— 服务器意外重启后 raylight/h3-console 全灭,补上 systemd 开机自启 | 35 | ## 2026-08-25(晚)— 服务器意外重启后 raylight/h3-console 全灭,补上 systemd 开机自启 |
| 4 | 36 | ||
| 5 | **背景**:用户反馈 `http://192.168.110.67:8080/newtask.html` 打不开。排查发现机器在当天 | 37 | **背景**:用户反馈 `http://192.168.110.67:8080/newtask.html` 打不开。排查发现机器在当天 |
h3-console/README.md
| @@ -166,8 +166,14 @@ console 譛ャ霄ォ譏ッ迢ャ遶玖ソ帷ィ具シ悟髣ョ鬚倡峩謗・ `kill` console 霑帷ィ句叉蜿ッ御ク | @@ -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 | - 莉サ蜉。謖∽ケ喧譏ッ console 閾ェ蟾ア逧 sqlite悟柱 `api.py` 蜀ュ俶∽ケ矩龍譛芽スョ隸「髣エ髫皮噪遏ュ證ゆク堺ク閾エ亥遘堤コァ会シ | 177 | - 莉サ蜉。謖∽ケ喧譏ッ console 閾ェ蟾ア逧 sqlite悟柱 `api.py` 蜀ュ俶∽ケ矩龍譛芽スョ隸「髣エ髫皮噪遏ュ證ゆク堺ク閾エ亥遘堤コァ会シ |
| 172 | 荳崎ソス豎ょシコ荳閾エ縲 | 178 | 荳崎ソス豎ょシコ荳閾エ縲 |
| 173 | - 逎∫尨啻/` 蜿ェ蜑ゥ 29G形/data` 83% 貊。御ク贋シ謗・蜿」逶ョ蜑榊宵蛛壼黒譁サカ 20MB 荳企剞譬。鬪鯉シ梧イ。蛛夂」∫尨菴咎譽譟・ | 179 | - 逎∫尨啻/` 蜿ェ蜑ゥ 29G形/data` 83% 貊。御ク贋シ謗・蜿」逶ョ蜑榊宵蛛壼黒譁サカ 20MB 荳企剞譬。鬪鯉シ梧イ。蛛夂」∫尨菴咎譽譟・ |
h3-console/server.py
| @@ -14,8 +14,9 @@ import os, re, sqlite3, time, uuid, json, asyncio, subprocess | @@ -14,8 +14,9 @@ import os, re, sqlite3, time, uuid, json, asyncio, subprocess | ||
| 14 | from pathlib import Path | 14 | from pathlib import Path |
| 15 | from typing import Optional | 15 | from typing import Optional |
| 16 | 16 | ||
| 17 | +import hmac | ||
| 17 | import httpx | 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 | from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse | 20 | from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse |
| 20 | from fastapi.staticfiles import StaticFiles | 21 | from fastapi.staticfiles import StaticFiles |
| 21 | from PIL import Image | 22 | from PIL import Image |
| @@ -34,6 +35,22 @@ ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" | @@ -34,6 +35,22 @@ ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" | ||
| 34 | # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名), | 35 | # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名), |
| 35 | # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。 | 36 | # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。 |
| 36 | BASE_PATH = os.environ.get("H3_CONSOLE_BASE_PATH", "").rstrip("/") | 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 | # GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。 | 55 | # GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。 |
| 39 | BACKEND_TOPOLOGY = [ | 56 | BACKEND_TOPOLOGY = [ |
| @@ -66,7 +83,9 @@ def _check_not_resetting(): | @@ -66,7 +83,9 @@ def _check_not_resetting(): | ||
| 66 | # 所有 /v1/* 接口挂在这个 router 上而不是直接挂在 app 上,是为了能整体套一层 BASE_PATH | 83 | # 所有 /v1/* 接口挂在这个 router 上而不是直接挂在 app 上,是为了能整体套一层 BASE_PATH |
| 67 | # 前缀(app.include_router(router, prefix=BASE_PATH),见文件末尾)——BASE_PATH 留空时 | 84 | # 前缀(app.include_router(router, prefix=BASE_PATH),见文件末尾)——BASE_PATH 留空时 |
| 68 | # 前缀是空字符串,行为和直接挂在 app 上完全一样。 | 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 | def db(): | 91 | def db(): |
| @@ -110,7 +129,7 @@ VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv") | @@ -110,7 +129,7 @@ VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv") | ||
| 110 | AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".flac") | 129 | AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".flac") |
| 111 | 130 | ||
| 112 | 131 | ||
| 113 | -@router.post("/v1/assets") | 132 | +@api.post("/v1/assets") |
| 114 | async def upload_asset(file: UploadFile = File(...)): | 133 | async def upload_asset(file: UploadFile = File(...)): |
| 115 | ext = Path(file.filename or "").suffix.lower() | 134 | ext = Path(file.filename or "").suffix.lower() |
| 116 | # 2026-08-23:原来只收图片,现在参考视频/独立音频参考也走这个接口——按扩展名分流到 | 135 | # 2026-08-23:原来只收图片,现在参考视频/独立音频参考也走这个接口——按扩展名分流到 |
| @@ -332,7 +351,7 @@ async def _discover_from_gateway(): | @@ -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 | async def create_video(req: dict): | 355 | async def create_video(req: dict): |
| 337 | _check_not_resetting() | 356 | _check_not_resetting() |
| 338 | partition = _partition_of(req) | 357 | partition = _partition_of(req) |
| @@ -351,7 +370,7 @@ async def create_video(req: dict): | @@ -351,7 +370,7 @@ async def create_video(req: dict): | ||
| 351 | return _row_view(row) | 370 | return _row_view(row) |
| 352 | 371 | ||
| 353 | 372 | ||
| 354 | -@router.post("/v1/videos/{jid}/cancel") | 373 | +@api.post("/v1/videos/{jid}/cancel") |
| 355 | async def cancel_video(jid: str): | 374 | async def cancel_video(jid: str): |
| 356 | with db() as con: | 375 | with db() as con: |
| 357 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | 376 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() |
| @@ -389,7 +408,7 @@ async def cancel_video(jid: str): | @@ -389,7 +408,7 @@ async def cancel_video(jid: str): | ||
| 389 | return _row_view(row) | 408 | return _row_view(row) |
| 390 | 409 | ||
| 391 | 410 | ||
| 392 | -@router.get("/v1/videos/{jid}") | 411 | +@api.get("/v1/videos/{jid}") |
| 393 | async def get_video(jid: str): | 412 | async def get_video(jid: str): |
| 394 | with db() as con: | 413 | with db() as con: |
| 395 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | 414 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() |
| @@ -404,7 +423,7 @@ async def get_video(jid: str): | @@ -404,7 +423,7 @@ async def get_video(jid: str): | ||
| 404 | return _row_view(row) | 423 | return _row_view(row) |
| 405 | 424 | ||
| 406 | 425 | ||
| 407 | -@router.get("/v1/videos/{jid}/file") | 426 | +@api.get("/v1/videos/{jid}/file") |
| 408 | async def download_video(jid: str, request: Request): | 427 | async def download_video(jid: str, request: Request): |
| 409 | with db() as con: | 428 | with db() as con: |
| 410 | row = con.execute( | 429 | row = con.execute( |
| @@ -464,7 +483,7 @@ async def download_video(jid: str, request: Request): | @@ -464,7 +483,7 @@ async def download_video(jid: str, request: Request): | ||
| 464 | return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough) | 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 | async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "", | 487 | async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "", |
| 469 | kind: str = "", sort: str = "desc"): | 488 | kind: str = "", sort: str = "desc"): |
| 470 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, | 489 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, |
| @@ -498,7 +517,7 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: | @@ -498,7 +517,7 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: | ||
| 498 | return {"jobs": [_row_view(r) for r in rows]} | 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 | async def delete_video(jid: str): | 521 | async def delete_video(jid: str): |
| 503 | """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" | 522 | """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" |
| 504 | with db() as con: | 523 | with db() as con: |
| @@ -508,7 +527,7 @@ async def delete_video(jid: str): | @@ -508,7 +527,7 @@ async def delete_video(jid: str): | ||
| 508 | return {"id": jid, "deleted": True} | 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 | async def restore_video(jid: str): | 531 | async def restore_video(jid: str): |
| 513 | with db() as con: | 532 | with db() as con: |
| 514 | cur = con.execute("UPDATE jobs SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (jid,)) | 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,14 +536,14 @@ async def restore_video(jid: str): | ||
| 517 | return {"id": jid, "deleted": False} | 536 | return {"id": jid, "deleted": False} |
| 518 | 537 | ||
| 519 | 538 | ||
| 520 | -@router.get("/v1/backends") | 539 | +@api.get("/v1/backends") |
| 521 | async def backends(): | 540 | async def backends(): |
| 522 | resp = await client.get(f"{GATEWAY_URL}/health") | 541 | resp = await client.get(f"{GATEWAY_URL}/health") |
| 523 | resp.raise_for_status() | 542 | resp.raise_for_status() |
| 524 | return resp.json() | 543 | return resp.json() |
| 525 | 544 | ||
| 526 | 545 | ||
| 527 | -@router.post("/v1/admin/reset-gpu") | 546 | +@api.post("/v1/admin/reset-gpu") |
| 528 | async def reset_gpu(): | 547 | async def reset_gpu(): |
| 529 | # 通用重置入口,不挂在具体任务上——应对Raylight actor崩溃/OOM后不自愈、显存卡在接近 | 548 | # 通用重置入口,不挂在具体任务上——应对Raylight actor崩溃/OOM后不自愈、显存卡在接近 |
| 530 | # 满载但JOBS里没有running任务的情况(这时候"停止"按钮不会出现,需要这个兜底)。 | 549 | # 满载但JOBS里没有running任务的情况(这时候"停止"按钮不会出现,需要这个兜底)。 |
| @@ -538,7 +557,7 @@ async def reset_gpu(): | @@ -538,7 +557,7 @@ async def reset_gpu(): | ||
| 538 | return resp.json() | 557 | return resp.json() |
| 539 | 558 | ||
| 540 | 559 | ||
| 541 | -@router.get("/v1/models") | 560 | +@api.get("/v1/models") |
| 542 | def models(): | 561 | def models(): |
| 543 | # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。 | 562 | # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。 |
| 544 | return { | 563 | return { |
| @@ -717,3 +736,4 @@ async def newtask_page(): | @@ -717,3 +736,4 @@ async def newtask_page(): | ||
| 717 | # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效, | 736 | # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效, |
| 718 | # 不用每个路由自己拼。 | 737 | # 不用每个路由自己拼。 |
| 719 | app.include_router(router, prefix=BASE_PATH) | 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,6 +257,40 @@ | ||
| 257 | // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。 | 257 | // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。 |
| 258 | const H3_BASE = "__H3_BASE_PATH__"; | 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 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [], 'ref2va-audios': [] }; | 294 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [], 'ref2va-audios': [] }; |
| 261 | let jobsCache = []; | 295 | let jobsCache = []; |
| 262 | let trashCache = []; | 296 | let trashCache = []; |
| @@ -894,8 +928,8 @@ async function refreshDetail() { | @@ -894,8 +928,8 @@ async function refreshDetail() { | ||
| 894 | 928 | ||
| 895 | let resultHtml = ''; | 929 | let resultHtml = ''; |
| 896 | if (j.status === 'done') { | 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 | } else if (j.status === 'failed') { | 933 | } else if (j.status === 'failed') { |
| 900 | resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'<')}</div>`; | 934 | resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'<')}</div>`; |
| 901 | } else if (j.status === 'running') { | 935 | } else if (j.status === 'running') { |
h3-console/systemd/h3-console-lan.service
| @@ -8,6 +8,7 @@ User=admin | @@ -8,6 +8,7 @@ User=admin | ||
| 8 | WorkingDirectory=/data/models/minimax-h3/console | 8 | WorkingDirectory=/data/models/minimax-h3/console |
| 9 | Environment=H3_GATEWAY_URL=http://127.0.0.1:8100 | 9 | Environment=H3_GATEWAY_URL=http://127.0.0.1:8100 |
| 10 | Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 | 10 | Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 |
| 11 | +Environment=H3_CONSOLE_AUTH_TOKEN=a470f4dc154c2b9a927c8dad76666a0e0cff3266c5ded42e | ||
| 11 | Environment=PORT=8080 | 12 | Environment=PORT=8080 |
| 12 | ExecStart=/data/models/minimax-h3/console/run.sh | 13 | ExecStart=/data/models/minimax-h3/console/run.sh |
| 13 | StandardOutput=append:/data/models/minimax-h3/console/console-8080.log | 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,6 +9,7 @@ WorkingDirectory=/data/models/minimax-h3/console | ||
| 9 | Environment=H3_GATEWAY_URL=http://127.0.0.1:8100 | 9 | Environment=H3_GATEWAY_URL=http://127.0.0.1:8100 |
| 10 | Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 | 10 | Environment=H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 |
| 11 | Environment=H3_CONSOLE_BASE_PATH=/console | 11 | Environment=H3_CONSOLE_BASE_PATH=/console |
| 12 | +Environment=H3_CONSOLE_AUTH_TOKEN=a470f4dc154c2b9a927c8dad76666a0e0cff3266c5ded42e | ||
| 12 | Environment=PORT=8081 | 13 | Environment=PORT=8081 |
| 13 | ExecStart=/data/models/minimax-h3/console/run.sh | 14 | ExecStart=/data/models/minimax-h3/console/run.sh |
| 14 | StandardOutput=append:/data/models/minimax-h3/console/console-subpath.log | 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,7 +338,7 @@ curl http://localhost:8000/v1/models # 预期 qwen2.5-72b | ||
| 338 | | `frpc.qwen.ini` | `[qwen-gw-20260610]` | `gateway.wudaima.com` | 8080 | ⚠ **8080 无监听,隧道空挂** | | 338 | | `frpc.qwen.ini` | `[qwen-gw-20260610]` | `gateway.wudaima.com` | 8080 | ⚠ **8080 无监听,隧道空挂** | |
| 339 | | `frpc.ini` | `[m3e]` | `m3e.embedding.wudaima.com` | 7590 | ✅ 嵌入服务 | | 339 | | `frpc.ini` | `[m3e]` | `m3e.embedding.wudaima.com` | 7590 | ✅ 嵌入服务 | |
| 340 | | `sd.frpc.ini` | `[stable-diffusion-web]` | `stable-diffusion.wudaima.com` | 7860 | ✅ SD WebUI | | 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 | | `ssh.frpc.ini` | `[ssh]` | — (tcp) | 22 → 公网 6000 | ⚠ **SSH 暴露公网** | | 342 | | `ssh.frpc.ini` | `[ssh]` | — (tcp) | 22 → 公网 6000 | ⚠ **SSH 暴露公网** | |
| 343 | 343 | ||
| 344 | ```bash | 344 | ```bash |
| @@ -346,7 +346,7 @@ pm2 list # 需先 export PATH 或用绝对路径 | @@ -346,7 +346,7 @@ pm2 list # 需先 export PATH 或用绝对路径 | ||
| 346 | pm2 logs frpc-qwen-client --lines 50 | 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 | ### 8.2 Nginx | 351 | ### 8.2 Nginx |
| 352 | 352 |