diff --git a/AGENTS.md b/AGENTS.md index a9d6c363b..08d36adc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ These decisions are architectural constraints, not naming suggestions: * External media detail projection stays in `app/domain/projection/`: `mapping.py` owns the immutable builder and `tmdb.py`/`douban.py`/`bangumi.py`/`anilist.py` own source rules. `app/domain/context.py` keeps canonical `MediaInfo` construction and the four historical setter ABI as thin delegates. Host callers import source owners directly; do not add SDK/Compat aliases for these internal owners. * Kodi-style NFO reading and metadata document generation are one domain capability and stay together in `app/domain/scraper.py`; a separate `domain/nfo.py` must not be recreated. * `app/application/mediaserver.py` is the single media-server service capability module. It owns configured service discovery together with Provider ID normalization and music-library matching, while reusing generic identity rules from `app/domain/media.py`. -* Configured notification-service discovery belongs in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`; it is not a reusable messaging capability module. +* Configured notification-service discovery belongs in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`; it is not a reusable messaging capability module. The Web Push protocol itself (aes128gcm encryption, VAPID signing, delivery through `RequestUtils`) is the network adapter `app/adapters/network/webpush.py`, shared by that endpoint and `WebPushModule`. * `app/adapters/system/resource.py` detects/downloads/installs resources and returns whether installation occurred. Only `app/startup/initializers/modules.py` may decide to restart the process afterward. * Process memory/GC policy belongs in `app/runtime/gc.py`; external IP-location APIs belong in `app/adapters/external/location.py`. * Security implementation filenames use package-context nouns: `app/application/security/url.py` and `app/application/security/twofactor.py`. Historical `app.utils.security` and `app.helper.twofa` remain compatibility mappings only. diff --git a/app/adapters/network/webpush.py b/app/adapters/network/webpush.py new file mode 100644 index 000000000..aa561ba47 --- /dev/null +++ b/app/adapters/network/webpush.py @@ -0,0 +1,116 @@ +""" +Web Push 协议发送(RFC 8030 投递、RFC 8291 aes128gcm 加密、RFC 8292 VAPID 签名)。 + +直接使用 http_ece 与 py_vapid 完成加密和签名,经 RequestUtils 投递,不导入 pywebpush: +后者只是这两个库的薄封装,却会在导入时加载 aiohttp。请求头、VAPID 声明与 pywebpush +2.3 的 ``webpush()`` 保持一致。 +""" + +import base64 +import time +from typing import Any, Dict, Mapping, Optional, cast +from urllib.parse import urlparse + +import http_ece +from cryptography.hazmat.primitives.asymmetric import ec +from py_vapid import Vapid + +from app.adapters.network.http import RequestUtils + +# VAPID JWT 有效期;与 pywebpush 默认一致,推送服务要求不超过 24 小时。 +_VAPID_EXPIRE_SECONDS = 12 * 60 * 60 +# 推送服务返回 2xx 中不超过 202 的状态码视为已接收。 +_MAX_ACCEPTED_STATUS = 202 +_DEFAULT_TIMEOUT_SECONDS = 20 + + +class WebPushDeliveryError(Exception): + """ + Web Push 投递失败。 + + ``response`` 为推送服务的 HTTP 响应,网络失败或订阅数据无效时为 None; + 调用方据其状态码(404/410)判断订阅是否已失效。 + """ + + def __init__(self, message: str, response: Any = None): + super().__init__(message) + self.response = response + + +def _b64url_decode(value: str) -> bytes: + """解码浏览器订阅中去掉填充的 base64url 字段。""" + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _vapid_headers(endpoint: str, private_key: str, subject: str) -> Dict[str, str]: + """按推送端点源站生成 VAPID Authorization 请求头。""" + url = urlparse(endpoint) + claims = { + "sub": subject, + "aud": f"{url.scheme}://{url.netloc}", + "exp": int(time.time()) + _VAPID_EXPIRE_SECONDS, + } + return cast(Dict[str, str], Vapid.from_string(private_key=private_key).sign(claims)) + + +def _encrypt(subscription: Mapping[str, Any], payload: bytes) -> bytes: + """用订阅公钥与 auth 密钥按 aes128gcm 加密消息体,每次使用新的临时 ECDH 密钥。""" + keys = subscription.get("keys") or {} + if not keys.get("p256dh") or not keys.get("auth"): + raise WebPushDeliveryError("订阅缺少 p256dh 或 auth 密钥") + receiver_key = _b64url_decode(keys["p256dh"]) + if len(receiver_key) != 65 or receiver_key[0] != 0x04: + raise WebPushDeliveryError("订阅的 p256dh 公钥格式无效") + return cast(bytes, http_ece.encrypt( + payload, + private_key=ec.generate_private_key(ec.SECP256R1()), + dh=receiver_key, + auth_secret=_b64url_decode(keys["auth"]), + version="aes128gcm", + )) + + +def send_webpush( + subscription: Mapping[str, Any], + data: str, + vapid_private_key: str, + vapid_subject: str, + ttl: int = 0, + headers: Optional[Mapping[str, str]] = None, + timeout: int = _DEFAULT_TIMEOUT_SECONDS, +) -> Any: + """ + 向单个浏览器订阅投递一条加密通知。 + + :param subscription: 浏览器 PushSubscription JSON(endpoint 与 keys.p256dh/auth) + :param data: 通知正文,通常是 Service Worker 解析的 JSON 字符串 + :param vapid_private_key: VAPID 私钥(base64url 编码的原始私钥或 DER) + :param vapid_subject: VAPID 联系方式,形如 ``mailto:...`` 或站点 URL + :param ttl: 接收端离线时推送服务保留消息的秒数,0 表示不保留 + :param headers: 端点专用的附加请求头,例如 WNS 缓存策略 + :param timeout: 请求超时秒数 + :return: 推送服务的 HTTP 响应 + :raises WebPushDeliveryError: 订阅无效、网络失败或推送服务拒收 + """ + endpoint = subscription.get("endpoint") + if not endpoint: + raise WebPushDeliveryError("订阅缺少 endpoint") + if not vapid_private_key: + raise WebPushDeliveryError("未配置 VAPID 私钥") + request_headers = { + **(headers or {}), + **_vapid_headers(endpoint, vapid_private_key, vapid_subject), + "Content-Encoding": "aes128gcm", + "TTL": str(ttl), + } + response = RequestUtils( + headers=request_headers, timeout=timeout, verify=True + ).post_res(endpoint, data=_encrypt(subscription, data.encode("utf-8"))) + if response is None: + raise WebPushDeliveryError("推送服务无响应") + if response.status_code > _MAX_ACCEPTED_STATUS: + raise WebPushDeliveryError( + f"推送服务拒收:{response.status_code} {response.reason},{response.text[:200]}", + response=response, + ) + return response diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 390d409a0..8353f0a29 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -43,7 +43,7 @@ _WNS_DEFAULT_TTL = 86400 class WebPushError(Protocol): """Web Push 订阅状态判断所需的最小异常协议。""" - response: Any # 推送服务响应,状态码字段由具体 SDK 提供 + response: Any # 推送服务 HTTP 响应,失败前未收到响应时为 None def is_webpush_subscription_gone(error: WebPushError) -> bool: @@ -63,7 +63,7 @@ def is_wns_endpoint(endpoint: str | None) -> bool: def webpush_options_for_endpoint(endpoint: str | None) -> dict[str, Any]: - """返回指定推送端点需要的 pywebpush 附加参数。""" + """返回指定推送端点需要的 Web Push 附加参数(ttl 与请求头)。""" if not is_wns_endpoint(endpoint): return {} return { @@ -372,7 +372,7 @@ def send_notification( """ 发送webpush通知 """ - from pywebpush import WebPushException, webpush + from app.adapters.network.webpush import WebPushDeliveryError, send_webpush subscriptions = webpush_registry.list() if not subscriptions: @@ -382,15 +382,15 @@ def send_notification( failure_count = 0 for sub in subscriptions: try: - webpush( - subscription_info=sub, + send_webpush( + subscription=sub, data=json.dumps(payload.model_dump()), vapid_private_key=get_api_runtime_config_snapshot().vapid_private_key, - vapid_claims={"sub": get_api_runtime_config_snapshot().vapid_subject}, + vapid_subject=get_api_runtime_config_snapshot().vapid_subject, **webpush_options_for_endpoint(sub.get("endpoint")), ) success_count += 1 - except WebPushException as err: + except WebPushDeliveryError as err: logger.error(f"WebPush发送失败: {str(err)}") if is_webpush_subscription_gone(err) and webpush_registry.remove(sub): logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}") diff --git a/app/modules/webpush/__init__.py b/app/modules/webpush/__init__.py index fd1ecccdc..71443f64d 100644 --- a/app/modules/webpush/__init__.py +++ b/app/modules/webpush/__init__.py @@ -1,8 +1,7 @@ import json -from typing import Union, Tuple - -from pywebpush import webpush, WebPushException +from typing import Any, Dict, Tuple, Union +from app.adapters.network.webpush import WebPushDeliveryError, send_webpush from app.runtime.settings import get_runtime_setting from app.runtime.webpush import webpush_registry @@ -89,26 +88,24 @@ class WebPushModule(_ModuleBase, _MessageBase): logger.debug(f"给 {sub} 发送WebPush:{caption} {content}") try: endpoint = sub.get("endpoint") - webpush_options = {} + webpush_options: Dict[str, Any] = {} if endpoint and "notify.windows.com" in endpoint: webpush_options = { "ttl": 86400, "headers": {"X-WNS-Cache-Policy": "cache"}, } - webpush( - subscription_info=sub, + send_webpush( + subscription=sub, data=json.dumps({ "title": caption, "body": content, "url": message.link or "/?shotcut=message" }), vapid_private_key=get_runtime_setting('VAPID').get("privateKey"), - vapid_claims={ - "sub": get_runtime_setting('VAPID').get("subject") - }, + vapid_subject=get_runtime_setting('VAPID').get("subject"), **webpush_options, ) - except WebPushException as err: + except WebPushDeliveryError as err: logger.error(f"WebPush发送失败: {str(err)}") response = getattr(err, "response", None) status_code = getattr(response, "status_code", None) or getattr( diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index b148d8ce6..dcf7fdf30 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -743,7 +743,7 @@ flowchart LR `--write-host` 不会替代人工决策。 - 同一 baseline 的 `direct_adapter_imports` 记录现存原始直连;Application 与 Chain 均已清零, policy 目标为空集合。新增、替换、删除后未清理 policy 都会失败。 -- `direct_egress` 记录全宿主 50 条 raw transport、network SDK 和协议操作 identity;普通 HTTP/ +- `direct_egress` 记录全宿主 48 条 raw transport、network SDK 和协议操作 identity;普通 HTTP/ Session bridge 与 Application DNS I/O 债务已清零,其余 canonical transport、SDK、 stream/vendor/diagnostic/control-plane 事实是精确 containment。每条初始边的指纹由测试独立冻结, bindings/uses 变化、分类互换、通配导入和初始边增长都会失败;债务删除时同步删除冻结项以禁止恢复, @@ -762,18 +762,18 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1056 | -| 内部导入边 | 9,033 | +| Python 模块 | 1057 | +| 内部导入边 | 9,041 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | -| Direct egress | 50(债务已清零,50 条精确 containment) | +| Direct egress | 48(债务已清零,48 条精确 containment) | | Module Contract V2 spec | 215(其中 211 个进入 `run_module` 观察面) | | Event Contract | 53 | | Event producer / consumer | 86(85 静态、1 动态)/ 17(16 静态、1 动态) | | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责;宿主依赖边现为 9,033,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责;宿主依赖边现为 9,041,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 159849227..de981f0f5 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -96,7 +96,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1056 / 9,033 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1057 / 9,041 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -543,7 +543,7 @@ S4/ARCH-110/111 的取消不撤销现有防回退门禁。后续按路线图的 具体 cache adapter;Memory/Redis 均实现严格 `AtomicCacheBackend.store/consume`。 - S0-L2.4b 已为 LLM streaming、第三方 SDK、移植库和本地控制面建立完整 egress identity 与 zero-growth policy;11 条普通 HTTP/Session bridge 和 1 条 Application DNS I/O 债务已清零。 - 当前 50 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新 + 当前 48 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新 baseline/policy 掩盖调用面增长,已删除债务也不得恢复。 **目标与步骤** diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 0a26179f5..dcba7ee6f 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -965,7 +965,9 @@ expired claimed task remains exclusively owned by fenced recovery APIs. application service. - Configured notification discovery lives in `app/application/notification.py`. Web Push subscription and manual-send HTTP - behavior stays in `app/api/endpoints/message.py`. + behavior stays in `app/api/endpoints/message.py`; the Web Push protocol + (aes128gcm encryption, VAPID signing, delivery) is the network adapter + `app/adapters/network/webpush.py`, shared by that endpoint and `WebPushModule`. - System network testing lives in `app/application/network.py`: it owns the server-only target catalog, exact legacy URL matching, HTTPS and credential checks, per-target redirect allowlists and content validation. The System API diff --git a/pyproject.toml b/pyproject.toml index 7a0c964b4..176a530e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "fast-bencode~=1.1.8", "fastapi~=0.141.1", "google-genai~=2.8.0", + "http-ece~=1.2.1", "httpx[http2,socks]~=0.28.1", "httpx2[http2,socks]~=2.12.0", "jinja2~=3.1.6", @@ -57,6 +58,7 @@ dependencies = [ "pinyin2hanzi~=0.1.1", "plexapi~=4.18.1", "psutil~=7.2.2", + "py-vapid~=1.9.4", "pycryptodome~=3.23.0", "pydantic>=2.13.4,<3.0.0", "pydantic-settings>=2.14.2,<3.0.0", @@ -72,6 +74,7 @@ dependencies = [ "python-multipart~=0.0.32", "pytz~=2026.2", "pyvirtualdisplay~=3.0", + # 宿主 Web Push 已直接使用 http-ece 与 py-vapid,此 SDK 暂时保留给插件使用。 "pywebpush~=2.3.0", "pyyaml~=6.0.3", "qbittorrent-api==2026.6.0", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 391de543a..91ed40b8a 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -43,9 +43,9 @@ "app.application": 0, "app.chain": 0 }, - "count": 50, + "count": 48, "counts_by_kind": { - "network_sdk": 25, + "network_sdk": 23, "protocol_operation": 7, "raw_transport": 18 }, @@ -418,19 +418,6 @@ "SearchWebTool._search_ddgs.sync_search|call:text" ] }, - { - "bindings": [ - "from:pywebpush.WebPushException as WebPushException", - "from:pywebpush.webpush as webpush" - ], - "fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b", - "kind": "network_sdk", - "source": "app.api.endpoints.message", - "target": "pywebpush", - "uses": [ - "send_notification|call:webpush" - ] - }, { "bindings": [ "from:urllib.request.Request as Request", @@ -892,19 +879,6 @@ "Transmission.__sync_incomplete_file_suffix|call:set_session" ] }, - { - "bindings": [ - "from:pywebpush.WebPushException as WebPushException", - "from:pywebpush.webpush as webpush" - ], - "fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76", - "kind": "network_sdk", - "source": "app.modules.webpush", - "target": "pywebpush", - "uses": [ - "WebPushModule.post_message|call:webpush" - ] - }, { "bindings": [ "import:websocket as websocket" @@ -1021,8 +995,8 @@ "runtime_only": true } }, - "edge_count": 9033, - "edge_sha256": "f522f58bff01f9a88f784c346dec18d467f3efd72898b134073bb6a1756b0d9b", + "edge_count": 9041, + "edge_sha256": "5d588f777a2453573d913ff6a0c4a403656a3a22eb1041a2397d50b9ba8c5b61", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -1134,6 +1108,9 @@ "app.adapters.network.doh -> app.runtime.settings", "app.adapters.network.http -> app.runtime", "app.adapters.network.http -> app.runtime.correlation", + "app.adapters.network.webpush -> app.adapters", + "app.adapters.network.webpush -> app.adapters.network", + "app.adapters.network.webpush -> app.adapters.network.http", "app.adapters.observability.otel -> app.runtime", "app.adapters.observability.otel -> app.runtime.observability", "app.adapters.system.backup.files -> app.runtime", @@ -2505,6 +2482,8 @@ "app.api.endpoints.message -> app.adapters", "app.api.endpoints.message -> app.adapters.external", "app.api.endpoints.message -> app.adapters.external.wechat", + "app.api.endpoints.message -> app.adapters.network", + "app.api.endpoints.message -> app.adapters.network.webpush", "app.api.endpoints.message -> app.adapters.web", "app.api.endpoints.message -> app.adapters.web.security", "app.api.endpoints.message -> app.adapters.web.security.access", @@ -8203,6 +8182,9 @@ "app.modules.vocechat.vocechat -> app.runtime", "app.modules.vocechat.vocechat -> app.runtime.execution", "app.modules.vocechat.vocechat -> app.runtime.log", + "app.modules.webpush -> app.adapters", + "app.modules.webpush -> app.adapters.network", + "app.modules.webpush -> app.adapters.network.webpush", "app.modules.webpush -> app.modules", "app.modules.webpush -> app.runtime", "app.modules.webpush -> app.runtime.log", @@ -10058,7 +10040,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1056, + "module_count": 1057, "modules": [ "app", "app.adapters", @@ -10082,6 +10064,7 @@ "app.adapters.network.http", "app.adapters.network.ip", "app.adapters.network.resolver", + "app.adapters.network.webpush", "app.adapters.observability", "app.adapters.observability.otel", "app.adapters.system", diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index d78732ac9..d285cd25f 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -138,7 +138,6 @@ {"source": "app.agent.llm.discovery", "target": "google.genai", "kind": "network_sdk", "fingerprint": "5b87a7bb210b521bd21971fb6e3d19ca450658548670106e3c756c0ce3a83cad"}, {"source": "app.agent.llm.discovery", "target": "openai", "kind": "network_sdk", "fingerprint": "fff154461ff3fdfdf1daea0530dcb68589d6ad7a67f3966e3b86338b483e6dfd"}, {"source": "app.agent.tools.impl.search_web", "target": "ddgs", "kind": "network_sdk", "fingerprint": "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b"}, - {"source": "app.api.endpoints.message", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b"}, {"source": "app.modules.discord.discord", "target": "discord", "kind": "network_sdk", "fingerprint": "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86"}, {"source": "app.modules.feishu.feishu", "target": "lark_oapi", "kind": "network_sdk", "fingerprint": "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b"}, {"source": "app.modules.filemanager.storages.smb", "target": "smbclient", "kind": "network_sdk", "fingerprint": "37619f050b03598fa559c6ae2df94bda6adf7cffd1cc42d9db9059f1fb41af47"}, @@ -147,8 +146,7 @@ {"source": "app.modules.qbittorrent.qbittorrent", "target": "qbittorrentapi", "kind": "network_sdk", "fingerprint": "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d"}, {"source": "app.modules.slack.slack", "target": "slack_bolt", "kind": "network_sdk", "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6"}, {"source": "app.modules.slack.slack", "target": "slack_sdk", "kind": "network_sdk", "fingerprint": "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14"}, - {"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"}, - {"source": "app.modules.webpush", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76"} + {"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"} ] }, { diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index dafebc40b..986f22087 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -38,7 +38,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = { ("app.agent.llm.discovery", "google.genai"), ("app.agent.llm.discovery", "openai"), ("app.agent.tools.impl.search_web", "ddgs"), - ("app.api.endpoints.message", "pywebpush"), ("app.modules.discord.discord", "discord"), ("app.modules.feishu.feishu", "lark_oapi"), ("app.modules.filemanager.storages.smb", "smbclient"), @@ -48,7 +47,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = { ("app.modules.slack.slack", "slack_bolt"), ("app.modules.slack.slack", "slack_sdk"), ("app.modules.transmission.transmission", "transmission_rpc"), - ("app.modules.webpush", "pywebpush"), }, "streaming_protocol": { ("app.startup.composition.network", "websocket"), @@ -110,7 +108,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { ("app.agent.llm.discovery", "google.genai"): "5b87a7bb210b521bd21971fb6e3d19ca450658548670106e3c756c0ce3a83cad", ("app.agent.llm.discovery", "openai"): "fff154461ff3fdfdf1daea0530dcb68589d6ad7a67f3966e3b86338b483e6dfd", ("app.agent.tools.impl.search_web", "ddgs"): "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b", - ("app.api.endpoints.message", "pywebpush"): "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b", ("app.cli", "urllib.request"): "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b", ("app.doctor.checks", "socket.create_connection"): "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6", ("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354", @@ -131,7 +128,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { ("app.modules.slack.slack", "slack_sdk"): "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14", ("app.modules.themoviedb.tmdbv3api.tmdb", "requests"): "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484", ("app.modules.transmission.transmission", "transmission_rpc"): "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf", - ("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76", ("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6", ("app.startup.composition.network", "websocket"): "09e1909ac750fe40b145dde8fee52eedf84aaf2123f6a1bc38109252c89d6000", ("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51", diff --git a/tests/test_webpush_helper.py b/tests/test_webpush_helper.py index 22faac4d4..ff9b17d8e 100644 --- a/tests/test_webpush_helper.py +++ b/tests/test_webpush_helper.py @@ -5,7 +5,7 @@ from app.api.endpoints.message import ( is_wns_endpoint, webpush_options_for_endpoint, ) -from pywebpush import WebPushException +from app.adapters.network.webpush import WebPushDeliveryError class _FakeResponse: @@ -15,14 +15,12 @@ class _FakeResponse: def test_is_webpush_subscription_gone_for_expired_status_codes() -> None: for status_code in (404, 410): - err = WebPushException("gone") - err.response = _FakeResponse(status_code) + err = WebPushDeliveryError("gone", response=_FakeResponse(status_code)) assert is_webpush_subscription_gone(err) def test_is_webpush_subscription_gone_for_other_errors() -> None: - err = WebPushException("bad request") - err.response = _FakeResponse(400) + err = WebPushDeliveryError("bad request", response=_FakeResponse(400)) assert not is_webpush_subscription_gone(err) diff --git a/tests/test_webpush_sender.py b/tests/test_webpush_sender.py new file mode 100644 index 000000000..bad0a5283 --- /dev/null +++ b/tests/test_webpush_sender.py @@ -0,0 +1,163 @@ +""" +Web Push 协议发送测试:用模拟浏览器订阅解密投递内容,并校验 VAPID 签名与请求头。 +""" +import base64 +import json +import time +from types import SimpleNamespace +from unittest.mock import patch + +import http_ece +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from py_vapid import Vapid + +from app.adapters.network.webpush import WebPushDeliveryError, send_webpush + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +@pytest.fixture +def browser(): + """模拟浏览器 PushSubscription:接收方 P-256 密钥对与 16 字节 auth 密钥。""" + private_key = ec.generate_private_key(ec.SECP256R1()) + public_raw = private_key.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + auth = b"0123456789abcdef" + subscription = { + "endpoint": "https://push.example.com/send/abc?x=1", + "keys": {"p256dh": _b64url(public_raw), "auth": _b64url(auth)}, + } + return SimpleNamespace(private_key=private_key, auth=auth, subscription=subscription) + + +@pytest.fixture +def vapid(): + """生成与宿主 VAPID 配置同格式(base64url 原始私钥)的服务端密钥。""" + key = Vapid() + key.generate_keys() + raw = key.private_key.private_numbers().private_value.to_bytes(32, "big") + return SimpleNamespace(private_key=_b64url(raw), public_key=key.public_key) + + +def _post(status_code=201): + """拦截 RequestUtils 构造与 POST,记录请求头和正文。""" + captured = {} + + class _Utils: + def __init__(self, headers=None, timeout=None, verify=None, **_): + captured.update(headers=headers, timeout=timeout, verify=verify) + + def post_res(self, url, data=None, **_): + captured.update(url=url, body=data) + return SimpleNamespace(status_code=status_code, reason="R", text="detail") + + return captured, patch("app.adapters.network.webpush.RequestUtils", _Utils) + + +def test_payload_decrypts_with_browser_keys_and_vapid_verifies(browser, vapid): + """浏览器私钥能解出原文;VAPID JWT 由服务端公钥签名,aud 为端点源站。""" + captured, patcher = _post() + payload = json.dumps({"title": "标题", "body": "内容"}, ensure_ascii=False) + with patcher: + send_webpush(browser.subscription, payload, vapid.private_key, "mailto:admin@example.com") + + decrypted = http_ece.decrypt( + captured["body"], + private_key=browser.private_key, + auth_secret=browser.auth, + version="aes128gcm", + ) + assert decrypted.decode("utf-8") == payload + + headers = captured["headers"] + assert headers["Content-Encoding"] == "aes128gcm" + assert headers["TTL"] == "0" + assert captured["url"] == browser.subscription["endpoint"] + assert captured["verify"] is True + + scheme, _, params = headers["Authorization"].partition(" ") + assert scheme == "vapid" + fields = dict(item.strip().split("=", 1) for item in params.split(",")) + public_pem = vapid.public_key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + claims = jwt.decode(fields["t"], public_pem, algorithms=["ES256"], audience="https://push.example.com") + assert claims["sub"] == "mailto:admin@example.com" + assert time.time() < claims["exp"] <= time.time() + 24 * 3600 + assert base64.urlsafe_b64decode(fields["k"] + "==") == vapid.public_key.public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + + +def test_each_send_uses_fresh_ephemeral_key(browser, vapid): + """同一内容两次加密结果不同(盐与临时 ECDH 公钥都随机),且都能解密。""" + captured, patcher = _post() + bodies = [] + with patcher: + for _ in range(2): + send_webpush(browser.subscription, "same", vapid.private_key, "mailto:a@b.c") + bodies.append(captured["body"]) + assert bodies[0] != bodies[1] + for body in bodies: + assert http_ece.decrypt(body, private_key=browser.private_key, + auth_secret=browser.auth, version="aes128gcm") == b"same" + + +def test_endpoint_headers_and_ttl_are_forwarded(browser, vapid): + """WNS 等端点的附加请求头和 TTL 原样发送,VAPID 头不被调用方覆盖。""" + captured, patcher = _post() + with patcher: + send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c", + ttl=86400, headers={"X-WNS-Cache-Policy": "cache", "Authorization": "fake"}) + assert captured["headers"]["X-WNS-Cache-Policy"] == "cache" + assert captured["headers"]["TTL"] == "86400" + assert captured["headers"]["Authorization"].startswith("vapid t=") + + +@pytest.mark.parametrize("status_code", [404, 410, 413]) +def test_rejected_delivery_raises_with_response(browser, vapid, status_code): + """推送服务返回 202 以上状态码时抛错并带上响应,供调用方清理失效订阅。""" + _, patcher = _post(status_code) + with patcher, pytest.raises(WebPushDeliveryError) as exc: + send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c") + assert exc.value.response.status_code == status_code + + +@pytest.mark.parametrize("keys", [{}, {"p256dh": "AAAA", "auth": "AAAA"}]) +def test_invalid_subscription_keys_are_rejected_before_request(vapid, keys): + """缺少或格式错误的订阅密钥在发请求前报错,且不带响应(不会被当成失效订阅删除)。""" + captured, patcher = _post() + with patcher, pytest.raises(WebPushDeliveryError) as exc: + send_webpush({"endpoint": "https://push.example.com/x", "keys": keys}, "x", + vapid.private_key, "mailto:a@b.c") + assert exc.value.response is None + assert "url" not in captured + + +def test_no_response_is_reported(browser, vapid): + """网络失败(RequestUtils 返回 None)时抛错而不是静默成功。""" + with patch("app.adapters.network.webpush.RequestUtils") as utils: + utils.return_value.post_res.return_value = None + with pytest.raises(WebPushDeliveryError): + send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c") + + +def test_host_configured_vapid_key_signs(browser): + """宿主内置的 VAPID 私钥(base64url 原始格式)可以签出有效的 Authorization 头。""" + from app.runtime.settings import get_runtime_setting + + vapid_config = get_runtime_setting("VAPID") + captured, patcher = _post() + with patcher: + send_webpush(browser.subscription, "x", vapid_config["privateKey"], vapid_config["subject"]) + fields = dict( + item.strip().split("=", 1) + for item in captured["headers"]["Authorization"].partition(" ")[2].split(",") + ) + assert _b64url(base64.urlsafe_b64decode(fields["k"] + "==")) == vapid_config["publicKey"] diff --git a/tests/test_webpush_subscription.py b/tests/test_webpush_subscription.py index 5e8dda00b..306e3c4b1 100644 --- a/tests/test_webpush_subscription.py +++ b/tests/test_webpush_subscription.py @@ -78,13 +78,13 @@ def test_send_notification_reports_all_delivery_failures(monkeypatch): lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"), ) - from pywebpush import WebPushException + from app.adapters.network.webpush import WebPushDeliveryError def fail_delivery(**_): - """模拟 Web Push SDK 抛出发送异常。""" - raise WebPushException("failed") + """模拟推送服务拒收。""" + raise WebPushDeliveryError("failed") - monkeypatch.setattr("pywebpush.webpush", fail_delivery) + monkeypatch.setattr("app.adapters.network.webpush.send_webpush", fail_delivery) response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object()) @@ -102,9 +102,9 @@ def test_send_notification_reports_partial_delivery(monkeypatch): lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"), ) - from pywebpush import WebPushException + from app.adapters.network.webpush import WebPushDeliveryError - attempts = iter([None, WebPushException("failed")]) + attempts = iter([None, WebPushDeliveryError("failed")]) def deliver_once(**_): """模拟一次成功和一次失败的设备发送。""" @@ -113,7 +113,7 @@ def test_send_notification_reports_partial_delivery(monkeypatch): raise attempt return attempt - monkeypatch.setattr("pywebpush.webpush", deliver_once) + monkeypatch.setattr("app.adapters.network.webpush.send_webpush", deliver_once) response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object()) diff --git a/uv.lock b/uv.lock index c12f1782f..d3eb38712 100644 --- a/uv.lock +++ b/uv.lock @@ -1672,6 +1672,7 @@ dependencies = [ { name = "fast-bencode" }, { name = "fastapi" }, { name = "google-genai" }, + { name = "http-ece" }, { name = "httpx", extra = ["http2", "socks"] }, { name = "httpx2", extra = ["http2", "socks"] }, { name = "jinja2" }, @@ -1698,6 +1699,7 @@ dependencies = [ { name = "pinyin2hanzi" }, { name = "plexapi" }, { name = "psutil" }, + { name = "py-vapid" }, { name = "pycryptodome" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1791,6 +1793,7 @@ requires-dist = [ { name = "fast-bencode", specifier = "~=1.1.8" }, { name = "fastapi", specifier = "~=0.141.1" }, { name = "google-genai", specifier = "~=2.8.0" }, + { name = "http-ece", specifier = "~=1.2.1" }, { name = "httpx", extras = ["http2", "socks"], specifier = "~=0.28.1" }, { name = "httpx2", extras = ["http2", "socks"], specifier = "~=2.12.0" }, { name = "jinja2", specifier = "~=3.1.6" }, @@ -1817,6 +1820,7 @@ requires-dist = [ { name = "pinyin2hanzi", specifier = "~=0.1.1" }, { name = "plexapi", specifier = "~=4.18.1" }, { name = "psutil", specifier = "~=7.2.2" }, + { name = "py-vapid", specifier = "~=1.9.4" }, { name = "pycryptodome", specifier = "~=3.23.0" }, { name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" },