From 5b49685ae9407fc8e6d283156f76bc6f0f4fc510 Mon Sep 17 00:00:00 2001 From: Leopere Date: Mon, 9 Feb 2026 13:47:23 -0500 Subject: [PATCH] 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 --- .woodpecker.yml | 71 ++---- Cargo.lock | 304 +++++++++++++++++++++++++- Cargo.toml | 6 +- docker-compose.yml | 13 ++ src/docs_handlers.rs | 244 +++++++++++++++++++++ src/entropy/extract.rs | 312 +++++---------------------- src/entropy/mod.rs | 12 +- src/entropy/pool.rs | 478 +++++++++++++++++++++++++++++++---------- src/index.html | 18 +- src/lib.rs | 13 +- src/main.rs | 348 ++++++++---------------------- 11 files changed, 1124 insertions(+), 695 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/docs_handlers.rs diff --git a/.woodpecker.yml b/.woodpecker.yml index 3b0ad55..d666ad5 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 2d1934f..8939b55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f08b478..b66cdac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2bdce44 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/docs_handlers.rs b/src/docs_handlers.rs new file mode 100644 index 0000000..fd1e2b1 --- /dev/null +++ b/src/docs_handlers.rs @@ -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 { + 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) +} + +/// 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 { + 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, "
{}
\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("
\n"); + } else if let Some(rest) = trimmed.strip_prefix("### ") { + write!(html, "

{}

\n", escape_html(rest)).unwrap(); + } else if let Some(rest) = trimmed.strip_prefix("## ") { + write!(html, "

{}

\n", escape_html(rest)).unwrap(); + } else if let Some(rest) = trimmed.strip_prefix("# ") { + write!(html, "

{}

\n", escape_html(rest)).unwrap(); + } else { + let processed = process_inline_markdown(trimmed); + write!(html, "

{}

\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, "{}", 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, "{}", 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, + "{}", + 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('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/src/entropy/extract.rs b/src/entropy/extract.rs index 9401186..8ded9c5 100644 --- a/src/entropy/extract.rs +++ b/src/entropy/extract.rs @@ -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, 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> { + let lsbs: Vec = 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 = 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, 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 { + 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, String> { - extract_raw_lsb_camera(num_bytes, config) -} - -pub fn extract_entropy(num_bytes: usize, config: &CameraConfig) -> Result, 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>, ) -> 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 = 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>, - ready: std::sync::mpsc::SyncSender>, -) -> 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, 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 = 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) } diff --git a/src/entropy/mod.rs b/src/entropy/mod.rs index 5931f9f..e72e828 100644 --- a/src/entropy/mod.rs +++ b/src/entropy/mod.rs @@ -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, }; diff --git a/src/entropy/pool.rs b/src/entropy/pool.rs index b4fbbda..0bca418 100644 --- a/src/entropy/pool.rs +++ b/src/entropy/pool.rs @@ -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> = 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>>, - next_id: u64, - producer_running: bool, +/// The global resilient pool, initialized once. +static RESILIENT_POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + +// ── Poison-safe mutex helper ──────────────────────────────────────── +/// Lock a mutex, recovering from poison (prior panic) instead of panicking. +fn lock_or_recover(m: &Mutex) -> 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, + /// 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, + /// CSPRNG fallback, re-seeded from camera entropy. + rng: Mutex, + /// 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, +} + +struct SubscriberMap { + subs: HashMap>>, + 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 { - 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>) { - 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 { + 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, 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::() { + 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 = 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 = 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 = 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 { + 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 = 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>) { + 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), + ) } diff --git a/src/index.html b/src/index.html index 23e5f26..f32dc59 100644 --- a/src/index.html +++ b/src/index.html @@ -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; diff --git a/src/lib.rs b/src/lib.rs index 953d75c..a8d8254 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/main.rs b/src/main.rs index 39e864e..a912c93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, @@ -44,28 +47,46 @@ struct StreamQuery { #[tokio::main] async fn main() -> Result<(), Box> { - 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> { 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 { - // 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 { +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, "
{}
-", 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 +async fn get_docs() -> Html { + docs_handlers::get_docs(SKILL_MD).await } -fn escape_html(s: &str) -> String { - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) +async fn get_skill_md() -> Response { + docs_handlers::skill_md_response(SKILL_MD) } - - +async fn mcp_wellknown(headers: HeaderMap, _uri: Uri) -> Json { + 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 { - // 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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");