If you’ve ever looked at OVN ACLs and wondered what actually happens after you run
ovn-nbctl acl-add — like, the full journey from that one command to the OpenFlow
rules sitting in your datapath — this is the deep dive I wish I had when I started.
I spent a lot of time tracing this path through the OVN source code, and it’s one of those things that looks simple on the surface (“it’s just an ACL, right?”) but gets surprisingly complex once you see every pipeline stage it touches. So here it is, all laid out.
Table of Contents
- High-Level Intent: The ovn-nbctl Command
- NB Database: ACL Record Storage
- Northd: Scanning ACLs and Tracking State
- Pipeline Stage Architecture
- Pre-ACL Stage: Conntrack Setup and Stateless Filters
- ACL Hints Stage: Pre-computing CT State-Based Categories
- ACL Evaluation Stage: Per-ACL Flow Generation
- ACL Action Stage: Verdict Enforcement and Tier Transitions
- Universal Flows: The High-Priority Safety Net
- Stateful Stage: Conntrack Commitment
- ACL Sampling and Logging
- Controller: Translating Logical Flows to OpenFlow
- End-to-End Packet Walk
1. High-Level Intent: The ovn-nbctl Command
Everything starts with a single command:
ovn-nbctl acl-add <switch> to-lport 1001 "ip4.src == 10.0.0.1" dropThat writes a row into the NB database’s ACL table. Each ACL record has a bunch
of fields that control how it behaves:
| Column | Type | Description |
|---|---|---|
priority | integer (0-32767) | Higher values evaluated first |
direction | from-lport or to-lport | Ingress (from-lport) or egress (to-lport) |
match | string | Logical expression (e.g., "ip4.src == 10.0.0.1") |
action | enum | allow, allow-related, allow-stateless, drop, reject, pass |
tier | integer (0-3) | ACL evaluation tier (default 0) |
log | boolean | Enable logging |
severity | enum | alert, warning, notice, info, debug |
meter | string | Rate-limiting meter for logging |
label | integer (0-4294967295) | Observation point ID for sampling |
sample_new | UUID ref | Sample rule for new connections |
sample_est | UUID ref | Sample rule for established connections |
network_function_group | UUID ref | Network function chain group |
options | map | persist-established, apply-after-lb, log-related |
You can also attach ACLs to port groups (via Port_Group.acls), which then apply
to all logical switches containing ports that belong to that port group. Handy for
writing a policy once and having it show up everywhere.
2. NB Database: ACL Record Storage
The NB Logical_Switch table references ACLs like this:
"acls": {"type": {"key": {"type": "uuid", "refTable": "ACL", "refType": "strong"}, "min": 0, "max": "unlimited"}}And the NB_Global table has a few options that affect ACL behavior globally:
| Option | Default | Effect |
|---|---|---|
default_acl_drop | false | When true, switches with ACLs drop traffic by default at priority 0 in ACL_ACTION stage (instead of next;) |
acl_ct_translation | false | When true, ACL match expressions use conntrack fields (ct_tp_src/dst, ct_proto) instead of header fields (tcp/udp src/dst), enabling fragment matching |
use_ct_inv_match | true | Whether to match ct.inv when dropping invalid connections |
These come up later. Just file them away for now.
3. Northd: Scanning ACLs and Tracking State
3.1 The en-ls-stateful Engine
Before any flows get generated, northd scans all the ACLs on each logical switch
and populates a per-switch tracking structure. This happens in
ovn/northd/en-ls-stateful.c.
The key data structure is struct ls_stateful_record:
struct ls_stateful_record { struct hmap_node key_node; struct uuid nbs_uuid; size_t ls_index;
bool has_stateful_acl; /* Set if ANY ACL uses "allow-related" */ bool has_lb_vip; /* Set if any LB VIP is configured */ bool has_acls; /* Set if any ACL exists at all */ struct acl_tier max_acl_tier; /* Max tier per direction */ struct uuidset related_acls; /* UUIDs of all ACLs on this switch */ struct lflow_ref *lflow_ref;};The scanning logic in ls_stateful_record_set_acls_() goes through every ACL and:
- Sets
has_acls = trueif any ACL exists. - Calls
update_ls_max_acl_tier()to track the maximum tier value for each direction (ingress pre-LB, ingress post-LB, egress). - Inserts the ACL UUID into
related_acls. - Sets
has_stateful_acl = trueonly if the ACL’s action is"allow-related".
Here’s something that tripped me up: has_stateful_acl is specifically tied to
allow-related. But the variable that actually decides whether conntrack is needed
is has_stateful, which is computed in build_acls():
bool has_stateful = (ls_stateful_rec->has_stateful_acl || ls_stateful_rec->has_lb_vip);So a switch with only LB VIPs (load balancer virtual IPs) and no allow-related
ACLs still forces traffic through conntrack, even though has_stateful_acl is false.
The LB VIPs need conntrack for connection tracking and NAT, so the has_stateful
aggregate flag makes sure the right flows are generated. Both flags are tracked
independently, and only their union drives the conntrack-dependent flow generation.
3.2 Tier Tracking
Each ACL has a tier field (0-3). The function update_ls_max_acl_tier() tracks
the maximum tier per direction:
static voidupdate_ls_max_acl_tier(struct ls_stateful_record *ls_stateful_rec, const struct nbrec_acl *acl){ if (!acl->tier) return;
uint64_t *tier; if (!strcmp(acl->direction, "from-lport")) { if (smap_get_bool(&acl->options, "apply-after-lb", false)) { tier = &ls_stateful_rec->max_acl_tier.ingress_post_lb; } else { tier = &ls_stateful_rec->max_acl_tier.ingress_pre_lb; } } else { tier = &ls_stateful_rec->max_acl_tier.egress; } *tier = MAX(*tier, acl->tier);}Tiers are how OVN separates ACL evaluation into independent groups. An ACL at tier 0 can allow or drop a packet, and if it allows, the packet moves to tier 1 for the next set of ACLs. More on this later.
4. Pipeline Stage Architecture
OVN logical switches have both ingress and egress pipelines, each split into multiple stages. ACL processing spans several stages in each direction. Here’s the full ACL-relevant pipeline:
Ingress Pipeline (traffic arriving at the switch)
Stage 5: PRE_ACL -- Conntrack defrag, skip ports, stateless filtersStage 6: PRE_LB -- Load balancing setupStage 7: PRE_STATEFUL -- ct_next, ct_lb_markStage 8: ACL_HINT -- Pre-compute hint bits from CT stateStage 9: ACL_EVAL -- Evaluate per-ACL rules, set verdict registersStage 10: ACL_SAMPLE -- Traffic sampling for observationStage 11: ACL_ACTION -- Execute allow/drop/reject verdictStage 12: QOS -- Quality of service rules...Stage 15: LB -- Load balancing...Stage 20: ACL_AFTER_LB_EVAL -- Post-LB ACL evaluation (apply-after-lb)Stage 21: ACL_AFTER_LB_SAMPLE -- Post-LB samplingStage 22: ACL_AFTER_LB_ACTION -- Post-LB verdict resolution...Stage 24: STATEFUL -- Conntrack commitment (ct_commit)Egress Pipeline (traffic leaving the switch)
Stage 2: PRE_ACL -- Conntrack defrag, skip ports, stateless filtersStage 3: PRE_LB -- Load balancing setupStage 4: PRE_STATEFUL -- ct_next, ct_lb_markStage 5: ACL_HINT -- Pre-compute hint bits from CT stateStage 6: ACL_EVAL -- Evaluate per-ACL rules, set verdict registersStage 7: ACL_SAMPLE -- Traffic sampling for observationStage 8: ACL_ACTION -- Execute allow/drop/reject verdict...Stage 12: STATEFUL -- Conntrack commitment (ct_commit)The ingress pipeline has more stages because it includes QOS (stage 12), LB (stage 15), and the after-LB ACL stages (20-22). The egress pipeline is simpler: after ACL_ACTION (stage 8), the next ACL-relevant stage is STATEFUL (stage 12). No separate QOS stage, no after-LB stages.
The pipeline diagram for both directions:
INGRESS EGRESS +-------------+ +-------------+ | PRE_ACL | | PRE_ACL | | (stage 5) | | (stage 2) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | PRE_LB | | PRE_LB | | (stage 6) | | (stage 3) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | PRE_STATEFUL| | PRE_STATEFUL| | (stage 7) | | (stage 4) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | ACL_HINT | | ACL_HINT | | (stage 8) | | (stage 5) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | ACL_EVAL | | ACL_EVAL | | (stage 9) | | (stage 6) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | ACL_SAMPLE | | ACL_SAMPLE | | (stage 10) | | (stage 7) | +------+------+ +------+------+ | | v v +------+------+ +------+------+ | ACL_ACTION | | ACL_ACTION | | (stage 11) | | (stage 8) | +------+------+ +------+------+ | | v v +------+------+ | | QOS (12) | | +------+------+ | v +------+------+ +------+------+ | LB (15) | | STATEFUL | +------+------+ | (stage 12) | | +------+------+ v ^ +------+------+ | | ACL_AFTER_LB| | | _EVAL (20) | | +------+------+ | | | v | +------+------+ | | ACL_AFTER_LB| | | _SAMPLE(21) | | +------+------+ | | | v | +------+------+ | | ACL_AFTER_LB| | | _ACTION(22) |---conntrack commit-------->+ +------+------+ | v +------+------+ | STATEFUL | | (stage 24) | +------+------+Register Layout for ACL Processing
A bunch of registers carry state between pipeline stages:
reg0 (R0) - ACL Hint Bits and CT Control: reg0[0] REGBIT_CONNTRACK_DEFRAG reg0[1] REGBIT_CONNTRACK_COMMIT reg0[2] REGBIT_CONNTRACK_NAT reg0[7] REGBIT_ACL_HINT_ALLOW_NEW reg0[8] REGBIT_ACL_HINT_ALLOW reg0[9] REGBIT_ACL_HINT_DROP reg0[10] REGBIT_ACL_HINT_BLOCK reg0[13] REGBIT_ACL_LABEL reg0[16] REGBIT_ACL_STATELESS reg0[17] REGBIT_ACL_HINT_ALLOW_REL reg0[20] REGBIT_ACL_PERSIST_ID reg0[21] REGBIT_ACL_HINT_ALLOW_PERSISTED
reg2 (R2) - Persistent ACL ID: reg2[16..31] REG_ACL_ID
reg8 (R8) - ACL Verdict and Tier: reg8[0..7] REG_OBS_COLLECTOR_ID_NEW reg8[8..15] REG_OBS_COLLECTOR_ID_EST reg8[16] REGBIT_ACL_VERDICT_ALLOW reg8[17] REGBIT_ACL_VERDICT_DROP reg8[18] REGBIT_ACL_VERDICT_REJECT reg8[19..20] REGBIT_ACL_OBS_STAGE reg8[30..31] REG_ACL_TIER
reg3 (R3) - Observation Point ID (new): reg3 REG_OBS_POINT_ID_NEW
reg9 (R9) - Observation Point ID (established): reg9 REG_OBS_POINT_ID_ESTDon’t try to memorize these. Just know they exist — each stage reads and writes bits in these registers to pass information forward.
5. Pre-ACL Stage: Conntrack Setup and Stateless Filters
The Pre-ACL stage is basically the warm-up lap. It prepares traffic for the real
ACL processing that comes later. If has_stateful is true (meaning
has_stateful_acl || has_lb_vip), this stage sends IP traffic to conntrack for
defragmentation and connection tracking.
5.1 Default Flows (build_pre_acls)
At priority 0, all traffic is allowed through. Priority 110 bypasses conntrack for service monitor MAC:
INGRESS PRE_ACL (stage 5): Priority 0: match="1" action="next;" Priority 110: match="eth.dst == $svc_monitor_mac" action="next;"
EGRESS PRE_ACL (stage 2): Priority 0: match="1" action="next;" Priority 110: match="eth.src == $svc_monitor_mac" action="next;"5.2 Stateful Pre-ACL Flows (build_ls_stateful_rec_pre_acls)
When has_stateful_acl is true (or has_lb_vip), extra flows get generated. These
basically decide what goes to conntrack and what doesn’t.
At priority 110, various traffic types are excluded from conntrack:
INGRESS PRE_ACL (stage 5): Priority 110: match="ip && inport == <router_port>" action="next;" (skips router ports unless enable_router_port_acl is set) Priority 110: match="ip && inport == <switch_port>" action="next;" (skips local switch ports) Priority 110: match="ip && inport == <localnet_port>" action="next;" (skips localnet ports) Priority 110: match="nd || nd_rs || nd_ra || mldv1 || mldv2 || (udp && udp.src == 546 && udp.dst == 547)" action="next;" (ND, ICMPv6, MLD bypass conntrack) Priority 110: match="<tunnel ICMP too-big>" action="next;" (tunnel ICMP packet-too-big bypass) Priority 110: match="eth.mcast" action="next;" (multicast bypass -- only when acl_ct_translation is disabled)
EGRESS PRE_ACL (stage 2): (mirror of ingress: same skip flows for router, switch, localnet ports, ND/ICMP/MLD, and multicast)At priority 100, the remaining IP traffic gets sent to conntrack:
INGRESS PRE_ACL (stage 5): Priority 100: match="ip" action="REGBIT_CONNTRACK_DEFRAG = 1; next;"
EGRESS PRE_ACL (stage 2): Priority 100: match="ip" action="REGBIT_CONNTRACK_DEFRAG = 1; next;"5.3 Stateless Filters (build_stateless_filters)
ACLs with action "allow-stateless" are handled entirely here in the Pre-ACL
stage. They set REGBIT_ACL_STATELESS = 1 and skip conntrack completely:
INGRESS PRE_ACL (stage 5): Priority <acl_priority + 1000>: match="(<acl_match>)" action="REGBIT_ACL_STATELESS = 1; next;"
EGRESS PRE_ACL (stage 2): Priority <acl_priority + 1000>: match="(<acl_match>)" action="REGBIT_ACL_STATELESS = 1; next;"The priority offset of 1000 (OVN_ACL_PRI_OFFSET) means stateless filter flows
live in the range 1000-33767 — above the infrastructure flows (0-110) but below
the universal flows at UINT16_MAX - 3 (65532).
6. ACL Hints Stage: Pre-computing CT State-Based Categories
This is one of my favorite parts of the ACL pipeline. The ACL_HINT stage generates flows that pre-compute which categories of ACL action a packet might match, based on connection tracking state bits. These hints reduce the number of OpenFlow rules needed in ACL_EVAL by narrowing down what each packet could hit.
The hint stage only generates flows when has_stateful_acl or has_lb_vip is true.
If neither is present, the stage just passes traffic through.
6.1 Hint Flow Summary
These flows are installed identically in both S_SWITCH_IN_ACL_HINT (stage 8) and
S_SWITCH_OUT_ACL_HINT (stage 5):
| Priority | Match | Hints Set | Meaning |
|---|---|---|---|
| 7 | ct.new && !ct.est | ALLOW_NEW=1, DROP=1, COMMIT=1 | New connections can hit allow or drop ACLs. COMMIT is set because new traffic must be tracked. |
| 6 | !ct.new && ct.est && !ct.rpl && ct_mark.blocked == 1 | ALLOW_NEW=1, DROP=1, COMMIT=1 | Established request-direction traffic that was previously blocked. May hit re-allow or drop ACLs (e.g., after an ACL policy is re-added). |
| 5 | !ct.trk | ALLOW=1, DROP=1 | Untracked traffic (not yet sent through conntrack). Can be allowed or dropped. |
| 4 | !ct.new && ct.est && !ct.rpl && ct_mark.blocked == 0 | ALLOW=1, BLOCK=1 | Established request-direction traffic that was previously allowed. Can hit allow ACLs (stays allowed) or drop ACLs (must be blocked). |
| 3 | !ct.est | DROP=1 | Not-established traffic. Catch-all for all non-established traffic where only drop ACLs are relevant. |
| 2 | ct.est && ct_mark.blocked == 1 | DROP=1 | Established connections that are already blocked. Only drop ACLs apply. |
| 1 | ct.est && ct_mark.blocked == 0 | BLOCK=1 | Established unblocked connections. If they hit a drop ACL, they need to be blocked. |
6.2 Why Priority 3 Covers All Non-Established Traffic
The priority 3 flow with match !ct.est is worth a closer look. Its match is
satisfied by any packet that is not in the ct.est (established) state. That
includes:
- New connections (
ct.new): Already covered by priority 7 with more specific hints (ALLOW_NEW + DROP + COMMIT). - Untracked packets (
!ct.trk): Already covered by priority 5 with ALLOW + DROP hints.
So priority 3 acts as a catch-all fallback for traffic that somehow didn’t match the more specific flows at priorities 7 or 5 but is still not established. Setting only the DROP hint here means such traffic defaults to the drop-hint path in ACL_EVAL. For non-established traffic, only drop ACLs are relevant because there’s no existing connection state to match allow ACLs against.
6.3 How Hints Reduce OpenFlow Rules
Without hints, every per-ACL flow in ACL_EVAL would need to match against ct.new,
ct.est, ct.rpl, ct_mark.blocked, etc. With hints, a per-ACL flow only needs
to match on the hint bit(s) relevant to that ACL type. For example, a drop ACL only
needs to match REGBIT_ACL_HINT_DROP == 1 (or REGBIT_ACL_HINT_BLOCK == 1),
avoiding the need to spell out the full CT state expression in every ACL flow.
It’s basically an optimization layer that saves you from generating hundreds of redundant flows.
7. ACL Evaluation Stage: Per-ACL Flow Generation
This is where the actual work happens. The ACL_EVAL stage (ls_in_acl_eval /
ls_out_acl_eval) evaluates each ACL rule. The central function is consider_acl(),
which generates logical flows for each ACL.
7.1 Stage Selection
Where the flow lands depends on the ACL’s direction and options:
if (ingress && apply-after-lb) { stage = S_SWITCH_IN_ACL_AFTER_LB_EVAL; /* stage 20 */} else if (ingress) { stage = S_SWITCH_IN_ACL_EVAL; /* stage 9 */} else { stage = S_SWITCH_OUT_ACL_EVAL; /* stage 6 */}7.2 Priority Calculation
The NB ACL priority (0-32767) gets offset by 1000:
logical_flow_priority = acl->priority + OVN_ACL_PRI_OFFSET (1000)So a priority 1001 ACL becomes logical flow priority 2001.
7.3 Tier Matching
When multiple tiers are in use, each per-ACL flow is prefixed with a tier match:
reg8[30..31] == <tier> && (<acl_match>)This ensures only ACLs belonging to the current evaluation tier get considered.
7.4 Allow / Allow-Related ACLs
For stateful switches (has_stateful is true), consider_acl() generates two
flows for each allow or allow-related ACL:
Flow 1 — New connections (ALLOW_NEW hint):
Priority <acl_pri + 1000>: match: reg8[30..31] == <tier> && reg0[7] == 1 && (<acl_match>) action: [log] [persist_id] [sample_label] [nfg] next;The reg0[7] == 1 match restricts this to new connections (or established-but-blocked
connections that may be re-allowed). For persist-established ACLs, the action
includes reg2[16..31] = <acl_id>; reg0[20] = 1; to store the persistent ACL ID.
Flow 2 — Established connections (ALLOW hint):
Priority <acl_pri + 1000>: match: reg8[30..31] == <tier> && reg0[8] == 1 && (<acl_match>) action: [log] [conditional_commit] [sample_label] [nfg] next;Here’s a critical detail about the conditional commit: REGBIT_CONNTRACK_COMMIT
(reg0[1]) is only set to 1 when the ACL has a label, sample_est, or
network_function_group:
if (acl->label || acl->sample_est || acl->network_function_group) { ds_put_cstr(actions, REGBIT_CONNTRACK_COMMIT" = 1; ");}For a plain allow-related ACL without label, sample_est, or
network_function_group, no commit happens for established flows. They simply
pass through without being re-committed to conntrack. The initial commitment
already happened when the connection was new (Flow 1). The COMMIT bit is only
needed for established flows when there’s extra metadata that needs to be stored
in the conntrack entry.
7.5 Drop / Reject ACLs
For drop or reject ACLs, two flows are generated:
Flow 1 — New/untracked traffic (DROP hint):
Priority <acl_pri + 1000>: match: reg8[30..31] == <tier> && reg0[9] == 1 && (<acl_match>) action: [log] [sample] next;The action sets the verdict register and proceeds to ACL_ACTION stage for enforcement.
Flow 2 — Established, previously allowed traffic (BLOCK hint):
Priority <acl_pri + 1000>: match: reg8[30..31] == <tier> && reg0[10] == 1 && (<acl_match>) action: [log] [sample] ct_commit { ct_mark.blocked = 1; ct_label.obs_point_id = <obs_pid>; }; next;This flow re-commits the connection with ct_mark.blocked = 1 to signal that
this previously-allowed connection is now blocked. Subsequent packets on this
connection will match the priority 1 universal flow (ip && ct.est && ct_mark.blocked == 1) and be re-allowed, preventing an asymmetric drop where the
SYN was allowed but return traffic was blocked.
7.6 Stateless / Pass ACLs
For pass, allow-stateless, or switches without stateful ACLs, only a single
flow is generated:
Priority <acl_pri + 1000>: match: (<acl_match>) action: [sample_label] next;No hint matching, no CT interaction. Simple and clean.
8. ACL Action Stage: Verdict Enforcement and Tier Transitions
The ACL_ACTION stage is where the verdict set in ACL_EVAL gets enforced, and where tier transitions happen.
8.1 Default Flows
At the start of each ACTION stage, the verdict registers get cleared:
action: reg8[16] = 0; reg8[17] = 0; reg8[18] = 0;This is prepended to all flows in this stage.
8.2 Verdict Enforcement
| Priority | Match | Action |
|---|---|---|
| 1000 | reg8[16] == 1 (ALLOW) | Clear verdict bits, next; |
| 1000 | reg8[17] == 1 (DROP) | Clear verdict bits, implicit drop |
| 1000 | reg8[18] == 1 (REJECT) | Clear verdict bits, reject action (ICMP unreachable or TCP RST) with COPP meter |
| 500 | reg8[30..31] == <tier> | reg8[30..31] = <tier+1>; next(pipeline, eval_stage); (tier advancement) |
| 0 | 1 | default_acl_action (either next; or implicit drop, depending on default_acl_drop option) |
8.3 Tier Transitions
When no verdict is reached at the current tier, the packet loops back to the ACL_EVAL stage with an incremented tier value:
Priority 500: match: reg8[30..31] == <current_tier> action: reg8[30..31] = <current_tier + 1>; next(pipeline=<ingress|egress>, table=<acl_eval_table>);This loop continues until either a verdict is reached or the tier counter exceeds the maximum tier, at which point the priority 0 default flow applies.
8.4 The Default Action and default_acl_drop
The NB_Global.options["default_acl_drop"] setting controls what happens at
priority 0:
- When
default_acl_drop = false(default): the priority 0 action isnext;— traffic without an ACL match proceeds to the next pipeline stage. - When
default_acl_drop = true: the priority 0 action is an implicit drop — traffic without any ACL match is dropped.
9. Universal Flows: The High-Priority Safety Net
At priority UINT16_MAX - 3 (65532), build_acls() generates “universal” flows
that apply to all traffic regardless of per-ACL matches. These handle edge cases
that must take precedence over user-defined ACLs.
All universal flows at priority 65532 are generated on both ingress and egress
ACL_EVAL stages when has_stateful is true.
9.1 Drop Invalid and Blocked Reply Traffic
INGRESS ACL_EVAL (stage 9) and EGRESS ACL_EVAL (stage 6): Priority 65532: match: ct.inv || (ct.est && ct.rpl && ct_mark.blocked == 1) action: reg8[17] = 1; next;Invalid conntrack entries (ct.inv) and reply-direction traffic on blocked
connections are dropped. The ct.inv match is conditional on use_ct_inv_match
and lb_with_stateless_mode.
9.2 Allow Established Reply Traffic
INGRESS ACL_EVAL (stage 9): Priority 65532: match: ct.est && !ct.rel && ct.rpl && ct_mark.blocked == 0 action: reg0[9] = 0; reg0[10] = 0; reg0[17] = 1; reg8[21] = ct_label.nf; reg8[16] = 1; next;
EGRESS ACL_EVAL (stage 6): Priority 65532: match: ct.est && !ct.rel && ct.rpl && ct_mark.blocked == 0 action: reg8[21] = ct_label.nf; reg8[16] = 1; next;Reply-direction traffic on established unblocked connections is always allowed.
The ingress version additionally clears the DROP and BLOCK hint bits and sets the
ALLOW_REL hint, while also propagating the network function enabled bit from
ct_label.nf.
9.3 Allow Related Traffic with NAT
INGRESS ACL_EVAL (stage 9) and EGRESS ACL_EVAL (stage 6): Priority 65532: match: !ct.est && ct.rel && !ct.new && ct_mark.blocked == 0 action: reg0[17] = 1; reg8[21] = ct_label.nf; reg8[16] = 1; ct_commit_nat;Related traffic (ICMP errors, FTP data connections, etc.) that is not new and not established is allowed with NAT commitment.
9.4 Allow Persistent Established Traffic
INGRESS ACL_EVAL (stage 9) and EGRESS ACL_EVAL (stage 6): Priority 65532: match: ct.est && ct_mark.allow_established == 1 action: reg8[21] = ct_label.nf; reg8[16] = 1; next;Traffic matching the persist-established flag in ct_mark is allowed without
further ACL evaluation.
9.5 Allow IPv6 CT-Omit Protocols
INGRESS ACL_EVAL (stage 9), EGRESS ACL_EVAL (stage 6),and INGRESS ACL_AFTER_LB_EVAL (stage 20): Priority 65532: match: <IPV6_CT_OMIT_MATCH> action: reg8[16] = 1; next;Certain IPv6 protocols always bypass conntrack. No questions asked.
9.6 After-LB Universal Flows
The after-LB stages have their own universal flows too:
INGRESS ACL_AFTER_LB_EVAL (stage 20): Priority 65532: match: reg0[17] == 1 (ALLOW_REL hint from ingress ACL_EVAL) action: reg8[16] = 1; next;
Priority 65532: match: reg0[21] == 1 (ALLOW_PERSISTED hint from ingress ACL_EVAL) action: reg8[16] = 1; next;9.7 Service Monitor and DNS Bypass
At priority 34000 (above the universal 65532 flows):
INGRESS ACL_EVAL (stage 9): Priority 34000: match: eth.dst == $svc_monitor_mac action: reg8[16] = 1; next;
EGRESS ACL_EVAL (stage 6): Priority 34000: match: eth.src == $svc_monitor_mac action: reg8[16] = 1; next;
EGRESS ACL_EVAL (stage 6) -- when DNS records exist: Priority 34000: match: flags.from_ctrl && udp.src == 53 action: reg8[16] = 1; ct_commit; next; (if has_stateful) action: reg8[16] = 1; next; (if !has_stateful)9.8 Default Flows in ACL_EVAL
INGRESS and EGRESS ACL_EVAL (stages 9 and 6): Priority 0: match="1" action="next;"
INGRESS ACL_AFTER_LB_EVAL (stage 20): Priority 0: match="1" action="next;"9.9 Log-Related Flows (Priority UINT16_MAX - 2)
For ACLs with log, label, and log-related option enabled, slightly
higher-priority flows get installed at UINT16_MAX - 2 (65533) to intercept
reply and related traffic for logging:
OPPOSITE pipeline ACL_EVAL (ingress ACLs get egress eval flows, and vice versa): Priority 65533: match: ct.est && !ct.rel && ct.rpl && ct_mark.blocked == 0 && ct_label.label == <acl_label> action: [log] reg8[21] = ct_label.nf; reg8[16] = 1; next;
Priority 65533: match: !ct.est && ct.rel && !ct.new && ct_mark.blocked == 0 && ct_label.label == <acl_label> action: [log] reg8[21] = ct_label.nf; reg8[16] = 1; next;Note that these flows are installed on the opposite pipeline from where the ACL itself is defined. An ingress ACL’s log-related flows are installed in the egress ACL_EVAL stage (and vice versa), because reply/related traffic flows in the opposite direction.
10. Stateful Stage: Conntrack Commitment
The STATEFUL stage (ls_in_stateful / ls_out_stateful) does the actual conntrack
commitment when REGBIT_CONNTRACK_COMMIT (reg0[1]) is set. This stage generates
flows on both ingress and egress pipelines.
10.1 Default Flow
INGRESS STATEFUL (stage 24) and EGRESS STATEFUL (stage 12): Priority 0: match="1" action="next;"10.2 Four Variants of ct_commit
The stage generates four variants of ct_commit flows, each on both ingress
(stage 24) and egress (stage 12):
Variant 1 — With label, no NF (Priority 100):
INGRESS STATEFUL (stage 24) and EGRESS STATEFUL (stage 12): Priority 100: match: reg0[1] == 1 && reg0[13] == 1 action: ct_commit { ct_mark.blocked = 0; ct_mark.allow_established = reg0[20]; ct_mark.obs_stage = reg8[19..20]; ct_mark.obs_collector_id = reg8[8..15]; ct_label.obs_point_id = reg9; ct_label.acl_id = reg2[16..31]; ct_label.nf = 0; ct_label.nf_id = 0; }; next;Used when the ACL has a label (observation point ID) or sample_est, but no network function group.
Variant 2 — Without label, no NF (Priority 100):
INGRESS STATEFUL (stage 24) and EGRESS STATEFUL (stage 12): Priority 100: match: reg0[1] == 1 && reg0[13] == 0 action: ct_commit { ct_mark.blocked = 0; ct_mark.allow_established = reg0[20]; ct_label.acl_id = reg2[16..31]; ct_label.nf = 0; ct_label.nf_id = 0; }; next;Used for plain allow-related ACLs without label or NF group.
Variant 3 — Without label, with NF (Priority 110):
INGRESS STATEFUL (stage 24) and EGRESS STATEFUL (stage 12): Priority 110: match: reg0[1] == 1 && reg0[13] == 0 && reg8[21] == 1 action: ct_commit { ct_mark.blocked = 0; ct_mark.allow_established = reg0[20]; ct_label.acl_id = reg2[16..31]; ct_label.nf = 1; ct_label.nf_id = reg0[22..29]; }; next;Variant 4 — With label AND NF (Priority 110):
INGRESS STATEFUL (stage 24) and EGRESS STATEFUL (stage 12): Priority 110: match: reg0[1] == 1 && reg0[13] == 1 && reg8[21] == 1 action: ct_commit { ct_mark.blocked = 0; ct_mark.allow_established = reg0[20]; ct_mark.obs_stage = reg8[19..20]; ct_mark.obs_collector_id = reg8[8..15]; ct_label.obs_point_id = reg9; ct_label.acl_id = reg2[16..31]; ct_label.nf = 1; ct_label.nf_id = reg0[22..29]; }; next;10.3 What Each ct_commit Field Stores
| Field | Source | Purpose |
|---|---|---|
ct_mark.blocked | Always 0 | New connections start unblocked |
ct_mark.allow_established | reg0[20] (REGBIT_ACL_PERSIST_ID) | Flag for persist-established ACLs |
ct_mark.obs_stage | reg8[19..20] | Observation stage for sampling |
ct_mark.obs_collector_id | reg8[8..15] | IPFIX collector ID |
ct_label.obs_point_id | reg9 | Observation point ID for sampling |
ct_label.acl_id | reg2[16..31] | Persistent ACL ID for flush |
ct_label.nf | 0 or 1 | Network function chain flag |
ct_label.nf_id | 0 or reg0[22..29] | Network function group ID |
10.4 When ct_commit Actually Happens
For a packet to reach the STATEFUL stage and trigger ct_commit, it must have
REGBIT_CONNTRACK_COMMIT (reg0[1]) set to 1. This bit is set in these cases:
- ACL_HINT stage, priority 7: New connections (
ct.new && !ct.est) — always set to ensure new traffic is tracked. - ACL_HINT stage, priority 6: Established-but-blocked request-direction traffic — set to allow re-commitment.
- ACL_EVAL stage, per-ACL Flow 2: Only when the ACL has a label, sample_est, or network_function_group. For a plain
allow-relatedACL without these, established flows pass through ACL_EVAL without setting the COMMIT bit and reach STATEFUL’s priority 0 defaultnext;flow, skippingct_commit.
This last point is important: plain allow-related ACLs don’t re-commit
established flows. The connection was already committed during the SYN. No need
to do it again for every data packet.
11. ACL Sampling and Logging
11.1 ACL Logging (build_acl_log)
When an ACL has log = true, the action includes a log() call:
log(name="<acl_name>", severity=<severity>, meter="<meter>", direction=<direction>);The severity defaults to "info" if not specified. The direction is always
from-lport for ingress or to-lport for egress.
When a fair meter is used, alloc_acl_log_unique_meter_name() generates a unique
name by appending a double underscore and the ACL’s UUID:
return xasprintf("%s__" UUID_FMT, acl->meter, UUID_ARGS(&acl->header_.uuid));This produces names like <meter>__<uuid>, making sure each ACL using the same
fair meter gets its own rate-limited counter.
11.2 ACL Sampling (build_acl_sample_*)
Traffic sampling for IPFIX/OVSFIX observation uses a multi-stage approach:
- ACL_EVAL stage:
build_acl_sample_label_action()stores observation point IDs and collector IDs in registers (reg3, reg9, reg8[0..15], reg8[19..20]). - ACL_SAMPLE stage: Sampling flows at priority 1100 (for new) and 1200 (for established) match on the register values and emit
sample()actions.
The sampling domain IDs are mapped by en-sampling-app:
SAMPLING_APP_DROP_DEBUG -> "drop"SAMPLING_APP_ACL_NEW -> "acl-new"SAMPLING_APP_ACL_EST -> "acl-est"12. Controller: Translating Logical Flows to OpenFlow
12.1 The Translation Process
ovn-controller reads logical flows from the SB database and translates them to
OpenFlow rules installed in the OVS datapath. The translation happens in
ovn/controller/lflow.c.
For each logical flow, the controller:
- Parses the match expression using the appropriate symbol table.
- Converts the OVN match to an OVS OpenFlow match.
- Converts the OVN action to OpenFlow instructions (apply-actions, ct actions, etc.).
- Installs the OpenFlow rule via
ofctrl_add_flow().
12.2 ACL CT Translation Symbol Table
When the acl_ct_translation global option is enabled, ACL logical flows are tagged
with "acl_ct_translation"="true" in the SB database. The controller detects this
tag and uses an alternative symbol table (acl_ct_symtab) that maps L4 port fields
to their conntrack equivalents:
| Standard Symbol | CT-Translated Match |
|---|---|
tcp | ct.trk && !ct.inv && ct_proto == 6 |
tcp.src | MFF_CT_TP_SRC (via tcp predicate) |
tcp.dst | MFF_CT_TP_DST (via tcp predicate) |
udp | ct.trk && !ct.inv && ct_proto == 17 |
udp.src | MFF_CT_TP_SRC (via udp predicate) |
udp.dst | MFF_CT_TP_DST (via udp predicate) |
sctp | ct.trk && !ct.inv && ct_proto == 132 |
sctp.src | MFF_CT_TP_SRC (via sctp predicate) |
sctp.dst | MFF_CT_TP_DST (via sctp predicate) |
The key difference: the standard table uses header fields (MFF_TCP_SRC,
MFF_TCP_DST) which are only available on the first fragment, while the CT
translation table uses conntrack fields (MFF_CT_TP_SRC, MFF_CT_TP_DST) which
are available for all fragments via the conntrack entry. This is what makes
fragment matching possible.
The ct_label.acl_id subfield is defined as:
expr_symtab_add_subfield_scoped(symtab, "ct_label.acl_id", NULL, "ct_label[80..95]", WR_CT_COMMIT);This maps ct_label.acl_id to bits 80-95 of the 128-bit conntrack label.
12.3 ACL ID Conntrack Flush (acl-ids)
When an ACL with persist-established gets deleted from the NB database:
- northd (
en-acl-ids) deletes the correspondingSB_ACL_IDrecord. - ovn-controller (
acl-ids.c) detects the deletion and enters theSB_DELETEDstate. - The controller sends a conntrack flush request to OVS matching all entries with the deleted ACL’s ID in
ct_label[80..95].
The flush mask is constructed as:
ovs_u128 mask = { .u64.hi = 0xffff0000,};To understand why this corresponds to ct_label[80..95]: the 128-bit label is split
into u64.hi (bits 64-127) and u64.lo (bits 0-63). The value 0xffff0000 is a
32-bit mask at bit positions 16-31 within the u64.hi word. Since u64.hi
represents bits 64-127, positions 16-31 within it map to bits 64+16=80 through
64+31=95 of the full 128-bit label. Consistent with the field definition
ct_label[80..95].
The ACL ID value is placed in the same position:
ovs_u128 ct_id = { .u64.hi = acl_id->id << 16,};The ID (16-bit value) is shifted left by 16 bits within u64.hi, placing it in
bits 16-31 of the hi word (bits 80-95 of the label), matching the flush mask.
The flush operation uses ofp_ct_match_encode() to send a datapath-level conntrack
flush request. If the flush fails, it retries up to 3 times (MAX_FLUSHES). After
3 failures, the entry is abandoned with a warning log.
12.4 ACL Log Packet-In
When a packet triggers an ACL log action, OVN generates a packet-in to
ovn-controller with the ACTION_OPCODE_LOG opcode. The handle_acl_log()
function in ovn/lib/acl-log.c formats and logs the verdict, severity, direction,
and flow headers via VLOG_INFO.
13. End-to-End Packet Walk
This is the part where everything comes together. I’ll trace individual packets through the entire pipeline for a few common scenarios.
13.1 Scenario: SYN Packet with Drop ACL
Setup: Logical switch with ACL priority 1001, to-lport, ip4.src == 10.0.0.1, drop.
A TCP SYN packet from 10.0.0.1 arrives at the switch.
Ingress pipeline:
-
PRE_ACL (stage 5): Match
ipat priority 100. Action:REGBIT_CONNTRACK_DEFRAG = 1; next;. Packet is sent to conntrack for defrag and tracking. -
PRE_LB (stage 6): Pass through (no LB flows).
-
PRE_STATEFUL (stage 7): Match triggers
ct_next();to execute the conntrack action. After this, the packet has CT state:ct.new=1, ct.est=0, ct.rpl=0, ct.trk=1, ct_mark.blocked=0. -
ACL_HINT (stage 8): Priority 7 matches
ct.new && !ct.est. SetsALLOW_NEW=1, DROP=1, COMMIT=1. SetsREGBIT_CONNTRACK_COMMIT = 1. -
ACL_EVAL (stage 9):
- Priority 65532 flows: The drop ACL’s match
ip4.src == 10.0.0.1does not match the universal flow patterns (which checkct.inv,ct.est, etc.). No match. - Priority 1:
ip && !ct.estmatches (the packet is not established). Action:next;(bare pass-through). - Priority 2001 (the ACL):
reg8[30..31] == 0 && reg0[9] == 1 && (ip4.src == 10.0.0.1). All conditions match. Action:reg8[17] = 1; next;. Verdict: DROP.
- Priority 65532 flows: The drop ACL’s match
-
ACL_SAMPLE (stage 10): Pass through (no sampling configured).
-
ACL_ACTION (stage 11): Priority 1000 matches
reg8[17] == 1(DROP). Action: clear verdict bits, implicit drop.
The SYN is dropped.
Now consider the return SYN-ACK:
The SYN-ACK arrives from the destination (not 10.0.0.1, so it does not match the drop ACL) in the egress pipeline.
Egress pipeline:
-
PRE_ACL (stage 2): Match
ipat priority 100.REGBIT_CONNTRACK_DEFRAG = 1; next;. -
PRE_STATEFUL (stage 4):
ct_next();. CT state for the SYN-ACK:ct.new=0, ct.est=1, ct.rpl=1, ct.trk=1, ct_mark.blocked=0. -
ACL_HINT (stage 5): Priority 4 matches
!ct.new && ct.est && !ct.rpl && ct_mark.blocked == 0. But the SYN-ACK is reply direction (ct.rpl=1), so this does NOT match. Priority 1 matchesct.est && ct_mark.blocked == 0. SetsBLOCK=1. -
ACL_EVAL (stage 6):
- Priority 65532:
ct.est && !ct.rel && ct.rpl && ct_mark.blocked == 0. This matches the SYN-ACK. Action:reg8[21] = ct_label.nf; reg8[16] = 1; next;. Verdict: ALLOW. - The SYN-ACK is allowed by the universal reply flow without ever reaching the egress drop ACL.
- Priority 65532:
-
ACL_ACTION (stage 8): Priority 1000 matches
reg8[16] == 1(ALLOW). Action:next;.
The SYN-ACK is allowed. This is important: even though the SYN was dropped by a stateful ACL, the reply traffic is always permitted by the universal flows. The connection tracking state makes sure that return traffic for any connection (including dropped ones) is allowed, preventing asymmetric routing issues.
13.2 Scenario: SYN Packet with Allow-Related ACL
Setup: Logical switch with ACL priority 1001, from-lport, ip4.dst == 10.0.0.2, allow-related.
A TCP SYN packet to 10.0.0.2 originates from a VM on this switch.
Ingress pipeline:
-
PRE_ACL (stage 5):
REGBIT_CONNTRACK_DEFRAG = 1; next;. -
PRE_STATEFUL (stage 7):
ct_next();. CT state:ct.new=1, ct.est=0, ct.rpl=0, ct.trk=1. -
ACL_HINT (stage 8): Priority 7 matches
ct.new && !ct.est. SetsALLOW_NEW=1, DROP=1, COMMIT=1. -
ACL_EVAL (stage 9):
- Priority 2001 (the ACL):
reg8[30..31] == 0 && reg0[7] == 1 && (ip4.dst == 10.0.0.2). Matches. Action:reg8[16] = 1; next;. Verdict: ALLOW.
- Priority 2001 (the ACL):
-
ACL_ACTION (stage 11): Priority 1000 matches
reg8[16] == 1. Action:next;. -
QOS (stage 12): Pass through.
-
LB (stage 15): Pass through.
-
STATEFUL (stage 24): Priority 100 matches
reg0[1] == 1 && reg0[13] == 0(COMMIT set by hint stage, no label). Action:ct_commit { ct_mark.blocked = 0; ct_mark.allow_established = reg0[20]; ct_label.acl_id = reg2[16..31]; ct_label.nf = 0; ct_label.nf_id = 0; }; next;.
The SYN is allowed and committed to conntrack.
Return SYN-ACK (egress):
-
PRE_ACL (stage 2):
REGBIT_CONNTRACK_DEFRAG = 1; next;. -
PRE_STATEFUL (stage 4):
ct_next();. CT state:ct.new=0, ct.est=1, ct.rpl=1, ct_mark.blocked=0. -
ACL_HINT (stage 5): Priority 1 matches
ct.est && ct_mark.blocked == 0. SetsBLOCK=1. -
ACL_EVAL (stage 6):
- Priority 65532:
ct.est && !ct.rel && ct.rpl && ct_mark.blocked == 0. Matches. Action:reg8[21] = ct_label.nf; reg8[16] = 1; next;. Verdict: ALLOW.
- Priority 65532:
-
ACL_ACTION (stage 8): ALLOW ->
next;.
The SYN-ACK is allowed via the universal reply flow. No per-ACL flow is needed for the return traffic.
13.3 Scenario: Established Data Packet with Allow-Related (Plain, No Label)
Setup: Same as 13.2. A subsequent TCP data packet on the now-established connection.
Ingress pipeline:
-
PRE_ACL through PRE_STATEFUL: Same as before. CT state:
ct.new=0, ct.est=1, ct.rpl=0, ct_mark.blocked=0. -
ACL_HINT (stage 8): Priority 4 matches
!ct.new && ct.est && !ct.rpl && ct_mark.blocked == 0. SetsALLOW=1, BLOCK=1. Note: COMMIT is NOT set at this priority. -
ACL_EVAL (stage 9):
- Priority 2001 (the ACL, Flow 2):
reg8[30..31] == 0 && reg0[8] == 1 && (ip4.dst == 10.0.0.2). Matches. Since this is a plainallow-relatedACL without label, sample_est, or network_function_group,REGBIT_CONNTRACK_COMMITis NOT set. Action:[log_verdict] next;.
- Priority 2001 (the ACL, Flow 2):
-
ACL_ACTION (stage 11): ALLOW ->
next;. -
STATEFUL (stage 24): The packet reaches STATEFUL with
reg0[1] == 0(COMMIT not set). It matches the priority 0 default flow:match="1", action="next;". No ct_commit happens.
This is correct behavior: the connection was already committed during the SYN.
Established flows on a plain allow-related ACL (without label/sample/nfg) pass
through without re-committing. The COMMIT bit is only set when additional metadata
needs to be stored in the conntrack entry.
13.4 Scenario: Established Data Packet with Allow-Related (With Label)
Setup: ACL priority 1001, from-lport, ip4.dst == 10.0.0.2, allow-related, label=42.
The established data packet reaches ACL_EVAL with ALLOW hint. Flow 2 of the ACL
matches. Because acl->label is set, the action includes
REGBIT_CONNTRACK_COMMIT = 1. The packet reaches STATEFUL with reg0[1] == 1,
and the variant 1 flow (priority 100, label=1) commits with observation metadata.
13.5 Scenario: Drop ACL Blocking an Established Connection
Setup: ACL priority 1001, to-lport, tcp.dst == 80, drop.
A previously-allowed connection on port 80 now has a drop ACL applied. An established data packet arrives.
Ingress pipeline:
-
ACL_HINT (stage 8): Priority 4 matches
!ct.new && ct.est && !ct.rpl && ct_mark.blocked == 0. SetsALLOW=1, BLOCK=1. -
ACL_EVAL (stage 9):
- Priority 2001 (the ACL, Flow 2):
reg8[30..31] == 0 && reg0[10] == 1 && (tcp.dst == 80). The BLOCK hint is set and the ACL match hits. Action:ct_commit { ct_mark.blocked = 1; ct_label.obs_point_id = <obs_pid>; }; next;.
- Priority 2001 (the ACL, Flow 2):
-
ACL_ACTION (stage 11): No verdict register is set by the drop ACL’s Flow 2 action (it sets
ct_mark.blockedviact_commitbut does not set any verdict register). The priority 0 default flow applies:next;or implicit drop depending ondefault_acl_drop.
Subsequent packets on this connection will have ct_mark.blocked == 1 after
the first blocked packet. They’ll match the priority 1 universal flow:
ip && ct.est && ct_mark.blocked == 1 -> reg8[16] = 1; next; (ALLOW). This
prevents a black hole where the SYN was allowed but subsequent data is dropped,
which would leave the connection in a half-open state from the server’s perspective.
Appendix A: Complete Flow Priority Map
| Priority | Stage | Description |
|---|---|---|
| 0 | PRE_ACL | Default allow |
| 100 | PRE_ACL | Send IP to CT for defrag |
| 110 | PRE_ACL | Skip ND/ICMP/MLD/monitor/multicast from CT |
| 0 | ACL_HINT | Default pass-through (no ACLs/LB VIPs) |
| 1-7 | ACL_HINT | CT state-based hint computation |
| 0 | ACL_EVAL | Default next; or implicit drop |
| 1 | ACL_EVAL | Re-allow blocked established; pass non-established |
| 34000 | ACL_EVAL | Service monitor and DNS bypass |
| 1000+ | ACL_EVAL | Per-ACL rules (user priority + 1000) |
| 65532 | ACL_EVAL | Universal: drop invalid/blocked; allow reply/related/persisted |
| 65533 | ACL_EVAL | Log-related flows for ACLs with label+log+log-related |
| 65535 | ACL_EVAL | Max priority (no-ACL switch: bypass) |
| 500 | ACL_ACTION | Tier advancement |
| 1000 | ACL_ACTION | Verdict enforcement (allow/drop/reject) |
| 0 | ACL_ACTION | Default action (next; or drop) |
| 100 | STATEFUL | ct_commit with label or without label |
| 110 | STATEFUL | ct_commit with NF enabled |
Appendix B: Summary of ACL Action Types
| Action | Conntrack | New Flow Match | Established Flow Match | Notes |
|---|---|---|---|---|
allow | Yes (commit) | ALLOW_NEW==1 && (match) | ALLOW==1 && (match) | Same as allow-related |
allow-related | Yes (commit) | ALLOW_NEW==1 && (match) | ALLOW==1 && (match) | Reply/related always allowed at priority 65532 |
allow-stateless | No | (match) | N/A | Handled in PRE_ACL stage |
drop | Block if established | DROP==1 && (match) | BLOCK==1 && (match) + ct_commit{blocked=1} | Re-blocks established connections |
reject | Block if established | DROP==1 && (match) | BLOCK==1 && (match) + ct_commit{blocked=1} | Sends ICMP unreachable or TCP RST |
pass | No | (match) | N/A | Skips to next tier |