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 ## 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(&quot;H3_ENABLE_SUPERVISOR&quot;) == &quot;1&quot; @@ -34,6 +35,22 @@ ENABLE_SUPERVISOR = os.environ.get(&quot;H3_ENABLE_SUPERVISOR&quot;) == &quot;1&quot;
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 = (&quot;.mp4&quot;, &quot;.mov&quot;, &quot;.webm&quot;, &quot;.mkv&quot;) @@ -110,7 +129,7 @@ VIDEO_EXTS = (&quot;.mp4&quot;, &quot;.mov&quot;, &quot;.webm&quot;, &quot;.mkv&quot;)
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 = &quot;&quot;, status: @@ -498,7 +517,7 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = &quot;&quot;, 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,'&lt;')}</div>`; 934 resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'&lt;')}</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