On 7/14/26 10:21 AM, Naveen Yerramneni wrote:
> A Network Function (NF) in inline mode redirects matched traffic
> through a service VM.  When the redirected traffic is IP
> unknown-unicast (destination MAC not yet in the FDB), the packet
> coming back from the NF is re-flooded by the switch, because the
> destination MAC is still unknown.  That re-flood produces a copy on
> the same port the packet originally arrived on, causing MAC flaps and
> potential L2 loops.
> 
> Following are the example packet flows.
> 
> Example 1, VLAN switch (MAC flap):
> 
>   Topology
>     VM1, VM2 and the NF are on the same logical switch (LS), which is
>     VLAN-backed (localnet port on every node).  VM1 is on N1, VM2 on
>     N2 (its port has "unknown" in addresses and a to-lport ACL that
>     redirects to the inline NF on N3).
> 
>   Flow
>     1. VM1 sends pkt to dst MAC X (not in FDB).
>     2. The LS floods the pkt; on N1 the copy exits the localnet port
>        and the TOR floods it to N2.
>     3. On N2 the pkt ingresses on localnet; the LS floods it and the
>        copy reaches VM2 (unknown-addr).
>     4. The ACL redirects the pkt to the NF on N3; the NF returns it
>        to N2.
>     5. X is still not in the FDB, so the LS floods again; on N2 one
>        copy exits the localnet port.
> 
>   Result
>     The TOR now sees VM1's source MAC on N2's port, but it had just
>     learned VM1's MAC on N1's port, so the MAC flaps on the TOR.
> 
> Example 2, VLAN switch with two protected VMs (loop):
> 
>   Topology
>     As above, plus VM3 (on N3, same LS) also has "unknown" in
>     addresses and is also NF-protected.
> 
>   Flow
>     1-5 as above; in parallel, the original flood also reaches N3 via
>         the TOR, where it is redirected to N3's NF, returns, and the
>         LS re-floods it out N3's localnet port.
>     6. N3's re-flood reaches N2 via the TOR; the LS floods it on N2,
>        the copy hits VM2 (unknown-addr), is redirected to the NF, and
>        re-floods out N2's localnet port.
>     7. That re-flood reaches N3 via the TOR; the LS floods it on N3,
>        the copy hits VM3 (also unknown-addr), is redirected to the
>        NF, re-floods out localnet, ...
> 
>   Result
>     The pkt keeps bouncing between N2 and N3 via the TOR, i.e. an
>     L2 loop, on top of continuous MAC flaps.  The loop persists as
>     long as X stays unknown.
> 
> Example 3, overlay switch (copy reflected to source port):
> 
>   Topology
>     VM1, VM2 and the NF are on the same LS and all on N1.  VM1 and
>     VM2 both have "unknown" in addresses; VM2's to-lport ACL
>     redirects to the inline NF.
> 
>   Flow
>     1. VM1 sends pkt to dst MAC X (not in FDB).
>     2. The LS floods to MC_UNKNOWN members on N1; one copy goes to
>        VM2.
>     3. VM2's ACL redirects its copy to the NF; the NF returns it to
>        N1.
>     4. X is still not in the FDB, so the LS floods to MC_UNKNOWN
>        again; one copy is headed back out VM1's own port.
> 
>   Result
>     VM1 receives a copy of the packet it just sent (reflected to the
>     source port).
> 
> Fix:
> 
> Use the nf_learn_orig_src_port() / nf_lookup_orig_src_port() actions
> from the previous commit to remember the original ingress port and
> drop the copy if it is about to be sent back out of that port.
> 
>   - Add a logical flag flags.inport_in_mc_unknown (bit 26) that
>     marks packets entering on an MC_UNKNOWN-member port (i.e. one
>     with "unknown" in addresses and receive_multicast not disabled,
>     the only ports that can both originate and receive
>     unknown-unicast floods).  northd sets it in ls_in_lookup_fdb,
>     piggybacking on the existing FDB-learn or flags.localnet flow
>     where possible, and emits a dedicated priority-50 flow for the
>     remaining MC_UNKNOWN members (ex: ports where
>     lsp_learn_fdb=false).
> 
>   - In the NF redirect stage (ls_in_nf for a from-lport ACL,
>     ls_out_nf for a to-lport ACL), on a switch with an inline NF group
>     on an ACL, learn the original ingress port
>     (nf_learn_orig_src_port()) for unicast IP packets that entered on
>     an MC_UNKNOWN-member port (flags.inport_in_mc_unknown set).  The
>     flag is set in ls_in_lookup_fdb and carried in MFF_LOG_FLAGS into
>     the egress pipeline, so it is still available in ls_out_nf.  Two
>     flows per IP version:
>     * Priority 100: learn, then redirect to the NF, when the ACL
>       selected the packet for redirect.  The existing NF-port and
>       multicast skip flows move up to priority 110 so multicast is
>       skipped before this flow, which therefore needs no !eth.mcast
>       match.
>     * Priority 50 (overlay switches only, in ls_in_nf): learn, then
>       continue to the next stage, when the ACL did not redirect the
>       packet (REGBIT_NF_ENABLED == 0).  On an overlay switch the
>       redirecting port and the source port can be on different nodes,
>       so the priority-100 learn would land on a different node than
>       the lookup; learning here, on the source port's own ingress
>       node, keeps them co-located.  VLAN-backed switches do not need
>       this: the re-flood returns to the redirecting node, where the
>       priority-100 learn already ran.
> 
>   - On the post-NF return path run
>     REGBIT_NF_LOOKUP_HIT = nf_lookup_orig_src_port() on both NF
>     ports.  The existing ls_out_pre_acl skip-stages flow does it for
>     the input_port (priority bumped 110->115 to win over the
>     priority-110 conntrack skip flow); a new priority-2 flow in
>     ls_out_nf does it for the output_port (packets redirected from
>     the ingress pipeline, e.g. a from-lport request re-flooded after
>     the NF, doing a fresh egress after conntrack).
> 
>   - A new priority-110 flow in ls_out_check_port_sec drops packets
>     with REGBIT_NF_LOOKUP_HIT == 1, i.e. the copies about to be
>     sent back out of the port they originally arrived on.
> 
> On VLAN-backed switches the re-flood returns to the redirecting node
> and egresses the shared localnet port, so the learn and the lookup
> always run on that node: dropping the copy headed back out that port
> removes both the MAC flap and the underlay L2 loop.
> 
> On overlay switches the copy headed back to the source port is always
> dropped, as in example 3: the learn runs on the source port's ingress
> and the lookup on its egress, both on the source port's node.
> 
> All new flows are gated on the switch having an inline NF group on
> an ACL.
> 
> Signed-off-by: Naveen Yerramneni <[email protected]>
> Acked-by: Aditya Mehakare <[email protected]>
> Fixes: 8e2d6fa14804 ("northd, tests: Network Function insertion logical flow 
> programming.")
> CC: Sragdhara Datta Chaudhuri <[email protected]>
> Assisted-by: Claude Opus 4.7, Cursor
> ---

Hi Naveen,

>  NEWS                         |   3 +
>  include/ovn/logical-fields.h |   9 +
>  lib/logical-fields.c         |   5 +
>  northd/northd.c              | 257 ++++++++++++++++++-----
>  northd/northd.h              |  19 ++
>  ovn-sb.xml                   |   8 +
>  tests/ovn-northd.at          | 393 +++++++++++++++++++++++++++++------
>  tests/ovn.at                 | 114 +++++++++-
>  8 files changed, 690 insertions(+), 118 deletions(-)
> 
> diff --git a/NEWS b/NEWS
> index 384e30820..9a7f03f19 100644
> --- a/NEWS
> +++ b/NEWS
> @@ -68,6 +68,9 @@ Post v26.03.0
>       (type 11) and Parameter Problem (type 12) - generated by an external
>       router are un-NATed correctly.  This makes Path MTU discovery and
>       traceroute work through stateless NAT.
> +   - Fixed MAC flaps and L2 loops caused by inline Network_Function
> +     redirection of unknown-unicast IP traffic, where the copy returning
> +     from the NF could be re-flooded out of the original ingress port.
>  

This is arguably a bug fix, it doesn't really need a NEWS item.  OTOH,
if we ever need to backport this we need to ensure that northd doesn't
use the new actions if not all ovn-controller are running an updated
version.  I presume your goal is to just fix this behavior on versions
>= 26.09.0.

>  OVN v26.03.0 - xxx xx xxxx
>  --------------------------
> diff --git a/include/ovn/logical-fields.h b/include/ovn/logical-fields.h
> index b2f1af64a..e3dddd0c4 100644
> --- a/include/ovn/logical-fields.h
> +++ b/include/ovn/logical-fields.h
> @@ -141,6 +141,7 @@ enum mff_log_flags_bits {
>      MLF_RECIRC_BIT = 24,
>      MLF_EVPN_LOOKUP_BIT = 25,
>      MLF_NF_LOOKUP_HIT_BIT = 26,
> +    MLF_INPORT_IN_MC_UNKNOWN_BIT = 27,
>      MLF_NETWORK_ID_START_BIT = 28,
>      MLF_NETWORK_ID_END_BIT = 31,
>  };
> @@ -226,6 +227,14 @@ enum mff_log_flags {
>       * MAC flaps / L2 loops after network function redirection. */
>      MLF_NF_LOOKUP_HIT = (1 << MLF_NF_LOOKUP_HIT_BIT),
>  
> +    /* Indicate that the packet entered the logical switch on a port that
> +     * is a member of MC_UNKNOWN (i.e. the port has "unknown" in its
> +     * addresses and "receive_multicast" is not disabled, so it can both
> +     * receive and originate unknown-unicast floods).  Used by the inline
> +     * network function loop prevention to learn the original source port
> +     * before redirection. */
> +    MLF_INPORT_IN_MC_UNKNOWN = (1 << MLF_INPORT_IN_MC_UNKNOWN_BIT),
> +
>      /* Assign network ID to packet to choose correct network for snat when
>       * lb_force_snat_ip=router_ip. */
>      MLF_NETWORK_ID = (OVN_MAX_NETWORK_ID << MLF_NETWORK_ID_START_BIT),
> diff --git a/lib/logical-fields.c b/lib/logical-fields.c
> index 35d9a2e22..8295d71f4 100644
> --- a/lib/logical-fields.c
> +++ b/lib/logical-fields.c
> @@ -188,6 +188,11 @@ ovn_init_symtab(struct shash *symtab)
>      snprintf(flags_str, sizeof flags_str, "flags[%d]", MLF_PKT_SAMPLED_BIT);
>      expr_symtab_add_subfield(symtab, "flags.pkt_sampled", NULL, flags_str);
>  
> +    snprintf(flags_str, sizeof flags_str, "flags[%d]",
> +             MLF_INPORT_IN_MC_UNKNOWN_BIT);
> +    expr_symtab_add_subfield(symtab, "flags.inport_in_mc_unknown", NULL,
> +                             flags_str);
> +
>      /* Connection tracking state. */
>      expr_symtab_add_field_scoped(symtab, "ct_mark", MFF_CT_MARK, NULL, false,
>                                   WR_CT_COMMIT);
> diff --git a/northd/northd.c b/northd/northd.c
> index 3a4afa063..65ed1edaf 100644
> --- a/northd/northd.c
> +++ b/northd/northd.c
> @@ -176,6 +176,10 @@ static bool vxlan_mode;
>  #define REGBIT_NF_ENABLED         "reg8[21]"
>  #define REGBIT_NF_ORIG_DIR        "reg8[22]"
>  #define REGBIT_NF_EGRESS_LOOPBACK "reg8[23]"
> +/* Set when a packet returning from an inline network function is about to
> + * be sent back out of the port it originally arrived on; such loopback
> + * copies are dropped. */
> +#define REGBIT_NF_LOOKUP_HIT      "reg8[24]"
>  /* Register to store the network function group id */
>  #define REG_NF_GROUP_ID           "reg0[22..29]"
>  /* REG_NF_ID overrides REG_NF_GROUP_ID in the pre_network_function stage. */
> @@ -315,6 +319,8 @@ static const char *reg_ct_state[] = {
>   * |    | REGBIT_NF_{ENABLED/ORIG_DIR/                 | G |                 
>                   |
>   * |    |            EGRESS_LOOPBACK}                  | 4 |                 
>                   |
>   * |    |       (>= ACL_EVAL* && <= NF*)               |   |                 
>                   |
> + * |    | REGBIT_NF_LOOKUP_HIT                         |   |                 
>                   |
> + * |    |     (>= OUT_PRE_ACL && <= OUT_CHECK_PORT_SEC)|   |                 
>                   |
>   * +----+----------------------------------------------+   
> +-----------------------------------+
>   * | R9 |              OBS_POINT_ID_EST                |   |                 
>                   |
>   * |    |       (>= ACL_EVAL* && <= ACL_ACTION*)       |   |                 
>                   |
> @@ -6386,6 +6392,19 @@ build_lswitch_port_sec_op(struct ovn_port *op, struct 
> lflow_table *lflows,
>      }
>  }
>  
> +/* True if 'op' emits the FDB-learn (LOOKUP_FDB / PUT_FDB) flows. */
> +static bool
> +lsp_emits_fdb_learn_lflow(const struct ovn_port *op)
> +{
> +    if (op->lsp_has_port_sec || !op->has_unknown) {
> +        return false;
> +    }
> +    return lsp_is_remote(op->nbsp)
> +        || (!strcmp(op->nbsp->type, "") && lsp_can_learn_mac(op->nbsp))
> +        || lsp_is_switch(op->nbsp)
> +        || (lsp_is_localnet(op->nbsp) && localnet_can_learn_mac(op->nbsp));
> +}
> +
>  static void
>  build_lswitch_learn_fdb_op(
>      struct ovn_port *op, struct lflow_table *lflows,
> @@ -6393,36 +6412,35 @@ build_lswitch_learn_fdb_op(
>  {
>      ovs_assert(op->nbsp);
>  
> -    if (op->lsp_has_port_sec || !op->has_unknown) {
> +    if (!lsp_emits_fdb_learn_lflow(op)) {
>          return;
>      }
>  
>      bool remote = lsp_is_remote(op->nbsp);
>  
> -    if (remote || (!strcmp(op->nbsp->type, "") && 
> lsp_can_learn_mac(op->nbsp))
> -        || lsp_is_switch(op->nbsp)
> -        || (lsp_is_localnet(op->nbsp) && localnet_can_learn_mac(op->nbsp))) {
> -        ds_clear(match);
> -        ds_clear(actions);
> -        ds_put_format(match, "inport == %s", op->json_key);
> -        if (lsp_is_localnet(op->nbsp)) {
> -            ds_put_cstr(actions, "flags.localnet = 1; ");
> -        }
> -        ds_put_format(actions, REGBIT_LKUP_FDB
> -                      " = lookup_fdb(inport, eth.src); next;");
> -        ovn_lflow_add(lflows, op->od, remote ? S_SWITCH_OUT_LOOKUP_FDB
> -                                             : S_SWITCH_IN_LOOKUP_FDB,
> -                      100, ds_cstr(match), ds_cstr(actions), op->lflow_ref,
> -                      WITH_IO_PORT(op->key), WITH_HINT(&op->nbsp->header_));
> -
> -        ds_put_cstr(match, " && "REGBIT_LKUP_FDB" == 0");
> -        ds_clear(actions);
> -        ds_put_cstr(actions, "put_fdb(inport, eth.src); next;");
> -        ovn_lflow_add(lflows, op->od, remote ? S_SWITCH_OUT_PUT_FDB
> -                                             : S_SWITCH_IN_PUT_FDB,
> -                      100, ds_cstr(match), ds_cstr(actions), op->lflow_ref,
> -                      WITH_IO_PORT(op->key), WITH_HINT(&op->nbsp->header_));
> +    ds_clear(match);
> +    ds_clear(actions);
> +    ds_put_format(match, "inport == %s", op->json_key);
> +    if (lsp_is_localnet(op->nbsp)) {
> +        ds_put_cstr(actions, "flags.localnet = 1; ");
>      }
> +    if (!remote && lsp_is_mc_unknown_ingress_member(op)) {

lsp_is_mc_unknown_ingress_member() already checks that the port is not
remote.

Also why skip remote ports?

But the larger question is: we're messing up the code quite a bit with
this "is traffic coming from a LSP with <unknown> addresses set but that
isn't a remote port" condition everywhere.  What's the downside to using
the learn action all the time?

If we _really_ need this condition, why not just implement it through
physical.c flows like we do for MLF_RX_FROM_TUNNEL_BIT for example?

All these new logical flow additions in various places and with various
combinations of conditions are very hard to maintain on the long term.

> +        ds_put_cstr(actions, "flags.inport_in_mc_unknown = 1; ");
> +    }
> +    ds_put_format(actions, REGBIT_LKUP_FDB
> +                  " = lookup_fdb(inport, eth.src); next;");
> +    ovn_lflow_add(lflows, op->od, remote ? S_SWITCH_OUT_LOOKUP_FDB
> +                                         : S_SWITCH_IN_LOOKUP_FDB,
> +                  100, ds_cstr(match), ds_cstr(actions), op->lflow_ref,
> +                  WITH_IO_PORT(op->key), WITH_HINT(&op->nbsp->header_));
> +
> +    ds_put_cstr(match, " && "REGBIT_LKUP_FDB" == 0");
> +    ds_clear(actions);
> +    ds_put_cstr(actions, "put_fdb(inport, eth.src); next;");
> +    ovn_lflow_add(lflows, op->od, remote ? S_SWITCH_OUT_PUT_FDB
> +                                         : S_SWITCH_IN_PUT_FDB,
> +                  100, ds_cstr(match), ds_cstr(actions), op->lflow_ref,
> +                  WITH_IO_PORT(op->key), WITH_HINT(&op->nbsp->header_));
>  }
>  
>  static void
> @@ -10445,20 +10463,52 @@ build_arp_nd_service_monitor_lflow(const char 
> *svc_monitor_mac,
>  
>  /* Ingress table: Lookup FDB.  Set flags.localnet for packets arriving from
>   * localnet ports so that downstream stages (e.g., ARP/ND responder) can
> - * condition their behavior on whether the packet came from localnet. */
> + * condition their behavior on whether the packet came from localnet.  Also
> + * set flags.inport_in_mc_unknown for MC_UNKNOWN-member localnet ports. */
>  static void
>  build_lswitch_from_localnet_op(struct ovn_port *op,
>                                 struct lflow_table *lflows,
> -                               struct ds *match)
> +                               struct ds *actions, struct ds *match)
>  {
>      ovs_assert(op->nbsp);
>      if (!lsp_is_localnet(op->nbsp)) {
>          return;
>      }
>      ds_clear(match);
> +    ds_clear(actions);
>      ds_put_format(match, "inport == %s", op->json_key);
> +    ds_put_cstr(actions, "flags.localnet = 1; ");
> +    if (lsp_is_mc_unknown_ingress_member(op)) {
> +        ds_put_cstr(actions, "flags.inport_in_mc_unknown = 1; ");
> +    }
> +    ds_put_cstr(actions, "next;");
>      ovn_lflow_add(lflows, op->od, S_SWITCH_IN_LOOKUP_FDB, 50,
> -                  ds_cstr(match), "flags.localnet = 1; next;",
> +                  ds_cstr(match), ds_cstr(actions),
> +                  op->lflow_ref, WITH_IO_PORT(op->key),
> +                  WITH_HINT(&op->nbsp->header_));
> +}
> +
> +/* Ingress table: Lookup FDB.  Set flags.inport_in_mc_unknown for
> + * MC_UNKNOWN-member ports not already covered by the FDB-learn or
> + * localnet flows. */
> +static void
> +build_lswitch_set_inport_in_mc_unknown_op(struct ovn_port *op,
> +                                          struct lflow_table *lflows,
> +                                          struct ds *match)
> +{
> +    ovs_assert(op->nbsp);
> +    if (!lsp_is_mc_unknown_ingress_member(op)) {
> +        return;
> +    }
> +    if (lsp_emits_fdb_learn_lflow(op) || lsp_is_localnet(op->nbsp)) {
> +        return;
> +    }

Like this..  this one is very hard to maintain on the long term.

> +
> +    ds_clear(match);
> +    ds_put_format(match, "inport == %s", op->json_key);
> +    ovn_lflow_add(lflows, op->od, S_SWITCH_IN_LOOKUP_FDB, 50,
> +                  ds_cstr(match),
> +                  "flags.inport_in_mc_unknown = 1; next;",
>                    op->lflow_ref, WITH_IO_PORT(op->key),
>                    WITH_HINT(&op->nbsp->header_));
>  }
> @@ -19310,6 +19360,61 @@ network_function_configure_fail_open_flows(struct 
> lflow_table *lflows,
>      ds_destroy(&match);
>  }
>  
> +/* Emit IPv4 and IPv6 nf_learn_orig_src_port() flows for flagged IP packets.
> + * 'extra_match' is ANDed onto the match if non-NULL; 'action_suffix' runs
> + * after the learn. */
> +static void
> +build_nf_learn_orig_src_port_flows(struct lflow_table *lflows,
> +                                   const struct ovn_datapath *od,
> +                                   const struct ovn_stage *stage,
> +                                   uint16_t priority,
> +                                   const char *extra_match,
> +                                   const char *action_suffix,
> +                                   struct lflow_ref *lflow_ref)
> +{
> +    for (int ipv6 = 0; ipv6 <= 1; ipv6++) {

IMO this is not so nice.

I'd just duplicate the code instead of obfuscating it like this.

> +        struct ds match = DS_EMPTY_INITIALIZER;
> +        struct ds action = DS_EMPTY_INITIALIZER;
> +
> +        ds_put_cstr(&match, ipv6 ? "ip6" : "ip4");
> +        ds_put_cstr(&match, " && flags.inport_in_mc_unknown == 1");
> +        if (extra_match) {
> +            ds_put_format(&match, " && %s", extra_match);
> +        }
> +        ds_put_format(&action, "nf_learn_orig_src_port(ipv6 = %s); %s",
> +                      ipv6 ? "true" : "false", action_suffix);
> +        ovn_lflow_add(lflows, od, stage, priority, ds_cstr(&match),
> +                      ds_cstr(&action), lflow_ref);
> +        ds_destroy(&match);
> +        ds_destroy(&action);
> +    }
> +}
> +
> +/* Emit a post-NF nf_lookup_orig_src_port() flow for packets re-entering on
> + * 'port'.  A hit sets REGBIT_NF_LOOKUP_HIT, which ls_out_check_port_sec
> + * drops.  'action_suffix' runs after the lookup. */
> +static void
> +build_nf_lookup_orig_src_port_flow(struct lflow_table *lflows,
> +                                   const struct ovn_datapath *od,
> +                                   const struct ovn_stage *stage,
> +                                   uint16_t priority,
> +                                   const struct ovn_port *port,
> +                                   const char *action_suffix,
> +                                   struct lflow_ref *lflow_ref)
> +{
> +    struct ds match = DS_EMPTY_INITIALIZER;
> +    struct ds action = DS_EMPTY_INITIALIZER;
> +
> +    ds_put_format(&match, "inport == %s", port->json_key);
> +    ds_put_format(&action,
> +                  REGBIT_NF_LOOKUP_HIT " = nf_lookup_orig_src_port(); %s",
> +                  action_suffix);
> +    ovn_lflow_add(lflows, od, stage, priority, ds_cstr(&match),
> +                  ds_cstr(&action), lflow_ref);
> +    ds_destroy(&match);
> +    ds_destroy(&action);
> +}
> +
>  static void
>  consider_network_function(struct lflow_table *lflows,
>                            const struct ovn_datapath *od,
> @@ -19415,6 +19520,15 @@ consider_network_function(struct lflow_table *lflows,
>                    (uint8_t) nf->id);
>      ovn_lflow_add(lflows, od, fwd_stage, 99, ds_cstr(&match),
>                    ds_cstr(&action), lflow_ref);
> +
> +    /* Priority 100 flows in fwd_stage:
> +     * Same as the priority-99 redirect above, but learn the original source
> +     * port first (for flagged IP packets).  Multicast is already skipped by
> +     * the higher-priority (110) flow below, so it is not matched here. */
> +    build_nf_learn_orig_src_port_flows(lflows, od, fwd_stage, 100,
> +                                       ds_cstr(&match), ds_cstr(&action),
> +                                       lflow_ref);
> +
>      ds_clear(&match);
>      ds_clear(&action);
>  
> @@ -19452,53 +19566,62 @@ consider_network_function(struct lflow_table 
> *lflows,
>      ds_clear(&match);
>      ds_clear(&action);
>  
> -    /* Priority 100 flow in in_nf:
> +    /* Priority 110 flow in in_nf:
>       * Allow packets to go through if coming from network-function port as
>       * we don't want the packets to be redirected again based on from-lport
>       * match.
>       */
>      ds_put_format(&match, "inport == %s", input_port->json_key);
>      ds_put_format(&action, REG_TUN_OFPORT" = ct_label.tun_if_id; next;");
> -    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 100,
> +    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 110,
>                    ds_cstr(&match), ds_cstr(&action), lflow_ref);
>      ds_clear(&match);
>  
>      ds_put_format(&match, "inport == %s", output_port->json_key);
> -    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 100,
> +    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 110,
>                    ds_cstr(&match), ds_cstr(&action), lflow_ref);
>      ds_clear(&match);
>      ds_clear(&action);
>  
> -    /* Priority 100 flow in out_nf:
> +    /* Priority 110 flow in out_nf:
>       * Allow packets to go through if outport is network-function port as
>       * we don't want the packets to be redirected again based on to-lport
>       * match.
>       */
>      ds_put_format(&match, "outport == %s", input_port->json_key);
>      ds_put_format(&action, "next;");
> -    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 100,
> +    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 110,
>                    ds_cstr(&match), ds_cstr(&action), lflow_ref);
>      ds_clear(&match);
>  
>      ds_put_format(&match, "outport == %s", output_port->json_key);
> -    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 100,
> +    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 110,
>                    ds_cstr(&match), ds_cstr(&action), lflow_ref);
>      ds_clear(&match);
>      ds_clear(&action);
>  
> -    /* For packets redirected from egress pipleline to the NF, when they come
> -     * out from the other NF port, we don't want to process them again 
> through
> -     * egress stages they already went through, especially not again through
> -     * conntrack as these packets are already accounted for there. Hence we
> -     * need to skip the initial pipeline stages for such packets and directly
> -     * start from the NF table. The packets that fall under this category are
> -     * the response packets from NF for from-lport ACLs and request packets
> -     * received from NF for to-lport ACLs. */
> -    ds_put_format(&match, "inport == %s", input_port->json_key);
> +    /* Priority 115 flow in out_pre_acl (input_port):
> +     * Extend the existing input_port skip-stages flow (skip the NF
> +     * table to avoid re-running conntrack) so the same flow also runs
> +     * nf_lookup_orig_src_port().  On hit, ls_out_check_port_sec drops
> +     * the copy heading back to its source port.
> +     *
> +     * Priority 115 to take precedence over the priority-110 conntrack
> +     * skip flow. */
>      ds_put_format(&action, "next(pipeline=egress, table=%d);",
> -                  (ovn_stage_get_table(S_SWITCH_OUT_NF) + 1));
> -    ovn_lflow_add(lflows, od, S_SWITCH_OUT_PRE_ACL, 110, ds_cstr(&match),
> -                  ds_cstr(&action), lflow_ref);
> +                  ovn_stage_get_table(S_SWITCH_OUT_NF) + 1);
> +    build_nf_lookup_orig_src_port_flow(lflows, od, S_SWITCH_OUT_PRE_ACL, 115,
> +                                       input_port, ds_cstr(&action),
> +                                       lflow_ref);
> +    ds_clear(&action);
> +
> +    /* Priority 2 flow in out_nf (output_port):
> +     * A post-NF packet re-entering here is doing a fresh egress and has not
> +     * run the egress stages, so run the lookup here.  A hit means it is
> +     * heading back out the port it arrived on, and ls_out_check_port_sec
> +     * drops it. */
> +    build_nf_lookup_orig_src_port_flow(lflows, od, S_SWITCH_OUT_NF, 2,
> +                                       output_port, "next;", lflow_ref);
>  
>      /* Priority 120 flows in out_stateful:
>       * If packet was received on a tunnel interface and being forwarded to a
> @@ -19519,6 +19642,7 @@ build_network_function(const struct ovn_datapath *od,
>  {
>      unsigned long *nfg_ingress_bitmap = 
> bitmap_allocate(MAX_OVN_NF_GROUP_IDS);
>      unsigned long *nfg_egress_bitmap = bitmap_allocate(MAX_OVN_NF_GROUP_IDS);
> +    bool has_nfg = false;
>  
>      /* This flow matches packets injected from out_nf stage -
>       * after it sets the outport - back to in_l2_lkup stage. This rule must 
> be
> @@ -19546,13 +19670,14 @@ build_network_function(const struct ovn_datapath 
> *od,
>                    REGBIT_NF_ENABLED" == 1 && " REGBIT_NF_ORIG_DIR" == 1",
>                    REG_NF_ID" = 0; next;", lflow_ref);
>  
> -    /* Ingress and Egress NF Table (Priority 100): ACL stage determined these
> +    /* Ingress and Egress NF Table (Priority 110): ACL stage determined these
>       * packets should be redirected, but these are multicast/broadcast
> -     * packets which can cause L2 loop if redirected to NF. */
> -    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 100,
> +     * packets which can cause L2 loop if redirected to NF.  Higher priority
> +     * than the redirect/learn flows so they skip both. */
> +    ovn_lflow_add(lflows, od, S_SWITCH_IN_NF, 110,
>                    REGBIT_NF_ENABLED" == 1 && eth.mcast",
>                    "next;", lflow_ref);
> -    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 100,
> +    ovn_lflow_add(lflows, od, S_SWITCH_OUT_NF, 110,
>                    REGBIT_NF_ENABLED" == 1 && eth.mcast",
>                    "next;", lflow_ref);
>  
> @@ -19587,6 +19712,7 @@ build_network_function(const struct ovn_datapath *od,
>                  continue;
>              }
>              nfg_bitmap = bitmap_set1(nfg_bitmap, nfg_id);
> +            has_nfg = true;
>              consider_network_function(lflows, od, 
> acl->network_function_group,
>                                        ingress, lflow_ref);
>          }
> @@ -19612,6 +19738,7 @@ build_network_function(const struct ovn_datapath *od,
>                          continue;
>                      }
>                      nfg_bitmap = bitmap_set1(nfg_bitmap, nfg_id);
> +                    has_nfg = true;
>                      consider_network_function(lflows, od,
>                                                acl->network_function_group,
>                                                ingress, lflow_ref);
> @@ -19619,6 +19746,35 @@ build_network_function(const struct ovn_datapath *od,
>              }
>          }
>      }
> +
> +    if (has_nfg) {
> +        /* Drop flow for loopback duplicates that the post-NF lookup
> +         * marked via REGBIT_NF_LOOKUP_HIT.  The lookup runs on both NF
> +         * return ports (ls_out_pre_acl for input_port, ls_out_nf for
> +         * output_port) and is installed by consider_network_function(). */
> +        ovn_lflow_add(lflows, od, S_SWITCH_OUT_CHECK_PORT_SEC, 110,
> +                      REGBIT_NF_LOOKUP_HIT " == 1", debug_drop_action(),
> +                      lflow_ref);
> +
> +        /* Priority 50 flows in in_nf, overlay switches only:
> +         * Learn the ingress port of a flagged unicast IP packet this ACL did
> +         * not redirect to an NF (REGBIT_NF_ENABLED == 0), so a post-NF copy
> +         * reflected back to that port can be dropped.  On overlay switches
> +         * the redirect may run on a different node than the source port, so
> +         * the priority-100 redirect-path learn would land on the wrong node;
> +         * learning here keeps it on the source port's node, co-located with
> +         * the lookup.  VLAN-backed switches don't need this: the re-flood
> +         * returns to the redirecting node, where the 100 learn already ran.
> +         * Unlike the redirect-path learn, no higher-priority flow skips
> +         * multicast here, so exclude it in the match. */
> +        if (!ls_has_localnet_port(od)) {
> +            build_nf_learn_orig_src_port_flows(
> +                lflows, od, S_SWITCH_IN_NF, 50,
> +                "!eth.mcast && " REGBIT_NF_ENABLED" == 0",
> +                "next;", lflow_ref);
> +        }
> +    }
> +
>      bitmap_free(nfg_ingress_bitmap);
>      bitmap_free(nfg_egress_bitmap);
>  }
> @@ -19766,7 +19922,8 @@ build_lswitch_and_lrouter_iterate_by_lsp(struct 
> ovn_port *op,
>      build_mirror_lflows(op, ls_ports, lflows);
>      build_lswitch_port_sec_op(op, lflows, actions, match);
>      build_lswitch_learn_fdb_op(op, lflows, actions, match);
> -    build_lswitch_from_localnet_op(op, lflows, match);
> +    build_lswitch_from_localnet_op(op, lflows, actions, match);
> +    build_lswitch_set_inport_in_mc_unknown_op(op, lflows, match);
>      build_lswitch_arp_nd_responder_known_ips(op, lflows, ls_ports,
>                                               meter_groups, actions, match);
>      build_lswitch_dhcp_options_and_response(op, lflows, meter_groups);
> diff --git a/northd/northd.h b/northd/northd.h
> index d27f519d6..976405571 100644
> --- a/northd/northd.h
> +++ b/northd/northd.h
> @@ -1155,6 +1155,25 @@ lsp_can_learn_mac(const struct 
> nbrec_logical_switch_port *nbsp)
>      return smap_get_bool(&nbsp->options, "lsp_learn_fdb", true);
>  }
>  
> +/* True if 'op' can receive unknown-unicast floods, i.e. it has 'unknown'
> + * among its addresses and "receive_multicast" is not disabled. */
> +static inline bool
> +lsp_can_receive_unknown_flood(const struct ovn_port *op)
> +{
> +    return op->nbsp
> +           && op->has_unknown
> +           && lsp_can_receive_multicast(op->nbsp);
> +}
> +
> +/* True if 'op' is a local MC_UNKNOWN-member ingress port (excludes
> + * remote ports).  Used to set flags.inport_in_mc_unknown. */
> +static inline bool
> +lsp_is_mc_unknown_ingress_member(const struct ovn_port *op)
> +{
> +    return lsp_can_receive_unknown_flood(op)
> +           && strcmp(op->nbsp->type, "remote");
> +}
> +
>  const char *lrp_find_member_ip(const struct ovn_port *op, const char *ip_s);
>  
>  /* This function returns true if 'op' is a gateway router port.
> diff --git a/ovn-sb.xml b/ovn-sb.xml
> index 434c8e43e..746373891 100644
> --- a/ovn-sb.xml
> +++ b/ovn-sb.xml
> @@ -1856,6 +1856,14 @@
>              otherwise cause MAC address flaps or L2 loops.
>            </p>
>  
> +          <p>
> +            northd emits the learn action only for packets entering on a port
> +            that is a member of the <code>MC_UNKNOWN</code> multicast group
> +            (gated by <code>flags.inport_in_mc_unknown</code>, set in the
> +            <code>ls_in_lookup_fdb</code> stage), since only such packets can
> +            be re-flooded by the NF.
> +          </p>
> +
>            <p>
>              <b>Example:</b>
>              <code>nf_learn_orig_src_port(ipv6 = false);</code>
> diff --git a/tests/ovn-northd.at b/tests/ovn-northd.at
> index 86cab3d5b..d35affe54 100644
> --- a/tests/ovn-northd.at
> +++ b/tests/ovn-northd.at
> @@ -8003,7 +8003,7 @@ AT_CAPTURE_FILE([S1flows])
>  #Verify the flows for default port type (VM port)
>  AT_CHECK([grep -e "ls_in_l2_lkup.*S1-vm1" S1flows | grep -e "match=(eth.dst 
> == 50:54:00:00:00:01)"], [1], [])
>  AT_CHECK([grep -e "ls_in_.*_fdb.*S1-vm1" S1flows | ovn_strip_lflows], [0], 
> [dnl
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-vm1"), 
> action=(reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-vm1"), 
> action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == "S1-vm1" 
> && reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
>  ])
>  
> @@ -8079,15 +8079,19 @@ AT_CAPTURE_FILE([S1flows])
>  
>  dnl Check that S2-vm address is not known on S1 and the forwarding to
>  dnl _MC_unknown group is configured.
> -AT_CHECK([grep -E "ls_in_l2_lkup.*S1-|unknown" S1flows | ovn_strip_lflows], 
> [0], [dnl
> +AT_CHECK([grep -E "ls_in_l2_lkup.*S1-|ls_in_l2_unknown" S1flows | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_l2_lkup      ), priority=50   , match=(eth.dst == 
> 50:54:00:00:00:01), action=(outport = "S1-vm"; output;)
>    table=??(ls_in_l2_unknown   ), priority=0    , match=(1), action=(output;)
>    table=??(ls_in_l2_unknown   ), priority=50   , match=(outport == "none"), 
> action=(outport = "_MC_unknown"; output;)
>  ])
>  
>  dnl Check that FDB learning is enabled for the switch port.
> +dnl The port type "switch" implicitly carries unknown addresses and thus
> +dnl can receive unknown-unicast floods, so flags.inport_in_mc_unknown is
> +dnl also set (consumed by inline NF redirection in ls_in_nf; harmless
> +dnl otherwise).
>  AT_CHECK([grep -E "ls_.*fdb.*S1-" S1flows | ovn_strip_lflows], [0], [dnl
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-S2"), 
> action=(reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-S2"), 
> action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == "S1-S2" && 
> reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
>  ])
>  
> @@ -8096,7 +8100,7 @@ AT_CAPTURE_FILE([S2flows])
>  
>  dnl Check that S1-vm address is not known on S2 and the forwarding to
>  dnl _MC_unknown group is configured.
> -AT_CHECK([grep -E "ls_in_l2_lkup.*S2-|unknown" S2flows | ovn_strip_lflows], 
> [0], [dnl
> +AT_CHECK([grep -E "ls_in_l2_lkup.*S2-|ls_in_l2_unknown" S2flows | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_l2_lkup      ), priority=50   , match=(eth.dst == 
> 50:54:00:00:00:02), action=(outport = "S2-vm"; output;)
>    table=??(ls_in_l2_unknown   ), priority=0    , match=(1), action=(output;)
>    table=??(ls_in_l2_unknown   ), priority=50   , match=(outport == "none"), 
> action=(outport = "_MC_unknown"; output;)
> @@ -8104,7 +8108,7 @@ AT_CHECK([grep -E "ls_in_l2_lkup.*S2-|unknown" S2flows 
> | ovn_strip_lflows], [0],
>  
>  dnl Check that FDB learning is enabled for the switch port.
>  AT_CHECK([grep -E "ls_.*fdb.*S2-" S2flows | ovn_strip_lflows], [0], [dnl
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S2-S1"), 
> action=(reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S2-S1"), 
> action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == "S2-S1" && 
> reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
>  ])
>  
> @@ -8116,7 +8120,7 @@ AT_CAPTURE_FILE([S1flows2])
>  
>  dnl Check that that address is now known on S1, but forwarding to _MC_unknown
>  dnl group is still configured for other potential addresses.
> -AT_CHECK([grep -E "ls_in_l2_lkup.*S1-|unknown" S1flows2 | ovn_strip_lflows], 
> [0], [dnl
> +AT_CHECK([grep -E "ls_in_l2_lkup.*S1-|ls_in_l2_unknown" S1flows2 | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_l2_lkup      ), priority=50   , match=(eth.dst == 
> 50:54:00:00:00:01), action=(outport = "S1-vm"; output;)
>    table=??(ls_in_l2_lkup      ), priority=50   , match=(eth.dst == 
> 50:54:00:00:00:02), action=(outport = "S1-S2"; output;)
>    table=??(ls_in_l2_unknown   ), priority=0    , match=(1), action=(output;)
> @@ -8125,7 +8129,7 @@ AT_CHECK([grep -E "ls_in_l2_lkup.*S1-|unknown" S1flows2 
> | ovn_strip_lflows], [0]
>  
>  dnl Check that FDB learning is still enabled for the switch port.
>  AT_CHECK([grep -E "ls_.*fdb.*S1-" S1flows | ovn_strip_lflows], [0], [dnl
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-S2"), 
> action=(reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "S1-S2"), 
> action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == "S1-S2" && 
> reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
>  ])
>  
> @@ -10368,10 +10372,14 @@ AT_CHECK([ovn-nbctl ls-add ls0])
>  AT_CHECK([ovn-nbctl lsp-add-localnet-port ls0 ln_port phys])
>  AT_CHECK([ovn-nbctl --wait=sb sync])
>  
> -# Check MAC learning flows with 'localnet_learn_fdb' default (false)
> +# Check MAC learning flows with 'localnet_learn_fdb' default (false).
> +# Because lsp-add-localnet-port defaults addresses to "unknown", the
> +# localnet port can receive unknown-unicast floods and gets
> +# flags.inport_in_mc_unknown set (consumed by inline NF redirection
> +# in ls_in_nf; harmless otherwise).
>  AT_CHECK([ovn-sbctl dump-flows ls0 | grep -e 'ls_in_\(put\|lookup\)_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>    table=??(ls_in_put_fdb      ), priority=0    , match=(1), action=(next;)
>  ])
>  
> @@ -10379,8 +10387,8 @@ AT_CHECK([ovn-sbctl dump-flows ls0 | grep -e 
> 'ls_in_\(put\|lookup\)_fdb' | ovn_s
>  AT_CHECK([ovn-nbctl --wait=sb lsp-set-options ln_port 
> localnet_learn_fdb=true])
>  AT_CHECK([ovn-sbctl dump-flows ls0 | grep -e 'ls_in_\(put\|lookup\)_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; 
> reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>    table=??(ls_in_put_fdb      ), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == "ln_port" 
> && reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
>  ])
> @@ -10389,7 +10397,7 @@ AT_CHECK([ovn-sbctl dump-flows ls0 | grep -e 
> 'ls_in_\(put\|lookup\)_fdb' | ovn_s
>  AT_CHECK([ovn-nbctl --wait=sb lsp-set-options ln_port 
> localnet_learn_fdb=false])
>  AT_CHECK([ovn-sbctl dump-flows ls0 | grep -e 'ls_in_\(put\|lookup\)_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == 
> "ln_port"), action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>    table=??(ls_in_put_fdb      ), priority=0    , match=(1), action=(next;)
>  ])
>  
> @@ -10762,7 +10770,7 @@ dnl ls1: ls_in_lookup_fdb should have priority 0 
> default +
>  dnl priority 50 flags.localnet.
>  AT_CHECK([ovn-sbctl dump-flows ls1 | grep -e 'ls_in_lookup_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>  ])
>  
>  dnl ls1: ls_in_arp_rsp should include flags.localnet condition for
> @@ -10802,8 +10810,8 @@ dnl ls1: ls_in_lookup_fdb should have priority 100 
> FDB +
>  dnl priority 50 fallback.
>  AT_CHECK([ovn-sbctl dump-flows ls1 | grep -e 'ls_in_lookup_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "ln1"), 
> action=(flags.localnet = 1; reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == "ln1"), 
> action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; reg0[[11]] = 
> lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>  ])
>  
>  dnl ls1: ls_in_arp_rsp should be unchanged.
> @@ -10824,7 +10832,7 @@ check ovn-nbctl --wait=sb lsp-set-options ln1 
> localnet_learn_fdb=false
>  
>  AT_CHECK([ovn-sbctl dump-flows ls1 | grep -e 'ls_in_lookup_fdb' | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , match=(1), action=(next;)
> -  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "ln1"), 
> action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
>  ])
>  
>  AT_CHECK([ovn-sbctl dump-flows ls1 | grep -e 'ls_in_arp_rsp' | 
> ovn_strip_lflows], [0], [dnl
> @@ -19962,15 +19970,20 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' sw0flows | ovn_strip_lflows | 
> sort], [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw0-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw0-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 101), action=(outport = "sw0-nf-p1"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p1"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p2"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p1"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p2"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 101), action=(outport = "sw0-nf-p2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
>  
> @@ -19978,8 +19991,11 @@ AT_CHECK([grep "ls_in_l2_lkup" sw0flows | 
> ovn_strip_lflows | grep 'priority=100'
>    table=??(ls_in_l2_lkup      ), priority=100  , match=(reg8[[23]] == 1), 
> action=(output;)
>  ])
>  
> +dnl LOOKUP runs on both NF return ports: input_port in ls_out_pre_acl (also
> +dnl skipping the already-traversed egress stages) and output_port in
> +dnl ls_out_nf (fresh egress, after conntrack).
>  AT_CHECK([grep "ls_out_pre_acl" sw0flows | ovn_strip_lflows | grep 
> 'sw0-nf-p1'], [0], [dnl
> -  table=??(ls_out_pre_acl     ), priority=110  , match=(inport == 
> "sw0-nf-p1"), action=(next(pipeline=egress, table=??);)
> +  table=??(ls_out_pre_acl     ), priority=115  , match=(inport == 
> "sw0-nf-p1"), action=(reg8[[24]] = nf_lookup_orig_src_port(); 
> next(pipeline=egress, table=??);)
>  ])
>  
>  AT_CHECK(
> @@ -20070,20 +20086,28 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' sw0flows | ovn_strip_lflows |  
> sort], [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw0-nf-p4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw0-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw0-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw0-nf-p4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 102), action=(outport = "sw0-nf-p3"; 
> output;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 101), action=(outport = "sw0-nf-p1"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p1"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p2"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p3"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw0-nf-p4"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw0-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw0-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p1"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p2"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p3"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw0-nf-p4"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p4"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 101), action=(outport = "sw0-nf-p2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 102), action=(outport = "sw0-nf-p4"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
> @@ -20152,20 +20176,28 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' sw1flows | ovn_strip_lflows | 
> sort], [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw1-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw1-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw1-nf-p3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "sw1-nf-p4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw1-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw1-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw1-nf-p1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw1-nf-p2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw1-nf-p3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "sw1-nf-p4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 102), action=(outport = "sw1-nf-p3"; 
> output;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 101), action=(outport = "sw1-nf-p1"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw1-nf-p1"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw1-nf-p2"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw1-nf-p3"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "sw1-nf-p4"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw1-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw1-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw1-nf-p1"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw1-nf-p2"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw1-nf-p3"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "sw1-nf-p4"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw1-nf-p2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw1-nf-p4"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 101), action=(outport = "sw1-nf-p2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 102), action=(outport = "sw1-nf-p4"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
> @@ -20200,6 +20232,210 @@ OVN_CLEANUP_NORTHD
>  AT_CLEANUP
>  ])
>  
> +OVN_FOR_EACH_NORTHD_NO_HV([
> +AT_SETUP([Network function -- inline post-NF re-flood loop prevention])
> +
> +dnl Verifies that with an inline-mode Network_Function_Group attached to an
> +dnl ACL, northd installs:
> +dnl  - flags.inport_in_mc_unknown = 1 in ls_in_lookup_fdb for every port
> +dnl    that can receive an unknown-unicast flood,
> +dnl  - per-NFG nf_learn_orig_src_port() redirect-path flows (priority 101)
> +dnl    in ls_in_nf (or ls_out_nf for to-lport ACLs) gated on that flag, and,

Priority 100?

> +dnl    on overlay switches only, a per-LS default-path flow (priority 50) in
> +dnl    ls_in_nf,
> +dnl  - nf_lookup_orig_src_port() flows on both NF ports: input_port in
> +dnl    ls_out_pre_acl (also skipping the already-traversed egress stages)
> +dnl    and output_port in ls_out_nf (fresh egress, after conntrack), and
> +dnl  - the ls_out_check_port_sec drop on REGBIT_NF_LOOKUP_HIT.
> +
> +ovn_start
> +
> +check ovn-nbctl ls-add sw0
> +
> +dnl NF port-pair.
> +check ovn-nbctl lsp-add sw0 sw0-nf-p1
> +check ovn-nbctl lsp-add sw0 sw0-nf-p2
> +check ovn-nbctl set logical_switch_port sw0-nf-p1 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p2
> +check ovn-nbctl set logical_switch_port sw0-nf-p2 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p1
> +
> +dnl A VM port and a localnet port, both with "unknown" in addresses: each
> +dnl can receive an unknown-unicast flood, so each must get the flag.
> +check ovn-nbctl lsp-add sw0 sw0-vm1 -- \
> +    lsp-set-addresses sw0-vm1 "00:00:00:00:00:01 10.0.0.10" unknown
> +check ovn-nbctl lsp-add sw0 sw0-ln -- \
> +    lsp-set-type sw0-ln localnet -- \
> +    lsp-set-options sw0-ln network_name=phys -- \
> +    lsp-set-addresses sw0-ln unknown
> +
> +dnl A second VM port without "unknown" must NOT get the flag set.
> +check ovn-nbctl lsp-add sw0 sw0-vm2 -- \
> +    lsp-set-addresses sw0-vm2 "00:00:00:00:00:02 10.0.0.11"
> +
> +dnl A third port with "unknown" but with receive_multicast=false must NOT
> +dnl get the flag set (it would never be a flood target).
> +check ovn-nbctl lsp-add sw0 sw0-vm3 -- \
> +    lsp-set-addresses sw0-vm3 "00:00:00:00:00:03 10.0.0.12" unknown -- \
> +    lsp-set-options sw0-vm3 receive_multicast=false
> +
> +check ovn-nbctl nf-add nf0 101 sw0-nf-p1 sw0-nf-p2
> +check ovn-nbctl nfg-add nfg0 201 inline nf0
> +check ovn-nbctl acl-add sw0 from-lport 1234 'ip' allow-related nfg0
> +check ovn-nbctl --wait=sb sync
> +
> +ovn-sbctl dump-flows sw0 > sw0flows
> +AT_CAPTURE_FILE([sw0flows])
> +
> +dnl FLAG: every port that can receive an unknown-unicast flood gets
> +dnl flags.inport_in_mc_unknown = 1 set in ls_in_lookup_fdb.  The VIF with
> +dnl "unknown" gets the action appended to its priority-100 FDB-learn flow;
> +dnl the localnet (without localnet_learn_fdb) gets it appended to its
> +dnl priority-50 flags.localnet flow.
> +AT_CHECK([grep 'ls_in_lookup_fdb' sw0flows | ovn_strip_lflows | \
> +          grep inport_in_mc_unknown | sort], [0], [dnl
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == 
> "sw0-vm1"), action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = 
> lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=50   , match=(inport == "sw0-ln"), 
> action=(flags.localnet = 1; flags.inport_in_mc_unknown = 1; next;)
> +])
> +
> +dnl FLAG must not be set for NF ports (receive_multicast=false), plain VM
> +dnl ports without "unknown", or ports with receive_multicast=false.
> +AT_CHECK([grep 'ls_in_lookup_fdb' sw0flows | ovn_strip_lflows | \
> +          grep inport_in_mc_unknown | \
> +          grep -E 'sw0-nf-p[[12]]|sw0-vm2|sw0-vm3' | wc -l], [0], [0
> +])
> +
> +dnl LEARN (redirect path): for the inline-mode NFG attached via from-lport
> +dnl ACL, ls_in_nf has priority-100 LEARN+redirect flows for IPv4 and IPv6,
> +dnl gated on the flag.  Multicast is skipped by the higher-priority (110)
> +dnl flow, so it is not matched here.  The existing priority-99 redirect flow
> +dnl remains as a catch-all.
> +AT_CHECK([grep 'ls_in_network_function' sw0flows | ovn_strip_lflows | \
> +          grep nf_learn | grep 'priority=100' | sort], [0], [dnl
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw0-nf-p1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 101), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw0-nf-p1"; output;)
> +])
> +
> +dnl LEARN (default path): the per-LS priority-50 default-path LEARN is only
> +dnl emitted on overlay switches.  sw0 has a localnet port (VLAN-backed), 
> where
> +dnl the redirect-path learn already co-locates with the lookup, so it must 
> NOT
> +dnl have any priority-50 nf_learn flow.  (Overlay coverage is below.)
> +AT_CHECK([grep 'ls_in_network_function' sw0flows | ovn_strip_lflows | \
> +          grep nf_learn | grep 'priority=50' | wc -l], [0], [0
> +])
> +
> +dnl LOOKUP: both NF ports run nf_lookup_orig_src_port() and stash the result
> +dnl in REGBIT_NF_LOOKUP_HIT.  input_port runs in ls_out_pre_acl and also
> +dnl skips the already-traversed egress stages; output_port (fresh egress,
> +dnl e.g. a re-flooded request) runs in ls_out_nf, after conntrack.
> +AT_CHECK([grep 'ls_out_pre_acl' sw0flows | ovn_strip_lflows | \
> +          grep 'sw0-nf-p1'], [0], [dnl
> +  table=??(ls_out_pre_acl     ), priority=115  , match=(inport == 
> "sw0-nf-p1"), action=(reg8[[24]] = nf_lookup_orig_src_port(); 
> next(pipeline=egress, table=??);)
> +])
> +AT_CHECK([grep 'ls_out_network_function' sw0flows | ovn_strip_lflows | \
> +          grep nf_lookup], [0], [dnl
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
> +])
> +
> +dnl DROP: ls_out_check_port_sec drops on REGBIT_NF_LOOKUP_HIT == 1.
> +AT_CHECK([grep 'ls_out_check_port_sec' sw0flows | ovn_strip_lflows | \
> +          grep 'reg8\[[24\]] == 1'], [0], [dnl
> +  table=??(ls_out_check_port_sec), priority=110  , match=(reg8[[24]] == 1), 
> action=(drop;)
> +])
> +
> +AS_BOX([An overlay switch (no localnet) gets the default-path LEARN])
> +
> +dnl On an overlay switch the redirect can run on a different node than the
> +dnl source port, so the redirect-path learn would land on the wrong node.
> +dnl northd therefore also emits the per-LS priority-50 default-path LEARN in
> +dnl ls_in_nf, recording the source port on its own ingress node.
> +check ovn-nbctl ls-add sw1
> +check ovn-nbctl lsp-add sw1 sw1-nf-p1
> +check ovn-nbctl lsp-add sw1 sw1-nf-p2
> +check ovn-nbctl set logical_switch_port sw1-nf-p1 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw1-nf-p2
> +check ovn-nbctl set logical_switch_port sw1-nf-p2 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw1-nf-p1
> +check ovn-nbctl nf-add nf2 103 sw1-nf-p1 sw1-nf-p2
> +check ovn-nbctl nfg-add nfg2 203 inline nf2
> +check ovn-nbctl acl-add sw1 from-lport 1234 'ip' allow-related nfg2
> +check ovn-nbctl --wait=sb sync
> +
> +ovn-sbctl dump-flows sw1 > sw1flows
> +AT_CAPTURE_FILE([sw1flows])
> +
> +AT_CHECK([grep 'ls_in_network_function' sw1flows | ovn_strip_lflows | \
> +          grep nf_learn | grep 'priority=50' | sort], [0], [dnl
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
> +])
> +
> +AS_BOX([A second inline NFG on a to-lport ACL emits its own LEARN+LOOKUP])
> +
> +dnl Adding a second NF pair and attaching it via a to-lport ACL must yield:
> +dnl  - additional priority-100 LEARN+redirect flows in ls_out_nf for that
> +dnl    NFG (gated on the flag),
> +dnl  - LOOKUP flows keyed on both of that NF's ports too.
> +dnl Flag-set flows in ls_in_lookup_fdb are LS-wide and are not duplicated by
> +dnl the second NFG.
> +check ovn-nbctl lsp-add sw0 sw0-nf-p3
> +check ovn-nbctl lsp-add sw0 sw0-nf-p4
> +check ovn-nbctl set logical_switch_port sw0-nf-p3 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p4
> +check ovn-nbctl set logical_switch_port sw0-nf-p4 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p3
> +check ovn-nbctl nf-add nf1 102 sw0-nf-p3 sw0-nf-p4
> +check ovn-nbctl nfg-add nfg1 202 inline nf1
> +check ovn-nbctl acl-add sw0 to-lport 1234 'ip' allow-related nfg1
> +check ovn-nbctl --wait=sb sync
> +
> +ovn-sbctl dump-flows sw0 > sw0flows
> +
> +AT_CHECK([grep 'ls_out_pre_acl' sw0flows | ovn_strip_lflows | \
> +          grep -E 'sw0-nf-p[[13]]' | sort], [0], [dnl
> +  table=??(ls_out_pre_acl     ), priority=115  , match=(inport == 
> "sw0-nf-p1"), action=(reg8[[24]] = nf_lookup_orig_src_port(); 
> next(pipeline=egress, table=??);)
> +  table=??(ls_out_pre_acl     ), priority=115  , match=(inport == 
> "sw0-nf-p3"), action=(reg8[[24]] = nf_lookup_orig_src_port(); 
> next(pipeline=egress, table=??);)
> +])
> +AT_CHECK([grep 'ls_out_network_function' sw0flows | ovn_strip_lflows | \
> +          grep nf_lookup | sort], [0], [dnl
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "sw0-nf-p4"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
> +])
> +
> +AT_CHECK([grep 'ls_out_network_function' sw0flows | ovn_strip_lflows | \
> +          grep nf_learn | sort], [0], [dnl
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = false); outport 
> = "sw0-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 102), action=(nf_learn_orig_src_port(ipv6 = true); outport 
> = "sw0-nf-p4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +])
> +
> +AS_BOX([Without any inline-mode NFG attached, no loop-prevention flows])
> +
> +check ovn-nbctl acl-del sw0
> +check ovn-nbctl --wait=sb sync
> +ovn-sbctl dump-flows sw0 > sw0flows
> +
> +AT_CHECK([grep -E 'nf_(learn|lookup)_orig_src_port' sw0flows | wc -l], [0], 
> [0
> +])
> +AT_CHECK([grep -E 'reg8\[[24\]]' sw0flows | wc -l], [0], [0
> +])
> +
> +dnl The ls_in_lookup_fdb flag-set flows persist even with no inline NFG
> +dnl attached: they are per-port and harmless (nothing consumes the flag
> +dnl without an inline NFG).
> +AT_CHECK([grep 'ls_in_lookup_fdb' sw0flows | ovn_strip_lflows | \
> +          grep inport_in_mc_unknown | wc -l], [0], [2
> +])
> +
> +OVN_CLEANUP_NORTHD
> +AT_CLEANUP
> +])
> +
>  OVN_FOR_EACH_NORTHD([
>  AT_SETUP([Network function health check])
>  AT_KEYWORDS([ovn])
> @@ -20291,16 +20527,23 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' lflows | ovn_strip_lflows | sort], 
> [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-1"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 1), action=(outport = "child-1"; 
> output;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 1), action=(outport = "child-1"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-1"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-2"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-2"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-2"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-1"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-2"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "child-2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 1), action=(outport = "child-2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 1), action=(outport = "child-2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
> @@ -20332,16 +20575,23 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' lflows | ovn_strip_lflows | sort], 
> [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-1"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-1"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-1"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-2"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 1), action=(outport = "child-1"; 
> output;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 1), action=(outport = "child-1"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-1"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-2"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-2"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 1), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-2"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-1"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-2"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "child-2"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 1), action=(outport = "child-2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 1), action=(outport = "child-2"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
> @@ -20373,16 +20623,23 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' lflows | ovn_strip_lflows | sort], 
> [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(inport == 
> "child-4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 2), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-3"; output;)
> +  table=??(ls_in_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 2), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-3"; output;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-3"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(inport == 
> "child-4"), action=(reg5[[16..31]] = ct_label.tun_if_id; next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 2), action=(outport = "child-3"; 
> output;)
>    table=??(ls_in_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 2), action=(outport = "child-3"; 
> output;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-3"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(outport == 
> "child-4"), action=(next;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=100  , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 2), action=(nf_learn_orig_src_port(ipv6 = false); outport = 
> "child-4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=100  , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && reg8[[21]] == 1 && reg8[[22]] == 1 && 
> reg0[[22..29]] == 2), action=(nf_learn_orig_src_port(ipv6 = true); outport = 
> "child-4"; reg8[[23]] = 1; next(pipeline=ingress, table=??);)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-3"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(outport == 
> "child-4"), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=2    , match=(inport == 
> "child-4"), action=(reg8[[24]] = nf_lookup_orig_src_port(); next;)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 0 && ct_label.nf_id == 2), action=(outport = "child-4"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>    table=??(ls_out_network_function), priority=99   , match=(reg8[[21]] == 1 
> && reg8[[22]] == 1 && reg0[[22..29]] == 2), action=(outport = "child-4"; 
> reg8[[23]] = 1; next(pipeline=ingress, table=??);)
>  ])
> @@ -20412,10 +20669,12 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' lflows | ovn_strip_lflows | sort], 
> [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
>  ])
>  
>  AS_BOX([Configure NFG fallback method to fail-open])
> @@ -20443,10 +20702,12 @@ AT_CHECK(
>    [grep -E 'ls_(in|out)_network_function' lflows | ovn_strip_lflows | sort], 
> [0], [dnl
>    table=??(ls_in_network_function), priority=0    , match=(1), action=(next;)
>    table=??(ls_in_network_function), priority=1    , match=(reg8[[21]] == 1), 
> action=(drop;)
> -  table=??(ls_in_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip4 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = false); next;)
> +  table=??(ls_in_network_function), priority=50   , match=(ip6 && 
> flags.inport_in_mc_unknown == 1 && !eth.mcast && reg8[[21]] == 0), 
> action=(nf_learn_orig_src_port(ipv6 = true); next;)
>    table=??(ls_out_network_function), priority=0    , match=(1), 
> action=(next;)
>    table=??(ls_out_network_function), priority=1    , match=(reg8[[21]] == 
> 1), action=(drop;)
> -  table=??(ls_out_network_function), priority=100  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
> +  table=??(ls_out_network_function), priority=110  , match=(reg8[[21]] == 1 
> && eth.mcast), action=(next;)
>  ])
>  
>  OVN_CLEANUP_NORTHD
> @@ -20944,7 +21205,7 @@ AT_CHECK([ovn-sbctl dump-flows ls1 > ls1flows])
>  AT_CAPTURE_FILE([ls1flows])
>  
>  AT_CHECK([grep "ls1-to-spine" ls1flows | ovn_strip_lflows | sort], [0], [dnl
> -  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == 
> "ls1-to-spine"), action=(reg0[[11]] = lookup_fdb(inport, eth.src); next;)
> +  table=??(ls_in_lookup_fdb   ), priority=100  , match=(inport == 
> "ls1-to-spine"), action=(flags.inport_in_mc_unknown = 1; reg0[[11]] = 
> lookup_fdb(inport, eth.src); next;)
>    table=??(ls_in_pre_acl      ), priority=110  , match=(ip && inport == 
> "ls1-to-spine"), action=(next;)
>    table=??(ls_in_pre_lb       ), priority=110  , match=(ip && inport == 
> "ls1-to-spine"), action=(next;)
>    table=??(ls_in_put_fdb      ), priority=100  , match=(inport == 
> "ls1-to-spine" && reg0[[11]] == 0), action=(put_fdb(inport, eth.src); next;)
> diff --git a/tests/ovn.at b/tests/ovn.at
> index 29ea90215..f7aeb8c4f 100644
> --- a/tests/ovn.at
> +++ b/tests/ovn.at
> @@ -33530,9 +33530,9 @@ AT_CHECK([grep "ls_in_lookup_fdb" sw0flows | 
> ovn_strip_lflows], [0], [dnl
>    table=??(ls_in_lookup_fdb   ), priority=0    , dnl
>  match=(1), action=(next;)
>    table=??(ls_in_lookup_fdb   ), priority=100  , dnl
> -match=(inport == "sw0-p1"), action=(reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
> +match=(inport == "sw0-p1"), action=(flags.inport_in_mc_unknown = 1; 
> reg0[[11]] = lookup_fdb(inport, eth.src); next;)
>    table=??(ls_in_lookup_fdb   ), priority=100  , dnl
> -match=(inport == "sw0-p3"), action=(reg0[[11]] = lookup_fdb(inport, 
> eth.src); next;)
> +match=(inport == "sw0-p3"), action=(flags.inport_in_mc_unknown = 1; 
> reg0[[11]] = lookup_fdb(inport, eth.src); next;)
>  ])
>  
>  AT_CHECK([grep "ls_in_put_fdb" sw0flows | ovn_strip_lflows], [0], [dnl
> @@ -44775,6 +44775,116 @@ OVN_CLEANUP([hv1],[hv2],[hv3])
>  AT_CLEANUP
>  ])
>  
> +OVN_FOR_EACH_NORTHD([
> +AT_SETUP([Network function -- post-NF re-flood copy to source port is 
> dropped])
> +AT_KEYWORDS([ovn])
> +ovn_start
> +
> +# Single-node, VLAN-backed (localnet) switch.  sw0-p1 and sw0-p2 carry
> +# "unknown" addresses, so they flood unknown-unicast; a to-lport ACL 
> redirects
> +# traffic destined to sw0-p2 to the inline NF.
> +#
> +# sw0-p1 sends to an unknown destination MAC.  The flood copy to sw0-p2 is
> +# redirected to the NF, where sw0-p1 is learned as the source port.  When the
> +# NF returns the packet, the still-unknown MAC makes the switch re-flood; the
> +# copy headed back out sw0-p1 is dropped by the loop-prevention, while sw0-p2
> +# still receives it.
> +
> +check ovn-nbctl ls-add sw0
> +check ovn-nbctl lsp-add sw0 sw0-p1 -- \
> +    lsp-set-addresses sw0-p1 "f0:00:00:00:00:01 192.168.0.11" "unknown"
> +check ovn-nbctl lsp-add sw0 sw0-p2 -- \
> +    lsp-set-addresses sw0-p2 "f0:00:00:00:00:02 192.168.0.12" "unknown"
> +for i in 1 2; do
> +    check ovn-nbctl lsp-add sw0 sw0-nf-p$i -- \
> +        lsp-set-addresses sw0-nf-p$i "f0:00:00:00:01:0$i"
> +done
> +check ovn-nbctl set logical_switch_port sw0-nf-p1 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p2
> +check ovn-nbctl set logical_switch_port sw0-nf-p2 \
> +    options:receive_multicast=false options:lsp_learn_mac=false \
> +    options:is-nf=true options:nf-linked-port=sw0-nf-p1
> +check ovn-nbctl nf-add nf0 1 sw0-nf-p1 sw0-nf-p2
> +check ovn-nbctl nfg-add nfg0 1 inline nf0
> +check ovn-nbctl acl-add sw0 to-lport 1002 'outport == "sw0-p2" && ip4' \
> +    allow-related nfg0
> +
> +# VLAN-backed: add a localnet port.
> +check ovn-nbctl lsp-add-localnet-port sw0 ln0 phys
> +check ovn-nbctl set logical_switch_port ln0 tag_request=100
> +
> +net_add n
> +sim_add hv1
> +as hv1
> +ovs-vsctl add-br br-phys
> +ovs-vsctl set open . external-ids:ovn-bridge-mappings=phys:br-phys
> +ovn_attach n br-phys 192.168.1.1
> +
> +ovs-vsctl add-port br-int vif1 -- \
> +    set interface vif1 external-ids:iface-id=sw0-p1 \
> +    options:tx_pcap=hv1/vif1-tx.pcap options:rxq_pcap=hv1/vif1-rx.pcap
> +ovs-vsctl add-port br-int vif2 -- \
> +    set interface vif2 external-ids:iface-id=sw0-p2 \
> +    options:tx_pcap=hv1/vif2-tx.pcap options:rxq_pcap=hv1/vif2-rx.pcap
> +for i in 1 2; do
> +    ovs-vsctl add-port br-int vif-nf$i -- \
> +        set interface vif-nf$i external-ids:iface-id=sw0-nf-p$i \
> +        options:tx_pcap=hv1/vif-nf$i-tx.pcap \
> +        options:rxq_pcap=hv1/vif-nf$i-rx.pcap
> +done
> +
> +OVN_POPULATE_ARP
> +wait_for_ports_up
> +check ovn-nbctl --wait=hv sync
> +
> +# Match the test packet in the pcaps (src IP 192.168.0.11 followed by dst IP
> +# 192.168.0.12, consecutive only in an IP header).
> +icmp_pkt_filter=c0a8000bc0a8000c
> +
> +# 1. sw0-p1 sends to an unknown destination MAC.  The flooded copy destined 
> to
> +#    sw0-p2 is redirected to the NF output port (sw0-nf-p2); the learn of the
> +#    original source port (sw0-p1) happens on this redirect.
> +packet="inport==\"sw0-p1\" && eth.src==f0:00:00:00:00:01 &&
> +        eth.dst==00:00:00:00:00:99 && ip.ttl==64 && ip4.src==192.168.0.11 &&
> +        ip4.dst==192.168.0.12 && icmp4.type==8 && icmp4.code==0"
> +check as hv1 ovs-appctl -t ovn-controller inject-pkt "$packet"
> +OVS_WAIT_UNTIL([
> +    pkts=`$PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" hv1/vif-nf2-tx.pcap | 
> grep ${icmp_pkt_filter} -c`
> +    test $pkts -ge 1
> +])
> +
> +# 2. The NF returns the packet on its input port (sw0-nf-p1).  The 
> destination
> +#    MAC is still unknown, so the switch re-floods.  The copy headed back out
> +#    sw0-p1 (the original ingress port) must be dropped by the 
> loop-prevention;
> +#    the sibling copy to sw0-p2 is delivered, confirming the re-flood 
> happened.
> +packet="inport==\"sw0-nf-p1\" && eth.src==f0:00:00:00:00:01 &&
> +        eth.dst==00:00:00:00:00:99 && ip.ttl==64 && ip4.src==192.168.0.11 &&
> +        ip4.dst==192.168.0.12 && icmp4.type==8 && icmp4.code==0"
> +check as hv1 ovs-appctl -t ovn-controller inject-pkt "$packet"
> +OVS_WAIT_UNTIL([
> +    pkts=`$PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" hv1/vif2-tx.pcap | 
> grep ${icmp_pkt_filter} -c`
> +    test $pkts -ge 1
> +])
> +
> +# The loop-prevention drop flow (ls_out_check_port_sec, REGBIT_NF_LOOKUP_HIT,
> +# rendered as reg8=0x1000000 -> drop) must have dropped exactly the one
> +# looped-back copy.
> +OVS_WAIT_UNTIL([
> +    hits=`as hv1 ovs-ofctl dump-flows br-int | grep "reg8=0x1000000" | sed 
> -n 's/.*n_packets=\([[0-9]]*\).*/\1/p'`
> +    test 1 -eq $hits

Instead of the rather complex sed here you could just "grep -v
n_packets=0", I think.

> +])
> +
> +# The source port must not receive the looped-back copy.
> +AT_CHECK([
> +    pkts=`$PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" hv1/vif1-tx.pcap | 
> grep ${icmp_pkt_filter} -c`
> +    test 0 -eq $pkts
> +])
> +
> +OVN_CLEANUP([hv1])
> +AT_CLEANUP
> +])
> +
>  OVN_FOR_EACH_NORTHD([
>  AT_SETUP([Unicast ARP when proxy ARP is configured])
>  CHECK_SCAPY

Regards,
Dumitru

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to