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

Fixes #11136
This commit is contained in:
Marcin Rataj 2026-01-12 17:33:41 +01:00
parent 447109df64
commit 05931fe67e
5 changed files with 286 additions and 2 deletions

View File

@ -19,5 +19,27 @@ func DNSResolver(cfg *config.Config) (*madns.Resolver, error) {
// Replace "auto" DNS resolver placeholders with autoconf values
resolvers := cfg.DNSResolversWithAutoConf()
return gateway.NewDNSResolver(resolvers, dohOpts...)
// Get base resolver from boxo (handles custom DoH resolvers per eTLD)
baseResolver, err := gateway.NewDNSResolver(resolvers, dohOpts...)
if err != nil {
return nil, err
}
// Build list of p2p-forge domains to resolve locally without network I/O.
// AutoTLS hostnames encode IP addresses directly (e.g., 1-2-3-4.peerID.libp2p.direct),
// so DNS lookups are wasteful. We always resolve these in-memory.
forgeDomains := []string{config.DefaultDomainSuffix}
customDomain := cfg.AutoTLS.DomainSuffix.WithDefault(config.DefaultDomainSuffix)
if customDomain != config.DefaultDomainSuffix {
forgeDomains = append(forgeDomains, customDomain)
}
forgeResolver := NewP2PForgeResolver(forgeDomains, baseResolver)
// Register p2p-forge resolver for each domain, fallback to baseResolver for others
opts := []madns.Option{madns.WithDefaultResolver(baseResolver)}
for _, domain := range forgeDomains {
opts = append(opts, madns.WithDomainResolver(domain+".", forgeResolver))
}
return madns.NewResolver(opts...)
}

View File

@ -0,0 +1,109 @@
package node
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
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
//
// TXT queries are delegated 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 basicResolver
}
// Compile-time check that p2pForgeResolver implements madns.BasicResolver.
var _ madns.BasicResolver = (*p2pForgeResolver)(nil)
// basicResolver is a subset of madns.BasicResolver for TXT lookups.
type basicResolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
}
// NewP2PForgeResolver creates a resolver for the given p2p-forge domain suffixes.
// Each suffix should be a bare domain like "libp2p.direct" (without leading dot).
// TXT queries are delegated to the fallback resolver for ACME compatibility.
func NewP2PForgeResolver(suffixes []string, fallback 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]
// - PeerID only: peerID.libp2p.direct -> [] (empty, no IP component)
func (r *p2pForgeResolver) LookupIPAddr(ctx context.Context, hostname string) ([]net.IPAddr, error) {
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 == "" {
return nil, fmt.Errorf("hostname %q does not match any p2p-forge suffix", hostname)
}
// split subdomain into parts: should be [ip-prefix, peerID] or just [peerID]
parts := strings.Split(subdomain, ".")
if len(parts) < 2 {
// peerID only, no IP component - return empty (NODATA equivalent)
return nil, nil
}
if len(parts) > 2 {
return nil, fmt.Errorf("invalid p2p-forge hostname format: %q", hostname)
}
ipPrefix := parts[0]
// RFC 1123: hostname labels cannot start or end with hyphen
if len(ipPrefix) == 0 || ipPrefix[0] == '-' || ipPrefix[len(ipPrefix)-1] == '-' {
return nil, fmt.Errorf("invalid IP encoding (RFC 1123 violation): %q", ipPrefix)
}
// try parsing as IPv4 first: segments joined by "-" become "."
segments := strings.Split(ipPrefix, "-")
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
}
return nil, fmt.Errorf("invalid IP encoding in hostname: %q", ipPrefix)
}
// 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)
}

View File

