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>
This commit is contained in:
mrambossek
2026-08-02 13:04:54 +02:00
co-authored by Claude Opus 5
parent f6849f8e6a
commit 8118e213ae
26 changed files with 1390 additions and 61 deletions
+33 -6
View File
@@ -63,20 +63,24 @@ type Server struct {
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
retainTo time.Time
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.
func New(zone, nsName string, v4, v6 netip.Addr) *Server {
// 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}
return &Server{
zone: z, nsName: strings.TrimSuffix(nsName, ".") + ".",
primaryV4: v4, primaryV6: v6, retention: retention,
}
}
// RecentForPrefix returns logged queries whose qname contains ".<prefix>."
@@ -84,6 +88,7 @@ func New(zone, nsName string, v4, v6 netip.Addr) *Server {
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 {
@@ -97,12 +102,34 @@ func (s *Server) RecentForPrefix(prefix string) []Query {
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)
+28 -1
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/netip"
"testing"
"time"
)
// buildQuery makes a single-question DNS query, optionally with an EDNS OPT.
@@ -65,7 +66,7 @@ func parseResponse(t *testing.T, resp []byte) (flags uint16, answers []ans) {
}
func newTestServer() *Server {
return New("c.echo-lot.app", "fmr", netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1"))
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) {
@@ -159,3 +160,29 @@ func TestOutOfZoneNXDomain(t *testing.T) {
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'")
}
}