diff --git a/bootnode/clconfig/config.go b/bootnode/clconfig/config.go index b8a854e..53ba155 100644 --- a/bootnode/clconfig/config.go +++ b/bootnode/clconfig/config.go @@ -134,6 +134,12 @@ func LoadConfig(path string) (*Config, error) { cfg.rawConfig = rawConfig + // GetBlobParamsForEpoch's early break and addBPOForks' naming both assume + // an ascending schedule; the YAML carries no ordering guarantee. + sort.SliceStable(cfg.BlobSchedule, func(i, j int) bool { + return cfg.BlobSchedule[i].Epoch < cfg.BlobSchedule[j].Epoch + }) + // Extract fork data dynamically from the map if err := cfg.extractForkData(rawConfig); err != nil { return nil, fmt.Errorf("failed to extract fork data: %w", err) @@ -524,28 +530,52 @@ func (c *Config) GetForkDigestForEpoch(epoch uint64) ForkDigest { return c.GetForkDigest(forkVersion, blobParams) } -// GetCurrentForkDigest returns the fork digest for the current epoch. -// -// Calculates the current epoch based on genesis time and returns the appropriate fork digest. -func (c *Config) GetCurrentForkDigest() ForkDigest { - // Get genesis time +// currentEpochNow computes the current epoch from genesis time and wall +// clock. The second return is false when no genesis time is configured. +func (c *Config) currentEpochNow() (uint64, bool) { genesisTime := c.GetGenesisTime() if genesisTime == 0 { - // No genesis time, fall back to latest fork with realistic epoch - return c.getFallbackForkDigest() + return 0, false } - - // Calculate current epoch currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() secondsPerSlot := c.SecondsPerSlot if secondsPerSlot == 0 { - secondsPerSlot = 12 // Default + secondsPerSlot = 12 } + return uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, c.GetSlotsPerEpoch())), true +} - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) +// forkBoundaryEpochs returns every epoch at which the wire digest can change: +// genesis, each registered fork (including BPO pseudo-forks), and each blob +// schedule boundary. Sorted ascending, deduplicated. +func (c *Config) forkBoundaryEpochs() []uint64 { + seen := map[uint64]bool{0: true} + epochs := []uint64{0} + add := func(epoch uint64) { + if epoch != math.MaxUint64 && !seen[epoch] { + seen[epoch] = true + epochs = append(epochs, epoch) + } + } + for _, fork := range c.getForks() { + add(fork.epoch) + } + for _, entry := range c.BlobSchedule { + add(entry.Epoch) + } + sort.Slice(epochs, func(i, j int) bool { return epochs[i] < epochs[j] }) + return epochs +} - // Return fork digest for current epoch +// GetCurrentForkDigest returns the fork digest for the current epoch. +// +// Calculates the current epoch based on genesis time and returns the appropriate fork digest. +func (c *Config) GetCurrentForkDigest() ForkDigest { + currentEpoch, ok := c.currentEpochNow() + if !ok { + // No genesis time, fall back to latest fork with realistic epoch + return c.getFallbackForkDigest() + } return c.GetForkDigestForEpoch(currentEpoch) } @@ -554,86 +584,44 @@ func (c *Config) GetGenesisForkDigest() ForkDigest { return c.GetForkDigest(c.genesisForkVersion, nil) } -// GetPreviousForkDigest returns the fork digest for the previous fork before the current one. -// Returns the genesis fork digest if there is no previous fork. -func (c *Config) GetPreviousForkDigest() ForkDigest { - // Get genesis time - genesisTime := c.GetGenesisTime() - if genesisTime == 0 { - // No genesis time, return genesis fork digest - return c.GetGenesisForkDigest() - } - - // Calculate current epoch - currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() - secondsPerSlot := c.SecondsPerSlot - if secondsPerSlot == 0 { - secondsPerSlot = 12 +// previousBoundaryEpoch returns the boundary epoch immediately before the +// currently active one, and whether such a boundary exists. +func (c *Config) previousBoundaryEpoch() (uint64, bool) { + currentEpoch, ok := c.currentEpochNow() + if !ok { + return 0, false } - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) - - // Find the fork before the current one by iterating through forks in forward order - forks := c.getForks() - var currentFork *forkDefinition - var previousFork *forkDefinition - - for i := 0; i < len(forks); i++ { - fork := forks[i] - if currentEpoch >= fork.epoch { - // This fork is active, remember it as current - previousFork = currentFork // The last current becomes previous - currentFork = &forks[i] // This is now current + var passed []uint64 + for _, epoch := range c.forkBoundaryEpochs() { + if epoch > currentEpoch { + break } + passed = append(passed, epoch) } - - // Return the previous fork if it exists - if previousFork != nil { - return c.GetForkDigest(previousFork.parsedVersion, nil) + if len(passed) < 2 { + return 0, false } + return passed[len(passed)-2], true +} - // No previous fork, return genesis - return c.GetGenesisForkDigest() +// GetPreviousForkDigest returns the wire digest that was current before the +// active boundary, computed on the same enumeration as GetAllForkDigests. +// Returns the genesis fork digest if there is no previous boundary. +func (c *Config) GetPreviousForkDigest() ForkDigest { + epoch, ok := c.previousBoundaryEpoch() + if !ok { + return c.GetGenesisForkDigest() + } + return c.GetForkDigestForEpoch(epoch) } // GetPreviousForkName returns the name of the previous fork before the current one. func (c *Config) GetPreviousForkName() string { - // Get genesis time - genesisTime := c.GetGenesisTime() - if genesisTime == 0 { + epoch, ok := c.previousBoundaryEpoch() + if !ok { return "Phase0" } - - // Calculate current epoch - currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() - secondsPerSlot := c.SecondsPerSlot - if secondsPerSlot == 0 { - secondsPerSlot = 12 - } - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) - - // Find the fork before the current one by iterating through forks in forward order - forks := c.getForks() - var currentFork *forkDefinition - var previousFork *forkDefinition - - for i := 0; i < len(forks); i++ { - fork := forks[i] - if currentEpoch >= fork.epoch { - // This fork is active, remember it as current - previousFork = currentFork // The last current becomes previous - currentFork = &forks[i] // This is now current - } - } - - // Return the previous fork name if it exists - if previousFork != nil { - return previousFork.name - } - - // No previous fork, return Phase0 - return "Phase0" + return c.GetForkNameAtEpoch(epoch) } // getFallbackForkDigest returns the latest fork with a realistic epoch. @@ -665,57 +653,43 @@ type ForkDigestInfo struct { ForkVersion [4]byte } -// GetAllForkDigests returns all possible fork digests for this config. -// -// This is useful for creating filters that accept nodes from multiple forks. -// Note: For Fulu+ forks with blob schedules, this returns multiple digests per fork. +// GetAllForkDigests returns every fork digest that can appear on the wire +// for this config: one per boundary epoch, computed through the same +// GetForkDigestForEpoch path that produces the live current digest. Digests +// for same-epoch intermediate forks (never current on the wire) are +// intentionally not included. func (c *Config) GetAllForkDigests() []ForkDigest { var digests []ForkDigest - - // Genesis (epoch 0) - use genesis fork version - digests = append(digests, c.GetForkDigest(c.genesisForkVersion, nil)) - - // All forks (including BPOs) - use their specific fork versions - for _, fork := range c.getForks() { - if fork.epoch != math.MaxUint64 { - // Get blob parameters active at this fork's epoch (if any) - blobParams := c.GetBlobParamsForEpoch(fork.epoch) - digests = append(digests, c.GetForkDigest(fork.parsedVersion, blobParams)) + seen := make(map[ForkDigest]bool) + for _, epoch := range c.forkBoundaryEpochs() { + digest := c.GetForkDigestForEpoch(epoch) + if !seen[digest] { + seen[digest] = true + digests = append(digests, digest) } } - return digests } -// GetAllForkDigestInfos returns all fork digests with their metadata. +// GetAllForkDigestInfos returns all wire-valid fork digests with their +// metadata, on the same boundary enumeration as GetAllForkDigests. func (c *Config) GetAllForkDigestInfos() []ForkDigestInfo { var infos []ForkDigestInfo - - // Add Genesis (epoch 0) - infos = append(infos, ForkDigestInfo{ - Digest: c.GetForkDigest(c.genesisForkVersion, nil), - Name: "Phase0/Genesis", - Epoch: 0, - BlobParams: nil, - ForkVersion: c.genesisForkVersion, - }) - - // Add all forks (including BPOs) - they're already in the correct order - for _, fork := range c.getForks() { - if fork.epoch != math.MaxUint64 { - // Get blob parameters active at this fork's epoch (if any) - blobParams := c.GetBlobParamsForEpoch(fork.epoch) - - infos = append(infos, ForkDigestInfo{ - Digest: c.GetForkDigest(fork.parsedVersion, blobParams), - Name: fork.name, - Epoch: fork.epoch, - BlobParams: blobParams, - ForkVersion: fork.parsedVersion, - }) + seen := make(map[ForkDigest]bool) + for _, epoch := range c.forkBoundaryEpochs() { + digest := c.GetForkDigestForEpoch(epoch) + if seen[digest] { + continue } + seen[digest] = true + infos = append(infos, ForkDigestInfo{ + Digest: digest, + Name: c.GetForkNameAtEpoch(epoch), + Epoch: epoch, + BlobParams: c.GetBlobParamsForEpoch(epoch), + ForkVersion: c.GetForkVersionAtEpoch(epoch), + }) } - return infos } @@ -825,8 +799,11 @@ func EncodeETH2Field(currentDigest ForkDigest, nextForkVersion [4]byte, nextFork // Next fork version (bytes 4-7) copy(field[4:8], nextForkVersion[:]) - // Next fork epoch (bytes 8-15, big endian) - binary.BigEndian.PutUint64(field[8:16], nextForkEpoch) + // Bytes 8-15: the eth2 entry is an SSZ ENRForkID, so the epoch is a + // little-endian uint64. Big-endian reads identically when the epoch is + // FAR_FUTURE_EPOCH, which is why this went unnoticed until a fork was + // actually scheduled. + binary.LittleEndian.PutUint64(field[8:16], nextForkEpoch) return field } diff --git a/bootnode/clconfig/config_test.go b/bootnode/clconfig/config_test.go new file mode 100644 index 0000000..30f2e61 --- /dev/null +++ b/bootnode/clconfig/config_test.go @@ -0,0 +1,142 @@ +package clconfig + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func enumerationConfig(currentEpoch uint64) *Config { + const ( + secondsPerSlot = 12 + slotsPerEpoch = 32 + ) + return &Config{ + SecondsPerSlot: secondsPerSlot, + customGenesisTime: uint64(time.Now().Unix()) - (currentEpoch * secondsPerSlot * slotsPerEpoch) - 60, + customSlotsPerEpoch: slotsPerEpoch, + genesisForkVersion: [4]byte{0x00, 0x00, 0x00, 0x01}, + forks: []forkDefinition{ + {name: "Altair", epoch: 0, parsedVersion: [4]byte{0x01, 0x00, 0x00, 0x00}}, + {name: "Bellatrix", epoch: 0, parsedVersion: [4]byte{0x02, 0x00, 0x00, 0x00}}, + {name: "Capella", epoch: 50, parsedVersion: [4]byte{0x03, 0x00, 0x00, 0x00}}, + }, + } +} + +// TestGetAllForkDigestsEqualsPerEpochEnumeration pins the unified digest +// computation: one digest per boundary epoch through GetForkDigestForEpoch, +// no phantom digests for same-epoch intermediate forks, and the live current +// digest always contained in the set. +func TestGetAllForkDigestsEqualsPerEpochEnumeration(t *testing.T) { + for _, currentEpoch := range []uint64{10, 100} { + cfg := enumerationConfig(currentEpoch) + + digests := cfg.GetAllForkDigests() + if len(digests) != 2 { + t.Fatalf("epoch %d: enumerated %d digests, want 2 boundaries (0, 50) with no phantom same-epoch intermediates", currentEpoch, len(digests)) + } + if digests[0] != cfg.GetForkDigestForEpoch(0) || digests[1] != cfg.GetForkDigestForEpoch(50) { + t.Fatalf("epoch %d: enumeration diverges from GetForkDigestForEpoch", currentEpoch) + } + + current := cfg.GetCurrentForkDigest() + found := false + for _, d := range digests { + if d == current { + found = true + } + } + if !found { + t.Fatalf("epoch %d: current digest %s not in enumerated set", currentEpoch, current.String()) + } + + infos := cfg.GetAllForkDigestInfos() + if len(infos) != len(digests) { + t.Fatalf("epoch %d: infos (%d) and digests (%d) disagree", currentEpoch, len(infos), len(digests)) + } + if infos[0].Name != "Bellatrix" || infos[1].Name != "Capella" { + t.Fatalf("epoch %d: info names = %q, %q; want the wire-active fork per boundary", currentEpoch, infos[0].Name, infos[1].Name) + } + } + + cfg := enumerationConfig(100) + if got := cfg.GetPreviousForkDigest(); got != cfg.GetForkDigestForEpoch(0) { + t.Fatalf("previous digest = %s, want the epoch-0 boundary digest", got.String()) + } + if got := cfg.GetPreviousForkName(); got != "Bellatrix" { + t.Fatalf("previous fork name = %q, want Bellatrix", got) + } +} + +// TestBlobScheduleSortedAtParse verifies an unsorted BLOB_SCHEDULE is sorted +// on load, so GetBlobParamsForEpoch's early break returns the right entry. +func TestBlobScheduleSortedAtParse(t *testing.T) { + yaml := ` +CONFIG_NAME: sorttest +MIN_GENESIS_TIME: 1700000000 +GENESIS_DELAY: 0 +GENESIS_FORK_VERSION: 0x00000001 +SECONDS_PER_SLOT: 12 +ELECTRA_FORK_EPOCH: 10 +ELECTRA_FORK_VERSION: 0x05000001 +FULU_FORK_EPOCH: 100 +FULU_FORK_VERSION: 0x06000001 +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +BLOB_SCHEDULE: + - EPOCH: 300 + MAX_BLOBS_PER_BLOCK: 21 + - EPOCH: 100 + MAX_BLOBS_PER_BLOCK: 12 + - EPOCH: 200 + MAX_BLOBS_PER_BLOCK: 15 +` + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + + for i := 1; i < len(cfg.BlobSchedule); i++ { + if cfg.BlobSchedule[i].Epoch < cfg.BlobSchedule[i-1].Epoch { + t.Fatalf("blob schedule not sorted: %+v", cfg.BlobSchedule) + } + } + if got := cfg.GetBlobParamsForEpoch(250); got == nil || got.MaxBlobsPerBlock != 15 { + t.Fatalf("blob params at 250 = %+v, want the epoch-200 entry (15)", got) + } + if got := cfg.GetBlobParamsForEpoch(50); got != nil { + t.Fatalf("blob params before fulu = %+v, want nil", got) + } + if got := cfg.GetBlobParamsForEpoch(150); got == nil || got.MaxBlobsPerBlock != 12 { + t.Fatalf("blob params at 150 = %+v, want the epoch-100 entry (12)", got) + } +} + +// TestEncodeETH2FieldEpochIsLittleEndian pins the SSZ encoding of +// ENRForkID.next_fork_epoch. A big-endian epoch is byte-identical when the +// value is FAR_FUTURE_EPOCH, so only a scheduled fork exposes the difference — +// which is why every all-forks-at-genesis devnet missed this. +func TestEncodeETH2FieldEpochIsLittleEndian(t *testing.T) { + digest := ForkDigest{0xaa, 0xbb, 0xcc, 0xdd} + version := [4]byte{0x70, 0x00, 0x00, 0x38} + + field := EncodeETH2Field(digest, version, 5) + if len(field) != 16 { + t.Fatalf("eth2 field = %d bytes, want 16", len(field)) + } + want := []byte{0x05, 0, 0, 0, 0, 0, 0, 0} + if !bytes.Equal(field[8:16], want) { + t.Fatalf("next_fork_epoch bytes = % x, want % x (little-endian 5)", field[8:16], want) + } + + // FAR_FUTURE_EPOCH is palindromic, so it must round-trip either way. + if got := EncodeETH2Field(digest, version, ^uint64(0)); !bytes.Equal(got[8:16], []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) { + t.Fatalf("far-future epoch bytes = % x", got[8:16]) + } +} diff --git a/bootnode/clconfig/filter.go b/bootnode/clconfig/filter.go index b36a608..fb90ac2 100644 --- a/bootnode/clconfig/filter.go +++ b/bootnode/clconfig/filter.go @@ -51,7 +51,6 @@ type ForkDigestFilter struct { acceptedOld int acceptedHistorical int rejectedInvalid int - rejectedExpired int } // Logger interface for debug messages @@ -233,8 +232,10 @@ func (f *ForkDigestFilter) Update() { f.currentForkDigest = newDigest f.lastUpdate = time.Now() - // Log would go here - // logger.Info("Fork activated", "old", oldDigest, "new", newDigest) + if f.logger != nil { + f.logger.Debugf("CL fork activated: digest %s -> %s (previous digest accepted for %s)", + oldDigest.String(), newDigest.String(), f.gracePeriod) + } } // Clean up expired old digests @@ -246,35 +247,6 @@ func (f *ForkDigestFilter) Update() { } } -// StartPeriodicUpdate starts a background goroutine that periodically updates the fork digest. -// -// Parameters: -// - interval: How often to check for fork activations (e.g., 5 minutes) -// - stopCh: Channel to signal shutdown -// -// Example: -// -// stopCh := make(chan struct{}) -// filter.StartPeriodicUpdate(5*time.Minute, genesisTime, stopCh) -// -// // Later, to stop: -// close(stopCh) -func (f *ForkDigestFilter) StartPeriodicUpdate(interval time.Duration, stopCh <-chan struct{}) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Update fork digest - f.Update() - - case <-stopCh: - return - } - } -} - // GetCurrentForkDigest returns the current fork digest being checked. func (f *ForkDigestFilter) GetCurrentForkDigest() ForkDigest { f.mu.RLock() @@ -308,7 +280,6 @@ type FilterStats struct { AcceptedOld int AcceptedHistorical int RejectedInvalid int - RejectedExpired int CurrentDigest ForkDigest OldDigests int LastUpdate time.Time @@ -325,7 +296,6 @@ func (f *ForkDigestFilter) GetStats() FilterStats { AcceptedOld: f.acceptedOld, AcceptedHistorical: f.acceptedHistorical, RejectedInvalid: f.rejectedInvalid, - RejectedExpired: f.rejectedExpired, CurrentDigest: f.currentForkDigest, OldDigests: len(f.oldForkDigests), LastUpdate: f.lastUpdate, @@ -375,6 +345,13 @@ func (f *ForkDigestFilter) nextForkInfo() ([4]byte, uint64) { currentForkVersion := f.config.GetForkVersionAtEpoch(currentEpoch) for _, fork := range f.config.getForks() { + // An unscheduled fork is not an upcoming one. Returning its version + // published a next_fork_version for a fork that may never activate, + // where the spec wants the current version once next_fork_epoch is + // FAR_FUTURE_EPOCH. + if fork.epoch == farFutureEpoch { + continue + } if fork.epoch > currentEpoch { return fork.parsedVersion, fork.epoch } @@ -499,12 +476,13 @@ func (f *ForkDigestFilter) GetRejectedInvalid() int { return f.rejectedInvalid } -// GetRejectedExpired returns the count of nodes rejected due to expired grace period. -func (f *ForkDigestFilter) GetRejectedExpired() int { +// GetAcceptedHistorical returns the count of nodes accepted on a historical +// fork digest (valid chain, not the current or grace-period fork). +func (f *ForkDigestFilter) GetAcceptedHistorical() int { f.mu.RLock() defer f.mu.RUnlock() - return f.rejectedExpired + return f.acceptedHistorical } // GetTotalChecks returns the total number of filter checks performed. @@ -562,20 +540,3 @@ func CompatibilityMode(acceptedDigests []ForkDigest) enr.ENRFilter { return digestMap[forkDigest] } } - -// ForkFilterStats contains statistics about fork digest filtering. -type ForkFilterStats struct { - NetworkName string - CurrentFork string - CurrentDigest string - PreviousFork string - PreviousDigest string - GenesisDigest string - GracePeriod string - OldDigests map[string]time.Duration - AcceptedCurrent int - AcceptedOld int - RejectedInvalid int - RejectedExpired int - TotalChecks int -} diff --git a/bootnode/clconfig/filter_test.go b/bootnode/clconfig/filter_test.go index 5169229..1c5eb0e 100644 --- a/bootnode/clconfig/filter_test.go +++ b/bootnode/clconfig/filter_test.go @@ -70,3 +70,50 @@ func TestNextForkInfoFallsBackToFarFuture(t *testing.T) { t.Fatalf("unexpected fallback next fork epoch: got %d", nextEpoch) } } + +// TestForkDigestFilterUpdateTransition verifies Update() tracks an epoch +// crossing: the current digest swaps, the old digest lands in the grace map +// and stays accepted, and the recomputed eth2 field reflects the new digest. +func TestForkDigestFilterUpdateTransition(t *testing.T) { + const ( + secondsPerSlot = 12 + slotsPerEpoch = 32 + ) + cfg := &Config{ + SecondsPerSlot: secondsPerSlot, + customSlotsPerEpoch: slotsPerEpoch, + genesisForkVersion: [4]byte{0x00, 0x00, 0x00, 0x01}, + forks: []forkDefinition{ + {name: "Altair", epoch: 0, parsedVersion: [4]byte{0x01, 0x00, 0x00, 0x00}}, + {name: "Capella", epoch: 100, parsedVersion: [4]byte{0x02, 0x00, 0x00, 0x00}}, + }, + } + cfg.SetGenesisTime(uint64(time.Now().Unix()) - (50 * secondsPerSlot * slotsPerEpoch) - 60) + + filter := NewForkDigestFilter(cfg, time.Hour) + before := cfg.GetForkDigestForEpoch(50) + after := cfg.GetForkDigestForEpoch(100) + if got := filter.GetStats().CurrentDigest; got != before { + t.Fatalf("initial digest = %s, want pre-transition %s", got.String(), before.String()) + } + + cfg.SetGenesisTime(uint64(time.Now().Unix()) - (150 * secondsPerSlot * slotsPerEpoch) - 60) + filter.Update() + + stats := filter.GetStats() + if stats.CurrentDigest != after { + t.Fatalf("post-update digest = %s, want %s", stats.CurrentDigest.String(), after.String()) + } + if stats.OldDigests != 1 { + t.Fatalf("old digests = %d, want the pre-transition digest in the grace map", stats.OldDigests) + } + field := filter.ComputeEth2Field() + if len(field) < 4 { + t.Fatalf("eth2 field too short: %d bytes", len(field)) + } + var gotDigest ForkDigest + copy(gotDigest[:], field[:4]) + if gotDigest != after { + t.Fatalf("eth2 field digest = %s, want post-transition %s", gotDigest.String(), after.String()) + } +} diff --git a/bootnode/elconfig/filter.go b/bootnode/elconfig/filter.go index b77e95a..de69f2c 100644 --- a/bootnode/elconfig/filter.go +++ b/bootnode/elconfig/filter.go @@ -2,29 +2,72 @@ package elconfig import ( "fmt" + "hash/crc32" + "math" + "sync" + "time" ) // ForkFilter validates fork IDs from remote nodes. // -// It checks if a remote fork ID is compatible with the local chain. +// It ports go-ethereum's EIP-2124 validation ruleset evaluated with a static +// head stance: a bootnode tracks no chain head, so every block-scheduled fork +// is treated as passed (exact on post-merge networks, which can only schedule +// forks by time) and the time head is the wall clock at validation time. type ForkFilter struct { - // validForkIDs contains all acceptable fork IDs - validForkIDs map[[4]byte]bool - - // allForkIDs contains the complete list for debugging - allForkIDs []ForkID - // genesisHash is the genesis block hash genesisHash [32]byte // chainConfig is the chain configuration chainConfig *ChainConfig + + // genesisTime is the genesis block timestamp; fork-id math must drop + // time-scheduled forks at or before it, exactly like go-ethereum's + // gatherForks(config, genesis.Time()). + genesisTime uint64 + + // forks holds every canonical fork boundary (blocks then times) plus the + // MaxUint64 sentry go-ethereum appends so the last real fork needs no + // special casing. + forks []uint64 + + // numBlockForks is the boundary index separating block forks from time + // forks in forks, including go-ethereum's rule that the sentry counts as + // a block fork when the chain has no time forks at all. + numBlockForks int + + // blockHead is the static block head: at or past every canonical block + // fork, before the sentry. + blockHead uint64 + + // sums[i] is the checksum after passing the first i fork boundaries. + sums [][4]byte + + // allForkIDs contains the complete canonical list for display + allForkIDs []ForkID + + // Admission outcomes, recorded by the admission call sites only (the + // filter is also invoked for per-packet layer classification, which must + // not pollute these numbers). + statsMu sync.Mutex + totalChecks uint64 + accepted uint64 + rejected uint64 + lastRejectedID ForkID +} + +// FilterStats is a snapshot of admission outcomes. +type FilterStats struct { + TotalChecks uint64 + Accepted uint64 + Rejected uint64 + LastRejectedID ForkID } // NewForkFilter creates a new fork ID filter. // -// It pre-computes all valid fork IDs for the chain so that nodes -// on any valid fork (past, current, or future) are accepted. +// It pre-computes the canonical fork checksum chain so remote fork IDs can be +// validated with go-ethereum's ruleset. // // Parameters: // - genesisHash: Genesis block hash @@ -33,50 +76,131 @@ type ForkFilter struct { // // Returns a filter that can validate remote fork IDs. func NewForkFilter(genesisHash [32]byte, config *ChainConfig, genesisTime uint64) *ForkFilter { - // Gather all forks forksByBlock, forksByTime := GatherForks(config, genesisTime) - // Compute all possible fork IDs - allForkIDs := ComputeAllForkIDs(genesisHash, forksByBlock, forksByTime) + forks := append(append([]uint64{}, forksByBlock...), forksByTime...) + sums := make([][4]byte, len(forks)+1) + hash := crc32.ChecksumIEEE(genesisHash[:]) + sums[0] = checksumToBytes(hash) + for i, fork := range forks { + hash = checksumUpdate(hash, fork) + sums[i+1] = checksumToBytes(hash) + } - // Build lookup map for fast validation - validForkIDs := make(map[[4]byte]bool) - for _, id := range allForkIDs { - validForkIDs[id.Hash] = true + blockHead := uint64(0) + if len(forksByBlock) > 0 { + blockHead = forksByBlock[len(forksByBlock)-1] } + numBlockForks := len(forksByBlock) + forks = append(forks, math.MaxUint64) + if len(forksByTime) == 0 { + // In purely block based forks, keep the sentry out of timestamp + // territory (go-ethereum's rule). + numBlockForks++ + } + + allForkIDs := ComputeAllForkIDs(genesisHash, forksByBlock, forksByTime) + return &ForkFilter{ - validForkIDs: validForkIDs, - allForkIDs: allForkIDs, - genesisHash: genesisHash, - chainConfig: config, + genesisHash: genesisHash, + chainConfig: config, + genesisTime: genesisTime, + forks: forks, + numBlockForks: numBlockForks, + blockHead: blockHead, + sums: sums, + allForkIDs: allForkIDs, } } // Filter checks if a fork ID is valid for this chain. // // Returns true if the fork ID is acceptable, false otherwise. -// -// This implementation is permissive - it accepts any node that appears -// to be on the same chain, regardless of whether they're ahead or behind. func (f *ForkFilter) Filter(id ForkID) bool { - return f.validForkIDs[id.Hash] + return f.validate(id, uint64(time.Now().Unix())) == nil } -// FilterStrict performs strict fork ID validation. +// validate runs go-ethereum's fork checksum validation ruleset with the +// static head stance (see the ForkFilter doc). now is a parameter so tests +// can pin the time head. // -// Returns nil if valid, error describing the issue otherwise. -func (f *ForkFilter) FilterStrict(id ForkID, currentBlock, currentTime uint64) error { - // Check if hash is valid - if !f.validForkIDs[id.Hash] { +// The ruleset, verbatim from go-ethereum: +// 1. If local and remote FORK_CSUM matches, compare local head to FORK_NEXT. +// 1a. A remotely announced but remotely not passed block is already +// passed locally: reject, the chains are incompatible. +// 1b. No remotely announced fork, or not yet passed locally: accept. +// 2. If the remote FORK_CSUM is a subset of the local past forks and the +// remote FORK_NEXT matches the locally following fork: accept (they are +// syncing). +// 3. If the remote FORK_CSUM is a superset of the local past forks and can +// be completed with locally known future forks: accept (we are syncing). +// 4. Reject in all other cases. +func (f *ForkFilter) validate(id ForkID, now uint64) error { + for i, fork := range f.forks { + head := f.blockHead + if i >= f.numBlockForks { + head = now + } + if head >= fork { + continue + } + // Found the first unpassed fork, check the remote against it (rule #1). + if f.sums[i] == id.Hash { + // A remote-announced fork we have already passed means the remote + // is stale (rule #1a). Every unpassed fork here is time-scheduled + // (block forks are all passed under the static stance), so the + // head to compare is the wall clock. + if id.Next > 0 && now >= id.Next { + return fmt.Errorf("remote is stale: announced fork %d already passed", id.Next) + } + return nil + } + // Different fork state: subset means the remote is syncing (rule #2). + for j := 0; j < i; j++ { + if f.sums[j] == id.Hash { + if f.forks[j] != id.Next { + return fmt.Errorf("remote is stale: subset checksum with next %d, want %d", id.Next, f.forks[j]) + } + return nil + } + } + // Superset means we would be the one syncing (rule #3). + for j := i + 1; j < len(f.sums); j++ { + if f.sums[j] == id.Hash { + return nil + } + } return fmt.Errorf("incompatible fork ID hash: %#x", id.Hash) } + // Unreachable: the MaxUint64 sentry can never be passed. + return nil +} - // For strict validation, we could also check id.Next against our state - // to detect if the remote is on a stale fork. For now, we accept any - // valid hash. +// RecordAdmission records an admission decision for the stats surface. Call +// this from admission paths only, never from layer classification. +func (f *ForkFilter) RecordAdmission(acceptedNode bool, id ForkID) { + f.statsMu.Lock() + defer f.statsMu.Unlock() + f.totalChecks++ + if acceptedNode { + f.accepted++ + return + } + f.rejected++ + f.lastRejectedID = id +} - return nil +// GetStats returns a snapshot of the admission outcomes. +func (f *ForkFilter) GetStats() FilterStats { + f.statsMu.Lock() + defer f.statsMu.Unlock() + return FilterStats{ + TotalChecks: f.totalChecks, + Accepted: f.accepted, + Rejected: f.rejected, + LastRejectedID: f.lastRejectedID, + } } // GetAllForkIDs returns all valid fork IDs for debugging. @@ -86,7 +210,7 @@ func (f *ForkFilter) GetAllForkIDs() []ForkID { // GetCurrentForkID calculates the current fork ID based on chain state. func (f *ForkFilter) GetCurrentForkID(currentBlock, currentTime uint64) ForkID { - forksByBlock, forksByTime := GatherForks(f.chainConfig, 0) + forksByBlock, forksByTime := GatherForks(f.chainConfig, f.genesisTime) return ComputeForkID(f.genesisHash, forksByBlock, forksByTime, currentBlock, currentTime) } @@ -100,81 +224,71 @@ type ForkIDWithName struct { // GetAllForkIDsWithNames returns all fork IDs along with their names. // This is useful for displaying fork information in the UI. -func (f *ForkFilter) GetAllForkIDsWithNames(genesisTime uint64) []ForkIDWithName { +// +// Multiple upgrades activating at one block or timestamp share a single fork +// ID, so names are grouped per deduplicated boundary instead of paired +// positionally. +func (f *ForkFilter) GetAllForkIDsWithNames() []ForkIDWithName { if f.chainConfig == nil { return nil } - - // Extract fork data if not already done if len(f.chainConfig.forksByBlock) == 0 && len(f.chainConfig.forksByTime) == 0 && f.chainConfig.rawConfig != nil { f.chainConfig.extractForkData() } - // Collect all forks with their activation points - type forkWithValue struct { - name string + type boundary struct { + names []string value uint64 isTime bool } - - var allForks []forkWithValue - - // Add genesis - allForks = append(allForks, forkWithValue{name: "genesis", value: 0, isTime: false}) - - // Add block-based forks + var boundaries []boundary + appendFork := func(name string, value uint64, isTime bool) { + for i := range boundaries { + if boundaries[i].value == value && boundaries[i].isTime == isTime { + boundaries[i].names = append(boundaries[i].names, name) + return + } + } + boundaries = append(boundaries, boundary{names: []string{name}, value: value, isTime: isTime}) + } for _, fork := range f.chainConfig.forksByBlock { if fork.value > 0 { - allForks = append(allForks, forkWithValue{name: fork.name, value: fork.value, isTime: false}) + appendFork(fork.name, fork.value, false) } } - - // Add time-based forks for _, fork := range f.chainConfig.forksByTime { - if fork.value > genesisTime { - allForks = append(allForks, forkWithValue{name: fork.name, value: fork.value, isTime: true}) + if fork.value > f.genesisTime { + appendFork(fork.name, fork.value, true) } } - // Gather fork values for computing fork IDs - forksByBlock, forksByTime := GatherForks(f.chainConfig, genesisTime) - - // Compute all fork IDs - allForkIDs := ComputeAllForkIDs(f.genesisHash, forksByBlock, forksByTime) - - // Build result - match fork IDs to names - // The fork IDs are in order: genesis, then each subsequent fork - result := make([]ForkIDWithName, 0, len(allForkIDs)) - - if len(allForkIDs) > 0 { - // First fork ID is always genesis - result = append(result, ForkIDWithName{ - ForkID: allForkIDs[0], - Name: "Genesis", - Activation: 0, - IsTime: false, - }) - - // Subsequent fork IDs correspond to activation points in chronological order - forkIdx := 1 - for _, fork := range allForks { - if fork.name != "genesis" && forkIdx < len(allForkIDs) { - // Capitalize first letter of fork name for display - displayName := fork.name - if len(displayName) > 0 { - displayName = string(displayName[0]-32) + displayName[1:] - } - - result = append(result, ForkIDWithName{ - ForkID: allForkIDs[forkIdx], - Name: displayName, - Activation: fork.value, - IsTime: fork.isTime, - }) - forkIdx++ + result := make([]ForkIDWithName, 0, len(f.allForkIDs)) + result = append(result, ForkIDWithName{ + ForkID: f.allForkIDs[0], + Name: "Genesis", + Activation: 0, + IsTime: false, + }) + for i, b := range boundaries { + if i+1 >= len(f.allForkIDs) { + break + } + name := "" + for j, n := range b.names { + if len(n) > 0 { + n = string(n[0]-32) + n[1:] + } + if j > 0 { + name += "/" } + name += n } + result = append(result, ForkIDWithName{ + ForkID: f.allForkIDs[i+1], + Name: name, + Activation: b.value, + IsTime: b.isTime, + }) } - return result } diff --git a/bootnode/elconfig/filter_test.go b/bootnode/elconfig/filter_test.go new file mode 100644 index 0000000..f022da5 --- /dev/null +++ b/bootnode/elconfig/filter_test.go @@ -0,0 +1,236 @@ +package elconfig + +import ( + "encoding/binary" + "encoding/hex" + "strings" + "testing" +) + +// mainnetGenesisHash is the Ethereum mainnet genesis block hash, so the +// checksums below can be verified against go-ethereum's forkid test vectors. +var mainnetGenesisHash = mustHash32("d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3") + +func mustHash32(s string) [32]byte { + b, err := hex.DecodeString(s) + if err != nil || len(b) != 32 { + panic("bad hash literal") + } + var out [32]byte + copy(out[:], b) + return out +} + +// mainnetConfig mirrors go-ethereum's params.MainnetChainConfig fork +// schedule. Mainnet's genesis block timestamp is 0. +func mainnetConfig() *ChainConfig { + return &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 1150000, + "daoForkBlock": 1920000, + "eip150Block": 2463000, + "eip155Block": 2675000, + "eip158Block": 2675000, + "byzantiumBlock": 4370000, + "constantinopleBlock": 7280000, + "petersburgBlock": 7280000, + "istanbulBlock": 9069000, + "muirGlacierBlock": 9200000, + "berlinBlock": 12244000, + "londonBlock": 12965000, + "arrowGlacierBlock": 13773000, + "grayGlacierBlock": 15050000, + "shanghaiTime": 1681338455, + "cancunTime": 1710338135, + "pragueTime": 1746612311, + "osakaTime": 1764798551, + "bpo1Time": 1765290071, + "bpo2Time": 1767747671, + }} +} + +// devnetConfig models a kurtosis-style devnet: every fork active at genesis. +func devnetConfig(genesisTime uint64) *ChainConfig { + return &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 0, + "londonBlock": 0, + "shanghaiTime": int(genesisTime), + "cancunTime": int(genesisTime), + "pragueTime": int(genesisTime), + }} +} + +func sum32(hash uint32) [4]byte { + var out [4]byte + binary.BigEndian.PutUint32(out[:], hash) + return out +} + +func TestGatherForksGenesisCutoff(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + if len(byBlock) != 12 { + t.Fatalf("block forks = %d (%v), want 12 deduplicated boundaries", len(byBlock), byBlock) + } + if len(byTime) != 6 { + t.Fatalf("time forks = %d (%v), want 6", len(byTime), byTime) + } + for i := 1; i < len(byBlock); i++ { + if byBlock[i] <= byBlock[i-1] { + t.Fatalf("block forks not strictly ascending: %v", byBlock) + } + } + + _, byTime = GatherForks(mainnetConfig(), 1710338135) + if len(byTime) != 4 || byTime[0] != 1746612311 { + t.Fatalf("cutoff at cancun kept %v, want time forks strictly after the cutoff", byTime) + } + + byBlock, byTime = GatherForks(devnetConfig(1700000000), 1700000000) + if len(byBlock) != 0 || len(byTime) != 0 { + t.Fatalf("all-at-genesis devnet gathered %v/%v, want none (genesis ruleset)", byBlock, byTime) + } +} + +// TestComputeForkIDNextSelection pins go-ethereum's mainnet TestCreation +// vectors at era boundaries. +func TestComputeForkIDNextSelection(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + cases := []struct { + head, time uint64 + wantHash uint32 + wantNext uint64 + }{ + {0, 0, 0xfc64ec04, 1150000}, + {1149999, 0, 0xfc64ec04, 1150000}, + {1150000, 0, 0x97c2c34c, 1920000}, + {15050000, 1681338454, 0xf0afd0e3, 1681338455}, + {20000000, 1681338455, 0xdce96c2d, 1710338135}, + {30000000, 1710338134, 0xdce96c2d, 1710338135}, + {30000000, 1710338135, 0x9f3d2254, 1746612311}, + {30000000, 1746612311, 0xc376cf8b, 1764798551}, + {30000000, 1767747671, 0x07c9462e, 0}, + {50000000, 2000000000, 0x07c9462e, 0}, + } + for _, c := range cases { + got := ComputeForkID(mainnetGenesisHash, byBlock, byTime, c.head, c.time) + if got.Hash != sum32(c.wantHash) || got.Next != c.wantNext { + t.Errorf("ComputeForkID(head=%d, time=%d) = %v, want {%#x %d}", c.head, c.time, got, c.wantHash, c.wantNext) + } + } +} + +func TestComputeAllForkIDsConsistentWithComputeForkID(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + all := ComputeAllForkIDs(mainnetGenesisHash, byBlock, byTime) + if len(all) != len(byBlock)+len(byTime)+1 { + t.Fatalf("enumerated %d ids, want %d", len(all), len(byBlock)+len(byTime)+1) + } + boundaries := append(append([]uint64{}, byBlock...), byTime...) + for i, boundary := range boundaries { + var head, time uint64 + if i < len(byBlock) { + head = boundary + time = 0 + } else { + head = byBlock[len(byBlock)-1] + time = boundary + } + got := ComputeForkID(mainnetGenesisHash, byBlock, byTime, head, time) + if got != all[i+1] { + t.Errorf("boundary %d: walked id %v != enumerated %v", boundary, got, all[i+1]) + } + } +} + +// TestGetCurrentForkIDUsesGenesisTime is the regression pin for the hardcoded +// genesisTime=0 bug: an all-at-genesis devnet must report the genesis-era id +// with no upcoming fork, and the result must be in the admission set. +func TestGetCurrentForkIDUsesGenesisTime(t *testing.T) { + const genesisTime = 1700000000 + genesisHash := mustHash32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + f := NewForkFilter(genesisHash, devnetConfig(genesisTime), genesisTime) + + got := f.GetCurrentForkID(999999999, genesisTime+600) + byBlock, byTime := GatherForks(devnetConfig(genesisTime), genesisTime) + want := ComputeForkID(genesisHash, byBlock, byTime, 0, 0) + if got != want || got.Next != 0 { + t.Fatalf("devnet current fork id = %v, want genesis-era %v with Next 0", got, want) + } + found := false + for _, id := range f.GetAllForkIDs() { + if id == got { + found = true + } + } + if !found { + t.Fatal("current fork id is not in the filter's own admission set") + } + + mf := NewForkFilter(mainnetGenesisHash, mainnetConfig(), 0) + if got := mf.GetCurrentForkID(999999999, 1710338135); got.Hash != sum32(0x9f3d2254) || got.Next != 1746612311 { + t.Fatalf("mainnet current-era id = %v, want {0x9f3d2254 1746612311}", got) + } +} + +// TestForkFilterValidation ports the static-stance-relevant subset of +// go-ethereum's validation rules: the time head is pinned mid-Cancun. +func TestForkFilterValidation(t *testing.T) { + f := NewForkFilter(mainnetGenesisHash, mainnetConfig(), 0) + const now = 1720000000 // between cancun (1710338135) and prague (1746612311) + + cases := []struct { + name string + id ForkID + accept bool + }{ + {"current era, correct next", ForkID{sum32(0x9f3d2254), 1746612311}, true}, + {"current era, no next", ForkID{sum32(0x9f3d2254), 0}, true}, + {"current era, stale next already passed (rule 1a)", ForkID{sum32(0x9f3d2254), 1710338135}, false}, + {"subset syncing peer, correct next (rule 2)", ForkID{sum32(0xdce96c2d), 1710338135}, true}, + {"subset stale peer, wrong next (rule 2)", ForkID{sum32(0xdce96c2d), 0}, false}, + {"deep subset syncing peer, correct next", ForkID{sum32(0xfc64ec04), 1150000}, true}, + {"superset future peer (rule 3)", ForkID{sum32(0xc376cf8b), 1764798551}, true}, + {"unknown chain (rule 4)", ForkID{sum32(0xdeadbeef), 0}, false}, + } + for _, c := range cases { + err := f.validate(c.id, now) + if (err == nil) != c.accept { + t.Errorf("%s: validate(%v) = %v, want accept=%v", c.name, c.id, err, c.accept) + } + } + + f.RecordAdmission(true, cases[0].id) + f.RecordAdmission(false, cases[7].id) + stats := f.GetStats() + if stats.TotalChecks != 2 || stats.Accepted != 1 || stats.Rejected != 1 || stats.LastRejectedID != cases[7].id { + t.Fatalf("admission stats = %+v", stats) + } +} + +// TestGetAllForkIDsWithNamesAlignment covers duplicate activations: two +// upgrades sharing one timestamp must collapse to one named row per fork id. +func TestGetAllForkIDsWithNamesAlignment(t *testing.T) { + cfg := &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 1000, + "shanghaiTime": 5000, + "cancunTime": 5000, + "pragueTime": 6000, + }} + genesisHash := mustHash32("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + f := NewForkFilter(genesisHash, cfg, 100) + + rows := f.GetAllForkIDsWithNames() + if len(rows) != len(f.GetAllForkIDs()) { + t.Fatalf("rows = %d, want one per fork id (%d)", len(rows), len(f.GetAllForkIDs())) + } + for i, row := range rows { + if row.ForkID != f.GetAllForkIDs()[i] { + t.Errorf("row %d id %v misaligned with enumerated %v", i, row.ForkID, f.GetAllForkIDs()[i]) + } + } + if rows[0].Name != "Genesis" || rows[1].Name != "Homestead" || rows[3].Name != "Prague" { + t.Fatalf("row names = %q, %q, _, %q", rows[0].Name, rows[1].Name, rows[3].Name) + } + if !strings.Contains(rows[2].Name, "Shanghai") || !strings.Contains(rows[2].Name, "Cancun") || rows[2].Activation != 5000 || !rows[2].IsTime { + t.Fatalf("shared-activation row = %+v, want both upgrade names at @5000", rows[2]) + } +} diff --git a/bootnode/enr.go b/bootnode/enr.go index 2feafa8..50066ed 100644 --- a/bootnode/enr.go +++ b/bootnode/enr.go @@ -1,9 +1,12 @@ package bootnode import ( + "bytes" "crypto/ecdsa" "fmt" + "math" "net" + "time" "github.com/ethpandaops/bootnodoor/bootnode/clconfig" "github.com/ethpandaops/bootnodoor/bootnode/elconfig" @@ -63,8 +66,20 @@ func NewENRManager(cfg *Config, key *ecdsa.PrivateKey, localNode *v5node.Node, s return manager } +// StaticHead returns the head a bootnode evaluates fork schedules at. It +// tracks no chain, so every block-scheduled fork counts as passed (exact on +// post-merge networks, which can only schedule forks by time) and the time +// head is the wall clock. +func StaticHead() (block, timestamp uint64) { + return math.MaxUint64 - 1, uint64(time.Now().Unix()) +} + // UpdateENR updates the local ENR with current eth and eth2 fields. // +// It is a no-op when the computed fields already match the published record, +// so periodic callers do not churn the sequence number (peers re-fetch a +// record on every bump). +// // This should be called: // - On startup // - After fork transitions @@ -78,6 +93,8 @@ func (m *ENRManager) UpdateENR(currentBlock, currentTime uint64) error { return fmt.Errorf("failed to clone ENR: %w", err) } + changed := false + // A bootnode serves no TCP, so never advertise tcp/tcp6 — including any // inherited from an ENR persisted by an older, TCP-advertising version. newRecord.Delete("tcp") @@ -98,24 +115,43 @@ func (m *ENRManager) UpdateENR(currentBlock, currentTime uint64) error { } newRecord.Set("eth", ethField) - m.config.Logger.WithField("forkID", forkID.String()).Debug("updated ENR with eth field") - } else { + if current, ok := record.Eth(); !ok || len(current) == 0 || + current[0].ForkID != forkID.Hash || current[0].NextForkEpoch != forkID.Next { + changed = true + m.config.Logger.WithField("forkID", forkID.String()).Debug("updated ENR with eth field") + } + } else if record.Has("eth") { // Drop any stale eth field (e.g. inherited from a reused shared ENR). newRecord.Delete("eth") + changed = true } if m.servesCL && m.config.HasCL() { eth2Field := m.clFilter.ComputeEth2Field() newRecord.Set("eth2", eth2Field) - // eth2Field is []byte, extract first 4 bytes as fork digest for logging - var forkDigest [4]byte - if len(eth2Field) >= 4 { - copy(forkDigest[:], eth2Field[0:4]) + var currentEth2 []byte + if err := record.Get("eth2", ¤tEth2); err != nil || !bytes.Equal(currentEth2, eth2Field) { + changed = true + + // eth2Field is []byte, extract first 4 bytes as fork digest for logging + var forkDigest [4]byte + if len(eth2Field) >= 4 { + copy(forkDigest[:], eth2Field[0:4]) + } + m.config.Logger.WithField("forkDigest", fmt.Sprintf("%#x", forkDigest)).Debug("updated ENR with eth2 field") } - m.config.Logger.WithField("forkDigest", fmt.Sprintf("%#x", forkDigest)).Debug("updated ENR with eth2 field") - } else { + } else if record.Has("eth2") { newRecord.Delete("eth2") + changed = true + } + + if record.Has("tcp") || record.Has("tcp6") { + changed = true + } + + if !changed { + return nil } // Increment sequence number diff --git a/bootnode/service.go b/bootnode/service.go index b28017b..9029e43 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -153,7 +153,8 @@ func New(cfg *Config) (*Service, error) { } id.enrManager = NewENRManager(cfg, id.key, localNode, id.servesEL, id.servesCL) - if uerr := id.enrManager.UpdateENR(0, 0); uerr != nil { + headBlock, headTime := StaticHead() + if uerr := id.enrManager.UpdateENR(headBlock, headTime); uerr != nil { cfg.Logger.WithError(uerr).Warn("failed to update ENR with eth/eth2 fields") } else if serr := s.storeENR(id.storeKey, localNode.Record()); serr != nil { cfg.Logger.WithError(serr).Warn("failed to store updated ENR") @@ -242,9 +243,8 @@ func New(cfg *Config) (*Service, error) { // Create lookup services for enabled layers if cfg.HasEL() && s.elTable != nil { - localNode := nodes.NewFromV5(s.localNode, s.elNodeDB) s.elLookupService = services.NewLookupService(services.Config{ - LocalNode: localNode, + LocalIDs: s.localIDs(), NodeDB: s.elNodeDB, Table: s.elTable, V5Handler: s.getV5Handler(), @@ -253,15 +253,36 @@ func New(cfg *Config) (*Service, error) { Layer: db.LayerEL, Alpha: 3, LookupTimeout: 30 * time.Second, - OnNodeFound: func(n *nodes.Node) bool { + OnNodeFound: func(n *nodes.Node) services.AdmissionResult { // Filter by fork ID before adding to table if n.Record() != nil && s.enrManager != nil { - if isEL, _ := s.enrManager.FilterELNode(n.Record()); !isEL { + isEL, forkID := s.enrManager.FilterELNode(n.Record()) + if !isEL { + // A record with no eth entry is a consensus node, not an + // execution node on the wrong fork. Counting those as + // fork rejections would make a healthy dual-layer network + // look like a fork-compatibility failure. + if !n.Record().Has("eth") { + if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerEL, "not_el"); err != nil { + cfg.Logger.WithError(err).Debug("failed to store bad node") + } + return services.AdmissionRejectedLayer + } + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(false, forkID) + } + cfg.Logger.WithFields(logrus.Fields{ + "peerID": n.PeerID(), + "eth": forkID.String(), + }).Debug("EL lookup admission rejected: incompatible fork id") // Mark as bad node if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerEL, "invalid_fork_id"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } - return false + return services.AdmissionRejectedFilter + } + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(true, forkID) } } @@ -300,14 +321,14 @@ func New(cfg *Config) (*Service, error) { } // Attempt to add to EL table - added := s.elTable.Add(n) - if added { - // Remove from bad nodes list if it was previously bad - if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerEL); err != nil { - cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") - } + if !s.elTable.Add(n) { + return services.AdmissionRejectedPool } - return added + // Remove from bad nodes list if it was previously bad + if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerEL); err != nil { + cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") + } + return services.AdmissionAccepted }, Logger: cfg.Logger.WithField("service", "el-lookup"), }) @@ -315,7 +336,6 @@ func New(cfg *Config) (*Service, error) { if cfg.HasCL() && s.clTable != nil { clID := s.clIdentity() - localNode := nodes.NewFromV5(clID.localNode, s.clNodeDB) // CL discovery runs under the CL identity's discv5 handler. discv4 is // EL-only, so only attach it when one shared identity serves both layers. var clV5Handler *v5protocol.Handler @@ -327,7 +347,7 @@ func New(cfg *Config) (*Service, error) { clV4Service = s.getV4Service() } s.clLookupService = services.NewLookupService(services.Config{ - LocalNode: localNode, + LocalIDs: s.localIDs(), NodeDB: s.clNodeDB, Table: s.clTable, V5Handler: clV5Handler, @@ -336,26 +356,34 @@ func New(cfg *Config) (*Service, error) { Layer: db.LayerCL, Alpha: 3, LookupTimeout: 30 * time.Second, - OnNodeFound: func(n *nodes.Node) bool { + OnNodeFound: func(n *nodes.Node) services.AdmissionResult { // Filter by fork digest before adding to table if n.Record() != nil && s.enrManager != nil { if !s.enrManager.FilterCLNode(n.Record()) { + // No eth2 entry means an execution node, not a consensus + // node on the wrong digest; keep the two distinguishable. + if !n.Record().Has("eth2") { + if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "not_cl"); err != nil { + cfg.Logger.WithError(err).Debug("failed to store bad node") + } + return services.AdmissionRejectedLayer + } // Mark as bad node if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "invalid_fork_digest"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } - return false + return services.AdmissionRejectedFilter } } // Attempt to add to CL table - added := s.clTable.Add(n) - if added { - // Remove from bad nodes list if it was previously bad - if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerCL); err != nil { - cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") - } + if !s.clTable.Add(n) { + return services.AdmissionRejectedPool } - return added + // Remove from bad nodes list if it was previously bad + if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerCL); err != nil { + cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") + } + return services.AdmissionAccepted }, Logger: cfg.Logger.WithField("service", "cl-lookup"), }) @@ -605,6 +633,7 @@ func (s *Service) maintenanceLoop() { supportCheck := time.NewTicker(30 * time.Minute) // Check protocol support every 30 minutes badNodesCleanup := time.NewTicker(24 * time.Hour) // Cleanup bad nodes once per day enrRequestCleanup := time.NewTicker(1 * time.Minute) // Cleanup stale ENR requests every minute + forkRefresh := time.NewTicker(1 * time.Minute) // Re-publish eth/eth2 when a fork activates defer tableMaintenance.Stop() defer alivenessCheck.Stop() @@ -612,6 +641,7 @@ func (s *Service) maintenanceLoop() { defer supportCheck.Stop() defer badNodesCleanup.Stop() defer enrRequestCleanup.Stop() + defer forkRefresh.Stop() for { select { @@ -635,7 +665,62 @@ func (s *Service) maintenanceLoop() { case <-enrRequestCleanup.C: s.cleanupStaleENRRequests() + + case <-forkRefresh.C: + s.refreshForkENR() + } + } +} + +// localIDs returns the node IDs of every discovery identity, so discovery can +// exclude our own records from candidate sets. +func (s *Service) localIDs() [][32]byte { + ids := make([][32]byte, 0, len(s.identities)) + for _, id := range s.identities { + if id.localNode != nil { + ids = append(ids, id.localNode.ID()) + } + } + return ids +} + +// refreshForkENR re-publishes the eth/eth2 ENR fields when a fork activates +// while running. Nothing else refreshes them, so without this a long-lived +// bootnode keeps advertising a stale fork id past every scheduled transition. +// UpdateENR is a no-op when nothing changed, so the sequence number only moves +// at real transitions. +func (s *Service) refreshForkENR() { + s.mu.Lock() + defer s.mu.Unlock() + + headBlock, headTime := StaticHead() + + for _, id := range s.identities { + if id.localNode == nil || id.enrManager == nil { + continue + } + // Advance the CL accept set first so the recomputed eth2 field carries + // the new digest. + if clFilter := id.enrManager.GetCLFilter(); clFilter != nil { + clFilter.Update() + } + + beforeSeq := id.localNode.Record().Seq() + if err := id.enrManager.UpdateENR(headBlock, headTime); err != nil { + s.config.Logger.WithError(err).Error("failed to refresh fork fields in ENR") + continue + } + if id.localNode.Record().Seq() == beforeSeq { + continue + } + + if err := s.storeENR(id.storeKey, id.localNode.Record()); err != nil { + s.config.Logger.WithError(err).Warn("failed to store refreshed ENR") + } + if id.servesEL && s.discv4Service != nil { + s.discv4Service.SetLocalENR(id.localNode.Record()) } + s.config.Logger.WithField("seq", id.localNode.Record().Seq()).Info("fork transition: re-published ENR fork fields") } } @@ -869,7 +954,11 @@ func (s *Service) connectELBootnodeENR(record *enr.Record) { // Filter by fork ID before adding if s.enrManager != nil { - if isEL, forkID := s.enrManager.FilterELNode(record); !isEL { + isEL, forkID := s.enrManager.FilterELNode(record) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, forkID) + } + if !isEL { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", v5.ID().Bytes()[:8]), "eth": forkID, @@ -881,8 +970,11 @@ func (s *Service) connectELBootnodeENR(record *enr.Record) { // Create generic node and add to table genericNode := nodes.NewFromV5(v5, s.elNodeDB) if s.elTable != nil { + if !s.elTable.Add(genericNode) { + s.config.Logger.Debug("ENR bootnode not admitted to table, not persisting") + return + } s.config.Logger.Info("added ENR bootnode to table") - s.elTable.Add(genericNode) // Persist to database if s.elNodeDB != nil { @@ -912,6 +1004,15 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { nodeID := v4Node.ID() + // Never dial ourselves: our own enode in the bootnode list would otherwise + // race our handshake challenges against our own identity. + for _, local := range s.localIDs() { + if local == nodeID { + s.config.Logger.WithField("enode", enodeURL).Debug("skipping bootnode: it is our own identity") + return + } + } + // Request ENR from the node s.config.Logger.WithField("enode", enodeURL).Debug("requesting ENR from enode bootnode") enrRecord, err := s.discv4Service.RequestENR(v4Node) @@ -925,7 +1026,11 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { // Filter by fork ID before adding if s.enrManager != nil { - if isEL, forkID := s.enrManager.FilterELNode(enrRecord); !isEL { + isEL, forkID := s.enrManager.FilterELNode(enrRecord) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, forkID) + } + if !isEL { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", nodeID[:8]), "eth": forkID, @@ -941,8 +1046,11 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { genericNode.IncrementSuccess() if s.elTable != nil { + if !s.elTable.Add(genericNode) { + s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Debug("enode bootnode not admitted to table, not persisting") + return + } s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Info("added enode bootnode to table") - s.elTable.Add(genericNode) // Persist to database if s.elNodeDB != nil { @@ -993,8 +1101,11 @@ func (s *Service) connectCLBootnodes() { // Create generic node and add to table genericNode := nodes.NewFromV5(v5, s.clNodeDB) if s.clTable != nil { + if !s.clTable.Add(genericNode) { + s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Debug("CL bootnode not admitted to table, not persisting") + continue + } s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Info("added CL ENR bootnode to table") - s.clTable.Add(genericNode) // Persist to database if s.clNodeDB != nil { @@ -1046,12 +1157,15 @@ func (s *Service) onNodeSeen(n *v5node.Node, timestamp time.Time) { // Look up the generic node from the table if genericNode := s.elTable.Get(nodeID); genericNode != nil { genericNode.SetLastSeen(timestamp) // This marks it dirty + // Get falls back to the DB, so Add re-admits demoted nodes + s.elTable.Add(genericNode) s.elNodeDB.QueueUpdate(genericNode) } } else if s.enrManager.FilterCLNode(n.Record()) && s.clTable != nil && s.clNodeDB != nil { // Look up the generic node from the table if genericNode := s.clTable.Get(nodeID); genericNode != nil { genericNode.SetLastSeen(timestamp) // This marks it dirty + s.clTable.Add(genericNode) s.clNodeDB.QueueUpdate(genericNode) } } @@ -1104,9 +1218,16 @@ func (s *Service) onNodeSeenV4(n *v4node.Node, timestamp time.Time) { if s.elTable != nil && s.elNodeDB != nil { // Look up the generic node from the table if genericNode := s.elTable.Get(n.ID()); genericNode != nil { - // Node exists, just update last seen genericNode.SetLastSeen(timestamp) // This marks it dirty s.elNodeDB.QueueUpdate(genericNode) + + // A known node still needs its record re-checked. PONG triggers an + // ENR refresh on the handler's node, but nothing propagates that to + // the table, so without this the table serves the peer's pre-fork + // record for the rest of its lifetime. + if rec := n.ENR(); rec != nil && rec.Seq() > genericNode.Record().Seq() { + s.checkAndAddNodeV4(n) + } return } @@ -1222,17 +1343,20 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { return false } - // Check if node already exists in table - if existingNode := s.elTable.Get(n.ID()); existingNode != nil { - s.config.Logger.WithFields(logrus.Fields{ - "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), - }).Debug("Discv4 node already in EL table, skipping add") - return false - } + // A node already in the table still needs its record re-checked: Add() + // installs a newer ENR, which is how a peer's post-fork eth entry reaches + // the table. Returning early here left the pre-fork record in place and + // served it to every FINDNODE querier. + alreadyKnown := s.elTable.Get(n.ID()) != nil // Filter the node using ENR manager (EL-only for discv4) if s.enrManager != nil { filter, forkID := s.enrManager.FilterELNode(n.ENR()) + if n.ENR().Has("eth") { + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(filter, forkID) + } + } if !filter { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), @@ -1248,10 +1372,12 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { // Try to add to table if s.elTable.Add(genericNode) { - s.config.Logger.WithFields(logrus.Fields{ - "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), - "addr": n.Addr().String(), - }).Info("Added discv4 node to EL table") + if !alreadyKnown { + s.config.Logger.WithFields(logrus.Fields{ + "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), + "addr": n.Addr().String(), + }).Info("Added discv4 node to EL table") + } return true } @@ -1265,8 +1391,16 @@ func (s *Service) checkAndAddNode(n *v5node.Node) bool { } // Determine layer - isEL, _ := s.enrManager.FilterELNode(n.Record()) + isEL, elForkID := s.enrManager.FilterELNode(n.Record()) isCL := s.enrManager.FilterCLNode(n.Record()) + // Only an execution record is an execution admission decision; counting + // consensus nodes here made the rejection counter track cross-layer + // traffic, which on a dual-layer network is most of what arrives. + if n.Record().Has("eth") { + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, elForkID) + } + } // Add to appropriate table(s) added := false diff --git a/bootnode/service_test.go b/bootnode/service_test.go index 6d80216..dd4d267 100644 --- a/bootnode/service_test.go +++ b/bootnode/service_test.go @@ -2,6 +2,7 @@ package bootnode import ( "crypto/ecdsa" + "fmt" "net" "testing" "time" @@ -528,3 +529,81 @@ func TestLayerENR_StripsForeignForkField(t *testing.T) { t.Error("CL ENR should not carry eth") } } + +// The published eth entry must describe the current fork era, not the genesis +// era with an already-passed Next (which geth-family peers treat as stale). +func TestUpdateENR_PublishesCurrentEraForkID(t *testing.T) { + const genesisTime = 1000 + passed := uint64(time.Now().Unix()) - 3600 + future := uint64(time.Now().Unix()) + 86400 + cfg := &Config{ + Logger: quietLogger(), + ELConfig: mustChainConfig(t, fmt.Sprintf(`{"chainId":1,"shanghaiTime":%d,"cancunTime":%d}`, passed, future)), + ELGenesisHash: [32]byte{1, 2, 3}, + ELGenesisTime: genesisTime, + } + key := mustKey(t) + ln, err := createLocalNode(cfg, key, net.ParseIP("1.2.3.4"), nil, 9000, nil) + if err != nil { + t.Fatalf("createLocalNode: %v", err) + } + + mgr := NewENRManager(cfg, key, ln, true, false) + headBlock, headTime := StaticHead() + if err := mgr.UpdateENR(headBlock, headTime); err != nil { + t.Fatalf("UpdateENR: %v", err) + } + + eth, ok := ln.Record().Eth() + if !ok || len(eth) == 0 { + t.Fatal("EL identity did not advertise eth") + } + want := mgr.GetELFilter().GetCurrentForkID(headBlock, headTime) + if eth[0].ForkID != want.Hash || eth[0].NextForkEpoch != want.Next { + t.Fatalf("published eth = {%#x %d}, want current era {%#x %d}", eth[0].ForkID, eth[0].NextForkEpoch, want.Hash, want.Next) + } + if eth[0].NextForkEpoch != future { + t.Fatalf("published Next = %d, want the upcoming fork %d", eth[0].NextForkEpoch, future) + } +} + +// The refresh tick calls UpdateENR every minute; an unchanged record must not +// bump the sequence number, because peers re-fetch on every bump. +func TestUpdateENR_NoSeqBumpWhenUnchanged(t *testing.T) { + cfg := &Config{ + Logger: quietLogger(), + ELConfig: mustChainConfig(t, `{"chainId":1,"shanghaiTime":1500}`), + ELGenesisHash: [32]byte{4, 5, 6}, + ELGenesisTime: 1000, + } + key := mustKey(t) + ln, err := createLocalNode(cfg, key, net.ParseIP("1.2.3.4"), nil, 9000, nil) + if err != nil { + t.Fatalf("createLocalNode: %v", err) + } + + mgr := NewENRManager(cfg, key, ln, true, false) + headBlock, headTime := StaticHead() + if err := mgr.UpdateENR(headBlock, headTime); err != nil { + t.Fatalf("first UpdateENR: %v", err) + } + seq := ln.Record().Seq() + + for i := 0; i < 3; i++ { + if err := mgr.UpdateENR(StaticHead()); err != nil { + t.Fatalf("repeat UpdateENR: %v", err) + } + } + if got := ln.Record().Seq(); got != seq { + t.Fatalf("sequence advanced from %d to %d without a field change", seq, got) + } +} + +func mustChainConfig(t *testing.T, jsonCfg string) *elconfig.ChainConfig { + t.Helper() + cfg, err := elconfig.ParseChainConfig([]byte(jsonCfg)) + if err != nil { + t.Fatalf("ParseChainConfig: %v", err) + } + return cfg +} diff --git a/bootnode/stats.go b/bootnode/stats.go new file mode 100644 index 0000000..2478a40 --- /dev/null +++ b/bootnode/stats.go @@ -0,0 +1,128 @@ +package bootnode + +import ( + "time" + + v4protocol "github.com/ethpandaops/bootnodoor/discv4/protocol" + "github.com/ethpandaops/bootnodoor/services" + "github.com/ethpandaops/bootnodoor/transport" +) + +// Stats aggregates the live counters the web UI renders. Everything here is +// summed across both layers (EL and CL lookup services) and across all +// discovery identities, of which there are two when separate EL and CL keys +// are configured. +type Stats struct { + Lookups services.LookupStats + Ping services.PingStats + Discv5 Discv5Stats + Discv4 v4protocol.HandlerStats + HasV4 bool + Sessions SessionStats + Packets transport.MetricsSnapshot +} + +// Discv5Stats is the per-identity discv5 handler counters, summed. +type Discv5Stats struct { + PacketsReceived int + PacketsSent int + InvalidPackets int + FilteredResponses int + FindNodeReceived int + PendingHandshakes int + PendingChallenges int +} + +// SessionStats is the discv5 session cache totals, summed per identity. +type SessionStats struct { + Total int + Active int + Expired int +} + +// GetStats returns a snapshot of the service's discovery counters. +func (s *Service) GetStats() Stats { + var out Stats + + for _, ls := range []*services.LookupService{s.elLookupService, s.clLookupService} { + if ls == nil { + continue + } + l := ls.GetStats() + out.Lookups.LookupsStarted += l.LookupsStarted + out.Lookups.LookupsCompleted += l.LookupsCompleted + out.Lookups.LookupsFailed += l.LookupsFailed + out.Lookups.NodesDiscovered += l.NodesDiscovered + out.Lookups.LookupsV5 += l.LookupsV5 + out.Lookups.LookupsV4 += l.LookupsV4 + } + + var totalRTT time.Duration + rttSamples := 0 + seenTransports := make(map[*transport.UDPTransport]bool) + + for _, id := range s.identities { + if id.pingService != nil { + p := id.pingService.GetStats() + out.Ping.PingsSent += p.PingsSent + out.Ping.PongsReceived += p.PongsReceived + out.Ping.PingTimeouts += p.PingTimeouts + out.Ping.PingsV5 += p.PingsV5 + out.Ping.PingsV4 += p.PingsV4 + if p.AverageRTT > 0 { + totalRTT += p.AverageRTT + rttSamples++ + } + } + + if id.discv5Service != nil { + if h := id.discv5Service.Handler(); h != nil { + d := h.GetStats() + out.Discv5.PacketsReceived += d.PacketsReceived + out.Discv5.PacketsSent += d.PacketsSent + out.Discv5.InvalidPackets += d.InvalidPackets + out.Discv5.FilteredResponses += d.FilteredResponses + out.Discv5.FindNodeReceived += d.FindNodeReceived + out.Discv5.PendingHandshakes += d.PendingHandshakes + out.Discv5.PendingChallenges += d.PendingChallenges + } + if c := id.discv5Service.Sessions(); c != nil { + sess := c.GetStats() + out.Sessions.Total += sess.Total + out.Sessions.Active += sess.Active + out.Sessions.Expired += sess.Expired + } + } + + // Identities sharing a bind port share one socket, so its packet + // counters must only be added once. + if id.transport != nil && !seenTransports[id.transport] { + seenTransports[id.transport] = true + m := id.transport.Metrics().Snapshot() + out.Packets.PacketsSent += m.PacketsSent + out.Packets.PacketsReceived += m.PacketsReceived + out.Packets.PacketsDropped += m.PacketsDropped + out.Packets.BytesSent += m.BytesSent + out.Packets.BytesReceived += m.BytesReceived + out.Packets.SendErrors += m.SendErrors + out.Packets.ReceiveErrors += m.ReceiveErrors + out.Packets.RateLimited += m.RateLimited + } + } + + if rttSamples > 0 { + out.Ping.AverageRTT = totalRTT / time.Duration(rttSamples) + } + if out.Ping.PingsSent > 0 { + out.Ping.SuccessRate = float64(out.Ping.PongsReceived) / float64(out.Ping.PingsSent) * 100 + } + + if v4 := s.getV4Service(); v4 != nil { + if h := v4.Handler(); h != nil { + out.Discv4 = h.GetStats() + out.HasV4 = true + } + } + + return out +} diff --git a/cmd/bootnodoor/main.go b/cmd/bootnodoor/main.go index 0c8042f..ac4e822 100644 --- a/cmd/bootnodoor/main.go +++ b/cmd/bootnodoor/main.go @@ -350,6 +350,12 @@ func runBootnode(cmd *cobra.Command, args []string) error { } logger.WithField("genesisTime", clGenesisTime).Info("calculated CL genesis time from config") } else { + // The override has to reach the config, or every epoch-derived value + // (current digest, next-fork info, the published eth2 entry) keeps + // using the YAML time and can land on the wrong side of a fork. + if err := clConfig.SetGenesisTime(clGenesisTime); err != nil { + return fmt.Errorf("failed to set CL genesis time: %w", err) + } logger.WithField("genesisTime", clGenesisTime).Info("using provided CL genesis time") } diff --git a/discv4/node/node.go b/discv4/node/node.go index 42d5e36..c85fa6b 100644 --- a/discv4/node/node.go +++ b/discv4/node/node.go @@ -31,6 +31,11 @@ type Node struct { // pubKey is the node's secp256k1 public key pubKey *ecdsa.PublicKey + // mu guards addr, enr and the stats pointer: SetAddr/SetENR/SetStats + // replace them from other goroutines while packet handling reads them. + // The pointed-to values synchronize themselves or are replaced whole. + mu sync.RWMutex + // addr is the node's UDP address addr *net.UDPAddr @@ -159,6 +164,8 @@ func (n *Node) PublicKey() *ecdsa.PublicKey { // Addr returns the node's UDP address. func (n *Node) Addr() *net.UDPAddr { + n.mu.RLock() + defer n.mu.RUnlock() return n.addr } @@ -166,17 +173,30 @@ func (n *Node) Addr() *net.UDPAddr { // // This is used when we receive packets from a different address than expected. func (n *Node) SetAddr(addr *net.UDPAddr) { + n.mu.Lock() n.addr = addr + n.mu.Unlock() } // ENR returns the node's ENR record, if available. func (n *Node) ENR() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } // SetENR updates the node's ENR record. func (n *Node) SetENR(record *enr.Record) { + n.mu.Lock() n.enr = record + n.mu.Unlock() +} + +// statsRef returns the current shared stats pointer for use outside the lock. +func (n *Node) statsRef() *stats.SharedStats { + n.mu.RLock() + defer n.mu.RUnlock() + return n.stats } // Enode returns the node's enode:// representation. @@ -185,12 +205,14 @@ func (n *Node) Enode() *enode.Enode { return n.enode } + addr := n.Addr() + // Build enode from node info return &enode.Enode{ PublicKey: n.pubKey, - IP: n.addr.IP, - UDP: uint16(n.addr.Port), - TCP: uint16(n.addr.Port), // Assume same port for TCP + IP: addr.IP, + UDP: uint16(addr.Port), + TCP: uint16(addr.Port), // Assume same port for TCP } } @@ -201,7 +223,7 @@ func (n *Node) String() string { n.bondMu.RUnlock() return fmt.Sprintf("Node{id=%x, addr=%s, bond=%s}", - n.id[:8], n.addr.String(), bondStatus) + n.id[:8], n.Addr().String(), bondStatus) } // Bond Status Methods @@ -241,7 +263,7 @@ func (n *Node) MarkPingSent() { } n.bondMu.Unlock() - n.stats.SetLastPing(now) + n.statsRef().SetLastPing(now) } // MarkPingReceived records that we received a PING from this node. @@ -276,7 +298,7 @@ func (n *Node) MarkPongReceived(bondDuration time.Duration) { n.consecutiveTimeout = 0 n.bondMu.Unlock() - n.stats.ResetFailureCount() + n.statsRef().ResetFailureCount() n.UpdateLastSeen() } @@ -286,7 +308,7 @@ func (n *Node) MarkTimeout() { n.consecutiveTimeout++ n.bondMu.Unlock() - n.stats.IncrementFailureCount() + n.statsRef().IncrementFailureCount() } // LastPingSent returns when we last sent a PING. @@ -314,17 +336,17 @@ func (n *Node) BondExpiration() time.Time { // UpdateLastSeen updates the last seen timestamp. func (n *Node) UpdateLastSeen() { - n.stats.SetLastSeen(time.Now()) + n.statsRef().SetLastSeen(time.Now()) } // LastSeen returns when we last saw a packet from this node. func (n *Node) LastSeen() time.Time { - return n.stats.LastSeen() + return n.statsRef().LastSeen() } // FailedPings returns the number of failed ping attempts. func (n *Node) FailedPings() uint32 { - return uint32(n.stats.FailureCount()) + return uint32(n.statsRef().FailureCount()) } // IncrementPacketsReceived increments the received packet counter. @@ -366,7 +388,9 @@ func (n *Node) ConsecutiveTimeouts() uint32 { // This allows the node to update stats owned by a parent node. func (n *Node) SetStats(sharedStats *stats.SharedStats) { if sharedStats != nil { + n.mu.Lock() n.stats = sharedStats + n.mu.Unlock() } } diff --git a/discv4/node/node_test.go b/discv4/node/node_test.go new file mode 100644 index 0000000..08f1cc6 --- /dev/null +++ b/discv4/node/node_test.go @@ -0,0 +1,86 @@ +package node + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/ethpandaops/bootnodoor/stats" +) + +// TestNodeConcurrentFieldAccess exercises SetAddr/SetENR/SetStats against every +// reader of the guarded fields, including Enode and String which read addr +// directly. Under the race detector it fails if any access is unsynchronized. +func TestNodeConcurrentFieldAccess(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + n := New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}) + + rec := enr.New() + if err := rec.Set("ip", net.IPv4(5, 6, 7, 8)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + shared := stats.NewSharedStats(time.Now()) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + writer := func(mutate func(i int)) { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + mutate(i) + } + } + } + + wg.Add(3) + go writer(func(i int) { + n.SetAddr(&net.UDPAddr{IP: net.IPv4(9, 9, 9, byte(i%256)), Port: 30303}) + }) + go writer(func(i int) { + if i%2 == 0 { + n.SetENR(rec) + } else { + n.SetENR(nil) + } + }) + go writer(func(int) { n.SetStats(shared) }) + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.Addr() + _ = n.ENR() + _ = n.Enode() + _ = n.String() + n.UpdateLastSeen() + _ = n.LastSeen() + n.MarkPingSent() + _ = n.IsBonded() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index 9a6b91c..fc1c72b 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -69,6 +69,10 @@ type Handler struct { pendingNeighborsMu sync.RWMutex pendingNeighbors map[string]*PendingNeighborsResponse + // Local ENR, replaceable while running (fork transitions, IP discovery) + localENRMu sync.RWMutex + localENR *enr.Record + // Statistics statsMu sync.RWMutex packetsReceived uint64 @@ -85,7 +89,9 @@ type HandlerConfig struct { // PrivateKey is our node's private key PrivateKey *ecdsa.PrivateKey - // LocalENR is our node's ENR record (optional) + // LocalENR is our node's ENR record (optional). It seeds the handler's + // record; SetLocalENR replaces it while running, so handler code must read + // LocalRecord() rather than this field. LocalENR *enr.Record // LocalAddr is our listening address @@ -100,6 +106,16 @@ type HandlerConfig struct { // ExpirationWindow is the acceptable time range for packet expiration (default 20s) ExpirationWindow time.Duration + // MaxNodes is the maximum number of nodes to track (default 50000). + // Once reached, new nodes are handled but not retained until a slot frees up, + // keeping memory bounded under floods of distinct node IDs. + MaxNodes int + + // NodeTTL is how long an unbonded node is retained since it was last seen + // before it becomes eligible for eviction (default 5 minutes). Bonded nodes + // are kept until their bond expires. + NodeTTL time.Duration + // Callbacks (all optional) OnPing OnPingCallback OnPongReceived OnPongReceivedCallback @@ -134,11 +150,17 @@ type PendingNeighborsResponse struct { // Nodes accumulated so far Nodes []*node.Node + // Reserved counts nodes a handler has claimed room for but may not have + // appended yet. Reserving before decoding makes the persistence cap exact + // even when packets are dispatched concurrently. + Reserved int + + // Closed marks a delivered entry. Packets processed after delivery must + // not reserve against it; cleanup evicts the tombstone by CreatedAt. + Closed bool + // CreatedAt is when we received the first packet CreatedAt time.Time - - // LastRecv is when we received the last packet - LastRecv time.Time } const ( @@ -154,8 +176,27 @@ const ( // cleanupInterval is how often we run cleanup cleanupInterval = 5 * time.Second - // neighborsTimeout is how long to wait for additional NEIGHBORS packets + // neighborsTimeout is how long a pending NEIGHBORS entry may live before + // cleanup evicts it as a backstop. neighborsTimeout = 2 * time.Second + + // defaultMaxNodes is the default cap on tracked nodes. It bounds memory + // against floods of distinct node IDs (for example fabricated NEIGHBORS + // records) that would otherwise grow the map without limit. + defaultMaxNodes = 50000 + + // defaultNodeTTL is how long an unbonded node is retained since it was last + // seen before it becomes eligible for eviction. + defaultNodeTTL = 5 * time.Minute + + // neighborsCollectWindow is how long we accumulate multi-packet NEIGHBORS + // before delivering the collected nodes to the waiting FINDNODE. + neighborsCollectWindow = 100 * time.Millisecond + + // maxNeighborsPerResponse caps the nodes accumulated for one FINDNODE. A + // discv4 FINDNODE returns at most one k-bucket, so anything beyond this is a + // flood and is dropped. + maxNeighborsPerResponse = 16 ) // NewHandler creates a new protocol handler. @@ -170,6 +211,12 @@ func NewHandler(ctx context.Context, config HandlerConfig, transport Transport) if config.ExpirationWindow == 0 { config.ExpirationWindow = defaultExpirationWindow } + if config.MaxNodes == 0 { + config.MaxNodes = defaultMaxNodes + } + if config.NodeTTL == 0 { + config.NodeTTL = defaultNodeTTL + } h := &Handler{ config: config, @@ -178,6 +225,7 @@ func NewHandler(ctx context.Context, config HandlerConfig, transport Transport) nodes: make(map[node.ID]*node.Node), requests: make(map[string]*PendingRequest), pendingNeighbors: make(map[string]*PendingNeighborsResponse), + localENR: config.LocalENR, } // Start cleanup goroutine @@ -337,7 +385,7 @@ func (h *Handler) handlePong(fromNode *node.Node, from *net.UDPAddr, pong *Pong) // Match to pending request req := h.getPendingRequest(string(pong.ReplyTok)) if req != nil { - req.ResponseChan <- pong + h.deliverResponse(req, pong) } // Check if remote node has newer ENR @@ -402,9 +450,41 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb h.incrementFindnodeResponsesRecv() - // Convert nodes - nodes := make([]*node.Node, 0, len(neighbors.Nodes)) + // Only accept NEIGHBORS in response to a FINDNODE we actually sent to this + // node. Dropping unsolicited NEIGHBORS prevents a peer we never queried from + // making us accumulate node records without bound. + matchedReq := h.findPendingFindnode(fromNode.ID()) + if matchedReq == nil { + return nil + } + + // Accumulate the response, keyed by the matched request's hash so each + // FINDNODE gets exactly one entry and a fresh request never collides with + // a delivered one. Room is reserved before decoding, so records past the + // cap are never persisted in the global node map, even when packets are + // dispatched concurrently. + key := string(matchedReq.RequestHash) + + h.pendingNeighborsMu.Lock() + pending := h.pendingNeighbors[key] + if pending != nil && pending.Closed { + h.pendingNeighborsMu.Unlock() + return nil + } + firstPacket := pending == nil + if firstPacket { + pending = &PendingNeighborsResponse{CreatedAt: time.Now()} + h.pendingNeighbors[key] = pending + } + take := min(len(neighbors.Nodes), maxNeighborsPerResponse-pending.Reserved) + pending.Reserved += take + h.pendingNeighborsMu.Unlock() + + nodes := make([]*node.Node, 0, take) for _, n := range neighbors.Nodes { + if len(nodes) >= take { + break + } pubkey, err := DecodePubkey(crypto.S256(), n.ID) if err != nil { logrus.WithError(err).Debug("Invalid node public key in NEIGHBORS") @@ -417,53 +497,33 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb } nodeID := node.PubkeyToID(pubkey) - discoveredNode := h.getOrCreateNode(nodeID, pubkey, addr) - nodes = append(nodes, discoveredNode) + nodes = append(nodes, h.getOrCreateNode(nodeID, pubkey, addr)) } - // Try to match to pending request - // We use the sender's node ID as the key for pending FINDNODE requests - key := string(fromNode.IDBytes()) - h.pendingNeighborsMu.Lock() - pending := h.pendingNeighbors[key] - if pending == nil { - pending = &PendingNeighborsResponse{ - Nodes: nodes, - CreatedAt: time.Now(), - LastRecv: time.Now(), - } - h.pendingNeighbors[key] = pending - } else { - pending.Nodes = append(pending.Nodes, nodes...) - pending.LastRecv = time.Now() + if p := h.pendingNeighbors[key]; p != nil && !p.Closed { + p.Nodes = append(p.Nodes, nodes...) } h.pendingNeighborsMu.Unlock() - // Check if we have a pending request waiting for this - h.requestsMu.RLock() - var matchedReq *PendingRequest - for _, req := range h.requests { - if req.PacketType == FindnodePacket && req.ToNode.ID() == fromNode.ID() { - matchedReq = req - break - } - } - h.requestsMu.RUnlock() - - if matchedReq != nil { - // Deliver accumulated nodes after a short delay - // (in case more NEIGHBORS packets arrive) + // Deliver once, after a short window that lets multi-packet responses + // arrive. Only the first packet schedules delivery, so a flood cannot spawn + // a goroutine per packet. + if firstPacket { go func() { - time.Sleep(100 * time.Millisecond) + time.Sleep(neighborsCollectWindow) h.pendingNeighborsMu.Lock() finalPending := h.pendingNeighbors[key] - delete(h.pendingNeighbors, key) + var collected []*node.Node + if finalPending != nil { + finalPending.Closed = true + collected = finalPending.Nodes + } h.pendingNeighborsMu.Unlock() if finalPending != nil { - matchedReq.ResponseChan <- finalPending.Nodes + h.deliverResponse(matchedReq, collected) } }() } @@ -522,7 +582,7 @@ func (h *Handler) handleENRResponse(fromNode *node.Node, from *net.UDPAddr, resp // Match to pending request req := h.getPendingRequest(string(resp.ReplyTok)) if req != nil { - req.ResponseChan <- resp.Record + h.deliverResponse(req, resp.Record) } return nil @@ -543,8 +603,8 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { } // Add ENR sequence if we have an ENR - if h.config.LocalENR != nil { - ping.ENRSeq = h.config.LocalENR.Seq() + if rec := h.LocalRecord(); rec != nil { + ping.ENRSeq = rec.Seq() } // Encode packet @@ -553,12 +613,12 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request; removal is deferred so every exit path clears it. req := h.addPendingRequest(hash, n, PingPacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -579,11 +639,9 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout): - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -613,12 +671,14 @@ func (h *Handler) Findnode(n *node.Node, target []byte) ([]*node.Node, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request. Removal is deferred so every exit path clears + // it: a completed request left in the map keeps matching later NEIGHBORS + // from that node and reopens collection windows until cleanup runs. req := h.addPendingRequest(hash, n, FindnodePacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -633,11 +693,9 @@ func (h *Handler) Findnode(n *node.Node, target []byte) ([]*node.Node, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout * 3): // Longer timeout for multi-packet responses - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -666,12 +724,12 @@ func (h *Handler) RequestENR(n *node.Node) (*enr.Record, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request; removal is deferred so every exit path clears it. pendingReq := h.addPendingRequest(hash, n, ENRRequestPacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -686,11 +744,9 @@ func (h *Handler) RequestENR(n *node.Node) (*enr.Record, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout): - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -704,8 +760,8 @@ func (h *Handler) sendPong(to *node.Node, addr *net.UDPAddr, localAddr *net.UDPA } // Add ENR sequence if we have an ENR - if h.config.LocalENR != nil { - pong.ENRSeq = h.config.LocalENR.Seq() + if rec := h.LocalRecord(); rec != nil { + pong.ENRSeq = rec.Seq() } packet, _, err := Encode(h.config.PrivateKey, pong) @@ -768,13 +824,14 @@ func (h *Handler) sendNeighbors(to *node.Node, addr *net.UDPAddr, localAddr *net // sendENRResponse sends an ENRRESPONSE. func (h *Handler) sendENRResponse(to *node.Node, addr *net.UDPAddr, localAddr *net.UDPAddr, replyTok []byte) error { - if h.config.LocalENR == nil { + rec := h.LocalRecord() + if rec == nil { return fmt.Errorf("no local ENR configured") } resp := &ENRResponse{ ReplyTok: replyTok, - Record: h.config.LocalENR, + Record: rec, } packet, _, err := Encode(h.config.PrivateKey, resp) @@ -808,8 +865,20 @@ func (h *Handler) getOrCreateNode(id node.ID, pubkey *ecdsa.PublicKey, addr *net return n } - // Create new node + // Create new node. Stamp last-seen with the insertion time: a node learned + // from a NEIGHBORS record has never sent us a packet, and a zero timestamp + // would make cleanup evict it on its next run regardless of NodeTTL. n = node.New(pubkey, addr) + n.UpdateLastSeen() + + // Bound the map so an unauthenticated flood of distinct node IDs (for + // example fabricated NEIGHBORS records) cannot grow it without limit. Stale + // unbonded entries are reclaimed by cleanup; until a slot frees up we still + // return the node so the packet is handled, but we do not retain it. + if len(h.nodes) >= h.config.MaxNodes { + return n + } + h.nodes[id] = n return n } @@ -860,6 +929,19 @@ func (h *Handler) getPendingRequest(hash string) *PendingRequest { return h.requests[hash] } +// findPendingFindnode returns a pending FINDNODE request awaiting a response +// from the given node, or nil if none exists. +func (h *Handler) findPendingFindnode(id node.ID) *PendingRequest { + h.requestsMu.RLock() + defer h.requestsMu.RUnlock() + for _, req := range h.requests { + if req.PacketType == FindnodePacket && req.ToNode != nil && req.ToNode.ID() == id { + return req + } + } + return nil +} + // removePendingRequest removes a pending request. func (h *Handler) removePendingRequest(hash string) { h.requestsMu.Lock() @@ -867,6 +949,20 @@ func (h *Handler) removePendingRequest(hash string) { h.requestsMu.Unlock() } +// deliverResponse hands a response to a waiting request without blocking. +// +// ResponseChan is buffered (size 1) and read at most once by the waiter. A +// duplicate, replayed or late response therefore finds the buffer full or the +// waiter already gone. Sending directly would park the packet-dispatch +// goroutine forever, so an unauthenticated peer could leak goroutines by +// replaying responses. The non-blocking send drops the extra response instead. +func (h *Handler) deliverResponse(req *PendingRequest, resp interface{}) { + select { + case req.ResponseChan <- resp: + default: + } +} + // Cleanup // cleanupLoop periodically cleans up expired requests and neighbors. @@ -900,11 +996,22 @@ func (h *Handler) cleanup() { // Clean up old pending neighbors h.pendingNeighborsMu.Lock() for key, pending := range h.pendingNeighbors { - if now.Sub(pending.LastRecv) > neighborsTimeout { + if now.Sub(pending.CreatedAt) > neighborsTimeout { delete(h.pendingNeighbors, key) } } h.pendingNeighborsMu.Unlock() + + // Evict stale, unbonded nodes so the map stays bounded. Bonded nodes are + // kept until their bond expires, after which IsBonded reports false and they + // become eligible here. + h.nodesMu.Lock() + for id, n := range h.nodes { + if !n.IsBonded() && now.Sub(n.LastSeen()) > h.config.NodeTTL { + delete(h.nodes, id) + } + } + h.nodesMu.Unlock() } // Statistics @@ -951,21 +1058,79 @@ func (h *Handler) incrementFindnodeResponsesRecv() { h.statsMu.Unlock() } -// Stats returns current statistics. -func (h *Handler) Stats() map[string]interface{} { +// HandlerStats is a snapshot of the handler's counters. +type HandlerStats struct { + PacketsReceived uint64 + PacketsSent uint64 + InvalidPackets uint64 + ExpiredPackets uint64 + UnbondedFindnode uint64 + FindnodeRequestsRecv uint64 + FindnodeResponsesRecv uint64 + KnownNodes int + PendingRequests int + PendingNeighbors int +} + +// GetStats returns current statistics. +func (h *Handler) GetStats() HandlerStats { + h.nodesMu.RLock() + knownNodes := len(h.nodes) + h.nodesMu.RUnlock() + h.requestsMu.RLock() + pendingRequests := len(h.requests) + h.requestsMu.RUnlock() + h.pendingNeighborsMu.RLock() + pendingNeighbors := len(h.pendingNeighbors) + h.pendingNeighborsMu.RUnlock() + h.statsMu.RLock() defer h.statsMu.RUnlock() + return HandlerStats{ + PacketsReceived: h.packetsReceived, + PacketsSent: h.packetsSent, + InvalidPackets: h.invalidPackets, + ExpiredPackets: h.expiredPackets, + UnbondedFindnode: h.unbondedFindnode, + FindnodeRequestsRecv: h.findnodeRequestsRecv, + FindnodeResponsesRecv: h.findnodeResponsesRecv, + KnownNodes: knownNodes, + PendingRequests: pendingRequests, + PendingNeighbors: pendingNeighbors, + } +} + +// Stats returns current statistics as a map, for callers that render it +// generically. +func (h *Handler) Stats() map[string]interface{} { + s := h.GetStats() return map[string]interface{}{ - "packets_received": h.packetsReceived, - "packets_sent": h.packetsSent, - "invalid_packets": h.invalidPackets, - "expired_packets": h.expiredPackets, - "unbonded_findnode": h.unbondedFindnode, - "findnode_requests_recv": h.findnodeRequestsRecv, - "findnode_responses_recv": h.findnodeResponsesRecv, - "known_nodes": len(h.nodes), - "pending_requests": len(h.requests), - "pending_neighbors": len(h.pendingNeighbors), + "packets_received": s.PacketsReceived, + "packets_sent": s.PacketsSent, + "invalid_packets": s.InvalidPackets, + "expired_packets": s.ExpiredPackets, + "unbonded_findnode": s.UnbondedFindnode, + "findnode_requests_recv": s.FindnodeRequestsRecv, + "findnode_responses_recv": s.FindnodeResponsesRecv, + "known_nodes": s.KnownNodes, + "pending_requests": s.PendingRequests, + "pending_neighbors": s.PendingNeighbors, } } + +// LocalRecord returns the ENR the handler currently advertises. +func (h *Handler) LocalRecord() *enr.Record { + h.localENRMu.RLock() + defer h.localENRMu.RUnlock() + return h.localENR +} + +// SetLocalENR replaces the advertised ENR in place, so a fork transition or an +// IP-discovery update does not have to rebuild the handler and lose its bonds, +// known nodes, pending requests and stats. +func (h *Handler) SetLocalENR(record *enr.Record) { + h.localENRMu.Lock() + h.localENR = record + h.localENRMu.Unlock() +} diff --git a/discv4/protocol/handler_test.go b/discv4/protocol/handler_test.go new file mode 100644 index 0000000..d21528f --- /dev/null +++ b/discv4/protocol/handler_test.go @@ -0,0 +1,99 @@ +package protocol + +import ( + "context" + "crypto/ecdsa" + "net" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv4/node" +) + +func testAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303} +} + +// makeNodeID returns a fresh, valid secp256k1 public key and its node ID, +// standing in for a distinct peer (or a fabricated NEIGHBORS record). +func makeNodeID(t *testing.T) (*ecdsa.PublicKey, node.ID) { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + pub := &key.PublicKey + return pub, node.PubkeyToID(pub) +} + +// TestGetOrCreateNodeRespectsMaxNodes verifies the tracked-node map is hard +// bounded: a flood of distinct node IDs (the NM-012/NM-104 vector) cannot grow +// it past MaxNodes. +func TestGetOrCreateNodeRespectsMaxNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const maxNodes = 100 + h := NewHandler(ctx, HandlerConfig{MaxNodes: maxNodes, NodeTTL: time.Hour}, nil) + + for i := 0; i < maxNodes*5; i++ { + pub, id := makeNodeID(t) + h.getOrCreateNode(id, pub, testAddr()) + } + + if got := len(h.AllNodes()); got != maxNodes { + t.Fatalf("node map not bounded: got %d nodes, want %d", got, maxNodes) + } +} + +// TestCleanupEvictsStaleUnbondedNodes verifies cleanup reclaims unbonded nodes +// past their TTL while keeping bonded nodes. +func TestCleanupEvictsStaleUnbondedNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + h := NewHandler(ctx, HandlerConfig{MaxNodes: 1000, NodeTTL: 20 * time.Millisecond}, nil) + + pubStale, idStale := makeNodeID(t) + h.getOrCreateNode(idStale, pubStale, testAddr()) + + pubBonded, idBonded := makeNodeID(t) + bonded := h.getOrCreateNode(idBonded, pubBonded, testAddr()) + bonded.MarkPongReceived(time.Hour) // establish a live bond + + time.Sleep(40 * time.Millisecond) // age both past NodeTTL + + h.cleanup() + + if h.GetNode(idStale) != nil { + t.Error("stale unbonded node was not evicted") + } + if h.GetNode(idBonded) == nil { + t.Error("bonded node was wrongly evicted") + } +} + +// TestCleanupReclaimsFloodedNodes verifies that a burst of unbonded nodes (a +// NEIGHBORS-injection flood) is fully reclaimed once it ages out. +func TestCleanupReclaimsFloodedNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + h := NewHandler(ctx, HandlerConfig{MaxNodes: 10000, NodeTTL: 10 * time.Millisecond}, nil) + + for i := 0; i < 500; i++ { + pub, id := makeNodeID(t) + h.getOrCreateNode(id, pub, testAddr()) + } + if got := len(h.AllNodes()); got != 500 { + t.Fatalf("setup: expected 500 tracked nodes, got %d", got) + } + + time.Sleep(20 * time.Millisecond) + h.cleanup() + + if got := len(h.AllNodes()); got != 0 { + t.Fatalf("flooded nodes not reclaimed: %d still tracked", got) + } +} diff --git a/discv4/protocol/pending_neighbors_test.go b/discv4/protocol/pending_neighbors_test.go new file mode 100644 index 0000000..b811080 --- /dev/null +++ b/discv4/protocol/pending_neighbors_test.go @@ -0,0 +1,319 @@ +package protocol + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv4/node" +) + +func newNeighborsHandler(t *testing.T) (*Handler, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + return NewHandler(ctx, HandlerConfig{}, nil), cancel +} + +func makeDiscv4Node(t *testing.T) *node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}) +} + +func makeNeighbors(t *testing.T, count int) *Neighbors { + t.Helper() + recs := make([]NodeRecord, count) + for i := range recs { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + recs[i] = NodeRecord{ + IP: net.IPv4(9, 9, 9, byte(i%256)), + UDP: 30303, + TCP: 30303, + ID: EncodePubkey(&key.PublicKey), + } + } + return &Neighbors{Nodes: recs, Expiration: uint64(time.Now().Add(time.Minute).Unix())} +} + +// TestUnsolicitedNeighborsDropped verifies that NEIGHBORS from a node we never +// sent a FINDNODE to create no pending accumulation. +func TestUnsolicitedNeighborsDropped(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 10)); err != nil { + t.Fatalf("handleNeighbors: %v", err) + } + + h.pendingNeighborsMu.RLock() + n := len(h.pendingNeighbors) + h.pendingNeighborsMu.RUnlock() + if n != 0 { + t.Fatalf("unsolicited NEIGHBORS created %d pending entries, want 0", n) + } +} + +// TestNeighborsAccumulationCapped verifies the accumulated nodes for a single +// FINDNODE never exceed the cap, even across multiple oversized packets. +func TestNeighborsAccumulationCapped(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + // Pre-build packets so no slow key generation happens between the calls and + // the read (the delivery goroutine deletes the entry after the window). + pkt1 := makeNeighbors(t, maxNeighborsPerResponse+20) + pkt2 := makeNeighbors(t, 20) + + if err := h.handleNeighbors(from, from.Addr(), pkt1); err != nil { + t.Fatal(err) + } + if err := h.handleNeighbors(from, from.Addr(), pkt2); err != nil { + t.Fatal(err) + } + + h.pendingNeighborsMu.RLock() + pending := h.pendingNeighbors["req"] + h.pendingNeighborsMu.RUnlock() + if pending == nil { + t.Fatal("expected a pending entry for the matched FINDNODE") + } + if len(pending.Nodes) != maxNeighborsPerResponse { + t.Fatalf("accumulated %d nodes, want cap %d", len(pending.Nodes), maxNeighborsPerResponse) + } +} + +// TestNeighborsDeliveredToWaiter verifies the happy path still delivers the +// collected nodes to the waiting FINDNODE. +func TestNeighborsDeliveredToWaiter(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + req := h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 5)); err != nil { + t.Fatal(err) + } + + select { + case resp := <-req.ResponseChan: + nodes, ok := resp.([]*node.Node) + if !ok || len(nodes) != 5 { + t.Fatalf("unexpected delivery: %T len=%d", resp, len(nodes)) + } + case <-time.After(2 * time.Second): + t.Fatal("collected nodes were not delivered to the waiter") + } +} + +// TestCleanupEvictsStalePendingNeighbors verifies cleanup evicts entries by +// creation time, so a stream of packets can no longer keep an entry alive. +func TestCleanupEvictsStalePendingNeighbors(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + h.pendingNeighborsMu.Lock() + h.pendingNeighbors["stale"] = &PendingNeighborsResponse{CreatedAt: time.Now().Add(-neighborsTimeout - time.Second)} + h.pendingNeighbors["fresh"] = &PendingNeighborsResponse{CreatedAt: time.Now()} + h.pendingNeighborsMu.Unlock() + + h.cleanup() + + h.pendingNeighborsMu.RLock() + _, staleExists := h.pendingNeighbors["stale"] + _, freshExists := h.pendingNeighbors["fresh"] + h.pendingNeighborsMu.RUnlock() + if staleExists { + t.Error("stale pending entry was not evicted") + } + if !freshExists { + t.Error("fresh pending entry was wrongly evicted") + } +} + +// TestNeighborsCapAppliesBeforeNodePersistence verifies records past the +// per-response cap are not persisted in the global node map either, so a +// queried peer cannot grow memory by flooding unique records. +func TestNeighborsCapAppliesBeforeNodePersistence(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, maxNeighborsPerResponse+20)); err != nil { + t.Fatal(err) + } + + h.nodesMu.RLock() + n := len(h.nodes) + h.nodesMu.RUnlock() + if n != maxNeighborsPerResponse { + t.Fatalf("node map persisted %d records, want at most the cap %d", n, maxNeighborsPerResponse) + } +} + +// TestFreshNodeSurvivesCleanup verifies a newly created node is not evicted by +// the next cleanup run before its NodeTTL: creation stamps last-seen, so a +// node learned from a NEIGHBORS record does not carry a zero timestamp. +func TestFreshNodeSurvivesCleanup(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + id := node.PubkeyToID(&key.PublicKey) + h.getOrCreateNode(id, &key.PublicKey, &net.UDPAddr{IP: net.IPv4(9, 9, 9, 9), Port: 30303}) + + h.cleanup() + + if h.GetNode(id) == nil { + t.Fatal("fresh unbonded node was evicted before NodeTTL") + } +} + +type stubTransport struct{} + +func (stubTransport) SendTo([]byte, *net.UDPAddr) error { return nil } + +func (stubTransport) Send([]byte, *net.UDPAddr, *net.UDPAddr) error { return nil } + +// TestFindnodeRemovesCompletedRequest verifies a delivered FINDNODE leaves no +// pending request behind, so later NEIGHBORS from the same node cannot keep +// matching it and reopening collection windows. +func TestFindnodeRemovesCompletedRequest(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + h := NewHandler(ctx, HandlerConfig{PrivateKey: key}, stubTransport{}) + + to := makeDiscv4Node(t) + to.MarkPongReceived(time.Hour) + target := EncodePubkey(&key.PublicKey) + + type result struct { + nodes []*node.Node + err error + } + done := make(chan result, 1) + go func() { + nodes, err := h.Findnode(to, target[:]) + done <- result{nodes, err} + }() + + deadline := time.Now().Add(2 * time.Second) + for h.findPendingFindnode(to.ID()) == nil { + if time.Now().After(deadline) { + t.Fatal("pending FINDNODE never registered") + } + time.Sleep(5 * time.Millisecond) + } + if err := h.handleNeighbors(to, to.Addr(), makeNeighbors(t, 3)); err != nil { + t.Fatal(err) + } + + res := <-done + if res.err != nil || len(res.nodes) != 3 { + t.Fatalf("Findnode = %d nodes, %v", len(res.nodes), res.err) + } + h.requestsMu.RLock() + remaining := len(h.requests) + h.requestsMu.RUnlock() + if remaining != 0 { + t.Fatalf("%d pending requests remain after a completed FINDNODE, want 0", remaining) + } +} + +// TestNeighborsPersistenceCapExactUnderConcurrency verifies that packets +// processed on concurrent dispatch goroutines cannot jointly persist more than +// the cap: room is reserved under the lock before any record is decoded. +func TestNeighborsPersistenceCapExactUnderConcurrency(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + packets := make([]*Neighbors, 6) + for i := range packets { + packets[i] = makeNeighbors(t, maxNeighborsPerResponse) + } + + var wg sync.WaitGroup + for _, pkt := range packets { + wg.Add(1) + go func(p *Neighbors) { + defer wg.Done() + if err := h.handleNeighbors(from, from.Addr(), p); err != nil { + t.Errorf("handleNeighbors: %v", err) + } + }(pkt) + } + wg.Wait() + + h.nodesMu.RLock() + persisted := len(h.nodes) + h.nodesMu.RUnlock() + if persisted != maxNeighborsPerResponse { + t.Fatalf("node map persisted %d records under concurrent packets, want exactly the cap %d", persisted, maxNeighborsPerResponse) + } +} + +// TestNeighborsAfterDeliveryPersistNothing verifies the delivered entry is +// tombstoned rather than deleted: a packet processed after the collection +// window must not reopen accumulation or persist records. +func TestNeighborsAfterDeliveryPersistNothing(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + req := h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 2)); err != nil { + t.Fatal(err) + } + select { + case <-req.ResponseChan: + case <-time.After(2 * time.Second): + t.Fatal("collected nodes were not delivered") + } + + h.nodesMu.RLock() + before := len(h.nodes) + h.nodesMu.RUnlock() + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 5)); err != nil { + t.Fatal(err) + } + + h.nodesMu.RLock() + after := len(h.nodes) + h.nodesMu.RUnlock() + if after != before { + t.Fatalf("a post-delivery packet persisted %d records, want 0", after-before) + } + h.pendingNeighborsMu.RLock() + pending := h.pendingNeighbors["req"] + h.pendingNeighborsMu.RUnlock() + if pending == nil || !pending.Closed || len(pending.Nodes) != 2 { + t.Fatalf("tombstone state = %+v, want closed with the delivered 2 nodes", pending) + } +} diff --git a/discv4/protocol/response_delivery_test.go b/discv4/protocol/response_delivery_test.go new file mode 100644 index 0000000..c2052a5 --- /dev/null +++ b/discv4/protocol/response_delivery_test.go @@ -0,0 +1,104 @@ +package protocol + +import ( + "context" + "sync" + "testing" + "time" +) + +func newTestHandler(t *testing.T) (*Handler, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + return NewHandler(ctx, HandlerConfig{}, nil), cancel +} + +// TestDeliverResponseNeverBlocks verifies that a flood of duplicate responses +// for a single request never parks the delivering goroutines and that exactly +// one value is handed to the waiter. With a blocking send every duplicate past +// the first would leak a goroutine. +func TestDeliverResponseNeverBlocks(t *testing.T) { + h, cancel := newTestHandler(t) + defer cancel() + + req := h.addPendingRequest([]byte("reqhash"), nil, PingPacket) + + const dups = 200 + var wg sync.WaitGroup + wg.Add(dups) + for i := 0; i < dups; i++ { + go func() { + defer wg.Done() + h.deliverResponse(req, "pong") + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("deliverResponse blocked: delivering goroutines leaked") + } + + // Exactly one value is buffered for the waiter, the rest were dropped. + select { + case <-req.ResponseChan: + default: + t.Fatal("expected one delivered response") + } + select { + case <-req.ResponseChan: + t.Fatal("more than one response buffered") + default: + } +} + +// TestDuplicateResponsesWaiterGetsOneNoLeak mirrors the real flow: a waiter +// consumes one response and removes the request while duplicate responses race +// through getPendingRequest and deliverResponse. The waiter must receive +// exactly one response and every delivery goroutine must finish. +func TestDuplicateResponsesWaiterGetsOneNoLeak(t *testing.T) { + h, cancel := newTestHandler(t) + defer cancel() + + hash := []byte("reqhash") + req := h.addPendingRequest(hash, nil, PingPacket) + + got := make(chan interface{}, 1) + var waiter sync.WaitGroup + waiter.Add(1) + go func() { + defer waiter.Done() + resp := <-req.ResponseChan + h.removePendingRequest(string(hash)) + got <- resp + }() + + const dups = 200 + var wg sync.WaitGroup + wg.Add(dups) + for i := 0; i < dups; i++ { + go func() { + defer wg.Done() + if r := h.getPendingRequest(string(hash)); r != nil { + h.deliverResponse(r, "pong") + } + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("duplicate responses leaked delivering goroutines") + } + + waiter.Wait() + select { + case <-got: + default: + t.Fatal("waiter did not receive a response") + } +} diff --git a/discv4/service.go b/discv4/service.go index 176acd6..abc2168 100644 --- a/discv4/service.go +++ b/discv4/service.go @@ -352,23 +352,15 @@ func (s *Service) LocalENR() *enr.Record { func (s *Service) SetLocalENR(record *enr.Record) { s.mu.Lock() s.localENR = record - if s.handler != nil && s.transport != nil { - // Update handler config - s.handler = protocol.NewHandler(s.ctx, protocol.HandlerConfig{ - PrivateKey: s.privateKey, - LocalENR: record, - LocalAddr: s.transport.LocalAddr(), - BondExpiration: s.config.BondExpiration, - RequestTimeout: s.config.RequestTimeout, - ExpirationWindow: s.config.ExpirationWindow, - OnPing: s.config.OnPing, - OnFindnode: s.config.OnFindnode, - OnENRRequest: s.config.OnENRRequest, - OnNodeSeen: s.config.OnNodeSeen, - OnPongReceived: s.config.OnPongReceived, - }, s.transport) - } + handler := s.handler s.mu.Unlock() + + // Update the live handler instead of replacing it: a rebuild discards every + // bond, known node, pending request and counter, orphans the old handler's + // cleanup goroutine, and delivers replies to a handler nobody is waiting on. + if handler != nil { + handler.SetLocalENR(record) + } } // LocalEnode returns the local enode:// URL. @@ -383,15 +375,23 @@ func (s *Service) LocalEnode() string { return en.String() } +// Handler returns the underlying protocol handler. +func (s *Service) Handler() *protocol.Handler { + s.mu.Lock() + defer s.mu.Unlock() + return s.handler +} + // Statistics // Stats returns service statistics. func (s *Service) Stats() map[string]interface{} { - if s.handler == nil { + handler := s.Handler() + if handler == nil { return map[string]interface{}{} } - stats := s.handler.Stats() + stats := handler.Stats() // Note: Transport stats are not included since transport is managed externally diff --git a/discv5/node/node.go b/discv5/node/node.go index d217b73..4eebd88 100644 --- a/discv5/node/node.go +++ b/discv5/node/node.go @@ -12,6 +12,7 @@ import ( "crypto/ecdsa" "fmt" "net" + "sync" "time" "github.com/ethereum/go-ethereum/crypto" @@ -57,6 +58,13 @@ func PubkeyToID(pub *ecdsa.PublicKey) ID { // It combines the node's ENR record with additional runtime information // like network statistics and last seen time. type Node struct { + // mu guards record, addr, tcpPort and the stats pointer: UpdateENR and + // SetStats replace them while packet handling and scoring read them from + // other goroutines. The pointed-to values need no guard - enr.Record has + // its own lock and is never mutated after signing, addr is replaced whole, + // and SharedStats synchronizes internally. + mu sync.RWMutex + // record is the ENR record containing node identity and metadata record *enr.Record @@ -143,40 +151,55 @@ func (n *Node) ID() ID { // Record returns the node's ENR record. func (n *Node) Record() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.record } // Addr returns the node's UDP address. func (n *Node) Addr() *net.UDPAddr { + n.mu.RLock() + defer n.mu.RUnlock() return n.addr } +// statsRef returns the current shared stats pointer for use outside the lock. +func (n *Node) statsRef() *stats.SharedStats { + n.mu.RLock() + defer n.mu.RUnlock() + return n.stats +} + // SetStats replaces the node's stats with a shared stats pointer. // This allows the node to update stats owned by a parent node. func (n *Node) SetStats(sharedStats *stats.SharedStats) { if sharedStats != nil { + n.mu.Lock() n.stats = sharedStats + n.mu.Unlock() } } // IP returns the node's IP address. func (n *Node) IP() net.IP { - return n.addr.IP + return n.Addr().IP } // UDPPort returns the node's UDP port. func (n *Node) UDPPort() uint16 { - return uint16(n.addr.Port) + return uint16(n.Addr().Port) } // TCPPort returns the node's TCP port (0 if not set). func (n *Node) TCPPort() uint16 { + n.mu.RLock() + defer n.mu.RUnlock() return n.tcpPort } // PublicKey returns the node's public key. func (n *Node) PublicKey() *ecdsa.PublicKey { - return n.record.PublicKey() + return n.Record().PublicKey() } // PeerID returns the libp2p peer ID for this node. @@ -201,7 +224,7 @@ func (n *Node) PeerID() string { // Digest returns the node's fork digest. func (n *Node) Digest() [4]byte { - eth2Data, ok := n.record.Eth2() + eth2Data, ok := n.Record().Eth2() if !ok { return [4]byte{} } @@ -210,37 +233,37 @@ func (n *Node) Digest() [4]byte { // SetLastSeen updates the last seen time. func (n *Node) SetLastSeen(t time.Time) { - n.stats.SetLastSeen(t) + n.statsRef().SetLastSeen(t) } // SetLastPing updates the last ping time. func (n *Node) SetLastPing(t time.Time) { - n.stats.SetLastPing(t) + n.statsRef().SetLastPing(t) } // SetFailureCount sets the failure count. func (n *Node) SetFailureCount(count int) { - n.stats.SetFailureCount(count) + n.statsRef().SetFailureCount(count) } // SetSuccessCount sets the success count. func (n *Node) SetSuccessCount(count int) { - n.stats.SetSuccessCount(count) + n.statsRef().SetSuccessCount(count) } // IncrementFailureCount increases the failure count by 1. func (n *Node) IncrementFailureCount() { - n.stats.IncrementFailureCount() + n.statsRef().IncrementFailureCount() } // ResetFailureCount resets the failure count to 0 and increments success count. func (n *Node) ResetFailureCount() { - n.stats.ResetFailureCount() + n.statsRef().ResetFailureCount() } // UpdateRTT updates the average RTT using exponential moving average. func (n *Node) UpdateRTT(rtt time.Duration) { - n.stats.UpdateRTT(rtt) + n.statsRef().UpdateRTT(rtt) } // UpdateENR updates the node's ENR record if the new one has a higher sequence number. @@ -251,6 +274,9 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { return false } + n.mu.Lock() + defer n.mu.Unlock() + // Only update if new record has higher sequence number if newRecord.Seq() > n.record.Seq() { n.record = newRecord @@ -279,7 +305,7 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { // // Format: Node[id=abc123..., addr=192.168.1.1:9000, seen=1m ago] func (n *Node) String() string { - lastSeen := n.stats.LastSeen() + lastSeen := n.statsRef().LastSeen() var seenStr string if lastSeen.IsZero() { @@ -290,7 +316,7 @@ func (n *Node) String() string { return fmt.Sprintf("Node[id=%s, addr=%s, seen=%s]", n.id.String()[:8]+"...", // First 8 chars of ID - n.addr.String(), + n.Addr().String(), seenStr, ) } @@ -308,7 +334,7 @@ type Stats struct { // GetStats returns the current statistics for the node. func (n *Node) GetStats() Stats { - snapshot := n.stats.GetSnapshot() + snapshot := n.statsRef().GetSnapshot() return Stats{ FirstSeen: snapshot.FirstSeen, LastSeen: snapshot.LastSeen, @@ -316,7 +342,7 @@ func (n *Node) GetStats() Stats { FailureCount: snapshot.FailureCount, SuccessCount: snapshot.SuccessCount, AvgRTT: snapshot.AvgRTT, - ENRSeq: n.record.Seq(), + ENRSeq: n.Record().Seq(), } } @@ -354,7 +380,7 @@ type ForkScoringInfo struct { // // Returns a score between 0.0 (worst) and 1.0 (best). func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { - snapshot := n.stats.GetSnapshot() + snapshot := n.statsRef().GetSnapshot() now := time.Now() // RTT score (30% weight, adjusted from 40%) diff --git a/discv5/node/node_test.go b/discv5/node/node_test.go new file mode 100644 index 0000000..8f9d8c1 --- /dev/null +++ b/discv5/node/node_test.go @@ -0,0 +1,133 @@ +package node + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/ethpandaops/bootnodoor/stats" +) + +func signedRecord(t *testing.T, seq uint64, port uint16) *enr.Record { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + rec := enr.New() + if err := rec.Set("ip", net.IPv4(3, 3, 3, 3)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", port); err != nil { + t.Fatalf("set udp: %v", err) + } + rec.SetSeq(seq) + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + return rec +} + +// TestNodeConcurrentENRAndStatsAccess exercises UpdateENR and SetStats against +// every reader of the guarded fields. Under the race detector it fails if any +// access to record, addr, tcpPort or the stats pointer is unsynchronized. +func TestNodeConcurrentENRAndStatsAccess(t *testing.T) { + n, err := New(signedRecord(t, 1, 30303)) + if err != nil { + t.Fatal(err) + } + newer := signedRecord(t, 2, 30304) + shared := stats.NewSharedStats(time.Now()) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(2) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + n.UpdateENR(newer) + } + } + }() + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + n.SetStats(shared) + } + } + }() + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.Record() + _ = n.Addr() + _ = n.IP() + _ = n.UDPPort() + _ = n.TCPPort() + _ = n.PublicKey() + _ = n.PeerID() + _ = n.Digest() + _ = n.GetStats() + _ = n.CalculateScore(nil) + _ = n.String() + n.SetLastSeen(time.Now()) + n.IncrementFailureCount() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestUpdateENRSeqMonotonicUnderConcurrency verifies interleaved refreshes can +// never install a lower-sequence record over a higher one. +func TestUpdateENRSeqMonotonicUnderConcurrency(t *testing.T) { + n, err := New(signedRecord(t, 1, 30303)) + if err != nil { + t.Fatal(err) + } + older := signedRecord(t, 5, 30305) + newest := signedRecord(t, 9, 30309) + + var wg sync.WaitGroup + for r := 0; r < 8; r++ { + rec := older + if r%2 == 0 { + rec = newest + } + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + n.UpdateENR(rec) + } + }() + } + wg.Wait() + + if got := n.Record().Seq(); got != 9 { + t.Fatalf("record seq = %d, want the highest installed sequence 9", got) + } +} diff --git a/discv5/protocol/handler.go b/discv5/protocol/handler.go index 3d3a256..476553c 100644 --- a/discv5/protocol/handler.go +++ b/discv5/protocol/handler.go @@ -42,6 +42,11 @@ type PendingChallenge struct { ChallengeData []byte PacketBytes []byte // Raw WHOAREYOU packet bytes for resending CreatedAt time.Time + + // KnownNode is the record our advertised ENRSeq came from, if any. A peer + // may legally answer a non-zero ENRSeq without repeating its record, so + // this is the only copy left to verify that handshake against. + KnownNode *node.Node } // OnHandshakeCompleteCallback is called when a handshake completes successfully. @@ -704,6 +709,10 @@ func (h *Handler) handleWHOAREYOUPacket(packet *Packet, from *net.UDPAddr, local } } + if sess.GetNode() == nil && remoteNode != nil { + sess.SetNode(remoteNode) + } + h.config.Sessions.Put(sess) // Call OnHandshakeComplete callback for outgoing handshake @@ -798,29 +807,12 @@ func (h *Handler) handleHandshakePacket(packet *Packet, from *net.UDPAddr, local h.removePendingChallenge(challengeKey, pendingChallenge) h.mu.Unlock() - // Get sender's static public key for signature verification - // First try to extract it from the ENR in the handshake packet - var senderPubKey *ecdsa.PublicKey - var remoteNodeFromENR *node.Node - if len(packet.Handshake.ENR) > 0 { - enrRecord := &enr.Record{} - if err := enrRecord.DecodeRLPBytes(packet.Handshake.ENR); err != nil { - h.config.Logger.WithError(err).Warn("handler: failed to decode ENR from handshake") - } else { - remoteNode, err := node.New(enrRecord) - if err != nil { - h.config.Logger.WithError(err).Warn("handler: failed to create node from ENR") - } else { - remoteNodeFromENR = remoteNode - senderPubKey = remoteNode.PublicKey() - } - } - } - - // ENR is required in handshake packet for signature verification - if senderPubKey == nil { - h.config.Logger.WithField("sourceNodeID", sourceNodeID.String()[:16]).Warn("handler: no ENR provided in handshake packet") - return fmt.Errorf("no ENR provided in handshake packet") + remoteNodeFromENR, senderPubKey, err := resolveHandshakeSender( + packet.Handshake.ENR, pendingChallenge, sourceNodeID, h.config.Logger) + if err != nil { + h.config.Logger.WithError(err).WithField("sourceNodeID", sourceNodeID.String()[:16]). + Warn("handler: cannot resolve handshake sender") + return err } // Decode ephemeral public key (for ECDH) @@ -1400,8 +1392,10 @@ func (h *Handler) sendWHOAREYOU(to *net.UDPAddr, destNodeID node.ID, nonce []byt // Get the current ENR sequence we have for this node enrSeq := uint64(0) + var knownNode *node.Node if sess := h.config.Sessions.Get(destNodeID); sess != nil { if existingNode := sess.GetNode(); existingNode != nil { + knownNode = existingNode enrSeq = existingNode.Record().Seq() } } @@ -1432,6 +1426,7 @@ func (h *Handler) sendWHOAREYOU(to *net.UDPAddr, destNodeID node.ID, nonce []byt ChallengeData: challengeData, PacketBytes: packetBytes, // Store for resending CreatedAt: time.Now(), + KnownNode: knownNode, } h.mu.Lock() @@ -1686,13 +1681,20 @@ func (h *Handler) requestENRUpdate(n *node.Node) { return } - // The NODES response handler will automatically update the ENR in our table nodesMsg, ok := resp.Message.(*Nodes) - if ok && len(nodesMsg.Records) > 0 { + if !ok { h.config.Logger.WithFields(logrus.Fields{ "nodeID": n.ID().String()[:16], - "count": len(nodesMsg.Records), - }).Debug("handler: received ENR update") + "type": fmt.Sprintf("%T", resp.Message), + }).Debug("handler: ENR update returned unexpected response") + return + } + + if h.applyENRUpdate(n, nodesMsg.Records) { + h.config.Logger.WithFields(logrus.Fields{ + "nodeID": n.ID().String()[:16], + "seq": n.Record().Seq(), + }).Debug("handler: installed ENR update") } case <-time.After(5 * time.Second): @@ -1703,6 +1705,87 @@ func (h *Handler) requestENRUpdate(n *node.Node) { }() } +// resolveHandshakeSender determines which node, and therefore which static key, +// a handshake must be verified against. +// +// The record is optional in the handshake message: a peer may legally omit it +// when our WHOAREYOU advertised an ENRSeq it has nothing newer than, in which +// case the challenge's own copy is the only one left. The whole node is +// returned rather than a bare key because the session and the handshake +// callback both hang off it, and a node-less session can never refresh its ENR. +func resolveHandshakeSender(enrBytes []byte, challenge *PendingChallenge, sourceNodeID node.ID, + logger logrus.FieldLogger, +) (*node.Node, *ecdsa.PublicKey, error) { + var remoteNode *node.Node + + if len(enrBytes) > 0 { + record := &enr.Record{} + if err := record.DecodeRLPBytes(enrBytes); err != nil { + logger.WithError(err).Warn("handler: failed to decode ENR from handshake") + } else if n, err := node.New(record); err != nil { + logger.WithError(err).Warn("handler: failed to create node from ENR") + } else { + remoteNode = n + } + } + + if remoteNode == nil && challenge != nil { + remoteNode = challenge.KnownNode + } + + if remoteNode == nil { + return nil, nil, fmt.Errorf("no ENR provided in handshake packet") + } + + pubKey := remoteNode.PublicKey() + if pubKey == nil { + return nil, nil, fmt.Errorf("handshake record carries no public key") + } + + // The session is keyed by the claimed source ID while the signature only + // proves possession of this key, so without binding the two a peer could + // authenticate as itself and be filed under another node's identity. + if node.PubkeyToID(pubKey) != sourceNodeID { + return nil, nil, fmt.Errorf("handshake key does not match source node ID") + } + + return remoteNode, pubKey, nil +} + +// applyENRUpdate installs the newest valid record returned by a distance-zero +// FINDNODE request. A peer must not be able to replace its session record with +// another node's ENR, even though the response itself matched the pending request. +func (h *Handler) applyENRUpdate(n *node.Node, records []*enr.Record) bool { + var newest *enr.Record + + for _, record := range records { + candidate, err := node.New(record) + if err != nil { + h.config.Logger.WithError(err).Debug("handler: ignoring invalid ENR update") + continue + } + if candidate.ID() != n.ID() { + h.config.Logger.WithFields(logrus.Fields{ + "nodeID": n.ID().String()[:16], + "recordID": candidate.ID().String()[:16], + }).Debug("handler: ignoring ENR update for a different node") + continue + } + if newest == nil || record.Seq() > newest.Seq() { + newest = record + } + } + + if newest == nil || !n.UpdateENR(newest) { + return false + } + + if h.config.OnNodeUpdate != nil { + h.config.OnNodeUpdate(n) + } + return true +} + // SendFindNode sends a FINDNODE request. // // Returns a channel that will receive the NODES response. diff --git a/discv5/protocol/handler_test.go b/discv5/protocol/handler_test.go new file mode 100644 index 0000000..c507385 --- /dev/null +++ b/discv5/protocol/handler_test.go @@ -0,0 +1,225 @@ +package protocol + +import ( + "crypto/ecdsa" + "net" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/sirupsen/logrus" +) + +// TestResolveHandshakeSender covers the optional-record rule from discv5 v5.1: +// a peer may answer our WHOAREYOU without repeating its ENR, and rejecting that +// would break bonding with exactly the peers whose records we already hold. +func TestResolveHandshakeSender(t *testing.T) { + key := generateKey(t) + record := signedRecord(t, key, 4, nil) + known, err := node.New(record) + if err != nil { + t.Fatalf("create node: %v", err) + } + + encoded, err := record.EncodeRLP() + if err != nil { + t.Fatalf("encode record: %v", err) + } + + otherKey := generateKey(t) + otherNode, err := node.New(signedRecord(t, otherKey, 1, nil)) + if err != nil { + t.Fatalf("create other node: %v", err) + } + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + tests := []struct { + name string + enrBytes []byte + challenge *PendingChallenge + source node.ID + wantErr bool + }{ + { + name: "record in handshake is used", + enrBytes: encoded, + source: known.ID(), + }, + { + name: "record omitted falls back to the challenge", + challenge: &PendingChallenge{KnownNode: known}, + source: known.ID(), + }, + { + name: "record omitted with nothing to fall back to", + challenge: &PendingChallenge{}, + source: known.ID(), + wantErr: true, + }, + { + name: "record belongs to a different node than claimed", + enrBytes: encoded, + source: otherNode.ID(), + wantErr: true, + }, + { + name: "fallback belongs to a different node than claimed", + challenge: &PendingChallenge{KnownNode: known}, + source: otherNode.ID(), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, pubKey, err := resolveHandshakeSender(tc.enrBytes, tc.challenge, tc.source, logger) + + if tc.wantErr { + if err == nil { + t.Fatal("expected an error, got a resolved sender") + } + return + } + + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got == nil { + t.Fatal("resolved a key but no node; the session would have nothing to refresh") + } + if node.PubkeyToID(pubKey) != tc.source { + t.Fatal("resolved key does not derive the claimed source ID") + } + }) + } +} + +func TestApplyENRUpdateInstallsNewestMatchingRecord(t *testing.T) { + key := generateKey(t) + currentRecord := signedRecord(t, key, 1, nil) + remoteNode, err := node.New(currentRecord) + if err != nil { + t.Fatalf("create node: %v", err) + } + + staleRecord := signedRecord(t, key, 1, nil) + newRecord := signedRecord(t, key, 2, map[string]interface{}{ + "eth": []struct { + Hash []byte + Next uint64 + }{{Hash: []byte{0xde, 0xad, 0xbe, 0xef}}}, + }) + newestRecord := signedRecord(t, key, 3, map[string]interface{}{ + "eth": []struct { + Hash []byte + Next uint64 + }{{Hash: []byte{0xca, 0xfe, 0xba, 0xbe}}}, + }) + + callbackCount := 0 + handler := testHandler(func(updated *node.Node) { + callbackCount++ + if updated != remoteNode { + t.Error("callback received a different node") + } + }) + + if !handler.applyENRUpdate(remoteNode, []*enr.Record{staleRecord, newestRecord, newRecord}) { + t.Fatal("expected ENR to be updated") + } + if got := remoteNode.Record().Seq(); got != 3 { + t.Fatalf("record sequence = %d, want 3", got) + } + eth, ok := remoteNode.Record().Eth() + if !ok { + t.Fatal("updated record is missing eth fork ID") + } + if got, want := eth[0].ForkID, [4]byte{0xca, 0xfe, 0xba, 0xbe}; got != want { + t.Fatalf("fork hash = %x, want %x", got, want) + } + if callbackCount != 1 { + t.Fatalf("callback count = %d, want 1", callbackCount) + } +} + +func TestApplyENRUpdateRejectsDifferentNode(t *testing.T) { + remoteNode, err := node.New(signedRecord(t, generateKey(t), 1, nil)) + if err != nil { + t.Fatalf("create node: %v", err) + } + differentRecord := signedRecord(t, generateKey(t), 99, nil) + + callbackCount := 0 + handler := testHandler(func(*node.Node) { callbackCount++ }) + if handler.applyENRUpdate(remoteNode, []*enr.Record{differentRecord}) { + t.Fatal("different node's ENR was accepted") + } + if got := remoteNode.Record().Seq(); got != 1 { + t.Fatalf("record sequence = %d, want 1", got) + } + if callbackCount != 0 { + t.Fatalf("callback count = %d, want 0", callbackCount) + } +} + +func TestApplyENRUpdateRejectsStaleRecord(t *testing.T) { + key := generateKey(t) + remoteNode, err := node.New(signedRecord(t, key, 2, nil)) + if err != nil { + t.Fatalf("create node: %v", err) + } + + callbackCount := 0 + handler := testHandler(func(*node.Node) { callbackCount++ }) + if handler.applyENRUpdate(remoteNode, []*enr.Record{signedRecord(t, key, 1, nil)}) { + t.Fatal("stale ENR was accepted") + } + if got := remoteNode.Record().Seq(); got != 2 { + t.Fatalf("record sequence = %d, want 2", got) + } + if callbackCount != 0 { + t.Fatalf("callback count = %d, want 0", callbackCount) + } +} + +func testHandler(onNodeUpdate OnNodeUpdateCallback) *Handler { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + return &Handler{config: HandlerConfig{ + Logger: logger, + OnNodeUpdate: onNodeUpdate, + }} +} + +func generateKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +func signedRecord(t *testing.T, key *ecdsa.PrivateKey, seq uint64, fields map[string]interface{}) *enr.Record { + t.Helper() + record := enr.New() + if err := record.Set("ip", net.IPv4(203, 0, 113, 1)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := record.Set("udp", uint16(30303)); err != nil { + t.Fatalf("set udp: %v", err) + } + for name, value := range fields { + if err := record.Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + record.SetSeq(seq) + if err := record.Sign(key); err != nil { + t.Fatalf("sign record: %v", err) + } + return record +} diff --git a/enr/record.go b/enr/record.go index 30b38f3..3584821 100644 --- a/enr/record.go +++ b/enr/record.go @@ -11,6 +11,7 @@ package enr import ( "crypto/ecdsa" + "encoding/binary" "errors" "fmt" "net" @@ -393,7 +394,7 @@ func (r *Record) Eth2() (*Eth2ENRData, bool) { // Eth2 field format: // - Bytes 0-3: Current fork digest // - Bytes 4-7: Next fork version - // - Bytes 8-15: Next fork epoch (big endian) + // - Bytes 8-15: Next fork epoch (SSZ uint64, little endian) if len(eth2Bytes) < 16 { return nil, false } @@ -401,16 +402,7 @@ func (r *Record) Eth2() (*Eth2ENRData, bool) { var eth2Data Eth2ENRData copy(eth2Data.ForkDigest[:], eth2Bytes[0:4]) copy(eth2Data.NextForkVersion[:], eth2Bytes[4:8]) - - // Decode next fork epoch (big endian) - eth2Data.NextForkEpoch = uint64(eth2Bytes[8])<<56 | - uint64(eth2Bytes[9])<<48 | - uint64(eth2Bytes[10])<<40 | - uint64(eth2Bytes[11])<<32 | - uint64(eth2Bytes[12])<<24 | - uint64(eth2Bytes[13])<<16 | - uint64(eth2Bytes[14])<<8 | - uint64(eth2Bytes[15]) + eth2Data.NextForkEpoch = binary.LittleEndian.Uint64(eth2Bytes[8:16]) return ð2Data, true } diff --git a/go.mod b/go.mod index 9b41905..4167178 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,21 @@ go 1.25.7 require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 - github.com/ethereum/go-ethereum v1.17.3 + github.com/ethereum/go-ethereum v1.17.4 github.com/glebarez/go-sqlite v1.22.0 github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/mr-tron/base58 v1.3.0 github.com/multiformats/go-multihash v0.2.3 - github.com/pk910/dynamic-ssz v1.3.1 - github.com/pressly/goose/v3 v3.27.1 - github.com/prometheus/client_golang v1.23.2 + github.com/pk910/dynamic-ssz v1.3.2 + github.com/pressly/goose/v3 v3.27.2 + github.com/prometheus/client_golang v1.24.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/tdewolff/minify v2.3.6+incompatible github.com/urfave/negroni v1.0.0 - golang.org/x/crypto v0.50.0 - golang.org/x/net v0.53.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -39,10 +39,10 @@ require ( github.com/multiformats/go-varint v0.0.6 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pk910/hashtree-bindings v0.1.0 // indirect + github.com/pk910/hashtree-bindings v0.2.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect @@ -51,14 +51,12 @@ require ( github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/tdewolff/test v1.0.11 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.1.6 // indirect - modernc.org/libc v1.72.1 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.49.1 // indirect + modernc.org/sqlite v1.53.0 // indirect ) diff --git a/go.sum b/go.sum index e8f1bfd..879ab68 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -27,8 +27,8 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -59,16 +59,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -100,26 +96,24 @@ github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9k github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pk910/dynamic-ssz v1.3.1 h1:S/no7kRA5FSORmfybG4Cs49CjPgP94fePKPxt8uKkdI= -github.com/pk910/dynamic-ssz v1.3.1/go.mod h1:ARK5qDyrJ/MHpaZHGJYvCKElvaMYTE9pXOQbvPDeE0U= -github.com/pk910/hashtree-bindings v0.1.0 h1:w7NyRWFi2OaYEFvo9ADcE/QU6PMuVLl3hBgx92KiH9c= -github.com/pk910/hashtree-bindings v0.1.0/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= +github.com/pk910/dynamic-ssz v1.3.2 h1:65UR/O+ss+U2Dn86Rdl7LwehHo3u2ElutduS/pcuUXE= +github.com/pk910/dynamic-ssz v1.3.2/go.mod h1:lqmnou2bjr2UWQ3C/L3082TGW0SFl/SwT7ionwM0+FU= +github.com/pk910/hashtree-bindings v0.2.2 h1:gkczxxekBW2NeMK9N3OLj7Jepe7zPmJGVwr8LyofGsA= +github.com/pk910/hashtree-bindings v0.2.2/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= -github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= @@ -147,24 +141,24 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -174,16 +168,16 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= @@ -196,9 +190,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -210,20 +203,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= -modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= -modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54= -modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -232,8 +225,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/nodes/flattable.go b/nodes/flattable.go index a412713..caf22c7 100644 --- a/nodes/flattable.go +++ b/nodes/flattable.go @@ -195,7 +195,25 @@ func (t *FlatTable) LoadInitialNodesFromDB() error { // Load random nodes from DB to bootstrap the active pool randomNodes := t.db.LoadRandomNodes(t.maxActiveNodes) + t.mu.Lock() + defer t.mu.Unlock() + for _, n := range randomNodes { + // The transport may have admitted nodes before this bulk load runs, so + // stop at the soft cap instead of stacking a full load on top of them. + if len(t.activeNodes) >= t.maxActiveNodes { + break + } + // Add applies this check; a persisted record of ourselves would + // otherwise sit in the pool for the whole process lifetime and be + // dialed by every lookup round. + if n.ID() == t.localID { + continue + } + if _, exists := t.activeNodes[n.ID()]; exists { + continue + } + if t.ipLimiter.CanAdd(n) { t.activeNodes[n.ID()] = n t.ipLimiter.Add(n) @@ -708,17 +726,6 @@ func (t *FlatTable) ActiveSize() int { return len(t.activeNodes) } -// NumBucketsFilled returns a compatibility value for the flat table. -// Since we don't have buckets, we return 1 if we have any active nodes, 0 otherwise. -func (t *FlatTable) NumBucketsFilled() int { - t.mu.RLock() - defer t.mu.RUnlock() - if len(t.activeNodes) > 0 { - return 1 - } - return 0 -} - // GetStats returns statistics about the table. func (t *FlatTable) GetStats() TableStats { t.mu.RLock() @@ -730,7 +737,6 @@ func (t *FlatTable) GetStats() TableStats { return TableStats{ TotalNodes: totalCount, ActiveNodes: activeCount, - BucketsFilled: 0, // Not applicable for flat table AdmissionRejections: t.admissionRejections, IPLimitRejections: t.ipLimitRejections, DeadNodesRemoved: t.deadNodesRemoved, diff --git a/nodes/flattable_test.go b/nodes/flattable_test.go new file mode 100644 index 0000000..8f3373a --- /dev/null +++ b/nodes/flattable_test.go @@ -0,0 +1,153 @@ +package nodes + +import ( + "context" + "net" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/bootnodoor/db" + discv5node "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" +) + +func makeV5At(t *testing.T, ip net.IP) *discv5node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + rec := enr.New() + if err := rec.Set("ip", ip); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := discv5node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return v5 +} + +// TestLoadInitialNodesFromDBRespectsSoftCap verifies the bulk load stops at +// maxActiveNodes when traffic has already admitted nodes before it runs. +func TestLoadInitialNodesFromDBRespectsSoftCap(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ndb := NewNodeDB(ctx, database, db.LayerCL, logger) + + for i := 0; i < 4; i++ { + n := NewFromV5(makeV5At(t, net.IPv4(10, 0, 0, byte(i+1))), ndb) + n.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(n); err != nil { + t.Fatal(err) + } + } + deadline := time.Now().Add(5 * time.Second) + for ndb.Count() < 4 { + if time.Now().After(deadline) { + t.Fatalf("only %d of 4 seeded nodes were persisted", ndb.Count()) + } + time.Sleep(50 * time.Millisecond) + } + + table, err := NewFlatTable(FlatTableConfig{DB: ndb, MaxActiveNodes: 2, Logger: logger}) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + n := NewFromV5(makeV5At(t, net.IPv4(10, 0, 1, byte(i+1))), ndb) + table.mu.Lock() + table.activeNodes[n.ID()] = n + table.ipLimiter.Add(n) + table.mu.Unlock() + } + + if err := table.LoadInitialNodesFromDB(); err != nil { + t.Fatal(err) + } + + table.mu.RLock() + got := len(table.activeNodes) + table.mu.RUnlock() + if got != 2 { + t.Fatalf("active pool holds %d nodes after bulk load, want the soft cap 2", got) + } +} + +// TestLoadInitialNodesFromDBSkipsSelf verifies a persisted record of ourselves +// is not loaded into the active pool, where every lookup round would dial it. +func TestLoadInitialNodesFromDBSkipsSelf(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ndb := NewNodeDB(ctx, database, db.LayerCL, logger) + + selfNode := NewFromV5(makeV5At(t, net.IPv4(10, 9, 0, 1)), ndb) + selfNode.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(selfNode); err != nil { + t.Fatal(err) + } + other := NewFromV5(makeV5At(t, net.IPv4(10, 9, 0, 2)), ndb) + other.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(other); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for ndb.Count() < 2 { + if time.Now().After(deadline) { + t.Fatalf("only %d of 2 nodes persisted", ndb.Count()) + } + time.Sleep(50 * time.Millisecond) + } + + table, err := NewFlatTable(FlatTableConfig{DB: ndb, LocalID: selfNode.ID(), MaxActiveNodes: 10, Logger: logger}) + if err != nil { + t.Fatal(err) + } + if err := table.LoadInitialNodesFromDB(); err != nil { + t.Fatal(err) + } + + table.mu.RLock() + _, selfLoaded := table.activeNodes[selfNode.ID()] + count := len(table.activeNodes) + table.mu.RUnlock() + if selfLoaded { + t.Fatal("our own persisted record was loaded into the active pool") + } + if count != 1 { + t.Fatalf("active pool holds %d nodes, want only the non-self node", count) + } +} diff --git a/nodes/node.go b/nodes/node.go index ac8c42f..59a88d7 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -141,6 +141,8 @@ func (n *Node) PublicKey() *ecdsa.PublicKey { // ENR returns the node's ENR record. func (n *Node) ENR() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } @@ -165,27 +167,38 @@ func (n *Node) SetAddr(addr *net.UDPAddr) { // V4 returns the discv4 node if available. func (n *Node) V4() *node.Node { + n.mu.RLock() + defer n.mu.RUnlock() return n.v4Node } // V5 returns the discv5 node if available. func (n *Node) V5() *discv5node.Node { + n.mu.RLock() + defer n.mu.RUnlock() return n.v5Node } // HasV4 returns true if this node supports discv4. func (n *Node) HasV4() bool { + n.mu.RLock() + defer n.mu.RUnlock() return n.v4Node != nil } // HasV5 returns true if this node supports discv5. func (n *Node) HasV5() bool { + n.mu.RLock() + defer n.mu.RUnlock() return n.v5Node != nil } // SetV4 sets the discv4 node and marks protocol support dirty. func (n *Node) SetV4(v4 *node.Node) { + n.mu.Lock() n.v4Node = v4 + n.mu.Unlock() + if v4 != nil && n.nodeStats != nil { // Ensure callback is set up (in case stats were created elsewhere) n.setupSharedStatsCallback() @@ -197,7 +210,10 @@ func (n *Node) SetV4(v4 *node.Node) { // SetV5 sets the discv5 node and marks protocol support dirty. func (n *Node) SetV5(v5 *discv5node.Node) { + n.mu.Lock() n.v5Node = v5 + n.mu.Unlock() + if v5 != nil && n.nodeStats != nil { // Ensure callback is set up (in case stats were created elsewhere) n.setupSharedStatsCallback() @@ -209,8 +225,12 @@ func (n *Node) SetV5(v5 *discv5node.Node) { // Enode returns the node's enode:// URL representation. func (n *Node) Enode() *enode.Enode { - if n.v4Node != nil { - return n.v4Node.Enode() + n.mu.RLock() + v4 := n.v4Node + n.mu.RUnlock() + + if v4 != nil { + return v4.Enode() } // Build from generic node info @@ -382,14 +402,20 @@ type NodeStats struct { // Record returns the node's ENR record. func (n *Node) Record() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } // PeerID returns the libp2p peer ID for this node. // Delegates to the v5 node if available, otherwise builds it from the public key. func (n *Node) PeerID() string { - if n.v5Node != nil { - return n.v5Node.PeerID() + n.mu.RLock() + v5 := n.v5Node + n.mu.RUnlock() + + if v5 != nil { + return v5.PeerID() } // Fallback: build peer ID from public key if n.pubKey != nil { @@ -406,18 +432,22 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { } // Update our ENR - if newRecord.Seq() > n.enr.Seq() { - n.enr = newRecord - - // Update v5 node if available - if n.v5Node != nil { - n.v5Node.UpdateENR(newRecord) - } + n.mu.Lock() + if newRecord.Seq() <= n.enr.Seq() { + n.mu.Unlock() + return false + } + n.enr = newRecord + v5 := n.v5Node + n.mu.Unlock() - return true + // Update v5 node if available - outside the lock so n.mu never nests with + // the v5 node's own mutex. + if v5 != nil { + v5.UpdateENR(newRecord) } - return false + return true } // NeedsPing checks if the node needs a liveness check. @@ -434,7 +464,11 @@ func (n *Node) IsAlive(maxAge time.Duration, maxFailures int) bool { // CalculateScore computes a quality score for the node. // Delegates to the v5 node if available. func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { - if n.v5Node != nil { + n.mu.RLock() + v5 := n.v5Node + n.mu.RUnlock() + + if v5 != nil { // Cast forkInfo to the v5 node's ForkScoringInfo type if forkInfo != nil { // Convert table.ForkScoringInfo to discv5/node.ForkScoringInfo @@ -444,10 +478,10 @@ func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { GenesisForkDigest: forkInfo.GenesisForkDigest, GracePeriodEnd: forkInfo.GracePeriodEnd, } - return n.v5Node.CalculateScore(v5ForkInfo) + return v5.CalculateScore(v5ForkInfo) } // If forkInfo is nil or wrong type, call with nil - return n.v5Node.CalculateScore(nil) + return v5.CalculateScore(nil) } // Basic fallback score based on success rate successCount := n.SuccessCount() diff --git a/nodes/node_test.go b/nodes/node_test.go new file mode 100644 index 0000000..1b4acf5 --- /dev/null +++ b/nodes/node_test.go @@ -0,0 +1,176 @@ +package nodes + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + discv4node "github.com/ethpandaops/bootnodoor/discv4/node" + discv5node "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" +) + +// newTestNode builds a node for exercising the protocol-pointer accessors. +// nodeStats is left nil so SetV4/SetV5 only touch the v4Node/v5Node pointers, +// keeping these tests focused on their synchronization. +func newTestNode(t *testing.T) *Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return &Node{ + pubKey: &key.PublicKey, + addr: &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}, + } +} + +func makeV4(t *testing.T) *discv4node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return discv4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(2, 2, 2, 2), Port: 30303}) +} + +func makeV5(t *testing.T) *discv5node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + rec := enr.New() + if err := rec.Set("ip", net.IPv4(3, 3, 3, 3)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(30303)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := discv5node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return v5 +} + +// TestNodeConcurrentProtocolPointerAccess exercises the readers and writers of +// the v4Node/v5Node pointers concurrently. Under the race detector it fails if +// any access to those pointers is unsynchronized. +func TestNodeConcurrentProtocolPointerAccess(t *testing.T) { + n := newTestNode(t) + v4 := makeV4(t) + v5 := makeV5(t) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + writer := func(set func(i int)) { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + set(i) + } + } + } + + wg.Add(2) + go writer(func(i int) { + if i%2 == 0 { + n.SetV4(v4) + } else { + n.SetV4(nil) + } + }) + go writer(func(i int) { + if i%2 == 0 { + n.SetV5(v5) + } else { + n.SetV5(nil) + } + }) + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.V4() + _ = n.V5() + _ = n.HasV4() + _ = n.HasV5() + _ = n.PeerID() + _ = n.Enode() + _ = n.String() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestNodeNoNilDerefDuringProtocolSwap targets the check-then-deref reader +// PeerID: while a node repeatedly gains and loses its v5 support, PeerID must +// never dereference a pointer that was cleared between the nil check and the +// call. CalculateScore reads the same pointer through the identical locked +// path. +func TestNodeNoNilDerefDuringProtocolSwap(t *testing.T) { + n := newTestNode(t) + v5 := makeV5(t) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + if i%2 == 0 { + n.SetV5(v5) + } else { + n.SetV5(nil) + } + } + } + }() + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.PeerID() + _ = n.V5() + _ = n.HasV5() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/nodes/types.go b/nodes/types.go index 8608a50..dda838a 100644 --- a/nodes/types.go +++ b/nodes/types.go @@ -36,7 +36,6 @@ type NodeChangedCallback func(*Node) type TableStats struct { TotalNodes int ActiveNodes int - BucketsFilled int AdmissionRejections int IPLimitRejections int DeadNodesRemoved int diff --git a/services/lookup.go b/services/lookup.go index 0cc553c..2f062b0 100644 --- a/services/lookup.go +++ b/services/lookup.go @@ -55,10 +55,36 @@ type LookupService struct { lookupsV4 int // Lookups using v4 } +// AdmissionResult reports why a discovered node was or was not admitted, so +// fork-filter rejections stay distinguishable from pool-capacity rejections. +type AdmissionResult int + +const ( + // AdmissionAccepted means the node was admitted to the table. + AdmissionAccepted AdmissionResult = iota + + // AdmissionRejectedFilter means the node is on this layer but announced an + // incompatible fork. + AdmissionRejectedFilter + + // AdmissionRejectedLayer means the node serves the other layer, so this + // lookup was never a candidate for it. On a dual-layer network most + // discovered nodes land here, which is healthy and must not be read as a + // fork-compatibility problem. + AdmissionRejectedLayer + + // AdmissionRejectedPool means the node passed validation but the table + // declined it (capacity, per-IP limit, or self). + AdmissionRejectedPool +) + // Config contains configuration for the lookup service. type Config struct { - // LocalNode is our node information - LocalNode *nodedb.Node + // LocalIDs are our own node IDs. A peer that returns one of our records in + // a NODES/NEIGHBORS response would otherwise make us dial ourselves every + // round, racing our own handshake challenges. There is one ID per discovery + // identity, so two when separate EL and CL keys are configured. + LocalIDs [][32]byte // NodeDB is the node database NodeDB *nodedb.NodeDB @@ -88,12 +114,98 @@ type Config struct { // OnNodeFound is called when a new node is discovered during lookup // The callback should handle admission checks and add the node if valid - OnNodeFound func(*nodedb.Node) bool + OnNodeFound func(*nodedb.Node) AdmissionResult // Logger for debug messages Logger logrus.FieldLogger } +// isLocal reports whether id belongs to one of our own identities. The +// parameter is the raw array so both discv4 and discv5 node IDs can be passed. +func (ls *LookupService) isLocal(id [32]byte) bool { + for _, local := range ls.config.LocalIDs { + if local == id { + return true + } + } + return false +} + +// discoveries accumulates the records observed during a single lookup, keeping +// the highest-sequence record per node plus the order nodes were first seen in. +type discoveries struct { + table map[node.ID]*nodedb.Node + best map[node.ID]*nodedb.Node + order []node.ID +} + +func newDiscoveries(tableNodes []*nodedb.Node) *discoveries { + // Live entries, not a sequence snapshot: Add mutates these same objects, so + // a refresh landing mid-lookup must not leave us admitting a stale record. + table := make(map[node.ID]*nodedb.Node, len(tableNodes)) + for _, n := range tableNodes { + table[n.ID()] = n + } + + return &discoveries{ + table: table, + best: make(map[node.ID]*nodedb.Node), + } +} + +// admit yields the best record per node in first-observation order. Order is +// preserved because the table applies capacity and per-IP limits as nodes +// arrive, so which record gets a slot depends on when it is offered. +func (d *discoveries) admit() []*nodedb.Node { + out := make([]*nodedb.Node, 0, len(d.order)) + + for _, id := range d.order { + n := d.best[id] + if known, ok := d.table[id]; ok && n.Record().Seq() <= known.Record().Seq() { + continue + } + out = append(out, n) + } + + return out +} + +// noteDiscovered records a discovered node, reporting whether it is new to us +// and therefore worth querying in the next round. Eligibility is settled before +// d.best is consulted so a later duplicate cannot bypass the known-node gate. +func (ls *LookupService) noteDiscovered(d *discoveries, n *nodedb.Node) bool { + id := n.ID() + if ls.isLocal(id) { + return false + } + + rec := n.Record() + if rec == nil { + return false + } + + // Only discv5 relays a peer's signed record; a v4 wrapper's ENR was fetched + // by dialing the peer, which is exactly what a stale peer is unreachable + // for, and re-admitting one re-runs the blocking v5 support probe. + known, isKnown := d.table[id] + if isKnown && (!n.HasV5() || rec.Seq() <= known.Record().Seq()) { + return false + } + + if prev, ok := d.best[id]; ok { + if rec.Seq() > prev.Record().Seq() { + d.best[id] = n + } + + return false + } + + d.best[id] = n + d.order = append(d.order, id) + + return !isKnown +} + // NewLookupService creates a new lookup service. func NewLookupService(cfg Config) *LookupService { if cfg.Alpha <= 0 { @@ -148,17 +260,10 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i return nil, fmt.Errorf("no nodes in table to query") } - // Track all discovered nodes and which ones we've queried - seen := make(map[node.ID]bool) + disc := newDiscoveries(allNodes) queried := make(map[node.ID]bool) - var allDiscovered []*nodedb.Node var mu sync.Mutex - // Add existing table nodes to the candidate pool - for _, n := range allNodes { - seen[n.ID()] = true - } - // For iterative lookup, we need a list of candidates sorted by distance to target // Start with closest nodes from our table var candidates []*nodedb.Node @@ -195,7 +300,6 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Query nodes in parallel for this round var wg sync.WaitGroup roundDiscovered := make([]*nodedb.Node, 0) - var roundMu sync.Mutex for _, n := range toQuery { wg.Add(1) @@ -205,9 +309,13 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Mark as queried mu.Lock() queried[n.ID()] = true - ls.queryHistory[n.ID()] = time.Now() mu.Unlock() + // queryHistory outlives this lookup and is read under ls.mu. + ls.mu.Lock() + ls.queryHistory[n.ID()] = time.Now() + ls.mu.Unlock() + // Calculate distances var distances []uint if isRandomWalk { @@ -229,9 +337,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Try discv5 first if available var discoveredNodes []*nodedb.Node if v5Node := n.V5(); v5Node != nil && ls.config.V5Handler != nil { - mu.Lock() + ls.mu.Lock() ls.lookupsV5++ - mu.Unlock() + ls.mu.Unlock() respChan, err := ls.config.V5Handler.SendFindNode(v5Node, distances) if err != nil { @@ -285,9 +393,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Try discv4 fallback if no v5 results and v4 is available if len(discoveredNodes) == 0 && n.V4() != nil && ls.config.V4Service != nil { - mu.Lock() + ls.mu.Lock() ls.lookupsV4++ - mu.Unlock() + ls.mu.Unlock() v4Node := n.V4() // Convert target to []byte for v4 @@ -317,6 +425,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i nodesWithENR := make([]*nodedb.Node, 0, len(nodes)) for _, v4n := range nodes { + if ls.isLocal(v4n.ID()) { + continue + } enrWg.Add(1) go func(v4n *v4node.Node) { defer enrWg.Done() @@ -394,22 +505,13 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i discoveredNodes = append(discoveredNodes, nodesWithENR...) } - // Add discovered nodes to round results - roundMu.Lock() + mu.Lock() for _, newNode := range discoveredNodes { - // Skip if already seen - mu.Lock() - alreadySeen := seen[newNode.ID()] - if !alreadySeen { - seen[newNode.ID()] = true - } - mu.Unlock() - - if !alreadySeen { + if ls.noteDiscovered(disc, newNode) { roundDiscovered = append(roundDiscovered, newNode) } } - roundMu.Unlock() + mu.Unlock() }(n) } @@ -420,11 +522,6 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i "discovered": len(roundDiscovered), }).Debug("lookup round complete") - // Add this round's discoveries to the total - mu.Lock() - allDiscovered = append(allDiscovered, roundDiscovered...) - mu.Unlock() - // Check context before next round select { case <-ctx.Done(): @@ -447,30 +544,45 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i } } + admitted := disc.admit() + ls.config.Logger.WithFields(logrus.Fields{ "target": target, "queried": len(queried), - "discovered": len(allDiscovered), + "discovered": len(admitted), }).Debug("lookup queries complete") // Add discovered nodes via callback (handles admission checks) var addedNodes []*nodedb.Node - for _, n := range allDiscovered { - if ls.config.OnNodeFound != nil && ls.config.OnNodeFound(n) { + var rejectedFilter, rejectedPool, rejectedLayer int + for _, n := range admitted { + if ls.config.OnNodeFound == nil { + continue + } + switch ls.config.OnNodeFound(n) { + case AdmissionAccepted: addedNodes = append(addedNodes, n) + case AdmissionRejectedFilter: + rejectedFilter++ + case AdmissionRejectedPool: + rejectedPool++ + case AdmissionRejectedLayer: + rejectedLayer++ } } ls.mu.Lock() - ls.nodesDiscovered += len(allDiscovered) + ls.nodesDiscovered += len(admitted) ls.lookupsCompleted++ ls.mu.Unlock() ls.config.Logger.WithFields(logrus.Fields{ - "target": target, - "discovered": len(allDiscovered), - "accepted": len(addedNodes), - "rejected": len(allDiscovered) - len(addedNodes), + "target": target, + "discovered": len(admitted), + "accepted": len(addedNodes), + "rejected_fork": rejectedFilter, + "rejected_layer": rejectedLayer, + "rejected_pool": rejectedPool, }).Info("lookup complete") return addedNodes, nil @@ -532,10 +644,10 @@ func (ls *LookupService) selectNodesToQuery(candidates []*nodedb.Node, queried m return nil } - // Filter out already queried nodes + // Filter out already queried nodes and ourselves var unqueried []*nodedb.Node for _, n := range candidates { - if !queried[n.ID()] { + if !queried[n.ID()] && !ls.isLocal(n.ID()) { unqueried = append(unqueried, n) } } diff --git a/services/lookup_test.go b/services/lookup_test.go new file mode 100644 index 0000000..29164c0 --- /dev/null +++ b/services/lookup_test.go @@ -0,0 +1,309 @@ +package services + +import ( + "crypto/ecdsa" + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + v4node "github.com/ethpandaops/bootnodoor/discv4/node" + "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" + nodedb "github.com/ethpandaops/bootnodoor/nodes" + "github.com/sirupsen/logrus" +) + +func testNode(t *testing.T, last byte) *nodedb.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + rec := enr.New() + if err := rec.Set("ip", net.IPv4(10, 0, 0, last)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return nodedb.NewFromV5(v5, nil) +} + +func recordAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *enr.Record { + t.Helper() + rec := enr.New() + if err := rec.Set("ip", net.IPv4(10, 0, 0, last)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + rec.SetSeq(seq) + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + return rec +} + +// v5NodeAtSeq builds a node the way a discv5 NODES response does. +func v5NodeAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *nodedb.Node { + t.Helper() + v5, err := node.New(recordAtSeq(t, key, last, seq)) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return nodedb.NewFromV5(v5, nil) +} + +// v4NodeAtSeq builds a node the way the discv4 NEIGHBORS path does, where the +// record came from dialing the peer for its ENR rather than from a relay. +func v4NodeAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *nodedb.Node { + t.Helper() + v4 := v4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(10, 0, 0, last), Port: 9000}) + v4.SetENR(recordAtSeq(t, key, last, seq)) + return nodedb.NewFromV4(v4, nil) +} + +func quietLookupService(localIDs [][32]byte) *LookupService { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + return NewLookupService(Config{LocalIDs: localIDs, Logger: logger}) +} + +// TestSelectNodesToQuerySkipsSelf verifies our own identities are never chosen +// as query targets, even when a table-sourced candidate list contains them. +func TestSelectNodesToQuerySkipsSelf(t *testing.T) { + selfEL := testNode(t, 1) + selfCL := testNode(t, 2) + peer := testNode(t, 3) + + ls := quietLookupService([][32]byte{selfEL.ID(), selfCL.ID()}) + + candidates := []*nodedb.Node{selfEL, selfCL, peer} + got := ls.selectNodesToQuery(candidates, map[node.ID]bool{}, node.ID(peer.ID()), 3, false) + + if len(got) != 1 { + t.Fatalf("selected %d nodes, want only the non-self peer", len(got)) + } + if got[0].ID() != peer.ID() { + t.Fatalf("selected %x, want the peer %x", got[0].ID(), peer.ID()) + } +} + +// TestIsLocalCoversBothIdentities verifies the check spans every configured +// identity (dual EL/CL keys) and accepts discv4 IDs too. +func TestIsLocalCoversBothIdentities(t *testing.T) { + selfEL := testNode(t, 1) + selfCL := testNode(t, 2) + peer := testNode(t, 3) + + ls := quietLookupService([][32]byte{selfEL.ID(), selfCL.ID()}) + + if !ls.isLocal(selfEL.ID()) || !ls.isLocal(selfCL.ID()) { + t.Fatal("a configured local identity was not recognized as self") + } + if ls.isLocal(peer.ID()) { + t.Fatal("a remote peer was misidentified as self") + } + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + v4Self := v4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(10, 0, 0, 9), Port: 9000}) + ls4 := quietLookupService([][32]byte{v4Self.ID()}) + if !ls4.isLocal(v4Self.ID()) { + t.Fatal("a discv4 local id was not recognized as self") + } +} + +func testKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +// TestNoteDiscoveredRefreshesKnownNode covers the bug this whole path exists +// for: a peer we cannot bond with only ever learns a newer record when another +// discv5 peer relays it, so a known node with a higher sequence must reach +// admission without being treated as a new frontier candidate. +func TestNoteDiscoveredRefreshesKnownNode(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + ls := quietLookupService(nil) + + tests := []struct { + name string + candidate *nodedb.Node + admitted bool + }{ + {"newer v5 record refreshes", v5NodeAtSeq(t, key, 1, 6), true}, + {"newer v4 record is not a relay", v4NodeAtSeq(t, key, 1, 6), false}, + {"equal sequence is not newer", v5NodeAtSeq(t, key, 1, 4), false}, + {"older sequence is ignored", v5NodeAtSeq(t, key, 1, 3), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := newDiscoveries([]*nodedb.Node{known}) + + if frontier := ls.noteDiscovered(d, tc.candidate); frontier { + t.Fatal("a known node must never re-enter the frontier") + } + + got := d.admit() + if tc.admitted && len(got) != 1 { + t.Fatalf("admitted %d nodes, want the refreshed record", len(got)) + } + if !tc.admitted && len(got) != 0 { + t.Fatalf("admitted %d nodes, want none", len(got)) + } + }) + } +} + +// TestNoteDiscoveredEligibilityPrecedesDedupe pins the rule ordering: a v4 +// record must not slip past the known-node gate just because it outranks a v5 +// record already held for that node. +func TestNoteDiscoveredEligibilityPrecedesDedupe(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + relayed := v5NodeAtSeq(t, key, 1, 5) + dialed := v4NodeAtSeq(t, key, 1, 6) + + ls := quietLookupService(nil) + d := newDiscoveries([]*nodedb.Node{known}) + + ls.noteDiscovered(d, relayed) + ls.noteDiscovered(d, dialed) + + got := d.admit() + if len(got) != 1 { + t.Fatalf("admitted %d nodes, want 1", len(got)) + } + if !got[0].HasV5() || got[0].Record().Seq() != 5 { + t.Fatalf("admitted seq %d (v5=%t), want the relayed v5 record at seq 5", + got[0].Record().Seq(), got[0].HasV5()) + } +} + +// TestNoteDiscoveredKeepsBestOfDuplicates verifies a newer duplicate replaces +// the record without queueing the peer for a second query in the same round. +func TestNoteDiscoveredKeepsBestOfDuplicates(t *testing.T) { + key := testKey(t) + ls := quietLookupService(nil) + d := newDiscoveries(nil) + + if !ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 5)) { + t.Fatal("an unknown node should enter the frontier") + } + if ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 6)) { + t.Fatal("a duplicate must not enter the frontier twice") + } + + got := d.admit() + if len(got) != 1 { + t.Fatalf("admitted %d nodes, want 1", len(got)) + } + if got[0].Record().Seq() != 6 { + t.Fatalf("admitted seq %d, want the newer duplicate at seq 6", got[0].Record().Seq()) + } +} + +// TestAdmitSkipsRecordsOvertakenMidLookup covers a direct refresh landing while +// the lookup is still running: the relayed record is no longer newer by the +// time we admit, and re-offering it would run the fork filter against a record +// the table has already moved past. +func TestAdmitSkipsRecordsOvertakenMidLookup(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + + ls := quietLookupService(nil) + d := newDiscoveries([]*nodedb.Node{known}) + + ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 5)) + + known.UpdateENR(recordAtSeq(t, key, 1, 7)) + + if got := d.admit(); len(got) != 0 { + t.Fatalf("admitted %d nodes, want none once the table overtook them", len(got)) + } +} + +// TestNoteDiscoveredSkipsSelf verifies our own relayed record never reaches +// admission, at any sequence number. +func TestNoteDiscoveredSkipsSelf(t *testing.T) { + key := testKey(t) + self := v5NodeAtSeq(t, key, 1, 1) + + ls := quietLookupService([][32]byte{self.ID()}) + d := newDiscoveries(nil) + + if ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 9)) { + t.Fatal("our own record entered the frontier") + } + if got := d.admit(); len(got) != 0 { + t.Fatalf("admitted %d nodes, want none", len(got)) + } +} + +// TestPingServiceStatsRace exercises the counters from many goroutines while a +// reader polls GetStats, which is what the web UI handler does. +func TestPingServiceStatsRace(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + ps := NewPingService(nil, nil, logger) + + var writers, reader sync.WaitGroup + stop := make(chan struct{}) + + reader.Add(1) + go func() { + defer reader.Done() + for { + select { + case <-stop: + return + default: + _ = ps.GetStats() + } + } + }() + + for i := 0; i < 4; i++ { + writers.Add(1) + go func() { + defer writers.Done() + for j := 0; j < 200; j++ { + ps.countPingSent() + ps.countProtocol(j%2 == 0) + ps.countPong(time.Millisecond) + ps.countTimeout() + } + }() + } + + writers.Wait() + close(stop) + reader.Wait() + + stats := ps.GetStats() + if stats.PingsSent != 800 || stats.PongsReceived != 800 || stats.PingTimeouts != 800 { + t.Fatalf("counters lost updates: %+v", stats) + } + if stats.PingsV5+stats.PingsV4 != 800 { + t.Fatalf("protocol counters = %d+%d, want 800 total", stats.PingsV5, stats.PingsV4) + } +} diff --git a/services/ping.go b/services/ping.go index 1bd6736..087dbbb 100644 --- a/services/ping.go +++ b/services/ping.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "sync" "time" "github.com/ethpandaops/bootnodoor/discv4" @@ -22,6 +23,10 @@ type PingService struct { // logger for debug messages logger logrus.FieldLogger + // mu guards the counters: PingMultiple fans pings out across goroutines + // while GetStats is read from the web UI handler. + mu sync.Mutex + // Stats pingsSent int pongsReceived int @@ -49,7 +54,7 @@ func NewPingService(v5Handler *protocol.Handler, v4Service *discv4.Service, logg // Returns true if the node responded, false on timeout. // Also updates the node's RTT statistics. func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { - ps.pingsSent++ + ps.countPingSent() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -60,13 +65,13 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // Try discv5 first if available if v5Node := n.V5(); v5Node != nil && ps.v5Handler != nil { - ps.pingsV5++ + ps.countProtocol(true) respChan, err := ps.v5Handler.SendPing(v5Node) if err != nil { // Failed to send ping - only increment failure if no v4 fallback available if n.V4() == nil || ps.v4Service == nil { // No v4 fallback - this is a final failure - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -91,10 +96,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { if resp.Error == nil { // Success - ps.pongsReceived++ - ps.totalRTT += rtt - ps.rttSampleCount++ - ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.countPong(rtt) n.UpdateRTT(rtt) n.ResetFailureCount() @@ -111,7 +113,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // V5 ping failed - only increment failure if no v4 fallback available if n.V4() == nil || ps.v4Service == nil { // No v4 fallback - this is a final failure - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -134,12 +136,12 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // Try discv4 fallback if available if v4Node := n.V4(); v4Node != nil && ps.v4Service != nil { - ps.pingsV4++ + ps.countProtocol(false) pong, err := ps.v4Service.Ping(v4Node) rtt := time.Since(start) if err != nil { - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -152,10 +154,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { } // Success - ps.pongsReceived++ - ps.totalRTT += rtt - ps.rttSampleCount++ - ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.countPong(rtt) n.UpdateRTT(rtt) n.ResetFailureCount() @@ -171,7 +170,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { } // No protocol available - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -436,6 +435,37 @@ func (ps *PingService) CheckProtocolSupportMultiple(nodes []*nodedb.Node) { }).Info("protocol support check batch complete") } +func (ps *PingService) countPingSent() { + ps.mu.Lock() + ps.pingsSent++ + ps.mu.Unlock() +} + +func (ps *PingService) countProtocol(isV5 bool) { + ps.mu.Lock() + if isV5 { + ps.pingsV5++ + } else { + ps.pingsV4++ + } + ps.mu.Unlock() +} + +func (ps *PingService) countTimeout() { + ps.mu.Lock() + ps.pingTimeouts++ + ps.mu.Unlock() +} + +func (ps *PingService) countPong(rtt time.Duration) { + ps.mu.Lock() + ps.pongsReceived++ + ps.totalRTT += rtt + ps.rttSampleCount++ + ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.mu.Unlock() +} + // PingStats returns statistics about PING operations. type PingStats struct { PingsSent int @@ -449,6 +479,9 @@ type PingStats struct { // GetStats returns PING statistics. func (ps *PingService) GetStats() PingStats { + ps.mu.Lock() + defer ps.mu.Unlock() + successRate := 0.0 if ps.pingsSent > 0 { successRate = float64(ps.pongsReceived) / float64(ps.pingsSent) * 100 diff --git a/webui/handlers/overview.go b/webui/handlers/overview.go index 65f8d67..6564dea 100644 --- a/webui/handlers/overview.go +++ b/webui/handlers/overview.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/http" + "sort" "strconv" "strings" "time" @@ -71,7 +72,6 @@ type OverviewPageData struct { // Routing table stats (combined) TableSize int - BucketsFilled int // Deprecated for flat table ActiveNodes int InactiveNodes int @@ -111,12 +111,19 @@ type OverviewPageData struct { FilteredResponses int FindNodeReceived int - // Fork filter stats - FilterAcceptedCurrent int - FilterAcceptedOld int - FilterRejectedInvalid int - FilterRejectedExpired int - FilterTotalChecks int + // CL fork digest filter stats + FilterAcceptedCurrent int + FilterAcceptedOld int + FilterRejectedInvalid int + FilterAcceptedHistorical int + FilterTotalChecks int + + // EL fork ID admission stats. Independent of the CL counters above: a + // dual-layer bootnode runs both filters, so neither can stand in for the + // other. + ELFilterAccepted int + ELFilterRejected int + ELFilterTotalChecks int // Database stats DBQueueSize int @@ -133,7 +140,6 @@ type TableStats struct { ActiveNodes int InactiveNodes int TotalNodes int - BucketsFilled int } type OldDigestInfo struct { @@ -334,7 +340,6 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { ActiveNodes: elStats.ActiveNodes, InactiveNodes: elInactiveNodes, TotalNodes: elStats.TotalNodes, - BucketsFilled: elStats.BucketsFilled, } // Update combined stats pageData.ActiveNodes += elStats.ActiveNodes @@ -352,7 +357,6 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { ActiveNodes: clStats.ActiveNodes, InactiveNodes: clInactiveNodes, TotalNodes: clStats.TotalNodes, - BucketsFilled: clStats.BucketsFilled, } // Update combined stats pageData.ActiveNodes += clStats.ActiveNodes @@ -364,13 +368,7 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { if elConfig := fh.bootnodeService.ELConfig(); elConfig != nil { if enrMgr := fh.bootnodeService.ENRManager(); enrMgr != nil { if elFilter := enrMgr.GetELFilter(); elFilter != nil { - // Get genesis time from config - genesisTime := uint64(0) - if fh.bootnodeService.ELConfig() != nil { - // Note: We'd need the genesis time here, defaulting to 0 - } - - allForksWithNames := elFilter.GetAllForkIDsWithNames(genesisTime) + allForksWithNames := elFilter.GetAllForkIDsWithNames() pageData.ELForks = make([]ForkInfo, 0, len(allForksWithNames)) for _, fork := range allForksWithNames { // Format activation point @@ -484,9 +482,63 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { } } - // Note: Detailed stats (lookups, pings, sessions, etc.) are not available - // through the public API of the new bootnode service. These would need to be - // exposed through additional methods if required. + stats := fh.bootnodeService.GetStats() + + pageData.LookupsStarted = stats.Lookups.LookupsStarted + pageData.LookupsCompleted = stats.Lookups.LookupsCompleted + pageData.LookupsFailed = stats.Lookups.LookupsFailed + + pageData.PingsSent = stats.Ping.PingsSent + pageData.PongsReceived = stats.Ping.PongsReceived + pageData.PingSuccessRate = stats.Ping.SuccessRate + + pageData.SessionsTotal = stats.Sessions.Total + pageData.SessionsActive = stats.Sessions.Active + pageData.SessionsExpired = stats.Sessions.Expired + + pageData.PendingHandshakes = stats.Discv5.PendingHandshakes + pageData.PendingChallenges = stats.Discv5.PendingChallenges + + // Packet totals come from the transport so both protocols are counted; the + // discv5-specific views stay on the handler counters. + pageData.PacketsReceived = int(stats.Packets.PacketsReceived) + pageData.PacketsSent = int(stats.Packets.PacketsSent) + pageData.InvalidPackets = stats.Discv5.InvalidPackets + int(stats.Discv4.InvalidPackets) + pageData.FilteredResponses = stats.Discv5.FilteredResponses + pageData.FindNodeReceived = stats.Discv5.FindNodeReceived + int(stats.Discv4.FindnodeRequestsRecv) + + if enrMgr := fh.bootnodeService.ENRManager(); enrMgr != nil { + if clFilter := enrMgr.GetCLFilter(); clFilter != nil { + filterStats := clFilter.GetStats() + pageData.NetworkName = clFilter.GetNetworkName() + pageData.CurrentFork = clFilter.GetCurrentFork() + pageData.CurrentDigest = clFilter.GetCurrentDigest() + pageData.PreviousFork = clFilter.GetPreviousForkName() + pageData.PreviousDigest = clFilter.GetPreviousForkDigest() + for digest, remaining := range clFilter.GetOldForkDigests() { + pageData.OldDigests = append(pageData.OldDigests, OldDigestInfo{ + Digest: digest.String(), + Remaining: remaining, + }) + } + sort.Slice(pageData.OldDigests, func(i, j int) bool { + return pageData.OldDigests[i].Remaining > pageData.OldDigests[j].Remaining + }) + pageData.GenesisDigest = clFilter.GetGenesisForkDigest() + pageData.GracePeriod = clFilter.GetGracePeriod() + pageData.FilterAcceptedCurrent = filterStats.AcceptedCurrent + pageData.FilterAcceptedOld = filterStats.AcceptedOld + pageData.FilterAcceptedHistorical = filterStats.AcceptedHistorical + pageData.FilterRejectedInvalid = filterStats.RejectedInvalid + pageData.FilterTotalChecks = filterStats.TotalChecks + } + if elFilter := enrMgr.GetELFilter(); elFilter != nil { + elStats := elFilter.GetStats() + pageData.ELFilterAccepted = int(elStats.Accepted) + pageData.ELFilterRejected = int(elStats.Rejected) + pageData.ELFilterTotalChecks = int(elStats.TotalChecks) + } + } return pageData, nil } diff --git a/webui/handlers/overview_test.go b/webui/handlers/overview_test.go new file mode 100644 index 0000000..39f0996 --- /dev/null +++ b/webui/handlers/overview_test.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + + ethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/bootnodoor/bootnode" + "github.com/ethpandaops/bootnodoor/bootnode/elconfig" + "github.com/ethpandaops/bootnodoor/db" +) + +func testService(t *testing.T, bindPort uint16) *bootnode.Service { + t.Helper() + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + key, err := ethcrypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + chainCfg, err := elconfig.ParseChainConfig([]byte(`{"chainId":1,"shanghaiTime":1500}`)) + if err != nil { + t.Fatal(err) + } + + cfg := bootnode.DefaultConfig() + cfg.PrivateKey = key + cfg.Database = database + cfg.Logger = logger + cfg.BindIP = net.IPv4(127, 0, 0, 1) + cfg.BindPort = bindPort + cfg.ENRIP = net.IPv4(127, 0, 0, 1) + cfg.ENRIPProvided = true + cfg.ELConfig = chainCfg + cfg.ELGenesisHash = [32]byte{9, 9, 9} + cfg.ELGenesisTime = 1000 + cfg.EnableDiscv4 = false + + svc, err := bootnode.New(cfg) + if err != nil { + t.Fatalf("bootnode.New: %v", err) + } + t.Cleanup(func() { _ = svc.Stop() }) + return svc +} + +// TestOverviewReportsLiveStats verifies the overview no longer renders +// hardcoded zeros: every formerly-dead field is backed by a real counter, so +// the panel reflects the running service. +func TestOverviewReportsLiveStats(t *testing.T) { + svc := testService(t, 42424) + fh := NewFrontendHandler(svc) + + rr := httptest.NewRecorder() + fh.Overview(rr, httptest.NewRequest(http.MethodGet, "/?ajax=1", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var got OverviewPageData + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + if got.Status == "" || got.PeerID == "" { + t.Fatalf("basic fields empty: %+v", got) + } + // Sessions are counted from the live cache, so the field is present and + // non-negative even on a service that has not peered yet. + if got.SessionsTotal < 0 || got.SessionsActive < 0 { + t.Fatalf("session stats = %d/%d", got.SessionsTotal, got.SessionsActive) + } + + stats := svc.GetStats() + if got.LookupsStarted != stats.Lookups.LookupsStarted { + t.Errorf("LookupsStarted = %d, want the aggregator's %d", got.LookupsStarted, stats.Lookups.LookupsStarted) + } + if got.PingsSent != stats.Ping.PingsSent { + t.Errorf("PingsSent = %d, want %d", got.PingsSent, stats.Ping.PingsSent) + } + if got.PacketsReceived != int(stats.Packets.PacketsReceived) { + t.Errorf("PacketsReceived = %d, want %d", got.PacketsReceived, stats.Packets.PacketsReceived) + } +} + +// TestGetStatsAggregatesAcrossLayers verifies the aggregator reads every +// source it claims to: lookups, pings, sessions and transport packets. +func TestGetStatsAggregatesAcrossLayers(t *testing.T) { + svc := testService(t, 42425) + stats := svc.GetStats() + + if stats.Lookups.LookupsStarted < 0 || stats.Ping.PingsSent < 0 { + t.Fatalf("counters are negative: %+v", stats) + } + if stats.HasV4 { + t.Fatal("discv4 was disabled for this service but reported as present") + } +} diff --git a/webui/templates/overview/overview.html b/webui/templates/overview/overview.html index ab7317e..7df7972 100644 --- a/webui/templates/overview/overview.html +++ b/webui/templates/overview/overview.html @@ -626,8 +626,35 @@
Fork Filter
{{ .FilterRejectedInvalid }} - Rejected (Expired) - {{ .FilterRejectedExpired }} + Accepted (Historical) + {{ .FilterAcceptedHistorical }} + + + + + + + {{ end }} + + + {{ if .ELFilterTotalChecks }} +
+
+
+
EL Fork ID Admission
+ + + + + + + + + + + + +
Total Checks{{ .ELFilterTotalChecks }}
Accepted{{ .ELFilterAccepted }}
Rejected (Fork){{ .ELFilterRejected }}
@@ -877,7 +904,14 @@
Old Fork Digests (Grace Period)
updateValue('[data-stat="filter-accepted-current"]', data.FilterAcceptedCurrent); updateValue('[data-stat="filter-accepted-old"]', data.FilterAcceptedOld); updateValue('[data-stat="filter-rejected-invalid"]', data.FilterRejectedInvalid); - updateValue('[data-stat="filter-rejected-expired"]', data.FilterRejectedExpired); + updateValue('[data-stat="filter-accepted-historical"]', data.FilterAcceptedHistorical); + } + + /* EL fork ID admission (independent of the CL filter above) */ + if (data.ELFilterTotalChecks) { + updateValue('[data-stat="el-filter-total-checks"]', data.ELFilterTotalChecks); + updateValue('[data-stat="el-filter-accepted"]', data.ELFilterAccepted); + updateValue('[data-stat="el-filter-rejected"]', data.ELFilterRejected); } }) .catch(function(error) {