/ core / node / p2pforge_resolver.go
p2pforge_resolver.go
  1  package node
  2  
  3  import (
  4  	"context"
  5  	"net"
  6  	"net/netip"
  7  	"strings"
  8  
  9  	"github.com/libp2p/go-libp2p/core/peer"
 10  	madns "github.com/multiformats/go-multiaddr-dns"
 11  )
 12  
 13  // p2pForgeResolver implements madns.BasicResolver for deterministic resolution
 14  // of p2p-forge domains (e.g., *.libp2p.direct) without network I/O for A/AAAA queries.
 15  //
 16  // p2p-forge encodes IP addresses in DNS hostnames:
 17  //   - IPv4: 1-2-3-4.peerID.libp2p.direct -> 1.2.3.4
 18  //   - IPv6: 2001-db8--1.peerID.libp2p.direct -> 2001:db8::1
 19  //
 20  // When local parsing fails (invalid format, invalid peerID, etc.), the resolver
 21  // falls back to network DNS. This ensures future <peerID>.libp2p.direct records
 22  // can still resolve if the authoritative DNS adds support for them.
 23  //
 24  // TXT queries always delegate to the fallback resolver. This is important for
 25  // p2p-forge/client ACME DNS-01 challenges to work correctly, as Let's Encrypt
 26  // needs to verify TXT records at _acme-challenge.peerID.libp2p.direct.
 27  //
 28  // See: https://github.com/ipshipyard/p2p-forge
 29  type p2pForgeResolver struct {
 30  	suffixes []string
 31  	fallback madns.BasicResolver
 32  }
 33  
 34  // Compile-time check that p2pForgeResolver implements madns.BasicResolver.
 35  var _ madns.BasicResolver = (*p2pForgeResolver)(nil)
 36  
 37  // NewP2PForgeResolver creates a resolver for the given p2p-forge domain suffixes.
 38  // Each suffix should be a bare domain like "libp2p.direct" (without leading dot).
 39  // When local IP parsing fails, queries fall back to the provided resolver.
 40  // TXT queries always delegate to the fallback resolver for ACME compatibility.
 41  func NewP2PForgeResolver(suffixes []string, fallback madns.BasicResolver) *p2pForgeResolver {
 42  	normalized := make([]string, len(suffixes))
 43  	for i, s := range suffixes {
 44  		normalized[i] = strings.ToLower(strings.TrimSuffix(s, "."))
 45  	}
 46  	return &p2pForgeResolver{suffixes: normalized, fallback: fallback}
 47  }
 48  
 49  // LookupIPAddr parses IP addresses encoded in the hostname.
 50  //
 51  // Format: <encoded-ip>.<peerID>.<suffix>
 52  //   - IPv4: 192-168-1-1.peerID.libp2p.direct -> [192.168.1.1]
 53  //   - IPv6: 2001-db8--1.peerID.libp2p.direct -> [2001:db8::1]
 54  //
 55  // If the hostname doesn't match the expected format (wrong suffix, invalid peerID,
 56  // invalid IP encoding, or peerID-only), the lookup falls back to network DNS.
 57  // This allows future DNS records like <peerID>.libp2p.direct to resolve normally.
 58  func (r *p2pForgeResolver) LookupIPAddr(ctx context.Context, hostname string) ([]net.IPAddr, error) {
 59  	// DNS is case-insensitive, normalize to lowercase
 60  	hostname = strings.ToLower(strings.TrimSuffix(hostname, "."))
 61  
 62  	// find matching suffix and extract subdomain
 63  	var subdomain string
 64  	for _, suffix := range r.suffixes {
 65  		if sub, found := strings.CutSuffix(hostname, "."+suffix); found {
 66  			subdomain = sub
 67  			break
 68  		}
 69  	}
 70  	if subdomain == "" {
 71  		// not a p2p-forge domain, fallback to network
 72  		return r.fallback.LookupIPAddr(ctx, hostname)
 73  	}
 74  
 75  	// split subdomain into parts: should be [ip-prefix, peerID]
 76  	parts := strings.Split(subdomain, ".")
 77  	if len(parts) != 2 {
 78  		// not the expected <ip>.<peerID> format, fallback to network
 79  		return r.fallback.LookupIPAddr(ctx, hostname)
 80  	}
 81  
 82  	encodedIP := parts[0]
 83  	peerIDStr := parts[1]
 84  
 85  	// validate peerID (same validation as libp2p.direct DNS server)
 86  	if _, err := peer.Decode(peerIDStr); err != nil {
 87  		// invalid peerID, fallback to network
 88  		return r.fallback.LookupIPAddr(ctx, hostname)
 89  	}
 90  
 91  	// RFC 1123: hostname labels cannot start or end with hyphen
 92  	if len(encodedIP) == 0 || encodedIP[0] == '-' || encodedIP[len(encodedIP)-1] == '-' {
 93  		// invalid hostname label, fallback to network
 94  		return r.fallback.LookupIPAddr(ctx, hostname)
 95  	}
 96  
 97  	// try parsing as IPv4 first: segments joined by "-" become "."
 98  	segments := strings.Split(encodedIP, "-")
 99  	if len(segments) == 4 {
100  		ipv4Str := strings.Join(segments, ".")
101  		if ip, err := netip.ParseAddr(ipv4Str); err == nil && ip.Is4() {
102  			return []net.IPAddr{{IP: ip.AsSlice()}}, nil
103  		}
104  	}
105  
106  	// try parsing as IPv6: segments joined by "-" become ":"
107  	ipv6Str := strings.Join(segments, ":")
108  	if ip, err := netip.ParseAddr(ipv6Str); err == nil && ip.Is6() {
109  		return []net.IPAddr{{IP: ip.AsSlice()}}, nil
110  	}
111  
112  	// IP parsing failed, fallback to network
113  	return r.fallback.LookupIPAddr(ctx, hostname)
114  }
115  
116  // LookupTXT delegates to the fallback resolver to support ACME DNS-01 challenges
117  // and any other TXT record lookups on p2p-forge domains.
118  func (r *p2pForgeResolver) LookupTXT(ctx context.Context, hostname string) ([]string, error) {
119  	return r.fallback.LookupTXT(ctx, hostname)
120  }