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
@@ -84,7 +84,12 @@ The app re-fetches the profile at the start of every run (falling back to the ca
|
||||
|
||||
### 2.3 Capabilities (v1 registry)
|
||||
|
||||
`udp-probe`, `stun-basic`, `stun-5780`, `canary-dns`, `recursive-dns`, `connect-back`, `delayed-echo`, `big-send`, `frag-send`, `tls-echo`, `http-echo`, `throughput`, `ntp`. A server omits what it can't offer (e.g. `stun-5780` without a second IP degrades to `stun-basic`). Clients must skip, and record as `unsupported`, any test whose capability is absent. Unknown capability strings are ignored.
|
||||
`udp-probe`, `stun-basic`, `stun-5780`, `canary-dns`, `recursive-dns`, `connect-back`, `delayed-echo`, `big-send`, `frag-send`, `tls-echo`, `http-echo`, `throughput`, `ntp`, plus:
|
||||
|
||||
- `downtrain` — server-sent downstream trains via the §5 `downtrain` action. Upstream trains need no capability of their own: they are plain client-sent data-plane packets and ride `udp-probe`.
|
||||
- `tcp-echo` — the plain-TCP echo endpoint (§4); `tls-echo` is its ALPN variant on the same port.
|
||||
|
||||
A server omits what it can't offer (e.g. `stun-5780` without a second IP degrades to `stun-basic`). Clients must skip, and record as `unsupported`, any test whose capability is absent. Unknown capability strings are ignored.
|
||||
|
||||
### 2.4 Sessions
|
||||
|
||||
|
||||
+5
-2
@@ -130,8 +130,11 @@ inject one. Running your own release pipeline? Mint a keypair with
|
||||
## First contact
|
||||
|
||||
```sh
|
||||
# 1. mint an enrollment token (admin listener is loopback-only)
|
||||
curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=phone'
|
||||
# 1. mint an enrollment token (admin listener is loopback-only; authenticates as the
|
||||
# break-glass admin — set that once with --set-admin-password)
|
||||
curl -s -u admin:<password> -H 'Accept: application/json' \
|
||||
-X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=phone'
|
||||
# → { "token": "…", "expires_in_s": 86400, "enroll_uri": "echolot://enroll?…" }
|
||||
# 2. device enrolls with it (normally via the echolot:// QR code)
|
||||
curl -sk -X POST https://<host>:8443/v1/enroll -H 'Authorization: Bearer <token>'
|
||||
# 3. device fetches its profile
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"echo-lot.app/server/internal/control"
|
||||
"echo-lot.app/server/internal/dataplane"
|
||||
"echo-lot.app/server/internal/oidc"
|
||||
"echo-lot.app/server/internal/ratelimit"
|
||||
"echo-lot.app/server/internal/runs"
|
||||
"echo-lot.app/server/internal/selftest"
|
||||
"echo-lot.app/server/internal/selfupdate"
|
||||
@@ -205,6 +206,16 @@ func serve(cfg *config.Config) error {
|
||||
|
||||
sessions := session.NewManager(15 * time.Minute)
|
||||
dp := &dataplane.Server{Sessions: sessions}
|
||||
// Spec §2.5 ceilings. Control plane answers 429; the data plane drops silently. 0 = off.
|
||||
if cfg.RateUDPPps > 0 {
|
||||
pps := float64(cfg.RateUDPPps)
|
||||
// Burst of two seconds' worth: a 5000-packet train arrives as one burst by design.
|
||||
dp.PacketRate = ratelimit.New(pps, 2*pps)
|
||||
}
|
||||
if cfg.RateUDPKbps > 0 {
|
||||
bytesPerSec := float64(cfg.RateUDPKbps) * 125 // kbps -> bytes/s
|
||||
dp.ByteRate = ratelimit.New(bytesPerSec, bytesPerSec)
|
||||
}
|
||||
// TCP echo shares the control cert for its elt-echo TLS variant.
|
||||
tcpSrv := &tcpecho.Server{
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
@@ -305,6 +316,12 @@ func serve(cfg *config.Config) error {
|
||||
ctl.FragSend = dp.FragSend
|
||||
}
|
||||
ctl.DownThroughput = dp.DownThroughput
|
||||
if cfg.RateSessionsPerMin > 0 {
|
||||
ctl.RateSessions = ratelimit.New(float64(cfg.RateSessionsPerMin)/60, float64(cfg.RateSessionsPerMin))
|
||||
}
|
||||
if cfg.RateActionsPerMin > 0 {
|
||||
ctl.RateActions = ratelimit.New(float64(cfg.RateActionsPerMin)/60, float64(cfg.RateActionsPerMin))
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -598,7 +615,8 @@ func serve(cfg *config.Config) error {
|
||||
var dnsTCP []net.Listener
|
||||
if dnsAddrs := config.Addrs(cfg.DNSListen); len(dnsAddrs) > 0 && cfg.CanaryZone != "" {
|
||||
v4, v6 := firstByFamily(dnsAddrs)
|
||||
cd := canarydns.New(cfg.CanaryZone, cfg.Name, v4, v6)
|
||||
cd := canarydns.New(cfg.CanaryZone, cfg.Name, v4, v6,
|
||||
time.Duration(cfg.DNSLogRetentionH)*time.Hour)
|
||||
for _, addr := range dnsAddrs {
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
|
||||
@@ -117,6 +117,10 @@ func (s *Server) Handler() http.Handler {
|
||||
// affects the whole server, so those stay with the admin.
|
||||
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.adminOnly(s.revokeDevice)))
|
||||
mux.HandleFunc("POST /enroll-tokens", s.guard(s.adminOnly(s.mintToken)))
|
||||
// The spec-shaped mint endpoint (§2.1: {token, expires_in_s, enroll_uri}), for curl and
|
||||
// scripts. Authenticates its own way — see apiAdmin — because guard's redirect-to-login is
|
||||
// useless to a caller without a browser.
|
||||
mux.HandleFunc("POST /admin/enroll-tokens", s.enrollTokensAPI)
|
||||
|
||||
return mux
|
||||
}
|
||||
@@ -235,6 +239,41 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// apiAdmin authenticates a programmatic admin request: the normal session cookie, or HTTP Basic
|
||||
// against the break-glass credential for callers without a cookie jar (the README's curl).
|
||||
//
|
||||
// The cookie path keeps CSRF, exactly like guard: a cookie is an ambient credential and this
|
||||
// endpoint changes state. Basic auth is exempt — the password is supplied explicitly per
|
||||
// request, so there is nothing for a cross-site form to ride on — and a wrong guess pays the
|
||||
// same throttle as the login form, so this is no better a password oracle than that is.
|
||||
func (s *Server) apiAdmin(w http.ResponseWriter, r *http.Request) (subject string, ok bool) {
|
||||
if sess := s.session(r); sess != nil {
|
||||
if !sess.Admin {
|
||||
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
if !s.csrfOK(r, sess) {
|
||||
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
return sess.Subject, true
|
||||
}
|
||||
if user, pass, hasBasic := r.BasicAuth(); hasBasic {
|
||||
if d := s.Throttle.Delay(); d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
if cred := s.Store.LocalAdmin(); cred != nil && cred.Verify(user, pass) {
|
||||
s.Throttle.Succeeded()
|
||||
return "local:" + user, true
|
||||
}
|
||||
s.Throttle.Failed()
|
||||
slog.Info("admin api auth failed", "user", user, "from", clientIP(r))
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="echolot-admin"`)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ---- OIDC -------------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) oidcAvailable() bool {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/adminauth"
|
||||
)
|
||||
|
||||
// tokenFixture wires just enough of the Server for the mint endpoint: a break-glass admin and
|
||||
// a stand-in EnrollLink (the real one belongs to the control server, injected the same way).
|
||||
func tokenFixture(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s, _, _, _ := fixture(t)
|
||||
secret, err := s.Store.SessionSecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Sessions = adminauth.NewSessions(secret, time.Hour)
|
||||
s.Throttle = adminauth.NewThrottle()
|
||||
cred, err := adminauth.NewCredential("admin", "a-long-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Store.SetLocalAdmin(cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.EnrollLink = func(tok string) string { return "echolot://enroll?v=1&t=" + tok }
|
||||
return s
|
||||
}
|
||||
|
||||
func TestEnrollTokensAPISpecShape(t *testing.T) {
|
||||
h := tokenFixture(t).Handler()
|
||||
|
||||
// No credentials → 401 with a challenge, never a token.
|
||||
req := httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("unauthenticated: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
// Wrong password → still 401.
|
||||
req = httptest.NewRequest("POST", "/admin/enroll-tokens", nil)
|
||||
req.SetBasicAuth("admin", "wrong")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad password: code=%d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
// Basic + Accept: application/json → the §2.1 shape.
|
||||
req = httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
|
||||
req.SetBasicAuth("admin", "a-long-test-password")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mint: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresS int `json:"expires_in_s"`
|
||||
EnrollURI string `json:"enroll_uri"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Token == "" || body.ExpiresS != 86400 || !strings.HasPrefix(body.EnrollURI, "echolot://enroll?") {
|
||||
t.Fatalf("spec shape violated: %+v", body)
|
||||
}
|
||||
|
||||
// Without Accept: the browser flow — redirect to the QR page, link in the query.
|
||||
req = httptest.NewRequest("POST", "/admin/enroll-tokens", nil)
|
||||
req.SetBasicAuth("admin", "a-long-test-password")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/devices?link=") {
|
||||
t.Fatalf("html flow: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,41 @@ func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminau
|
||||
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// enrollTokensAPI is POST /admin/enroll-tokens, the endpoint the spec's §2.1 example names.
|
||||
// Content-negotiated: Accept: application/json gets the spec shape {token, expires_in_s,
|
||||
// enroll_uri}; anything else (a browser) gets the same redirect-to-QR flow as the form above,
|
||||
// so the one path serves both audiences.
|
||||
func (s *Server) enrollTokensAPI(w http.ResponseWriter, r *http.Request) {
|
||||
subject, ok := s.apiAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
note := r.URL.Query().Get("note")
|
||||
if note == "" {
|
||||
note = "admin-api"
|
||||
}
|
||||
const ttl = 24 * time.Hour
|
||||
tok, err := s.Store.NewEnrollToken(ttl, note)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("enrolment token minted", "by", subject, "note", note)
|
||||
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// The whole link, not the bare token (§2.1): the server is the only party holding URL, pin
|
||||
// and token at once, and a hand-assembled pin wrong by one character fails as an inscrutable
|
||||
// TLS error later rather than loudly here.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": tok,
|
||||
"expires_in_s": int(ttl.Seconds()),
|
||||
"enroll_uri": s.EnrollLink(tok),
|
||||
})
|
||||
}
|
||||
|
||||
// EnrollLink is supplied by the caller so this package does not need the control server's pin.
|
||||
var _ = 0
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,18 @@ type Config struct {
|
||||
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
|
||||
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization
|
||||
|
||||
// Rate limits (spec §2.5), each applied per credential and per source IP. 0 disables a
|
||||
// ceiling. The UDP ceilings are deliberately above the largest legitimate run (a 200 Mbps
|
||||
// upstream throughput test is ~21k pps of 1472-byte packets), so they only ever catch abuse
|
||||
// — a rate limit that clips a real measurement produces a confidently wrong number.
|
||||
RateSessionsPerMin int // ECHOLOT_RATE_SESSIONS_PER_MIN / --rate-sessions-per-min
|
||||
RateActionsPerMin int // ECHOLOT_RATE_ACTIONS_PER_MIN / --rate-actions-per-min
|
||||
RateUDPPps int // ECHOLOT_RATE_UDP_PPS / --rate-udp-pps
|
||||
RateUDPKbps int // ECHOLOT_RATE_UDP_KBPS / --rate-udp-kbps
|
||||
|
||||
// How long canary DNS query logs are kept, in hours (spec §6; privacy default 24).
|
||||
DNSLogRetentionH int // ECHOLOT_DNS_LOG_RETENTION_H / --dns-log-retention-h
|
||||
|
||||
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
|
||||
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
|
||||
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
||||
@@ -209,6 +221,11 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects")
|
||||
fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default <state-dir>/acme)")
|
||||
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
|
||||
fs.IntVar(&c.RateSessionsPerMin, "rate-sessions-per-min", envInt("RATE_SESSIONS_PER_MIN", 10), "per-credential and per-IP ceiling on session creation (spec §2.5); 0 disables")
|
||||
fs.IntVar(&c.RateActionsPerMin, "rate-actions-per-min", envInt("RATE_ACTIONS_PER_MIN", 60), "per-credential and per-IP ceiling on §5 actions; 0 disables")
|
||||
fs.IntVar(&c.RateUDPPps, "rate-udp-pps", envInt("RATE_UDP_PPS", 25_000), "per-credential and per-IP data-plane packet ceiling, packets/s, silent drop; 0 disables")
|
||||
fs.IntVar(&c.RateUDPKbps, "rate-udp-kbps", envInt("RATE_UDP_KBPS", 250_000), "per-credential and per-IP data-plane byte ceiling, kbit/s, silent drop; 0 disables")
|
||||
fs.IntVar(&c.DNSLogRetentionH, "dns-log-retention-h", envInt("DNS_LOG_RETENTION_H", 24), "hours canary DNS query logs are kept (spec §6 privacy default 24); 0 keeps until the ring overwrites")
|
||||
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
|
||||
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
|
||||
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"echo-lot.app/server/internal/compat"
|
||||
"echo-lot.app/server/internal/dataplane"
|
||||
"echo-lot.app/server/internal/oidc"
|
||||
"echo-lot.app/server/internal/ratelimit"
|
||||
"echo-lot.app/server/internal/runs"
|
||||
"echo-lot.app/server/internal/session"
|
||||
"echo-lot.app/server/internal/store"
|
||||
@@ -57,7 +59,8 @@ type Server struct {
|
||||
// DelayedEcho schedules/sends a DELAYED_ECHO for a session (may be nil).
|
||||
DelayedEcho func(sess *session.Session, actionID string) error
|
||||
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
|
||||
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
|
||||
// DownTrain's dscp is -1 for "leave the socket's default marking alone".
|
||||
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs, dscp int) (int, error)
|
||||
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
|
||||
// OIDC verifies ID tokens presented by the *app* (may be nil).
|
||||
OIDC *oidc.Verifier
|
||||
@@ -97,6 +100,11 @@ type Server struct {
|
||||
// AppRange is the app-version window this server will serve. Zero value means the built-in
|
||||
// default (see DefaultAppRange).
|
||||
AppRange compat.Range
|
||||
|
||||
// Spec §2.5 token buckets, keyed per credential and per source IP inside the limiter.
|
||||
// Nil disables a ceiling (config value 0).
|
||||
RateSessions *ratelimit.Limiter // POST /v1/sessions
|
||||
RateActions *ratelimit.Limiter // POST /v1/sessions/{id}/actions
|
||||
}
|
||||
|
||||
// AppVersionHeader is how a client states its version. A client too old to send it is treated as
|
||||
@@ -106,7 +114,12 @@ const AppVersionHeader = "X-Echolot-App-Version"
|
||||
|
||||
// ProtocolVersion is the wire contract (probe-protocol.md) this build implements. It is what the
|
||||
// version window is really about; the release version is only a proxy for it.
|
||||
const ProtocolVersion = "1.0.0"
|
||||
//
|
||||
// 1.0.1: upstream trains (§3.2 types 0x03/0x04/0x05) and the action-id bytes at payload[8:16]
|
||||
// of granted packets. Patch, not minor: both are additive — a client that never sends
|
||||
// TRAIN_REPORT_REQ and never reads granted payloads (today's client reads only header fields)
|
||||
// sees no difference, so the fleet must not be split over it (§8.1).
|
||||
const ProtocolVersion = "1.0.1"
|
||||
|
||||
// SchemaVersion is the measurement-document format this server can store.
|
||||
const SchemaVersion = "1.0.0"
|
||||
@@ -164,10 +177,12 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
gate := s.requireCompatibleApp
|
||||
mux.HandleFunc("POST /v1/enroll", gate(s.enroll))
|
||||
mux.HandleFunc("POST /v1/sessions", gate(s.newSession))
|
||||
// §2.5 buckets sit on the two endpoints that make the server DO things — create state,
|
||||
// send traffic. GET /v1/profile stays ungated on every axis (see above).
|
||||
mux.HandleFunc("POST /v1/sessions", gate(s.rateLimited(s.RateSessions, s.newSession)))
|
||||
mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession))
|
||||
mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations))
|
||||
mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions))
|
||||
mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.rateLimited(s.RateActions, s.actions)))
|
||||
mux.HandleFunc("POST /v1/echo", gate(s.httpEcho))
|
||||
mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference))
|
||||
mux.HandleFunc("POST /v1/runs", gate(s.uploadRun))
|
||||
@@ -177,7 +192,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
|
||||
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
|
||||
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
|
||||
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -199,6 +213,37 @@ func selftestSignal(f func() (bool, bool)) map[string]any {
|
||||
return map[string]any{"mtu_ok": mtuOK, "sysctl_ok": sysctlOK}
|
||||
}
|
||||
|
||||
// rateLimited enforces one §2.5 bucket policy on an endpoint: a token per credential AND one per
|
||||
// source IP. Two keys because each closes the other's hole — keyed only by credential, one
|
||||
// address cycles through credentials; keyed only by address, one credential rides many
|
||||
// addresses. The refusal is 429 with Retry-After, which is the whole point of a token bucket
|
||||
// over a hard drop here: a well-behaved client is told when to come back.
|
||||
func (s *Server) rateLimited(l *ratelimit.Limiter, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
okCred, waitCred := l.Allow("cred:" + bearer(r))
|
||||
okIP, waitIP := l.Allow("ip:" + remoteIP(r))
|
||||
if !okCred || !okIP {
|
||||
wait := max(waitCred, waitIP)
|
||||
secs := int(wait/time.Second) + 1 // Retry-After is whole seconds, rounded up
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": "rate limited", "retry_after_s": secs,
|
||||
})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// remoteIP is the request's source address without the port, for rate-limit keys.
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
// sessionAuth resolves {id} and requires the bearer to be the owning device.
|
||||
func (s *Server) sessionAuth(w http.ResponseWriter, r *http.Request) *session.Session {
|
||||
dev := s.Store.DeviceByCredential(bearer(r))
|
||||
@@ -235,7 +280,13 @@ func (s *Server) observations(w http.ResponseWriter, r *http.Request) {
|
||||
dnsCanary = s.CanaryQueries(sess.ID[:16]) // the session's wire prefix
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"udp": map[string]any{"packets_seen": packetsSeen, "packets": udp},
|
||||
"udp": map[string]any{
|
||||
"packets_seen": packetsSeen,
|
||||
"packets": udp,
|
||||
// The per-train received view (spec §6 "trains"), columnar like the wire report —
|
||||
// the flat packet list above stays for clients that predate trains.
|
||||
"trains": trainsJSON(sess.Trains()),
|
||||
},
|
||||
"tcp": tcp,
|
||||
"connect_back": cb,
|
||||
// The sender's own count, which is what makes the receiver's count mean something.
|
||||
@@ -264,6 +315,7 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
IntervalUs int `json:"interval_us"`
|
||||
SizesBytes []int `json:"sizes_bytes"`
|
||||
DSCP *int `json:"dscp"`
|
||||
DF *bool `json:"df"`
|
||||
Mode string `json:"mode"`
|
||||
FragBytes int `json:"frag_bytes"`
|
||||
@@ -326,6 +378,11 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "downtrain not wired"})
|
||||
return
|
||||
}
|
||||
dscp, err := dscpArg(req.DSCP)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// A downstream train sends far more than it receives, so it needs a grant (§3.4).
|
||||
count := clamp(req.Count, 1, 5000)
|
||||
size := clamp(req.SizeBytes, dataMinPacket, 1500)
|
||||
@@ -336,13 +393,20 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
sent, err := s.DownTrain(sess, g, count, size, interval)
|
||||
sent, err := s.DownTrain(sess, g, count, size, interval, dscp)
|
||||
slog.Info("downtrain finished", "action", actionID, "sent", sent, "bytes", g.Sent(), "err", err)
|
||||
}()
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
resp := map[string]any{
|
||||
"action_id": actionID, "count": count, "size_bytes": size, "interval_us": interval,
|
||||
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
|
||||
})
|
||||
}
|
||||
if dscp >= 0 {
|
||||
resp["dscp"] = dscp
|
||||
// Told up front, not discovered: a client measuring DSCP survival on a burst the
|
||||
// server could not mark would conclude the network stripped it.
|
||||
resp["dscp_applied"] = dataplane.TOSSupported
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, resp)
|
||||
|
||||
case "big_send":
|
||||
if s.BigSend == nil {
|
||||
@@ -525,8 +589,54 @@ func (s *Server) maxDFPayload(sess *session.Session) int {
|
||||
return mtu - overhead
|
||||
}
|
||||
|
||||
// dataMinPacket is the smallest datagram that still carries a header + a little payload.
|
||||
const dataMinPacket = 40
|
||||
// dscpArg validates the optional downtrain dscp parameter (spec §5). Absent means -1: leave the
|
||||
// socket's default marking alone, which is different from asking for DSCP 0 (explicitly
|
||||
// best-effort). Out-of-range values are refused rather than clamped — a clamped 46→63 would mark
|
||||
// the burst with a class the client never asked for and silently change what the test measures.
|
||||
func dscpArg(v *int) (int, error) {
|
||||
if v == nil {
|
||||
return -1, nil
|
||||
}
|
||||
if *v < 0 || *v > 63 {
|
||||
return 0, fmt.Errorf("dscp %d is out of range: the field is 6 bits (0..63)", *v)
|
||||
}
|
||||
return *v, nil
|
||||
}
|
||||
|
||||
// trainsJSON renders the per-train received view columnar — one array per field, matching the
|
||||
// wire report and the schema's train-evidence shape — with []int for the byte-wide columns
|
||||
// because encoding/json would base64 a []uint8.
|
||||
func trainsJSON(trains []session.Train) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(trains))
|
||||
for _, t := range trains {
|
||||
n := len(t.Entries)
|
||||
seq := make([]uint32, n)
|
||||
trx := make([]int64, n)
|
||||
size := make([]int, n)
|
||||
ttl := make([]int, n)
|
||||
dscp := make([]int, n)
|
||||
ecn := make([]int, n)
|
||||
for i, e := range t.Entries {
|
||||
seq[i], trx[i], size[i] = e.Seq, e.TRxNs, int(e.Size)
|
||||
ttl[i], dscp[i], ecn[i] = int(e.TTL), int(e.DSCP), int(e.ECN)
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"train_id": t.ID,
|
||||
// The loss denominator: every packet counted, whether or not its row was kept.
|
||||
"packets_received": t.Received,
|
||||
"truncated": t.Truncated,
|
||||
"seq": seq, "t_rx_ns": trx, "size": size,
|
||||
"ttl": ttl, "dscp": dscp, "ecn": ecn,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dataMinPacket is the smallest granted datagram: header + 16 payload bytes, because [8:16] of
|
||||
// every granted payload carries the action id. It must match what the senders raise short sizes
|
||||
// to, or a minimum-size train's grant is budgeted for fewer bytes than actually leave and the
|
||||
// train is cut short by its own arithmetic.
|
||||
const dataMinPacket = dataplane.HeaderSize + 16
|
||||
|
||||
var noDataPlaneYet = map[string]string{
|
||||
"error": "no data-plane traffic seen yet — send an ECHO first so the destination is verified",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package control
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDscpArg(t *testing.T) {
|
||||
ptr := func(v int) *int { return &v }
|
||||
for _, tc := range []struct {
|
||||
in *int
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, -1, false}, // absent: leave the socket alone
|
||||
{ptr(0), 0, false}, // explicit best-effort is not the same as absent
|
||||
{ptr(46), 46, false}, // EF, the value people actually test with
|
||||
{ptr(63), 63, false},
|
||||
{ptr(64), 0, true}, // one past the 6-bit field
|
||||
{ptr(-1), 0, true},
|
||||
} {
|
||||
got, err := dscpArg(tc.in)
|
||||
if (err != nil) != tc.wantErr || got != tc.want {
|
||||
t.Errorf("dscpArg(%v) = %d, err=%v; want %d, wantErr=%v", tc.in, got, err, tc.want, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build linux
|
||||
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Per-packet TTL and TOS/traffic-class arrive as control messages, and only if asked for at
|
||||
// socket setup. These fill the spec §3.3 observation-block fields that were shipped as the 0xFF
|
||||
// sentinel until now — received TTL is path-length evidence, the TOS byte is DSCP/ECN survival.
|
||||
|
||||
// enableRecvMeta asks the kernel to attach the cmsgs to every received datagram. Both the v4 and
|
||||
// the v6 option sets are attempted on every socket: a dual-stack socket delivers v4-mapped
|
||||
// traffic through the v6 fd, and the kernel refuses whichever set does not apply. Errors are
|
||||
// dropped on purpose — a socket that cannot deliver metadata still serves probes, and the
|
||||
// sentinel already says "not observed" for it.
|
||||
func enableRecvMeta(conn *net.UDPConn) {
|
||||
raw, err := conn.SyscallConn()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = raw.Control(func(fd uintptr) {
|
||||
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_RECVTTL, 1)
|
||||
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_RECVTOS, 1)
|
||||
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_RECVHOPLIMIT, 1)
|
||||
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_RECVTCLASS, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// parseMeta extracts TTL and the TOS byte from one datagram's control messages. Anything absent
|
||||
// or unparseable keeps the sentinel — reported as unobserved, never guessed.
|
||||
func parseMeta(oob []byte) pktMeta {
|
||||
m := pktMeta{TTL: metaUnavailable, TOS: metaUnavailable}
|
||||
if len(oob) == 0 {
|
||||
return m
|
||||
}
|
||||
cmsgs, err := syscall.ParseSocketControlMessage(oob)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, c := range cmsgs {
|
||||
switch {
|
||||
case c.Header.Level == syscall.IPPROTO_IP && c.Header.Type == syscall.IP_TTL,
|
||||
c.Header.Level == syscall.IPPROTO_IPV6 && c.Header.Type == syscall.IPV6_HOPLIMIT:
|
||||
m.TTL = cmsgValue(c.Data)
|
||||
case c.Header.Level == syscall.IPPROTO_IP && c.Header.Type == syscall.IP_TOS,
|
||||
c.Header.Level == syscall.IPPROTO_IPV6 && c.Header.Type == syscall.IPV6_TCLASS:
|
||||
m.TOS = cmsgValue(c.Data)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// cmsgValue reads a cmsg the kernel encodes either as a native-endian int (IP_TTL,
|
||||
// IPV6_HOPLIMIT, IPV6_TCLASS) or as a single byte (IP_TOS). Both fit a byte by definition.
|
||||
func cmsgValue(data []byte) uint8 {
|
||||
switch {
|
||||
case len(data) >= 4:
|
||||
return uint8(binary.NativeEndian.Uint32(data))
|
||||
case len(data) >= 1:
|
||||
return data[0]
|
||||
}
|
||||
return metaUnavailable
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package dataplane
|
||||
|
||||
import "net"
|
||||
|
||||
// Per-packet TTL/TOS needs Linux's IP_RECVTTL-family cmsgs. Elsewhere the observation block and
|
||||
// train buffers keep the spec §3.3 sentinel (0xFF = not observed) — absent is honest, a guess
|
||||
// is not. Deployment targets are Linux; this build exists so the Windows dev loop compiles.
|
||||
func enableRecvMeta(_ *net.UDPConn) {}
|
||||
|
||||
func parseMeta(_ []byte) pktMeta { return pktMeta{TTL: metaUnavailable, TOS: metaUnavailable} }
|
||||
@@ -104,8 +104,8 @@ func (s *Server) FragSend(
|
||||
return res, fmt.Errorf("session has no recorded local address")
|
||||
}
|
||||
|
||||
if sizeBytes < HeaderSize+8 {
|
||||
sizeBytes = HeaderSize + 8
|
||||
if sizeBytes < HeaderSize+32 {
|
||||
sizeBytes = HeaderSize + 32
|
||||
}
|
||||
if sizeBytes > 8000 {
|
||||
sizeBytes = 8000
|
||||
@@ -117,7 +117,10 @@ func (s *Server) FragSend(
|
||||
// The ELT1 packet, signed exactly as any other, then wrapped in UDP.
|
||||
payload := make([]byte, sizeBytes-HeaderSize)
|
||||
binary.BigEndian.PutUint32(payload[0:4], uint32(sizeBytes))
|
||||
copy(payload[4:], mode)
|
||||
// [8:16]: the action id, as on every granted packet (spec §5 correlation); the mode string
|
||||
// sits past the reserved slot.
|
||||
putActionID(payload, g.ActionID)
|
||||
copy(payload[16:], mode)
|
||||
elt := s.buildPacket(sess, TypeFragData, 0, payload)
|
||||
|
||||
udp := buildUDP(local, target, elt)
|
||||
|
||||
@@ -21,7 +21,11 @@ import (
|
||||
// client measures downstream loss, reordering and jitter from what arrives — the direction an
|
||||
// upstream-only train cannot see. Returns how many packets actually went out (the grant may cut
|
||||
// it short, which is itself reportable).
|
||||
func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error) {
|
||||
//
|
||||
// dscp ≥ 0 marks the burst (spec §5 downtrain `dscp`): downstream DSCP survival is the half the
|
||||
// client cannot produce itself. Best-effort off Linux — see withTOS/TOSSupported; the action
|
||||
// response has already told the client whether the marking was applied.
|
||||
func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs, dscp int) (int, error) {
|
||||
target := sess.DataSource()
|
||||
if !target.IsValid() {
|
||||
return 0, fmt.Errorf("no observed data-plane source")
|
||||
@@ -30,11 +34,15 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB
|
||||
if conn == nil {
|
||||
return 0, fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
if sizeBytes < HeaderSize+8 {
|
||||
sizeBytes = HeaderSize + 8
|
||||
if sizeBytes < HeaderSize+16 {
|
||||
sizeBytes = HeaderSize + 16
|
||||
}
|
||||
payload := make([]byte, sizeBytes-HeaderSize)
|
||||
// Payload [8:16] carries the action id on every granted packet, so arriving traffic can be
|
||||
// attributed to the action that caused it (spec §5/§9: test.params.action_id).
|
||||
putActionID(payload, g.ActionID)
|
||||
sent := 0
|
||||
burst := func() error {
|
||||
for i := 0; i < count; i++ {
|
||||
if !g.Allow(sizeBytes) {
|
||||
break // budget or rate exhausted — stop, do not sleep it off
|
||||
@@ -49,7 +57,18 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB
|
||||
time.Sleep(time.Duration(intervalUs) * time.Microsecond)
|
||||
}
|
||||
}
|
||||
return sent, nil
|
||||
return nil
|
||||
}
|
||||
if dscp >= 0 && TOSSupported {
|
||||
// Same shared-socket borrow as the DF window: dfMu keeps a concurrent burst from riding
|
||||
// along with — or clearing — this marking.
|
||||
s.dfMu.Lock()
|
||||
defer s.dfMu.Unlock()
|
||||
err := withTOS(conn, dscp, burst) // sent must be read after the burst ran, not before
|
||||
return sent, err
|
||||
}
|
||||
err := burst()
|
||||
return sent, err
|
||||
}
|
||||
|
||||
// BigSendResult records what happened to one requested size. `Sent` false with an EMSGSIZE-ish
|
||||
@@ -83,8 +102,8 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d
|
||||
results := make([]BigSendResult, 0, len(sizes))
|
||||
burst := func() error {
|
||||
for i, size := range sizes {
|
||||
if size < HeaderSize+8 {
|
||||
size = HeaderSize + 8
|
||||
if size < HeaderSize+16 {
|
||||
size = HeaderSize + 16
|
||||
}
|
||||
if size > 9000 { // jumbo ceiling; beyond this the kernel will refuse anyway
|
||||
size = 9000
|
||||
@@ -96,6 +115,8 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d
|
||||
// Echo the intended size into the payload so a truncated/fragmented arrival is
|
||||
// still attributable to the size we meant to send.
|
||||
binary.BigEndian.PutUint32(payload[0:4], uint32(size))
|
||||
// [8:16]: the action id, as on every granted packet (spec §5 correlation).
|
||||
putActionID(payload, g.ActionID)
|
||||
err := s.sendErr(conn, target, sess, TypeBigSend, uint32(i), payload)
|
||||
results = append(results, BigSendResult{
|
||||
SizeBytes: size, Seq: i, Sent: err == nil, Err: errString(err),
|
||||
|
||||
@@ -120,7 +120,7 @@ func (s *Server) DownThroughput(
|
||||
|
||||
// Same plan the grant was sized from, so the two cannot disagree.
|
||||
durationMs, kbps = ThroughputPlan(durationMs, kbps)
|
||||
if sizeBytes < HeaderSize+16 {
|
||||
if sizeBytes < HeaderSize+24 {
|
||||
sizeBytes = 1200 // a size that survives every common path unfragmented
|
||||
}
|
||||
if sizeBytes > 1472 {
|
||||
@@ -134,6 +134,10 @@ func (s *Server) DownThroughput(
|
||||
}
|
||||
|
||||
payload := make([]byte, sizeBytes-HeaderSize)
|
||||
// [8:16]: the action id, as on every granted packet (spec §5 correlation). The send
|
||||
// timestamp lives past it at [16:24]; the client reads only header fields today, so
|
||||
// reserving the slot costs nothing and keeps one layout rule across granted types.
|
||||
putActionID(payload, g.ActionID)
|
||||
deadline := time.Now().Add(time.Duration(durationMs) * time.Millisecond)
|
||||
start := time.Now()
|
||||
next := start
|
||||
@@ -157,7 +161,7 @@ func (s *Server) DownThroughput(
|
||||
// Reaching here means the run is progressing normally; the clock will end it.
|
||||
res.LimitedBy = "duration"
|
||||
binary.BigEndian.PutUint32(payload[0:4], seq)
|
||||
binary.BigEndian.PutUint64(payload[4:12], uint64(time.Since(s.start).Nanoseconds()))
|
||||
binary.BigEndian.PutUint64(payload[16:24], uint64(time.Since(s.start).Nanoseconds()))
|
||||
if err := s.sendErr(conn, target, sess, TypeThroughputData, seq, payload); err != nil {
|
||||
// A send error mid-run is a local condition (buffer full, route gone). Stop and
|
||||
// report what got out rather than pretending the rest was lost on the path.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build linux
|
||||
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// withTOS runs fn with the socket's TOS/traffic class set to dscp<<2 (ECN bits left zero — the
|
||||
// test is about DSCP survival, and claiming ECN capability we do not use would pollute it), then
|
||||
// restores what was there before.
|
||||
//
|
||||
// Same borrow discipline as withDF: the socket is shared by every session on that family, so the
|
||||
// caller must hold Server.dfMu for the whole window or a concurrent burst rides along with — or
|
||||
// clears — someone else's marking.
|
||||
func withTOS(conn *net.UDPConn, dscp int, fn func() error) error {
|
||||
raw, err := conn.SyscallConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v4 := conn.LocalAddr().(*net.UDPAddr).IP.To4() != nil
|
||||
level, opt := syscall.IPPROTO_IPV6, syscall.IPV6_TCLASS
|
||||
if v4 {
|
||||
level, opt = syscall.IPPROTO_IP, syscall.IP_TOS
|
||||
}
|
||||
|
||||
var setErr error
|
||||
prev := 0
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
if p, e := syscall.GetsockoptInt(int(fd), level, opt); e == nil {
|
||||
prev = p
|
||||
}
|
||||
setErr = syscall.SetsockoptInt(int(fd), level, opt, dscp<<2)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if setErr != nil {
|
||||
return setErr
|
||||
}
|
||||
defer func() {
|
||||
_ = raw.Control(func(fd uintptr) {
|
||||
_ = syscall.SetsockoptInt(int(fd), level, opt, prev)
|
||||
})
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// TOSSupported reports whether withTOS can actually mark packets here. Exported so the control
|
||||
// plane can tell the client up front that its dscp request will not be honored, instead of the
|
||||
// client measuring an unmarked burst and concluding the network stripped the marking.
|
||||
const TOSSupported = true
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package dataplane
|
||||
|
||||
import "net"
|
||||
|
||||
// Setting DSCP per-burst uses IP_TOS/IPV6_TCLASS under the same fd-borrow pattern as withDF,
|
||||
// which is only exercised on Linux deployments. Elsewhere the burst goes out with the default
|
||||
// class and TOSSupported lets the action response say so — an unmarked burst reported as marked
|
||||
// would read as "the network stripped DSCP", the exact wrong conclusion.
|
||||
func withTOS(_ *net.UDPConn, _ int, fn func() error) error { return fn() }
|
||||
|
||||
const TOSSupported = false
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package dataplane
|
||||
|
||||
// Upstream trains (spec §3.2, types 0x03–0x05). The client blasts TRAIN_DATA at the server and
|
||||
// the server answers nothing per-packet — a reply would double the traffic and measure the
|
||||
// return path at the same time. Afterwards the client asks for the server's received view with
|
||||
// TRAIN_REPORT_REQ, and gets it back columnar, split across as many TRAIN_REPORT datagrams as
|
||||
// it takes to stay under a safe size.
|
||||
//
|
||||
// Both TRAIN_DATA and TRAIN_REPORT_REQ carry the train id in payload[0:4]; the id is the
|
||||
// client's to choose, unique within the session.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
// trainReportMaxDatagram caps one TRAIN_REPORT datagram at a size that survives every common
|
||||
// path unfragmented. A report about loss must not itself be lost to MTU.
|
||||
trainReportMaxDatagram = 1200
|
||||
trainReportHeader = 16
|
||||
trainReportRow = 17 // 4 seq + 8 t_rx_ns + 2 size + 1 ttl + 1 dscp + 1 ecn
|
||||
)
|
||||
|
||||
// recordTrain buffers one TRAIN_DATA packet into its train (session-side, bounded — see
|
||||
// session/train.go). A payload too short to carry the id is unreportable and stays only in the
|
||||
// flat packet log, which already recorded it.
|
||||
func recordTrain(sess *session.Session, payload []byte, seq uint32, size int, tRxNs int64, meta pktMeta) {
|
||||
if len(payload) < 4 {
|
||||
return
|
||||
}
|
||||
sess.RecordTrainPacket(binary.BigEndian.Uint32(payload[0:4]), session.TrainEntry{
|
||||
Seq: seq, TRxNs: tRxNs, Size: uint16(min(size, 0xFFFF)),
|
||||
TTL: meta.TTL, DSCP: meta.dscp(), ECN: meta.ecn(),
|
||||
})
|
||||
}
|
||||
|
||||
// trainReport answers one TRAIN_REPORT_REQ with the full columnar report.
|
||||
//
|
||||
// Grant-free on purpose. §3.4 caps ungranted responses at the request size, and a multi-part
|
||||
// report is larger than the single REPORT_REQ that asked for it — but it cannot amplify: every
|
||||
// 17-byte row accounts for one HMAC-valid TRAIN_DATA packet of at least HeaderSize+4 bytes this
|
||||
// session already delivered here, so the whole report is a strict fraction of the traffic it
|
||||
// describes, and it only ever goes to the session's verified source address. An unknown id gets
|
||||
// a single zero-row report rather than silence — "nothing arrived" IS the measurement.
|
||||
func (s *Server) trainReport(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, payload []byte) {
|
||||
if len(payload) < 4 {
|
||||
return
|
||||
}
|
||||
id := binary.BigEndian.Uint32(payload[0:4])
|
||||
train, _ := sess.TrainView(id)
|
||||
train.ID = id
|
||||
for i, part := range buildTrainReport(train) {
|
||||
s.send(conn, raddr, sess, TypeTrainReport, uint32(i), part)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTrainReport lays a train's received view out columnar and cuts it into datagram-sized
|
||||
// payloads. Layout (mirrored by the client; all big-endian):
|
||||
//
|
||||
// 0 4 train_id
|
||||
// 4 4 received (every packet counted, buffered or not — loss math uses this)
|
||||
// 8 2 part (0-based)
|
||||
// 10 2 parts
|
||||
// 12 1 flags (bit0: buffer overflowed; rows beyond the cap were counted, not kept —
|
||||
// the schema's evidence_truncated honesty, on the wire)
|
||||
// 13 1 reserved
|
||||
// 14 2 n (rows in this part)
|
||||
// 16 n×4 seq, n×8 t_rx_ns, n×2 size, n×1 ttl, n×1 dscp, n×1 ecn (columns contiguous)
|
||||
func buildTrainReport(t session.Train) [][]byte {
|
||||
perPart := (trainReportMaxDatagram - HeaderSize - trainReportHeader) / trainReportRow
|
||||
parts := (len(t.Entries) + perPart - 1) / perPart
|
||||
if parts == 0 {
|
||||
parts = 1 // an empty train still gets its "received: 0" answer
|
||||
}
|
||||
out := make([][]byte, 0, parts)
|
||||
for p := 0; p < parts; p++ {
|
||||
rows := t.Entries[p*perPart : min((p+1)*perPart, len(t.Entries))]
|
||||
n := len(rows)
|
||||
b := make([]byte, trainReportHeader+n*trainReportRow)
|
||||
binary.BigEndian.PutUint32(b[0:4], t.ID)
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(t.Received))
|
||||
binary.BigEndian.PutUint16(b[8:10], uint16(p))
|
||||
binary.BigEndian.PutUint16(b[10:12], uint16(parts))
|
||||
if t.Truncated {
|
||||
b[12] = 1
|
||||
}
|
||||
binary.BigEndian.PutUint16(b[14:16], uint16(n))
|
||||
off := trainReportHeader
|
||||
for i, r := range rows {
|
||||
binary.BigEndian.PutUint32(b[off+i*4:], r.Seq)
|
||||
}
|
||||
off += n * 4
|
||||
for i, r := range rows {
|
||||
binary.BigEndian.PutUint64(b[off+i*8:], uint64(r.TRxNs))
|
||||
}
|
||||
off += n * 8
|
||||
for i, r := range rows {
|
||||
binary.BigEndian.PutUint16(b[off+i*2:], r.Size)
|
||||
}
|
||||
off += n * 2
|
||||
for i, r := range rows {
|
||||
b[off+i] = r.TTL
|
||||
}
|
||||
off += n
|
||||
for i, r := range rows {
|
||||
b[off+i] = r.DSCP
|
||||
}
|
||||
off += n
|
||||
for i, r := range rows {
|
||||
b[off+i] = r.ECN
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
// parseReportPart decodes one TRAIN_REPORT payload back into rows, checking the header.
|
||||
func parseReportPart(t *testing.T, b []byte) (id uint32, received int, part, parts int, truncated bool, rows []session.TrainEntry) {
|
||||
t.Helper()
|
||||
if len(b) < trainReportHeader {
|
||||
t.Fatalf("report part shorter than its header: %d", len(b))
|
||||
}
|
||||
id = binary.BigEndian.Uint32(b[0:4])
|
||||
received = int(binary.BigEndian.Uint32(b[4:8]))
|
||||
part = int(binary.BigEndian.Uint16(b[8:10]))
|
||||
parts = int(binary.BigEndian.Uint16(b[10:12]))
|
||||
truncated = b[12]&1 != 0
|
||||
n := int(binary.BigEndian.Uint16(b[14:16]))
|
||||
if want := trainReportHeader + n*trainReportRow; len(b) != want {
|
||||
t.Fatalf("part length %d, want %d for %d rows", len(b), want, n)
|
||||
}
|
||||
off := trainReportHeader
|
||||
rows = make([]session.TrainEntry, n)
|
||||
for i := range rows {
|
||||
rows[i].Seq = binary.BigEndian.Uint32(b[off+i*4:])
|
||||
}
|
||||
off += n * 4
|
||||
for i := range rows {
|
||||
rows[i].TRxNs = int64(binary.BigEndian.Uint64(b[off+i*8:]))
|
||||
}
|
||||
off += n * 8
|
||||
for i := range rows {
|
||||
rows[i].Size = binary.BigEndian.Uint16(b[off+i*2:])
|
||||
}
|
||||
off += n * 2
|
||||
for i := range rows {
|
||||
rows[i].TTL = b[off+i]
|
||||
}
|
||||
off += n
|
||||
for i := range rows {
|
||||
rows[i].DSCP = b[off+i]
|
||||
}
|
||||
off += n
|
||||
for i := range rows {
|
||||
rows[i].ECN = b[off+i]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestBuildTrainReportSplitsAndRoundTrips(t *testing.T) {
|
||||
const count = 250 // enough to need several parts
|
||||
train := session.Train{ID: 42, Received: count, Truncated: true}
|
||||
for i := 0; i < count; i++ {
|
||||
train.Entries = append(train.Entries, session.TrainEntry{
|
||||
Seq: uint32(i), TRxNs: int64(i) * 1_000_000, Size: uint16(100 + i),
|
||||
TTL: 64, DSCP: 46, ECN: 1,
|
||||
})
|
||||
}
|
||||
|
||||
parts := buildTrainReport(train)
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("250 rows should not fit one ≤%d-byte datagram", trainReportMaxDatagram)
|
||||
}
|
||||
var got []session.TrainEntry
|
||||
for i, p := range parts {
|
||||
if HeaderSize+len(p) > trainReportMaxDatagram {
|
||||
t.Fatalf("part %d would be a %d-byte datagram, cap is %d", i, HeaderSize+len(p), trainReportMaxDatagram)
|
||||
}
|
||||
id, received, part, total, truncated, rows := parseReportPart(t, p)
|
||||
if id != 42 || received != count || part != i || total != len(parts) || !truncated {
|
||||
t.Fatalf("part %d header: id=%d received=%d part=%d/%d truncated=%v",
|
||||
i, id, received, part, total, truncated)
|
||||
}
|
||||
got = append(got, rows...)
|
||||
}
|
||||
if len(got) != count {
|
||||
t.Fatalf("round-tripped %d rows, want %d", len(got), count)
|
||||
}
|
||||
for i, r := range got {
|
||||
want := train.Entries[i]
|
||||
if r != want {
|
||||
t.Fatalf("row %d = %+v, want %+v", i, r, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrainReportEmptyTrainStillAnswers(t *testing.T) {
|
||||
parts := buildTrainReport(session.Train{ID: 9})
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("empty train: %d parts, want 1 — 'nothing arrived' is the answer, not silence", len(parts))
|
||||
}
|
||||
id, received, _, total, _, rows := parseReportPart(t, parts[0])
|
||||
if id != 9 || received != 0 || total != 1 || len(rows) != 0 {
|
||||
t.Fatalf("empty report: id=%d received=%d parts=%d rows=%d", id, received, total, len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainDataThenReportOverTheWire(t *testing.T) {
|
||||
mgr, addr := startServer(t)
|
||||
sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(addr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
client.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
// A short train: id 5 in payload[0:4], plus padding.
|
||||
const trainID, count = 5, 4
|
||||
for i := 0; i < count; i++ {
|
||||
payload := make([]byte, 60)
|
||||
binary.BigEndian.PutUint32(payload[0:4], trainID)
|
||||
if _, err := client.Write(craft(t, sess, TypeTrainData, uint32(i+1), payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// TRAIN_DATA must be silent (spec §3.2).
|
||||
client.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
if _, err := client.Read(make([]byte, 1500)); err == nil {
|
||||
t.Fatal("TRAIN_DATA got a response, want none")
|
||||
}
|
||||
|
||||
// Ask for the report.
|
||||
reqPayload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(reqPayload, trainID)
|
||||
client.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 100, reqPayload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 2000)
|
||||
n, err := client.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("no TRAIN_REPORT: %v", err)
|
||||
}
|
||||
if buf[4] != TypeTrainReport {
|
||||
t.Fatalf("type = %#x, want TRAIN_REPORT", buf[4])
|
||||
}
|
||||
id, received, part, parts, truncated, rows := parseReportPart(t, buf[HeaderSize:n])
|
||||
if id != trainID || received != count || part != 0 || parts != 1 || truncated {
|
||||
t.Fatalf("report header: id=%d received=%d part=%d/%d truncated=%v", id, received, part, parts, truncated)
|
||||
}
|
||||
if len(rows) != count {
|
||||
t.Fatalf("%d rows, want %d", len(rows), count)
|
||||
}
|
||||
for i, r := range rows {
|
||||
if r.Seq != uint32(i+1) {
|
||||
t.Fatalf("row %d seq = %d, want %d", i, r.Seq, i+1)
|
||||
}
|
||||
if r.Size != HeaderSize+60 {
|
||||
t.Fatalf("row %d size = %d, want %d", i, r.Size, HeaderSize+60)
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown train id: one zero-row report, not silence.
|
||||
binary.BigEndian.PutUint32(reqPayload, 999)
|
||||
if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 101, reqPayload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err = client.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("no report for unknown train: %v", err)
|
||||
}
|
||||
id, received, _, _, _, rows = parseReportPart(t, buf[HeaderSize:n])
|
||||
if id != 999 || received != 0 || len(rows) != 0 {
|
||||
t.Fatalf("unknown-train report: id=%d received=%d rows=%d", id, received, len(rows))
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package dataplane implements the binary UDP probe protocol (spec §3):
|
||||
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block.
|
||||
// Skeleton scope: ECHO_REQ/ECHO_RESP and TIMESYNC only; trains, MTU probes
|
||||
// and delayed echo land with the corresponding client tests.
|
||||
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block,
|
||||
// upstream trains with columnar reports, and the granted server->client sends.
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/ratelimit"
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,11 @@ const (
|
||||
|
||||
TypeEchoReq = 0x01
|
||||
TypeEchoResp = 0x02
|
||||
// Upstream trains (spec §3.2): DATA gets no per-packet response; REPORT_REQ fetches the
|
||||
// server's received view as one or more REPORT datagrams (train.go).
|
||||
TypeTrainData = 0x03
|
||||
TypeTrainReportReq = 0x04
|
||||
TypeTrainReport = 0x05
|
||||
TypeTimesyncReq = 0x07
|
||||
TypeTimesyncRsp = 0x08
|
||||
TypeMtuProbe = 0x09
|
||||
@@ -47,6 +53,12 @@ const (
|
||||
|
||||
type Server struct {
|
||||
Sessions *session.Manager
|
||||
// Spec §2.5 ceilings on verified traffic, silent-drop (nil = no ceiling). Charged after the
|
||||
// HMAC gate so an unauthenticated flood cannot spend anyone's budget, keyed per source
|
||||
// address AND per device credential so neither one hot address nor one hot credential can
|
||||
// crowd out the rest.
|
||||
PacketRate *ratelimit.Limiter // tokens are packets
|
||||
ByteRate *ratelimit.Limiter // tokens are bytes
|
||||
// Epoch for server-side t_rx/t_tx: process start; observation consumers
|
||||
// only need differences plus the timesync exchange, not absolute time.
|
||||
start time.Time
|
||||
@@ -59,6 +71,34 @@ type Server struct {
|
||||
dfMu sync.Mutex
|
||||
}
|
||||
|
||||
// pktMeta is what the kernel told us about one received datagram beyond its bytes (spec §3.3:
|
||||
// received TTL, DSCP, ECN). 0xFF means "not observed": non-Linux hosts and datagrams whose
|
||||
// cmsg never arrived keep the sentinel rather than inventing a value.
|
||||
type pktMeta struct {
|
||||
TTL uint8
|
||||
TOS uint8 // the whole DSCP/ECN byte; DSCP = TOS>>2, ECN = TOS&3
|
||||
}
|
||||
|
||||
const metaUnavailable = 0xFF
|
||||
|
||||
func (m pktMeta) dscp() uint8 {
|
||||
if m.TOS == metaUnavailable {
|
||||
return metaUnavailable
|
||||
}
|
||||
return m.TOS >> 2
|
||||
}
|
||||
|
||||
func (m pktMeta) ecn() uint8 {
|
||||
if m.TOS == metaUnavailable {
|
||||
return metaUnavailable
|
||||
}
|
||||
return m.TOS & 0x3
|
||||
}
|
||||
|
||||
// oobCap fits the two cmsgs (TTL + TOS, each ≤ CMSG_SPACE(4)) with headroom for whatever else
|
||||
// the kernel decides to attach.
|
||||
const oobCap = 64
|
||||
|
||||
// Serve runs the read loop for one socket; call once per bound address.
|
||||
// The socket is retained so actions (delayed echo) can pick a family-matching
|
||||
// sender later.
|
||||
@@ -69,14 +109,16 @@ func (s *Server) Serve(conn *net.UDPConn) error {
|
||||
}
|
||||
s.conns = append(s.conns, conn)
|
||||
s.mu.Unlock()
|
||||
enableRecvMeta(conn)
|
||||
buf := make([]byte, 65535)
|
||||
oob := make([]byte, oobCap)
|
||||
for {
|
||||
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
|
||||
n, oobn, _, raddr, err := conn.ReadMsgUDPAddrPort(buf, oob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tRx := time.Since(s.start).Nanoseconds()
|
||||
s.handle(conn, raddr, buf[:n], tRx)
|
||||
s.handle(conn, raddr, buf[:n], tRx, parseMeta(oob[:oobn]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +168,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
|
||||
|
||||
// handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session,
|
||||
// replayed seq → silent drop, never a response.
|
||||
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) {
|
||||
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64, meta pktMeta) {
|
||||
if len(pkt) < HeaderSize || string(pkt[0:4]) != Magic {
|
||||
return
|
||||
}
|
||||
@@ -149,6 +191,12 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
if !hmac.Equal(mac.Sum(nil)[:4], pkt[28:32]) {
|
||||
return
|
||||
}
|
||||
// Spec §2.5: over-ceiling traffic is silently dropped (probes tolerate loss by design).
|
||||
// After the HMAC gate so a spoofed flood cannot drain a victim's budget; before the replay
|
||||
// window so a dropped packet's seq stays usable for a resend.
|
||||
if !s.allowUDP(raddr, sess.Device, len(pkt)) {
|
||||
return
|
||||
}
|
||||
if !sess.CheckSeq(seq) {
|
||||
return
|
||||
}
|
||||
@@ -169,9 +217,16 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
Src: raddr.String(), Size: len(pkt), Type: typ,
|
||||
})
|
||||
|
||||
payload := pkt[HeaderSize : HeaderSize+int(payloadLen)]
|
||||
switch typ {
|
||||
case TypeEchoReq:
|
||||
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs, meta)
|
||||
case TypeTrainData:
|
||||
// No response (spec §3.2): the train is upstream-only; its received view is fetched
|
||||
// afterwards via TRAIN_REPORT_REQ or the observations API.
|
||||
recordTrain(sess, payload, seq, len(pkt), tRxNs, meta)
|
||||
case TypeTrainReportReq:
|
||||
s.trainReport(conn, raddr, sess, payload)
|
||||
case TypeTimesyncReq:
|
||||
s.timesyncResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
case TypeMtuProbe:
|
||||
@@ -181,6 +236,16 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
}
|
||||
}
|
||||
|
||||
// allowUDP charges the §2.5 packet and byte buckets, per source address and per credential.
|
||||
func (s *Server) allowUDP(raddr netip.AddrPort, device string, size int) bool {
|
||||
ipKey, credKey := "ip:"+raddr.Addr().String(), "cred:"+device
|
||||
okA, _ := s.PacketRate.Allow(ipKey)
|
||||
okC, _ := s.PacketRate.Allow(credKey)
|
||||
okAB, _ := s.ByteRate.AllowN(ipKey, float64(size))
|
||||
okCB, _ := s.ByteRate.AllowN(credKey, float64(size))
|
||||
return okA && okC && okAB && okCB
|
||||
}
|
||||
|
||||
// mtuAck replies to an MTU_PROBE with a small MTU_ACK carrying the total
|
||||
// datagram size the server actually received (spec §3.2). The client sends
|
||||
// DF-flagged probes of increasing size and binary-searches the path MTU / a
|
||||
@@ -198,28 +263,43 @@ func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.S
|
||||
// 8 8 t_tx_ns
|
||||
// 16 16 observed source IP (v4-mapped when v4)
|
||||
// 32 2 observed source port
|
||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||
// 34 1 received TTL (0xFF = not observed; cmsgs unavailable on this host)
|
||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||
// 36 4 received size
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int, meta pktMeta) []byte {
|
||||
b := make([]byte, 40)
|
||||
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
||||
binary.BigEndian.PutUint64(b[8:16], uint64(tTxNs))
|
||||
a16 := src.Addr().As16()
|
||||
copy(b[16:32], a16[:])
|
||||
binary.BigEndian.PutUint16(b[32:34], src.Port())
|
||||
b[34], b[35] = 0xFF, 0xFF
|
||||
b[34], b[35] = meta.TTL, meta.TOS
|
||||
binary.BigEndian.PutUint32(b[36:40], uint32(rcvd))
|
||||
return b
|
||||
}
|
||||
|
||||
// putActionID writes a grant's action id into payload[8:16] — the correlation the spec promises
|
||||
// (§5: "an action_id echoed in resulting data-plane packets"), consumed by the client as
|
||||
// test.params.action_id (§9). Bytes [0:8] stay with the packet type; [8:16] is reserved for this
|
||||
// across every granted type, so the client needs one rule, not five.
|
||||
func putActionID(payload []byte, actionID string) {
|
||||
if len(payload) < 16 {
|
||||
return
|
||||
}
|
||||
raw, err := hex.DecodeString(actionID)
|
||||
if err != nil || len(raw) != 8 {
|
||||
return // a malformed id yields zero bytes, not a crash mid-burst
|
||||
}
|
||||
copy(payload[8:16], raw)
|
||||
}
|
||||
|
||||
// echoResp mirrors the request header (type flipped), appends the observation
|
||||
// block, and re-HMACs with the session key. Anti-amplification: the response
|
||||
// is capped at the request size (spec §3.4) — the observation block replaces
|
||||
// padding rather than growing the datagram; if the request was smaller than
|
||||
// header+observation, the block is truncated to fit.
|
||||
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
|
||||
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req))
|
||||
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64, meta pktMeta) {
|
||||
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req), meta)
|
||||
max := len(req)
|
||||
if max < HeaderSize {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package ratelimit implements the spec §2.5 token buckets: per-credential and per-source-IP
|
||||
// ceilings on session creation, actions, UDP packets and bytes. One Limiter holds one policy
|
||||
// (rate + burst) and lazily creates a bucket per key; callers namespace their keys ("cred:…",
|
||||
// "ip:…") so a single Limiter can enforce both axes of the same rule.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter is a keyed set of token buckets sharing one rate and burst.
|
||||
//
|
||||
// A nil *Limiter allows everything: the ceilings are configurable down to "off" (config value 0),
|
||||
// and a nil check in one place beats a sentinel policy that every call site must know about.
|
||||
type Limiter struct {
|
||||
rate float64 // tokens per second
|
||||
burst float64
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
lastSweep time.Time
|
||||
now func() time.Time // swappable so tests need no sleeping
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// New creates a limiter granting ratePerSec tokens per second per key, holding at most burst.
|
||||
func New(ratePerSec, burst float64) *Limiter {
|
||||
return &Limiter{
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
buckets: map[string]*bucket{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow takes one token for key. See AllowN.
|
||||
func (l *Limiter) Allow(key string) (bool, time.Duration) {
|
||||
return l.AllowN(key, 1)
|
||||
}
|
||||
|
||||
// AllowN takes n tokens for key, reporting whether they were available and — when they were
|
||||
// not — how long until they will be, which is what a control-plane 429 puts in Retry-After.
|
||||
// A refusal consumes nothing: the caller being told to wait must not itself push the wait out.
|
||||
func (l *Limiter) AllowN(key string, n float64) (bool, time.Duration) {
|
||||
if l == nil {
|
||||
return true, 0
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweepLocked(now)
|
||||
b := l.buckets[key]
|
||||
if b == nil {
|
||||
b = &bucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Seconds() * l.rate
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens >= n {
|
||||
b.tokens -= n
|
||||
return true, 0
|
||||
}
|
||||
return false, time.Duration((n - b.tokens) / l.rate * float64(time.Second))
|
||||
}
|
||||
|
||||
// sweepEvery bounds how often the map is walked; the walk is cheap but there is no point doing
|
||||
// it per packet on the data plane's hot path.
|
||||
const sweepEvery = time.Minute
|
||||
|
||||
// sweepLocked drops buckets that have been idle long enough to be full again. A full bucket
|
||||
// carries no state a fresh one would not, and without the sweep the map grows one entry per
|
||||
// source address ever seen — an attacker-controlled key space must not be an unbounded one.
|
||||
func (l *Limiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < sweepEvery {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
idle := sweepEvery
|
||||
if l.rate > 0 {
|
||||
if refill := time.Duration(l.burst / l.rate * float64(time.Second)); refill > idle {
|
||||
idle = refill
|
||||
}
|
||||
}
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > idle {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clockAt pins the limiter to a fake clock so refill is a function of arithmetic, not sleeping.
|
||||
func clockAt(l *Limiter) *time.Time {
|
||||
t := time.Unix(1000, 0)
|
||||
l.now = func() time.Time { return t }
|
||||
return &t
|
||||
}
|
||||
|
||||
func TestBurstThenRefusalThenRefill(t *testing.T) {
|
||||
l := New(1, 3) // 1 token/s, burst 3
|
||||
now := clockAt(l)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatalf("token %d of the burst refused", i)
|
||||
}
|
||||
}
|
||||
ok, wait := l.Allow("k")
|
||||
if ok {
|
||||
t.Fatal("fourth token inside the same instant should be refused")
|
||||
}
|
||||
if wait <= 0 || wait > time.Second {
|
||||
t.Fatalf("retry-after = %v, want (0, 1s]", wait)
|
||||
}
|
||||
|
||||
*now = now.Add(2 * time.Second) // refills 2 tokens
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("refused after refill")
|
||||
}
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("second refilled token refused")
|
||||
}
|
||||
if ok, _ := l.Allow("k"); ok {
|
||||
t.Fatal("third token allowed but only two seconds elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalConsumesNothing(t *testing.T) {
|
||||
l := New(1, 1)
|
||||
now := clockAt(l)
|
||||
l.Allow("k")
|
||||
// Hammering while empty must not push the refill out.
|
||||
for i := 0; i < 10; i++ {
|
||||
if ok, _ := l.Allow("k"); ok {
|
||||
t.Fatal("allowed while empty")
|
||||
}
|
||||
}
|
||||
*now = now.Add(time.Second)
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("the refused attempts ate the refill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysAreIndependent(t *testing.T) {
|
||||
l := New(1, 1)
|
||||
clockAt(l)
|
||||
l.Allow("a")
|
||||
if ok, _ := l.Allow("b"); !ok {
|
||||
t.Fatal("draining key a refused key b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowNChargesBytes(t *testing.T) {
|
||||
l := New(1000, 1000) // e.g. bytes/s
|
||||
clockAt(l)
|
||||
if ok, _ := l.AllowN("k", 900); !ok {
|
||||
t.Fatal("900 of 1000 refused")
|
||||
}
|
||||
if ok, _ := l.AllowN("k", 200); ok {
|
||||
t.Fatal("1100 of 1000 allowed")
|
||||
}
|
||||
if ok, _ := l.AllowN("k", 100); !ok {
|
||||
t.Fatal("the refused 200 consumed the remaining 100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilLimiterAllowsEverything(t *testing.T) {
|
||||
var l *Limiter
|
||||
if ok, wait := l.AllowN("k", 1e12); !ok || wait != 0 {
|
||||
t.Fatal("nil limiter must be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepDropsIdleBucketsOnly(t *testing.T) {
|
||||
l := New(1, 3)
|
||||
now := clockAt(l)
|
||||
l.Allow("idle")
|
||||
*now = now.Add(2 * time.Minute)
|
||||
l.Allow("busy") // triggers the sweep; "idle" refilled long ago
|
||||
if _, held := l.buckets["idle"]; held {
|
||||
t.Fatal("idle bucket survived the sweep")
|
||||
}
|
||||
if _, held := l.buckets["busy"]; !held {
|
||||
t.Fatal("active bucket was swept")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ type Session struct {
|
||||
connectBack []ConnectBackResult
|
||||
throughput []ThroughputReport
|
||||
upstream UpstreamCounter
|
||||
// Upstream trains (spec §3.2), buffered apart from udpObs — see train.go for why the
|
||||
// flat ring must not be the only home of a 5000-packet train.
|
||||
trains []*Train
|
||||
}
|
||||
|
||||
const obsCap = 4096
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package session
|
||||
|
||||
// Upstream trains (spec §3.2): TRAIN_DATA packets get no per-packet response, so the server's
|
||||
// received view is the only record of what survived the upstream path. It is kept per train —
|
||||
// NOT in the flat udpObs ring, whose 4096-entry cap would silently roll the head of a 5000-packet
|
||||
// train out from under the report that is about to be requested. Losing the head quietly turns
|
||||
// real early-train packets into phantom loss; a bounded buffer that says when it overflowed
|
||||
// (Truncated, mirroring the measurement schema's evidence_truncated) keeps the numbers honest.
|
||||
|
||||
// TrainEntry is the server's received view of one train packet. TTL/DSCP/ECN use 0xFF for
|
||||
// "not observed" (spec §3.3), same sentinel as the echo observation block.
|
||||
type TrainEntry struct {
|
||||
Seq uint32
|
||||
TRxNs int64 // server clock, session epoch
|
||||
Size uint16
|
||||
TTL uint8
|
||||
DSCP uint8
|
||||
ECN uint8
|
||||
}
|
||||
|
||||
// Train is the received view of one upstream train, keyed by the id the client put in the
|
||||
// TRAIN_DATA payload.
|
||||
type Train struct {
|
||||
ID uint32
|
||||
// Received counts every packet of the train, including any the entry buffer no longer holds;
|
||||
// the loss figure must come from this, not from len(Entries).
|
||||
Received int
|
||||
Truncated bool
|
||||
Entries []TrainEntry
|
||||
}
|
||||
|
||||
const (
|
||||
// trainCap comfortably holds the largest train the client-side action bounds allow (5000
|
||||
// packets, matching the downtrain clamp). Overflow keeps the head and sets Truncated: the
|
||||
// early packets are the ones a ring would drop, and the tail's absence is at least declared.
|
||||
trainCap = 8192
|
||||
// maxTrains bounds one session's train memory (~8×8k×24 B ≈ 1.5 MiB worst case). The oldest
|
||||
// train is evicted for a new one because reports are requested train-by-train, right after
|
||||
// each train — an id still being sent to is always the one worth keeping.
|
||||
maxTrains = 8
|
||||
)
|
||||
|
||||
// RecordTrainPacket appends one received TRAIN_DATA packet to its train's buffer.
|
||||
func (s *Session) RecordTrainPacket(trainID uint32, e TrainEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var t *Train
|
||||
for _, c := range s.trains {
|
||||
if c.ID == trainID {
|
||||
t = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if t == nil {
|
||||
if len(s.trains) >= maxTrains {
|
||||
s.trains = s.trains[1:]
|
||||
}
|
||||
t = &Train{ID: trainID}
|
||||
s.trains = append(s.trains, t)
|
||||
}
|
||||
t.Received++
|
||||
if len(t.Entries) >= trainCap {
|
||||
t.Truncated = true
|
||||
return
|
||||
}
|
||||
t.Entries = append(t.Entries, e)
|
||||
}
|
||||
|
||||
// TrainView returns a copy of one train. A missing id reports ok=false; the caller decides
|
||||
// whether "never saw it" is an error or (for a report request) the answer itself.
|
||||
func (s *Session) TrainView(trainID uint32) (Train, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.trains {
|
||||
if t.ID == trainID {
|
||||
cp := *t
|
||||
cp.Entries = append([]TrainEntry(nil), t.Entries...)
|
||||
return cp, true
|
||||
}
|
||||
}
|
||||
return Train{}, false
|
||||
}
|
||||
|
||||
// Trains returns copies of every train witnessed in this session, oldest first.
|
||||
func (s *Session) Trains() []Train {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Train, 0, len(s.trains))
|
||||
for _, t := range s.trains {
|
||||
cp := *t
|
||||
cp.Entries = append([]TrainEntry(nil), t.Entries...)
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package session
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTrainBufferKeepsHeadAndDeclaresTruncation(t *testing.T) {
|
||||
s := &Session{}
|
||||
over := trainCap + 100
|
||||
for i := 0; i < over; i++ {
|
||||
s.RecordTrainPacket(7, TrainEntry{Seq: uint32(i), Size: 64})
|
||||
}
|
||||
tr, ok := s.TrainView(7)
|
||||
if !ok {
|
||||
t.Fatal("train not found")
|
||||
}
|
||||
if tr.Received != over {
|
||||
t.Fatalf("Received = %d, want %d — the count must include unbuffered packets", tr.Received, over)
|
||||
}
|
||||
if len(tr.Entries) != trainCap {
|
||||
t.Fatalf("buffered %d entries, want the cap %d", len(tr.Entries), trainCap)
|
||||
}
|
||||
if !tr.Truncated {
|
||||
t.Fatal("overflow must be declared, not silent")
|
||||
}
|
||||
// The head must survive: it is what a ring buffer would have lost.
|
||||
if tr.Entries[0].Seq != 0 || tr.Entries[trainCap-1].Seq != trainCap-1 {
|
||||
t.Fatalf("buffer kept seqs %d..%d, want the head 0..%d",
|
||||
tr.Entries[0].Seq, tr.Entries[trainCap-1].Seq, trainCap-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainEvictionDropsOldestTrain(t *testing.T) {
|
||||
s := &Session{}
|
||||
for id := uint32(0); id < maxTrains+2; id++ {
|
||||
s.RecordTrainPacket(id, TrainEntry{Seq: 1})
|
||||
}
|
||||
if _, ok := s.TrainView(0); ok {
|
||||
t.Fatal("oldest train should have been evicted")
|
||||
}
|
||||
if _, ok := s.TrainView(1); ok {
|
||||
t.Fatal("second-oldest train should have been evicted")
|
||||
}
|
||||
if _, ok := s.TrainView(maxTrains + 1); !ok {
|
||||
t.Fatal("newest train missing")
|
||||
}
|
||||
if got := len(s.Trains()); got != maxTrains {
|
||||
t.Fatalf("holding %d trains, want %d", got, maxTrains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainViewReturnsACopy(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.RecordTrainPacket(3, TrainEntry{Seq: 10})
|
||||
tr, _ := s.TrainView(3)
|
||||
tr.Entries[0].Seq = 99
|
||||
again, _ := s.TrainView(3)
|
||||
if again.Entries[0].Seq != 10 {
|
||||
t.Fatal("TrainView leaked the internal slice")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user