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