fmr binds two IPv4 addresses. connFor picked whichever socket of the right family came first in the bind list, so a downtrain for a session established on .150 went out from .151 — and every packet was dropped by the client's NAT, which has no mapping for that pair. tcpdump on the server showed all 50 leaving; the client saw none. Read as "100% downstream loss", which is the worst kind of wrong: a confident measurement of something that never happened. Sessions now record which of our own bound addresses received their traffic, and granted sends (and delayed echo) go back out through that socket. The fallback to a family match is kept for the case where nothing has been received yet, and the test pins both paths — a single-homed lab can never reproduce this. Also: the client-side halves of the same work — anonymizer (core-privacy), local run archive with retention (core-archive), upload client, and the app's settings and history screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package canarydns
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"net"
|
|
"net/netip"
|
|
"testing"
|
|
)
|
|
|
|
// buildQuery makes a single-question DNS query, optionally with an EDNS OPT.
|
|
func buildQuery(name string, qtype uint16, ednsBufsize int) []byte {
|
|
msg := make([]byte, 12)
|
|
binary.BigEndian.PutUint16(msg[0:2], 0x1234)
|
|
binary.BigEndian.PutUint16(msg[2:4], flagRD)
|
|
binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT
|
|
msg = append(msg, encodeName(name)...)
|
|
msg = binary.BigEndian.AppendUint16(msg, qtype)
|
|
msg = binary.BigEndian.AppendUint16(msg, classIN)
|
|
if ednsBufsize > 0 {
|
|
binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT
|
|
msg = append(msg, 0) // root name
|
|
msg = binary.BigEndian.AppendUint16(msg, typeOPT)
|
|
msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize))
|
|
msg = binary.BigEndian.AppendUint32(msg, 0)
|
|
msg = binary.BigEndian.AppendUint16(msg, 0)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// parseAnswers pulls (type, ttl, rdata) tuples from a response.
|
|
type ans struct {
|
|
typ uint16
|
|
ttl uint32
|
|
data []byte
|
|
}
|
|
|
|
func parseResponse(t *testing.T, resp []byte) (flags uint16, answers []ans) {
|
|
t.Helper()
|
|
flags = binary.BigEndian.Uint16(resp[2:4])
|
|
qd := binary.BigEndian.Uint16(resp[4:6])
|
|
an := binary.BigEndian.Uint16(resp[6:8])
|
|
off := 12
|
|
for i := uint16(0); i < qd; i++ {
|
|
_, next, ok := readName(resp, off)
|
|
if !ok {
|
|
t.Fatal("bad question name")
|
|
}
|
|
off = next + 4
|
|
}
|
|
for i := uint16(0); i < an; i++ {
|
|
_, next, ok := readName(resp, off)
|
|
if !ok {
|
|
t.Fatal("bad answer name")
|
|
}
|
|
typ := binary.BigEndian.Uint16(resp[next : next+2])
|
|
ttl := binary.BigEndian.Uint32(resp[next+4 : next+8])
|
|
rdlen := int(binary.BigEndian.Uint16(resp[next+8 : next+10]))
|
|
answers = append(answers, ans{typ, ttl, resp[next+10 : next+10+rdlen]})
|
|
off = next + 10 + rdlen
|
|
}
|
|
return
|
|
}
|
|
|
|
func newTestServer() *Server {
|
|
return New("c.echo-lot.app", "fmr", netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1"))
|
|
}
|
|
|
|
func TestReferenceRecords(t *testing.T) {
|
|
s := newTestServer()
|
|
resolver := netip.MustParseAddr("198.51.100.7")
|
|
|
|
// ttl-5 A → 192.0.2.5, TTL 5
|
|
resp := s.handle(buildQuery("ttl-5.c.echo-lot.app", typeA, 0), resolver, "udp")
|
|
_, answers := parseResponse(t, resp)
|
|
if len(answers) != 1 || answers[0].ttl != 5 || !netip.AddrFrom4([4]byte(answers[0].data)).IsValid() {
|
|
t.Fatalf("ttl-5 A: %+v", answers)
|
|
}
|
|
if got := net.IP(answers[0].data).String(); got != "192.0.2.5" {
|
|
t.Fatalf("ttl-5 A = %s, want 192.0.2.5", got)
|
|
}
|
|
|
|
// many-rr → exactly 8 A records, in order .101..108
|
|
resp = s.handle(buildQuery("many-rr.c.echo-lot.app", typeA, 0), resolver, "udp")
|
|
_, answers = parseResponse(t, resp)
|
|
if len(answers) != 8 {
|
|
t.Fatalf("many-rr: got %d A records, want 8", len(answers))
|
|
}
|
|
for i, a := range answers {
|
|
if a.data[3] != byte(101+i) {
|
|
t.Fatalf("many-rr order: record %d = .%d, want .%d", i, a.data[3], 101+i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBigTxtTruncationVsEDNS(t *testing.T) {
|
|
s := newTestServer()
|
|
resolver := netip.MustParseAddr("198.51.100.7")
|
|
|
|
// No EDNS → 512 cap → TC set, answers dropped.
|
|
resp := s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "udp")
|
|
flags, answers := parseResponse(t, resp)
|
|
if flags&flagTC == 0 {
|
|
t.Fatal("big-txt over plain UDP should set TC")
|
|
}
|
|
if len(answers) != 0 {
|
|
t.Fatalf("truncated response should carry no answers, got %d", len(answers))
|
|
}
|
|
|
|
// EDNS bufsize 4096 → full answer, no TC.
|
|
resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 4096), resolver, "udp")
|
|
flags, answers = parseResponse(t, resp)
|
|
if flags&flagTC != 0 {
|
|
t.Fatal("big-txt with EDNS 4096 should not truncate")
|
|
}
|
|
if len(answers) != 1 {
|
|
t.Fatalf("want 1 TXT answer, got %d", len(answers))
|
|
}
|
|
|
|
// TCP → never truncates.
|
|
resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "tcp")
|
|
flags, _ = parseResponse(t, resp)
|
|
if flags&flagTC != 0 {
|
|
t.Fatal("TCP must not truncate")
|
|
}
|
|
}
|
|
|
|
func TestQueryLogAndPerPrefix(t *testing.T) {
|
|
s := newTestServer()
|
|
s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 1232), netip.MustParseAddr("198.51.100.7"), "udp")
|
|
s.handle(buildQuery("def456.other.c.echo-lot.app", typeA, 0), netip.MustParseAddr("203.0.113.9"), "udp")
|
|
|
|
all := s.RecentForPrefix("sessprefix1")
|
|
if len(all) != 1 {
|
|
t.Fatalf("per-prefix filter: got %d, want 1", len(all))
|
|
}
|
|
q := all[0]
|
|
if q.ResolverIP != "198.51.100.7" || q.Transport != "udp" {
|
|
t.Fatalf("logged resolver/transport wrong: %+v", q)
|
|
}
|
|
if !q.EDNS.Present || q.EDNS.Bufsize != 1232 {
|
|
t.Fatalf("EDNS not captured: %+v", q.EDNS)
|
|
}
|
|
// nonce answer is deterministic + in doc range
|
|
resp := s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp")
|
|
_, answers := parseResponse(t, resp)
|
|
if len(answers) != 1 || answers[0].data[0] != 192 || answers[0].data[1] != 0 || answers[0].data[2] != 2 {
|
|
t.Fatalf("nonce answer not in 192.0.2.0/24: %+v", answers)
|
|
}
|
|
}
|
|
|
|
func TestOutOfZoneNXDomain(t *testing.T) {
|
|
s := newTestServer()
|
|
resp := s.handle(buildQuery("example.com", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp")
|
|
flags, _ := parseResponse(t, resp)
|
|
if flags&0x000F != rcodeNXDomain {
|
|
t.Fatalf("out-of-zone should be NXDOMAIN, flags=%#x", flags)
|
|
}
|
|
}
|