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
This commit is contained in:
Marcin Rataj 2026-01-30 17:20:56 +01:00 committed by GitHub
parent ef99e0a0f7
commit 7de7af0820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 502 additions and 2 deletions

View File

@ -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
)

View File

@ -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...)
}

View File

@ -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 <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)
}

View File

@ -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)
})
}

View File

@ -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.

View File

@ -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"}`

View File

@ -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", "<id>")
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/<ip-encoded>.<peerID-base36>.libp2p.direct/tcp/<port>/p2p/<peerID>
// 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")
})
}