Files
echolot/server/internal/session/session.go
T
mrambossekandClaude Fable 5 892e952a8e
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 33s
throughput: the upstream direction, counted by the only party that can
The client generates the traffic and the server counts it. No grant is involved
- the client is sending its own packets, so there is nothing to amplify - but it
does need the server's tally, because only the far end knows how much arrived.
Without that number a sender measures how fast it can transmit, which is usually
just the speed of the local NIC and is not the question being asked.

A new wire type the server counts and deliberately never answers: a reply would
double the traffic and drag the return path into a measurement that is
specifically about the outbound one.

The tally is a counter, not a list, and short-circuits before the observation
log. A five-second run at 20 Mbps is around ten thousand packets; one struct
each would turn a measurement into an allocation storm on a shared server, and
nothing needs the per-packet detail since the client holds the send-side record.
The gap between the two counts is the loss.

direction=up on the throughput action sends nothing - it zeroes the counter, so
a second run in one session measures itself instead of inheriting the first.

Same honesty rule as downstream: measures_network is false when what arrived
matches what was offered, because then the path was never the constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:54:09 +02:00

316 lines
9.8 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package session implements spec §2.4 sessions and the §2.4 key schedule:
// HKDF-SHA256(ikm=device_credential, salt=key_salt, info="echolot-v1/"+session_id).
package session
import (
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/netip"
"sync"
"time"
)
type Session struct {
ID string // opaque hex; first 8 bytes (16 hex chars) are the wire prefix
Key [32]byte // derived, never crosses the wire
Epoch time.Time // server wall clock at creation (spec: RFC3339 in the response)
Expires time.Time
Device string // device ID
// Observed source of the session-creating request — the ONLY address
// reflected/generated traffic may target (spec §2.5).
ControlSource netip.Addr
// Last data-plane source seen with a valid HMAC (NAT rebinding evidence).
mu sync.Mutex
dataSource netip.AddrPort
dataLocal netip.AddrPort
// Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus
// a bitmask of the 1024 preceding.
maxSeq uint32
window [16]uint64
// Active asymmetric grants (spec §3.4/§5) — the only licence to send more than we receive.
grants []*Grant
// Observations (spec §6): per-packet UDP view + connect-back results.
packetsSeen uint64
udpObs []UDPObservation // ring, newest last, cap obsCap
connectBack []ConnectBackResult
throughput []ThroughputReport
upstream UpstreamCounter
}
const obsCap = 4096
// UDPObservation is the server's witnessed view of one data-plane packet.
type UDPObservation struct {
Seq uint32 `json:"seq"`
TRxNs int64 `json:"t_rx_ns"`
TTxNs int64 `json:"t_tx_ns"`
Src string `json:"src"`
Size int `json:"size"`
Type uint8 `json:"type"`
}
// ThroughputReport is the server's own account of a sustained send: what it managed to put on
// the wire, and what stopped it. The client needs this to interpret its own count — the gap
// between the two IS the loss, and without the sender's number a receiver can only guess.
type ThroughputReport struct {
ActionID string `json:"action_id"`
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
DurationMs int64 `json:"duration_ms"`
Kbps int `json:"kbps"`
LimitedBy string `json:"limited_by"`
}
// UpstreamCounter is the server's tally of a client-driven throughput run.
//
// Deliberately a counter and not a list. A five-second upstream run at 20 Mbps is around ten
// thousand packets; one observation struct each would turn a measurement into an allocation
// storm on a shared server, and nothing downstream needs the per-packet detail - the client
// already has its own send record. The gap between the two counts IS the loss.
type UpstreamCounter struct {
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
FirstRxNs int64 `json:"first_rx_ns"`
LastRxNs int64 `json:"last_rx_ns"`
}
// SpanMs is the time between the first and last packet, which is the interval the rate should be
// computed over - not the client's requested duration, which includes ramp-up and the tail.
func (u UpstreamCounter) SpanMs() int64 {
if u.Packets < 2 || u.LastRxNs <= u.FirstRxNs {
return 0
}
return (u.LastRxNs - u.FirstRxNs) / 1_000_000
}
// Kbps is bits per millisecond, which is kilobits per second - no scaling constant to get wrong.
func (u UpstreamCounter) Kbps() int {
if ms := u.SpanMs(); ms > 0 {
return int(u.Bytes * 8 / ms)
}
return 0
}
// ConnectBackResult records one connect-back action outcome.
type ConnectBackResult struct {
ActionID string `json:"action_id"`
Result string `json:"result"` // connected | refused | timeout
RttMs float64 `json:"rtt_ms"`
}
// RecordUDP appends a packet observation (ring-capped).
func (s *Session) RecordUDP(o UDPObservation) {
s.mu.Lock()
defer s.mu.Unlock()
s.packetsSeen++
if len(s.udpObs) >= obsCap {
s.udpObs = s.udpObs[1:]
}
s.udpObs = append(s.udpObs, o)
}
// RecordConnectBack appends a connect-back outcome.
func (s *Session) RecordConnectBack(r ConnectBackResult) {
s.mu.Lock()
defer s.mu.Unlock()
s.connectBack = append(s.connectBack, r)
}
// Observations returns a copy of everything witnessed so far.
func (s *Session) Observations() (packetsSeen uint64, udp []UDPObservation, cb []ConnectBackResult) {
s.mu.Lock()
defer s.mu.Unlock()
return s.packetsSeen, append([]UDPObservation(nil), s.udpObs...),
append([]ConnectBackResult(nil), s.connectBack...)
}
// CountUpstream tallies one client-sent throughput packet.
//
// Called on the hot path for every packet of an upstream run, so it does exactly two additions
// and two comparisons under the lock and allocates nothing.
func (s *Session) CountUpstream(sizeBytes int, tRxNs int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.upstream.Packets == 0 {
s.upstream.FirstRxNs = tRxNs
}
s.upstream.Packets++
s.upstream.Bytes += int64(sizeBytes)
s.upstream.LastRxNs = tRxNs
}
// Upstream returns the tally so far.
func (s *Session) Upstream() UpstreamCounter {
s.mu.Lock()
defer s.mu.Unlock()
return s.upstream
}
// ResetUpstream clears the tally, so a second run in one session measures itself rather than
// inheriting the first one's packets.
func (s *Session) ResetUpstream() {
s.mu.Lock()
defer s.mu.Unlock()
s.upstream = UpstreamCounter{}
}
// RecordThroughput stores the server's account of one sustained send.
//
// Kept as a per-action summary rather than per-packet records: a ten-second run at 50 Mbps is
// half a million packets, and holding one struct each would turn a measurement into a memory
// exhaustion. The client has the per-packet view; the server only needs to say how many it sent.
func (s *Session) RecordThroughput(actionID string, packets int, bytes, durationMs int64, kbps int, limitedBy string) {
s.mu.Lock()
defer s.mu.Unlock()
s.throughput = append(s.throughput, ThroughputReport{
ActionID: actionID, Packets: packets, Bytes: bytes,
DurationMs: durationMs, Kbps: kbps, LimitedBy: limitedBy,
})
}
// ThroughputReports returns the server's account of every sustained send in this session.
func (s *Session) ThroughputReports() []ThroughputReport {
s.mu.Lock()
defer s.mu.Unlock()
return append([]ThroughputReport(nil), s.throughput...)
}
// DataSource returns the last verified data-plane source (invalid when the
// session has not sent data-plane traffic yet).
func (s *Session) DataSource() netip.AddrPort {
s.mu.Lock()
defer s.mu.Unlock()
return s.dataSource
}
// KeySalt returns nothing — the salt is not retained after derivation; it is
// generated in New and returned once for the response body.
type Manager struct {
mu sync.Mutex
byPrefix map[string]*Session // key: first 16 hex chars of ID
ttl time.Duration
}
func NewManager(ttl time.Duration) *Manager {
return &Manager{byPrefix: map[string]*Session{}, ttl: ttl}
}
// New creates a session for a device credential per the spec key schedule.
// Returns the session and the one-time key_salt for the response.
func (m *Manager) New(deviceID, credential string, controlSource netip.Addr) (*Session, []byte, error) {
idBytes := make([]byte, 16)
if _, err := rand.Read(idBytes); err != nil {
return nil, nil, err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, nil, err
}
id := hex.EncodeToString(idBytes)
key, err := hkdf.Key(sha256.New, []byte(credential), salt, "echolot-v1/"+id, 32)
if err != nil {
return nil, nil, err
}
s := &Session{
ID: id,
Epoch: time.Now().UTC(),
Expires: time.Now().Add(m.ttl),
Device: deviceID,
ControlSource: controlSource,
}
copy(s.Key[:], key)
m.mu.Lock()
m.byPrefix[id[:16]] = s
m.mu.Unlock()
return s, salt, nil
}
// ByWirePrefix resolves the 8-byte on-the-wire prefix (as raw bytes).
func (m *Manager) ByWirePrefix(prefix [8]byte) *Session {
m.mu.Lock()
defer m.mu.Unlock()
s := m.byPrefix[hex.EncodeToString(prefix[:])]
if s == nil || time.Now().After(s.Expires) {
return nil
}
return s
}
// ByID resolves a full session id (sessions are keyed by their wire prefix).
func (m *Manager) ByID(id string) *Session {
if len(id) < 16 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
s := m.byPrefix[id[:16]]
if s == nil || s.ID != id || time.Now().After(s.Expires) {
return nil
}
return s
}
func (m *Manager) Delete(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.byPrefix, id[:16])
}
// CheckSeq enforces the 1024-wide anti-replay window. Returns false for
// replays and for packets older than the window.
func (s *Session) CheckSeq(seq uint32) bool {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case seq > s.maxSeq:
shift := seq - s.maxSeq
for i := uint32(0); i < shift && i < 1024; i++ {
idx := (s.maxSeq + 1 + i) % 1024
s.window[idx/64] &^= 1 << (idx % 64)
}
s.maxSeq = seq
case s.maxSeq-seq >= 1024:
return false
}
idx := seq % 1024
if s.window[idx/64]&(1<<(idx%64)) != 0 {
return false
}
s.window[idx/64] |= 1 << (idx % 64)
return true
}
// DataLocal returns the server-side address that received this session's data-plane traffic.
//
// This matters more than it looks: a server bound to several addresses must send granted traffic
// back from the one the client has been talking to. Any stateful firewall or NAT in between has
// a mapping keyed on that exact pair, and a reply from a sibling address is dropped — which the
// client would then measure as downstream loss. See connFor.
func (s *Session) DataLocal() netip.AddrPort {
s.mu.Lock()
defer s.mu.Unlock()
return s.dataLocal
}
// NoteDataLocal records which of our own bound addresses saw this session's traffic.
func (s *Session) NoteDataLocal(ap netip.AddrPort) {
s.mu.Lock()
defer s.mu.Unlock()
s.dataLocal = ap
}
// NoteDataSource records the latest verified data-plane source.
func (s *Session) NoteDataSource(ap netip.AddrPort) {
s.mu.Lock()
s.dataSource = ap
s.mu.Unlock()
}