Files
nebula/firewall_events_test.go
2026-07-07 14:30:55 -05:00

732 lines
24 KiB
Go

package nebula
import (
"net"
"net/netip"
"sync"
"testing"
"time"
"github.com/gaissmai/bart"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/firewall"
"github.com/slackhq/nebula/firewall/events"
"github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingReporter captures every event fired against it. Its methods take
// the conntrack lock implicitly (via the firewall code path that invokes
// them), so we synchronize accumulator mutations with a small mutex to keep
// the race detector happy across goroutines in case a test introduces any.
type recordingReporter struct {
mu sync.Mutex
drops []recordedDrop
creates []recordedCreate
evicts []recordedEvict
reloads []recordedReload
}
type recordedDrop struct {
incoming bool
reason events.DropReason
remote netip.Addr
local netip.Addr
peerName string
rulesVersion uint16
ctx firewall.PacketContext
}
type recordedCreate struct {
incoming bool
remote netip.Addr
local netip.Addr
peerName string
rulesVersion uint16
ctx firewall.PacketContext
}
type recordedEvict struct {
incoming bool
remote netip.Addr
local netip.Addr
rulesVersion uint16
expired bool
}
type recordedReload struct {
oldVersion uint16
newVersion uint16
}
func (r *recordingReporter) ReportDrop(e events.DropEvent) {
r.mu.Lock()
defer r.mu.Unlock()
name := ""
if e.PeerCert != nil && e.PeerCert.Certificate != nil {
name = e.PeerCert.Certificate.Name()
}
r.drops = append(r.drops, recordedDrop{
incoming: e.Incoming,
reason: e.Reason,
remote: e.Packet.RemoteAddr,
local: e.Packet.LocalAddr,
peerName: name,
rulesVersion: e.RulesVersion,
ctx: e.Context,
})
}
func (r *recordingReporter) ReportFlowCreate(e events.FlowCreateEvent) {
r.mu.Lock()
defer r.mu.Unlock()
name := ""
if e.PeerCert != nil && e.PeerCert.Certificate != nil {
name = e.PeerCert.Certificate.Name()
}
r.creates = append(r.creates, recordedCreate{
incoming: e.Incoming,
remote: e.Packet.RemoteAddr,
local: e.Packet.LocalAddr,
peerName: name,
rulesVersion: e.RulesVersion,
ctx: e.Context,
})
}
func (r *recordingReporter) ReportFlowEvict(e events.FlowEvictEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.evicts = append(r.evicts, recordedEvict{
incoming: e.Incoming,
remote: e.Packet.RemoteAddr,
local: e.Packet.LocalAddr,
rulesVersion: e.RulesVersion,
expired: e.Expired,
})
}
func (r *recordingReporter) ReportRulesReload(e events.RulesReloadEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.reloads = append(r.reloads, recordedReload{
oldVersion: e.OldVersion,
newVersion: e.NewVersion,
})
}
// eventFixture builds a Firewall wired to a Control plus a packet/hostinfo
// pair that a test can reuse. By default the ruleset allows the packet;
// callers mutate fw / p / h as needed before invoking Drop.
type eventFixture struct {
ctl *Control
fw *Firewall
p firewall.Packet
h *HostInfo
cp *cert.CAPool
}
func newEventFixture(t *testing.T) *eventFixture {
t.Helper()
l := test.NewLogger()
// myVpnNetworksTable covers our single peer address so buildNetworks takes
// the "simple case" path (h.networks stays nil); tests that want a populated
// BART table overwrite h.networks directly.
vpnNetworks := new(bart.Lite)
vpnNetworks.Insert(netip.MustParsePrefix("1.2.3.0/24"))
// Use the same cert for "peer" and "local" endpoints, matching the
// TestFirewall_Drop fixture style: LocalAddr == RemoteAddr == peer vpn addr.
c := &dummyCert{
name: "host1",
networks: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/24")},
groups: []string{"default-group"},
issuer: "signer-shasum",
}
h := &HostInfo{
ConnectionState: &ConnectionState{
peerCert: &cert.CachedCertificate{
Certificate: c,
InvertedGroups: map[string]struct{}{"default-group": {}},
},
},
vpnAddrs: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
}
h.buildNetworks(vpnNetworks, c)
fw := NewFirewall(l, time.Minute, time.Minute, time.Minute, c)
require.NoError(t, fw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
require.NoError(t, fw.AddRule(false, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
ctl := &Control{
f: &Interface{firewall: fw},
l: l,
}
return &eventFixture{
ctl: ctl,
fw: fw,
p: firewall.Packet{
LocalAddr: netip.MustParseAddr("1.2.3.4"),
RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 10,
RemotePort: 90,
Protocol: firewall.ProtoUDP,
},
h: h,
cp: cert.NewCAPool(),
}
}
// firewall() returns the currently-installed firewall. Needed because
// SetFirewallEventReporter replaces it via shallow-copy swap.
func (f *eventFixture) firewall() *Firewall {
return f.ctl.f.firewall
}
func TestEvents_ReportDrop_InvalidRemoteIP(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// Packet to an address not in the cert's networks.
f.p.RemoteAddr = netip.MustParseAddr("9.9.9.9")
assert.Equal(t, ErrInvalidRemoteIP, f.firewall().Drop(f.p, firewall.PacketContext{}, false, f.h, f.cp, nil))
require.Len(t, r.drops, 1)
assert.Equal(t, events.DropInvalidRemoteIP, r.drops[0].reason)
assert.False(t, r.drops[0].incoming)
assert.Equal(t, "host1", r.drops[0].peerName)
assert.Empty(t, r.creates)
assert.Empty(t, r.evicts)
}
func TestEvents_ReportDrop_InvalidLocalIP(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// LocalAddr outside our routable networks.
f.p.LocalAddr = netip.MustParseAddr("9.9.9.9")
assert.Equal(t, ErrInvalidLocalIP, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.drops, 1)
assert.Equal(t, events.DropInvalidLocalIP, r.drops[0].reason)
assert.True(t, r.drops[0].incoming)
}
func TestEvents_ReportDrop_NoMatchingRule(t *testing.T) {
f := newEventFixture(t)
// Reset to a firewall with no matching rule.
l := test.NewLogger()
fw := NewFirewall(l, time.Minute, time.Minute, time.Minute, f.h.ConnectionState.peerCert.Certificate)
// Rule that won't match (group not in peer's groups).
require.NoError(t, fw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"nope"}, "", "", "", "", ""))
require.NoError(t, fw.AddRule(false, firewall.ProtoAny, 0, 0, []string{"nope"}, "", "", "", "", ""))
f.ctl.f.firewall = fw
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
assert.Equal(t, ErrNoMatchingRule, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.drops, 1)
assert.Equal(t, events.DropNoMatchingRule, r.drops[0].reason)
}
func TestEvents_ReportDrop_PeerRejected(t *testing.T) {
f := newEventFixture(t)
// Re-classify the remote as VPNPeer so it triggers DropPeerRejected.
f.h.networks = new(bart.Table[NetworkType])
f.h.networks.Insert(netip.MustParsePrefix("1.2.3.0/24"), NetworkTypeVPNPeer)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
assert.Equal(t, ErrPeerRejected, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.drops, 1)
assert.Equal(t, events.DropPeerRejected, r.drops[0].reason)
}
func TestEvents_ReportDrop_UnknownNetwork(t *testing.T) {
f := newEventFixture(t)
// Insert an unrecognized NetworkType value to hit the default branch.
f.h.networks = new(bart.Table[NetworkType])
f.h.networks.Insert(netip.MustParsePrefix("1.2.3.0/24"), NetworkTypeUnknown)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
assert.Equal(t, ErrUnknownNetworkType, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.drops, 1)
assert.Equal(t, events.DropUnknownNetwork, r.drops[0].reason)
}
func TestEvents_ReportFlowCreate_OnceOnly(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// First allowed packet creates the conntrack entry.
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
// Second matching packet on the same tuple is short-circuited by conntrack
// and must not fire another FlowCreate.
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
assert.True(t, r.creates[0].incoming)
assert.Equal(t, f.p.RemoteAddr, r.creates[0].remote)
assert.Empty(t, r.drops)
}
func TestEvents_ReportFlowEvict_OnReloadPurge(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// Create a flow under the current rules.
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
// Simulate a reload that produces rules the existing flow no longer
// matches. Bump rulesVersion and replace InRules with an empty table so
// revalidation fails.
fw := f.firewall()
fw.Conntrack.Lock()
fw.rulesVersion++
fw.InRules = newFirewallTable()
fw.Conntrack.Unlock()
// Next packet triggers re-validation, which fails and evicts the entry.
err := fw.Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil)
assert.Equal(t, ErrNoMatchingRule, err)
require.Len(t, r.evicts, 1)
assert.False(t, r.evicts[0].expired, "evict from reload purge is not expiration")
assert.True(t, r.evicts[0].incoming)
}
func TestEvents_ReportFlowEvict_OnTimeout(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
// Force expiration by rewinding the entry's deadline.
fw := f.firewall()
fw.Conntrack.Lock()
c := fw.Conntrack.Conns[f.p]
require.NotNil(t, c)
c.Expires = time.Now().Add(-time.Hour)
fw.evict(f.p)
fw.Conntrack.Unlock()
require.Len(t, r.evicts, 1)
assert.True(t, r.evicts[0].expired)
}
func TestEvents_SetNil_Clears(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
f.ctl.SetFirewallEventReporter(nil)
resetConntrack(f.firewall())
require.NoError(t, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
// No second create should be recorded.
assert.Len(t, r.creates, 1)
}
func TestEvents_ReporterSurvivesSwap(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// Simulate a reload by swapping in a fresh Firewall that carries the
// reporter forward. Mirrors what reloadFirewall does with the shared
// conntrack pointer.
l := test.NewLogger()
oldFw := f.firewall()
newFw := NewFirewall(l, time.Minute, time.Minute, time.Minute, f.h.ConnectionState.peerCert.Certificate)
require.NoError(t, newFw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
require.NoError(t, newFw.AddRule(false, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
newFw.Conntrack = oldFw.Conntrack
newFw.rulesVersion = oldFw.rulesVersion + 1
newFw.reporter = oldFw.reporter
f.ctl.f.firewall = newFw
newFw.reportRulesReload(oldFw.rulesVersion, newFw.rulesVersion)
require.Len(t, r.reloads, 1)
assert.Equal(t, oldFw.rulesVersion, r.reloads[0].oldVersion)
assert.Equal(t, newFw.rulesVersion, r.reloads[0].newVersion)
// Events on the new firewall should still reach the same reporter.
require.NoError(t, newFw.Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
assert.Equal(t, newFw.rulesVersion, r.creates[0].rulesVersion)
}
func TestEvents_InstallDoesNotMutateOldFirewall(t *testing.T) {
f := newEventFixture(t)
before := f.firewall()
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
after := f.firewall()
assert.NotSame(t, before, after, "SetFirewallEventReporter must replace the Firewall pointer")
assert.Nil(t, before.reporter, "the pre-install Firewall must remain untouched")
assert.NotNil(t, after.reporter)
}
// --- PacketContext parse tests --------------------------------------------
func mustSerialize(t *testing.T, lrs ...gopacket.SerializableLayer) []byte {
t.Helper()
buf := gopacket.NewSerializeBuffer()
opt := gopacket.SerializeOptions{ComputeChecksums: false, FixLengths: true}
require.NoError(t, gopacket.SerializeLayers(buf, opt, lrs...))
return buf.Bytes()
}
func TestPacketContext_IPv4_TCPFlags(t *testing.T) {
ip := &layers.IPv4{
Version: 4, TTL: 64, Protocol: layers.IPProtocolTCP,
SrcIP: net.IPv4(10, 0, 0, 1), DstIP: net.IPv4(10, 0, 0, 2),
}
tcp := &layers.TCP{SrcPort: 1234, DstPort: 80, SYN: true, ACK: true}
require.NoError(t, tcp.SetNetworkLayerForChecksum(ip))
data := mustSerialize(t, ip, tcp, gopacket.Payload([]byte("hello")))
var fp firewall.Packet
var ctx firewall.PacketContext
require.NoError(t, newPacket(data, true, &fp, &ctx))
assert.Equal(t, uint8(firewall.ProtoTCP), fp.Protocol)
// SYN (0x02) + ACK (0x10) = 0x12
assert.Equal(t, uint8(0x12), ctx.TCPFlags)
assert.Equal(t, uint16(len(data)), ctx.Length)
assert.Equal(t, uint8(0), ctx.ICMPType)
assert.Equal(t, uint8(0), ctx.ICMPCode)
}
func TestPacketContext_IPv4_ICMPTypeCode(t *testing.T) {
ip := &layers.IPv4{
Version: 4, TTL: 64, Protocol: layers.IPProtocolICMPv4,
SrcIP: net.IPv4(10, 0, 0, 1), DstIP: net.IPv4(10, 0, 0, 2),
}
// Destination Unreachable, code 3 (port unreachable)
icmp := &layers.ICMPv4{
TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeDestinationUnreachable, layers.ICMPv4CodePort),
}
data := mustSerialize(t, ip, icmp, gopacket.Payload([]byte{0, 0, 0, 0}))
var fp firewall.Packet
var ctx firewall.PacketContext
require.NoError(t, newPacket(data, true, &fp, &ctx))
assert.Equal(t, uint8(firewall.ProtoICMP), fp.Protocol)
assert.Equal(t, uint8(layers.ICMPv4TypeDestinationUnreachable), ctx.ICMPType)
assert.Equal(t, uint8(layers.ICMPv4CodePort), ctx.ICMPCode)
assert.Equal(t, uint16(len(data)), ctx.Length)
assert.Equal(t, uint8(0), ctx.TCPFlags)
}
func TestPacketContext_IPv4_UDPLengthOnly(t *testing.T) {
ip := &layers.IPv4{
Version: 4, TTL: 64, Protocol: layers.IPProtocolUDP,
SrcIP: net.IPv4(10, 0, 0, 1), DstIP: net.IPv4(10, 0, 0, 2),
}
udp := &layers.UDP{SrcPort: 1234, DstPort: 53}
require.NoError(t, udp.SetNetworkLayerForChecksum(ip))
data := mustSerialize(t, ip, udp, gopacket.Payload([]byte("query")))
var fp firewall.Packet
var ctx firewall.PacketContext
require.NoError(t, newPacket(data, true, &fp, &ctx))
assert.Equal(t, uint8(firewall.ProtoUDP), fp.Protocol)
assert.Equal(t, uint16(len(data)), ctx.Length)
assert.Zero(t, ctx.TCPFlags)
assert.Zero(t, ctx.ICMPType)
assert.Zero(t, ctx.ICMPCode)
}
func TestPacketContext_IPv6_TCPFlags(t *testing.T) {
ip := &layers.IPv6{
Version: 6, HopLimit: 64, NextHeader: layers.IPProtocolTCP,
SrcIP: net.ParseIP("fd00::1"), DstIP: net.ParseIP("fd00::2"),
}
tcp := &layers.TCP{SrcPort: 1234, DstPort: 443, FIN: true, ACK: true}
require.NoError(t, tcp.SetNetworkLayerForChecksum(ip))
data := mustSerialize(t, ip, tcp, gopacket.Payload([]byte("bye")))
var fp firewall.Packet
var ctx firewall.PacketContext
require.NoError(t, newPacket(data, true, &fp, &ctx))
assert.Equal(t, uint8(firewall.ProtoTCP), fp.Protocol)
// FIN (0x01) + ACK (0x10) = 0x11
assert.Equal(t, uint8(0x11), ctx.TCPFlags)
assert.Equal(t, uint16(len(data)), ctx.Length)
}
func TestPacketContext_IPv6_ICMPv6TypeCode(t *testing.T) {
ip := &layers.IPv6{
Version: 6, HopLimit: 64, NextHeader: layers.IPProtocolICMPv6,
SrcIP: net.ParseIP("fd00::1"), DstIP: net.ParseIP("fd00::2"),
}
icmp := &layers.ICMPv6{
TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeDestinationUnreachable, layers.ICMPv6CodePortUnreachable),
}
require.NoError(t, icmp.SetNetworkLayerForChecksum(ip))
data := mustSerialize(t, ip, icmp, gopacket.Payload([]byte{0, 0, 0, 0, 0, 0, 0, 0}))
var fp firewall.Packet
var ctx firewall.PacketContext
require.NoError(t, newPacket(data, true, &fp, &ctx))
assert.Equal(t, uint8(firewall.ProtoICMPv6), fp.Protocol)
assert.Equal(t, uint8(layers.ICMPv6TypeDestinationUnreachable), ctx.ICMPType)
assert.Equal(t, uint8(layers.ICMPv6CodePortUnreachable), ctx.ICMPCode)
assert.Equal(t, uint16(len(data)), ctx.Length)
}
// TestPacketContext_NilOK confirms a nil context pointer is accepted by
// newPacket (the hot path may elect not to pass one).
func TestPacketContext_NilOK(t *testing.T) {
ip := &layers.IPv4{
Version: 4, TTL: 64, Protocol: layers.IPProtocolUDP,
SrcIP: net.IPv4(10, 0, 0, 1), DstIP: net.IPv4(10, 0, 0, 2),
}
udp := &layers.UDP{SrcPort: 1, DstPort: 2}
require.NoError(t, udp.SetNetworkLayerForChecksum(ip))
data := mustSerialize(t, ip, udp)
var fp firewall.Packet
require.NoError(t, newPacket(data, true, &fp, nil))
}
// TestPacketContext_FlowCreateCarriesContext exercises the full Drop -> addConn
// -> ReportFlowCreate path with a realistic TCP packet and confirms the
// context makes it into the reporter.
func TestPacketContext_FlowCreateCarriesContext(t *testing.T) {
f := newEventFixture(t)
r := &recordingReporter{}
f.ctl.SetFirewallEventReporter(r)
// Hand-construct a matching TCP packet.
ctx := firewall.PacketContext{Length: 1500, TCPFlags: 0x12}
p := f.p
p.Protocol = firewall.ProtoTCP
require.NoError(t, f.firewall().Drop(p, ctx, true, f.h, f.cp, nil))
require.Len(t, r.creates, 1)
assert.Equal(t, uint16(1500), r.creates[0].ctx.Length)
assert.Equal(t, uint8(0x12), r.creates[0].ctx.TCPFlags)
}
// --- benchmarks ------------------------------------------------------------
// noopReporter is the cheapest possible reporter. Methods discard the event.
type noopReporter struct{}
func (noopReporter) ReportDrop(events.DropEvent) {}
func (noopReporter) ReportFlowCreate(events.FlowCreateEvent) {}
func (noopReporter) ReportFlowEvict(events.FlowEvictEvent) {}
func (noopReporter) ReportRulesReload(events.RulesReloadEvent) {
}
// bufferedReporter demonstrates a realistic zero-alloc reporter: each event
// is forwarded to a value-typed channel. The channel send is a memcpy into
// the channel's pre-allocated ring buffer -- no heap traffic. A background
// goroutine would drain these; the bench skips draining to keep the report
// path pure.
type bufferedReporter struct {
drops chan events.DropEvent
flows chan events.FlowCreateEvent
evicts chan events.FlowEvictEvent
}
func newBufferedReporter(cap int) *bufferedReporter {
return &bufferedReporter{
drops: make(chan events.DropEvent, cap),
flows: make(chan events.FlowCreateEvent, cap),
evicts: make(chan events.FlowEvictEvent, cap),
}
}
func (r *bufferedReporter) ReportDrop(e events.DropEvent) {
select {
case r.drops <- e:
default:
}
}
func (r *bufferedReporter) ReportFlowCreate(e events.FlowCreateEvent) {
select {
case r.flows <- e:
default:
}
}
func (r *bufferedReporter) ReportFlowEvict(e events.FlowEvictEvent) {
select {
case r.evicts <- e:
default:
}
}
func (r *bufferedReporter) ReportRulesReload(events.RulesReloadEvent) {}
// pointerReporter is the anti-pattern: it takes the address of the incoming
// event struct, which forces the callee-side copy onto the heap. Kept for
// comparison so we can see the alloc cost an unwary reporter would incur.
type pointerReporter struct {
last *events.DropEvent
}
func (r *pointerReporter) ReportDrop(e events.DropEvent) {
r.last = &e
}
func (r *pointerReporter) ReportFlowCreate(events.FlowCreateEvent) {}
func (r *pointerReporter) ReportFlowEvict(events.FlowEvictEvent) {}
func (r *pointerReporter) ReportRulesReload(events.RulesReloadEvent) {
}
func newBenchFixture(b *testing.B) *eventFixture {
b.Helper()
l := test.NewLogger()
vpnNetworks := new(bart.Lite)
vpnNetworks.Insert(netip.MustParsePrefix("1.2.3.0/24"))
c := &dummyCert{
name: "host1",
networks: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/24")},
groups: []string{"default-group"},
issuer: "signer-shasum",
}
h := &HostInfo{
ConnectionState: &ConnectionState{
peerCert: &cert.CachedCertificate{
Certificate: c,
InvertedGroups: map[string]struct{}{"default-group": {}},
},
},
vpnAddrs: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
}
h.buildNetworks(vpnNetworks, c)
fw := NewFirewall(l, time.Minute, time.Minute, time.Minute, c)
// Inbound rule that matches our packet; outbound has no match so we can
// also benchmark the no-rule drop path.
if err := fw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""); err != nil {
b.Fatal(err)
}
ctl := &Control{f: &Interface{firewall: fw}, l: l}
return &eventFixture{
ctl: ctl,
fw: fw,
p: firewall.Packet{
LocalAddr: netip.MustParseAddr("1.2.3.4"),
RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 10,
RemotePort: 90,
Protocol: firewall.ProtoUDP,
},
h: h,
cp: cert.NewCAPool(),
}
}
// BenchmarkFirewallDropPath measures the cost of Firewall.Drop on a packet
// that reaches the no-matching-rule branch (the longest drop path). Compare
// reporter shapes:
//
// nilReporter -- no reporter installed (feature cost when off)
// noopReporter -- reporter installed, methods discard args (minimum on-cost)
// bufferedReporter -- realistic zero-alloc reporter: value-typed channels
// pointerReporter -- anti-pattern that takes &composite-literal (allocates)
func BenchmarkFirewallDropPath(b *testing.B) {
run := func(b *testing.B, install func(*Control)) {
f := newBenchFixture(b)
install(f.ctl)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = f.firewall().Drop(f.p, firewall.PacketContext{}, false, f.h, f.cp, nil)
}
}
b.Run("nilReporter", func(b *testing.B) { run(b, func(*Control) {}) })
b.Run("noopReporter", func(b *testing.B) {
run(b, func(c *Control) { c.SetFirewallEventReporter(noopReporter{}) })
})
b.Run("bufferedReporter", func(b *testing.B) {
run(b, func(c *Control) { c.SetFirewallEventReporter(newBufferedReporter(1024)) })
})
b.Run("pointerReporter", func(b *testing.B) {
run(b, func(c *Control) { c.SetFirewallEventReporter(&pointerReporter{}) })
})
}
// BenchmarkConntrackCreate measures Firewall.Drop for an allowed inbound
// packet on a fresh conntrack (so addConn fires each iteration).
func BenchmarkConntrackCreate(b *testing.B) {
run := func(b *testing.B, install func(*Control)) {
f := newBenchFixture(b)
install(f.ctl)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
resetConntrack(f.firewall())
_ = f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil)
}
}
b.Run("nilReporter", func(b *testing.B) { run(b, func(*Control) {}) })
b.Run("noopReporter", func(b *testing.B) {
run(b, func(c *Control) { c.SetFirewallEventReporter(noopReporter{}) })
})
b.Run("bufferedReporter", func(b *testing.B) {
run(b, func(c *Control) { c.SetFirewallEventReporter(newBufferedReporter(1024)) })
})
}
// BenchmarkConntrackHit measures the hot path where a flow is already in
// conntrack and short-circuits rule evaluation. The reporter slot is checked
// only on create/evict, so this bench should show the reporter having zero
// impact regardless of install state.
func BenchmarkConntrackHit(b *testing.B) {
b.Run("nilReporter", func(b *testing.B) {
f := newBenchFixture(b)
// Prime conntrack.
require.NoError(b, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil)
}
})
b.Run("noopReporter", func(b *testing.B) {
f := newBenchFixture(b)
f.ctl.SetFirewallEventReporter(noopReporter{})
require.NoError(b, f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = f.firewall().Drop(f.p, firewall.PacketContext{}, true, f.h, f.cp, nil)
}
})
}