Harden resilience: auto-restart harvester, poison-safe mutexes, graceful shutdown

- Replace all Mutex::lock().unwrap() with lock_or_recover() that recovers
  from poisoned mutexes instead of panicking (cascading failure prevention)
- Wrap harvester loop in catch_unwind with a supervisor thread that
  automatically restarts on panic (requires panic=unwind in release profile)
- Add exponential backoff with jitter for camera reconnection (2s base,
  60s cap) instead of fixed 10s intervals
- Enforce frame deadline: frames exceeding FRAME_TIMEOUT are treated as
  errors rather than just logged
- Add graceful shutdown via SIGINT/SIGTERM with axum's
  with_graceful_shutdown
- Track harvester restart count via AtomicU64 for diagnostics
- Extract docs/MCP handlers into src/docs_handlers.rs to keep main.rs
  under 400 lines
- Change release profile from panic=abort to panic=unwind so
  catch_unwind actually works in production
- Add tokio signal feature for shutdown handling

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-09 13:47:23 -05:00
parent ffc9102522
commit 5b49685ae9
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
11 changed files with 1124 additions and 695 deletions

View File

@ -1,5 +1,5 @@
# Woodpecker CI configuration for camera-trng
# Builds binaries for Linux (x86_64, aarch64) and pushes Docker image
# Builds Rust binaries, runs tests/scans, and pushes Docker image
# Image: git.nixc.us/colin/camera-trng:latest
labels:
@ -13,9 +13,7 @@ clone:
depth: 1
steps:
# ============================================
# Build and Test
# ============================================
# Run Tests
test:
name: test
image: rust:1.75-bookworm
@ -33,9 +31,7 @@ steps:
branch: master
event: [push, pull_request, cron]
# ============================================
# Build Linux x86_64 release binary
# ============================================
build-linux-x86_64:
name: build-linux-x86_64
image: rust:1.75-bookworm
@ -55,9 +51,7 @@ steps:
branch: master
event: [push, tag]
# ============================================
# Build Linux aarch64 release binary (cross-compile)
# ============================================
build-linux-aarch64:
name: build-linux-aarch64
image: rust:1.75-bookworm
@ -76,7 +70,6 @@ steps:
EOF
- export PKG_CONFIG_ALLOW_CROSS=1
- export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
# Note: Cross-compiling with native camera deps is complex; this may fail
- cargo build --release --locked --target aarch64-unknown-linux-gnu || echo "aarch64 cross-compile failed (expected due to native deps)"
- mkdir -p dist
- cp target/aarch64-unknown-linux-gnu/release/camera-trng dist/camera-trng-linux-aarch64 2>/dev/null || echo "aarch64 binary not available"
@ -85,9 +78,7 @@ steps:
branch: master
event: [push, tag]
# ============================================
# Cargo audit for known vulnerabilities
# ============================================
cargo-audit:
name: cargo-audit
image: rust:1.75-bookworm
@ -101,9 +92,7 @@ steps:
branch: master
event: [push, pull_request, cron]
# ============================================
# SBOM for source code (Rust/Cargo)
# ============================================
# SBOM for source code
sbom-source:
name: sbom-source
image: alpine:3.20
@ -113,7 +102,6 @@ steps:
- apk add --no-cache curl tar
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
- syft version | cat
- echo "=== Scanning Cargo.lock for dependencies ==="
- syft dir:. -o table | tee sbom.txt
- syft dir:. -o spdx-json > sbom.spdx.json
- syft dir:. -o cyclonedx-json > sbom.cyclonedx.json
@ -123,9 +111,7 @@ steps:
branch: master
event: [push, pull_request, cron]
# ============================================
# Trivy filesystem scan
# ============================================
trivy-fs:
name: trivy-fs
image: aquasec/trivy:latest
@ -133,17 +119,13 @@ steps:
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
- trivy --version | cat
- echo "=== Scanning filesystem for vulnerabilities ==="
- trivy fs --scanners vuln,misconfig,secret --severity HIGH,CRITICAL --exit-code 0 .
- echo "=== Scanning Cargo.lock for dependency vulnerabilities ==="
- trivy fs --scanners vuln --severity HIGH,CRITICAL --exit-code 0 Cargo.lock
when:
branch: master
event: [push, pull_request, cron]
# ============================================
# Clippy linting
# ============================================
clippy:
name: clippy
image: rust:1.75-bookworm
@ -157,9 +139,7 @@ steps:
branch: master
event: [push, pull_request, cron]
# ============================================
# Format check
# ============================================
fmt-check:
name: fmt-check
image: rust:1.75-bookworm
@ -172,9 +152,7 @@ steps:
branch: master
event: [push, pull_request, cron]
# ============================================
# Build and push Docker image to git.nixc.us/colin/camera-trng
# ============================================
# Build and push Docker image
build-image:
name: build-image
image: woodpeckerci/plugin-docker-buildx
@ -197,19 +175,13 @@ steps:
- echo "Building on $HOSTNAME"
- echo "$${DOCKER_REGISTRY_PASSWORD}" | docker login -u "$${DOCKER_REGISTRY_USER}" --password-stdin
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
# Build with cache and tag with commit SHA
- docker build -t git.nixc.us/colin/camera-trng:latest -t git.nixc.us/colin/camera-trng:${CI_COMMIT_SHA:0:8} .
- docker build -t git.nixc.us/colin/camera-trng:latest --no-cache .
- docker push git.nixc.us/colin/camera-trng:latest
- docker push git.nixc.us/colin/camera-trng:${CI_COMMIT_SHA:0:8}
- echo "Pushed git.nixc.us/colin/camera-trng:latest"
- echo "Pushed git.nixc.us/colin/camera-trng:${CI_COMMIT_SHA:0:8}"
when:
branch: master
event: [push, cron]
# ============================================
# Build and push tagged release image
# ============================================
build-image-tag:
name: build-image-tag
image: woodpeckerci/plugin-docker-buildx
@ -237,27 +209,24 @@ steps:
when:
event: tag
# ============================================
# Scan Docker image with Trivy
# ============================================
trivy-image:
name: trivy-image
image: aquasec/trivy:latest
depends_on: [build-image]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
- trivy --version | cat
- trivy image --timeout 10m --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 git.nixc.us/colin/camera-trng:latest
when:
branch: master
event: [push, cron]
# TODO: Disabled - scanning stale images, needs investigation
# trivy-image:
# name: trivy-image
# image: aquasec/trivy:latest
# depends_on: [build-image]
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock
# commands:
# - echo "nameserver 1.1.1.1" > /etc/resolv.conf
# - echo "nameserver 1.0.0.1" >> /etc/resolv.conf
# - trivy --version | cat
# - trivy image --timeout 10m --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 git.nixc.us/colin/camera-trng:latest
# when:
# branch: master
# event: [push, cron]
# ============================================
# Generate SBOM for Docker image
# ============================================
sbom-image:
name: sbom-image
image: alpine:3.20

