A comprehensive Ethereum bootnode implementation supporting both Execution Layer (EL) and Consensus Layer (CL) peer discovery with intelligent fork-aware filtering.
Note: This project is under active development and not yet production-ready. Use at your own risk.
bootnodoor provides a unified bootnode service with multiple protocol implementations:
A complete dual-stack bootnode supporting both EL and CL networks:
- Dual Layer Support: Run EL-only, CL-only, or both simultaneously
- Dual Protocol Support: Discovery v4 (discv4) and Discovery v5 (discv5)
- Protocol Multiplexing: Both protocols share a single UDP port
- Fork-Aware Filtering:
- EL: EIP-2124 fork ID validation via
ethENR field - CL: Fork digest validation via
eth2ENR field with grace periods
- EL: EIP-2124 fork ID validation via
- Separate Routing Tables: Independent tables for EL and CL nodes (500 nodes each by default)
- Intelligent Node Routing: Only serves compatible nodes to requesters
- LAN-Aware Filtering: Prevents leaking private network topology
- IP Discovery: Automatic external IP detection from PONG consensus
- Web UI: Real-time statistics dashboard with EL/CL breakdowns
Legacy protocol for Execution Layer discovery:
- Full Wire Protocol: PING/PONG, FINDNODE/NEIGHBORS, ENRREQUEST/ENRRESPONSE
- Bond Mechanism: Bidirectional PING/PONG required before serving nodes
- Storm Prevention: Protection against ping-pong amplification attacks
- ENR Support: Supports both modern ENR and legacy enode URLs
- EL-Specific: Only used for Execution Layer nodes
Modern encrypted protocol for both EL and CL:
- Protocol Handler: Full discv5 wire protocol (PING/PONG, FINDNODE/NODES, TALKREQ/TALKRESP)
- Session Management: Encrypted messaging with WHOAREYOU handshakes
- UDP Transport: Network communication with per-IP rate limiting
- ENR Support: Full Ethereum Node Records implementation
- DoS Protection: Bounded pending maps with LRU eviction and per-IP limits
- Cross-Layer: Used by both EL and CL nodes
Single bootnode binary supporting all Ethereum network types:
- Execution Layer: Ethereum mainnet and testnets (EL nodes)
- Consensus Layer: Beacon chain nodes (CL nodes)
- Dual-Stack Mode: Serve both EL and CL simultaneously on the same port
- Protocol Agnostic: Supports both discv4 (EL) and discv5 (EL+CL)
- Fork ID Validation: Validates nodes based on
ethENR field - Chain Compatibility: Ensures nodes are on the correct chain and fork
- Historical Acceptance: Accepts any valid fork from chain history
- Network Isolation: Mainnet nodes won't be served to testnet peers
- Fork Digest Validation: Validates nodes based on
eth2ENR field - Grace Period Support: Accepts nodes from previous forks during transitions (default: 60 minutes)
- Fork Awareness: Automatically updates accepted fork digests based on beacon chain schedule
- Quality Assurance: Only serves nodes that have been validated and pinged successfully
This ensures that peers connecting to the bootnode receive only valid, reachable nodes from their specific network and fork.
- Context-Driven: Graceful shutdown via context cancellation
- Memory Efficient: Bounded data structures with configurable limits (500 nodes per table by default)
- Attack Resistant: Multi-layer DoS protection (per-IP limits, rate limiting, bond mechanism, storm prevention)
- Observable: Comprehensive statistics and web UI for monitoring
- Database Persistence: SQLite storage with automatic migrations
- Self-Configuring: Automatic external IP discovery from PONG consensus
go build -o bootnodoor ./cmd/bootnodoor./bootnodoor \
--network mainnet \
--private-key "$(openssl rand -hex 32)" \
--bind-port 30303 \
--enr-ip $(curl -s ifconfig.me) \
--nodedb ./data/mainnet.db \
--web-ui./bootnodoor \
--network mainnet \
--enable-cl=false \
--private-key "$(openssl rand -hex 32)" \
--bind-port 30303 \
--enr-ip $(curl -s ifconfig.me) \
--el-bootnodes "enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@18.138.108.67:30303" \
--nodedb ./data/mainnet-el.db \
--web-ui./bootnodoor \
--network mainnet \
--enable-el=false \
--private-key "$(openssl rand -hex 32)" \
--bind-port 9000 \
--enr-ip $(curl -s ifconfig.me) \
--cl-bootnodes "enr:-Ku4QImhMc1z8yCiNJ1TyUxdcfNucje3BGwEHzodEZUan8PherEo4sF7pPHPSIB1NNuSg5fZy7qFsjmUKs2ea1Whi0EBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQOVphkDqal4QzPMksc5wnpuC3gvSC8AfbFOnZY_On34wIN1ZHCCIyg" \
--nodedb ./data/mainnet-cl.db \
--web-ui--network <name>: Fetch the configs formainnet,sepoliaorhoodifrom eth-clients at startup- Fetches
metadata/genesis.json(EL) andmetadata/config.yaml(CL) from themainbranch ofeth-clients/<name> - Sets the genesis hash, the genesis validators root and the CL genesis time for that network
- Fetches only the files for the enabled layers (
--enable-el,--enable-cl) - Caches each file in
network-<name>/next to the--nodedbfile, or in the user cache directory (for example~/.cache/bootnodoor/) when--nodedbis not set. Without a user cache directory (noHOME), set--nodedb. Keep the--nodedbfile in a directory that only the bootnodoor user can write - Replaces the cache only with a file that parses and matches the network. If the fetch fails, uses the cached copy and logs a warning
- Reads the configs only at startup. Restart the bootnode before a fork to get the new fork schedule
- Fetches
Flags on the command line always win over --network values. A --el-config or
--cl-config flag takes over its whole layer: --network then does not fetch that
file or set its genesis values. For a custom network or a devnet, leave out --network
and set the flags below.
For Execution Layer:
-
--el-config <path>: Path to EL chain config file (JSON)- Contains fork schedule and network parameters
- Example: Mainnet, Sepolia, Hoodi configs
-
--el-genesis-hash <hex>: Genesis block hash (0x-prefixed hex)- Used to compute fork IDs
- Example:
0xd4e56740...for Ethereum mainnet
-
The genesis block timestamp is read from the
timestampfield of--el-config, as hex or decimal.
For Consensus Layer:
-
--cl-config <path>: Path to CL beacon config file (YAML)- Contains fork schedule, genesis config, and network parameters
- For public networks, use
metadata/config.yamlfrom the network's eth-clients repository
-
--genesis-validators-root <hex>: Genesis validators root (0x-prefixed hex)- Used to compute fork digests
- Unique per network (mainnet, sepolia, hoodi, etc.)
-
--cl-genesis-time <unix>: CL genesis time (default0: calculateMIN_GENESIS_TIME + GENESIS_DELAYfrom the config)- Set it when the actual genesis time differs from the calculated value. On mainnet, the calculated value (1607428800) is 7 days late.
--network mainnetsets the correct value (1606824023)
- Set it when the actual genesis time differs from the calculated value. On mainnet, the calculated value (1607428800) is 7 days late.
Common Required:
-
--private-key <hex>: Node private key (64 hex characters, optional 0x prefix)- Used for node identity and ENR signing
- Keep this secret! Anyone with this key can impersonate your node
- Acts as the shared fallback for any layer without its own
--el-private-key/--cl-private-key
-
--enr-ip <ip>: Public IPv4 address to advertise in ENR- This is the address other nodes will use to connect to you
- Must be reachable from the internet
- Can be omitted for automatic discovery from PONG responses
By default a single key serves both layers under one node ID. To migrate from separate EL and CL bootnodes without changing the node IDs peers already know, give each layer its own key (and optionally its own port):
--el-private-key <hex|file>: Private key for the EL identity (falls back to--private-key)--cl-private-key <hex|file>: Private key for the CL identity (falls back to--private-key)--el-port <port>/--cl-port <port>: Per-layer UDP listen port (default:--bind-port)--el-enr-port <port>/--cl-enr-port <port>: Per-layer advertised port (default: the layer's listen port)
With distinct keys the bootnode publishes two ENRs — an EL record (with the eth
fork field) and a CL record (with the eth2 field) — each signed by its own key.
They share one UDP socket unless --el-port/--cl-port differ. Fetch each from
the web UI via /enr?layer=el and /enr?layer=cl.
--bind-addr <ip>: IP address to bind UDP socket (default:0.0.0.0)--bind-port <port>: UDP port to bind (default:9000)--enr-ip6 <ip>: Optional IPv6 address to advertise--enr-port <port>: UDP port to advertise (default: use--bind-port)
--enable-el: Enable execution bootnode (default:true)--enable-cl: Enable consensus bootnode (default:true)
--nodedb <path>: Path to persistent SQLite database file- Stores discovered nodes across restarts with automatic migrations
- Leave empty for in-memory database (no persistence)
- Database contains separate tables for EL and CL nodes
-
--el-bootnodes <enr1,enr2,...>: Comma-separated list of EL bootnode ENRs or enode URLs- Used for initial EL peer discovery
- Supports both ENR and legacy enode format
-
--cl-bootnodes <enr1,enr2,...>: Comma-separated list of CL bootnode ENRs- Used for initial CL peer discovery
- Only ENR format supported
-
--max-active-nodes <count>: Maximum active nodes per table (default:500)- Separate limit for EL and CL tables
-
--max-nodes-per-ip <count>: Maximum nodes to track per IP address (default:10)- Prevents single IPs from dominating the routing table
-
--discv4-max-nodes <count>: Maximum nodes discv4 tracks for bonding (default:50000)- When the limit is reached, a new node replaces an unbonded node first. If none is left, it replaces the bonded node that has been idle longest, but only after 5 minutes without a packet. Active peers are never replaced.
- A flood that keeps every entry active can still block new nodes until entries go idle.
--grace-period <duration>: Grace period for old CL fork digests (default:60m)- How long to accept nodes from previous forks after transition
- Format:
60m,2h,30s - EL fork IDs accept any historical fork without grace period
-
--web-ui: Enable web UI dashboard -
--web-host <ip>: Web UI host (default:0.0.0.0) -
--web-port <port>: Web UI port (default:8080) -
--web-sitename <name>: Web UI site name (default:bootnodoor) -
--ip-names <file>: Path to a file mapping IP addresses to display names. Named nodes show the name in place of the IP in the node tables (the real IP stays available as a tooltip). Edits apply without a restart: a nodes page request checks the file's modification time, at most once every 30 seconds. A copy that keeps the old modification time (cp -p,rsync -t) is not detected. Two formats are supported, auto-detected:A YAML map of IPs or CIDR ranges to names (exact IPs win over CIDR ranges; among CIDRs the longest prefix wins):
# ip-names.yaml "170.64.167.121": do-syd-bootnode-1 "10.0.0.0/24": internal-lab
Or an Ansible INI inventory — point the flag at your existing
inventory.iniand each host's inventory name is used for itsansible_hostIP (hosts whoseansible_hostis a DNS name are skipped):[bootnodes] do-syd-bootnode-1 ansible_host=170.64.167.121
-
--pprof: Enable pprof performance profiling endpoints
--log-level <level>: Log level:debug,info,warn,error(default:info)
./bootnodoor \
--network mainnet \
--private-key "$(openssl rand -hex 32)" \
--bind-port 30303 \
--enr-ip $(curl -s ifconfig.me) \
--nodedb ./data/mainnet.db \
--web-ui \
--web-port 8080./bootnodoor \
--network sepolia \
--enable-el=false \
--private-key "$(openssl rand -hex 32)" \
--bind-port 9000 \
--enr-ip $(curl -s ifconfig.me) \
--nodedb ./data/sepolia-cl.db \
--web-ui./bootnodoor \
--network hoodi \
--enable-cl=false \
--private-key "$(openssl rand -hex 32)" \
--bind-port 30303 \
--enr-ip $(curl -s ifconfig.me) \
--nodedb ./data/hoodi-el.db \
--web-uiWithout --network, give the config files and genesis values for each layer:
./bootnodoor \
--el-config ./network-configs/genesis.json \
--el-genesis-hash 0x... \
--cl-config ./network-configs/config.yaml \
--genesis-validators-root 0x... \
--private-key "$(openssl rand -hex 32)" \
--bind-port 30303 \
--enr-ip $(curl -s ifconfig.me) \
--nodedb ./data/devnet.db./bootnodoor \
--network hoodi \
--enable-el=false \
--private-key "1234567890123456789012345678901212345678901234567890123456789012" \
--bind-addr 127.0.0.1 \
--bind-port 9000 \
--enr-ip 127.0.0.1 \
--log-level debugThe generic discv5 library can be used independently:
package main
import (
"context"
"crypto/ecdsa"
"log"
ethcrypto "github.com/ethereum/go-ethereum/crypto"
"github.com/ethpandaops/bootnodoor/discv5"
"github.com/ethpandaops/bootnodoor/discv5/protocol"
)
func main() {
// Generate private key
privKey, _ := ethcrypto.GenerateKey()
// Create configuration
cfg := discv5.DefaultConfig()
cfg.PrivateKey = privKey
cfg.BindPort = 9000
// Set callbacks for protocol events
cfg.OnHandshakeComplete = func(n *node.Node, incoming bool) {
log.Printf("Handshake complete with %s", n.PeerID())
}
cfg.OnFindNode = func(msg *protocol.FindNode) []*node.Node {
// Return nodes from your routing table
return myTable.FindClosest(msg.Distances)
}
// Create service
service, err := discv5.New(cfg)
if err != nil {
log.Fatal(err)
}
// Start service
if err := service.Start(); err != nil {
log.Fatal(err)
}
defer service.Stop()
// Use the service
nodes, err := service.FindNode(targetNode, []uint{256})
if err != nil {
log.Fatal(err)
}
log.Printf("Discovered %d nodes", len(nodes))
}When --web-ui is enabled, access the dashboard at http://localhost:8080 (or your configured port).
The dashboard shows:
- Overview: Real-time EL and CL node counts, database statistics
- EL Nodes Page: List of all EL nodes with fork IDs, protocol support, and quality metrics
- CL Nodes Page: List of all CL nodes with fork digests and statistics
- Fork Information: Current and historical fork data for both layers
- Protocol Metrics: Packets sent/received, handshakes, sessions
- Database Stats: Node counts, quality metrics, protocol support distribution
GET /- Web dashboard overviewGET /el-nodes- EL nodes list pageGET /cl-nodes- CL nodes list pageGET /enr- Local ENR in base64 format (use?layer=el|clto select an identity,?generic=1for the fork-stripped record)GET /enode- Local enode URL (EL only; discovery-only, so it advertisestcp=0with the UDP port in?discport=)GET /metrics- Prometheus metrics (when enabled)GET /debug/pprof/- Performance profiling (when--pprofenabled)
Fork IDs are computed from the EL chain config (JSON):
{
"chainId": 1,
"homesteadBlock": 1150000,
"daoForkBlock": 1920000,
"eip150Block": 2463000,
...
"shanghaiTime": 1681338455,
"cancunTime": 1710338135,
"pragueTime": 1746612311
}The bootnode automatically:
- Computes fork ID from genesis hash and all fork blocks/timestamps
- Validates
ethENR field against chain history - Accepts any valid historical fork ID
- No grace period needed (all historical forks are valid)
Fork digests are computed from the CL beacon config (YAML):
# mainnet config.yaml (excerpt)
CONFIG_NAME: "mainnet"
# Fork schedule
ALTAIR_FORK_EPOCH: 74240
BELLATRIX_FORK_EPOCH: 144896
CAPELLA_FORK_EPOCH: 194048
DENEB_FORK_EPOCH: 269568
# Fork versions
GENESIS_FORK_VERSION: 0x00000000
ALTAIR_FORK_VERSION: 0x01000000
BELLATRIX_FORK_VERSION: 0x02000000
CAPELLA_FORK_VERSION: 0x03000000
DENEB_FORK_VERSION: 0x04000000
# Network parameters
SECONDS_PER_SLOT: 12
SLOTS_PER_EPOCH: 32The bootnode automatically:
- Computes fork digests:
compute_fork_digest(fork_version, genesis_validators_root)[:4] - Determines current fork based on network time
- Accepts nodes with current fork digest
- Accepts nodes with old fork digests within grace period (default: 60 minutes)
- Deprioritizes but accepts historical fork digests
The bootnode implements multiple layers of DoS protection:
- Rate Limiting: 100 packets/second per IP at transport layer
- Pending Limits: Bounded pending maps for handshakes and challenges
- Per-IP Limits: Max 10 nodes per IP address to prevent Sybil attacks
- LRU Eviction: Oldest entries evicted when limits reached
- Session Limits: Max 1024 concurrent discv5 sessions with 12-hour lifetime
- Bond Mechanism: Discv4 requires bidirectional PING/PONG before serving nodes
- Storm Prevention: Protection against ping-pong amplification attacks
- Bad Node Caching: Tracks rejected nodes to avoid repeated validation
- WAN clients don't receive LAN nodes (RFC1918 filtering)
- Prevents disclosure of private network topology
- Separate handling for LAN and WAN requesters
- Private Key: Generate a unique key for each bootnode, never reuse keys
- Firewall: Allow only UDP traffic on your configured port
- Monitoring: Enable web UI on localhost only or behind authentication/firewall
- Database: Backup node database periodically for faster restarts
- Updates: Keep the fork schedule current. With
--network, restart before each fork to fetch the new configs. Without it, update your config files and restart - IP Discovery: Use
--enr-ipto manually set public IP, or rely on automatic discovery
For both layers:
- Check
--enr-ipis your public IP, not0.0.0.0or127.0.0.1 - Verify UDP port is open in firewall:
nc -u -z -v <ip> <port> - Ensure system time is synchronized (use NTP)
- Check bootnode configuration (--el-bootnodes or --cl-bootnodes)
For EL:
- Verify
--el-genesis-hashmatches your network - Check the
timestampfield in the genesis file is correct - Ensure EL config has correct fork schedule
- Verify discv4 is enabled if using enode bootnodes
For CL:
- Verify
--genesis-validators-rootis correct for your network - Ensure CL config has correct fork schedule
- Check
GENESIS_FORK_VERSIONin config file
EL fork ID errors:
- Verify genesis hash and time match the network
- Check EL chain config has correct fork blocks and timestamps
- Ensure config matches the network you're trying to join
CL fork digest errors:
- Verify genesis validators root is correct
- Check CL config file has correct fork versions
- Ensure fork schedule matches the network
- Reduce
--max-active-nodes(default: 500 per table) - Reduce
--max-nodes-per-ip(default: 10) - Enable database persistence with
--nodedb - Disable unused layer (don't configure both EL and CL if only one is needed)
- Add more bootnodes (
--el-bootnodesand/or--cl-bootnodes) - Check network connectivity to initial bootnodes
- Verify layer settings (
--enable-el,--enable-cl) - For CL: reduce grace period if too permissive:
--grace-period 30m
go test ./...make buildContributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes with tests
- Submit a pull request
MIT License - see LICENSE file for details.