Changes:
- Add next-themes dependency for theme management
- Create ThemeProvider wrapper for app root layout
- Set dark mode as default theme
- Create ThemeToggle component with Sun/Moon icons
- Add theme toggle to home page navigation
- Add theme toggle to dashboard header
- App now starts in dark mode with ability to switch to light mode
Styling uses existing Tailwind dark mode variables configured in
tailwind.config.ts and globals.css. All existing components automatically
support dark theme.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
352 lines
10 KiB
Python
352 lines
10 KiB
Python
"""
|
|
Bootnode Service - Peer Discovery for PoA Blockchain Validators
|
|
|
|
This service helps validators discover each other and bootstrap into the network.
|
|
It maintains a registry of known peers and provides discovery endpoints.
|
|
|
|
Features:
|
|
- Peer registration endpoint (POST /register_peer)
|
|
- Peer discovery endpoint (GET /discover)
|
|
- Peer listing endpoint (GET /peers)
|
|
- Health check endpoint
|
|
- Periodic cleanup of stale peers
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import json
|
|
import logging
|
|
from typing import Dict, List, Optional
|
|
from datetime import datetime, timedelta
|
|
from fastapi import FastAPI, HTTPException, status
|
|
from pydantic import BaseModel
|
|
import asyncio
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ============================================================================
|
|
# Data Models
|
|
# ============================================================================
|
|
|
|
class PeerInfo(BaseModel):
|
|
"""Information about a validator peer"""
|
|
node_id: str
|
|
ip: str
|
|
p2p_port: int
|
|
rpc_port: int
|
|
public_key: Optional[str] = None
|
|
|
|
|
|
class PeerRegistration(BaseModel):
|
|
"""Request to register a peer"""
|
|
node_id: str
|
|
ip: str
|
|
p2p_port: int
|
|
rpc_port: int
|
|
public_key: Optional[str] = None
|
|
|
|
|
|
class PeerDiscoveryResponse(BaseModel):
|
|
"""Response from peer discovery"""
|
|
peers: List[PeerInfo]
|
|
count: int
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
"""Health check response"""
|
|
status: str
|
|
timestamp: str
|
|
peers_count: int
|
|
|
|
|
|
# ============================================================================
|
|
# Bootnode Service
|
|
# ============================================================================
|
|
|
|
class PeerRegistry:
|
|
"""In-memory registry of known peers with expiration"""
|
|
|
|
def __init__(self, peer_timeout_seconds: int = 300):
|
|
self.peers: Dict[str, dict] = {} # node_id -> peer info with timestamp
|
|
self.peer_timeout = peer_timeout_seconds
|
|
|
|
def register_peer(self, peer: PeerInfo) -> None:
|
|
"""Register or update a peer"""
|
|
self.peers[peer.node_id] = {
|
|
"info": peer,
|
|
"registered_at": time.time(),
|
|
"last_heartbeat": time.time()
|
|
}
|
|
logger.info(
|
|
f"Peer registered: {peer.node_id} "
|
|
f"({peer.ip}:{peer.p2p_port}, RPC:{peer.rpc_port})"
|
|
)
|
|
|
|
def update_heartbeat(self, node_id: str) -> None:
|
|
"""Update heartbeat timestamp for a peer"""
|
|
if node_id in self.peers:
|
|
self.peers[node_id]["last_heartbeat"] = time.time()
|
|
|
|
def get_peer(self, node_id: str) -> Optional[PeerInfo]:
|
|
"""Get a peer by node_id"""
|
|
if node_id in self.peers:
|
|
return self.peers[node_id]["info"]
|
|
return None
|
|
|
|
def get_all_peers(self) -> List[PeerInfo]:
|
|
"""Get all active peers"""
|
|
return [entry["info"] for entry in self.peers.values()]
|
|
|
|
def get_peers_except(self, exclude_node_id: str) -> List[PeerInfo]:
|
|
"""Get all peers except the specified one"""
|
|
return [
|
|
entry["info"]
|
|
for node_id, entry in self.peers.items()
|
|
if node_id != exclude_node_id
|
|
]
|
|
|
|
def cleanup_stale_peers(self) -> int:
|
|
"""Remove peers that haven't sent heartbeat recently"""
|
|
current_time = time.time()
|
|
stale_peers = [
|
|
node_id for node_id, entry in self.peers.items()
|
|
if (current_time - entry["last_heartbeat"]) > self.peer_timeout
|
|
]
|
|
|
|
for node_id in stale_peers:
|
|
logger.warning(f"Removing stale peer: {node_id}")
|
|
del self.peers[node_id]
|
|
|
|
return len(stale_peers)
|
|
|
|
|
|
# ============================================================================
|
|
# FastAPI Application
|
|
# ============================================================================
|
|
|
|
app = FastAPI(
|
|
title="E-Voting Bootnode",
|
|
description="Peer discovery service for PoA validators",
|
|
version="1.0.0"
|
|
)
|
|
|
|
# Global peer registry
|
|
peer_registry = PeerRegistry(peer_timeout_seconds=300)
|
|
|
|
|
|
# ============================================================================
|
|
# Health Check
|
|
# ============================================================================
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
async def health_check():
|
|
"""Health check endpoint"""
|
|
return HealthResponse(
|
|
status="healthy",
|
|
timestamp=datetime.utcnow().isoformat(),
|
|
peers_count=len(peer_registry.get_all_peers())
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Peer Registration
|
|
# ============================================================================
|
|
|
|
@app.post("/register_peer", response_model=PeerDiscoveryResponse)
|
|
async def register_peer(peer: PeerRegistration):
|
|
"""
|
|
Register a peer node with the bootnode.
|
|
|
|
The peer must provide:
|
|
- node_id: Unique identifier (e.g., "validator-1")
|
|
- ip: IP address or Docker service name
|
|
- p2p_port: Port for P2P communication
|
|
- rpc_port: Port for JSON-RPC communication
|
|
- public_key: (Optional) Validator's public key for signing
|
|
|
|
Returns: List of other known peers
|
|
"""
|
|
try:
|
|
# Register the peer
|
|
peer_info = PeerInfo(**peer.dict())
|
|
peer_registry.register_peer(peer_info)
|
|
|
|
# Return other known peers
|
|
other_peers = peer_registry.get_peers_except(peer.node_id)
|
|
|
|
logger.info(f"Registration successful. Peer {peer.node_id} now knows {len(other_peers)} peers")
|
|
|
|
return PeerDiscoveryResponse(
|
|
peers=other_peers,
|
|
count=len(other_peers)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error registering peer: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Failed to register peer: {str(e)}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Peer Discovery
|
|
# ============================================================================
|
|
|
|
@app.get("/discover", response_model=PeerDiscoveryResponse)
|
|
async def discover_peers(node_id: str):
|
|
"""
|
|
Discover peers currently in the network.
|
|
|
|
Query Parameters:
|
|
- node_id: The requesting peer's node_id (to exclude from results)
|
|
|
|
Returns: List of all other known peers
|
|
"""
|
|
try:
|
|
# Update heartbeat for the requesting peer
|
|
peer_registry.update_heartbeat(node_id)
|
|
|
|
# Return all peers except the requester
|
|
peers = peer_registry.get_peers_except(node_id)
|
|
|
|
logger.info(f"Discovery request from {node_id}: returning {len(peers)} peers")
|
|
|
|
return PeerDiscoveryResponse(
|
|
peers=peers,
|
|
count=len(peers)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error discovering peers: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to discover peers: {str(e)}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Peer Listing (Admin)
|
|
# ============================================================================
|
|
|
|
@app.get("/peers", response_model=PeerDiscoveryResponse)
|
|
async def list_all_peers():
|
|
"""
|
|
List all known peers (admin endpoint).
|
|
|
|
Returns: All registered peers
|
|
"""
|
|
peers = peer_registry.get_all_peers()
|
|
|
|
return PeerDiscoveryResponse(
|
|
peers=peers,
|
|
count=len(peers)
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Peer Heartbeat
|
|
# ============================================================================
|
|
|
|
@app.post("/heartbeat")
|
|
async def peer_heartbeat(node_id: str):
|
|
"""
|
|
Send a heartbeat to indicate the peer is still alive.
|
|
|
|
Query Parameters:
|
|
- node_id: The peer's node_id
|
|
|
|
This keeps the peer in the registry and prevents timeout.
|
|
"""
|
|
try:
|
|
peer_registry.update_heartbeat(node_id)
|
|
logger.debug(f"Heartbeat received from {node_id}")
|
|
|
|
return {
|
|
"status": "ok",
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing heartbeat: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Failed to process heartbeat: {str(e)}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Stats (Admin)
|
|
# ============================================================================
|
|
|
|
@app.get("/stats")
|
|
async def get_stats():
|
|
"""Get bootnode statistics"""
|
|
peers = peer_registry.get_all_peers()
|
|
|
|
return {
|
|
"total_peers": len(peers),
|
|
"peers": [
|
|
{
|
|
"node_id": p.node_id,
|
|
"ip": p.ip,
|
|
"p2p_port": p.p2p_port,
|
|
"rpc_port": p.rpc_port
|
|
}
|
|
for p in peers
|
|
],
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Background Tasks
|
|
# ============================================================================
|
|
|
|
async def cleanup_stale_peers_task():
|
|
"""Periodic task to cleanup stale peers"""
|
|
while True:
|
|
await asyncio.sleep(60) # Cleanup every 60 seconds
|
|
removed_count = peer_registry.cleanup_stale_peers()
|
|
if removed_count > 0:
|
|
logger.info(f"Cleaned up {removed_count} stale peers")
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""Start background tasks on application startup"""
|
|
logger.info("Bootnode starting up...")
|
|
asyncio.create_task(cleanup_stale_peers_task())
|
|
logger.info("Cleanup task started")
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown_event():
|
|
"""Log shutdown"""
|
|
logger.info("Bootnode shutting down...")
|
|
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
port = int(os.getenv("BOOTNODE_PORT", "8546"))
|
|
host = os.getenv("BOOTNODE_HOST", "0.0.0.0")
|
|
|
|
logger.info(f"Starting bootnode on {host}:{port}")
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host=host,
|
|
port=port,
|
|
log_level="info"
|
|
)
|