csaf-crud 0.2.0

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! CSAF CRUD web server entry point.
//!
//! Starts an HTTPS server with TLS 1.3 supporting:
//! - HTTP/1.1 + HTTP/2 auto-detection over TCP (via hyper-util)
//! - HTTP/3 over QUIC/UDP (via quinn + h3, feature-gated)
//!
//! Self-signed certificates are generated automatically if no TLS cert is
//! configured, following the Let's Encrypt short-lived cert approach (45 days).

#![allow(
    clippy::redundant_closure_for_method_calls,
    clippy::ignored_unit_patterns,
    clippy::map_unwrap_or
)]

use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;

use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use tokio::net::TcpListener;

use csaf_core::config::AppConfig;
use csaf_core::storage::CsafStorage;
use csaf_models::db::DbPool;

use csaf_crud::api;
use csaf_crud::app_state::AppState;
use csaf_crud::router::{Router, SharedRouter, handler_fn};
use csaf_crud::routes;
use csaf_crud::static_files;

// ---------------------------------------------------------------------------
// TLS helpers
// ---------------------------------------------------------------------------

/// Generate a self-signed TLS certificate valid for 45 days.
fn generate_self_signed_cert() -> anyhow::Result<(
    Vec<rustls::pki_types::CertificateDer<'static>>,
    rustls::pki_types::PrivateKeyDer<'static>,
)> {
    use rcgen::{CertificateParams, SanType};
    use std::net::{Ipv4Addr, Ipv6Addr};

    let mut params = CertificateParams::new(vec!["localhost".to_owned()])?;
    params.subject_alt_names = vec![
        SanType::DnsName("localhost".try_into()?),
        SanType::IpAddress(Ipv4Addr::LOCALHOST.into()),
        SanType::IpAddress(Ipv6Addr::LOCALHOST.into()),
    ];

    let now = time::OffsetDateTime::now_utc();
    params.not_before = now;
    params.not_after = now + time::Duration::days(45);

    let key_pair = rcgen::KeyPair::generate()?;
    let cert = params.self_signed(&key_pair)?;
    let cert_der = rustls::pki_types::CertificateDer::from(cert);
    let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
        .map_err(|e| anyhow::anyhow!("Failed to parse private key: {e}"))?;

    Ok((vec![cert_der], key_der))
}

/// Build a TLS 1.3-only server configuration.
fn build_tls13_server_config(
    cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
    key_der: rustls::pki_types::PrivateKeyDer<'static>,
    alpn: Vec<Vec<u8>>,
) -> anyhow::Result<rustls::ServerConfig> {
    let mut config =
        rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
            .with_no_client_auth()
            .with_single_cert(cert_chain, key_der)?;

    config.alpn_protocols = alpn;
    Ok(config)
}

// ---------------------------------------------------------------------------
// HTTP/3 QUIC server (feature-gated)
// ---------------------------------------------------------------------------

#[cfg(feature = "quic")]
async fn run_quic_server(
    addr: SocketAddr,
    router: SharedRouter,
    cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
    key_der: rustls::pki_types::PrivateKeyDer<'static>,
) -> anyhow::Result<()> {
    let mut tls_config = build_tls13_server_config(cert_chain, key_der, vec![b"h3".to_vec()])?;
    tls_config.max_early_data_size = u32::MAX;

    let server_config = quinn::ServerConfig::with_crypto(Arc::new(
        quinn::crypto::rustls::QuicServerConfig::try_from(tls_config)?,
    ));

    let endpoint = quinn::Endpoint::server(server_config, addr)?;
    tracing::info!(%addr, "QUIC/HTTP3 endpoint listening (TLS 1.3)");

    while let Some(incoming) = endpoint.accept().await {
        let router = router.clone();
        tokio::spawn(async move {
            if let Err(e) = handle_quic_connection(incoming, router).await {
                tracing::debug!(error = %e, "QUIC connection error");
            }
        });
    }

    Ok(())
}

