uploader-bot/app/core/stats/gossip_manager.py

173 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)