1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14use std::{sync::Arc, time::Duration};
15
16use aide::{axum::routing, swagger::Swagger};
17use axum::{
18 Json,
19 http::{self, Request},
20};
21use cda_comm_doip::DoipGatewaySetupError;
22use cda_interfaces::{
23 FunctionalDescriptionConfig, HashMap, SchemaProvider, UdsEcu,
24 communication_control::CommunicationAccess, datatypes::ComponentsConfig, dlt_ctx,
25 file_manager::FileManager, http_protection::registry::HttpRouteMatcher,
26};
27use cda_plugin_security::SecurityPluginLoader;
28use dynamic_router::DynamicRouter;
29pub use dynamic_router::{RouteGroupNotFound, RouteHandle};
30pub use http::Method;
31use opensovd_axum_extra::ExtractHost;
32use sovd::apps::sovd2uds::bulk_data::runtimefiles::RuntimeUpdateRouteState;
33use tokio::net::TcpListener;
34#[cfg(unix)]
35use tokio::net::UnixListener;
36use tower::{Layer, ServiceExt as TowerServiceExt};
37use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
38
39/// Public API surface re-exported from the crate-internal `sovd` module.
40pub use crate::sovd::{
41 SovdLockStateProvider, error::VendorErrorCode, locks::Locks,
42 request_guard::install_http_restriction_guard, static_data::add_static_data_endpoint,
43};
44pub mod dynamic_router;
45mod openapi;
46pub(crate) mod sovd;
47
48// Consts for HTTP
49pub const SWAGGER_UI_ROUTE: &str = "/swagger-ui";
50pub const OPENAPI_JSON_ROUTE: &str = "/openapi.json";
51#[derive(Clone)]
52pub enum WebServerConfig {
53 /// Bind to this host/port over TCP.
54 Tcp { host: String, port: u16 },
55 /// Bind to this Unix domain socket path instead of TCP.
56 UnixSocket { path: String },
57}
58
59/// Static configuration for vehicle SOVD routes.
60pub struct VehicleConfig {
61 pub flash_files_path: String,
62 /// Default functional class used to resolve the flash services 0x34, 0x36 and 0x37.
63 pub flash_functional_class_default: String,
64 pub functional_group_config: FunctionalDescriptionConfig,
65 pub components_config: ComponentsConfig,
66}
67
68/// Runtime resources (handles, shared state) for vehicle SOVD routes.
69pub struct VehicleResources<T, M> {
70 pub ecu_uds: T,
71 pub file_managers: HashMap<String, M>,
72 pub locks: Arc<Locks>,
73 /// Access-only view used to admit diagnostic communication activities.
74 pub communication_access: Arc<dyn CommunicationAccess>,
75}
76
[docs] 77/// [[ dimpl~sovd-api-http-server, Starts HTTP Server (TCP or Unix domain socket) ]]
78///
79/// Launches the http(s) webserver with deferred initialization
80///
81/// The server starts immediately with static endpoints. SOVD routes and other functionality
82/// can be added later by calling methods on the returned `DynamicRouter`.
83///
84/// # Errors
85/// Will return `Err` in case that the webserver couldn't be launched.
86/// This can be caused due to invalid config, ports or addresses already being in use.
87///
88#[tracing::instrument(skip(config, shutdown_signal))]
89pub async fn launch_webserver<F>(
90 config: WebServerConfig,
91 shutdown_signal: F,
92) -> Result<(DynamicRouter, tokio::task::JoinHandle<()>), DoipGatewaySetupError>
93where
94 F: Future<Output = ()> + Clone + Send + 'static,
95{
96 let dynamic_router = DynamicRouter::new();
97 let dynamic_router_for_service = dynamic_router.clone();
98 let service = tower::service_fn(move |request: Request<axum::body::Body>| {
99 let dr = dynamic_router_for_service.clone();
100 async move {
101 let router = dr.get_router().await;
102 TowerServiceExt::oneshot(router, request).await
103 }
104 });
105
106 let middleware = tower::util::MapRequestLayer::new(rewrite_request_uri);
107 let trim_trailing_slash_middleware = NormalizePathLayer::trim_trailing_slash();
108 let service_with_middleware = middleware.layer(trim_trailing_slash_middleware.layer(service));
109
110 let serve_future: std::pin::Pin<Box<dyn Future<Output = ()> + Send>> = match config {
111 WebServerConfig::UnixSocket { path: socket_path } => {
112 #[cfg(unix)]
113 {
114 remove_stale_unix_socket(&socket_path)?;
115 let listener = UnixListener::bind(&socket_path).map_err(|e| {
116 DoipGatewaySetupError::ServerError(format!(
117 "Failed to bind to unix socket {socket_path}: {e}"
118 ))
119 })?;
120 tracing::info!("SOVD HTTP server listening on unix socket {socket_path}");
121 Box::pin(async move {
122 let _ =
123 axum::serve(listener, tower::make::Shared::new(service_with_middleware))
124 .with_graceful_shutdown(shutdown_signal)
125 .await;
126 })
127 }
128 #[cfg(not(unix))]
129 {
130 return Err(DoipGatewaySetupError::ServerError(format!(
131 "Unix domain sockets are not supported on this platform (requested path: \
132 {socket_path})"
133 )));
134 }
135 }
136 WebServerConfig::Tcp { host, port } => {
137 let listen_address = format!("{host}:{port}");
138 let listener = TcpListener::bind(&listen_address).await.map_err(|e| {
139 DoipGatewaySetupError::ServerError(format!(
140 "Failed to bind to {listen_address}: {e}"
141 ))
142 })?;
143 tracing::info!("SOVD HTTP server listening on {listen_address}");
144 Box::pin(async move {
145 let _ = axum::serve(listener, tower::make::Shared::new(service_with_middleware))
146 .with_graceful_shutdown(shutdown_signal)
147 .await;
148 })
149 }
150 };
151
152 let webserver_task = cda_interfaces::spawn_named!("webserver", serve_future);
153
154 Ok((dynamic_router, webserver_task))
155}
156
157/// Removes a pre-existing file at `socket_path`, if any, so that binding a
158/// fresh `UnixListener` there doesn't fail with `AddrInUse` because of a
159/// socket file left behind by a previous unclean shutdown.
160#[cfg(unix)]
161fn remove_stale_unix_socket(socket_path: &str) -> Result<(), DoipGatewaySetupError> {
162 match std::fs::remove_file(socket_path) {
163 Ok(()) => {
164 tracing::debug!("Removed stale unix socket file at {socket_path}");
165 Ok(())
166 }
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
168 Err(e) => Err(DoipGatewaySetupError::ServerError(format!(
169 "Failed to remove stale unix socket file at {socket_path}: {e}"
170 ))),
171 }
172}
173
174/// Add vehicle routes to the dynamic router
175///
176/// This function should be called after the database is loaded to add all vehicle routes
177///
178/// # Errors
179/// Returns `Err` if routes cannot be added to the dynamic router.
180#[allow(
181 clippy::implicit_hasher,
182 reason = "Type alias doesn't allow specifying hasher"
183)]
184#[tracing::instrument(
185 skip(dynamic_router, config, resources),
186 fields(
187 flash_files_path = %config.flash_files_path
188 )
189)]
190pub async fn add_vehicle_routes<T, M, S>(
191 dynamic_router: &DynamicRouter,
192 config: VehicleConfig,
193 resources: VehicleResources<T, M>,
194) -> Result<RouteHandle, DoipGatewaySetupError>
195where
196 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
197 M: FileManager + Send + Sync + 'static,
198 S: SecurityPluginLoader,
199{
200 let vehicle_router = build_vehicle_routes::<T, M, S>(config, resources).await;
201
202 let handle = dynamic_router.add_routes(vehicle_router).await;
203
204 tracing::info!("Vehicle routes added to webserver");
205 Ok(handle)
206}
207
208#[allow(
209 clippy::implicit_hasher,
210 reason = "Type alias doesn't allow specifying hasher"
211)]
212pub async fn build_vehicle_routes<T, M, S>(
213 config: VehicleConfig,
214 resources: VehicleResources<T, M>,
215) -> aide::axum::ApiRouter
216where
217 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
218 M: FileManager + Send + Sync + 'static,
219 S: SecurityPluginLoader,
220{
221 sovd::route::<T, M, S>(
222 config.functional_group_config,
223 config.components_config,
224 &resources.ecu_uds,
225 sovd::FlashConfig {
226 files_path: config.flash_files_path,
227 functional_class_default: config.flash_functional_class_default,
228 },
229 resources.file_managers,
230 resources.locks,
231 resources.communication_access,
232 )
233 .await
234}
235
236/// Mounts the runtime-update HTTP routes onto the dynamic router and returns a handle to them.
237///
238/// Adds the runtime-file update endpoints to the router.
239pub async fn add_runtime_update_routes<S, P, L>(
240 dynamic_router: &DynamicRouter,
241 plugin: Arc<P>,
242 lock_state: Arc<L>,
243 upload_limit: usize,
244 retry_after: Duration,
245) -> RouteHandle
246where
247 S: SecurityPluginLoader,
248 P: cda_interfaces::runtime_update_api::RuntimeFilesUpdatePlugin,
249 L: cda_interfaces::runtime_update_api::LockStateProvider,
250{
251 let route_state = RuntimeUpdateRouteState {
252 plugin,
253 vehicle_lock_states: lock_state,
254 retry_after,
255 };
256 let bulk_data_router = sovd::apps::sovd2uds::bulk_data::runtimefiles::routes::<S, P, L>(
257 route_state.clone(),
258 upload_limit,
259 );
260 let operations_router =
261 sovd::apps::sovd2uds::operations::runtimefilesupdate::routes::<S, P, L>(route_state);
262 let router = bulk_data_router.merge(operations_router);
263 let handle = dynamic_router.add_routes(router.into()).await;
264 tracing::info!("Runtime update routes added to webserver");
265 handle
266}
267
268/// Routes that remain available while an update execution blocks other HTTP requests.
269#[must_use]
270pub fn routes_accessible_during_update() -> Vec<HttpRouteMatcher> {
271 sovd::apps::sovd2uds::operations::runtimefilesupdate::routes_accessible_during_update()
272}
273
274/// `OpenAPI` spec regenerates on every recomposition, reflecting current routes.
275///
276/// The server URL embedded in `openapi.json` is derived dynamically from each
277/// request's `Host` header (with `X-Forwarded-Host` / `Forwarded` taking
278/// precedence for reverse-proxy deployments), so the Swagger-UI always reflects
279/// the address the client actually used to reach CDA.
280pub async fn add_openapi_routes(dynamic_router: &DynamicRouter) {
281 let dr = dynamic_router.clone();
282 // Created once: aide leaks the Swagger UI HTML (~1.6 MB) for every route it creates,
283 // while the finalizer runs on every recomposition (e.g. each runtime database reload).
284 let swagger_route: axum::routing::MethodRouter =
285 Swagger::new(OPENAPI_JSON_ROUTE).axum_route().into();
286 dynamic_router
287 .add_finalizer(Arc::new(move |router: axum::Router| -> axum::Router {
288 let dr = dr.clone();
289 let swagger_route = swagger_route.clone();
290 let openapi_route: axum::routing::MethodRouter =
291 routing::get(move |ExtractHost(host): ExtractHost| {
292 let dr = dr.clone();
293 async move {
294 let mut api = (*dr.get_openapi().await).clone();
295 let server_url = format!("http://{host}");
296 let _ = openapi::api_docs(
297 aide::transform::TransformOpenApi::new(&mut api),
298 server_url,
299 );
300 Json(api)
301 }
302 })
303 .into();
304 router
305 .route(SWAGGER_UI_ROUTE, swagger_route)
306 .route(OPENAPI_JSON_ROUTE, openapi_route)
307 }))
308 .await;
309}
310
311fn rewrite_request_uri<B>(mut req: Request<B>) -> Request<B> {
312 let uri = req.uri();
313 let decoded_path = percent_encoding::percent_decode_str(uri.path())
314 .decode_utf8()
315 .unwrap_or_else(|_| uri.path().into());
316 let normalized_path = decoded_path.to_lowercase();
317 let normalized_path_and_query = uri.query().map_or_else(
318 || normalized_path.clone(),
319 |query| format!("{normalized_path}?{query}"),
320 );
321
322 let new_uri = match normalized_path_and_query.parse() {
323 Ok(uri) => uri,
324 Err(e) => {
325 tracing::warn!(error = %e, "Failed to parse URI, using original");
326 uri.clone()
327 }
328 };
329 *req.uri_mut() = new_uri;
330 req
331}
332
333fn create_trace_layer<S>(route: axum::Router<S>) -> axum::Router<S>
334where
335 S: Clone + Send + Sync + 'static,
336{
337 route.layer(
338 TraceLayer::new_for_http()
339 .make_span_with(|request: &axum::http::Request<_>| {
340 tracing::info_span!(
341 "request",
342 method = ?request.method(),
343 path = request.uri().to_string(),
344 status_code = tracing::field::Empty,
345 latency = tracing::field::Empty,
346 error = tracing::field::Empty,
347 dlt_context = dlt_ctx!("SOVD"),
348 )
349 })
350 .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
351 tracing::debug!(
352 method = %request.method(),
353 path = %request.uri(),
354 "Request received"
355 );
356 })
357 .on_response(
358 |response: &axum::http::Response<_>,
359 latency: std::time::Duration,
360 span: &tracing::Span| {
361 span.record("status_code", response.status().as_u16());
362 span.record("latency", format!("{latency:?}"));
363 },
364 )
365 .on_failure(
366 |error: tower_http::classify::ServerErrorsFailureClass,
367 latency: std::time::Duration,
368 span: &tracing::Span| {
369 span.record("latency", format!("{latency:?}"));
370 if let tower_http::classify::ServerErrorsFailureClass::StatusCode(status) =
371 error
372 {
373 span.record("status_code", status.as_u16());
374 if status == http::StatusCode::BAD_GATEWAY {
375 return; // Ignore 502 errors
376 }
377 }
378 span.record("error", error.to_string());
379 tracing::error!("HTTP request failed");
380 },
381 ),
382 )
383}
384
385#[cfg(test)]
386mod tests {
387 use axum::{body::Body, http::Request};
388 use tower::ServiceExt;
389
390 use super::{OPENAPI_JSON_ROUTE, add_openapi_routes, dynamic_router::DynamicRouter};
391
392 #[tokio::test]
393 async fn openapi_route_is_available_without_vehicle_routes() {
394 let dynamic_router = DynamicRouter::new();
395 add_openapi_routes(&dynamic_router).await;
396
397 let response = dynamic_router
398 .get_router()
399 .await
400 .oneshot(
401 Request::builder()
402 .uri(OPENAPI_JSON_ROUTE)
403 .header("host", "127.0.0.1:20002")
404 .body(Body::empty())
405 .unwrap(),
406 )
407 .await
408 .unwrap();
409
410 assert_eq!(response.status(), axum::http::StatusCode::OK);
411 }
412}
413
414#[cfg(test)]
415pub(crate) mod test_utils {
416 use serde::de::DeserializeOwned;
417
418 pub(crate) async fn axum_response_into<T: DeserializeOwned>(
419 response: axum::response::Response,
420 ) -> Result<T, serde_json::Error> {
421 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
422 .await
423 .unwrap();
424 serde_json::from_slice::<T>(body.as_ref())
425 }
426}
427
428#[cfg(test)]
429mod uri_rewrite_tests {
430 use axum::{body::Body, extract::Query};
431 use sovd_interfaces::components::ecu::operations::{OperationDeleteQuery, OperationQuery};
432 use tower::ServiceExt;
433
434 use super::*;
435
436 async fn rewrite_uri(uri: &str) -> http::Uri {
437 let service = tower::service_fn(|request: Request<Body>| async move {
438 Ok::<_, std::convert::Infallible>(request.uri().clone())
439 });
440 let service = tower::util::MapRequestLayer::new(rewrite_request_uri).layer(service);
441 let request = Request::builder()
442 .uri(uri)
443 .body(Body::empty())
444 .expect("failed to build request");
445
446 service
447 .oneshot(request)
448 .await
449 .expect("URI rewrite service failed")
450 }
451
452 #[tokio::test]
453 async fn rewrite_request_uri_normalizes_only_path() {
454 let uri =
455 rewrite_uri("/Vehicle/%45CU?x-sovd2uds-suppressService=true&caseSensitive=AbC%26X%3DY")
456 .await;
457
458 assert_eq!(uri.path(), "/vehicle/ecu");
459 assert_eq!(
460 uri.query(),
461 Some("x-sovd2uds-suppressService=true&caseSensitive=AbC%26X%3DY")
462 );
463 }
464
465 #[tokio::test]
466 async fn rewrite_request_uri_preserves_operation_queries() {
467 let uri = rewrite_uri(concat!(
468 "/Vehicle/v15/components/ECU/operations/Routine/executions/id",
469 "?x-sovd2uds-suppressService=true"
470 ))
471 .await;
472 let Query(query) = Query::<OperationQuery>::try_from_uri(&uri)
473 .expect("failed to deserialize operation query");
474 let Query(delete_query) = Query::<OperationDeleteQuery>::try_from_uri(&uri)
475 .expect("failed to deserialize operation delete query");
476
477 assert!(query.suppress_service);
478 assert!(delete_query.suppress_service);
479 }
480}
481
482#[cfg(all(test, unix))]
483mod webserver_bind_tests {
484 use std::time::Duration;
485
486 use futures::FutureExt;
487
488 use super::*;
489
490 /// Sends a GET request over a Unix domain socket using `reqwest` (the
491 /// same HTTP client used elsewhere in this crate and in the integration
492 /// tests) and returns the response status code. Used instead of parsing
493 /// raw bytes off a `UnixStream`, so the tests exercise a spec-compliant
494 /// HTTP client the same way a real consumer of the Unix socket transport
495 /// would.
496 async fn get_over_unix_socket(socket_path: &str, path: &str) -> http::StatusCode {
497 let client = reqwest::Client::builder()
498 .unix_socket(socket_path)
499 .build()
500 .expect("failed to build unix socket client");
501
502 let response = client
503 .get(format!("http://localhost{path}"))
504 .send()
505 .await
506 .expect("failed to send request over unix socket");
507 http::StatusCode::from_u16(response.status().as_u16())
508 .expect("response returned an invalid status code")
509 }
510
511 fn shutdown_channel() -> (
512 tokio::sync::broadcast::Sender<()>,
513 impl Future<Output = ()> + Clone + Send + 'static,
514 ) {
515 let (tx, mut rx) = tokio::sync::broadcast::channel::<()>(1);
516 let signal = async move {
517 rx.recv().await.ok();
518 }
519 .shared();
520 (tx, signal)
521 }
522
523 /// Creates a temp directory and a `WebServerConfig::UnixSocket` pointing
524 /// at a socket file inside it. The returned `TempDir` must be kept alive
525 /// for as long as the socket path is used.
526 fn temp_socket_config() -> (tempfile::TempDir, String, WebServerConfig) {
527 let dir = tempfile::tempdir().expect("failed to create temp dir");
528 let socket_path = dir.path().join("cda.sock").to_string_lossy().to_string();
529 let config = WebServerConfig::UnixSocket {
530 path: socket_path.clone(),
531 };
532 (dir, socket_path, config)
533 }
534
535 /// Asserts that `socket_path` is reachable (a request gets routed through
536 /// the dynamic router, even if it 404s because no routes are registered),
537 /// then signals shutdown and waits for `webserver_task` to finish.
538 async fn assert_reachable_then_shutdown(
539 socket_path: &str,
540 shutdown_tx: tokio::sync::broadcast::Sender<()>,
541 webserver_task: tokio::task::JoinHandle<()>,
542 ) {
543 // No routes are registered, so the dynamic router responds with 404 -
544 // that's fine, we only need to confirm the connection was accepted
545 // and routed.
546 let status = get_over_unix_socket(socket_path, "/").await;
547 assert_eq!(status, http::StatusCode::NOT_FOUND);
548
549 let _ = shutdown_tx.send(());
550 tokio::time::timeout(Duration::from_secs(5), webserver_task)
551 .await
552 .expect("webserver task didn't shut down in time")
553 .expect("webserver task panicked");
554 }
555
[docs]556 /// [[ test~sovd-api-http-server-unix-socket-bind, Binds the server to a unix domain socket and reaches it, test ]]
557 #[tokio::test]
558 async fn launch_webserver_binds_unix_socket() {
559 let (_dir, socket_path, config) = temp_socket_config();
560 let (shutdown_tx, shutdown_signal) = shutdown_channel();
561
562 let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
563 .await
564 .expect("failed to launch webserver on unix socket");
565
566 assert_reachable_then_shutdown(&socket_path, shutdown_tx, webserver_task).await;
567 }
568
[docs]569 /// [[ test~sovd-api-http-server-tcp-bind, Binds the server to a TCP host/port and reaches it, test ]]
570 #[tokio::test]
571 async fn launch_webserver_binds_tcp() {
572 let tcp_listener = TcpListener::bind("127.0.0.1:0")
573 .await
574 .expect("failed to reserve a tcp port");
575 let tcp_port = tcp_listener.local_addr().unwrap().port();
576 drop(tcp_listener);
577
578 let config = WebServerConfig::Tcp {
579 host: "127.0.0.1".to_owned(),
580 port: tcp_port,
581 };
582 let (shutdown_tx, shutdown_signal) = shutdown_channel();
583
584 let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
585 .await
586 .expect("failed to launch webserver on tcp");
587
588 let client = reqwest::Client::new();
589 let response = client
590 .get(format!("http://127.0.0.1:{tcp_port}/"))
591 .send()
592 .await
593 .expect("failed to send request over tcp");
594 assert_eq!(response.status().as_u16(), http::StatusCode::NOT_FOUND);
595
596 let _ = shutdown_tx.send(());
597 tokio::time::timeout(Duration::from_secs(5), webserver_task)
598 .await
599 .expect("webserver task didn't shut down in time")
600 .expect("webserver task panicked");
601 }
602
[docs]603 /// [[ test~sovd-api-http-server-unix-socket-stale-cleanup, Removes a stale unix socket file left by an unclean shutdown before binding, test ]]
604 #[tokio::test]
605 async fn launch_webserver_removes_stale_unix_socket_file() {
606 let (_dir, socket_path, config) = temp_socket_config();
607
608 // Simulate a leftover socket file from a previous unclean shutdown.
609 std::fs::write(&socket_path, b"stale").expect("failed to create stale socket file");
610
611 let (shutdown_tx, shutdown_signal) = shutdown_channel();
612
613 let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
614 .await
615 .expect("failed to launch webserver despite stale socket file");
616
617 assert_reachable_then_shutdown(&socket_path, shutdown_tx, webserver_task).await;
618 }
619
[docs]620 /// [[ test~sovd-api-http-server-unix-socket-stale-cleanup-failure, Fails cleanly when a stale unix socket path can't be removed, test ]]
621 #[tokio::test]
622 async fn launch_webserver_fails_if_stale_socket_cannot_be_removed() {
623 let (_dir, socket_path, config) = temp_socket_config();
624 // A directory can't be removed with `remove_file`, so binding must fail
625 // with a clear error instead of silently misbehaving.
626 std::fs::create_dir(&socket_path).expect("failed to create directory");
627
628 let (_shutdown_tx, shutdown_signal) = shutdown_channel();
629
630 let result = launch_webserver(config, shutdown_signal).await;
631 assert!(
632 result.is_err(),
633 "expected launch_webserver to fail when the stale socket path is a directory"
634 );
635 }
636}