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;
34use tower::{Layer, ServiceExt as TowerServiceExt};
35use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
36
37/// Public API surface re-exported from the crate-internal `sovd` module.
38pub use crate::sovd::{
39 SovdLockStateProvider, error::VendorErrorCode, locks::Locks,
40 request_guard::install_http_restriction_guard, static_data::add_static_data_endpoint,
41};
42pub mod dynamic_router;
43mod openapi;
44pub(crate) mod sovd;
45
46// Consts for HTTP
47pub const SWAGGER_UI_ROUTE: &str = "/swagger-ui";
48pub const OPENAPI_JSON_ROUTE: &str = "/openapi.json";
49#[derive(Clone)]
50pub struct WebServerConfig {
51 pub host: String,
52 pub port: u16,
53}
54
55/// Static configuration for vehicle SOVD routes.
56pub struct VehicleConfig {
57 pub flash_files_path: String,
58 pub functional_group_config: FunctionalDescriptionConfig,
59 pub components_config: ComponentsConfig,
60}
61
62/// Runtime resources (handles, shared state) for vehicle SOVD routes.
63pub struct VehicleResources<T, M> {
64 pub ecu_uds: T,
65 pub file_managers: HashMap<String, M>,
66 pub locks: Arc<Locks>,
67 /// Access-only view used to admit diagnostic communication activities.
68 pub communication_access: Arc<dyn CommunicationAccess>,
69}
70
[docs] 71/// [[ dimpl~sovd-api-http-server, Starts HTTP Server ]]
72///
73/// Launches the http(s) webserver with deferred initialization
74///
75/// The server starts immediately with static endpoints. SOVD routes and other functionality
76/// can be added later by calling methods on the returned `DynamicRouter`.
77///
78/// # Errors
79/// Will return `Err` in case that the webserver couldn't be launched.
80/// This can be caused due to invalid config, ports or addresses already being in use.
81///
82#[tracing::instrument(
83 skip(config, shutdown_signal),
84 fields(
85 host = %config.host,
86 port = %config.port,
87 )
88)]
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 listen_address = format!("{}:{}", config.host, config.port);
98 let listener = TcpListener::bind(&listen_address).await.map_err(|e| {
99 DoipGatewaySetupError::ServerError(format!("Failed to bind to {listen_address}: {e}"))
100 })?;
101
102 let dynamic_router_for_service = dynamic_router.clone();
103 let webserver_task = cda_interfaces::spawn_named!("webserver", async move {
104 let service = tower::service_fn(move |request: Request<axum::body::Body>| {
105 let dr = dynamic_router_for_service.clone();
106 async move {
107 let router = dr.get_router().await;
108 TowerServiceExt::oneshot(router, request).await
109 }
110 });
111
112 let middleware = tower::util::MapRequestLayer::new(rewrite_request_uri);
113 let trim_trailing_slash_middleware = NormalizePathLayer::trim_trailing_slash();
114 let service_with_middleware =
115 middleware.layer(trim_trailing_slash_middleware.layer(service));
116
117 let _ = axum::serve(listener, tower::make::Shared::new(service_with_middleware))
118 .with_graceful_shutdown(shutdown_signal)
119 .await;
120 });
121
122 Ok((dynamic_router, webserver_task))
123}
124
125/// Add vehicle routes to the dynamic router
126///
127/// This function should be called after the database is loaded to add all vehicle routes
128///
129/// # Errors
130/// Returns `Err` if routes cannot be added to the dynamic router.
131#[allow(
132 clippy::implicit_hasher,
133 reason = "Type alias doesn't allow specifying hasher"
134)]
135#[tracing::instrument(
136 skip(dynamic_router, config, resources),
137 fields(
138 flash_files_path = %config.flash_files_path
139 )
140)]
141pub async fn add_vehicle_routes<T, M, S>(
142 dynamic_router: &DynamicRouter,
143 config: VehicleConfig,
144 resources: VehicleResources<T, M>,
145) -> Result<RouteHandle, DoipGatewaySetupError>
146where
147 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
148 M: FileManager + Send + Sync + 'static,
149 S: SecurityPluginLoader,
150{
151 let vehicle_router = build_vehicle_routes::<T, M, S>(config, resources).await;
152
153 let handle = dynamic_router.add_routes(vehicle_router).await;
154
155 tracing::info!("Vehicle routes added to webserver");
156 Ok(handle)
157}
158
159#[allow(
160 clippy::implicit_hasher,
161 reason = "Type alias doesn't allow specifying hasher"
162)]
163pub async fn build_vehicle_routes<T, M, S>(
164 config: VehicleConfig,
165 resources: VehicleResources<T, M>,
166) -> aide::axum::ApiRouter
167where
168 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
169 M: FileManager + Send + Sync + 'static,
170 S: SecurityPluginLoader,
171{
172 sovd::route::<T, M, S>(
173 config.functional_group_config,
174 config.components_config,
175 &resources.ecu_uds,
176 config.flash_files_path,
177 resources.file_managers,
178 resources.locks,
179 resources.communication_access,
180 )
181 .await
182}
183
184/// Mounts the runtime-update HTTP routes onto the dynamic router and returns a handle to them.
185///
186/// Adds the runtime-file update endpoints to the router.
187pub async fn add_runtime_update_routes<S, P, L>(
188 dynamic_router: &DynamicRouter,
189 plugin: Arc<P>,
190 lock_state: Arc<L>,
191 upload_limit: usize,
192 retry_after: Duration,
193) -> RouteHandle
194where
195 S: SecurityPluginLoader,
196 P: cda_interfaces::runtime_update_api::RuntimeFilesUpdatePlugin,
197 L: cda_interfaces::runtime_update_api::LockStateProvider,
198{
199 let route_state = RuntimeUpdateRouteState {
200 plugin,
201 vehicle_lock_states: lock_state,
202 retry_after,
203 };
204 let bulk_data_router = sovd::apps::sovd2uds::bulk_data::runtimefiles::routes::<S, P, L>(
205 route_state.clone(),
206 upload_limit,
207 );
208 let operations_router =
209 sovd::apps::sovd2uds::operations::runtimefilesupdate::routes::<S, P, L>(route_state);
210 let router = bulk_data_router.merge(operations_router);
211 let handle = dynamic_router.add_routes(router.into()).await;
212 tracing::info!("Runtime update routes added to webserver");
213 handle
214}
215
216/// Routes that remain available while an update execution blocks other HTTP requests.
217#[must_use]
218pub fn routes_accessible_during_update() -> Vec<HttpRouteMatcher> {
219 sovd::apps::sovd2uds::operations::runtimefilesupdate::routes_accessible_during_update()
220}
221
222/// `OpenAPI` spec regenerates on every recomposition, reflecting current routes.
223///
224/// The server URL embedded in `openapi.json` is derived dynamically from each
225/// request's `Host` header (with `X-Forwarded-Host` / `Forwarded` taking
226/// precedence for reverse-proxy deployments), so the Swagger-UI always reflects
227/// the address the client actually used to reach CDA.
228pub async fn add_openapi_routes(dynamic_router: &DynamicRouter) {
229 let dr = dynamic_router.clone();
230 dynamic_router
231 .add_finalizer(Arc::new(move |router: axum::Router| -> axum::Router {
232 let dr = dr.clone();
233 let swagger_route: axum::routing::MethodRouter =
234 Swagger::new(OPENAPI_JSON_ROUTE).axum_route().into();
235 let openapi_route: axum::routing::MethodRouter =
236 routing::get(move |ExtractHost(host): ExtractHost| {
237 let dr = dr.clone();
238 async move {
239 let mut api = (*dr.get_openapi().await).clone();
240 let server_url = format!("http://{host}");
241 let _ = openapi::api_docs(
242 aide::transform::TransformOpenApi::new(&mut api),
243 server_url,
244 );
245 Json(api)
246 }
247 })
248 .into();
249 router
250 .route(SWAGGER_UI_ROUTE, swagger_route)
251 .route(OPENAPI_JSON_ROUTE, openapi_route)
252 }))
253 .await;
254}
255
256fn rewrite_request_uri<B>(mut req: Request<B>) -> Request<B> {
257 let uri = req.uri();
258 // Decode URI here, so we can use query params later without
259 // needing to decode them later on.
260 let decoded = percent_encoding::percent_decode_str(
261 uri.path_and_query()
262 .map(http::uri::PathAndQuery::as_str)
263 .unwrap_or_default(),
264 )
265 .decode_utf8()
266 .unwrap_or_else(|_| uri.to_string().into());
267
268 let new_uri = match decoded.to_lowercase().parse() {
269 Ok(uri) => uri,
270 Err(e) => {
271 tracing::warn!(error = %e, "Failed to parse URI, using original");
272 uri.clone()
273 }
274 };
275 *req.uri_mut() = new_uri;
276 req
277}
278
279fn create_trace_layer<S>(route: axum::Router<S>) -> axum::Router<S>
280where
281 S: Clone + Send + Sync + 'static,
282{
283 route.layer(
284 TraceLayer::new_for_http()
285 .make_span_with(|request: &axum::http::Request<_>| {
286 tracing::info_span!(
287 "request",
288 method = ?request.method(),
289 path = request.uri().to_string(),
290 status_code = tracing::field::Empty,
291 latency = tracing::field::Empty,
292 error = tracing::field::Empty,
293 dlt_context = dlt_ctx!("SOVD"),
294 )
295 })
296 .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
297 tracing::debug!(
298 method = %request.method(),
299 path = %request.uri(),
300 "Request received"
301 );
302 })
303 .on_response(
304 |response: &axum::http::Response<_>,
305 latency: std::time::Duration,
306 span: &tracing::Span| {
307 span.record("status_code", response.status().as_u16());
308 span.record("latency", format!("{latency:?}"));
309 },
310 )
311 .on_failure(
312 |error: tower_http::classify::ServerErrorsFailureClass,
313 latency: std::time::Duration,
314 span: &tracing::Span| {
315 span.record("latency", format!("{latency:?}"));
316 if let tower_http::classify::ServerErrorsFailureClass::StatusCode(status) =
317 error
318 {
319 span.record("status_code", status.as_u16());
320 if status == http::StatusCode::BAD_GATEWAY {
321 return; // Ignore 502 errors
322 }
323 }
324 span.record("error", error.to_string());
325 tracing::error!("HTTP request failed");
326 },
327 ),
328 )
329}
330
331#[cfg(test)]
332pub(crate) mod test_utils {
333 use serde::de::DeserializeOwned;
334
335 pub(crate) async fn axum_response_into<T: DeserializeOwned>(
336 response: axum::response::Response,
337 ) -> Result<T, serde_json::Error> {
338 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
339 .await
340 .unwrap();
341 serde_json::from_slice::<T>(body.as_ref())
342 }
343}