Skip to main content

bacnet_server/server/
device_bindings.rs

1use super::binding_probes::{BindingProbes, WhoIsScope};
2use super::event_recipient_route::RecipientRoute;
3use super::*;
4use bacnet_encoding::primitives::{
5    encode_app_object_id, encode_app_octet_string, encode_app_unsigned,
6};
7
8/// Maximum number of configured and observed device bindings held by a server.
9pub(super) const MAX_DEVICE_BINDINGS: usize = 4096;
10
11/// Freshness window for passively observed I-Am bindings.
12pub(super) const OBSERVED_BINDING_TTL: Duration = Duration::from_secs(10 * 60);
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15enum DeviceBindingTarget {
16    Local {
17        peer_mac: MacAddr,
18    },
19    Routed {
20        network: u16,
21        final_mac: MacAddr,
22        router_mac: MacAddr,
23    },
24}
25
26/// One explicitly configured unicast route to a Device object.
27///
28/// Constructed values are transport-neutral. A builder performs the remaining
29/// concrete data-link check before the transport is started: neither the
30/// peer nor the router may be a broadcast or other group address of the link
31/// ([`TransportPort::is_group_destination`], #1493).
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DeviceBinding {
34    device: ObjectIdentifier,
35    target: DeviceBindingTarget,
36}
37
38impl DeviceBinding {
39    /// Create a binding to a peer on the server's local network.
40    pub fn local(device: ObjectIdentifier, peer_mac: impl AsRef<[u8]>) -> Result<Self, Error> {
41        validate_device_identifier(device)?;
42        let peer_mac = MacAddr::from_slice(peer_mac.as_ref());
43        if peer_mac.is_empty() {
44            return Err(binding_error("local peer MAC must be non-empty"));
45        }
46        Ok(Self {
47            device,
48            target: DeviceBindingTarget::Local { peer_mac },
49        })
50    }
51
52    /// Create a routed binding with a final network/address and local router.
53    pub fn routed(
54        device: ObjectIdentifier,
55        network: u16,
56        final_mac: impl AsRef<[u8]>,
57        router_mac: impl AsRef<[u8]>,
58    ) -> Result<Self, Error> {
59        validate_device_identifier(device)?;
60        if !(1..=0xFFFE).contains(&network) {
61            return Err(binding_error("routed network must be in 1..=65534"));
62        }
63        let final_mac = MacAddr::from_slice(final_mac.as_ref());
64        if final_mac.is_empty() {
65            return Err(binding_error("routed final MAC must be non-empty"));
66        }
67        let router_mac = MacAddr::from_slice(router_mac.as_ref());
68        if router_mac.is_empty() {
69            return Err(binding_error("routed router MAC must be non-empty"));
70        }
71        Ok(Self {
72            device,
73            target: DeviceBindingTarget::Routed {
74                network,
75                final_mac,
76                router_mac,
77            },
78        })
79    }
80}
81
82fn binding_error(message: &str) -> Error {
83    Error::Encoding(format!("invalid device binding: {message}"))
84}
85
86fn validate_device_identifier(device: ObjectIdentifier) -> Result<(), Error> {
87    if device.object_type() != ObjectType::DEVICE {
88        return Err(binding_error("identifier must name a Device object"));
89    }
90    Ok(())
91}
92
93pub(super) fn register_configured_binding(
94    bindings: &mut Vec<DeviceBinding>,
95    binding: DeviceBinding,
96) -> Result<(), Error> {
97    if bindings.len() >= MAX_DEVICE_BINDINGS {
98        return Err(binding_error("configured binding capacity exceeded"));
99    }
100    if bindings
101        .iter()
102        .any(|configured| configured.device == binding.device)
103    {
104        return Err(binding_error("duplicate configured Device identifier"));
105    }
106    bindings.push(binding);
107    Ok(())
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub(super) enum DeviceResolution {
112    ResolvedLocal {
113        peer_mac: MacAddr,
114        freshness: BindingFreshness,
115    },
116    ResolvedRouted {
117        network: u16,
118        final_mac: MacAddr,
119        router_mac: MacAddr,
120        freshness: BindingFreshness,
121    },
122    Unknown,
123    Stale,
124    Invalid,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(super) enum BindingFreshness {
129    Configured,
130    ObservedUntil(tokio::time::Instant),
131}
132
133impl BindingFreshness {
134    pub(super) fn permits_attempt_at(self, now: tokio::time::Instant) -> bool {
135        match self {
136            Self::Configured => true,
137            Self::ObservedUntil(deadline) => now < deadline,
138        }
139    }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub(super) enum ObservationOutcome {
144    Inserted,
145    Refreshed,
146    ConfiguredPreserved,
147    RejectedInvalid,
148    RejectedCapacity,
149}
150
151#[derive(Debug, Clone)]
152enum BindingEntry {
153    Configured(DeviceBindingTarget),
154    Observed {
155        target: DeviceBindingTarget,
156        observed_at: Instant,
157    },
158}
159
160/// The server's device bindings, behind one `RwLock`.
161///
162/// Lock order: the database guard, when a caller holds one, comes first. A
163/// read of Device_Address_Binding takes this table's read guard under the
164/// database read guard, after the COV table's guard is gone, and nothing
165/// that holds this table's guard waits for the database or the COV table,
166/// or across a send or a wait.
167#[derive(Debug, Default)]
168pub(super) struct DeviceBindingTable {
169    entries: HashMap<ObjectIdentifier, BindingEntry>,
170    /// Targeted Who-Is requests out or held off, kept under the same guard
171    /// so an I-Am can wake the writes waiting on them (#1322).
172    pub(super) probes: BindingProbes,
173}
174
175impl DeviceBindingTable {
176    /// Snapshot original sender and correlation while holding only the binding
177    /// guard. `local_network` is this network's own number, which
178    /// [`Self::source_binding`] matches against.
179    pub(super) fn command_origin(
180        &self,
181        immediate: &[u8],
182        routed: Option<&NpduAddress>,
183        local_network: Option<u16>,
184        is_group: impl Fn(&[u8]) -> bool,
185    ) -> bacnet_objects::command_source::CommandOrigin {
186        bacnet_objects::command_source::CommandOrigin::Remote {
187            actual_address: bacnet_types::constructed::BACnetAddress {
188                network_number: routed.map_or(0, |source| source.network),
189                mac_address: routed.map_or_else(
190                    || MacAddr::from_slice(immediate),
191                    |source| source.mac_address.clone(),
192                ),
193            },
194            binding: self.source_binding(immediate, routed, local_network, is_group),
195        }
196    }
197
198    /// Prefer a known, unambiguous Device identity in target Audit records.
199    /// This is address correlation, never authentication of a principal.
200    pub(super) fn source_device(
201        &self,
202        immediate: &[u8],
203        routed: Option<&NpduAddress>,
204        local_network: Option<u16>,
205        is_group: impl Fn(&[u8]) -> bool,
206    ) -> Option<ObjectIdentifier> {
207        match self.source_binding(immediate, routed, local_network, is_group) {
208            bacnet_objects::command_source::CommandDeviceBinding::Unique(device) => Some(device),
209            _ => None,
210        }
211    }
212
213    /// The Device whose binding names a request's source, when exactly one
214    /// does. A routed binding names a request relayed with its network and
215    /// final MAC as SNET and SADR. A local binding names a request from its
216    /// MAC with no SNET (Clause 6.2.2), and, once `local_network`, this
217    /// network's own number, is known, one relayed with that number and its
218    /// MAC as SNET and SADR: a router here passing a peer's request back onto
219    /// this network adds that pair (Clause 6.5.4), and network numbers are
220    /// unique, so both forms name the same node. A binding routed through
221    /// `local_network` is the local binding it is (#1404), as sends to it
222    /// take it ([`RecipientRoute::localize`], #1358).
223    pub(super) fn source_binding(
224        &self,
225        immediate: &[u8],
226        routed: Option<&NpduAddress>,
227        local_network: Option<u16>,
228        is_group: impl Fn(&[u8]) -> bool,
229    ) -> bacnet_objects::command_source::CommandDeviceBinding {
230        use bacnet_objects::command_source::CommandDeviceBinding;
231        let now = Instant::now();
232        let mut matched = None;
233        for device in self.entries.keys() {
234            let resolution = self.resolve_at(device, now, &is_group);
235            let route = RecipientRoute::from_device_resolution(resolution).localize(
236                local_network,
237                &is_group,
238                &is_group,
239            );
240            let matches = match (route, routed) {
241                (RecipientRoute::BoundLocalUnicast { mac, .. }, None) => {
242                    mac.as_slice() == immediate
243                }
244                (RecipientRoute::BoundLocalUnicast { mac, .. }, Some(source)) => {
245                    Some(source.network) == local_network && mac == source.mac_address
246                }
247                (RecipientRoute::BoundRoutedUnicast { network, mac, .. }, Some(source)) => {
248                    network == source.network && mac == source.mac_address
249                }
250                _ => false,
251            };
252            if matches {
253                if matched.is_some() {
254                    return CommandDeviceBinding::Ambiguous;
255                }
256                matched = Some(*device);
257            }
258        }
259        matched.map_or(CommandDeviceBinding::Unknown, CommandDeviceBinding::Unique)
260    }
261
262    pub(super) fn new() -> Self {
263        Self::default()
264    }
265
266    pub(super) fn from_configured(
267        bindings: Vec<DeviceBinding>,
268        is_group: impl Fn(&[u8]) -> bool,
269    ) -> Result<Self, Error> {
270        let mut table = Self::new();
271        for binding in bindings {
272            table.insert_configured(binding, &is_group)?;
273        }
274        Ok(table)
275    }
276
277    /// Immutable configured routes, already validated against the concrete link.
278    pub(super) fn configured_resolutions(
279        &self,
280    ) -> impl Iterator<Item = (ObjectIdentifier, DeviceResolution)> + '_ {
281        self.entries
282            .iter()
283            .filter(|(_, entry)| matches!(entry, BindingEntry::Configured(_)))
284            .map(|(device, _)| (*device, self.resolve_at(device, Instant::now(), |_| false)))
285    }
286
287    #[cfg(test)]
288    pub(super) fn len(&self) -> usize {
289        self.entries.len()
290    }
291
292    pub(super) fn insert_configured(
293        &mut self,
294        binding: DeviceBinding,
295        is_group: impl Fn(&[u8]) -> bool,
296    ) -> Result<(), Error> {
297        validate_device_identifier(binding.device)?;
298        if let Some((role, mac)) = group_hop(&binding.target, &is_group) {
299            return Err(binding_error(&format!(
300                "Device {} is bound at {} as {role}, a broadcast or group address of \
301                 this link; bind the device's unicast address",
302                binding.device.instance_number(),
303                colon_hex(mac)
304            )));
305        }
306        if !target_is_usable(&binding.target, &is_group) {
307            return Err(binding_error("the binding names no reachable device"));
308        }
309        if self.entries.contains_key(&binding.device) {
310            return Err(binding_error("duplicate configured Device identifier"));
311        }
312        if self.entries.len() >= MAX_DEVICE_BINDINGS {
313            return Err(binding_error("configured binding capacity exceeded"));
314        }
315        self.entries
316            .insert(binding.device, BindingEntry::Configured(binding.target));
317        Ok(())
318    }
319
320    pub(super) fn observe_i_am_at(
321        &mut self,
322        device: ObjectIdentifier,
323        source_mac: &[u8],
324        source_network: Option<&NpduAddress>,
325        now: Instant,
326        is_group: impl Fn(&[u8]) -> bool,
327    ) -> ObservationOutcome {
328        if validate_device_identifier(device).is_err() {
329            return ObservationOutcome::RejectedInvalid;
330        }
331        let target = match source_network {
332            None => DeviceBindingTarget::Local {
333                peer_mac: MacAddr::from_slice(source_mac),
334            },
335            Some(source)
336                if (1..=0xFFFE).contains(&source.network) && !source.mac_address.is_empty() =>
337            {
338                DeviceBindingTarget::Routed {
339                    network: source.network,
340                    final_mac: source.mac_address.clone(),
341                    router_mac: MacAddr::from_slice(source_mac),
342                }
343            }
344            Some(_) => return ObservationOutcome::RejectedInvalid,
345        };
346        if !target_is_usable(&target, &is_group) {
347            return ObservationOutcome::RejectedInvalid;
348        }
349
350        let outcome = self.record_observation(device, target, now);
351        if matches!(
352            outcome,
353            ObservationOutcome::Inserted | ObservationOutcome::Refreshed
354        ) {
355            self.probes.answer(&device);
356        }
357        outcome
358    }
359
360    fn record_observation(
361        &mut self,
362        device: ObjectIdentifier,
363        target: DeviceBindingTarget,
364        now: Instant,
365    ) -> ObservationOutcome {
366        match self.entries.get_mut(&device) {
367            Some(BindingEntry::Configured(_)) => ObservationOutcome::ConfiguredPreserved,
368            Some(BindingEntry::Observed {
369                target: observed,
370                observed_at,
371            }) => {
372                *observed = target;
373                *observed_at = now;
374                ObservationOutcome::Refreshed
375            }
376            None => {
377                if self.entries.len() >= MAX_DEVICE_BINDINGS {
378                    self.entries.retain(|_, entry| match entry {
379                        BindingEntry::Configured(_) => true,
380                        BindingEntry::Observed { observed_at, .. } => {
381                            now.saturating_duration_since(*observed_at) < OBSERVED_BINDING_TTL
382                        }
383                    });
384                }
385                if self.entries.len() >= MAX_DEVICE_BINDINGS {
386                    return ObservationOutcome::RejectedCapacity;
387                }
388                self.entries.insert(
389                    device,
390                    BindingEntry::Observed {
391                        target,
392                        observed_at: now,
393                    },
394                );
395                ObservationOutcome::Inserted
396            }
397        }
398    }
399
400    /// The Device object's Device_Address_Binding at `now` (Clause 12.11.34,
401    /// #1369): one BACnetAddressBinding for each configured binding and each
402    /// I-Am observation younger than [`OBSERVED_BINDING_TTL`], devices found
403    /// by a targeted Who-Is included, in Device instance order. A binding on
404    /// this network reads network 0 and the peer's MAC; a routed one reads
405    /// its network and the device's MAC there, not the router's. A stale
406    /// observation drops out here, with no sweep, as it stops routing.
407    pub(crate) fn address_binding_list(&self, now: Instant) -> PropertyValue {
408        let mut bindings: Vec<_> = self
409            .entries
410            .iter()
411            .filter_map(|(device, entry)| {
412                let target = match entry {
413                    BindingEntry::Configured(target) => target,
414                    BindingEntry::Observed {
415                        target,
416                        observed_at,
417                    } => {
418                        if now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL {
419                            return None;
420                        }
421                        target
422                    }
423                };
424                Some((*device, target))
425            })
426            .collect();
427        bindings.sort_by_key(|(device, _)| device.instance_number());
428        PropertyValue::List(
429            bindings
430                .into_iter()
431                .map(|(device, target)| {
432                    let (network, mac) = match target {
433                        DeviceBindingTarget::Local { peer_mac } => (0, peer_mac),
434                        DeviceBindingTarget::Routed {
435                            network, final_mac, ..
436                        } => (*network, final_mac),
437                    };
438                    // The device's identifier, then its BACnetAddress: the
439                    // network number and the MAC, each application tagged.
440                    let mut encoded = BytesMut::new();
441                    encode_app_object_id(&mut encoded, &device);
442                    encode_app_unsigned(&mut encoded, u64::from(network));
443                    encode_app_octet_string(&mut encoded, mac);
444                    PropertyValue::ApplicationData(encoded.to_vec())
445                })
446                .collect(),
447        )
448    }
449
450    /// Where a targeted Who-Is for `device` goes: the network its last I-Am
451    /// came from while that observation is held, or every network for a
452    /// device never heard from.
453    pub(super) fn who_is_scope(&self, device: &ObjectIdentifier) -> WhoIsScope {
454        match self.entries.get(device) {
455            Some(BindingEntry::Observed {
456                target: DeviceBindingTarget::Local { .. },
457                ..
458            }) => WhoIsScope::Local,
459            Some(BindingEntry::Observed {
460                target: DeviceBindingTarget::Routed { network, .. },
461                ..
462            }) => WhoIsScope::Remote(*network),
463            Some(BindingEntry::Configured(_)) | None => WhoIsScope::Global,
464        }
465    }
466
467    /// Drop `device`'s observation if it is stale at `now`: a Who-Is to
468    /// where it was last seen drew nothing, so its next one goes global. A
469    /// configured binding, or an observation an I-Am refreshed, stays.
470    pub(super) fn forget_stale(&mut self, device: &ObjectIdentifier, now: Instant) {
471        if let Some(BindingEntry::Observed { observed_at, .. }) = self.entries.get(device) {
472            if now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL {
473                self.entries.remove(device);
474            }
475        }
476    }
477
478    pub(super) fn resolve_at(
479        &self,
480        device: &ObjectIdentifier,
481        now: Instant,
482        is_group: impl Fn(&[u8]) -> bool,
483    ) -> DeviceResolution {
484        if device.object_type() != ObjectType::DEVICE {
485            return DeviceResolution::Invalid;
486        }
487        let (target, freshness) = match self.entries.get(device) {
488            Some(BindingEntry::Configured(target)) => (target, BindingFreshness::Configured),
489            Some(BindingEntry::Observed {
490                target,
491                observed_at,
492            }) => {
493                if now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL {
494                    return DeviceResolution::Stale;
495                }
496                (
497                    target,
498                    BindingFreshness::ObservedUntil(tokio::time::Instant::from_std(
499                        *observed_at + OBSERVED_BINDING_TTL,
500                    )),
501                )
502            }
503            None => return DeviceResolution::Unknown,
504        };
505        if !target_is_usable(target, is_group) {
506            return DeviceResolution::Invalid;
507        }
508        match target {
509            DeviceBindingTarget::Local { peer_mac } => DeviceResolution::ResolvedLocal {
510                peer_mac: peer_mac.clone(),
511                freshness,
512            },
513            DeviceBindingTarget::Routed {
514                network,
515                final_mac,
516                router_mac,
517            } => DeviceResolution::ResolvedRouted {
518                network: *network,
519                final_mac: final_mac.clone(),
520                router_mac: router_mac.clone(),
521                freshness,
522            },
523        }
524    }
525}
526
527/// The MAC of `target` that is a group address of the link, the device's own
528/// or its router's, with which of the two it is (#1493).
529fn group_hop(
530    target: &DeviceBindingTarget,
531    is_group: impl Fn(&[u8]) -> bool,
532) -> Option<(&'static str, &MacAddr)> {
533    match target {
534        DeviceBindingTarget::Local { peer_mac } => {
535            is_group(peer_mac).then_some(("its own MAC", peer_mac))
536        }
537        DeviceBindingTarget::Routed { router_mac, .. } => {
538            is_group(router_mac).then_some(("its router's MAC", router_mac))
539        }
540    }
541}
542
543/// `mac` as colon-separated hex octets, the form the address parsers take.
544fn colon_hex(mac: &[u8]) -> String {
545    mac.iter()
546        .map(|octet| format!("{octet:02x}"))
547        .collect::<Vec<_>>()
548        .join(":")
549}
550
551/// Whether a binding names one device this link can reach. A binding never
552/// takes a group address, the link's broadcast or any other such as a
553/// multicast address (`is_group`, [`TransportPort::is_group_destination`]),
554/// for its peer or its next-hop router (#1493): one names no single device,
555/// and a confirmed request sent there would be a local broadcast.
556fn target_is_usable(target: &DeviceBindingTarget, is_group: impl Fn(&[u8]) -> bool) -> bool {
557    match target {
558        DeviceBindingTarget::Local { peer_mac } => !peer_mac.is_empty() && !is_group(peer_mac),
559        DeviceBindingTarget::Routed {
560            network,
561            final_mac,
562            router_mac,
563        } => {
564            (1..=0xFFFE).contains(network)
565                && !final_mac.is_empty()
566                && !router_mac.is_empty()
567                && !is_group(router_mac)
568        }
569    }
570}
571
572/// Snapshot only services that can submit a tracked command, before the
573/// database lock. `local_network` is this network's own number as read for
574/// the request.
575pub(super) async fn snapshot_command_origin(
576    service: ConfirmedServiceChoice,
577    immediate: &[u8],
578    routed: Option<&NpduAddress>,
579    local_network: Option<u16>,
580    bindings: &Arc<RwLock<DeviceBindingTable>>,
581    transactions: &Arc<NotificationTransactions>,
582) -> Option<bacnet_objects::command_source::CommandOrigin> {
583    if !matches!(
584        service,
585        ConfirmedServiceChoice::WRITE_PROPERTY
586            | ConfirmedServiceChoice::WRITE_PROPERTY_MULTIPLE
587            | ConfirmedServiceChoice::CREATE_OBJECT
588    ) {
589        return None;
590    }
591    Some(
592        bindings
593            .read()
594            .await
595            .command_origin(immediate, routed, local_network, |mac| {
596                transactions
597                    .audit_routes
598                    .get()
599                    .is_some_and(|routes| routes.is_group(mac))
600            }),
601    )
602}
603
604#[cfg(test)]
605#[path = "group_binding_tests.rs"]
606mod group_binding_tests;