server: send granted traffic from the address the session actually used
fmr binds two IPv4 addresses. connFor picked whichever socket of the right family came first in the bind list, so a downtrain for a session established on .150 went out from .151 — and every packet was dropped by the client's NAT, which has no mapping for that pair. tcpdump on the server showed all 50 leaving; the client saw none. Read as "100% downstream loss", which is the worst kind of wrong: a confident measurement of something that never happened. Sessions now record which of our own bound addresses received their traffic, and granted sends (and delayed echo) go back out through that socket. The fallback to a family match is kept for the case where nothing has been received yet, and the test pins both paths — a single-homed lab can never reproduce this. Also: the client-side halves of the same work — anonymizer (core-privacy), local run archive with retention (core-archive), upload client, and the app's settings and history screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7a94c9a3d7
commit
ce1aaa332a
@@ -39,13 +39,13 @@ const (
|
||||
|
||||
// Query is one logged canary lookup (spec §6 dns_canary shape).
|
||||
type Query struct {
|
||||
QName string `json:"qname"`
|
||||
At time.Time `json:"at"`
|
||||
ResolverIP string `json:"resolver_ip"`
|
||||
Transport string `json:"transport"` // "udp" | "tcp"
|
||||
EDNS edns `json:"edns"`
|
||||
ECS string `json:"ecs,omitempty"`
|
||||
CasePreserved bool `json:"case_preserved"`
|
||||
QName string `json:"qname"`
|
||||
At time.Time `json:"at"`
|
||||
ResolverIP string `json:"resolver_ip"`
|
||||
Transport string `json:"transport"` // "udp" | "tcp"
|
||||
EDNS edns `json:"edns"`
|
||||
ECS string `json:"ecs,omitempty"`
|
||||
CasePreserved bool `json:"case_preserved"`
|
||||
// qname_minimized is not reliably detectable authoritative-side without
|
||||
// cross-query correlation; left false (TODO) rather than guessed.
|
||||
QNameMinimized bool `json:"qname_minimized"`
|
||||
@@ -59,8 +59,8 @@ type edns struct {
|
||||
|
||||
// Server is the authoritative responder for one canary zone.
|
||||
type Server struct {
|
||||
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
|
||||
nsName string // this server's own name for NS/authority answers
|
||||
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
|
||||
nsName string // this server's own name for NS/authority answers
|
||||
primaryV4 netip.Addr
|
||||
primaryV6 netip.Addr
|
||||
|
||||
@@ -284,4 +284,3 @@ func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte {
|
||||
}
|
||||
return hdr
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func buildQuery(name string, qtype uint16, ednsBufsize int) []byte {
|
||||
msg = binary.BigEndian.AppendUint16(msg, classIN)
|
||||
if ednsBufsize > 0 {
|
||||
binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT
|
||||
msg = append(msg, 0) // root name
|
||||
msg = append(msg, 0) // root name
|
||||
msg = binary.BigEndian.AppendUint16(msg, typeOPT)
|
||||
msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize))
|
||||
msg = binary.BigEndian.AppendUint32(msg, 0)
|
||||
|
||||
@@ -60,4 +60,3 @@ func TestTLSReferenceReturnsChain(t *testing.T) {
|
||||
t.Fatalf("leaf DER not round-tripped: %x", first)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A multi-homed server must answer from the address the client has been talking to. Sending from
|
||||
// a sibling address is silently dropped by the client's NAT or stateful firewall, and the client
|
||||
// then reports downstream loss that never happened — a wrong measurement, which is worse than a
|
||||
// failed one. This was a real bug: fmr binds two IPv4 addresses, the granted train went out from
|
||||
// the one the session had never used, and every packet vanished in transit.
|
||||
func TestConnForPrefersTheAddressTheSessionUsed(t *testing.T) {
|
||||
// Two loopback-bound sockets stand in for the two service addresses.
|
||||
a := mustListen(t, "127.0.0.1:0")
|
||||
b := mustListen(t, "127.0.0.1:0")
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
srv := &Server{}
|
||||
srv.conns = []*net.UDPConn{a, b}
|
||||
|
||||
client := netip.MustParseAddrPort("198.51.100.7:41000")
|
||||
bLocal := b.LocalAddr().(*net.UDPAddr).AddrPort()
|
||||
|
||||
if got := srv.connFor(client, bLocal); got != b {
|
||||
t.Fatalf("connFor picked the wrong socket: want the one the session used (%v)", bLocal)
|
||||
}
|
||||
|
||||
// With no recorded local address (nothing received yet) any socket of the right family is
|
||||
// the best available answer — but it must still be one, not nil.
|
||||
if got := srv.connFor(client, netip.AddrPort{}); got == nil {
|
||||
t.Fatal("connFor returned nil when a family match exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnForFallsBackByFamily(t *testing.T) {
|
||||
v4 := mustListen(t, "127.0.0.1:0")
|
||||
defer v4.Close()
|
||||
srv := &Server{}
|
||||
srv.conns = []*net.UDPConn{v4}
|
||||
|
||||
// A recorded local address that no longer matches any bound socket (a reload changed the
|
||||
// binds) must not strand the session: fall back rather than return nil.
|
||||
stale := netip.MustParseAddrPort("203.0.113.1:8442")
|
||||
if got := srv.connFor(netip.MustParseAddrPort("198.51.100.7:41000"), stale); got != v4 {
|
||||
t.Fatal("connFor should fall back to a family match when the recorded socket is gone")
|
||||
}
|
||||
|
||||
// No IPv6 socket is bound, so an IPv6 target has no answer — nil, not an IPv4 socket.
|
||||
if got := srv.connFor(netip.MustParseAddrPort("[2001:db8::1]:41000"), netip.AddrPort{}); got != nil {
|
||||
t.Fatal("connFor returned an IPv4 socket for an IPv6 target")
|
||||
}
|
||||
}
|
||||
|
||||
func mustListen(t *testing.T, addr string) *net.UDPConn {
|
||||
t.Helper()
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := net.ListenUDP("udp", ua)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB
|
||||
if !target.IsValid() {
|
||||
return 0, fmt.Errorf("no observed data-plane source")
|
||||
}
|
||||
conn := s.connFor(target)
|
||||
conn := s.connFor(target, sess.DataLocal())
|
||||
if conn == nil {
|
||||
return 0, fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d
|
||||
if !target.IsValid() {
|
||||
return nil, fmt.Errorf("no observed data-plane source")
|
||||
}
|
||||
conn := s.connFor(target)
|
||||
conn := s.connFor(target, sess.DataLocal())
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
|
||||
@@ -73,9 +73,23 @@ func (s *Server) Serve(conn *net.UDPConn) error {
|
||||
}
|
||||
|
||||
// connFor picks a retained socket whose family matches the target.
|
||||
func (s *Server) connFor(target netip.AddrPort) *net.UDPConn {
|
||||
// connFor picks the socket to send to target from.
|
||||
//
|
||||
// When the session recorded which local address it has been talking to (local), that socket wins
|
||||
// outright. Falling back to "any socket of the right family" is only correct for a single-homed
|
||||
// server: on a multi-homed one it sends from a sibling address the client's NAT has no mapping
|
||||
// for, the packets are dropped in transit, and the client reports downstream loss that does not
|
||||
// exist. That bug is invisible in a lab with one address, which is exactly why this is explicit.
|
||||
func (s *Server) connFor(target, local netip.AddrPort) *net.UDPConn {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if local.IsValid() {
|
||||
for _, c := range s.conns {
|
||||
if c.LocalAddr().(*net.UDPAddr).AddrPort() == local {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
want4 := target.Addr().Unmap().Is4()
|
||||
for _, c := range s.conns {
|
||||
la := c.LocalAddr().(*net.UDPAddr).AddrPort()
|
||||
@@ -94,7 +108,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
|
||||
if !target.IsValid() {
|
||||
return fmt.Errorf("session has no observed data-plane source yet")
|
||||
}
|
||||
conn := s.connFor(target)
|
||||
conn := s.connFor(target, sess.DataLocal())
|
||||
if conn == nil {
|
||||
return fmt.Errorf("no data-plane socket matches target family")
|
||||
}
|
||||
@@ -131,6 +145,9 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
return
|
||||
}
|
||||
sess.NoteDataSource(raddr)
|
||||
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
sess.NoteDataLocal(la.AddrPort())
|
||||
}
|
||||
sess.RecordUDP(session.UDPObservation{
|
||||
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
|
||||
Src: raddr.String(), Size: len(pkt), Type: typ,
|
||||
|
||||
@@ -33,10 +33,10 @@ type Check struct {
|
||||
|
||||
// MTUResult is one egress path-MTU probe outcome.
|
||||
type MTUResult struct {
|
||||
Target string `json:"target"`
|
||||
DiscoveredMTU int `json:"discovered_mtu"`
|
||||
FullMTU bool `json:"full_mtu"` // >= 1500
|
||||
Err string `json:"err,omitempty"`
|
||||
Target string `json:"target"`
|
||||
DiscoveredMTU int `json:"discovered_mtu"`
|
||||
FullMTU bool `json:"full_mtu"` // >= 1500
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Report is the whole self-test.
|
||||
|
||||
@@ -27,6 +27,7 @@ type Session struct {
|
||||
// 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
|
||||
@@ -192,6 +193,25 @@ func (s *Session) CheckSeq(seq uint32) bool {
|
||||
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()
|
||||
|
||||
@@ -18,7 +18,7 @@ func buildClientHello(ciphers []uint16) []byte {
|
||||
var body []byte
|
||||
body = append(body, u16(0x0303)...) // client_version TLS1.2
|
||||
body = append(body, make([]byte, 32)...) // random
|
||||
body = append(body, 0) // session_id len 0
|
||||
body = append(body, 0) // session_id len 0
|
||||
// cipher suites
|
||||
cs := []byte{}
|
||||
for _, c := range ciphers {
|
||||
@@ -36,8 +36,8 @@ func buildClientHello(ciphers []uint16) []byte {
|
||||
exts = append(exts, data...)
|
||||
}
|
||||
// SNI: server_name_list -> host_name "x"
|
||||
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
|
||||
sni = append(sni, u16(1)...) // name len 1
|
||||
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
|
||||
sni = append(sni, u16(1)...) // name len 1
|
||||
sni = append(sni, 'x')
|
||||
addExt(0x0000, sni)
|
||||
// ALPN: protocol_name_list -> "h2"
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestPlainEchoServerSpeaksFirst(t *testing.T) {
|
||||
t.Fatalf("no greeting: %v", err)
|
||||
}
|
||||
var g struct {
|
||||
TLS bool `json:"tls"`
|
||||
TLS bool `json:"tls"`
|
||||
Src string `json:"observed_src"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &g); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user