11"""FLAGGED 告警即时推送 Cog。
22
3- Bot 内嵌一个 aiohttp 服务器(127 .0.0.1 :CHATBOT_ALERT_PORT),接收后端
3+ Bot 内嵌一个 aiohttp 服务器(0 .0.0.0 :CHATBOT_ALERT_PORT),接收后端
44SharedLinkEnrichmentWorker 在判定 status=FLAGGED 时 fire 的 webhook POST。
55收到后立即推送 Discord 管理员频道 + 邮件,不走每日 digest。
66
7- 鉴权:X-Internal-Key header,和后端共用同一把密钥。
8- loopback 端口:和后端同机,不经 Caddy 不开公网,纯内网通信。
7+ 为什么绑 0.0.0.0 而不是 127.0.0.1:backend 跑在 Docker 容器里,从容器看
8+ 宿主机是 docker bridge (host.docker.internal),只绑 loopback 接不到。
9+ 对外暴露面由三层兜:
10+ (a) X-Internal-Key 常量时间比较,防 timing 猜解
11+ (b) 可选 HMAC-SHA256 签名(WEBHOOK_HMAC_SECRET 配了就强校验)
12+ (c) 上游 Oracle VCN ingress / Docker networking 决定哪些 IP 能打过来
913
1014payload 形如:
1115{
2125
2226from __future__ import annotations
2327
28+ import hashlib
29+ import hmac
30+ import json
2431from datetime import datetime
2532from zoneinfo import ZoneInfo
2633
3744_CST = ZoneInfo ("Asia/Shanghai" )
3845
3946
47+ def _loads_json (raw : bytes ) -> dict :
48+ """把 raw bytes 解析为 dict(上层用 try/except 兜 JSONDecodeError)。"""
49+ return json .loads (raw .decode ("utf-8" ))
50+
51+
4052class AlertServer (commands .Cog ):
4153 def __init__ (self , bot : commands .Bot , settings : Settings ) -> None :
4254 self .bot = bot
@@ -53,26 +65,69 @@ async def cog_load(self) -> None:
5365 await self ._runner .setup ()
5466 # 绑 0.0.0.0:Backend 跑在 Docker 容器里,从容器内看 127.0.0.1 是容器自己
5567 # 而不是宿主机,因此必须监听所有接口才能接 docker bridge (host.docker.internal)。
56- # 公网侧安全性由 Oracle VCN ingress + X-Internal-Key header 双重保证 。
68+ # 公网侧安全性由 Oracle VCN ingress + X-Internal-Key header + 可选 HMAC 签名保证 。
5769 site = web .TCPSite (
5870 self ._runner , host = "0.0.0.0" , port = self .settings .chatbot_alert_port # noqa: S104
5971 )
6072 await site .start ()
6173 log .info ("alert_server_listening" , port = self .settings .chatbot_alert_port )
74+ # HMAC 没配:允许向前兼容(后端可能还没部署签名逻辑),但必须显式警告
75+ if self .settings .webhook_hmac_secret is None :
76+ log .warning (
77+ "alert_webhook_hmac_disabled" ,
78+ note = "WEBHOOK_HMAC_SECRET 未配置:/alert/flagged 只做 X-Internal-Key"
79+ " 校验,不验签。属于过渡模式(backend 尚未发出签名)。backend 上线"
80+ " 签名后,把密钥同步到本服务 env 即可启用。" ,
81+ )
6282
6383 async def cog_unload (self ) -> None :
6484 if self ._runner :
6585 await self ._runner .cleanup ()
6686
87+ @staticmethod
88+ def _verify_hmac (secret : str , raw_body : bytes , sig_header : str ) -> bool :
89+ """校验 X-Signature: sha256=<hex> 格式的签名。
90+
91+ - header 缺失 / 格式不对 / digest 不匹配 → False
92+ - 用 hmac.compare_digest 做常量时间比较
93+ """
94+ if not sig_header or not sig_header .startswith ("sha256=" ):
95+ return False
96+ provided_hex = sig_header [len ("sha256=" ):].strip ()
97+ if not provided_hex :
98+ return False
99+ expected_hex = hmac .new (
100+ secret .encode ("utf-8" ), raw_body , hashlib .sha256
101+ ).hexdigest ()
102+ # 大小写无关比较——避免后端大写/小写差异导致误拒
103+ return hmac .compare_digest (provided_hex .lower (), expected_hex .lower ())
104+
67105 async def _handle_flagged (self , req : web .Request ) -> web .Response :
68- # 鉴权
106+ # 鉴权 1:X-Internal-Key(常量时间比较,避免 timing 泄露 key 前缀)
69107 provided = req .headers .get ("X-Internal-Key" , "" )
70108 expected = self .settings .internal_api_key .get_secret_value ()
71- if not expected or provided != expected :
109+ if not expected or not hmac . compare_digest ( provided , expected ) :
72110 return web .json_response ({"ok" : False , "msg" : "forbidden" }, status = 403 )
73111
112+ # 先把原始 body 读出来——HMAC 必须对 raw bytes 算,JSON parse 之后再序列化会漂
113+ raw_body = await req .read ()
114+
115+ # 鉴权 2:HMAC-SHA256 签名(可选)。配了 secret 就强校验,没配就跳过。
116+ hmac_secret = self .settings .webhook_hmac_secret
117+ if hmac_secret is not None :
118+ sig_header = req .headers .get ("X-Signature" , "" )
119+ if not self ._verify_hmac (hmac_secret .get_secret_value (), raw_body , sig_header ):
120+ log .warning (
121+ "alert_hmac_reject" ,
122+ has_header = bool (sig_header ),
123+ body_len = len (raw_body ),
124+ )
125+ return web .json_response (
126+ {"ok" : False , "msg" : "invalid signature" }, status = 401
127+ )
128+
74129 try :
75- payload = await req . json ( )
130+ payload = _loads_json ( raw_body )
76131 except Exception :
77132 return web .json_response ({"ok" : False , "msg" : "bad json" }, status = 400 )
78133
0 commit comments