Skip to main content

bacnet_server/server/
dcc_policy.rs

1use super::{BipServerBuilder, ServerBuilder, TransportPort};
2use bacnet_encoding::npdu::NpduAddress;
3use bacnet_types::constructed::BACnetAddress;
4use bacnet_types::error::Error;
5
6/// A source address length a DCC or time-sync restriction entry can hold and
7/// match: 1 to [`BACnetAddress::MAX_MAC_LEN`] octets. Every routed source and
8/// every built-in transport's MAC fits that bound (#1141), so a longer entry
9/// could never match anything (#1157, #1266).
10pub(super) fn address_length_fits(length: usize) -> bool {
11    (1..=BACnetAddress::MAX_MAC_LEN).contains(&length)
12}
13
14/// Whether a routed DCC or time-sync allowlist entry for `network` and
15/// `address` names the node that sent a request from link MAC `mac` with no
16/// SNET (#1458): true only when `local_network`, this network's own number,
17/// is known and is `network`, and `mac` is `address`. The entry is taken as
18/// the local route [`RecipientRoute::localize`] makes of it, as a send to
19/// that address would be (#1358). While the number is unknown, or for any
20/// other network, the entry names only the routed source it spells out.
21///
22/// Only this direction widens. A Device binding also takes a request relayed
23/// with this network's number as its direct station (#1404); an allowlist
24/// does not. A direct entry still matches no routed source: any node on the
25/// link can claim this network's number and a MAC as SNET and SADR, while a
26/// direct entry names the link source itself.
27///
28/// [`RecipientRoute::localize`]: super::event_recipient_route::RecipientRoute::localize
29pub(super) fn routed_entry_names_direct_source(
30    network: u16,
31    address: &[u8],
32    mac: &[u8],
33    local_network: Option<u16>,
34) -> bool {
35    use super::event_recipient_route::RecipientRoute;
36    let route = RecipientRoute::RemoteUnicast {
37        network,
38        mac: bacnet_types::MacAddr::from_slice(address),
39    };
40    // A source MAC is never a link broadcast, so none is assumed here.
41    matches!(route.localize(local_network, |_| false, |_| false),
42        RecipientRoute::LocalUnicast(station) if station.as_slice() == mac)
43}
44
45/// Exact claimed DCC source, not an authenticated principal (including SC VMAC).
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum DccSource {
48    /// Full link source MAC bytes, used only when no routed source is present.
49    Direct(Vec<u8>),
50    /// Full routed source network and address, independent of the immediate router.
51    /// Once the server knows its own network's number, an entry on that
52    /// network also matches the station's direct requests from `address`
53    /// with no routed source (#1458).
54    Routed {
55        /// Claimed source network (1..=65534).
56        network: u16,
57        /// Complete claimed source address (1 to
58        /// [`BACnetAddress::MAX_MAC_LEN`] octets).
59        address: Vec<u8>,
60    },
61}
62
63/// Validated static exact-source restriction. An empty list denies every source.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct DccSourceRestriction(Vec<DccSource>);
66
67impl DccSourceRestriction {
68    /// Accept at most 256 entries, each with 1 to
69    /// [`BACnetAddress::MAX_MAC_LEN`] (18) address octets, the longest source
70    /// address the network layer delivers (#1157). Routed networks must be
71    /// 1..=65534. These are local configuration limits.
72    pub fn new(sources: Vec<DccSource>) -> Result<Self, Error> {
73        if sources.len() > 256 {
74            return Err(Error::Encoding(
75                "DCC source restriction allows at most 256 entries".into(),
76            ));
77        }
78        for source in &sources {
79            let address = match source {
80                DccSource::Direct(address) => address,
81                DccSource::Routed { network, address } => {
82                    if !(1..=65534).contains(network) {
83                        return Err(Error::Encoding(
84                            "DCC routed source network must be 1..=65534".into(),
85                        ));
86                    }
87                    address
88                }
89            };
90            if !address_length_fits(address.len()) {
91                return Err(Error::Encoding(format!(
92                    "DCC source address must contain 1..={} octets",
93                    BACnetAddress::MAX_MAC_LEN
94                )));
95            }
96        }
97        Ok(Self(sources))
98    }
99
100    /// Reject a configured restriction unless password-required policy is explicit.
101    pub fn validate_policy(&self, policy: DccPolicy) -> Result<(), Error> {
102        if policy != DccPolicy::RequirePassword {
103            return Err(Error::Encoding(
104                "DCC source restriction requires RequirePassword policy".into(),
105            ));
106        }
107        Ok(())
108    }
109
110    /// Whether a request from link MAC `mac`, with `routed` as its SNET and
111    /// SADR when a router relayed it, comes from a listed source.
112    /// `local_network` is this network's own number, once known: a routed
113    /// entry on it also lists the station's direct requests
114    /// ([`routed_entry_names_direct_source`], #1458).
115    pub(super) fn allows(
116        &self,
117        mac: &[u8],
118        routed: Option<&NpduAddress>,
119        local_network: Option<u16>,
120    ) -> bool {
121        // Never use the admission canonicalizer: its malformed routed fallback
122        // is not an authorization identity. Compare all bytes, not telemetry.
123        match routed {
124            Some(source) => {
125                (1..=65534).contains(&source.network)
126                    && address_length_fits(source.mac_address.len())
127                    && self.0.iter().any(|entry| matches!(entry,
128                        DccSource::Routed { network, address }
129                        if *network == source.network && address.as_slice() == source.mac_address.as_slice()))
130            }
131            None => {
132                address_length_fits(mac.len())
133                    && self.0.iter().any(|entry| match entry {
134                        DccSource::Direct(address) => address.as_slice() == mac,
135                        DccSource::Routed { network, address } => {
136                            routed_entry_names_direct_source(*network, address, mac, local_network)
137                        }
138                    })
139            }
140        }
141    }
142}
143
144impl super::ServerConfig {
145    pub(super) fn validate_dcc_config(&self) -> Result<(), Error> {
146        self.dcc_policy.validate(&self.dcc_password)?;
147        if let Some(restriction) = &self.dcc_source_restriction {
148            restriction.validate_policy(self.dcc_policy)?;
149        }
150        if let Some(limit) = self.dcc_disable_rate_limit {
151            limit.validate()?;
152        }
153        Ok(())
154    }
155}
156
157/// Local DeviceCommunicationControl authorization, not source authentication.
158#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
159pub enum DccPolicy {
160    /// Deny all valid DCC modes, even with a correct configured password.
161    #[default]
162    DenyAll,
163    /// Allow supported modes only with a configured nonempty password.
164    RequirePassword,
165    /// INSECURE compatibility mode: preserve optional-password authorization.
166    /// A configured password is still checked; absence permits any requester.
167    LegacyPermissive,
168}
169
170impl DccPolicy {
171    /// Validate operator configuration before transport startup or dialing.
172    pub fn validate(self, password: &Option<String>) -> Result<(), Error> {
173        if self == Self::RequirePassword && password.as_ref().is_none_or(String::is_empty) {
174            return Err(Error::Encoding(
175                "RequirePassword DCC policy requires a nonempty dcc_password".into(),
176            ));
177        }
178        Ok(())
179    }
180}
181
182impl<T: TransportPort + 'static> ServerBuilder<T> {
183    /// Set the password required for DeviceCommunicationControl requests.
184    pub fn dcc_password(mut self, password: impl Into<String>) -> Self {
185        self.config.dcc_password = Some(password.into());
186        self
187    }
188
189    /// Restrict claimed DCC sources; None preserves unrestricted policy behavior.
190    pub fn dcc_source_restriction(mut self, restriction: Option<DccSourceRestriction>) -> Self {
191        self.config.dcc_source_restriction = restriction;
192        self
193    }
194    /// Select explicit local DCC authorization (default: deny all).
195    pub fn dcc_policy(mut self, policy: DccPolicy) -> Self {
196        self.config.dcc_policy = policy;
197        self
198    }
199}
200
201impl BipServerBuilder {
202    /// Set the password required for DeviceCommunicationControl requests.
203    pub fn dcc_password(mut self, password: impl Into<String>) -> Self {
204        self.config.dcc_password = Some(password.into());
205        self
206    }
207
208    /// Restrict claimed DCC sources; requires explicit RequirePassword policy.
209    pub fn dcc_source_restriction(mut self, restriction: Option<DccSourceRestriction>) -> Self {
210        self.config.dcc_source_restriction = restriction;
211        self
212    }
213    /// Select explicit local DCC authorization (default: deny all).
214    pub fn dcc_policy(mut self, policy: DccPolicy) -> Self {
215        self.config.dcc_policy = policy;
216        self
217    }
218}