commit c97f1e322e22a621277681dfcc6d73cdb19046b4
Author: Vjaceslavs Klimovs <vklimovs@gmail.com>
Date:   Mon May 11 14:50:39 2026 -0700

    bridge: full IFLA_BR_* support, filter defaults from show

diff --git a/libifstate/__init__.py b/libifstate/__init__.py
index 797ef7b..585fa75 100644
--- a/libifstate/__init__.py
+++ b/libifstate/__init__.py
@@ -1,5 +1,6 @@
 from libifstate.exception import LinkDuplicate, NetnsUnknown
 from libifstate.link.base import ethtool_path, Link
+from libifstate.link import bridge as br
 from libifstate.link.vlan import VlanFlags
 from libifstate.address import Addresses, AddressIgnore
 from libifstate.fdb import FDB
@@ -57,6 +58,7 @@ import logging
 
 __version__ = "2.3.0"
 
+
 class IfState():
     def __init__(self):
         logger.debug('IfState {}'.format(__version__))
@@ -817,16 +819,31 @@ class IfState():
                                         vlan_flags[flag.name] = enabled
                                 if vlan_flags:
                                     ifs_link['link']['vlan_flags'] = vlan_flags
+                            # split br_multi_boolopt into individual bridge boolean options
+                            elif k == 'IFLA_BR_MULTI_BOOLOPT':
+                                for boolopt_attr, bo_val in br.split_multi_boolopt(v).items():
+                                    if showall or not br.is_schema_default(boolopt_attr, bo_val):
+                                        ifs_link['link'][boolopt_attr] = bo_val
                             # add scalar attributes
                             elif k != 'UNKNOWN':
                                 attr = ipr_link.nla2name(k)
+                                if not showall and kind == 'bridge' \
+                                        and attr in br.BR_READONLY_ATTRS:
+                                    continue
                                 if attr in Link.attr_value_maps:
-                                    ifs_link['link'][attr] = Link.attr_value_maps[attr].get(
-                                        v, v)
+                                    mapped = Link.attr_value_maps[attr].get(v, v)
                                 elif attr in Link.attr_value_lookup:
-                                    ifs_link['link'][attr] = Link.attr_value_lookup[attr].lookup_str(v)
+                                    mapped = Link.attr_value_lookup[attr].lookup_str(v)
                                 else:
-                                    ifs_link['link'][attr] = v
+                                    mapped = v
+                                if kind == 'bridge':
+                                    # kernel returns uint8 0/1 for `type: boolean`
+                                    # attrs; coerce so the dump validates as YAML
+                                    if type(mapped) is int and attr in br.bridge_boolean_attrs():
+                                        mapped = bool(mapped)
+                                    if not showall and br.is_schema_default(attr, mapped):
+                                        continue
+                                ifs_link['link'][attr] = mapped
                 else:
                     ifs_link['link']['kind'] = 'physical'
                     addr = ipr_link.get_attr('IFLA_ADDRESS')
diff --git a/libifstate/link/__init__.py b/libifstate/link/__init__.py
index 174dd44..d75b82b 100644
--- a/libifstate/link/__init__.py
+++ b/libifstate/link/__init__.py
@@ -1,4 +1,5 @@
 import libifstate.link.base
+import libifstate.link.bridge
 import libifstate.link.dsa
 import libifstate.link.physical
 import libifstate.link.tun
