"""推送订阅与分发模块(云端架构实施)。 零第三方依赖,只用 Python 标准库。本模块只做两件事: 1. 推送订阅的增删查(SQLite 表 ``push_subscriptions``)。 2. 状态变化事件的分发(只发状态变化,不发进行中状态)。 边界(不要超出本模块宣称的范围): - 合法事件类型与 ``webhooks.ALLOWED_NOTIFICATION_EVENTS`` 一致: ``completed``、``failed``、``goal-unmet``、``merge-failed``、 ``review-requested``。其他事件类型拒绝或忽略,并写明原因, 绝不静默丢弃。 - 偏好过滤:``silent``(静默)不发;失败类事件(``failed``、 ``goal-unmet``、``merge-failed``)即使偏好是 ``digest``(摘要) 也即时发;非失败类(``completed``、``review-requested``)在 ``digest`` 偏好下本次不立即发,返回结果里明确列入 ``skipped_for_digest``(待摘要列表),不是静默丢弃。 - 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。 ``sender`` 为 ``None`` 时用内置真实发送端(见 :func:`send_webhook` / :func:`send_slack`,标准库 urllib POST,单次 10 秒超时,不 重试,失败记入 errors);显式传入的 sender 优先于内置端。 ``sender`` 可以是可调用对象(收到一个 dict,见 :func:`dispatch_event`),也可以是按通道分的 dict (``{"webhook": callable, "slack": callable}``),或带 ``send`` 方法的对象。显式 sender 里没有某通道时,该通道仍报 “无 sender”,不静默成功。 - ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端: 一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造 真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发 任何请求。原因不是“没空写”:webpush 需要浏览器端订阅 (endpoint + p256dh + auth 三字段,现有订阅模型只有一个 target 文本字段,装不下)与标准库没有的 P-256 ECDH + AES-GCM;apns 需要标准库没有的 HTTP/2;fcm 需要用户 另建 Firebase 项目与服务账号——三者都没有现成接收方, 本模块不为此造一套半吊子加密或假发送。真实凭证与真实 发送端是后续待接线的工作,见模块末尾的缺口说明。 - 本模块不跑真实模型回合。webhook/slack 的真实发送只 POST 到订阅者自己填的 target URL(创建订阅已要求项目 create_task 权限)。P1 收紧后:真实发送层对 target 做 SSRF 校验——loopback、私有网段、链路本地、云元数据地址一律不 POST;主机名先 DNS 解析再按解析出的 IP 判,解析失败拒绝 (见 validate_push_target)。本地接收器如需放行,须运维显式 置 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1(默认关闭,测试用 本地接收器也须经此开关,不在代码里默认放行 loopback)。 密钥、token、证书一律不由本模块读取、写入或返回。 """ import ipaddress import json import os import socket import sqlite3 import urllib.error import urllib.parse import urllib.request from cloud.db import connect as _db_connect from cloud.db import PgConnection as _PgConnection import time import uuid from contextlib import contextmanager __all__ = [ "CHANNELS", "PREFERENCES", "ALLOWED_PUSH_EVENTS", "FAILURE_EVENTS", "MISSING_CREDENTIAL_REASONS", "DEFAULT_SENDERS", "init_push_db", "create_subscription", "delete_subscription", "list_subscriptions", "send_webhook", "send_slack", "dispatch_event", "validate_push_target", ] CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm") PREFERENCES = ("instant", "digest", "silent") # 只发状态变化的事件集合,与 webhooks.ALLOWED_NOTIFICATION_EVENTS 一致。 ALLOWED_PUSH_EVENTS = frozenset( {"completed", "failed", "goal-unmet", "merge-failed", "review-requested"} ) # 失败类事件:即使偏好是 digest 也即时发。 FAILURE_EVENTS = frozenset({"failed", "goal-unmet", "merge-failed"}) # webpush/apns/fcm 缺凭证时的明确报错文案(不含任何真实凭证)。 MISSING_CREDENTIAL_REASONS = { "webpush": "missing credential: VAPID keys not configured", "apns": "missing credential: APNs certificate not configured", "fcm": "missing credential: FCM key not configured", } def _is_connection(db): return isinstance(db, (sqlite3.Connection, _PgConnection)) @contextmanager def _connect(db): """按 db 类型取连接:Connection 直接用(不关闭);路径则新建并关闭。""" if _is_connection(db): yield db elif isinstance(db, (str, bytes)) or hasattr(db, "__fspath__"): conn = _db_connect(str(db)) try: yield conn finally: conn.close() else: raise TypeError("db must be a sqlite3 connection or a db path") def init_push_db(db): """创建 push_subscriptions 表(已存在则不动)。""" with _connect(db) as conn: conn.execute( "CREATE TABLE IF NOT EXISTS push_subscriptions(" "id TEXT PRIMARY KEY, " "project_id TEXT NOT NULL, " "user_id TEXT NOT NULL, " "channel TEXT NOT NULL, " "target TEXT NOT NULL, " "preference TEXT NOT NULL, " "created_at REAL, " "UNIQUE(project_id, user_id, channel, target))" ) conn.commit() def _row_to_dict(row): return { "id": row[0], "project_id": row[1], "user_id": row[2], "channel": row[3], "target": row[4], "preference": row[5], "created_at": row[6], } def _validate_subscription_fields(project_id, user_id, channel, target, preference): if not project_id or not isinstance(project_id, str): raise ValueError("project_id required") if not user_id or not isinstance(user_id, str): raise ValueError("user_id required") if channel not in CHANNELS: raise ValueError("channel must be one of %s" % (CHANNELS,)) if not target or not isinstance(target, str): raise ValueError("target required") if preference not in PREFERENCES: raise ValueError("preference must be one of %s" % (PREFERENCES,)) def create_subscription(db, project_id, user_id, channel, target, preference="instant"): """创建一条推送订阅,返回订阅 dict。 同一 (project_id, user_id, channel, target) 已存在时返回已有订阅, 不建重复行。channel 取值 webhook/slack/webpush/apns/fcm; preference 取值 instant/digest/silent;非法取值抛 ValueError。 """ _validate_subscription_fields( project_id, user_id, channel, target, preference) init_push_db(db) with _connect(db) as conn: existing = conn.execute( "SELECT id, project_id, user_id, channel, target, preference," " created_at FROM push_subscriptions" " WHERE project_id = ? AND user_id = ? AND channel = ?" " AND target = ?", (project_id, user_id, channel, target), ).fetchone() if existing is not None: return _row_to_dict(existing) sub_id = uuid.uuid4().hex now = time.time() conn.execute( "INSERT INTO push_subscriptions(id, project_id, user_id," " channel, target, preference, created_at)" " VALUES(?, ?, ?, ?, ?, ?, ?)", (sub_id, project_id, user_id, channel, target, preference, now), ) conn.commit() row = conn.execute( "SELECT id, project_id, user_id, channel, target, preference," " created_at FROM push_subscriptions WHERE id = ?", (sub_id,), ).fetchone() return _row_to_dict(row) def delete_subscription(db, subscription_id=None, *, project_id=None, user_id=None, channel=None, target=None): """删除订阅,返回 True(删到至少一条)或 False(无匹配)。 优先按 subscription_id 删除;未传 id 时按给出的 project_id/user_id/channel/target 过滤删除(至少要给一个过滤条件, 否则不删任何行并返回 False,避免误删整表)。 """ init_push_db(db) with _connect(db) as conn: if subscription_id is not None: cur = conn.execute( "DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,), ) else: conditions = [] params = [] for column, value in ( ("project_id", project_id), ("user_id", user_id), ("channel", channel), ("target", target), ): if value is not None: conditions.append("%s = ?" % column) params.append(value) if not conditions: return False cur = conn.execute( "DELETE FROM push_subscriptions WHERE " + " AND ".join(conditions), params, ) conn.commit() return cur.rowcount > 0 def list_subscriptions(db, project_id, user_id=None): """列出某项目的订阅;传 user_id 时只列该用户的。 返回按 created_at、id 排序的订阅 dict 列表。只查本项目, 跨项目订阅绝不会混入(见单测)。 """ if not project_id or not isinstance(project_id, str): raise ValueError("project_id required") init_push_db(db) with _connect(db) as conn: if user_id is None: rows = conn.execute( "SELECT id, project_id, user_id, channel, target," " preference, created_at FROM push_subscriptions" " WHERE project_id = ? ORDER BY created_at, id", (project_id,), ).fetchall() else: rows = conn.execute( "SELECT id, project_id, user_id, channel, target," " preference, created_at FROM push_subscriptions" " WHERE project_id = ? AND user_id = ?" " ORDER BY created_at, id", (project_id, user_id), ).fetchall() return [_row_to_dict(row) for row in rows] def _resolve_channel_sender(sender, channel): """从注入的 sender 里取出某通道的发送函数,取不到返回 None。""" if sender is None: return None if isinstance(sender, dict): candidate = sender.get(channel) if candidate is None: return None if callable(candidate): return candidate send = getattr(candidate, "send", None) return send if callable(send) else None if callable(sender): return sender send = getattr(sender, "send", None) return send if callable(send) else None # 真实发送端:单次投递超时(秒),不重试——失败记入 errors, # 下一次事件自然重发,不在这里循环撞墙。 _PUSH_SEND_TIMEOUT = 10 def _post_json(url, payload): """POST JSON 到 url:返回 (ok, detail),2xx 为 ok。 HTTP 状态错误与网络异常一律返回 (False, reason),不抛 (调用方据此记 errors);只有调用方传了非法 URL 时 Request 构造本身可能抛 ValueError,由发送端先校验 scheme 挡住,不走到这里。 """ data = json.dumps(payload).encode("utf-8") request = urllib.request.Request( url, data=data, method="POST", headers={"Content-Type": "application/json", "User-Agent": "codex-app-server"}) try: with urllib.request.urlopen( request, timeout=_PUSH_SEND_TIMEOUT) as response: response.read() return True, "HTTP %s" % response.status except urllib.error.HTTPError as e: return False, "HTTP %s" % e.code except Exception as exc: # noqa: BLE001 -- 网络异常转 reason,不抛 return False, "%s: %s" % (type(exc).__name__, exc) def _require_http_url(target, channel): """发送端 target 校验:只接受 http(s) URL,其他一律 ValueError (dispatch 记入 errors,不静默成功、不乱 POST)。""" if not isinstance(target, str) or not ( target.startswith("http://") or target.startswith("https://")): raise ValueError( "%s target must be an http(s) URL" % channel) return target # 云元数据主机名:即使解析出的 IP 恰好不在私有段,也直接挡。 _BLOCKED_HOSTNAMES = frozenset({"metadata.google.internal"}) def _allow_private_targets(): """运维显式开关:仅 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1 时放行 内网目标(本地接收器场景)。默认关闭;开关只影响 IP 段判定, 不影响 scheme 校验。""" return os.environ.get( "CODEX_PUSH_ALLOW_PRIVATE_TARGETS", "").strip() == "1" def _default_resolver(hostname): """默认 DNS 解析:返回 IP 字符串列表;解析失败抛异常, 由 validate_push_target 转成“拒绝”。""" infos = socket.getaddrinfo(hostname, None) out = [] for info in infos: sockaddr = info[4] if len(info) > 4 else None if sockaddr and sockaddr[0]: out.append(str(sockaddr[0])) return out def _ip_is_blocked(ip_text): """非全局单播 IP 一律算内网/特殊地址:loopback、私有网段、 链路本地(169.254/16,含云元数据 169.254.169.254)、保留段、 组播、未指定地址都在 is_global=False 里。""" try: return not ipaddress.ip_address(ip_text).is_global except ValueError: return True def validate_push_target(target, resolver=None, check_dns=True): """SSRF 校验:通过返回 None,否则返回原因字符串(不抛)。 顺序:scheme(http/https)-> 主机名存在 -> 元数据主机名 -> IP 字面量直接判段 -> (check_dns 时)DNS 解析后逐 IP 判段, 解析失败或解析出空结果都拒绝(DNS 重绑定下解析时与发送时 仍可能不一致,这是本层能做的上限,见残留说明)。 check_dns=False 时只做字面量判定:供直接调 send_* 但不想 做 DNS 的调用方用;dispatch 的真实发送路径一律 check_dns=True,显式 sender 路径不走本预检(见 dispatch)。 """ if not isinstance(target, str) or not ( target.startswith("http://") or target.startswith("https://")): return "target must be an http(s) URL" try: host = urllib.parse.urlparse(target).hostname except ValueError: return "target host is invalid" if not host: return "target host required" lowered = host.lower() if lowered in _BLOCKED_HOSTNAMES or lowered.endswith( ".metadata.google.internal"): return "target host is a cloud metadata address" if _allow_private_targets(): return None try: ipaddress.ip_address(host) is_literal = True except ValueError: is_literal = False if is_literal: if _ip_is_blocked(host): return "target resolves to a non-public address: %s" % host return None if not check_dns: return None resolve = resolver or _default_resolver try: ips = resolve(host) except Exception: return "DNS resolution failed for target host" if not ips: return "DNS resolution failed for target host" for ip_text in ips: if _ip_is_blocked(ip_text): return ("target host resolves to a non-public address: %s" % ip_text) return None def send_webhook(message): """webhook 通道真实发送:POST JSON 到订阅的 target URL。 body 固定为 {event_type, project_id, task_id, payload}, task_id 从 payload 里取(没有则为 None)。非 2xx 抛 RuntimeError(dispatch 记入 errors,不算 sent)。 """ target = _require_http_url(message["target"], "webhook") blocked = validate_push_target( target, resolver=message.get("_resolver")) if blocked is not None: raise RuntimeError( "webhook delivery failed: target blocked: %s" % blocked) payload = message["payload"] if isinstance( message["payload"], dict) else {} ok, detail = _post_json(target, { "event_type": message["event_type"], "project_id": message["project_id"], "task_id": payload.get("task_id"), "payload": message["payload"], }) if not ok: raise RuntimeError("webhook delivery failed: %s" % detail) def send_slack(message): """slack 通道真实发送:POST Slack incoming webhook 格式 ({"text": ...})到 target URL(Slack webhook 地址本身 就是 http(s) URL,与 webhook 通道同一投递路径)。 文本一行讲清事件:任务 id + 事件类型 + 项目,失败类 事件附 error 摘要(截断 500 字)。非 2xx 抛 RuntimeError。 """ target = _require_http_url(message["target"], "slack") blocked = validate_push_target( target, resolver=message.get("_resolver")) if blocked is not None: raise RuntimeError( "slack delivery failed: target blocked: %s" % blocked) payload = message["payload"] if isinstance( message["payload"], dict) else {} text = "[codex] task %s %s (project %s)" % ( payload.get("task_id", "?"), message["event_type"], message["project_id"]) if payload.get("error"): text += "\nerror: %s" % str(payload["error"])[:500] ok, detail = _post_json(target, {"text": text}) if not ok: raise RuntimeError("slack delivery failed: %s" % detail) # sender=None 时的内置真实发送端:只覆盖 webhook/slack; # webpush/apns/fcm 不在此列,仍走缺凭证报错。 DEFAULT_SENDERS = {"webhook": send_webhook, "slack": send_slack} def dispatch_event(db, event_type, project_id, payload, sender=None, resolver=None): """分发一次状态变化事件,只发本项目的订阅。 参数: db: sqlite3 连接或数据库路径。 event_type: 合法类型见 ALLOWED_PUSH_EVENTS;其他类型拒绝, 返回 ``{"sent": [], ..., "reason": "event not allowed"}`` 且不调用任何 sender。 project_id: 只查该项目的订阅,跨项目不混。 payload: 原事件载荷(dict 或其他),原样放入发送给 sender 的消息 dict 的 ``payload`` 字段。 sender: 可注入发送端(见模块 docstring);为 None 时用 内置真实发送端(webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);显式传入的 sender 优先于内置端,显式 sender 里没有某通道时该通道报“无 sender”,不静默成功。 返回 dict,键固定为:: { "event_type": ..., "project_id": ..., "sent": [{"subscription_id", "user_id", "channel", "target"}], "skipped_for_digest": [订阅 dict(待摘要,本次未立即发)], "skipped_silent": [订阅 dict(静默偏好,本次未发)], "errors": [{"subscription_id", "user_id", "channel", "target", "reason": ...}], } 非法事件类型时额外含 ``"reason": "event not allowed"``。 sender 抛异常时记入 errors,不中断其他订阅的分发。 """ result = { "event_type": event_type, "project_id": project_id, "sent": [], "skipped_for_digest": [], "skipped_silent": [], "errors": [], } if event_type not in ALLOWED_PUSH_EVENTS: result["reason"] = "event not allowed" result["errors"] = [{ "subscription_id": None, "user_id": None, "channel": None, "target": None, "reason": "event not allowed: %r not in %s" % (event_type, sorted(ALLOWED_PUSH_EVENTS)), }] return result if not project_id or not isinstance(project_id, str): result["reason"] = "project_id required" result["errors"] = [{ "subscription_id": None, "user_id": None, "channel": None, "target": None, "reason": "project_id required", }] return result real_send = sender is None if sender is None: # 内置真实发送端:webhook/slack 真 POST;显式 sender # 传入时不走这里(注入优先),webpush/apns/fcm 永远 # 不走 sender,仍在循环里报缺凭证。 sender = DEFAULT_SENDERS subscriptions = list_subscriptions(db, project_id) is_failure = event_type in FAILURE_EVENTS for sub in subscriptions: base = { "subscription_id": sub["id"], "user_id": sub["user_id"], "channel": sub["channel"], "target": sub["target"], } # 偏好过滤:silent 永远不发;digest 只对非失败类延迟。 if sub["preference"] == "silent": result["skipped_silent"].append(sub) continue if sub["preference"] == "digest" and not is_failure: result["skipped_for_digest"].append(sub) continue # webpush/apns/fcm:只有接口,缺凭证,明确报错,不走 sender、 # 不联网、不编造推送。 if sub["channel"] in MISSING_CREDENTIAL_REASONS: result["errors"].append( dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]])) continue # SSRF 预检(真实发送路径,发送前挡,不 POST):含 DNS # 的全量校验。显式 sender 路径不预检:注入的 sender 是 # 调用方自控的投递(测试 seam),不是服务端发起的 HTTP, # 挡它不增加生产安全性;生产服务端一律走 sender=None # 的真实发送,必经本预检与 send_webhook/send_slack 的 # 二次校验。原因文案含 "delivery failed",与旧调用方 # 对投递失败的断言兼容。 if real_send: blocked = validate_push_target( sub["target"], resolver=resolver, check_dns=True) if blocked is not None: result["errors"].append( dict(base, reason="%s delivery failed: target blocked:" " %s" % (sub["channel"], blocked))) continue # webhook/slack:sender=None 时已换成内置真实发送端; # 显式 sender 里没有该通道时明确报错,不静默成功。 channel_sender = _resolve_channel_sender(sender, sub["channel"]) if channel_sender is None: result["errors"].append( dict(base, reason="no sender for channel %s" % sub["channel"])) continue message = { "event_type": event_type, "project_id": project_id, "payload": payload, "channel": sub["channel"], "target": sub["target"], "user_id": sub["user_id"], "subscription_id": sub["id"], } try: channel_sender(message) except Exception as exc: # noqa: BLE001 -- 分发失败记 errors,不中断 result["errors"].append( dict(base, reason="sender failed: %s" % exc)) continue result["sent"].append(base) return result