mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 12:16:52 +01:00
The sticky gate only drains socket submissions, so moving relay data between reader and staged sender CPUs can reorder packets on independent NIC queues. Keep relayed batches on the reader with separate, shared counter reservations while preserving the direct gate and sender affinity. Cover gate switches, oversized relay credit, mixed direct placement and stalled shared-tunnel reservations. Add Forgejo build, test, race, crypto, lint and artifact jobs on incus-docker.
232 lines
8.3 KiB
Go
232 lines
8.3 KiB
Go
//go:build linux && !android && !e2e_testing
|
|
|
|
package nebula
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
"os"
|
|
"os/exec"
|
|
"testing"
|
|
|
|
"github.com/slackhq/nebula/overlay/tio"
|
|
|
|
"github.com/flynn/noise"
|
|
"github.com/slackhq/nebula/header"
|
|
"github.com/slackhq/nebula/udp"
|
|
"github.com/slackhq/nebula/util"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// queuedWire models two CPU-selected NIC queues with independent completion: the reader's
|
|
// queue is congested, while another CPU's queue drains immediately. WriteBatch returning only
|
|
// means the kernel accepted the packets; draining staged slots does not drain a NIC queue.
|
|
// The sampled affinity makes the schedule deterministic without a real socket or NIC. An
|
|
// unpinned sender alternates between allowed CPUs, even if it happened not to in this run.
|
|
func (c *affinityConn) queuedWire(readerCPU int) [][]byte {
|
|
var slow, fast [][]byte
|
|
wire := c.captured()
|
|
off := 0
|
|
for i, n := range c.sizes {
|
|
mask := c.masks[i]
|
|
if mask.IsSet(readerCPU) && (mask.Count() == 1 || i%2 == 0) {
|
|
slow = append(slow, wire[off:off+n]...)
|
|
} else {
|
|
fast = append(fast, wire[off:off+n]...)
|
|
}
|
|
off += n
|
|
}
|
|
return append(fast, slow...)
|
|
}
|
|
|
|
// TestFlowStagedTXRelayQueuedWire reproduces reorder after successful, ordered socket
|
|
// submissions. Mixed reads switch the sticky gate with an empty software pipeline; relayed
|
|
// data must stay on the reader throughout, without moving the direct sender. Each routine owns a flow.
|
|
func TestFlowStagedTXRelayQueuedWire(t *testing.T) {
|
|
if os.Getenv("NEBULA_FLOW_RING_TEST") != "1" {
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestFlowStagedTXRelayQueuedWire$")
|
|
cmd.Env = append(os.Environ(), "NEBULA_FLOW_RING_TEST=1")
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "%s", out)
|
|
return
|
|
}
|
|
allowed, err := util.AllowedCPUs()
|
|
require.NoError(t, err)
|
|
if len(allowed) < 2 {
|
|
t.Skip("requires two allowed CPUs")
|
|
}
|
|
for _, mode := range []string{"gate", "always-staged", "oversized"} {
|
|
for _, explicit := range []bool{false, true} {
|
|
for _, routines := range []int{1, 2} {
|
|
for q := range routines {
|
|
t.Run(fmt.Sprintf("%s/explicit=%t/routines=%d/q=%d", mode, explicit, routines, q), func(t *testing.T) {
|
|
sc := gated(stagedGateHold)
|
|
if mode != "gate" {
|
|
sc.inlineBelow = 0
|
|
}
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, sc)
|
|
f := sender.f
|
|
f.pinThreads, f.routines = true, routines
|
|
if explicit {
|
|
f.cpuAffinity = allowed[:2]
|
|
}
|
|
_, relayHI, relayPeer := addTestRelay(t, sender, ReplayWindow)
|
|
conn := &affinityConn{}
|
|
f.writers = make([]udp.Conn, routines)
|
|
for i := range f.writers {
|
|
f.writers[i] = conn
|
|
}
|
|
shape := append(repeatBool(false, 2), repeatBool(true, stagedGateHold+2)...)
|
|
shape = append(shape, repeatBool(false, stagedGateHold+2)...)
|
|
if mode != "gate" {
|
|
shape = []bool{false, true, false, true}
|
|
}
|
|
reads, want := gateReads(shape...)
|
|
if mode == "oversized" {
|
|
// A deliberately tiny outer budget alone forces the oversized fallback.
|
|
relayHI.ConnectionState.window = NewBits(16)
|
|
}
|
|
queue := &affinityQueue{flowBatchQueue: newFlowBatchQueue(reads), conn: conn}
|
|
// Each Read waits for all previous socket submissions, making software drain
|
|
// irrelevant to the independently queued packets in queuedWire.
|
|
f.listenIn(queue, q)
|
|
unwrap := func(wire [][]byte) [][]byte {
|
|
inner := make([][]byte, 0, len(wire))
|
|
for _, pkt := range wire {
|
|
inner = append(inner, pkt[header.Len:len(pkt)-relayPeer.dKey.Overhead()])
|
|
}
|
|
return inner
|
|
}
|
|
require.Equal(t, want, decryptWire(t, receiver, unwrap(conn.captured())), "socket submission order")
|
|
require.Equal(t, want, decryptWire(t, receiver, unwrap(conn.queuedWire(f.threadCPU(q)))), "NIC queue completion order")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mixed reads must keep direct sends on the original staged sender, including when relay data is present.
|
|
func TestFlowStagedTXMixedPlacement(t *testing.T) {
|
|
if os.Getenv("NEBULA_FLOW_MIXED_TEST") != "1" {
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestFlowStagedTXMixedPlacement$")
|
|
cmd.Env = append(os.Environ(), "NEBULA_FLOW_MIXED_TEST=1")
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "%s", out)
|
|
return
|
|
}
|
|
allowed, err := util.AllowedCPUs()
|
|
require.NoError(t, err)
|
|
if len(allowed) < 2 {
|
|
t.Skip("requires two allowed CPUs")
|
|
}
|
|
for _, explicit := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("explicit=%t", explicit), func(t *testing.T) {
|
|
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
_, _, relayPeer := addTestRelay(t, sender, ReplayWindow)
|
|
direct, directReceiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
directAddr := netip.MustParseAddr("10.0.0.4")
|
|
direct.hostinfo.vpnAddrs = []netip.Addr{directAddr}
|
|
f := sender.f
|
|
f.hostMap.Hosts[directAddr] = direct.hostinfo
|
|
f.pinThreads, f.routines = true, 2
|
|
sendCPU := -1
|
|
if explicit {
|
|
f.cpuAffinity = allowed[:2]
|
|
sendCPU = allowed[1]
|
|
}
|
|
conn := &affinityConn{}
|
|
f.writers = []udp.Conn{conn, conn}
|
|
var reads [][]tio.Packet
|
|
var wantRelay, wantDirect []decodedSeg
|
|
for seq := range uint32(6) {
|
|
r := flowTCPSuperpacket(0, seq, 3)
|
|
d := flowTCPSuperpacket(1, seq, 9)
|
|
copy(d.Bytes[16:20], directAddr.AsSlice())
|
|
reads = append(reads, []tio.Packet{r, d})
|
|
for i := range 3 {
|
|
wantRelay = append(wantRelay, decodedSeg{seq: seq, idx: uint16(i)})
|
|
}
|
|
for i := range 9 {
|
|
wantDirect = append(wantDirect, decodedSeg{flow: 1, seq: seq, idx: uint16(i)})
|
|
}
|
|
}
|
|
queue := &affinityQueue{flowBatchQueue: newFlowBatchQueue(reads), conn: conn}
|
|
f.listenIn(queue, 0)
|
|
wire := conn.captured()
|
|
var directWire, relayWire [][]byte
|
|
off := 0
|
|
for i, n := range conn.sizes {
|
|
for _, pkt := range wire[off : off+n] {
|
|
var h header.H
|
|
require.NoError(t, h.Parse(pkt))
|
|
cpu := sendCPU
|
|
if h.Subtype == header.MessageRelay {
|
|
cpu = f.threadCPU(0)
|
|
require.NoError(t, relayPeer.VerifyRelay(nil, h.MessageCounter, pkt, make([]byte, 12)))
|
|
relayWire = append(relayWire, pkt[header.Len:len(pkt)-relayPeer.dKey.Overhead()])
|
|
} else {
|
|
directWire = append(directWire, pkt)
|
|
}
|
|
requireFlowCPU(t, conn.masks[i:i+1], cpu, allowed)
|
|
}
|
|
off += n
|
|
}
|
|
require.Equal(t, wantDirect, decryptWire(t, directReceiver, directWire))
|
|
require.Equal(t, wantRelay, decryptWire(t, receiver, relayWire))
|
|
})
|
|
}
|
|
}
|
|
|
|
// A relayed batch shares its outer tunnel with older staged direct data on the SAME routine. Inline completion
|
|
// must not refund that direct sender's credit while its oldest reservation is stalled.
|
|
func TestFlowStagedTXRelaySharedBudget(t *testing.T) {
|
|
sender, _ := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
|
|
_, via, _ := addTestRelay(t, sender, ReplayWindow)
|
|
// Give the relay host the same ordinary peer metadata as a direct tunnel.
|
|
direct := newFlowTestPeer(t, flowTestLocal, via.vpnAddrs[0], via.ConnectionState, 300, 400)
|
|
via.buildNetworks(sender.f.myVpnNetworksTable, direct.hostinfo.ConnectionState.peerCert.Certificate)
|
|
via.remoteIndexId = direct.hostinfo.remoteIndexId
|
|
ci := via.ConnectionState
|
|
ci.window = NewBits(32)
|
|
start := ci.messageCounter.Load()
|
|
entered, unblock := make(chan struct{}), make(chan struct{})
|
|
var release sync.Once
|
|
delayCrypto(direct, func(n uint64) {
|
|
if n == start+1 {
|
|
close(entered)
|
|
<-unblock
|
|
}
|
|
})
|
|
p := newStagedTx(sender.f, newFlowBatchQueue(nil), 0)
|
|
defer p.close()
|
|
defer release.Do(func() { close(unblock) })
|
|
d := flowTCPSuperpacket(1, 0, 9)
|
|
copy(d.Bytes[16:20], via.vpnAddrs[0].AsSlice())
|
|
p.send([]tio.Packet{d})
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("direct worker did not start")
|
|
}
|
|
for seq := range uint32(7) {
|
|
p.send([]tio.Packet{flowTCPSuperpacket(0, seq, 1)})
|
|
}
|
|
require.Equal(t, uint64(16), (ci.txBudget.state.Load()>>24)&txBudgetMask)
|
|
done := make(chan struct{})
|
|
go func() { defer close(done); p.send([]tio.Packet{flowTCPSuperpacket(0, 7, 1)}) }()
|
|
select {
|
|
case <-done:
|
|
t.Error("relay recycled credit around a stalled direct reservation")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
require.Equal(t, start+16, ci.messageCounter.Load())
|
|
release.Do(func() { close(unblock) })
|
|
<-done
|
|
p.drain()
|
|
require.Zero(t, ci.txBudget.state.Load())
|
|
}
|