@ -0,0 +1,147 @@
package node
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockTXTResolver is a mock fallback resolver for testing
type mockTXTResolver struct {
records map[string][]string
}
func (m *mockTXTResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
if m.records != nil {
return m.records[name], nil
}
return nil, nil
}
func TestP2PForgeResolver_LookupIPAddr_IPv4(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{})
tests := []struct {
name string
hostname string
wantIP string
}{
{"basic", "192-168-1-1.12D3KooWPeerID.libp2p.direct", "192.168.1.1"},
{"zeros", "0-0-0-0.peerID.libp2p.direct", "0.0.0.0"},
{"max", "255-255-255-255.peerID.libp2p.direct", "255.255.255.255"},
{"with trailing dot", "10-0-0-1.peerID.libp2p.direct.", "10.0.0.1"},
{"uppercase", "192-168-1-1.PeerID.LIBP2P.DIRECT", "192.168.1.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addrs, err := r.LookupIPAddr(t.Context(), tt.hostname)
require.NoError(t, err)
require.Len(t, addrs, 1)
assert.Equal(t, tt.wantIP, addrs[0].IP.String())
})
}
}
func TestP2PForgeResolver_LookupIPAddr_IPv6(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{})
tests := []struct {
name string
hostname string
wantIP string
}{
{"full", "2001-db8-0-0-0-0-0-1.peerID.libp2p.direct", "2001:db8::1"},
{"compressed", "2001-db8--1.peerID.libp2p.direct", "2001:db8::1"},
{"loopback", "0--1.peerID.libp2p.direct", "::1"},
{"all zeros", "0--0.peerID.libp2p.direct", "::"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addrs, err := r.LookupIPAddr(t.Context(), tt.hostname)
require.NoError(t, err)
require.Len(t, addrs, 1)
assert.Equal(t, tt.wantIP, addrs[0].IP.String())
})
}
}
func TestP2PForgeResolver_LookupIPAddr_MultipleSuffixes(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct", "custom.example.com"}, &mockTXTResolver{})
tests := []struct {
hostname string
wantIP string
}{
{"192-168-1-1.peerID.libp2p.direct", "192.168.1.1"},
{"10-0-0-1.peerID.custom.example.com", "10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.hostname, func(t *testing.T) {
addrs, err := r.LookupIPAddr(t.Context(), tt.hostname)
require.NoError(t, err)
require.Len(t, addrs, 1)
assert.Equal(t, tt.wantIP, addrs[0].IP.String())
})
}
}
func TestP2PForgeResolver_LookupIPAddr_PeerIDOnly(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{})
// peerID-only should return empty (NODATA equivalent)
addrs, err := r.LookupIPAddr(t.Context(), "12D3KooWPeerID.libp2p.direct")
require.NoError(t, err)
assert.Empty(t, addrs)
}
func TestP2PForgeResolver_LookupIPAddr_Errors(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{})
tests := []struct {
name string
hostname string
wantErr string
}{
{"wrong suffix", "192-168-1-1.peerID.example.com", "does not match any p2p-forge suffix"},
{"invalid IP", "not-an-ip.peerID.libp2p.direct", "invalid IP encoding"},
{"leading hyphen", "-192-168-1-1.peerID.libp2p.direct", "RFC 1123 violation"},
{"trailing hyphen", "192-168-1-1-.peerID.libp2p.direct", "RFC 1123 violation"},
{"too many parts", "extra.192-168-1-1.peerID.libp2p.direct", "invalid p2p-forge hostname format"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := r.LookupIPAddr(t.Context(), tt.hostname)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestP2PForgeResolver_LookupTXT_Delegates(t *testing.T) {
// TXT lookups should delegate to fallback resolver (for ACME DNS-01 challenges)
fallback := &mockTXTResolver{
records: map[string][]string{
"_acme-challenge.peerID.libp2p.direct": {"acme-token-value"},
},
}
r := NewP2PForgeResolver([]string{"libp2p.direct"}, fallback)
records, err := r.LookupTXT(t.Context(), "_acme-challenge.peerID.libp2p.direct")
require.NoError(t, err)
assert.Equal(t, []string{"acme-token-value"}, records)
}
func TestP2PForgeResolver_LookupTXT_EmptyFallback(t *testing.T) {
r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{})
// When fallback returns nothing, we return nothing
records, err := r.LookupTXT(t.Context(), "anything.libp2p.direct")
require.NoError(t, err)
assert.Empty(t, records)
}

View File

@ -16,6 +16,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
- [Improved `ipfs dag stat` output](#improved-ipfs-dag-stat-output)
- [Skip bad keys when listing](#skip_bad_keys_when_listing)
- [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together)
- [No unnecessary DNS lookups for AutoTLS multiaddrs](#no-unnecessary-dns-lookups-for-autotls-multiaddrs)
- [📦️ Dependency updates](#-dependency-updates)
- [📝 Changelog](#-changelog)
- [👨‍👩‍👧‍👦 Contributors](#-contributors)
@ -87,6 +88,10 @@ Change the `ipfs key list` behavior to log an error and continue listing keys wh
Previously, provide operations could start before the Accelerated DHT Client discovered enough peers, causing sweep mode to lose its efficiency benefits. Now, providing waits for the initial network crawl (about 10 minutes). Your content will be properly distributed across DHT regions after initial DHT map is created. Check `ipfs provide stat` to see when providing begins.
#### No unnecessary DNS lookups for AutoTLS multiaddrs
Kubo no longer makes DNS queries for peer addresses using [AutoTLS](https://blog.libp2p.io/autotls/) domains like `*.libp2p.direct`. These hostnames encode the IP address directly (e.g., `1-2-3-4` means `1.2.3.4`), so DNS lookups were wasteful. Kubo now parses the IP locally.
#### 📦️ Dependency updates
- update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0)

View File

@ -3459,7 +3459,7 @@ Please remove this option from your config.
## `DNS`
Options for configuring DNS resolution for [DNSLink](https://docs.ipfs.tech/concepts/dnslink/) and `/dns*` [Multiaddrs][libp2p-multiaddrs].
Options for configuring DNS resolution for [DNSLink](https://docs.ipfs.tech/concepts/dnslink/) and `/dns*` [Multiaddrs][libp2p-multiaddrs] (including peer addresses discovered via DHT or delegated routing).
### `DNS.Resolvers`
@ -3489,6 +3489,7 @@ Be mindful that:
- The default catch-all resolver is the cleartext one provided by your operating system. It can be overridden by adding a DoH entry for the DNS root indicated by `.` as illustrated above.
- Out-of-the-box support for selected non-ICANN TLDs relies on third-party centralized services provided by respective communities on best-effort basis.
- The special value `"auto"` uses DNS resolvers from [AutoConf](#autoconf) when enabled. For example: `{".": "auto"}` uses any custom DoH resolver (global or per TLD) provided by AutoConf system.
- Domains matching [`AutoTLS.DomainSuffix`](#autotlsdomainsuffix) (default: `libp2p.direct`) are always resolved locally without network I/O by parsing the IP directly from the hostname. Since the IP is already encoded in these hostnames, DNS lookups would be wasteful.
Default: `{".": "auto"}`