Skip to content

VPC egress ACL: end ACL_OUTBOUND with RETURN so its rules stay in order - #14355

Open
bhouse-nexthop wants to merge 2 commits into
apache:4.22from
bhouse-nexthop:fix-acl-outbound-order
Open

bhouse-nexthop wants to merge 2 commits into
apache:4.22from
bhouse-nexthop:fix-acl-outbound-order

Conversation

@bhouse-nexthop

@bhouse-nexthop bhouse-nexthop commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Description

On a VPC virtual router, rules in the egress ACL chain ACL_OUTBOUND_ethX (mangle) are not kept in the order the ACL list gives them, so the chain's own rules can end up behind the ACL's deny.

Fixes #14354

Why it breaks

CsNetfilters.compare() puts each rule of an ACL_INBOUND_*/ACL_OUTBOUND_* chain at the chain's current rule count, i.e. just ahead of the last rule already in the chain. That works for ACL_INBOUND_ethX, because CsAddress always closes it with a -j DROP, so every ACL rule lands ahead of that DROP, in order.

ACL_OUTBOUND_ethX has no terminal rule, so whichever rule happens to be last is pushed behind every ACL rule:

  • Tier with a public network: CsAddress adds two front accepts, for 224.0.0.18 (VRRP) and 225.0.0.50. The 225.0.0.50 accept ends up last, behind an egress deny.
    • 225.0.0.50 is the multicast group conntrackd uses to sync connection state between redundant VPC routers, over the guest interface (CsRedundant.py).
    • The peer router's sync packets come from inside the tier's CIDR, so the PREROUTING ... -j ACL_OUTBOUND_ethX jump sends them through this chain.
    • With an egress deny on that tier (the default_deny list has one), they are dropped, so state sync between the redundant routers breaks.
  • Private gateway: CsAddress adds nothing to the chain, so it starts empty, and the ACL's first rule is the one pushed to the end, behind the deny.
  • Tier on a VPC without a public network, with a static route through it: neither ACL chain gets a rule of its own. ACL_INBOUND doesn't get its DROP either, and both are jumped to only for the static route. Both then put the ACL's first rule behind its deny, ingress included.

How it is fixed

CsAddress now closes ACL_OUTBOUND_ethX with -j RETURN on a tier and on a private gateway, the same way it closes ACL_INBOUND_ethX with -j DROP. For a static route tier on a VPC without a public network, it closes both ACL_INBOUND_ethX and ACL_OUTBOUND_ethX with -j RETURN. A DROP there would newly block traffic that falls through today. RETURN at the end of a user chain does exactly what falling off its end did, so no traffic is treated differently. The ACL rules simply land ahead of it, in order.

On a VR that already has a misordered chain, the next apply puts it back in order. The RETURN is appended at the end of the chain first; the ACL rules, which are always re-inserted, then land ahead of it, and their stale copies are removed.

How to reproduce the old behaviour

  1. In a VPC, create an ACL list with egress rules:
    # Protocol CIDR Port Action
    1 TCP 10.9.9.9/32 443 Allow
    2 UDP 8.8.8.8/32 53 Allow
    3 All 0.0.0.0/0 – Deny
  2. Attach it to a tier, and to a private gateway.
  3. On the VR, run iptables -t mangle -S ACL_OUTBOUND_ethX for each interface.

Tier, before (the 225.0.0.50 accept is behind the deny):

-A ACL_OUTBOUND_eth3 -d 224.0.0.18/32 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 10.9.9.9/32 -p tcp -m tcp --dport 443 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 8.8.8.8/32 -p udp -m udp --dport 53 -j ACCEPT
-A ACL_OUTBOUND_eth3 -j DROP
-A ACL_OUTBOUND_eth3 -d 225.0.0.50/32 -j ACCEPT

Tier, after:

-A ACL_OUTBOUND_eth3 -d 224.0.0.18/32 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 225.0.0.50/32 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 10.9.9.9/32 -p tcp -m tcp --dport 443 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 8.8.8.8/32 -p udp -m udp --dport 53 -j ACCEPT
-A ACL_OUTBOUND_eth3 -j DROP
-A ACL_OUTBOUND_eth3 -j RETURN

Private gateway, before (rule 1 is behind the deny and never matches):

-A ACL_OUTBOUND_eth3 -d 8.8.8.8/32 -p udp -m udp --dport 53 -j ACCEPT
-A ACL_OUTBOUND_eth3 -j DROP
-A ACL_OUTBOUND_eth3 -d 10.9.9.9/32 -p tcp -m tcp --dport 443 -j ACCEPT

