Skip to content

Commit 74077a1

Browse files
committed
internal/node: fall back to DNS-over-TCP when a TXT lookup comes back empty
libp2p's dnsaddr resolution ('no good addresses') was failing on a ChromeOS Crostini VM even though the _dnsaddr TXT record was valid: 'dig' with the default (UDP) resolver returned nothing, 'dig +tcp' returned it cleanly, and querying 8.8.8.8/1.1.1.1 directly over UDP came back partial and flagged as a malformed packet - while 'dig +trace' full traversal, and a second Linux machine on the same network, resolved it correctly every time. That points at this VM's handling of a fragmented/oversized UDP response, not the record or the network path, since a plain TCP query sidesteps the problem entirely. Add tcpFallbackResolver (internal/node/dnstcp.go), wrapping madns.BasicResolver: if the wrapped LookupTXT returns empty, retry with a direct, length-prefixed DNS-over-TCP exchange (via golang.org/x/net/dns/dnsmessage, already a dependency - no go.mod change) against the system's configured nameservers, before giving up. Wired in via an init() that replaces madns.DefaultResolver, so both sam-node and sam-box get it automatically with no flag or env var needed. Verified live against the real bananas.sam-mesh.dev testnet on the affected machine: 'sam-node run --join' went from a hard failure ('no good addresses' on 3 of 5 candidate routers) to 'SAM Node Online'. Added unit tests: a fake DNS-over-TCP server for the wire-format exchange, and coverage for both fallback branches (short-circuits under 1s when the UDP result is already non-empty; falls back and recovers when it's empty).
1 parent 51abff0 commit 74077a1

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

internal/node/dnstcp.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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 []string // nameserver "host:port" entries used for the TCP retry
59+
}
60+
61+
var _ madns.BasicResolver = (*tcpFallbackResolver)(nil)
62+
63+
// newTCPFallbackResolver builds a tcpFallbackResolver backed by def, using
64+
// the system's configured nameservers (from /etc/resolv.conf) for the TCP
65+
// retry. If none can be determined (e.g. on Windows), the TCP retry is
66+
// simply never attempted and def's own result/error is always returned.
67+
func newTCPFallbackResolver(def madns.BasicResolver) *tcpFallbackResolver {
68+
servers, err := systemNameservers()
69+
if err != nil {
70+
logger.Debugf("dnstcp: no nameservers for TCP fallback: %v", err)
71+
}
72+
return &tcpFallbackResolver{def: def, servers: servers}
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+
if len(r.servers) == 0 {
85+
return txt, err
86+
}
87+
tcpTXT, tcpErr := lookupTXTOverTCP(ctx, name, r.servers)
88+
if tcpErr != nil || len(tcpTXT) == 0 {
89+
logger.Debugf("dnstcp: TCP fallback for TXT %q also failed: %v", name, tcpErr)
90+
return txt, err
91+
}
92+
logger.Debugf("dnstcp: recovered %d TXT record(s) for %q via TCP after an empty UDP result", len(tcpTXT), name)
93+
return tcpTXT, nil
94+
}
95+
96+
// lookupTXTOverTCP resolves a TXT record with a direct, length-prefixed
97+
// DNS-over-TCP exchange (RFC 1035 section 4.2.2) against servers in order,
98+
// bypassing the standard resolver's UDP-first behavior entirely.
99+
func lookupTXTOverTCP(ctx context.Context, name string, servers []string) ([]string, error) {
100+
qname, err := dnsmessage.NewName(fqdnName(name))
101+
if err != nil {
102+
return nil, fmt.Errorf("invalid DNS name %q: %w", name, err)
103+
}
104+
query := dnsmessage.Message{
105+
Header: dnsmessage.Header{ID: uint16(time.Now().UnixNano()), RecursionDesired: true},
106+
Questions: []dnsmessage.Question{{
107+
Name: qname,
108+
Type: dnsmessage.TypeTXT,
109+
Class: dnsmessage.ClassINET,
110+
}},
111+
}
112+
packed, err := query.Pack()
113+
if err != nil {
114+
return nil, fmt.Errorf("failed to build DNS query: %w", err)
115+
}
116+
117+
var lastErr error
118+
for _, server := range servers {
119+
txt, err := exchangeTCP(ctx, server, packed)
120+
if err == nil {
121+
return txt, nil
122+
}
123+
lastErr = err
124+
}
125+
return nil, lastErr
126+
}
127+
128+
func exchangeTCP(ctx context.Context, server string, query []byte) ([]string, error) {
129+
d := net.Dialer{Timeout: 5 * time.Second}
130+
conn, err := d.DialContext(ctx, "tcp", server)
131+
if err != nil {
132+
return nil, fmt.Errorf("dial %s: %w", server, err)
133+
}
134+
defer func() { _ = conn.Close() }()
135+
deadline := time.Now().Add(5 * time.Second)
136+
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
137+
deadline = ctxDeadline
138+
}
139+
_ = conn.SetDeadline(deadline)
140+
141+
var lenBuf [2]byte
142+
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(query)))
143+
if _, err := conn.Write(lenBuf[:]); err != nil {
144+
return nil, fmt.Errorf("writing length prefix to %s: %w", server, err)
145+
}
146+
if _, err := conn.Write(query); err != nil {
147+
return nil, fmt.Errorf("writing query to %s: %w", server, err)
148+
}
149+
150+
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
151+
return nil, fmt.Errorf("reading response length from %s: %w", server, err)
152+
}
153+
resp := make([]byte, binary.BigEndian.Uint16(lenBuf[:]))
154+
if _, err := io.ReadFull(conn, resp); err != nil {
155+
return nil, fmt.Errorf("reading response from %s: %w", server, err)
156+
}
157+
158+
var msg dnsmessage.Message
159+
if err := msg.Unpack(resp); err != nil {
160+
return nil, fmt.Errorf("parsing DNS response from %s: %w", server, err)
161+
}
162+
if msg.RCode != dnsmessage.RCodeSuccess {
163+
return nil, fmt.Errorf("%s returned %s", server, msg.RCode)
164+
}
165+
166+
var out []string
167+
for _, ans := range msg.Answers {
168+
if txtRes, ok := ans.Body.(*dnsmessage.TXTResource); ok {
169+
out = append(out, strings.Join(txtRes.TXT, ""))
170+
}
171+
}
172+
return out, nil
173+
}
174+
175+
// systemNameservers reads the "nameserver" entries from /etc/resolv.conf.
176+
// It returns a nil slice, not an error, when the file doesn't exist (e.g. on
177+
// Windows) so callers can silently skip the TCP fallback there.
178+
func systemNameservers() ([]string, error) {
179+
data, err := os.ReadFile("/etc/resolv.conf")
180+
if err != nil {
181+
if os.IsNotExist(err) {
182+
return nil, nil
183+
}
184+
return nil, err
185+
}
186+
return parseNameservers(string(data)), nil
187+
}
188+
189+
// parseNameservers extracts "host:port" nameserver entries (port 53) from
190+
// the contents of a resolv.conf file.
191+
func parseNameservers(resolvConf string) []string {
192+
var servers []string
193+
for _, line := range strings.Split(resolvConf, "\n") {
194+
fields := strings.Fields(line)
195+
if len(fields) < 2 || fields[0] != "nameserver" {
196+
continue
197+
}
198+
servers = append(servers, net.JoinHostPort(fields[1], "53"))
199+
}
200+
return servers
201+
}
202+
203+
func init() {
204+
r, err := madns.NewResolver(madns.WithDefaultResolver(newTCPFallbackResolver(net.DefaultResolver)))
205+
if err != nil {
206+
logger.Warnf("dnstcp: failed to install TCP-fallback DNS resolver, dnsaddr resolution keeps its default UDP-only behavior: %v", err)
207+
return
208+
}
209+
madns.DefaultResolver = r
210+
}

