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 }