mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 09:36: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.
154 lines
5.2 KiB
Go
154 lines
5.2 KiB
Go
package nebula
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/slackhq/nebula/cert"
|
|
"github.com/slackhq/nebula/handshake"
|
|
"github.com/slackhq/nebula/header"
|
|
"github.com/slackhq/nebula/noiseutil"
|
|
)
|
|
|
|
const (
|
|
ReplayWindow = 8192
|
|
|
|
// RehandshakeAfterMessages rolls keys inside the AES-GCM data-volume margin (~2^-36 advantage at 64KB frames).
|
|
RehandshakeAfterMessages = uint64(1) << 34
|
|
|
|
// RejectAfterMessages is the nonce ceiling enforced by noiseutil; a tunnel here is deleted locally, not notified.
|
|
RejectAfterMessages = noiseutil.RejectAfterMessages
|
|
)
|
|
|
|
// RehandshakeAfterMessages must stay below RejectAfterMessages so tunnels roll before the hard send stop.
|
|
const _ = RejectAfterMessages - RehandshakeAfterMessages
|
|
|
|
// sessionEpoch hands out a receiver-local ordinal to every ConnectionState at creation. The RX
|
|
// staging sort (overlay/batch) orders packets by (epoch, message counter). A re-handshake never
|
|
// rekeys an existing tunnel; it brings up a new hostinfo and ConnectionState with a counter space
|
|
// starting near zero, while the old tunnel keeps decrypting until torn down. During that cutover
|
|
// one flush batch can hold packets from both tunnels, and the epoch keeps the old tunnel's
|
|
// packets sorted first.
|
|
var sessionEpoch atomic.Uint64
|
|
|
|
type ConnectionState struct {
|
|
eKey noiseutil.CipherState
|
|
dKey noiseutil.CipherState
|
|
myCert cert.Certificate
|
|
peerCert *cert.CachedCertificate
|
|
initiator bool
|
|
messageCounter atomic.Uint64
|
|
window *Bits
|
|
decryptLock sync.Mutex
|
|
writeLock sync.Mutex
|
|
// epoch is this session's sessionEpoch ordinal. Immutable after creation.
|
|
epoch uint64
|
|
}
|
|
|
|
// newConnectionStateFromResult builds a fully-populated ConnectionState from a
|
|
// completed handshake.Result. It seeds messageCounter and the replay window so
|
|
// that the post-handshake message indices already used on the wire don't count
|
|
// as missed traffic in the data plane.
|
|
func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error) {
|
|
// Refuse a MessageIndex too big for the replay window: it can only be a bug, and would spin the seed loop below.
|
|
if r.MessageIndex >= ReplayWindow {
|
|
return nil, fmt.Errorf("handshake message index %d exceeds replay window", r.MessageIndex)
|
|
}
|
|
|
|
ci := &ConnectionState{
|
|
myCert: r.MyCert,
|
|
initiator: r.Initiator,
|
|
peerCert: r.RemoteCert,
|
|
eKey: noiseutil.NewCipherState(r.EKey, r.Cipher),
|
|
dKey: noiseutil.NewCipherState(r.DKey, r.Cipher),
|
|
window: NewBits(ReplayWindow),
|
|
epoch: sessionEpoch.Add(1),
|
|
}
|
|
ci.messageCounter.Add(r.MessageIndex)
|
|
for i := uint64(1); i <= r.MessageIndex; i++ {
|
|
ci.window.Update(nil, i)
|
|
}
|
|
return ci, nil
|
|
}
|
|
|
|
func (cs *ConnectionState) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(m{
|
|
"certificate": cs.peerCert,
|
|
"initiator": cs.initiator,
|
|
"message_counter": cs.messageCounter.Load(),
|
|
})
|
|
}
|
|
|
|
// NextMessageCounter reserves the next 1-based counter; RejectAfterMessages is the first we refuse, pinned to not wrap.
|
|
func (cs *ConnectionState) NextMessageCounter() (uint64, bool) {
|
|
c := cs.messageCounter.Add(1)
|
|
if c >= RejectAfterMessages {
|
|
cs.messageCounter.Store(RejectAfterMessages)
|
|
return c, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func (cs *ConnectionState) Curve() cert.Curve {
|
|
return cs.myCert.Curve()
|
|
}
|
|
|
|
func (cs *ConnectionState) Decrypt(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) ([]byte, error) {
|
|
cs.decryptLock.Lock()
|
|
result := cs.window.Check(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return nil, ErrAlreadySeen
|
|
}
|
|
|
|
out, err := cs.decryptPacket(messageCounter, packet, nb)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cs.decryptLock.Lock()
|
|
result = cs.window.Update(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return nil, ErrAlreadySeen
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decryptPacket authenticates and decrypts packet in place, leaving the replay window to the caller.
|
|
func (cs *ConnectionState) decryptPacket(messageCounter uint64, packet, nb []byte) ([]byte, error) {
|
|
return cs.dKey.DecryptDanger(packet[header.Len:header.Len], packet[:header.Len], packet[header.Len:], messageCounter, nb)
|
|
}
|
|
|
|
func (cs *ConnectionState) VerifyRelay(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) error {
|
|
cs.decryptLock.Lock()
|
|
result := cs.window.Check(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return ErrAlreadySeen
|
|
}
|
|
|
|
// The entire body is sent as AD, not encrypted.
|
|
// The packet consists of a 16-byte parsed Nebula header, Associated Data-protected payload, and a trailing 16-byte AEAD signature value.
|
|
// The packet is guaranteed to be at least 16 bytes at this point, b/c it got past the h.Parse() call above. If it's
|
|
// otherwise malformed (meaning, there is no trailing 16 byte AEAD value), then this will result in at worst a 0-length slice
|
|
// which will gracefully fail in the DecryptDanger call.
|
|
signedPayload := packet[:len(packet)-cs.dKey.Overhead()]
|
|
signatureValue := packet[len(packet)-cs.dKey.Overhead():]
|
|
_, err := cs.dKey.DecryptDanger(nil, signedPayload, signatureValue, messageCounter, nb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cs.decryptLock.Lock()
|
|
result = cs.window.Update(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return ErrAlreadySeen
|
|
}
|
|
return nil
|
|
}
|