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