#!/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 候选根(本文件在 /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("*", , ...)` 路由级 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 `)。 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 ", # 随后 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)