local-deep-research 通知系统告警原因分类修复:`invalid_url` 与 `egress_denied` 的诚实化重构

发布时间:2026/9/16 15:23:04
local-deep-research 通知系统告警原因分类修复:`invalid_url` 与 `egress_denied` 的诚实化重构
local-deep-research 通知系统告警原因分类修复invalid_url与egress_denied的诚实化重构【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10 search engines - arXiv, PubMed, your private documents. Everything Local Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research本文基于仓库中 changelog.d/notification-invalid-url-followup.bugfix.md 这一变更记录展开深入解析 local-deep-research 通知模块基于 Apprise 的多通道通知服务在一次告警原因drop-reason分类修复中的完整技术方案。文章将带你理解invalid_url与egress_denied两种失败原因在什么情况下会被误报、本次修复如何在发送路径、测试通知端点、审计日志与 DNS 固定DNS-pin防护层上把分类逻辑与日志脱敏做得更诚实并给出可复现的配置示例与源码级证据。背景通知被拒后运营者看到的“原因”为什么不可信local-deep-research 的通知系统通过 NotificationManager 与 NotificationService 两层协作前者读取用户设置快照并给出结构化结果后者封装 Apprise 执行实际投递。当一条通知未能发出时调用方和日志需要一个精确的失败原因——NotificationReason 枚举定义了sent、server_disabled、event_disabled、unconfigured、egress_denied、invalid_url、webhook_failed、exception等取值。问题恰恰出在两种容易混淆的原因上egress_denied表示 URL 本身合法但被出站egress策略按目标地址拒收——即策略确实被评估过并做出了“拒绝”的裁决invalid_url表示 URL 根本无法解析或通过校验属于配置错误与策略评估无关。此前存在一类误报issue #5110/#5113 的后续当notifications.service_url无法解析时发送路径会一路落到 egress 过滤分支返回egress_denied并附带“all configured URLs refused by egress policy”之类的描述——但事实上 egress 策略从未被咨询过。让运营者去“放宽 egress 范围”来解决一个根本不是策略拒绝的问题既误导排障方向也掩盖了真正的配置错误。本次变更记录正是对这一缺陷的收尾修复。核心修复把“未解析”如实归入invalid_url修复后以下三种此前会被误报为egress_denied的形态现在一律如实归入invalid_url不可解析的 service URL例如配置了无协议的example.com/hook在 tests/notifications/test_manager.py 的test_unparseable_service_url_is_invalid_url_not_egress_denied中锁定该行为——结果必须满足reason is NotificationReason.INVALID_URL且 detail 中不得出现 egress 字样多 URL 列表中的不可解析尾随片段例如discord://x example.com/hook见同文件的test_unparseable_fragment_in_multi_url_list_is_invalid_urltest_manager.py仅含分隔符的值notifications.service_url解析后得到0 个 URL例如值只有,同样是invalid_url而非egress_denied——没有任何 URL 被评估过就谈不上“被 egress 策略全部拒绝”见 test_manager.py 的test_separator_only_service_url_is_invalid_url_not_egress_denied。这三类断言的核心逻辑在 notification_validator.py 的parse_notification_url_list第 289 行起中实现它返回(parsed_urls, invalid_fragment)只要invalid_fragment非空调用方就必须整体拒绝整个输入而不是只丢弃坏条目后投递“合法子集”。对应到 NotificationManager.send_notification 的空列表守卫任何解析为 0 条或携带非法片段的配置都会在进入 egress 过滤之前短路为invalid_url。为什么不能把invalid_fragment当作“替换 URL”来验证parse_notification_url_list的文档字符串对调用方给出了三条关键警告见 notification_validator.py片段可能自带 schemeRFC_FORBIDDEN_URL_CHARS_RE覆盖反斜杠与除\s外的控制字节而方案边界正则只在[,\s]处切分因此a://h1/p\https://h2/q仍是一个条目其非法片段是https://h2/q——拿这个“诱饵”去验证而真正被投递的是指向h1的原始字符串片段单独看可能是合法的永远不要假设对它的验证必然报错片段可能携带凭据当非法字符在条目尾部时片段就是整个条目本身而对 token 型 scheme如slack://xoxb-...即使做scheme://host脱敏保留的第一段 authority 也仍然是密钥本身因此日志只能记录其长度绝不能记录内容。行为变更含非法字符的条目使整个设置归为invalid_url本次变更记录明确标注了一个Behavior changenotifications.service_url的某个条目若包含 URI 中不允许未编码出现的字符——空格、反斜杠或控制字节——则该条目的存在会使整个设置被报告为invalid_url。受影响的值形如discord://x garbage slack://t/x/y\此前这类值虽然也会在投递前被拒URL 校验在发送路径和测试通知路径上早已拒绝它们但运营者看到的失败原因可能是egress_denied——一个从未被咨询过的策略做出的“拒绝”。现在原因统一且诚实invalid_url。修复方式有两种对非法字符做百分号编码例如把空格编码为%20把值拆分成多个用逗号分隔的完整 URL每个 URL 独立合法。而条目两端的空白仍然会被安全地裁剪包括非 ASCII 空白——例如从网页复制配置时带入的U00A0no-break space不会导致误判。这一点由parse_notification_url_list内部的_strip_notification_url_delimitersnotification_validator.py保证它对条目的首尾按,与任意 Unicode 空白做裁剪与其它消费方使用的裸str.strip()行为保持一致。对应回归测试是 test_manager.py 的test_whitespace_mixed_malformed_url_is_invalid_url_not_egress_denied配置discord://x garbage加policy.egress_scope: private_only时若解析器只识别带点的无协议名如example.com/hook而漏掉garbage该形态就会漏到 egress 过滤器被PRIVATE_ONLY策略按 discord scheme 拒收并误标为egress_denied。测试注释明确说明了这一点。混合 URL 列表不再谎称 Apprise “一个都没接受”另一个诚实化细节当混合 URL 列表中有一个条目被拒时旧的invalid_url详情文案会说 Apprise “accepted none”一个都没接受——这在混合列表中是不真实的因为合法的分区从未被尝试投递。修复后详情改为如实描述“Apprise rejected a configured service URL”拒绝了某个配置的服务 URL见 test_manager.py 的test_invalid_url_when_apprise_accepts_no_urls其中明确断言accepted none不得再出现在 detail 中。“Send Test Notification”端点把 Apprise 的 URL 切分器移出信任路径测试通知端点NotificationService.test_service是本轮修复的另一个重点包含三层强化整体拒绝不可无歧义切分的输入任何不能切分成无歧义条目的 service URL 都会被拒绝而不是像以前那样——校验“从该条目中切出的尾随片段”来代替条目本身这等于验证了一个诱饵向 Apprise 传入已解析的条目列表而非原始字符串test_service现在调用temp_apprise.add(list(url_entries))service.py。代码注释解释了原因Apprise 的parse_urls切分器与 LDR 的边界正则并不一致——Apprise 的 scheme 字符类是[a-z0-9]前导数字会开启新条目 . -不会而 LDR 要求前导字母且接受 . -。因此discord://a/b,7z://x对 LDR 是一个条目按 discord URL 校验对 Apprise 却是两个——第二个从未经过 SSRF 校验器。传入列表后切分路径上只有一个解析器Apprise 实际投递的集合与已被校验的集合严格一致保留解析器差异的结构性防线len(temp_apprise) len(url_entries)时拒绝发送service.py。在列表形式下这属于结构性不变量每个元素至多实例化一个插件但它仍能捕获未来某个 Apprise 版本把一个 URL 展开成多个服务器的情况。此外test_service对不可用 URL 的分类与发送路径完全一致不可解析或空值不再提示运营者去放宽一个从未被咨询的 egress 范围。测试端点同时受MAX_NOTIFICATION_TARGETS 20service.py上限约束防止认证调用者用逗号/空格分隔的主机名列表触发无界的 DNS 解析与外发请求扇出。审计日志脱敏片段只记长度被拒 webhook 只记scheme://host凭据泄漏风险贯穿整个修复invalid_url审计日志不再记录非法片段的任何形式只记录其长度。原因在 test_manager.py 的test_fragment_drop_log_never_carries_the_fragment中讲得很清楚redact_url_for_log会保留scheme://host[:port]但对 token-in-authority 的 Apprise scheme第一段 authority 本身就是密钥——slack://xoxb-SECRET-TOKEN/T00/B00脱敏后仍是slack://xoxb-SECRET-TOKEN而当非法字符位于条目尾部时解析器返回的片段就是整条运营者真实的、携带凭据的 service URL。因此日志绑定字段改为内容无关的fragment_lengthegress 策略审计日志同样收紧对一条被拒的 webhook现在只记录它的scheme://host而不是完整 URL。第二处调用点由test_egress_filter_fragment_log_never_carries_the_fragmenttest_manager.py覆盖。DNS-pin 回归测试收紧SecurityBlockError与NotificationGuardUnavailableError的端到端断言本轮变更还收紧了 DNS-pin 防护层的测试口径七条 DNS-pin 回归测试此前被放宽为“接受投递前安全拦截或发送时安全拦截任一形态”现在全部改为专门断言发送时的SecurityBlockError。SecurityBlockError定义在 notifications/exceptions.py是ServiceError的子类语义为“发送时确认的 SSRF/DNS-rebind 拦截”——它保留ServiceError的捕获兼容性except ServiceError仍能捕获且保持非瞬态的invalid_url分类不变同时让关心“校验时拒收”与“发送时确认目标敌对”差异的调用方无需解析异常文本即可区分NotificationGuardUnavailableError获得端到端测试该异常定义在 dns_pinning.py是RuntimeError的子类由 pinned_notification_send 在getaddrinfoshim 不是当前活动解析器时抛出——这是 DNS-pin 防护的fail-closed 拒绝无法保证 pin/block 生效时宁可拒绝发送也不静默无防护地发出去。测试见 tests/notifications/test_notification_webhook_dns_pin.py断言其具体类型沿NotificationService.send()端到端传播。在 NotificationService.send 的实现中这两类失败都被重新路由为SecurityBlockErrorservice.py发送时被 block-private 窗口拦截的ValueError与 DNS-pin shim 不可用的NotificationGuardUnavailableError都不应被误标为可重试的webhook_failed——前者会诱导调用方重试一个恶意/rebinding 目标后者会掩盖一个刻意为之的安全拒绝。同时Tenacity 重试谓词service.py将ValueError、RuntimeError、SecurityBlockError排除在重试之外确保已确认的恶意或错误配置目标快速失败而非重试三次。IDNA 不可编码主机普通解析失败而非“确认的安全拦截”最后一项细节在 DNS block-private 窗口内一个无法进行 IDNA 编码的主机现在被当作一次普通的解析失败拒绝而不再被误标为“已确认的安全拦截”。这与SecurityBlockError的语义保持一致——“确认的敌对目标”必须有确凿证据解析到内网/私网/云元数据地址而 IDNA 编码失败只是配置/字符集问题不应升级为安全结论。从源码结构看这与 dns_pinning.py 中_resolve_and_validate将UnicodeError路由到ConnectionError的处理路径相呼应见 dns_pinning.py 附近注释。配置与验证速查以仓库源码为据运营者可按以下清单自查notifications.service_url配置形态修复前可能的误报修复后的原因example.com/hook无协议egress_deniedinvalid_urldiscord://x example.com/hook条目内带空格egress_deniedinvalid_url整个设置slack://t/x/y\条目尾部反斜杠egress_deniedinvalid_url整个设置,仅分隔符egress_deniedinvalid_urldiscord://x garbageegress_deniedinvalid_url整个设置混合列表中单条被 Apprise 拒绝谎称 “accepted none”如实描述 “rejected a configured service URL”规避建议对条目的空格/反斜杠做百分号编码或将多目标拆成独立的逗号分隔 URL条目两端的普通空白与U00A0无需处理会被安全裁剪。测试通知端点同样适用上述规则——不能无歧义切分的输入将被整体拒绝且不会向 Apprise 传入原始字符串。参考与延伸阅读变更记录原文changelog.d/notification-invalid-url-followup.bugfix.md解析与校验实现src/local_deep_research/security/notification_validator.py发送/测试服务与异常路由src/local_deep_research/notifications/service.py、src/local_deep_research/notifications/exceptions.py原因枚举与分类入口src/local_deep_research/notifications/manager.pyDNS-pin 防护与守卫不可用异常src/local_deep_research/security/dns_pinning.py回归测试tests/notifications/test_manager.py、tests/notifications/test_notification_webhook_dns_pin.py、tests/notifications/test_notification_url_pipeline_default_parser.py、tests/notifications/test_notification_url_pipeline_custom_separator.py本次修复的价值在于原因分类的诚实性本身就是一种安全属性——它确保运营者在排障时看到的信息真实反映系统的决策路径不会因误导性的egress_denied去放宽从未参与决策的安全策略也不会因日志中的片段内容泄漏密钥。理解这条调用链parse_notification_url_list→validate_multiple_urls→ egress 过滤 →pinned_notification_send→SecurityBlockError路由就能在配置、测试与审计三个层面正确使用 local-deep-research 的通知能力。【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10 search engines - arXiv, PubMed, your private documents. Everything Local Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考