mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 15:36:52 +01:00
Set tun.staged.workers to a positive count on Linux, with tun.use_offloads
enabled, to move encryption off each tun reader. The reader filters and
reserves counters for a whole read, hands the batch to that many encrypt
workers, and an ordered sender flushes the batches in read order. Eight slots
per routine bound the batches in flight; a full pipeline blocks the reader.
Zero workers, the default, keeps today's inline path, as do tun queues that
cannot hand off their read buffer and builds whose AES-GCM needs nonce-ordered
encryption (BoringCrypto, FIPS).
IMPORTANT: staged senders need peers with the 8192-entry replay window of
master. Released v1.11.x peers use 1024 and will drop reordered batches. The
window is not negotiated.
Readers keep today's pinning. The sender is only pinned with an explicit
tun.cpu_affinity: sender i uses entry i+1, unless that is its reader's CPU,
so a one-entry list leaves the sender unpinned rather than sharing it: two
busy transmit threads on one CPU serialise (eight TCP streams over 100 GbE
fell from 25.9 to 22.8 Gbit/s). Without a list the sender is not pinned. Placing it by queue index on a
hybrid CPU put it on an efficiency core, and one TCP stream over 100 GbE
reached 18.6-19.6 Gbit/s pinned against 20.2-20.6 unpinned.
New API, each needed across a package boundary:
- tio.Offload.SwapRxBuf hands the buffer of the last Read to the reader and
reads into the one given. It is a method on the concrete queue, found through
an unexported interface in nebula, so tio.Queue and its other
implementations are unchanged.
- batch.SendBatch.Reset drops queued packets without sending, for a sender
whose sockets are already closed; Flush now uses it.
Metrics, registered only for active pipelines: flow.tx.batches counts reads
handed to the sender, flow.stage.{tx_read,tx_encrypt,tx_send}.q<N>.busy_ns the
elapsed busy time of each stage, to find the limiting one.
Tests cover ordering and counters under backpressure with both ciphers (AES
skipped where nonce order is required), read errors with batches in flight,
shutdown, exhaustion, relays, fallback, firewall parity, and the actual reader
and send thread affinity for every placement, with pinning on and off: the
sender is pinned only to an explicit entry other than its reader's CPU.
447 lines
14 KiB
Go
447 lines
14 KiB
Go
package nebula
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/slackhq/nebula/config"
|
|
"github.com/slackhq/nebula/cpupick"
|
|
"github.com/slackhq/nebula/noiseutil"
|
|
"github.com/slackhq/nebula/overlay"
|
|
"github.com/slackhq/nebula/sshd"
|
|
"github.com/slackhq/nebula/udp"
|
|
"github.com/slackhq/nebula/util"
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
type m = map[string]any
|
|
|
|
// maxRoutines caps routines below the RejectHeadroom nonce gap so concurrent senders can't race the counter past wrap.
|
|
const maxRoutines = 1 << 16
|
|
|
|
// The reject headroom must exceed every sender that can be mid-reservation at once, about two per routine.
|
|
const _ = noiseutil.RejectHeadroom - 4*maxRoutines
|
|
|
|
func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, deviceFactory overlay.DeviceFactory) (retcon *Control, reterr error) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
// The goroutines started below stop only when this context does, and only a caller holding the
|
|
// Control can arrange that. Cancel whenever we are not handing one back, which covers an error
|
|
// and a config test alike: a config test used to leave the lighthouse query worker, and a
|
|
// hostname resolver per dns named static host, running for the life of the process.
|
|
defer func() {
|
|
if retcon == nil {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
if buildVersion == "" {
|
|
buildVersion = moduleVersion()
|
|
}
|
|
|
|
// Debug builds (-tags debug) serve pprof on :6060; a no-op otherwise.
|
|
startPprofServer(ctx, l)
|
|
|
|
// Print the config if in test, the exit comes later
|
|
if configTest {
|
|
b, err := yaml.Marshal(c.Settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Print the final config
|
|
l.Info(string(b))
|
|
}
|
|
|
|
pki, err := NewPKIFromConfig(l, c)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Failed to load PKI from config", err)
|
|
}
|
|
|
|
fw, err := NewFirewallFromConfig(l, pki.getCertState(), c)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Error while loading firewall rules", err)
|
|
}
|
|
l.Info("Firewall started", "firewallHashes", fw.GetRuleHashes())
|
|
|
|
ssh, err := sshd.NewSSHServer(ctx, l.With("subsystem", "sshd"))
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Error while creating SSH server", err)
|
|
}
|
|
wireSSHReload(l, ssh, c)
|
|
var sshStart func()
|
|
if c.GetBool("sshd.enabled", false) {
|
|
sshStart, err = configSSH(l, ssh, c, nil)
|
|
if err != nil {
|
|
l.Warn("Failed to configure sshd, ssh debugging will not be available", "error", err)
|
|
sshStart = nil
|
|
}
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// All non system modifying configuration consumption should live above this line
|
|
// tun config, listeners, anything modifying the computer should be below
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
var routines int
|
|
|
|
// If `routines` is set, use that and ignore the specific values
|
|
if routines = c.GetInt("routines", 0); routines != 0 {
|
|
if routines < 1 {
|
|
routines = 1
|
|
}
|
|
} else {
|
|
// deprecated and undocumented
|
|
tunQueues := c.GetInt("tun.routines", 1)
|
|
udpQueues := c.GetInt("listen.routines", 1)
|
|
routines = max(tunQueues, udpQueues)
|
|
if routines != 1 {
|
|
l.Warn("Setting tun.routines and listen.routines is deprecated. Use `routines` instead", "routines", routines)
|
|
}
|
|
}
|
|
if routines > maxRoutines {
|
|
l.Warn("Using multiple routines", "routines", maxRoutines, "clamped", true, "requestedRoutines", routines)
|
|
routines = maxRoutines
|
|
} else if routines > 1 {
|
|
l.Info("Using multiple routines", "routines", routines)
|
|
}
|
|
|
|
// EXPERIMENTAL
|
|
// Intentionally not documented yet while we do more testing and determine
|
|
// a good default value.
|
|
conntrackCacheTimeout := c.GetDuration("firewall.conntrack.routine_cache_timeout", 0)
|
|
if routines > 1 && !c.IsSet("firewall.conntrack.routine_cache_timeout") {
|
|
// Use a different default if we are running with multiple routines
|
|
conntrackCacheTimeout = 1 * time.Second
|
|
}
|
|
if conntrackCacheTimeout > 0 {
|
|
l.Info("Using routine-local conntrack cache", "duration", conntrackCacheTimeout)
|
|
}
|
|
|
|
var tun overlay.Device
|
|
if !configTest {
|
|
c.CatchHUP(ctx)
|
|
|
|
if deviceFactory == nil {
|
|
deviceFactory = overlay.NewDeviceFromConfig
|
|
}
|
|
|
|
tun, err = deviceFactory(c, l, pki.getCertState().myVpnNetworks, routines)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Failed to get a tun/tap device", err)
|
|
}
|
|
|
|
defer func() {
|
|
if reterr != nil {
|
|
tun.Close()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// set up our UDP listener
|
|
udpConns := make([]udp.Conn, routines)
|
|
port := c.GetInt("listen.port", 0)
|
|
|
|
// Callers get no handle to these until the Control is returned, release them on any error.
|
|
defer func() {
|
|
if reterr != nil {
|
|
for _, u := range udpConns {
|
|
if u != nil {
|
|
_ = u.Close()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
if !configTest {
|
|
rawListenHost := c.GetString("listen.host", "0.0.0.0")
|
|
var listenHost netip.Addr
|
|
if rawListenHost == "[::]" {
|
|
// Old guidance was to provide the literal `[::]` in `listen.host` but that won't resolve.
|
|
listenHost = netip.IPv6Unspecified()
|
|
|
|
} else {
|
|
ips, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", rawListenHost)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Failed to resolve listen.host", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, util.ContextualizeIfNeeded("Failed to resolve listen.host", err)
|
|
}
|
|
listenHost = ips[0].Unmap()
|
|
}
|
|
|
|
for i := 0; i < routines; i++ {
|
|
listen := netip.AddrPortFrom(listenHost, uint16(port))
|
|
l.Info("listening", "addr", listen)
|
|
batchSize := c.GetInt("listen.batch", 64)
|
|
if batchSize < 1 {
|
|
oldBatch := batchSize
|
|
batchSize = 1
|
|
l.Warn("listen.batch size is invalid", "provided", oldBatch, "overridden to", batchSize)
|
|
}
|
|
udpSettings := udp.Settings{
|
|
Listen: listen,
|
|
Multi: routines > 1,
|
|
Batch: batchSize,
|
|
Offloads: c.GetBool("listen.udp_offloads", false),
|
|
}
|
|
udpServer, err := udp.NewListener(l, udpSettings)
|
|
if err != nil {
|
|
return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
|
|
}
|
|
udpServer.ReloadConfig(c, nil)
|
|
udpConns[i] = udpServer
|
|
|
|
// If port is dynamic, discover it before the next pass through the for loop
|
|
// This way all routines will use the same port correctly
|
|
if port == 0 {
|
|
uPort, err := udpServer.LocalAddr()
|
|
if err != nil {
|
|
return nil, util.NewContextualError("Failed to get listening port", nil, err)
|
|
}
|
|
port = int(uPort.Port())
|
|
}
|
|
}
|
|
}
|
|
|
|
hostMap := NewHostMapFromConfig(l, c)
|
|
punchy := NewPunchyFromConfig(l, c, udpConns[0])
|
|
connManager := newConnectionManagerFromConfig(l, c, hostMap, punchy)
|
|
lightHouse, err := NewLightHouseFromConfig(ctx, l, c, pki.getCertState(), udpConns[0], punchy)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Failed to initialize lighthouse handler", err)
|
|
}
|
|
|
|
var messageMetrics *MessageMetrics
|
|
if c.GetBool("stats.message_metrics", false) {
|
|
messageMetrics = newMessageMetrics()
|
|
} else {
|
|
messageMetrics = newMessageMetricsOnlyRecvError()
|
|
}
|
|
|
|
handshakeConfig := HandshakeConfig{
|
|
tryInterval: c.GetDuration("handshakes.try_interval", DefaultHandshakeTryInterval),
|
|
retries: int64(c.GetInt("handshakes.retries", DefaultHandshakeRetries)),
|
|
triggerBuffer: c.GetInt("handshakes.trigger_buffer", DefaultHandshakeTriggerBuffer),
|
|
messageMetrics: messageMetrics,
|
|
}
|
|
|
|
handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
|
|
lightHouse.handshakeTrigger = handshakeManager.trigger
|
|
|
|
ds, err := newDnsServerFromConfig(ctx, l, pki, hostMap, c)
|
|
if err != nil {
|
|
l.Warn("Failed to start DNS responder", "error", err)
|
|
}
|
|
|
|
pinThreads := c.GetBool("tun.pin_threads", true)
|
|
cpuAffinity := parseCpuAffinity(c, l, routines)
|
|
var defaultCpuAffinity []int
|
|
staged := parseStagedConfig(c, l)
|
|
if pinThreads && routines > 1 && len(cpuAffinity) == 0 && !configTest {
|
|
// The operator didn't choose pin CPUs, so pick a default set that
|
|
// prefers performance cores and doesn't stack co-located instances
|
|
// onto allowed[0].
|
|
|
|
// key is used to seed the spreading of routines->cores.
|
|
// use PID if you want to ensure many different Nebulas in VMs or containers land on different cores
|
|
// use port if you want to always end up on the same cores, ideal for benchmarking.
|
|
key := uint64(os.Getpid()) //default to PID
|
|
pinKeyStr := strings.ToLower(c.GetString("tun.pin_threads_key", ""))
|
|
switch pinKeyStr {
|
|
case "":
|
|
l.Debug("tun.pin_threads_key is empty, using PID")
|
|
case "pid":
|
|
l.Debug("tun.pin_threads_key is PID")
|
|
case "port":
|
|
if ap, err := udpConns[0].LocalAddr(); err == nil && ap.Port() != 0 {
|
|
l.Info("tun.pin_threads_key is port number")
|
|
key = uint64(ap.Port())
|
|
} else {
|
|
l.Warn("Failed to get a port number for tun.pin_threads_key, falling back to PID", "err", err)
|
|
}
|
|
default:
|
|
l.Warn("tun.pin_threads_key is invalid, using PID")
|
|
}
|
|
|
|
defaultCpuAffinity = cpupick.Default(routines, key, l)
|
|
}
|
|
|
|
ifConfig := &InterfaceConfig{
|
|
HostMap: hostMap,
|
|
Inside: tun,
|
|
Outside: udpConns[0],
|
|
pki: pki,
|
|
Firewall: fw,
|
|
DnsServer: ds,
|
|
HandshakeManager: handshakeManager,
|
|
connectionManager: connManager,
|
|
lightHouse: lightHouse,
|
|
tryPromoteEvery: c.GetUint32("counters.try_promote", defaultPromoteEvery),
|
|
reQueryEvery: c.GetUint32("counters.requery_every_packets", defaultReQueryEvery),
|
|
reQueryWait: c.GetDuration("timers.requery_wait_duration", defaultReQueryWait),
|
|
DropLocalBroadcast: c.GetBool("tun.drop_local_broadcast", false),
|
|
DropMulticast: c.GetBool("tun.drop_multicast", false),
|
|
routines: routines,
|
|
MessageMetrics: messageMetrics,
|
|
version: buildVersion,
|
|
relayManager: NewRelayManager(ctx, l, hostMap, c),
|
|
punchy: punchy,
|
|
ConntrackCacheTimeout: conntrackCacheTimeout,
|
|
CpuAffinity: cpuAffinity,
|
|
defaultCpuAffinity: defaultCpuAffinity,
|
|
PinThreads: pinThreads,
|
|
staged: staged,
|
|
l: l,
|
|
}
|
|
|
|
var ifce *Interface
|
|
var emitStats func()
|
|
if !configTest {
|
|
ifce, err = NewInterface(ctx, ifConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize interface: %s", err)
|
|
}
|
|
|
|
ifce.writers = udpConns
|
|
lightHouse.ifce = ifce
|
|
|
|
ifce.RegisterConfigChangeCallbacks(c)
|
|
// These are only read at startup
|
|
c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
|
|
r.RestartRequiredIfChanged(
|
|
"routines", "tun.routines", "listen.routines", "firewall.conntrack.routine_cache_timeout",
|
|
"listen.port", "listen.host", "stats.message_metrics",
|
|
"handshakes.try_interval", "handshakes.retries", "handshakes.trigger_buffer",
|
|
"tun.drop_local_broadcast", "tun.drop_multicast",
|
|
)
|
|
r.RestartRequiredIfChanged(osStartupOnlyKeys...)
|
|
})
|
|
ifce.reloadDisconnectInvalid(c)
|
|
ifce.reloadSendRecvError(c)
|
|
ifce.reloadAcceptRecvError(c)
|
|
|
|
handshakeManager.f = ifce
|
|
go handshakeManager.Run(ctx)
|
|
|
|
punchy.Start(ctx, ifce, hostMap, lightHouse)
|
|
|
|
// On the stats server's loop, so the gauges follow the interval it accepted
|
|
emitStats = ifce.statsEmitter()
|
|
}
|
|
|
|
stats, err := newStatsServerFromConfig(ctx, l, c, buildVersion, configTest, emitStats)
|
|
if err != nil {
|
|
return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err)
|
|
}
|
|
|
|
if configTest {
|
|
return nil, nil
|
|
}
|
|
|
|
attachCommands(l, c, ssh, ifce)
|
|
|
|
networkChanges := udp.NewNetworkChangeMonitor(ctx, l, c)
|
|
|
|
return &Control{
|
|
state: StateReady,
|
|
f: ifce,
|
|
l: l,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
sshStart: sshStart,
|
|
statsStart: stats.Start,
|
|
dnsStart: ds.Start,
|
|
lighthouseStart: lightHouse.StartUpdateWorker,
|
|
networkChangeStart: networkChanges.Start,
|
|
connectionManagerStart: connManager.Start,
|
|
}, nil
|
|
}
|
|
|
|
// parseCpuAffinity reads `tun.cpu_affinity` from the config — a list of
|
|
// integer CPU IDs, one per TUN reader goroutine. Empty / unset returns nil
|
|
// (listenIn falls back to spreading queues across the allowed CPU set).
|
|
// Length mismatch with `routines` is a warning, not an error: shorter lists
|
|
// are modulo-cycled across queues, longer lists' tail is ignored. Invalid
|
|
// entries (non-integer, or a CPU ID we're not allowed to run on) are also a
|
|
// warning and disable the override entirely so we don't silently pin to the
|
|
// wrong CPU. Entries are validated against the process's current affinity
|
|
// mask (util.AllowedCPUs) rather than 0..NumCPU-1: under a cgroup cpuset or
|
|
// taskset the runnable IDs are frequently not that contiguous range, and
|
|
// pinning to an unrunnable ID always fails. If the allowed set can't be
|
|
// determined we fall back to a plain non-negative check.
|
|
func parseCpuAffinity(c *config.C, l *slog.Logger, routines int) []int {
|
|
raw := c.Get("tun.cpu_affinity")
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
rv, ok := raw.([]any)
|
|
if !ok {
|
|
l.Warn("tun.cpu_affinity must be a list of integers; ignoring", "value", raw)
|
|
return nil
|
|
}
|
|
// allowed is the set of CPU IDs we're actually permitted to run on. A nil
|
|
// slice (unsupported platform or lookup error) means "can't tell", so we
|
|
// only apply the weaker non-negative check in that case.
|
|
allowed, err := util.AllowedCPUs()
|
|
if err != nil {
|
|
l.Warn("could not determine allowed CPUs; validating tun.cpu_affinity against non-negative only", "error", err)
|
|
allowed = nil
|
|
}
|
|
cpus := make([]int, 0, len(rv))
|
|
for i, e := range rv {
|
|
var cpu int
|
|
switch v := e.(type) {
|
|
case int:
|
|
cpu = v
|
|
case int64:
|
|
cpu = int(v)
|
|
case float64:
|
|
cpu = int(v)
|
|
default:
|
|
l.Warn("tun.cpu_affinity entry not an integer; ignoring affinity",
|
|
"index", i, "value", e)
|
|
return nil
|
|
}
|
|
if cpu < 0 {
|
|
l.Warn("tun.cpu_affinity entry out of range; ignoring affinity",
|
|
"index", i, "cpu", cpu)
|
|
return nil
|
|
}
|
|
if len(allowed) > 0 && !slices.Contains(allowed, cpu) {
|
|
l.Warn("tun.cpu_affinity entry not in allowed CPU set; ignoring affinity",
|
|
"index", i, "cpu", cpu, "allowed", allowed)
|
|
return nil
|
|
}
|
|
cpus = append(cpus, cpu)
|
|
}
|
|
if len(cpus) != routines {
|
|
l.Warn("tun.cpu_affinity length doesn't match routines; queues will modulo-cycle through the list",
|
|
"affinity_len", len(cpus), "routines", routines)
|
|
}
|
|
return cpus
|
|
}
|
|
|
|
func moduleVersion() string {
|
|
info, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
for _, dep := range info.Deps {
|
|
if dep.Path == "github.com/slackhq/nebula" {
|
|
return strings.TrimPrefix(dep.Version, "v")
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|