Skip to main content

bacnet_network/router/
control_policy.rs

1//! RB-09 routing-control admission policy (wire ingress only).
2//!
3//! Single admission boundary for state-changing routing controls. Direct
4//! [`crate::router_table::RouterTable`] API calls bypass this policy by
5//! construction (LOCAL); all wire ingress including loopback stays untrusted.
6//! APDUs never reach this boundary; directed-forward paths mutate nothing.
7//!
8//! Protected (hardened denies unknown authority): Init updates/purges, I-Am
9//! learning, Init-ACK learning, Busy/Available marks, Reject transitions,
10//! I-Could-Be inserts. Read-only (always allowed): Who-Is relay/answer,
11//! Init queries, What-Is/Number-Is, Establish/Disconnect no-ops, security
12//! ack-only, proprietary explicit-reject, directed opaque forwarding.
13//! Deny is a silent local drop: no mutation, relay, ACK, or Reject emission.
14//! Validation precedes policy. The router's engine asks for each decision
15//! under its lock, with the message's claims, before anything changes.
16//!
17//! ## Who may change routes (#518)
18//!
19//! [`ControlAuthContext::sending_peer_principal`] is the verified leaf
20//! certificate of the direct BACnet/SC peer that handed the message to this
21//! router: the last hop, even when the message carries an SNET. Trusting a
22//! principal trusts everything that peer forwards, and allowing it binds the
23//! routes it announces to the VMAC it sent them from, not to the certificate.
24//!
25//! It is only there for **unicast** controls: Initialize-Routing-Table and its
26//! Ack, Reject-Message-To-Network, and a directed I-Could-Be-Router-To-Network.
27//! The route announcements are broadcasts (Clauses 6.6.3.2, 6.6.3.3):
28//! I-Am-Router-To-Network, and the ones marking a route busy or available
29//! again,
30//! and on BACnet/SC a broadcast always goes through the hub, which relays it
31//! with its own registration of the sender's VMAC and never the sender's
32//! certificate. So a conforming SC router's announcements carry no principal:
33//! a limit of BACnet/SC, not of this router. B/IP, B/IPv6, MS/TP and Ethernet
34//! senders have none either.
35//!
36//! A `Hardened` gate can still require configured router certificates for
37//! the unicast controls on SC ports, and give announcements and other ports
38//! their own rule. On this router ports 0 and 1 are BACnet/SC and port 2 is
39//! B/IP:
40//!
41//! ```
42//! use std::sync::Arc;
43//! use bacnet_network::router::control_policy::{
44//!     ControlAuthContext, ControlAuthorizer, ControlPolicy,
45//! };
46//! use bacnet_npdu::NetworkMessageType as Message;
47//! use bacnet_transport::port::ScPrincipal;
48//! use bacnet_types::MacAddr;
49//!
50//! fn site(routers: Vec<ScPrincipal>, announcers: Vec<MacAddr>) -> (ControlPolicy, ControlAuthorizer) {
51//!     const SC_PORTS: [usize; 2] = [0, 1];
52//!     let authorizer: ControlAuthorizer = Arc::new(move |context: &ControlAuthContext| {
53//!         if !SC_PORTS.contains(&context.port_idx) {
54//!             // B/IP has no certificates: keep the standard behaviour.
55//!             return true;
56//!         }
57//!         let unicast = [
58//!             Message::INITIALIZE_ROUTING_TABLE,
59//!             Message::INITIALIZE_ROUTING_TABLE_ACK,
60//!             Message::REJECT_MESSAGE_TO_NETWORK,
61//!             Message::I_COULD_BE_ROUTER_TO_NETWORK,
62//!         ]
63//!         .contains(&context.message_type);
64//!         if unicast {
65//!             context
66//!                 .sending_peer_principal()
67//!                 .is_some_and(|peer| routers.contains(&peer))
68//!         } else {
69//!             // Announcements arrive hub-relayed, with no principal: gate
70//!             // them on the hub-registered VMAC, a claim the hub vouches for.
71//!             announcers.contains(&context.source_mac)
72//!         }
73//!     });
74//!     (ControlPolicy::Hardened, authorizer)
75//! }
76//! # let _ = site(Vec::new(), Vec::new());
77//! ```
78//!
79//! A VMAC is only as good as the hub's registration: hub certificate bindings
80//! (#800) tie a VMAC to the certificates allowed to register it. SC mutual TLS
81//! authenticates a connection; it doesn't authorize route changes. The router
82//! stays `Permissive` unless a policy is configured, for interoperability
83//! with routers that announce themselves as the standard expects.
84//!
85//! Repeated advertisements circulating in a looped topology are a separate
86//! problem, tracked as #1810 in `bacnet-router-core`. Each SC node also
87//! limits how many network-layer messages one source may hand in
88//! (`ScControlRateLimit` in `bacnet-transport`).
89
90use std::panic::AssertUnwindSafe;
91use std::sync::Arc;
92use std::sync::atomic::{AtomicU64, Ordering};
93
94use bacnet_transport::port::TransportProvenance;
95use tracing::debug;
96
97/// Wire-control authorization mode. Permissive preserves today's behavior.
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
99pub enum ControlPolicy {
100    /// Wire controls are admitted unless an installed authorizer says otherwise.
101    #[default]
102    Permissive,
103    /// Protected controls are denied unless an installed authorizer approves them.
104    Hardened,
105}
106
107/// Channel/relay scope only. Never leaf identity: a verified SC ingress
108/// asserts the channel/relay validation, not that a claimed SNET/SADR leaf
109/// is the authenticated peer. Provenance alone never authorizes; only the
110/// callback does (callback-only, no static allowlist).
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum ControlTrust {
113    /// Ingress arrived on a path with no channel or relay verification.
114    Unverified,
115    /// Ingress arrived over a verified direct-peer or hub channel.
116    VerifiedChannel,
117    /// Ingress was relayed from a verified origin behind another node.
118    VerifiedRelay,
119}
120
121impl ControlTrust {
122    /// Map transport provenance to a trust scope: direct-peer or hub channels are
123    /// `VerifiedChannel`, relayed origins are `VerifiedRelay`, everything else is `Unverified`.
124    pub fn from_provenance(p: &TransportProvenance) -> Self {
125        if p.is_direct_peer() || p.is_hub_channel() {
126            Self::VerifiedChannel
127        } else if p.is_relayed_origin() {
128            Self::VerifiedRelay
129        } else {
130            Self::Unverified
131        }
132    }
133}
134
135/// Immutable admission facts for one protected control. Claims only.
136#[derive(Clone, PartialEq, Eq)]
137pub struct ControlAuthContext {
138    /// Index of the router port the control arrived on.
139    pub port_idx: usize,
140    /// Network number of that port.
141    pub port_network: u16,
142    /// Data-link source address of the sending node.
143    pub source_mac: bacnet_types::MacAddr,
144    /// How the transport learned of the frame's origin (direct peer, hub, relayed).
145    pub provenance: TransportProvenance,
146    /// Channel-level trust derived from `provenance`; says nothing about the leaf sender's
147    /// identity.
148    pub trust: ControlTrust,
149    /// The control's network-layer message type, as received.
150    pub message_type: bacnet_npdu::NetworkMessageType,
151    /// Vendor ID from a proprietary network message, if present.
152    pub vendor_id: Option<u16>,
153    /// Source network number claimed in the NPDU, if any.
154    pub source_net: Option<u16>,
155    /// Destination network number claimed in the NPDU, if any.
156    pub dest_net: Option<u16>,
157    /// Network numbers the control refers to, after validation.
158    pub target_nets: Vec<u16>,
159    /// True when the control omitted its network list, meaning every network served via the sender.
160    pub targets_omitted: bool,
161}
162
163impl ControlAuthContext {
164    /// The verified leaf certificate of the direct BACnet/SC peer that
165    /// handed this message to the router: the last hop, even when the message
166    /// carries an SNET. `None` for a hub-relayed message, which every SC
167    /// broadcast is, and for any other data link; see the
168    /// [module documentation](self) for which controls can carry one.
169    pub fn sending_peer_principal(&self) -> Option<bacnet_transport::port::ScPrincipal> {
170        self.provenance.principal()
171    }
172}
173
174impl std::fmt::Debug for ControlAuthContext {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("ControlAuthContext")
177            .field("port_idx", &self.port_idx)
178            .field("port_network", &self.port_network)
179            .field("source_mac_len", &self.source_mac.len())
180            .field("provenance", &self.provenance)
181            .field(
182                "sending_peer_principal",
183                &self.sending_peer_principal().is_some(),
184            )
185            .field("trust", &self.trust)
186            .field("message_type", &self.message_type)
187            .field("vendor_id", &self.vendor_id)
188            .field("source_net", &self.source_net)
189            .field("dest_net", &self.dest_net)
190            .field("target_nets", &self.target_nets)
191            .field("targets_omitted", &self.targets_omitted)
192            .finish_non_exhaustive()
193    }
194}
195
196/// Fast, nonblocking, side-effect-free control authorizer.
197///
198/// Consulted for protected controls only; read-only discovery never calls it.
199/// Do not block, reenter the router/table, or perform side effects. It runs
200/// under the router's engine lock (#1679), one call at a time per router:
201/// every port's routing waits while it runs, and calling into the router
202/// from it ([`BACnetRouter::table`](super::BACnetRouter::table) included)
203/// deadlocks. Returning `false` or panicking denies fail-closed (silent
204/// drop, no mutation/relay/ACK/Reject).
205pub type ControlAuthorizer = Arc<dyn Fn(&ControlAuthContext) -> bool + Send + Sync>;
206
207/// Protected control class, `bacnet-router-core`'s. One counter row each.
208pub use bacnet_router_core::router::ControlClass;
209
210/// The counter row of `class`; `None` for a class a later engine adds,
211/// which has no row yet and isn't counted.
212fn index(class: ControlClass) -> Option<usize> {
213    match class {
214        ControlClass::IAm => Some(0),
215        ControlClass::InitAck => Some(1),
216        ControlClass::InitMgmt => Some(2),
217        ControlClass::Busy => Some(3),
218        ControlClass::Available => Some(4),
219        ControlClass::Reject => Some(5),
220        ControlClass::ICouldBe => Some(6),
221        _ => None,
222    }
223}
224
225/// Saturating per-class decision totals. Count-only; never affects policy.
226#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
227pub struct ControlServiceCounters {
228    /// Controls of this class that were admitted.
229    pub allow_total: u64,
230    /// Controls of this class that were refused for any reason (includes `policy_deny_total`).
231    pub deny_total: u64,
232    /// Refusals caused by hardened policy with no authorizer installed.
233    pub policy_deny_total: u64,
234}
235
236/// Fixed-shape telemetry for the seven protected classes.
237#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
238pub struct ControlDecisionCounters {
239    /// Totals for I-Am-Router-To-Network.
240    pub i_am: ControlServiceCounters,
241    /// Totals for Initialize-Routing-Table-Ack.
242    pub init_ack: ControlServiceCounters,
243    /// Totals for Initialize-Routing-Table updates and purges.
244    pub init_mgmt: ControlServiceCounters,
245    /// Totals for Router-Busy-To-Network.
246    pub busy: ControlServiceCounters,
247    /// Totals for Router-Available-To-Network.
248    pub available: ControlServiceCounters,
249    /// Totals for Reject-Message-To-Network.
250    pub reject: ControlServiceCounters,
251    /// Totals for I-Could-Be-Router-To-Network.
252    pub i_could_be: ControlServiceCounters,
253}
254
255#[derive(Clone, Copy)]
256enum Decision {
257    Allow,
258    Deny,
259    PolicyDeny,
260}
261
262/// Atomic per-class totals shared via [`ControlGate`].
263#[derive(Default)]
264pub struct ControlDecisions([[AtomicU64; 3]; 7]);
265
266impl std::fmt::Debug for ControlDecisions {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        f.debug_struct("ControlDecisions")
269            .field("snapshot", &self.snapshot())
270            .finish_non_exhaustive()
271    }
272}
273
274impl ControlDecisions {
275    /// Copy the current totals; each counter is read independently, so the set is not atomic.
276    pub fn snapshot(&self) -> ControlDecisionCounters {
277        let rows = self.0.each_ref().map(|r| {
278            let [a, d, p] = r.each_ref().map(|n| n.load(Ordering::Relaxed));
279            ControlServiceCounters {
280                allow_total: a,
281                deny_total: d,
282                policy_deny_total: p,
283            }
284        });
285        let [
286            i_am,
287            init_ack,
288            init_mgmt,
289            busy,
290            available,
291            reject,
292            i_could_be,
293        ] = rows;
294        ControlDecisionCounters {
295            i_am,
296            init_ack,
297            init_mgmt,
298            busy,
299            available,
300            reject,
301            i_could_be,
302        }
303    }
304
305    fn record(&self, class: ControlClass, decision: Decision) {
306        let Some(row) = index(class) else {
307            debug!(
308                ?class,
309                "No decision counters for this control class; not counted"
310            );
311            return;
312        };
313        let inc = |col: usize| {
314            let _ = self.0[row][col].try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
315                Some(n.saturating_add(1))
316            });
317        };
318        match decision {
319            Decision::Allow => inc(0),
320            Decision::Deny => inc(1),
321            Decision::PolicyDeny => {
322                inc(1);
323                inc(2);
324            }
325        }
326    }
327}
328
329/// Policy + optional authorizer + shared counters for wire controls.
330pub struct ControlGate {
331    policy: ControlPolicy,
332    authorizer: Option<ControlAuthorizer>,
333    decisions: ControlDecisions,
334}
335
336impl std::fmt::Debug for ControlGate {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_struct("ControlGate")
339            .field("policy", &self.policy)
340            .field(
341                "authorizer",
342                &self.authorizer.as_ref().map(|_| "<callback>"),
343            )
344            .field("decisions", &self.decisions)
345            .finish_non_exhaustive()
346    }
347}
348
349impl Default for ControlGate {
350    fn default() -> Self {
351        Self::permissive()
352    }
353}
354
355impl ControlGate {
356    /// Gate that admits every protected control (no authorizer).
357    pub fn permissive() -> Self {
358        Self {
359            policy: ControlPolicy::Permissive,
360            authorizer: None,
361            decisions: ControlDecisions::default(),
362        }
363    }
364
365    /// Gate that denies every protected control (no authorizer).
366    pub fn hardened() -> Self {
367        Self {
368            policy: ControlPolicy::Hardened,
369            authorizer: None,
370            decisions: ControlDecisions::default(),
371        }
372    }
373
374    /// Gate with an explicit policy and optional authorizer; a present authorizer decides every
375    /// protected control regardless of `policy`.
376    pub fn new(policy: ControlPolicy, authorizer: Option<ControlAuthorizer>) -> Self {
377        Self {
378            policy,
379            authorizer,
380            decisions: ControlDecisions::default(),
381        }
382    }
383
384    /// Configured policy mode.
385    pub fn policy(&self) -> ControlPolicy {
386        self.policy
387    }
388
389    /// Snapshot of the allow/deny totals recorded by this gate.
390    pub fn snapshot(&self) -> ControlDecisionCounters {
391        self.decisions.snapshot()
392    }
393
394    /// Decide `class` for the redacted context `ctx`, counting the
395    /// decision. The engine's host calls it after validation, before any
396    /// change.
397    pub(super) fn decide(&self, ctx: &ControlAuthContext, class: ControlClass) -> bool {
398        match (&self.policy, &self.authorizer) {
399            (ControlPolicy::Permissive, None) => {
400                self.decisions.record(class, Decision::Allow);
401                true
402            }
403            (ControlPolicy::Hardened, None) => {
404                self.decisions.record(class, Decision::PolicyDeny);
405                false
406            }
407            (_, Some(auth)) => {
408                let allowed =
409                    std::panic::catch_unwind(AssertUnwindSafe(|| auth(ctx))).unwrap_or(false);
410                self.decisions.record(
411                    class,
412                    if allowed {
413                        Decision::Allow
414                    } else {
415                        Decision::Deny
416                    },
417                );
418                allowed
419            }
420        }
421    }
422}