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}