bacnet-network 0.15.0

BACnet network layer: routing, router tables, priority channels
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! RB-09 routing-control admission policy (wire ingress only).
//!
//! Single admission boundary for state-changing routing controls. Direct
//! [`crate::router_table::RouterTable`] API calls bypass this policy by
//! construction (LOCAL); all wire ingress including loopback stays untrusted.
//! APDUs never reach this boundary; directed-forward paths mutate nothing.
//!
//! Protected (hardened denies unknown authority): Init updates/purges, I-Am
//! learning, Init-ACK learning, Busy/Available marks, Reject transitions,
//! I-Could-Be inserts. Read-only (always allowed): Who-Is relay/answer,
//! Init queries, What-Is/Number-Is, Establish/Disconnect no-ops, security
//! ack-only, proprietary explicit-reject, directed opaque forwarding.
//! Deny is a silent local drop: no mutation, relay, ACK, or Reject emission.
//! Validation precedes policy. The router's engine asks for each decision
//! under its lock, with the message's claims, before anything changes.
//!
//! ## Who may change routes (#518)
//!
//! [`ControlAuthContext::sending_peer_principal`] is the verified leaf
//! certificate of the direct BACnet/SC peer that handed the message to this
//! router: the last hop, even when the message carries an SNET. Trusting a
//! principal trusts everything that peer forwards, and allowing it binds the
//! routes it announces to the VMAC it sent them from, not to the certificate.
//!
//! It is only there for **unicast** controls: Initialize-Routing-Table and its
//! Ack, Reject-Message-To-Network, and a directed I-Could-Be-Router-To-Network.
//! The route announcements are broadcasts (Clauses 6.6.3.2, 6.6.3.3):
//! I-Am-Router-To-Network, and the ones marking a route busy or available
//! again,
//! and on BACnet/SC a broadcast always goes through the hub, which relays it
//! with its own registration of the sender's VMAC and never the sender's
//! certificate. So a conforming SC router's announcements carry no principal:
//! a limit of BACnet/SC, not of this router. B/IP, B/IPv6, MS/TP and Ethernet
//! senders have none either.
//!
//! A `Hardened` gate can still require configured router certificates for
//! the unicast controls on SC ports, and give announcements and other ports
//! their own rule. On this router ports 0 and 1 are BACnet/SC and port 2 is
//! B/IP:
//!
//! ```
//! use std::sync::Arc;
//! use bacnet_network::router::control_policy::{
//!     ControlAuthContext, ControlAuthorizer, ControlPolicy,
//! };
//! use bacnet_npdu::NetworkMessageType as Message;
//! use bacnet_transport::port::ScPrincipal;
//! use bacnet_types::MacAddr;
//!
//! fn site(routers: Vec<ScPrincipal>, announcers: Vec<MacAddr>) -> (ControlPolicy, ControlAuthorizer) {
//!     const SC_PORTS: [usize; 2] = [0, 1];
//!     let authorizer: ControlAuthorizer = Arc::new(move |context: &ControlAuthContext| {
//!         if !SC_PORTS.contains(&context.port_idx) {
//!             // B/IP has no certificates: keep the standard behaviour.
//!             return true;
//!         }
//!         let unicast = [
//!             Message::INITIALIZE_ROUTING_TABLE,
//!             Message::INITIALIZE_ROUTING_TABLE_ACK,
//!             Message::REJECT_MESSAGE_TO_NETWORK,
//!             Message::I_COULD_BE_ROUTER_TO_NETWORK,
//!         ]
//!         .contains(&context.message_type);
//!         if unicast {
//!             context
//!                 .sending_peer_principal()
//!                 .is_some_and(|peer| routers.contains(&peer))
//!         } else {
//!             // Announcements arrive hub-relayed, with no principal: gate
//!             // them on the hub-registered VMAC, a claim the hub vouches for.
//!             announcers.contains(&context.source_mac)
//!         }
//!     });
//!     (ControlPolicy::Hardened, authorizer)
//! }
//! # let _ = site(Vec::new(), Vec::new());
//! ```
//!
//! A VMAC is only as good as the hub's registration: hub certificate bindings
//! (#800) tie a VMAC to the certificates allowed to register it. SC mutual TLS
//! authenticates a connection; it doesn't authorize route changes. The router
//! stays `Permissive` unless a policy is configured, for interoperability
//! with routers that announce themselves as the standard expects.
//!
//! Repeated advertisements circulating in a looped topology are a separate
//! problem, tracked as #1810 in `bacnet-router-core`. Each SC node also
//! limits how many network-layer messages one source may hand in
//! (`ScControlRateLimit` in `bacnet-transport`).

use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use bacnet_transport::port::TransportProvenance;
use tracing::debug;

/// Wire-control authorization mode. Permissive preserves today's behavior.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ControlPolicy {
    /// Wire controls are admitted unless an installed authorizer says otherwise.
    #[default]
    Permissive,
    /// Protected controls are denied unless an installed authorizer approves them.
    Hardened,
}

