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
@@ -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,8 +280,14 @@ 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},
|
||||
"tcp": tcp,
|
||||
"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.
|
||||
"throughput": sess.ThroughputReports(),
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user