onvif_server/server.rs
1// Server implementation — builder skeleton wired in plan 03; actual server start in phase 2
2use std::collections::HashSet;
3use std::sync::Arc;
4
5use crate::service::device::DeviceServiceHandler;
6use crate::service::events::EventServiceHandler;
7use crate::service::imaging::ImagingServiceHandler;
8use crate::service::media::MediaServiceHandler;
9use crate::service::ptz::PTZServiceHandler;
10use crate::traits::{DeviceService, EventService, ImagingService, MediaService, PTZService};
11use crate::wsdl_loader::EmbeddedWsdlLoader;
12
13/// Error returned by [`OnvifServerBuilder::build`] when required configuration is missing.
14#[derive(Debug, thiserror::Error)]
15pub enum BuildError {
16 #[error("Required service not registered: {0}")]
17 MissingRequiredService(String),
18}
19
20/// Error returned by [`OnvifServer::run`].
21///
22/// Distinguishes between startup/configuration failures and I/O failures at bind time.
23#[derive(Debug, thiserror::Error)]
24pub enum RunError {
25 /// The server failed to bind or serve on the configured port (I/O error).
26 #[error("I/O error: {0}")]
27 Io(#[from] std::io::Error),
28 /// A required service was not registered (validated again at run time for services
29 /// beyond `device_service`, which is checked at build time).
30 #[error("Startup error: {0}")]
31 Startup(String),
32}
33
34/// A built, configured ONVIF server handle.
35///
36/// Phase 1 stores all builder fields for Phase 2 to use when actually binding a port
37/// and starting the soap-server. No network activity occurs in Phase 1.
38///
39/// Fields are intentionally `pub(crate)` to prevent consumers from bypassing the
40/// builder API or accessing credentials directly. Use the provided accessor methods
41/// for fields that consumers legitimately need.
42pub struct OnvifServer {
43 pub(crate) port: u16,
44 pub(crate) username: Option<String>,
45 pub(crate) password: Option<String>,
46 pub(crate) device_service: Option<Arc<dyn DeviceService>>,
47 pub(crate) media_service: Option<Arc<dyn MediaService>>,
48 pub(crate) ptz_service: Option<Arc<dyn PTZService>>,
49 pub(crate) imaging_service: Option<Arc<dyn ImagingService>>,
50 pub(crate) event_service: Option<Arc<dyn EventService>>,
51 pub(crate) auth_bypass: HashSet<String>,
52 pub(crate) advertised_host: String,
53 /// Stable WS-Discovery EndpointReference UUID for this device (F-7).
54 ///
55 /// ONVIF WS-Discovery requires the EndpointReference Address to be a stable
56 /// per-device identity across all discovery cycles. When explicitly set via
57 /// [`OnvifServerBuilder::discovery_uuid`], that UUID is used verbatim. When
58 /// unset, the server derives a stable UUID-v5 from the advertised host so the
59 /// same device always produces the same endpoint identity.
60 pub(crate) discovery_uuid: uuid::Uuid,
61}
62
63impl OnvifServer {
64 /// Returns the port this server is configured to listen on.
65 pub fn port(&self) -> u16 {
66 self.port
67 }
68
69 /// Returns the advertised host used in XAddrs for WS-Discovery and capabilities.
70 pub fn advertised_host(&self) -> &str {
71 &self.advertised_host
72 }
73
74 /// Returns the stable WS-Discovery EndpointReference UUID for this device.
75 ///
76 /// ONVIF conformance (F-7) requires this to be identical across all discovery
77 /// cycles. Set it explicitly via [`OnvifServerBuilder::discovery_uuid`], or let
78 /// the builder derive a stable UUID-v5 from the advertised host.
79 pub fn discovery_uuid(&self) -> uuid::Uuid {
80 self.discovery_uuid
81 }
82
83 /// Returns the configured username, if any.
84 pub fn username(&self) -> Option<&str> {
85 self.username.as_deref()
86 }
87
88 /// Create a new builder with default settings.
89 ///
90 /// Defaults: port 8080, `GetSystemDateAndTime` pre-registered as an auth bypass
91 /// operation (per ONVIF spec — clock sync must work without credentials).
92 pub fn builder() -> OnvifServerBuilder {
93 OnvifServerBuilder::new()
94 }
95
96 /// Build the merged axum `Router` for all registered services (device + any registered
97 /// media/ptz/imaging/events), WITHOUT binding a port or starting the WS-Discovery UDP task.
98 /// Used by `run()` and by in-process harnesses/tests (axum_test).
99 pub fn into_router(self) -> Result<axum::Router, RunError> {
100 let device_svc = self
101 .device_service
102 .ok_or_else(|| RunError::Startup("device_service is required".into()))?;
103
104 let xaddr = format!(
105 "http://{}:{}/onvif/device_service",
106 self.advertised_host, self.port
107 );
108
109 // Build optional XAddrs — only Some when the corresponding service is registered.
110 let media_xaddr = self.media_service.as_ref().map(|_| {
111 format!(
112 "http://{}:{}/onvif/media_service",
113 self.advertised_host, self.port
114 )
115 });
116 let ptz_xaddr = self.ptz_service.as_ref().map(|_| {
117 format!(
118 "http://{}:{}/onvif/ptz_service",
119 self.advertised_host, self.port
120 )
121 });
122 let imaging_xaddr = self.imaging_service.as_ref().map(|_| {
123 format!(
124 "http://{}:{}/onvif/imaging_service",
125 self.advertised_host, self.port
126 )
127 });
128 let events_xaddr = self.event_service.as_ref().map(|_| {
129 format!(
130 "http://{}:{}/onvif/events_service",
131 self.advertised_host, self.port
132 )
133 });
134
135 let handler = DeviceServiceHandler::new(
136 device_svc,
137 xaddr,
138 media_xaddr.clone().unwrap_or_default(),
139 ptz_xaddr.clone().unwrap_or_default(),
140 imaging_xaddr.clone().unwrap_or_default(),
141 events_xaddr.clone().unwrap_or_default(),
142 );
143
144 let auth_bypass = self.auth_bypass;
145 let credentials = self
146 .username
147 .as_ref()
148 .zip(self.password.as_ref())
149 .map(|(u, p)| (u.clone(), p.clone()));
150
151 // Helper macro: builds a soap_server::ServerBuilder for a given WSDL/path,
152 // attaches auth only when credentials are configured, then calls .build().
153 macro_rules! build_service {
154 ($wsdl_bytes:expr, $path:expr, $handler:expr, $bypass_iter:expr) => {{
155 let mut b = soap_server::ServerBuilder::from_wsdl_bytes_with_loader(
156 $wsdl_bytes.to_vec(),
157 EmbeddedWsdlLoader,
158 )
159 .path($path)
160 .default_handler($handler)
161 .auth_bypass($bypass_iter);
162
163 if let Some((ref u, ref p)) = credentials {
164 let u = u.clone();
165 let p = p.clone();
166 b = b.auth(move |user: &str| -> Option<String> {
167 if user == u {
168 Some(p.clone())
169 } else {
170 None
171 }
172 });
173 }
174
175 b.build()
176 .map_err(|e| RunError::Startup(format!("ServerBuilder::build failed: {e}")))?
177 }};
178 }
179
180 let soap_svc = build_service!(
181 include_bytes!("../wsdl/devicemgmt.wsdl"),
182 "/onvif/device_service",
183 handler,
184 auth_bypass.into_iter()
185 );
186
187 let mut router = soap_svc.into_router();
188
189 // Mount optional services — only when registered.
190 if let Some(media_svc) = self.media_service {
191 let media_handler =
192 MediaServiceHandler::new(media_svc, media_xaddr.as_deref().unwrap_or_default());
193 let media_soap_svc = build_service!(
194 include_bytes!("../wsdl/media.wsdl"),
195 "/onvif/media_service",
196 media_handler,
197 std::iter::empty::<String>()
198 );
199 router = router.merge(media_soap_svc.into_router());
200 }
201
202 if let Some(ptz_svc) = self.ptz_service {
203 let ptz_handler = PTZServiceHandler::new(ptz_svc);
204 let ptz_soap_svc = build_service!(
205 include_bytes!("../wsdl/ptz.wsdl"),
206 "/onvif/ptz_service",
207 ptz_handler,
208 std::iter::empty::<String>()
209 );
210 router = router.merge(ptz_soap_svc.into_router());
211 }
212
213 if let Some(imaging_svc) = self.imaging_service {
214 let imaging_handler = ImagingServiceHandler::new(imaging_svc);
215 let imaging_soap_svc = build_service!(
216 include_bytes!("../wsdl/imaging.wsdl"),
217 "/onvif/imaging_service",
218 imaging_handler,
219 std::iter::empty::<String>()
220 );
221 router = router.merge(imaging_soap_svc.into_router());
222 }
223
224 if let Some(event_svc) = self.event_service {
225 let events_xaddr_str = events_xaddr.as_deref().unwrap_or_default().to_string();
226 let events_handler = EventServiceHandler::new(event_svc, events_xaddr_str);
227 let events_soap_svc = build_service!(
228 include_bytes!("../wsdl/events.wsdl"),
229 "/onvif/events_service",
230 events_handler,
231 std::iter::empty::<String>()
232 );
233 router = router.merge(events_soap_svc.into_router());
234 }
235
236 Ok(router)
237 }
238
239 /// Bind the configured port and start serving SOAP requests.
240 ///
241 /// This method does not return until the server is shut down.
242 /// Requires a tokio async runtime (`#[tokio::main]` or `tokio::runtime::Runtime`).
243 ///
244 /// # Auth behaviour
245 ///
246 /// If the builder was configured with `.auth(username, password)`, WS-Security
247 /// UsernameToken authentication is enforced on all non-bypassed operations.
248 /// If `.auth()` was NOT called, the server runs **unauthenticated** — all
249 /// operations are accessible without credentials.
250 ///
251 /// # Service optionality
252 ///
253 /// Only `device_service` is required (checked at [`OnvifServerBuilder::build`] time).
254 /// Media, PTZ, Imaging, and Events services are optional; their routes are only
255 /// mounted and their capabilities only advertised when they are registered.
256 ///
257 /// # Errors
258 ///
259 /// Returns [`RunError::Startup`] if `device_service` is somehow absent at run time.
260 /// Returns [`RunError::Io`] if the TCP listener fails to bind or serve.
261 pub async fn run(self) -> Result<(), RunError> {
262 // Capture port and xaddr BEFORE consuming self via into_router().
263 let port = self.port;
264 let disc_xaddr = format!(
265 "http://{}:{}/onvif/device_service",
266 self.advertised_host, self.port
267 );
268 // The device's WS-Discovery EndpointReference UUID is a STABLE identity for the
269 // device's lifetime (set via the builder, else a per-build default) — never
270 // regenerated per probe.
271 let disc_uuid = self.discovery_uuid;
272
273 #[cfg(feature = "discovery")]
274 {
275 let xaddr_for_disc = disc_xaddr.clone();
276 tokio::spawn(async move {
277 if let Err(e) =
278 crate::discovery::run_discovery_with_uuid(xaddr_for_disc, disc_uuid).await
279 {
280 eprintln!("[discovery] task exited: {e}");
281 }
282 });
283 }
284 let _ = disc_uuid;
285 // Suppress unused-variable warning when discovery feature is off.
286 let _ = disc_xaddr;
287
288 let router = self.into_router()?;
289
290 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
291 axum::serve(listener, router).await?;
292 Ok(())
293 }
294}
295
296/// Builder for configuring and constructing an [`OnvifServer`].
297///
298/// Service registration, auth credentials, port, and auth bypass operations are
299/// all set here. Fields are `pub(crate)` — use the builder methods to configure
300/// the server.
301pub struct OnvifServerBuilder {
302 pub(crate) port: u16,
303 pub(crate) username: Option<String>,
304 pub(crate) password: Option<String>,
305 pub(crate) device_service: Option<Arc<dyn DeviceService>>,
306 pub(crate) media_service: Option<Arc<dyn MediaService>>,
307 pub(crate) ptz_service: Option<Arc<dyn PTZService>>,
308 pub(crate) imaging_service: Option<Arc<dyn ImagingService>>,
309 pub(crate) event_service: Option<Arc<dyn EventService>>,
310 pub(crate) auth_bypass: HashSet<String>,
311 pub(crate) advertised_host: String,
312 pub(crate) discovery_uuid: uuid::Uuid,
313}
314
315impl OnvifServerBuilder {
316 fn new() -> Self {
317 let mut auth_bypass = HashSet::new();
318 // ONVIF spec requires GetSystemDateAndTime to be accessible without auth
319 // so clients can synchronise their clocks before authenticating.
320 auth_bypass.insert("GetSystemDateAndTime".to_string());
321
322 Self {
323 port: 8080,
324 username: None,
325 password: None,
326 device_service: None,
327 media_service: None,
328 ptz_service: None,
329 imaging_service: None,
330 event_service: None,
331 auth_bypass,
332 advertised_host: "0.0.0.0".to_string(),
333 discovery_uuid: uuid::Uuid::new_v4(),
334 }
335 }
336
337 /// Set the port the server will listen on. Defaults to 8080.
338 pub fn port(mut self, port: u16) -> Self {
339 self.port = port;
340 self
341 }
342
343 /// Set the host advertised in XAddrs for GetCapabilities, GetServices, and WS-Discovery.
344 /// Real ONVIF clients need a routable address (e.g. "192.168.1.10"), not "0.0.0.0".
345 /// Defaults to "0.0.0.0" for backward compatibility.
346 pub fn advertised_host(mut self, host: &str) -> Self {
347 self.advertised_host = host.to_string();
348 self
349 }
350
351 /// Set the credentials used for WS-Security digest auth validation.
352 ///
353 /// When called, WS-Security UsernameToken authentication is enforced on all
354 /// non-bypassed operations during [`OnvifServer::run`]. When NOT called, the
355 /// server runs **unauthenticated** — all operations are accessible without
356 /// credentials.
357 pub fn auth(mut self, username: &str, password: &str) -> Self {
358 self.username = Some(username.to_string());
359 self.password = Some(password.to_string());
360 self
361 }
362
363 /// Register a Device Management Service implementation.
364 pub fn device_service(mut self, svc: impl DeviceService + 'static) -> Self {
365 self.device_service = Some(Arc::new(svc));
366 self
367 }
368
369 /// Register a Media Service implementation.
370 ///
371 /// Optional: if not registered, the media route is not mounted and media
372 /// capabilities are not advertised.
373 pub fn media_service(mut self, svc: impl MediaService + 'static) -> Self {
374 self.media_service = Some(Arc::new(svc));
375 self
376 }
377
378 /// Register a PTZ Service implementation.
379 ///
380 /// Optional: if not registered, the PTZ route is not mounted and PTZ
381 /// capabilities are not advertised.
382 pub fn ptz_service(mut self, svc: impl PTZService + 'static) -> Self {
383 self.ptz_service = Some(Arc::new(svc));
384 self
385 }
386
387 /// Register an Imaging Service implementation.
388 ///
389 /// Optional: if not registered, the imaging route is not mounted and imaging
390 /// capabilities are not advertised.
391 pub fn imaging_service(mut self, svc: impl ImagingService + 'static) -> Self {
392 self.imaging_service = Some(Arc::new(svc));
393 self
394 }
395
396 /// Register an Event Service implementation.
397 ///
398 /// Optional: if not registered, the events route is not mounted and events
399 /// capabilities are not advertised.
400 pub fn event_service(mut self, svc: impl EventService + 'static) -> Self {
401 self.event_service = Some(Arc::new(svc));
402 self
403 }
404
405 /// Override the stable WS-Discovery EndpointReference UUID for this device.
406 ///
407 /// When not called, the builder defaults to a random UUID-v4. Callers that
408 /// need a deterministic identity across restarts should supply a stable UUID
409 /// here (e.g. derived from hardware ID or stored configuration).
410 pub fn discovery_uuid(mut self, uuid: uuid::Uuid) -> Self {
411 self.discovery_uuid = uuid;
412 self
413 }
414
415 /// Accessor for the auth bypass operation set. Used in tests and Phase 2 wiring.
416 pub fn auth_bypass_set(&self) -> &HashSet<String> {
417 &self.auth_bypass
418 }
419
420 /// Build the configured [`OnvifServer`].
421 ///
422 /// Returns `Err(BuildError::MissingRequiredService("device_service"))` if no
423 /// device service has been registered. `device_service` is required by the ONVIF
424 /// spec — it provides `GetSystemDateAndTime` and core device management operations.
425 ///
426 /// All other services (media, PTZ, imaging, events) are optional. When omitted,
427 /// their routes are not mounted at run time and their capabilities are not
428 /// advertised in `GetCapabilities` / `GetServices`.
429 pub fn build(self) -> Result<OnvifServer, BuildError> {
430 if self.device_service.is_none() {
431 return Err(BuildError::MissingRequiredService(
432 "device_service".to_string(),
433 ));
434 }
435 Ok(OnvifServer {
436 port: self.port,
437 username: self.username,
438 password: self.password,
439 device_service: self.device_service,
440 media_service: self.media_service,
441 ptz_service: self.ptz_service,
442 imaging_service: self.imaging_service,
443 event_service: self.event_service,
444 auth_bypass: self.auth_bypass,
445 advertised_host: self.advertised_host,
446 discovery_uuid: self.discovery_uuid,
447 })
448 }
449}