Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/config/crd/projectcalico.org_felixconfigurations.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions api/pkg/apis/projectcalico/v3/felixconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,15 @@ type FelixConfigurationSpec struct {
// +optional
LocalSubnetL2Reachability *LocalSubnetL2ReachabilityMode `json:"localSubnetL2Reachability,omitempty" validate:"omitempty,oneof=Disabled PodsAndLoadBalancers"`

// LocalSubnetL2ReachabilityRefreshInterval controls how often Felix re-announces
// (gratuitous ARP / unsolicited NA) every IP it proxies ARP/NDP for when
// LocalSubnetL2Reachability is enabled, keeping neighbor caches and switch
// forwarding tables warm even when the set of proxied IPs is unchanged. Set to 0
// to disable periodic re-announcement, leaving only the one-shot announce when an

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nell Nell — nit: The schema type here is a duration string; would “Set to 0s” be clearer than “Set to 0”? We use “2m0s” elsewhere, so this keeps it consistent.

Suggested change
// to disable periodic re-announcement, leaving only the one-shot announce when an
// forwarding tables warm even when the set of proxied IPs is unchanged. Set to 0s

// IP is added. [Default: 120s]
// +optional
LocalSubnetL2ReachabilityRefreshInterval *metav1.Duration `json:"localSubnetL2ReachabilityRefreshInterval,omitempty" configv1timescale:"seconds"`
Comment on lines +1127 to +1134

// WindowsManageFirewallRules configures whether or not Felix will program Windows Firewall rules (to allow inbound access to its own metrics ports). [Default: Disabled]
// +optional
WindowsManageFirewallRules *WindowsManageFirewallRulesMode `json:"windowsManageFirewallRules,omitempty" validate:"omitempty,oneof=Enabled Disabled"`
Expand Down
5 changes: 5 additions & 0 deletions api/pkg/apis/projectcalico/v3/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions api/pkg/openapi/generated.openapi.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions felix/config/config_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,13 @@ type Config struct {
// selected LoadBalancer VIPs that overlap the host subnet. [Default: Disabled]
LocalSubnetL2Reachability string `config:"oneof(Disabled,PodsAndLoadBalancers);Disabled"`

// LocalSubnetL2ReachabilityRefreshInterval controls how often Felix re-announces
// (gratuitous ARP / unsolicited NA) every IP it proxies ARP/NDP for, keeping
// neighbor caches and switch forwarding tables warm even when the set of
// proxied IPs is unchanged. Set to 0 to disable periodic re-announcement,
// leaving only the one-shot announce when an IP is added. [Default: 120s]
LocalSubnetL2ReachabilityRefreshInterval time.Duration `config:"seconds;120"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Maintainability & Tests — New config param is added with default and docs, but there’s no unit test here asserting:

  • default value (120s),
  • parsing from YAML/env,
  • zero disables behavior at the config layer.

Suggestion:

  • If there’s an existing table-driven UT suite for config parsing/defaults, add this param to it. This catches regressions in the config surface independent of dataplane wiring.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Security — The LocalSubnetL2ReachabilityRefreshInterval accepts arbitrary durations with default 120s and no lower bound. A very small interval (e.g., milliseconds) could cause frequent re-announcements across many IPs, creating an L2 broadcast/multicast flood and availability impact. While the listener loop wakes at least each read timeout (1s), on busy loops it may fire more frequently; and even at 1 Hz, re-announcing thousands of IPs per second can stress the segment.
Mitigation:

  • Enforce a minimum interval (for example ≥1s or ≥5s) at parse/validation time; clamp values below the floor and log a warning.
  • Optionally enforce a maximum sensible interval to avoid pathological “almost never refresh” behavior if that matters operationally.


// WindowsManageFirewallRules configures whether or not Felix will program Windows Firewall rules. [Default: Disabled]
WindowsManageFirewallRules string `config:"oneof(Enabled,Disabled);Disabled"`

Expand Down
9 changes: 5 additions & 4 deletions felix/dataplane/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,11 @@ func StartDataplaneDriver(
}

dpConfig := intdataplane.Config{
Hostname: felixHostname,
NodeZone: felixNodeZone,
FloatingIPsEnabled: strings.EqualFold(configParams.FloatingIPs, string(apiv3.FloatingIPsEnabled)),
LocalSubnetL2Reachability: configParams.LocalSubnetL2Reachability,
Hostname: felixHostname,
NodeZone: felixNodeZone,
FloatingIPsEnabled: strings.EqualFold(configParams.FloatingIPs, string(apiv3.FloatingIPsEnabled)),
LocalSubnetL2Reachability: configParams.LocalSubnetL2Reachability,
LocalSubnetL2ReachabilityRefreshInterval: configParams.LocalSubnetL2ReachabilityRefreshInterval,
IfaceMonitorConfig: ifacemonitor.Config{
InterfaceExcludes: configParams.InterfaceExclude,
ResyncInterval: configParams.InterfaceRefreshInterval,
Expand Down
3 changes: 2 additions & 1 deletion felix/dataplane/linux/int_dataplane.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ type Config struct {

FloatingIPsEnabled bool

LocalSubnetL2Reachability string
LocalSubnetL2Reachability string
LocalSubnetL2ReachabilityRefreshInterval time.Duration

Wireguard wireguard.Config

Expand Down
100 changes: 78 additions & 22 deletions felix/dataplane/linux/proxy_neigh_mgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ import (
"github.com/projectcalico/calico/libcalico-go/lib/set"
)

// readDeadlineInterval bounds how long an ARP/NDP listener blocks in a
// single socket read before waking to re-check for context cancellation.
// A read that hits the deadline simply loops
const readDeadlineInterval = 1 * time.Second

// ipv6AllNodesMulticast is the IPv6 all-nodes link-local multicast address
const ipv6AllNodesMulticast = "ff02::1"
const (
// readDeadlineInterval bounds how long an ARP/NDP listener blocks in a
// single socket read before waking to re-check for context cancellation.
// A read that hits the deadline simply loops
readDeadlineInterval = 1 * time.Second

// ipv6AllNodesMulticast is the IPv6 all-nodes link-local multicast address
ipv6AllNodesMulticast = "ff02::1"
)

// serviceID identifies a Kubernetes Service by namespace and name. Used as a
// map key for tracking LoadBalancer service IPs.
Expand Down Expand Up @@ -94,6 +96,10 @@ type proxyNeighManager struct {
// before looping to re-check for cancellation.
readTimeout time.Duration

// announceInterval is how often each listener re-announces every IP it owns.
// Zero or negative disables periodic re-announcement.
announceInterval time.Duration

// hostIfaceToCIDRs maps host interface name to the parsed CIDRs on that interface.
hostIfaceToCIDRs map[string][]net.IPNet

Expand Down Expand Up @@ -178,7 +184,7 @@ func newProxyNeighManager(dpConfig Config, ipVersion uint8) *proxyNeighManager {
return conn, ifi.HardwareAddr, nil
}
}
return newProxyNeighManagerWithShims(dpConfig, ipVersion, nl, af, nf, readDeadlineInterval)
return newProxyNeighManagerWithShims(dpConfig, ipVersion, nl, af, nf, readDeadlineInterval, dpConfig.LocalSubnetL2ReachabilityRefreshInterval)
}

// setIgnoreOutgoing sets PACKET_IGNORE_OUTGOING on the raw socket so the kernel
Expand Down Expand Up @@ -207,6 +213,7 @@ func newProxyNeighManagerWithShims(
af arpClientFactory,
nf ndpConnFactory,
readTimeout time.Duration,
announceInterval time.Duration,
) *proxyNeighManager {
wlIfacesPattern := "^(" + strings.Join(dpConfig.RulesConfig.WorkloadIfacePrefixes, "|") + ").*"
wlIfacesRegexp := regexp.MustCompile(wlIfacesPattern)
Expand All @@ -217,6 +224,7 @@ func newProxyNeighManagerWithShims(
hostname: dpConfig.Hostname,
wlIfacesRegexp: wlIfacesRegexp,
readTimeout: readTimeout,
announceInterval: announceInterval,
hostIfaceToCIDRs: make(map[string][]net.IPNet),
localWorkloadIPs: make(map[types.WorkloadEndpointID][]string),
lbServiceIPs: make(map[serviceID][]string),
Expand Down Expand Up @@ -522,11 +530,12 @@ func (m *proxyNeighManager) publishDesiredIPs(desiredByIface map[string]set.Set[
// listener goroutine.
func (m *proxyNeighManager) startListener(ifaceName string) error {
l := &ifaceListener{
ifaceName: ifaceName,
readTimeout: m.readTimeout,
reconcile: make(chan struct{}, 1),
announced: set.New[string](),
done: make(chan struct{}),
ifaceName: ifaceName,
readTimeout: m.readTimeout,
announceInterval: m.announceInterval,
reconcile: make(chan struct{}, 1),
announced: set.New[string](),
done: make(chan struct{}),
}

l.ctx, l.cancel = context.WithCancel(m.ctx)
Expand Down Expand Up @@ -578,18 +587,27 @@ type ifaceListener struct {

// announced tracks the IPs the goroutine has already announced/joined for,
// so it can compute the delta against a new desired set. Goroutine-local:
// only ever touched by the listener goroutine, so it needs no
// synchronization.
// only ever touched by the listener goroutine, so it needs no synchronization.
announced set.Set[string]

arpCli arpClient // IPv4 only
ndpCli ndpConn // IPv6 only
hwAddr net.HardwareAddr
readTimeout time.Duration
ctx context.Context
cancel context.CancelFunc
done chan struct{}
failed atomic.Bool

// announceInterval is how often the goroutine re-announces every owned IP.
// Zero or negative disables periodic re-announcement (only the initial
// announce on add fires).
announceInterval time.Duration

// lastAnnounce is when the goroutine last re-announced its full owned set.
// Goroutine-local: compared against announceInterval each loop iteration.
lastAnnounce time.Time

ctx context.Context
cancel context.CancelFunc
done chan struct{}
failed atomic.Bool
}

// stop cancels the listener goroutine and waits for it to exit. The goroutine
Expand Down Expand Up @@ -663,10 +681,8 @@ func (l *ifaceListener) applyDesiredState() {
}
if l.ndpCli != nil {
l.joinNDPGroup(ip)
l.sendUNA(addr)
} else {
l.sendGARP(addr)
}
l.announce(addr)
}

// Release the multicast subscription for IPs that dropped out of the desired set.
Expand All @@ -682,6 +698,32 @@ func (l *ifaceListener) applyDesiredState() {
l.announced = desired
}

// announce sends a single gratuitous ARP (IPv4) or unsolicited NA (IPv6) for
// addr. It does not touch multicast group membership, so it is safe to call
// both for a newly desired IP (after joinNDPGroup) and for periodic refresh of
// an already-joined IP.
func (l *ifaceListener) announce(addr netip.Addr) {
if l.ndpCli != nil {
l.sendUNA(addr)
} else {
l.sendGARP(addr)
}
}

// reannounceAll re-sends a gratuitous ARP / unsolicited NA for every IP this
// listener currently owns, refreshing neighbor caches and switch tables without
// changing group membership. Driven by announceInterval from the listener loop.
func (l *ifaceListener) reannounceAll() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Security — reannounceAll() iterates over every owned IP and sends an ARP/NA for each immediately. On hosts proxying large IP sets, this concentrates a large number of broadcasts/multicasts into a single loop iteration, potentially causing microbursts that impact switch/neighbor tables and link capacity.
Mitigation:

  • Batch or throttle re-announcements (e.g., cap number per iteration, schedule across multiple iterations, or insert short delays) to smooth traffic.
  • Add metrics/logging on number of re-announced IPs and announce duration to detect/configure safely.

for ip := range l.announced.All() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Maintainability & Tests — reannounceAll parses string IPs back to netip.Addr each cycle. This repeats parsing work and introduces potential parse failures.

Suggestion:

  • If feasible, store announced as set.Set[netip.Addr] (or maintain both string and parsed forms) to avoid parse-on-each-iteration and reduce error handling here. This also simplifies call sites using netip.Addr.

addr, err := netip.ParseAddr(ip)
if err != nil {
logrus.WithError(err).WithField("ip", ip).Debug("Failed to parse IP for re-announce")
continue
}
l.announce(addr)
}
}

// closeSocket leaves any joined NDP multicast groups and closes the raw socket.
// It runs on the listener goroutine (deferred from runListener) so the socket
// stays single-owner; stop() only cancels the context and waits.
Expand Down Expand Up @@ -795,6 +837,11 @@ func (l *ifaceListener) runListener(proto string, setDeadline func(time.Time) er
defer close(l.done)
defer l.closeSocket()

// Anchor the periodic re-announce clock to listener start so the first
// refresh fires announceInterval from now, not immediately after the
// initial announce that applyDesiredState performs below.
l.lastAnnounce = time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casey Casey — Question: anchoring the first periodic tick at listener start means we won’t refresh until one full interval after the initial add-time announce. Was that intentional? If we wanted a “top-up” shortly after add (some switches/hosts age quickly), we could consider starting the clock at the time of the initial announce instead (or setting lastAnnounce to time.Now().Add(-announceInterval/2)). I’m not necessarily against the current choice — just checking on the rationale.


logCtx := logrus.WithFields(logrus.Fields{"iface": l.ifaceName, "proto": proto})
for {
if l.ctx.Err() != nil {
Expand All @@ -811,6 +858,15 @@ func (l *ifaceListener) runListener(proto string, setDeadline func(time.Time) er
default:
}

// Periodically re-announce every owned IP so neighbor caches and switch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Maintainability & Tests — Manual interval bookkeeping (time.Since(lastAnnounce) >= announceInterval) inside the listener loop couples accuracy to readTimeout and requires bespoke timing logic.

Suggestion:

  • Consider a time.Ticker (or injected clock/ticker) and add a select case on ticker.C. This:
    • Simplifies the loop,
    • Decouples periodicity from readTimeout,
    • Makes tests easier/less flaky by allowing a stubbed time source.
  • If you keep the current approach, consider a brief comment warning that announce cadence is “interval ± readTimeout” so future maintainers don’t tighten assertions unexpectedly.

// forwarding tables stay warm even when the desired set is unchanged.
// The loop wakes at least every readTimeout, so this fires within one
// readTimeout of the interval elapsing.
if l.announceInterval > 0 && time.Since(l.lastAnnounce) >= l.announceInterval {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nell Nell — Anchor the clock at start is good to avoid an immediate re-announce, but do we also want to reset lastAnnounce after applyDesiredState? Otherwise, a reconcile that triggers the initial announce could be followed very soon by the periodic re-announce if the interval is nearly up. Maybe that’s fine (gratuitous ARP/NA are cheap), but WDYT?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casey Casey — Docs say “Set to 0 to disable,” but the code path here disables for any interval <= 0 (since it only fires when announceInterval > 0). I think we should either (a) clamp negatives to zero where we parse the config, or (b) explicitly document “0 or less disables” for consistency. Personally I’d lean to clamping at parse-time so the API validation/docs can stay “non-negative”.

A couple of places we could address this:

  • Add a min=0 validation on the API field if we want to prevent negatives there.
  • Or, in felix config load, normalize negative durations to zero before plumbing into the dataplane.

Fine to leave the runtime guard as-is either way; it’s just the API/UX bit I’m flagging.

l.reannounceAll()
l.lastAnnounce = time.Now()
}

if err := setDeadline(time.Now().Add(l.readTimeout)); err != nil {
logCtx.WithError(err).Warn("Failed to set read deadline")
}
Expand Down
Loading
Loading