diff --git a/alembic/versions/20261003_0002_source_target_description.py b/alembic/versions/20261003_0002_source_target_description.py new file mode 100644 index 0000000..2486656 --- /dev/null +++ b/alembic/versions/20261003_0002_source_target_description.py @@ -0,0 +1,28 @@ +"""description для sources и channel_targets + +Поле «что это за сервис/цель» — для человека и ИИ-агента (в админке и +в s2s-вебхуке получателя), не гадать по названию. + +Revision ID: a4c1e9b02d77 +Revises: 8737d8f652f9 +Create Date: 2026-10-03 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "a4c1e9b02d77" +down_revision = "8737d8f652f9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("sources", sa.Column("description", sa.Text(), nullable=True)) + op.add_column("channel_targets", sa.Column("description", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("channel_targets", "description") + op.drop_column("sources", "description") \ No newline at end of file diff --git a/app/cli.py b/app/cli.py index bb66a7f..4d04ac7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -37,7 +37,7 @@ if exists: print(f"источник '{args.name}' уже существует (id={exists.id})") return 1 - source = Source(name=args.name, label=args.label) + source = Source(name=args.name, label=args.label, description=args.description) db.add(source) db.commit() print(f"ok: source id={source.id} name={source.name}") @@ -84,7 +84,10 @@ def cmd_add_target(args: argparse.Namespace) -> int: with SessionLocal() as db: target = ChannelTarget( - channel=args.channel, name=args.name, config=json.loads(args.config) + channel=args.channel, + name=args.name, + description=args.description, + config=json.loads(args.config), ) db.add(target) db.commit() @@ -146,6 +149,7 @@ p = sub.add_parser("create-source") p.add_argument("name") p.add_argument("--label") + p.add_argument("--description", help="что это за сервис — для ИИ-агента и человека") p.set_defaults(func=cmd_create_source) p = sub.add_parser("create-key") @@ -163,6 +167,7 @@ p = sub.add_parser("add-target") p.add_argument("channel") p.add_argument("name") + p.add_argument("--description", help="что это за клиент-сервис — для ИИ-агента и человека") p.add_argument("--config", default="{}") p.set_defaults(func=cmd_add_target) diff --git a/app/models/dicts.py b/app/models/dicts.py index 4fa39c0..c0fdcc1 100644 --- a/app/models/dicts.py +++ b/app/models/dicts.py @@ -44,6 +44,9 @@ id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(64), unique=True) # slug: "monitoring" label: Mapped[str | None] = mapped_column(String(120)) # человекочитаемое + # Описание «что это за сервис» для человека и ИИ-агента: уходит в s2s-вебхук + # (X-Synapse-метаданные/тело), чтобы получатель не гадал по имени источника. + description: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) @@ -110,6 +113,9 @@ id: Mapped[int] = mapped_column(Integer, primary_key=True) channel: Mapped[str] = mapped_column(String(32)) name: Mapped[str] = mapped_column(String(120)) + # Описание «что это за клиент-сервис» — для человека и ИИ-агента в админке: + # не гадать по названию (например, «Navi rei — домашний ассистент на swarm»). + description: Mapped[str | None] = mapped_column(Text) config: Mapped[dict] = mapped_column(JSONB) enabled: Mapped[bool] = mapped_column(default=True) created_at: Mapped[datetime] = mapped_column( diff --git a/docs/05-ingestion-api.md b/docs/05-ingestion-api.md index e027f70..e834a92 100644 --- a/docs/05-ingestion-api.md +++ b/docs/05-ingestion-api.md @@ -76,6 +76,48 @@ - **Реестр типов**: тройка `(source, subject, action)`, опционально JSON Schema payload'а. Заводит админ Synapse в UI. - **Правило маршрутизации**: условия (`source`, `subject`, набор `actions`, `priority >= X`, позже — payload-матчинг и теги) → действия (каналы + шаблон + цели: TG-чат, SMTP, s2s-ендпоинт). +## Доставка s2s: подпись и верификация + +Когда правило направляет событие в системный webhook (Navi и другие сервисы), Synapse подписывает доставку. Схема — **ровно та же, что у вебхуков gnexus-auth** (`WebhookSignature.php`), так что принимающий код в экосистеме один и тот же. + +Заголовки (как у gnexus-auth, плюс один): + +``` +X-Gnexus-Event-Id: +X-Gnexus-Event-Type: monitoring.container.down # ".." +X-Gnexus-Event-Timestamp: 1759483200 +X-Gnexus-Signature: t=1759483200,v1= +X-Synapse-Source: monitoring # доп. заголовок Synapse +``` + +Подпись — HMAC-SHA256 по «сырому телу» запроса (то, что уйдёт в сеть, байт в байт): + +``` +sig = "t=" + unix_timestamp + ",v1=" + hex(hmac_sha256(unix_timestamp + "." + raw_body, secret)) +``` + +Проверка на принимающей стороне: + +1. Вычислить ожидаемую строку `t=…,v1=…` из **raw body** (как оно пришло, до каких-либо парсингов) и своего секрета. +2. Сравнить через **константное время** (PHP `hash_equals`, Python `hmac.compare_digest`) — не через `==`. +3. Свежесть: `|now - t|` в пределах допуска (gnexus-auth — 5 минут) → защита от replay. + +Секрет — **свой у каждой цели** (per-target, поле `token_ref` в `channel_targets.config`), значения в `.env`/gnexus-creds, в БД только ссылки. Ротация секрета цели не затрагивает правила маршрутизации. + +Цепочка доверия: получатель проверил подпись ⇒ целостность и авторство Synapse. Синтезировать чужое событие Synapse не может — на приёме его ключ и `source` сверились бы с реестром (403), а ретрансляция чужого ключом источника невозможна, поскольку событие должно нести `source`, совпадающий с ключом (анти-спуфинг). + +Тело s2s-доставки — конверт события плюс описание источника (и человек, и ИИ-агент получателя видят, кто это, без гадания по имени): + +```json +{ + "event_id": "uuid", + "source": "monitoring", + "source_description": "Демон-контроля контейнеров на swarm-хостах — следит за здоровьем сервисов", + "subject": "container", "action": "down", "priority": "critical", + "payload": { "container": "nomin-web", "host": "melody" } +} +``` + ## Таблицы БД (постановка #34) `api_keys` (хэш, привязка к source) · `notification_types(source, subject, action)` · `routing_rules` (условия, действия, шаблон) · `events` (конверт + payload + статус) · `deliveries` (канал, цель, статус, попытки, ошибки) · `channel_targets`. \ No newline at end of file