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
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}