Private gateway, after:

-A ACL_OUTBOUND_eth3 -d 10.9.9.9/32 -p tcp -m tcp --dport 443 -j ACCEPT
-A ACL_OUTBOUND_eth3 -d 8.8.8.8/32 -p udp -m udp --dport 53 -j ACCEPT
-A ACL_OUTBOUND_eth3 -j DROP
-A ACL_OUTBOUND_eth3 -j RETURN

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)
  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (improves an existing feature and functionality)
  • Cleanup (Code refactoring and cleanup, that may add test cases)
  • Build/CI
  • Test (unit or integration test code)

Feature/Enhancement Scale or Bug Severity

Bug Severity

  • BLOCKER
  • Critical
  • Major
  • Minor
  • Trivial

How Has This Been Tested?

  • The before/after chains above are real output, not hand-written:

    • The ACL rules came from AclDevice, and were fed through the unmodified CsNetfilters().compare() against real iptables in a network namespace.
    • The CsAddress entries for the chain were reproduced as they stand before and after this change.
    • Covered for a tier and for a private gateway: a fresh apply, a second apply (no change), and an upgrade from the misordered chain (put back in order).
  • New tests in systemvm/test/TestCsAddress.py call CsIP.fw_vpcrouter() and check the last rule of the ACL chains:

    • for a tier and for a private gateway;
    • for a static route tier without a public network (both chains end in RETURN);
    • for one with a public network (still exactly one DROP and one RETURN).

    They fail on the current 4.22 code and pass with this change.

  • I also ran the static-route cases end to end, with the fw list built by the real CsIP.fw_vpcrouter(), against real iptables. (Nothing in .github/workflows runs systemvm/test, so they run via systemvm/test/runtests.sh.)

  • pycodestyle and pylint (as in runtests.sh) report nothing new.

  • The conntrackd impact is from reading CsRedundant.py and the PREROUTING jump. I have not observed it on a redundant pair.

How did you try to break this feature and the system with this change?

  • Ingress: ACL_INBOUND is unchanged wherever it has its DROP, and only gains a RETURN in the static-route case without a public network.
  • Traffic handling: a RETURN at the end of the chain behaves exactly like falling off its end, so traffic that matches no ACL rule is treated as before.
  • Re-apply: the RETURN is recognised as already present, so re-applying doesn't add a second one.

CsNetfilters.compare() inserts each ACL rule ahead of the last rule in its
ACL chain, which is right for ACL_INBOUND, whose last rule is its DROP.
ACL_OUTBOUND (mangle) has no terminal rule, so whatever rule happens to be
last is pushed behind every ACL rule:

- on a tier, the 225.0.0.50 accept (conntrackd's sync multicast between
  redundant routers) ends up after the ACL's own deny, so with an egress
  deny the routers stop syncing connection state;
- on a private gateway the chain is empty, so the ACL's first rule is the
  one pushed to the end, behind its deny.

Close ACL_OUTBOUND with a RETURN the way ACL_INBOUND is closed with its
DROP. RETURN is what falling off the end of the chain does already, so
traffic is treated as before; the ACL rules just land ahead of it, in order.

Fixes: apache#14354

Signed-off-by: Brad House <bhouse@nexthop.ai>
@bhouse-nexthop

Copy link
Copy Markdown
Collaborator Author

@vladimirpetrov @sureshanaparti could you take a look at this one? We'd like this fix to make it into the upcoming 4.22.2 release.

@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 18.02%. Comparing base (2974af8) to head (b621e41).
⚠️ Report is 1 commits behind head on 4.22.

Additional details and impacted files
@@             Coverage Diff              @@
##               4.22   #14355      +/-   ##
============================================
- Coverage     18.02%   18.02%   -0.01%     
  Complexity    16250    16250              
============================================
  Files          5936     5936              
  Lines        535823   535823              
  Branches      65612    65612              
============================================
- Hits          96582    96579       -3     
- Misses       428242   428243       +1     
- Partials      10999    11001       +2     
Flag Coverage Δ
uitests 4.04% <ø> (ø)
unittests 19.09% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

On a VPC without a public network a tier's ACL chains get no rules of
their own, ACL_INBOUND no DROP either, and are only jumped to for a static
route whose gateway is in the tier. Both then had the same problem: the
ACL's first rule ended up behind its deny. Close both chains with RETURN
in that case; it does what falling off their end did, so traffic is
treated as before.

Signed-off-by: Brad House <bhouse@nexthop.ai>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant