mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 08:56:52 +01:00
With tun.staged.workers set, each Linux UDP listener classifies a recvmmsg
batch, hands its data packets to that many decrypt workers, and an ordered
writer commits the batch in arrival order: replay window, roaming, firewall,
control packets and tun writes go through the same helpers inline receive
uses. Six batches per routine bound memory and backpressure. Conns that cannot
hand off their buffers stay inline.
The listener's classification is speculative. Before touching replay state the
writer resolves each data packet through its own batch-local hostmap cache,
exactly where inline receive would, so data after a close in the same batch is
still delivered while data after a removal or replacement between batches is
dropped, with the same RecvError reply inline receive sends. Known replays are
marked by a precheck and skip decryption, but keep their place so that reply
stays identical when an earlier RecvError deleted their tunnel.
New API: udp.StdConn.ListenOutHandoff, and the same on TesterConn for e2e,
runs the receive loop while handing each batch's buffers to the caller in
exchange for replacements. It is found through an unexported interface in
nebula, so udp.Conn and its other implementations are unchanged.
Metrics: flow.rx.batches, and flow.stage.{rx_read,rx_decrypt,rx_write}.q<N>.busy_ns.
Tests cover both ciphers, ordering, duplicate and corrupted packets from other
addresses, window jumps, control packets mid-batch, RecvError then replay,
tunnel replacement, firewall parity, fallback, real UDP sockets, partial
batches at close, and a handshake and traffic through e2e that checks pipeline
use on Linux and the inline fallback elsewhere. No isolated receive throughput
claim is made.
221 lines
9.5 KiB
Go
221 lines
9.5 KiB
Go
//go:build linux && !android && !e2e_testing
|
|
|
|
package nebula
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/flynn/noise"
|
|
"github.com/slackhq/nebula/header"
|
|
"github.com/slackhq/nebula/noiseutil"
|
|
"github.com/slackhq/nebula/udp"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
)
|
|
|
|
// TestFlowStagedRXOrderAndReplay feeds every packet behind a forgery of it, from another address, and a replay of
|
|
// it, then a test packet that jumps the window and the whole stream again, with shuffled workers and a slow tun,
|
|
// through small and large batches: the tun sees each segment once, in order, the window marks them all, the forgeries
|
|
// neither consume a counter nor roam, and the jump does not get the earlier data packets of its batch rejected.
|
|
func TestFlowStagedRXOrderAndReplay(t *testing.T) {
|
|
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
|
for _, cipher := range []noise.CipherFunc{noiseutil.CipherAESGCM, noise.CipherChaChaPoly} {
|
|
for _, batchLen := range []int{8, 1000} {
|
|
t.Run(fmt.Sprintf("%s/batch=%d", cipher.CipherName(), batchLen), func(t *testing.T) {
|
|
sender, receiver := newFlowTestPair(t, cipher, stagedOn(4))
|
|
delayCrypto(receiver, func(n uint64) {
|
|
if n%32 == 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
})
|
|
receiver.tun.delay = 100 * time.Microsecond
|
|
batches, want := flowTestBatches(30, 4, 1, 10)
|
|
wire := wireFor(t, sender, batches)
|
|
var feed [][]byte
|
|
for i, p := range wire {
|
|
// Corrupt the tag or the payload
|
|
bad := append([]byte(nil), p...)
|
|
bad[[]int{len(p) - 1, header.Len + 3}[i%2]] ^= 1
|
|
feed = append(feed, bad, p, p)
|
|
}
|
|
// The jump lands in a batch with fresh data before it, at batch=1000
|
|
sender.hostinfo.ConnectionState.messageCounter.Add(2 * ReplayWindow)
|
|
feed = append(append(feed, sendControl(sender, header.Test, header.TestReply)), wire...)
|
|
forged := func(i int) netip.AddrPort {
|
|
if i < 3*len(wire) && i%3 == 0 {
|
|
return netip.MustParseAddrPort("198.51.100.9:4242")
|
|
}
|
|
return flowTestUnder
|
|
}
|
|
rx0 := rxBatches.Count()
|
|
conn := &rxConn{wire: feed, batchLen: batchLen, from: forged}
|
|
receiver.receive(conn)
|
|
require.Positive(t, rxBatches.Count()-rx0)
|
|
require.Equal(t, (len(feed)+batchLen-1)/batchLen, conn.swaps, "every batch is handed off")
|
|
require.Equal(t, want, receiver.tun.segments(t), "every packet exactly once, in order")
|
|
require.Equal(t, flowTestUnder, receiver.hostinfo.GetRemote())
|
|
for _, p := range wire {
|
|
h := &header.H{}
|
|
require.NoError(t, h.Parse(p))
|
|
require.False(t, receiver.hostinfo.ConnectionState.window.Check(receiver.f.l, h.MessageCounter), "counter %d must be marked seen", h.MessageCounter)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFlowStagedRXClose: a CloseTunnel in the middle of a batch is handled in its place, by the writer. The data after
|
|
// it in the batch is still delivered from the batch's hostmap cache, as inline receive does.
|
|
func TestFlowStagedRXClose(t *testing.T) {
|
|
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
batches, want := flowTestBatches(4, 4, 1, 8)
|
|
wire := wireFor(t, sender, batches)
|
|
half := len(wire) / 2
|
|
feed := append(append(append([][]byte{}, wire[:half]...), sendControl(sender, header.CloseTunnel, 0)), wire[half:]...)
|
|
receiver.receive(&rxConn{wire: feed, batchLen: len(feed)})
|
|
require.Equal(t, want, receiver.tun.segments(t))
|
|
require.Nil(t, receiver.f.hostMap.QueryIndex(receiver.hostinfo.localIndexId))
|
|
}
|
|
|
|
// sendControl returns the wire packet of a control message from sender.
|
|
func sendControl(sender *flowTestPeer, typ header.MessageType, st header.MessageSubType) []byte {
|
|
sender.f.sendNoMetrics(typ, st, sender.hostinfo.ConnectionState, sender.hostinfo, flowTestUnder, nil, make([]byte, 12), make([]byte, mtu), 0)
|
|
wire := sender.conn.captured()
|
|
sender.conn.pkts, sender.conn.addrs = nil, nil
|
|
return wire[len(wire)-1]
|
|
}
|
|
|
|
// receivePaths is inline, staged and, with the threshold, adaptive receive.
|
|
func receivePaths() []stagedConfig {
|
|
return []stagedConfig{{}, stagedOn(2)}
|
|
}
|
|
|
|
// TestFlowStagedRXRecvError feeds a RecvError that deletes the tunnel, then a known replay and new data for it, in one
|
|
// batch. Every receive path must drop the data through its own view of the hostmap, without touching the replay window,
|
|
// and answer each packet with a RecvError, as inline receive does, without decrypting the replay again.
|
|
func TestFlowStagedRXRecvError(t *testing.T) {
|
|
for _, sc := range receivePaths() {
|
|
t.Run(stagedName(sc), func(t *testing.T) {
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, sc)
|
|
receiver.f.messageMetrics = newMessageMetrics()
|
|
receiver.f.acceptRecvErrorConfig = recvErrorAlways
|
|
receiver.f.sendRecvErrorConfig = recvErrorAlways
|
|
batches, want := flowTestBatches(1, 1, 1, 33)
|
|
wire := wireFor(t, sender, batches)
|
|
rxc := newRxContext(receiver.f, 0)
|
|
receiver.f.readOutsidePackets(ViaSender{UdpAddr: flowTestUnder}, append([]byte(nil), wire[0]...), rxc)
|
|
receiver.f.flushOutside(rxc)
|
|
h := &header.H{}
|
|
require.NoError(t, h.Parse(wire[0]))
|
|
replay := h.MessageCounter
|
|
var decrypts atomic.Int64
|
|
delayCrypto(receiver, func(n uint64) {
|
|
if n == replay {
|
|
decrypts.Add(1)
|
|
}
|
|
})
|
|
ctl := header.Encode(make([]byte, header.Len), header.Version, header.RecvError, 0, receiver.hostinfo.remoteIndexId, 0)
|
|
conn := &rxConn{wire: append([][]byte{ctl}, wire...), batchLen: len(wire) + 1}
|
|
receiver.receive(conn)
|
|
require.Nil(t, receiver.f.hostMap.QueryIndex(receiver.hostinfo.localIndexId))
|
|
require.Equal(t, want[:1], receiver.tun.segments(t))
|
|
require.Zero(t, decrypts.Load(), "known replays must not be decrypted again")
|
|
replies := conn.captured()
|
|
require.Len(t, replies, len(wire))
|
|
for i, reply := range replies {
|
|
require.NoError(t, h.Parse(reply))
|
|
require.Equal(t, header.RecvError, h.Type)
|
|
require.NoError(t, h.Parse(wire[i]))
|
|
require.True(t, i == 0 || receiver.hostinfo.ConnectionState.window.Check(nil, h.MessageCounter), "dropped data must not update replay state")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFlowStagedRXReplacedTunnel classifies a data packet, then a close and a new handshake replace its tunnel at the
|
|
// same index before the writer commits it, as when the listener runs ahead: it must be dropped untouched.
|
|
func TestFlowStagedRXReplacedTunnel(t *testing.T) {
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
receiver.f.messageMetrics = newMessageMetrics()
|
|
batches, _ := flowTestBatches(1, 1, 1, 1)
|
|
wire := wireFor(t, sender, batches)
|
|
closePacket := sendControl(sender, header.CloseTunnel, 0)
|
|
p := &stagedRx{f: receiver.f, h: &header.H{}, hostmapCache: map[uint32]*HostInfo{}}
|
|
item := rxItem{packet: wire[0], via: flowTestUnder}
|
|
p.classify(&item)
|
|
require.NotNil(t, item.ci)
|
|
rxc := newRxContext(receiver.f, 0)
|
|
p.process(&rxBatch{items: []rxItem{{packet: closePacket, via: flowTestUnder}}}, rxc)
|
|
require.Nil(t, receiver.f.hostMap.QueryIndex(receiver.hostinfo.localIndexId))
|
|
_, replacement := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
receiver.f.hostMap.Lock()
|
|
receiver.f.hostMap.unlockedAddHostInfo(replacement.hostinfo, receiver.f)
|
|
receiver.f.hostMap.Unlock()
|
|
p.process(&rxBatch{items: []rxItem{item}}, rxc)
|
|
require.Empty(t, receiver.tun.writes)
|
|
require.True(t, item.ci.window.Check(nil, item.counter))
|
|
require.True(t, replacement.hostinfo.ConnectionState.window.Check(nil, item.counter))
|
|
}
|
|
|
|
// TestFlowStagedRXRealSocket exercises buffer handoff through the real Linux UDP receive loop.
|
|
func TestFlowStagedRXRealSocket(t *testing.T) {
|
|
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(3))
|
|
batches, want := flowTestBatches(60, 4, 1, 8)
|
|
wire := wireFor(t, sender, batches)
|
|
|
|
l := receiver.f.l
|
|
conn, err := udp.NewListener(l, udp.Settings{Listen: netip.MustParseAddrPort("127.0.0.1:0"), Batch: 16, Offloads: true})
|
|
require.NoError(t, err)
|
|
addr, err := conn.LocalAddr()
|
|
require.NoError(t, err)
|
|
out, err := udp.NewListener(l, udp.Settings{Listen: netip.MustParseAddrPort("127.0.0.1:0"), Batch: 16, Offloads: true})
|
|
require.NoError(t, err)
|
|
defer out.Close()
|
|
// The sender's address is the tunnel's remote, so no roaming
|
|
from, err := out.LocalAddr()
|
|
require.NoError(t, err)
|
|
receiver.hostinfo.remote.Store(&from)
|
|
|
|
done := make(chan struct{})
|
|
go func() { receiver.receive(conn); close(done) }()
|
|
// Send in bursts small enough for the default socket buffer
|
|
for off := 0; off < len(wire); off += 32 {
|
|
end := min(off+32, len(wire))
|
|
addrs := make([]netip.AddrPort, end-off)
|
|
for i := range addrs {
|
|
addrs[i] = addr
|
|
}
|
|
_, err := out.WriteBatch(wire[off:end], addrs)
|
|
require.NoError(t, err)
|
|
require.Eventually(t, func() bool { return len(receiver.tun.segments(t)) >= end }, 5*time.Second, 100*time.Microsecond)
|
|
}
|
|
receiver.f.closed.Store(true)
|
|
require.NoError(t, conn.Close())
|
|
<-done
|
|
require.Equal(t, want, receiver.tun.segments(t))
|
|
}
|
|
|
|
// TestFlowStagedRXCloseFlushesPartial: packets the listener holds in a batch it never got to end when the conn fails
|
|
// are still committed, in order, on a live interface.
|
|
func TestFlowStagedRXCloseFlushesPartial(t *testing.T) {
|
|
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
batches, want := flowTestBatches(11, 4, 1, 8)
|
|
wire := wireFor(t, sender, batches)
|
|
p := newStagedRx(receiver.f, 0, newRxContext(receiver.f, 0))
|
|
for i, packet := range wire {
|
|
p.deliver(flowTestUnder, packet)
|
|
if (i+1)%64 == 0 {
|
|
p.endBatch()
|
|
}
|
|
}
|
|
p.close()
|
|
require.Equal(t, want, receiver.tun.segments(t))
|
|
}
|