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