Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dnslink-host>`) 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
Expand Down
4 changes: 2 additions & 2 deletions examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions examples/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
28 changes: 22 additions & 6 deletions namesys/dns_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
47 changes: 46 additions & 1 deletion namesys/dns_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"net"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
22 changes: 19 additions & 3 deletions namesys/namesys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
22 changes: 22 additions & 0 deletions namesys/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package namesys
import (
"context"
"strings"
"time"

"github.com/ipfs/boxo/path"
"go.opentelemetry.io/otel"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -92,13 +98,18 @@ 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 {
subCh = nil
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)
Expand All @@ -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
Expand Down
Loading