diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a31c0da..68b4f40de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ The following emojis are used to highlight certain changes: ### Added +- `namesys`: DNS resolution can report the TXT record TTL. `WithDNSResolver` uses it automatically when the resolver implements [`madns.TXTWithTTLResolver`](https://github.com/multiformats/go-multiaddr-dns/pull/75), and `NewDNSResolverWithTTL`, `WithDNSResolverWithTTL`, and `LookupTXTWithTTLFunc` take a TTL-aware lookup directly. A gateway can then set `Cache-Control: max-age` for DNSLink (`/ipns/`) responses from the real DNS TTL instead of a static value. `NewDNSResolver` and `LookupTXTFunc` keep their signatures and report an unknown TTL (0). [#329](https://github.com/ipfs/boxo/issues/329) + ### Changed +- `namesys`: a resolved name's TTL is the shortest across all of its hops, so a DNSLink or recursive IPNS name is not cached past its earliest-expiring link. Hops with an unknown TTL (0) are ignored, leaving single-hop and current DNSLink results unaffected. [#329](https://github.com/ipfs/boxo/issues/329) + ### Removed ### Fixed diff --git a/examples/go.mod b/examples/go.mod index cea38b548..de581f49b 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -71,7 +71,7 @@ require ( github.com/koron/go-ssdp v0.0.6 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-cidranger v1.1.0 // indirect - github.com/libp2p/go-doh-resolver v0.5.0 // indirect + github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748 // indirect github.com/libp2p/go-flow-metrics v0.3.0 // indirect github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect github.com/libp2p/go-libp2p-kad-dht v0.40.0 // indirect @@ -91,7 +91,7 @@ require ( github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multiaddr-dns v0.5.0 // indirect + github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127 // indirect github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect diff --git a/examples/go.sum b/examples/go.sum index 2ecae659d..7c7baea21 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -319,8 +319,8 @@ github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6 github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-cidranger v1.1.0 h1:ewPN8EZ0dd1LSnrtuwd4709PXVcITVeuwbag38yPW7c= github.com/libp2p/go-cidranger v1.1.0/go.mod h1:KWZTfSr+r9qEo9OkI9/SIEeAtw+NNoU0dXIXt15Okic= -github.com/libp2p/go-doh-resolver v0.5.0 h1:4h7plVVW+XTS+oUBw2+8KfoM1jF6w8XmO7+skhePFdE= -github.com/libp2p/go-doh-resolver v0.5.0/go.mod h1:aPDxfiD2hNURgd13+hfo29z9IC22fv30ee5iM31RzxU= +github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748 h1:oHfW49Wd7aQckSbLeURGHKCuqaxxazQKtOYO0a3qHBo= +github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748/go.mod h1:ORKldXV7t2tneaa5J3KjuDN3IQqgGPVouLA4wzavwRU= github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTnvo= @@ -388,8 +388,8 @@ github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multiaddr-dns v0.5.0 h1:p/FTyHKX0nl59f+S+dEUe8HRK+i5Ow/QHMw8Nh3gPCo= -github.com/multiformats/go-multiaddr-dns v0.5.0/go.mod h1:yJ349b8TPIAANUyuOzn1oz9o22tV9f+06L+cCeMxC14= +github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127 h1:SjNba11B5BQXxoeE9qYN83CwmsHi/TApsC/0EDv3FPA= +github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127/go.mod h1:dwIQwdORZfnNQCeS7xLXyn+7626oRmMsVP30Uronhf0= github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= diff --git a/go.mod b/go.mod index 1a80b2be1..e8164df4d 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/ipld/go-codec-dagpb v1.7.0 github.com/ipld/go-ipld-prime v0.23.0 github.com/libp2p/go-buffer-pool v0.1.0 - github.com/libp2p/go-doh-resolver v0.5.0 + github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748 github.com/libp2p/go-libp2p v0.48.0 github.com/libp2p/go-libp2p-kad-dht v0.40.0 github.com/libp2p/go-libp2p-record v0.3.1 @@ -46,7 +46,7 @@ require ( github.com/mr-tron/base58 v1.3.0 github.com/multiformats/go-base32 v0.1.0 github.com/multiformats/go-multiaddr v0.16.1 - github.com/multiformats/go-multiaddr-dns v0.5.0 + github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127 github.com/multiformats/go-multibase v0.3.0 github.com/multiformats/go-multicodec v0.10.0 github.com/multiformats/go-multihash v0.2.3 diff --git a/go.sum b/go.sum index 561bea3bf..7df7ffa2a 100644 --- a/go.sum +++ b/go.sum @@ -316,8 +316,8 @@ github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6 github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-cidranger v1.1.0 h1:ewPN8EZ0dd1LSnrtuwd4709PXVcITVeuwbag38yPW7c= github.com/libp2p/go-cidranger v1.1.0/go.mod h1:KWZTfSr+r9qEo9OkI9/SIEeAtw+NNoU0dXIXt15Okic= -github.com/libp2p/go-doh-resolver v0.5.0 h1:4h7plVVW+XTS+oUBw2+8KfoM1jF6w8XmO7+skhePFdE= -github.com/libp2p/go-doh-resolver v0.5.0/go.mod h1:aPDxfiD2hNURgd13+hfo29z9IC22fv30ee5iM31RzxU= +github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748 h1:oHfW49Wd7aQckSbLeURGHKCuqaxxazQKtOYO0a3qHBo= +github.com/libp2p/go-doh-resolver v0.5.1-0.20260603151746-4e7758ec9748/go.mod h1:ORKldXV7t2tneaa5J3KjuDN3IQqgGPVouLA4wzavwRU= github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTnvo= @@ -385,8 +385,8 @@ github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multiaddr-dns v0.5.0 h1:p/FTyHKX0nl59f+S+dEUe8HRK+i5Ow/QHMw8Nh3gPCo= -github.com/multiformats/go-multiaddr-dns v0.5.0/go.mod h1:yJ349b8TPIAANUyuOzn1oz9o22tV9f+06L+cCeMxC14= +github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127 h1:SjNba11B5BQXxoeE9qYN83CwmsHi/TApsC/0EDv3FPA= +github.com/multiformats/go-multiaddr-dns v0.5.1-0.20260603161026-e8eb3c2de127/go.mod h1:dwIQwdORZfnNQCeS7xLXyn+7626oRmMsVP30Uronhf0= github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= diff --git a/namesys/dns_resolver.go b/namesys/dns_resolver.go index 43f85b90d..e7268704a 100644 --- a/namesys/dns_resolver.go +++ b/namesys/dns_resolver.go @@ -20,15 +20,31 @@ import ( // LookupTXTFunc is a function that lookups TXT record values. type LookupTXTFunc func(ctx context.Context, name string) (txt []string, err error) +// LookupTXTWithTTLFunc is like [LookupTXTFunc] but also returns how long the TXT +// records may be cached. A TTL of 0 means the TTL is unknown. +type LookupTXTWithTTLFunc func(ctx context.Context, name string) (txt []string, ttl time.Duration, err error) + // DNSResolver implements [Resolver] on DNS domains. type DNSResolver struct { - lookupTXT LookupTXTFunc + lookupTXT LookupTXTWithTTLFunc } var _ Resolver = &DNSResolver{} -// NewDNSResolver constructs a name resolver using DNS TXT records. +// NewDNSResolver constructs a name resolver from DNS TXT records. It reports an +// unknown TTL (0) for every result; use [NewDNSResolverWithTTL] when the lookup +// can report real TTLs. func NewDNSResolver(lookup LookupTXTFunc) *DNSResolver { + return &DNSResolver{lookupTXT: func(ctx context.Context, name string) ([]string, time.Duration, error) { + txt, err := lookup(ctx, name) + return txt, 0, err + }} +} + +// NewDNSResolverWithTTL is like [NewDNSResolver] but takes a lookup that reports +// each record's TTL. The TTL flows into the resolved result, so a gateway can +// set Cache-Control max-age from a DNSLink's TTL. +func NewDNSResolverWithTTL(lookup LookupTXTWithTTLFunc) *DNSResolver { return &DNSResolver{lookupTXT: lookup} } @@ -85,7 +101,7 @@ func (r *DNSResolver) resolveOnceAsync(ctx context.Context, p path.Path, options } if subRes.Err == nil { p, err := joinPaths(subRes.Path, p) - emitOnceResult(ctx, out, AsyncResult{Path: p, LastMod: time.Now(), Err: err}) + emitOnceResult(ctx, out, AsyncResult{Path: p, TTL: subRes.TTL, LastMod: time.Now(), Err: err}) // Return without waiting for rootRes, since this result // (for "_dnslink."+fqdn) takes precedence } else { @@ -107,7 +123,7 @@ func workDomain(ctx context.Context, r *DNSResolver, name string, res chan Async defer close(res) - txt, err := r.lookupTXT(ctx, name) + txt, ttl, err := r.lookupTXT(ctx, name) if err != nil { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { @@ -142,8 +158,8 @@ func workDomain(ctx context.Context, r *DNSResolver, name string, res chan Async // There were no TXT records with a dnslink res <- AsyncResult{Err: ErrMissingDNSLinkRecord} case 1: - // Found 1 valid! Return it. - res <- AsyncResult{Path: paths[0]} + // Found 1 valid! Return it with the record TTL. + res <- AsyncResult{Path: paths[0], TTL: ttl} default: // Found more than 1 IPFS/IPNS path. res <- AsyncResult{Err: ErrMultipleDNSLinkRecords} diff --git a/namesys/dns_resolver_test.go b/namesys/dns_resolver_test.go index c174a590a..fa65c7ad9 100644 --- a/namesys/dns_resolver_test.go +++ b/namesys/dns_resolver_test.go @@ -4,6 +4,7 @@ import ( "context" "net" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -152,7 +153,7 @@ func newMockDNS() *mockDNS { func TestDNSResolution(t *testing.T) { t.Parallel() - r := &DNSResolver{lookupTXT: newMockDNS().lookupTXT} + r := NewDNSResolver(newMockDNS().lookupTXT) for _, testCase := range []struct { name string @@ -206,3 +207,47 @@ func TestDNSResolution(t *testing.T) { }) } } + +func TestDNSResolutionWithTTL(t *testing.T) { + t.Parallel() + + const ttl = 42 * time.Second + lookup := func(ctx context.Context, name string) ([]string, time.Duration, error) { + if name == "_dnslink.ttl.example.com." { + return []string{"dnslink=/ipfs/QmY3hE8xgFCjGcz6PHgnvJz5HZi1BaKRfPkn1ghZUcYMjD"}, ttl, nil + } + return nil, 0, &net.DNSError{IsNotFound: true} + } + + // the TTL-aware resolver propagates the record TTL to the resolved result + r := NewDNSResolverWithTTL(lookup) + testResolution(t, r, "/ipns/ttl.example.com", DefaultDepthLimit, "/ipfs/QmY3hE8xgFCjGcz6PHgnvJz5HZi1BaKRfPkn1ghZUcYMjD", ttl, nil) + + // the legacy constructor reports an unknown TTL (0) for the same lookup + rNoTTL := NewDNSResolver(func(ctx context.Context, name string) ([]string, error) { + txt, _, err := lookup(ctx, name) + return txt, err + }) + testResolution(t, rNoTTL, "/ipns/ttl.example.com", DefaultDepthLimit, "/ipfs/QmY3hE8xgFCjGcz6PHgnvJz5HZi1BaKRfPkn1ghZUcYMjD", 0, nil) +} + +func TestMinNonZeroTTL(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + a, b, want time.Duration + }{ + {0, 0, 0}, // both unknown -> unknown + {0, 5 * time.Second, 5 * time.Second}, // one unknown -> the other + {5 * time.Second, 0, 5 * time.Second}, // one unknown -> the other + {3 * time.Second, 5 * time.Second, 3 * time.Second}, // both known -> min + {5 * time.Second, 3 * time.Second, 3 * time.Second}, // both known -> min + {-1 * time.Second, 5 * time.Second, 5 * time.Second}, // negative ignored -> the other + {-3 * time.Second, -5 * time.Second, 0}, // both negative -> never negative + {-1 * time.Second, 0, 0}, // negative and unknown -> never negative + } { + if got := minNonZeroTTL(tc.a, tc.b); got != tc.want { + t.Fatalf("minNonZeroTTL(%s, %s) = %s; want %s", tc.a, tc.b, got, tc.want) + } + } +} diff --git a/namesys/namesys.go b/namesys/namesys.go index 0f4084723..eff35e6ec 100644 --- a/namesys/namesys.go +++ b/namesys/namesys.go @@ -91,11 +91,27 @@ func WithMaxCacheTTL(dur time.Duration) Option { } } -// WithDNSResolver is an option that supplies a custom DNS resolver to use instead -// of the system default. +// WithDNSResolver sets a custom DNS resolver in place of the system default. If +// that resolver also implements [madns.TXTWithTTLResolver], its TXT TTLs flow +// into resolved results, and from there into the gateway's Cache-Control header. func WithDNSResolver(rslv madns.BasicResolver) Option { return func(ns *namesys) error { - ns.dnsResolver = NewDNSResolver(rslv.LookupTXT) + // A resolver that reports TXT TTLs (such as a DoH resolver via + // multiformats/go-multiaddr-dns#75) carries the DNSLink TTL through. + if ttlRslv, ok := rslv.(madns.TXTWithTTLResolver); ok { + ns.dnsResolver = NewDNSResolverWithTTL(ttlRslv.LookupTXTWithTTL) + } else { + ns.dnsResolver = NewDNSResolver(rslv.LookupTXT) + } + return nil + } +} + +// WithDNSResolverWithTTL is like [WithDNSResolver] but takes a lookup that +// reports TXT TTLs directly. +func WithDNSResolverWithTTL(lookup LookupTXTWithTTLFunc) Option { + return func(ns *namesys) error { + ns.dnsResolver = NewDNSResolverWithTTL(lookup) return nil } } diff --git a/namesys/utilities.go b/namesys/utilities.go index ca72316c1..1c80753e8 100644 --- a/namesys/utilities.go +++ b/namesys/utilities.go @@ -3,6 +3,7 @@ package namesys import ( "context" "strings" + "time" "github.com/ipfs/boxo/path" "go.opentelemetry.io/otel" @@ -46,6 +47,11 @@ func resolveAsync(ctx context.Context, r resolver, p path.Path, options ResolveO var subCh <-chan AsyncResult var cancelSub context.CancelFunc + // parentTTL is the TTL of the mutable result that started this recursion, + // for example a DNSLink that points at an IPNS name. Each sub-result's TTL + // is held to the shorter of the two, so a name is never cached past its + // shortest hop. + var parentTTL time.Duration defer func() { if cancelSub != nil { cancelSub() @@ -92,6 +98,7 @@ func resolveAsync(ctx context.Context, r resolver, p path.Path, options ResolveO subCtx, cancelSub = context.WithCancel(ctx) _ = cancelSub + parentTTL = res.TTL subCh = resolveAsync(subCtx, r, res.Path, subOpts) case res, ok := <-subCh: if !ok { @@ -99,6 +106,10 @@ func resolveAsync(ctx context.Context, r resolver, p path.Path, options ResolveO break } + // Keep the shorter of the parent (e.g. DNSLink) and sub-result + // TTLs so the result is never cached past its shortest hop. + res.TTL = minNonZeroTTL(parentTTL, res.TTL) + // We don't bother returning here in case of context timeout as there is // no good reason to do that, and we may still be able to emit a result emitResult(ctx, outCh, res) @@ -120,6 +131,17 @@ func emitResult(ctx context.Context, outCh chan<- AsyncResult, r AsyncResult) { } } +// minNonZeroTTL returns the shorter of two TTLs, treating a non-positive value +// as unknown and ignoring it. If both are unknown it returns 0, and a negative +// input is never returned. +func minNonZeroTTL(a, b time.Duration) time.Duration { + ttl := min(a, b) + if ttl <= 0 { + ttl = max(0, a, b) + } + return ttl +} + func joinPaths(resolvedBase, unresolvedPath path.Path) (path.Path, error) { if resolvedBase == nil { return nil, nil