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:
parent
ffc9102522
commit
5b49685ae9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
13
src/lib.rs
13
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};
|
||||
|
||||
|
|
|
|||
348
src/main.rs
348
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<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("&", "&")
|
||||
.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<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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue