perf(webpush): send Web Push with http-ece and py-vapid instead of pywebpush

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-09-30 13:07:42 +08:00
committed by InfinityPacer
parent 0833169781
commit 4bd7e3dc04
15 changed files with 336 additions and 76 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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')}")

View File

@@ -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(

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 掩盖调用面增长,已删除债务也不得恢复。
**目标与步骤**

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"}
]
},
{

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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())

4
uv.lock generated
View File

@@ -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" },