include more link layer mirrors
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-03-09 16:13:53 +01:00
parent 6534f2c4fa
commit 3f0e3c5400
4 changed files with 255 additions and 14 deletions

View File

@@ -100,6 +100,11 @@ class BridgeMemberLinkStateInfo(BaseModel):
admin_up: Optional[bool] = Field(None, description="Whether the interface has IFF_UP set.") admin_up: Optional[bool] = Field(None, description="Whether the interface has IFF_UP set.")
carrier_up: Optional[bool] = Field(None, description="Whether the interface currently reports carrier.") carrier_up: Optional[bool] = Field(None, description="Whether the interface currently reports carrier.")
operstate: Optional[str] = Field(None, description="Kernel operational state string.") operstate: Optional[str] = Field(None, description="Kernel operational state string.")
mtu: Optional[int] = Field(None, description="Current interface MTU.")
ethernet_profile: Optional[Dict[str, Any]] = Field(
None,
description="Current speed/duplex/autoneg profile when available.",
)
link_ready: bool = Field(..., description="Whether the member currently looks usable for forwarding.") link_ready: bool = Field(..., description="Whether the member currently looks usable for forwarding.")
suppressed: bool = Field(..., description="Whether the watcher administratively suppressed this member.") suppressed: bool = Field(..., description="Whether the watcher administratively suppressed this member.")
@@ -115,6 +120,10 @@ class BridgeLinkStateWatcherStatus(BaseModel):
last_action: Optional[str] = Field(None, description="Most recent propagation action.") last_action: Optional[str] = Field(None, description="Most recent propagation action.")
suppressed_members: List[str] = Field(default_factory=list, description="Members currently forced down by the watcher.") suppressed_members: List[str] = Field(default_factory=list, description="Members currently forced down by the watcher.")
recovery_holdoff_seconds: Optional[float] = Field(None, description="Configured recovery holdoff before restoring siblings.") recovery_holdoff_seconds: Optional[float] = Field(None, description="Configured recovery holdoff before restoring siblings.")
degraded_recheck_seconds: Optional[float] = Field(
None,
description="Low-rate fallback recheck interval while the bridge is degraded.",
)
members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.") members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.")
message: Optional[str] = Field(None, description="Optional informational message.") message: Optional[str] = Field(None, description="Optional informational message.")

View File

