Skip to main content

bacnet_server/server/
dcc_policy.rs

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