From 05931fe67efdf38a25d06c545ca8b8fbdff61a44 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 12 Jan 2026 17:33:41 +0100 Subject: [PATCH 1/3] 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 --- core/node/dns.go | 24 ++++- core/node/p2pforge_resolver.go | 109 +++++++++++++++++++++ core/node/p2pforge_resolver_test.go | 147 ++++++++++++++++++++++++++++ docs/changelogs/v0.40.md | 5 + docs/config.md | 3 +- 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 core/node/p2pforge_resolver.go create mode 100644 core/node/p2pforge_resolver_test.go diff --git a/core/node/dns.go b/core/node/dns.go index 3f0875afb..b6d8d083e 100644 --- a/core/node/dns.go +++ b/core/node/dns.go @@ -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...) } diff --git a/core/node/p2pforge_resolver.go b/core/node/p2pforge_resolver.go new file mode 100644 index 000000000..f19561372 --- /dev/null +++ b/core/node/p2pforge_resolver.go @@ -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: .. +// - 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) +} diff --git a/core/node/p2pforge_resolver_test.go b/core/node/p2pforge_resolver_test.go new file mode 100644 index 000000000..a8c68bd48 --- /dev/null +++ b/core/node/p2pforge_resolver_test.go @@ -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) +} diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index 8b92baf34..db105c42d 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -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) diff --git a/docs/config.md b/docs/config.md index 986798296..3204341ab 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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"}` From 52ddea703a0f2c058188615adf4118cf0cc809aa Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 12 Jan 2026 18:10:42 +0100 Subject: [PATCH 2/3] test(cli): verify DNS.Resolvers applies to multiaddr resolution regression test for https://github.com/ipfs/kubo/issues/9199 confirms that DNS.Resolvers config is used when resolving /dnsaddr multiaddrs during swarm connect, not just for DNSLink resolution --- test/cli/dns_resolvers_multiaddr_test.go | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/cli/dns_resolvers_multiaddr_test.go diff --git a/test/cli/dns_resolvers_multiaddr_test.go b/test/cli/dns_resolvers_multiaddr_test.go new file mode 100644 index 000000000..eec51c70d --- /dev/null +++ b/test/cli/dns_resolvers_multiaddr_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/ipfs/kubo/test/cli/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDNSResolversApplyToMultiaddr is a regression test for: +// https://github.com/ipfs/kubo/issues/9199 +// +// It verifies that DNS.Resolvers config is used when resolving /dnsaddr, +// /dns, /dns4, /dns6 multiaddrs during peer connections, not just for +// DNSLink resolution. +func TestDNSResolversApplyToMultiaddr(t *testing.T) { + t.Parallel() + + t.Run("invalid DoH resolver causes multiaddr resolution to fail", func(t *testing.T) { + t.Parallel() + + node := harness.NewT(t).NewNode().Init("--profile=test") + + // Set an invalid DoH resolver that will fail when used. + // If DNS.Resolvers is properly wired to multiaddr resolution, + // swarm connect to a /dnsaddr will fail with an error mentioning + // the invalid resolver URL. + invalidResolver := "https://invalid.broken.resolver.test/dns-query" + node.SetIPFSConfig("DNS.Resolvers", map[string]string{ + ".": invalidResolver, + }) + + // Clear bootstrap peers to prevent background connection attempts + node.SetIPFSConfig("Bootstrap", []string{}) + + node.StartDaemon() + defer node.StopDaemon() + + // Give daemon time to fully start + time.Sleep(2 * time.Second) + + // Verify daemon is responsive + result := node.RunIPFS("id") + require.Equal(t, 0, result.ExitCode(), "daemon should be responsive") + + // Try to connect to a /dnsaddr peer - this should fail because + // the DNS.Resolvers config points to an invalid DoH server + result = node.RunIPFS("swarm", "connect", "/dnsaddr/bootstrap.libp2p.io") + + // The connection should fail + require.NotEqual(t, 0, result.ExitCode(), + "swarm connect should fail when DNS.Resolvers points to invalid DoH server") + + // The error should mention the invalid resolver, proving DNS.Resolvers + // is being used for multiaddr resolution + stderr := result.Stderr.String() + assert.True(t, + strings.Contains(stderr, "invalid.broken.resolver.test") || + strings.Contains(stderr, "no such host") || + strings.Contains(stderr, "lookup") || + strings.Contains(stderr, "dial"), + "error should indicate DNS resolution failure using custom resolver. got: %s", stderr) + }) +} From d44a5770efffb030c52add219154e332226213df Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 19 Jan 2026 22:44:41 +0100 Subject: [PATCH 3/3] refactor(dns): address PR review feedback for p2p-forge resolver Changes based on PR review comments: - 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) --- config/autotls.go | 8 + core/node/dns.go | 13 +- core/node/p2pforge_resolver.go | 59 ++++--- core/node/p2pforge_resolver_test.go | 189 +++++++++++++---------- docs/changelogs/v0.40.md | 8 +- docs/config.md | 18 ++- test/cli/dns_resolvers_multiaddr_test.go | 76 +++++++++ 7 files changed, 260 insertions(+), 111 deletions(-) diff --git a/config/autotls.go b/config/autotls.go index 805a9ded6..4d90b7171 100644 --- a/config/autotls.go +++ b/config/autotls.go @@ -16,6 +16,13 @@ type AutoTLS struct { // Optional, controls if Kubo should add /tls/sni/.../ws listener to every /tcp port if no explicit /ws is defined in Addresses.Swarm AutoWSS Flag `json:",omitempty"` + // Optional, controls whether to skip network DNS lookups for p2p-forge domains. + // Applies to resolution via DNS.Resolvers, including /dns* multiaddrs in go-libp2p. + // When enabled (default), A/AAAA queries for *.libp2p.direct are resolved + // locally by parsing the IP directly from the hostname, avoiding network I/O. + // Set to false to always use network DNS (useful for debugging). + SkipDNSLookup Flag `json:",omitempty"` + // Optional override of the parent domain that will be used DomainSuffix *OptionalString `json:",omitempty"` @@ -42,5 +49,6 @@ const ( DefaultCAEndpoint = p2pforge.DefaultCAEndpoint DefaultAutoWSS = true // requires AutoTLS.Enabled DefaultAutoTLSShortAddrs = true // requires AutoTLS.Enabled + DefaultAutoTLSSkipDNSLookup = true // skip network DNS for p2p-forge domains DefaultAutoTLSRegistrationDelay = 1 * time.Hour ) diff --git a/core/node/dns.go b/core/node/dns.go index b6d8d083e..ba4e00784 100644 --- a/core/node/dns.go +++ b/core/node/dns.go @@ -10,6 +10,10 @@ import ( madns "github.com/multiformats/go-multiaddr-dns" ) +// Compile-time interface check: *madns.Resolver (returned by gateway.NewDNSResolver +// and madns.NewResolver) must implement madns.BasicResolver for p2pForgeResolver fallback. +var _ madns.BasicResolver = (*madns.Resolver)(nil) + func DNSResolver(cfg *config.Config) (*madns.Resolver, error) { var dohOpts []doh.Option if !cfg.DNS.MaxCacheTTL.IsDefault() { @@ -25,9 +29,16 @@ func DNSResolver(cfg *config.Config) (*madns.Resolver, error) { return nil, err } + // Check if we should skip network DNS lookups for p2p-forge domains + skipAutoTLSDNS := cfg.AutoTLS.SkipDNSLookup.WithDefault(config.DefaultAutoTLSSkipDNSLookup) + if !skipAutoTLSDNS { + // Local resolution disabled, use network DNS for everything + return baseResolver, nil + } + // 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. + // so DNS lookups are wasteful. We resolve these in-memory when possible. forgeDomains := []string{config.DefaultDomainSuffix} customDomain := cfg.AutoTLS.DomainSuffix.WithDefault(config.DefaultDomainSuffix) if customDomain != config.DefaultDomainSuffix { diff --git a/core/node/p2pforge_resolver.go b/core/node/p2pforge_resolver.go index f19561372..6ddbb1904 100644 --- a/core/node/p2pforge_resolver.go +++ b/core/node/p2pforge_resolver.go @@ -2,11 +2,11 @@ package node import ( "context" - "fmt" "net" "net/netip" "strings" + "github.com/libp2p/go-libp2p/core/peer" madns "github.com/multiformats/go-multiaddr-dns" ) @@ -17,28 +17,28 @@ import ( // - 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 +// When local parsing fails (invalid format, invalid peerID, etc.), the resolver +// falls back to network DNS. This ensures future .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 basicResolver + fallback madns.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 { +// 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, ".")) @@ -51,8 +51,12 @@ func NewP2PForgeResolver(suffixes []string, fallback basicResolver) *p2pForgeRes // Format: .. // - 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) +// +// 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 .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 @@ -64,28 +68,34 @@ func (r *p2pForgeResolver) LookupIPAddr(ctx context.Context, hostname string) ([ } } if subdomain == "" { - return nil, fmt.Errorf("hostname %q does not match any p2p-forge suffix", hostname) + // not a p2p-forge domain, fallback to network + return r.fallback.LookupIPAddr(ctx, hostname) } - // split subdomain into parts: should be [ip-prefix, peerID] or just [peerID] + // split subdomain into parts: should be [ip-prefix, 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) + if len(parts) != 2 { + // not the expected . format, fallback to network + return r.fallback.LookupIPAddr(ctx, hostname) } - ipPrefix := parts[0] + 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(ipPrefix) == 0 || ipPrefix[0] == '-' || ipPrefix[len(ipPrefix)-1] == '-' { - return nil, fmt.Errorf("invalid IP encoding (RFC 1123 violation): %q", ipPrefix) + 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(ipPrefix, "-") + segments := strings.Split(encodedIP, "-") if len(segments) == 4 { ipv4Str := strings.Join(segments, ".") if ip, err := netip.ParseAddr(ipv4Str); err == nil && ip.Is4() { @@ -99,7 +109,8 @@ func (r *p2pForgeResolver) LookupIPAddr(ctx context.Context, hostname string) ([ return []net.IPAddr{{IP: ip.AsSlice()}}, nil } - return nil, fmt.Errorf("invalid IP encoding in hostname: %q", ipPrefix) + // IP parsing failed, fallback to network + return r.fallback.LookupIPAddr(ctx, hostname) } // LookupTXT delegates to the fallback resolver to support ACME DNS-01 challenges diff --git a/core/node/p2pforge_resolver_test.go b/core/node/p2pforge_resolver_test.go index a8c68bd48..caa1b6409 100644 --- a/core/node/p2pforge_resolver_test.go +++ b/core/node/p2pforge_resolver_test.go @@ -2,146 +2,171 @@ package node import ( "context" + "errors" + "net" "testing" + "github.com/ipfs/kubo/config" "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 +// Test constants matching p2p-forge production format +const ( + // testPeerID is a valid peerID in CIDv1 base36 format as used by p2p-forge. + // Base36 is lowercase-only, making it safe for case-insensitive DNS. + // Corresponds to 12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN in base58btc. + testPeerID = "k51qzi5uqu5dhnwe629wdlncpql6frppdpwnz4wtlcw816aysd5wwlk63g4wmh" + + // domainSuffix is the default p2p-forge domain used in tests. + domainSuffix = config.DefaultDomainSuffix +) + +// mockResolver implements madns.BasicResolver for testing +type mockResolver struct { + txtRecords map[string][]string + ipRecords map[string][]net.IPAddr + ipErr error } -func (m *mockTXTResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { - if m.records != nil { - return m.records[name], nil +func (m *mockResolver) LookupIPAddr(_ context.Context, hostname string) ([]net.IPAddr, error) { + if m.ipErr != nil { + return nil, m.ipErr + } + if m.ipRecords != nil { + return m.ipRecords[hostname], 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 (m *mockResolver) LookupTXT(_ context.Context, name string) ([]string, error) { + if m.txtRecords != nil { + return m.txtRecords[name], nil } + return nil, nil } -func TestP2PForgeResolver_LookupIPAddr_IPv6(t *testing.T) { - r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{}) +// newTestResolver creates a p2pForgeResolver with default suffix. +func newTestResolver(t *testing.T) *p2pForgeResolver { + t.Helper() + return NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{}) +} + +// assertLookupIP verifies that hostname resolves to wantIP. +func assertLookupIP(t *testing.T, r *p2pForgeResolver, hostname, wantIP string) { + t.Helper() + addrs, err := r.LookupIPAddr(t.Context(), hostname) + require.NoError(t, err) + require.Len(t, addrs, 1) + assert.Equal(t, wantIP, addrs[0].IP.String()) +} + +func TestP2PForgeResolver_LookupIPAddr(t *testing.T) { + r := newTestResolver(t) 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", "::"}, + // IPv4 + {"ipv4/basic", "192-168-1-1." + testPeerID + "." + domainSuffix, "192.168.1.1"}, + {"ipv4/zeros", "0-0-0-0." + testPeerID + "." + domainSuffix, "0.0.0.0"}, + {"ipv4/max", "255-255-255-255." + testPeerID + "." + domainSuffix, "255.255.255.255"}, + {"ipv4/trailing dot", "10-0-0-1." + testPeerID + "." + domainSuffix + ".", "10.0.0.1"}, + {"ipv4/uppercase suffix", "192-168-1-1." + testPeerID + ".LIBP2P.DIRECT", "192.168.1.1"}, + // IPv6 + {"ipv6/full", "2001-db8-0-0-0-0-0-1." + testPeerID + "." + domainSuffix, "2001:db8::1"}, + {"ipv6/compressed", "2001-db8--1." + testPeerID + "." + domainSuffix, "2001:db8::1"}, + {"ipv6/loopback", "0--1." + testPeerID + "." + domainSuffix, "::1"}, + {"ipv6/all zeros", "0--0." + testPeerID + "." + domainSuffix, "::"}, } 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()) + assertLookupIP(t, r, tt.hostname, tt.wantIP) }) } } func TestP2PForgeResolver_LookupIPAddr_MultipleSuffixes(t *testing.T) { - r := NewP2PForgeResolver([]string{"libp2p.direct", "custom.example.com"}, &mockTXTResolver{}) + r := NewP2PForgeResolver([]string{domainSuffix, "custom.example.com"}, &mockResolver{}) 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"}, + {"192-168-1-1." + testPeerID + "." + domainSuffix, "192.168.1.1"}, + {"10-0-0-1." + testPeerID + ".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()) + assertLookupIP(t, r, tt.hostname, tt.wantIP) }) } } -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{}) +func TestP2PForgeResolver_LookupIPAddr_FallbackToNetwork(t *testing.T) { + fallbackIP := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}} 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"}, + {"peerID only", testPeerID + "." + domainSuffix}, + {"invalid peerID", "192-168-1-1.invalid-peer-id." + domainSuffix}, + {"invalid IP encoding", "not-an-ip." + testPeerID + "." + domainSuffix}, + {"leading hyphen", "-192-168-1-1." + testPeerID + "." + domainSuffix}, + {"too many parts", "extra.192-168-1-1." + testPeerID + "." + domainSuffix}, + {"wrong suffix", "192-168-1-1." + testPeerID + ".example.com"}, } + // Build fallback records from test cases + ipRecords := make(map[string][]net.IPAddr, len(tests)) + for _, tt := range tests { + ipRecords[tt.hostname] = fallbackIP + } + fallback := &mockResolver{ipRecords: ipRecords} + r := NewP2PForgeResolver([]string{domainSuffix}, fallback) + 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) + addrs, err := r.LookupIPAddr(t.Context(), tt.hostname) + require.NoError(t, err) + require.Len(t, addrs, 1, "should fallback to network") + assert.Equal(t, "93.184.216.34", addrs[0].IP.String()) }) } } -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) +func TestP2PForgeResolver_LookupIPAddr_FallbackError(t *testing.T) { + expectedErr := errors.New("network error") + r := NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{ipErr: expectedErr}) - records, err := r.LookupTXT(t.Context(), "_acme-challenge.peerID.libp2p.direct") - require.NoError(t, err) - assert.Equal(t, []string{"acme-token-value"}, records) + // peerID-only triggers fallback, which returns error + _, err := r.LookupIPAddr(t.Context(), testPeerID+"."+domainSuffix) + require.ErrorIs(t, err, expectedErr) } -func TestP2PForgeResolver_LookupTXT_EmptyFallback(t *testing.T) { - r := NewP2PForgeResolver([]string{"libp2p.direct"}, &mockTXTResolver{}) +func TestP2PForgeResolver_LookupTXT(t *testing.T) { + t.Run("delegates to fallback for ACME DNS-01", func(t *testing.T) { + acmeHost := "_acme-challenge." + testPeerID + "." + domainSuffix + fallback := &mockResolver{ + txtRecords: map[string][]string{acmeHost: {"acme-token-value"}}, + } + r := NewP2PForgeResolver([]string{domainSuffix}, fallback) - // When fallback returns nothing, we return nothing - records, err := r.LookupTXT(t.Context(), "anything.libp2p.direct") - require.NoError(t, err) - assert.Empty(t, records) + records, err := r.LookupTXT(t.Context(), acmeHost) + require.NoError(t, err) + assert.Equal(t, []string{"acme-token-value"}, records) + }) + + t.Run("returns empty when fallback has no records", func(t *testing.T) { + r := NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{}) + + records, err := r.LookupTXT(t.Context(), "anything."+domainSuffix) + require.NoError(t, err) + assert.Empty(t, records) + }) } diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index db105c42d..efd02b67f 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -16,7 +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) + - [๐ŸŒ No unnecessary DNS lookups for AutoTLS addresses](#-no-unnecessary-dns-lookups-for-autotls-addresses) - [๐Ÿ“ฆ๏ธ Dependency updates](#-dependency-updates) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -88,9 +88,11 @@ 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 +#### ๐ŸŒ No unnecessary DNS lookups for AutoTLS addresses -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. +Kubo no longer makes DNS queries for [AutoTLS](https://blog.libp2p.io/autotls/) addresses like `1-2-3-4.peerid.libp2p.direct`. Since the IP is encoded in the hostname (`1-2-3-4` means `1.2.3.4`), Kubo extracts it locally. This reduces load on the public good DNS servers at `libp2p.direct` run by [Shipyard](https://ipshipyard.com), reserving them for web browsers which lack direct DNS access and must rely on the browser's resolver. + +To disable, set [`AutoTLS.SkipDNSLookup`](https://github.com/ipfs/kubo/blob/master/docs/config.md#autotlsskipdnslookup) to `false`. #### ๐Ÿ“ฆ๏ธ Dependency updates diff --git a/docs/config.md b/docs/config.md index 3204341ab..9638a6122 100644 --- a/docs/config.md +++ b/docs/config.md @@ -776,6 +776,22 @@ Default: `true` Type: `flag` +### `AutoTLS.SkipDNSLookup` + +Optional. Controls whether to skip network DNS lookups for [p2p-forge] domains like `*.libp2p.direct`. + +This applies to DNS resolution performed via [`DNS.Resolvers`](#dnsresolvers), including `/dns*` multiaddrs resolved by go-libp2p (e.g., peer addresses from DHT or delegated routing). + +When enabled (default), A/AAAA queries for hostnames matching [`AutoTLS.DomainSuffix`](#autotlsdomainsuffix) are resolved locally by parsing the IP address directly from the hostname (e.g., `1-2-3-4.peerID.libp2p.direct` resolves to `1.2.3.4` without network I/O). This avoids unnecessary DNS queries since the IP is already encoded in the hostname. + +If the hostname format is invalid (wrong peerID, malformed IP encoding), the resolver falls back to network DNS, ensuring forward compatibility with potential future DNS record types. + +Set to `false` to always use network DNS for these domains. This is primarily useful for debugging or if you need to override resolution behavior via [`DNS.Resolvers`](#dnsresolvers). + +Default: `true` + +Type: `flag` + ### `AutoTLS.DomainSuffix` Optional override of the parent domain suffix that will be used in DNS+TLS+WebSockets multiaddrs generated by [p2p-forge] client. @@ -3489,7 +3505,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. +- When [`AutoTLS.SkipDNSLookup`](#autotlsskipdnslookup) is enabled (default), domains matching [`AutoTLS.DomainSuffix`](#autotlsdomainsuffix) (default: `libp2p.direct`) are resolved locally by parsing the IP directly from the hostname. Set `AutoTLS.SkipDNSLookup=false` to force network DNS lookups for these domains. Default: `{".": "auto"}` diff --git a/test/cli/dns_resolvers_multiaddr_test.go b/test/cli/dns_resolvers_multiaddr_test.go index eec51c70d..b330004ea 100644 --- a/test/cli/dns_resolvers_multiaddr_test.go +++ b/test/cli/dns_resolvers_multiaddr_test.go @@ -5,11 +5,15 @@ import ( "testing" "time" + "github.com/ipfs/kubo/config" "github.com/ipfs/kubo/test/cli/harness" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// testDomainSuffix is the default p2p-forge domain used in tests +const testDomainSuffix = config.DefaultDomainSuffix // libp2p.direct + // TestDNSResolversApplyToMultiaddr is a regression test for: // https://github.com/ipfs/kubo/issues/9199 // @@ -64,4 +68,76 @@ func TestDNSResolversApplyToMultiaddr(t *testing.T) { strings.Contains(stderr, "dial"), "error should indicate DNS resolution failure using custom resolver. got: %s", stderr) }) + + t.Run("libp2p.direct resolves locally even with broken DNS.Resolvers", func(t *testing.T) { + t.Parallel() + + h := harness.NewT(t) + nodes := h.NewNodes(2).Init("--profile=test") + + // Configure node0 with a broken DNS resolver + // This would break all DNS resolution if libp2p.direct wasn't resolved locally + invalidResolver := "https://invalid.broken.resolver.test/dns-query" + nodes[0].SetIPFSConfig("DNS.Resolvers", map[string]string{ + ".": invalidResolver, + }) + + // Clear bootstrap peers on both nodes + for _, n := range nodes { + n.SetIPFSConfig("Bootstrap", []string{}) + } + + nodes.StartDaemons() + defer nodes.StopDaemons() + + // Get node1's peer ID in base36 format (what p2p-forge uses in DNS hostnames) + // DNS is case-insensitive, and base36 is lowercase-only, making it ideal for DNS + idResult := nodes[1].RunIPFS("id", "--peerid-base", "base36", "-f", "") + require.Equal(t, 0, idResult.ExitCode()) + node1IDBase36 := strings.TrimSpace(idResult.Stdout.String()) + node1ID := nodes[1].PeerID().String() + node1Addrs := nodes[1].SwarmAddrs() + + // Find a TCP address we can use + var tcpAddr string + for _, addr := range node1Addrs { + addrStr := addr.String() + if strings.Contains(addrStr, "/tcp/") && strings.Contains(addrStr, "/ip4/127.0.0.1") { + tcpAddr = addrStr + break + } + } + require.NotEmpty(t, tcpAddr, "node1 should have a local TCP address") + + // Extract port from address like /ip4/127.0.0.1/tcp/12345/... + parts := strings.Split(tcpAddr, "/") + var port string + for i, p := range parts { + if p == "tcp" && i+1 < len(parts) { + port = parts[i+1] + break + } + } + require.NotEmpty(t, port, "should find TCP port in address") + + // Construct a libp2p.direct hostname that encodes 127.0.0.1 + // Format: /dns4/..libp2p.direct/tcp//p2p/ + // p2p-forge uses base36 peerIDs in DNS hostnames (lowercase, DNS-safe) + libp2pDirectAddr := "/dns4/127-0-0-1." + node1IDBase36 + "." + testDomainSuffix + "/tcp/" + port + "/p2p/" + node1ID + + // This connection should succeed because libp2p.direct is resolved locally + // even though DNS.Resolvers points to a broken server + result := nodes[0].RunIPFS("swarm", "connect", libp2pDirectAddr) + + // The connection should succeed - local resolution bypasses broken DNS + assert.Equal(t, 0, result.ExitCode(), + "swarm connect to libp2p.direct should succeed with local resolution. stderr: %s", + result.Stderr.String()) + + // Verify the connection was actually established + result = nodes[0].RunIPFS("swarm", "peers") + require.Equal(t, 0, result.ExitCode()) + assert.Contains(t, result.Stdout.String(), node1ID, + "node0 should be connected to node1") + }) }