diff --git a/config/dns.go b/config/dns.go index 0b269675f..922550568 100644 --- a/config/dns.go +++ b/config/dns.go @@ -14,4 +14,11 @@ type DNS struct { Resolvers map[string]string // MaxCacheTTL is the maximum duration DNS entries are valid in the cache. MaxCacheTTL *OptionalDuration `json:",omitempty"` + // OverrideSystem controls whether DNS.Resolvers config is applied globally + // to all DNS lookups performed by the daemon, including third-party libraries. + // When enabled (default), net.DefaultResolver is replaced with one that uses + // the configured resolvers, ensuring consistent DNS behavior across the daemon. + // Set to false to use the OS resolver for code that doesn't explicitly use + // the Kubo DNS resolver (useful for testing or debugging). + OverrideSystem Flag `json:",omitempty"` } diff --git a/core/node/dns.go b/core/node/dns.go index 3f0875afb..eaf502736 100644 --- a/core/node/dns.go +++ b/core/node/dns.go @@ -2,12 +2,15 @@ package node import ( "math" + "net" "time" "github.com/ipfs/boxo/gateway" config "github.com/ipfs/kubo/config" + "github.com/ipfs/kubo/core/node/libp2p" doh "github.com/libp2p/go-doh-resolver" madns "github.com/multiformats/go-multiaddr-dns" + "go.uber.org/fx" ) func DNSResolver(cfg *config.Config) (*madns.Resolver, error) { @@ -21,3 +24,20 @@ func DNSResolver(cfg *config.Config) (*madns.Resolver, error) { return gateway.NewDNSResolver(resolvers, dohOpts...) } + +// OverrideDefaultResolver replaces net.DefaultResolver with one that uses +// the provided madns.Resolver. This ensures all Go code in the daemon +// (including third-party libraries like p2p-forge/client) respects the +// DNS.Resolvers configuration. +func OverrideDefaultResolver(resolver *madns.Resolver) { + net.DefaultResolver = libp2p.NewNetResolverFromMadns(resolver) +} + +// maybeOverrideDefaultResolver returns an fx.Option that conditionally +// invokes OverrideDefaultResolver based on the DNS.OverrideSystem config flag. +func maybeOverrideDefaultResolver(enabled bool) fx.Option { + if enabled { + return fx.Invoke(OverrideDefaultResolver) + } + return fx.Options() +} diff --git a/core/node/dns_test.go b/core/node/dns_test.go new file mode 100644 index 000000000..936b6b56c --- /dev/null +++ b/core/node/dns_test.go @@ -0,0 +1,57 @@ +package node + +import ( + "context" + "net" + "testing" + + madns "github.com/multiformats/go-multiaddr-dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockResolver implements madns.BasicResolver for testing +type mockResolver struct { + txtRecords map[string][]string + ipRecords map[string][]net.IPAddr +} + +func (m *mockResolver) LookupIPAddr(ctx context.Context, name string) ([]net.IPAddr, error) { + if m.ipRecords != nil { + return m.ipRecords[name], nil + } + return nil, nil +} + +func (m *mockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + if m.txtRecords != nil { + return m.txtRecords[name], nil + } + return nil, nil +} + +func TestOverrideDefaultResolver(t *testing.T) { + // Save original resolver to restore after test + originalResolver := net.DefaultResolver + t.Cleanup(func() { + net.DefaultResolver = originalResolver + }) + + // Create mock with known records + mock := &mockResolver{ + txtRecords: map[string][]string{ + "test.override.example": {"override-test-value"}, + }, + } + + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + // Override the default resolver + OverrideDefaultResolver(madnsResolver) + + // Verify net.DefaultResolver now uses our mock + records, err := net.DefaultResolver.LookupTXT(t.Context(), "test.override.example") + require.NoError(t, err) + assert.Equal(t, []string{"override-test-value"}, records) +} diff --git a/core/node/groups.go b/core/node/groups.go index bacc12160..84b78241f 100644 --- a/core/node/groups.go +++ b/core/node/groups.go @@ -355,6 +355,7 @@ func Online(bcfg *BuildCfg, cfg *config.Config, userResourceOverrides rcmgr.Part fx.Provide(Bitswap(isBitswapServerEnabled, isBitswapLibp2pEnabled, isHTTPRetrievalEnabled)), fx.Provide(OnlineExchange(isBitswapLibp2pEnabled)), fx.Provide(DNSResolver), + maybeOverrideDefaultResolver(cfg.DNS.OverrideSystem.WithDefault(true)), fx.Provide(Namesys(ipnsCacheSize, cfg.Ipns.MaxCacheTTL.WithDefault(config.DefaultIpnsMaxCacheTTL))), fx.Provide(Peering), PeerWith(cfg.Peering.Peers...), @@ -373,6 +374,7 @@ func Offline(cfg *config.Config) fx.Option { return fx.Options( fx.Provide(offline.Exchange), fx.Provide(DNSResolver), + maybeOverrideDefaultResolver(cfg.DNS.OverrideSystem.WithDefault(true)), fx.Provide(Namesys(0, 0)), fx.Provide(libp2p.Routing), fx.Provide(libp2p.ContentRouting), diff --git a/core/node/libp2p/madns_net_resolver.go b/core/node/libp2p/madns_net_resolver.go new file mode 100644 index 000000000..f321f82c3 --- /dev/null +++ b/core/node/libp2p/madns_net_resolver.go @@ -0,0 +1,139 @@ +package libp2p + +import ( + "bytes" + "context" + "encoding/binary" + "net" + "strings" + "time" + + "github.com/miekg/dns" + madns "github.com/multiformats/go-multiaddr-dns" +) + +// NewNetResolverFromMadns creates a *net.Resolver that uses madns.Resolver internally. +// This allows p2p-forge to use DNS.Resolvers config for ACME DNS-01 self-checks. +func NewNetResolverFromMadns(resolver *madns.Resolver) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + return &madnsProxyConn{ + resolver: resolver, + ctx: ctx, + }, nil + }, + } +} + +// madnsProxyConn implements net.Conn by proxying DNS queries to madns.Resolver. +// It intercepts DNS wire protocol, parses queries, calls the madns resolver, +// and returns properly formatted DNS responses. +type madnsProxyConn struct { + resolver *madns.Resolver + ctx context.Context + resp bytes.Buffer +} + +func (c *madnsProxyConn) Write(p []byte) (int, error) { + c.resp.Reset() + + // Go's net.Resolver with PreferGo=true uses TCP-style messages + // with 2-byte length prefix even for "udp" network + var queryData []byte + if len(p) >= 2 { + length := int(binary.BigEndian.Uint16(p[:2])) + if len(p) >= 2+length { + queryData = p[2 : 2+length] + } else { + queryData = p[2:] // partial data + } + } else { + queryData = p + } + + if len(queryData) == 0 { + return len(p), nil + } + + // Parse DNS message + var msg dns.Msg + if err := msg.Unpack(queryData); err != nil { + // Return len(p) to indicate we consumed the data, but don't fail + // The response buffer will be empty, causing Read to return EOF + return len(p), nil + } + + // Build response + resp := &dns.Msg{} + resp.SetReply(&msg) + resp.Authoritative = true // Prevents "lame referral" errors + + for _, q := range msg.Question { + name := strings.TrimSuffix(q.Name, ".") + switch q.Qtype { + case dns.TypeTXT: + records, err := c.resolver.LookupTXT(c.ctx, name) + if err == nil { + for _, txt := range records { + resp.Answer = append(resp.Answer, &dns.TXT{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 300}, + Txt: []string{txt}, + }) + } + } + case dns.TypeA: + addrs, err := c.resolver.LookupIPAddr(c.ctx, name) + if err == nil { + for _, addr := range addrs { + if ipv4 := addr.IP.To4(); ipv4 != nil { + resp.Answer = append(resp.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, + A: ipv4, + }) + } + } + } + case dns.TypeAAAA: + addrs, err := c.resolver.LookupIPAddr(c.ctx, name) + if err == nil { + for _, addr := range addrs { + if addr.IP.To4() == nil && addr.IP.To16() != nil { + resp.Answer = append(resp.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300}, + AAAA: addr.IP, + }) + } + } + } + default: + // Unsupported query type - return empty response (NODATA) + } + } + + // Pack response + respData, err := resp.Pack() + if err != nil { + return len(p), err + } + + // Go's pure-Go resolver (PreferGo=true) always uses TCP-style length prefix + // Write 2-byte big-endian length, then the response data + lengthBuf := make([]byte, 2) + binary.BigEndian.PutUint16(lengthBuf, uint16(len(respData))) + c.resp.Write(lengthBuf) + c.resp.Write(respData) + + return len(p), nil +} + +func (c *madnsProxyConn) Read(p []byte) (int, error) { + return c.resp.Read(p) +} + +func (c *madnsProxyConn) Close() error { return nil } +func (c *madnsProxyConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)} } +func (c *madnsProxyConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)} } +func (c *madnsProxyConn) SetDeadline(t time.Time) error { return nil } +func (c *madnsProxyConn) SetReadDeadline(t time.Time) error { return nil } +func (c *madnsProxyConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/core/node/libp2p/madns_net_resolver_test.go b/core/node/libp2p/madns_net_resolver_test.go new file mode 100644 index 000000000..8f3b21320 --- /dev/null +++ b/core/node/libp2p/madns_net_resolver_test.go @@ -0,0 +1,153 @@ +package libp2p + +import ( + "context" + "net" + "testing" + + madns "github.com/multiformats/go-multiaddr-dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockBasicResolver implements madns.BasicResolver for testing +type mockBasicResolver struct { + txtRecords map[string][]string + ipRecords map[string][]net.IPAddr +} + +func (m *mockBasicResolver) LookupIPAddr(ctx context.Context, name string) ([]net.IPAddr, error) { + if m.ipRecords != nil { + return m.ipRecords[name], nil + } + return nil, nil +} + +func (m *mockBasicResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + if m.txtRecords != nil { + return m.txtRecords[name], nil + } + return nil, nil +} + +func TestNewNetResolverFromMadns_LookupTXT(t *testing.T) { + // Create mock resolver with known TXT records + mock := &mockBasicResolver{ + txtRecords: map[string][]string{ + "_acme-challenge.peer.libp2p.direct": {"test-acme-token-12345"}, + "_dnslink.example.com": {"dnslink=/ipfs/QmTest"}, + }, + } + + // Create madns resolver with mock as default + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + // Create net.Resolver via our bridge + netResolver := NewNetResolverFromMadns(madnsResolver) + + // Test TXT lookup + records, err := netResolver.LookupTXT(t.Context(), "_acme-challenge.peer.libp2p.direct") + require.NoError(t, err) + assert.Equal(t, []string{"test-acme-token-12345"}, records) + + // Test another domain + records, err = netResolver.LookupTXT(t.Context(), "_dnslink.example.com") + require.NoError(t, err) + assert.Equal(t, []string{"dnslink=/ipfs/QmTest"}, records) + + // Test non-existent domain - Go's net.Resolver returns error for empty responses + records, err = netResolver.LookupTXT(t.Context(), "nonexistent.example.com") + // net.Resolver interprets empty authoritative response as "no such host" + require.Error(t, err) + assert.Empty(t, records) +} + +func TestNewNetResolverFromMadns_LookupIP(t *testing.T) { + t.Run("returns both IPv4 and IPv6", func(t *testing.T) { + mock := &mockBasicResolver{ + ipRecords: map[string][]net.IPAddr{ + "example.com": { + {IP: net.ParseIP("192.168.1.1")}, + {IP: net.ParseIP("2001:db8::1")}, + }, + }, + } + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + netResolver := NewNetResolverFromMadns(madnsResolver) + ips, err := netResolver.LookupIP(t.Context(), "ip", "example.com") + require.NoError(t, err) + assert.Len(t, ips, 2) + }) + + t.Run("IPv4 only", func(t *testing.T) { + mock := &mockBasicResolver{ + ipRecords: map[string][]net.IPAddr{ + "ipv4only.example.com": { + {IP: net.ParseIP("10.0.0.1")}, + {IP: net.ParseIP("10.0.0.2")}, + }, + }, + } + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + netResolver := NewNetResolverFromMadns(madnsResolver) + ips, err := netResolver.LookupIP(t.Context(), "ip4", "ipv4only.example.com") + require.NoError(t, err) + assert.Len(t, ips, 2) + for _, ip := range ips { + assert.NotNil(t, ip.To4(), "expected IPv4 address") + } + }) + + t.Run("IPv6 only", func(t *testing.T) { + mock := &mockBasicResolver{ + ipRecords: map[string][]net.IPAddr{ + "ipv6only.example.com": { + {IP: net.ParseIP("2001:db8::1")}, + {IP: net.ParseIP("2001:db8::2")}, + }, + }, + } + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + netResolver := NewNetResolverFromMadns(madnsResolver) + ips, err := netResolver.LookupIP(t.Context(), "ip6", "ipv6only.example.com") + require.NoError(t, err) + assert.Len(t, ips, 2) + for _, ip := range ips { + assert.Nil(t, ip.To4(), "expected IPv6 address") + } + }) + + t.Run("non-existent domain returns error", func(t *testing.T) { + mock := &mockBasicResolver{} + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + netResolver := NewNetResolverFromMadns(madnsResolver) + ips, err := netResolver.LookupIP(t.Context(), "ip", "nonexistent.example.com") + // net.Resolver returns error for empty authoritative response + require.Error(t, err) + assert.Empty(t, ips) + }) +} + +func TestNewNetResolverFromMadns_MultipleTXTRecords(t *testing.T) { + mock := &mockBasicResolver{ + txtRecords: map[string][]string{ + "multi.example.com": {"value1", "value2", "value3"}, + }, + } + madnsResolver, err := madns.NewResolver(madns.WithDefaultResolver(mock)) + require.NoError(t, err) + + netResolver := NewNetResolverFromMadns(madnsResolver) + records, err := netResolver.LookupTXT(t.Context(), "multi.example.com") + require.NoError(t, err) + assert.Equal(t, []string{"value1", "value2", "value3"}, records) +} diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index 8b92baf34..deab19e55 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) + - [`DNS.OverrideSystem` config flag](#dnsoverridesystem-config-flag) - [๐Ÿ“ฆ๏ธ Dependency updates](#-dependency-updates) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -87,6 +88,15 @@ 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. +#### `DNS.OverrideSystem` config flag + +A new [`DNS.OverrideSystem`](https://github.com/ipfs/kubo/blob/master/docs/config.md#dnsoverridesystem) config flag (enabled by default) extends [`DNS.Resolvers`](https://github.com/ipfs/kubo/blob/master/docs/config.md#dnsresolvers) to apply globally to all DNS lookups in the daemon process. This goes beyond DNSLink and Multiaddr resolution, affecting AutoTLS ACME DNS-01 challenge verification, HTTP client requests (including HTTP retrieval), and any third-party library code. + +> [!NOTE] +> This is an exploration of how DNS configuration can be applied daemon-wide without refactoring `boxo/gateway`. A future improvement may create a native `net.Resolver` from config first, and convert it to Multiaddr DNS resolver only when passing to go-libp2p, which would be a cleaner architecture. + +Set to `false` to revert to previous behavior where `DNS.Resolvers` only affected DNSLink and Multiaddr resolution. + #### ๐Ÿ“ฆ๏ธ 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..2068d32da 100644 --- a/docs/config.md +++ b/docs/config.md @@ -219,6 +219,7 @@ config file at runtime. - [`DNS`](#dns) - [`DNS.Resolvers`](#dnsresolvers) - [`DNS.MaxCacheTTL`](#dnsmaxcachettl) + - [`DNS.OverrideSystem`](#dnsoverridesystem) - [`HTTPRetrieval`](#httpretrieval) - [`HTTPRetrieval.Enabled`](#httpretrievalenabled) - [`HTTPRetrieval.Allowlist`](#httpretrievalallowlist) @@ -3512,6 +3513,26 @@ Default: Respect DNS Response TTL Type: `optionalDuration` +### `DNS.OverrideSystem` + +Controls whether [`DNS.Resolvers`](#dnsresolvers) configuration is applied globally +to all DNS lookups performed by the daemon process, beyond just DNSLink and Multiaddr resolution. + +When enabled, Go's `net.DefaultResolver` is replaced with one that routes all DNS queries +through the configured resolvers. This affects: + +- AutoTLS (p2p-forge) ACME DNS-01 challenge verification +- HTTP client requests (including [`HTTPRetrieval`](#httpretrieval) block fetching) +- Any third-party library code that performs DNS lookups + +Set to `false` to limit [`DNS.Resolvers`](#dnsresolvers) to only DNSLink and Multiaddr resolution, +letting other code use the operating system's DNS resolver. +This can be useful for testing or debugging DNS-related issues. + +Default: `true` + +Type: `flag` + ## `HTTPRetrieval` `HTTPRetrieval` is configuration for pure HTTP retrieval based on Trustless HTTP Gateways'