#[cfg(feature = "quic")]
async fn handle_quic_connection(
    incoming: quinn::Incoming,
    router: SharedRouter,
) -> anyhow::Result<()> {
    let connection = incoming.await?;
    let mut h3_conn = h3::server::Connection::new(h3_quinn::Connection::new(connection)).await?;

    loop {
        match h3_conn.accept().await {
            Ok(Some(resolver)) => {
                let (req, mut stream) = resolver.resolve_request().await?;
                let router = router.clone();
                tokio::spawn(async move {
                    let (parts, _) = req.into_parts();
                    let response = router.handle_parts(parts).await;

                    let resp_headers = http::Response::builder()
                        .status(response.status())
                        .body(())
                        .unwrap_or_default();

                    if let Err(e) = stream.send_response(resp_headers).await {
                        tracing::debug!(error = %e, "Failed to send h3 response headers");
                        return;
                    }

                    let body_bytes = http_body_util::BodyExt::collect(response.into_body())
                        .await
                        .map(|b| b.to_bytes())
                        .unwrap_or_default();

                    if let Err(e) = stream.send_data(body_bytes).await {
                        tracing::debug!(error = %e, "Failed to send h3 response body");
                    }

                    let _ = stream.finish().await;
                });
            },
            Ok(None) => break,
            Err(e) => {
                tracing::debug!(error = %e, "H3 accept error");
                break;
            },
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize tracing.
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .init();

    // Install the rustls crypto provider (ring). `install_default` is
    // infallible in practice — it only errors if a provider is already
    // installed, which cannot happen at the top of `main`. We still
    // handle the error explicitly rather than panicking.
    if rustls::crypto::ring::default_provider()
        .install_default()
        .is_err()
    {
        return Err(anyhow::anyhow!(
            "rustls crypto provider was already installed"
        ));
    }

    // Load configuration.
    let config_path = std::env::args()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("config/default.toml"));

    let config = AppConfig::load(&config_path)?;

    // Ensure data directory exists.
    tokio::fs::create_dir_all(&config.data_dir).await.ok();

    // Open databases.
    let csaf_storage = CsafStorage::open(&config.redb_path())?;
    let db_pool = DbPool::open(&config.sqlite_path())
        .map_err(|e| anyhow::anyhow!("SQLite open failed: {e}"))?;

    // Load settings from storage (or defaults).
    let settings = csaf_storage.get_settings().unwrap_or_default();

    // Build application state and router.
    let state = AppState::new(csaf_storage, db_pool, config.clone(), settings);
    let shared_router = build_router(state)?.into_shared();

    // Generate self-signed TLS certificate.
    let (certs, key) = generate_self_signed_cert()?;

    // --- TCP listener (HTTP/1.1 + HTTP/2 over TLS 1.3) ---
    let tcp_addr: SocketAddr = config.listen_address().parse()?;
    let tcp_tls_config = build_tls13_server_config(
        certs.clone(),
        key.clone_key(),
        vec![b"h2".to_vec(), b"http/1.1".to_vec()],
    )?;
    let tcp_tls_acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tcp_tls_config));
    let listener = TcpListener::bind(tcp_addr).await?;
    tracing::info!(%tcp_addr, "Listening (TLS 1.3 -- HTTP/1.1 + HTTP/2 over TCP)");

    // --- QUIC/HTTP3 listener (UDP, TLS 1.3) ---
    #[cfg(feature = "quic")]
    {
        let quic_port = config.listen_port + 1;
        let quic_addr: SocketAddr = format!("{}:{quic_port}", config.listen_addr).parse()?;
        let quic_router = shared_router.clone();
        let quic_certs = certs;
        let quic_key = key;
        tokio::spawn(async move {
            if let Err(e) = run_quic_server(quic_addr, quic_router, quic_certs, quic_key).await {
                tracing::error!(error = %e, "QUIC server failed");
            }
        });
        tracing::info!(%quic_addr, "QUIC/HTTP3 listener spawned (UDP, TLS 1.3)");
    }

    // --- TCP/TLS accept loop ---
    loop {
        let (stream, remote_addr) = match listener.accept().await {
            Ok(pair) => pair,
            Err(e) => {
                tracing::warn!(error = %e, "TCP accept error");
                continue;
            },
        };

        let router = shared_router.clone();
        let acceptor = tcp_tls_acceptor.clone();

        tokio::spawn(async move {
            let tls_stream = match acceptor.accept(stream).await {
                Ok(s) => s,
                Err(e) => {
                    tracing::debug!(error = %e, %remote_addr, "TLS handshake failed");
                    return;
                },
            };

            let io = TokioIo::new(tls_stream);
            let service = service_fn(move |req: http::Request<Incoming>| {
                let router = router.clone();
                async move { Ok::<_, std::convert::Infallible>(router.handle(req).await) }
            });

            if let Err(e) = Builder::new(TokioExecutor::new())
                .serve_connection(io, service)
                .await
            {
                tracing::debug!(error = %e, %remote_addr, "Connection error");
            }
        });
    }
}

