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)