@@ -1,10 +1,11 @@
"""Event-driven watcher that propagates bridge member link failures to sibling ports.""" """Event-driven watcher that propagates bridge member failures and selected link settings."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
import select import select
import subprocess
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -18,20 +19,42 @@ from src.utilities.interface_bridge_helpers import (
get_bridge_ports_once, get_bridge_ports_once,
read_interface_admin_up, read_interface_admin_up,
read_interface_carrier, read_interface_carrier,
read_interface_mtu,
read_interface_operstate, read_interface_operstate,
) )
logger = logging.getLogger("bridge_link_state_manager") logger = logging.getLogger("bridge_link_state_manager")
_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool"
@dataclass(frozen=True)
class EthernetProfile:
"""Subset of ethtool settings that can be mirrored across peer ports."""
speed_mbps: Optional[int]
duplex: Optional[str]
autoneg: Optional[bool]
def to_dict(self) -> Dict[str, Any]:
"""Serialize the profile for API responses."""
return {
"speed_mbps": self.speed_mbps,
"duplex": self.duplex,
"autoneg": self.autoneg,
}
@dataclass @dataclass
class MemberLinkState: class MemberLinkState:
"""Current sysfs-derived state for one bridge member.""" """Current state for one bridge member."""
ifname: str ifname: str
admin_up: Optional[bool] admin_up: Optional[bool]
carrier_up: Optional[bool] carrier_up: Optional[bool]
operstate: Optional[str] operstate: Optional[str]
mtu: Optional[int]
ethernet_profile: Optional[EthernetProfile]
@property @property
def link_ready(self) -> bool: def link_ready(self) -> bool:
@@ -51,6 +74,8 @@ class MemberLinkState:
"admin_up": self.admin_up, "admin_up": self.admin_up,
"carrier_up": self.carrier_up, "carrier_up": self.carrier_up,
"operstate": self.operstate, "operstate": self.operstate,
"mtu": self.mtu,
"ethernet_profile": self.ethernet_profile.to_dict() if self.ethernet_profile is not None else None,
"link_ready": self.link_ready, "link_ready": self.link_ready,
"suppressed": suppressed, "suppressed": suppressed,
} }
@@ -75,6 +100,7 @@ class BridgeLinkStateWatcher:
self._running = False self._running = False
self._suppressed_members: dict[str, bool] = {} self._suppressed_members: dict[str, bool] = {}
self._settle_deadlines: dict[str, float] = {} self._settle_deadlines: dict[str, float] = {}
self._managed_event_deadlines: dict[str, float] = {}
self._all_clear_since: Optional[float] = None self._all_clear_since: Optional[float] = None
self._degraded = False self._degraded = False
self._last_event_ts: Optional[float] = None self._last_event_ts: Optional[float] = None
@@ -163,12 +189,13 @@ class BridgeLinkStateWatcher:
continue continue
if any(msg.get("event") in {"RTM_NEWLINK", "RTM_DELLINK"} for msg in messages): if any(msg.get("event") in {"RTM_NEWLINK", "RTM_DELLINK"} for msg in messages):
self._evaluate_bridge_state(reason="netlink_event") source_ifname = self._pick_source_interface(messages)
self._evaluate_bridge_state(reason="netlink_event", source_ifname=source_ifname)
continue continue
self._evaluate_bridge_state(reason="recovery_deadline") self._evaluate_bridge_state(reason="recovery_deadline")
def _evaluate_bridge_state(self, reason: str) -> None: def _evaluate_bridge_state(self, reason: str, source_ifname: Optional[str] = None) -> None:
members = [iface for iface in get_bridge_ports_once(self.bridge_name) if check_interface_exists(iface)] members = [iface for iface in get_bridge_ports_once(self.bridge_name) if check_interface_exists(iface)]
states = {iface: self._read_member_state(iface) for iface in members} states = {iface: self._read_member_state(iface) for iface in members}
now = time.time() now = time.time()
@@ -187,6 +214,11 @@ class BridgeLinkStateWatcher:
for iface, deadline in self._settle_deadlines.items() for iface, deadline in self._settle_deadlines.items()
if iface in states and deadline > now if iface in states and deadline > now
} }
self._managed_event_deadlines = {
iface: deadline
for iface, deadline in self._managed_event_deadlines.items()
if iface in states and deadline > now
}
if len(states) < 2: if len(states) < 2:
with self._lock: with self._lock:
@@ -197,9 +229,7 @@ class BridgeLinkStateWatcher:
with self._lock: with self._lock:
suppressed_snapshot = set(self._suppressed_members) suppressed_snapshot = set(self._suppressed_members)
settling_snapshot = { settling_snapshot = {iface for iface, deadline in self._settle_deadlines.items() if deadline > now}
iface for iface, deadline in self._settle_deadlines.items() if deadline > now
}
failing_members = sorted( failing_members = sorted(
ifname ifname
@@ -233,17 +263,42 @@ class BridgeLinkStateWatcher:
self._degraded = False self._degraded = False
self._all_clear_since = None self._all_clear_since = None
self._restore_suppressed_members(reason=reason) if source_ifname in states and self._is_source_eligible(source_ifname, states):
self._sync_member_configuration(source_ifname, states)
return
self._set_last_action(f"no_restore_needed ({reason})")
def _pick_source_interface(self, messages: list[dict[str, Any]]) -> Optional[str]:
"""Pick the most relevant bridge member from a batch of netlink messages."""
members = set(get_bridge_ports_once(self.bridge_name))
source_ifname: Optional[str] = None
for message in messages:
attrs = dict(message.get("attrs", []))
ifname = attrs.get("IFLA_IFNAME")
if ifname in members:
source_ifname = ifname
return source_ifname
def _is_source_eligible(self, ifname: str, states: dict[str, MemberLinkState]) -> bool:
"""Return whether this member should be trusted as the configuration source."""
with self._lock:
ignored = self._managed_event_deadlines.get(ifname, 0.0) > time.time()
suppressed = ifname in self._suppressed_members
if ignored or suppressed:
return False
state = states.get(ifname)
return state is not None and state.link_ready
def _next_wait_timeout(self) -> Optional[float]: def _next_wait_timeout(self) -> Optional[float]:
"""Return how long the watcher may sleep before the next restore deadline.""" """Return how long the watcher may sleep before the next restore deadline."""
with self._lock: with self._lock:
if self._suppressed_members and self._all_clear_since is not None: if self._suppressed_members and self._all_clear_since is not None:
deadline = self._all_clear_since + self.recovery_holdoff_seconds deadline = self._all_clear_since + self.recovery_holdoff_seconds
return max( return max(0.0, min(deadline - time.time(), settings.bridge_link_state_degraded_recheck_seconds))
0.0,
min(deadline - time.time(), settings.bridge_link_state_degraded_recheck_seconds),
)
if self._degraded: if self._degraded:
return settings.bridge_link_state_degraded_recheck_seconds return settings.bridge_link_state_degraded_recheck_seconds
return None return None
@@ -271,8 +326,155 @@ class BridgeLinkStateWatcher:
admin_up=read_interface_admin_up(ifname), admin_up=read_interface_admin_up(ifname),
carrier_up=read_interface_carrier(ifname), carrier_up=read_interface_carrier(ifname),
operstate=read_interface_operstate(ifname), operstate=read_interface_operstate(ifname),
mtu=read_interface_mtu(ifname),
ethernet_profile=self._read_ethernet_profile(ifname),
) )
def _read_ethernet_profile(self, ifname: str) -> Optional[EthernetProfile]:
"""Read ethtool speed/duplex/autoneg for one interface if supported."""
try:
result = subprocess.run(
[_ETHTOOL_BIN, ifname],
capture_output=True,
text=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError):
return None
values: dict[str, str] = {}
for line in result.stdout.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
values[key.strip()] = value.strip()
speed_value = values.get("Speed")
duplex_value = values.get("Duplex")
autoneg_value = values.get("Auto-negotiation")
speed_mbps: Optional[int] = None
if speed_value and speed_value.endswith("Mb/s"):
try:
speed_mbps = int(speed_value[:-4], 10)
except ValueError:
speed_mbps = None
duplex: Optional[str] = None
if duplex_value and duplex_value.lower() in {"full", "half"}:
duplex = duplex_value.lower()
autoneg: Optional[bool] = None
if autoneg_value:
lowered = autoneg_value.lower()
if lowered in {"on", "off"}:
autoneg = lowered == "on"
if speed_mbps is None and duplex is None and autoneg is None:
return None
return EthernetProfile(
speed_mbps=speed_mbps,
duplex=duplex,
autoneg=autoneg,
)
def _sync_member_configuration(self, source_ifname: str, states: dict[str, MemberLinkState]) -> None:
"""Mirror MTU and ethtool link settings from one healthy member to its siblings."""
source = states[source_ifname]
changed_members: list[str] = []
for target_ifname, target in sorted(states.items()):
if target_ifname == source_ifname:
continue
mtu_changed = self._sync_member_mtu(source_ifname, source, target_ifname, target)
profile_changed = self._sync_member_ethernet_profile(source_ifname, source, target_ifname, target)
if mtu_changed or profile_changed:
changed_members.append(target_ifname)
if changed_members:
self._set_last_action(f"synchronized configuration from {source_ifname} to {changed_members}")
def _sync_member_mtu(
self,
source_ifname: str,
source: MemberLinkState,
target_ifname: str,
target: MemberLinkState,
) -> bool:
"""Mirror MTU when the source member differs from the target."""
if source.mtu is None or target.mtu is None or source.mtu == target.mtu:
return False
with IPRoute() as ipr:
indices = ipr.link_lookup(ifname=target_ifname)
if not indices:
raise RuntimeError(f"Interface {target_ifname} not found while synchronizing MTU")
ipr.link("set", index=indices[0], mtu=source.mtu)
self._mark_managed_change(target_ifname)
logger.info(
"Synchronized MTU from %s to %s on bridge=%s: %s",
source_ifname,
target_ifname,
self.bridge_name,
source.mtu,
)
return True
def _sync_member_ethernet_profile(
self,
source_ifname: str,
source: MemberLinkState,
target_ifname: str,
target: MemberLinkState,
) -> bool:
"""Mirror ethtool speed/duplex/autoneg from the source member to the target."""
source_profile = source.ethernet_profile
target_profile = target.ethernet_profile
if source_profile is None or target_profile is None or source_profile == target_profile:
return False
cmd = [_ETHTOOL_BIN, "-s", target_ifname]
if source_profile.autoneg is True:
cmd.extend(["autoneg", "on"])
elif source_profile.autoneg is False and source_profile.speed_mbps is not None and source_profile.duplex is not None:
cmd.extend(
[
"speed",
str(source_profile.speed_mbps),
"duplex",
source_profile.duplex,
"autoneg",
"off",
]
)
else:
return False
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
logger.debug(
"Failed to synchronize ethtool profile from %s to %s on bridge=%s: %s",
source_ifname,
target_ifname,
self.bridge_name,
exc,
)
return False
self._mark_managed_change(target_ifname)
logger.info(
"Synchronized ethtool profile from %s to %s on bridge=%s: %s",
source_ifname,
target_ifname,
self.bridge_name,
source_profile,
)
return True
def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None: def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None:
desired_suppressed = set(states) - set(failing_members) desired_suppressed = set(states) - set(failing_members)
@@ -303,8 +505,7 @@ class BridgeLinkStateWatcher:
suppressed = dict(self._suppressed_members) suppressed = dict(self._suppressed_members)
if not suppressed: if not suppressed:
with self._lock: self._set_last_action(f"no_restore_needed ({reason})")
self._last_action = f"no_restore_needed ({reason})"
return return
restored_members: list[str] = [] restored_members: list[str] = []
@@ -334,6 +535,17 @@ class BridgeLinkStateWatcher:
if target_up: if target_up:
with self._lock: with self._lock:
self._settle_deadlines[ifname] = time.time() + self.recovery_holdoff_seconds self._settle_deadlines[ifname] = time.time() + self.recovery_holdoff_seconds
self._mark_managed_change(ifname)
def _mark_managed_change(self, ifname: str) -> None:
"""Ignore immediate follow-up netlink events caused by our own changes."""
with self._lock:
self._managed_event_deadlines[ifname] = time.time() + self.recovery_holdoff_seconds
def _set_last_action(self, action: str) -> None:
"""Update the watcher action text."""
with self._lock:
self._last_action = action
class BridgeLinkStateManager: class BridgeLinkStateManager:

View File

@@ -73,6 +73,19 @@ def read_interface_admin_up(iface: str) -> Optional[bool]:
return None return None
def read_interface_mtu(iface: str) -> Optional[int]:
"""Return the interface MTU if available."""
value = _read_sysfs_text(f"/sys/class/net/{iface}/mtu")
if value is None:
return None
try:
return int(value, 10)
except ValueError:
logger.warning("Unexpected MTU value for %s: %r", iface, value)
return None
def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]: def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]:
""" """
Read bridge member interfaces from sysfs. Internal helper that always reads. Read bridge member interfaces from sysfs. Internal helper that always reads.

View File

@@ -60,6 +60,12 @@ export interface BridgeMemberLinkStateInfo {
admin_up?: boolean | null; admin_up?: boolean | null;
carrier_up?: boolean | null; carrier_up?: boolean | null;
operstate?: string | null; operstate?: string | null;
mtu?: number | null;
ethernet_profile?: {
speed_mbps?: number | null;
duplex?: string | null;
autoneg?: boolean | null;
} | null;
link_ready: boolean; link_ready: boolean;
suppressed: boolean; suppressed: boolean;
} }
@@ -73,6 +79,7 @@ export interface BridgeLinkStateWatcherStatus {
last_action?: string | null; last_action?: string | null;
suppressed_members: string[]; suppressed_members: string[];
recovery_holdoff_seconds?: number | null; recovery_holdoff_seconds?: number | null;
degraded_recheck_seconds?: number | null;
members: Record<string, BridgeMemberLinkStateInfo>; members: Record<string, BridgeMemberLinkStateInfo>;
message?: string | null; message?: string | null;
} }