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 3f0875afb..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() { @@ -19,5 +23,34 @@ 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 + } + + // 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 resolve these in-memory when possible. + 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..6ddbb1904 --- /dev/null +++ b/core/node/p2pforge_resolver.go @@ -0,0 +1,120 @@ +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 .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: .. +// - 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 .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 . 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) +} diff --git a/core/node/p2pforge_resolver_test.go b/core/node/p2pforge_resolver_test.go new file mode 100644 index 000000000..caa1b6409 --- /dev/null +++ b/core/node/p2pforge_resolver_test.go @@ -0,0 +1,172 @@ +package node + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/ipfs/kubo/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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 *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 (m *mockResolver) LookupTXT(_ context.Context, name string) ([]string, error) { + if m.txtRecords != nil { + return m.txtRecords[name], nil + } + return nil, nil +} + +// 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 + }{ + // 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) { + assertLookupIP(t, r, tt.hostname, tt.wantIP) + }) + } +} + +func TestP2PForgeResolver_LookupIPAddr_MultipleSuffixes(t *testing.T) { + r := NewP2PForgeResolver([]string{domainSuffix, "custom.example.com"}, &mockResolver{}) + + tests := []struct { + hostname string + wantIP string + }{ + {"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) { + assertLookupIP(t, r, tt.hostname, tt.wantIP) + }) + } +} + +func TestP2PForgeResolver_LookupIPAddr_FallbackToNetwork(t *testing.T) { + fallbackIP := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}} + + tests := []struct { + name string + hostname string + }{ + {"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) { + 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_LookupIPAddr_FallbackError(t *testing.T) { + expectedErr := errors.New("network error") + r := NewP2PForgeResolver([]string{domainSuffix}, &mockResolver{ipErr: expectedErr}) + + // peerID-only triggers fallback, which returns error + _, err := r.LookupIPAddr(t.Context(), testPeerID+"."+domainSuffix) + require.ErrorIs(t, err, expectedErr) +} + +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) + + 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 fd8c41b5b..bcdb6f0e2 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -19,6 +19,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 addresses](#-no-unnecessary-dns-lookups-for-autotls-addresses) - [⏱️ Configurable gateway request duration limit](#️-configurable-gateway-request-duration-limit) - [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root) - [📋 Long listing format for `ipfs ls`](#-long-listing-format-for-ipfs-ls) @@ -118,6 +119,12 @@ 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 addresses + +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`. + #### ⏱️ Configurable gateway request duration limit [`Gateway.MaxRequestDuration`](https://github.com/ipfs/kubo/blob/master/docs/config.md#gatewaymaxrequestduration) sets an absolute deadline for gateway requests. Unlike `RetrievalTimeout` (which resets on each data write and catches stalled transfers), this is a hard limit on the total time a request can take. diff --git a/docs/config.md b/docs/config.md index e6ab44d04..8c160f062 100644 --- a/docs/config.md +++ b/docs/config.md @@ -779,6 +779,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 @@ 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` @@ -3519,6 +3535,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. +- 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 new file mode 100644 index 000000000..b330004ea --- /dev/null +++ b/test/cli/dns_resolvers_multiaddr_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "strings" + "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 +// +// 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) + }) + + 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") + }) +}