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}