/// Build the router with all routes.
///
/// # Errors
///
/// Returns `matchit::InsertError` if any route template is malformed or
/// conflicts with a previously registered route. All templates here are
/// hard-coded so this is effectively a compile-time check performed at
/// startup.
fn build_router(state: AppState) -> Result<Router, matchit::InsertError> {
    Router::new(state)
        // Static files
        .get("/static/{*path}", handler_fn(static_files::serve_static))?
        // UI routes
        .get("/", handler_fn(routes::home::dashboard))?
        .get("/csaf", handler_fn(routes::csaf::list))?
        .get("/csaf/new", handler_fn(routes::csaf::new_form))?
        .post("/csaf", handler_fn(routes::csaf::create))?
        .get("/csaf/{id}", handler_fn(routes::csaf::view))?
        .get("/csaf/{id}/edit", handler_fn(routes::csaf::edit_form))?
        .post("/csaf/{id}/update", handler_fn(routes::csaf::update))?
        .post("/csaf/{id}/delete", handler_fn(routes::csaf::delete))?
        .get("/csaf/{id}/json", handler_fn(routes::csaf::json_download))?
        .get("/admin/import", handler_fn(routes::admin::import_page))?
        .post("/admin/import", handler_fn(routes::admin::import_execute))?
        .get("/admin/export", handler_fn(routes::admin::export_page))?
        .post("/admin/export", handler_fn(routes::admin::export_execute))?
        .post("/admin/dump", handler_fn(routes::admin::dump_execute))?
        .get("/settings", handler_fn(routes::settings::settings_form))?
        .post("/settings", handler_fn(routes::settings::settings_update))?
        .post(
            "/settings/toggle-theme",
            handler_fn(routes::settings::toggle_theme),
        )?
        .get("/info/about", handler_fn(routes::info::about))?
        .get("/info/license", handler_fn(routes::info::license))?
        .get("/info/system", handler_fn(routes::info::system_info))?
        .get("/info/privacy", handler_fn(routes::info::privacy))?
        .get("/info/security", handler_fn(routes::info::security))?
        // API v1 routes (HATEOAS)
        .get("/api/v1", handler_fn(api::v1::csaf::api_root))?
        .get("/api/v1/csaf", handler_fn(api::v1::csaf::list_csaf))?
        .post("/api/v1/csaf", handler_fn(api::v1::csaf::create_csaf))?
        .get("/api/v1/csaf/{id}", handler_fn(api::v1::csaf::get_csaf))?
        .put("/api/v1/csaf/{id}", handler_fn(api::v1::csaf::update_csaf))?
        .delete("/api/v1/csaf/{id}", handler_fn(api::v1::csaf::delete_csaf))?
        .get(
            "/api/v1/csaf/{id}/validate",
            handler_fn(api::v1::csaf::validate_csaf),
        )?
        .get(
            "/api/v1/audit-log",
            handler_fn(api::v1::audit_log::list_audit_log),
        )?
        .get(
            "/api/v1/settings",
            handler_fn(api::v1::settings::get_settings),
        )?
        .put(
            "/api/v1/settings",
            handler_fn(api::v1::settings::update_settings),
        )?
        .get(
            "/api/v1/system/info",
            handler_fn(api::v1::system::system_info),
        )?
        .get(
            "/api/v1/system/health",
            handler_fn(api::v1::system::health_check),
        )
}