internal/node/dnstcp_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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+
"errors"
21+
"io"
22+
"net"
23+
"testing"
24+
"time"
25+
26+
"golang.org/x/net/dns/dnsmessage"
27+
)
28+
29+
func TestParseNameservers(t *testing.T) {
30+
const resolvConf = `# comment, ignored
31+
nameserver 127.0.0.53
32+
options edns0 trust-ad
33+
nameserver 2001:db8::1
34+
search example.com
35+
`
36+
got := parseNameservers(resolvConf)
37+
want := []string{"127.0.0.53:53", "[2001:db8::1]:53"}
38+
if len(got) != len(want) {
39+
t.Fatalf("parseNameservers() = %v, want %v", got, want)
40+
}
41+
for i := range want {
42+
if got[i] != want[i] {
43+
t.Errorf("parseNameservers()[%d] = %q, want %q", i, got[i], want[i])
44+
}
45+
}
46+
}
47+
48+
// startFakeDNSOverTCP starts a minimal length-prefixed DNS-over-TCP server
49+
// that always answers with the given TXT strings, and returns its address.
50+
func startFakeDNSOverTCP(t *testing.T, txt []string) string {
51+
t.Helper()
52+
ln, err := net.Listen("tcp", "127.0.0.1:0")
53+
if err != nil {
54+
t.Fatalf("failed to start fake DNS server: %v", err)
55+
}
56+
t.Cleanup(func() { _ = ln.Close() })
57+
58+
go func() {
59+
conn, err := ln.Accept()
60+
if err != nil {
61+
return
62+
}
63+
defer func() { _ = conn.Close() }()
64+
65+
var lenBuf [2]byte
66+
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
67+
return
68+
}
69+
query := make([]byte, binary.BigEndian.Uint16(lenBuf[:]))
70+
if _, err := io.ReadFull(conn, query); err != nil {
71+
return
72+
}
73+
var q dnsmessage.Message
74+
if err := q.Unpack(query); err != nil {
75+
return
76+
}
77+
78+
resp := dnsmessage.Message{
79+
Header: dnsmessage.Header{ID: q.ID, Response: true, RCode: dnsmessage.RCodeSuccess},
80+
Questions: q.Questions,
81+
}
82+
for _, s := range txt {
83+
resp.Answers = append(resp.Answers, dnsmessage.Resource{
84+
Header: dnsmessage.ResourceHeader{
85+
Name: q.Questions[0].Name,
86+
Type: dnsmessage.TypeTXT,
87+
Class: dnsmessage.ClassINET,
88+
TTL: 300,
89+
},
90+
Body: &dnsmessage.TXTResource{TXT: []string{s}},
91+
})
92+
}
93+
packed, err := resp.Pack()
94+
if err != nil {
95+
return
96+
}
97+
var out [2]byte
98+
binary.BigEndian.PutUint16(out[:], uint16(len(packed)))
99+
_, _ = conn.Write(out[:])
100+
_, _ = conn.Write(packed)
101+
}()
102+
103+
return ln.Addr().String()
104+
}
105+
106+
func TestLookupTXTOverTCP(t *testing.T) {
107+
want := []string{"dnsaddr=/ip4/1.2.3.4/tcp/4501/p2p/foo", "dnsaddr=/ip4/1.2.3.4/udp/4501/quic-v1/p2p/foo"}
108+
server := startFakeDNSOverTCP(t, want)
109+
110+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
111+
defer cancel()
112+
got, err := lookupTXTOverTCP(ctx, "_dnsaddr.example.com", []string{server})
113+
if err != nil {
114+
t.Fatalf("lookupTXTOverTCP failed: %v", err)
115+
}
116+
if len(got) != len(want) {
117+
t.Fatalf("lookupTXTOverTCP() = %v, want %v", got, want)
118+
}
119+
for i := range want {
120+
if got[i] != want[i] {
121+
t.Errorf("lookupTXTOverTCP()[%d] = %q, want %q", i, got[i], want[i])
122+
}
123+
}
124+
}
125+
126+
// stubResolver is a minimal madns.BasicResolver test double.
127+
type stubResolver struct {
128+
txt []string
129+
txtErr error
130+
}
131+
132+
func (s *stubResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
133+
return nil, errors.New("not implemented")
134+
}
135+
136+
func (s *stubResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
137+
return s.txt, s.txtErr
138+
}
139+
140+
func TestTCPFallbackResolver_FallsBackWhenUDPEmpty(t *testing.T) {
141+
want := []string{"dnsaddr=/ip4/5.6.7.8/tcp/4501/p2p/bar"}
142+
server := startFakeDNSOverTCP(t, want)
143+
144+
r := &tcpFallbackResolver{def: &stubResolver{}, servers: []string{server}}
145+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
146+
defer cancel()
147+
148+
got, err := r.LookupTXT(ctx, "_dnsaddr.example.com")
149+
if err != nil {
150+
t.Fatalf("LookupTXT failed: %v", err)
151+
}
152+
if len(got) != 1 || got[0] != want[0] {
153+
t.Errorf("LookupTXT() = %v, want %v", got, want)
154+
}
155+
}
156+
157+
func TestTCPFallbackResolver_UsesUDPResultWhenNonEmpty(t *testing.T) {
158+
want := []string{"dnsaddr=/ip4/9.9.9.9/tcp/4501/p2p/baz"}
159+
// An unroutable TEST-NET-3 address (RFC 5737): if the wrapper incorrectly
160+
// attempted the TCP fallback despite a good UDP result, this would hang
161+
// until the context deadline instead of returning immediately.
162+
r := &tcpFallbackResolver{def: &stubResolver{txt: want}, servers: []string{"203.0.113.1:53"}}
163+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
164+
defer cancel()
165+
166+
start := time.Now()
167+
got, err := r.LookupTXT(ctx, "_dnsaddr.example.com")
168+
if err != nil {
169+
t.Fatalf("LookupTXT failed: %v", err)
170+
}
171+
if len(got) != 1 || got[0] != want[0] {
172+
t.Errorf("LookupTXT() = %v, want %v", got, want)
173+
}
174+
if elapsed := time.Since(start); elapsed > time.Second {
175+
t.Errorf("LookupTXT() took %s; a good UDP result must short-circuit the TCP fallback", elapsed)
176+
}
177+
}
178+
179+
func TestTCPFallbackResolver_NoServersReturnsOriginalResult(t *testing.T) {
180+
origErr := errors.New("boom")
181+
r := &tcpFallbackResolver{def: &stubResolver{txtErr: origErr}}
182+
183+
_, err := r.LookupTXT(context.Background(), "_dnsaddr.example.com")
184+
if !errors.Is(err, origErr) {
185+
t.Errorf("LookupTXT() error = %v, want %v", err, origErr)
186+
}
187+
}

0 commit comments

Comments
 (0)