[PATCH net-next v2 4/6] net: bridge: allow MDB_FLAGS_STREAM_RESERVED on host groups

From: Luke Howard

Date: Mon Jun 01 2026 - 20:45:24 EST


Allow the local bridge host to declare itself a reserved stream listener
for a MDB group, for example on a device which is both an AVB end station
and bridge.

Only MDB_FLAGS_STREAM_RESERVED is accepted on host groups; the other
MDB_FLAGS_* bits remain port-group-only.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Luke Howard <lukeh@xxxxxxxx>
---
include/uapi/linux/if_bridge.h | 7 +-
net/bridge/br_input.c | 2 +-
net/bridge/br_mdb.c | 21 +++-
net/bridge/br_multicast.c | 37 ++++--
net/bridge/br_private.h | 15 ++-
.../net/forwarding/bridge_mdb_stream_reserved.sh | 125 ++++++++++++++++++++-
6 files changed, 182 insertions(+), 25 deletions(-)

diff --git a/include/uapi/linux/if_bridge.h b/include/uapi/linux/if_bridge.h
index 01955a575528c..989d13a866be4 100644
--- a/include/uapi/linux/if_bridge.h
+++ b/include/uapi/linux/if_bridge.h
@@ -748,9 +748,10 @@ enum {
* [MDBE_ATTR_xxx]
* ...
* [MDBE_ATTR_FLAGS]
- * u32, a mask of MDB_FLAGS_* values to set on the entry. Valid only
- * for port-group entries; currently only MDB_FLAGS_STREAM_RESERVED
- * may be set from user space.
+ * u32, a mask of MDB_FLAGS_* values to set on the entry. Currently
+ * only MDB_FLAGS_STREAM_RESERVED may be set from user space, and is
+ * accepted on both port-group and host-group entries (on the latter
+ * it declares the local bridge host as a reserved-stream listener).
* }
*/
enum {
diff --git a/net/bridge/br_input.c b/net/bridge/br_input.c
index 2e8aa19a9b542..649b819906bf8 100644
--- a/net/bridge/br_input.c
+++ b/net/bridge/br_input.c
@@ -105,7 +105,7 @@ static bool br_sr_admission_denied(const struct net_bridge_port *p,
if (!mdst)
return true;

- if (mdst->flags & BRIDGE_MDBE_F_HOST_STREAM_RESERVED)
+ if ((mdst->flags & BRIDGE_MDBE_F_HOST_MASK) == BRIDGE_MDBE_F_HOST_MASK)
return false;

for (pg = rcu_dereference(mdst->ports); pg;
diff --git a/net/bridge/br_mdb.c b/net/bridge/br_mdb.c
index b95ca72ec6347..93127a8ea54f7 100644
--- a/net/bridge/br_mdb.c
+++ b/net/bridge/br_mdb.c
@@ -250,6 +250,9 @@ static int __mdb_fill_info(struct sk_buff *skb,
} else {
ifindex = mp->br->dev->ifindex;
mtimer = &mp->timer;
+ if (mp->flags & BRIDGE_MDBE_F_HOST_STREAM_RESERVED)
+ flags = MDB_PG_FLAGS_PERMANENT |
+ MDB_PG_FLAGS_STREAM_RESERVED;
}

__mdb_entry_fill_flags(&e, flags);
@@ -1059,7 +1062,10 @@ static int br_mdb_add_group(const struct br_mdb_config *cfg,
return -EEXIST;
}

- br_multicast_host_join(brmctx, mp, false);
+ br_multicast_host_join(brmctx, mp,
+ cfg->pg_flags & MDB_PG_FLAGS_STREAM_RESERVED ?
+ BR_MCAST_SR_SET : BR_MCAST_SR_CLEAR,
+ false);
br_mdb_notify(br->dev, mp, NULL, RTM_NEWMDB);

return 0;
@@ -1219,11 +1225,14 @@ static int br_mdb_config_attrs_init(struct nlattr *set_attrs,
}

if (mdb_attrs[MDBE_ATTR_FLAGS]) {
- if (!cfg->p) {
- NL_SET_ERR_MSG_MOD(extack, "Flags cannot be set for host groups");
+ u32 attr_flags = nla_get_u32(mdb_attrs[MDBE_ATTR_FLAGS]);
+
+ if (!cfg->p && (attr_flags & ~MDB_FLAGS_STREAM_RESERVED)) {
+ NL_SET_ERR_MSG_MOD(extack,
+ "Only stream_reserved may be set on host groups");
return -EINVAL;
}
- if (nla_get_u32(mdb_attrs[MDBE_ATTR_FLAGS]) & MDB_FLAGS_STREAM_RESERVED)
+ if (attr_flags & MDB_FLAGS_STREAM_RESERVED)
cfg->pg_flags |= MDB_PG_FLAGS_STREAM_RESERVED;
}

@@ -1320,8 +1329,8 @@ int br_mdb_add(struct net_device *dev, struct nlattr *tb[], u16 nlmsg_flags,

/* host join errors which can happen before creating the group */
if (!cfg.p && !br_group_is_l2(&cfg.group)) {
- /* don't allow any flags for host-joined IP groups */
- if (cfg.entry->state) {
+ if (cfg.entry->state &&
+ !(cfg.pg_flags & MDB_PG_FLAGS_STREAM_RESERVED)) {
NL_SET_ERR_MSG_MOD(extack, "Flags are not allowed for host groups");
goto out;
}
diff --git a/net/bridge/br_multicast.c b/net/bridge/br_multicast.c
index 4107bf7bd271f..e3fc61bb63092 100644
--- a/net/bridge/br_multicast.c
+++ b/net/bridge/br_multicast.c
@@ -397,10 +397,10 @@ static void br_multicast_sg_host_state(struct net_bridge_mdb_entry *star_mp,
sg_mp = br_mdb_ip_get(star_mp->br, &sg->key.addr);
if (!sg_mp)
return;
- sg_mp->flags |= BRIDGE_MDBE_F_HOST_JOINED;
+ sg_mp->flags |= star_mp->flags & BRIDGE_MDBE_F_HOST_MASK;
}

-/* set the host_joined state of all of *,G's S,G entries */
+/* set the host state of all of *,G's S,G entries */
static void br_multicast_star_g_host_state(struct net_bridge_mdb_entry *star_mp)
{
struct net_bridge *br = star_mp->br;
@@ -425,8 +425,8 @@ static void br_multicast_star_g_host_state(struct net_bridge_mdb_entry *star_mp)
sg_mp = br_mdb_ip_get(br, &sg_ip);
if (!sg_mp)
continue;
- sg_mp->flags &= ~BRIDGE_MDBE_F_HOST_JOINED;
- sg_mp->flags |= star_mp->flags & BRIDGE_MDBE_F_HOST_JOINED;
+ sg_mp->flags &= ~BRIDGE_MDBE_F_HOST_MASK;
+ sg_mp->flags |= star_mp->flags & BRIDGE_MDBE_F_HOST_MASK;
}
}
}
@@ -454,7 +454,7 @@ static void br_multicast_sg_del_exclude_ports(struct net_bridge_mdb_entry *sgmp)
* we treat it as EXCLUDE {}, so for an S,G it's considered a
* STAR_EXCLUDE entry and we can safely leave it
*/
- sgmp->flags &= ~BRIDGE_MDBE_F_HOST_JOINED;
+ sgmp->flags &= ~BRIDGE_MDBE_F_HOST_MASK;

for (pp = &sgmp->ports;
(p = mlock_dereference(*pp, sgmp->br)) != NULL;) {
@@ -1470,10 +1470,18 @@ void br_multicast_del_port_group(struct net_bridge_port_group *p)
}

void br_multicast_host_join(const struct net_bridge_mcast *brmctx,
- struct net_bridge_mdb_entry *mp, bool notify)
+ struct net_bridge_mdb_entry *mp,
+ enum br_mcast_sr_op sr_op, bool notify)
{
- if (!(mp->flags & BRIDGE_MDBE_F_HOST_JOINED)) {
- mp->flags |= BRIDGE_MDBE_F_HOST_JOINED;
+ u8 old_flags = mp->flags;
+
+ mp->flags |= BRIDGE_MDBE_F_HOST_JOINED;
+ if (sr_op == BR_MCAST_SR_SET)
+ mp->flags |= BRIDGE_MDBE_F_HOST_STREAM_RESERVED;
+ else if (sr_op == BR_MCAST_SR_CLEAR)
+ mp->flags &= ~BRIDGE_MDBE_F_HOST_STREAM_RESERVED;
+
+ if ((mp->flags ^ old_flags) & BRIDGE_MDBE_F_HOST_MASK) {
if (br_multicast_is_star_g(&mp->addr))
br_multicast_star_g_host_state(mp);
if (notify)
@@ -1483,6 +1491,14 @@ void br_multicast_host_join(const struct net_bridge_mcast *brmctx,
if (br_group_is_l2(&mp->addr))
return;

+ /* Host stream-reserved entries are permanent and have no timer; drop
+ * any timer left from an earlier non-reserved host join.
+ */
+ if (mp->flags & BRIDGE_MDBE_F_HOST_STREAM_RESERVED) {
+ timer_delete(&mp->timer);
+ return;
+ }
+
mod_timer(&mp->timer, jiffies + brmctx->multicast_membership_interval);
}

@@ -1491,7 +1507,8 @@ void br_multicast_host_leave(struct net_bridge_mdb_entry *mp, bool notify)
if (!(mp->flags & BRIDGE_MDBE_F_HOST_JOINED))
return;

- mp->flags &= ~BRIDGE_MDBE_F_HOST_JOINED;
+ mp->flags &= ~(BRIDGE_MDBE_F_HOST_JOINED |
+ BRIDGE_MDBE_F_HOST_STREAM_RESERVED);
if (br_multicast_is_star_g(&mp->addr))
br_multicast_star_g_host_state(mp);
if (notify)
@@ -1520,7 +1537,7 @@ __br_multicast_add_group(struct net_bridge_mcast *brmctx,
return ERR_CAST(mp);

if (!pmctx) {
- br_multicast_host_join(brmctx, mp, true);
+ br_multicast_host_join(brmctx, mp, BR_MCAST_SR_KEEP, true);
goto out;
}

diff --git a/net/bridge/br_private.h b/net/bridge/br_private.h
index 4ae050ae4826e..fbb7a8156f347 100644
--- a/net/bridge/br_private.h
+++ b/net/bridge/br_private.h
@@ -375,6 +375,18 @@ struct net_bridge_port_group {

#define BRIDGE_MDBE_F_HOST_JOINED BIT(0)
#define BRIDGE_MDBE_F_HOST_STREAM_RESERVED BIT(1)
+#define BRIDGE_MDBE_F_HOST_MASK \
+ (BRIDGE_MDBE_F_HOST_JOINED | BRIDGE_MDBE_F_HOST_STREAM_RESERVED)
+
+/* How a host join treats BRIDGE_MDBE_F_HOST_STREAM_RESERVED. Only the MDB
+ * netlink path administers the flag (SET/CLEAR); data-path joins must leave an
+ * existing reservation intact (KEEP).
+ */
+enum br_mcast_sr_op {
+ BR_MCAST_SR_KEEP,
+ BR_MCAST_SR_CLEAR,
+ BR_MCAST_SR_SET,
+};

struct net_bridge_mdb_entry {
struct rhash_head rhnode;
@@ -1049,7 +1061,8 @@ int br_mdb_dump(struct net_device *dev, struct sk_buff *skb,
int br_mdb_get(struct net_device *dev, struct nlattr *tb[], u32 portid, u32 seq,
struct netlink_ext_ack *extack);
void br_multicast_host_join(const struct net_bridge_mcast *brmctx,
- struct net_bridge_mdb_entry *mp, bool notify);
+ struct net_bridge_mdb_entry *mp,
+ enum br_mcast_sr_op sr_op, bool notify);
void br_multicast_host_leave(struct net_bridge_mdb_entry *mp, bool notify);
void br_multicast_star_g_handle_mode(struct net_bridge_port_group *pg,
u8 filter_mode);
diff --git a/tools/testing/selftests/net/forwarding/bridge_mdb_stream_reserved.sh b/tools/testing/selftests/net/forwarding/bridge_mdb_stream_reserved.sh
index a21dc2ec3e95c..4c5933455037a 100755
--- a/tools/testing/selftests/net/forwarding/bridge_mdb_stream_reserved.sh
+++ b/tools/testing/selftests/net/forwarding/bridge_mdb_stream_reserved.sh
@@ -30,6 +30,8 @@
ALL_TESTS="
cfg_test
fwd_sr_member_test
+ fwd_sr_host_member_test
+ fwd_sr_host_persistence_test
fwd_foreign_blocked_test
fwd_unicast_blocked_test
fwd_flag_gates_test
@@ -217,11 +219,43 @@ cfg_test()
bridge mdb add dev br0 port $swp2 grp $GRP vid $VID \
stream_reserved 2>/dev/null
check_fail $? "non-permanent stream_reserved port entry accepted"
-
- # The flag must be rejected on host groups.
- bridge mdb add dev br0 port br0 grp $GRP permanent vid $VID \
+ bridge mdb add dev br0 port br0 grp $GRP vid $VID \
stream_reserved 2>/dev/null
- check_fail $? "stream_reserved accepted on a host group"
+ check_fail $? "non-permanent stream_reserved host group accepted"
+
+ # A plain (non-SR) host join is still accepted, must not be permanent,
+ # and toggles cleanly with stream_reserved on replace.
+ bridge mdb add dev br0 port br0 grp $GRP vid $VID
+ check_err $? "plain host join rejected"
+ bridge mdb add dev br0 port br0 grp $GRP permanent vid $VID 2>/dev/null
+ check_fail $? "permanent flag accepted on a plain host group"
+ bridge -d mdb show dev br0 | grep "port br0" | grep "$GRP" | \
+ grep -q "stream_reserved"
+ check_fail $? "stream_reserved unexpectedly set on a plain host join"
+ bridge mdb replace dev br0 port br0 grp $GRP permanent vid $VID \
+ stream_reserved
+ check_err $? "Failed to replace plain host join with stream_reserved"
+ bridge mdb replace dev br0 port br0 grp $GRP vid $VID
+ check_err $? "Failed to replace stream_reserved host group with plain"
+ bridge -d mdb show dev br0 | grep "port br0" | grep "$GRP" | \
+ grep -q "stream_reserved"
+ check_fail $? "stream_reserved not cleared on host group replace"
+ bridge mdb del dev br0 port br0 grp $GRP vid $VID
+
+ # permanent + stream_reserved is accepted on host groups and the
+ # entry is dumped as both permanent and stream_reserved.
+ bridge mdb add dev br0 port br0 grp $GRP permanent vid $VID \
+ stream_reserved
+ check_err $? "stream_reserved rejected on a host group"
+ bridge -d mdb show dev br0 | grep "port br0" | grep "$GRP" | \
+ grep -q "stream_reserved"
+ check_err $? "stream_reserved flag not shown on host group"
+ bridge -d mdb get dev br0 grp $GRP vid $VID | grep -q permanent
+ check_err $? "host stream_reserved entry not reported as permanent"
+ bridge -d -s mdb get dev br0 grp $GRP vid $VID | grep "port br0" | \
+ grep -q " 0.00"
+ check_err $? "host stream_reserved entry has a pending group timer"
+ bridge mdb del dev br0 port br0 grp $GRP permanent vid $VID

# Add a port group with the flag and confirm it is reflected in dump.
bridge mdb add dev br0 port $swp2 grp $GRP permanent vid $VID \
@@ -328,6 +362,89 @@ fwd_sr_member_test()
log_test "MDB stream_reserved member delivery"
}

+# An SR-class frame for a group the local bridge host has joined with
+# stream_reserved is delivered to the host (passed up via br0); without the
+# flag set on the host join, the same frame is denied at ingress.
+fwd_sr_host_member_test()
+{
+ RET=0
+
+ tc qdisc add dev br0 clsact
+ sr_filter $swp1 on
+
+ # Host join WITHOUT stream_reserved: SR-class frame must be dropped.
+ # A plain host-joined IP group cannot be permanent.
+ bridge mdb add dev br0 port br0 grp $GRP vid $VID
+ rx_filter_install br0 6 $GRP
+
+ send_mc $GRP $GRP_DMAC $SR_PCP
+ tc_check_packets "dev br0 ingress" 6 0
+ check_err $? "SR-class frame delivered to host without stream_reserved"
+
+ send_mc $GRP $GRP_DMAC $BE_PCP
+ tc_check_packets "dev br0 ingress" 6 1
+ check_err $? "best-effort frame not delivered to host"
+
+ # Replace host join WITH stream_reserved: SR-class frame admitted.
+ bridge mdb replace dev br0 port br0 grp $GRP permanent vid $VID \
+ stream_reserved
+ check_err $? "Failed to replace host group with stream_reserved"
+
+ send_mc $GRP $GRP_DMAC $SR_PCP
+ tc_check_packets "dev br0 ingress" 6 2
+ check_err $? "reserved-stream SR-class frame not delivered to host"
+
+ rx_filter_uninstall br0 6
+ bridge mdb del dev br0 port br0 grp $GRP permanent vid $VID
+ sr_filter $swp1 off
+ tc qdisc del dev br0 clsact
+
+ log_test "MDB stream_reserved host listener delivery"
+}
+
+# A permanent + stream_reserved host group has no group timer and must
+# outlive the membership interval, even when promoted from a plain (timer
+# armed) host join, which must not leave a stale group timer behind.
+fwd_sr_host_persistence_test()
+{
+ RET=0
+
+ ip link set dev br0 type bridge mcast_membership_interval 200
+
+ bridge mdb add dev br0 port br0 grp $GRP permanent vid $VID \
+ stream_reserved
+ check_err $? "Failed to add permanent stream_reserved host group"
+
+ sleep 3
+
+ bridge mdb get dev br0 grp $GRP vid $VID &>/dev/null
+ check_err $? "host stream_reserved entry expired"
+
+ bridge mdb del dev br0 port br0 grp $GRP permanent vid $VID
+
+ # A plain host join arms the group timer; promoting it to
+ # stream_reserved must cancel that timer, otherwise the reservation is
+ # torn down when the stale timer expires.
+ bridge mdb add dev br0 port br0 grp $GRP vid $VID
+ check_err $? "plain host join rejected"
+ bridge mdb replace dev br0 port br0 grp $GRP permanent vid $VID \
+ stream_reserved
+ check_err $? "Failed to promote plain host join to stream_reserved"
+ bridge -d -s mdb get dev br0 grp $GRP vid $VID | grep "port br0" | \
+ grep -q " 0.00"
+ check_err $? "stale group timer left after promotion to stream_reserved"
+
+ sleep 3
+
+ bridge mdb get dev br0 grp $GRP vid $VID &>/dev/null
+ check_err $? "promoted stream_reserved entry expired"
+
+ bridge mdb del dev br0 port br0 grp $GRP permanent vid $VID
+ ip link set dev br0 type bridge mcast_membership_interval 26000
+
+ log_test "MDB stream_reserved host entry persistence"
+}
+
# swp1 filters SR-class ingress. A foreign (non-reserved) group GRP2 at SR class
# is dropped at ingress, reaching neither listener, while a best-effort (TC 0)
# frame is admitted and delivered to both.

--
2.43.0