//! HTTP API: router, the API description and shared response helpers. pub(crate) mod auth; mod debug; mod document; mod health; mod log; mod peers; mod stats; mod store; use axum::{ Json, Router, body::Body, extract::{Request, State}, http::{ HeaderValue, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG}, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get}, }; use serde_json::{Value as JsonValue, json}; use sha2::{Digest, Sha256}; use tracing::{error, warn}; use utoipa_swagger_ui::SwaggerUi; use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth}; use crate::document::meta; use crate::document::store::StoreError; use crate::state::AppState; /// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`, /// `/openapi.yaml`), `/health` and `/ready` is authenticated. pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router { let mut api: Router = Router::new() .route("/store", get(store::get_store_root)) .route( "/store/{*key}", get(store::get_store_entry) .put(store::put_store_entry) .delete(store::delete_store_entry), ) .route("/stream", get(store::stream_store_root)) .route("/stream/{*key}", get(store::stream_store_entry)) .route("/peers", get(peers::list_peers)) .route("/peers/{peer}", delete(peers::delete_peer)) .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_stats)) .route("/admin/document", get(document::get_document)) .route("/admin/config", get(get_config)) .merge(crate::remake::router::()); if debug_api { warn!("Debug API enabled"); api = api.route( "/debug/firewall", get(debug::get_debug_firewall).put(debug::put_debug_firewall), ); } api.layer(middleware::from_fn_with_state(auth_state, basic_auth)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) .route("/health", get(health::health)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) .with_state(state) } /// The HTTP router of a node that only relays (`--relay-only`): its figures and the log /// filter (authenticated), and `/health`. #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) fn relay_only_router( transport: std::sync::Arc, auth_state: AuthState, ) -> Router { let relay = stats::RelayOnly { transport, started: tokio::time::Instant::now(), }; Router::new() .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_relay_only_stats)) .layer(middleware::from_fn_with_state(auth_state, basic_auth)) .route("/health", get(health::health)) .layer(middleware::from_fn(json_errors)) .with_state(relay) } /// An error answer: `{"error": message}` with `details`' fields added (`docs/openapi.yaml`, /// `Error`). pub(crate) fn error_response( status: StatusCode, message: impl Into, details: Option, ) -> Response { let mut body = json!({ "error": message.into() }); if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) { object.extend(extra); } (status, Json(body)).into_response() } /// Gives every error answer the same body (`error_response`). Handlers that fail with /// a bare status or a text message, and axum's own rejections (a malformed query or /// body), get it here: the text becomes the message, or the status's reason if there /// is none. Errors that are JSON already pass unchanged, and so do their headers /// (`WWW-Authenticate` on a 401). async fn json_errors(request: Request, next: Next) -> Response { let response = next.run(request).await; let status = response.status(); if !(status.is_client_error() || status.is_server_error()) { return response; } let is_json = response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("application/json")); if is_json { return response; } let (mut parts, body) = response.into_parts(); // Error texts are short; a larger one is cut to its reason. let text = axum::body::to_bytes(body, 64 * 1024) .await .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned()) .unwrap_or_default(); let message = if text.is_empty() { status.canonical_reason().unwrap_or("error").to_lowercase() } else { text }; let body = json!({ "error": message }).to_string(); parts.headers.remove(CONTENT_LENGTH); parts .headers .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Response::from_parts(parts, Body::from(body)) } /// `GET /openapi.yaml`: the API description (`docs/openapi.yaml`, built in). async fn openapi_spec() -> impl IntoResponse { ( [(CONTENT_TYPE, HeaderValue::from_static("application/yaml"))], include_str!("../../docs/openapi.yaml"), ) } #[derive(Debug, Default, serde::Deserialize)] struct ConfigQuery { /// Text with placeholders as it ends up, or as written. #[serde(default)] values: crate::config::Values, } /// `GET /admin/config`: the effective configuration with each value's source; /// imported values and secrets are left out. async fn get_config( State(state): State, axum::extract::Query(query): axum::extract::Query, ) -> Json { Json(state.config.get(query.values).clone()) } // --- shared helpers ---------------------------------------------------------- pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { match error { StoreError::Conflict => StatusCode::CONFLICT, StoreError::NotFound => StatusCode::NOT_FOUND, other => { error!(error = %format!("{other:#}"), "{context}"); StatusCode::INTERNAL_SERVER_ERROR } } } fn etag_for(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let hex = digest .iter() .map(|byte| format!("{byte:02x}")) .collect::(); format!("\"{hex}\"") } fn parse_key_path(key: &str) -> Result, StatusCode> { let segments: Vec = key.split('/').map(str::to_owned).collect(); if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) { return Err(StatusCode::BAD_REQUEST); } Ok(segments) } /// A path for a write. Protocol data (`_continuum`: remake records, members, forget /// records, epoch metadata) is what every node acts on, so a write there acts on the /// whole swarm: only users listed in `--protocol-writers` may, and each such write is /// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never /// writable: the protocol reads it at the snapshot change, so an edit only misleads /// whoever reads it later. `403` otherwise. fn parse_write_path( state: &AppState, user: &AuthenticatedUser, key: &str, method: &str, ) -> Result, StatusCode> { let path = parse_key_path(key)?; if !meta::is_protocol(&path) { return Ok(path); } // `_continuum` itself, epoch metadata and the swarm ID: nobody. let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM; if fixed || !state.protocol_writers.contains(&user.0) { warn!(user = %user.0, method, path = %key, "Refused a write to protocol data"); return Err(StatusCode::FORBIDDEN); } warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm"); Ok(path) } fn response_for_value( value: &JsonValue, accept: Option<&HeaderValue>, ) -> Result { let wants_text = accept .and_then(|value| value.to_str().ok()) .is_some_and(|value| { value .split(',') .any(|item| item.trim().starts_with("text/plain")) }); let (content_type, body) = if wants_text { let body = match value { JsonValue::String(value) => value.clone(), other => serde_json::to_string(other).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, }; ("text/plain; charset=utf-8", body) } else { ( "application/json", serde_json::to_string(value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ) }; let etag = etag_for(body.as_bytes()); let mut response = body.into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); response.headers_mut().insert( ETAG, HeaderValue::from_str(&etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ); Ok(response) }