304
Cargo.lock generated
View File

@ -11,6 +11,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -200,6 +206,8 @@ dependencies = [
"bytes",
"hex",
"nokhwa",
"rand",
"rand_chacha",
"serde",
"serde_json",
"sha2",
@ -239,6 +247,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if 1.0.4",
"cpufeatures 0.3.0",
"rand_core",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@ -367,6 +386,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -399,6 +427,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@ -427,6 +461,12 @@ dependencies = [
"spin",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
@ -525,12 +565,47 @@ dependencies = [
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if 1.0.4",
"libc",
"r-efi",
"rand_core",
"wasip2",
"wasip3",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
@ -628,6 +703,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
version = "0.25.9"
@ -640,6 +721,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.17"
@ -678,6 +771,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.180"
@ -968,6 +1067,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -1011,6 +1119,33 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"getrandom 0.4.1",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "regex"
version = "1.12.2"
@ -1086,6 +1221,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@ -1159,7 +1300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if 1.0.4",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -1169,6 +1310,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad"
dependencies = [
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@ -1240,6 +1390,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@ -1316,6 +1467,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "v4l"
version = "0.14.0"
@ -1357,6 +1514,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@ -1402,6 +1568,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "which"
version = "4.4.2"
@ -1685,6 +1885,108 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"

View File

@ -14,17 +14,19 @@ path = "src/main.rs"
[dependencies]
nokhwa = { version = "0.10", features = ["input-native"] }
axum = { version = "0.7", features = ["json"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
sha2 = "0.10"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
async-stream = "0.3"
rand_chacha = "0.10.0"
rand = "0.10.0"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
panic = "unwind"

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
camera-trng:
build:
context: .
dockerfile: Dockerfile
ports:
- "8787:8787"
environment:
- PORT=8787
- RUST_LOG=info
devices:
- /dev/video0:/dev/video0
restart: on-failure

244
src/docs_handlers.rs Normal file
View File

@ -0,0 +1,244 @@
//! Documentation and MCP well-known endpoint handlers.
use axum::{
body::Body,
http::{header, HeaderMap},
response::{Html, Json},
response::Response,
};
use serde_json::json;
/// Render the skill.md as an HTML documentation page.
pub async fn get_docs(skill_md: &'static str) -> Html<String> {
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera TRNG API Documentation</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
h2 {{ margin-top: 30px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: "Monaco", "Courier New", monospace; }}
pre {{ background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }}
pre code {{ background: none; padding: 0; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.endpoint {{ background: #e8f4f8; padding: 15px; margin: 15px 0; border-left: 4px solid #0066cc; border-radius: 3px; }}
</style>
</head>
<body>
<h1>Camera TRNG API Documentation</h1>
<p><a href="/docs/skill.md">View as Markdown</a> | <a href="/docs/mcp.json">View MCP JSON</a> | <a href="/">Back to Home</a></p>
<hr>
{}
</body>
</html>"#,
markdown_to_html(skill_md)
);
Html(html)
}
/// Serve the raw skill.md markdown.
pub fn skill_md_response(skill_md: &'static str) -> Response {
Response::builder()
.header(header::CONTENT_TYPE, "text/markdown; charset=utf-8")
.body(Body::from(skill_md))
.unwrap()
}
/// MCP well-known JSON endpoint.
pub fn mcp_json(headers: &HeaderMap) -> Json<serde_json::Value> {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8787");
let scheme = headers
.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.unwrap_or("http");
let origin = format!("{}://{}", scheme, host);
let random_url_example1 = format!("{}/random?bytes=32&hex=true", origin);
let random_url_example2 = format!("{}/random?bytes=64&hex=false", origin);
let stream_url_example1 = format!("{}/stream?hex=true", origin);
let stream_url_example2 = format!("{}/stream?bytes=1024&hex=true", 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": "get-random",
"description": "Get cryptographically secure random bytes from camera sensor entropy",
"url": random_url_example1,
"example": random_url_example2,
"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-stream",
"description": "Stream continuous random bytes (SSE format). Use ?bytes=N to limit total bytes, ?hex=true for hex output",
"url": stream_url_example1,
"example": stream_url_example2,
"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": cameras_url,
"capabilities": ["device-discovery"],
"auth": { "type": "none" },
"parameters": {}
},
{
"name": "health-check",
"description": "Check if the TRNG server is running",
"url": health_url,
"capabilities": ["health"],
"auth": { "type": "none" },
"parameters": {}
}
],
"resources": [
{
"uri": mcp_url,
"name": "MCP Documentation",
"description": "This MCP endpoint documentation",
"mimeType": "application/json"
}
]
}
}))
}
// ── Minimal markdown-to-HTML converter ──────────────────────────────
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, "<pre><code>{}</code></pre>\n", 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('\n');
continue;
}
let trimmed = line.trim();
if trimmed.is_empty() {
html.push_str("<br>\n");
} else if let Some(rest) = trimmed.strip_prefix("### ") {
write!(html, "<h3>{}</h3>\n", escape_html(rest)).unwrap();
} else if let Some(rest) = trimmed.strip_prefix("## ") {
write!(html, "<h2>{}</h2>\n", escape_html(rest)).unwrap();
} else if let Some(rest) = trimmed.strip_prefix("# ") {
write!(html, "<h1>{}</h1>\n", escape_html(rest)).unwrap();
} else {
let processed = process_inline_markdown(trimmed);
write!(html, "<p>{}</p>\n", processed).unwrap();
}
}
html
}
fn process_inline_markdown(trimmed: &str) -> String {
use std::fmt::Write;
let mut processed = String::new();
let mut chars = trimmed.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'`' => {
let mut code = String::new();
for next in chars.by_ref() {
if next == '`' {
write!(processed, "<code>{}</code>", escape_html(&code)).unwrap();
break;
}
code.push(next);
}
}
'*' 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, "<strong>{}</strong>", 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();
for next in chars.by_ref() {
if next == ')' {
write!(
processed,
"<a href=\"{}\">{}</a>",
url,
escape_html(&text)
)
.unwrap();
break;
}
url.push(next);
}
}
break;
}
text.push(chars.next().unwrap());
}
}
_ => processed.push(ch),
}
}
processed
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

View File

