From bb397e50303e35db3823c71584edabe6f4151b27 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Mon, 4 Sep 2023 20:28:08 +0200 Subject: [PATCH] chore: apply feedback from @lidel --- namesys/dns_resolver.go | 4 +- namesys/interface.go | 204 ++++++++++ namesys/ipns_publisher.go | 2 +- namesys/mpns.go | 313 --------------- namesys/namesys.go | 407 +++++++++++++------- namesys/{mpns_cache.go => namesys_cache.go} | 6 +- namesys/namesys_test.go | 4 +- 7 files changed, 469 insertions(+), 471 deletions(-) create mode 100644 namesys/interface.go delete mode 100644 namesys/mpns.go rename namesys/{mpns_cache.go => namesys_cache.go} (83%) diff --git a/namesys/dns_resolver.go b/namesys/dns_resolver.go index d85344a07..188c98ea9 100644 --- a/namesys/dns_resolver.go +++ b/namesys/dns_resolver.go @@ -22,8 +22,6 @@ type LookupTXTFunc func(ctx context.Context, name string) (txt []string, err err // DNSResolver implements [Resolver] on DNS domains. type DNSResolver struct { lookupTXT LookupTXTFunc - // TODO: maybe some sort of caching? - // cache would need a timeout } var _ Resolver = &DNSResolver{} @@ -57,7 +55,7 @@ func (r *DNSResolver) resolveOnceAsync(ctx context.Context, name string, options domain := segments[0] if _, ok := dns.IsDomainName(domain); !ok { - out <- ResolveResult{Err: fmt.Errorf("not a valid domain name: %s", domain)} + out <- ResolveResult{Err: fmt.Errorf("not a valid domain name: %q", domain)} close(out) return out } diff --git a/namesys/interface.go b/namesys/interface.go new file mode 100644 index 000000000..9133cf9c5 --- /dev/null +++ b/namesys/interface.go @@ -0,0 +1,204 @@ +package namesys + +import ( + "context" + "errors" + "time" + + "github.com/ipfs/boxo/path" + logging "github.com/ipfs/go-log/v2" + ci "github.com/libp2p/go-libp2p/core/crypto" +) + +var log = logging.Logger("namesys") + +var ( + // ErrResolveFailed signals an error when attempting to resolve. + ErrResolveFailed = errors.New("could not resolve name") + + // ErrResolveRecursion signals a recursion-depth limit. + ErrResolveRecursion = errors.New("could not resolve name (recursion limit exceeded)") +) + +const ( + // DefaultDepthLimit is the default depth limit used by [Resolver]. + DefaultDepthLimit = 32 + + // UnlimitedDepth allows infinite recursion in [Resolver]. You probably don't want + // to use this, but it's here if you absolutely trust resolution to eventually + // complete and can't put an upper limit on how many steps it will take. + UnlimitedDepth = 0 + + // DefaultIPNSRecordTTL specifies the time that the record can be cached before + // checking if its validity again. + DefaultIPNSRecordTTL = time.Minute + + // DefaultIPNSRecordEOL specifies the time that the network will cache IPNS + // records after being published. Records should be re-published before this + // interval expires. We use the same default expiration as the DHT. + DefaultIPNSRecordEOL = 48 * time.Hour + + // DefaultResolverCacheTTL defines max TTL of a record placed in [NameSystem] cache. + DefaultResolverCacheTTL = time.Minute +) + +// NameSystem represents a cohesive name publishing and resolving system. +// +// Publishing a name is the process of establishing a mapping, a key-value +// pair, according to naming rules and databases. +// +// Resolving a name is the process of looking up the value associated with the +// key (name). +type NameSystem interface { + Resolver + Publisher +} + +// ResolveResult is the return type for [Resolver.ResolveAsync]. +type ResolveResult struct { + Path path.Path + TTL time.Duration + Err error +} + +// Resolver is an object capable of resolving names. +type Resolver interface { + // Resolve performs a recursive lookup, returning the dereferenced path. For example, + // if example.com has a DNS TXT record pointing to: + // + // /ipns/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy + // + // and there is a DHT IPNS entry for + // + // QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy + // -> /ipfs/Qmcqtw8FfrVSBaRmbWwHxt3AuySBhJLcvmFYi3Lbc4xnwj + // + // then + // + // Resolve(ctx, "/ipns/ipfs.io") + // + // will resolve both names, returning + // + // /ipfs/Qmcqtw8FfrVSBaRmbWwHxt3AuySBhJLcvmFYi3Lbc4xnwj + // + // There is a default depth-limit to avoid infinite recursion. Most users will be fine with + // this default limit, but if you need to adjust the limit you can specify it as an option. + Resolve(ctx context.Context, name string, options ...ResolveOption) (value path.Path, ttl time.Duration, err error) + + // ResolveAsync performs recursive name lookup, like Resolve, but it returns entries as + // they are discovered in the DHT. Each returned result is guaranteed to be "better" + // (which usually means newer) than the previous one. + ResolveAsync(ctx context.Context, name string, options ...ResolveOption) <-chan ResolveResult +} + +// ResolveOptions specifies options for resolving an IPNS Path. +type ResolveOptions struct { + // Depth is the recursion depth limit. + Depth uint + + // DhtRecordCount is the number of IPNS Records to retrieve from the DHT + // (the best record is selected from this set). + DhtRecordCount uint + + // DhtTimeout is the amount of time to wait for DHT records to be fetched + // and verified. A zero value indicates that there is no explicit timeout + // (although there is an implicit timeout due to dial timeouts within the DHT). + DhtTimeout time.Duration +} + +// DefaultResolveOptions returns the default options for resolving an IPNS Path. +func DefaultResolveOptions() ResolveOptions { + return ResolveOptions{ + Depth: DefaultDepthLimit, + DhtRecordCount: 16, + DhtTimeout: time.Minute, + } +} + +// ResolveOption is used to set a resolve option. +type ResolveOption func(*ResolveOptions) + +// ResolveWithDepth sets [ResolveOptions.Depth]. +func ResolveWithDepth(depth uint) ResolveOption { + return func(o *ResolveOptions) { + o.Depth = depth + } +} + +// ResolveWithDhtRecordCount sets [ResolveOptions.DhtRecordCount]. +func ResolveWithDhtRecordCount(count uint) ResolveOption { + return func(o *ResolveOptions) { + o.DhtRecordCount = count + } +} + +// ResolveWithDhtTimeout sets [ResolveOptions.ResolveWithDhtTimeout]. +func ResolveWithDhtTimeout(timeout time.Duration) ResolveOption { + return func(o *ResolveOptions) { + o.DhtTimeout = timeout + } +} + +// ProcessResolveOptions converts an array of [ResolveOption] into a [ResolveOptions] object. +func ProcessResolveOptions(opts []ResolveOption) ResolveOptions { + resolveOptions := DefaultResolveOptions() + for _, option := range opts { + option(&resolveOptions) + } + return resolveOptions +} + +// Publisher is an object capable of publishing particular names. +type Publisher interface { + // Publish establishes a name-value mapping. + // TODO make this not PrivKey specific. + Publish(ctx context.Context, name ci.PrivKey, value path.Path, options ...PublishOption) error +} + +// PublishOptions specifies options for publishing an IPNS Record. +type PublishOptions struct { + EOL time.Time + TTL time.Duration + CompatibleWithV1 bool +} + +// DefaultPublishOptions returns the default options for publishing an IPNS Record. +func DefaultPublishOptions() PublishOptions { + return PublishOptions{ + EOL: time.Now().Add(DefaultIPNSRecordEOL), + TTL: DefaultIPNSRecordTTL, + } +} + +// PublishOption is used to set an option for [PublishOptions]. +type PublishOption func(*PublishOptions) + +// PublishWithEOL sets [PublishOptions.EOL]. +func PublishWithEOL(eol time.Time) PublishOption { + return func(o *PublishOptions) { + o.EOL = eol + } +} + +// PublishWithEOL sets [PublishOptions.TTL]. +func PublishWithTTL(ttl time.Duration) PublishOption { + return func(o *PublishOptions) { + o.TTL = ttl + } +} + +// PublishCompatibleWithV1 sets [PublishOptions.CompatibleWithV1]. +func PublishCompatibleWithV1(compatible bool) PublishOption { + return func(o *PublishOptions) { + o.CompatibleWithV1 = compatible + } +} + +// ProcessPublishOptions converts an array of [PublishOption] into a [PublishOptions] object. +func ProcessPublishOptions(opts []PublishOption) PublishOptions { + publishOptions := DefaultPublishOptions() + for _, option := range opts { + option(&publishOptions) + } + return publishOptions +} diff --git a/namesys/ipns_publisher.go b/namesys/ipns_publisher.go index 2e3a74ee7..48ec2edba 100644 --- a/namesys/ipns_publisher.go +++ b/namesys/ipns_publisher.go @@ -252,7 +252,7 @@ func PublishPublicKey(ctx context.Context, r routing.ValueStore, key string, pub ctx, span := startSpan(ctx, "PublishPublicKey", trace.WithAttributes(attribute.String("Key", key))) defer span.End() - log.Debugf("Storing pubkey at: %s", key) + log.Debugf("Storing pubkey at: %q", key) bytes, err := crypto.MarshalPublicKey(pubKey) if err != nil { return err diff --git a/namesys/mpns.go b/namesys/mpns.go deleted file mode 100644 index c5dc469f6..000000000 --- a/namesys/mpns.go +++ /dev/null @@ -1,313 +0,0 @@ -package namesys - -import ( - "context" - "fmt" - "os" - "strings" - "time" - - lru "github.com/hashicorp/golang-lru/v2" - "github.com/ipfs/boxo/ipns" - "github.com/ipfs/boxo/path" - "github.com/ipfs/go-cid" - ds "github.com/ipfs/go-datastore" - dssync "github.com/ipfs/go-datastore/sync" - ci "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" - "github.com/libp2p/go-libp2p/core/routing" - "github.com/miekg/dns" - madns "github.com/multiformats/go-multiaddr-dns" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -// nameSys is a multi-protocol [NameSystem] that implements generic IPFS naming. -// It uses several [Resolver]s: -// -// 1. IPFS routing naming: SFS-like PKI names. -// 2. dns domains: resolves using links in DNS TXT records -// -// It can only publish to: 1. IPFS routing naming. -type nameSys struct { - ds ds.Datastore - - dnsResolver, ipnsResolver resolver - ipnsPublisher Publisher - - staticMap map[string]path.Path - cache *lru.Cache[string, any] -} - -var _ NameSystem = &nameSys{} - -type Option func(*nameSys) error - -// WithCache is an option that instructs the name system to use a (LRU) cache of the given size. -func WithCache(size int) Option { - return func(ns *nameSys) error { - if size <= 0 { - return fmt.Errorf("invalid cache size %d; must be > 0", size) - } - - cache, err := lru.New[string, any](size) - if err != nil { - return err - } - - ns.cache = cache - return nil - } -} - -// WithDNSResolver is an option that supplies a custom DNS resolver to use instead -// of the system default. -func WithDNSResolver(rslv madns.BasicResolver) Option { - return func(ns *nameSys) error { - ns.dnsResolver = NewDNSResolver(rslv.LookupTXT) - return nil - } -} - -// WithDatastore is an option that supplies a datastore to use instead of an in-memory map datastore. -// The datastore is used to store published IPNS Records and make them available for querying. -func WithDatastore(ds ds.Datastore) Option { - return func(ns *nameSys) error { - ns.ds = ds - return nil - } -} - -// NewNameSystem constructs an IPFS [NameSystem] based on the given [routing.ValueStore]. -func NewNameSystem(r routing.ValueStore, opts ...Option) (NameSystem, error) { - var staticMap map[string]path.Path - - // Prewarm namesys cache with static records for deterministic tests and debugging. - // Useful for testing things like DNSLink without real DNS lookup. - // Example: - // IPFS_NS_MAP="dnslink-test.example.com:/ipfs/bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am" - if list := os.Getenv("IPFS_NS_MAP"); list != "" { - staticMap = make(map[string]path.Path) - for _, pair := range strings.Split(list, ",") { - mapping := strings.SplitN(pair, ":", 2) - key := mapping[0] - value, err := path.NewPath(mapping[1]) - if err != nil { - return nil, err - } - staticMap[key] = value - } - } - - ns := &nameSys{ - staticMap: staticMap, - } - - for _, opt := range opts { - err := opt(ns) - if err != nil { - return nil, err - } - } - - if ns.ds == nil { - ns.ds = dssync.MutexWrap(ds.NewMapDatastore()) - } - - if ns.dnsResolver == nil { - ns.dnsResolver = NewDNSResolver(madns.DefaultResolver.LookupTXT) - } - - ns.ipnsResolver = NewIPNSResolver(r) - ns.ipnsPublisher = NewIPNSPublisher(r, ns.ds) - - return ns, nil -} - -// Resolve implements Resolver. -func (ns *nameSys) Resolve(ctx context.Context, name string, options ...ResolveOption) (path.Path, time.Duration, error) { - ctx, span := startSpan(ctx, "MPNS.Resolve", trace.WithAttributes(attribute.String("Name", name))) - defer span.End() - - if strings.HasPrefix(name, "/ipfs/") { - p, err := path.NewPath(name) - return p, 0, err - } - - if !strings.HasPrefix(name, "/") { - p, err := path.NewPath("/ipfs/" + name) - return p, 0, err - } - - return resolve(ctx, ns, name, ProcessResolveOptions(options)) -} - -func (ns *nameSys) ResolveAsync(ctx context.Context, name string, options ...ResolveOption) <-chan ResolveResult { - ctx, span := startSpan(ctx, "MPNS.ResolveAsync", trace.WithAttributes(attribute.String("Name", name))) - defer span.End() - - if strings.HasPrefix(name, "/ipfs/") { - p, err := path.NewPath(name) - res := make(chan ResolveResult, 1) - res <- ResolveResult{Path: p, Err: err} - close(res) - return res - } - - if !strings.HasPrefix(name, "/") { - p, err := path.NewPath("/ipfs/" + name) - res := make(chan ResolveResult, 1) - res <- ResolveResult{Path: p, Err: err} - close(res) - return res - } - - return resolveAsync(ctx, ns, name, ProcessResolveOptions(options)) -} - -// resolveOnce implements resolver. -func (ns *nameSys) resolveOnceAsync(ctx context.Context, name string, options ResolveOptions) <-chan ResolveResult { - ctx, span := startSpan(ctx, "MPNS.ResolveOnceAsync") - defer span.End() - - out := make(chan ResolveResult, 1) - - if !strings.HasPrefix(name, ipns.NamespacePrefix) { - name = ipns.NamespacePrefix + name - } - segments := strings.SplitN(name, "/", 4) - if len(segments) < 3 || segments[0] != "" { - log.Debugf("invalid name syntax for %s", name) - out <- ResolveResult{Err: ErrResolveFailed} - close(out) - return out - } - - key := segments[2] - - // Resolver selection: - // 1. if it is a PeerID/CID/multihash resolve through "ipns". - // 2. if it is a domain name, resolve through "dns" - - var res resolver - ipnsKey, err := peer.Decode(key) - // CIDs in IPNS are expected to have libp2p-key multicodec - // We ease the transition by returning a more meaningful error with a valid CID - if err != nil { - ipnsCid, cidErr := cid.Decode(key) - if cidErr == nil && ipnsCid.Version() == 1 && ipnsCid.Type() != cid.Libp2pKey { - fixedCid := cid.NewCidV1(cid.Libp2pKey, ipnsCid.Hash()).String() - codecErr := fmt.Errorf("peer ID represented as CIDv1 require libp2p-key multicodec: retry with /ipns/%s", fixedCid) - log.Debugf("RoutingResolver: could not convert public key hash %q to peer ID: %s\n", key, codecErr) - out <- ResolveResult{Err: codecErr} - close(out) - return out - } - } - - cacheKey := key - if err == nil { - cacheKey = string(ipnsKey) - } - - if p, ok := ns.cacheGet(cacheKey); ok { - var err error - if len(segments) > 3 { - p, err = path.Join(p, segments[3]) - } - span.SetAttributes(attribute.Bool("CacheHit", true)) - span.RecordError(err) - - out <- ResolveResult{Path: p, Err: err} - close(out) - return out - } - span.SetAttributes(attribute.Bool("CacheHit", false)) - - if err == nil { - res = ns.ipnsResolver - } else if _, ok := dns.IsDomainName(key); ok { - res = ns.dnsResolver - } else { - out <- ResolveResult{Err: fmt.Errorf("invalid IPNS root: %q", key)} - close(out) - return out - } - - resCh := res.resolveOnceAsync(ctx, key, options) - var best ResolveResult - go func() { - defer close(out) - for { - select { - case res, ok := <-resCh: - if !ok { - if best != (ResolveResult{}) { - ns.cacheSet(cacheKey, best.Path, best.TTL) - } - return - } - if res.Err == nil { - best = res - } - p := res.Path - err := res.Err - ttl := res.TTL - - // Attach rest of the path - if len(segments) > 3 { - p, err = path.Join(p, segments[3]) - } - - emitOnceResult(ctx, out, ResolveResult{Path: p, TTL: ttl, Err: err}) - case <-ctx.Done(): - return - } - } - }() - - return out -} - -func emitOnceResult(ctx context.Context, outCh chan<- ResolveResult, r ResolveResult) { - select { - case outCh <- r: - case <-ctx.Done(): - } -} - -// Publish implements Publisher -func (ns *nameSys) Publish(ctx context.Context, name ci.PrivKey, value path.Path, options ...PublishOption) error { - ctx, span := startSpan(ctx, "MPNS.Publish") - defer span.End() - - // This is a bit hacky. We do this because the EOL is based on the current - // time, but also needed in the end of the function. Therefore, we parse - // the options immediately and add an option PublishWithEOL with the EOL - // calculated in this moment. - publishOpts := ProcessPublishOptions(options) - options = append(options, PublishWithEOL(publishOpts.EOL)) - - id, err := peer.IDFromPrivateKey(name) - if err != nil { - span.RecordError(err) - return err - } - span.SetAttributes(attribute.String("ID", id.String())) - if err := ns.ipnsPublisher.Publish(ctx, name, value, options...); err != nil { - // Invalidate the cache. Publishing may _partially_ succeed but - // still return an error. - ns.cacheInvalidate(string(id)) - span.RecordError(err) - return err - } - ttl := DefaultResolverCacheTTL - if publishOpts.TTL >= 0 { - ttl = publishOpts.TTL - } - if ttEOL := time.Until(publishOpts.EOL); ttEOL < ttl { - ttl = ttEOL - } - ns.cacheSet(string(id), value, ttl) - return nil -} diff --git a/namesys/namesys.go b/namesys/namesys.go index 4ff6b9cc0..7014303e1 100644 --- a/namesys/namesys.go +++ b/namesys/namesys.go @@ -14,203 +14,312 @@ package namesys import ( "context" - "errors" + "fmt" + "os" + "strings" "time" + lru "github.com/hashicorp/golang-lru/v2" + "github.com/ipfs/boxo/ipns" "github.com/ipfs/boxo/path" - logging "github.com/ipfs/go-log/v2" + "github.com/ipfs/go-cid" + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" ci "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/routing" + "github.com/miekg/dns" + madns "github.com/multiformats/go-multiaddr-dns" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) -var log = logging.Logger("namesys") - -var ( - // ErrResolveFailed signals an error when attempting to resolve. - ErrResolveFailed = errors.New("could not resolve name") +// namesys is a multi-protocol [NameSystem] that implements generic IPFS naming. +// It uses several [Resolver]s: +// +// 1. IPFS routing naming: SFS-like PKI names. +// 2. dns domains: resolves using links in DNS TXT records +// +// It can only publish to: 1. IPFS routing naming. +type namesys struct { + ds ds.Datastore - // ErrResolveRecursion signals a recursion-depth limit. - ErrResolveRecursion = errors.New("could not resolve name (recursion limit exceeded)") -) + dnsResolver, ipnsResolver resolver + ipnsPublisher Publisher -const ( - // DefaultDepthLimit is the default depth limit used by [Resolver]. - DefaultDepthLimit = 32 + staticMap map[string]path.Path + cache *lru.Cache[string, any] +} - // UnlimitedDepth allows infinite recursion in [Resolver]. You probably don't want - // to use this, but it's here if you absolutely trust resolution to eventually - // complete and can't put an upper limit on how many steps it will take. - UnlimitedDepth = 0 +var _ NameSystem = &namesys{} - // DefaultIPNSRecordTTL specifies the time that the record can be cached before - // checking if its validity again. - DefaultIPNSRecordTTL = time.Minute +type Option func(*namesys) error - // DefaultIPNSRecordEOL specifies the time that the network will cache IPNS - // records after being published. Records should be re-published before this - // interval expires. We use the same default expiration as the DHT. - DefaultIPNSRecordEOL = 48 * time.Hour +// WithCache is an option that instructs the name system to use a (LRU) cache of the given size. +func WithCache(size int) Option { + return func(ns *namesys) error { + if size <= 0 { + return fmt.Errorf("invalid cache size %d; must be > 0", size) + } - // DefaultResolverCacheTTL defines max TTL of a record placed in [NameSystem] cache. - DefaultResolverCacheTTL = time.Minute -) + cache, err := lru.New[string, any](size) + if err != nil { + return err + } -// NameSystem represents a cohesive name publishing and resolving system. -// -// Publishing a name is the process of establishing a mapping, a key-value -// pair, according to naming rules and databases. -// -// Resolving a name is the process of looking up the value associated with the -// key (name). -type NameSystem interface { - Resolver - Publisher + ns.cache = cache + return nil + } } -// ResolveResult is the return type for [Resolver.ResolveAsync]. -type ResolveResult struct { - Path path.Path - TTL time.Duration - Err error +// WithDNSResolver is an option that supplies a custom DNS resolver to use instead +// of the system default. +func WithDNSResolver(rslv madns.BasicResolver) Option { + return func(ns *namesys) error { + ns.dnsResolver = NewDNSResolver(rslv.LookupTXT) + return nil + } } -// Resolver is an object capable of resolving names. -type Resolver interface { - // Resolve performs a recursive lookup, returning the dereferenced path. For example, - // if example.com has a DNS TXT record pointing to: - // - // /ipns/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy - // - // and there is a DHT IPNS entry for - // - // QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy - // -> /ipfs/Qmcqtw8FfrVSBaRmbWwHxt3AuySBhJLcvmFYi3Lbc4xnwj - // - // then - // - // Resolve(ctx, "/ipns/ipfs.io") - // - // will resolve both names, returning - // - // /ipfs/Qmcqtw8FfrVSBaRmbWwHxt3AuySBhJLcvmFYi3Lbc4xnwj - // - // There is a default depth-limit to avoid infinite recursion. Most users will be fine with - // this default limit, but if you need to adjust the limit you can specify it as an option. - Resolve(ctx context.Context, name string, options ...ResolveOption) (value path.Path, ttl time.Duration, err error) - - // ResolveAsync performs recursive name lookup, like Resolve, but it returns entries as - // they are discovered in the DHT. Each returned result is guaranteed to be "better" - // (which usually means newer) than the previous one. - ResolveAsync(ctx context.Context, name string, options ...ResolveOption) <-chan ResolveResult +// WithDatastore is an option that supplies a datastore to use instead of an in-memory map datastore. +// The datastore is used to store published IPNS Records and make them available for querying. +func WithDatastore(ds ds.Datastore) Option { + return func(ns *namesys) error { + ns.ds = ds + return nil + } } -// ResolveOptions specifies options for resolving an IPNS Path. -type ResolveOptions struct { - // Depth is the recursion depth limit. - Depth uint +// NewNameSystem constructs an IPFS [NameSystem] based on the given [routing.ValueStore]. +func NewNameSystem(r routing.ValueStore, opts ...Option) (NameSystem, error) { + var staticMap map[string]path.Path - // DhtRecordCount is the number of IPNS Records to retrieve from the DHT - // (the best record is selected from this set). - DhtRecordCount uint + // Prewarm namesys cache with static records for deterministic tests and debugging. + // Useful for testing things like DNSLink without real DNS lookup. + // Example: + // IPFS_NS_MAP="dnslink-test.example.com:/ipfs/bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am" + if list := os.Getenv("IPFS_NS_MAP"); list != "" { + staticMap = make(map[string]path.Path) + for _, pair := range strings.Split(list, ",") { + mapping := strings.SplitN(pair, ":", 2) + key := mapping[0] + value, err := path.NewPath(mapping[1]) + if err != nil { + return nil, err + } + staticMap[key] = value + } + } - // DhtTimeout is the amount of time to wait for DHT records to be fetched - // and verified. A zero value indicates that there is no explicit timeout - // (although there is an implicit timeout due to dial timeouts within the DHT). - DhtTimeout time.Duration -} + ns := &namesys{ + staticMap: staticMap, + } -// DefaultResolveOptions returns the default options for resolving an IPNS Path. -func DefaultResolveOptions() ResolveOptions { - return ResolveOptions{ - Depth: DefaultDepthLimit, - DhtRecordCount: 16, - DhtTimeout: time.Minute, + for _, opt := range opts { + err := opt(ns) + if err != nil { + return nil, err + } } -} -// ResolveOption is used to set a resolve option. -type ResolveOption func(*ResolveOptions) + if ns.ds == nil { + ns.ds = dssync.MutexWrap(ds.NewMapDatastore()) + } -// ResolveWithDepth sets [ResolveOptions.Depth]. -func ResolveWithDepth(depth uint) ResolveOption { - return func(o *ResolveOptions) { - o.Depth = depth + if ns.dnsResolver == nil { + ns.dnsResolver = NewDNSResolver(madns.DefaultResolver.LookupTXT) } + + ns.ipnsResolver = NewIPNSResolver(r) + ns.ipnsPublisher = NewIPNSPublisher(r, ns.ds) + + return ns, nil } -// ResolveWithDhtRecordCount sets [ResolveOptions.DhtRecordCount]. -func ResolveWithDhtRecordCount(count uint) ResolveOption { - return func(o *ResolveOptions) { - o.DhtRecordCount = count +// Resolve implements Resolver. +func (ns *namesys) Resolve(ctx context.Context, name string, options ...ResolveOption) (path.Path, time.Duration, error) { + ctx, span := startSpan(ctx, "MPNS.Resolve", trace.WithAttributes(attribute.String("Name", name))) + defer span.End() + + if strings.HasPrefix(name, "/ipfs/") { + p, err := path.NewPath(name) + return p, 0, err } -} -// ResolveWithDhtTimeout sets [ResolveOptions.ResolveWithDhtTimeout]. -func ResolveWithDhtTimeout(timeout time.Duration) ResolveOption { - return func(o *ResolveOptions) { - o.DhtTimeout = timeout + if !strings.HasPrefix(name, "/") { + p, err := path.NewPath("/ipfs/" + name) + return p, 0, err } + + return resolve(ctx, ns, name, ProcessResolveOptions(options)) } -// ProcessResolveOptions converts an array of [ResolveOption] into a [ResolveOptions] object. -func ProcessResolveOptions(opts []ResolveOption) ResolveOptions { - resolveOptions := DefaultResolveOptions() - for _, option := range opts { - option(&resolveOptions) +func (ns *namesys) ResolveAsync(ctx context.Context, name string, options ...ResolveOption) <-chan ResolveResult { + ctx, span := startSpan(ctx, "MPNS.ResolveAsync", trace.WithAttributes(attribute.String("Name", name))) + defer span.End() + + if strings.HasPrefix(name, "/ipfs/") { + p, err := path.NewPath(name) + res := make(chan ResolveResult, 1) + res <- ResolveResult{Path: p, Err: err} + close(res) + return res } - return resolveOptions -} -// Publisher is an object capable of publishing particular names. -type Publisher interface { - // Publish establishes a name-value mapping. - // TODO make this not PrivKey specific. - Publish(ctx context.Context, name ci.PrivKey, value path.Path, options ...PublishOption) error -} + if !strings.HasPrefix(name, "/") { + p, err := path.NewPath("/ipfs/" + name) + res := make(chan ResolveResult, 1) + res <- ResolveResult{Path: p, Err: err} + close(res) + return res + } -// PublishOptions specifies options for publishing an IPNS Record. -type PublishOptions struct { - EOL time.Time - TTL time.Duration - CompatibleWithV1 bool + return resolveAsync(ctx, ns, name, ProcessResolveOptions(options)) } -// DefaultPublishOptions returns the default options for publishing an IPNS Record. -func DefaultPublishOptions() PublishOptions { - return PublishOptions{ - EOL: time.Now().Add(DefaultIPNSRecordEOL), - TTL: DefaultIPNSRecordTTL, +// resolveOnce implements resolver. +func (ns *namesys) resolveOnceAsync(ctx context.Context, name string, options ResolveOptions) <-chan ResolveResult { + ctx, span := startSpan(ctx, "MPNS.ResolveOnceAsync") + defer span.End() + + out := make(chan ResolveResult, 1) + + if !strings.HasPrefix(name, ipns.NamespacePrefix) { + name = ipns.NamespacePrefix + name + } + segments := strings.SplitN(name, "/", 4) + if len(segments) < 3 || segments[0] != "" { + log.Debugf("invalid name syntax for %s", name) + out <- ResolveResult{Err: ErrResolveFailed} + close(out) + return out } -} -// PublishOption is used to set an option for [PublishOptions]. -type PublishOption func(*PublishOptions) + key := segments[2] -// PublishWithEOL sets [PublishOptions.EOL]. -func PublishWithEOL(eol time.Time) PublishOption { - return func(o *PublishOptions) { - o.EOL = eol + // Resolver selection: + // 1. if it is a PeerID/CID/multihash resolve through "ipns". + // 2. if it is a domain name, resolve through "dns" + + var res resolver + ipnsKey, err := peer.Decode(key) + // CIDs in IPNS are expected to have libp2p-key multicodec + // We ease the transition by returning a more meaningful error with a valid CID + if err != nil { + ipnsCid, cidErr := cid.Decode(key) + if cidErr == nil && ipnsCid.Version() == 1 && ipnsCid.Type() != cid.Libp2pKey { + fixedCid := cid.NewCidV1(cid.Libp2pKey, ipnsCid.Hash()).String() + codecErr := fmt.Errorf("peer ID represented as CIDv1 require libp2p-key multicodec: retry with /ipns/%s", fixedCid) + log.Debugf("RoutingResolver: could not convert public key hash %q to peer ID: %s\n", key, codecErr) + out <- ResolveResult{Err: codecErr} + close(out) + return out + } } -} -// PublishWithEOL sets [PublishOptions.TTL]. -func PublishWithTTL(ttl time.Duration) PublishOption { - return func(o *PublishOptions) { - o.TTL = ttl + cacheKey := key + if err == nil { + cacheKey = string(ipnsKey) + } + + if p, ok := ns.cacheGet(cacheKey); ok { + var err error + if len(segments) > 3 { + p, err = path.Join(p, segments[3]) + } + span.SetAttributes(attribute.Bool("CacheHit", true)) + span.RecordError(err) + + out <- ResolveResult{Path: p, Err: err} + close(out) + return out } + span.SetAttributes(attribute.Bool("CacheHit", false)) + + if err == nil { + res = ns.ipnsResolver + } else if _, ok := dns.IsDomainName(key); ok { + res = ns.dnsResolver + } else { + out <- ResolveResult{Err: fmt.Errorf("invalid IPNS root: %q", key)} + close(out) + return out + } + + resCh := res.resolveOnceAsync(ctx, key, options) + var best ResolveResult + go func() { + defer close(out) + for { + select { + case res, ok := <-resCh: + if !ok { + if best != (ResolveResult{}) { + ns.cacheSet(cacheKey, best.Path, best.TTL) + } + return + } + if res.Err == nil { + best = res + } + p := res.Path + err := res.Err + ttl := res.TTL + + // Attach rest of the path + if len(segments) > 3 { + p, err = path.Join(p, segments[3]) + } + + emitOnceResult(ctx, out, ResolveResult{Path: p, TTL: ttl, Err: err}) + case <-ctx.Done(): + return + } + } + }() + + return out } -// PublishCompatibleWithV1 sets [PublishOptions.CompatibleWithV1]. -func PublishCompatibleWithV1(compatible bool) PublishOption { - return func(o *PublishOptions) { - o.CompatibleWithV1 = compatible +func emitOnceResult(ctx context.Context, outCh chan<- ResolveResult, r ResolveResult) { + select { + case outCh <- r: + case <-ctx.Done(): } } -// ProcessPublishOptions converts an array of [PublishOption] into a [PublishOptions] object. -func ProcessPublishOptions(opts []PublishOption) PublishOptions { - publishOptions := DefaultPublishOptions() - for _, option := range opts { - option(&publishOptions) +// Publish implements Publisher +func (ns *namesys) Publish(ctx context.Context, name ci.PrivKey, value path.Path, options ...PublishOption) error { + ctx, span := startSpan(ctx, "MPNS.Publish") + defer span.End() + + // This is a bit hacky. We do this because the EOL is based on the current + // time, but also needed in the end of the function. Therefore, we parse + // the options immediately and add an option PublishWithEOL with the EOL + // calculated in this moment. + publishOpts := ProcessPublishOptions(options) + options = append(options, PublishWithEOL(publishOpts.EOL)) + + id, err := peer.IDFromPrivateKey(name) + if err != nil { + span.RecordError(err) + return err + } + span.SetAttributes(attribute.String("ID", id.String())) + if err := ns.ipnsPublisher.Publish(ctx, name, value, options...); err != nil { + // Invalidate the cache. Publishing may _partially_ succeed but + // still return an error. + ns.cacheInvalidate(string(id)) + span.RecordError(err) + return err + } + ttl := DefaultResolverCacheTTL + if publishOpts.TTL >= 0 { + ttl = publishOpts.TTL + } + if ttEOL := time.Until(publishOpts.EOL); ttEOL < ttl { + ttl = ttEOL } - return publishOptions + ns.cacheSet(string(id), value, ttl) + return nil } diff --git a/namesys/mpns_cache.go b/namesys/namesys_cache.go similarity index 83% rename from namesys/mpns_cache.go rename to namesys/namesys_cache.go index 935bc8f18..d761971ee 100644 --- a/namesys/mpns_cache.go +++ b/namesys/namesys_cache.go @@ -6,7 +6,7 @@ import ( "github.com/ipfs/boxo/path" ) -func (ns *nameSys) cacheGet(name string) (path.Path, bool) { +func (ns *namesys) cacheGet(name string) (path.Path, bool) { // existence of optional mapping defined via IPFS_NS_MAP is checked first if ns.staticMap != nil { val, ok := ns.staticMap[name] @@ -39,7 +39,7 @@ func (ns *nameSys) cacheGet(name string) (path.Path, bool) { return nil, false } -func (ns *nameSys) cacheSet(name string, val path.Path, ttl time.Duration) { +func (ns *namesys) cacheSet(name string, val path.Path, ttl time.Duration) { if ns.cache == nil || ttl <= 0 { return } @@ -49,7 +49,7 @@ func (ns *nameSys) cacheSet(name string, val path.Path, ttl time.Duration) { }) } -func (ns *nameSys) cacheInvalidate(name string) { +func (ns *namesys) cacheInvalidate(name string) { if ns.cache == nil { return } diff --git a/namesys/namesys_test.go b/namesys/namesys_test.go index e6ab89236..a02371a59 100644 --- a/namesys/namesys_test.go +++ b/namesys/namesys_test.go @@ -64,7 +64,7 @@ func mockResolverTwo() *mockResolver { } func TestNamesysResolution(t *testing.T) { - r := &nameSys{ + r := &namesys{ ipnsResolver: mockResolverOne(), dnsResolver: mockResolverTwo(), } @@ -146,7 +146,7 @@ func TestPublishWithTTL(t *testing.T) { err = nsys.Publish(context.Background(), priv, p, PublishWithEOL(eol), PublishWithTTL(ttl)) require.NoError(t, err) - ientry, ok := nsys.(*nameSys).cache.Get(string(pid)) + ientry, ok := nsys.(*namesys).cache.Get(string(pid)) require.True(t, ok) entry, ok := ientry.(cacheEntry)