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    min_entropy: f64,
84    time: f64,
85    failures: u64,
86}
87
88#[derive(Deserialize, Default)]
89struct DiagnosticsParams {
90    telemetry: Option<bool>,
91}
92
93fn include_telemetry(params: &DiagnosticsParams) -> bool {
94    params.telemetry.unwrap_or(false)
95}
96
97async fn handle_random(
98    State(state): State<Arc<AppState>>,
99    Query(params): Query<RandomParams>,
100) -> (StatusCode, Json<RandomResponse>) {
101    let length = params.length.unwrap_or(1024).clamp(1, 65536);
102    let data_type = params.data_type.unwrap_or_else(|| "hex16".to_string());
103
104    // Determine conditioning mode: ?conditioning= takes priority, then ?raw=true
105    let mode = if let Some(ref c) = params.conditioning {
106        match c.as_str() {
107            "raw" if state.allow_raw => ConditioningMode::Raw,
108            "vonneumann" | "von_neumann" | "vn" => ConditioningMode::VonNeumann,
109            "raw" => ConditioningMode::Sha256, // raw not allowed
110            _ => ConditioningMode::Sha256,
111        }
112    } else if params.raw.unwrap_or(false) && state.allow_raw {
113        ConditioningMode::Raw
114    } else {
115        ConditioningMode::Sha256
116    };
117
118    let pool = state.pool.lock().await;
119    let raw = if let Some(ref source_name) = params.source {
120        match pool.get_source_bytes(source_name, length, mode) {
121            Some(bytes) => bytes,
122            None => {
123                let err_msg = format!(
124                    "Unknown source: {source_name}. Use /sources to list available sources."
125                );
126                return Json(RandomResponse {
127                    data_type,
128                    length: 0,
129                    data: serde_json::Value::Array(vec![]),
130                    success: false,
131                    conditioned: mode != ConditioningMode::Raw,
132                    source: Some(source_name.clone()),
133                    error: Some(err_msg),
134                })
135                .with_status(StatusCode::BAD_REQUEST);
136            }
137        }
138    } else {
139        pool.get_bytes(length, mode)
140    };
141    let use_raw = mode == ConditioningMode::Raw;
142
143    let data = match data_type.as_str() {
144        "hex16" => {
145            let hex_pairs: Vec<String> = raw
146                .chunks(2)
147                .filter(|c| c.len() == 2)
148                .map(|c| format!("{:02x}{:02x}", c[0], c[1]))
149                .collect();
150            serde_json::Value::Array(
151                hex_pairs
152                    .into_iter()
153                    .map(serde_json::Value::String)
154                    .collect(),
155            )
156        }
157        "uint8" => {
158            serde_json::Value::Array(raw.iter().map(|&b| serde_json::Value::from(b)).collect())
159        }
160        "uint16" => {
161            let vals: Vec<u16> = raw
162                .chunks(2)
163                .filter(|c| c.len() == 2)
164                .map(|c| u16::from_le_bytes([c[0], c[1]]))
165                .collect();
166            serde_json::Value::Array(vals.into_iter().map(serde_json::Value::from).collect())
167        }
168        _ => serde_json::Value::String(hex::encode(&raw)),
169    };
170
171    let len = match &data {
172        serde_json::Value::Array(a) => a.len(),
173        _ => length,
174    };
175
176    (
177        StatusCode::OK,
178        Json(RandomResponse {
179            data_type,
180            length: len,
181            data,
182            success: true,
183            conditioned: !use_raw,
184            source: params.source,
185            error: None,
186        }),
187    )
188}
189
190trait JsonWithStatus<T> {
191    fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>);
192}
193
194impl<T> JsonWithStatus<T> for Json<T> {
195    fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>) {
196        (status, self)
197    }
198}
199
200async fn handle_health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
201    let pool = state.pool.lock().await;
202    let report = pool.health_report();
203    Json(HealthResponse {
204        status: if report.healthy > 0 {
205            "healthy".to_string()
206        } else {
207            "degraded".to_string()
208        },
209        sources_healthy: report.healthy,
210        sources_total: report.total,
211        raw_bytes: report.raw_bytes,
212        output_bytes: report.output_bytes,
213    })
214}
215
216async fn handle_sources(
217    State(state): State<Arc<AppState>>,
218    Query(params): Query<DiagnosticsParams>,
219) -> Json<SourcesResponse> {
220    let telemetry_start = include_telemetry(&params).then(collect_telemetry_snapshot);
221    let pool = state.pool.lock().await;
222    let report = pool.health_report();
223    drop(pool);
224    let telemetry_v1 = telemetry_start.map(collect_telemetry_window);
225    let sources: Vec<SourceEntry> = report
226        .sources
227        .iter()
228        .map(|s| SourceEntry {
229            name: s.name.clone(),
230            healthy: s.healthy,
231            bytes: s.bytes,
232            entropy: s.entropy,
233            min_entropy: s.min_entropy,
234            time: s.time,
235            failures: s.failures,
236        })
237        .collect();
238    let total = sources.len();
239    Json(SourcesResponse {
240        sources,
241        total,
242        telemetry_v1,
243    })
244}
245
246async fn handle_pool_status(
247    State(state): State<Arc<AppState>>,
248    Query(params): Query<DiagnosticsParams>,
249) -> Json<serde_json::Value> {
250    let telemetry_start = include_telemetry(&params).then(collect_telemetry_snapshot);
251    let pool = state.pool.lock().await;
252    let report = pool.health_report();
253    drop(pool);
254
255    let mut payload = serde_json::json!({
256        "healthy": report.healthy,
257        "total": report.total,
258        "raw_bytes": report.raw_bytes,
259        "output_bytes": report.output_bytes,
260        "buffer_size": report.buffer_size,
261        "sources": report.sources.iter().map(|s| serde_json::json!({
262            "name": s.name,
263            "healthy": s.healthy,
264            "bytes": s.bytes,
265            "entropy": s.entropy,
266            "min_entropy": s.min_entropy,
267            "time": s.time,
268            "failures": s.failures,
269        })).collect::<Vec<_>>(),
270    });
271    if let Some(window) = telemetry_start.map(collect_telemetry_window) {
272        payload["telemetry_v1"] = serde_json::json!(window);
273    }
274    Json(payload)
275}
276
277async fn handle_index(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
278    let pool = state.pool.lock().await;
279    let source_names = pool.source_names();
280    drop(pool);
281
282    Json(serde_json::json!({
283        "name": "OpenEntropy Server",
284        "version": openentropy_core::VERSION,
285        "sources": source_names.len(),
286        "endpoints": {
287            "/": "This API index",
288            "/api/v1/random": {
289                "method": "GET",
290                "description": "Get random entropy bytes",
291                "params": {
292                    "length": "Number of bytes (1-65536, default: 1024)",
293                    "type": "Output format: hex16, uint8, uint16 (default: hex16)",
294                    "source": format!("Request from a specific source by name. Available: {}", source_names.join(", ")),
295                    "conditioning": "Conditioning mode: sha256 (default), vonneumann, raw",
296                }
297            },
298            "/sources": {
299                "description": "List all active entropy sources with health metrics",
300                "params": {
301                    "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
302                }
303            },
304            "/pool/status": {
305                "description": "Detailed pool status",
306                "params": {
307                    "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
308                }
309            },
310            "/health": "Health check",
311        },
312        "examples": {
313            "mixed_pool": "/api/v1/random?length=32&type=uint8",
314            "single_source": format!("/api/v1/random?length=32&source={}", source_names.first().map(|s| s.as_str()).unwrap_or("clock_jitter")),
315            "raw_output": "/api/v1/random?length=32&conditioning=raw",
316            "sources_with_telemetry": "/sources?telemetry=true",
317            "pool_with_telemetry": "/pool/status?telemetry=true",
318        }
319    }))
320}
321
322/// Build the axum router.
323fn build_router(pool: EntropyPool, allow_raw: bool) -> Router {
324    let state = Arc::new(AppState {
325        pool: Mutex::new(pool),
326        allow_raw,
327    });
328
329    Router::new()
330        .route("/", get(handle_index))
331        .route("/api/v1/random", get(handle_random))
332        .route("/health", get(handle_health))
333        .route("/sources", get(handle_sources))
334        .route("/pool/status", get(handle_pool_status))
335        .with_state(state)
336}
337
338/// Run the HTTP entropy server.
339///
340/// Returns an error if the address cannot be bound or the server encounters
341/// a fatal I/O error.
342pub async fn run_server(
343    pool: EntropyPool,
344    host: &str,
345    port: u16,
346    allow_raw: bool,
347) -> std::io::Result<()> {
348    let app = build_router(pool, allow_raw);
349    let addr = format!("{host}:{port}");
350    let listener = tokio::net::TcpListener::bind(&addr).await?;
351    axum::serve(listener, app).await?;
352    Ok(())
353}
354
355// Simple hex encoding without external dep
356mod hex {
357    pub fn encode(data: &[u8]) -> String {
358        data.iter().map(|b| format!("{b:02x}")).collect()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::{DiagnosticsParams, include_telemetry};
365
366    #[test]
367    fn telemetry_flag_defaults_to_false() {
368        let default = DiagnosticsParams::default();
369        assert!(!include_telemetry(&default));
370        assert!(include_telemetry(&DiagnosticsParams {
371            telemetry: Some(true),
372        }));
373    }
374}