fixes global

This commit is contained in:
user committed 2025-08-08 09:14:18 +03:00
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
+169
View File
@@ -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")
+94
View File
@@ -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()
+122
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
+233
View File
@@ -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)
+186
View File
@@ -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
+271
View File
@@ -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)
+214
View File
@@ -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
+4 -2
View File
@@ -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',
]
+231
View File
@@ -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)
+14 -4
View File
@@ -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:
+42
View File
@@ -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 # подпись может быть и в заголовке
+74
View File
@@ -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
+54
View File
@@ -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
+60
View File
@@ -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
+151
View File
@@ -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
View File
@@ -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 ---
+173
View File
@@ -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)
+194
View File
@@ -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)
+198
View File
@@ -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)
+136
View File
@@ -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)
+119
View File
@@ -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))
+119
View File
@@ -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))