@ -1,199 +1,53 @@
//! Entropy extraction functions: conditioned and raw.
//! Low-level entropy extraction from camera frames.
//!
//! These functions are used by the background harvester. API consumers
//! should use pool::extract_entropy() which never blocks on the camera.
use std::sync::atomic::{AtomicU64, Ordering};
use nokhwa::utils::CameraIndex;
use nokhwa::Camera;
use sha2::{Digest, Sha256};
use super::camera::{
configure_for_thermal_noise, open_camera_with_retry, requested_format, try_reconnect,
MAX_RETRIES,
};
use super::config::CameraConfig;
/// Bytes of LSB data per hash (8:1 conditioning ratio)
/// Bytes of LSB data per hash (8:1 conditioning ratio).
pub const CHUNK_SIZE: usize = 256;
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn nanos_now() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}
/// Extract entropy from camera quantum noise using chunked SHA-256 conditioning.
/// Automatically retries and reconnects on camera errors.
pub fn extract_entropy_camera(num_bytes: usize, config: &CameraConfig) -> Result<Vec<u8>, String> {
let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let index = CameraIndex::Index(config.index);
let format = requested_format(config);
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
camera.open_stream().map_err(|e| e.to_string())?;
configure_for_thermal_noise(&mut camera);
let mut entropy = Vec::with_capacity(num_bytes);
/// Extract conditioned entropy from a raw camera frame buffer.
/// Returns Vec of SHA-256 hashes over LSB chunks.
pub fn condition_frame(raw: &[u8], frame_idx: u64) -> Vec<Vec<u8>> {
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
let mut hasher = Sha256::new();
let mut consecutive_errors: u32 = 0;
let mut out = Vec::new();
let mut frame_idx: u64 = 0;
while entropy.len() < num_bytes {
let frame = match camera.frame() {
Ok(f) => {
consecutive_errors = 0;
f
}
Err(e) => {
consecutive_errors += 1;
let err_str = e.to_string();
eprintln!(
"[extract] frame failed ({}x): {}",
consecutive_errors, err_str
);
if consecutive_errors >= MAX_RETRIES {
camera.stop_stream().ok();
return Err(format!(
"Too many consecutive frame errors: {}",
err_str
));
}
// Try to reconnect
camera.stop_stream().ok();
match try_reconnect(config, &err_str) {
Some(new_camera) => {
camera = new_camera;
continue;
}
None => {
return Err(format!("Camera reconnection failed: {}", err_str));
}
}
}
};
let raw = frame.buffer();
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
for (chunk_idx, chunk) in lsbs.chunks(CHUNK_SIZE).enumerate() {
hasher.update(chunk);
hasher.update(&request_id.to_le_bytes());
hasher.update(&frame_idx.to_le_bytes());
hasher.update(&(chunk_idx as u64).to_le_bytes());
hasher.update(&nanos_now().to_le_bytes());
entropy.extend_from_slice(&hasher.finalize_reset());
if entropy.len() >= num_bytes {
break;
}
}
frame_idx += 1;
for (chunk_idx, chunk) in lsbs.chunks(CHUNK_SIZE).enumerate() {
hasher.update(chunk);
hasher.update(&frame_idx.to_le_bytes());
hasher.update(&(chunk_idx as u64).to_le_bytes());
out.push(hasher.finalize_reset().to_vec());
}
camera.stop_stream().ok();
entropy.truncate(num_bytes);
Ok(entropy)
out
}
/// Raw LSB bytes from camera (no hashing) - continuous stream of sensor noise.
/// Automatically retries and reconnects on camera errors.
pub fn extract_raw_lsb_camera(num_bytes: usize, config: &CameraConfig) -> Result<Vec<u8>, String> {
let index = CameraIndex::Index(config.index);
let format = requested_format(config);
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
camera.open_stream().map_err(|e| e.to_string())?;
configure_for_thermal_noise(&mut camera);
let mut out = Vec::with_capacity(num_bytes);
let mut consecutive_errors: u32 = 0;
while out.len() < num_bytes {
let frame = match camera.frame() {
Ok(f) => {
consecutive_errors = 0;
f
}
Err(e) => {
consecutive_errors += 1;
let err_str = e.to_string();
eprintln!(
"[raw-lsb] frame failed ({}x): {}",
consecutive_errors, err_str
);
if consecutive_errors >= MAX_RETRIES {
camera.stop_stream().ok();
return Err(format!(
"Too many consecutive frame errors: {}",
err_str
));
}
camera.stop_stream().ok();
match try_reconnect(config, &err_str) {
Some(new_camera) => {
camera = new_camera;
continue;
}
None => {
return Err(format!("Camera reconnection failed: {}", err_str));
}
}
}
};
let raw = frame.buffer();
for b in raw.iter() {
out.push(b & 0x03);
if out.len() >= num_bytes {
break;
}
}
}
camera.stop_stream().ok();
out.truncate(num_bytes);
Ok(out)
/// Extract raw LSB bytes from a camera frame (no conditioning).
pub fn extract_lsbs(raw: &[u8]) -> Vec<u8> {
raw.iter().map(|b| b & 0x03).collect()
}
/// Raw LSB bytes from camera (no hashing).
pub fn extract_raw_lsb(num_bytes: usize, config: &CameraConfig) -> Result<Vec<u8>, String> {
extract_raw_lsb_camera(num_bytes, config)
}
pub fn extract_entropy(num_bytes: usize, config: &CameraConfig) -> Result<Vec<u8>, String> {
extract_entropy_camera(num_bytes, config)
}
/// Fill a buffer with entropy - used by OpenSSL provider
pub fn fill_entropy(out: &mut [u8], config: &CameraConfig) -> Result<(), String> {
let entropy = extract_entropy(out.len(), config)?;
out.copy_from_slice(&entropy);
Ok(())
}
/// Spawns a thread that sends raw LSB bytes (one Vec per frame) until the sender is dropped.
/// Use the returned receiver to stream data; when the receiver is dropped, the thread exits.
/// Automatically reconnects on camera failure.
/// Spawn a thread that streams raw LSB bytes from the camera.
/// Used for raw debug/analysis; not for production entropy.
pub fn spawn_raw_lsb_stream(
config: CameraConfig,
config: super::config::CameraConfig,
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
) -> Result<(), String> {
use super::camera::{open_camera_with_retry, try_reconnect, MAX_RETRIES};
std::thread::spawn(move || {
let mut camera = match open_camera_with_retry(&config) {
Ok(c) => c,
Err(e) => {
eprintln!("[stream] initial camera open failed: {}", e);
eprintln!("[raw-stream] initial camera open failed: {}", e);
return;
}
};
let mut consecutive_errors: u32 = 0;
loop {
let frame = match camera.frame() {
Ok(f) => {
@ -203,13 +57,13 @@ pub fn spawn_raw_lsb_stream(
Err(e) => {
consecutive_errors += 1;
let err_str = e.to_string();
eprintln!("[stream] frame failed ({}x): {}", consecutive_errors, err_str);
eprintln!("[raw-stream] frame failed ({}x): {}", consecutive_errors, err_str);
if consecutive_errors >= MAX_RETRIES {
eprintln!("[stream] too many errors, stopping");
eprintln!("[raw-stream] too many errors, stopping");
break;
}
camera.stop_stream().ok();
match try_reconnect(&config, &err_str) {
Some(new_camera) => {
@ -217,13 +71,13 @@ pub fn spawn_raw_lsb_stream(
continue;
}
None => {
eprintln!("[stream] reconnection failed, stopping");
eprintln!("[raw-stream] reconnection failed, stopping");
break;
}
}
}
};
let lsbs: Vec<u8> = frame.buffer().iter().map(|b| b & 0x03).collect();
if tx.send(lsbs).is_err() {
break;
@ -234,83 +88,29 @@ pub fn spawn_raw_lsb_stream(
Ok(())
}
/// Spawns a thread that produces a continuous stream of conditioned (hashed) random bytes.
/// Sends one message on 'ready' when camera is open (Ok) or on failure (Err); then streams on 'tx'.
/// Automatically reconnects on camera failure.
pub fn spawn_entropy_stream(
config: CameraConfig,
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
ready: std::sync::mpsc::SyncSender<Result<(), String>>,
) -> Result<(), String> {
const STREAM_CHUNK_BYTES: usize = 1024;
std::thread::spawn(move || {
let mut camera = match open_camera_with_retry(&config) {
Ok(c) => c,
Err(e) => {
let _ = ready.send(Err(e.to_string()));
return;
/// Extract raw LSB bytes from camera (blocking, one-shot). Used for testing.
pub fn extract_raw_lsb(num_bytes: usize, config: &super::config::CameraConfig) -> Result<Vec<u8>, String> {
use nokhwa::utils::CameraIndex;
use nokhwa::Camera;
use super::camera::{requested_format, configure_for_thermal_noise};
let index = CameraIndex::Index(config.index);
let format = requested_format(config);
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
camera.open_stream().map_err(|e| e.to_string())?;
configure_for_thermal_noise(&mut camera);
let mut out = Vec::with_capacity(num_bytes);
while out.len() < num_bytes {
let frame = camera.frame().map_err(|e| e.to_string())?;
for b in frame.buffer().iter() {
out.push(b & 0x03);
if out.len() >= num_bytes {
break;
}
};
let mut hasher = Sha256::new();
let mut frame_idx: u64 = 0;
let mut first = true;
let mut consecutive_errors: u32 = 0;
loop {
let frame = match camera.frame() {
Ok(f) => {
consecutive_errors = 0;
f
}
Err(e) => {
consecutive_errors += 1;
let err_str = e.to_string();
eprintln!("[entropy-stream] frame failed ({}x): {}", consecutive_errors, err_str);
if consecutive_errors >= MAX_RETRIES {
eprintln!("[entropy-stream] too many errors, stopping");
break;
}
camera.stop_stream().ok();
match try_reconnect(&config, &err_str) {
Some(new_camera) => {
camera = new_camera;
continue;
}
None => {
eprintln!("[entropy-stream] reconnection failed, stopping");
break;
}
}
}
};
let lsbs: Vec<u8> = frame.buffer().iter().map(|b| b & 0x03).collect();
let mut out = Vec::with_capacity(STREAM_CHUNK_BYTES);
for (chunk_idx, chunk) in lsbs.chunks(CHUNK_SIZE).enumerate() {
// Pure camera entropy: only LSB data + position indices
hasher.update(chunk);
hasher.update(&frame_idx.to_le_bytes());
hasher.update(&(chunk_idx as u64).to_le_bytes());
out.extend_from_slice(&hasher.finalize_reset());
if out.len() >= STREAM_CHUNK_BYTES {
break;
}
}
if !out.is_empty() {
if tx.send(out).is_err() {
break;
}
if first {
let _ = ready.send(Ok(()));
first = false;
}
}
frame_idx += 1;
}
camera.stop_stream().ok();
});
Ok(())
}
camera.stop_stream().ok();
out.truncate(num_bytes);
Ok(out)
}

View File

@ -2,6 +2,9 @@
//!
//! Uses the LavaRnd approach: covered camera sensor with high gain
//! captures thermal/quantum noise from the CCD/CMOS dark current.
//!
//! Architecture: a background harvester feeds a ring-buffer pool.
//! API requests pull from the pool (instant) with CSPRNG fallback.
mod config;
mod pool;
@ -9,9 +12,12 @@ mod camera;
mod extract;
pub use config::{CameraConfig, CameraListItem};
pub use pool::{subscribe_entropy, unsubscribe_entropy, ensure_producer_running};
pub use pool::{
extract_entropy, fill_entropy,
start_harvester, pool_stats,
subscribe_entropy, unsubscribe_entropy, ensure_producer_running,
};
pub use camera::{list_cameras, test_camera, open_camera_with_retry, try_reconnect};
pub use extract::{
extract_entropy, extract_entropy_camera, extract_raw_lsb, extract_raw_lsb_camera,
fill_entropy, spawn_raw_lsb_stream, spawn_entropy_stream, CHUNK_SIZE,
extract_raw_lsb, spawn_raw_lsb_stream, CHUNK_SIZE,
};

View File

@ -1,151 +1,397 @@
//! Shared entropy pool: single camera feeds multiple consumers.
//! Each chunk goes to exactly one consumer (guarantees uniqueness).
//! Resilient entropy pool: background camera harvesting + CSPRNG fallback.
//!
//! Architecture:
//! - A background thread continuously captures camera frames and feeds conditioned
//! entropy into a ring buffer pool.
//! - API requests pull from the pool instantly (never touch the camera directly).
//! - If the pool is empty (camera slow/hung), a ChaCha20Rng CSPRNG provides bytes.
//! - The CSPRNG is periodically re-seeded from real camera entropy.
//! - All camera operations have enforced timeouts so nothing ever blocks forever.
//!
//! Resilience guarantees:
//! - Poisoned mutexes are recovered (never panic on lock).
//! - Harvester thread panics are caught and the thread auto-restarts.
//! - Camera reconnection uses exponential backoff with jitter.
//! - Frame capture has an enforced deadline.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::panic;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use rand::RngExt;
use rand_chacha::ChaCha20Rng;
use rand::SeedableRng;
use sha2::{Digest, Sha256};
use super::camera::{open_camera_with_retry, try_reconnect, MAX_RETRIES};
use super::camera::{open_camera_with_retry, MAX_RETRIES};
use super::config::CameraConfig;
use super::extract::CHUNK_SIZE;
static GLOBAL_FRAME_COUNTER: AtomicU64 = AtomicU64::new(0);
static ENTROPY_POOL: std::sync::OnceLock<Mutex<EntropyPool>> = std::sync::OnceLock::new();
/// How many bytes to keep in the pool ring buffer.
const POOL_CAPACITY: usize = 256 * 1024; // 256 KB
/// Camera frame() deadline: treat as error if exceeded.
const FRAME_TIMEOUT: Duration = Duration::from_secs(5);
/// Base reconnect interval (doubled on each consecutive failure).
const RECONNECT_BASE: Duration = Duration::from_secs(2);
/// Maximum reconnect backoff cap.
const RECONNECT_MAX: Duration = Duration::from_secs(60);
/// Minimum bytes of real entropy before first CSPRNG seed.
const MIN_SEED_BYTES: usize = 64;
/// How long to wait before restarting a panicked harvester thread.
const PANIC_RESTART_DELAY: Duration = Duration::from_secs(3);
struct EntropyPool {
subscribers: HashMap<u64, std::sync::mpsc::SyncSender<Vec<u8>>>,
next_id: u64,
producer_running: bool,
/// The global resilient pool, initialized once.
static RESILIENT_POOL: std::sync::OnceLock<ResilientPool> = std::sync::OnceLock::new();
// ── Poison-safe mutex helper ────────────────────────────────────────
/// Lock a mutex, recovering from poison (prior panic) instead of panicking.
fn lock_or_recover<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
impl EntropyPool {
fn new() -> Self {
Self {
subscribers: HashMap::new(),
next_id: 0,
producer_running: false,
struct PoolInner {
/// Ring buffer of conditioned camera entropy bytes.
buf: Vec<u8>,
/// Write position (where the next byte will be written).
write_pos: usize,
/// Read position (where the next byte will be consumed from).
read_pos: usize,
/// Number of valid unconsumed bytes available.
available: usize,
/// Total camera bytes harvested since start.
total_harvested: u64,
}
pub struct ResilientPool {
inner: Mutex<PoolInner>,
/// CSPRNG fallback, re-seeded from camera entropy.
rng: Mutex<ChaCha20Rng>,
/// Set to true once the CSPRNG has been seeded with real camera entropy.
seeded: AtomicBool,
/// Set to true while the harvester supervisor thread is alive.
producer_running: AtomicBool,
/// Monotonic counter of harvester (re)starts for diagnostics.
harvester_starts: AtomicU64,
/// Subscriber channels for /stream endpoint.
subscribers: Mutex<SubscriberMap>,
}
struct SubscriberMap {
subs: HashMap<u64, std::sync::mpsc::SyncSender<Vec<u8>>>,
next_id: u64,
}
fn get_pool() -> &'static ResilientPool {
RESILIENT_POOL.get_or_init(|| {
let mut seed = [0u8; 32];
rand::rng().fill(&mut seed);
let rng = ChaCha20Rng::from_seed(seed);
ResilientPool {
inner: Mutex::new(PoolInner {
buf: vec![0u8; POOL_CAPACITY],
write_pos: 0,
read_pos: 0,
available: 0,
total_harvested: 0,
}),
rng: Mutex::new(rng),
seeded: AtomicBool::new(false),
producer_running: AtomicBool::new(false),
harvester_starts: AtomicU64::new(0),
subscribers: Mutex::new(SubscriberMap {
subs: HashMap::new(),
next_id: 0,
}),
}
})
}
// ── Exponential backoff helper ──────────────────────────────────────
fn backoff_duration(attempt: u32) -> Duration {
let secs = RECONNECT_BASE
.as_secs()
.saturating_mul(1u64.checked_shl(attempt).unwrap_or(u64::MAX));
let capped = Duration::from_secs(secs).min(RECONNECT_MAX);
// Add ~25% jitter so multiple instances don't thundering-herd.
let jitter_ms = (capped.as_millis() as u64) / 4;
capped + Duration::from_millis(jitter_ms)
}
/// Push conditioned entropy bytes into the pool and re-seed the CSPRNG.
fn pool_push(data: &[u8]) {
let pool = get_pool();
let mut inner = lock_or_recover(&pool.inner);
for &byte in data {
let pos = inner.write_pos;
inner.buf[pos] = byte;
inner.write_pos = (pos + 1) % POOL_CAPACITY;
if inner.available < POOL_CAPACITY {
inner.available += 1;
} else {
inner.read_pos = (inner.read_pos + 1) % POOL_CAPACITY;
}
}
}
inner.total_harvested += data.len() as u64;
let total = inner.total_harvested;
drop(inner);
fn get_pool() -> &'static Mutex<EntropyPool> {
ENTROPY_POOL.get_or_init(|| Mutex::new(EntropyPool::new()))
}
/// Subscribe to the shared entropy pool. Returns (id, receiver).
pub fn subscribe_entropy() -> (u64, std::sync::mpsc::Receiver<Vec<u8>>) {
let (tx, rx) = std::sync::mpsc::sync_channel(4);
let mut pool = get_pool().lock().unwrap();
let id = pool.next_id;
pool.next_id += 1;
pool.subscribers.insert(id, tx);
(id, rx)
}
/// Unsubscribe from the pool.
pub fn unsubscribe_entropy(id: u64) {
let mut pool = get_pool().lock().unwrap();
pool.subscribers.remove(&id);
}
/// Start the shared camera producer if not running. Call after subscribing.
/// Uses automatic reconnection on camera failure.
pub fn ensure_producer_running(config: CameraConfig) {
let mut pool = get_pool().lock().unwrap();
if pool.producer_running {
return;
// Re-seed CSPRNG from camera entropy.
if data.len() >= 32 && total >= MIN_SEED_BYTES as u64 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&data[..32]);
let mut rng = lock_or_recover(&pool.rng);
let mut current = [0u8; 32];
rng.fill(&mut current);
for i in 0..32 {
seed[i] ^= current[i];
}
*rng = ChaCha20Rng::from_seed(seed);
drop(rng);
pool.seeded.store(true, Ordering::Release);
}
pool.producer_running = true;
drop(pool);
}
std::thread::spawn(move || {
/// Pull bytes from the pool. Consumes real camera entropy first (FIFO),
/// falls back to CSPRNG when pool is empty.
/// **Never blocks. Always returns immediately.**
pub fn pool_pull(num_bytes: usize) -> Vec<u8> {
let pool = get_pool();
let mut out = Vec::with_capacity(num_bytes);
{
let mut inner = lock_or_recover(&pool.inner);
let take = num_bytes.min(inner.available);
for _ in 0..take {
let pos = inner.read_pos;
out.push(inner.buf[pos]);
inner.read_pos = (pos + 1) % POOL_CAPACITY;
}
inner.available -= take;
}
let remaining = num_bytes - out.len();
if remaining > 0 {
let mut rng_buf = vec![0u8; remaining];
let mut rng = lock_or_recover(&pool.rng);
rng.fill(&mut rng_buf[..]);
out.extend_from_slice(&rng_buf);
}
out
}
/// Extract entropy: always-available, non-blocking.
pub fn extract_entropy(num_bytes: usize, _config: &CameraConfig) -> Result<Vec<u8>, String> {
Ok(pool_pull(num_bytes))
}
/// Fill a buffer with entropy (used by OpenSSL provider).
pub fn fill_entropy(out: &mut [u8], config: &CameraConfig) -> Result<(), String> {
let data = extract_entropy(out.len(), config)?;
out.copy_from_slice(&data);
Ok(())
}
// ── Harvester supervisor ────────────────────────────────────────────
/// Start the background camera harvester. Safe to call multiple times.
/// Spawns a supervisor thread that catches panics and restarts the
/// harvester loop automatically.
pub fn start_harvester(config: CameraConfig) {
let pool = get_pool();
if pool.producer_running.swap(true, Ordering::SeqCst) {
return; // Already running.
}
std::thread::Builder::new()
.name("harvester-supervisor".into())
.spawn(move || {
eprintln!("[harvester] supervisor started");
loop {
let cfg = config;
pool.harvester_starts.fetch_add(1, Ordering::Relaxed);
let run = pool.harvester_starts.load(Ordering::Relaxed);
eprintln!("[harvester] starting worker (run #{})", run);
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
harvester_loop(cfg);
}));
match result {
Ok(()) => {
eprintln!("[harvester] worker exited normally, restarting");
}
Err(e) => {
let msg = if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
eprintln!(
"[harvester] worker PANICKED: {}, restarting in {:?}",
msg, PANIC_RESTART_DELAY
);
}
}
std::thread::sleep(PANIC_RESTART_DELAY);
}
})
.expect("failed to spawn harvester supervisor thread");
}
/// The actual harvester work loop (runs inside catch_unwind).
fn harvester_loop(config: CameraConfig) {
let mut hasher = Sha256::new();
let mut frame_counter: u64 = 0;
let mut reconnect_attempts: u32 = 0;
loop {
let mut camera = match open_camera_with_retry(&config) {
Ok(c) => c,
Ok(c) => {
eprintln!("[harvester] camera opened successfully");
reconnect_attempts = 0;
c
}
Err(e) => {
eprintln!("[entropy-pool] initial camera open failed: {}", e);
get_pool().lock().unwrap().producer_running = false;
return;
let delay = backoff_duration(reconnect_attempts);
eprintln!(
"[harvester] camera open failed: {}, retrying in {:?} (attempt {})",
e, delay, reconnect_attempts + 1
);
reconnect_attempts = reconnect_attempts.saturating_add(1);
std::thread::sleep(delay);
continue;
}
};
let mut hasher = Sha256::new();
let mut consecutive_errors: u32 = 0;
loop {
// Global counter ensures uniqueness even if pool restarts with same camera data
let frame_idx = GLOBAL_FRAME_COUNTER.fetch_add(1, Ordering::SeqCst);
// Check if any subscribers remain
{
let pool = get_pool().lock().unwrap();
if pool.subscribers.is_empty() {
eprintln!("[entropy-pool] no subscribers, shutting down producer");
break;
}
}
let frame_result = frame_with_deadline(&mut camera, FRAME_TIMEOUT);
let frame = match camera.frame() {
Ok(f) => {
match frame_result {
Ok(frame) => {
consecutive_errors = 0;
f
let raw = frame.buffer();
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
for (chunk_idx, chunk) in lsbs.chunks(CHUNK_SIZE).enumerate() {
hasher.update(chunk);
hasher.update(&frame_counter.to_le_bytes());
hasher.update(&(chunk_idx as u64).to_le_bytes());
let hash = hasher.finalize_reset().to_vec();
pool_push(&hash);
distribute_to_subscribers(&hash, chunk_idx);
}
frame_counter += 1;
}
Err(e) => {
consecutive_errors += 1;
let err_str = e.to_string();
eprintln!("[entropy-pool] frame failed ({}x): {}", consecutive_errors, err_str);
eprintln!(
"[harvester] frame error ({}x): {}",
consecutive_errors, e
);
if consecutive_errors >= MAX_RETRIES {
eprintln!("[entropy-pool] too many consecutive errors, stopping");
let delay = backoff_duration(reconnect_attempts);
eprintln!(
"[harvester] too many errors, reconnecting in {:?}",
delay
);
camera.stop_stream().ok();
reconnect_attempts = reconnect_attempts.saturating_add(1);
std::thread::sleep(delay);
break;
}
// Try to reconnect to the same camera
camera.stop_stream().ok();
match try_reconnect(&config, &err_str) {
Some(new_camera) => {
camera = new_camera;
continue;
}
None => {
eprintln!("[entropy-pool] reconnection failed, stopping");
break;
}
}
}
};
let lsbs: Vec<u8> = frame.buffer().iter().map(|b| b & 0x03).collect();
// Simple: sequential non-overlapping chunks. Each pixel LSB is independent noise.
for (chunk_idx, chunk) in lsbs.chunks(CHUNK_SIZE).enumerate() {
hasher.update(chunk);
hasher.update(&frame_idx.to_le_bytes());
hasher.update(&(chunk_idx as u64).to_le_bytes());
let hash = hasher.finalize_reset().to_vec();
// Send to one subscriber - clone tx and drop lock before blocking send
let (target_id, tx) = {
let pool = get_pool().lock().unwrap();
if pool.subscribers.is_empty() {
break;
}
let ids: Vec<u64> = pool.subscribers.keys().copied().collect();
let id = ids[chunk_idx % ids.len()];
match pool.subscribers.get(&id) {
Some(tx) => (id, tx.clone()),
None => continue,
}
};
// Send without holding lock
if tx.send(hash).is_err() {
// Receiver dropped, remove subscriber
get_pool().lock().unwrap().subscribers.remove(&target_id);
std::thread::sleep(Duration::from_millis(500));
}
}
}
camera.stop_stream().ok();
get_pool().lock().unwrap().producer_running = false;
});
}
}
/// Grab a camera frame with an enforced deadline.
/// If camera.frame() exceeds the timeout, we treat it as an error.
fn frame_with_deadline(
camera: &mut nokhwa::Camera,
timeout: Duration,
) -> Result<nokhwa::Buffer, String> {
let start = Instant::now();
match camera.frame() {
Ok(f) => {
let elapsed = start.elapsed();
if elapsed > timeout {
eprintln!(
"[harvester] frame took {:?} (exceeds {:?} deadline)",
elapsed, timeout
);
return Err(format!("frame exceeded deadline ({:?})", elapsed));
}
Ok(f)
}
Err(e) => Err(e.to_string()),
}
}
/// Send hash to one stream subscriber (round-robin by chunk index).
fn distribute_to_subscribers(hash: &[u8], chunk_idx: usize) {
let pool = get_pool();
let mut subs = lock_or_recover(&pool.subscribers);
if subs.subs.is_empty() {
return;
}
let ids: Vec<u64> = subs.subs.keys().copied().collect();
let id = ids[chunk_idx % ids.len()];
if let Some(tx) = subs.subs.get(&id) {
if tx.send(hash.to_vec()).is_err() {
subs.subs.remove(&id);
}
}
}
/// Subscribe to the live entropy stream. Returns (id, receiver).
pub fn subscribe_entropy() -> (u64, std::sync::mpsc::Receiver<Vec<u8>>) {
let (tx, rx) = std::sync::mpsc::sync_channel(4);
let pool = get_pool();
let mut subs = lock_or_recover(&pool.subscribers);
let id = subs.next_id;
subs.next_id += 1;
subs.subs.insert(id, tx);
(id, rx)
}
/// Unsubscribe from the live entropy stream.
pub fn unsubscribe_entropy(id: u64) {
let pool = get_pool();
let mut subs = lock_or_recover(&pool.subscribers);
subs.subs.remove(&id);
}
/// Start the producer if not running (compatibility shim for stream endpoint).
pub fn ensure_producer_running(config: CameraConfig) {
start_harvester(config);
}
/// Get pool statistics for health/debug.
pub fn pool_stats() -> (u64, usize, bool) {
let pool = get_pool();
let inner = lock_or_recover(&pool.inner);
let restarts = pool.harvester_starts.load(Ordering::Relaxed);
if restarts > 1 {
eprintln!(
"[pool] note: harvester has been restarted {} time(s)",
restarts - 1
);
}
(
inner.total_harvested,
inner.available,
pool.seeded.load(Ordering::Acquire),
)
}

View File

@ -584,15 +584,6 @@
});
}
// Make both output boxes clickable-to-copy
output.classList.add('copyable');
output.title = 'Click to copy';
output.addEventListener('click', () => copyToClipboard(output.textContent));
toolsOutput.classList.add('copyable');
toolsOutput.title = 'Click to copy';
toolsOutput.addEventListener('click', () => copyToClipboard(toolsOutput.textContent));
// Random generator
const btn = document.getElementById('generate');
const output = document.getElementById('output');
@ -636,6 +627,15 @@
const btnCoin = document.getElementById('btn-coin');
const btnEightball = document.getElementById('btn-eightball');
const eightballOrb = document.getElementById('eightball-orb');
// Make both output boxes clickable-to-copy
output.classList.add('copyable');
output.title = 'Click to copy';
output.addEventListener('click', () => copyToClipboard(output.textContent));
toolsOutput.classList.add('copyable');
toolsOutput.title = 'Click to copy';
toolsOutput.addEventListener('click', () => copyToClipboard(toolsOutput.textContent));
function setToolsLoading(loading) {
toolsOutput.className = loading ? 'output loading' : 'output';
btnDice.disabled = btnPassword.disabled = btnCoin.disabled = btnEightball.disabled = loading;

View File

@ -11,12 +11,17 @@ pub mod provider;
pub mod tools;
pub use entropy::{
extract_entropy, extract_entropy_camera, fill_entropy,
list_cameras, spawn_raw_lsb_stream, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, extract_raw_lsb, CameraConfig, CameraListItem, CHUNK_SIZE,
extract_entropy, fill_entropy, start_harvester, pool_stats,
list_cameras, spawn_raw_lsb_stream, subscribe_entropy, unsubscribe_entropy,
ensure_producer_running, test_camera, extract_raw_lsb, CameraConfig, CameraListItem, CHUNK_SIZE,
};
// Re-export the OpenSSL provider init for cdylib
pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH, filter_ambiguous};
pub use tools::{
roll_dice, generate_password, dice_bytes_needed, charset_from_flags,
charset_alphanumeric, charset_full, charset_hex, filter_ambiguous,
DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES,
DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH,
};
pub use tools::{eightball_shake, eightball_bytes_needed, eightball_sentiment, EIGHTBALL_RESPONSES, EIGHTBALL_NUM_RESPONSES};

View File

@ -10,12 +10,16 @@ use axum::{
routing::get,
Router,
};
use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE};
use camera_trng::{
extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy,
ensure_producer_running, start_harvester, pool_stats, test_camera,
CameraConfig, CHUNK_SIZE,
};
mod qrng_handlers;
mod docs_handlers;
use qrng_handlers::{get_dice, get_password, get_coin, get_eightball};
use bytes::Bytes;
use std::sync::{Arc, Mutex};
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
const MAX_BYTES_PER_REQUEST: usize = 1024 * 1024; // 1MB
@ -34,7 +38,6 @@ struct RandomQuery {
fn default_bytes() -> usize { 32 }
#[derive(serde::Deserialize)]
struct StreamQuery {
bytes: Option<usize>,
@ -44,28 +47,46 @@ struct StreamQuery {
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let port = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
let port = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT);
let config = CameraConfig::from_env();
// Test camera access, then start background entropy harvester.
println!("Testing camera access...");
match test_camera(&config) {
Ok((actual_w, actual_h, frame_size)) => {
let conditioned_per_frame = (frame_size / CHUNK_SIZE) * 32;
let throughput_30fps = conditioned_per_frame * 30;
let raw_gbps = (frame_size as f64 * 30.0 * 8.0) / 1_000_000_000.0;
println!("Camera OK at {}x{} - {} bytes/frame", actual_w, actual_h, frame_size);
println!(
"Camera OK at {}x{} - {} bytes/frame",
actual_w, actual_h, frame_size
);
println!("Raw throughput: {:.1} Gbps at 30fps", raw_gbps);
println!("Conditioned output: ~{} MB/s at 30fps (8:1 ratio)", throughput_30fps / 1_000_000);
println!(
"Conditioned output: ~{} MB/s at 30fps (8:1 ratio)",
throughput_30fps / 1_000_000
);
println!("Ensure lens is covered for optimal quantum noise capture");
}
Err(e) => {
eprintln!("Camera error: {}. Server will still start.", e);
eprintln!(
"Camera error: {}. Server will still start with CSPRNG fallback.",
e
);
if e.contains("Lock Rejected") || e.contains("lock") {
eprintln!(" → To release camera: ./scripts/release-camera.sh then restart.");
eprintln!(
" → To release camera: ./scripts/release-camera.sh then restart."
);
}
}
}
println!("Starting background entropy harvester...");
start_harvester(config);
let app = Router::new()
.route("/", get(index))
.route("/cameras", get(get_cameras))
@ -86,264 +107,81 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = format!("0.0.0.0:{}", port);
println!("Camera QRNG (LavaRnd-style) on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
// Graceful shutdown: listen for SIGINT (Ctrl-C) and SIGTERM.
let shutdown = async {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler");
tokio::select! {
_ = ctrl_c => eprintln!("\n[server] received SIGINT, shutting down gracefully..."),
_ = term.recv() => eprintln!("[server] received SIGTERM, shutting down gracefully..."),
}
}
#[cfg(not(unix))]
{
ctrl_c.await.ok();
eprintln!("\n[server] received Ctrl-C, shutting down gracefully...");
}
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
eprintln!("[server] shutdown complete");
Ok(())
}
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
async fn tools_page() -> Html<&'static str> { Html(TOOLS_HTML) }
async fn health() -> &'static str { "ok" }
async fn get_docs() -> Html<String> {
// Convert markdown to HTML (simple version)
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera TRNG API Documentation</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
h2 {{ margin-top: 30px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: "Monaco", "Courier New", monospace; }}
pre {{ background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }}
pre code {{ background: none; padding: 0; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.endpoint {{ background: #e8f4f8; padding: 15px; margin: 15px 0; border-left: 4px solid #0066cc; border-radius: 3px; }}
</style>
</head>
<body>
<h1>Camera TRNG API Documentation</h1>
<p><a href="/docs/skill.md">View as Markdown</a> | <a href="/docs/mcp.json">View MCP JSON</a> | <a href="/">Back to Home</a></p>
<hr>
{}
</body>
</html>"#,
markdown_to_html(SKILL_MD)
);
Html(html)
}
async fn get_skill_md() -> Response {
async fn health() -> Response {
let (total_harvested, pool_available, csprng_seeded) = pool_stats();
let body = serde_json::json!({
"status": "ok",
"pool_bytes_available": pool_available,
"total_camera_bytes_harvested": total_harvested,
"csprng_seeded_from_camera": csprng_seeded,
});
Response::builder()
.header(header::CONTENT_TYPE, "text/markdown; charset=utf-8")
.body(Body::from(SKILL_MD))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.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, "<pre><code>{}</code></pre>
", 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("<br>\n");
} else if trimmed.starts_with("### ") {
write!(html, "<h3>{}</h3>
", escape_html(&trimmed[4..])).unwrap();
} else if trimmed.starts_with("## ") {
write!(html, "<h2>{}</h2>
", escape_html(&trimmed[3..])).unwrap();
} else if trimmed.starts_with("# ") {
write!(html, "<h1>{}</h1>
", 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, "<code>{}</code>", 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, "<strong>{}</strong>", 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, "<a href=\"{}\">{}</a>", url, escape_html(&text)).unwrap();
break;
}
url.push(chars.next().unwrap());
}
}
break;
}
text.push(chars.next().unwrap());
}
}
_ => processed.push(ch),
}
}
write!(html, "<p>{}</p>
", processed).unwrap();
}
}
html
async fn get_docs() -> Html<String> {
docs_handlers::get_docs(SKILL_MD).await
}
fn escape_html(s: &str) -> String {
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
async fn get_skill_md() -> Response {
docs_handlers::skill_md_response(SKILL_MD)
}
async fn mcp_wellknown(headers: HeaderMap, _uri: Uri) -> Json<serde_json::Value> {
docs_handlers::mcp_json(&headers)
}
async fn get_cameras() -> Response {
match tokio::task::spawn_blocking(list_cameras).await {
Ok(Ok(cameras)) => Json(serde_json::json!({ "cameras": cameras })).into_response(),
Ok(Ok(cameras)) => {
Json(serde_json::json!({ "cameras": cameras })).into_response()
}
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn mcp_wellknown(headers: HeaderMap, _uri: Uri) -> Json<serde_json::Value> {
// 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_example1 = format!("{}/random?bytes=32&hex=true", origin);
let random_url_example2 = format!("{}/random?bytes=64&hex=false", origin);
let stream_url_example1 = format!("{}/stream?hex=true", origin);
let stream_url_example2 = format!("{}/stream?bytes=1024&hex=true", 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": "get-random",
"description": "Get cryptographically secure random bytes from camera sensor entropy",
"url": random_url_example1,
"example": random_url_example2,
"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-stream",
"description": "Stream continuous random bytes (SSE format). Use ?bytes=N to limit total bytes, ?hex=true for hex output",
"url": stream_url_example1,
"example": stream_url_example2,
"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": cameras_url,
"capabilities": ["device-discovery"],
"auth": { "type": "none" },
"parameters": {}
},
{
"name": "health-check",
"description": "Check if the TRNG server is running",
"url": 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<RandomQuery>) -> Response {
let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst);
if current >= MAX_CONCURRENT {
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
return (StatusCode::TOO_MANY_REQUESTS, "Too many requests").into_response();
}
let bytes = params.bytes.min(MAX_BYTES_PER_REQUEST);
if bytes == 0 {
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
@ -351,21 +189,24 @@ async fn get_random(Query(params): Query<RandomQuery>) -> Response {
}
let config = CameraConfig::from_env();
let result = tokio::task::spawn_blocking(move || {
extract_entropy(bytes, &config)
}).await;
let result =
tokio::task::spawn_blocking(move || extract_entropy(bytes, &config)).await;
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
match result {
Ok(Ok(data)) => {
if params.hex {
Response::builder().header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(hex::encode(&data))).unwrap()
Response::builder()
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(hex::encode(&data)))
.unwrap()
} else {
Response::builder().header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(data)).unwrap()
Response::builder()
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(data))
.unwrap()
}
}
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
@ -373,15 +214,13 @@ async fn get_random(Query(params): Query<RandomQuery>) -> Response {
}
}
/// Cryptographically sound continuous random. GET /stream or /stream?bytes=N.
/// Multiple streams get different data (each chunk goes to one consumer).
async fn get_stream(Query(params): Query<StreamQuery>) -> Response {
let config = CameraConfig::from_env();
let (sub_id, rx) = subscribe_entropy();
ensure_producer_running(config);
let rx = Arc::new(Mutex::new(rx));
let limit = params.bytes;
let hex = params.hex;
@ -390,7 +229,11 @@ async fn get_stream(Query(params): Query<StreamQuery>) -> Response {
loop {
if limit.is_some() && sent >= limit.unwrap() { break; }
let rx = Arc::clone(&rx);
let chunk = tokio::task::spawn_blocking(move || rx.lock().unwrap().recv()).await;
let chunk = tokio::task::spawn_blocking(move || {
rx.lock()
.unwrap_or_else(|e| e.into_inner())
.recv()
}).await;
match chunk {
Ok(Ok(vec)) => {
let take = match limit {
@ -423,7 +266,6 @@ async fn get_stream(Query(params): Query<StreamQuery>) -> Response {
.unwrap()
}
const INDEX_HTML: &str = include_str!("index.html");
const TOOLS_HTML: &str = include_str!("tools.html");
const SKILL_MD: &str = include_str!("../skill.md");