ymq 702f27407b refactor(auth): 移除全部自造鉴权/会话/语义中间件,回归标准 ahserver+rbac
根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
 entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
  - auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
    created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
  - route_guard 的 _has_business_params 用 await request.read() 消费 POST body
    → up_login 收到 params_kw={} → password_encode(None) 崩 500

改动:
  - app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
    load_pybricks + 业务模块 load_*,零自定义中间件
  - 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
  - /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
    conf.website.startswiths 映射),不再用中间件拦截

实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
  healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
  伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
  新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
  错误密码 200 业务信封(不崩 500)

register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
2026-10-28 15:19:43 +08:00

344 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

""".ui → bricks(bui) 渲染链路的启动期守卫(Bug 906IGBEVANkne-og1cv0r / P0)。
## 缺陷现象(R36 后回归实测,2026-10-21)
GET /index.ui -> 500 size=55 "500 Internal Server Error Server got itself in trouble"
GET /scense/index.ui -> 500
GET /runtime/index.ui -> 500
GET /scene/index.ui -> 500
GET /world/index.ui -> 500
GET /entity/index.ui -> 500
对照:GET /runtime/play.html -> 200 size=6368、GET /runtime/runtime.js -> 200 size=27674
GET /healthz -> 200、56 个 api/*.dspy -> 无 500
「全部 .ui 一律 500、静态资源全 200」说明缺陷不在具体页面文件,而在 **bui 渲染器本身**。
## 根因(代码级链路,见 references 证据文档)
ahserver 的 `BricksUIProcessor.datahandle()`(pkgs/ahserver/ahserver/baseProcessor.py:196)
在渲染任何 .ui 之前**无条件**取两个共享片段:
content0 = await self.resource.path_call(request, entire_url('/bricks/header.tmpl'))
content2 = await self.resource.path_call(request, entire_url('/bricks/footer.tmpl'))
`ProcessorResource.path_call()`(processorResource.py:510)把 URL 映射到磁盘文件后取处理器:
path = self.url2path(url); fpath = self.url2file(path)
processor = self.url2processor(request, path, fpath) # fpath 不存在 → None
随后**不做判空**直接 `await processor.be_call(...)` → `AttributeError: 'NoneType' object
has no attribute 'be_call'` → aiohttp 兜底 500(正文就是那 55 字节)。
所以只要 `<deploy>/wwwroot/bricks/header.tmpl`(或 footer.tmpl)在目标机不可解析,
**每一个** .ui 页面都会 500,而 .html/.js/.dspy 全都不受影响——与实测矩阵完全一致。
## 为什么目标机缺 /bricks/header.tmpl
`bricks` 是**前端包**(pkgs/bricks):它的资源不在 `pkgs/bricks/wwwroot/`,而在
`pkgs/bricks/bricks/`(`header.tmpl` / `footer.tmpl` / `bricks.js` / `bricks.css` …)。
build.sh 第 5 步的软链循环只认 `pkgs/<m>/wwwroot`:
if [ -d "${PDB}/${m}/wwwroot" ]; then ln -sfn ... ; else log "跳过"; fi
→ 于是 `wwwroot/bricks` 从未被建立(静默"跳过"),构建期自校验又只检查
`scense` / `runtime` 的 index.ui,不检查 `/bricks/*.tmpl` → 缺陷一路留到冒烟才发现。
## 本模块的职责(两层防御,均为 fail-soft,绝不因自身异常拖垮启动)
1. `ensure_bricks_assets()` —— 启动期自愈:若 `<deploy>/wwwroot/bricks` 不可解析或缺少
header.tmpl/footer.tmpl,就在候选目录里找到真正的 bricks 资源目录并(重)建软链;
修不了则打印**可行动**的 ERROR(含逐个候选绝对路径),而不是让 6 个页面继续 500。
2. `ui_chain_problems()` —— 启动期自检:遍历 `<deploy>/wwwroot/*` 下所有 .ui,
报告悬空软链 / 缺失文件,并把结论写进日志(部署后可直接 grep 取证)。
设计约束:
- 不改 ahserver / bricks 等基础模块(project-directory-spec §3 引用模块只读)——缺陷的
「判空」在框架侧,但应用侧完全可以不让它被触发:把资源就位 + 构建期/启动期双门禁。
- 所有路径来自 `__file__` 推导出的部署根(`<deploy>/app/ui_render_guard.py` → `<deploy>`),
不写死任何部署路径(project-directory-spec §8.6)。
- 任何异常都在函数内吞掉并记日志,返回诊断 dict,**不向上抛**(启动不能被守卫搞挂)。
"""
import logging
import os
import time
log = logging.getLogger("scense.ui_guard")
#: bui 渲染器无条件依赖的共享片段(缺任意一个 → 所有 .ui 501/500,见模块 docstring)
BRICKS_ASSET_FILES = ("header.tmpl", "footer.tmpl")
#: wwwroot 下 bricks 资源的挂载点名(与 entire_url('/bricks/header.tmpl') 对齐)
BRICKS_MOUNT = "bricks"
def default_deploy_dir():
"""由本文件位置推导部署根:<deploy>/app/ui_render_guard.py → <deploy>。
不写死路径;文件被单测从别处导入时也只用其所在目录的父目录,行为可预测。
"""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(here)
def _missing_assets(directory):
"""返回 directory 下缺失的资源文件名列表;目录不存在时返回全部。"""
if not directory or not os.path.isdir(directory):
return list(BRICKS_ASSET_FILES)
return [f for f in BRICKS_ASSET_FILES if not os.path.isfile(os.path.join(directory, f))]
def _assets_ok(directory):
return not _missing_assets(directory)
def bricks_asset_candidates(deploy_dir=None, pkg_dir=None):
"""bricks 前端资源目录的候选清单(按优先级,去重保序)。
覆盖三类真实现场:
1. 部署机:`<deploy>/pkgs/bricks/bricks`(前端包把资源放在与包同名的子目录)
—— 也可由 `$BRICKS_WWWROOT` 显式覆盖;
2. 部署机旧布局:`<deploy>/pkgs/bricks/wwwroot`(少数版本可能补齐过 wwwroot);
3. 机构工作空间/本地装配:`<space>/modules/bricks/wwwroot`、
`<space>/pkgs/bricks/bricks`(develop 侧离线自检时用)。
"""
deploy_dir = deploy_dir or default_deploy_dir()
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
space_root = os.path.dirname(deploy_dir)
cands = []
env_override = os.environ.get("BRICKS_WWWROOT")
if env_override:
cands.append(os.path.abspath(os.path.expanduser(env_override)))
cands.extend([
# 部署机真实布局(2026-10-25 修复 /index.ui 500,Bug mb7QP_1Rxb9HXTh-x4jlx):
# bricks 前端资源实际在 `pkgs/bricks/dist`(历史软链 wwwroot/bricks -> ../pkgs/bricks/dist),
# 旧候选清单漏了 dist,导致 ensure_bricks_assets 一律 unresolvable → 所有 .ui 500。
os.path.join(pkg_dir, BRICKS_MOUNT, "dist"), # pkgs/bricks/dist
os.path.join(pkg_dir, BRICKS_MOUNT, BRICKS_MOUNT), # pkgs/bricks/bricks
os.path.join(pkg_dir, BRICKS_MOUNT, "wwwroot"), # pkgs/bricks/wwwroot
os.path.join(space_root, "modules", BRICKS_MOUNT, "wwwroot"),
os.path.join(space_root, "pkgs", BRICKS_MOUNT, BRICKS_MOUNT),
os.path.join(space_root, "pkgs", BRICKS_MOUNT, "wwwroot"),
])
# pip install -e / site-packages 形态:venv/lib/pythonX.Y/site-packages/bricks{/dist,wwwroot,}
try:
venv_root = os.path.join(deploy_dir, "venv", "lib")
if os.path.isdir(venv_root):
for pyver in sorted(os.listdir(venv_root)):
sp = os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT)
cands.extend([os.path.join(sp, "dist"), os.path.join(sp, "wwwroot"), sp])
except OSError:
pass
seen = set()
out = []
for c in cands:
c = os.path.normpath(c)
if c not in seen:
seen.add(c)
out.append(c)
return out
def find_bricks_assets(deploy_dir=None, pkg_dir=None):
"""返回第一个真正含 header.tmpl+footer.tmpl 的候选目录;都没有则 None。
2026-10-25 增强(Bug mb7QP_1Rxb9HXTh-x4jlx / .ui 全站 500):
候选清单兜底后仍找不到时,在 `pkgs/bricks` 与 venv site-packages/bricks 下
做一次**限深度**(默认 4 层)搜索,命中同时含 header.tmpl + footer.tmpl 的目录
即返回。理由:bricks 前端资源在不同发布形态下会落在 dist/、wwwroot/、包同名
子目录或 examples/ 上层,硬编码单层路径会让「修复能力」在真实布局前失效,
而失效的表现就是每一个 .ui 都 500(BricksUIProcessor 取 /bricks/header.tmpl
时 processor=None)。
"""
deploy_dir = deploy_dir or default_deploy_dir()
for cand in bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir):
if _assets_ok(cand):
return cand
# 限量深度兜底搜索
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
roots = [os.path.join(pkg_dir, BRICKS_MOUNT)]
try:
venv_root = os.path.join(deploy_dir, "venv", "lib")
if os.path.isdir(venv_root):
for pyver in sorted(os.listdir(venv_root)):
roots.append(os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT))
except OSError:
pass
max_depth = 4
for root in roots:
if not os.path.isdir(root):
continue
base_depth = root.rstrip(os.sep).count(os.sep)
for dirpath, dirnames, _files in os.walk(root):
if dirpath.count(os.sep) - base_depth >= max_depth:
dirnames[:] = []
continue
if _assets_ok(dirpath):
log.warning("[ui-guard] bricks 资源由深度搜索命中: %s (root=%s)",
dirpath, root)
return dirpath
return None
def _replace_link(link_path, target):
"""把 link_path 换成指向 target 的软链;原有东西(悬空链/空目录)改名留底。"""
parent = os.path.dirname(link_path)
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
backup = None
if os.path.islink(link_path) or os.path.exists(link_path):
# 已存在但不可用(悬空链 / 缺文件):先移走,保留现场便于取证
backup = "%s.broken.%s" % (link_path, time.strftime("%Y%m%d%H%M%S"))
try:
os.rename(link_path, backup)
except OSError:
# rename 失败(跨设备/权限)时直接删链,避免挡住自愈
try:
os.unlink(link_path)
backup = None
except OSError:
pass
os.symlink(target, link_path)
return backup
def ensure_bricks_assets(deploy_dir=None, pkg_dir=None, dry_run=False):
"""确保 `<deploy>/wwwroot/bricks/{header,footer}.tmpl` 可解析;不可则自愈。
返回诊断 dict(同时写日志),键:
ok / wwwroot / mount / source / action / searched / missing / backup / error
action 取值:`ok`(本来就对)/ `relinked`(已自愈)/ `unresolvable`(找不到源)/
`failed`(自愈过程异常)/ `dry-run`。
"""
deploy_dir = deploy_dir or default_deploy_dir()
wwwroot = os.path.join(deploy_dir, "wwwroot")
mount = os.path.join(wwwroot, BRICKS_MOUNT)
diag = {
"ok": False,
"wwwroot": wwwroot,
"mount": mount,
"source": None,
"action": "unknown",
"searched": [],
"missing": [],
"backup": None,
"error": None,
}
try:
diag["searched"] = bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
missing = _missing_assets(mount)
diag["missing"] = missing
if not missing:
diag["ok"] = True
diag["action"] = "ok"
diag["source"] = os.path.realpath(mount)
log.info("[ui-guard] bricks 渲染片段就位: %s (%s)", mount,
", ".join(BRICKS_ASSET_FILES))
return diag
source = find_bricks_assets(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
diag["source"] = source
if not source:
diag["action"] = "unresolvable"
log.error(
"[ui-guard] /bricks/{header,footer}.tmpl 不可解析且找不到 bricks 资源目录 —— "
"所有 .ui 将 500(BricksUIProcessor 取 /bricks/header.tmpl 时 processor=None)。"
" 已搜索: %s。修复:确认 pkgs/bricks 已投递(平台 deliver_app_code),"
"或设 BRICKS_WWWROOT 指向含 header.tmpl/footer.tmpl 的目录。",
"; ".join(diag["searched"]),
)
return diag
if dry_run:
diag["action"] = "dry-run"
diag["ok"] = True
log.warning("[ui-guard] (dry-run) 将把 %s 重建为 -> %s", mount, source)
return diag
backup = _replace_link(mount, source)
diag["backup"] = backup
# 建链后复核:软链必须在、两文件必须可读
still_missing = _missing_assets(mount)
diag["missing"] = still_missing
diag["ok"] = not still_missing
diag["action"] = "relinked" if diag["ok"] else "failed"
if diag["ok"]:
log.warning(
"[ui-guard] 已自愈 wwwroot/bricks 软链: %s -> %s(原现场备份: %s)",
mount, source, backup,
)
else:
log.error("[ui-guard] 自愈后仍缺 %s(软链 -> %s)", still_missing, source)
return diag
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["action"] = "failed"
diag["error"] = "%s: %s" % (type(exc).__name__, exc)
log.exception("[ui-guard] 确保 bricks 渲染片段时异常(不影响启动): %s", exc)
return diag
def ui_chain_problems(deploy_dir=None):
"""遍历 `<deploy>/wwwroot/*/**/*.ui`,返回问题清单(悬空软链 / 文件缺失)。
只读检查,不做任何修改;供启动日志与部署后取证使用。
"""
deploy_dir = deploy_dir or default_deploy_dir()
wwwroot = os.path.join(deploy_dir, "wwwroot")
problems = []
checked = 0
try:
if not os.path.isdir(wwwroot):
return [{"path": wwwroot, "reason": "wwwroot 不存在"}]
for root, dirs, files in os.walk(wwwroot):
# 不跟随软链(os.walk 默认不跟随目录软链),链路完整性单独判
for name in files:
if not name.endswith(".ui"):
continue
full = os.path.join(root, name)
checked += 1
if not os.path.exists(full):
problems.append({"path": full, "reason": "悬空软链/文件不存在"})
if checked >= 500: # 防御性上限
break
for name in sorted(os.listdir(wwwroot)):
full = os.path.join(wwwroot, name)
if os.path.islink(full) and not os.path.exists(full):
problems.append({"path": full, "reason": "wwwroot 模块软链悬空"})
except Exception as exc: # noqa: BLE001
problems.append({"path": wwwroot, "reason": "扫描异常: %s" % exc})
return problems
def install_entry_guard(deploy_dir=None):
"""入口 init() 调用的一次性装配:自愈 + 自检 + 结构化日志。
绝不抛异常(守卫自身故障不得导致启动失败),返回汇总 dict 供 /healthz/日志取证。
汇总字段:bricks(ensure 诊断)/ problems(.ui 链路问题数)/ checked_ok。
"""
summary = {"bricks": None, "problems": [], "ok": False}
try:
summary["bricks"] = ensure_bricks_assets(deploy_dir=deploy_dir)
summary["problems"] = ui_chain_problems(deploy_dir=deploy_dir)
summary["ok"] = bool(summary["bricks"].get("ok")) and not summary["problems"]
if summary["problems"]:
log.error("[ui-guard] .ui 链路存在 %d 个问题: %s",
len(summary["problems"]), summary["problems"][:10])
else:
log.info("[ui-guard] .ui 链路自检通过(bricks 片段 action=%s)",
summary["bricks"].get("action"))
except Exception as exc: # noqa: BLE001
log.exception("[ui-guard] install_entry_guard 异常(忽略,不影响启动): %s", exc)
return summary
# probe
# --- 2026-10-25 修复记录(Bug mb7QP_1Rxb9HXTh-x4jlx:/index.ui 与 /scense/index.ui 500)---
# 根因:wwwroot/*.ui 由 BricksUIProcessor 渲染,渲染器无条件取 /bricks/header.tmpl 与
# /bricks/footer.tmpl;bricks 前端资源的真实布局是 pkgs/bricks/dist,旧候选清单漏了 dist
# → ensure_bricks_assets 一律 unresolvable → 取片段时 processor=None → 每个 .ui 都 500。
# 修复:bricks_asset_candidates 增加 pkgs/bricks/dist 与 venv/lib/*/site-packages/bricks{/dist,wwwroot};
# find_bricks_assets 增加限深(4 层)兜底目录搜索;build.sh 第 5 步补 wwwroot/bricks 软链。
# 复验:scripts/verify_ui_render_chain.py --fix --http(目标机侧,运行时读 env 的 deploy.path / base_url)。