from __future__ import annotations import asyncio import logging import time from typing import Dict, Any, List, Optional, Tuple, Set from app.core.crypto import get_ed25519_manager from app.core.network.node_client import NodeClient from app.core.models.stats.metrics_models import NodeStats logger = logging.getLogger(__name__) class GossipSecurityError(Exception): pass class GossipManager: """ Gossip протокол для обмена статистикой между нодами. - Подпись ed25519 всех исходящих сообщений - Валидация подписи входящих сообщений - Антиспам: проверка timestamp (±300с), дедуп по nonce, rate limiting """ def __init__(self, rate_limit_per_minute: int = 240) -> None: self._seen_nonces: Set[str] = set() self._nonce_ttl: Dict[str, float] = {} self._rate_counters: Dict[str, Tuple[int, float]] = {} # node_id -> (count, window_start) self._rate_limit = rate_limit_per_minute self._lock = asyncio.Lock() async def _prune(self) -> None: now = time.time() # очистка старых nonces stale = [n for n, ts in self._nonce_ttl.items() if now - ts > 600] for n in stale: self._nonce_ttl.pop(n, None) self._seen_nonces.discard(n) # очистка rate окон for node_id, (cnt, wnd) in list(self._rate_counters.items()): if now - wnd > 60: self._rate_counters.pop(node_id, None) async def _register_nonce(self, nonce: str) -> bool: await self._prune() if nonce in self._seen_nonces: return False self._seen_nonces.add(nonce) self._nonce_ttl[nonce] = time.time() return True async def _check_rate(self, node_id: str) -> bool: now = time.time() cnt, wnd = self._rate_counters.get(node_id, (0, now)) if now - wnd > 60: cnt, wnd = 0, now cnt += 1 self._rate_counters[node_id] = (cnt, wnd) return cnt <= self._rate_limit async def broadcast_stats(self, peers: List[str], stats: NodeStats) -> Dict[str, Dict[str, Any]]: """ Подписывает и отправляет статистику на список пиров. Возвращает словарь результатов по нодам. """ results: Dict[str, Dict[str, Any]] = {} crypto = get_ed25519_manager() signed_payload = stats.to_dict(include_signature=False) # canonical signing signature = crypto.sign_message(NodeStats.canonical_payload(signed_payload)) signed_payload["signature"] = signature async with NodeClient() as client: tasks: List[Tuple[str, asyncio.Task]] = [] for url in peers: # POST /api/node/stats/report — уже реализованный маршрут приемника task = asyncio.create_task(self._post_signed_report(client, url, signed_payload)) tasks.append((url, task)) for url, t in tasks: try: results[url] = await t except Exception as e: logger.exception("broadcast_stats error to %s: %s", url, e) results[url] = {"success": False, "error": str(e)} return results async def _post_signed_report(self, client: NodeClient, target_url: str, payload: Dict[str, Any]) -> Dict[str, Any]: """ Использует NodeClient для отправки подписанного запроса на /api/node/stats/report. """ from urllib.parse import urljoin # локальный импорт чтобы не тянуть наверх endpoint = urljoin(target_url, "/api/node/stats/report") # NodeClient формирует заголовки/подпись через _create_signed_request, # но мы уже подписали тело, поэтому вложим его как data.metrics. # Обернем в совместимый формат NodeStatsReport. body = { "action": "stats_report", "reporter_node_id": payload["node_id"], "reporter_public_key": payload["public_key"], "timestamp": payload["timestamp"], "metrics": payload, # целиком вложим NodeStats как metrics "signature": payload.get("signature"), } req = await client._create_signed_request("stats_report", body, target_url) # noqa: protected access by design try: async with client.session.post(endpoint, **req) as resp: data = await resp.json() return {"success": resp.status == 200, "status": resp.status, "data": data} except Exception as e: logger.warning("Failed to send stats to %s: %s", target_url, e) return {"success": False, "error": str(e)} async def receive_stats(self, incoming: Dict[str, Any]) -> NodeStats: """ Прием и валидация входящей статистики от другой ноды. Возвращает десериализованный NodeStats при успехе, иначе бросает GossipSecurityError. expected format: NodeStats dict (с signature) """ crypto = get_ed25519_manager() try: # базовые проверки for key in ("node_id", "public_key", "timestamp", "nonce", "system", "app"): if key not in incoming: raise GossipSecurityError(f"Missing field: {key}") # timestamp window now = int(time.time()) if abs(now - int(incoming["timestamp"])) > 300: raise GossipSecurityError("Timestamp out of window") # nonce dedup async with self._lock: if not await self._register_nonce(str(incoming["nonce"])): raise GossipSecurityError("Duplicate nonce") # rate limit per source async with self._lock: if not await self._check_rate(str(incoming["node_id"])): raise GossipSecurityError("Rate limit exceeded") # verify signature signature = incoming.get("signature") if not signature: raise GossipSecurityError("Missing signature") if not crypto.verify_signature(NodeStats.canonical_payload(incoming), signature, incoming["public_key"]): raise GossipSecurityError("Invalid signature") return NodeStats.from_dict(incoming) except GossipSecurityError: raise except Exception as e: logger.exception("receive_stats validation error: %s", e) raise GossipSecurityError(str(e)) async def sync_with_peers(self, peers: List[str], get_local_stats_cb) -> Dict[str, Dict[str, Any]]: """ Выполняет сбор локальной статистики через callback и рассылает ее всем пирам. get_local_stats_cb: async () -> NodeStats """ try: local_stats: NodeStats = await get_local_stats_cb() except Exception as e: logger.exception("sync_with_peers: failed to get local stats: %s", e) return {"error": {"success": False, "error": "local_stats_failure", "detail": str(e)}} return await self.broadcast_stats(peers, local_stats)