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}