Skip to main content

bacnet_server/server/
sc_builder.rs

1use super::*;
2
3#[cfg(test)]
4#[path = "sc_dcc_mtls_tests.rs"]
5mod dcc_mtls_tests;
6
7impl BACnetServer<bacnet_transport::sc::ScTransport<bacnet_transport::sc_tls::TlsWebSocket>> {
8    /// Create an SC-specific builder that connects to a BACnet/SC hub.
9    #[cfg_attr(docsrs, doc(cfg(feature = "sc-tls")))]
10    pub fn sc_builder() -> ScServerBuilder {
11        ScServerBuilder {
12            config: ServerConfig::default(),
13            db: None,
14            configured_device_bindings: Vec::new(),
15            hub_url: String::new(),
16            tls_config: None,
17            vmac: [0; 6],
18            device_uuid: [0; 16],
19            heartbeat_interval_ms: 30_000,
20            heartbeat_timeout_ms: 60_000,
21            reconnect: None,
22        }
23    }
24}
25
26/// SC-specific server builder.
27///
28/// Created by [`BACnetServer::sc_builder()`]. Requires the `sc-tls` feature.
29/// A caller-provisioned, nonzero [`device_uuid`](Self::device_uuid) and validated
30/// [`tls_config`](Self::tls_config) are required before `build` can connect.
31#[cfg_attr(docsrs, doc(cfg(feature = "sc-tls")))]
32pub struct ScServerBuilder {
33    pub(super) config: ServerConfig,
34    db: Option<ObjectDatabase>,
35    pub(super) configured_device_bindings: Vec<DeviceBinding>,
36    hub_url: String,
37    tls_config: Option<bacnet_transport::sc_tls::ScNodeTlsConfig>,
38    vmac: bacnet_transport::sc_frame::Vmac,
39    device_uuid: [u8; 16],
40    heartbeat_interval_ms: u64,
41    heartbeat_timeout_ms: u64,
42    reconnect: Option<bacnet_transport::sc::ScReconnectConfig>,
43}
44
45impl ScServerBuilder {
46    /// Set the hub WebSocket URL (e.g. `wss://hub.example.com/bacnet`).
47    pub fn hub_url(mut self, url: &str) -> Self {
48        self.hub_url = url.to_string();
49        self
50    }
51
52    /// Set the validated local node TLS policy, shared across initial and
53    /// reconnect attempts (including normal TLS resumption).
54    ///
55    /// ```
56    /// use bacnet_server::server::{BACnetServer, ScServerBuilder};
57    /// use bacnet_transport::sc_tls::ScNodeTlsConfig;
58    /// fn configured(tls: ScNodeTlsConfig) -> ScServerBuilder {
59    ///     BACnetServer::sc_builder().tls_config(tls)
60    /// }
61    /// ```
62    ///
63    /// ```compile_fail,E0308
64    /// use bacnet_server::server::BACnetServer;
65    /// fn raw(config: std::sync::Arc<tokio_rustls::rustls::ClientConfig>) {
66    ///     let _ = BACnetServer::sc_builder().tls_config(config);
67    /// }
68    /// ```
69    pub fn tls_config(mut self, config: bacnet_transport::sc_tls::ScNodeTlsConfig) -> Self {
70        self.tls_config = Some(config);
71        self
72    }
73
74    /// Set the local VMAC address.
75    pub fn vmac(mut self, vmac: [u8; 6]) -> Self {
76        self.vmac = vmac;
77        self
78    }
79
80    /// Set the caller-provisioned, persistent BACnet/SC device UUID.
81    ///
82    /// The caller must generate it before deployment, store it durably, and reuse
83    /// the same bytes for the device's lifetime, including restarts. Distinct
84    /// devices must not share a UUID. This builder neither generates nor stores
85    /// identities across lifecycles and cannot detect a changed provisioned UUID.
86    /// `build` rejects missing/all-zero UUIDs before dialing; it does not enforce
87    /// UUID version or variant bits.
88    pub fn device_uuid(mut self, uuid: [u8; 16]) -> Self {
89        self.device_uuid = uuid;
90        self
91    }
92
93    /// Set the one Device identity. With no supplied database, startup creates
94    /// a stock Device after resolving the owned transport's receive capacity.
95    pub fn device(mut self, identity: bacnet_objects::DeviceIdentity) -> Self {
96        self.config.device = identity;
97        self
98    }
99
100    /// Set the object database (transfers ownership).
101    pub fn database(mut self, db: ObjectDatabase) -> Self {
102        self.db = Some(db);
103        self
104    }
105
106    /// Register one explicit unicast route for a Device recipient.
107    pub fn device_binding(mut self, binding: DeviceBinding) -> Result<Self, Error> {
108        register_configured_binding(&mut self.configured_device_bindings, binding)?;
109        Ok(self)
110    }
111
112    /// Set the heartbeat interval in milliseconds (default 30 000).
113    pub fn heartbeat_interval_ms(mut self, ms: u64) -> Self {
114        self.heartbeat_interval_ms = ms;
115        self
116    }
117
118    /// Set the heartbeat timeout in milliseconds (default 60 000).
119    pub fn heartbeat_timeout_ms(mut self, ms: u64) -> Self {
120        self.heartbeat_timeout_ms = ms;
121        self
122    }
123
124    /// Enable automatic reconnection with the given configuration.
125    pub fn reconnect(mut self, config: bacnet_transport::sc::ScReconnectConfig) -> Self {
126        self.reconnect = Some(config);
127        self
128    }
129
130    /// Set the password required for DeviceCommunicationControl requests.
131    pub fn dcc_password(mut self, password: impl Into<String>) -> Self {
132        self.config.dcc_password = Some(password.into());
133        self
134    }
135
136    /// Select explicit local DCC authorization (default: deny all).
137    pub fn dcc_policy(mut self, policy: DccPolicy) -> Self {
138        self.config.dcc_policy = policy;
139        self
140    }
141
142    /// Restrict claimed DCC sources; requires explicit RequirePassword policy.
143    pub fn dcc_source_restriction(
144        mut self,
145        restriction: Option<super::DccSourceRestriction>,
146    ) -> Self {
147        self.config.dcc_source_restriction = restriction;
148        self
149    }
150
151    /// Limit authorized DISABLE_INITIATION globally; None (default) disables it.
152    pub fn dcc_disable_rate_limit(mut self, limit: Option<super::DccDisableRateLimit>) -> Self {
153        self.config.dcc_disable_rate_limit = limit;
154        self
155    }
156
157    /// Select the only local Audit Log that receives authorized notifications.
158    pub fn audit_notification_sink(mut self, sink: ObjectIdentifier) -> Self {
159        self.config.audit_notification_sink = Some(sink);
160        self
161    }
162
163    /// Enable periodic fault detection / reliability evaluation.
164    ///
165    /// When enabled, every object's opt-in reliability hook runs every 10
166    /// seconds; the default hook is a no-op.
167    pub fn enable_fault_detection(mut self, enabled: bool) -> Self {
168        self.config.enable_fault_detection = enabled;
169        self
170    }
171
172    /// Enable periodic Event Enrollment evaluation (default `true`).
173    pub fn enable_event_enrollment(mut self, enabled: bool) -> Self {
174        self.config.enable_event_enrollment = enabled;
175        self
176    }
177
178    /// Set the interval in seconds between Event Enrollment evaluation passes.
179    pub fn event_enrollment_interval_secs(mut self, secs: u64) -> Self {
180        self.config.event_enrollment_interval_secs = secs;
181        self
182    }
183
184    /// Set the COV quota and notification work budget policy, checked by
185    /// [`CovPolicy::validate`] before SC dialing.
186    pub fn cov_policy(mut self, policy: CovPolicy) -> Self {
187        self.config.cov_policy = policy;
188        self
189    }
190
191    /// Connect to the hub and start the server.
192    ///
193    /// Reconnect configuration is validated before UUID validation, binding-table
194    /// construction, TLS lookup, or dialing, and again when the transport starts. An error
195    /// still consumes this builder and drops its inputs; this does not promise
196    /// generic endpoint rollback.
197    pub async fn build(
198        mut self,
199    ) -> Result<
200        BACnetServer<bacnet_transport::sc::ScTransport<bacnet_transport::sc_tls::TlsWebSocket>>,
201        Error,
202    > {
203        if let Some(config) = &self.reconnect {
204            config.validate()?;
205        }
206        if self.device_uuid == [0; 16] {
207            return Err(Error::Encoding(
208                "SC server builder: device_uuid is required and must not be all zero".into(),
209            ));
210        }
211        // The SC link's only group address is its broadcast VMAC.
212        DeviceBindingTable::from_configured(self.configured_device_bindings.clone(), |mac| {
213            mac == bacnet_transport::sc_frame::BROADCAST_VMAC
214        })?;
215
216        let tls_config = self
217            .tls_config
218            .ok_or_else(|| Error::Encoding("SC server builder: tls_config is required".into()))?;
219
220        self.config.request_admission_policy.validate()?;
221        self.config.validate_dcc_config()?;
222        self.config.time_sync_policy.validate()?;
223        self.config.read_property_multiple_budget.validate()?;
224        self.config.get_alarm_summary_budget.validate()?;
225        self.config.get_enrollment_summary_budget.validate()?;
226        self.config.atomic_read_file_budget.validate()?;
227        self.config.atomic_write_file_budget.validate()?;
228        self.config.read_range_budget.validate()?;
229        self.config.get_event_information_budget.validate()?;
230        self.config.cov_policy.validate()?;
231        let db = crate::local_device::prepare_database(
232            &mut self.config, self.db,
233            bacnet_transport::sc::ScTransport::<bacnet_transport::sc_tls::TlsWebSocket>::LOCAL_RECEIVE_APDU_CAPACITY,
234        )?;
235        super::segmented_receive::validate_segment_timeout(&self.config)?;
236
237        let ws = bacnet_transport::sc_tls::TlsWebSocket::connect(&self.hub_url, tls_config.clone())
238            .await?;
239
240        let mut transport = bacnet_transport::sc::ScTransport::new(ws, self.vmac)
241            .with_device_uuid(self.device_uuid)
242            .with_heartbeat_interval_ms(self.heartbeat_interval_ms)
243            .with_heartbeat_timeout_ms(self.heartbeat_timeout_ms);
244        if let Some(rc) = self.reconnect {
245            let hub_url = self.hub_url.clone();
246            let tls_config = tls_config.clone();
247            transport = transport
248                .with_connector(move || {
249                    let hub_url = hub_url.clone();
250                    let tls_config = tls_config.clone();
251                    async move {
252                        bacnet_transport::sc_tls::TlsWebSocket::connect(&hub_url, tls_config).await
253                    }
254                })
255                .with_reconnect(rc);
256        }
257
258        BACnetServer::start_with_clock_mode_and_bindings(
259            self.config,
260            db,
261            transport,
262            Some(ClockConfig::default()),
263            self.configured_device_bindings,
264        )
265        .await
266    }
267}
268
269#[cfg(test)]
270pub(super) const TEST_DEVICE_UUID: [u8; 16] = [
271    0x8e, 0x62, 0xac, 0x46, 0xd7, 0x08, 0x42, 0x26, 0x91, 0x37, 0x76, 0xa3, 0x2b, 0x61, 0x93, 0x15,
272];
273
274#[cfg(test)]
275pub(super) fn test_tls_config() -> bacnet_transport::sc_tls::ScNodeTlsConfig {
276    let rcgen::CertifiedKey { cert, signing_key } =
277        rcgen::generate_simple_self_signed(vec!["node".into()]).unwrap();
278    bacnet_transport::sc_tls::ScNodeTlsConfig::from_der(
279        vec![cert.der().clone()],
280        vec![cert.der().clone()],
281        tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from(signing_key.serialize_der())
282            .into(),
283    )
284    .unwrap()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use bacnet_transport::sc::ScReconnectConfig;
291
292    #[tokio::test]
293    async fn sc_server_builder_requires_device_uuid_before_dial() {
294        for explicit_zero in [false, true] {
295            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
296            listener.set_nonblocking(true).unwrap();
297            let url = format!("wss://{}", listener.local_addr().unwrap());
298            let mut builder = BACnetServer::sc_builder()
299                .hub_url(&url)
300                .tls_config(test_tls_config());
301            if explicit_zero {
302                builder = builder.device_uuid([0; 16]);
303            }
304            let error = tokio::time::timeout(std::time::Duration::from_secs(1), builder.build())
305                .await
306                .expect("UUID failure must not wait for TLS/network I/O")
307                .err()
308                .expect("missing/zero UUID must fail before dialing");
309            assert!(
310                matches!(&error, Error::Encoding(message) if message == "SC server builder: device_uuid is required and must not be all zero"),
311                "expected UUID configuration error, got {error:?}"
312            );
313            assert_eq!(
314                listener.accept().unwrap_err().kind(),
315                std::io::ErrorKind::WouldBlock
316            );
317            // Qualify the same accept queue with a real connection.
318            let _probe = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
319            let listener = tokio::net::TcpListener::from_std(listener).unwrap();
320            let _accepted =
321                tokio::time::timeout(std::time::Duration::from_secs(1), listener.accept())
322                    .await
323                    .unwrap()
324                    .unwrap();
325        }
326    }
327
328    #[tokio::test]
329    async fn alarm_summary_sc_budget_validation_before_dial() {
330        for budget in [
331            GetAlarmSummaryBudget {
332                max_objects: 0,
333                ..Default::default()
334            },
335            GetAlarmSummaryBudget {
336                max_service_ack_bytes: 0,
337                ..Default::default()
338            },
339        ] {
340            let builder = BACnetServer::sc_builder()
341                .hub_url("not-a-websocket-url")
342                .tls_config(test_tls_config())
343                .device_uuid(TEST_DEVICE_UUID)
344                .get_alarm_summary_budget(budget);
345            assert_eq!(builder.config.get_alarm_summary_budget, budget);
346            assert!(
347                matches!(builder.build().await.err(), Some(Error::Encoding(m)) if m.contains("alarm_summary_max_"))
348            );
349        }
350    }
351
352    #[tokio::test]
353    async fn rpm_sc_budget_validation_before_dial() {
354        for budget in [
355            ReadPropertyMultipleBudget {
356                max_result_elements: 0,
357                ..Default::default()
358            },
359            ReadPropertyMultipleBudget {
360                max_service_ack_bytes: 0,
361                ..Default::default()
362            },
363        ] {
364            let builder = BACnetServer::sc_builder()
365                .hub_url("not-a-websocket-url")
366                .tls_config(test_tls_config())
367                .device_uuid(TEST_DEVICE_UUID)
368                .read_property_multiple_budget(budget);
369            assert_eq!(builder.config.read_property_multiple_budget, budget);
370            let error = builder.build().await.err().unwrap();
371            assert!(matches!(error, Error::Encoding(m) if m.contains("rpm_max_")));
372            let error = BACnetServer::sc_builder()
373                .device_uuid(TEST_DEVICE_UUID)
374                .read_property_multiple_budget(budget)
375                .build()
376                .await
377                .err()
378                .unwrap();
379            assert!(matches!(error, Error::Encoding(m) if m.contains("tls_config")));
380        }
381    }
382
383    #[tokio::test]
384    async fn sc_server_builder_rejects_invalid_reconnect_before_tls_and_bindings() {
385        for broadcast_binding in [false, true] {
386            for max_retries in [0, 10] {
387                for (initial_delay_ms, max_delay_ms) in [(0, 1), (1, 0), (0, 0), (2, 1)] {
388                    let mut builder = BACnetServer::sc_builder().reconnect(ScReconnectConfig {
389                        initial_delay_ms,
390                        max_delay_ms,
391                        max_retries,
392                    });
393                    if broadcast_binding {
394                        let device = ObjectIdentifier::new(ObjectType::DEVICE, 46).unwrap();
395                        let binding = DeviceBinding::local(
396                            device,
397                            bacnet_transport::sc_frame::BROADCAST_VMAC,
398                        )
399                        .unwrap();
400                        builder = builder.device_binding(binding).unwrap();
401                    }
402                    let error = builder
403                        .build()
404                        .await
405                        .err()
406                        .expect("invalid reconnect must fail");
407                    assert!(
408                        matches!(&error, Error::OutOfRange(message) if message.contains("reconnect")),
409                        "expected reconnect error before TLS/bindings, got {error:?}"
410                    );
411                }
412            }
413        }
414    }
415
416    #[tokio::test]
417    async fn sc_server_builder_valid_reconnect_preserves_missing_tls_error() {
418        for reconnect in [
419            None,
420            Some(ScReconnectConfig::default()),
421            Some(ScReconnectConfig {
422                initial_delay_ms: 1,
423                max_delay_ms: 1,
424                max_retries: 0,
425            }),
426            Some(ScReconnectConfig {
427                initial_delay_ms: 1,
428                max_delay_ms: 2,
429                max_retries: u32::MAX,
430            }),
431        ] {
432            let mut builder = BACnetServer::sc_builder().device_uuid(TEST_DEVICE_UUID);
433            if let Some(config) = reconnect {
434                builder = builder.reconnect(config);
435            }
436            assert!(matches!(
437                builder.build().await,
438                Err(Error::Encoding(message)) if message == "SC server builder: tls_config is required"
439            ));
440        }
441    }
442
443    #[test]
444    fn sc_server_builder_cov_policy_configures_server_config() {
445        let policy = CovPolicy {
446            max_subscriptions_per_peer: 12,
447            ..CovPolicy::default()
448        };
449        let builder = BACnetServer::sc_builder().cov_policy(policy.clone());
450        assert_eq!(builder.config.cov_policy, policy);
451    }
452
453    #[tokio::test]
454    async fn sc_invalid_cov_policy_precedes_dial() {
455        let error = BACnetServer::sc_builder()
456            .hub_url("not-a-websocket-url")
457            .tls_config(test_tls_config())
458            .device_uuid(TEST_DEVICE_UUID)
459            .cov_policy(CovPolicy {
460                max_subscriptions_global: 0,
461                ..CovPolicy::default()
462            })
463            .build()
464            .await
465            .err()
466            .unwrap();
467        assert!(matches!(error, Error::Encoding(m) if m.contains("max_subscriptions_global")));
468    }
469
470    #[tokio::test]
471    async fn admission_sc_invalid_policy_precedes_dial_and_preserves_reconnect_precedence() {
472        for bad in [0, usize::MAX] {
473            let policy = RequestAdmissionPolicy {
474                max_confirmed_in_flight: 1,
475                confirmed_recovery_reserve: 0,
476                max_unconfirmed_in_flight: bad,
477                ..Default::default()
478            };
479            let error = BACnetServer::sc_builder()
480                .hub_url("not-a-websocket-url")
481                .tls_config(test_tls_config())
482                .device_uuid(TEST_DEVICE_UUID)
483                .request_admission_policy(policy)
484                .build()
485                .await
486                .err()
487                .unwrap();
488            assert!(matches!(error, Error::Encoding(m) if m.contains("max_unconfirmed_in_flight")));
489            let error = BACnetServer::sc_builder()
490                .request_admission_policy(policy)
491                .reconnect(ScReconnectConfig {
492                    initial_delay_ms: 0,
493                    ..Default::default()
494                })
495                .build()
496                .await
497                .err()
498                .unwrap();
499            assert!(matches!(error, Error::OutOfRange(m) if m.contains("reconnect")));
500        }
501    }
502
503    #[tokio::test]
504    async fn admission_sc_invalid_policy_recovery_precedes_dial() {
505        for reserve in [64, 65] {
506            let policy = RequestAdmissionPolicy {
507                confirmed_recovery_reserve: reserve,
508                ..Default::default()
509            };
510            let error = BACnetServer::sc_builder()
511                .hub_url("not-a-websocket-url")
512                .tls_config(test_tls_config())
513                .device_uuid(TEST_DEVICE_UUID)
514                .request_admission_policy(policy)
515                .build()
516                .await
517                .err()
518                .unwrap();
519            assert!(
520                matches!(error, Error::Encoding(m) if m.contains("confirmed_recovery_reserve"))
521            );
522            let error = BACnetServer::sc_builder()
523                .request_admission_policy(policy)
524                .reconnect(ScReconnectConfig {
525                    initial_delay_ms: 0,
526                    ..Default::default()
527                })
528                .build()
529                .await
530                .err()
531                .unwrap();
532            assert!(matches!(error, Error::OutOfRange(m) if m.contains("reconnect")));
533        }
534    }
535
536    #[tokio::test]
537    async fn admission_sc_invalid_policy_peer_limits_precede_dial() {
538        for confirmed in [true, false] {
539            for bad in [0, usize::MAX] {
540                let mut policy = RequestAdmissionPolicy::default();
541                let name = if confirmed {
542                    policy.max_confirmed_in_flight_per_peer = bad;
543                    "max_confirmed_in_flight_per_peer"
544                } else {
545                    policy.max_unconfirmed_in_flight_per_peer = bad;
546                    "max_unconfirmed_in_flight_per_peer"
547                };
548                let builder = BACnetServer::sc_builder()
549                    .hub_url("not-a-websocket-url")
550                    .tls_config(test_tls_config())
551                    .device_uuid(TEST_DEVICE_UUID)
552                    .request_admission_policy(policy);
553                assert_eq!(builder.config.request_admission_policy, policy);
554                let error = builder.build().await.err().unwrap();
555                assert!(matches!(error, Error::Encoding(m) if m.contains(name)));
556            }
557        }
558    }
559}