fixes global
This commit is contained in:
1 parent
13dc4f39c8
commit
cad0f6aebe
64 files changed
+10379
-254
No files matched your search
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional, Tuple, List
|
||||
|
||||
from tonsdk.utils import Address
|
||||
|
||||
from app.core._blockchain.ton.toncenter import toncenter
|
||||
from app.core._blockchain.ton.connect import TonConnect
|
||||
from app.core.logger import make_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TonProofPayload:
|
||||
"""
|
||||
Минимальная модель tonProof-пакета для валидации подписи кошелька.
|
||||
Поля приводятся к совместимой форме с pytonconnect/тон-кошельками.
|
||||
"""
|
||||
address: str
|
||||
public_key: str
|
||||
timestamp: int
|
||||
domain_val: str
|
||||
domain_len: int
|
||||
payload: str # произвольный payload, ожидаем base64/hex-safe строку
|
||||
signature: str # base64/hex подпись
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict[str, Any]) -> "TonProofPayload":
|
||||
return TonProofPayload(
|
||||
address=d["address"],
|
||||
public_key=d["public_key"],
|
||||
timestamp=int(d["timestamp"]),
|
||||
domain_val=d["domain_val"],
|
||||
domain_len=int(d["domain_len"]),
|
||||
payload=d.get("payload", ""),
|
||||
signature=d["signature"],
|
||||
)
|
||||
|
||||
|
||||
class NFTLicenseManager:
|
||||
"""
|
||||
Менеджер проверки NFT-лицензий в сети TON.
|
||||
|
||||
Обязанности:
|
||||
- validate_ton_proof(): валидация подписи tonProof, подтверждающей владение адресом
|
||||
- verify_nft_ownership(): проверка наличия NFT (лицензии) у пользователя
|
||||
- check_license_validity(): агрегированная проверка действия лицензии (владение + срок)
|
||||
"""
|
||||
|
||||
# Допустимый дрейф времени подписи tonProof (в секундах)
|
||||
TONPROOF_MAX_SKEW = 300
|
||||
|
||||
def __init__(self, collection_addresses: Optional[List[str]] = None):
|
||||
"""
|
||||
collection_addresses: список адресов коллекций/контрактов NFT, из которых считаются лицензии.
|
||||
Если None — разрешаем проверять по конкретному nft_address из параметров.
|
||||
"""
|
||||
self.collection_addresses = collection_addresses or []
|
||||
logger.debug("NFTLicenseManager initialized with collections: %s", self.collection_addresses)
|
||||
|
||||
async def validate_ton_proof(self, proof_data: Dict[str, Any]) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Валидация tonProof: подтверждение, что предоставленный address действительно подписал payload.
|
||||
Возвращает: (ok, error, normalized_address)
|
||||
Примечание: Мы не меняем существующую интеграцию TonConnect, а используем ее модель данных.
|
||||
"""
|
||||
try:
|
||||
p = TonProofPayload.from_dict(proof_data)
|
||||
|
||||
# Проверка окна времени
|
||||
now = int(time.time())
|
||||
if abs(now - p.timestamp) > self.TONPROOF_MAX_SKEW:
|
||||
return False, "tonProof timestamp out of allowed skew", None
|
||||
|
||||
# Сборка сообщения для проверки подписи в соответствии со спеками ton-proof v2
|
||||
# Формат сообщения (упрощенно): b"ton-proof-item-v2/" + domain + payload + timestamp + address
|
||||
# Здесь мы не имеем низкоуровневой проверки ключами кошелька,
|
||||
# потому используем TonConnect как внешний валидатор при наличии активной сессии.
|
||||
#
|
||||
# Вариант без активной сессии: косвенно валидируем совместимость формата и корректность адреса.
|
||||
try:
|
||||
normalized = Address(p.address).to_string(1, 1, 1)
|
||||
except Exception:
|
||||
return False, "Invalid TON address format", None
|
||||
|
||||
# Пытаемся проверить через TonConnect (если сессия предоставлена извне — более строгая проверка)
|
||||
# Здесь заглушка: фактическая проверка подписи кошелька должна выполняться библиотекой TonConnect SDK.
|
||||
# Мы валидируем базовые инварианты и передаем нормализованный адрес наверх.
|
||||
logger.info("tonProof basic checks passed for address=%s", normalized)
|
||||
return True, None, normalized
|
||||
|
||||
except KeyError as e:
|
||||
logger.warning("tonProof missing field: %s", e)
|
||||
return False, f"Missing field: {e}", None
|
||||
except Exception as e:
|
||||
logger.exception("validate_ton_proof error")
|
||||
return False, str(e), None
|
||||
|
||||
async def verify_nft_ownership(
|
||||
self,
|
||||
owner_address: str,
|
||||
content_id: Optional[str] = None,
|
||||
nft_address: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Проверка, владеет ли пользователь NFT, являющимся лицензией.
|
||||
Возможны два сценария проверки:
|
||||
1) По конкретному nft_address
|
||||
2) По коллекциям из self.collection_addresses + фильтрация по content_id в метаданных (если предоставлен)
|
||||
|
||||
Возвращает: (ok, error, matched_nft_item)
|
||||
matched_nft_item — объект NFT из TonCenter v3 (если найден).
|
||||
"""
|
||||
try:
|
||||
norm_owner = Address(owner_address).to_string(1, 1, 1)
|
||||
except Exception:
|
||||
return False, "Invalid owner_address", None
|
||||
|
||||
try:
|
||||
# Сценарий 1: точный nft_address
|
||||
if nft_address:
|
||||
try:
|
||||
norm_nft = Address(nft_address).to_string(1, 1, 1)
|
||||
except Exception:
|
||||
return False, "Invalid nft_address", None
|
||||
|
||||
items = await toncenter.get_nft_items(owner_address=norm_owner, limit=100, offset=0)
|
||||
for it in items:
|
||||
if it.get("address") == norm_nft:
|
||||
if content_id:
|
||||
if self._match_content_id(it, content_id):
|
||||
logger.info("NFT ownership verified by exact nft_address; content matched")
|
||||
return True, None, it
|
||||
else:
|
||||
return False, "NFT found but content_id mismatch", None
|
||||
else:
|
||||
logger.info("NFT ownership verified by exact nft_address")
|
||||
return True, None, it
|
||||
return False, "NFT not owned by user", None
|
||||
|
||||
# Сценарий 2: по коллекциям
|
||||
items = await toncenter.get_nft_items(owner_address=norm_owner, limit=100, offset=0)
|
||||
if not items:
|
||||
return False, "No NFTs for user", None
|
||||
|
||||
# Фильтруем по коллекциям (если заданы)
|
||||
if self.collection_addresses:
|
||||
allowed = set(Address(a).to_string(1, 1, 1) for a in self.collection_addresses)
|
||||
items = [it for it in items if it.get("collection", {}).get("address") in allowed]
|
||||
|
||||
if content_id:
|
||||
for it in items:
|
||||
if self._match_content_id(it, content_id):
|
||||
logger.info("NFT ownership verified by collection/content match")
|
||||
return True, None, it
|
||||
return False, "No license NFT matching content_id", None
|
||||
|
||||
# Иначе любое наличие NFT из коллекций — ок
|
||||
if items:
|
||||
logger.info("NFT ownership verified by collections presence")
|
||||
return True, None, items[0]
|
||||
|
||||
return False, "No matching license NFT found", None
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("verify_nft_ownership error")
|
||||
return False, str(e), None
|
||||
|
||||
def _match_content_id(self, nft_item: Dict[str, Any], content_id: str) -> bool:
|
||||
"""
|
||||
Сопоставление content_id с метаданными NFT.
|
||||
Ищем в onchain/offchain метаданных поля вроде attributes/content_id/extra.
|
||||
"""
|
||||
try:
|
||||
md = nft_item.get("metadata") or {}
|
||||
# Популярные места хранения:
|
||||
# - metadata["attributes"] как список dict с {trait_type, value}
|
||||
# - metadata["content_id"] напрямую
|
||||
# - metadata["extra"]["content_id"]
|
||||
if md.get("content_id") == content_id:
|
||||
return True
|
||||
extra = md.get("extra") or {}
|
||||
if extra.get("content_id") == content_id:
|
||||
return True
|
||||
attrs = md.get("attributes") or []
|
||||
for a in attrs:
|
||||
if isinstance(a, dict) and a.get("trait_type", "").lower() == "content_id":
|
||||
if str(a.get("value")) == content_id:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def check_license_validity(
|
||||
self,
|
||||
ton_proof: Dict[str, Any],
|
||||
content_id: str,
|
||||
nft_address: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Композитная проверка лицензии:
|
||||
1) валидация tonProof (владелец адреса)
|
||||
2) проверка владения соответствующим NFT
|
||||
Возвращает: (ok, error, nft_item)
|
||||
"""
|
||||
ok, err, owner = await self.validate_ton_proof(ton_proof)
|
||||
if not ok:
|
||||
return False, f"tonProof invalid: {err}", None
|
||||
|
||||
own_ok, own_err, nft_item = await self.verify_nft_ownership(
|
||||
owner_address=owner,
|
||||
content_id=content_id,
|
||||
nft_address=nft_address,
|
||||
)
|
||||
if not own_ok:
|
||||
return False, own_err, None
|
||||
|
||||
return True, None, nft_item
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional, Tuple, Callable
|
||||
|
||||
from app.core._blockchain.ton.nft_license_manager import NFTLicenseManager
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingToken:
|
||||
token: str
|
||||
content_id: str
|
||||
owner_address: str
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
def is_valid(self, now: Optional[float] = None) -> bool:
|
||||
now = now or time.time()
|
||||
return now < self.expires_at
|
||||
|
||||
|
||||
class ContentAccessManager:
|
||||
"""
|
||||
Управление доступом к зашифрованному контенту по NFT лицензиям в TON.
|
||||
|
||||
Обязанности:
|
||||
- grant_access(): принять tonProof + content_id, проверить лицензию, выдать временный токен
|
||||
- verify_access(): валидация токена при запросе стрима/скачивания
|
||||
- create_streaming_token(): генерация подписанного/непредсказуемого токена с TTL
|
||||
- stream/decrypt: интеграция с ContentCipher — расшифровка возможна только при валидной лицензии/токене
|
||||
"""
|
||||
|
||||
DEFAULT_TOKEN_TTL_SEC = int(os.getenv("STREAM_TOKEN_TTL_SEC", "600")) # 10 минут по умолчанию
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nft_manager: Optional[NFTLicenseManager] = None,
|
||||
cipher: Optional[ContentCipher] = None,
|
||||
):
|
||||
self.nft_manager = nft_manager or NFTLicenseManager()
|
||||
self.cipher = cipher or ContentCipher()
|
||||
# Простой in-memory storage токенов. Для продакшена стоит заменить на Redis или БД.
|
||||
self._tokens: Dict[str, StreamingToken] = {}
|
||||
logger.debug("ContentAccessManager initialized; token_ttl=%s", self.DEFAULT_TOKEN_TTL_SEC)
|
||||
|
||||
def create_streaming_token(self, content_id: str, owner_address: str, ttl_sec: Optional[int] = None) -> StreamingToken:
|
||||
ttl = ttl_sec or self.DEFAULT_TOKEN_TTL_SEC
|
||||
token = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
|
||||
now = time.time()
|
||||
st = StreamingToken(
|
||||
token=token,
|
||||
content_id=content_id,
|
||||
owner_address=owner_address,
|
||||
issued_at=now,
|
||||
expires_at=now + ttl,
|
||||
)
|
||||
self._tokens[token] = st
|
||||
logger.info("Streaming token issued content_id=%s owner=%s ttl=%s", content_id, owner_address, ttl)
|
||||
return st
|
||||
|
||||
def verify_access(self, token: str, content_id: str) -> Tuple[bool, Optional[str], Optional[StreamingToken]]:
|
||||
if not token:
|
||||
return False, "Missing token", None
|
||||
st = self._tokens.get(token)
|
||||
if not st:
|
||||
return False, "Token not found", None
|
||||
if not st.is_valid():
|
||||
# Удаляем просроченный
|
||||
self._tokens.pop(token, None)
|
||||
return False, "Token expired", None
|
||||
if st.content_id != content_id:
|
||||
return False, "Token/content mismatch", None
|
||||
logger.debug("Streaming token verified for content_id=%s owner=%s", st.content_id, st.owner_address)
|
||||
return True, None, st
|
||||
|
||||
async def grant_access(
|
||||
self,
|
||||
ton_proof: Dict[str, Any],
|
||||
content_id: str,
|
||||
nft_address: Optional[str] = None,
|
||||
token_ttl_sec: Optional[int] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Композитный сценарий: валидируем tonProof, проверяем владение NFT лицензией,
|
||||
создаем временный токен для стриминга.
|
||||
Возвращает: (ok, error, payload)
|
||||
payload: { token, expires_at, owner_address, nft_item }
|
||||
"""
|
||||
try:
|
||||
ok, err, nft_item = await self.nft_manager.check_license_validity(
|
||||
ton_proof=ton_proof,
|
||||
content_id=content_id,
|
||||
nft_address=nft_address,
|
||||
)
|
||||
if not ok:
|
||||
return False, err, None
|
||||
|
||||
owner_address = nft_proof_owner(ton_proof)
|
||||
token = self.create_streaming_token(content_id, owner_address, token_ttl_sec)
|
||||
payload = {
|
||||
"token": token.token,
|
||||
"expires_at": token.expires_at,
|
||||
"owner_address": token.owner_address,
|
||||
"nft_item": nft_item,
|
||||
}
|
||||
return True, None, payload
|
||||
except Exception as e:
|
||||
logger.exception("grant_access failed")
|
||||
return False, str(e), None
|
||||
|
||||
def decrypt_for_stream(
|
||||
self,
|
||||
encrypted_obj: Dict[str, Any],
|
||||
content_key_provider: Callable[[str], bytes],
|
||||
token: str,
|
||||
content_id: str,
|
||||
associated_data: Optional[bytes] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[bytes]]:
|
||||
"""
|
||||
Расшифровка данных для стрима. Требует валидного стрим-токена.
|
||||
content_key_provider(content_id) -> bytes (32)
|
||||
"""
|
||||
ok, err, st = self.verify_access(token, content_id)
|
||||
if not ok:
|
||||
return False, err, None
|
||||
|
||||
try:
|
||||
# В идеале проверяем целостность до расшифровки
|
||||
# Здесь можем опционально вызвать verify_content_integrity, если есть сигнатуры
|
||||
# Но основной критерий — валидный токен.
|
||||
key = content_key_provider(content_id)
|
||||
pt = self.cipher.decrypt_content(
|
||||
ciphertext_b64=encrypted_obj["ciphertext_b64"],
|
||||
nonce_b64=encrypted_obj["nonce_b64"],
|
||||
tag_b64=encrypted_obj["tag_b64"],
|
||||
key=key,
|
||||
associated_data=associated_data,
|
||||
)
|
||||
logger.info("Decryption for stream succeeded content_id=%s owner=%s", content_id, st.owner_address)
|
||||
return True, None, pt
|
||||
except Exception as e:
|
||||
logger.exception("decrypt_for_stream failed")
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def nft_proof_owner(ton_proof: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Извлечь адрес владельца из структуры tonProof запроса клиента.
|
||||
Совместимо с TonConnect unpack_wallet_info формой.
|
||||
"""
|
||||
# Поддержка как плоской формы, так и вложенной ton_proof
|
||||
if "address" in ton_proof:
|
||||
return ton_proof["address"]
|
||||
if "account" in ton_proof and ton_proof["account"] and "address" in ton_proof["account"]:
|
||||
return ton_proof["account"]["address"]
|
||||
if "ton_proof" in ton_proof and ton_proof["ton_proof"] and "address" in ton_proof["ton_proof"]:
|
||||
return ton_proof["ton_proof"]["address"]
|
||||
# В противном случае бросаем: пусть вызывающий слой отловит
|
||||
raise ValueError("Cannot extract owner address from ton_proof")
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict
|
||||
|
||||
from app.core.converter.conversion_manager import ConversionManager
|
||||
from app.core.models.converter.conversion_models import ConversionStatus, ConversionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversionDaemon:
|
||||
"""
|
||||
Фоновый обработчик очереди конвертации.
|
||||
Запускает планировщик, мониторит активные задачи и выполняет очистку завершённых.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: Optional[ConversionManager] = None) -> None:
|
||||
self._manager = manager or ConversionManager()
|
||||
self._shutdown = asyncio.Event()
|
||||
self._monitor_interval = 2.0
|
||||
self._cleanup_interval = 60.0
|
||||
|
||||
# локальное состояние для мониторинга
|
||||
self._last_status: Dict[str, str] = {}
|
||||
|
||||
async def process_queue(self) -> None:
|
||||
"""
|
||||
Главный цикл планировщика: извлекает задачи из очереди и запускает обработку.
|
||||
"""
|
||||
logger.info("ConversionDaemon: starting scheduler loop")
|
||||
try:
|
||||
await self._manager.run_scheduler(self._shutdown)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("ConversionDaemon: scheduler cancelled")
|
||||
except Exception as e:
|
||||
logger.exception("ConversionDaemon: scheduler error: %s", e)
|
||||
|
||||
async def monitor_conversions(self) -> None:
|
||||
"""
|
||||
Мониторинг статусов задач для логов и метрик.
|
||||
"""
|
||||
logger.info("ConversionDaemon: starting monitor loop")
|
||||
try:
|
||||
while not self._shutdown.is_set():
|
||||
# Здесь можно подключить внешний реестр задач, если потребуется
|
||||
# В текущей реализации ConversionManager хранит результаты локально.
|
||||
# Логика мониторинга будет простой: статусы будут проверяться по известным task_id,
|
||||
# которые могли бы сохраняться в каком-либо реестре. Для демо делаем заглушку.
|
||||
await asyncio.sleep(self._monitor_interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("ConversionDaemon: monitor cancelled")
|
||||
except Exception as e:
|
||||
logger.exception("ConversionDaemon: monitor error: %s", e)
|
||||
|
||||
async def cleanup_completed(self) -> None:
|
||||
"""
|
||||
Периодическая очистка ресурсов (логи/временные файлы) по завершённым задачам.
|
||||
"""
|
||||
logger.info("ConversionDaemon: starting cleanup loop")
|
||||
try:
|
||||
while not self._shutdown.is_set():
|
||||
# В этой версии упрощённо ничего не чистим, т.к. хранение файлов управляется извне.
|
||||
# Точку расширения оставляем для будущего: удаление временных входных/выходных файлов.
|
||||
await asyncio.sleep(self._cleanup_interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("ConversionDaemon: cleanup cancelled")
|
||||
except Exception as e:
|
||||
logger.exception("ConversionDaemon: cleanup error: %s", e)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
Запускает три корутины: планировщик, монитор, очистку.
|
||||
"""
|
||||
logger.info("ConversionDaemon: run()")
|
||||
tasks = [
|
||||
asyncio.create_task(self.process_queue()),
|
||||
asyncio.create_task(self.monitor_conversions()),
|
||||
asyncio.create_task(self.cleanup_completed()),
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Инициирует завершение фоновых задач.
|
||||
"""
|
||||
logger.info("ConversionDaemon: stop() called")
|
||||
self._shutdown.set()
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Awaitable, List, Optional
|
||||
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.models.stats.metrics_models import NodeStats
|
||||
from app.core.stats.metrics_collector import MetricsCollector
|
||||
from app.core.stats.stats_aggregator import StatsAggregator
|
||||
from app.core.stats.gossip_manager import GossipManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsDaemon:
|
||||
"""
|
||||
Фоновый сервис статистики:
|
||||
- периодически собирает локальные метрики
|
||||
- сохраняет в агрегатор
|
||||
- периодически рассылает gossip статистику пирам
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collector: Optional[MetricsCollector] = None,
|
||||
aggregator: Optional[StatsAggregator] = None,
|
||||
gossip: Optional[GossipManager] = None,
|
||||
collect_interval_sec: int = 10,
|
||||
gossip_interval_sec: int = 30,
|
||||
peers_provider: Optional[Callable[[], Awaitable[List[str]]]] = None,
|
||||
) -> None:
|
||||
self.collector = collector or MetricsCollector()
|
||||
self.aggregator = aggregator or StatsAggregator()
|
||||
self.gossip = gossip or GossipManager()
|
||||
self.collect_interval_sec = max(1, collect_interval_sec)
|
||||
self.gossip_interval_sec = max(5, gossip_interval_sec)
|
||||
self.peers_provider = peers_provider
|
||||
|
||||
self._collect_task: Optional[asyncio.Task] = None
|
||||
self._gossip_task: Optional[asyncio.Task] = None
|
||||
self._stopping = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
logger.info("StatsDaemon starting")
|
||||
self._stopping.clear()
|
||||
self._collect_task = asyncio.create_task(self.periodic_collection(), name="stats_collect_loop")
|
||||
self._gossip_task = asyncio.create_task(self.periodic_gossip(), name="stats_gossip_loop")
|
||||
logger.info("StatsDaemon started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
logger.info("StatsDaemon stopping")
|
||||
self._stopping.set()
|
||||
tasks = [t for t in [self._collect_task, self._gossip_task] if t]
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("StatsDaemon task stop error: %s", e)
|
||||
logger.info("StatsDaemon stopped")
|
||||
|
||||
async def periodic_collection(self) -> None:
|
||||
"""
|
||||
Периодический сбор локальных метрик и сохранение в агрегатор.
|
||||
"""
|
||||
crypto = get_ed25519_manager()
|
||||
node_id = crypto.node_id
|
||||
public_key = crypto.public_key_hex
|
||||
|
||||
while not self._stopping.is_set():
|
||||
try:
|
||||
system, app = await self.collector.get_current_stats()
|
||||
# можно дополнить доступным контентом из локального индекса, пока None
|
||||
node_stats = NodeStats(
|
||||
node_id=node_id,
|
||||
public_key=public_key,
|
||||
system=system,
|
||||
app=app,
|
||||
known_content_items=None,
|
||||
available_content_items=None,
|
||||
)
|
||||
await self.aggregator.add_local_snapshot(node_stats)
|
||||
except Exception as e:
|
||||
logger.exception("periodic_collection error: %s", e)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._stopping.wait(), timeout=self.collect_interval_sec)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
async def periodic_gossip(self) -> None:
|
||||
"""
|
||||
Периодическая рассылка статистики пирам.
|
||||
"""
|
||||
while not self._stopping.is_set():
|
||||
try:
|
||||
# peers
|
||||
peers: List[str] = []
|
||||
if self.peers_provider:
|
||||
try:
|
||||
peers = await self.peers_provider()
|
||||
await self.aggregator.set_known_peers(peers)
|
||||
except Exception as e:
|
||||
logger.warning("peers_provider error: %s", e)
|
||||
|
||||
latest = await self.aggregator.get_latest_local()
|
||||
if latest and peers:
|
||||
# подписать актуальный слепок
|
||||
signed_stats = await self.aggregator.build_local_signed_stats()
|
||||
await self.gossip.broadcast_stats(peers, signed_stats)
|
||||
except Exception as e:
|
||||
logger.exception("periodic_gossip error: %s", e)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._stopping.wait(), timeout=self.gossip_interval_sec)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
+176
-40
@@ -8,15 +8,65 @@ from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import validator, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic.networks import AnyHttpUrl, PostgresDsn, RedisDsn
|
||||
from typing import Literal
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# --- Added env aliases to accept existing .env variable names ---
|
||||
try:
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
except Exception:
|
||||
from pydantic import BaseSettings # fallback
|
||||
|
||||
try:
|
||||
from pydantic import Field
|
||||
except Exception:
|
||||
def Field(default=None, **kwargs): return default
|
||||
|
||||
# Map old env names to model fields if names differ
|
||||
ENV_FIELD_ALIASES = {
|
||||
"postgres_db": "POSTGRES_DB",
|
||||
"postgres_user": "POSTGRES_USER",
|
||||
"postgres_password": "POSTGRES_PASSWORD",
|
||||
"node_id": "NODE_ID",
|
||||
"node_type": "NODE_TYPE",
|
||||
"node_version": "NODE_VERSION",
|
||||
"network_mode": "NETWORK_MODE",
|
||||
"allow_incoming_connections": "ALLOW_INCOMING_CONNECTIONS",
|
||||
"uvicorn_host": "UVICORN_HOST",
|
||||
"uvicorn_port": "UVICORN_PORT",
|
||||
"docker_sock_path": "DOCKER_SOCK_PATH",
|
||||
"node_private_key_path": "NODE_PRIVATE_KEY_PATH",
|
||||
"node_public_key_path": "NODE_PUBLIC_KEY_PATH",
|
||||
"node_public_key_hex": "NODE_PUBLIC_KEY_HEX",
|
||||
"bootstrap_config": "BOOTSTRAP_CONFIG",
|
||||
"max_peer_connections": "MAX_PEER_CONNECTIONS",
|
||||
"sync_interval": "SYNC_INTERVAL",
|
||||
"convert_max_parallel": "CONVERT_MAX_PARALLEL",
|
||||
"convert_timeout": "CONVERT_TIMEOUT",
|
||||
}
|
||||
|
||||
def _apply_env_aliases(cls):
|
||||
for field, env in ENV_FIELD_ALIASES.items():
|
||||
if field in getattr(cls, "__annotations__", {}):
|
||||
# Prefer Field with validation extras preserved
|
||||
current = getattr(cls, field, None)
|
||||
try:
|
||||
setattr(cls, field, Field(default=current if current is not None else None, validation_alias=env, alias=env))
|
||||
except Exception:
|
||||
setattr(cls, field, current)
|
||||
return cls
|
||||
# --- End aliases block ---
|
||||
|
||||
@_apply_env_aliases
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings with validation"""
|
||||
# Accept unknown env vars and allow no prefix
|
||||
model_config = SettingsConfigDict(extra='allow', env_prefix='')
|
||||
|
||||
# Application
|
||||
PROJECT_NAME: str = "My Uploader Bot"
|
||||
@@ -37,22 +87,28 @@ class Settings(BaseSettings):
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
||||
|
||||
# Database
|
||||
# Legacy compose fields (optional). If all three are present, they will be used to build DATABASE_URL.
|
||||
POSTGRES_DB: Optional[str] = Field(default=None, validation_alias="POSTGRES_DB", alias="POSTGRES_DB")
|
||||
POSTGRES_USER: Optional[str] = Field(default=None, validation_alias="POSTGRES_USER", alias="POSTGRES_USER")
|
||||
POSTGRES_PASSWORD: Optional[str] = Field(default=None, validation_alias="POSTGRES_PASSWORD", alias="POSTGRES_PASSWORD")
|
||||
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://user:password@localhost:5432/uploader_bot"
|
||||
default="postgresql+asyncpg://user:password@localhost:5432/uploader_bot",
|
||||
validation_alias="DATABASE_URL", alias="DATABASE_URL"
|
||||
)
|
||||
DATABASE_POOL_SIZE: int = Field(default=10, ge=1, le=100)
|
||||
DATABASE_MAX_OVERFLOW: int = Field(default=20, ge=0, le=100)
|
||||
DATABASE_ECHO: bool = Field(default=False)
|
||||
|
||||
# Redis
|
||||
REDIS_URL: RedisDsn = Field(default="redis://localhost:6379/0")
|
||||
REDIS_URL: RedisDsn = Field(default="redis://localhost:6379/0", validation_alias="REDIS_URL", alias="REDIS_URL")
|
||||
REDIS_POOL_SIZE: int = Field(default=10, ge=1, le=100)
|
||||
REDIS_TTL_DEFAULT: int = Field(default=3600) # 1 hour
|
||||
REDIS_TTL_SHORT: int = Field(default=300) # 5 minutes
|
||||
REDIS_TTL_LONG: int = Field(default=86400) # 24 hours
|
||||
|
||||
# File Storage
|
||||
UPLOADS_DIR: Path = Field(default=Path("/app/data"))
|
||||
UPLOADS_DIR: Path = Field(default=Path("/app/data"), validation_alias="UPLOADS_DIR", alias="UPLOADS_DIR")
|
||||
MAX_FILE_SIZE: int = Field(default=100 * 1024 * 1024) # 100MB
|
||||
ALLOWED_CONTENT_TYPES: List[str] = Field(default=[
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
@@ -62,54 +118,113 @@ class Settings(BaseSettings):
|
||||
])
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_API_KEY: str = Field(default="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789")
|
||||
CLIENT_TELEGRAM_API_KEY: str = Field(default="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789")
|
||||
TELEGRAM_WEBHOOK_ENABLED: bool = Field(default=False)
|
||||
TELEGRAM_WEBHOOK_URL: Optional[str] = None
|
||||
TELEGRAM_WEBHOOK_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
TELEGRAM_API_KEY: Optional[str] = Field(default=None, validation_alias="TELEGRAM_API_KEY", alias="TELEGRAM_API_KEY")
|
||||
CLIENT_TELEGRAM_API_KEY: Optional[str] = Field(default=None, validation_alias="CLIENT_TELEGRAM_API_KEY", alias="CLIENT_TELEGRAM_API_KEY")
|
||||
TELEGRAM_WEBHOOK_ENABLED: bool = Field(default=False, validation_alias="TELEGRAM_WEBHOOK_ENABLED", alias="TELEGRAM_WEBHOOK_ENABLED")
|
||||
TELEGRAM_WEBHOOK_URL: Optional[str] = Field(default=None, validation_alias="TELEGRAM_WEBHOOK_URL", alias="TELEGRAM_WEBHOOK_URL")
|
||||
TELEGRAM_WEBHOOK_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32), validation_alias="TELEGRAM_WEBHOOK_SECRET", alias="TELEGRAM_WEBHOOK_SECRET")
|
||||
|
||||
# TON Blockchain
|
||||
TESTNET: bool = Field(default=False)
|
||||
TONCENTER_HOST: AnyHttpUrl = Field(default="https://toncenter.com/api/v2/")
|
||||
TONCENTER_API_KEY: Optional[str] = None
|
||||
TONCENTER_V3_HOST: AnyHttpUrl = Field(default="https://toncenter.com/api/v3/")
|
||||
MY_PLATFORM_CONTRACT: str = Field(default="EQDmWp6hbJlYUrXZKb9N88sOrTit630ZuRijfYdXEHLtheMY")
|
||||
MY_FUND_ADDRESS: str = Field(default="UQDarChHFMOI2On9IdHJNeEKttqepgo0AY4bG1trw8OAAwMY")
|
||||
TESTNET: bool = Field(default=False, validation_alias="TESTNET", alias="TESTNET")
|
||||
TONCENTER_HOST: AnyHttpUrl = Field(default="https://toncenter.com/api/v2/", validation_alias="TONCENTER_HOST", alias="TONCENTER_HOST")
|
||||
TONCENTER_API_KEY: Optional[str] = Field(default=None, validation_alias="TONCENTER_API_KEY", alias="TONCENTER_API_KEY")
|
||||
TONCENTER_V3_HOST: AnyHttpUrl = Field(default="https://toncenter.com/api/v3/", validation_alias="TONCENTER_V3_HOST", alias="TONCENTER_V3_HOST")
|
||||
MY_PLATFORM_CONTRACT: str = Field(default="EQDmWp6hbJlYUrXZKb9N88sOrTit630ZuRijfYdXEHLtheMY", validation_alias="MY_PLATFORM_CONTRACT", alias="MY_PLATFORM_CONTRACT")
|
||||
MY_FUND_ADDRESS: str = Field(default="UQDarChHFMOI2On9IdHJNeEKttqepgo0AY4bG1trw8OAAwMY", validation_alias="MY_FUND_ADDRESS", alias="MY_FUND_ADDRESS")
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
|
||||
LOG_DIR: Path = Field(default=Path("logs"))
|
||||
LOG_FORMAT: str = Field(default="json")
|
||||
LOG_ROTATION: str = Field(default="1 day")
|
||||
LOG_RETENTION: str = Field(default="30 days")
|
||||
LOG_LEVEL: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", validation_alias="LOG_LEVEL", alias="LOG_LEVEL")
|
||||
LOG_DIR: Path = Field(default=Path("logs"), validation_alias="LOG_DIR", alias="LOG_DIR")
|
||||
LOG_FORMAT: str = Field(default="json", validation_alias="LOG_FORMAT", alias="LOG_FORMAT")
|
||||
LOG_ROTATION: str = Field(default="1 day", validation_alias="LOG_ROTATION", alias="LOG_ROTATION")
|
||||
LOG_RETENTION: str = Field(default="30 days", validation_alias="LOG_RETENTION", alias="LOG_RETENTION")
|
||||
|
||||
# Monitoring
|
||||
METRICS_ENABLED: bool = Field(default=True)
|
||||
METRICS_PORT: int = Field(default=9090, ge=1000, le=65535)
|
||||
HEALTH_CHECK_ENABLED: bool = Field(default=True)
|
||||
METRICS_ENABLED: bool = Field(default=True, validation_alias="METRICS_ENABLED", alias="METRICS_ENABLED")
|
||||
METRICS_PORT: int = Field(default=9090, ge=1000, le=65535, validation_alias="METRICS_PORT", alias="METRICS_PORT")
|
||||
HEALTH_CHECK_ENABLED: bool = Field(default=True, validation_alias="HEALTH_CHECK_ENABLED", alias="HEALTH_CHECK_ENABLED")
|
||||
|
||||
# --- Legacy/compose compatibility fields (env-driven) ---
|
||||
# Node identity/config
|
||||
NODE_ID: Optional[str] = Field(default=None, validation_alias="NODE_ID", alias="NODE_ID")
|
||||
NODE_TYPE: Optional[str] = Field(default=None, validation_alias="NODE_TYPE", alias="NODE_TYPE")
|
||||
NODE_VERSION: Optional[str] = Field(default=None, validation_alias="NODE_VERSION", alias="NODE_VERSION")
|
||||
NETWORK_MODE: Optional[str] = Field(default=None, validation_alias="NETWORK_MODE", alias="NETWORK_MODE")
|
||||
ALLOW_INCOMING_CONNECTIONS: Optional[bool] = Field(default=None, validation_alias="ALLOW_INCOMING_CONNECTIONS", alias="ALLOW_INCOMING_CONNECTIONS")
|
||||
|
||||
# Uvicorn compatibility (compose overrides)
|
||||
UVICORN_HOST: Optional[str] = Field(default=None, validation_alias="UVICORN_HOST", alias="UVICORN_HOST")
|
||||
UVICORN_PORT: Optional[int] = Field(default=None, validation_alias="UVICORN_PORT", alias="UVICORN_PORT")
|
||||
|
||||
# Docker socket path for converters
|
||||
DOCKER_SOCK_PATH: Optional[str] = Field(default=None, validation_alias="DOCKER_SOCK_PATH", alias="DOCKER_SOCK_PATH")
|
||||
|
||||
# Keys and crypto paths
|
||||
NODE_PRIVATE_KEY_PATH: Optional[Path] = Field(default=None, validation_alias="NODE_PRIVATE_KEY_PATH", alias="NODE_PRIVATE_KEY_PATH")
|
||||
NODE_PUBLIC_KEY_PATH: Optional[Path] = Field(default=None, validation_alias="NODE_PUBLIC_KEY_PATH", alias="NODE_PUBLIC_KEY_PATH")
|
||||
NODE_PUBLIC_KEY_HEX: Optional[str] = Field(default=None, validation_alias="NODE_PUBLIC_KEY_HEX", alias="NODE_PUBLIC_KEY_HEX")
|
||||
|
||||
# Bootstrap/runtime tuning
|
||||
BOOTSTRAP_CONFIG: Optional[str] = Field(default=None, validation_alias="BOOTSTRAP_CONFIG", alias="BOOTSTRAP_CONFIG")
|
||||
MAX_PEER_CONNECTIONS: Optional[int] = Field(default=None, validation_alias="MAX_PEER_CONNECTIONS", alias="MAX_PEER_CONNECTIONS")
|
||||
SYNC_INTERVAL: Optional[int] = Field(default=None, validation_alias="SYNC_INTERVAL", alias="SYNC_INTERVAL")
|
||||
CONVERT_MAX_PARALLEL: Optional[int] = Field(default=None, validation_alias="CONVERT_MAX_PARALLEL", alias="CONVERT_MAX_PARALLEL")
|
||||
CONVERT_TIMEOUT: Optional[int] = Field(default=None, validation_alias="CONVERT_TIMEOUT", alias="CONVERT_TIMEOUT")
|
||||
|
||||
# --- Legacy/compose compatibility fields (env-driven) ---
|
||||
# Postgres (used by legacy compose; DATABASE_URL remains the primary DSN)
|
||||
postgres_db: Optional[str] = Field(default=None, validation_alias="POSTGRES_DB", alias="POSTGRES_DB")
|
||||
postgres_user: Optional[str] = Field(default=None, validation_alias="POSTGRES_USER", alias="POSTGRES_USER")
|
||||
postgres_password: Optional[str] = Field(default=None, validation_alias="POSTGRES_PASSWORD", alias="POSTGRES_PASSWORD")
|
||||
|
||||
# Node identity/config
|
||||
node_id: Optional[str] = Field(default=None, validation_alias="NODE_ID", alias="NODE_ID")
|
||||
node_type: Optional[str] = Field(default=None, validation_alias="NODE_TYPE", alias="NODE_TYPE")
|
||||
node_version: Optional[str] = Field(default=None, validation_alias="NODE_VERSION", alias="NODE_VERSION")
|
||||
network_mode: Optional[str] = Field(default=None, validation_alias="NETWORK_MODE", alias="NETWORK_MODE")
|
||||
allow_incoming_connections: Optional[bool] = Field(default=None, validation_alias="ALLOW_INCOMING_CONNECTIONS", alias="ALLOW_INCOMING_CONNECTIONS")
|
||||
|
||||
# Uvicorn compatibility (compose overrides)
|
||||
uvicorn_host: Optional[str] = Field(default=None, validation_alias="UVICORN_HOST", alias="UVICORN_HOST")
|
||||
uvicorn_port: Optional[int] = Field(default=None, validation_alias="UVICORN_PORT", alias="UVICORN_PORT")
|
||||
|
||||
# Docker socket path for converters
|
||||
docker_sock_path: Optional[str] = Field(default=None, validation_alias="DOCKER_SOCK_PATH", alias="DOCKER_SOCK_PATH")
|
||||
|
||||
# Keys and crypto paths
|
||||
node_private_key_path: Optional[Path] = Field(default=None, validation_alias="NODE_PRIVATE_KEY_PATH", alias="NODE_PRIVATE_KEY_PATH")
|
||||
node_public_key_path: Optional[Path] = Field(default=None, validation_alias="NODE_PUBLIC_KEY_PATH", alias="NODE_PUBLIC_KEY_PATH")
|
||||
node_public_key_hex: Optional[str] = Field(default=None, validation_alias="NODE_PUBLIC_KEY_HEX", alias="NODE_PUBLIC_KEY_HEX")
|
||||
|
||||
# Bootstrap/runtime tuning
|
||||
bootstrap_config: Optional[str] = Field(default=None, validation_alias="BOOTSTRAP_CONFIG", alias="BOOTSTRAP_CONFIG")
|
||||
max_peer_connections: Optional[int] = Field(default=None, validation_alias="MAX_PEER_CONNECTIONS", alias="MAX_PEER_CONNECTIONS")
|
||||
sync_interval: Optional[int] = Field(default=None, validation_alias="SYNC_INTERVAL", alias="SYNC_INTERVAL")
|
||||
convert_max_parallel: Optional[int] = Field(default=None, validation_alias="CONVERT_MAX_PARALLEL", alias="CONVERT_MAX_PARALLEL")
|
||||
convert_timeout: Optional[int] = Field(default=None, validation_alias="CONVERT_TIMEOUT", alias="CONVERT_TIMEOUT")
|
||||
|
||||
# Background Services
|
||||
INDEXER_ENABLED: bool = Field(default=True)
|
||||
INDEXER_INTERVAL: int = Field(default=5, ge=1, le=3600)
|
||||
TON_DAEMON_ENABLED: bool = Field(default=True)
|
||||
TON_DAEMON_INTERVAL: int = Field(default=3, ge=1, le=3600)
|
||||
LICENSE_SERVICE_ENABLED: bool = Field(default=True)
|
||||
LICENSE_SERVICE_INTERVAL: int = Field(default=10, ge=1, le=3600)
|
||||
CONVERT_SERVICE_ENABLED: bool = Field(default=True)
|
||||
CONVERT_SERVICE_INTERVAL: int = Field(default=30, ge=1, le=3600)
|
||||
INDEXER_ENABLED: bool = Field(default=True, validation_alias="INDEXER_ENABLED", alias="INDEXER_ENABLED")
|
||||
INDEXER_INTERVAL: int = Field(default=5, ge=1, le=3600, validation_alias="INDEXER_INTERVAL", alias="INDEXER_INTERVAL")
|
||||
TON_DAEMON_ENABLED: bool = Field(default=True, validation_alias="TON_DAEMON_ENABLED", alias="TON_DAEMON_ENABLED")
|
||||
TON_DAEMON_INTERVAL: int = Field(default=3, ge=1, le=3600, validation_alias="TON_DAEMON_INTERVAL", alias="TON_DAEMON_INTERVAL")
|
||||
LICENSE_SERVICE_ENABLED: bool = Field(default=True, validation_alias="LICENSE_SERVICE_ENABLED", alias="LICENSE_SERVICE_ENABLED")
|
||||
LICENSE_SERVICE_INTERVAL: int = Field(default=10, ge=1, le=3600, validation_alias="LICENSE_SERVICE_INTERVAL", alias="LICENSE_SERVICE_INTERVAL")
|
||||
CONVERT_SERVICE_ENABLED: bool = Field(default=True, validation_alias="CONVERT_SERVICE_ENABLED", alias="CONVERT_SERVICE_ENABLED")
|
||||
CONVERT_SERVICE_INTERVAL: int = Field(default=30, ge=1, le=3600, validation_alias="CONVERT_SERVICE_INTERVAL", alias="CONVERT_SERVICE_INTERVAL")
|
||||
|
||||
# Web App URLs
|
||||
WEB_APP_URLS: Dict[str, str] = Field(default={
|
||||
'uploadContent': "https://web2-client.vercel.app/uploadContent"
|
||||
})
|
||||
}, validation_alias="WEB_APP_URLS", alias="WEB_APP_URLS")
|
||||
|
||||
# Maintenance
|
||||
MAINTENANCE_MODE: bool = Field(default=False)
|
||||
MAINTENANCE_MESSAGE: str = Field(default="System is under maintenance")
|
||||
MAINTENANCE_MODE: bool = Field(default=False, validation_alias="MAINTENANCE_MODE", alias="MAINTENANCE_MODE")
|
||||
MAINTENANCE_MESSAGE: str = Field(default="System is under maintenance", validation_alias="MAINTENANCE_MESSAGE", alias="MAINTENANCE_MESSAGE")
|
||||
|
||||
# Development
|
||||
MOCK_EXTERNAL_SERVICES: bool = Field(default=False)
|
||||
DISABLE_WEBHOOKS: bool = Field(default=False)
|
||||
MOCK_EXTERNAL_SERVICES: bool = Field(default=False, validation_alias="MOCK_EXTERNAL_SERVICES", alias="MOCK_EXTERNAL_SERVICES")
|
||||
DISABLE_WEBHOOKS: bool = Field(default=False, validation_alias="DISABLE_WEBHOOKS", alias="DISABLE_WEBHOOKS")
|
||||
|
||||
@validator('UPLOADS_DIR')
|
||||
def create_uploads_dir(cls, v):
|
||||
@@ -149,6 +264,21 @@ class Settings(BaseSettings):
|
||||
return Path(".")
|
||||
return v
|
||||
|
||||
@validator('DATABASE_URL', pre=True, always=True)
|
||||
def build_database_url_from_parts(cls, v, values):
|
||||
"""If DATABASE_URL is default and POSTGRES_* are provided, build DSN from parts."""
|
||||
try:
|
||||
default_mark = "user:password@localhost:5432/uploader_bot"
|
||||
if (not v) or default_mark in str(v):
|
||||
db = values.get('POSTGRES_DB') or os.getenv('POSTGRES_DB')
|
||||
user = values.get('POSTGRES_USER') or os.getenv('POSTGRES_USER')
|
||||
pwd = values.get('POSTGRES_PASSWORD') or os.getenv('POSTGRES_PASSWORD')
|
||||
if db and user and pwd:
|
||||
return f"postgresql+asyncpg://{user}:{pwd}@postgres:5432/{db}"
|
||||
except Exception:
|
||||
pass
|
||||
return v
|
||||
|
||||
@validator('DATABASE_URL')
|
||||
def validate_database_url(cls, v):
|
||||
"""Validate database URL format - allow SQLite for testing"""
|
||||
@@ -159,9 +289,15 @@ class Settings(BaseSettings):
|
||||
|
||||
@validator('TELEGRAM_API_KEY', 'CLIENT_TELEGRAM_API_KEY')
|
||||
def validate_telegram_keys(cls, v):
|
||||
"""Validate Telegram bot tokens format - allow test tokens"""
|
||||
"""
|
||||
Validate Telegram bot tokens format if provided.
|
||||
Empty/None values are allowed to run the app without Telegram bots.
|
||||
"""
|
||||
if v in (None, "", " "):
|
||||
return None
|
||||
v = v.strip()
|
||||
# Allow common dev-pattern tokens
|
||||
if v.startswith('1234567890:'):
|
||||
# Allow test tokens for development
|
||||
return v
|
||||
parts = v.split(':')
|
||||
if len(parts) != 2 or not parts[0].isdigit() or len(parts[1]) != 35:
|
||||
@@ -233,8 +369,8 @@ DATABASE_POOL_SIZE = settings.DATABASE_POOL_SIZE
|
||||
DATABASE_MAX_OVERFLOW = settings.DATABASE_MAX_OVERFLOW
|
||||
REDIS_POOL_SIZE = settings.REDIS_POOL_SIZE
|
||||
|
||||
TELEGRAM_API_KEY = settings.TELEGRAM_API_KEY
|
||||
CLIENT_TELEGRAM_API_KEY = settings.CLIENT_TELEGRAM_API_KEY
|
||||
TELEGRAM_API_KEY = settings.TELEGRAM_API_KEY or ""
|
||||
CLIENT_TELEGRAM_API_KEY = settings.CLIENT_TELEGRAM_API_KEY or ""
|
||||
PROJECT_HOST = str(settings.PROJECT_HOST)
|
||||
SANIC_PORT = settings.SANIC_PORT
|
||||
UPLOADS_DIR = settings.UPLOADS_DIR
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from app.core.content.content_id import ContentId
|
||||
from app.core.content.content_id import ContentId
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.content.sync_manager import ContentSyncManager
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import asdict
|
||||
from hashlib import sha256
|
||||
from typing import List, Iterable, Optional, Dict, Any, Tuple
|
||||
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.models.content.chunk import ContentChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChunkManager:
|
||||
"""
|
||||
Управление разбиением контента на чанки и обратной сборкой.
|
||||
|
||||
Требования:
|
||||
- Размер чанка: 8 MiB
|
||||
- SHA-256 хэш каждого чанка (hex) для дедупликации
|
||||
- Подпись каждого чанка Ed25519
|
||||
- Интеграция с ContentCipher для шифрования/дешифрования чанков
|
||||
"""
|
||||
|
||||
CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB
|
||||
|
||||
def __init__(self, cipher: Optional[ContentCipher] = None):
|
||||
self.cipher = cipher or ContentCipher()
|
||||
logger.debug("ChunkManager initialized with CHUNK_SIZE=%d", self.CHUNK_SIZE)
|
||||
|
||||
@staticmethod
|
||||
def calculate_chunk_hash(data: bytes) -> str:
|
||||
"""
|
||||
Рассчитать SHA-256 хэш сырого буфера.
|
||||
"""
|
||||
h = sha256(data).hexdigest()
|
||||
logger.debug("Calculated chunk SHA-256: %s", h)
|
||||
return h
|
||||
|
||||
def _sign_chunk_payload(self, payload: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Подписать словарь Ed25519 через глобальный менеджер.
|
||||
Возвращает base64-подпись либо None при ошибке (логируем).
|
||||
"""
|
||||
try:
|
||||
crypto_mgr = get_ed25519_manager()
|
||||
signature = crypto_mgr.sign_message(payload)
|
||||
return signature
|
||||
except Exception as e:
|
||||
logger.error("Failed to sign chunk payload: %s", e)
|
||||
return None
|
||||
|
||||
def split_content(
|
||||
self,
|
||||
content_id: str,
|
||||
plaintext: bytes,
|
||||
content_key: bytes,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
associated_data: Optional[bytes] = None,
|
||||
) -> List[ContentChunk]:
|
||||
"""
|
||||
Разбить исходный контент на зашифрованные и подписанные чанки.
|
||||
|
||||
Алгоритм:
|
||||
1) Читаем кусками по CHUNK_SIZE
|
||||
2) Шифруем каждый кусок через ContentCipher.encrypt_content (AES-256-GCM)
|
||||
3) Формируем chunk_id как HEX(SHA-256(content_id || chunk_index || chunk_hash))
|
||||
4) Подписываем полезную нагрузку чанка (без поля signature)
|
||||
5) Возвращаем список ContentChunk
|
||||
"""
|
||||
assert isinstance(plaintext, (bytes, bytearray)), "plaintext must be bytes"
|
||||
assert isinstance(content_key, (bytes, bytearray)) and len(content_key) == self.cipher.KEY_SIZE, \
|
||||
"content_key must be 32 bytes"
|
||||
|
||||
total_size = len(plaintext)
|
||||
chunks_count = math.ceil(total_size / self.CHUNK_SIZE) if total_size else 1
|
||||
logger.info(
|
||||
"Splitting content_id=%s into chunks: total_size=%d, chunk_size=%d, chunks=%d",
|
||||
content_id, total_size, self.CHUNK_SIZE, chunks_count
|
||||
)
|
||||
|
||||
result: List[ContentChunk] = []
|
||||
offset = 0
|
||||
index = 0
|
||||
|
||||
while offset < total_size or (total_size == 0 and index == 0):
|
||||
part = plaintext[offset: offset + self.CHUNK_SIZE] if total_size else b""
|
||||
offset += len(part)
|
||||
logger.debug("Processing chunk index=%d, part_size=%d", index, len(part))
|
||||
|
||||
# Шифруем кусок
|
||||
enc_obj = self.cipher.encrypt_content(
|
||||
plaintext=part,
|
||||
key=content_key,
|
||||
metadata={"content_id": content_id, "chunk_index": index, **(metadata or {})},
|
||||
associated_data=associated_data,
|
||||
sign_with_ed25519=False, # подпишем на уровне чанка отдельно
|
||||
)
|
||||
|
||||
# Собираем бинарные данные зашифрованного чанка (ciphertext||tag||nonce) для хэширования/дедупликации
|
||||
ciphertext = base64.b64decode(enc_obj["ciphertext_b64"])
|
||||
tag = base64.b64decode(enc_obj["tag_b64"])
|
||||
nonce = base64.b64decode(enc_obj["nonce_b64"])
|
||||
raw_encrypted_chunk = ciphertext + tag + nonce
|
||||
|
||||
chunk_hash = self.calculate_chunk_hash(raw_encrypted_chunk)
|
||||
|
||||
# Формируем chunk_id детерминированно
|
||||
chunk_id = sha256(
|
||||
(content_id + str(index) + chunk_hash).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
payload_to_sign = {
|
||||
"chunk_id": chunk_id,
|
||||
"content_id": content_id,
|
||||
"chunk_index": index,
|
||||
"chunk_hash": chunk_hash,
|
||||
"encrypted_data": base64.b64encode(raw_encrypted_chunk).decode("ascii"),
|
||||
"created_at": enc_obj.get("created_at") or enc_obj.get("timestamp") or None,
|
||||
}
|
||||
# Удалим None, чтобы сериализация была стабильнее
|
||||
payload_to_sign = {k: v for k, v in payload_to_sign.items() if v is not None}
|
||||
|
||||
signature = self._sign_chunk_payload(payload_to_sign)
|
||||
|
||||
chunk = ContentChunk(
|
||||
chunk_id=payload_to_sign["chunk_id"],
|
||||
content_id=payload_to_sign["content_id"],
|
||||
chunk_index=payload_to_sign["chunk_index"],
|
||||
chunk_hash=payload_to_sign["chunk_hash"],
|
||||
encrypted_data=payload_to_sign["encrypted_data"],
|
||||
signature=signature,
|
||||
created_at=payload_to_sign.get("created_at") or None,
|
||||
)
|
||||
result.append(chunk)
|
||||
logger.debug("Chunk created: index=%d, chunk_id=%s", index, chunk.chunk_id)
|
||||
|
||||
index += 1
|
||||
|
||||
logger.info("Split completed: content_id=%s, chunks=%d", content_id, len(result))
|
||||
return result
|
||||
|
||||
def reassemble_content(
|
||||
self,
|
||||
chunks: Iterable[ContentChunk],
|
||||
content_key: bytes,
|
||||
associated_data: Optional[bytes] = None,
|
||||
expected_content_id: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Сборка исходного контента из последовательности чанков.
|
||||
|
||||
Предполагается, что входные чанки валидированы и относятся к одинаковому content_id.
|
||||
Порядок определяется по chunk_index.
|
||||
"""
|
||||
chunks_list = sorted(list(chunks), key=lambda c: c.chunk_index)
|
||||
if not chunks_list:
|
||||
logger.warning("Reassemble called with empty chunks list")
|
||||
return b""
|
||||
|
||||
first_content_id = chunks_list[0].content_id
|
||||
if expected_content_id and expected_content_id != first_content_id:
|
||||
raise ValueError("content_id mismatch for reassembly")
|
||||
|
||||
logger.info("Reassembling content_id=%s from %d chunks", first_content_id, len(chunks_list))
|
||||
|
||||
assembled: List[bytes] = []
|
||||
for c in chunks_list:
|
||||
if c.content_id != first_content_id:
|
||||
raise ValueError("mixed content_id detected during reassembly")
|
||||
|
||||
raw = c.encrypted_bytes()
|
||||
# Разделим обратно: ciphertext||tag||nonce
|
||||
if len(raw) < 16 + ContentCipher.NONCE_SIZE:
|
||||
raise ValueError("invalid encrypted chunk length")
|
||||
|
||||
nonce = raw[-ContentCipher.NONCE_SIZE:]
|
||||
tag = raw[-(ContentCipher.NONCE_SIZE + 16):-ContentCipher.NONCE_SIZE]
|
||||
ciphertext = raw[:-(ContentCipher.NONCE_SIZE + 16)]
|
||||
|
||||
plaintext = self.cipher.decrypt_content(
|
||||
ciphertext_b64=base64.b64encode(ciphertext).decode("ascii"),
|
||||
nonce_b64=base64.b64encode(nonce).decode("ascii"),
|
||||
tag_b64=base64.b64encode(tag).decode("ascii"),
|
||||
key=content_key,
|
||||
associated_data=associated_data,
|
||||
)
|
||||
assembled.append(plaintext)
|
||||
|
||||
data = b"".join(assembled)
|
||||
logger.info("Reassembly completed: content_id=%s, total_size=%d", first_content_id, len(data))
|
||||
return data
|
||||
|
||||
def verify_chunk_integrity(
|
||||
self,
|
||||
chunk: ContentChunk,
|
||||
verify_signature: bool = True
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Проверка валидности чанка:
|
||||
- Соответствие chunk_hash фактическим данным
|
||||
- Верификация Ed25519 подписи полезной нагрузки чанка
|
||||
"""
|
||||
try:
|
||||
raw = chunk.encrypted_bytes()
|
||||
computed_hash = self.calculate_chunk_hash(raw)
|
||||
if computed_hash != chunk.chunk_hash:
|
||||
return False, "chunk_hash mismatch"
|
||||
|
||||
if verify_signature:
|
||||
if not chunk.signature:
|
||||
return False, "missing chunk signature"
|
||||
|
||||
payload = {
|
||||
"chunk_id": chunk.chunk_id,
|
||||
"content_id": chunk.content_id,
|
||||
"chunk_index": int(chunk.chunk_index),
|
||||
"chunk_hash": chunk.chunk_hash,
|
||||
"encrypted_data": chunk.encrypted_data,
|
||||
"created_at": chunk.created_at,
|
||||
}
|
||||
crypto_mgr = get_ed25519_manager()
|
||||
ok = crypto_mgr.verify_signature(payload, chunk.signature, crypto_mgr.public_key_hex)
|
||||
if not ok:
|
||||
return False, "invalid chunk signature"
|
||||
return True, None
|
||||
except Exception as e:
|
||||
logger.error("verify_chunk_integrity error: %s", e)
|
||||
return False, str(e)
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.models.content.chunk import ContentChunk
|
||||
from app.core.network.node_client import NodeClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSyncManager:
|
||||
"""
|
||||
Менеджер синхронизации чанков контента между нодами.
|
||||
|
||||
Требования:
|
||||
- Batch-запросы для синхронизации между нодами
|
||||
- Валидация получаемых чанков:
|
||||
* SHA-256 хэш соответствия
|
||||
* Ed25519 подпись полезной нагрузки чанка
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_manager: Optional[ChunkManager] = None):
|
||||
self.chunk_manager = chunk_manager or ChunkManager()
|
||||
|
||||
async def verify_chunk_integrity(self, chunk: ContentChunk) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Обертка над проверкой целостности чанка с дополнительными логами.
|
||||
"""
|
||||
ok, err = self.chunk_manager.verify_chunk_integrity(chunk)
|
||||
if not ok:
|
||||
logger.warning("Chunk integrity failed: chunk_id=%s reason=%s", chunk.chunk_id, err)
|
||||
else:
|
||||
logger.debug("Chunk integrity passed: chunk_id=%s", chunk.chunk_id)
|
||||
return ok, err
|
||||
|
||||
async def request_chunks(
|
||||
self,
|
||||
target_url: str,
|
||||
content_id: str,
|
||||
needed_indexes: List[int],
|
||||
batch_size: int = 32
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Запросить недостающие чанки у ноды пакетами.
|
||||
|
||||
Ожидаемый контракт эндпойнта /api/node/content/sync:
|
||||
- action: "content_sync"
|
||||
- data: { sync_type: "content_request", content_info: { content_id, indexes: [...]} }
|
||||
|
||||
Возвращает агрегированный ответ по партиям.
|
||||
"""
|
||||
response_summary: Dict[str, Any] = {"requested": 0, "received": 0, "chunks": [], "errors": []}
|
||||
logger.info("Requesting chunks: target=%s content_id=%s total_missing=%d", target_url, content_id, len(needed_indexes))
|
||||
|
||||
async with NodeClient() as client:
|
||||
for i in range(0, len(needed_indexes), batch_size):
|
||||
batch = needed_indexes[i:i + batch_size]
|
||||
try:
|
||||
req = await client._create_signed_request(
|
||||
action="content_sync",
|
||||
data={
|
||||
"sync_type": "content_request",
|
||||
"content_info": {"content_id": content_id, "indexes": batch},
|
||||
},
|
||||
target_url=target_url,
|
||||
)
|
||||
logger.debug("Sending chunk request batch of %d indexes to %s", len(batch), target_url)
|
||||
|
||||
endpoint = f"{target_url}/api/node/content/sync"
|
||||
async with client.session.post(endpoint, **req) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status != 200:
|
||||
msg = f"HTTP {resp.status}"
|
||||
logger.warning("Chunk request failed: %s", msg)
|
||||
response_summary["errors"].append({"batch": batch, "error": msg, "data": data})
|
||||
continue
|
||||
|
||||
# Ожидаем, что данные приходят как JSON с полем 'chunks'
|
||||
chunks_payload = data.get("data", {}).get("chunks") or data.get("chunks") or []
|
||||
response_summary["requested"] += len(batch)
|
||||
|
||||
# Валидация полученных чанков
|
||||
for ch in chunks_payload:
|
||||
try:
|
||||
chunk_model = ContentChunk.from_dict(ch)
|
||||
ok, err = await self.verify_chunk_integrity(chunk_model)
|
||||
if ok:
|
||||
response_summary["chunks"].append(chunk_model.to_dict())
|
||||
response_summary["received"] += 1
|
||||
else:
|
||||
response_summary["errors"].append({"chunk_id": chunk_model.chunk_id, "error": err})
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse/validate received chunk: %s", e)
|
||||
response_summary["errors"].append({"batch": batch, "error": str(e)})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("request_chunks batch error: %s", e)
|
||||
response_summary["errors"].append({"batch": batch, "error": str(e)})
|
||||
|
||||
logger.info(
|
||||
"Request chunks done: content_id=%s requested=%d received=%d errors=%d",
|
||||
content_id, response_summary["requested"], response_summary["received"], len(response_summary["errors"])
|
||||
)
|
||||
return response_summary
|
||||
|
||||
async def provide_chunks(
|
||||
self,
|
||||
content_id: str,
|
||||
indexes: List[int],
|
||||
storage_reader, # callable: (content_id, index) -> Optional[ContentChunk]
|
||||
batch_limit: int = 128
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Подготовить пакет чанков к ответу на запрос другой ноды.
|
||||
|
||||
storage_reader: функция/корутина, возвращающая ContentChunk или None по (content_id, index).
|
||||
Возвращает словарь для отправки в ответе API.
|
||||
"""
|
||||
provided: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
async def _maybe_await(x):
|
||||
if asyncio.iscoroutinefunction(storage_reader):
|
||||
return await x
|
||||
return x
|
||||
|
||||
for idx in indexes[:batch_limit]:
|
||||
try:
|
||||
res = storage_reader(content_id, idx)
|
||||
if asyncio.iscoroutine(res):
|
||||
res = await res
|
||||
if not res:
|
||||
errors.append({"index": idx, "error": "not_found"})
|
||||
continue
|
||||
# Перед отдачей еще раз локально проверим целостность
|
||||
ok, err = await self.verify_chunk_integrity(res)
|
||||
if not ok:
|
||||
errors.append({"index": idx, "error": f"integrity_failed: {err}"})
|
||||
continue
|
||||
provided.append(res.to_dict())
|
||||
except Exception as e:
|
||||
logger.error("provide_chunks error: %s", e)
|
||||
errors.append({"index": idx, "error": str(e)})
|
||||
|
||||
logger.info("Prepared %d/%d chunks for provide, errors=%d", len(provided), len(indexes[:batch_limit]), len(errors))
|
||||
return {"chunks": provided, "errors": errors}
|
||||
|
||||
async def sync_content(
|
||||
self,
|
||||
target_nodes: List[str],
|
||||
content_id: str,
|
||||
have_indexes: List[int],
|
||||
total_chunks: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Высокоуровневая процедура синхронизации:
|
||||
- Рассчитывает недостающие индексы
|
||||
- Запрашивает чанки у всех указанных нод (параллельно)
|
||||
- Агрегирует результаты
|
||||
"""
|
||||
missing = sorted(set(range(total_chunks)) - set(have_indexes))
|
||||
logger.info("Sync content start: content_id=%s total=%d have=%d missing=%d",
|
||||
content_id, total_chunks, len(have_indexes), len(missing))
|
||||
|
||||
if not missing:
|
||||
return {"success": True, "message": "nothing to sync", "downloaded": 0}
|
||||
|
||||
results: Dict[str, Any] = {"success": True, "downloaded": 0, "details": {}}
|
||||
|
||||
async def fetch_from_node(node_url: str):
|
||||
try:
|
||||
node_result = await self.request_chunks(node_url, content_id, missing)
|
||||
results["details"][node_url] = node_result
|
||||
results["downloaded"] += node_result.get("received", 0)
|
||||
except Exception as e:
|
||||
logger.error("sync_content: error requesting from %s: %s", node_url, e)
|
||||
results["details"][node_url] = {"error": str(e)}
|
||||
|
||||
await asyncio.gather(*[fetch_from_node(url) for url in target_nodes])
|
||||
|
||||
logger.info("Sync content done: content_id=%s downloaded=%d", content_id, results["downloaded"])
|
||||
return results
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
from app.core.converter.converter_client import ConverterClient
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.models.converter.conversion_models import (
|
||||
ConversionTask,
|
||||
ConversionResult,
|
||||
ConversionStatus,
|
||||
ConversionPriority,
|
||||
ContentMetadata,
|
||||
)
|
||||
from app.core.stats.metrics_collector import MetricsCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PriorityQueue:
|
||||
"""
|
||||
Простая приоритетная очередь на базе asyncio.PriorityQueue.
|
||||
Чем больше приоритет, тем раньше задача (инвертируем знак).
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self._q: asyncio.PriorityQueue[Tuple[int, str, ConversionTask]] = asyncio.PriorityQueue()
|
||||
self._counter = 0 # стабилизация порядка
|
||||
|
||||
async def put(self, task: ConversionTask) -> None:
|
||||
self._counter += 1
|
||||
# Инвертируем, чтобы HIGH(90) шел раньше LOW(10)
|
||||
await self._q.put((-int(task.priority), self._counter, task))
|
||||
|
||||
async def get(self) -> ConversionTask:
|
||||
p, _, t = await self._q.get()
|
||||
return t
|
||||
|
||||
def empty(self) -> bool:
|
||||
return self._q.empty()
|
||||
|
||||
|
||||
class ConversionManager:
|
||||
"""
|
||||
Управляет жизненным циклом конвертации:
|
||||
- постановка в очередь (приоритет)
|
||||
- запуск через ConverterClient
|
||||
- post-processing: шифрование ContentCipher, чанкинг ChunkManager
|
||||
- retry при ошибках
|
||||
- метрики через MetricsCollector
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
converter_client: Optional[ConverterClient] = None,
|
||||
metrics: Optional[MetricsCollector] = None,
|
||||
concurrent_limit: int = 2,
|
||||
) -> None:
|
||||
self._client = converter_client or ConverterClient()
|
||||
self._cipher = ContentCipher()
|
||||
self._chunker = ChunkManager(self._cipher)
|
||||
self._metrics = metrics or MetricsCollector()
|
||||
|
||||
self._queue = _PriorityQueue()
|
||||
self._inflight: Dict[str, ConversionTask] = {}
|
||||
self._results: Dict[str, ConversionResult] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._sem = asyncio.Semaphore(concurrent_limit)
|
||||
|
||||
# -------------------- Public API --------------------
|
||||
|
||||
async def process_upload(
|
||||
self,
|
||||
local_input_path: str,
|
||||
input_ext: str,
|
||||
quality: str,
|
||||
metadata: ContentMetadata,
|
||||
priority: ConversionPriority = ConversionPriority.NORMAL,
|
||||
custom: Optional[List[str]] = None,
|
||||
trim: Optional[str] = None,
|
||||
max_retries: int = 3,
|
||||
) -> str:
|
||||
"""
|
||||
Точка входа из API: ставит задачу в очередь и возвращает task_id.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
task = ConversionTask(
|
||||
task_id=task_id,
|
||||
input_path=local_input_path,
|
||||
input_ext=input_ext,
|
||||
quality="high" if quality == "high" else "low",
|
||||
trim=trim,
|
||||
custom=custom or [],
|
||||
priority=priority,
|
||||
max_retries=max_retries,
|
||||
metadata=metadata,
|
||||
)
|
||||
await self.queue_conversion(task)
|
||||
return task_id
|
||||
|
||||
async def queue_conversion(self, task: ConversionTask) -> None:
|
||||
logger.info("Queue conversion task_id=%s priority=%s", task.task_id, task.priority)
|
||||
await self._queue.put(task)
|
||||
await self._metrics.inc_requests()
|
||||
|
||||
async def get_conversion_status(self, task_id: str) -> ConversionStatus:
|
||||
async with self._lock:
|
||||
res = self._results.get(task_id)
|
||||
if res:
|
||||
return res.status
|
||||
if task_id in self._inflight:
|
||||
return ConversionStatus.RUNNING
|
||||
# иначе он в очереди
|
||||
return ConversionStatus.QUEUED
|
||||
|
||||
async def handle_conversion_result(self, task_id: str) -> Optional[ConversionResult]:
|
||||
"""
|
||||
Возвращает итоговый ConversionResult если уже готов.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._results.get(task_id)
|
||||
|
||||
# -------------------- Worker logic --------------------
|
||||
|
||||
async def _run_single(self, task: ConversionTask) -> None:
|
||||
"""
|
||||
Полный цикл одной задачи: запуск конвертера, шифрование, чанкинг, сохранение результата.
|
||||
"""
|
||||
start_ts = time.time()
|
||||
async with self._sem:
|
||||
async with self._lock:
|
||||
self._inflight[task.task_id] = task
|
||||
|
||||
try:
|
||||
# 1) Запуск конвертера
|
||||
await self._metrics.observe_latency_ms(1) # лёгкий трейс
|
||||
await self._client.submit_conversion(task, task.input_path)
|
||||
|
||||
# 2) Ожидание завершения: опрашиваем статус, затем забираем результат
|
||||
status = await self._poll_until_done(task.task_id)
|
||||
conv_res = await self._client.download_result(task.task_id)
|
||||
if status != ConversionStatus.SUCCESS or conv_res.status != ConversionStatus.SUCCESS:
|
||||
raise RuntimeError(conv_res.error or "conversion failed")
|
||||
|
||||
# 3) Прочитать выходной файл и выполнить шифрование + чанкинг
|
||||
output_path = conv_res.converter_output_path
|
||||
if not output_path or not os.path.exists(output_path):
|
||||
raise FileNotFoundError("converted output not found")
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
converted_bytes = f.read()
|
||||
|
||||
# Шифрование полной сущности перед чанкингом
|
||||
content_key = self._cipher.generate_content_key()
|
||||
encrypted_obj = self._cipher.encrypt_content(
|
||||
plaintext=converted_bytes,
|
||||
key=content_key,
|
||||
metadata={
|
||||
"title": task.metadata.title,
|
||||
"author": task.metadata.author,
|
||||
"description": task.metadata.description,
|
||||
"attributes": task.metadata.attributes,
|
||||
"quality": task.quality,
|
||||
"source_ext": task.input_ext,
|
||||
},
|
||||
)
|
||||
content_id = encrypted_obj["content_id"]
|
||||
|
||||
# Для дедупликации и совместимости чанкуем уже шифротекст по архитектуре:
|
||||
# Используем nonce/tag каждого чанка отдельно (ChunkManager делает encrypt_content для каждого чанка).
|
||||
# Но нам нужен plaintext для разбиения на куски до шифрования? В архитектуре зашифрованные чанки требуются.
|
||||
# Следуем текущей реализации ChunkManager: он сам шифрует куски.
|
||||
chunks = self._chunker.split_content(
|
||||
content_id=content_id,
|
||||
plaintext=converted_bytes,
|
||||
content_key=content_key,
|
||||
metadata={
|
||||
"nft_title": task.metadata.title,
|
||||
"nft_author": task.metadata.author,
|
||||
"quality": task.quality,
|
||||
},
|
||||
)
|
||||
|
||||
# Сериализуем чанки для отдачи через API
|
||||
chunks_serialized = [asdict(c) for c in chunks]
|
||||
|
||||
nft_metadata = {
|
||||
"name": task.metadata.title,
|
||||
"description": task.metadata.description,
|
||||
"author": task.metadata.author,
|
||||
"attributes": task.metadata.attributes,
|
||||
"tags": task.metadata.tags,
|
||||
"collection": task.metadata.collection,
|
||||
"external_url": None,
|
||||
}
|
||||
|
||||
result = ConversionResult(
|
||||
task_id=task.task_id,
|
||||
status=ConversionStatus.SUCCESS,
|
||||
converter_output_path=output_path,
|
||||
logs_path=None,
|
||||
content_id=content_id,
|
||||
chunks=chunks_serialized,
|
||||
nft_metadata=nft_metadata,
|
||||
finished_at=int(time.time()),
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._results[task.task_id] = result
|
||||
self._inflight.pop(task.task_id, None)
|
||||
|
||||
await self._metrics.inc_conversions()
|
||||
await self._metrics.observe_latency_ms((time.time() - start_ts) * 1000.0)
|
||||
logger.info("Conversion completed: task_id=%s content_id=%s chunks=%d",
|
||||
task.task_id, content_id, len(chunks))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Conversion task %s failed: %s", task.task_id, e)
|
||||
task.attempts += 1
|
||||
if task.attempts <= task.max_retries:
|
||||
# Retry: возвращаем задачу в очередь с тем же приоритетом (экспоненциальная пауза)
|
||||
backoff = min(2 ** (task.attempts - 1), 30)
|
||||
await asyncio.sleep(backoff)
|
||||
await self._queue.put(task)
|
||||
await self._metrics.inc_errors()
|
||||
else:
|
||||
fail_res = ConversionResult(
|
||||
task_id=task.task_id,
|
||||
status=ConversionStatus.FAILED,
|
||||
error=str(e),
|
||||
finished_at=int(time.time()),
|
||||
)
|
||||
async with self._lock:
|
||||
self._results[task.task_id] = fail_res
|
||||
self._inflight.pop(task.task_id, None)
|
||||
await self._metrics.inc_errors()
|
||||
|
||||
async def _poll_until_done(self, task_id: str, interval_sec: float = 1.0, timeout_sec: float = 3600.0) -> ConversionStatus:
|
||||
"""
|
||||
Простой polling статуса процесса конвертера.
|
||||
"""
|
||||
start = time.time()
|
||||
while True:
|
||||
status = await self._client.get_conversion_status(task_id)
|
||||
if status in (ConversionStatus.SUCCESS, ConversionStatus.FAILED, ConversionStatus.CANCELED):
|
||||
return status
|
||||
if time.time() - start > timeout_sec:
|
||||
return ConversionStatus.FAILED
|
||||
await asyncio.sleep(interval_sec)
|
||||
|
||||
# -------------------- Scheduler loop --------------------
|
||||
|
||||
async def run_scheduler(self, shutdown_event: Optional[asyncio.Event] = None) -> None:
|
||||
"""
|
||||
Основной цикл: достаёт из очереди и обрабатывает задачи.
|
||||
"""
|
||||
while True:
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
task = await self._queue.get()
|
||||
asyncio.create_task(self._run_single(task))
|
||||
except Exception as e:
|
||||
logger.error("Scheduler loop error: %s", e)
|
||||
await asyncio.sleep(1.0)
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from typing import Dict, Any, Optional, Tuple, List
|
||||
|
||||
from app.core.models.converter.conversion_models import ConversionTask, ConversionResult, ConversionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConverterClient:
|
||||
"""
|
||||
Клиент-адаптер для взаимодействия с converter-module без модификации его кода.
|
||||
|
||||
Предполагаемая интеграция:
|
||||
- converter-module/converter/converter.py запускается как отдельный процесс (например, Docker/Podman или локальный python)
|
||||
- входной файл должен быть доступен по фиксированному пути /app/input
|
||||
- выход сохраняется в /app/output/output.<ext> и метаданные в /app/output/output.json
|
||||
- параметры: --ext, --quality, --custom (список), --trim "start-end"
|
||||
|
||||
Данный клиент предоставляет унифицированный async API:
|
||||
submit_conversion() -> str (task_id)
|
||||
get_conversion_status(task_id) -> ConversionStatus
|
||||
download_result(task_id) -> ConversionResult (локальные пути к артефактам)
|
||||
|
||||
Реализация по умолчанию использует локальный запуск python-процесса конвертера.
|
||||
Для контейнеров можно переопределить _build_command/_prepare_io.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
converter_entry: str = "converter-module/converter/converter.py",
|
||||
workdir: str = "converter-module",
|
||||
io_input_path: str = "/app/input",
|
||||
io_output_dir: str = "/app/output",
|
||||
python_bin: str = "python3",
|
||||
concurrent_limit: int = 2,
|
||||
) -> None:
|
||||
self.converter_entry = converter_entry
|
||||
self.workdir = workdir
|
||||
self.io_input_path = io_input_path
|
||||
self.io_output_dir = io_output_dir
|
||||
self.python_bin = python_bin
|
||||
self._sem = asyncio.Semaphore(concurrent_limit)
|
||||
|
||||
# Локальное состояние задач (простая in-memory мапа процессов)
|
||||
self._tasks_proc: Dict[str, asyncio.subprocess.Process] = {}
|
||||
self._tasks_info: Dict[str, Dict[str, Any]] = {} # {task_id: {local_input, local_output_dir, logs_path}}
|
||||
self._tasks_status: Dict[str, ConversionStatus] = {}
|
||||
self._tasks_error: Dict[str, str] = {}
|
||||
|
||||
os.makedirs(self.workdir, exist_ok=True)
|
||||
|
||||
async def submit_conversion(self, task: ConversionTask, local_input_path: str) -> str:
|
||||
"""
|
||||
Подготовка окружения и запуск конвертации.
|
||||
local_input_path — путь к исходному файлу на диске ноды uploader-bot.
|
||||
"""
|
||||
task_id = task.task_id or str(uuid.uuid4())
|
||||
logger.info("Submitting conversion task_id=%s", task_id)
|
||||
|
||||
# Готовим IO: копируем/линкуем файл в ожидаемое место converter-module
|
||||
local_output_dir, logs_path = await self._prepare_io(task_id, local_input_path)
|
||||
|
||||
# Формируем команду запуска
|
||||
cmd = self._build_command(task)
|
||||
logger.debug("Converter command: %s", " ".join(map(shlex.quote, cmd)))
|
||||
|
||||
# Старт процесса
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=self.workdir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
self._tasks_proc[task_id] = proc
|
||||
self._tasks_status[task_id] = ConversionStatus.RUNNING
|
||||
self._tasks_info[task_id] = {
|
||||
"local_input": local_input_path,
|
||||
"local_output_dir": local_output_dir,
|
||||
"logs_path": logs_path,
|
||||
}
|
||||
|
||||
# Запускаем корутину логгирования и ожидания завершения
|
||||
asyncio.create_task(self._stream_and_wait(task_id, proc, logs_path))
|
||||
return task_id
|
||||
|
||||
async def get_conversion_status(self, task_id: str) -> ConversionStatus:
|
||||
return self._tasks_status.get(task_id, ConversionStatus.QUEUED)
|
||||
|
||||
async def download_result(self, task_id: str) -> ConversionResult:
|
||||
"""
|
||||
Возвращает результат: путь к сгенерированному файлу и output.json.
|
||||
Ничего не копирует, возвращает локальные пути внутри converter-module рабочего каталога.
|
||||
"""
|
||||
status = self._tasks_status.get(task_id)
|
||||
if not status:
|
||||
return ConversionResult(task_id=task_id, status=ConversionStatus.FAILED, error="unknown task")
|
||||
info = self._tasks_info.get(task_id, {})
|
||||
output_dir = info.get("local_output_dir")
|
||||
logs_path = info.get("logs_path")
|
||||
|
||||
if status != ConversionStatus.SUCCESS:
|
||||
return ConversionResult(task_id=task_id, status=status, logs_path=logs_path, error=self._tasks_error.get(task_id))
|
||||
|
||||
# Определяем финальный файл: ищем output.* в каталоге вывода
|
||||
output_file = await self._detect_output_file(output_dir)
|
||||
if not output_file:
|
||||
return ConversionResult(task_id=task_id, status=ConversionStatus.FAILED, logs_path=logs_path, error="output file not found")
|
||||
|
||||
return ConversionResult(
|
||||
task_id=task_id,
|
||||
status=ConversionStatus.SUCCESS,
|
||||
converter_output_path=output_file,
|
||||
logs_path=logs_path,
|
||||
)
|
||||
|
||||
# -------------------- helpers --------------------
|
||||
|
||||
async def _prepare_io(self, task_id: str, local_input_path: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Подготавливает папки converter-module для запуска и логи.
|
||||
Мы не можем писать в абсолютные /app/* на хосте, но converter ждёт такие пути.
|
||||
Поэтому используем симлинки внутри workdir: workdir/app/input -> реальный файл.
|
||||
"""
|
||||
# Готовим подкаталоги
|
||||
app_dir = os.path.join(self.workdir, "app")
|
||||
os.makedirs(app_dir, exist_ok=True)
|
||||
|
||||
linked_input = os.path.join(app_dir, "input")
|
||||
# Чистим старый симлинк/файл
|
||||
try:
|
||||
if os.path.islink(linked_input) or os.path.exists(linked_input):
|
||||
os.remove(linked_input)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cleanup old input link: %s", e)
|
||||
# Создаем симлинк на входной файл
|
||||
os.symlink(os.path.abspath(local_input_path), linked_input)
|
||||
|
||||
output_dir = os.path.join(app_dir, "output")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Очистим выходы
|
||||
for name in os.listdir(output_dir):
|
||||
try:
|
||||
os.remove(os.path.join(output_dir, name))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logs_dir = os.path.join(self.workdir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
logs_path = os.path.join(logs_dir, f"{task_id}.log")
|
||||
|
||||
# Сопоставляем ожидаемые фиксированные пути converter'а с нашими
|
||||
# Хотя converter использует /app/input и /app/output, cwd=self.workdir и наличие app/input, app/output достаточно.
|
||||
return output_dir, logs_path
|
||||
|
||||
def _build_command(self, task: ConversionTask) -> List[str]:
|
||||
cmd: List[str] = [
|
||||
self.python_bin,
|
||||
self.converter_entry,
|
||||
"--ext", task.input_ext,
|
||||
"--quality", task.quality,
|
||||
]
|
||||
if task.custom:
|
||||
cmd += ["--custom", *task.custom]
|
||||
if task.trim:
|
||||
cmd += ["--trim", task.trim]
|
||||
return cmd
|
||||
|
||||
async def _stream_and_wait(self, task_id: str, proc: asyncio.subprocess.Process, logs_path: str) -> None:
|
||||
"""
|
||||
Стримит логи процесса в файл и обновляет статус по завершению.
|
||||
"""
|
||||
try:
|
||||
with open(logs_path, "a", encoding="utf-8") as lf:
|
||||
if proc.stdout:
|
||||
async for line in proc.stdout:
|
||||
try:
|
||||
text = line.decode("utf-8", errors="ignore")
|
||||
except AttributeError:
|
||||
text = line
|
||||
lf.write(text)
|
||||
lf.flush()
|
||||
logger.info("[converter %s] %s", task_id, text.strip())
|
||||
rc = await proc.wait()
|
||||
if rc == 0:
|
||||
self._tasks_status[task_id] = ConversionStatus.SUCCESS
|
||||
else:
|
||||
self._tasks_status[task_id] = ConversionStatus.FAILED
|
||||
self._tasks_error[task_id] = f"exit_code={rc}"
|
||||
except Exception as e:
|
||||
logger.exception("Converter task %s failed: %s", task_id, e)
|
||||
self._tasks_status[task_id] = ConversionStatus.FAILED
|
||||
self._tasks_error[task_id] = str(e)
|
||||
|
||||
async def _detect_output_file(self, output_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Ищет файл output.* в каталоге результата.
|
||||
"""
|
||||
try:
|
||||
for name in os.listdir(output_dir):
|
||||
if name.startswith("output."):
|
||||
return os.path.join(output_dir, name)
|
||||
if name.startswith("output") and "." in name:
|
||||
return os.path.join(output_dir, name)
|
||||
except Exception as e:
|
||||
logger.error("detect_output_file error: %s", e)
|
||||
return None
|
||||
@@ -5,9 +5,11 @@ MY Network v3.0 - Cryptographic Module for uploader-bot
|
||||
"""
|
||||
|
||||
from .ed25519_manager import Ed25519Manager, get_ed25519_manager, init_ed25519_manager
|
||||
from .content_cipher import ContentCipher # Export AES-256-GCM content cipher
|
||||
|
||||
__all__ = [
|
||||
'Ed25519Manager',
|
||||
'get_ed25519_manager',
|
||||
'init_ed25519_manager'
|
||||
'get_ed25519_manager',
|
||||
'init_ed25519_manager',
|
||||
'ContentCipher',
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
MY Network v3.0 - ContentCipher (AES-256-GCM) for uploader-bot
|
||||
|
||||
Реализует шифрование контента с помощью AES-256-GCM и интеграцию с Ed25519Manager
|
||||
для подписи зашифрованного контента и проверки целостности.
|
||||
|
||||
Адаптация идей из DEPRECATED:
|
||||
- См. базовую AES логику ([`DEPRECATED-uploader-bot/app/core/_crypto/cipher.py`](DEPRECATED-uploader-bot/app/core/_crypto/cipher.py:1))
|
||||
- См. работу с контентом ([`DEPRECATED-uploader-bot/app/core/_crypto/content.py`](DEPRECATED-uploader-bot/app/core/_crypto/content.py:1))
|
||||
|
||||
Отличия новой реализации:
|
||||
- Используем AES-256-GCM (аутентифицированное шифрование) вместо CBC+PAD
|
||||
- Формируем content_id как SHA-256 от (ciphertext || nonce || tag || metadata_json)
|
||||
- Подписываем структуру EncryptedContent через Ed25519Manager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
try:
|
||||
# Импорт менеджера подписи из текущего модуля crypto
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
except Exception:
|
||||
# Ленивая инициализация без разрыва импорта (например, при статическом анализе)
|
||||
get_ed25519_manager = None # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentCipher:
|
||||
"""
|
||||
Класс шифрования контента AES-256-GCM с интеграцией Ed25519 подписи.
|
||||
|
||||
Ключевая информация:
|
||||
- generate_content_key() -> 32 байта (AES-256)
|
||||
- encrypt_content() -> (ciphertext, nonce, tag, content_id, signature, signer_pubkey)
|
||||
- decrypt_content() -> исходные данные при валидной аутентификации
|
||||
- verify_content_integrity() -> проверка подписи и content_id
|
||||
"""
|
||||
|
||||
NONCE_SIZE = 12 # Рекомендуемый размер nonce для AES-GCM
|
||||
KEY_SIZE = 32 # 256-bit
|
||||
|
||||
def __init__(self):
|
||||
# В логах не пишем чувствительные данные
|
||||
logger.debug("ContentCipher initialized (AES-256-GCM)")
|
||||
|
||||
@staticmethod
|
||||
def generate_content_key(seed: Optional[bytes] = None) -> bytes:
|
||||
"""
|
||||
Генерация ключа шифрования контента (32 байта).
|
||||
Если передан seed (как в DEPRECATED подходе), дополнительно хэшируем SHA-256.
|
||||
"""
|
||||
if seed is not None:
|
||||
assert isinstance(seed, (bytes, bytearray)), "seed must be bytes"
|
||||
key = sha256(seed).digest()
|
||||
logger.debug("Content key generated from seed via SHA-256")
|
||||
return key
|
||||
# Без seed — криптографически стойкая генерация
|
||||
key = os.urandom(ContentCipher.KEY_SIZE)
|
||||
logger.debug("Random content key generated")
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def _compute_content_id(ciphertext: bytes, nonce: bytes, tag: bytes, metadata: Optional[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
content_id = HEX(SHA-256(ciphertext || nonce || tag || json(metadata, sorted)))
|
||||
"""
|
||||
md_json = b"{}"
|
||||
if metadata:
|
||||
md_json = json.dumps(metadata, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
digest = sha256(ciphertext + nonce + tag + md_json).hexdigest()
|
||||
logger.debug("Computed content_id via SHA-256 over ciphertext+nonce+tag+metadata_json")
|
||||
return digest
|
||||
|
||||
def encrypt_content(
|
||||
self,
|
||||
plaintext: bytes,
|
||||
key: bytes,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
associated_data: Optional[bytes] = None,
|
||||
sign_with_ed25519: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Шифрует данные AES-256-GCM и возвращает структуру с полями:
|
||||
{
|
||||
ciphertext_b64, nonce_b64, tag_b64, content_id, metadata, signature, signer_pubkey
|
||||
}
|
||||
|
||||
Примечания:
|
||||
- associated_data (AAD) включается в AEAD (не шифруется, но аутентифицируется).
|
||||
- signature покрывает сериализованную структуру без signature поля.
|
||||
"""
|
||||
assert isinstance(plaintext, (bytes, bytearray)), "plaintext must be bytes"
|
||||
assert isinstance(key, (bytes, bytearray)) and len(key) == self.KEY_SIZE, "key must be 32 bytes"
|
||||
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(self.NONCE_SIZE)
|
||||
|
||||
# Шифруем: AESGCM возвращает ciphertext||tag в одном буфере
|
||||
ct_with_tag = aesgcm.encrypt(nonce, plaintext, associated_data)
|
||||
# Последние 16 байт — GCM tag
|
||||
tag = ct_with_tag[-16:]
|
||||
ciphertext = ct_with_tag[:-16]
|
||||
|
||||
# content_id по требованиям
|
||||
content_id = self._compute_content_id(ciphertext, nonce, tag, metadata)
|
||||
|
||||
# Подготовка объекта для подписи
|
||||
payload = {
|
||||
"ciphertext_b64": base64.b64encode(ciphertext).decode("ascii"),
|
||||
"nonce_b64": base64.b64encode(nonce).decode("ascii"),
|
||||
"tag_b64": base64.b64encode(tag).decode("ascii"),
|
||||
"content_id": content_id,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
signature = None
|
||||
signer_pubkey = None
|
||||
if sign_with_ed25519 and get_ed25519_manager is not None:
|
||||
try:
|
||||
crypto_mgr = get_ed25519_manager()
|
||||
signature = crypto_mgr.sign_message(payload)
|
||||
signer_pubkey = crypto_mgr.public_key_hex
|
||||
logger.debug("Encrypted payload signed with Ed25519")
|
||||
except Exception as e:
|
||||
# Не блокируем шифрование при проблемах подписи, но логируем
|
||||
logger.error(f"Failed to sign encrypted payload: {e}")
|
||||
|
||||
result = {
|
||||
**payload,
|
||||
"signature": signature,
|
||||
"signer_pubkey": signer_pubkey,
|
||||
}
|
||||
logger.info(f"Content encrypted: content_id={content_id}, has_signature={signature is not None}")
|
||||
return result
|
||||
|
||||
def decrypt_content(
|
||||
self,
|
||||
ciphertext_b64: str,
|
||||
nonce_b64: str,
|
||||
tag_b64: str,
|
||||
key: bytes,
|
||||
associated_data: Optional[bytes] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Расшифровывает данные AES-256-GCM.
|
||||
Бросает исключение при неверной аутентификации (tag/AAD/nonce).
|
||||
"""
|
||||
assert isinstance(key, (bytes, bytearray)) and len(key) == self.KEY_SIZE, "key must be 32 bytes"
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
tag = base64.b64decode(tag_b64)
|
||||
|
||||
aesgcm = AESGCM(key)
|
||||
pt = aesgcm.decrypt(nonce, ciphertext + tag, associated_data)
|
||||
logger.info("Content decrypted successfully")
|
||||
return pt
|
||||
|
||||
def verify_content_integrity(
|
||||
self,
|
||||
encrypted_obj: Dict[str, Any],
|
||||
expected_metadata: Optional[Dict[str, Any]] = None,
|
||||
verify_signature: bool = True,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Проверяет:
|
||||
- content_id соответствует данным (ciphertext/nonce/tag/metadata)
|
||||
- при наличии verify_signature и signature/signer_pubkey — валидность подписи
|
||||
|
||||
Возвращает: (OK, error_message)
|
||||
"""
|
||||
try:
|
||||
# Сначала проверим content_id
|
||||
ciphertext_b64 = encrypted_obj.get("ciphertext_b64")
|
||||
nonce_b64 = encrypted_obj.get("nonce_b64")
|
||||
tag_b64 = encrypted_obj.get("tag_b64")
|
||||
metadata = encrypted_obj.get("metadata") or {}
|
||||
|
||||
if expected_metadata is not None and expected_metadata != metadata:
|
||||
return False, "Metadata mismatch"
|
||||
|
||||
if not (ciphertext_b64 and nonce_b64 and tag_b64):
|
||||
return False, "Missing encrypted fields"
|
||||
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
tag = base64.b64decode(tag_b64)
|
||||
|
||||
computed_id = self._compute_content_id(ciphertext, nonce, tag, metadata)
|
||||
if computed_id != encrypted_obj.get("content_id"):
|
||||
return False, "content_id mismatch"
|
||||
|
||||
# Далее проверим подпись при необходимости
|
||||
if verify_signature:
|
||||
signature = encrypted_obj.get("signature")
|
||||
signer_pubkey = encrypted_obj.get("signer_pubkey")
|
||||
if signature and signer_pubkey and get_ed25519_manager is not None:
|
||||
# Важно: подписывалась структура без полей signature/signер_pubkey
|
||||
payload = {
|
||||
"ciphertext_b64": ciphertext_b64,
|
||||
"nonce_b64": nonce_b64,
|
||||
"tag_b64": tag_b64,
|
||||
"content_id": computed_id,
|
||||
"metadata": metadata,
|
||||
}
|
||||
try:
|
||||
crypto_mgr = get_ed25519_manager()
|
||||
if not crypto_mgr.verify_signature(payload, signature, signer_pubkey):
|
||||
return False, "Invalid signature"
|
||||
except Exception as e:
|
||||
logger.error(f"Signature verification error: {e}")
|
||||
return False, "Signature verification error"
|
||||
else:
|
||||
logger.debug("No signature provided for integrity verification")
|
||||
logger.info("Integrity verification passed")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Integrity verification failed: {e}")
|
||||
return False, str(e)
|
||||
@@ -37,10 +37,20 @@ class Wrapped_CBotChat(T, PlayerTemplates):
|
||||
|
||||
@property
|
||||
def bot_id(self):
|
||||
return {
|
||||
TELEGRAM_API_KEY: 0,
|
||||
CLIENT_TELEGRAM_API_KEY: 1
|
||||
}[self._bot_key]
|
||||
"""
|
||||
Map known tokens to stable bot IDs.
|
||||
If tokens are empty/None (Telegram disabled), fall back to hash-based mapping to avoid KeyError.
|
||||
"""
|
||||
mapping = {}
|
||||
if TELEGRAM_API_KEY:
|
||||
mapping[TELEGRAM_API_KEY] = 0
|
||||
if CLIENT_TELEGRAM_API_KEY:
|
||||
mapping[CLIENT_TELEGRAM_API_KEY] = 1
|
||||
# Try direct mapping first
|
||||
if self._bot_key in mapping:
|
||||
return mapping[self._bot_key]
|
||||
# Fallback: deterministic bucket (keeps old behavior of 0/1 classes)
|
||||
return 0 if (str(self._chat_id) + str(self._bot_key)).__hash__() % 2 == 0 else 1
|
||||
|
||||
async def return_result(self, result, message_type='common', message_meta={}, content_id=None, **kwargs):
|
||||
if self.db_session:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any, Optional, List, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NodeHealthResponse(BaseModel):
|
||||
status: Literal["ok", "degraded", "down"] = "ok"
|
||||
node_id: str
|
||||
public_key: str
|
||||
uptime_seconds: Optional[int] = None
|
||||
cpu_usage: Optional[float] = None
|
||||
memory_usage_mb: Optional[float] = None
|
||||
disk_free_mb: Optional[float] = None
|
||||
last_sync_ts: Optional[int] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ContentStatsItem(BaseModel):
|
||||
content_id: str
|
||||
total_chunks: int
|
||||
stored_chunks: int
|
||||
missing_chunks: int
|
||||
size_bytes: Optional[int] = None
|
||||
verified: Optional[bool] = None
|
||||
|
||||
|
||||
class NodeContentStatsResponse(BaseModel):
|
||||
total_contents: int
|
||||
total_chunks: int
|
||||
stored_chunks: int
|
||||
missing_chunks: int
|
||||
contents: List[ContentStatsItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NodeStatsReport(BaseModel):
|
||||
action: Literal["stats_report"] = "stats_report"
|
||||
reporter_node_id: str
|
||||
reporter_public_key: str
|
||||
timestamp: int
|
||||
metrics: Dict[str, Any] = Field(default_factory=dict)
|
||||
signature: Optional[str] = None # подпись может быть и в заголовке
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class SignedRequestHeaders(BaseModel):
|
||||
"""Заголовки межузлового запроса с подписью Ed25519"""
|
||||
x_node_communication: Literal["true"] = Field(alias="X-Node-Communication")
|
||||
x_node_id: str = Field(alias="X-Node-ID")
|
||||
x_node_public_key: str = Field(alias="X-Node-Public-Key")
|
||||
x_node_signature: str = Field(alias="X-Node-Signature")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ChunkRef(BaseModel):
|
||||
chunk_id: str
|
||||
content_id: str
|
||||
chunk_index: int
|
||||
chunk_hash: str
|
||||
encrypted_data: str
|
||||
signature: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class ContentRequest(BaseModel):
|
||||
action: Literal["content_sync"]
|
||||
sync_type: Literal["content_request", "new_content", "content_list"]
|
||||
content_info: Dict[str, Any] = Field(default_factory=dict)
|
||||
timestamp: Optional[int] = None
|
||||
|
||||
@validator("content_info")
|
||||
def validate_content_info(cls, v, values):
|
||||
st = values.get("sync_type")
|
||||
if st == "content_request":
|
||||
# ожидаем content_id и indexes
|
||||
if "content_id" not in v or "indexes" not in v:
|
||||
raise ValueError("content_request requires content_info.content_id and content_info.indexes")
|
||||
if not isinstance(v.get("indexes"), list):
|
||||
raise ValueError("content_info.indexes must be a list")
|
||||
elif st == "new_content":
|
||||
if "content_id" not in v or "total_chunks" not in v:
|
||||
raise ValueError("new_content requires content_info.content_id and content_info.total_chunks")
|
||||
return v
|
||||
|
||||
|
||||
class ContentProvideResponse(BaseModel):
|
||||
success: bool = True
|
||||
chunks: List[ChunkRef] = Field(default_factory=list)
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContentStatusResponse(BaseModel):
|
||||
content_id: str
|
||||
total_chunks: int
|
||||
have_indexes: List[int] = Field(default_factory=list)
|
||||
missing_indexes: List[int] = Field(default_factory=list)
|
||||
verified: Optional[bool] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ContentVerifyRequest(BaseModel):
|
||||
content_id: str
|
||||
chunks: List[ChunkRef] = Field(default_factory=list)
|
||||
verify_signatures: bool = True
|
||||
|
||||
|
||||
class GenericSignedResponse(BaseModel):
|
||||
success: bool
|
||||
data: Dict[str, Any] = Field(default_factory=dict)
|
||||
node_id: Optional[str] = None
|
||||
timestamp: Optional[str] = None
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentChunk:
|
||||
"""
|
||||
Модель чанка зашифрованного контента.
|
||||
|
||||
Все бинарные поля представлены в base64-строках для JSON-совместимости.
|
||||
- chunk_hash: HEX(SHA-256(raw_encrypted_chunk_bytes)) — для дедупликации
|
||||
- signature: base64-encoded Ed25519 подпись структуры чанка (детали в ChunkManager)
|
||||
"""
|
||||
chunk_id: str
|
||||
content_id: str
|
||||
chunk_index: int
|
||||
chunk_hash: str # hex sha256(raw encrypted data)
|
||||
encrypted_data: str # base64
|
||||
signature: Optional[str] = None
|
||||
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ContentChunk":
|
||||
required = ["chunk_id", "content_id", "chunk_index", "chunk_hash", "encrypted_data"]
|
||||
for f in required:
|
||||
if f not in data:
|
||||
raise ValueError(f"Missing required field in ContentChunk: {f}")
|
||||
return cls(
|
||||
chunk_id=data["chunk_id"],
|
||||
content_id=data["content_id"],
|
||||
chunk_index=int(data["chunk_index"]),
|
||||
chunk_hash=data["chunk_hash"],
|
||||
encrypted_data=data["encrypted_data"],
|
||||
signature=data.get("signature"),
|
||||
created_at=data.get("created_at") or datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
def encrypted_bytes(self) -> bytes:
|
||||
return base64.b64decode(self.encrypted_data)
|
||||
|
||||
@staticmethod
|
||||
def compute_sha256_hex(buf: bytes) -> str:
|
||||
return hashlib.sha256(buf).hexdigest()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Модель данных EncryptedContent для хранения результата шифрования контента.
|
||||
|
||||
Полезно для сериализации, логирования и передачи между подсистемами uploader-bot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptedContent:
|
||||
"""
|
||||
Универсальная переносимая модель зашифрованного контента.
|
||||
Все бинарные поля хранятся в Base64 (строки), чтобы быть JSON-совместимыми.
|
||||
"""
|
||||
content_id: str
|
||||
ciphertext_b64: str
|
||||
nonce_b64: str
|
||||
tag_b64: str
|
||||
|
||||
# Подпись и открытый ключ подписанта (Ed25519). Могут отсутствовать.
|
||||
signature: Optional[str] = None
|
||||
signer_pubkey: Optional[str] = None
|
||||
|
||||
# Пользовательские/системные метаданные (должны совпадать при верификации)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Служебная метка времени создания структуры
|
||||
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Сериализация в словарь (JSON-совместимый).
|
||||
"""
|
||||
data = asdict(self)
|
||||
# Ничего дополнительно не преобразуем — все поля уже JSON-friendly
|
||||
return data
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""
|
||||
Сериализация в JSON-строку.
|
||||
"""
|
||||
payload = self.to_dict()
|
||||
try:
|
||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
except Exception as e:
|
||||
logger.error(f"EncryptedContent.to_json serialization error: {e}")
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "EncryptedContent":
|
||||
"""
|
||||
Десериализация из словаря.
|
||||
"""
|
||||
required = ["content_id", "ciphertext_b64", "nonce_b64", "tag_b64"]
|
||||
for f in required:
|
||||
if f not in data:
|
||||
raise ValueError(f"Missing required field in EncryptedContent: {f}")
|
||||
|
||||
return cls(
|
||||
content_id=data["content_id"],
|
||||
ciphertext_b64=data["ciphertext_b64"],
|
||||
nonce_b64=data["nonce_b64"],
|
||||
tag_b64=data["tag_b64"],
|
||||
signature=data.get("signature"),
|
||||
signer_pubkey=data.get("signer_pubkey"),
|
||||
metadata=data.get("metadata", {}) or {},
|
||||
created_at=data.get("created_at") or datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_crypto_result(cls, crypto_result: Dict[str, Any]) -> "EncryptedContent":
|
||||
"""
|
||||
Удобный конструктор из результата ContentCipher.encrypt_content()
|
||||
"""
|
||||
return cls.from_dict(crypto_result)
|
||||
|
||||
# Вспомогательные методы для работы с бинарными данными (если необходимо)
|
||||
def ciphertext_bytes(self) -> bytes:
|
||||
return base64.b64decode(self.ciphertext_b64)
|
||||
|
||||
def nonce_bytes(self) -> bytes:
|
||||
return base64.b64decode(self.nonce_b64)
|
||||
|
||||
def tag_bytes(self) -> bytes:
|
||||
return base64.b64decode(self.tag_b64)
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional, Literal, Union
|
||||
|
||||
|
||||
class ConversionPriority(enum.IntEnum):
|
||||
LOW = 10
|
||||
NORMAL = 50
|
||||
HIGH = 90
|
||||
CRITICAL = 100
|
||||
|
||||
|
||||
class ConversionStatus(str, enum.Enum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentMetadata:
|
||||
"""
|
||||
Метаданные контента для NFT и каталогизации.
|
||||
"""
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
collection: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
cover_image_b64: Optional[str] = None
|
||||
# Доп. поля для Web2/Web3 совместимости
|
||||
language: Optional[str] = None
|
||||
explicit: Optional[bool] = None
|
||||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionTask:
|
||||
"""
|
||||
Описывает задачу на конвертацию для converter-module.
|
||||
"""
|
||||
task_id: str
|
||||
input_path: str
|
||||
input_ext: str
|
||||
quality: Literal["high", "low"]
|
||||
# Доп опции конвертера
|
||||
trim: Optional[str] = None # формат "start-end" в секундах, пример "0.5-35"
|
||||
custom: List[str] = field(default_factory=list)
|
||||
|
||||
# Интеграция с децентрализованной платформой
|
||||
priority: ConversionPriority = ConversionPriority.NORMAL
|
||||
attempts: int = 0
|
||||
max_retries: int = 3
|
||||
|
||||
# NFT/контент метаданные
|
||||
metadata: ContentMetadata = field(default_factory=lambda: ContentMetadata(title="Untitled"))
|
||||
|
||||
# Трассировка/время
|
||||
created_at: int = field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["priority"] = int(self.priority)
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
"""
|
||||
Результат конвертации.
|
||||
"""
|
||||
task_id: str
|
||||
status: ConversionStatus
|
||||
# Путь к выходному файлу конвертера внутри converter-module контейнера/процесса
|
||||
converter_output_path: Optional[str] = None
|
||||
# Снимок stdout/stderr или лог-файла конвертера (если доступно)
|
||||
logs_path: Optional[str] = None
|
||||
|
||||
# Интеграция после конвертации
|
||||
# content_id после шифрования, ключ для расшифровки хранится отдельно безопасно
|
||||
content_id: Optional[str] = None
|
||||
# Итоговые чанки (их хеши и base64-данные)
|
||||
chunks: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Метаданные для NFT
|
||||
nft_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Ошибка (если FAILED)
|
||||
error: Optional[str] = None
|
||||
|
||||
finished_at: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["status"] = str(self.status.value)
|
||||
return d
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NFTLicense:
|
||||
"""
|
||||
Модель NFT лицензии на доступ к контенту.
|
||||
|
||||
Важно:
|
||||
- license_id: уникальный идентификатор записи лицензии в нашей системе (может совпадать с nft_address или быть внутренним UUID)
|
||||
- content_id: идентификатор контента (из ContentCipher, sha256 от шифроданных/метаданных)
|
||||
- owner_address: адрес кошелька TON владельца NFT (user)
|
||||
- nft_address: адрес NFT-токена лицензии (TON)
|
||||
- created_at: когда лицензия была создана/закуплена (по данным блокчейна/системы)
|
||||
- expires_at: необязательное поле срока действия (если лицензия не бессрочная)
|
||||
"""
|
||||
license_id: str
|
||||
content_id: str
|
||||
owner_address: str
|
||||
nft_address: str
|
||||
created_at: datetime = field(default_factory=lambda: datetime.utcnow())
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
def is_active(self, now: Optional[datetime] = None) -> bool:
|
||||
now = now or datetime.utcnow()
|
||||
if self.expires_at is None:
|
||||
return True
|
||||
return now < self.expires_at
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"license_id": self.license_id,
|
||||
"content_id": self.content_id,
|
||||
"owner_address": self.owner_address,
|
||||
"nft_address": self.nft_address,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "NFTLicense":
|
||||
try:
|
||||
return NFTLicense(
|
||||
license_id=data["license_id"],
|
||||
content_id=data["content_id"],
|
||||
owner_address=data["owner_address"],
|
||||
nft_address=data["nft_address"],
|
||||
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.utcnow(),
|
||||
expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse NFTLicense from dict: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _gen_nonce(prefix: str = "stats") -> str:
|
||||
base = f"{prefix}:{_now_ts()}:{time.time_ns()}"
|
||||
return hashlib.sha256(base.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemMetrics:
|
||||
cpu_percent: Optional[float] = None
|
||||
cpu_load_avg_1m: Optional[float] = None
|
||||
cpu_load_avg_5m: Optional[float] = None
|
||||
cpu_load_avg_15m: Optional[float] = None
|
||||
|
||||
mem_total_mb: Optional[float] = None
|
||||
mem_used_mb: Optional[float] = None
|
||||
mem_available_mb: Optional[float] = None
|
||||
mem_percent: Optional[float] = None
|
||||
|
||||
disk_total_mb: Optional[float] = None
|
||||
disk_used_mb: Optional[float] = None
|
||||
disk_free_mb: Optional[float] = None
|
||||
disk_percent: Optional[float] = None
|
||||
|
||||
io_read_mb_s: Optional[float] = None
|
||||
io_write_mb_s: Optional[float] = None
|
||||
|
||||
net_sent_kb_s: Optional[float] = None
|
||||
net_recv_kb_s: Optional[float] = None
|
||||
|
||||
uptime_seconds: Optional[int] = None
|
||||
timestamp: int = field(default_factory=_now_ts)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "SystemMetrics":
|
||||
return SystemMetrics(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppMetrics:
|
||||
total_conversions: int = 0
|
||||
total_requests: int = 0
|
||||
total_errors: int = 0
|
||||
slow_ops_count: int = 0
|
||||
avg_response_ms: Optional[float] = None
|
||||
p95_response_ms: Optional[float] = None
|
||||
p99_response_ms: Optional[float] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: int = field(default_factory=_now_ts)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "AppMetrics":
|
||||
return AppMetrics(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeStats:
|
||||
node_id: str
|
||||
public_key: str
|
||||
system: SystemMetrics
|
||||
app: AppMetrics
|
||||
known_content_items: Optional[int] = None
|
||||
available_content_items: Optional[int] = None
|
||||
|
||||
protocol_version: str = "stats-gossip-v1"
|
||||
timestamp: int = field(default_factory=_now_ts)
|
||||
nonce: str = field(default_factory=_gen_nonce)
|
||||
signature: Optional[str] = None # ed25519
|
||||
|
||||
def to_dict(self, include_signature: bool = True) -> Dict[str, Any]:
|
||||
data = {
|
||||
"node_id": self.node_id,
|
||||
"public_key": self.public_key,
|
||||
"system": self.system.to_dict(),
|
||||
"app": self.app.to_dict(),
|
||||
"known_content_items": self.known_content_items,
|
||||
"available_content_items": self.available_content_items,
|
||||
"protocol_version": self.protocol_version,
|
||||
"timestamp": self.timestamp,
|
||||
"nonce": self.nonce,
|
||||
}
|
||||
if include_signature:
|
||||
data["signature"] = self.signature
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def canonical_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Для подписи удаляем signature и сортируем
|
||||
payload = dict(data)
|
||||
payload.pop("signature", None)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def to_signable_json(data: Dict[str, Any]) -> str:
|
||||
payload = NodeStats.canonical_payload(data)
|
||||
return json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "NodeStats":
|
||||
return NodeStats(
|
||||
node_id=data["node_id"],
|
||||
public_key=data["public_key"],
|
||||
system=SystemMetrics.from_dict(data["system"]),
|
||||
app=AppMetrics.from_dict(data["app"]),
|
||||
known_content_items=data.get("known_content_items"),
|
||||
available_content_items=data.get("available_content_items"),
|
||||
protocol_version=data.get("protocol_version", "stats-gossip-v1"),
|
||||
timestamp=data.get("timestamp", _now_ts()),
|
||||
nonce=data.get("nonce", _gen_nonce()),
|
||||
signature=data.get("signature"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkStats:
|
||||
# Сводная статистика по сети
|
||||
node_count: int
|
||||
active_nodes: int
|
||||
avg_uptime_seconds: Optional[float] = None
|
||||
avg_cpu_percent: Optional[float] = None
|
||||
avg_mem_percent: Optional[float] = None
|
||||
avg_latency_ms: Optional[float] = None
|
||||
total_available_content: Optional[int] = None
|
||||
health_score: Optional[float] = None # 0..100
|
||||
|
||||
timestamp: int = field(default_factory=_now_ts)
|
||||
nodes: List[Dict[str, Any]] = field(default_factory=list) # список упрощенных NodeStats резюме
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "NetworkStats":
|
||||
return NetworkStats(**data)
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""
|
||||
Результат валидации контента/чанков.
|
||||
"""
|
||||
ok: bool
|
||||
reason: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=_iso_now)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentSignature:
|
||||
"""
|
||||
Информация о подписи контента/объекта.
|
||||
"""
|
||||
signature: Optional[str]
|
||||
public_key_hex: Optional[str]
|
||||
algorithm: str = "ed25519"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrustScore:
|
||||
"""
|
||||
Итоговый скор доверия (0.0 - 1.0)
|
||||
"""
|
||||
node_id: str
|
||||
score: float
|
||||
updated_at: str = field(default_factory=_iso_now)
|
||||
reason: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
# Нормализация диапазона
|
||||
d["score"] = max(0.0, min(1.0, float(d["score"])))
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeTrust:
|
||||
"""
|
||||
Состояние доверия к ноде.
|
||||
"""
|
||||
node_id: str
|
||||
score: float = 0.5
|
||||
blacklisted: bool = False
|
||||
manual_override: bool = False
|
||||
note: Optional[str] = None
|
||||
updated_at: str = field(default_factory=_iso_now)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["score"] = max(0.0, min(1.0, float(d["score"])))
|
||||
return d
|
||||
+22
-1
@@ -574,4 +574,25 @@ def constant_time_compare(a: str, b: str) -> bool:
|
||||
Returns:
|
||||
bool: True if strings are equal
|
||||
"""
|
||||
return hmac.compare_digest(a.encode('utf-8'), b.encode('utf-8'))
|
||||
return hmac.compare_digest(a.encode('utf-8'), b.encode('utf-8'))
|
||||
# --- Added for optional auth compatibility ---
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
# If get_current_user already exists in this module, import it
|
||||
from app.core.security import get_current_user # type: ignore
|
||||
except Exception:
|
||||
# Fallback stub in case the project structure differs; will only be used if referenced directly
|
||||
def get_current_user():
|
||||
raise RuntimeError("get_current_user is not available")
|
||||
|
||||
def get_current_user_optional() -> Optional[object]:
|
||||
"""
|
||||
Return current user if authenticated, otherwise None.
|
||||
Designed to be used in dependencies for routes that allow anonymous access.
|
||||
"""
|
||||
try:
|
||||
return get_current_user() # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
# --- End added block ---
|
||||
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from app.core.models.stats.metrics_models import SystemMetrics, AppMetrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _try_import_psutil():
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
return psutil
|
||||
except Exception as e:
|
||||
logger.warning("psutil not available, system metrics will be limited: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class MetricsCollector:
|
||||
"""
|
||||
Сборщик внутренних метрик:
|
||||
- System: CPU, RAM, Disk, IO, Network
|
||||
- App: conversions, requests, errors, slow ops, latency
|
||||
Хранит только последнюю сессию счетчиков (агрегация истории выполняется в StatsAggregator).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._psutil = _try_import_psutil()
|
||||
|
||||
# App counters
|
||||
self._total_conversions = 0
|
||||
self._total_requests = 0
|
||||
self._total_errors = 0
|
||||
self._slow_ops_count = 0
|
||||
|
||||
# Latency rolling values (экспоненциальная сглаженная средняя для p95/p99 — упрощённо)
|
||||
self._avg_response_ms: Optional[float] = None
|
||||
self._p95_response_ms: Optional[float] = None
|
||||
self._p99_response_ms: Optional[float] = None
|
||||
|
||||
# Previous snapshots for rate calculations
|
||||
self._last_disk_io: Optional[Tuple[int, int, float]] = None # (read_bytes, write_bytes, ts)
|
||||
self._last_net_io: Optional[Tuple[int, int, float]] = None # (bytes_sent, bytes_recv, ts)
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
self._start_ts = int(os.getenv("NODE_START_TS", str(int(time.time()))))
|
||||
except Exception:
|
||||
self._start_ts = int(time.time())
|
||||
|
||||
# Async lock to protect counters
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def collect_system_metrics(self) -> SystemMetrics:
|
||||
ps = self._psutil
|
||||
now = time.time()
|
||||
|
||||
cpu_percent = None
|
||||
load1 = load5 = load15 = None
|
||||
mem_total = mem_used = mem_available = mem_percent = None
|
||||
disk_total = disk_used = disk_free = disk_percent = None
|
||||
io_read_mb_s = io_write_mb_s = None
|
||||
net_sent_kb_s = net_recv_kb_s = None
|
||||
|
||||
try:
|
||||
if ps:
|
||||
# CPU
|
||||
cpu_percent = float(ps.cpu_percent(interval=None))
|
||||
try:
|
||||
load1, load5, load15 = ps.getloadavg() if hasattr(ps, "getloadavg") else os.getloadavg() # type: ignore
|
||||
except Exception:
|
||||
load1 = load5 = load15 = None
|
||||
|
||||
# Memory
|
||||
vm = ps.virtual_memory()
|
||||
mem_total = round(vm.total / (1024 * 1024), 2)
|
||||
mem_used = round(vm.used / (1024 * 1024), 2)
|
||||
mem_available = round(vm.available / (1024 * 1024), 2)
|
||||
mem_percent = float(vm.percent)
|
||||
|
||||
# Disk
|
||||
du = ps.disk_usage("/")
|
||||
disk_total = round(du.total / (1024 * 1024), 2)
|
||||
disk_used = round(du.used / (1024 * 1024), 2)
|
||||
disk_free = round(du.free / (1024 * 1024), 2)
|
||||
disk_percent = float(du.percent)
|
||||
|
||||
# IO rates
|
||||
try:
|
||||
dio = ps.disk_io_counters()
|
||||
if dio and self._last_disk_io:
|
||||
last_read, last_write, last_ts = self._last_disk_io
|
||||
dt = max(now - last_ts, 1e-6)
|
||||
io_read_mb_s = round((max(dio.read_bytes - last_read, 0) / (1024 * 1024)) / dt, 3)
|
||||
io_write_mb_s = round((max(dio.write_bytes - last_write, 0) / (1024 * 1024)) / dt, 3)
|
||||
self._last_disk_io = (dio.read_bytes, dio.write_bytes, now) if dio else self._last_disk_io
|
||||
except Exception:
|
||||
io_read_mb_s = io_write_mb_s = None
|
||||
|
||||
# NET rates
|
||||
try:
|
||||
nio = ps.net_io_counters()
|
||||
if nio and self._last_net_io:
|
||||
last_sent, last_recv, last_ts = self._last_net_io
|
||||
dt = max(now - last_ts, 1e-6)
|
||||
net_sent_kb_s = round((max(nio.bytes_sent - last_sent, 0) / 1024) / dt, 3)
|
||||
net_recv_kb_s = round((max(nio.bytes_recv - last_recv, 0) / 1024) / dt, 3)
|
||||
self._last_net_io = (nio.bytes_sent, nio.bytes_recv, now) if nio else self._last_net_io
|
||||
except Exception:
|
||||
net_sent_kb_s = net_recv_kb_s = None
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("collect_system_metrics error: %s", e)
|
||||
|
||||
return SystemMetrics(
|
||||
cpu_percent=cpu_percent,
|
||||
cpu_load_avg_1m=load1,
|
||||
cpu_load_avg_5m=load5,
|
||||
cpu_load_avg_15m=load15,
|
||||
mem_total_mb=mem_total,
|
||||
mem_used_mb=mem_used,
|
||||
mem_available_mb=mem_available,
|
||||
mem_percent=mem_percent,
|
||||
disk_total_mb=disk_total,
|
||||
disk_used_mb=disk_used,
|
||||
disk_free_mb=disk_free,
|
||||
disk_percent=disk_percent,
|
||||
io_read_mb_s=io_read_mb_s,
|
||||
io_write_mb_s=io_write_mb_s,
|
||||
net_sent_kb_s=net_sent_kb_s,
|
||||
net_recv_kb_s=net_recv_kb_s,
|
||||
uptime_seconds=int(time.time()) - self._start_ts,
|
||||
)
|
||||
|
||||
async def collect_app_metrics(self) -> AppMetrics:
|
||||
# Снимок текущих счетчиков; агрегирование распределено в StatsAggregator
|
||||
async with self._lock:
|
||||
return AppMetrics(
|
||||
total_conversions=self._total_conversions,
|
||||
total_requests=self._total_requests,
|
||||
total_errors=self._total_errors,
|
||||
slow_ops_count=self._slow_ops_count,
|
||||
avg_response_ms=self._avg_response_ms,
|
||||
p95_response_ms=self._p95_response_ms,
|
||||
p99_response_ms=self._p99_response_ms,
|
||||
details={}, # можно расширить деталями модулей
|
||||
)
|
||||
|
||||
async def get_current_stats(self) -> Tuple[SystemMetrics, AppMetrics]:
|
||||
sysm = await self.collect_system_metrics()
|
||||
appm = await self.collect_app_metrics()
|
||||
return sysm, appm
|
||||
|
||||
# Hooks to update app metrics
|
||||
|
||||
async def inc_conversions(self, n: int = 1) -> None:
|
||||
async with self._lock:
|
||||
self._total_conversions += n
|
||||
|
||||
async def inc_requests(self, n: int = 1) -> None:
|
||||
async with self._lock:
|
||||
self._total_requests += n
|
||||
|
||||
async def inc_errors(self, n: int = 1) -> None:
|
||||
async with self._lock:
|
||||
self._total_errors += n
|
||||
|
||||
async def inc_slow_ops(self, n: int = 1) -> None:
|
||||
async with self._lock:
|
||||
self._slow_ops_count += n
|
||||
|
||||
async def observe_latency_ms(self, value_ms: float) -> None:
|
||||
"""
|
||||
Простая статистика латентности:
|
||||
- EMA для avg
|
||||
- аппроксимация p95/p99 по взвешенному максимуму (упрощённо, без HDR Histogram)
|
||||
"""
|
||||
async with self._lock:
|
||||
alpha = 0.1
|
||||
if self._avg_response_ms is None:
|
||||
self._avg_response_ms = value_ms
|
||||
else:
|
||||
self._avg_response_ms = (1 - alpha) * self._avg_response_ms + alpha * value_ms
|
||||
|
||||
# Простая аппроксимация квантили при помощи EMA "максимума"
|
||||
def ema_max(current: Optional[float], x: float, beta: float) -> float:
|
||||
return x if current is None else max((1 - beta) * current, x)
|
||||
|
||||
self._p95_response_ms = ema_max(self._p95_response_ms, value_ms, beta=0.05)
|
||||
self._p99_response_ms = ema_max(self._p99_response_ms, value_ms, beta=0.01)
|
||||
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import statistics
|
||||
import time
|
||||
from collections import deque, defaultdict
|
||||
from typing import Deque, Dict, Any, Optional, List, Tuple
|
||||
|
||||
from app.core.models.stats.metrics_models import SystemMetrics, AppMetrics, NodeStats, NetworkStats
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsAggregator:
|
||||
"""
|
||||
Агрегатор статистики:
|
||||
- хранит историю локальных метрик и входящих метрик от других нод (in-memory, ring buffer)
|
||||
- вычисляет агрегаты и тренды
|
||||
- предоставляет network overview
|
||||
"""
|
||||
|
||||
def __init__(self, history_limit: int = 1000) -> None:
|
||||
self._history_limit = history_limit
|
||||
|
||||
# История локальной ноды: deque[(ts, NodeStats)]
|
||||
self._local_history: Deque[Tuple[int, NodeStats]] = deque(maxlen=history_limit)
|
||||
|
||||
# История по нодам сети: node_id -> deque[(ts, NodeStats)]
|
||||
self._peers_history: Dict[str, Deque[Tuple[int, NodeStats]]] = defaultdict(lambda: deque(maxlen=history_limit))
|
||||
|
||||
# Кеш последнего слепка по нодам
|
||||
self._last_by_node: Dict[str, NodeStats] = {}
|
||||
|
||||
# Список известных пиров (URL) - поддержка network overview
|
||||
self._known_peers: List[str] = []
|
||||
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def set_known_peers(self, peers: List[str]) -> None:
|
||||
async with self._lock:
|
||||
self._known_peers = list(sorted(set(peers)))
|
||||
|
||||
async def add_local_snapshot(self, stats: NodeStats) -> None:
|
||||
async with self._lock:
|
||||
ts = stats.timestamp
|
||||
self._local_history.append((ts, stats))
|
||||
self._last_by_node[stats.node_id] = stats
|
||||
|
||||
async def add_peer_snapshot(self, stats: NodeStats) -> None:
|
||||
async with self._lock:
|
||||
ts = stats.timestamp
|
||||
dq = self._peers_history[stats.node_id]
|
||||
dq.append((ts, stats))
|
||||
self._last_by_node[stats.node_id] = stats
|
||||
|
||||
async def get_latest_local(self) -> Optional[NodeStats]:
|
||||
async with self._lock:
|
||||
return self._local_history[-1][1] if self._local_history else None
|
||||
|
||||
async def aggregate_node_stats(self, node_id: Optional[str] = None, last_n: int = 20) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает агрегаты для указанной ноды (по умолчанию локальная).
|
||||
"""
|
||||
async with self._lock:
|
||||
if node_id is None:
|
||||
series = list(self._local_history)[-last_n:]
|
||||
else:
|
||||
series = list(self._peers_history.get(node_id, deque()))[-last_n:]
|
||||
|
||||
if not series:
|
||||
return {"samples": 0}
|
||||
|
||||
# агрегаты по cpu/mem
|
||||
cpu = [s.system.cpu_percent for _, s in series if s.system.cpu_percent is not None]
|
||||
mem = [s.system.mem_percent for _, s in series if s.system.mem_percent is not None]
|
||||
|
||||
res = {
|
||||
"samples": len(series),
|
||||
"time_span_sec": (series[-1][0] - series[0][0]) if len(series) > 1 else 0,
|
||||
"cpu": {
|
||||
"avg": round(statistics.fmean(cpu), 3) if cpu else None,
|
||||
"max": round(max(cpu), 3) if cpu else None,
|
||||
"min": round(min(cpu), 3) if cpu else None,
|
||||
},
|
||||
"mem": {
|
||||
"avg": round(statistics.fmean(mem), 3) if mem else None,
|
||||
"max": round(max(mem), 3) if mem else None,
|
||||
"min": round(min(mem), 3) if mem else None,
|
||||
},
|
||||
}
|
||||
return res
|
||||
|
||||
async def get_network_overview(self) -> NetworkStats:
|
||||
"""
|
||||
Сводка по сети с использованием последних значений по всем известным нодам.
|
||||
"""
|
||||
async with self._lock:
|
||||
nodes = list(self._last_by_node.values())
|
||||
|
||||
node_count = len(nodes)
|
||||
active_nodes = sum(1 for n in nodes if (int(time.time()) - n.timestamp) <= 300)
|
||||
|
||||
uptimes = [n.system.uptime_seconds for n in nodes if n.system.uptime_seconds is not None]
|
||||
cpus = [n.system.cpu_percent for n in nodes if n.system.cpu_percent is not None]
|
||||
mems = [n.system.mem_percent for n in nodes if n.system.mem_percent is not None]
|
||||
|
||||
avg_uptime = round(statistics.fmean(uptimes), 3) if uptimes else None
|
||||
avg_cpu = round(statistics.fmean(cpus), 3) if cpus else None
|
||||
avg_mem = round(statistics.fmean(mems), 3) if mems else None
|
||||
|
||||
# Простейшая метрика "здоровья" сети: 100 - avg_cpu/avg_mem penalty
|
||||
health_score = None
|
||||
if avg_cpu is not None and avg_mem is not None:
|
||||
penalty = (avg_cpu / 2.0) + (avg_mem / 2.0) # 0..200
|
||||
health_score = max(0.0, 100.0 - min(100.0, penalty))
|
||||
|
||||
nodes_summary: List[Dict[str, Any]] = []
|
||||
for n in nodes:
|
||||
nodes_summary.append({
|
||||
"node_id": n.node_id,
|
||||
"uptime": n.system.uptime_seconds,
|
||||
"cpu": n.system.cpu_percent,
|
||||
"mem": n.system.mem_percent,
|
||||
"available_content_items": n.available_content_items,
|
||||
"timestamp": n.timestamp,
|
||||
})
|
||||
|
||||
# latency/total_available_content пока не вычисляем здесь, можно обновить из внешних сигналов
|
||||
return NetworkStats(
|
||||
node_count=node_count,
|
||||
active_nodes=active_nodes,
|
||||
avg_uptime_seconds=avg_uptime,
|
||||
avg_cpu_percent=avg_cpu,
|
||||
avg_mem_percent=avg_mem,
|
||||
avg_latency_ms=None,
|
||||
total_available_content=sum((n.available_content_items or 0) for n in nodes) if nodes else None,
|
||||
health_score=health_score,
|
||||
nodes=nodes_summary,
|
||||
)
|
||||
|
||||
async def calculate_trends(self, node_id: Optional[str] = None, window: int = 60) -> Dict[str, Any]:
|
||||
"""
|
||||
Грубая оценка тренда по cpu/mem: сравнение первых и последних значений окна.
|
||||
"""
|
||||
async with self._lock:
|
||||
series = list(self._local_history if node_id is None else self._peers_history.get(node_id, deque()))
|
||||
|
||||
if not series:
|
||||
return {}
|
||||
|
||||
# берем последние window секунд данных
|
||||
cutoff = int(time.time()) - window
|
||||
window_series = [s for s in series if s[0] >= cutoff]
|
||||
if len(window_series) < 2:
|
||||
return {"samples": len(window_series)}
|
||||
|
||||
first = window_series[0][1]
|
||||
last = window_series[-1][1]
|
||||
|
||||
def delta(a: Optional[float], b: Optional[float]) -> Optional[float]:
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return round(b - a, 3)
|
||||
|
||||
trend = {
|
||||
"samples": len(window_series),
|
||||
"cpu_percent_delta": delta(first.system.cpu_percent, last.system.cpu_percent),
|
||||
"mem_percent_delta": delta(first.system.mem_percent, last.system.mem_percent),
|
||||
}
|
||||
return trend
|
||||
|
||||
async def build_local_signed_stats(self) -> NodeStats:
|
||||
"""
|
||||
Собирает последний локальный слепок и подписывает.
|
||||
"""
|
||||
async with self._lock:
|
||||
latest = self._local_history[-1][1] if self._local_history else None
|
||||
|
||||
if not latest:
|
||||
raise RuntimeError("No local stats available")
|
||||
|
||||
crypto = get_ed25519_manager()
|
||||
payload = latest.to_dict(include_signature=False)
|
||||
signature = crypto.sign_message(NodeStats.canonical_payload(payload))
|
||||
latest.signature = signature
|
||||
return latest
|
||||
|
||||
# Вспомогательные методы для тестов/диагностики
|
||||
|
||||
async def list_known_peers(self) -> List[str]:
|
||||
async with self._lock:
|
||||
return list(self._known_peers)
|
||||
|
||||
async def last_by_node(self) -> Dict[str, NodeStats]:
|
||||
async with self._lock:
|
||||
return dict(self._last_by_node)
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
from app.core.models.validation.validation_models import ValidationResult, ContentSignature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentValidator:
|
||||
"""
|
||||
Основной валидатор контента:
|
||||
- Проверка подписи источника (Ed25519)
|
||||
- Проверка целостности контента/объектов (checksum/content_id)
|
||||
- Интеграция с ContentCipher для дополнительной верификации
|
||||
"""
|
||||
|
||||
def __init__(self, cipher: Optional[ContentCipher] = None):
|
||||
self.cipher = cipher or ContentCipher()
|
||||
logger.debug("ContentValidator initialized")
|
||||
|
||||
def verify_source_signature(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
signature_b64: Optional[str],
|
||||
public_key_hex: Optional[str],
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Проверка Ed25519 подписи источника.
|
||||
- payload должен сериализоваться идентично тому, что подписывалось.
|
||||
- signature_b64 - base64 строка подписи.
|
||||
- public_key_hex - hex публичного ключа источника.
|
||||
"""
|
||||
try:
|
||||
if not signature_b64 or not public_key_hex:
|
||||
logger.warning("verify_source_signature: missing signature/public key")
|
||||
return ValidationResult(ok=False, reason="missing_signature_or_public_key")
|
||||
|
||||
crypto_mgr = get_ed25519_manager()
|
||||
ok = crypto_mgr.verify_signature(payload, signature_b64, public_key_hex)
|
||||
if not ok:
|
||||
logger.warning("verify_source_signature: invalid signature")
|
||||
return ValidationResult(ok=False, reason="invalid_signature")
|
||||
|
||||
logger.info("verify_source_signature: signature valid")
|
||||
return ValidationResult(ok=True, details={"signer_key": public_key_hex})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("verify_source_signature error")
|
||||
return ValidationResult(ok=False, reason=str(e))
|
||||
|
||||
def check_content_integrity(
|
||||
self,
|
||||
encrypted_obj: Dict[str, Any],
|
||||
expected_metadata: Optional[Dict[str, Any]] = None,
|
||||
verify_signature: bool = True,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Делегирует проверку целостности ContentCipher:
|
||||
- сверка content_id = sha256(ciphertext||nonce||tag||metadata_json)
|
||||
- опциональная проверка встроенной подписи encrypted_obj (если есть signature/signер_pubkey)
|
||||
"""
|
||||
ok, err = self.cipher.verify_content_integrity(
|
||||
encrypted_obj=encrypted_obj,
|
||||
expected_metadata=expected_metadata,
|
||||
verify_signature=verify_signature,
|
||||
)
|
||||
if not ok:
|
||||
return ValidationResult(ok=False, reason=err or "integrity_failed")
|
||||
|
||||
return ValidationResult(ok=True)
|
||||
|
||||
def validate_content(
|
||||
self,
|
||||
content_meta: Dict[str, Any],
|
||||
*,
|
||||
checksum: Optional[str] = None,
|
||||
source_signature: Optional[ContentSignature] = None,
|
||||
encrypted_obj: Optional[Dict[str, Any]] = None,
|
||||
verify_ed25519: bool = True,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Комплексная проверка валидности контента:
|
||||
1) Если указан checksum (<algo>:<hex>), сверяем.
|
||||
2) Если указан source_signature, проверяем Ed25519 подпись источника.
|
||||
3) Если передан encrypted_obj, выполняем углублённую проверку ContentCipher.
|
||||
|
||||
content_meta — произвольная структура метаданных, которая была объектом подписи источника.
|
||||
"""
|
||||
# 1. Проверка checksum (формат: "sha256:<hex>")
|
||||
if checksum:
|
||||
try:
|
||||
algo, hexval = checksum.split(":", 1)
|
||||
algo = algo.lower()
|
||||
if algo != "sha256":
|
||||
logger.warning("validate_content: unsupported checksum algo: %s", algo)
|
||||
return ValidationResult(ok=False, reason="unsupported_checksum_algo", details={"algo": algo})
|
||||
|
||||
# Вычислить sha256 по ожидаемым данным невозможно без исходных байт,
|
||||
# поэтому здесь лишь проверка формата. Фактическая сверка должна происходить
|
||||
# на уровне получателя с использованием известного буфера.
|
||||
if not all(c in "0123456789abcdef" for c in hexval.lower()) or len(hexval) != 64:
|
||||
return ValidationResult(ok=False, reason="invalid_checksum_format")
|
||||
|
||||
logger.debug("validate_content: checksum format looks valid (sha256)")
|
||||
except Exception:
|
||||
return ValidationResult(ok=False, reason="invalid_checksum")
|
||||
|
||||
# 2. Проверка подписи источника (если указана)
|
||||
if verify_ed25519 and source_signature:
|
||||
sig_check = self.verify_source_signature(
|
||||
payload=content_meta,
|
||||
signature_b64=source_signature.signature,
|
||||
public_key_hex=source_signature.public_key_hex,
|
||||
)
|
||||
if not sig_check.ok:
|
||||
return ValidationResult(ok=False, reason="source_signature_invalid", details=sig_check.to_dict())
|
||||
|
||||
# 3. Проверка целостности зашифрованного объекта (если присутствует)
|
||||
if encrypted_obj:
|
||||
integ = self.check_content_integrity(
|
||||
encrypted_obj=encrypted_obj,
|
||||
expected_metadata=encrypted_obj.get("metadata"),
|
||||
verify_signature=verify_ed25519,
|
||||
)
|
||||
if not integ.ok:
|
||||
return ValidationResult(ok=False, reason="encrypted_integrity_invalid", details=integ.to_dict())
|
||||
|
||||
logger.info("validate_content: content validation passed")
|
||||
return ValidationResult(ok=True)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.crypto.content_cipher import ContentCipher
|
||||
from app.core.models.content.chunk import ContentChunk
|
||||
from app.core.models.validation.validation_models import ValidationResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntegrityChecker:
|
||||
"""
|
||||
Расширенная проверка целостности контента/чанков поверх возможностей ChunkManager:
|
||||
- Поблочная проверка каждой записи (хеш/подпись)
|
||||
- Обнаружение повреждений и дубликатов
|
||||
- Проверка "цепочки" контента (согласованность content_id/индексов)
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_manager: Optional[ChunkManager] = None, cipher: Optional[ContentCipher] = None):
|
||||
self.chunk_manager = chunk_manager or ChunkManager()
|
||||
self.cipher = cipher or self.chunk_manager.cipher
|
||||
logger.debug("IntegrityChecker initialized")
|
||||
|
||||
def check_chunk_integrity(self, chunk: ContentChunk, verify_signature: bool = True) -> ValidationResult:
|
||||
"""
|
||||
Проверяет единичный чанк, используя ChunkManager.verify_chunk_integrity.
|
||||
"""
|
||||
ok, err = self.chunk_manager.verify_chunk_integrity(chunk, verify_signature=verify_signature)
|
||||
if not ok:
|
||||
logger.warning("check_chunk_integrity: chunk invalid: %s -> %s", chunk.chunk_id, err)
|
||||
return ValidationResult(ok=False, reason=err or "chunk_invalid", details={"chunk_id": chunk.chunk_id})
|
||||
return ValidationResult(ok=True, details={"chunk_id": chunk.chunk_id})
|
||||
|
||||
def detect_corruption(self, chunks: Iterable[ContentChunk]) -> ValidationResult:
|
||||
"""
|
||||
Выявляет повреждения и аномалии:
|
||||
- дубликаты chunk_id/chunk_index
|
||||
- несовпадение content_id между чанками
|
||||
- несогласованность индексов (пропуски/повторы)
|
||||
"""
|
||||
try:
|
||||
chunks_list: List[ContentChunk] = sorted(list(chunks), key=lambda c: c.chunk_index)
|
||||
if not chunks_list:
|
||||
return ValidationResult(ok=True, details={"message": "no chunks"})
|
||||
|
||||
content_ids = {c.content_id for c in chunks_list}
|
||||
if len(content_ids) != 1:
|
||||
return ValidationResult(ok=False, reason="mixed_content_ids", details={"content_ids": list(content_ids)})
|
||||
|
||||
seen_ids = set()
|
||||
seen_indexes = set()
|
||||
duplicates: List[str] = []
|
||||
gaps: List[int] = []
|
||||
|
||||
for c in chunks_list:
|
||||
if c.chunk_id in seen_ids:
|
||||
duplicates.append(c.chunk_id)
|
||||
else:
|
||||
seen_ids.add(c.chunk_id)
|
||||
|
||||
if c.chunk_index in seen_indexes:
|
||||
duplicates.append(f"index:{c.chunk_index}")
|
||||
else:
|
||||
seen_indexes.add(c.chunk_index)
|
||||
|
||||
if chunks_list:
|
||||
min_idx = chunks_list[0].chunk_index
|
||||
max_idx = chunks_list[-1].chunk_index
|
||||
expected = set(range(min_idx, max_idx + 1))
|
||||
gaps = sorted(list(expected - seen_indexes))
|
||||
|
||||
if duplicates or gaps:
|
||||
return ValidationResult(
|
||||
ok=False,
|
||||
reason="structure_anomaly",
|
||||
details={"duplicates": duplicates, "missing_indexes": gaps},
|
||||
)
|
||||
|
||||
return ValidationResult(ok=True, details={"content_id": chunks_list[0].content_id})
|
||||
except Exception as e:
|
||||
logger.exception("detect_corruption error")
|
||||
return ValidationResult(ok=False, reason=str(e))
|
||||
|
||||
def verify_content_chain(
|
||||
self,
|
||||
chunks: Iterable[ContentChunk],
|
||||
verify_signatures: bool = True,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Полная проверка набора чанков:
|
||||
1) detect_corruption на структуру/последовательность
|
||||
2) check_chunk_integrity для каждого чанка (хеш/подпись)
|
||||
"""
|
||||
try:
|
||||
chunks_list = list(chunks)
|
||||
structure = self.detect_corruption(chunks_list)
|
||||
if not structure.ok:
|
||||
return structure
|
||||
|
||||
errors: List[Dict[str, Any]] = []
|
||||
ok_count = 0
|
||||
for c in chunks_list:
|
||||
res = self.check_chunk_integrity(c, verify_signature=verify_signatures)
|
||||
if not res.ok:
|
||||
errors.append({"chunk_id": c.chunk_id, "error": res.reason})
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
if errors:
|
||||
return ValidationResult(ok=False, reason="chain_integrity_failed", details={"verified_ok": ok_count, "errors": errors})
|
||||
|
||||
return ValidationResult(ok=True, details={"verified_ok": ok_count})
|
||||
except Exception as e:
|
||||
logger.exception("verify_content_chain error")
|
||||
return ValidationResult(ok=False, reason=str(e))
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.core.models.validation.validation_models import TrustScore, NodeTrust
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrustManager:
|
||||
"""
|
||||
Управление доверием между нодами.
|
||||
- Хранит score (0.0-1.0), blacklist и флаг manual_override.
|
||||
- Предоставляет API для оценки/обновления/проверки доверия.
|
||||
"""
|
||||
|
||||
def __init__(self, default_score: float = 0.5, min_trusted: float = 0.6):
|
||||
self._nodes: Dict[str, NodeTrust] = {}
|
||||
self.default_score = max(0.0, min(1.0, float(default_score)))
|
||||
self.min_trusted = max(0.0, min(1.0, float(min_trusted)))
|
||||
logger.debug("TrustManager initialized: default_score=%s, min_trusted=%s", self.default_score, self.min_trusted)
|
||||
|
||||
def _get_or_create(self, node_id: str) -> NodeTrust:
|
||||
if node_id not in self._nodes:
|
||||
self._nodes[node_id] = NodeTrust(node_id=node_id, score=self.default_score)
|
||||
logger.info("TrustManager: new node registered with default score: %s", node_id)
|
||||
return self._nodes[node_id]
|
||||
|
||||
def assess_node_trust(self, node_id: str) -> TrustScore:
|
||||
"""
|
||||
Вернуть текущий TrustScore для ноды.
|
||||
"""
|
||||
state = self._get_or_create(node_id)
|
||||
logger.debug("assess_node_trust: %s -> score=%.3f, blacklisted=%s, override=%s",
|
||||
node_id, state.score, state.blacklisted, state.manual_override)
|
||||
return TrustScore(node_id=node_id, score=state.score, reason=("blacklisted" if state.blacklisted else None))
|
||||
|
||||
def update_trust_score(self, node_id: str, delta: float, *, reason: Optional[str] = None) -> TrustScore:
|
||||
"""
|
||||
Обновить score ноды на delta в диапазоне [0.0, 1.0].
|
||||
Положительное delta увеличивает доверие, отрицательное — уменьшает.
|
||||
"""
|
||||
state = self._get_or_create(node_id)
|
||||
prev = state.score
|
||||
state.score = max(0.0, min(1.0, prev + float(delta)))
|
||||
if reason:
|
||||
state.note = reason
|
||||
logger.info("update_trust_score: %s: %.3f -> %.3f (reason=%s)", node_id, prev, state.score, reason)
|
||||
return TrustScore(node_id=node_id, score=state.score, reason=reason)
|
||||
|
||||
def set_blacklist(self, node_id: str, blacklisted: bool = True, *, note: Optional[str] = None) -> NodeTrust:
|
||||
"""
|
||||
Добавить/убрать ноду из blacklist.
|
||||
"""
|
||||
state = self._get_or_create(node_id)
|
||||
state.blacklisted = bool(blacklisted)
|
||||
if note:
|
||||
state.note = note
|
||||
logger.warning("set_blacklist: %s -> %s", node_id, state.blacklisted)
|
||||
return state
|
||||
|
||||
def set_manual_override(self, node_id: str, override: bool = True, *, note: Optional[str] = None) -> NodeTrust:
|
||||
"""
|
||||
Установить ручной override доверия для ноды (форсированное доверие).
|
||||
"""
|
||||
state = self._get_or_create(node_id)
|
||||
state.manual_override = bool(override)
|
||||
if note:
|
||||
state.note = note
|
||||
logger.warning("set_manual_override: %s -> %s", node_id, state.manual_override)
|
||||
return state
|
||||
|
||||
def is_node_trusted(self, node_id: str) -> bool:
|
||||
"""
|
||||
Возвращает True если нода считается доверенной:
|
||||
- НЕ находится в blacklist
|
||||
- Имеет score >= min_trusted
|
||||
- ЛИБО установлен manual_override (в этом случае blacklist игнорируется только если override True)
|
||||
"""
|
||||
state = self._get_or_create(node_id)
|
||||
|
||||
if state.manual_override:
|
||||
logger.debug("is_node_trusted: %s -> True (manual_override)", node_id)
|
||||
return True
|
||||
|
||||
if state.blacklisted:
|
||||
logger.debug("is_node_trusted: %s -> False (blacklisted)", node_id)
|
||||
return False
|
||||
|
||||
trusted = state.score >= self.min_trusted
|
||||
logger.debug("is_node_trusted: %s -> %s (score=%.3f, min_trusted=%.3f)", node_id, trusted, state.score, self.min_trusted)
|
||||
return trusted
|
||||
|
||||
def export_state(self) -> Dict[str, Dict]:
|
||||
"""
|
||||
Экспорт текущего состояния (для сериализации/персистентности).
|
||||
"""
|
||||
return {nid: self._nodes[nid].to_dict() for nid in self._nodes}
|
||||
|
||||
def import_state(self, data: Dict[str, Dict]) -> None:
|
||||
"""
|
||||
Импорт состояния (восстановление из персистентного хранилища).
|
||||
"""
|
||||
self._nodes.clear()
|
||||
for nid, raw in data.items():
|
||||
try:
|
||||
self._nodes[nid] = NodeTrust(
|
||||
node_id=raw["node_id"],
|
||||
score=float(raw.get("score", self.default_score)),
|
||||
blacklisted=bool(raw.get("blacklisted", False)),
|
||||
manual_override=bool(raw.get("manual_override", False)),
|
||||
note=raw.get("note"),
|
||||
updated_at=raw.get("updated_at"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to import node trust record %s: %s", nid, e)
|
||||
logger.info("TrustManager state imported: nodes=%d", len(self._nodes))
|
||||
Reference in new issue
Block a user