126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List
|
|
|
|
from sanic import response
|
|
|
|
from app.core.logger import make_log
|
|
from app.core._utils.b58 import b58decode
|
|
from app.core.network.dht.records import DHTRecord
|
|
from app.core.network.dht.store import DHTStore
|
|
from app.core.network.dht.crypto import compute_node_id
|
|
from app.core.network.dht.keys import MetaKey, MembershipKey, MetricKey
|
|
from sqlalchemy import select
|
|
from app.core.models.my_network import KnownNode
|
|
|
|
|
|
def _merge_strategy_for(key: str):
|
|
# Выбираем правильную стратегию merge по префиксу ключа
|
|
from app.core.network.dht.replication import ReplicationState
|
|
from app.core.network.dht.membership import MembershipState
|
|
from app.core.network.dht.metrics import ContentMetricsState
|
|
if key.startswith('meta:'):
|
|
return lambda a, b: ReplicationState.from_dict(a).merge_with(ReplicationState.from_dict(b)).to_dict()
|
|
if key.startswith('membership:'):
|
|
# Для membership нужен node_id, но это только для локального состояния; здесь достаточно CRDT-мерджа
|
|
return lambda a, b: MembershipState.from_dict('remote', None, a).merge(MembershipState.from_dict('remote', None, b)).to_dict()
|
|
if key.startswith('metric:'):
|
|
return lambda a, b: ContentMetricsState.from_dict('remote', a).merge(ContentMetricsState.from_dict('remote', b)).to_dict()
|
|
return lambda a, b: b
|
|
|
|
|
|
async def s_api_v1_dht_get(request):
|
|
"""Возвращает запись DHT по fingerprint или key."""
|
|
store: DHTStore = request.app.ctx.memory.dht_store
|
|
fp = request.args.get('fingerprint')
|
|
key = request.args.get('key')
|
|
if fp:
|
|
rec = store.get(fp)
|
|
if not rec:
|
|
return response.json({'error': 'NOT_FOUND'}, status=404)
|
|
return response.json({**rec.to_payload(), 'signature': rec.signature})
|
|
if key:
|
|
snap = store.snapshot()
|
|
for _fp, payload in snap.items():
|
|
if payload.get('key') == key:
|
|
return response.json(payload)
|
|
return response.json({'error': 'NOT_FOUND'}, status=404)
|
|
return response.json({'error': 'BAD_REQUEST'}, status=400)
|
|
|
|
|
|
def _verify_publisher(node_id: str, public_key_b58: str) -> bool:
|
|
try:
|
|
derived = compute_node_id(b58decode(public_key_b58))
|
|
return derived == node_id
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def s_api_v1_dht_put(request):
|
|
"""Принимает запись(и) DHT, проверяет подпись и выполняет merge/persist.
|
|
|
|
Поддерживает одиночную запись (record: {...}) и пакет (records: [{...}]).
|
|
Требует поле public_key отправителя и соответствие node_id.
|
|
"""
|
|
mem = request.app.ctx.memory
|
|
store: DHTStore = mem.dht_store
|
|
data = request.json or {}
|
|
public_key = data.get('public_key')
|
|
if not public_key:
|
|
return response.json({'error': 'MISSING_PUBLIC_KEY'}, status=400)
|
|
|
|
# Determine publisher role (trusted/read-only/deny)
|
|
role = None
|
|
try:
|
|
session = request.ctx.db_session
|
|
kn = (await session.execute(select(KnownNode).where(KnownNode.public_key == public_key))).scalars().first()
|
|
role = (kn.meta or {}).get('role') if kn and kn.meta else None
|
|
except Exception:
|
|
role = None
|
|
|
|
def _process_one(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
try:
|
|
rec = DHTRecord.create(
|
|
key=payload['key'],
|
|
fingerprint=payload['fingerprint'],
|
|
value=payload['value'],
|
|
node_id=payload['node_id'],
|
|
logical_counter=int(payload['logical_counter']),
|
|
signature=payload.get('signature'),
|
|
timestamp=float(payload.get('timestamp') or 0),
|
|
)
|
|
except Exception as e:
|
|
return {'error': f'BAD_RECORD: {e}'}
|
|
if not _verify_publisher(rec.node_id, public_key):
|
|
return {'error': 'NODE_ID_MISMATCH'}
|
|
# Подтверждение подписи записи
|
|
if not rec.verify(public_key):
|
|
return {'error': 'BAD_SIGNATURE'}
|
|
# Enforce ACL: untrusted nodes may not mutate meta/metric records
|
|
if role != 'trusted':
|
|
if rec.key.startswith('meta:') or rec.key.startswith('metric:'):
|
|
return {'error': 'FORBIDDEN_NOT_TRUSTED'}
|
|
merge_fn = _merge_strategy_for(rec.key)
|
|
try:
|
|
merged = store.merge_record(rec, merge_fn)
|
|
return {'ok': True, 'fingerprint': merged.fingerprint}
|
|
except Exception as e:
|
|
make_log('DHT.put', f'merge failed: {e}', level='warning')
|
|
return {'error': 'MERGE_FAILED'}
|
|
|
|
if 'record' in data:
|
|
result = _process_one(data['record'])
|
|
status = 200 if 'ok' in result else 400
|
|
return response.json(result, status=status)
|
|
elif 'records' in data and isinstance(data['records'], list):
|
|
results: List[Dict[str, Any]] = []
|
|
ok = True
|
|
for item in data['records']:
|
|
res = _process_one(item)
|
|
if 'error' in res:
|
|
ok = False
|
|
results.append(res)
|
|
return response.json({'ok': ok, 'results': results}, status=200 if ok else 207)
|
|
return response.json({'error': 'BAD_REQUEST'}, status=400)
|