server: §3.4 asymmetric grants + downtrain and big_send actions
The grant is the keystone that makes server->client sends safe: created only by an authenticated control-plane action, bound at creation to the session's OBSERVED data-plane source (so it can never be aimed at a third party), and bounded by bytes, average rate and expiry. Sends stop the moment the budget runs out, so a buggy action cannot become a flood. Two granted actions on top of it: - downtrain: N packets at a given size/interval toward the client, with seq + send-timestamp in the payload — downstream loss/reorder/jitter, which an upstream-only train cannot measure. - big_send: one datagram per requested size, echoing the intended size in the payload — downstream MTU / black-hole evidence the client cannot produce for itself (only the far end can emit a large packet toward it). Tests cover the security properties: no grant without a verified destination, client requests clamped to server limits, byte budget stops sending exactly, expiry refuses, and the rate ceiling throttles a burst. Capabilities gain downtrain + big-send. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c75a9f5eb7
commit
7e1015c211
@@ -0,0 +1,87 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
// Server-to-client sends that exceed the request size, and are therefore only legal under an
|
||||
// asymmetric grant (spec §3.4). Every one of them:
|
||||
// - targets the session's observed data-plane source, fixed when the grant was created;
|
||||
// - stops the moment the grant's byte/rate/time budget is exhausted;
|
||||
// - is HMAC-signed with the session key, so the client can tell our packets from injected ones.
|
||||
|
||||
// DownTrain sends `count` DOWNTRAIN_DATA packets of `sizeBytes` spaced `intervalUs` apart. The
|
||||
// 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) {
|
||||
target := sess.DataSource()
|
||||
if !target.IsValid() {
|
||||
return 0, fmt.Errorf("no observed data-plane source")
|
||||
}
|
||||
conn := s.connFor(target)
|
||||
if conn == nil {
|
||||
return 0, fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
if sizeBytes < HeaderSize+8 {
|
||||
sizeBytes = HeaderSize + 8
|
||||
}
|
||||
payload := make([]byte, sizeBytes-HeaderSize)
|
||||
sent := 0
|
||||
for i := 0; i < count; i++ {
|
||||
if !g.Allow(sizeBytes) {
|
||||
break // budget or rate exhausted — stop, do not sleep it off
|
||||
}
|
||||
// Sequence + send timestamp in the payload head so the client can order and time them
|
||||
// even when packets arrive out of order.
|
||||
binary.BigEndian.PutUint32(payload[0:4], uint32(i))
|
||||
binary.BigEndian.PutUint32(payload[4:8], uint32(time.Since(s.start).Microseconds()))
|
||||
s.send(conn, target, sess, TypeDownTrainData, uint32(i), payload)
|
||||
sent++
|
||||
if intervalUs > 0 && i < count-1 {
|
||||
time.Sleep(time.Duration(intervalUs) * time.Microsecond)
|
||||
}
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
// BigSend transmits one datagram per requested size, largest-first metadata intact, so the client
|
||||
// can see which sizes survive the *downstream* path — the mtu.pmtud_down / mtu.blackhole evidence.
|
||||
// The client cannot produce this itself: only the far end can emit a large packet toward it.
|
||||
func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int) ([]int, error) {
|
||||
target := sess.DataSource()
|
||||
if !target.IsValid() {
|
||||
return nil, fmt.Errorf("no observed data-plane source")
|
||||
}
|
||||
conn := s.connFor(target)
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
attempted := make([]int, 0, len(sizes))
|
||||
for i, size := range sizes {
|
||||
if size < HeaderSize+8 {
|
||||
size = HeaderSize + 8
|
||||
}
|
||||
if size > 9000 { // jumbo ceiling; beyond this the kernel will refuse anyway
|
||||
size = 9000
|
||||
}
|
||||
if !g.Allow(size) {
|
||||
break
|
||||
}
|
||||
payload := make([]byte, size-HeaderSize)
|
||||
// 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))
|
||||
s.send(conn, target, sess, TypeBigSend, uint32(i), payload)
|
||||
attempted = append(attempted, size)
|
||||
time.Sleep(20 * time.Millisecond) // keep bursts from being read as congestion loss
|
||||
}
|
||||
return attempted, nil
|
||||
}
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
TypeMtuProbe = 0x09
|
||||
TypeMtuAck = 0x0A
|
||||
TypeDelayedEcho = 0x0B
|
||||
// Server->client under an asymmetric grant (spec §3.4/§5).
|
||||
TypeDownTrainData = 0x06
|
||||
TypeBigSend = 0x0C
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
|
||||
Reference in New Issue
Block a user