Skip to main content

openentropy_server/
lib.rs

1//! HTTP entropy server — ANU QRNG API compatible.
2//!
3//! Serves random bytes via HTTP, compatible with the ANU QRNG API format for easy integration with
4//! QRNG backend and any client expecting the ANU API format.
5
6use std::sync::Arc;
7
8use axum::{
9    Router,
10    extract::{Query, State},
11    http::StatusCode,
12    response::Json,
13    routing::get,
14};
15use serde::{Deserialize, Serialize};
16use tokio::sync::Mutex;
17
18use openentropy_core::conditioning::ConditioningMode;
19use openentropy_core::pool::EntropyPool;
20use openentropy_core::telemetry::{
21    TelemetryWindowReport, collect_telemetry_snapshot, collect_telemetry_window,
22};
23
24/// Shared server state.
25struct AppState {
26    pool: Mutex<EntropyPool>,
27    allow_raw: bool,
28}
29
30#[derive(Deserialize)]
31struct RandomParams {
32    length: Option<usize>,
33    #[serde(rename = "type")]
34    data_type: Option<String>,
35    /// If true, return raw unconditioned entropy (no SHA-256/DRBG).
36    raw: Option<bool>,
37    /// Conditioning mode: raw, vonneumann, sha256 (overrides `raw` flag).
38    conditioning: Option<String>,
39    /// Request entropy from a specific source by name.
40    source: Option<String>,
41}
42
43#[derive(Serialize)]
44struct RandomResponse {
45    #[serde(rename = "type")]
46    data_type: String,
47    length: usize,
48    data: serde_json::Value,
49    success: bool,
50    /// Whether this output was conditioned (SHA-256) or raw.
51    conditioned: bool,
52    /// Which source was queried (null if mixed pool).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    source: Option<String>,
55    /// Error message if request failed.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    error: Option<String>,
58}
59
60#[derive(Serialize)]
61struct HealthResponse {
62    status: String,
63    sources_healthy: usize,
64    sources_total: usize,
65    raw_bytes: u64,
66    output_bytes: u64,
67}
68
69#[derive(Serialize)]
70struct SourcesResponse {
71    sources: Vec<SourceEntry>,
72    total: usize,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    telemetry_v1: Option<TelemetryWindowReport>,
75}
76
77#[derive(Serialize)]
78struct SourceEntry {
79    name: String,
80    healthy: bool,
81    bytes: u64,
82    entropy: f64,
83    time: f64,
84    failures: u64,
85}
86
87#[derive(Deserialize, Default)]
88struct DiagnosticsParams {
89    telemetry: Option<bool>,
90}
91
92fn include_telemetry(params: &DiagnosticsParams) -> bool {
93    params.telemetry.unwrap_or(false)
94}
95
96async fn handle_random(
97    State(state): State<Arc<AppState>>,
98    Query(params): Query<RandomParams>,
99) -> (StatusCode, Json<RandomResponse>) {
100    let length = params.length.unwrap_or(1024).clamp(1, 65536);
101    let data_type = params.data_type.unwrap_or_else(|| "hex16".to_string());
102
103    // Determine conditioning mode: ?conditioning= takes priority, then ?raw=true
104    let mode = if let Some(ref c) = params.conditioning {
105        match c.as_str() {
106            "raw" if state.allow_raw => ConditioningMode::Raw,
107            "vonneumann" | "von_neumann" | "vn" => ConditioningMode::VonNeumann,
108            "raw" => ConditioningMode::Sha256, // raw not allowed
109            _ => ConditioningMode::Sha256,
110        }
111    } else if params.raw.unwrap_or(false) && state.allow_raw {
112        ConditioningMode::Raw
113    } else {
114        ConditioningMode::Sha256
115    };
116
117    let pool = state.pool.lock().await;
118    let raw = if let Some(ref source_name) = params.source {
119        match pool.get_source_bytes(source_name, length, mode) {
120            Some(bytes) => bytes,
121            None => {
122                let err_msg = format!(
123                    "Unknown source: {source_name}. Use /sources to list available sources."
124                );
125                return Json(RandomResponse {
126                    data_type,
127                    length: 0,
128                    data: serde_json::Value::Array(vec![]),
129                    success: false,
130                    conditioned: mode != ConditioningMode::Raw,
131                    source: Some(source_name.clone()),
132                    error: Some(err_msg),
133                })
134                .with_status(StatusCode::BAD_REQUEST);
135            }
136        }
137    } else {
138        pool.get_bytes(length, mode)
139    };
140    let use_raw = mode == ConditioningMode::Raw;
141
142    let data = match data_type.as_str() {
143        "hex16" => {
144            let hex_pairs: Vec<String> = raw
145                .chunks(2)
146                .filter(|c| c.len() == 2)
147                .map(|c| format!("{:02x}{:02x}", c[0], c[1]))
148                .collect();
149            serde_json::Value::Array(
150                hex_pairs
151                    .into_iter()
152                    .map(serde_json::Value::String)
153                    .collect(),
154            )
155        }
156        "uint8" => {
157            serde_json::Value::Array(raw.iter().map(|&b| serde_json::Value::from(b)).collect())
158        }
159        "uint16" => {
160            let vals: Vec<u16> = raw
161                .chunks(2)
162                .filter(|c| c.len() == 2)
163                .map(|c| u16::from_le_bytes([c[0], c[1]]))
164                .collect();
165            serde_json::Value::Array(vals.into_iter().map(serde_json::Value::from).collect())
166        }
167        _ => serde_json::Value::String(hex::encode(&raw)),
168    };
169
170    let len = match &data {
171        serde_json::Value::Array(a) => a.len(),
172        _ => length,
173    };
174
175    (
176        StatusCode::OK,
177        Json(RandomResponse {
178            data_type,
179            length: len,
180            data,
181            success: true,
182            conditioned: !use_raw,
183            source: params.source,
184            error: None,
185        }),
186    )
187}
188
189trait JsonWithStatus<T> {
190    fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>);
191}
192
193impl<T> JsonWithStatus<T> for Json<T> {
194    fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>) {
195        (status, self)
196    }
197}
198
199async fn handle_health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
200    let pool = state.pool.lock().await;
201    let report = pool.health_report();
202    Json(HealthResponse {
203        status: if report.healthy > 0 {
204            "healthy".to_string()
205        } else {
206            "degraded".to_string()
207        },
208        sources_healthy: report.healthy,
209        sources_total: report.total,
210        raw_bytes: report.raw_bytes,
211        output_bytes: report.output_bytes,
212    })
213}
214
215async fn handle_sources(
216    State(state): State<Arc<AppState>>,
217    Query(params): Query<DiagnosticsParams>,
218) -> Json<SourcesResponse> {
219    let telemetry_start = include_telemetry(&params).then(collect_telemetry_snapshot);
220    let pool = state.pool.lock().await;
221    let report = pool.health_report();
222    drop(pool);
223    let telemetry_v1 = telemetry_start.map(collect_telemetry_window);
224    let sources: Vec<SourceEntry> = report
225        .sources
226        .iter()
227        .map(|s| SourceEntry {
228            name: s.name.clone(),
229            healthy: s.healthy,
230            bytes: s.bytes,
231            entropy: s.entropy,
232            time: s.time,
233            failures: s.failures,
234        })
235        .collect();
236    let total = sources.len();
237    Json(SourcesResponse {
238        sources,
239        total,
240        telemetry_v1,
241    })
242}
243
244async fn handle_pool_status(
245    State(state): State<Arc<AppState>>,
246    Query(params): Query<DiagnosticsParams>,
247) -> Json<serde_json::Value> {
248    let telemetry_start = include_telemetry(&params).then(collect_telemetry_snapshot);
249    let pool = state.pool.lock().await;
250    let report = pool.health_report();
251    drop(pool);
252
253    let mut payload = serde_json::json!({
254        "healthy": report.healthy,
255        "total": report.total,
256        "raw_bytes": report.raw_bytes,
257        "output_bytes": report.output_bytes,
258        "buffer_size": report.buffer_size,
259        "sources": report.sources.iter().map(|s| serde_json::json!({
260            "name": s.name,
261            "healthy": s.healthy,
262            "bytes": s.bytes,
263            "entropy": s.entropy,
264            "time": s.time,
265            "failures": s.failures,
266        })).collect::<Vec<_>>(),
267    });
268    if let Some(window) = telemetry_start.map(collect_telemetry_window) {
269        payload["telemetry_v1"] = serde_json::json!(window);
270    }
271    Json(payload)
272}
273
274async fn handle_index(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
275    let pool = state.pool.lock().await;
276    let source_names = pool.source_names();
277    drop(pool);
278
279    Json(serde_json::json!({
280        "name": "OpenEntropy Server",
281        "version": openentropy_core::VERSION,
282        "sources": source_names.len(),
283        "endpoints": {
284            "/": "This API index",
285            "/api/v1/random": {
286                "method": "GET",
287                "description": "Get random entropy bytes",
288                "params": {
289                    "length": "Number of bytes (1-65536, default: 1024)",
290                    "type": "Output format: hex16, uint8, uint16 (default: hex16)",
291                    "source": format!("Request from a specific source by name. Available: {}", source_names.join(", ")),
292                    "conditioning": "Conditioning mode: sha256 (default), vonneumann, raw",
293                }
294            },
295            "/sources": {
296                "description": "List all active entropy sources with health metrics",
297                "params": {
298                    "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
299                }
300            },
301            "/pool/status": {
302                "description": "Detailed pool status",
303                "params": {
304                    "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
305                }
306            },
307            "/health": "Health check",
308        },
309        "examples": {
310            "mixed_pool": "/api/v1/random?length=32&type=uint8",
311            "single_source": format!("/api/v1/random?length=32&source={}", source_names.first().map(|s| s.as_str()).unwrap_or("clock_jitter")),
312            "raw_output": "/api/v1/random?length=32&conditioning=raw",
313            "sources_with_telemetry": "/sources?telemetry=true",
314            "pool_with_telemetry": "/pool/status?telemetry=true",
315        }
316    }))
317}
318
319/// Build the axum router.
320fn build_router(pool: EntropyPool, allow_raw: bool) -> Router {
321    let state = Arc::new(AppState {
322        pool: Mutex::new(pool),
323        allow_raw,
324    });
325
326    Router::new()
327        .route("/", get(handle_index))
328        .route("/api/v1/random", get(handle_random))
329        .route("/health", get(handle_health))
330        .route("/sources", get(handle_sources))
331        .route("/pool/status", get(handle_pool_status))
332        .with_state(state)
333}
334
335/// Run the HTTP entropy server.
336pub async fn run_server(pool: EntropyPool, host: &str, port: u16, allow_raw: bool) {
337    let app = build_router(pool, allow_raw);
338    let addr = format!("{host}:{port}");
339    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
340    axum::serve(listener, app).await.unwrap();
341}
342
343// Simple hex encoding without external dep
344mod hex {
345    pub fn encode(data: &[u8]) -> String {
346        data.iter().map(|b| format!("{b:02x}")).collect()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::{DiagnosticsParams, include_telemetry};
353
354    #[test]
355    fn telemetry_flag_defaults_to_false() {
356        let default = DiagnosticsParams::default();
357        assert!(!include_telemetry(&default));
358        assert!(include_telemetry(&DiagnosticsParams {
359            telemetry: Some(true),
360        }));
361    }
362}