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:
co-authored by
Claude Opus 5
parent
f6849f8e6a
commit
8118e213ae
@@ -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)
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user