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
17use std::panic::AssertUnwindSafe;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Arc;
20
21use bacnet_transport::port::TransportProvenance;
22use tracing::debug;
23
24/// Wire-control authorization mode. Permissive preserves today's behavior.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ControlPolicy {
27 /// Wire controls are admitted unless an installed authorizer says otherwise.
28 #[default]
29 Permissive,
30 /// Protected controls are denied unless an installed authorizer approves them.
31 Hardened,
32}
33
34/// Channel/relay scope only. Never leaf identity: a verified SC ingress
35/// asserts the channel/relay validation, not that a claimed SNET/SADR leaf
36/// is the authenticated peer. Provenance alone never authorizes; only the
37/// callback does (callback-only, no static allowlist).
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum ControlTrust {
40 /// Ingress arrived on a path with no channel or relay verification.
41 Unverified,
42 /// Ingress arrived over a verified direct-peer or hub channel.
43 VerifiedChannel,
44 /// Ingress was relayed from a verified origin behind another node.
45 VerifiedRelay,
46}
47
48impl ControlTrust {
49 /// Map transport provenance to a trust scope: direct-peer or hub channels are
50 /// `VerifiedChannel`, relayed origins are `VerifiedRelay`, everything else is `Unverified`.
51 pub fn from_provenance(p: TransportProvenance) -> Self {
52 if p.is_direct_peer() || p.is_hub_channel() {
53 Self::VerifiedChannel
54 } else if p.is_relayed_origin() {
55 Self::VerifiedRelay
56 } else {
57 Self::Unverified
58 }
59 }
60}
61
62/// Immutable admission facts for one protected control. Claims only.
63#[derive(Clone, PartialEq, Eq)]
64pub struct ControlAuthContext {
65 /// Index of the router port the control arrived on.
66 pub port_idx: usize,
67 /// Network number of that port.
68 pub port_network: u16,
69 /// Data-link source address of the sending node.
70 pub source_mac: bacnet_types::MacAddr,
71 /// How the transport learned of the frame's origin (direct peer, hub, relayed).
72 pub provenance: TransportProvenance,
73 /// Channel-level trust derived from `provenance`; says nothing about the leaf sender's
74 /// identity.
75 pub trust: ControlTrust,
76 /// Network-layer message type code of the control, as received.
77 pub message_type: u8,
78 /// Vendor ID from a proprietary network message, if present.
79 pub vendor_id: Option<u16>,
80 /// Source network number claimed in the NPDU, if any.
81 pub source_net: Option<u16>,
82 /// Destination network number claimed in the NPDU, if any.
83 pub dest_net: Option<u16>,
84 /// Network numbers the control refers to, after validation.
85 pub target_nets: Vec<u16>,
86 /// True when the control omitted its network list, meaning every network served via the sender.
87 pub targets_omitted: bool,
88}
89
90impl std::fmt::Debug for ControlAuthContext {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("ControlAuthContext")
93 .field("port_idx", &self.port_idx)
94 .field("port_network", &self.port_network)
95 .field("source_mac_len", &self.source_mac.len())
96 .field("provenance", &self.provenance)
97 .field("trust", &self.trust)
98 .field("message_type", &self.message_type)
99 .field("vendor_id", &self.vendor_id)
100 .field("source_net", &self.source_net)
101 .field("dest_net", &self.dest_net)
102 .field("target_nets", &self.target_nets)
103 .field("targets_omitted", &self.targets_omitted)
104 .finish_non_exhaustive()
105 }
106}
107
108/// Fast, nonblocking, side-effect-free control authorizer.
109///
110/// Consulted for protected controls only; read-only discovery never calls it.
111/// Do not block, reenter the router/table, or perform side effects. It runs
112/// under the router's engine lock (#1679), one call at a time per router:
113/// every port's routing waits while it runs, and calling into the router
114/// from it ([`BACnetRouter::table`](super::BACnetRouter::table) included)
115/// deadlocks. Returning `false` or panicking denies fail-closed (silent
116/// drop, no mutation/relay/ACK/Reject).
117pub type ControlAuthorizer = Arc<dyn Fn(&ControlAuthContext) -> bool + Send + Sync>;
118
119/// Protected control class, `bacnet-router-core`'s. One counter row each.
120pub use bacnet_router_core::router::ControlClass;
121
122/// The counter row of `class`; `None` for a class a later engine adds,
123/// which has no row yet and isn't counted.
124fn index(class: ControlClass) -> Option<usize> {
125 match class {
126 ControlClass::IAm => Some(0),
127 ControlClass::InitAck => Some(1),
128 ControlClass::InitMgmt => Some(2),
129 ControlClass::Busy => Some(3),
130 ControlClass::Available => Some(4),
131 ControlClass::Reject => Some(5),
132 ControlClass::ICouldBe => Some(6),
133 _ => None,
134 }
135}
136
137/// Saturating per-class decision totals. Count-only; never affects policy.
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub struct ControlServiceCounters {
140 /// Controls of this class that were admitted.
141 pub allow_total: u64,
142 /// Controls of this class that were refused for any reason (includes `policy_deny_total`).
143 pub deny_total: u64,
144 /// Refusals caused by hardened policy with no authorizer installed.
145 pub policy_deny_total: u64,
146}
147
148/// Fixed-shape telemetry for the seven protected classes.
149#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
150pub struct ControlDecisionCounters {
151 /// Totals for I-Am-Router-To-Network.
152 pub i_am: ControlServiceCounters,
153 /// Totals for Initialize-Routing-Table-Ack.
154 pub init_ack: ControlServiceCounters,
155 /// Totals for Initialize-Routing-Table updates and purges.
156 pub init_mgmt: ControlServiceCounters,
157 /// Totals for Router-Busy-To-Network.
158 pub busy: ControlServiceCounters,
159 /// Totals for Router-Available-To-Network.
160 pub available: ControlServiceCounters,
161 /// Totals for Reject-Message-To-Network.
162 pub reject: ControlServiceCounters,
163 /// Totals for I-Could-Be-Router-To-Network.
164 pub i_could_be: ControlServiceCounters,
165}
166
167#[derive(Clone, Copy)]
168enum Decision {
169 Allow,
170 Deny,
171 PolicyDeny,
172}
173
174/// Atomic per-class totals shared via [`ControlGate`].
175#[derive(Default)]
176pub struct ControlDecisions([[AtomicU64; 3]; 7]);
177
178impl std::fmt::Debug for ControlDecisions {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("ControlDecisions")
181 .field("snapshot", &self.snapshot())
182 .finish_non_exhaustive()
183 }
184}
185
186impl ControlDecisions {
187 /// Copy the current totals; each counter is read independently, so the set is not atomic.
188 pub fn snapshot(&self) -> ControlDecisionCounters {
189 let rows = self.0.each_ref().map(|r| {
190 let [a, d, p] = r.each_ref().map(|n| n.load(Ordering::Relaxed));
191 ControlServiceCounters {
192 allow_total: a,
193 deny_total: d,
194 policy_deny_total: p,
195 }
196 });
197 let [i_am, init_ack, init_mgmt, busy, available, reject, i_could_be] = rows;
198 ControlDecisionCounters {
199 i_am,
200 init_ack,
201 init_mgmt,
202 busy,
203 available,
204 reject,
205 i_could_be,
206 }
207 }
208
209 fn record(&self, class: ControlClass, decision: Decision) {
210 let Some(row) = index(class) else {
211 debug!(
212 ?class,
213 "No decision counters for this control class; not counted"
214 );
215 return;
216 };
217 let inc = |col: usize| {
218 #[allow(deprecated, reason = "try_update needs Rust 1.95; the MSRV is 1.93")]
219 let _ = self.0[row][col].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
220 Some(n.saturating_add(1))
221 });
222 };
223 match decision {
224 Decision::Allow => inc(0),
225 Decision::Deny => inc(1),
226 Decision::PolicyDeny => {
227 inc(1);
228 inc(2);
229 }
230 }
231 }
232}
233
234/// Policy + optional authorizer + shared counters for wire controls.
235pub struct ControlGate {
236 policy: ControlPolicy,
237 authorizer: Option<ControlAuthorizer>,
238 decisions: ControlDecisions,
239}
240
241impl std::fmt::Debug for ControlGate {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.debug_struct("ControlGate")
244 .field("policy", &self.policy)
245 .field(
246 "authorizer",
247 &self.authorizer.as_ref().map(|_| "<callback>"),
248 )
249 .field("decisions", &self.decisions)
250 .finish_non_exhaustive()
251 }
252}
253
254impl Default for ControlGate {
255 fn default() -> Self {
256 Self::permissive()
257 }
258}
259
260impl ControlGate {
261 /// Gate that admits every protected control (no authorizer).
262 pub fn permissive() -> Self {
263 Self {
264 policy: ControlPolicy::Permissive,
265 authorizer: None,
266 decisions: ControlDecisions::default(),
267 }
268 }
269
270 /// Gate that denies every protected control (no authorizer).
271 pub fn hardened() -> Self {
272 Self {
273 policy: ControlPolicy::Hardened,
274 authorizer: None,
275 decisions: ControlDecisions::default(),
276 }
277 }
278
279 /// Gate with an explicit policy and optional authorizer; a present authorizer decides every
280 /// protected control regardless of `policy`.
281 pub fn new(policy: ControlPolicy, authorizer: Option<ControlAuthorizer>) -> Self {
282 Self {
283 policy,
284 authorizer,
285 decisions: ControlDecisions::default(),
286 }
287 }
288
289 /// Configured policy mode.
290 pub fn policy(&self) -> ControlPolicy {
291 self.policy
292 }
293
294 /// Snapshot of the allow/deny totals recorded by this gate.
295 pub fn snapshot(&self) -> ControlDecisionCounters {
296 self.decisions.snapshot()
297 }
298
299 /// Decide `class` for the redacted context `ctx`, counting the
300 /// decision. The engine's host calls it after validation, before any
301 /// change.
302 pub(super) fn decide(&self, ctx: &ControlAuthContext, class: ControlClass) -> bool {
303 match (&self.policy, &self.authorizer) {
304 (ControlPolicy::Permissive, None) => {
305 self.decisions.record(class, Decision::Allow);
306 true
307 }
308 (ControlPolicy::Hardened, None) => {
309 self.decisions.record(class, Decision::PolicyDeny);
310 false
311 }
312 (_, Some(auth)) => {
313 let allowed =
314 std::panic::catch_unwind(AssertUnwindSafe(|| auth(ctx))).unwrap_or(false);
315 self.decisions.record(
316 class,
317 if allowed {
318 Decision::Allow
319 } else {
320 Decision::Deny
321 },
322 );
323 allowed
324 }
325 }
326 }
327}