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}