| 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 | } |