master
go 120 lines 4.41 KB
Raw
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 }