diff --git a/skill.md b/skill.md new file mode 100644 index 0000000..2c5d96a --- /dev/null +++ b/skill.md @@ -0,0 +1,189 @@ +# Camera TRNG API Skill Documentation + +This document describes how to use the Camera TRNG (True Random Number Generator) API as an MCP-compatible service. + +## Overview + +The Camera TRNG server provides quantum-based random number generation using thermal noise from a covered camera sensor. The API is accessible via HTTP and self-documents its capabilities through the `.well-known/mcp.json` endpoint. + +## Discovery + +The API can be discovered by fetching the MCP endpoint: + +```bash +curl http://localhost:8787/.well-known/mcp.json +``` + +The response includes self-referencing URLs that use the request's hostname (from SNI/HTTP Host header), making it work correctly whether accessed via `localhost`, a domain name, or behind a reverse proxy. + +## API Endpoints + +### 1. Get Random Bytes (`/random`) + +Generate cryptographically secure random bytes from camera sensor entropy. + +**Parameters:** +- `bytes` (integer, default: 32, max: 1048576): Number of random bytes to generate +- `hex` (boolean, default: false): Return bytes as hexadecimal string instead of binary + +**Examples:** +```bash +# Get 32 bytes as binary +curl "http://localhost:8787/random?bytes=32" -o random.bin + +# Get 64 bytes as hex string +curl "http://localhost:8787/random?bytes=64&hex=true" + +# Get 1KB of random data +curl "http://localhost:8787/random?bytes=1024" -o random.bin +``` + +### 2. Get Raw Data (`/raw`) + +Get raw LSB (least significant bit) data from camera sensor without cryptographic extraction. Useful for testing or custom post-processing. + +**Parameters:** +- `bytes` (integer, default: 65536, max: 1048576): Number of raw bytes to extract + +**Example:** +```bash +curl "http://localhost:8787/raw?bytes=1024" -o raw.bin +``` + +### 3. Stream Random Bytes (`/stream`) + +Stream continuous random bytes using Server-Sent Events (SSE) format. Useful for high-throughput applications. + +**Parameters:** +- `bytes` (integer, optional): Total bytes to stream (omit for unlimited) +- `hex` (boolean, default: false): Stream as hexadecimal strings + +**Examples:** +```bash +# Stream unlimited random bytes +curl -N "http://localhost:8787/stream" + +# Stream exactly 1MB +curl -N "http://localhost:8787/stream?bytes=1048576" + +# Stream as hex +curl -N "http://localhost:8787/stream?hex=true" +``` + +### 4. List Cameras (`/cameras`) + +List available camera devices on the system. + +**Example:** +```bash +curl "http://localhost:8787/cameras" +``` + +**Response:** +```json +{ + "cameras": [ + { + "index": 0, + "human_name": "FaceTime HD Camera", + "description": "Built-in camera", + "misc": "" + } + ] +} +``` + +### 5. Health Check (`/health`) + +Check if the TRNG server is running. + +**Example:** +```bash +curl "http://localhost:8787/health" +``` + +**Response:** `ok` + +## MCP Integration + +The API implements the Model Context Protocol (MCP) specification, allowing AI assistants and other MCP clients to discover and use the TRNG service automatically. + +### MCP Tools + +The following tools are exposed via MCP: + +1. **get-random**: Generate random bytes (cryptographically secure) +2. **get-raw**: Extract raw sensor data +3. **get-stream**: Stream random bytes continuously +4. **list-cameras**: Discover available cameras +5. **health-check**: Verify server status + +### Self-Referencing URLs + +The MCP endpoint (`/.well-known/mcp.json`) automatically detects the request's hostname from: +- HTTP `Host` header +- `X-Forwarded-Proto` header (for reverse proxy scenarios) + +This ensures URLs in the MCP response always reference the correct origin, whether accessed via: +- `localhost:8787` +- `trng.example.com` +- Behind a reverse proxy with forwarded headers + +## Usage Examples + +### Python +```python +import requests + +# Get 32 random bytes +response = requests.get("http://localhost:8787/random?bytes=32") +random_bytes = response.content + +# Get hex string +response = requests.get("http://localhost:8787/random?bytes=64&hex=true") +hex_string = response.text +``` + +### JavaScript/Node.js +```javascript +// Get random bytes +const response = await fetch("http://localhost:8787/random?bytes=32"); +const buffer = await response.arrayBuffer(); +const bytes = new Uint8Array(buffer); + +// Stream random data +const stream = await fetch("http://localhost:8787/stream?bytes=1024"); +const reader = stream.body.getReader(); +// ... read chunks +``` + +### curl +```bash +# Basic usage +curl "http://localhost:8787/random?bytes=32&hex=true" + +# Save to file +curl "http://localhost:8787/random?bytes=1024" -o random.bin + +# Stream +curl -N "http://localhost:8787/stream" +``` + +## Limitations + +- Maximum 1MB per request (`/random` and `/raw`) +- Maximum 4 concurrent requests +- Requires a camera device with covered lens +- Performance depends on camera frame rate + +## Security Notes + +- No authentication required (intended for local/trusted networks) +- Random data is cryptographically secure when using `/random` endpoint +- Raw endpoint (`/raw`) provides unprocessed sensor data +- Consider rate limiting in production deployments + +## See Also + +- `TECHNICAL.md` - Technical implementation details +- `README.md` - Project overview and setup instructions diff --git a/src/main.rs b/src/main.rs index 87c8626..67e54a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use axum::{ body::Body, extract::Query, - http::{header, StatusCode}, + http::{header, HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse, Response, Json}, routing::get, Router, @@ -77,7 +77,10 @@ async fn main() -> Result<(), Box> { .route("/raw", get(get_raw)) .route("/stream", get(get_stream)) .route("/health", get(health)) - .route("/.well-known/mcp.json", get(mcp_wellknown)); + .route("/.well-known/mcp.json", get(mcp_wellknown)) + .route("/docs", get(get_docs)) + .route("/docs/skill.md", get(get_skill_md)) + .route("/docs/mcp.json", get(mcp_wellknown)); let addr = format!("0.0.0.0:{}", port); println!("Camera QRNG (LavaRnd-style) on http://{}", addr); @@ -89,6 +92,156 @@ async fn main() -> Result<(), Box> { async fn index() -> Html<&'static str> { Html(INDEX_HTML) } async fn health() -> &'static str { "ok" } +async fn get_docs() -> Html { + // Convert markdown to HTML (simple version) + let html = format!( + r#" + + + + + Camera TRNG API Documentation + + + +

