Skip to content

Commit 2bce37d

Browse files
authored
Merge pull request #273 from aojea/dns-tcp-fallback
internal/node: fall back to DNS-over-TCP when a TXT lookup comes back…
2 parents 51abff0 + b4353ae commit 2bce37d

2 files changed

Lines changed: 465 additions & 0 deletions

File tree

internal/node/dnstcp.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package node
16+
17+
import (
18+
"context"
19+
"encoding/binary"
20+
"fmt"
21+
"io"
22+
"net"
23+
"os"
24+
"strings"
25+
"time"
26+
27+
madns "github.com/multiformats/go-multiaddr-dns"
28+
"golang.org/x/net/dns/dnsmessage"
29+
)
30+
31+
// fqdnName appends a trailing dot, which dnsmessage.NewName requires for a
32+
// fully-qualified name, unless the caller already supplied one.
33+
func fqdnName(name string) string {
34+
if strings.HasSuffix(name, ".") {
35+
return name
36+
}
37+
return name + "."
38+
}
39+
40+
// tcpFallbackResolver wraps a madns.BasicResolver and retries LookupTXT with
41+
// a direct DNS-over-TCP exchange whenever the wrapped (UDP-based) lookup
42+
// comes back empty. Some networks silently drop or corrupt UDP DNS responses
43+
// once they're large enough to need fragmentation, instead of returning the
44+
// truncated reply a resolver would normally retry over TCP on its own - which
45+
// breaks resolution of libp2p's dnsaddr TXT records (often several entries)
46+
// even though the record itself is fine and a plain TCP query resolves it.
47+
//
48+
// Observed concretely on a ChromeOS Crostini VM: its DNS proxy answered a
49+
// multi-entry dnsaddr TXT record with an empty result over plain UDP, and a
50+
// direct UDP query to 8.8.8.8/1.1.1.1 from the same VM came back partial and
51+
// flagged as a malformed packet - while `dig +tcp` and a second Linux machine
52+
// on the same network resolved it correctly every time. That points at the
53+
// VM's handling of a fragmented/oversized UDP response, not the record or
54+
// the wider network, so retrying over TCP - which needs no fragmentation -
55+
// is the fix rather than anything specific to that one environment.
56+
type tcpFallbackResolver struct {
57+
def madns.BasicResolver
58+
// servers overrides the nameservers used for the TCP retry; only set in
59+
// tests. Production leaves this nil and reads /etc/resolv.conf fresh on
60+
// every fallback instead, so nameserver changes take effect immediately.
61+
servers []string
62+
}
63+
64+
var _ madns.BasicResolver = (*tcpFallbackResolver)(nil)
65+
66+
// newTCPFallbackResolver builds a tcpFallbackResolver backed by def. The
67+
// system's configured nameservers are read from /etc/resolv.conf on demand
68+
// for each TCP retry (see LookupTXT) rather than cached here, so the node
69+
// keeps working across VPN connects, Wi-Fi switches, and DHCP renewals
70+
// without needing a restart.
71+
func newTCPFallbackResolver(def madns.BasicResolver) *tcpFallbackResolver {
72+
return &tcpFallbackResolver{def: def}
73+
}
74+
75+
func (r *tcpFallbackResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
76+
return r.def.LookupIPAddr(ctx, host)
77+
}
78+
79+
func (r *tcpFallbackResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
80+
txt, err := r.def.LookupTXT(ctx, name)
81+
if err == nil && len(txt) > 0 {
82+
return txt, nil
83+
}
84+
// r.servers is a test-only override; production always re-reads
85+
// /etc/resolv.conf here so nameserver changes take effect immediately.
86+
servers := r.servers
87+
if len(servers) == 0 {
88+
var sysErr error
89+
servers, sysErr = systemNameservers()
90+
if sysErr != nil {
91+
logger.Debugf("dnstcp: no nameservers for TCP fallback: %v", sysErr)
92+
}
93+
}
94+
if len(servers) == 0 {
95+
return txt, err
96+
}
97+
tcpTXT, tcpErr := lookupTXTOverTCP(ctx, name, servers)
98+
if tcpErr != nil || len(tcpTXT) == 0 {
99+
logger.Debugf("dnstcp: TCP fallback for TXT %q also failed: %v", name, tcpErr)
100+
return txt, err
101+
}
102+
logger.Debugf("dnstcp: recovered %d TXT record(s) for %q via TCP after an empty UDP result", len(tcpTXT), name)
103+
return tcpTXT, nil
104+
}
105+
106+
// lookupTXTOverTCP resolves a TXT record with a direct, length-prefixed
107+
// DNS-over-TCP exchange (RFC 1035 section 4.2.2) against servers in order,
108+
// bypassing the standard resolver's UDP-first behavior entirely.
109+
func lookupTXTOverTCP(ctx context.Context, name string, servers []string) ([]string, error) {
110+
qname, err := dnsmessage.NewName(fqdnName(name))
111+
if err != nil {
112+
return nil, fmt.Errorf("invalid DNS name %q: %w", name, err)
113+
}
114+
query := dnsmessage.Message{
115+
Header: dnsmessage.Header{ID: uint16(time.Now().UnixNano()), RecursionDesired: true},
116+
Questions: []dnsmessage.Question{{
117+
Name: qname,
118+
Type: dnsmessage.TypeTXT,
119+
Class: dnsmessage.ClassINET,
120+
}},
121+
}
122+
packed, err := query.Pack()
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to build DNS query: %w", err)
125+
}
126+
127+
var lastErr error
128+
for _, server := range servers {
129+
txt, err := exchangeTCP(ctx, server, packed)
130+
if err == nil {
131+
return txt, nil
132+
}
133+
lastErr = err
134+
}
135+
return nil, lastErr
136+
}
137+
138+
func exchangeTCP(ctx context.Context, server string, query []byte) ([]string, error) {
139+
d := net.Dialer{Timeout: 5 * time.Second}
140+
conn, err := d.DialContext(ctx, "tcp", server)
141+
if err != nil {
142+
return nil, fmt.Errorf("dial %s: %w", server, err)
143+
}
144+
defer func() { _ = conn.Close() }()
145+
deadline := time.Now().Add(5 * time.Second)
146+
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
147+
deadline = ctxDeadline
148+
}
149+
_ = conn.SetDeadline(deadline)
150+
151+
// conn.Write/Read below only respect the deadline above, not ctx
152+
// cancellation directly; close the connection as soon as the caller's
153+
// context is done so a cancelled/timed-out caller isn't stuck waiting
154+
// out the full deadline.
155+
done := make(chan struct{})
156+
defer close(done)
157+
go func() {
158+
select {
159+
case <-ctx.Done():
160+
_ = conn.Close()
161+
case <-done:
162+
}
163+
}()
164+
165+
var lenBuf [2]byte
166+
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(query)))
167+
if _, err := conn.Write(lenBuf[:]); err != nil {
168+
return nil, fmt.Errorf("writing length prefix to %s: %w", server, err)
169+
}
170+
if _, err := conn.Write(query); err != nil {
171+
return nil, fmt.Errorf("writing query to %s: %w", server, err)
172+
}
173+
174+
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
175+
return nil, fmt.Errorf("reading response length from %s: %w", server, err)
176+
}
177+
resp := make([]byte, binary.BigEndian.Uint16(lenBuf[:]))
178+
if _, err := io.ReadFull(conn, resp); err != nil {
179+
return nil, fmt.Errorf("reading response from %s: %w", server, err)
180+
}
181+
182+
var msg dnsmessage.Message
183+
if err := msg.Unpack(resp); err != nil {
184+
return nil, fmt.Errorf("parsing DNS response from %s: %w", server, err)
185+
}
186+
if msg.RCode != dnsmessage.RCodeSuccess {
187+
return nil, fmt.Errorf("%s returned %s", server, msg.RCode)
188+
}
189+
190+
var out []string
191+
for _, ans := range msg.Answers {
192+
if txtRes, ok := ans.Body.(*dnsmessage.TXTResource); ok {
193+
out = append(out, strings.Join(txtRes.TXT, ""))
194+
}
195+
}
196+
return out, nil
197+
}
198+
199+
// resolvConfPath is a package-level var (rather than a hardcoded literal)
200+
// purely so tests can point it at a fixture without touching the real file.
201+
var resolvConfPath = "/etc/resolv.conf"
202+
203+
// systemNameservers reads the "nameserver" entries from /etc/resolv.conf.
204+
// It returns a nil slice, not an error, when the file doesn't exist (e.g. on
205+
// Windows) so callers can silently skip the TCP fallback there.
206+
func systemNameservers() ([]string, error) {
207+
data, err := os.ReadFile(resolvConfPath)
208+
if err != nil {
209+
if os.IsNotExist(err) {
210+
return nil, nil
211+
}
212+
return nil, err
213+
}
214+
return parseNameservers(string(data)), nil
215+
}
216+
217+
// parseNameservers extracts "host:port" nameserver entries (port 53) from
218+
// the contents of a resolv.conf file.
219+
func parseNameservers(resolvConf string) []string {
220+
var servers []string
221+
for _, line := range strings.Split(resolvConf, "\n") {
222+
fields := strings.Fields(line)
223+
if len(fields) < 2 || fields[0] != "nameserver" {
224+
continue
225+
}
226+
servers = append(servers, net.JoinHostPort(fields[1], "53"))
227+
}
228+
return servers
229+
}
230+
231+
func init() {
232+
r, err := madns.NewResolver(madns.WithDefaultResolver(newTCPFallbackResolver(net.DefaultResolver)))
233+
if err != nil {
234+
logger.Warnf("dnstcp: failed to install TCP-fallback DNS resolver, dnsaddr resolution keeps its default UDP-only behavior: %v", err)
235+
return
236+
}
237+
madns.DefaultResolver = r
238+
}

0 commit comments

Comments
 (0)