173 lines
7.5 KiB
Python
173 lines
7.5 KiB
Python
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) |