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
// 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.

🛡️ Security — The new LocalSubnetL2ReachabilityRefreshInterval lacks bounds/validation, allowing extremely small intervals that could cause sustained L2 broadcast/multicast floods (gratuitous ARPs to broadcast and IPv6 NAs to ff02::1) proportional to the number of proxied IPs. This presents a potential DoS/availability risk on the local subnet if misconfigured. Although the listener loop only checks the interval once per readTimeout (1s in production), a node with many proxied IPs would still transmit a burst of frames for every owned IP each second if the interval is set below that boundary.

Risk:

  • Excessive L2 traffic can degrade network performance, trigger switch/IDS protections, and broaden the blast radius of misconfiguration. This is especially acute on noisy subnets or nodes proxying large IP sets.

Suggested mitigations:

  • Enforce a sane minimum and maximum in config parsing or at manager construction, e.g. clamp to [5s, 1h] and log a warning if clamped. For example:
    • In config parse/validation, reject or clamp values below a minimum (e.g., 5s or 10s).
    • Alternatively, clamp in newProxyNeighManagerWithShims before assigning announceInterval:
      if announceInterval < 5time.Second {
      logrus.Warn("Refresh interval too small; clamping to 5s")
      announceInterval = 5
      time.Second
      }
  • Rate-limit or spread announcements across ticks to avoid large per-tick bursts (e.g., jitter and per-iteration cap) when many IPs are owned.
  • Consider adding OpenAPI/CRD validation with a minimum if feasible (or document hard constraints prominently in config docs and enforce in code).
  • Expose metrics for re-announce counts per interface to aid detection of excessive traffic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hmm it a good comment to make the developer think about the concern.

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 disables for any value <= 0. I think we should either validate non-negative (and clamp negatives to 0), or update the docs to say “0 or negative disables.” Personally I’d lean toward validating non-negative to avoid surprising users. WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

API validation should not allow negative values.


// 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,

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 — IIUC we only construct and run the proxy-neighbour machinery when LocalSubnetL2Reachability is enabled, so passing a non-zero interval here won’t have side-effects when the feature is Disabled. Just checking that’s the current flow — or do we want to gate the interval at source as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it's too nit picking. I don't think Nell would add such a comment:-D

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 — I think we’re passing the refresh interval unconditionally — can you confirm the proxy-neigh manager (and its listeners) are only started when LocalSubnetL2Reachability != Disabled? If not, I think we should either zero the interval when the mode is Disabled or skip creating the manager entirely so we don’t spin a periodic loop that will never announce anything. WDYT?

@mazdakn mazdakn Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit picking. I don't think Casey would add this comment.

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() {
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.

Casey Casey — nit: We parse the IP on every periodic pass. Doesn’t hurt really, but since announced is goroutine-local you could store netip.Addr instead of string to avoid re-parsing here. Fine to leave as-is, though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hmm good point.

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

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 — Do we want to treat this as a Warn (or higher) rather than Debug? IIUC, any string in l.announced should have been derived from a valid netip.Addr earlier, so a parse failure here likely points at a logic bug, and with Debug we might miss it in production. Also, should we drop the bad entry from l.announced so we don’t spin on it every interval?

Suggested change
logrus.WithError(err).WithField("ip", ip).Debug("Failed to parse IP for re-announce")
logrus.WithError(err).WithField("ip", ip).Warn("Failed to parse IP for re-announce")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this comment specially about removing it from the map. I thought about how to handle the case. Given that a parsing error here should not happen (since it's already parsed), I did not address it.

Again, it's a good comment to think about the issue.

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

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 — runListener anchors lastAnnounce to time.Now and uses time.Since in the loop. Using wall-clock time in logic that’s under test can make UTs brittle and hard to reason about if this logic evolves (e.g., more complex cadence).

  • Suggestion: Consider factoring the timing dependency behind a small interface (e.g., a Clock with Now() and a Ticker-like abstraction) and pass it into newProxyNeighManagerWithShims. In production, use the real time; in tests, use a fake clock to deterministically trigger reannounces. This will improve test determinism and future maintainability of the loop logic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is correct, but also similar to the first two comments in the UT. So feels 3 comments about the same issue.

// 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 — I wonder about anchoring lastAnnounce at listener start — if a reconcile happens long after startup, we could end up with the initial announce followed almost immediately by the periodic refresh (since the interval since start has already elapsed). Not harmful, but a little bursty. Maybe we should reset lastAnnounce on reconcile (e.g., when we first announce a new desired IP) so the periodic timer spaces out from the initial announce? But I could be swayed either way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This could be a comment from Casey.

The comment makes sense, but it's not a hard issue. Addressing it needs keeping announcement time per IP which might not worth it.


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
// 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 — Small question on intent: we anchor lastAnnounce at listener start (so the first periodic fires announceInterval after start), and applyDesiredState does the initial one-shot announce on add. That matches the comment — just checking we don’t also want to reset lastAnnounce on reconcile (e.g., when the desired set changes) to avoid an immediate re-announce right after a bulk add? It’s probably fine as-is, but WDYT?

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