/// Channel/relay scope only. Never leaf identity: a verified SC ingress
/// asserts the channel/relay validation, not that a claimed SNET/SADR leaf
/// is the authenticated peer. Provenance alone never authorizes; only the
/// callback does (callback-only, no static allowlist).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlTrust {
    /// Ingress arrived on a path with no channel or relay verification.
    Unverified,
    /// Ingress arrived over a verified direct-peer or hub channel.
    VerifiedChannel,
    /// Ingress was relayed from a verified origin behind another node.
    VerifiedRelay,
}

impl ControlTrust {
    /// Map transport provenance to a trust scope: direct-peer or hub channels are
    /// `VerifiedChannel`, relayed origins are `VerifiedRelay`, everything else is `Unverified`.
    pub fn from_provenance(p: &TransportProvenance) -> Self {
        if p.is_direct_peer() || p.is_hub_channel() {
            Self::VerifiedChannel
        } else if p.is_relayed_origin() {
            Self::VerifiedRelay
        } else {
            Self::Unverified
        }
    }
}

/// Immutable admission facts for one protected control. Claims only.
#[derive(Clone, PartialEq, Eq)]
pub struct ControlAuthContext {
    /// Index of the router port the control arrived on.
    pub port_idx: usize,
    /// Network number of that port.
    pub port_network: u16,
    /// Data-link source address of the sending node.
    pub source_mac: bacnet_types::MacAddr,
    /// How the transport learned of the frame's origin (direct peer, hub, relayed).
    pub provenance: TransportProvenance,
    /// Channel-level trust derived from `provenance`; says nothing about the leaf sender's
    /// identity.
    pub trust: ControlTrust,
    /// The control's network-layer message type, as received.
    pub message_type: bacnet_npdu::NetworkMessageType,
    /// Vendor ID from a proprietary network message, if present.
    pub vendor_id: Option<u16>,
    /// Source network number claimed in the NPDU, if any.
    pub source_net: Option<u16>,
    /// Destination network number claimed in the NPDU, if any.
    pub dest_net: Option<u16>,
    /// Network numbers the control refers to, after validation.
    pub target_nets: Vec<u16>,
    /// True when the control omitted its network list, meaning every network served via the sender.
    pub targets_omitted: bool,
}

impl ControlAuthContext {
    /// The verified leaf certificate of the direct BACnet/SC peer that
    /// handed this message to the router: the last hop, even when the message
    /// carries an SNET. `None` for a hub-relayed message, which every SC
    /// broadcast is, and for any other data link; see the
    /// [module documentation](self) for which controls can carry one.
    pub fn sending_peer_principal(&self) -> Option<bacnet_transport::port::ScPrincipal> {
        self.provenance.principal()
    }
}

impl std::fmt::Debug for ControlAuthContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ControlAuthContext")
            .field("port_idx", &self.port_idx)
            .field("port_network", &self.port_network)
            .field("source_mac_len", &self.source_mac.len())
            .field("provenance", &self.provenance)
            .field(
                "sending_peer_principal",
                &self.sending_peer_principal().is_some(),
            )
            .field("trust", &self.trust)
            .field("message_type", &self.message_type)
            .field("vendor_id", &self.vendor_id)
            .field("source_net", &self.source_net)
            .field("dest_net", &self.dest_net)
            .field("target_nets", &self.target_nets)
            .field("targets_omitted", &self.targets_omitted)
            .finish_non_exhaustive()
    }
}

/// Fast, nonblocking, side-effect-free control authorizer.
///
/// Consulted for protected controls only; read-only discovery never calls it.
/// Do not block, reenter the router/table, or perform side effects. It runs
/// under the router's engine lock (#1679), one call at a time per router:
/// every port's routing waits while it runs, and calling into the router
/// from it ([`BACnetRouter::table`](super::BACnetRouter::table) included)
/// deadlocks. Returning `false` or panicking denies fail-closed (silent
/// drop, no mutation/relay/ACK/Reject).
pub type ControlAuthorizer = Arc<dyn Fn(&ControlAuthContext) -> bool + Send + Sync>;

/// Protected control class, `bacnet-router-core`'s. One counter row each.
pub use bacnet_router_core::router::ControlClass;

/// The counter row of `class`; `None` for a class a later engine adds,
/// which has no row yet and isn't counted.
fn index(class: ControlClass) -> Option<usize> {
    match class {
        ControlClass::IAm => Some(0),
        ControlClass::InitAck => Some(1),
        ControlClass::InitMgmt => Some(2),
        ControlClass::Busy => Some(3),
        ControlClass::Available => Some(4),
        ControlClass::Reject => Some(5),
        ControlClass::ICouldBe => Some(6),
        _ => None,
    }
}

/// Saturating per-class decision totals. Count-only; never affects policy.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ControlServiceCounters {
    /// Controls of this class that were admitted.
    pub allow_total: u64,
    /// Controls of this class that were refused for any reason (includes `policy_deny_total`).
    pub deny_total: u64,
    /// Refusals caused by hardened policy with no authorizer installed.
    pub policy_deny_total: u64,
}

