837 lines
37 KiB
Python
837 lines
37 KiB
Python
|
|
# leader_election.py
|
|||
|
|
# Montana Protocol — 3-Mirror Leader Election
|
|||
|
|
#
|
|||
|
|
# Архитектура из 003_ТРОЙНОЕ_ЗЕРКАЛО.md:
|
|||
|
|
# - Детерминированный выбор лидера по цепочке
|
|||
|
|
# - Активная проверка "кто жив" каждые 5 сек
|
|||
|
|
# - Я лидер если ВСЕ узлы ДО меня в цепочке мертвы
|
|||
|
|
# - Failover < 10 секунд
|
|||
|
|
# - Breathing Sync: git pull/push каждые 12 сек
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import asyncio
|
|||
|
|
import logging
|
|||
|
|
import socket
|
|||
|
|
import subprocess
|
|||
|
|
import random
|
|||
|
|
import time
|
|||
|
|
import psutil
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional, List, Tuple, Dict
|
|||
|
|
from datetime import datetime
|
|||
|
|
from collections import deque
|
|||
|
|
|
|||
|
|
# Breathing Sync
|
|||
|
|
try:
|
|||
|
|
from breathing_sync import get_breathing_sync, BreathingSync
|
|||
|
|
BREATHING_SYNC_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
BREATHING_SYNC_AVAILABLE = False
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ЦЕПОЧКА УЗЛОВ (из документации)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# BOT_CHAIN — цепочка для Montana API (кто делает polling)
|
|||
|
|
# Порядок = приоритет. Первый живой = master.
|
|||
|
|
BOT_CHAIN: List[Tuple[str, str]] = [
|
|||
|
|
("amsterdam", "72.56.102.240"), # PRIMARY
|
|||
|
|
("moscow", "176.124.208.93"), # MIRROR 1
|
|||
|
|
("almaty", "91.200.148.93"), # MIRROR 2
|
|||
|
|
("spb", "188.225.58.98"), # MIRROR 3
|
|||
|
|
("novosibirsk", "147.45.147.247"), # MIRROR 4
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Константы мониторинга
|
|||
|
|
CHECK_INTERVAL = 5 # секунд между проверками
|
|||
|
|
PING_TIMEOUT = 2 # секунд таймаут пинга
|
|||
|
|
STARTUP_DELAY = 3 # секунд перед первой проверкой
|
|||
|
|
BOT_HEALTH_PORT = 8889 # Порт для проверки здоровья БОТА (не сервера)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ПРОВЕРКА УЗЛОВ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def is_node_alive(ip: str, timeout: int = PING_TIMEOUT) -> bool:
|
|||
|
|
"""
|
|||
|
|
Проверить жив ли узел через ping.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True если узел отвечает на ping
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# Linux/macOS ping с таймаутом
|
|||
|
|
if os.name == 'nt':
|
|||
|
|
cmd = ['ping', '-n', '1', '-w', str(timeout * 1000), ip]
|
|||
|
|
else:
|
|||
|
|
cmd = ['ping', '-c', '1', '-W', str(timeout), ip]
|
|||
|
|
|
|||
|
|
result = subprocess.run(
|
|||
|
|
cmd,
|
|||
|
|
stdout=subprocess.DEVNULL,
|
|||
|
|
stderr=subprocess.DEVNULL,
|
|||
|
|
timeout=timeout + 1
|
|||
|
|
)
|
|||
|
|
return result.returncode == 0
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
return False
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Ping error {ip}: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_node_alive_tcp(ip: str, port: int = 22, timeout: int = PING_TIMEOUT) -> bool:
|
|||
|
|
"""
|
|||
|
|
Проверить жив ли узел через TCP connect (SSH порт).
|
|||
|
|
Более надёжно чем ICMP ping если firewall блокирует ping.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|||
|
|
sock.settimeout(timeout)
|
|||
|
|
result = sock.connect_ex((ip, port))
|
|||
|
|
sock.close()
|
|||
|
|
return result == 0
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"TCP check error {ip}:{port}: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_node_health(ip: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
Комплексная проверка здоровья узла.
|
|||
|
|
|
|||
|
|
ВАЖНО: Сначала проверяем порт БОТА (8889), а не сервера!
|
|||
|
|
Если порт бота закрыт — бот не работает, даже если сервер жив.
|
|||
|
|
|
|||
|
|
Порядок проверок:
|
|||
|
|
1. BOT_HEALTH_PORT (8889) — бот запущен?
|
|||
|
|
2. Ping — fallback если порт не настроен
|
|||
|
|
3. TCP 22/443 — последний fallback
|
|||
|
|
"""
|
|||
|
|
# ГЛАВНАЯ проверка — порт здоровья бота
|
|||
|
|
if is_node_alive_tcp(ip, BOT_HEALTH_PORT, timeout=1):
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# Если порт бота закрыт — считаем узел мёртвым для целей leader election
|
|||
|
|
# Но проверяем что сервер хотя бы жив (чтобы отличить от полностью мёртвого)
|
|||
|
|
server_alive = is_node_alive(ip) or is_node_alive_tcp(ip, 22)
|
|||
|
|
|
|||
|
|
if server_alive:
|
|||
|
|
# Сервер жив но бот не отвечает на health port = бот не работает
|
|||
|
|
logger.debug(f" {ip}: сервер жив, но бот не отвечает на порту {BOT_HEALTH_PORT}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Сервер полностью недоступен
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# HEALTH SERVER — для проверки здоровья бота
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_health_server: Optional[asyncio.AbstractServer] = None
|
|||
|
|
|
|||
|
|
async def start_health_server():
|
|||
|
|
"""
|
|||
|
|
Запустить TCP сервер здоровья на BOT_HEALTH_PORT.
|
|||
|
|
|
|||
|
|
Другие узлы проверяют этот порт чтобы убедиться что БОТ работает,
|
|||
|
|
а не просто сервер отвечает на ping.
|
|||
|
|
"""
|
|||
|
|
global _health_server
|
|||
|
|
|
|||
|
|
if _health_server is not None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
async def health_handler(reader, writer):
|
|||
|
|
"""Отвечаем OK на любой запрос"""
|
|||
|
|
try:
|
|||
|
|
writer.write(b"OK\n")
|
|||
|
|
await writer.drain()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
finally:
|
|||
|
|
writer.close()
|
|||
|
|
try:
|
|||
|
|
await writer.wait_closed()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
_health_server = await asyncio.start_server(
|
|||
|
|
health_handler,
|
|||
|
|
'0.0.0.0',
|
|||
|
|
BOT_HEALTH_PORT,
|
|||
|
|
reuse_address=True
|
|||
|
|
)
|
|||
|
|
logger.info(f"✅ Health server started on port {BOT_HEALTH_PORT}")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"⚠️ Could not start health server on port {BOT_HEALTH_PORT}: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def stop_health_server():
|
|||
|
|
"""Остановить TCP сервер здоровья"""
|
|||
|
|
global _health_server
|
|||
|
|
|
|||
|
|
if _health_server is not None:
|
|||
|
|
_health_server.close()
|
|||
|
|
await _health_server.wait_closed()
|
|||
|
|
_health_server = None
|
|||
|
|
logger.info(f"🛑 Health server stopped")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# ATTACK DETECTOR
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class AttackDetector:
|
|||
|
|
"""
|
|||
|
|
ОТКЛЮЧЕНО — ложные срабатывания мешали failover.
|
|||
|
|
|
|||
|
|
Детекция атак отключена, но класс сохранён для совместимости API.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def record_failure(self):
|
|||
|
|
"""NOOP"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def record_success(self):
|
|||
|
|
"""NOOP"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def record_response_time(self, response_time: float):
|
|||
|
|
"""NOOP"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def is_under_attack(self) -> bool:
|
|||
|
|
"""Всегда False — детекция отключена"""
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def reset(self):
|
|||
|
|
"""NOOP"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# LEADER ELECTION
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class LeaderElection:
|
|||
|
|
"""
|
|||
|
|
3-Mirror Leader Election по документации Montana.
|
|||
|
|
|
|||
|
|
Логика:
|
|||
|
|
- Цепочка узлов определяет приоритет
|
|||
|
|
- Я лидер если ВСЕ узлы ДО меня в цепочке мертвы
|
|||
|
|
- Проверка каждые 5 секунд
|
|||
|
|
- Failover < 10 секунд
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, chain: List[Tuple[str, str]] = None):
|
|||
|
|
self.chain = chain or BOT_CHAIN
|
|||
|
|
self.original_chain = list(self.chain) # Сохраняем оригинальную цепочку
|
|||
|
|
self.my_name: Optional[str] = None
|
|||
|
|
self.my_ip: Optional[str] = None
|
|||
|
|
self.my_position: int = -1 # Позиция в цепочке (-1 = не в цепочке)
|
|||
|
|
self.is_master: bool = False
|
|||
|
|
self._stop_event = asyncio.Event()
|
|||
|
|
|
|||
|
|
# Breathing Sync
|
|||
|
|
self._breathing_sync: Optional[BreathingSync] = None
|
|||
|
|
self._breathing_task: Optional[asyncio.Task] = None
|
|||
|
|
|
|||
|
|
# Attack Detection
|
|||
|
|
self.attack_detector = AttackDetector()
|
|||
|
|
self.chain_shuffled = False # Флаг - цепочка перемешана?
|
|||
|
|
|
|||
|
|
# Определяем себя
|
|||
|
|
self._detect_self()
|
|||
|
|
|
|||
|
|
def _detect_self(self):
|
|||
|
|
"""Определить текущий узел по NODE_NAME или IP"""
|
|||
|
|
|
|||
|
|
# Способ 1: через env variable MONTANA_NODE_NAME
|
|||
|
|
node_name = os.getenv('MONTANA_NODE_NAME', '').lower()
|
|||
|
|
if node_name:
|
|||
|
|
for i, (name, ip) in enumerate(self.chain):
|
|||
|
|
if name.lower() == node_name:
|
|||
|
|
self.my_name = name
|
|||
|
|
self.my_ip = ip
|
|||
|
|
self.my_position = i
|
|||
|
|
logger.info(f"🏔 Узел определён по NODE_NAME: {name} (позиция {i} в цепочке)")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Способ 2: через локальные IP адреса
|
|||
|
|
local_ips = self._get_local_ips()
|
|||
|
|
for i, (name, ip) in enumerate(self.chain):
|
|||
|
|
if ip in local_ips:
|
|||
|
|
self.my_name = name
|
|||
|
|
self.my_ip = ip
|
|||
|
|
self.my_position = i
|
|||
|
|
logger.info(f"🏔 Узел определён по IP: {name} ({ip}, позиция {i})")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Способ 3: fallback на первый узел (для локальной разработки)
|
|||
|
|
if self.chain:
|
|||
|
|
self.my_name = self.chain[0][0]
|
|||
|
|
self.my_ip = self.chain[0][1]
|
|||
|
|
self.my_position = 0
|
|||
|
|
logger.warning(f"⚠️ Узел не определён, fallback на {self.my_name} (позиция 0)")
|
|||
|
|
|
|||
|
|
def _get_local_ips(self) -> set:
|
|||
|
|
"""Получить все локальные IP адреса"""
|
|||
|
|
ips = set()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
hostname = socket.gethostname()
|
|||
|
|
ips.add(socket.gethostbyname(hostname))
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Внешний IP через connect
|
|||
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|||
|
|
s.connect(("8.8.8.8", 80))
|
|||
|
|
ips.add(s.getsockname()[0])
|
|||
|
|
s.close()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return ips
|
|||
|
|
|
|||
|
|
def am_i_the_master(self) -> bool:
|
|||
|
|
"""
|
|||
|
|
Я мастер если ВСЕ узлы ДО меня в цепочке мертвы.
|
|||
|
|
|
|||
|
|
Из документации 003_ТРОЙНОЕ_ЗЕРКАЛО.md:
|
|||
|
|
```
|
|||
|
|
def am_i_the_brain(my_name: str) -> bool:
|
|||
|
|
for name, ip in BRAIN_CHAIN:
|
|||
|
|
if name == my_name:
|
|||
|
|
return True # Дошли до себя - я лидер
|
|||
|
|
if is_node_alive(ip):
|
|||
|
|
return False # Кто-то выше меня жив
|
|||
|
|
return False
|
|||
|
|
```
|
|||
|
|
"""
|
|||
|
|
if self.my_position < 0:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Проверяем всех кто выше в цепочке
|
|||
|
|
for i, (name, ip) in enumerate(self.chain):
|
|||
|
|
if name == self.my_name:
|
|||
|
|
# Дошли до себя — все выше мертвы, я мастер
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# Проверяем жив ли узел выше меня
|
|||
|
|
if check_node_health(ip):
|
|||
|
|
logger.debug(f" {name} ({ip}) — ALIVE, я не мастер")
|
|||
|
|
return False
|
|||
|
|
else:
|
|||
|
|
logger.debug(f" {name} ({ip}) — DEAD")
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def get_chain_status(self) -> str:
|
|||
|
|
"""Получить статус всей цепочки для логов"""
|
|||
|
|
status = []
|
|||
|
|
for name, ip in self.chain:
|
|||
|
|
alive = check_node_health(ip)
|
|||
|
|
marker = "🟢" if alive else "🔴"
|
|||
|
|
is_me = " ← я" if name == self.my_name else ""
|
|||
|
|
status.append(f"{marker} {name}{is_me}")
|
|||
|
|
return " | ".join(status)
|
|||
|
|
|
|||
|
|
def _pq_secure_shuffle(self, items: list) -> list:
|
|||
|
|
"""
|
|||
|
|
Постквантово-безопасное перемешивание списка.
|
|||
|
|
|
|||
|
|
Использует:
|
|||
|
|
1. ML-DSA-65 подпись текущего timestamp для seed
|
|||
|
|
2. secrets.SystemRandom() как fallback (CSPRNG)
|
|||
|
|
|
|||
|
|
Атакующий НЕ может предсказать порядок даже с квантовым компьютером.
|
|||
|
|
"""
|
|||
|
|
import secrets
|
|||
|
|
import hashlib
|
|||
|
|
|
|||
|
|
# Пробуем использовать ML-DSA-65 для генерации seed
|
|||
|
|
try:
|
|||
|
|
from node_crypto import get_node_crypto_system
|
|||
|
|
node_crypto = get_node_crypto_system()
|
|||
|
|
|
|||
|
|
# Данные для подписи: timestamp + список узлов
|
|||
|
|
entropy_data = f"{time.time()}:{','.join([n for n, _ in items])}".encode()
|
|||
|
|
|
|||
|
|
# Подписываем ML-DSA-65
|
|||
|
|
signature = node_crypto.sign(entropy_data)
|
|||
|
|
if signature:
|
|||
|
|
# Хэш подписи = криптографически безопасный seed
|
|||
|
|
seed_bytes = hashlib.sha256(signature).digest()
|
|||
|
|
seed = int.from_bytes(seed_bytes[:8], 'big')
|
|||
|
|
logger.info(f"🔐 PQ-shuffle: ML-DSA-65 seed generated")
|
|||
|
|
else:
|
|||
|
|
# Fallback на secrets
|
|||
|
|
seed = secrets.randbits(64)
|
|||
|
|
logger.info(f"🔐 PQ-shuffle: secrets fallback")
|
|||
|
|
except Exception as e:
|
|||
|
|
# Fallback на CSPRNG
|
|||
|
|
seed = secrets.randbits(64)
|
|||
|
|
logger.warning(f"⚠️ ML-DSA-65 unavailable, using secrets: {e}")
|
|||
|
|
|
|||
|
|
# Перемешиваем используя seed
|
|||
|
|
shuffled = list(items)
|
|||
|
|
rng = random.Random(seed)
|
|||
|
|
rng.shuffle(shuffled)
|
|||
|
|
|
|||
|
|
return shuffled
|
|||
|
|
|
|||
|
|
def shuffle_chain_on_attack(self, external_trigger: bool = False):
|
|||
|
|
"""
|
|||
|
|
При обнаружении атаки — случайный порядок failover.
|
|||
|
|
|
|||
|
|
ПОСТКВАНТОВАЯ БЕЗОПАСНОСТЬ:
|
|||
|
|
- Используется ML-DSA-65 для генерации seed
|
|||
|
|
- Атакующий НЕ может предсказать следующего мастера
|
|||
|
|
- Цепочка перемешивается криптографически безопасно
|
|||
|
|
- Только здоровые узлы учитываются
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
external_trigger: True если вызван внешне (AtlantGuard)
|
|||
|
|
"""
|
|||
|
|
if not external_trigger and not self.attack_detector.is_under_attack():
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
logger.warning("🚨 АТАКА ОБНАРУЖЕНА! Переход на PQ-случайный failover")
|
|||
|
|
|
|||
|
|
# Постквантово-безопасное перемешивание
|
|||
|
|
shuffled = self._pq_secure_shuffle(self.chain)
|
|||
|
|
|
|||
|
|
# Фильтруем только живые узлы
|
|||
|
|
healthy_nodes = []
|
|||
|
|
for name, ip in shuffled:
|
|||
|
|
if check_node_health(ip):
|
|||
|
|
healthy_nodes.append((name, ip))
|
|||
|
|
logger.info(f" ✅ {name} ({ip}) — ЗДОРОВ")
|
|||
|
|
else:
|
|||
|
|
logger.warning(f" ❌ {name} ({ip}) — НЕДОСТУПЕН")
|
|||
|
|
|
|||
|
|
if healthy_nodes:
|
|||
|
|
self.chain = healthy_nodes
|
|||
|
|
self.chain_shuffled = True
|
|||
|
|
logger.info(f"🎲 Новый случайный порядок: {' → '.join([n for n, _ in self.chain])}")
|
|||
|
|
|
|||
|
|
# Обновляем мою позицию
|
|||
|
|
for i, (name, ip) in enumerate(self.chain):
|
|||
|
|
if name == self.my_name:
|
|||
|
|
self.my_position = i
|
|||
|
|
break
|
|||
|
|
else:
|
|||
|
|
logger.error("❌ НЕТ ЗДОРОВЫХ УЗЛОВ! Оставляем старую цепочку")
|
|||
|
|
|
|||
|
|
# Сбрасываем attack detector после failover
|
|||
|
|
self.attack_detector.reset()
|
|||
|
|
|
|||
|
|
def restore_original_chain(self):
|
|||
|
|
"""Восстановить оригинальную детерминированную цепочку"""
|
|||
|
|
if self.chain_shuffled:
|
|||
|
|
logger.info("🔄 Восстановление оригинальной цепочки")
|
|||
|
|
self.chain = list(self.original_chain)
|
|||
|
|
self.chain_shuffled = False
|
|||
|
|
|
|||
|
|
# Обновляем позицию
|
|||
|
|
for i, (name, ip) in enumerate(self.chain):
|
|||
|
|
if name == self.my_name:
|
|||
|
|
self.my_position = i
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# PULSE MODE — РЕЖИМ ПУЛЬСАЦИИ
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def check_majority_under_attack(self) -> tuple:
|
|||
|
|
"""
|
|||
|
|
Проверяет, атаковано ли большинство узлов.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(is_majority_attack: bool, healthy_count: int, total: int)
|
|||
|
|
"""
|
|||
|
|
total = len(self.original_chain)
|
|||
|
|
healthy_count = 0
|
|||
|
|
unhealthy_nodes = []
|
|||
|
|
|
|||
|
|
for name, ip in self.original_chain:
|
|||
|
|
if check_node_health(ip):
|
|||
|
|
healthy_count += 1
|
|||
|
|
else:
|
|||
|
|
unhealthy_nodes.append(name)
|
|||
|
|
|
|||
|
|
# Большинство = более 50% недоступны
|
|||
|
|
majority_threshold = total // 2 + 1
|
|||
|
|
is_majority_attack = (total - healthy_count) >= majority_threshold
|
|||
|
|
|
|||
|
|
if is_majority_attack:
|
|||
|
|
logger.warning(f"🚨 MAJORITY ATTACK: {total - healthy_count}/{total} узлов недоступны")
|
|||
|
|
logger.warning(f" Недоступны: {', '.join(unhealthy_nodes)}")
|
|||
|
|
|
|||
|
|
return is_majority_attack, healthy_count, total
|
|||
|
|
|
|||
|
|
def enter_pulse_mode(self) -> dict:
|
|||
|
|
"""
|
|||
|
|
Входит в режим пульсации при атаке на большинство.
|
|||
|
|
|
|||
|
|
PULSE MODE:
|
|||
|
|
- Сеть "засыпает"
|
|||
|
|
- Узлы пульсируют поочерёдно
|
|||
|
|
- Только один узел активен в момент времени
|
|||
|
|
- Минимизация поверхности атаки
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
{
|
|||
|
|
"mode": "pulse",
|
|||
|
|
"pulse_duration": int (секунды активности),
|
|||
|
|
"sleep_duration": int (секунды сна),
|
|||
|
|
"my_pulse_slot": int (мой слот в очереди),
|
|||
|
|
"total_slots": int
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
import hashlib
|
|||
|
|
import secrets
|
|||
|
|
|
|||
|
|
# Параметры пульсации
|
|||
|
|
PULSE_DURATION = 30 # 30 сек активности
|
|||
|
|
SLEEP_DURATION = 60 # 60 сек сна между пульсами
|
|||
|
|
|
|||
|
|
# Находим живые узлы
|
|||
|
|
healthy_nodes = []
|
|||
|
|
for name, ip in self.original_chain:
|
|||
|
|
if check_node_health(ip):
|
|||
|
|
healthy_nodes.append((name, ip))
|
|||
|
|
|
|||
|
|
if not healthy_nodes:
|
|||
|
|
logger.error("❌ PULSE MODE: Нет живых узлов!")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
total_slots = len(healthy_nodes)
|
|||
|
|
|
|||
|
|
# Определяем мой слот через PQ-хэш
|
|||
|
|
# Используем ML-DSA-65 если доступен, иначе SHA256
|
|||
|
|
try:
|
|||
|
|
from node_crypto import get_node_crypto_system
|
|||
|
|
node_crypto = get_node_crypto_system()
|
|||
|
|
|
|||
|
|
# Подписываем текущий час (все узлы получат одинаковый результат)
|
|||
|
|
hour_marker = int(time.time() // 3600)
|
|||
|
|
entropy = f"PULSE:{hour_marker}:{','.join([n for n, _ in healthy_nodes])}".encode()
|
|||
|
|
|
|||
|
|
signature = node_crypto.sign(entropy)
|
|||
|
|
if signature:
|
|||
|
|
seed_bytes = hashlib.sha256(signature).digest()
|
|||
|
|
else:
|
|||
|
|
seed_bytes = hashlib.sha256(entropy).digest()
|
|||
|
|
except Exception:
|
|||
|
|
hour_marker = int(time.time() // 3600)
|
|||
|
|
entropy = f"PULSE:{hour_marker}:{','.join([n for n, _ in healthy_nodes])}".encode()
|
|||
|
|
seed_bytes = hashlib.sha256(entropy).digest()
|
|||
|
|
|
|||
|
|
# Перемешиваем узлы детерминированно
|
|||
|
|
seed = int.from_bytes(seed_bytes[:8], 'big')
|
|||
|
|
rng = random.Random(seed)
|
|||
|
|
pulse_order = list(healthy_nodes)
|
|||
|
|
rng.shuffle(pulse_order)
|
|||
|
|
|
|||
|
|
# Находим мой слот
|
|||
|
|
my_pulse_slot = -1
|
|||
|
|
for i, (name, ip) in enumerate(pulse_order):
|
|||
|
|
if name == self.my_name:
|
|||
|
|
my_pulse_slot = i
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if my_pulse_slot < 0:
|
|||
|
|
logger.warning(f"⚠️ PULSE MODE: Я ({self.my_name}) не в списке живых узлов")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
logger.warning(f"💓 PULSE MODE ACTIVATED")
|
|||
|
|
logger.warning(f" Порядок: {' → '.join([n for n, _ in pulse_order])}")
|
|||
|
|
logger.warning(f" Мой слот: #{my_pulse_slot + 1}/{total_slots}")
|
|||
|
|
logger.warning(f" Пульс: {PULSE_DURATION}s активен, {SLEEP_DURATION}s сон")
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"mode": "pulse",
|
|||
|
|
"pulse_duration": PULSE_DURATION,
|
|||
|
|
"sleep_duration": SLEEP_DURATION,
|
|||
|
|
"my_pulse_slot": my_pulse_slot,
|
|||
|
|
"total_slots": total_slots,
|
|||
|
|
"pulse_order": [n for n, _ in pulse_order]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def is_my_pulse_active(self, pulse_config: dict) -> bool:
|
|||
|
|
"""
|
|||
|
|
Проверяет, активен ли сейчас мой пульс.
|
|||
|
|
|
|||
|
|
В режиме пульсации узлы работают по очереди:
|
|||
|
|
- Узел 0: активен 0-30 сек, спит 30-120 сек
|
|||
|
|
- Узел 1: активен 30-60 сек, спит 0-30 + 60-120 сек
|
|||
|
|
- и т.д.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True если сейчас моя очередь быть активным
|
|||
|
|
"""
|
|||
|
|
if not pulse_config:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
pulse_duration = pulse_config["pulse_duration"]
|
|||
|
|
sleep_duration = pulse_config["sleep_duration"]
|
|||
|
|
my_slot = pulse_config["my_pulse_slot"]
|
|||
|
|
total_slots = pulse_config["total_slots"]
|
|||
|
|
|
|||
|
|
# Полный цикл = все узлы по очереди
|
|||
|
|
cycle_duration = total_slots * pulse_duration + sleep_duration
|
|||
|
|
|
|||
|
|
# Текущая позиция в цикле
|
|||
|
|
current_time = time.time()
|
|||
|
|
position_in_cycle = current_time % cycle_duration
|
|||
|
|
|
|||
|
|
# Мой активный период в цикле
|
|||
|
|
my_start = my_slot * pulse_duration
|
|||
|
|
my_end = my_start + pulse_duration
|
|||
|
|
|
|||
|
|
is_active = my_start <= position_in_cycle < my_end
|
|||
|
|
|
|||
|
|
if is_active:
|
|||
|
|
remaining = my_end - position_in_cycle
|
|||
|
|
logger.debug(f"💓 Мой пульс АКТИВЕН (осталось {remaining:.0f}s)")
|
|||
|
|
else:
|
|||
|
|
# Когда следующий пульс
|
|||
|
|
if position_in_cycle < my_start:
|
|||
|
|
next_pulse = my_start - position_in_cycle
|
|||
|
|
else:
|
|||
|
|
next_pulse = cycle_duration - position_in_cycle + my_start
|
|||
|
|
logger.debug(f"😴 Сплю (следующий пульс через {next_pulse:.0f}s)")
|
|||
|
|
|
|||
|
|
return is_active
|
|||
|
|
|
|||
|
|
def exit_pulse_mode(self):
|
|||
|
|
"""Выход из режима пульсации"""
|
|||
|
|
logger.info("💓 PULSE MODE DEACTIVATED — возврат к нормальной работе")
|
|||
|
|
self.restore_original_chain()
|
|||
|
|
|
|||
|
|
def stop(self):
|
|||
|
|
"""Остановить leader election и breathing sync"""
|
|||
|
|
self._stop_event.set()
|
|||
|
|
if self._breathing_sync:
|
|||
|
|
self._breathing_sync.stop()
|
|||
|
|
if self._breathing_task:
|
|||
|
|
self._breathing_task.cancel()
|
|||
|
|
# Health server остановится при выходе из event loop
|
|||
|
|
|
|||
|
|
async def start_breathing_sync(self):
|
|||
|
|
"""Запустить Breathing Sync"""
|
|||
|
|
if not BREATHING_SYNC_AVAILABLE:
|
|||
|
|
logger.warning("⚠️ Breathing Sync недоступен")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self._breathing_sync = get_breathing_sync()
|
|||
|
|
self._breathing_task = asyncio.create_task(
|
|||
|
|
self._breathing_sync.run_breathing_loop(
|
|||
|
|
only_when_master=True,
|
|||
|
|
is_master_func=lambda: self.is_master
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
logger.info("🌬 Breathing Sync запущен")
|
|||
|
|
|
|||
|
|
async def run_leader_loop(
|
|||
|
|
self,
|
|||
|
|
on_become_master,
|
|||
|
|
on_become_standby,
|
|||
|
|
check_interval: int = CHECK_INTERVAL
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Основной цикл проверки лидерства.
|
|||
|
|
|
|||
|
|
РЕЖИМЫ РАБОТЫ:
|
|||
|
|
1. NORMAL — стандартная цепочка приоритетов
|
|||
|
|
2. ATTACK — PQ-случайный failover при атаке на узел
|
|||
|
|
3. PULSE — поочерёдная пульсация при атаке на большинство
|
|||
|
|
|
|||
|
|
Каждые check_interval секунд:
|
|||
|
|
1. Проверяем состояние сети
|
|||
|
|
2. Определяем режим работы
|
|||
|
|
3. Принимаем решение о мастерстве
|
|||
|
|
"""
|
|||
|
|
# Запускаем health server для проверки другими узлами
|
|||
|
|
await start_health_server()
|
|||
|
|
|
|||
|
|
logger.info(f"🔄 Запуск leader election loop (интервал {check_interval} сек)")
|
|||
|
|
logger.info(f"📍 Моя позиция: {self.my_name} #{self.my_position}")
|
|||
|
|
# Breathing Sync ОТКЛЮЧЁН — вызывает спам ошибок без git repo
|
|||
|
|
# logger.info(f"🌬 Breathing Sync: {'✅' if BREATHING_SYNC_AVAILABLE else '❌'}")
|
|||
|
|
# if BREATHING_SYNC_AVAILABLE:
|
|||
|
|
# await self.start_breathing_sync()
|
|||
|
|
|
|||
|
|
# Начальная задержка
|
|||
|
|
await asyncio.sleep(STARTUP_DELAY)
|
|||
|
|
|
|||
|
|
was_master = False
|
|||
|
|
pulse_mode_config = None # Конфигурация режима пульсации
|
|||
|
|
pulse_mode_active = False
|
|||
|
|
|
|||
|
|
while not self._stop_event.is_set():
|
|||
|
|
try:
|
|||
|
|
check_start_time = time.time()
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# ПРОВЕРКА 1: Атака на большинство → PULSE MODE
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
is_majority_attack, healthy_count, total_nodes = self.check_majority_under_attack()
|
|||
|
|
|
|||
|
|
if is_majority_attack and not pulse_mode_active:
|
|||
|
|
# ВХОДИМ В РЕЖИМ ПУЛЬСАЦИИ
|
|||
|
|
logger.warning(f"🚨 MAJORITY ATTACK DETECTED! {total_nodes - healthy_count}/{total_nodes} узлов недоступны")
|
|||
|
|
logger.warning(f"💓 Переход в PULSE MODE — сеть засыпает и пульсирует")
|
|||
|
|
|
|||
|
|
pulse_mode_config = self.enter_pulse_mode()
|
|||
|
|
pulse_mode_active = pulse_mode_config is not None
|
|||
|
|
|
|||
|
|
# Уходим в standby пока не наш пульс
|
|||
|
|
if was_master:
|
|||
|
|
self.is_master = False
|
|||
|
|
was_master = False
|
|||
|
|
await on_become_standby()
|
|||
|
|
|
|||
|
|
elif not is_majority_attack and pulse_mode_active:
|
|||
|
|
# ВЫХОДИМ ИЗ РЕЖИМА ПУЛЬСАЦИИ
|
|||
|
|
logger.info(f"✅ Сеть восстановлена: {healthy_count}/{total_nodes} узлов здоровы")
|
|||
|
|
self.exit_pulse_mode()
|
|||
|
|
pulse_mode_active = False
|
|||
|
|
pulse_mode_config = None
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# РЕЖИМ ПУЛЬСАЦИИ — поочерёдная работа узлов
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
if pulse_mode_active and pulse_mode_config:
|
|||
|
|
is_my_pulse = self.is_my_pulse_active(pulse_mode_config)
|
|||
|
|
|
|||
|
|
if is_my_pulse and not was_master:
|
|||
|
|
# МОЙ ПУЛЬС — становлюсь мастером
|
|||
|
|
self.is_master = True
|
|||
|
|
was_master = True
|
|||
|
|
logger.info(f"💓 {self.my_name} → PULSE MASTER (слот #{pulse_mode_config['my_pulse_slot'] + 1})")
|
|||
|
|
self.attack_detector.record_success()
|
|||
|
|
await on_become_master()
|
|||
|
|
|
|||
|
|
elif not is_my_pulse and was_master:
|
|||
|
|
# НЕ МОЙ ПУЛЬС — засыпаю
|
|||
|
|
self.is_master = False
|
|||
|
|
was_master = False
|
|||
|
|
logger.info(f"😴 {self.my_name} → PULSE SLEEP")
|
|||
|
|
await on_become_standby()
|
|||
|
|
|
|||
|
|
# В режиме пульсации пропускаем обычную логику
|
|||
|
|
await asyncio.sleep(check_interval)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# ПРОВЕРКА 2: Атака на узел → PQ-FAILOVER
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
if self.attack_detector.is_under_attack():
|
|||
|
|
# Атака обнаружена — переход на случайный failover
|
|||
|
|
self.shuffle_chain_on_attack()
|
|||
|
|
|
|||
|
|
# Если я был мастером — передаём управление
|
|||
|
|
if was_master:
|
|||
|
|
logger.warning("🚨 Передача мастерства из-за атаки")
|
|||
|
|
self.is_master = False
|
|||
|
|
was_master = False
|
|||
|
|
await on_become_standby()
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# НОРМАЛЬНЫЙ РЕЖИМ — стандартная цепочка
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
should_be_master = self.am_i_the_master()
|
|||
|
|
|
|||
|
|
# Записываем время отклика
|
|||
|
|
check_duration = time.time() - check_start_time
|
|||
|
|
self.attack_detector.record_response_time(check_duration)
|
|||
|
|
|
|||
|
|
if should_be_master and not was_master:
|
|||
|
|
# Стали мастером
|
|||
|
|
self.is_master = True
|
|||
|
|
was_master = True
|
|||
|
|
logger.info(f"👑 {self.my_name} → MASTER")
|
|||
|
|
logger.info(f" Цепочка: {self.get_chain_status()}")
|
|||
|
|
self.attack_detector.record_success()
|
|||
|
|
await on_become_master()
|
|||
|
|
|
|||
|
|
elif not should_be_master and was_master:
|
|||
|
|
# Потеряли мастерство (кто-то выше ожил)
|
|||
|
|
self.is_master = False
|
|||
|
|
was_master = False
|
|||
|
|
logger.info(f"😴 {self.my_name} → STANDBY (узел выше ожил)")
|
|||
|
|
logger.info(f" Цепочка: {self.get_chain_status()}")
|
|||
|
|
await on_become_standby()
|
|||
|
|
|
|||
|
|
elif not should_be_master and not was_master:
|
|||
|
|
# Всё ещё standby
|
|||
|
|
logger.debug(f"😴 {self.my_name} — STANDBY")
|
|||
|
|
|
|||
|
|
# Ждём до следующей проверки
|
|||
|
|
await asyncio.sleep(check_interval)
|
|||
|
|
|
|||
|
|
except asyncio.CancelledError:
|
|||
|
|
break
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка в leader loop: {e}")
|
|||
|
|
self.attack_detector.record_failure()
|
|||
|
|
await asyncio.sleep(check_interval)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# SINGLETON
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
_leader_election: Optional[LeaderElection] = None
|
|||
|
|
|
|||
|
|
def get_leader_election() -> LeaderElection:
|
|||
|
|
"""Получить singleton экземпляр LeaderElection"""
|
|||
|
|
global _leader_election
|
|||
|
|
if _leader_election is None:
|
|||
|
|
_leader_election = LeaderElection()
|
|||
|
|
return _leader_election
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# TEST
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print("🏔 Montana 3-Mirror Leader Election Test")
|
|||
|
|
print("=" * 50)
|
|||
|
|
|
|||
|
|
le = get_leader_election()
|
|||
|
|
|
|||
|
|
print(f"\nМой узел: {le.my_name}")
|
|||
|
|
print(f"Мой IP: {le.my_ip}")
|
|||
|
|
print(f"Моя позиция: {le.my_position}")
|
|||
|
|
print(f"\nЦепочка узлов:")
|
|||
|
|
|
|||
|
|
for i, (name, ip) in enumerate(le.chain):
|
|||
|
|
alive = check_node_health(ip)
|
|||
|
|
status = "🟢 ALIVE" if alive else "🔴 DEAD"
|
|||
|
|
is_me = " ← Я" if name == le.my_name else ""
|
|||
|
|
print(f" {i}. {name:12} {ip:16} {status}{is_me}")
|
|||
|
|
|
|||
|
|
print(f"\nЯ мастер? {le.am_i_the_master()}")
|
|||
|
|
print(f"\nСтатус: {le.get_chain_status()}")
|