diff --git a/README.md b/README.md index a7880d4..8dbf324 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # gnexus-synapse — Python-клиент Synapse Тонкий клиент Ingestion API v1 хаба уведомлений Gnexus Synapse -(`git+https://git.gnexus.space/git/root/gn-synapse.git`): собрать конверт, -проверить локально, послать — без ретраев и локальных очередей (очередь +(`git+https://git.gnexus.space/git/root/gn-synapse.git`): **в обе +стороны** — сборка и отправка событий (Ingestion) и приём s2s-доставок +(проверка подписи webhook'а) — без ретраев и локальных очередей (очередь у Synapse своя, приём отвечает мгновенно). Контракт — `docs/05-ingestion-api.md` -в репозитории `gn-synapse`. +в репозитории `gn-synapse`, порядок интеграции — `docs/09-integration-guide.md`. ## Установка ```bash -pip install "git+https://git.gnexus.space/git/root/gn-synapse-client-py.git@v0.1.0" +pip install "git+https://git.gnexus.space/git/root/gn-synapse-client-py.git@v0.1.1" ``` Серверный репозиторий — `https://git.gnexus.space/git/root/gn-synapse.git`. @@ -69,6 +70,7 @@ | `SynapseValidationError` | 422 сервера или **локальная** ошибка конверта (`status_code is None`) | | `SynapseNotFoundError` | 404 — событие не у источника ключа | | `SynapseServerError` | 5xx — событие не принято, повторите при желании | +| `SynapseWebhookError` | приём s2s: подпись/freshness/не-JSON — не прошло проверку `verify_webhook` | Локальная валидация зеркалит серверную (`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`, 1..64; priority low/normal/high/critical; ttl 1..604800; dedup_key ≤ 255; @@ -85,8 +87,28 @@ event = await syn.send("gntodo", "task", "created", payload={"user_id": uid}) ``` -## Конвенции и оговорки +## Приём s2s-доставки (webhook-приёмник) +Сервисы, которые и шлют события, и принимают доставки Synapse (Navi, …), +используют тот же SDK для второй стороны: + +```python +from gnexus_synapse import verify_webhook, SIGNATURE_HEADER + +@router.post("/webhooks/synapse") +async def synapse_webhook(request: Request): + envelope = verify_webhook(await request.body(), request.headers, S2S_SECRET) + ... # envelope["event_id"], envelope["subject"], envelope["payload"] +``` + +Один вход: не-подпись/чужой секрет/replay (тело старше `max_skew`, дефолт +300 с)/не-JSON — всё ловится одним `SynapseWebhookError` (детали в `detail`). +Схема подписи — одна на всю экосистему (совместима с gnexus-auth и +с `app/signature.py` Synapse); проверка константным сравнением. +`make_signature()` — для тестов приёмника. Секрет per-target — +`S2S_SECRET_` (docs/05). + +## Конвенции и оговорки - **Дедуп best-effort** (окно 24 ч, сервер не даёт гарантии уникальности): не стройте бизнес-логику на отсутствии дублей. - **Дедуп вернул 202 с `deduplicated=true`** — это id *первого* события, не ошибки. diff --git a/pyproject.toml b/pyproject.toml index 9afe2cb..4d03c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "gnexus-synapse" -version = "0.1.0" +version = "0.1.1" description = "Thin client library for gn-synapse (Gnexus notification hub) event ingestion" readme = "README.md" requires-python = ">=3.11" diff --git a/src/gnexus_synapse/__init__.py b/src/gnexus_synapse/__init__.py index 26a71c3..46ea74e 100644 --- a/src/gnexus_synapse/__init__.py +++ b/src/gnexus_synapse/__init__.py @@ -16,9 +16,11 @@ SynapseNotFoundError, SynapseServerError, SynapseValidationError, + SynapseWebhookError, ) +from .webhook import DEFAULT_MAX_SKEW, SIGNATURE_HEADER, make_signature, verify_webhook -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "AsyncSynapseClient", @@ -36,7 +38,12 @@ "SynapseNotFoundError", "SynapseValidationError", "SynapseServerError", + "SynapseWebhookError", "SynapseError", + "verify_webhook", + "make_signature", + "SIGNATURE_HEADER", + "DEFAULT_MAX_SKEW", "ENV_URL", "ENV_API_KEY", "ENV_TIMEOUT", diff --git a/src/gnexus_synapse/exceptions.py b/src/gnexus_synapse/exceptions.py index 39d8f72..08eebd6 100644 --- a/src/gnexus_synapse/exceptions.py +++ b/src/gnexus_synapse/exceptions.py @@ -46,3 +46,9 @@ class SynapseServerError(SynapseError): """5xx: Synapse недоступен/ошибка на его стороне — сообщение не ушло.""" + + +class SynapseWebhookError(SynapseError): + """s2s-доставка не прошла проверку: нет/битый заголовок подписи, + чужой секрет, replay (тело старше окна свежести), не-JSON тело. + Локальная ошибка приёма (status_code=None) — это не про отправку.""" diff --git a/src/gnexus_synapse/webhook.py b/src/gnexus_synapse/webhook.py new file mode 100644 index 0000000..d9f3f0e --- /dev/null +++ b/src/gnexus_synapse/webhook.py @@ -0,0 +1,96 @@ +"""Приём s2s-доставки: проверка подписи вебхука Synapse. + +Одна схема подписи на всю экосистему (docs/05 → «Доставка s2s»), зеркало +`app/signature.py` сервера и webhooks gnexus-auth: + + sig = "t=,v1=" + hex(hmac_sha256(".", secret)) + +`verify_webhook()` — одна точка входа принимающего сервиса: проверяет +подпись (константным сравнением) и окно свежести (replay), возвращает +распарсенный конверт события. Тело — байт в байт то, что пришло в сеть, +до любого парсинга. + +Использование (FastAPI-приёмник Navi): + + @router.post("/webhooks/synapse") + async def synapse_webhook(request: Request) -> dict: + raw = await request.body() + envelope = verify_webhook(raw, request.headers, S2S_SECRET) + ... # envelope["event_id"], envelope["subject"], ... +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from collections.abc import Mapping + +from .exceptions import SynapseWebhookError + +__all__ = ["SIGNATURE_HEADER", "DEFAULT_MAX_SKEW", "verify_webhook", "make_signature"] + +SIGNATURE_HEADER = "x-gnexus-signature" +DEFAULT_MAX_SKEW = 300 # секунд (зеркало gnexus-auth) + + +def make_signature(body: bytes, secret: str, timestamp: int | None = None) -> str: + """Подпись «сырое» тело (для тестов и отправщиков, приём — не его работа).""" + if timestamp is None: + timestamp = int(time.time()) + digest = hmac.new( + secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256 + ).hexdigest() + return f"t={timestamp},v1={digest}" + + +def verify_webhook( + raw_body: bytes, + headers: Mapping[str, str], + secret: str, + *, + max_skew: int = DEFAULT_MAX_SKEW, + now: int | None = None, +) -> dict[str, object]: + """Проверить подпись и распарсить доставку. Возвращает конверт (dict). + + Тело — байт в байт как пришло (request.body(), не json-объект). + Заголовки ищутся без учёта регистра. Ошибка → SynapseWebhookError: + нет/битый `X-Gnexus-Signature`, replay (старше max_skew), чужой секрет, + не-JSON тело. Все — одна ловушка, разбирать по detail. + """ + supplied: str = "" + for key, value in headers.items(): + if key.lower() == SIGNATURE_HEADER: + supplied = value + break + parts: dict[str, str] = {} + for chunk in supplied.split(","): + name, _, raw_value = chunk.partition("=") + parts[name.strip().lower()] = raw_value.strip() + try: + timestamp = int(parts["t"]) + digest = parts["v1"] + except (KeyError, ValueError): + raise SynapseWebhookError("нет или битый заголовок X-Gnexus-Signature") from None + if not digest: + raise SynapseWebhookError("подпись без v1-части") + + current = int(time.time()) if now is None else now + if abs(current - timestamp) > max_skew: + raise SynapseWebhookError(f"тело не свежее: отклонение {abs(current - timestamp)} с > {max_skew} с (replay?)") + + expected = hmac.new( + secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(expected, digest): + raise SynapseWebhookError("подпись не совпала — чужой секрет или тело искажено") + + try: + envelope = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + raise SynapseWebhookError("тело не JSON — приём Synapse шлёт конверт") from None + if not isinstance(envelope, dict): + raise SynapseWebhookError("тело — не JSON-объект конверта") + return envelope diff --git a/tests/unit/test_webhook.py b/tests/unit/test_webhook.py new file mode 100644 index 0000000..2a56317 --- /dev/null +++ b/tests/unit/test_webhook.py @@ -0,0 +1,108 @@ +"""verify_webhook: подпись, freshness, парс конверта.""" + +from __future__ import annotations + +import json + +from gnexus_synapse import ( + DEFAULT_MAX_SKEW, + SIGNATURE_HEADER, + SynapseWebhookError, + make_signature, + verify_webhook, +) + +SECRET = "s2s-secret-test" +BODY = json.dumps( + { + "event_id": "11111111-1111-1111-1111-111111111111", + "source": "monitoring", + "subject": "container", + "action": "down", + "priority": "critical", + "payload": {"container": "api"}, + } +).encode() + + +def test_happy_path_returns_envelope() -> None: + header = make_signature(BODY, SECRET, timestamp=1_000_000) + envelope = verify_webhook( + BODY, {SIGNATURE_HEADER: header}, SECRET, now=1_000_000 + ) + assert envelope["source"] == "monitoring" + assert envelope["event_id"] == "11111111-1111-1111-1111-111111111111" + + +def test_header_search_is_case_insensitive() -> None: + header = make_signature(BODY, SECRET, timestamp=1_000_000) + envelope = verify_webhook( + BODY, + {"X-GNEXUS-SIGNATURE": header, "X-Synapse-Source": "monitoring"}, + SECRET, + now=1_000_000, + ) + assert envelope["action"] == "down" + + +def test_wrong_secret_rejected() -> None: + header = make_signature(BODY, "other-secret", timestamp=1_000_000) + try: + verify_webhook(BODY, {SIGNATURE_HEADER: header}, SECRET, now=1_000_000) + except SynapseWebhookError as ex: + assert "не совпала" in ex.detail + else: + raise AssertionError("чужой секрет принят") + + +def test_replay_rejected() -> None: + header = make_signature(BODY, SECRET, timestamp=1_000_000) + try: + verify_webhook( + BODY, {SIGNATURE_HEADER: header}, SECRET, now=1_000_000 + DEFAULT_MAX_SKEW + 1 + ) + except SynapseWebhookError as ex: + assert "replay" in ex.detail or "свежее" in ex.detail + else: + raise AssertionError("старая доставка принята") + + +def test_modified_body_rejected() -> None: + header = make_signature(BODY, SECRET, timestamp=1_000_000) + try: + verify_webhook(BODY + b" ", {SIGNATURE_HEADER: header}, SECRET, now=1_000_000) + except SynapseWebhookError: + pass + else: + raise AssertionError("искажённое тело принято") + + +def test_missing_and_broken_headers_rejected() -> None: + for headers in ({}, {SIGNATURE_HEADER: "t=1"}, {SIGNATURE_HEADER: "garbage"}, + {SIGNATURE_HEADER: f"t={1_000_000},v1="}): + try: + verify_webhook(BODY, dict(headers), SECRET, now=1_000_000) + except SynapseWebhookError: + pass + else: + raise AssertionError(f'заголовок {headers} принят без подписи') + + +def test_non_json_body_rejected() -> None: + header = make_signature(b"not json", SECRET, timestamp=1_000_000) + try: + verify_webhook(b"not json", {SIGNATURE_HEADER: header}, SECRET, now=1_000_000) + except SynapseWebhookError: + pass + else: + raise AssertionError("не-JSON тело принято") + + +def test_list_body_rejected() -> None: + header = make_signature(b"[1,2]", SECRET, timestamp=1_000_000) + try: + verify_webhook(b"[1,2]", {SIGNATURE_HEADER: header}, SECRET, now=1_000_000) + except SynapseWebhookError: + pass + else: + raise AssertionError("JSON-массив принят как конверт")