Files
mrambossekandClaude Opus 5 8118e213ae server: upstream trains, observed TTL/DSCP/ECN, rate limits, action ids
Types 0x03/0x04/0x05 land with a bounded columnar train buffer (head kept,
truncation declared) and grant-free multi-part reports - a report row is
smaller than the packet it answers, so $3.4 holds without a grant. The
read loop now collects TTL/TOS cmsgs on Linux, replacing the 0xFF stubs in
the observation block with what the kernel saw; downtrain gained a dscp
parameter, so DSCP survival is measurable in both directions.

Rate limiting ($2.5) exists now: per-credential AND per-source buckets,
429 on the control plane, silent drop on the data plane after the HMAC
gate and before the replay window. UDP ceilings default above the largest
legitimate run - a limit that clips a real measurement produces a
confidently wrong number.

Every granted packet carries its action_id at payload[8:16]; overlapping
actions were unattributable before. Canary DNS logs now honor the stated
24h privacy default. /admin/enroll-tokens answers the spec's JSON shape.
protocol_version 1.0.1 (additive).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:04:54 +02:00

189 lines
6.3 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package canarydns
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"time"
)
// 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"), 24*time.Hour)
}
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)
}
}
func TestQueryLogRetentionForgetsOldEntries(t *testing.T) {
s := newTestServer() // 24 h retention
now := time.Now().UTC()
s.record(Query{QName: "old.sess1.c.echo-lot.app", At: now.Add(-25 * time.Hour)})
s.record(Query{QName: "fresh.sess1.c.echo-lot.app", At: now})
got := s.RecentForPrefix("sess1")
if len(got) != 1 || got[0].QName != "fresh.sess1.c.echo-lot.app" {
t.Fatalf("retention not enforced: %+v", got)
}
// Reads must age the log too: an idle server still has to forget on schedule.
s.log[0].At = now.Add(-25 * time.Hour)
if got := s.RecentForPrefix("sess1"); len(got) != 0 {
t.Fatalf("read path did not expire entries: %+v", got)
}
}
func TestZeroRetentionKeepsEverything(t *testing.T) {
s := New("c.echo-lot.app", "fmr", netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1"), 0)
s.record(Query{QName: "ancient.sess1.c.echo-lot.app", At: time.Now().UTC().Add(-1000 * time.Hour)})
if got := s.RecentForPrefix("sess1"); len(got) != 1 {
t.Fatal("retention 0 must mean 'ring cap only', not 'keep nothing'")
}
}