bacnet_network/router/mod.rs
1//! BACnet half-router — forwards APDUs between BACnet networks.
2//!
3//! A BACnet router (ASHRAE 135-2020 Clause 6.6) sits on several BACnet
4//! networks at once and relays messages among them, rewriting the NPDU
5//! source/destination fields and decrementing the hop count as it goes.
6//!
7//! This implementation supports:
8//! - Forwarding APDUs between directly-connected networks
9//! - Who-Is-Router-To-Network / I-Am-Router-To-Network messages
10//! - Reject-Message-To-Network for unknown routes
11//! - Learned routes from I-Am-Router-To-Network announcements
12//!
13//! Local application delivery follows the shared
14//! [receive-queue contract](crate::layer#receive-queue-admission), independently
15//! of forwarding and inline network-message handling.
16//!
17//! # Routing-claim trust model (RB-06)
18//!
19//! Classic BACnet routing claims are unauthenticated: an ingress port, next-hop
20//! MAC or advertised network is not proof that a peer is authorized to control
21//! that route. These local mitigations do not prevent route poisoning or
22//! authenticate a claim (Clauses 6.4 and 6.6.3):
23//! - Direct routes cannot be overwritten by learning, changed by rejects, or
24//! changed by Initialize-Routing-Table management updates.
25//! - Standard convergence applies 6.6.3.2 last-wins immediately: each new
26//! I-Am-Router / Init-ACK advertisement updates routing information, with
27//! flap warnings retained. Same-port refreshes and absent learning stay
28//! immediate; I-Could-Be-Router remains absent-only, within the route cap.
29//! - Hardened convergence ([`Convergence::Hardened`](crate::router_table::Convergence)
30//! set through [`BACnetRouter::table`], explicit opt-in only)
31//! holds cross-port learned moves for a second same-(network, port) claim
32//! within 60s inclusive. Repeats in separate messages suffice; duplicates in
33//! one message cannot corroborate. The old route forwards while pending.
34//! Alternation resets the slot; single-shot advertisers need the 60s reaper
35//! plus a fresh claim. Corroboration is dampening, not identity assurance.
36//! - Initialize-Routing-Table updates are management writes (6.4.7/6.6.3.8):
37//! nonzero Port ID replaces/appends the learned entry, Port ID 0 purges it,
38//! immediately in wire order. They never create/alter direct routes. Wire
39//! controls pass [`control_policy::ControlGate`] (RB-09); direct immunity
40//! stays safety scoping, not auth. LOCAL table calls bypass policy.
41//! - A query (Number of Ports 0) never mutates and answers with the complete
42//! table in ascending-DNET bounded portions (6.6.3.9); wire Port IDs are
43//! `port_index + 1` (0 stays the purge trigger).
44//! - Hardened pending holds one challenger per learned network (bounded by live
45//! learned routes). Slots expire lazily on the next claim plus the 60s aging
46//! reaper; apply/refresh/removal/aging/direct/manual edits clear the slot.
47//! - Freshness is split: `last_seen` (control confirmation) drives 300s expiry;
48//! `last_used` (forwarding use) never extends it. Busy/Available touch
49//! neither. Traffic no longer pins a dead route past the rescue.
50//! - Unknown DNETs solicit once per 5s per DNET (coalesced, max 256 pending,
51//! 30s entry age, reserved never solicited) then fail the caller with
52//! NOT_DIRECTLY_CONNECTED (6.6.3.1/6.5 retryable); packets are never buffered.
53//! `stop` cancels pending discovery.
54//! - Disconnect never removes routes (PTP unimplemented). Reject transitions
55//! are dampened per (ingress port, network) with a 30s hold-down; no-op
56//! rejects never extend busy deadlines. The table's
57//! [`claim_counters`](crate::router_table::Table::claim_counters) stay
58//! count-only saturating and never affect decisions.
59
60mod actions;
61pub mod control_policy;
62mod dispatch;
63mod engine;
64mod hosted_host;
65mod local_control;
66mod local_delivery;
67mod options;
68mod send_warning;
69
70use std::ops::{Deref, DerefMut};
71use std::sync::atomic::AtomicU64;
72use std::sync::Arc;
73
74/// The engine's counts, as [`BACnetRouter::counters`] reads them.
75pub use bacnet_router_core::router::Counters;
76use bacnet_router_core::{Instant, Mac};
77use bacnet_transport::port::{DataAttribute, TransportPort};
78use bacnet_types::error::Error;
79use bacnet_types::MacAddr;
80use bytes::Bytes;
81use tokio::sync::mpsc;
82use tokio::task::JoinHandle;
83
84use crate::layer::ReceivedNetworkControl;
85use crate::layer::{AdmissionReceiver, AdmissionSender, QueueAdmissionCounters, ReceivedApdu};
86
87pub use control_policy::{ControlAuthContext, ControlAuthorizer, ControlClass};
88pub use control_policy::{ControlDecisionCounters, ControlGate, ControlPolicy};
89pub use control_policy::{ControlServiceCounters, ControlTrust};
90use dispatch::{Outputs, PortDispatch, Shared};
91use engine::{Engine, EngineGuard, RouterClock, PORTS};
92use hosted_host::PortGroups;
93pub use options::{LocalApduReceiver, NetworkControlReceiver, RouterOptions, StartedRouter};
94
95// The suites that start a router take these names from here, as before the
96// engine.
97#[cfg(test)]
98use {
99 bacnet_encoding::npdu::{encode_npdu, Npdu},
100 bacnet_transport::port::TransportProvenance,
101 bacnet_types::enums::NetworkMessageType,
102 bytes::BytesMut,
103 std::time::Duration,
104};
105
106/// A send request to be forwarded on a port.
107#[derive(Debug)]
108enum SendRequest {
109 Unicast {
110 npdu: Bytes,
111 mac: MacAddr,
112 data_attributes: Vec<DataAttribute>,
113 },
114 Broadcast {
115 npdu: Bytes,
116 data_attributes: Vec<DataAttribute>,
117 },
118}
119
120/// A router port: a transport bound to a specific BACnet network number.
121///
122/// [`BACnetRouter::start`] calls
123/// [`TransportPort::prepare_for_routing`] on each port's transport before it
124/// starts it, so an MS/TP port takes and sends extended frames whatever its
125/// `MstpConfig` says: Clause 9.3 requires them of a routing node. Once the
126/// transport has started, it reads [`TransportPort::max_npdu`], and answers
127/// an NPDU too long for the port with reject reason 4 (#1665).
128pub struct RouterPort<T: TransportPort> {
129 /// The transport for this port.
130 pub transport: T,
131 /// The network number assigned to this port.
132 pub network_number: u16,
133}
134
135/// BACnet router connecting multiple networks.
136///
137/// The router holds multiple ports, each bound to a different BACnet network.
138/// When an NPDU arrives on one port with a destination network that maps to
139/// another port, the router forwards the message.
140/// See the [receive-queue contract](crate::layer#receive-queue-admission) for
141/// raw/tracked local receivers, admission limits, counters and lifecycle.
142///
143/// Every routing decision is `bacnet-router-core`'s engine (#1679), which
144/// the router keeps behind one lock: each port's dispatch task takes it for
145/// one frame at a time, as the sweep and [`Self::table`] do, and nothing
146/// holds it across an await. The engine's timers run on Tokio time from the
147/// router's start. It follows Clause 6 where the router before #1679 didn't
148/// (R1 to R7 of #1679): egress limits, rejects that keep routes,
149/// network numbers at startup, reachable-only and arrival-port-aware
150/// route answers, the hop count, and the router's own address on any port.
151pub struct BACnetRouter {
152 /// The engine, the gate and the ports' groups.
153 shared: Arc<Shared>,
154 /// Dispatch tasks (one per port).
155 dispatch_tasks: Vec<JoinHandle<()>>,
156 /// Sender tasks (one per port, owns the transport for outgoing messages).
157 sender_tasks: Vec<JoinHandle<()>>,
158 /// Background task that runs the engine's sweep when it is due.
159 aging_task: Option<JoinHandle<()>>,
160 /// Last sequence given to a control for the router's own consumer.
161 network_control_ingress_sequence: Arc<AtomicU64>,
162}
163
164/// The routing table of a running [`BACnetRouter`], borrowed for local
165/// management (see [`BACnetRouter::table`]).
166///
167/// While it lives, every port's dispatch and the sweep wait for it: read or
168/// change what you need, then drop it. It can't be held across an await in
169/// a spawned task.
170///
171/// The lock behind it isn't reentrant. Don't call [`BACnetRouter::table`]
172/// again while holding one, nor from a [`ControlAuthorizer`], which runs
173/// while that lock is held: the call waits for the lock already held, and
174/// on the same thread that is forever. The router's other `&self` methods
175/// take no lock, so they are safe to call meanwhile: the counts
176/// ([`BACnetRouter::counters`] and the four drop counts),
177/// [`BACnetRouter::now`] and [`BACnetRouter::control_snapshot`].
178pub struct RouterTableGuard<'a> {
179 router: EngineGuard<'a>,
180}
181
182impl Deref for RouterTableGuard<'_> {
183 type Target = crate::router_table::RouterTable;
184
185 fn deref(&self) -> &Self::Target {
186 self.router.table()
187 }
188}
189
190impl DerefMut for RouterTableGuard<'_> {
191 fn deref_mut(&mut self) -> &mut Self::Target {
192 self.router.table_mut()
193 }
194}
195
196impl std::fmt::Debug for RouterTableGuard<'_> {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 self.router.table().fmt(f)
199 }
200}
201
202impl BACnetRouter {
203 /// Create and start a router from a list of ports, set up as `options`
204 /// asks (#1220).
205 ///
206 /// Returns the router with its local APDU receiver, for messages without
207 /// a remote destination or for which this router is the final hop, plus
208 /// the network-control receiver when the options ask for it.
209 /// [`RouterOptions::new`] is the plain router; see [`RouterOptions`] for
210 /// the tracked APDU receiver, the wire-control policy and the
211 /// network-control receiver, which combine freely.
212 ///
213 /// Fails if there are no ports or more than 255 (a routing table's Port
214 /// ID names at most 255), if two ports share a network number, if a
215 /// transport fails to start, or if a transport's own MAC is longer than
216 /// [`NpduAddress::MAX_MAC_LEN`](bacnet_encoding::npdu::NpduAddress::MAX_MAC_LEN)
217 /// octets.
218 pub async fn start<T, A, C>(
219 ports: Vec<RouterPort<T>>,
220 options: RouterOptions<A, C>,
221 ) -> Result<StartedRouter<A, C>, Error>
222 where
223 T: TransportPort + 'static,
224 A: LocalApduReceiver,
225 C: NetworkControlReceiver,
226 {
227 let (control_tx, network_control) = if options.wants_network_control() {
228 let (tx, rx, counters) = AdmissionReceiver::channel(C::TRACKED);
229 (Some(tx), Some(C::from_queue(rx, counters)))
230 } else {
231 (None, None)
232 };
233 let gate = Arc::new(options.control_gate());
234 let (router, rx, counters) =
235 Self::start_dispatch(ports, A::TRACKED, gate, control_tx).await?;
236 Ok(StartedRouter {
237 router,
238 apdus: A::from_queue(rx, counters),
239 network_control,
240 })
241 }
242
243 /// Count-only RB-09 decision totals (allow/deny/policy-deny per class).
244 pub fn control_snapshot(&self) -> control_policy::ControlDecisionCounters {
245 self.shared.gate.snapshot()
246 }
247
248 /// The engine's counts since start: the four drop counts below, and
249 /// what else the engine counts, such as the frames it refused as
250 /// malformed, the relays a spent hop count stopped, the rejects it sent,
251 /// the sends too long for their egress port (`egress_too_long`: the
252 /// limit its transport's [`TransportPort::max_npdu`] reports, #1665),
253 /// and the rewrites it couldn't write (`write_failures`: longer than its
254 /// 65,556-octet scratch, or with a field no NPDU may carry). They
255 /// saturate and name no peer. The table's own counts are
256 /// [`claim_counters`](crate::router_table::Table::claim_counters).
257 ///
258 /// They are the counts as of the last frame, sweep or table change the
259 /// engine finished, copied out before its lock was let go, so reading
260 /// them takes no lock: it is safe while a [`RouterTableGuard`] lives,
261 /// and so are the four drop counts below, which read them.
262 pub fn counters(&self) -> Counters {
263 self.shared.engine.counters()
264 }
265
266 /// NPDUs refused on any port since start because an address in them was
267 /// past [`NpduAddress::MAX_MAC_LEN`](bacnet_encoding::npdu::NpduAddress::MAX_MAC_LEN):
268 /// their DLEN or SLEN (#1141), or the link-layer source MAC the port's
269 /// transport reported (#1198). Saturates at `u64::MAX`.
270 ///
271 /// Such an NPDU is neither forwarded nor delivered locally, and a network
272 /// message in it changes no route. When its DLEN or SLEN is the long one
273 /// and it names a specific DNET, the router also answers the sender with
274 /// Reject-Message-To-Network reason 6, `ADDRESSING_ERROR` (Clause 6.4.4),
275 /// the way it rejects a DNET it cannot reach. A global broadcast, or an
276 /// NPDU without a DNET, is only dropped. So is a frame from an over-long
277 /// link-layer source MAC: that is a fault in the transport, not in the
278 /// NPDU, and the router sends nothing back to such a MAC. No built-in
279 /// transport reports one.
280 pub fn address_length_drops(&self) -> u64 {
281 self.counters().address_length_drops
282 }
283
284 /// NPDUs dropped on any port since start because their DNET was 0xFFFF
285 /// and they also carried a DADR (#1379). Saturates at `u64::MAX`.
286 ///
287 /// DNET 0xFFFF already names every device on every network (Clauses
288 /// 6.2.2 and 6.3.2), so a DADR beside it contradicts it. The router does
289 /// not pass such an NPDU on to its other ports, deliver it locally,
290 /// act on a network message in it, or answer it with a reject: a
291 /// global broadcast never draws one. The address lengths are within
292 /// bounds, so the drop is counted here and not in
293 /// [`Self::address_length_drops`].
294 pub fn global_broadcast_dadr_drops(&self) -> u64 {
295 self.counters().global_broadcast_dadr_drops
296 }
297
298 /// NPDUs dropped on any port since start because they were addressed to
299 /// a broadcast, global (DNET 0xFFFF) or remote (a DNET with DLEN 0), and
300 /// carried an APDU other than an Unconfirmed-Request (#1491). Saturates
301 /// at `u64::MAX`.
302 ///
303 /// Only an Unconfirmed-Request may go to a broadcast network address
304 /// (Clause 6.3). Anything else belongs to one peer's transaction, so the
305 /// router neither passes it on to any network nor delivers it locally,
306 /// and answers it with no reject. The PDU type comes from the first APDU
307 /// octet, with no decode; network messages are not affected.
308 pub fn broadcast_pdu_type_drops(&self) -> u64 {
309 self.counters().broadcast_pdu_type_drops
310 }
311
312 /// NPDUs dropped on any port since start because the router would have
313 /// delivered them, on a directly connected port, to a DADR that is a
314 /// group destination there, such as a B/IP multicast address, and their
315 /// APDU isn't an Unconfirmed-Request (#1504). Saturates at `u64::MAX`.
316 ///
317 /// Sent as one unicast, such an NPDU would reach every node in the
318 /// group without the broadcast network addresses that
319 /// [`Self::broadcast_pdu_type_drops`] filters: a confirmed request would
320 /// draw an answer from each node, and an answer would reach nodes that
321 /// asked nothing. The port's rule is
322 /// [`TransportPort::group_destinations`],
323 /// so a custom transport that keeps the default recognizes none. The
324 /// router delivers it nowhere and answers it with no reject. A network
325 /// message routed to such a DADR, or a Reject-Message-To-Network relayed
326 /// to one, is dropped and counted too: it must not reach every router on
327 /// that network as a broadcast. An Unconfirmed-Request for one of the
328 /// groups the port's broadcast stands for
329 /// ([`TransportPort::local_broadcast_destinations`]) isn't counted: it
330 /// goes out on that port as a local broadcast, and reaches this router's
331 /// application where a DLEN 0 broadcast would (#1677). On B/IP, one for a
332 /// group at another UDP port, another B/IP network, is counted.
333 pub fn group_dadr_drops(&self) -> u64 {
334 self.counters().group_dadr_drops
335 }
336
337 async fn start_dispatch<T: TransportPort + 'static>(
338 mut ports: Vec<RouterPort<T>>,
339 track_depth: bool,
340 control: Arc<control_policy::ControlGate>,
341 network_control: Option<AdmissionSender<ReceivedNetworkControl>>,
342 ) -> Result<(Self, mpsc::Receiver<ReceivedApdu>, QueueAdmissionCounters), Error> {
343 if ports.is_empty() || ports.len() > PORTS {
344 return Err(Error::Encoding(format!(
345 "A router needs 1 to {PORTS} ports, not {}",
346 ports.len()
347 )));
348 }
349 // Reject duplicate network numbers
350 {
351 let mut seen = std::collections::HashSet::new();
352 for port in &ports {
353 if !seen.insert(port.network_number) {
354 return Err(Error::Encoding(format!(
355 "Duplicate network number {} in router ports",
356 port.network_number
357 )));
358 }
359 }
360 }
361
362 let (local_tx, local_rx, counters) = AdmissionReceiver::channel(track_depth);
363
364 // Start each transport, and note what the engine and its hooks need.
365 let mut port_receivers = Vec::new();
366 let mut configs = Vec::new();
367 let mut groups = Vec::new();
368 let mut networks = Vec::new();
369
370 for port in &mut ports {
371 // A routing node may owe its data link more than other nodes:
372 // MS/TP turns on extended frames here (Clause 9.3).
373 port.transport.prepare_for_routing();
374 let rx = port.transport.start().await?;
375 port_receivers.push(rx);
376 let mac = Mac::from_slice(port.transport.local_mac()).map_err(|_| {
377 Error::Encoding(format!(
378 "Router port on network {} has a MAC longer than {} octets",
379 port.network_number,
380 bacnet_router_core::MAX_MAC_LEN
381 ))
382 })?;
383 // Read once the transport has started: MS/TP's grows with the
384 // extended frames routing turned on, and BACnet/SC's comes from
385 // its hub connection.
386 configs.push(Engine::port(
387 port.network_number,
388 mac,
389 port.transport.max_npdu(),
390 ));
391 groups.push(PortGroups {
392 groups: port.transport.group_destinations(),
393 local_broadcasts: port.transport.local_broadcast_destinations(),
394 });
395 networks.push(port.network_number);
396 }
397 let engine = Engine::new(&configs, RouterClock::start())
398 .map_err(|error| Error::Encoding(error.to_string()))?;
399
400 // Move transports into sender tasks
401 let mut send_txs = Vec::new();
402 let mut sender_tasks = Vec::new();
403 for port in ports {
404 let (send_tx, mut send_rx) = mpsc::channel::<SendRequest>(256);
405 send_txs.push(send_tx);
406
407 let transport = port.transport;
408 let task = tokio::spawn(async move {
409 let warnings = send_warning::SendWarnings::default();
410 while let Some(req) = send_rx.recv().await {
411 match req {
412 SendRequest::Unicast {
413 npdu,
414 mac,
415 data_attributes,
416 } => {
417 if let Err(e) = transport
418 .send_unicast_with_data_attributes(&npdu, &mac, &data_attributes)
419 .await
420 {
421 warnings.unicast_failed(&e);
422 }
423 }
424 SendRequest::Broadcast {
425 npdu,
426 data_attributes,
427 } => {
428 if let Err(e) = transport
429 .send_broadcast_with_data_attributes(&npdu, &data_attributes)
430 .await
431 {
432 warnings.broadcast_failed(&e);
433 }
434 }
435 }
436 }
437 });
438 sender_tasks.push(task);
439 }
440
441 let network_control_ingress_sequence = Arc::new(AtomicU64::new(0));
442 let shared = Arc::new(Shared {
443 engine,
444 gate: control,
445 groups,
446 networks,
447 warnings: Default::default(),
448 });
449
450 // Startup: each port hears its own network number, in a
451 // Network-Number-Is (R3, Clause 6.4.20), then, in an I-Am-Router,
452 // which networks lie beyond the other ports.
453 shared.announce(&send_txs);
454
455 let outputs = Arc::new(Outputs {
456 send_txs,
457 local_tx,
458 local_control: local_control::LocalControl::new(
459 network_control,
460 Arc::clone(&network_control_ingress_sequence),
461 ),
462 });
463 let dispatch_tasks = port_receivers
464 .into_iter()
465 .enumerate()
466 .map(|(index, rx)| {
467 let port = PortDispatch {
468 shared: Arc::clone(&shared),
469 outputs: Arc::clone(&outputs),
470 // At most 255 ports, checked above.
471 port: index as u8,
472 };
473 tokio::spawn(port.run(rx))
474 })
475 .collect();
476
477 // Run the engine's sweep at each of its deadlines: it purges stale
478 // learned routes, lapsed busy marks, hardened pending slots and
479 // discovery entries. `last_used` never extends `last_seen` expiry.
480 //
481 // The task sleeps until the deadline it last read, and nothing
482 // wakes it early. That holds because only the sweep itself
483 // (`on_deadline`) moves the engine's `next_deadline`; if the core
484 // ever lets another call move it, that call's site must wake this
485 // task (a `Notify`, say), or the sweep runs late.
486 let aging = Arc::clone(&shared);
487 let aging_task = tokio::spawn(async move {
488 let clock = aging.engine.clock();
489 let mut next = aging.engine.lock().next_deadline();
490 loop {
491 match next {
492 Some(deadline) => tokio::time::sleep_until(clock.instant(deadline)).await,
493 None => std::future::pending().await,
494 }
495 next = aging.sweep();
496 }
497 });
498
499 Ok((
500 Self {
501 shared,
502 dispatch_tasks,
503 sender_tasks,
504 aging_task: Some(aging_task),
505 network_control_ingress_sequence,
506 },
507 local_rx,
508 counters,
509 ))
510 }
511
512 /// The routing table, locked for local management: read routes, or
513 /// change them with no wire-control policy in the way.
514 ///
515 /// Every port's dispatch waits while the guard lives, so drop it soon,
516 /// and never hold it across an await. Times in it are the router's own
517 /// ([`Self::now`]).
518 ///
519 /// Don't call it again while a guard lives on the same thread, or from
520 /// a [`ControlAuthorizer`]: the lock isn't reentrant, so that call
521 /// never returns. The counts, [`Self::now`] and
522 /// [`Self::control_snapshot`] take no lock and are safe to call while
523 /// the guard lives.
524 pub fn table(&self) -> RouterTableGuard<'_> {
525 RouterTableGuard {
526 router: self.shared.engine.lock(),
527 }
528 }
529
530 /// The router's time now: the clock its table and timers run on, Tokio
531 /// time since the router started.
532 pub fn now(&self) -> Instant {
533 self.shared.engine.now()
534 }
535
536 /// Stop the router.
537 ///
538 /// Aborts and awaits dispatch, sender and aging tasks without waiting for
539 /// the local application consumer, even with a full queue. Queued local
540 /// APDUs remain drainable, retaining their reply senders until consumed or
541 /// the receiver is dropped. Stop is not an admission drop and does not clear
542 /// depth/high-water/drop totals. See the
543 /// [receive-queue contract](crate::layer#receive-queue-admission).
544 pub async fn stop(&mut self) {
545 for task in self.dispatch_tasks.drain(..) {
546 task.abort();
547 let _ = task.await;
548 }
549 for task in self.sender_tasks.drain(..) {
550 task.abort();
551 let _ = task.await;
552 }
553 if let Some(task) = self.aging_task.take() {
554 task.abort();
555 let _ = task.await;
556 }
557 // Cancel pending unknown-destination discovery so no further
558 // solicitation is emitted and coalesced waiters observe shutdown.
559 self.shared.engine.lock().cancel_discovery();
560 }
561
562 /// How many unknown networks the router is looking for.
563 #[cfg(test)]
564 fn searches(&self) -> (usize, bool) {
565 let router = self.shared.engine.lock();
566 (router.discovery().len(), router.discovery().is_cancelled())
567 }
568}
569
570#[cfg(test)]
571mod admission_tests;
572#[cfg(test)]
573mod bip_group_tests;
574#[cfg(test)]
575mod busy_scope_tests;
576#[cfg(test)]
577mod claim_tests;
578#[cfg(test)]
579mod corroboration_tests;
580#[cfg(test)]
581mod cutover_tests;
582#[cfg(test)]
583mod envelope_control_tests;
584#[cfg(test)]
585mod envelope_discovery_tests;
586#[cfg(test)]
587mod envelope_harness;
588#[cfg(test)]
589mod fixes_tests;
590#[cfg(test)]
591mod ingress_harness;
592#[cfg(test)]
593mod init_routing_table_tests;
594#[cfg(test)]
595mod mstp_extended_tests;
596#[cfg(test)]
597mod rb06_convergence_tests;
598#[cfg(test)]
599mod rb09_control_policy_tests;
600#[cfg(test)]
601mod reject_tests;
602#[cfg(test)]
603mod tests;