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>
314 lines
8.8 KiB
Go
314 lines
8.8 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package canarydns
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"hash/fnv"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DNS constants (RFC 1035 + RFC 6891 EDNS).
|
|
const (
|
|
typeA = 1
|
|
typeNS = 2
|
|
typeTXT = 16
|
|
typeAAAA = 28
|
|
typeOPT = 41
|
|
|
|
classIN = 1
|
|
|
|
rcodeNoError = 0
|
|
rcodeNXDomain = 3
|
|
|
|
flagQR = 0x8000
|
|
flagAA = 0x0400
|
|
flagTC = 0x0200
|
|
flagRD = 0x0100
|
|
flagRA = 0x0080
|
|
|
|
udpMaxNoEDNS = 512
|
|
ednsDO = 0x8000 // DO bit lives in the OPT TTL field's high half
|
|
optECS = 8 // EDNS Client Subnet option code
|
|
)
|
|
|
|
// Query is one logged canary lookup (spec §6 dns_canary shape).
|
|
type Query struct {
|
|
QName string `json:"qname"`
|
|
At time.Time `json:"at"`
|
|
ResolverIP string `json:"resolver_ip"`
|
|
Transport string `json:"transport"` // "udp" | "tcp"
|
|
EDNS edns `json:"edns"`
|
|
ECS string `json:"ecs,omitempty"`
|
|
CasePreserved bool `json:"case_preserved"`
|
|
// qname_minimized is not reliably detectable authoritative-side without
|
|
// cross-query correlation; left false (TODO) rather than guessed.
|
|
QNameMinimized bool `json:"qname_minimized"`
|
|
}
|
|
|
|
type edns struct {
|
|
Present bool `json:"present"`
|
|
Bufsize int `json:"bufsize"`
|
|
Flags []string `json:"flags"`
|
|
}
|
|
|
|
// Server is the authoritative responder for one canary zone.
|
|
type Server struct {
|
|
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
|
|
nsName string // this server's own name for NS/authority answers
|
|
primaryV4 netip.Addr
|
|
primaryV6 netip.Addr
|
|
retention time.Duration // query-log age limit; <= 0 means only the ring cap bounds it
|
|
|
|
mu sync.Mutex
|
|
log []Query // ring, newest last
|
|
}
|
|
|
|
const logCap = 8192
|
|
|
|
// New creates a server for zone (with or without trailing dot). nsName is the
|
|
// server's own hostname (for the zone's NS record); primary v4/v6 are this
|
|
// host's addresses used to answer the zone apex / NS glue. retention is how
|
|
// long logged queries are kept (spec §6; the privacy default is 24 h).
|
|
func New(zone, nsName string, v4, v6 netip.Addr, retention time.Duration) *Server {
|
|
z := strings.ToLower(strings.TrimSuffix(zone, ".")) + "."
|
|
return &Server{
|
|
zone: z, nsName: strings.TrimSuffix(nsName, ".") + ".",
|
|
primaryV4: v4, primaryV6: v6, retention: retention,
|
|
}
|
|
}
|
|
|
|
// RecentForPrefix returns logged queries whose qname contains ".<prefix>."
|
|
// (the session prefix the app embeds: <nonce>.<session-prefix>.<zone>).
|
|
func (s *Server) RecentForPrefix(prefix string) []Query {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.dropExpiredLocked(time.Now().UTC())
|
|
needle := "." + strings.ToLower(prefix) + "."
|
|
var out []Query
|
|
for _, q := range s.log {
|
|
if strings.Contains(strings.ToLower(q.QName), needle) {
|
|
out = append(out, q)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) record(q Query) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.dropExpiredLocked(q.At)
|
|
if len(s.log) >= logCap {
|
|
s.log = s.log[1:]
|
|
}
|
|
s.log = append(s.log, q)
|
|
}
|
|
|
|
// dropExpiredLocked enforces the retention window on the query log.
|
|
//
|
|
// The 24-hour retention was advertised as the privacy default (spec §6/§7) and then not
|
|
// enforced: the ring only bounded *count*, so on a quiet server a resolver's queries could sit
|
|
// in memory for weeks. Aged out on every write and every read — whichever comes first — so an
|
|
// idle log still forgets on schedule the moment anyone looks. Entries are appended in time
|
|
// order, so expiry is always a prefix of the slice.
|
|
func (s *Server) dropExpiredLocked(now time.Time) {
|
|
if s.retention <= 0 {
|
|
return
|
|
}
|
|
cutoff := now.Add(-s.retention)
|
|
i := 0
|
|
for i < len(s.log) && s.log[i].At.Before(cutoff) {
|
|
i++
|
|
}
|
|
if i > 0 {
|
|
s.log = append([]Query(nil), s.log[i:]...) // reallocate so the old backing array frees
|
|
}
|
|
}
|
|
|
|
// ServeUDP / ServeTCP run read loops; call one per bound address.
|
|
func (s *Server) ServeUDP(conn *net.UDPConn) error {
|
|
buf := make([]byte, 1500)
|
|
for {
|
|
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp := s.handle(buf[:n], raddr.Addr(), "udp")
|
|
if resp != nil {
|
|
_, _ = conn.WriteToUDPAddrPort(resp, raddr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) ServeTCP(ln net.Listener) error {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go s.handleTCP(c)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleTCP(c net.Conn) {
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
|
var lenBuf [2]byte
|
|
if _, err := readFull(c, lenBuf[:]); err != nil {
|
|
return
|
|
}
|
|
msg := make([]byte, binary.BigEndian.Uint16(lenBuf[:]))
|
|
if _, err := readFull(c, msg); err != nil {
|
|
return
|
|
}
|
|
ra, _ := netip.ParseAddrPort(c.RemoteAddr().String())
|
|
resp := s.handle(msg, ra.Addr(), "tcp")
|
|
if resp == nil {
|
|
return
|
|
}
|
|
// TCP has no 512 limit; never truncate.
|
|
out := make([]byte, 2+len(resp))
|
|
binary.BigEndian.PutUint16(out[0:2], uint16(len(resp)))
|
|
copy(out[2:], resp)
|
|
_, _ = c.Write(out)
|
|
}
|
|
|
|
func readFull(c net.Conn, b []byte) (int, error) {
|
|
got := 0
|
|
for got < len(b) {
|
|
n, err := c.Read(b[got:])
|
|
got += n
|
|
if err != nil {
|
|
return got, err
|
|
}
|
|
}
|
|
return got, nil
|
|
}
|
|
|
|
// handle parses one query, logs it, and returns the wire response (nil to drop).
|
|
func (s *Server) handle(pkt []byte, resolver netip.Addr, transport string) []byte {
|
|
if len(pkt) < 12 {
|
|
return nil
|
|
}
|
|
id := binary.BigEndian.Uint16(pkt[0:2])
|
|
qdcount := binary.BigEndian.Uint16(pkt[4:6])
|
|
arcount := binary.BigEndian.Uint16(pkt[10:12])
|
|
if qdcount != 1 {
|
|
return s.errorResponse(id, rcodeNoError, nil) // we only answer single-question queries
|
|
}
|
|
|
|
qnameRaw, qtype, _, qEnd, ok := parseQuestion(pkt, 12)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// EDNS OPT is an additional-section RR; scan for it after the question.
|
|
opt := parseOPT(pkt, qEnd, arcount)
|
|
|
|
// Log every query — this is the whole point of the canary zone.
|
|
q := Query{
|
|
QName: strings.TrimSuffix(qnameRaw, "."), At: time.Now().UTC(),
|
|
ResolverIP: resolver.Unmap().String(), Transport: transport,
|
|
EDNS: opt.edns,
|
|
ECS: opt.ecs,
|
|
CasePreserved: qnameRaw == strings.ToLower(qnameRaw), // mixed case ⇒ 0x20 randomization
|
|
}
|
|
s.record(q)
|
|
|
|
name := strings.ToLower(qnameRaw)
|
|
if !strings.HasSuffix(name, s.zone) {
|
|
return s.errorResponse(id, rcodeNXDomain, &opt)
|
|
}
|
|
sub := strings.TrimSuffix(name, s.zone) // e.g. "ttl-5." or "" for apex
|
|
|
|
return s.answer(id, pkt, qEnd, sub, qtype, &opt, transport)
|
|
}
|
|
|
|
// answer builds the response for a name known to be in-zone.
|
|
func (s *Server) answer(id uint16, pkt []byte, qEnd int, sub string, qtype uint16, opt *optInfo, transport string) []byte {
|
|
labels := splitLabels(sub) // e.g. ["ttl-5"], [], ["<nonce>","miss"], ["<nonce>","<sessprefix>"]
|
|
|
|
var rrs []rr
|
|
switch {
|
|
case len(labels) == 0: // zone apex
|
|
if qtype == typeNS {
|
|
rrs = append(rrs, rr{ttl: 3600, typ: typeNS, ns: s.nsName})
|
|
} else if qtype == typeA && s.primaryV4.IsValid() {
|
|
rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: s.primaryV4})
|
|
} else if qtype == typeAAAA && s.primaryV6.IsValid() {
|
|
rrs = append(rrs, rr{ttl: 3600, typ: typeAAAA, addr: s.primaryV6})
|
|
}
|
|
case len(labels) == 1:
|
|
if ref := findReference(labels[0]); ref != nil {
|
|
rrs = referenceAnswers(ref, qtype)
|
|
}
|
|
default:
|
|
// Per-query names: <nonce>.miss.<zone> and <nonce>.<session-prefix>.<zone>.
|
|
// Deterministic A derived from the leftmost label (the nonce), TTL 3600,
|
|
// documentation range — ground truth that can never be pre-cached.
|
|
if qtype == typeA {
|
|
rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: nonceAddr(labels[0])})
|
|
}
|
|
}
|
|
|
|
if len(rrs) == 0 {
|
|
// In-zone but no such record/type → NOERROR/NODATA (or NXDOMAIN at apex miss).
|
|
return s.buildResponse(id, pkt, qEnd, nil, opt, transport, rcodeNoError)
|
|
}
|
|
return s.buildResponse(id, pkt, qEnd, rrs, opt, transport, rcodeNoError)
|
|
}
|
|
|
|
// nonceAddr maps a nonce label into 192.0.2.0/24 deterministically.
|
|
func nonceAddr(nonce string) netip.Addr {
|
|
h := fnv.New32a()
|
|
_, _ = h.Write([]byte(nonce))
|
|
return netip.AddrFrom4([4]byte{192, 0, 2, byte(h.Sum32()%254 + 1)})
|
|
}
|
|
|
|
func referenceAnswers(ref *refRecord, qtype uint16) []rr {
|
|
var rrs []rr
|
|
switch qtype {
|
|
case typeA:
|
|
for _, a := range ref.a {
|
|
if a.Is4() {
|
|
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeA, addr: a})
|
|
}
|
|
}
|
|
case typeAAAA:
|
|
for _, a := range ref.a {
|
|
if a.Is6() && !a.Is4In6() {
|
|
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeAAAA, addr: a})
|
|
}
|
|
}
|
|
case typeTXT:
|
|
if len(ref.txt) > 0 {
|
|
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeTXT, txt: ref.txt})
|
|
}
|
|
}
|
|
return rrs
|
|
}
|
|
|
|
func splitLabels(sub string) []string {
|
|
sub = strings.TrimSuffix(sub, ".")
|
|
if sub == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(sub, ".")
|
|
}
|
|
|
|
func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte {
|
|
hdr := make([]byte, 12)
|
|
binary.BigEndian.PutUint16(hdr[0:2], id)
|
|
binary.BigEndian.PutUint16(hdr[2:4], uint16(flagQR|flagAA|rcode))
|
|
if opt != nil && opt.edns.Present {
|
|
binary.BigEndian.PutUint16(hdr[10:12], 1)
|
|
return append(hdr, buildOPT(opt)...)
|
|
}
|
|
return hdr
|
|
}
|