diff --git a/libifstate/link/bridge.py b/libifstate/link/bridge.py
new file mode 100644
index 0000000..6fbb3f4
--- /dev/null
+++ b/libifstate/link/bridge.py
@@ -0,0 +1,258 @@
+from libifstate.link.base import Link
+
+import functools
+import gzip
+import json
+import os
+import pkgutil
+import re
+
+from pyroute2.netlink import nla
+from pyroute2.netlink.rtnl.ifinfmsg import ifinfmsg
+
+
+# bit positions in br_boolopt_multi (linux/if_bridge.h enum br_boolopt_id)
+_BR_BOOLOPTS = {
+    'br_no_linklocal_learn': 0,
+    'br_mcast_vlan_snooping': 1,
+    'br_mst_enabled': 2,
+}
+
+# read-only / runtime bridge attrs the kernel emits but the user can't set.
+# Filtered from `ifstate show` (showall still dumps them for debugging).
+BR_READONLY_ATTRS = frozenset([
+    'br_root_id',
+    'br_bridge_id',
+    'br_root_port',
+    'br_root_path_cost',
+    'br_topology_change',
+    'br_topology_change_detected',
+    'br_hello_timer',
+    'br_tcn_timer',
+    'br_topology_change_timer',
+    'br_gc_timer',
+    'br_pad',
+    'br_fdb_n_learned',
+    'br_mcast_querier_state',
+])
+
+# attrs the kernel rounds USER_HZ -> jiffies -> USER_HZ. The conversion is
+# lossy when (TICK_NSEC % 1e7) != 0 (HZ=300, 250, ...), so a fresh set reads
+# back a smaller value and re-apply would chase its tail. BridgeLink tolerates
+# the predicted rounding loss in get_if_attr.
+_BR_USER_HZ_ATTRS = frozenset([
+    'br_forward_delay',
+    'br_hello_time',
+    'br_max_age',
+    'br_ageing_time',
+    'br_mcast_last_member_intvl',
+    'br_mcast_membership_intvl',
+    'br_mcast_querier_intvl',
+    'br_mcast_query_intvl',
+    'br_mcast_query_response_intvl',
+    'br_mcast_startup_query_intvl',
+])
+
+_USER_HZ = os.sysconf('SC_CLK_TCK')
+
+
+@functools.lru_cache(maxsize=1)
+def _load_schema():
+    # Match libifstate/__init__.py's loading pattern: the major version
+    # of ifstate selects the schema directory. Cached + lazy so this module
+    # doesn't depend on libifstate.__version__ being defined at import time.
+    import libifstate  # noqa: PLC0415  late-bound to avoid circular import
+    return json.loads(pkgutil.get_data(
+        "libifstate",
+        "schema/{}/ifstate.conf.schema.json".format(
+            libifstate.__version__.split('.')[0])))
+
+
+def _detect_kernel_hz():
+    for opener, path in (
+        (gzip.open, '/proc/config.gz'),
+        (open, '/boot/config-{}'.format(os.uname().release)),
+    ):
+        try:
+            with opener(path, 'rt') as fh:
+                for line in fh:
+                    m = re.match(r'CONFIG_HZ=(\d+)', line)
+                    if m:
+                        return int(m.group(1))
+        except (OSError, EOFError):
+            continue
+    return None
+
+
+_KERNEL_HZ = _detect_kernel_hz()
+
+
+@functools.lru_cache(maxsize=1)
+def _bridge_variant_props():
+    # The bridge variant lives deep in the schema (under a regex-named
+    # patternProperties key and a positional oneOf index, both volatile),
+    # so walk iteratively for any oneOf whose kind.const == 'bridge'.
+    try:
+        schema = _load_schema()
+    except (OSError, ValueError):
+        return None
+    stack = [schema]
+    while stack:
+        sub = stack.pop()
+        if isinstance(sub, dict):
+            if isinstance(sub.get('oneOf'), list):
+                for variant in sub['oneOf']:
+                    kind = variant.get('properties', {}).get('kind', {})
+                    if kind.get('const') == 'bridge':
+                        return variant['properties']
+            stack.extend(sub.values())
+        elif isinstance(sub, list):
+            stack.extend(sub)
+    return None
+
+
+@functools.lru_cache(maxsize=1)
+def _bridge_schema_defaults():
+    props = _bridge_variant_props() or {}
+    return {
+        attr: spec['default']
+        for attr, spec in props.items()
+        if isinstance(spec, dict) and 'default' in spec
+    }
+
+
+@functools.lru_cache(maxsize=1)
+def bridge_boolean_attrs():
+    """Bridge attrs whose schema type is `boolean`. The kernel returns these
+    as uint8 0/1; show coerces to Python bool so the dump validates as YAML."""
+    props = _bridge_variant_props() or {}
+    return frozenset(
+        attr for attr, spec in props.items()
+        if isinstance(spec, dict) and spec.get('type') == 'boolean'
+    )
+
+
+def _kernel_round(user_value):
+    # Predict what the kernel will report after USER_HZ -> jiffies -> USER_HZ
+    # round-trip. Returns None when HZ is unknown or the math doesn't apply.
+    if _KERNEL_HZ is None or type(user_value) is not int:
+        return None
+    if _KERNEL_HZ % _USER_HZ != 0:
+        return None
+    tick_nsec = (10 ** 9 + _KERNEL_HZ // 2) // _KERNEL_HZ
+    jiffies = user_value * (_KERNEL_HZ // _USER_HZ)
+    return (jiffies * tick_nsec) // (10 ** 9 // _USER_HZ)
+
+
+def split_multi_boolopt(struct):
+    """Decode IFLA_BR_MULTI_BOOLOPT ({optval, optmask}) into a dict of
+    {attr: bool} for each bit the kernel reports."""
+    if not isinstance(struct, dict):
+        return {}
+    optval = struct.get('optval', 0)
+    optmask = struct.get('optmask', 0)
+    return {
+        attr: bool(optval & (1 << bit))
+        for attr, bit in _BR_BOOLOPTS.items()
+        if optmask & (1 << bit)
+    }
+
+
+def is_schema_default(attr, value):
+    """True iff `value` matches the schema default for bridge `attr`.
+
+    USER_HZ timer attrs are matched against both the schema default and
+    its kernel-rounded variant (e.g. 1500 ≡ 1499 on HZ=300).
+    """
+    defaults = _bridge_schema_defaults()
+    if attr not in defaults:
+        return False
+    default = defaults[attr]
+    if value == default:
+        return True
+    if attr in _BR_USER_HZ_ATTRS and type(default) is int:
+        rounded = _kernel_round(default)
+        if rounded is not None and value == rounded:
+            return True
+    return False
+
+
+def _patch_bridge_nla_map():
+    """Extend pyroute2's bridge_data nla_map with attrs added to the kernel
+    after pyroute2 0.9.5 was cut. Mutates pyroute2 globally — every
+    IPRoute() in this Python process sees the extended map.
+
+    pyroute2 derives NLA type ids from position in nla_map; the order of
+    `extras` MUST match the kernel's IFLA_BR_* enum or every set/dump
+    will misroute. To stay forward-compatible, we patch all-or-nothing:
+    if pyroute2 already knows ANY of these attrs we leave it alone
+    (assume it has them all, in the right positions).
+    """
+    bd = ifinfmsg.ifinfo.bridge_data
+
+    # order is the kernel's IFLA_BR_* enum from VLAN_STATS_PER_PORT onward
+    extras = (
+        ('IFLA_BR_VLAN_STATS_PER_PORT', 'uint8'),
+        ('IFLA_BR_MULTI_BOOLOPT', 'br_multi_boolopt'),
+        ('IFLA_BR_MCAST_QUERIER_STATE', 'hex'),
+        ('IFLA_BR_FDB_N_LEARNED', 'uint32'),
+        ('IFLA_BR_FDB_MAX_LEARNED', 'uint32'),
+    )
+    existing = {row[0] for row in bd.nla_map}
+    if any(name in existing for name, _ in extras):
+        return
+
+    class br_multi_boolopt(nla):
+        fields = (('optval', 'I'), ('optmask', 'I'))
+
+    bd.br_multi_boolopt = br_multi_boolopt
+    bd.nla_map = tuple(bd.nla_map) + extras
+
+    # invalidate pyroute2's cached nla_map so it recompiles on next use
+    for attr_name in [a for a in vars(bd) if 'compiled' in a]:
+        setattr(bd, attr_name, False)
+
+
+_patch_bridge_nla_map()
+
+
+class BridgeLink(Link):
+    def __init__(self, ifstate, netns, name, link, identify, ethtool, hooks,
+                 vrrp, brport, brvlan):
+        super().__init__(ifstate, netns, name, link, identify, ethtool, hooks,
+                         vrrp, brport, brvlan)
+
+        # collapse user-facing boolopt bools into the packed kernel struct
+        optval = 0
+        optmask = 0
+        for key, bit in _BR_BOOLOPTS.items():
+            if key in self.settings:
+                if self.settings.pop(key):
+                    optval |= 1 << bit
+                optmask |= 1 << bit
+        if optmask:
+            self.settings['br_multi_boolopt'] = {
+                'optval': optval,
+                'optmask': optmask,
+            }
+
+    def get_if_attr(self, key):
+        if key == 'br_multi_boolopt':
+            v = super().get_if_attr(key)
+            if not isinstance(v, dict):
+                return None
+            settings_mask = self.settings.get(
+                'br_multi_boolopt', {}).get('optmask', 0)
+            return {
+                'optval': v.get('optval', 0) & settings_mask,
+                'optmask': settings_mask,
+            }
+        if key in _BR_USER_HZ_ATTRS:
+            kernel_val = super().get_if_attr(key)
+            user_val = self.settings.get(key)
+            if user_val is not None and kernel_val is not None:
+                predicted = _kernel_round(user_val)
+                if predicted is not None and predicted == kernel_val:
+                    return user_val
+            return kernel_val
+        return super().get_if_attr(key)
diff --git a/schema/2/ifstate.conf.schema.json b/schema/2/ifstate.conf.schema.json
index 5325722..244a1c3 100644
--- a/schema/2/ifstate.conf.schema.json
+++ b/schema/2/ifstate.conf.schema.json
@@ -3032,12 +3032,55 @@
                                         "ifalias": {
                                             "$ref": "#/$defs/iface-link_ifalias"
                                         },
+                                        "br_forward_delay": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 1500,
+                                            "description": "STP forward delay in 1/100 seconds (USER_HZ); kernel may round to nearest jiffy"
+                                        },
+                                        "br_hello_time": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 200,
+                                            "description": "STP hello time in 1/100 seconds (USER_HZ); kernel may round to nearest jiffy"
+                                        },
+                                        "br_max_age": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 2000,
+                                            "description": "STP max age in 1/100 seconds (USER_HZ); kernel may round to nearest jiffy"
+                                        },
                                         "br_ageing_time": {
                                             "type": "integer",
                                             "minimum": 0,
                                             "default": 30000,
                                             "description": "FDB entry ageing time in milliseconds"
                                         },
