server: canary DNS — authoritative zone with frozen §6.1 reference records
Stdlib DNS responder (no external deps): parses single-question queries
with EDNS OPT (bufsize, DO, ECS), serves the spec's frozen reference
records (ttl-{5,60,3600,86400} A/AAAA/TXT, many-rr 8×A in order, big-txt
~1800B), and per-query <nonce>.<session>.<zone> answers in 192.0.2.0/24.
UDP truncation sets TC past 512 (or the EDNS bufsize); TCP never
truncates — the EDNS-bufsize / TCP-fallback test. Every query is logged
(qname, resolver, transport, EDNS, ECS, case) and surfaced per session
prefix in GET /v1/sessions/{id}/observations as dns_canary. Profile gains
canary_zone + the canary-dns capability when configured.
Wire format validated against an independent client (correct rcodes,
answer counts, TC behavior, full EDNS response); unit tests cover
references, truncation-vs-EDNS, logging, NXDOMAIN.
Versioning: patch-first convention recorded in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4f5499198b
commit
35baf70cdb
@@ -0,0 +1,161 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user