[sample #12602] [v2] feat(bpf): implement connection limit enforcement (QoS max connections)#238
[sample #12602] [v2] feat(bpf): implement connection limit enforcement (QoS max connections)#238stevegaossou wants to merge 1 commit into
Conversation
Mirrors the diff the human reviewers first saw on projectcalico#12602 ('feat(bpf): implement connection limit enforcement (QoS max connections)' by @coutinhop) for the Council of Claudes benchmark. base(fork)=28ca14a6bd3e2a50aff9c279a8f6e44c85b080f6 head(as-reviewed)=c265a80b36e2fa6a9750b4eafee390918262bf4a
| if ctKey.Proto() != 6 { // TCP only | ||
| return ScanVerdictOK, 0 | ||
| } | ||
| if ctVal.Type() == TypeNATForward { |
There was a problem hiding this comment.
🔎 Correctness — The scanner skips TypeNATForward but not TypeNATReverse entries, leading to double counting for service NAT.
Problem:
In Check(), NAT forward entries are skipped:
if ctVal.Type() == TypeNATForward { return ... }
but NAT reverse entries are not. For service traffic, both normal and NAT reverse entries may be present; counting the NAT reverse entry as well as the normal one will overcount.
Suggestion:
Skip NAT reverse too, or conversely only count TypeNormal:
if ctVal.Type() != TypeNormal { return ScanVerdictOK, 0 }
This avoids double counting across NATed paths.
| } | ||
|
|
||
| for _, ipNet := range wep.Ipv4Nets { | ||
| ip, _, err := net.ParseCIDR(ipNet) |
There was a problem hiding this comment.
🔎 Correctness — buildConnLimitPodInfo() uses net.ParseCIDR but the import for “net” isn’t shown in this file.
Problem:
The new function calls net.ParseCIDR; the diff only adds a new bpfconntrack import. If “net” isn’t already imported elsewhere in this file, this will not compile.
Suggestion:
Ensure “net” is imported in felix/dataplane/linux/bpf_ep_mgr.go:
import "net"
If it is already present elsewhere in the file’s import list, please disregard; otherwise, add it.
There was a problem hiding this comment.
🔎 Council of Claudes — Correctness
Note
Correctness lens · bugs · completeness · concurrency · edge cases
3 potential correctness issues
- The decrement-on-close path in BPF relies on a non-atomic “flag then decrement” sequence guarded only by reading/writing a CT entry flag. Two CPUs handling close packets for the same flow concurrently can both miss the flag and double-decrement the QoS counter before either write becomes visible. This can transiently undercount connections and allow extra connections until the scanner corrects it.
- The connlimit scanner recounts skip NAT forward entries but not NAT reverse entries. Counting NAT reverse entries will double-count service connections in many paths.
- In bpf_ep_mgr.go, the new buildConnLimitPodInfo() uses net.ParseCIDR but the diff doesn’t show adding an import for the net package in that file. If net wasn’t already imported, this won’t build.
🤖 Council of Claudes · 2 inline comment(s)
| // true count to the cali_qos BPF map. This corrects any drift from LRU | ||
| // eviction or connection close without explicit decrement. | ||
| // | ||
| // The BPF dataplane increments the count on new TCP SYN. This scanner |
There was a problem hiding this comment.
🧪 Maintainability & Tests — The comment states the scanner “serves as the sole decrement mechanism,” but the code also decrements in the BPF packet path on FIN/RST and in conntrack_cleanup. This is misleading for future maintainers.
- Reword to something like: “The BPF dataplane increments on SYN and decrements on FIN/RST; the scanner periodically recounts established connections and overwrites current_count to correct drift from silent purges (idle TCPEstablished, half-close, partition, LRU eviction).”
| return nil | ||
| } | ||
|
|
||
| // buildConnLimitPodInfo builds the pod IP → ConnLimitPodInfo mapping from |
There was a problem hiding this comment.
🧪 Maintainability & Tests — buildConnLimitPodInfo rolls its own net.IP-to-map-key conversion (string(ip.To4()/To16())). The scanner has a similar ipToString helper.
- Factor this into a small shared helper (e.g., a tiny internal util in felix/bpf/conntrack or qos) to avoid drift, and add a unit test to validate both IPv4 and IPv6 key generation match on both producer and consumer sides.
| FlagSetDSCP uint32 = (1 << 15) | ||
| FlagMaglev uint32 = (1 << 16) | ||
| FlagSendRST uint32 = (1 << 17) | ||
| FlagConnLimitIn uint32 = (1 << 18) |
There was a problem hiding this comment.
🧪 Maintainability & Tests — New CT flags (FlagConnLimitIn/Rej/Out/Dec) are added to FlagNames but lack a small unit test. Since FlagNames is used for debug/logging, drift here can silently degrade diagnosability.
- Add a concise unit test asserting that the string names appear when corresponding bits are set (including a combination containing existing flags to catch future regressions).
|
|
||
| // 2*IFACE_STATE_MAP_SIZE because it will potentially have 2 entries for each interface (ingress/egress) | ||
| CALI_MAP(cali_qos,, | ||
| CALI_MAP(cali_qos, 2, |
There was a problem hiding this comment.
🧪 Maintainability & Tests — The QoS BPF map version was bumped to 2 (good), and new fields were added. The header comment still only mentions “packet rate” and does not call out the new connection-limit fields or the map version.
- Expand the comment above calico_qos_val and the map definition to describe max_connections/current_count semantics (0=disabled), locking, and to note that this is version 2 of cali_qos to aid future readers syncing userspace/BPF layouts.
| return qosKey.AsBytes(), newVal.AsBytes(), true | ||
| } | ||
|
|
||
| // ipToString converts a net.IP to a string key for map lookup. |
There was a problem hiding this comment.
🧪 Maintainability & Tests — ipToString is critical glue (keys are raw IP bytes in Go string form), but there’s no direct test for the IPv6 branch.
- Add a unit test passing an IPv6 address to ensure the string key matches what buildConnLimitPodInfo produces for v6 nets; this guards against accidental To16/To4 mismatches.
| default: | ||
| Expect(true).To(BeFalse(), "hook must be either 'ingress' or 'egress', '%s' is invalid", hook) | ||
| } | ||
| qosMapName := qos.MapParams.VersionedName() |
There was a problem hiding this comment.
🧪 Maintainability & Tests — getBPFQoSValue and dependent helpers key direction with magic numbers (ingress=1, egress=0) and duplicate parsing logic.
- Consider using small constants (e.g., qos.DirectionIngress/DirectionEgress) or at least local consts with a single helper to encode the direction. This reduces copy-paste errors and aligns with qos.NewKey’s semantics.
| // TestConnLimitScannerBatchesActiveCountUpdates verifies that IterationEnd | ||
| // batches updates for entries whose counts changed, preserves the packet-rate | ||
| // fields, and leaves unchanged entries alone. | ||
| func TestConnLimitScannerBatchesActiveCountUpdates(t *testing.T) { |
There was a problem hiding this comment.
🧪 Maintainability & Tests — The batching tests nicely cover active updates/no-op/zeroing. Given the reliance on unix.BPF_F_LOCK, add one small assertion that failed BatchUpdate propagates a warning but doesn’t panic (simulate f.batchUpdateErr != nil and assert behavior). This guards the error path and makes future refactors safer.
| } | ||
|
|
||
| It("should limit connections correctly", func() { | ||
| It("should limit connections correctly (connlimit counter)", func() { |
There was a problem hiding this comment.
🧪 Maintainability & Tests — The test mixes iptables/nftables and BPF-mode branches in a single, long scenario. For long-term maintainability and to reduce conditional complexity:
- Consider moving the BPF-specific connlimit FV into a bpf_*_test.go file (following the existing pattern) so it’s easy to reason about per-dataplane expectations and tailor timeouts/setup independently.
There was a problem hiding this comment.
🧪 Council of Claudes — Maintainability & Tests
Tip
Maintainability & Tests lens · simplicity · tests · docs · idioms
Well-tested with solid UT/FV additions, but missing BPF design/doc updates and a few maintainability nits (dead macro, a misleading comment, and a potentially slow/flaky FV wait).
High-level/architectural
- BPF design/docs need updating per repo rules. This PR adds new CT flags, expands the QoS map layout, introduces new global flags, and changes the packet path (accepted_entrypoint/new_flow) plus a new CT scanner. Please update the relevant design docs in the same PR:
- felix/design/bpf-overview.md: summarize the ingress/egress connlimit admission logic at WEP tc ingress/egress; explain fast-path decrement on FIN/RST and cleanup-time decrement; retransmission rejection behavior.
- felix/design/bpf-conntrack-flowstate.md: document the new CT flags (CALI_CT_FLAG_CONNLIMIT_{INGRESS,EGRESS,REJECTED,DEC}; Go: FlagConnLimit{In,Out,Rej,Dec}) — where they’re set/cleared (first SYN, new_flow egress mark, reject mark, decrement path), and how the scanner treats DEC.
- felix/design/bpf-maps.md (or the sub-design covering QoS): describe cali_qos v2 layout (max_connections and current_count), locking (spin_lock), reader/writer responsibilities (BPF fast-path increments/decrements; periodic userspace recount), and the version bump rationale.
Suggested ready-to-paste prompt: - @copilot update felix/design/bpf-conntrack-flowstate.md "Flags & state in CT entries" to cover CALI_CT_FLAG_CONNLIMIT_{INGRESS,EGRESS,REJECTED,DEC} — when they are set, how they’re used in admission and decrement paths, and how the scanner skips DEC-marked entries.
- @copilot update felix/design/bpf-maps.md "cali_qos (QoS & connection limits)" to add the v2 layout (packet-rate fields + max_connections + current_count), BPF spin_lock use, and recount semantics (ConnLimitScanner) with version-bump notes.
- @copilot update felix/design/bpf-overview.md "Admission at WEP ingress/egress" to include connection-limit checks, fast-path decrement on FIN/RST, cleanup-time decrement, and retransmit rejection via CONNLIMIT_REJECTED.
- Tests are commendably thorough (new unit tests for the scanner, extended FV including BPF-only scenarios). However, a couple of FV waits rely on long sleeps in Eventually loops; consider exposing a test knob to shorten the scanner sampling (or signal a recount) to reduce runtime and flake risk.
- Minor duplication: two places convert IPs to raw-byte string map keys. Consider a small shared helper to avoid drift and ease future refactors.
- Small dead code and a misleading comment add future-reader cost; see line notes.
Line-specific findings
🤖 Council of Claudes · 8 inline comment(s)
| * flag), otherwise allow (the connection was already accepted and counted). | ||
| * The Go-side CT scanner periodically recounts and corrects drift. | ||
| */ | ||
| if (CALI_F_TO_WEP && !policy_skipped && |
There was a problem hiding this comment.
🛡️ Security — The ingress connection limit check is gated on “!policy_skipped”:
- Risk: If policy_skipped is true for traffic to a WEP (e.g., due to a fast-path or a “skip policy” condition), the configured connection limit is not enforced, allowing unlimited new TCP connections despite the QoS limit. This is a policy enforcement bypass that weakens isolation.
- Mitigation: Ensure that connection limits are enforced independent of policy_skipped (unless there’s a deliberate and documented exception). Either:
- Remove the policy_skipped clause from this check, or
- Guarantee that policy_skipped is never set for traffic to workloads with configured conn limits (and document that invariant), and add a unit/FV test to prevent regressions.
| if (!(cl_flags & (CALI_CT_FLAG_CONNLIMIT_INGRESS | CALI_CT_FLAG_CONNLIMIT_EGRESS))) { | ||
| return; | ||
| } | ||
| ct_value_set_flags(v, CALI_CT_FLAG_CONNLIMIT_DEC); |
There was a problem hiding this comment.
🛡️ Security — qos_connlimit_decrement_for_ct sets CALI_CT_FLAG_CONNLIMIT_DEC then decrements without synchronization:
- Risk: Two CPUs handling close events for the same flow can race: both read flags without DEC, both set DEC, and both call qos_connlimit_decrement(), resulting in a double-decrement (under-enforcement). Similarly, first‑SYN handling (see below) can race to double-increment. The periodic scanner eventually corrects drift, but within the scan window (~30–60s), limits can be under‑ or over‑enforced (allowing more connections than configured or causing spurious denials).
- Mitigation:
- Make DEC setting + decrement atomic per-CT entry. Options include adding a bpf_spin_lock to the conntrack value struct and using it around flag checks/updates, or using an atomic bit test-and-set helper if available. If modifying the CT value is too costly, consider a per-flow dedupe (e.g., a small LRU set keyed by CT key hash) to gate decrements to once-per-flow close.
- Tighten scanner cadence when connlimits are configured to reduce the correction window.
- Add FV that stresses concurrent close on the same flow to guard against regressions.
| * CT_RES_CONNLIMIT_FIRST_SYN is set in the result ONLY on the | ||
| * first SYN, so the accepted_entrypoint knows to increment. | ||
| */ | ||
| if (CALI_F_TO_WEP && INGRESS_CONN_LIMIT_CONFIGURED) { |
There was a problem hiding this comment.
🛡️ Security — First‑SYN marking for ingress (sets CALI_CT_FLAG_CONNLIMIT_INGRESS and signals CT_RES_CONNLIMIT_FIRST_SYN):
- Risk: This write is also unsynchronized. Parallel handling of SYN/SYN-ACK (or rapid retransmits) on multiple CPUs can race before the flag is visible, causing multiple “first‑SYN” observations and therefore multiple increments. The result is transient over‑enforcement (fewer connections allowed) until the scanner corrects the count.
- Mitigation:
- As above, add synchronization around the CT flag update (per‑CT spinlock or atomic bit test-and-set) to ensure single‑writer semantics.
- Alternatively, derive the acceptance-side increment solely from a single well-defined entrypoint (e.g., only from calico_tc_skb_new_flow_entrypoint on the opener side) and avoid relying on unsynchronized shared CT flags to gate first‑time increments.
There was a problem hiding this comment.
🛡️ Council of Claudes — Security
Caution
Security lens · validation · secrets · authz · isolation
2 potential security issues
- The new connection-limit enforcement in BPF relies on best‑effort flagging in conntrack entries to ensure single increment/decrement. Those flag writes aren’t synchronized, which can race across CPUs. In the worst case, this can either double-decrement (under‑enforcement) or double-increment (over‑enforcement) the QoS counter until the periodic scanner corrects it. That creates a correctness window where limits may be bypassed or cause unintended denial of service.
- Ingress enforcement is gated on a “policy_skipped” condition. If that flag is ever set for a workload that has a configured connection limit, the check is skipped entirely — a potential enforcement bypass depending on how policy_skipped is set in the call chain. This should be reviewed to ensure that configured QoS limits cannot be disabled by policy fast‑path/skip conditions.
🤖 Council of Claudes · 3 inline comment(s)
| * Atomically check the limit and increment the counter in the QoS map. | ||
| * The Go-side CT scanner periodically recounts and corrects drift. | ||
| */ | ||
| if (CALI_F_FROM_WEP && state->ip_proto == IPPROTO_TCP && |
There was a problem hiding this comment.
Nell — Do we want to gate this on the global “egress connlimit configured” flag to avoid a QoS map lookup when the feature is off? Something like:
| if (CALI_F_FROM_WEP && state->ip_proto == IPPROTO_TCP && | |
| if (CALI_F_FROM_WEP && state->ip_proto == IPPROTO_TCP && | |
| EGRESS_CONN_LIMIT_CONFIGURED && | |
| !(state->flags & CALI_ST_SUPPRESS_CT_STATE)) { |
(We do that for marking the CT ctx a few lines below; feels consistent.)
| } | ||
| applied, err := s.qosMap.BatchUpdate(batchK, batchV, unix.BPF_F_LOCK) | ||
| if err != nil { | ||
| log.WithError(err). |
| func (s *ConnLimitScanner) prepareUpdate(ifindex, direction uint32, count int32) (keyBytes, valBytes []byte, changed bool) { | ||
| qosKey := qos.NewKey(ifindex, direction) | ||
| qosValBytes, err := s.qosMap.Get(qosKey.AsBytes()) | ||
| if err != nil { |
There was a problem hiding this comment.
Council of Claudes — Nell
Important
Nell lens · simulated reviewer · simplicity · naming · error handling · keep useful comments
2 potential correctness/perf nits; a few polish items and a doc-update ask
Broadly looks solid — flags line up, map version bump matches in-kernel/userspace, and you’ve covered the decrement paths with both packet-path and cleanup. Nice tests, especially exercising FIN vs RST and the cleanup-time decrements.
Two things I’d like us to tighten:
- For new TCP from a WEP, we unconditionally do a QoS map lookup; suspect we should cheaply gate that on EGRESS_CONN_LIMIT_CONFIGURED to avoid the extra lookup when the feature is off.
- The connlimit scanner logs-and-continues on QoS map update/get errors. That’s probably OK given the “best-effort drift corrector” role, but can we at least surface a metric/counter so we can spot a broken QoS map?
Doc-update rule: this adds new CT flags and extends cali_qos with max_connections/current_count plus a background recount. Per the repo rule, can we update the BPF design to cover the new mechanics?
- @copilot update felix/design/bpf-overview.md “Maps & flags” to list the new QoS fields (max_connections/current_count) and the new CT flags (connlimit-in/out/dec/rej) and what sets/clears them.
- @copilot update felix/design/bpf-qos.md (or the matching QoS/conntrack sub-design) “Connection limit enforcement” to describe: first-SYN marking, increment path(s), decrement on FIN/RST and on cleanup, and the periodic recount (trigger, scope, and downsampling).
Line-specific findings
🤖 Council of Claudes · 3 inline comment(s)
| } | ||
|
|
||
| // Add connection limit scanner as a low-frequency drift safety net. | ||
| // It piggybacks on the CT scan loop but downsamples its recount work |
There was a problem hiding this comment.
Casey — I think this comment is inconsistent with the scanner’s own constant? In connlimit_scanner.go the downsample is N=3 with a 10s parent scan → “roughly every 30s”, but here it says “roughly every 60s.” Can we standardize this so the comment matches reality (or reference the constant)?
| // It piggybacks on the CT scan loop but downsamples its recount work | |
| // ... it downsamples internally (see connLimitScannerRunEveryN), so the actual recount runs roughly every 30s by default. |
| Eventually(func() int32 { | ||
| tc.Felixes[0].Exec("calico-bpf", "conntrack", "clean") | ||
| tc.Felixes[1].Exec("calico-bpf", "conntrack", "clean") | ||
| time.Sleep(12 * time.Second) |
There was a problem hiding this comment.
Casey — I am a little bit skeptical of this sleep-inside-Eventually pattern — we try to avoid sleeps since they can sneakily add minutes across the suite. Could we make the scanner cadence configurable for tests (e.g., env var to set N=1 or a “force recount” debug hook) and then poll without an explicit Sleep? Even better if we can observe a concrete condition to drive progress instead of time.
| @@ -234,7 +234,12 @@ var _ = infrastructure.DatastoreDescribe( | |||
| if _, ok := infra.(*infrastructure.EtcdDatastoreInfra); ok && BPFMode() { | |||
There was a problem hiding this comment.
Casey — I think this self-skip might fall into the “tests that silently don’t run” bucket we’ve been trying to avoid. If this test genuinely requires a non-etcd datastore in BPF mode, can we fail explicitly with a clear reason (or gate it behind a label that CI selects)? For example:
Fail("This test requires Kubernetes datastore in BPF mode; etcd+BPF is not supported for QoS connlimit FVs.")
If FV is different than e2e here and we do want to skip, fine to leave as-is — just calling out the pattern.
| return nil | ||
| } | ||
|
|
||
| // buildConnLimitPodInfo builds the pod IP → ConnLimitPodInfo mapping from |
There was a problem hiding this comment.
Casey — I wonder about the atomic.Pointer-to-map here. Returning a dereferenced pointer to a map header means readers share the same underlying map, which works because you rebuild-and-swap, but it’s easy to accidentally mutate from the reader. Would using atomic.Value with a plain map[string]ConnLimitPodInfo (and returning the map value), or returning a shallow copy, be simpler/safer? No big deal though if we keep it — the current comments do call out the single-writer.
| ret; \ | ||
| }) | ||
|
|
||
| #define ct_value_clear_flags(v, f) do { \ |
| } | ||
|
|
||
| By("Waiting for cleanup-time decrement after TCPFinsSeen expiry") | ||
| // 5s TCPFinsSeen + ~10s scan period + margin. |
There was a problem hiding this comment.
Casey — This is sort-of a crazy long timeout cascade (5s CT timeout + ~10s scan + 30s Eventually windows). I think the intent is good (exercise cleanup-time decrement), but can we trim this by: (a) lowering the downsample in tests, (b) tightening the CT scan period for FV, or (c) adding a test-only hook to kick the recount? Avoiding 30s Eventuallys would help keep the suite lean.
| return | ||
| } | ||
|
|
||
| log.WithField("counts", s.counts).WithField("numPods", len(s.podInfo)).Debug("ConnLimitScanner: recount done") |
| Name: "cali_qos", | ||
| Flags: unix.BPF_F_NO_PREALLOC, | ||
| Version: 1, | ||
| Version: 2, |
There was a problem hiding this comment.
Council of Claudes — Casey
Warning
Casey lens · simulated reviewer · testing discipline · API design · simplicity · robustness
3 non-blocking issues; 1 documentation/update-rule gap
I think the overall shape looks good — the BPF fast-path plumbing (mark on first SYN, RST/FIN decrement, cleanup-time decrement) plus the low-frequency scanner as a drift safety net makes sense, and you’ve bumped the QoS map version correctly. I am a little bit skeptical of a couple of FV patterns (hard sleeps and long timeouts) that will add up across the suite, and there’s a small comment mismatch. Also, per our doc-update rule, this changes how the BPF dataplane works (new CT flags, new QoS map fields, enforcement points), so I think we need a design doc tweak to record the new bits (which flags, where they’re set, scanner cadence and what it does). WDYT about adding a short section to the relevant sub-design(s) — bpf-overview.md and the conntrack flow-state topic?
- Tests/time cost: there are a few “sleep inside an Eventually” patterns and 30–60s Eventually windows that seem sort-of a crazy long timeout for FV — they’ll sneakily add minutes over the suite. If we can make the scanner cadence configurable (env var or a test hook to force a recount) and avoid Sleep(), we should do that IMO.
- Doc-update rule: new CALI_CT_FLAG_CONNLIMIT_* bits, the decrement semantics (fast-path vs cleanup-time), and the cali_qos layout change should be captured under felix/design (bpf-overview + the CT flow-state design). If you drop an “@copilot update …” with the section names, we can iterate there.
- Minor API shape: storing a pointer-to-map in an atomic and handing it out to other goroutines feels a bit brittle. Returning a fresh map (or using atomic.Value with a plain map value) would avoid accidental mutation and the double indirection — but I could be swayed either way if you prefer the current shape.
Line-specific findings
🤖 Council of Claudes · 8 inline comment(s)
- Iteration 2 (Lever-2 persona prompts), preliminary single-PR result (calico#12602 v2 #238 vs v3 #247): composition shifted as intended — Maintainability reflexive test-asks 3->0; Casey voice/focus recalibrated (naming appeared, skeptical catchphrase gone, briefer) per casey-report.html; no loss of substance. Flagged single-run/preliminary; real Nell/Casey human eval next. - Document the input-required root cause (surfaced by the new poll diagnostics) and the planned fix (treat input-required/auth-required as terminal + prompt the orchestrator never to ask for input). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark sample reproducing the as-first-reviewed state of projectcalico/calico#12602 (by @coutinhop).
This PR's diff equals what the human reviewers first saw: fork point
28ca14a6bd3e→ earliest human-reviewed commitc265a80b36e2. The original PR drew 10 human top-level review comments — the ground truth to compare the Council's feedback against.🤖 Generated for the Council of Claudes benchmark.