+                                        "br_stp_state": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "enable spanning tree protocol"
+                                        },
+                                        "br_priority": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "maximum": 65535,
+                                            "default": 32768,
+                                            "description": "STP bridge priority (high 4 bits used for root election)"
+                                        },
+                                        "br_group_fwd_mask": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "maximum": 65535,
+                                            "default": 0,
+                                            "description": "bitmask of group-addressed link-local frames to forward"
+                                        },
+                                        "br_group_addr": {
+                                            "type": "string",
+                                            "pattern": "^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$",
+                                            "default": "01:80:c2:00:00:00",
+                                            "description": "MAC address the bridge listens on for STP/LLDP-style frames"
+                                        },
                                         "br_vlan_protocol": {
                                             "enum": [
                                                 33024,
@@ -3069,6 +3112,143 @@
                                             "maximum": 4094,
                                             "default": 1,
                                             "description": "bridge default PVID"
+                                        },
+                                        "br_mcast_snooping": {
+                                            "type": "boolean",
+                                            "default": true,
+                                            "description": "enable IGMP/MLD snooping"
+                                        },
+                                        "br_mcast_router": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "maximum": 2,
+                                            "default": 1,
+                                            "description": "multicast router state: 0=disabled, 1=auto (default), 2=permanently enabled"
+                                        },
+                                        "br_mcast_query_use_ifaddr": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "use bridge interface address as IGMP/MLD querier source instead of 0.0.0.0/::"
+                                        },
+                                        "br_mcast_querier": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "send IGMP/MLD queries from this bridge"
+                                        },
+                                        "br_mcast_hash_elasticity": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 16,
+                                            "description": "multicast group hash chain elasticity (deprecated on newer kernels)"
+                                        },
+                                        "br_mcast_hash_max": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 4096,
+                                            "description": "multicast group hash table size cap"
+                                        },
+                                        "br_mcast_last_member_cnt": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 2,
+                                            "description": "number of queries sent on last-member departure before forgetting the group"
+                                        },
+                                        "br_mcast_startup_query_cnt": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 2,
+                                            "description": "number of queries sent during startup interval"
+                                        },
+                                        "br_mcast_last_member_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 100,
+                                            "description": "last-member query interval in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_membership_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 26000,
+                                            "description": "multicast membership interval in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_querier_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 25500,
+                                            "description": "other-querier present interval in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_query_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 12500,
+                                            "description": "general query interval in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_query_response_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 1000,
+                                            "description": "maximum response delay in queries in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_startup_query_intvl": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 3125,
+                                            "description": "startup query interval in 1/100 seconds (USER_HZ)"
+                                        },
+                                        "br_mcast_stats_enabled": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "enable multicast (IGMP/MLD) per-port stats accounting"
+                                        },
+                                        "br_mcast_igmp_version": {
+                                            "type": "integer",
+                                            "minimum": 2,
+                                            "maximum": 3,
+                                            "default": 2,
+                                            "description": "IGMP protocol version (2 or 3)"
+                                        },
+                                        "br_mcast_mld_version": {
+                                            "type": "integer",
+                                            "minimum": 1,
+                                            "maximum": 2,
+                                            "default": 1,
+                                            "description": "MLD protocol version (1 or 2)"
+                                        },
+                                        "br_nf_call_iptables": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "pass bridged IPv4 frames through iptables (br_netfilter must be loaded)"
+                                        },
+                                        "br_nf_call_ip6tables": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "pass bridged IPv6 frames through ip6tables (br_netfilter must be loaded)"
+                                        },
+                                        "br_nf_call_arptables": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "pass bridged ARP frames through arptables (br_netfilter must be loaded)"
+                                        },
+                                        "br_no_linklocal_learn": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "disable learning source MACs from link-local (e.g. STP) frames"
+                                        },
+                                        "br_mcast_vlan_snooping": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "enable per-VLAN multicast snooping (requires br_vlan_filtering)"
+                                        },
+                                        "br_mst_enabled": {
+                                            "type": "boolean",
+                                            "default": false,
+                                            "description": "enable Multiple Spanning Tree on this bridge"
+                                        },
+                                        "br_fdb_max_learned": {
+                                            "type": "integer",
+                                            "minimum": 0,
+                                            "default": 0,
+                                            "description": "cap on dynamically learned FDB entries (0 = unlimited)"
                                         }
                                     }
                                 },