/// Fixed-shape telemetry for the seven protected classes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ControlDecisionCounters {
    /// Totals for I-Am-Router-To-Network.
    pub i_am: ControlServiceCounters,
    /// Totals for Initialize-Routing-Table-Ack.
    pub init_ack: ControlServiceCounters,
    /// Totals for Initialize-Routing-Table updates and purges.
    pub init_mgmt: ControlServiceCounters,
    /// Totals for Router-Busy-To-Network.
    pub busy: ControlServiceCounters,
    /// Totals for Router-Available-To-Network.
    pub available: ControlServiceCounters,
    /// Totals for Reject-Message-To-Network.
    pub reject: ControlServiceCounters,
    /// Totals for I-Could-Be-Router-To-Network.
    pub i_could_be: ControlServiceCounters,
}

#[derive(Clone, Copy)]
enum Decision {
    Allow,
    Deny,
    PolicyDeny,
}

/// Atomic per-class totals shared via [`ControlGate`].
#[derive(Default)]
pub struct ControlDecisions([[AtomicU64; 3]; 7]);

impl std::fmt::Debug for ControlDecisions {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ControlDecisions")
            .field("snapshot", &self.snapshot())
            .finish_non_exhaustive()
    }
}

impl ControlDecisions {
    /// Copy the current totals; each counter is read independently, so the set is not atomic.
    pub fn snapshot(&self) -> ControlDecisionCounters {
        let rows = self.0.each_ref().map(|r| {
            let [a, d, p] = r.each_ref().map(|n| n.load(Ordering::Relaxed));
            ControlServiceCounters {
                allow_total: a,
                deny_total: d,
                policy_deny_total: p,
            }
        });
        let [
            i_am,
            init_ack,
            init_mgmt,
            busy,
            available,
            reject,
            i_could_be,
        ] = rows;
        ControlDecisionCounters {
            i_am,
            init_ack,
            init_mgmt,
            busy,
            available,
            reject,
            i_could_be,
        }
    }

    fn record(&self, class: ControlClass, decision: Decision) {
        let Some(row) = index(class) else {
            debug!(
                ?class,
                "No decision counters for this control class; not counted"
            );
            return;
        };
        let inc = |col: usize| {
            let _ = self.0[row][col].try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
                Some(n.saturating_add(1))
            });
        };
        match decision {
            Decision::Allow => inc(0),
            Decision::Deny => inc(1),
            Decision::PolicyDeny => {
                inc(1);
                inc(2);
            }
        }
    }
}

/// Policy + optional authorizer + shared counters for wire controls.
pub struct ControlGate {
    policy: ControlPolicy,
    authorizer: Option<ControlAuthorizer>,
    decisions: ControlDecisions,
}

impl std::fmt::Debug for ControlGate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ControlGate")
            .field("policy", &self.policy)
            .field(
                "authorizer",
                &self.authorizer.as_ref().map(|_| "<callback>"),
            )
            .field("decisions", &self.decisions)
            .finish_non_exhaustive()
    }
}

impl Default for ControlGate {
    fn default() -> Self {
        Self::permissive()
    }
}

impl ControlGate {
    /// Gate that admits every protected control (no authorizer).
    pub fn permissive() -> Self {
        Self {
            policy: ControlPolicy::Permissive,
            authorizer: None,
            decisions: ControlDecisions::default(),
        }
    }

    /// Gate that denies every protected control (no authorizer).
    pub fn hardened() -> Self {
        Self {
            policy: ControlPolicy::Hardened,
            authorizer: None,
            decisions: ControlDecisions::default(),
        }
    }

    /// Gate with an explicit policy and optional authorizer; a present authorizer decides every
    /// protected control regardless of `policy`.
    pub fn new(policy: ControlPolicy, authorizer: Option<ControlAuthorizer>) -> Self {
        Self {
            policy,
            authorizer,
            decisions: ControlDecisions::default(),
        }
    }

    /// Configured policy mode.
    pub fn policy(&self) -> ControlPolicy {
        self.policy
    }

    /// Snapshot of the allow/deny totals recorded by this gate.
    pub fn snapshot(&self) -> ControlDecisionCounters {
        self.decisions.snapshot()
    }

    /// Decide `class` for the redacted context `ctx`, counting the
    /// decision. The engine's host calls it after validation, before any
    /// change.
    pub(super) fn decide(&self, ctx: &ControlAuthContext, class: ControlClass) -> bool {
        match (&self.policy, &self.authorizer) {
            (ControlPolicy::Permissive, None) => {
                self.decisions.record(class, Decision::Allow);
                true
            }
            (ControlPolicy::Hardened, None) => {
                self.decisions.record(class, Decision::PolicyDeny);
                false
            }
            (_, Some(auth)) => {
                let allowed =
                    std::panic::catch_unwind(AssertUnwindSafe(|| auth(ctx))).unwrap_or(false);
                self.decisions.record(
                    class,
                    if allowed {
                        Decision::Allow
                    } else {
                        Decision::Deny
                    },
                );
                allowed
            }
        }
    }
}