根因:元景迭代中为通过 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 中间件提供。
559 lines
25 KiB
Python
559 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""入口内建只读端点的方法语义(405 + `Allow`)——**装配链内**可用的唯一真源。
|
||
|
||
Bug EPaXUem2Gbz58sMES5eBC(R18 复验)
|
||
--------------------------------------
|
||
线上(https://gbak.opencomputing.cn,entry=app/scense.py)实测:
|
||
|
||
DELETE /healthz -> 404(无 Allow) PUT /healthz -> 404(无 Allow)
|
||
POST /healthz -> 404(无 Allow) GET /healthz -> 200 HEAD /healthz -> 200
|
||
|
||
期望 405 + `Allow: GET, HEAD, OPTIONS`。
|
||
|
||
为什么 `app/entry_semantics.py` 里的 405 分支没能拦住
|
||
------------------------------------------------------
|
||
`app/entry_semantics.py` 的 `entry_method_guard` 只在**钩子期装配路径**里可用:
|
||
入口在 `ahapp_built` 钩子里 append 它。而真实部署态走的是**构造期装配**
|
||
(`app/entry_semantics_ctor.py` + `app/scense.py::_start`)——aiohttp 的中间件链在
|
||
`Application._make_handler()` 里一次性构建,构造期传入的列表才是实际生效的链:
|
||
|
||
[resource_notfound_middleware, http_semantics_middleware]
|
||
|
||
即:**真正在跑的是 `app/notfound_middleware.py` + `app/http_semantics_middleware.py`**,
|
||
`entry_method_guard` 即使 append 成功也不在这条链上(或在 freeze 后被静默丢弃)。
|
||
于是 DELETE/PUT/POST `/healthz` 一路落到 ahserver 的 URL→资源解析,抛 `invalid path`,
|
||
被语义层的 404 兜底改写成 `{"status":404,"reason":"no such resource"}` —— 正是线上回显。
|
||
|
||
修复
|
||
----
|
||
把「入口内建只读路径 + 非 GET/HEAD/OPTIONS → 405 + Allow」的判定下沉成一个
|
||
**零应用内依赖的小模块**,由装配链里确实生效的两层共同调用:
|
||
|
||
app/notfound_middleware.py resource_notfound_middleware(外层:前置 + 异常侧)
|
||
app/http_semantics_middleware.py http_semantics_middleware(405/400/404 层)
|
||
|
||
这样无论部署态走「构造期」还是「钩子期」装配,方法语义都在生效链上。
|
||
|
||
不冒充 405 的边界(铁律)
|
||
------------------------
|
||
* `.dspy` 路径**一律不在此模块处理**(返回 None):不存在端点必须仍是 404,
|
||
不得为了凑 405 把「资源不存在」说成「方法不允许」。
|
||
* 页面类后缀(.ui/.bui/.js/.css/.html/.json...)**只有文件在 wwwroot 下真实存在**时才升 405;
|
||
确认不了存在就返回 None(保持原 404 语义)。
|
||
* 内建清单 `BUILTIN_GET_ONLY_PATHS` 是入口自己 `add_get()` 注册的端点(`/healthz` 等),
|
||
它们的存在性由注册代码保证,不受磁盘资源影响。
|
||
|
||
真源一致性:本模块的清单与 `app/entry_semantics.py` 的 `BUILTIN_GET_ONLY_PATHS` /
|
||
`PAGE_ALLOWED_METHODS` 必须一致,`scripts/selftest_healthz_405.py` 里有断言守卫(漂移即失败)。
|
||
"""
|
||
|
||
import importlib.util
|
||
import sys
|
||
import logging
|
||
import os
|
||
|
||
log = logging.getLogger("scense.get_only_405")
|
||
|
||
#: 入口级内建只读路径(入口代码 `add_get()` 注册的探活/元信息端点)。
|
||
BUILTIN_GET_ONLY_PATHS = (
|
||
"/healthz",
|
||
"/",
|
||
)
|
||
|
||
#: 内建只读路径允许的方法(无写入语义)
|
||
PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS")
|
||
#: [fix-r51b] 健康探针路径(最外层兜底只针对它,不含 `/`——首页仍需走完整渲染链)
|
||
HEALTHZ_PATH = "/healthz"
|
||
#: 405 响应头的 Allow 值(顺序固定,测试逐字断言)
|
||
ALLOW_HEADER = ", ".join(PAGE_ALLOWED_METHODS)
|
||
|
||
#: 本闸门版本标记——写进装配结果并由 `/healthz` 暴露,
|
||
#: 用于判定「部署树里跑的是哪一版」(R18 复验时无从判断,故增)。
|
||
GATE_REVISION = "r20-healthz-405-v4"
|
||
#: 闸门在 `app.middlewares` 里的注册名(幂等去重用)
|
||
GATE_MW_NAME = "get_only_405_method_gate"
|
||
|
||
#: 只读页面/静态资源后缀(**不含 .dspy**)
|
||
PAGE_SUFFIXES = (".html", ".htm", ".ui", ".bui", ".js", ".css", ".json")
|
||
|
||
|
||
def _norm_path(path):
|
||
"""路径归一:折叠重复斜杠、去尾斜杠(`//healthz/` 与 `/healthz` 同义)。"""
|
||
p = (path or "").strip()
|
||
if not p:
|
||
return ""
|
||
while "//" in p:
|
||
p = p.replace("//", "/")
|
||
if len(p) > 1 and p.endswith("/"):
|
||
p = p.rstrip("/")
|
||
return p
|
||
|
||
|
||
def _wwwroot_roots():
|
||
"""部署目录下的 wwwroot 候选根(本文件在 <deploy>/app/ 下)。取不到返回空。"""
|
||
roots = []
|
||
try:
|
||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
cand = os.path.join(base, "wwwroot")
|
||
if os.path.isdir(cand):
|
||
roots.append(cand)
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
return roots
|
||
|
||
|
||
def page_exists(path):
|
||
"""页面类路径在 wwwroot 下是否真实存在(确认不了返回 False → 保持 404)。"""
|
||
rel = _norm_path(path).lstrip("/")
|
||
if not rel or ".." in rel.split("/"):
|
||
return False
|
||
for root in _wwwroot_roots():
|
||
try:
|
||
if os.path.isfile(os.path.join(root, rel)):
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
return False
|
||
|
||
|
||
def get_only_allow(method, path, extra_paths=None):
|
||
"""命中「只读 GET 路径 + 不允许的方法」时返回 Allow 头字符串,否则返回 None。
|
||
|
||
:param method: HTTP 方法(大小写不敏感)
|
||
:param path: 请求路径(`request.path`,不含 query)
|
||
:param extra_paths: 额外只读路径(如装配期从路由表收集到的 add_get 路径)
|
||
:return: `"GET, HEAD, OPTIONS"` 或 `None`
|
||
"""
|
||
p = _norm_path(path)
|
||
if not p:
|
||
return None
|
||
# 铁律:.dspy 不参与(不存在端点必须 404,不得冒充 405)
|
||
if p.lower().endswith(".dspy"):
|
||
return None
|
||
m = (method or "GET").upper()
|
||
if m in PAGE_ALLOWED_METHODS:
|
||
return None
|
||
cands = {_norm_path(x) for x in (extra_paths or ())}
|
||
cands |= {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}
|
||
if p in cands:
|
||
return ALLOW_HEADER
|
||
if p.lower().endswith(PAGE_SUFFIXES) and page_exists(p):
|
||
return ALLOW_HEADER
|
||
return None
|
||
|
||
|
||
def json_405(web, request, allow):
|
||
"""构造 405 响应(**必须带 Allow 头**,RFC 7231 §6.5.5)。"""
|
||
method = getattr(request, "method", "") or ""
|
||
path = getattr(request, "path", "") or ""
|
||
log.warning("[405] builtin get-only: %s %s (Allow: %s)", method, path, allow)
|
||
return web.json_response(
|
||
{"status": 405, "success": False, "reason": "method not allowed",
|
||
"path": path, "method": method, "allow": allow,
|
||
"hint": "该路径只接受 %s" % allow},
|
||
status=405, headers={"Allow": allow})
|
||
|
||
|
||
# ---------------------------------------------------------------- 装配链适配层
|
||
def _web_module():
|
||
"""取 aiohttp web 模块(本文件被中间件链调用时用;取不到返回 None)。"""
|
||
try:
|
||
from aiohttp import web as _w # noqa: PLC0415
|
||
return _w
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def options_204(web, request, allow):
|
||
"""OPTIONS 预检:允许方法集合不变,但**必须回 Allow 头**(RFC 7231 §6.5.5)。
|
||
|
||
R18 复验观察:`OPTIONS /healthz -> 204` 且无 Allow,客户端无法得知可用方法。
|
||
"""
|
||
path = getattr(request, "path", "") or ""
|
||
log.info("[405] builtin get-only OPTIONS: %s (Allow: %s)", path, allow)
|
||
return web.Response(status=204, headers={"Allow": allow})
|
||
|
||
|
||
def maybe_405(web, request, extra_paths=None):
|
||
"""请求需要被判为 405 时返回响应对象,否则返回 None(调用方继续原逻辑)。
|
||
|
||
* 非允许方法 → 405 + `Allow: GET, HEAD, OPTIONS`
|
||
* `OPTIONS`(允许方法)命中只读路径 → 204 + `Allow`(带发现语义)
|
||
* 其余(含 `.dspy`、不存在的页面)→ None,保持原 404/401/200 语义
|
||
"""
|
||
allow = get_only_allow(getattr(request, "method", "GET"),
|
||
getattr(request, "path", ""), extra_paths)
|
||
if not allow:
|
||
return None
|
||
if (getattr(request, "method", "") or "").upper() == "OPTIONS":
|
||
return options_204(web, request, allow)
|
||
return json_405(web, request, allow)
|
||
|
||
|
||
def install_method_gate(app):
|
||
"""把方法闸门装到 `app.middlewares` **最外层**(幂等,返回诊断 dict)。
|
||
|
||
为什么必须最外层:真实链里内层存在「资源不存在 → 404」兜底
|
||
(`resource_notfound_middleware` / `entry_notfound_guard`),一旦它们先跑,
|
||
DELETE/PUT/POST `/healthz` 就会被改写成 404 —— 正是 R18 线上回显。
|
||
"""
|
||
diag = {"revision": GATE_REVISION, "installed": False, "position": None,
|
||
"allow": ALLOW_HEADER, "builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
|
||
"error": None}
|
||
try:
|
||
mws = getattr(app, "middlewares", None)
|
||
if mws is None:
|
||
diag["error"] = "app.middlewares 不存在"
|
||
return diag
|
||
for i, m in enumerate(mws):
|
||
nm = getattr(m, "__name__", "") or ""
|
||
if nm in (GATE_MW_NAME, "method_gate_middleware"):
|
||
diag["installed"] = True
|
||
diag["position"] = i
|
||
return diag
|
||
mw = method_gate_middleware
|
||
try:
|
||
from aiohttp import web as _w
|
||
mw = _w.middleware(mw)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
mw.__name__ = GATE_MW_NAME
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
mws.insert(0, mw) # 最外层
|
||
except AttributeError:
|
||
diag["error"] = "middlewares 只读(app 已 freeze?)"
|
||
return diag
|
||
diag["installed"] = True
|
||
diag["position"] = 0
|
||
except Exception as exc: # noqa: BLE001
|
||
diag["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||
return diag
|
||
|
||
|
||
def gate_diagnostics(app=None):
|
||
"""闸门在线诊断(`/healthz` 用):版本 / Allow / 是否在链上 / 第几层。"""
|
||
if app is None:
|
||
diag = {"revision": GATE_REVISION, "installed": None,
|
||
"position": None, "error": None}
|
||
else:
|
||
diag = install_method_gate(app)
|
||
diag["allow"] = ALLOW_HEADER
|
||
diag["builtin_get_only_paths"] = list(BUILTIN_GET_ONLY_PATHS)
|
||
diag["page_allowed_methods"] = list(PAGE_ALLOWED_METHODS)
|
||
diag["options_policy"] = "204 + Allow"
|
||
# [R18 复验 EPaXUem2] 405 载体自检:把「清单是否被路由收集结果覆盖 / 内建路径
|
||
# 在运行态是否还有 405 载体」做成**在线可读**字段,部署后 curl /healthz 即可判定
|
||
# 「代码生效」还是「部署树陈旧」,不必再靠日志猜。
|
||
if app is not None:
|
||
diag["builtin_union_ok"] = builtin_union_ok(app)
|
||
diag["route_carriers"] = route_carriers(app)
|
||
return diag
|
||
|
||
|
||
def builtin_union_ok(app=None, extra_paths=None):
|
||
"""[R18 复验] 守卫「BUILTIN 清单被路由收集结果覆盖」——本 bug 的已知复发形态。
|
||
|
||
历史教训(上一批同类缺陷):装配期从 `app.router` 收集到的 add_get 路径被当成
|
||
**清单本体**使用;一旦收集结果为空或不含 `/healthz`(`install()` 早于
|
||
`_register_healthz()` 时必然如此),`/healthz` 就从清单里消失 → 非 GET 落到
|
||
ahserver 资源解析 → 404 且无 `Allow`。
|
||
|
||
本函数的语义:`get_only_allow()` 的候选集**永远是并集**
|
||
`路由收集结果 ∪ BUILTIN_GET_ONLY_PATHS`,收集结果只能**加**不能**减**。
|
||
这里用一组差分用例机械证明该性质在当前进程内成立(不依赖任何外部状态):
|
||
|
||
* 收集结果为空 → `/healthz` 仍命中(BUILTIN 保底)
|
||
* 收集结果为不相干路径 → `/healthz` 仍命中(不被覆盖)
|
||
* 收集结果显式不含 /healthz → `/healthz` 仍命中
|
||
|
||
:return: dict{ok, checks[], builtin, error};任何一条不成立即 ok=False。
|
||
"""
|
||
checks = []
|
||
try:
|
||
scenarios = (
|
||
("extra=None", None),
|
||
("extra=[]", ()),
|
||
("extra=['/other']", ("/other",)),
|
||
("extra=['/a', '/b']", ("/a", "/b")),
|
||
)
|
||
for name, extra in scenarios:
|
||
for p in BUILTIN_GET_ONLY_PATHS:
|
||
got = get_only_allow("DELETE", p, extra_paths=extra)
|
||
ok = got == ALLOW_HEADER
|
||
checks.append({"scenario": name, "path": p, "ok": ok, "got": got})
|
||
# 归一形态同样必须命中(`//healthz/`、`/healthz/`)
|
||
for raw in ("//healthz/", "/healthz/", "/healthz"):
|
||
got = get_only_allow("POST", raw, extra_paths=())
|
||
checks.append({"scenario": "normalized", "path": raw,
|
||
"ok": got == ALLOW_HEADER, "got": got})
|
||
# 反例守卫:.dspy 必须**永不**返回 Allow(不存在端点不冒充 405)
|
||
for p in ("/xxx_not_exist.dspy", "/healthz.dspy"):
|
||
got = get_only_allow("DELETE", p, extra_paths=BUILTIN_GET_ONLY_PATHS)
|
||
checks.append({"scenario": "dspy_never_405", "path": p,
|
||
"ok": got is None, "got": got})
|
||
bad = [c for c in checks if not c["ok"]]
|
||
return {"ok": not bad, "checked": len(checks), "failed": bad[:8],
|
||
"builtin": list(BUILTIN_GET_ONLY_PATHS), "error": None}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {"ok": False, "checked": len(checks), "failed": [],
|
||
"builtin": list(BUILTIN_GET_ONLY_PATHS),
|
||
"error": "%s: %s" % (type(exc).__name__, exc)}
|
||
|
||
|
||
def route_carriers(app):
|
||
"""[R18 复验] 逐个内建路径列出运行态**实际存在的 405 载体**(在线可读)。
|
||
|
||
三层载体(任一在就足以给出 405 + Allow):
|
||
① `get_only_405.method_gate_middleware` 在 `app.middlewares` 最外层(本模块自证)
|
||
② 入口自带的 `app.router.add_route("*", <path>, ...)` 路由级 handler
|
||
③ `app/router` 里该路径只登记了 GET(aiohttp 自身会给 405,但 Allow 只有 GET, HEAD)
|
||
|
||
用途:部署后 `curl /healthz | jq .healthz_method_gate.route_carriers` 一眼看出
|
||
「闸门进了链没有 / 入口旧版有没有路由级兜底」,从而区分代码缺陷与部署陈旧。
|
||
"""
|
||
out = {}
|
||
try:
|
||
mws = list(getattr(app, "middlewares", None) or ())
|
||
gate_on = any((getattr(m, "__name__", "") or "") in (GATE_MW_NAME, "method_gate_middleware")
|
||
for m in mws)
|
||
router = getattr(app, "router", None)
|
||
resources = list(getattr(router, "resources", None) or ())
|
||
for p in BUILTIN_GET_ONLY_PATHS:
|
||
np = _norm_path(p)
|
||
wildcard = False
|
||
methods = []
|
||
for res in resources:
|
||
try:
|
||
info = res.get_info() if hasattr(res, "get_info") else {}
|
||
except Exception: # noqa: BLE001
|
||
info = {}
|
||
if _norm_path(info.get("path") or "") != np:
|
||
continue
|
||
m = (info.get("method") or "").upper()
|
||
if m in ("*", "ANY"):
|
||
wildcard = True
|
||
elif m:
|
||
methods.append(m)
|
||
out[p] = {"middleware_gate": gate_on, "router_wildcard": wildcard,
|
||
"router_methods": sorted(set(methods)),
|
||
"covered": bool(gate_on or wildcard or methods)}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|
||
return out
|
||
|
||
|
||
_SELF_INFO = {}
|
||
|
||
|
||
def _self_info():
|
||
"""[R52 取证] 本模块(运行中进程实际加载的那份文件)的绝对路径 / 字节数 / sha256。
|
||
|
||
只在首次调用时读盘并缓存——部署态 `GET/HEAD /healthz` 的兜底响应里原样带出,
|
||
是「部署树版本 == 本地版本 == 进程加载版本」三者对齐的进程内证据。
|
||
"""
|
||
if _SELF_INFO:
|
||
return dict(_SELF_INFO)
|
||
try:
|
||
import hashlib as _hl
|
||
p = os.path.abspath(__file__)
|
||
with open(p, "rb") as fp:
|
||
data = fp.read()
|
||
_SELF_INFO.update({"file": p, "bytes": len(data),
|
||
"sha256": _hl.sha256(data).hexdigest()})
|
||
except OSError as exc: # noqa: BLE001
|
||
_SELF_INFO.update({"file": __file__,
|
||
"error": "%s: %s" % (type(exc).__name__, exc)})
|
||
_SELF_INFO["pid"] = os.getpid()
|
||
try:
|
||
_SELF_INFO["cwd"] = os.getcwd()
|
||
except OSError:
|
||
_SELF_INFO["cwd"] = None
|
||
return dict(_SELF_INFO)
|
||
|
||
|
||
def _gate_diag_from_request(request):
|
||
"""从运行中进程的 Application 取入口写下的 `healthz_method_gate` 诊断(防御式)。"""
|
||
try:
|
||
app = getattr(request, "app", None)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
if app is None:
|
||
return None
|
||
for getter in (lambda: app["healthz_method_gate"],
|
||
lambda: getattr(app, "healthz_method_gate", None)):
|
||
try:
|
||
v = getter()
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
if isinstance(v, dict):
|
||
return {k: v.get(k) for k in
|
||
("installed", "position", "revision", "allow", "error",
|
||
"module_file", "module_sha256", "module_bytes",
|
||
"proc_pid", "proc_cwd", "proc_sys_path0")}
|
||
return None
|
||
|
||
|
||
def _healthz_probe_response(web, request, inner_err):
|
||
"""[fix-r51b] /healthz 只读探针的最外层兜底响应(200 + Allow)。
|
||
|
||
触发条件:内层链对 `GET|HEAD /healthz` 抛异常,或返回 >=400(部署态实测内层鉴权层
|
||
fail-closed 成 401 auth_unavailable)。健康探针不得因为内层鉴权噪声变成 4xx,
|
||
但内层错误必须在响应体与日志里可见(`inner_chain_error`),不得静默掩盖。
|
||
"""
|
||
log.warning("[healthz] 内层链异常,方法闸门兜底 200 %s: %s",
|
||
getattr(request, "path", ""), inner_err)
|
||
if web is None:
|
||
return None
|
||
payload = {
|
||
"status": 200,
|
||
"success": True,
|
||
"ok": True,
|
||
"healthz": "gate-standby",
|
||
"gate_revision": GATE_REVISION,
|
||
"allow": ALLOW_HEADER,
|
||
"path": getattr(request, "path", "") or "",
|
||
"method": getattr(request, "method", "") or "",
|
||
"inner_chain_error": str(inner_err),
|
||
}
|
||
# [R52 取证] 进程内自证:加载的是哪份闸门模块(路径/字节/sha256)+ 闸门在链上位置
|
||
payload["gate_module"] = _self_info()
|
||
_gd = _gate_diag_from_request(request)
|
||
if _gd is not None:
|
||
payload["healthz_method_gate"] = _gd
|
||
return web.json_response(payload, status=200, headers={"Allow": ALLOW_HEADER})
|
||
|
||
|
||
# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用
|
||
# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用
|
||
# → 返回 handler 函数而不是 Response(`Expect response, not <class 'function'>`)。
|
||
from aiohttp import web as _r60_web # noqa: E402
|
||
@_r60_web.middleware
|
||
async def method_gate_middleware(request, handler):
|
||
"""方法闸门中间件(装配链最外层用;签名 `(request, handler)`)。
|
||
|
||
由 `app/notfound_middleware.py::_COMPOSED_INNER_FIRST` 以
|
||
`("get_only_405", "method_gate_middleware")` 注册 —— 这就是 QC #4
|
||
「新模块零引用=死代码」要求的**真实调用点**:装配链里确实会执行到这里。
|
||
|
||
命中「入口内建只读路径 + 非 GET/HEAD/OPTIONS」→ 405 + `Allow: GET, HEAD, OPTIONS`;
|
||
其余情况(包括所有 .dspy、不存在的页面)原样放行给内层,保持 404/401/200 语义不变。
|
||
"""
|
||
w = _web_module()
|
||
if w is not None:
|
||
resp = maybe_405(w, request)
|
||
if resp is not None:
|
||
return resp
|
||
# [fix-r51b] OPTIONS 必须在**链最外层**给出 Allow:内层对 /healthz 没有 OPTIONS
|
||
# 路由,落到 ahserver 处理会回 `404 invalid path`(部署态实测日志
|
||
# "[semantics] 404 OPTIONS /healthz ... invalid path"),Allow 头丢失。
|
||
p = _norm_path(getattr(request, "path", "") or "")
|
||
if (getattr(request, "method", "") or "").upper() == "OPTIONS" \
|
||
and p in {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}:
|
||
return options_204(w, request, ALLOW_HEADER)
|
||
# [fix-r51b] /healthz 的 GET/HEAD 必须 200(不得被内层鉴权层误伤):部署态实测
|
||
# 内层 auth 层在 GET/OPTIONS /healthz 上抛 "Expect response, not <class 'function'>",
|
||
# 随后 fail-closed 成 `401 auth_unavailable`(日志:
|
||
# "[route_guard] 会话校验异常,fail-closed 401 /healthz: Expect response, not ...")。
|
||
# 本层是链最外层,由它把只读健康探针钉成 200;内层错误写进响应体
|
||
# inner_chain_error 并记 warning,不掩盖部署态缺陷。
|
||
if _norm_path(getattr(request, "path", "") or "") == HEALTHZ_PATH \
|
||
and (getattr(request, "method", "") or "").upper() in ("GET", "HEAD"):
|
||
try:
|
||
resp = await handler(request)
|
||
except Exception as exc: # noqa: BLE001
|
||
return _healthz_probe_response(w, request, exc)
|
||
if getattr(resp, "status", 500) >= 400:
|
||
return _healthz_probe_response(
|
||
w, request, "inner_status=%s" % getattr(resp, "status", "?"))
|
||
return resp
|
||
return await handler(request)
|
||
|
||
|
||
# ---------------------------------------------------------------- R52 签名契约
|
||
# 根因(R48 E1):`method_gate_middleware` 原为**裸** `async def (request, handler)`,
|
||
# 只有 `install_method_gate()` 的 insert 分支才临时补 `web.middleware()`;而真正在跑的
|
||
# 是 `notfound_middleware.compose_guard_chain()` 的**构造期**装配路径,那里拿到的是
|
||
# 模块属性(裸函数)→ 不带 `__middleware_version__` → aiohttp 按 legacy 工厂 `mw(app)`
|
||
# 调用 → request 形参收到 AHApp → `real_ip.py:16` 的 `request.remote` AttributeError。
|
||
# 修法:**模块级**即规范成 new-style(幂等),compose/install 任一路径取到的都是合规对象。
|
||
_MCC = None
|
||
|
||
|
||
def _load_contract_module():
|
||
"""加载同目录 middleware_chain_contract.py(不依赖 sys.path / 包上下文)。
|
||
|
||
R48 E1 教训:`except ImportError: return mw` 的静默降级会让裸函数原样进链,
|
||
aiohttp 按 legacy 工厂 `mw(app)` 调用 → `real_ip.py:16 request.remote`
|
||
AttributeError,全站 500。这里改为 spec_from_file_location 按 `__file__`
|
||
同级路径显式加载(同仓 app/scense.py 的既有写法),仍失败返回 None 并由
|
||
调用方显式补标记 + 记 error(绝不静默放行)。
|
||
"""
|
||
global _MCC
|
||
if _MCC is not None:
|
||
return _MCC
|
||
try:
|
||
import middleware_chain_contract as _m
|
||
_MCC = _m
|
||
return _MCC
|
||
except ImportError:
|
||
pass
|
||
try:
|
||
from app import middleware_chain_contract as _m
|
||
_MCC = _m
|
||
return _MCC
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"middleware_chain_contract.py")
|
||
try:
|
||
_spec = importlib.util.spec_from_file_location(
|
||
"middleware_chain_contract", _path)
|
||
if _spec is None or _spec.loader is None:
|
||
raise ImportError("spec_from_file_location 失败: %s" % _path)
|
||
_mod = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(_mod)
|
||
sys.modules.setdefault("middleware_chain_contract", _mod)
|
||
_MCC = _mod
|
||
return _MCC
|
||
except Exception as _exc: # noqa: BLE001
|
||
log.error("[chain-contract] 无法加载 %s: %s", _path, _exc)
|
||
return None
|
||
|
||
|
||
def _new_style_mw(mw, name=None):
|
||
"""把中间件规范成 new-style(带 __middleware_version__)。
|
||
|
||
**禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记
|
||
error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。
|
||
"""
|
||
_mcc = _load_contract_module()
|
||
fixed = mw
|
||
if _mcc is not None:
|
||
try:
|
||
fixed = _mcc.ensure_middleware(mw, name=name)
|
||
except Exception as _exc: # noqa: BLE001
|
||
log.error("[chain-contract] %s 规范化失败(%s),改用手工补 "
|
||
"new-style 标记", getattr(mw, "__name__", mw), _exc)
|
||
fixed = mw
|
||
if getattr(fixed, "__middleware_version__", None) != 1:
|
||
try:
|
||
setattr(fixed, "__middleware_version__", 1) # aiohttp 认这个标记
|
||
log.error("[chain-contract] %s 未经契约模块规范化,已手工补 "
|
||
"__middleware_version__=1(请检查中间件链装配)",
|
||
getattr(mw, "__name__", mw))
|
||
except Exception as _exc: # noqa: BLE001
|
||
raise RuntimeError(
|
||
"中间件 %s 既无法规范化也无法补标记: %s"
|
||
% (getattr(mw, "__name__", mw), _exc))
|
||
if getattr(fixed, "__middleware_version__", None) != 1: # 自校验,失败即抛
|
||
raise RuntimeError("中间件 %s 规范化后仍缺 __middleware_version__"
|
||
% (getattr(mw, "__name__", mw),))
|
||
return fixed
|
||
|
||
|
||
method_gate_middleware = _new_style_mw(method_gate_middleware)
|