Camera TRNG API Documentation

+

View as Markdown | View MCP JSON | Back to Home

+
+ {} + +"#, + markdown_to_html(SKILL_MD) + ); + Html(html) +} + +async fn get_skill_md() -> Response { + Response::builder() + .header(header::CONTENT_TYPE, "text/markdown; charset=utf-8") + .body(Body::from(SKILL_MD)) + .unwrap() +} + +fn markdown_to_html(md: &str) -> String { + use std::fmt::Write; + let mut html = String::new(); + let mut in_code_block = false; + let mut code_content = String::new(); + + for line in md.lines() { + if line.starts_with("```") { + if in_code_block { + write!(html, "
{}
+", escape_html(&code_content)).unwrap(); + code_content.clear(); + in_code_block = false; + } else { + in_code_block = true; + } + continue; + } + + if in_code_block { + code_content.push_str(line); + code_content.push_str("\n"); + continue; + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + html.push_str("
\n"); + } else if trimmed.starts_with("### ") { + write!(html, "

{}

+", escape_html(&trimmed[4..])).unwrap(); + } else if trimmed.starts_with("## ") { + write!(html, "

{}

+", escape_html(&trimmed[3..])).unwrap(); + } else if trimmed.starts_with("# ") { + write!(html, "

{}

+", escape_html(&trimmed[2..])).unwrap(); + } else { + // Simple inline processing + let mut processed = String::new(); + let mut chars = trimmed.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '`' => { + let mut code = String::new(); + while let Some(&next) = chars.peek() { + if next == '`' { + chars.next(); + write!(processed, "{}", escape_html(&code)).unwrap(); + break; + } + code.push(chars.next().unwrap()); + } + } + '*' if chars.peek() == Some(&'*') => { + chars.next(); + let mut bold = String::new(); + while let Some(&next) = chars.peek() { + if next == '*' && chars.clone().nth(1) == Some('*') { + chars.next(); + chars.next(); + write!(processed, "{}", escape_html(&bold)).unwrap(); + break; + } + bold.push(chars.next().unwrap()); + } + } + '[' => { + let mut text = String::new(); + let mut url = String::new(); + while let Some(&next) = chars.peek() { + if next == ']' { + chars.next(); + if chars.peek() == Some(&'(') { + chars.next(); + while let Some(&next) = chars.peek() { + if next == ')' { + chars.next(); + write!(processed, "{}", url, escape_html(&text)).unwrap(); + break; + } + url.push(chars.next().unwrap()); + } + } + break; + } + text.push(chars.next().unwrap()); + } + } + _ => processed.push(ch), + } + } + write!(html, "

{}

+", processed).unwrap(); + } + } + + html +} + +fn escape_html(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) +} + + + + async fn get_cameras() -> Response { match tokio::task::spawn_blocking(list_cameras).await { Ok(Ok(cameras)) => Json(serde_json::json!({ "cameras": cameras })).into_response(), @@ -97,27 +250,98 @@ async fn get_cameras() -> Response { } } -async fn mcp_wellknown() -> Json { +async fn mcp_wellknown(headers: HeaderMap, _uri: Uri) -> Json { + // Extract origin from request headers (Host header + scheme) + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8787"); + + // Determine scheme from X-Forwarded-Proto or default to http + let scheme = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + let origin = format!("{}://{}", scheme, host); + + // Build URL templates as strings first + let random_url = format!("{}/random?bytes={{bytes}}&hex={{hex}}", origin); + let raw_url = format!("{}/raw?bytes={{bytes}}", origin); + let stream_url = format!("{}/stream?bytes={{bytes}}&hex={{hex}}", origin); + let cameras_url = format!("{}/cameras", origin); + let health_url = format!("{}/health", origin); + let mcp_url = format!("{}/.well-known/mcp.json", origin); + Json(json!({ "mcp": { "spec_version": "2026-01-21", "status": "active", "servers": [], - "tools": [{ - "name": "camera-qrng", - "description": "High-throughput quantum RNG using thermal noise from covered camera sensor", - "url_template": "{origin}/random?bytes={bytes}&hex={hex}", - "capabilities": ["random-generation", "entropy-source", "quantum"], - "auth": { "type": "none" }, - "parameters": { - "bytes": { "type": "integer", "default": 32, "max": 1048576 }, - "hex": { "type": "boolean", "default": false } + "tools": [ + { + "name": "get-random", + "description": "Get cryptographically secure random bytes from camera sensor entropy", + "url_template": random_url, + "capabilities": ["random-generation", "entropy-source", "quantum"], + "auth": { "type": "none" }, + "parameters": { + "bytes": { "type": "integer", "default": 32, "min": 1, "max": 1048576, "description": "Number of random bytes to generate (max 1MB)" }, + "hex": { "type": "boolean", "default": false, "description": "Return bytes as hexadecimal string instead of binary" } + } + }, + { + "name": "get-raw", + "description": "Get raw LSB (least significant bit) data from camera sensor without cryptographic extraction", + "url_template": raw_url, + "capabilities": ["entropy-source", "raw-data"], + "auth": { "type": "none" }, + "parameters": { + "bytes": { "type": "integer", "default": 65536, "min": 1, "max": 1048576, "description": "Number of raw bytes to extract (max 1MB)" } + } + }, + { + "name": "get-stream", + "description": "Stream continuous random bytes (SSE format). Use ?bytes=N to limit total bytes, ?hex=true for hex output", + "url_template": stream_url, + "capabilities": ["random-generation", "entropy-source", "quantum", "streaming"], + "auth": { "type": "none" }, + "parameters": { + "bytes": { "type": "integer", "optional": true, "description": "Total bytes to stream (omit for unlimited)" }, + "hex": { "type": "boolean", "default": false, "description": "Stream as hexadecimal strings" } + } + }, + { + "name": "list-cameras", + "description": "List available camera devices", + "url_template": cameras_url, + "capabilities": ["device-discovery"], + "auth": { "type": "none" }, + "parameters": {} + }, + { + "name": "health-check", + "description": "Check if the TRNG server is running", + "url_template": health_url, + "capabilities": ["health"], + "auth": { "type": "none" }, + "parameters": {} } - }] + ], + "resources": [ + { + "uri": mcp_url, + "name": "MCP Documentation", + "description": "This MCP endpoint documentation", + "mimeType": "application/json" + } + ] } })) } + + async fn get_random(Query(params): Query) -> Response { let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst); if current >= MAX_CONCURRENT { @@ -222,3 +446,4 @@ async fn get_stream(Query(params): Query) -> Response { const INDEX_HTML: &str = include_str!("index.html"); +const SKILL_MD: &str = include_str!("../skill.md");