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::atomic::{AtomicU64, Ordering};
92use std::sync::Arc;
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 [i_am, init_ack, init_mgmt, busy, available, reject, i_could_be] = rows;
286 ControlDecisionCounters {
287 i_am,
288 init_ack,
289 init_mgmt,
290 busy,
291 available,
292 reject,
293 i_could_be,
294 }
295 }
296
297 fn record(&self, class: ControlClass, decision: Decision) {
298 let Some(row) = index(class) else {
299 debug!(
300 ?class,
301 "No decision counters for this control class; not counted"
302 );
303 return;
304 };
305 let inc = |col: usize| {
306 #[allow(deprecated, reason = "try_update needs Rust 1.95; the MSRV is 1.93")]
307 let _ = self.0[row][col].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
308 Some(n.saturating_add(1))
309 });
310 };
311 match decision {
312 Decision::Allow => inc(0),
313 Decision::Deny => inc(1),
314 Decision::PolicyDeny => {
315 inc(1);
316 inc(2);
317 }
318 }
319 }
320}
321
322/// Policy + optional authorizer + shared counters for wire controls.
323pub struct ControlGate {
324 policy: ControlPolicy,
325 authorizer: Option<ControlAuthorizer>,
326 decisions: ControlDecisions,
327}
328
329impl std::fmt::Debug for ControlGate {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 f.debug_struct("ControlGate")
332 .field("policy", &self.policy)
333 .field(
334 "authorizer",
335 &self.authorizer.as_ref().map(|_| "<callback>"),
336 )
337 .field("decisions", &self.decisions)
338 .finish_non_exhaustive()
339 }
340}
341
342impl Default for ControlGate {
343 fn default() -> Self {
344 Self::permissive()
345 }
346}
347
348impl ControlGate {
349 /// Gate that admits every protected control (no authorizer).
350 pub fn permissive() -> Self {
351 Self {
352 policy: ControlPolicy::Permissive,
353 authorizer: None,
354 decisions: ControlDecisions::default(),
355 }
356 }
357
358 /// Gate that denies every protected control (no authorizer).
359 pub fn hardened() -> Self {
360 Self {
361 policy: ControlPolicy::Hardened,
362 authorizer: None,
363 decisions: ControlDecisions::default(),
364 }
365 }
366
367 /// Gate with an explicit policy and optional authorizer; a present authorizer decides every
368 /// protected control regardless of `policy`.
369 pub fn new(policy: ControlPolicy, authorizer: Option<ControlAuthorizer>) -> Self {
370 Self {
371 policy,
372 authorizer,
373 decisions: ControlDecisions::default(),
374 }
375 }
376
377 /// Configured policy mode.
378 pub fn policy(&self) -> ControlPolicy {
379 self.policy
380 }
381
382 /// Snapshot of the allow/deny totals recorded by this gate.
383 pub fn snapshot(&self) -> ControlDecisionCounters {
384 self.decisions.snapshot()
385 }
386
387 /// Decide `class` for the redacted context `ctx`, counting the
388 /// decision. The engine's host calls it after validation, before any
389 /// change.
390 pub(super) fn decide(&self, ctx: &ControlAuthContext, class: ControlClass) -> bool {
391 match (&self.policy, &self.authorizer) {
392 (ControlPolicy::Permissive, None) => {
393 self.decisions.record(class, Decision::Allow);
394 true
395 }
396 (ControlPolicy::Hardened, None) => {
397 self.decisions.record(class, Decision::PolicyDeny);
398 false
399 }
400 (_, Some(auth)) => {
401 let allowed =
402 std::panic::catch_unwind(AssertUnwindSafe(|| auth(ctx))).unwrap_or(false);
403 self.decisions.record(
404 class,
405 if allowed {
406 Decision::Allow
407 } else {
408 Decision::Deny
409 },
410 );
411 allowed
412 }
413 }
414 }
415}