p2p/discover: resolve DNS hostnames for bootstrap nodes

Since PR #30822 moved DNS resolution from parse-time to dial-time,
bootstrap nodes specified with DNS hostnames (e.g.
enode://<key>@hostname:30303) fail with "missing IP address" because
setFallbackNodes calls ValidateComplete which requires a valid IP.

This is a regression from v1.14.x that blocks multiple L2 networks
(Omni, Optimism, Unichain) from upgrading to v1.15+.

Fix by resolving DNS hostnames in setFallbackNodes before validation,
mirroring the dnsResolveHostname pattern used for static nodes in the
dial scheduler. The resolved node retains its hostname for potential
future periodic re-resolution.

Fixes #31208
This commit is contained in:
Charles Dusek 2026-03-27 00:08:03 -05:00
parent acdd139717
commit 0962686a6e
2 changed files with 105 additions and 0 deletions

View file

@ -25,6 +25,7 @@ package discover
import (
"context"
"fmt"
"net"
"net/netip"
"slices"
"sync"
@ -36,6 +37,7 @@ import (
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/metrics"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/ethereum/go-ethereum/p2p/netutil"
)
@ -205,6 +207,13 @@ func (tab *Table) close() {
func (tab *Table) setFallbackNodes(nodes []*enode.Node) error {
nursery := make([]*enode.Node, 0, len(nodes))
for _, n := range nodes {
if n.Hostname() != "" && !n.IPAddr().IsValid() {
resolved, err := resolveBootnodeHostname(n, tab.log)
if err != nil {
return fmt.Errorf("bad bootstrap node %q: %v", n, err)
}
n = resolved
}
if err := n.ValidateComplete(); err != nil {
return fmt.Errorf("bad bootstrap node %q: %v", n, err)
}
@ -218,6 +227,42 @@ func (tab *Table) setFallbackNodes(nodes []*enode.Node) error {
return nil
}
// resolveBootnodeHostname resolves the DNS hostname of a bootstrap node to an IP address.
func resolveBootnodeHostname(n *enode.Node, logger log.Logger) (*enode.Node, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", n.Hostname())
if err != nil {
return nil, fmt.Errorf("DNS lookup failed for %q: %v", n.Hostname(), err)
}
var ip4, ip6 netip.Addr
for _, ip := range ips {
if ip.Is4() && !ip4.IsValid() {
ip4 = ip
}
if ip.Is6() && !ip6.IsValid() {
ip6 = ip
}
}
if !ip4.IsValid() && !ip6.IsValid() {
return nil, fmt.Errorf("no IP addresses found for hostname %q", n.Hostname())
}
rec := n.Record()
if ip4.IsValid() {
rec.Set(enr.IPv4Addr(ip4))
}
if ip6.IsValid() {
rec.Set(enr.IPv6Addr(ip6))
}
rec.SetSeq(n.Seq())
resolved := enode.SignNull(rec, n.ID()).WithHostname(n.Hostname())
logger.Debug("Resolved bootstrap node hostname", "name", n.Hostname(), "ip", resolved.IP())
return resolved, nil
}
// isInitDone returns whether the table's initial seeding procedure has completed.
func (tab *Table) isInitDone() bool {
select {

View file

@ -490,6 +490,66 @@ func quickcfg() *quick.Config {
}
}
func TestSetFallbackNodes_DNSHostname(t *testing.T) {
// Create a node with a DNS hostname but no IP, simulating an enode URL
// like enode://<key>@localhost:30303.
key := newkey()
node := enode.NewV4(&key.PublicKey, nil, 30303, 30303).WithHostname("localhost")
// Verify the node has a hostname but no valid IP.
if node.Hostname() != "localhost" {
t.Fatal("expected hostname to be set")
}
if node.IPAddr().IsValid() {
t.Fatal("expected no IP address")
}
// Create a table and set the hostname node as a bootnode.
// This should resolve the hostname to an IP address.
db, _ := enode.OpenDB(t.TempDir() + "/node.db")
defer db.Close()
cfg := Config{Log: testlog.Logger(t, log.LvlTrace)}
cfg = cfg.withDefaults()
tab := &Table{
cfg: cfg,
log: cfg.Log,
refreshReq: make(chan chan struct{}),
revalResponseCh: make(chan revalidationResponse),
addNodeCh: make(chan addNodeOp),
addNodeHandled: make(chan bool),
trackRequestCh: make(chan trackRequestOp),
initDone: make(chan struct{}),
closeReq: make(chan struct{}),
closed: make(chan struct{}),
ips: netutil.DistinctNetSet{Subnet: tableSubnet, Limit: tableIPLimit},
}
for i := range tab.buckets {
tab.buckets[i] = &bucket{
index: i,
ips: netutil.DistinctNetSet{Subnet: bucketSubnet, Limit: bucketIPLimit},
}
}
err := tab.setFallbackNodes([]*enode.Node{node})
if err != nil {
t.Fatalf("setFallbackNodes failed: %v", err)
}
if len(tab.nursery) != 1 {
t.Fatalf("expected 1 nursery node, got %d", len(tab.nursery))
}
// The resolved node should have a valid IP and retain the hostname.
resolved := tab.nursery[0]
if !resolved.IPAddr().IsValid() {
t.Fatal("expected resolved node to have a valid IP")
}
if resolved.Hostname() != "localhost" {
t.Errorf("expected hostname to be preserved, got %q", resolved.Hostname())
}
t.Logf("resolved localhost to %v", resolved.IPAddr())
}
func newkey() *ecdsa.PrivateKey {
key, err := crypto.GenerateKey()
if err != nil {