kubo/core/node/p2pforge_resolver.go
Marcin Rataj 7de7af0820
feat(dns): skip DNS lookups for AutoTLS hostnames (#11140)
* feat(dns): resolve libp2p.direct addresses locally without network I/O

p2p-forge hostnames encode IP addresses directly (e.g., 1-2-3-4.peerID.libp2p.direct -> 1.2.3.4),
so DNS queries are wasteful. kubo now parses these IPs in-memory.

- applies to both default libp2p.direct and custom AutoTLS.DomainSuffix
- TXT queries still delegate to network for ACME DNS-01 compatibility

- https://github.com/ipfs/kubo/pull/11140#discussion_r2683477754
  use fallback to network DNS instead of returning errors when local
  parsing fails, ensuring forward compatibility with future DNS records

- https://github.com/ipfs/kubo/pull/11140#discussion_r2683512408
  add peerID validation using peer.Decode(), matching libp2p.direct
  server behavior, with fallback on invalid peerID

- https://github.com/ipfs/kubo/pull/11140#discussion_r2683521930
  document interaction with DNS.Resolvers in config.md

- https://github.com/ipfs/kubo/pull/11140#discussion_r2683526647
  add AutoTLS.SkipDNSLookup config flag to disable local resolution
  (useful for debugging or custom DNS override scenarios)

- https://github.com/ipfs/kubo/pull/11140#discussion_r2683533462
  add E2E test verifying libp2p.direct resolves locally even when
  DNS.Resolvers points to a broken server

additional improvements:
- use madns.BasicResolver interface instead of custom basicResolver
- add compile-time interface checks for p2pForgeResolver and madns.Resolver
- refactor tests: merge IPv4/IPv6, add helpers, use config.DefaultDomainSuffix
- improve changelog to explain public good benefit (reducing DNS load)

Fixes #11136
2026-01-30 17:20:56 +01:00

121 lines
4.4 KiB
Go

package node
import (
"context"
"net"
"net/netip"
"strings"
"github.com/libp2p/go-libp2p/core/peer"
madns "github.com/multiformats/go-multiaddr-dns"
)
// p2pForgeResolver implements madns.BasicResolver for deterministic resolution
// of p2p-forge domains (e.g., *.libp2p.direct) without network I/O for A/AAAA queries.
//
// p2p-forge encodes IP addresses in DNS hostnames:
// - IPv4: 1-2-3-4.peerID.libp2p.direct -> 1.2.3.4
// - IPv6: 2001-db8--1.peerID.libp2p.direct -> 2001:db8::1
//
// When local parsing fails (invalid format, invalid peerID, etc.), the resolver
// falls back to network DNS. This ensures future <peerID>.libp2p.direct records
// can still resolve if the authoritative DNS adds support for them.
//
// TXT queries always delegate to the fallback resolver. This is important for
// p2p-forge/client ACME DNS-01 challenges to work correctly, as Let's Encrypt
// needs to verify TXT records at _acme-challenge.peerID.libp2p.direct.
//
// See: https://github.com/ipshipyard/p2p-forge
type p2pForgeResolver struct {
suffixes []string
fallback madns.BasicResolver
}
// Compile-time check that p2pForgeResolver implements madns.BasicResolver.
var _ madns.BasicResolver = (*p2pForgeResolver)(nil)
// NewP2PForgeResolver creates a resolver for the given p2p-forge domain suffixes.
// Each suffix should be a bare domain like "libp2p.direct" (without leading dot).
// When local IP parsing fails, queries fall back to the provided resolver.
// TXT queries always delegate to the fallback resolver for ACME compatibility.
func NewP2PForgeResolver(suffixes []string, fallback madns.BasicResolver) *p2pForgeResolver {
normalized := make([]string, len(suffixes))
for i, s := range suffixes {
normalized[i] = strings.ToLower(strings.TrimSuffix(s, "."))
}
return &p2pForgeResolver{suffixes: normalized, fallback: fallback}
}
// LookupIPAddr parses IP addresses encoded in the hostname.
//
// Format: <encoded-ip>.<peerID>.<suffix>
// - IPv4: 192-168-1-1.peerID.libp2p.direct -> [192.168.1.1]
// - IPv6: 2001-db8--1.peerID.libp2p.direct -> [2001:db8::1]
//
// If the hostname doesn't match the expected format (wrong suffix, invalid peerID,
// invalid IP encoding, or peerID-only), the lookup falls back to network DNS.
// This allows future DNS records like <peerID>.libp2p.direct to resolve normally.
func (r *p2pForgeResolver) LookupIPAddr(ctx context.Context, hostname string) ([]net.IPAddr, error) {
// DNS is case-insensitive, normalize to lowercase
hostname = strings.ToLower(strings.TrimSuffix(hostname, "."))
// find matching suffix and extract subdomain
var subdomain string
for _, suffix := range r.suffixes {
if sub, found := strings.CutSuffix(hostname, "."+suffix); found {
subdomain = sub
break
}
}
if subdomain == "" {
// not a p2p-forge domain, fallback to network
return r.fallback.LookupIPAddr(ctx, hostname)
}
// split subdomain into parts: should be [ip-prefix, peerID]
parts := strings.Split(subdomain, ".")
if len(parts) != 2 {
// not the expected <ip>.<peerID> format, fallback to network
return r.fallback.LookupIPAddr(ctx, hostname)
}
encodedIP := parts[0]
peerIDStr := parts[1]
// validate peerID (same validation as libp2p.direct DNS server)
if _, err := peer.Decode(peerIDStr); err != nil {
// invalid peerID, fallback to network
return r.fallback.LookupIPAddr(ctx, hostname)
}
// RFC 1123: hostname labels cannot start or end with hyphen
if len(encodedIP) == 0 || encodedIP[0] == '-' || encodedIP[len(encodedIP)-1] == '-' {
// invalid hostname label, fallback to network
return r.fallback.LookupIPAddr(ctx, hostname)
}
// try parsing as IPv4 first: segments joined by "-" become "."
segments := strings.Split(encodedIP, "-")
if len(segments) == 4 {
ipv4Str := strings.Join(segments, ".")
if ip, err := netip.ParseAddr(ipv4Str); err == nil && ip.Is4() {
return []net.IPAddr{{IP: ip.AsSlice()}}, nil
}
}
// try parsing as IPv6: segments joined by "-" become ":"
ipv6Str := strings.Join(segments, ":")
if ip, err := netip.ParseAddr(ipv6Str); err == nil && ip.Is6() {
return []net.IPAddr{{IP: ip.AsSlice()}}, nil
}
// IP parsing failed, fallback to network
return r.fallback.LookupIPAddr(ctx, hostname)
}
// LookupTXT delegates to the fallback resolver to support ACME DNS-01 challenges
// and any other TXT record lookups on p2p-forge domains.
func (r *p2pForgeResolver) LookupTXT(ctx context.Context, hostname string) ([]string, error) {
return r.fallback.LookupTXT(ctx, hostname)
}