use std::collections::HashMap;
use std::time::{Duration, Instant};
use bacnet_types::MacAddr;
const HOLD_DOWN: Duration = Duration::from_secs(30);
const CORROBORATION_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct RoutingClaimSnapshot {
pub reject_applied: u64,
pub reject_dampened: u64,
pub reject_dampened_same_state: u64,
pub reject_dampened_hold_down: u64,
pub learned_ok: u64,
pub learned_cap_ignored: u64,
pub flap_warned: u64,
pub pending_started: u64,
pub corroborated_applied: u64,
pub pending_expired: u64,
pub disconnect_removal_ignored: u64,
}
#[derive(Debug, Clone)]
struct PendingReplacement {
port_index: usize,
first_seen: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReachabilityStatus {
Reachable,
Busy,
Unreachable,
}
#[derive(Debug, Clone)]
pub struct RouteEntry {
pub port_index: usize,
pub directly_connected: bool,
pub next_hop_mac: MacAddr,
pub last_seen: Option<Instant>,
pub last_used: Option<Instant>,
pub reachability: ReachabilityStatus,
pub busy_until: Option<Instant>,
pub flap_count: u8,
pub last_port_change: Option<Instant>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConvergenceMode {
#[default]
Standard,
Hardened,
}
#[derive(Debug, Clone)]
pub struct RouterTable {
routes: HashMap<u16, RouteEntry>,
reject_transitions: HashMap<u16, HashMap<usize, Instant>>,
pending_replacements: HashMap<u16, PendingReplacement>,
claim_counters: RoutingClaimSnapshot,
convergence: ConvergenceMode,
}
impl RouterTable {
pub fn new() -> Self {
Self {
routes: HashMap::new(),
reject_transitions: HashMap::new(),
pending_replacements: HashMap::new(),
claim_counters: RoutingClaimSnapshot::default(),
convergence: ConvergenceMode::Standard,
}
}
pub fn new_hardened() -> Self {
let mut table = Self::new();
table.convergence = ConvergenceMode::Hardened;
table
}
pub fn set_convergence(&mut self, mode: ConvergenceMode) {
self.convergence = mode;
}
pub fn convergence(&self) -> ConvergenceMode {
self.convergence
}
pub fn pending_count(&self) -> usize {
self.pending_replacements.len()
}
pub fn claim_snapshot(&self) -> RoutingClaimSnapshot {
self.claim_counters
}
pub(crate) fn record_learned(&mut self) {
self.claim_counters.learned_ok = self.claim_counters.learned_ok.saturating_add(1);
}
pub(crate) fn record_learning_cap(&mut self) {
self.claim_counters.learned_cap_ignored =
self.claim_counters.learned_cap_ignored.saturating_add(1);
}
pub(crate) fn record_disconnect_removal_ignored(&mut self) {
self.claim_counters.disconnect_removal_ignored = self
.claim_counters
.disconnect_removal_ignored
.saturating_add(1);
}
pub(crate) fn apply_learning_claim(
&mut self,
network: u16,
port_index: usize,
next_hop_mac: MacAddr,
now: Instant,
) -> bool {
if self.convergence == ConvergenceMode::Standard {
self.pending_replacements.remove(&network);
return self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, now);
}
let pending = self
.pending_replacements
.remove(&network)
.filter(|pending| {
if now.duration_since(pending.first_seen) > CORROBORATION_WINDOW {
self.claim_counters.pending_expired =
self.claim_counters.pending_expired.saturating_add(1);
false
} else {
true
}
});
if self
.routes
.get(&network)
.is_some_and(|entry| !entry.directly_connected && entry.port_index != port_index)
{
if pending.is_some_and(|pending| pending.port_index == port_index) {
self.claim_counters.corroborated_applied =
self.claim_counters.corroborated_applied.saturating_add(1);
} else {
self.pending_replacements.insert(
network,
PendingReplacement {
port_index,
first_seen: now,
},
);
self.claim_counters.pending_started =
self.claim_counters.pending_started.saturating_add(1);
debug_assert!(self.pending_replacements.keys().all(|net| self
.routes
.get(net)
.is_some_and(|entry| !entry.directly_connected)));
return false; }
}
self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, now)
}
pub fn expire_pending_at(&mut self, now: Instant) -> Vec<u16> {
let mut expired: Vec<u16> = self
.pending_replacements
.iter()
.filter(|(_, pending)| now.duration_since(pending.first_seen) > CORROBORATION_WINDOW)
.map(|(net, _)| *net)
.collect();
expired.sort_unstable();
for net in &expired {
self.pending_replacements.remove(net);
self.claim_counters.pending_expired =
self.claim_counters.pending_expired.saturating_add(1);
}
expired
}
pub(crate) fn apply_reject(
&mut self,
network: u16,
ingress_port: usize,
reason: u8,
now: Instant,
) {
let changes_state = self.routes.get(&network).is_some_and(|entry| {
if entry.directly_connected {
return false;
}
match reason {
1 => entry.reachability != ReachabilityStatus::Unreachable,
2 => {
entry.reachability != ReachabilityStatus::Unreachable
&& (entry.reachability != ReachabilityStatus::Busy
|| entry.busy_until.is_some_and(|deadline| now >= deadline))
}
_ => true,
}
});
let held_down = self
.reject_transitions
.get(&network)
.and_then(|ports| ports.get(&ingress_port))
.is_some_and(|last| now.duration_since(*last) < HOLD_DOWN);
if !changes_state || held_down {
let counters = &mut self.claim_counters;
counters.reject_dampened = counters.reject_dampened.saturating_add(1);
if !changes_state {
counters.reject_dampened_same_state =
counters.reject_dampened_same_state.saturating_add(1);
} else {
counters.reject_dampened_hold_down =
counters.reject_dampened_hold_down.saturating_add(1);
}
return; }
match reason {
1 => self.mark_unreachable(network),
2 => self.mark_busy(network, now + Duration::from_secs(30)),
_ => {
self.remove(network); }
}
if self.routes.contains_key(&network) {
self.reject_transitions
.entry(network)
.or_default()
.insert(ingress_port, now);
}
self.claim_counters.reject_applied = self.claim_counters.reject_applied.saturating_add(1);
}
pub fn add_direct(&mut self, network: u16, port_index: usize) {
if network == 0 || network == 0xFFFF {
return;
}
self.reject_transitions.remove(&network);
self.pending_replacements.remove(&network);
self.routes.insert(
network,
RouteEntry {
port_index,
directly_connected: true,
next_hop_mac: MacAddr::new(),
last_seen: None,
last_used: None,
reachability: ReachabilityStatus::Reachable,
busy_until: None,
flap_count: 0,
last_port_change: None,
},
);
}
pub fn add_learned(&mut self, network: u16, port_index: usize, next_hop_mac: MacAddr) {
self.add_learned_at(network, port_index, next_hop_mac, Instant::now());
}
pub fn add_learned_at(
&mut self,
network: u16,
port_index: usize,
next_hop_mac: MacAddr,
now: Instant,
) {
if network == 0 || network == 0xFFFF {
return;
}
let preserved_used = self.routes.get(&network).and_then(|existing| {
if existing.directly_connected {
None
} else if existing.port_index == port_index {
existing.last_used
} else {
None
}
});
if self
.routes
.get(&network)
.is_some_and(|existing| existing.directly_connected)
{
return; }
self.reject_transitions.remove(&network);
self.pending_replacements.remove(&network);
self.routes.insert(
network,
RouteEntry {
port_index,
directly_connected: false,
next_hop_mac,
last_seen: Some(now),
last_used: preserved_used,
reachability: ReachabilityStatus::Reachable,
busy_until: None,
flap_count: 0,
last_port_change: None,
},
);
}
pub fn wire_port_id(port_index: usize) -> Option<u8> {
u8::try_from(port_index.checked_add(1)?).ok()
}
pub fn port_index_for_wire_id(port_id: u8, port_count: usize) -> Option<usize> {
usize::from(port_id)
.checked_sub(1)
.filter(|index| *index < port_count)
}
pub fn sorted_networks(&self) -> Vec<u16> {
let mut networks = self.networks();
networks.sort_unstable();
networks
}
pub(crate) fn apply_management_update(
&mut self,
network: u16,
port_index: usize,
next_hop_mac: MacAddr,
) -> bool {
if network == 0 || network == 0xFFFF {
return false;
}
if self
.routes
.get(&network)
.is_some_and(|entry| entry.directly_connected)
{
return false;
}
self.add_learned(network, port_index, next_hop_mac);
true
}
pub(crate) fn apply_management_removal(&mut self, network: u16) -> bool {
if network == 0 || network == 0xFFFF {
return false;
}
if self
.routes
.get(&network)
.is_some_and(|entry| entry.directly_connected)
{
return false;
}
self.remove(network).is_some()
}
pub fn add_learned_with_flap_detection(
&mut self,
network: u16,
port_index: usize,
next_hop_mac: MacAddr,
) -> bool {
self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, Instant::now())
}
pub fn add_learned_with_flap_detection_at(
&mut self,
network: u16,
port_index: usize,
next_hop_mac: MacAddr,
now: Instant,
) -> bool {
if network == 0 || network == 0xFFFF {
return false;
}
if let Some(existing) = self.routes.get(&network) {
if existing.directly_connected {
return false;
}
if existing.port_index != port_index {
let flap_count = match existing.last_port_change {
Some(changed) if now.duration_since(changed) < Duration::from_secs(60) => {
existing.flap_count.saturating_add(1)
}
_ => 1,
};
if flap_count >= 3 {
self.claim_counters.flap_warned =
self.claim_counters.flap_warned.saturating_add(1);
tracing::warn!(
network,
old_port = existing.port_index,
new_port = port_index,
flap_count,
"Route flapping detected — network changed ports {} times in 60s",
flap_count
);
}
self.reject_transitions.remove(&network);
self.pending_replacements.remove(&network);
self.routes.insert(
network,
RouteEntry {
port_index,
directly_connected: false,
next_hop_mac,
last_seen: Some(now),
last_used: None,
reachability: ReachabilityStatus::Reachable,
busy_until: None,
flap_count,
last_port_change: Some(now),
},
);
return true;
}
}
self.add_learned_at(network, port_index, next_hop_mac, now);
true
}
pub fn mark_busy(&mut self, network: u16, deadline: Instant) {
if let Some(entry) = self.routes.get_mut(&network) {
if !entry.directly_connected && entry.reachability != ReachabilityStatus::Unreachable {
entry.reachability = ReachabilityStatus::Busy;
entry.busy_until = Some(deadline);
}
}
}
pub fn mark_available(&mut self, network: u16) {
if let Some(entry) = self.routes.get_mut(&network) {
if !entry.directly_connected {
entry.reachability = ReachabilityStatus::Reachable;
entry.busy_until = None;
}
}
}
pub fn is_served_via_peer(&self, network: u16, port_index: usize, next_hop: &MacAddr) -> bool {
self.routes.get(&network).is_some_and(|entry| {
!entry.directly_connected
&& entry.port_index == port_index
&& entry.next_hop_mac == *next_hop
})
}
pub fn routes_served_via_peer(&self, port_index: usize, next_hop: &MacAddr) -> Vec<u16> {
self.routes
.iter()
.filter(|(_, entry)| {
!entry.directly_connected
&& entry.port_index == port_index
&& entry.next_hop_mac == *next_hop
})
.map(|(net, _)| *net)
.collect()
}
pub fn mark_unreachable(&mut self, network: u16) {
if let Some(entry) = self.routes.get_mut(&network) {
if !entry.directly_connected {
entry.reachability = ReachabilityStatus::Unreachable;
entry.busy_until = None;
}
}
}
pub fn clear_expired_busy(&mut self) {
let now = Instant::now();
for entry in self.routes.values_mut() {
if let Some(deadline) = entry.busy_until {
if now >= deadline {
entry.reachability = ReachabilityStatus::Reachable;
entry.busy_until = None;
}
}
}
}
pub fn effective_reachability(&self, network: u16) -> Option<ReachabilityStatus> {
self.routes.get(&network).map(|entry| {
if entry.reachability == ReachabilityStatus::Busy {
if let Some(deadline) = entry.busy_until {
if Instant::now() >= deadline {
return ReachabilityStatus::Reachable;
}
}
}
entry.reachability
})
}
pub fn lookup(&self, network: u16) -> Option<&RouteEntry> {
self.routes.get(&network)
}
pub fn lookup_mut(&mut self, network: u16) -> Option<&mut RouteEntry> {
self.pending_replacements.remove(&network);
self.routes.get_mut(&network)
}
pub fn remove(&mut self, network: u16) -> Option<RouteEntry> {
self.reject_transitions.remove(&network);
self.pending_replacements.remove(&network);
self.routes.remove(&network)
}
pub fn networks(&self) -> Vec<u16> {
self.routes.keys().copied().collect()
}
pub fn networks_not_on_port(&self, exclude_port: usize) -> Vec<u16> {
self.routes
.iter()
.filter(|(_, entry)| entry.port_index != exclude_port)
.map(|(net, _)| *net)
.collect()
}
pub fn networks_on_port(&self, port_index: usize) -> Vec<u16> {
self.routes
.iter()
.filter(|(_, entry)| entry.port_index == port_index)
.map(|(net, _)| *net)
.collect()
}
pub fn len(&self) -> usize {
self.routes.len()
}
pub fn is_empty(&self) -> bool {
self.routes.is_empty()
}
pub fn touch(&mut self, network: u16) {
self.touch_used_at(network, Instant::now());
}
pub fn touch_used_at(&mut self, network: u16, now: Instant) {
if let Some(entry) = self.routes.get_mut(&network) {
if !entry.directly_connected {
entry.last_used = Some(now);
}
}
}
pub fn purge_stale(&mut self, max_age: Duration) -> Vec<u16> {
self.purge_stale_at(Instant::now(), max_age)
}
pub fn purge_stale_at(&mut self, now: Instant, max_age: Duration) -> Vec<u16> {
let stale: Vec<u16> = self
.routes
.iter()
.filter(|(_, entry)| {
if let Some(seen) = entry.last_seen {
!entry.directly_connected && now.duration_since(seen) > max_age
} else {
false
}
})
.map(|(net, _)| *net)
.collect();
for net in &stale {
self.remove(*net);
}
stale
}
}
impl Default for RouterTable {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "router_table_reject_tests.rs"]
mod reject_tests;
#[cfg(test)]
#[path = "router_table_corroboration_tests.rs"]
mod corroboration_tests;
#[cfg(test)]
#[path = "router_table_tests.rs"]
mod tests;