Add FAKE_CAMERA mode and OBS virtual camera testing support
- Add FAKE_CAMERA=1 env var to use /dev/urandom instead of real camera for testing in environments without camera hardware (Docker, CI) - Add extract_entropy_fake() that simulates camera frames with random data - Add web UI (index.html) and MCP well-known endpoint - Add Dockerfile with multi-stage build for containerized deployment - Add OBS virtual camera scripts for testing with simulated noise input - Update Dockerfile to use rust:latest and add clang for bindgen
This commit is contained in:
parent
1b7e21a8a0
commit
8608b530ee
|
|
@ -1 +1,2 @@
|
|||
/target
|
||||
scripts/node_modules/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
# Woodpecker CI configuration for camera-trng
|
||||
# Builds binaries for Linux (x86_64, aarch64) and pushes Docker image
|
||||
# Image: git.nixc.us/colin/camera-trng:latest
|
||||
|
||||
labels:
|
||||
location: manager
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 1
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# Build and Test
|
||||
# ============================================
|
||||
test:
|
||||
name: test
|
||||
image: rust:1.75-bookworm
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- apt-get update && apt-get install -y --no-install-recommends libv4l-dev libudev-dev pkg-config
|
||||
- rustc --version | cat
|
||||
- cargo --version | cat
|
||||
- cargo check --locked
|
||||
- cargo test --locked
|
||||
- cargo build --release --locked
|
||||
- ls -lh target/release/camera-trng | cat || true
|
||||
when:
|
||||
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
|
||||
depends_on: [test]
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- apt-get update && apt-get install -y --no-install-recommends libv4l-dev libudev-dev pkg-config
|
||||
- rustup target add x86_64-unknown-linux-gnu
|
||||
- cargo build --release --locked --target x86_64-unknown-linux-gnu
|
||||
- mkdir -p dist
|
||||
- cp target/x86_64-unknown-linux-gnu/release/camera-trng dist/camera-trng-linux-x86_64
|
||||
- chmod +x dist/camera-trng-linux-x86_64
|
||||
- ls -lh dist/ | cat
|
||||
- sha256sum dist/* | tee dist/checksums-x86_64.txt
|
||||
when:
|
||||
branch: master
|
||||
event: [push, tag]
|
||||
|
||||
# ============================================
|
||||
# Build Linux aarch64 release binary (cross-compile)
|
||||
# ============================================
|
||||
build-linux-aarch64:
|
||||
name: build-linux-aarch64
|
||||
image: rust:1.75-bookworm
|
||||
depends_on: [test]
|
||||
failure: ignore
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- apt-get update && apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross pkg-config
|
||||
- rustup target add aarch64-unknown-linux-gnu
|
||||
- |
|
||||
mkdir -p ~/.cargo
|
||||
cat > ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
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"
|
||||
- ls -lh dist/ | cat || true
|
||||
when:
|
||||
branch: master
|
||||
event: [push, tag]
|
||||
|
||||
# ============================================
|
||||
# Cargo audit for known vulnerabilities
|
||||
# ============================================
|
||||
cargo-audit:
|
||||
name: cargo-audit
|
||||
image: rust:1.75-bookworm
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- cargo install cargo-audit
|
||||
- cargo audit --json | tee cargo-audit.json
|
||||
- cargo audit || true
|
||||
when:
|
||||
branch: master
|
||||
event: [push, pull_request, cron]
|
||||
|
||||
# ============================================
|
||||
# SBOM for source code (Rust/Cargo)
|
||||
# ============================================
|
||||
sbom-source:
|
||||
name: sbom-source
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- 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
|
||||
- echo "SBOM generated successfully"
|
||||
- ls -lh sbom.* | cat
|
||||
when:
|
||||
branch: master
|
||||
event: [push, pull_request, cron]
|
||||
|
||||
# ============================================
|
||||
# Trivy filesystem scan
|
||||
# ============================================
|
||||
trivy-fs:
|
||||
name: trivy-fs
|
||||
image: aquasec/trivy:latest
|
||||
commands:
|
||||
- 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
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- apt-get update && apt-get install -y --no-install-recommends libv4l-dev libudev-dev pkg-config
|
||||
- rustup component add clippy
|
||||
- cargo clippy --locked -- -D warnings || true
|
||||
when:
|
||||
branch: master
|
||||
event: [push, pull_request, cron]
|
||||
|
||||
# ============================================
|
||||
# Format check
|
||||
# ============================================
|
||||
fmt-check:
|
||||
name: fmt-check
|
||||
image: rust:1.75-bookworm
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- rustup component add rustfmt
|
||||
- cargo fmt --check
|
||||
when:
|
||||
branch: master
|
||||
event: [push, pull_request, cron]
|
||||
|
||||
# ============================================
|
||||
# Build and push Docker image to git.nixc.us/colin/camera-trng
|
||||
# ============================================
|
||||
build-image:
|
||||
name: build-image
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on: [test]
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
DOCKER_REGISTRY_USER:
|
||||
from_secret: DOCKER_REGISTRY_USER
|
||||
DOCKER_REGISTRY_PASSWORD:
|
||||
from_secret: DOCKER_REGISTRY_PASSWORD
|
||||
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
|
||||
- HOSTNAME=$(docker info --format "{{.Name}}")
|
||||
- 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 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
|
||||
depends_on: [test]
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
DOCKER_REGISTRY_USER:
|
||||
from_secret: DOCKER_REGISTRY_USER
|
||||
DOCKER_REGISTRY_PASSWORD:
|
||||
from_secret: DOCKER_REGISTRY_PASSWORD
|
||||
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
|
||||
- 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
|
||||
- docker build -t git.nixc.us/colin/camera-trng:${CI_COMMIT_TAG} -t git.nixc.us/colin/camera-trng:latest .
|
||||
- docker push git.nixc.us/colin/camera-trng:${CI_COMMIT_TAG}
|
||||
- docker push git.nixc.us/colin/camera-trng:latest
|
||||
- echo "Pushed git.nixc.us/colin/camera-trng:${CI_COMMIT_TAG}"
|
||||
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]
|
||||
|
||||
# ============================================
|
||||
# Generate SBOM for Docker image
|
||||
# ============================================
|
||||
sbom-image:
|
||||
name: sbom-image
|
||||
image: alpine:3.20
|
||||
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
|
||||
- apk add --no-cache curl docker-cli
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
- syft version | cat
|
||||
- syft docker:git.nixc.us/colin/camera-trng:latest -o table | tee sbom-image.txt
|
||||
- syft docker:git.nixc.us/colin/camera-trng:latest -o spdx-json > sbom-image.spdx.json
|
||||
- echo "Image SBOM generated successfully"
|
||||
- ls -lh sbom-image.* | cat
|
||||
when:
|
||||
branch: master
|
||||
event: [push, cron]
|
||||
|
|
@ -177,6 +177,7 @@ dependencies = [
|
|||
"hex",
|
||||
"nokhwa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,17 +5,13 @@ edition = "2021"
|
|||
description = "True random number generator using camera sensor noise"
|
||||
|
||||
[dependencies]
|
||||
# Camera capture - cross platform
|
||||
nokhwa = { version = "0.10", features = ["input-native"] }
|
||||
|
||||
# Lightweight HTTP server
|
||||
axum = "0.7"
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
|
||||
# For extracting entropy
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# Build stage
|
||||
FROM rust:latest AS builder
|
||||
|
||||
# Install build dependencies for nokhwa (v4l2) and bindgen (clang)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libv4l-dev \
|
||||
libudev-dev \
|
||||
pkg-config \
|
||||
clang \
|
||||
libclang-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for better caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Create dummy src to cache dependencies
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release && rm -rf src target/release/deps/camera_trng*
|
||||
|
||||
# Copy actual source and build
|
||||
COPY src ./src
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage - minimal image
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies for v4l2 camera access
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libv4l-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 trng
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/target/release/camera-trng .
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R trng:trng /app
|
||||
|
||||
USER trng
|
||||
|
||||
ENV PORT=8787
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["./camera-trng"]
|
||||
111
README.md
111
README.md
|
|
@ -25,6 +25,32 @@ cargo build --release
|
|||
PORT=9000 ./target/release/camera-trng
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Pull the pre-built image:
|
||||
|
||||
```bash
|
||||
docker pull git.nixc.us/colin/camera-trng:latest
|
||||
```
|
||||
|
||||
Run with camera access (Linux with V4L2):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name camera-trng \
|
||||
--device /dev/video0:/dev/video0 \
|
||||
-p 8787:8787 \
|
||||
git.nixc.us/colin/camera-trng:latest
|
||||
```
|
||||
|
||||
### Available Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest build from master branch |
|
||||
| `<commit-sha>` | Specific commit (first 8 chars) |
|
||||
| `<version>` | Semantic version tags (e.g., `v1.0.0`) |
|
||||
|
||||
## API
|
||||
|
||||
### GET /random
|
||||
|
|
@ -64,6 +90,91 @@ Uses `nokhwa` for camera access, supporting:
|
|||
- Windows (Media Foundation)
|
||||
- Linux (V4L2)
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
This project uses Woodpecker CI to automatically build, test, and deploy.
|
||||
|
||||
### Pipeline Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Woodpecker CI │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ On Push/PR to master: │
|
||||
│ ┌─────────┐ │
|
||||
│ │ test │──┬──> build-linux-x86_64 ──> binary artifact │
|
||||
│ └─────────┘ │ │
|
||||
│ ├──> build-linux-aarch64 ──> binary artifact │
|
||||
│ │ │
|
||||
│ └──> build-image ──┬──> trivy-image (scan) │
|
||||
│ └──> sbom-image (SBOM) │
|
||||
│ │
|
||||
│ Parallel checks: cargo-audit, trivy-fs, clippy, fmt-check │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
| Artifact | Architecture | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `camera-trng-linux-x86_64` | x86_64 | Linux AMD64 binary |
|
||||
| `camera-trng-linux-aarch64` | aarch64 | Linux ARM64 binary (best-effort) |
|
||||
| Docker Image | linux/amd64 | Container image |
|
||||
|
||||
### Docker Image Registry
|
||||
|
||||
Images are pushed to: `git.nixc.us/colin/camera-trng`
|
||||
|
||||
- **On push to master**: Tags `latest` and `<commit-sha>`
|
||||
- **On version tag**: Tags `<version>` and `latest`
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Configure these secrets in Woodpecker:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `REGISTRY_USER` | Username for git.nixc.us registry |
|
||||
| `REGISTRY_PASSWORD` | Password/token for git.nixc.us registry |
|
||||
| `DOCKER_REGISTRY_USER` | Docker Hub username (for base images) |
|
||||
| `DOCKER_REGISTRY_PASSWORD` | Docker Hub password/token |
|
||||
|
||||
### Security Scanning
|
||||
|
||||
The pipeline includes:
|
||||
- **cargo-audit**: Scans Rust dependencies for known vulnerabilities
|
||||
- **trivy-fs**: Scans filesystem and Cargo.lock for vulnerabilities
|
||||
- **trivy-image**: Scans the built Docker image
|
||||
- **SBOM generation**: Creates SPDX and CycloneDX SBOMs for dependencies
|
||||
|
||||
### Cross-Compilation Notes
|
||||
|
||||
**Linux x86_64**: Fully supported, built natively on CI runners.
|
||||
|
||||
**Linux aarch64**: Best-effort cross-compilation. May fail due to native camera library dependencies (libv4l). For production ARM64 builds, consider using native ARM64 runners.
|
||||
|
||||
**macOS/Windows**: Not built in CI due to native camera library requirements. Build locally:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
cargo build --release
|
||||
|
||||
# Windows (requires MSVC toolchain)
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Local Docker Build
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -t camera-trng:local .
|
||||
|
||||
# Test locally (requires camera device)
|
||||
docker run --rm --device /dev/video0 -p 8787:8787 camera-trng:local
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
This is intended for hobby/experimental use. For cryptographic applications:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* OBS Virtual Camera with Noise Source
|
||||
*
|
||||
* This script connects to OBS via websocket and:
|
||||
* 1. Creates a scene with a noise/random pattern source
|
||||
* 2. Starts the virtual camera
|
||||
*
|
||||
* Prerequisites:
|
||||
* - OBS Studio 28+ (has obs-websocket built-in)
|
||||
* - Enable WebSocket Server in OBS: Tools -> WebSocket Server Settings
|
||||
* - Set a password or disable authentication for local use
|
||||
*
|
||||
* Usage: node obs-noise-camera.mjs [start|stop] [--password=YOUR_PASSWORD]
|
||||
*/
|
||||
|
||||
import OBSWebSocket from 'obs-websocket-js';
|
||||
|
||||
const obs = new OBSWebSocket();
|
||||
|
||||
const SCENE_NAME = 'NoiseCamera';
|
||||
const SOURCE_NAME = 'RandomNoise';
|
||||
|
||||
async function connect(password) {
|
||||
const url = 'ws://127.0.0.1:4455';
|
||||
try {
|
||||
const config = password ? { rpcVersion: 1, password } : { rpcVersion: 1 };
|
||||
await obs.connect(url, password);
|
||||
console.log('Connected to OBS WebSocket');
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.message?.includes('Authentication')) {
|
||||
console.error('Authentication required. Use --password=YOUR_PASSWORD');
|
||||
console.error('Or disable authentication in OBS: Tools -> WebSocket Server Settings');
|
||||
} else {
|
||||
console.error('Failed to connect to OBS:', err.message);
|
||||
console.error('Make sure OBS is running and WebSocket Server is enabled');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoiseScene() {
|
||||
// Check if scene exists
|
||||
const { scenes } = await obs.call('GetSceneList');
|
||||
const sceneExists = scenes.some(s => s.sceneName === SCENE_NAME);
|
||||
|
||||
if (!sceneExists) {
|
||||
console.log(`Creating scene: ${SCENE_NAME}`);
|
||||
await obs.call('CreateScene', { sceneName: SCENE_NAME });
|
||||
}
|
||||
|
||||
// Switch to the scene
|
||||
await obs.call('SetCurrentProgramScene', { sceneName: SCENE_NAME });
|
||||
|
||||
// Check if source exists in scene
|
||||
const { sceneItems } = await obs.call('GetSceneItemList', { sceneName: SCENE_NAME });
|
||||
const sourceExists = sceneItems.some(item => item.sourceName === SOURCE_NAME);
|
||||
|
||||
if (!sourceExists) {
|
||||
console.log(`Creating noise source: ${SOURCE_NAME}`);
|
||||
|
||||
// Create a color source with shader/noise effect
|
||||
// We'll use a color source and apply noise filter, or use browser source with noise
|
||||
await obs.call('CreateInput', {
|
||||
sceneName: SCENE_NAME,
|
||||
inputName: SOURCE_NAME,
|
||||
inputKind: 'color_source_v3',
|
||||
inputSettings: {
|
||||
color: 0xFF808080, // Gray base
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
});
|
||||
|
||||
// Add a noise/static filter using the Shader filter if available
|
||||
// Or we can use a different approach - let's try adding the "Noise Displacement" filter
|
||||
try {
|
||||
await obs.call('CreateSourceFilter', {
|
||||
sourceName: SOURCE_NAME,
|
||||
filterName: 'ShaderNoise',
|
||||
filterKind: 'streamfx-filter-shader',
|
||||
filterSettings: {
|
||||
'Shader.Type': 0, // File
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// StreamFX might not be installed, try built-in approach
|
||||
console.log('Note: For best results, install StreamFX plugin for shader-based noise');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Scene "${SCENE_NAME}" ready`);
|
||||
}
|
||||
|
||||
async function createBrowserNoiseSource() {
|
||||
// Alternative: Create a browser source with animated noise
|
||||
const { scenes } = await obs.call('GetSceneList');
|
||||
const sceneExists = scenes.some(s => s.sceneName === SCENE_NAME);
|
||||
|
||||
if (!sceneExists) {
|
||||
console.log(`Creating scene: ${SCENE_NAME}`);
|
||||
await obs.call('CreateScene', { sceneName: SCENE_NAME });
|
||||
}
|
||||
|
||||
await obs.call('SetCurrentProgramScene', { sceneName: SCENE_NAME });
|
||||
|
||||
const { sceneItems } = await obs.call('GetSceneItemList', { sceneName: SCENE_NAME });
|
||||
const sourceExists = sceneItems.some(item => item.sourceName === SOURCE_NAME);
|
||||
|
||||
if (!sourceExists) {
|
||||
console.log(`Creating browser noise source: ${SOURCE_NAME}`);
|
||||
|
||||
// HTML/JS that generates animated noise
|
||||
const noiseHtml = `data:text/html,
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><style>body{margin:0;overflow:hidden}canvas{display:block}</style></head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<script>
|
||||
const c=document.getElementById('c'),x=c.getContext('2d');
|
||||
c.width=1920;c.height=1080;
|
||||
function draw(){
|
||||
const d=x.createImageData(c.width,c.height);
|
||||
for(let i=0;i<d.data.length;i+=4){
|
||||
const v=Math.random()*255|0;
|
||||
d.data[i]=d.data[i+1]=d.data[i+2]=v;
|
||||
d.data[i+3]=255;
|
||||
}
|
||||
x.putImageData(d,0,0);
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await obs.call('CreateInput', {
|
||||
sceneName: SCENE_NAME,
|
||||
inputName: SOURCE_NAME,
|
||||
inputKind: 'browser_source',
|
||||
inputSettings: {
|
||||
url: noiseHtml,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
reroute_audio: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Browser noise source "${SOURCE_NAME}" ready`);
|
||||
}
|
||||
|
||||
async function startVirtualCamera() {
|
||||
const { outputActive } = await obs.call('GetVirtualCamStatus');
|
||||
if (!outputActive) {
|
||||
console.log('Starting virtual camera...');
|
||||
await obs.call('StartVirtualCam');
|
||||
console.log('Virtual camera started');
|
||||
} else {
|
||||
console.log('Virtual camera already running');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopVirtualCamera() {
|
||||
const { outputActive } = await obs.call('GetVirtualCamStatus');
|
||||
if (outputActive) {
|
||||
console.log('Stopping virtual camera...');
|
||||
await obs.call('StopVirtualCam');
|
||||
console.log('Virtual camera stopped');
|
||||
} else {
|
||||
console.log('Virtual camera not running');
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const { outputActive } = await obs.call('GetVirtualCamStatus');
|
||||
const { currentProgramSceneName } = await obs.call('GetCurrentProgramScene');
|
||||
console.log(`Virtual Camera: ${outputActive ? 'RUNNING' : 'STOPPED'}`);
|
||||
console.log(`Current Scene: ${currentProgramSceneName}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args.find(a => !a.startsWith('--')) || 'start';
|
||||
const passwordArg = args.find(a => a.startsWith('--password='));
|
||||
const password = passwordArg ? passwordArg.split('=')[1] : undefined;
|
||||
|
||||
if (!await connect(password)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'start':
|
||||
await createBrowserNoiseSource();
|
||||
await startVirtualCamera();
|
||||
await getStatus();
|
||||
break;
|
||||
case 'stop':
|
||||
await stopVirtualCamera();
|
||||
break;
|
||||
case 'status':
|
||||
await getStatus();
|
||||
break;
|
||||
default:
|
||||
console.log('Usage: node obs-noise-camera.mjs [start|stop|status] [--password=PASS]');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
} finally {
|
||||
obs.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
{
|
||||
"name": "obs-virtual-camera",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obs-virtual-camera",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"obs-websocket-js": "^5.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpack/msgpack": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
|
||||
"integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic-ws": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
|
||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obs-websocket-js": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.7.tgz",
|
||||
"integrity": "sha512-SdSNSyrLVR6F0ogInKr7qcadV1tYaTUse/vbabxjkUL8hU3P3dyifxkZ7pEkDDrtCp3TkQ53Enx23kgZO0Cjcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^2.7.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"debug": "^4.3.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"type-fest": "^3.11.0",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
|
||||
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "obs-virtual-camera",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"obs-websocket-js": "^5.0.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/bash
|
||||
# Start OBS with noise virtual camera for testing camera-trng
|
||||
#
|
||||
# Usage: ./start-obs-noise.sh [--password=YOUR_OBS_WEBSOCKET_PASSWORD]
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. OBS Studio 28+ installed
|
||||
# 2. WebSocket Server enabled in OBS: Tools -> WebSocket Server Settings
|
||||
# 3. Node.js installed
|
||||
# 4. Run 'npm install' in this directory first
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Check for node_modules
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# Check if OBS is running
|
||||
if ! pgrep -x "OBS" > /dev/null 2>&1; then
|
||||
echo "Starting OBS..."
|
||||
open -a "OBS" --args --minimize-to-tray
|
||||
echo "Waiting for OBS to start..."
|
||||
sleep 5
|
||||
|
||||
# Wait for websocket to be available
|
||||
for i in {1..10}; do
|
||||
if nc -z 127.0.0.1 4455 2>/dev/null; then
|
||||
echo "OBS WebSocket ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for OBS WebSocket... ($i/10)"
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
# Pass through any arguments (like --password=xxx)
|
||||
echo "Configuring noise source and starting virtual camera..."
|
||||
node obs-noise-camera.mjs start "$@"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "OBS Virtual Camera is now active!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "The virtual camera should appear as 'OBS Virtual Camera'"
|
||||
echo "in your camera selection. You can now run camera-trng:"
|
||||
echo ""
|
||||
echo " cargo run --release"
|
||||
echo ""
|
||||
echo "To stop the virtual camera:"
|
||||
echo " ./start-obs-noise.sh stop"
|
||||
echo ""
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
# Stop OBS virtual camera
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ -d "node_modules" ]; then
|
||||
node obs-noise-camera.mjs stop "$@"
|
||||
else
|
||||
echo "Run 'npm install' first, or just stop the virtual camera in OBS manually"
|
||||
fi
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Camera TRNG - True random number generator using camera sensor noise. Generate cryptographically random bytes from thermal and shot noise.">
|
||||
<title>Camera TRNG - True Random Number Generator</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--accent-color: #0056b3;
|
||||
--border-color: #e0e0e0;
|
||||
--hover-color: #003d82;
|
||||
--theme-bg: #f5f5f5;
|
||||
--theme-border: #ddd;
|
||||
--theme-hover: #e0e0e0;
|
||||
--date-color: #555555;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-tertiary: #eaeaea;
|
||||
--bg-hover: #f0f0f0;
|
||||
--text-primary: #333333;
|
||||
--focus-outline-color: #0056b3;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #e0e0e0;
|
||||
--accent-color: #5fa9ff;
|
||||
--border-color: #404040;
|
||||
--hover-color: #8ac2ff;
|
||||
--theme-bg: #2d2d2d;
|
||||
--theme-border: #404040;
|
||||
--theme-hover: #3d3d3d;
|
||||
--date-color: #a0a0a0;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #3d3d3d;
|
||||
--bg-hover: #333333;
|
||||
--text-primary: #e0e0e0;
|
||||
--focus-outline-color: #5fa9ff;
|
||||
}
|
||||
}
|
||||
html[data-theme='light'] {
|
||||
--bg-color: #ffffff; --text-color: #333333; --accent-color: #0056b3;
|
||||
--border-color: #e0e0e0; --hover-color: #003d82; --theme-bg: #f5f5f5;
|
||||
--theme-border: #ddd; --theme-hover: #e0e0e0; --date-color: #555555;
|
||||
--bg-primary: #ffffff; --bg-secondary: #f5f5f5; --bg-tertiary: #eaeaea;
|
||||
--bg-hover: #f0f0f0; --text-primary: #333333; --focus-outline-color: #0056b3;
|
||||
}
|
||||
html[data-theme='dark'] {
|
||||
--bg-color: #1a1a1a; --text-color: #e0e0e0; --accent-color: #5fa9ff;
|
||||
--border-color: #404040; --hover-color: #8ac2ff; --theme-bg: #2d2d2d;
|
||||
--theme-border: #404040; --theme-hover: #3d3d3d; --date-color: #a0a0a0;
|
||||
--bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --bg-tertiary: #3d3d3d;
|
||||
--bg-hover: #333333; --text-primary: #e0e0e0; --focus-outline-color: #5fa9ff;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
.skip-to-content {
|
||||
position: absolute; top: -40px; left: 0; background: var(--accent-color);
|
||||
color: white; padding: 8px 16px; text-decoration: none; z-index: 100;
|
||||
border-radius: 0 0 4px 0; font-weight: bold;
|
||||
}
|
||||
.skip-to-content:focus { top: 0; outline: 3px solid var(--focus-outline-color); outline-offset: 2px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6; color: var(--text-color); background-color: var(--bg-color);
|
||||
margin: 0 auto; padding: 20px; max-width: 800px;
|
||||
}
|
||||
a { color: var(--accent-color); text-decoration: underline; }
|
||||
a:hover { color: var(--hover-color); }
|
||||
.main-nav { display: flex; justify-content: center; margin: 1rem 0; }
|
||||
.main-nav ul {
|
||||
display: flex; list-style: none; margin: 0; padding: 0.5rem 1rem; gap: 1rem;
|
||||
border-radius: 4px; background-color: var(--theme-bg); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.main-nav li { margin: 0; padding: 0; position: relative; }
|
||||
.main-nav a {
|
||||
display: block; padding: 0.5rem 1rem; text-decoration: none; color: var(--text-color);
|
||||
font-weight: 500; border-radius: 4px; transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
.main-nav a:hover { background-color: var(--theme-hover); color: var(--accent-color); }
|
||||
.main-nav .dropdown > a::after { content: "▼"; font-size: 0.7em; margin-left: 0.5em; }
|
||||
.main-nav .dropdown-content {
|
||||
display: none; position: absolute; top: 100%; left: 0; background-color: var(--theme-bg);
|
||||
min-width: 200px; box-shadow: 0 8px 16px rgba(0,0,0,0.15); z-index: 1000;
|
||||
border-radius: 4px; padding: 0.5rem 0; margin-top: 0.25rem;
|
||||
}
|
||||
.main-nav .dropdown:hover .dropdown-content { display: block; }
|
||||
.main-nav .dropdown-content a {
|
||||
padding: 0.5rem 1rem; display: block; white-space: nowrap; border-radius: 0;
|
||||
}
|
||||
.nav-story-tbd { opacity: 0.6; }
|
||||
.theme-switch { position: fixed; top: 20px; right: 20px; z-index: 1000; }
|
||||
.theme-switch button {
|
||||
background: var(--theme-bg); border: 1px solid var(--border-color); font-size: 1.5em;
|
||||
cursor: pointer; padding: 8px 12px; border-radius: 8px; transition: background-color 0.3s;
|
||||
}
|
||||
.theme-switch button:hover { background-color: var(--theme-hover); }
|
||||
h1 {
|
||||
font-size: 2em; color: var(--accent-color); border-bottom: 2px solid var(--accent-color);
|
||||
padding-bottom: 0.3em; margin-top: 1.5em; margin-bottom: 0.5em;
|
||||
}
|
||||
h2 { font-size: 1.5em; color: var(--accent-color); margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
h3 { font-size: 1.2em; color: var(--text-color); margin-top: 1em; margin-bottom: 0.5em; }
|
||||
.subtitle { color: var(--date-color); font-style: italic; margin-bottom: 2rem; }
|
||||
.card {
|
||||
background: var(--bg-secondary); border: 1px solid var(--border-color);
|
||||
border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.controls { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.field { flex: 1; min-width: 120px; }
|
||||
label {
|
||||
display: block; font-size: 0.85rem; color: var(--date-color);
|
||||
margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
input, select {
|
||||
width: 100%; padding: 0.75rem 1rem; background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color); border-radius: 4px;
|
||||
color: var(--text-color); font-family: monospace; font-size: 0.9rem;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(0,86,179,0.1);
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 0.75rem 2rem; background: var(--accent-color); border: none; border-radius: 4px;
|
||||
color: white; font-weight: 600; font-size: 1rem; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--hover-color); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.output {
|
||||
background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px;
|
||||
padding: 1rem; font-family: monospace; font-size: 0.85rem; word-break: break-all;
|
||||
line-height: 1.6; min-height: 80px; max-height: 150px; overflow-y: auto; color: var(--accent-color);
|
||||
}
|
||||
.output.error { color: #dc3545; }
|
||||
.stats { display: flex; gap: 2rem; color: var(--date-color); font-size: 0.85rem; margin-top: 1rem; }
|
||||
.stats span { color: var(--text-color); font-family: monospace; }
|
||||
pre {
|
||||
background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px;
|
||||
padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 0.5rem 0;
|
||||
}
|
||||
code { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; }
|
||||
.code-tabs { display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid var(--border-color); }
|
||||
.code-tab {
|
||||
padding: 0.5rem 1rem; background: var(--bg-secondary); border: 1px solid var(--border-color);
|
||||
border-bottom: none; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 0.85rem;
|
||||
margin-right: -1px; color: var(--text-color);
|
||||
}
|
||||
.code-tab.active { background: var(--bg-tertiary); font-weight: 600; }
|
||||
.code-panel { display: none; }
|
||||
.code-panel.active { display: block; }
|
||||
.code-panel pre { border-radius: 0 4px 4px 4px; margin-top: 0; }
|
||||
.mcp-link {
|
||||
display: inline-block; padding: 0.5rem 1rem; background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color); border-radius: 4px; font-family: monospace;
|
||||
font-size: 0.85rem; margin: 0.5rem 0;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.loading { animation: pulse 1s infinite; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 10px; } h1 { font-size: 1.75em; }
|
||||
.main-nav ul { flex-direction: column; gap: 0.5rem; }
|
||||
.main-nav a { text-align: center; }
|
||||
.main-nav .dropdown-content { position: static; box-shadow: none; }
|
||||
.controls { flex-direction: column; }
|
||||
.code-tabs { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u="//metrics.nixc.us/";
|
||||
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||
_paq.push(['setSiteId', '3']);
|
||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
</script>
|
||||
<!-- PostHog -->
|
||||
<script>
|
||||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog&&window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture identify setPersonProperties group reset get_distinct_id getGroups get_session_id alias set_config opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('phc_3WDvcJlYYXlBVYL8vC1raT0gMfjkMuCyOpXdmgjK0CK',{api_host:'https://eu.i.posthog.com',person_profiles:'identified_only'})
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-to-content">Skip to content</a>
|
||||
<div class="theme-switch">
|
||||
<button id="themeToggle" type="button" role="switch" aria-label="Theme mode: Auto">🌓</button>
|
||||
</div>
|
||||
|
||||
<nav class="main-nav">
|
||||
<ul>
|
||||
<li><a href="https://colinknapp.com/" id="nav-portfolio">Portfolio</a></li>
|
||||
<li class="dropdown">
|
||||
<a href="https://colinknapp.com/resumes/" id="nav-resumes">Resumes</a>
|
||||
<div class="dropdown-content">
|
||||
<a href="https://colinknapp.com/resumes/business-development.html">Business Development</a>
|
||||
<a href="https://colinknapp.com/resumes/devsecops.html">DevSecOps</a>
|
||||
<a href="https://colinknapp.com/resumes/team-leadership.html">Team Leadership</a>
|
||||
<a href="https://colinknapp.com/resumes/tool-building.html">Tool Building</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="dropdown">
|
||||
<a href="https://colinknapp.com/stories/" id="nav-stories">Stories</a>
|
||||
<div class="dropdown-content">
|
||||
<a href="https://colinknapp.com/stories/app-development.html">App Development</a>
|
||||
<a href="https://colinknapp.com/stories/athion-turnaround.html">Athion Turnaround</a>
|
||||
<a href="https://colinknapp.com/stories/home-infrastructure.html">Home Infrastructure</a>
|
||||
<a href="https://colinknapp.com/stories/motherboard-repair.html">Motherboard Repair</a>
|
||||
<a href="https://colinknapp.com/stories/nuclear-dns.html">Nuclear Dns</a>
|
||||
<a href="https://colinknapp.com/stories/open-source-success.html">Open Source Success</a>
|
||||
<a href="https://colinknapp.com/stories/scansnap-webdav.html">Scansnap Webdav</a>
|
||||
<a href="https://colinknapp.com/stories/showerloop.html">Showerloop</a>
|
||||
<a href="https://colinknapp.com/stories/well-known-mcp.html">.well-known MCP</a>
|
||||
<a href="https://colinknapp.com/stories/web-design-java.html">Web Design Java</a>
|
||||
<a href="https://colinknapp.com/stories/youtube-game-dev.html">Youtube Game Dev</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="dropdown">
|
||||
<a href="https://colinknapp.com/one-pager-tools/csv-tool.html" id="nav-tools">Tools</a>
|
||||
<div class="dropdown-content">
|
||||
<a href="https://colinknapp.com/website-questionnaire/">Website Questionnaire</a>
|
||||
<a href="https://colinknapp.com/rich-snippets/">Rich Snippets Generator</a>
|
||||
<a href="https://colinknapp.com/one-pager-tools/csv-tool.html">CSV Tool</a>
|
||||
<a href="https://colinknapp.com/one-pager-tools/utm-tool.html">UTM Builder</a>
|
||||
<a href="https://md.colinknapp.com" target="_blank" rel="noopener">Markdown Tool</a>
|
||||
<a href="https://nix.colinknapp.com" target="_blank" rel="noopener">NixOS Validator</a>
|
||||
<a href="https://qr.colinknapp.com" target="_blank" rel="noopener">QR Code Tool</a>
|
||||
<a href="https://ai-live.colinknapp.com" target="_blank" rel="noopener">AI Live ISO</a>
|
||||
</div>
|
||||
</li>
|
||||
<li><a href="https://meet.colinknapp.com" target="_blank" rel="noopener" title="No-account web meetings">Meet</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main id="main-content">
|
||||
<h1>Camera TRNG</h1>
|
||||
<p class="subtitle">True random numbers from camera sensor noise</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="controls">
|
||||
<div class="field">
|
||||
<label for="bytes">Bytes</label>
|
||||
<input type="number" id="bytes" value="32" min="1" max="1024">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="format">Format</label>
|
||||
<select id="format">
|
||||
<option value="hex">Hexadecimal</option>
|
||||
<option value="base64">Base64</option>
|
||||
<option value="raw">Raw Binary</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="display:flex;align-items:flex-end;">
|
||||
<button id="generate" class="btn-primary">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="output" id="output">Click generate to get random bytes...</div>
|
||||
<div class="stats">
|
||||
<div>Bytes: <span id="stat-bytes">-</span></div>
|
||||
<div>Time: <span id="stat-time">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>API Usage</h2>
|
||||
<p style="margin-bottom:1rem;color:var(--date-color);">Examples update automatically based on your settings above.</p>
|
||||
|
||||
<div class="code-tabs">
|
||||
<button class="code-tab active" data-tab="curl">cURL</button>
|
||||
<button class="code-tab" data-tab="python">Python</button>
|
||||
<button class="code-tab" data-tab="js">JavaScript</button>
|
||||
<button class="code-tab" data-tab="rust">Rust</button>
|
||||
<button class="code-tab" data-tab="go">Go</button>
|
||||
</div>
|
||||
|
||||
<div class="code-panel active" id="panel-curl">
|
||||
<pre><code id="code-curl"></code></pre>
|
||||
</div>
|
||||
<div class="code-panel" id="panel-python">
|
||||
<pre><code id="code-python"></code></pre>
|
||||
</div>
|
||||
<div class="code-panel" id="panel-js">
|
||||
<pre><code id="code-js"></code></pre>
|
||||
</div>
|
||||
<div class="code-panel" id="panel-rust">
|
||||
<pre><code id="code-rust"></code></pre>
|
||||
</div>
|
||||
<div class="code-panel" id="panel-go">
|
||||
<pre><code id="code-go"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>MCP Integration</h2>
|
||||
<p style="margin-bottom:1rem;">This service exposes a <a href="https://colinknapp.com/stories/well-known-mcp.html">.well-known/mcp.json</a> endpoint for machine-readable service discovery:</p>
|
||||
<a class="mcp-link" id="mcp-url" href="/.well-known/mcp.json" target="_blank"></a>
|
||||
<h3>Fetch MCP Manifest</h3>
|
||||
<pre><code id="code-mcp"></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>How it works</h2>
|
||||
<p><strong>Entropy source:</strong> Extracts thermal and shot noise from camera sensor pixels (LSBs), then whitens via SHA-256 hashing for uniform distribution. Each request captures fresh frames ensuring unique entropy.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto';
|
||||
function updateTheme(theme) {
|
||||
const icons = { light: '🌞', dark: '🌙', auto: '🌓' };
|
||||
themeToggle.textContent = icons[theme];
|
||||
themeToggle.setAttribute('aria-label', 'Theme: ' + theme);
|
||||
if (theme === 'auto') html.removeAttribute('data-theme');
|
||||
else html.setAttribute('data-theme', theme);
|
||||
}
|
||||
updateTheme(savedTheme);
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const cur = html.getAttribute('data-theme') || 'auto';
|
||||
const next = cur === 'light' ? 'dark' : cur === 'dark' ? 'auto' : 'light';
|
||||
updateTheme(next); localStorage.setItem('theme', next);
|
||||
});
|
||||
|
||||
// Code tabs
|
||||
document.querySelectorAll('.code-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.code-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.code-panel').forEach(p => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Dynamic code examples
|
||||
const origin = window.location.origin;
|
||||
const bytesInput = document.getElementById('bytes');
|
||||
const formatSelect = document.getElementById('format');
|
||||
|
||||
function updateCodeExamples() {
|
||||
const bytes = bytesInput.value || 32;
|
||||
const fmt = formatSelect.value;
|
||||
const hexParam = fmt === 'hex' ? '&hex=true' : '';
|
||||
const url = `${origin}/random?bytes=${bytes}${hexParam}`;
|
||||
|
||||
document.getElementById('code-curl').textContent = fmt === 'raw'
|
||||
? `# Get ${bytes} raw random bytes\ncurl -s "${origin}/random?bytes=${bytes}" -o random.bin\n\n# Or pipe to xxd for viewing\ncurl -s "${origin}/random?bytes=${bytes}" | xxd`
|
||||
: `# Get ${bytes} random bytes as ${fmt}\ncurl -s "${url}"`;
|
||||
|
||||
document.getElementById('code-python').textContent = fmt === 'raw'
|
||||
? `import requests\n\nresp = requests.get("${origin}/random?bytes=${bytes}")\nrandom_bytes = resp.content # ${bytes} bytes\nprint(random_bytes.hex())`
|
||||
: `import requests\n\nresp = requests.get("${url}")\nrandom_${fmt} = resp.text\nprint(random_${fmt})`;
|
||||
|
||||
document.getElementById('code-js').textContent = fmt === 'raw'
|
||||
? `const resp = await fetch("${origin}/random?bytes=${bytes}");\nconst buffer = await resp.arrayBuffer();\nconst bytes = new Uint8Array(buffer); // ${bytes} bytes\nconsole.log([...bytes].map(b => b.toString(16).padStart(2,'0')).join(''));`
|
||||
: `const resp = await fetch("${url}");\nconst random = await resp.text();\nconsole.log(random);`;
|
||||
|
||||
document.getElementById('code-rust').textContent = `use reqwest;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n let bytes = reqwest::get("${url}")\n .await?.${fmt === 'raw' ? 'bytes' : 'text'}().await?;\n println!("{:?}", bytes);\n Ok(())\n}`;
|
||||
|
||||
document.getElementById('code-go').textContent = `package main\n\nimport (\n "fmt"\n "io"\n "net/http"\n)\n\nfunc main() {\n resp, _ := http.Get("${url}")\n defer resp.Body.Close()\n body, _ := io.ReadAll(resp.Body)\n fmt.Println(string(body))\n}`;
|
||||
|
||||
document.getElementById('mcp-url').href = origin + '/.well-known/mcp.json';
|
||||
document.getElementById('mcp-url').textContent = origin + '/.well-known/mcp.json';
|
||||
document.getElementById('code-mcp').textContent = `curl -s "${origin}/.well-known/mcp.json" | jq`;
|
||||
}
|
||||
|
||||
bytesInput.addEventListener('input', updateCodeExamples);
|
||||
formatSelect.addEventListener('change', updateCodeExamples);
|
||||
updateCodeExamples();
|
||||
|
||||
// Random generator
|
||||
const btn = document.getElementById('generate');
|
||||
const output = document.getElementById('output');
|
||||
const statBytes = document.getElementById('stat-bytes');
|
||||
const statTime = document.getElementById('stat-time');
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const bytes = Math.min(1024, Math.max(1, parseInt(bytesInput.value) || 32));
|
||||
bytesInput.value = bytes;
|
||||
const fmt = formatSelect.value;
|
||||
btn.disabled = true; output.className = 'output loading';
|
||||
output.textContent = 'Capturing camera noise...';
|
||||
const start = performance.now();
|
||||
try {
|
||||
const url = fmt === 'raw'
|
||||
? `/random?bytes=${bytes}`
|
||||
: `/random?bytes=${bytes}&hex=true`;
|
||||
const res = await fetch(url);
|
||||
const elapsed = (performance.now() - start).toFixed(0);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
if (fmt === 'raw') {
|
||||
const buf = await res.arrayBuffer();
|
||||
const arr = new Uint8Array(buf);
|
||||
output.textContent = `[${bytes} raw bytes] ` + [...arr].map(b => b.toString(16).padStart(2,'0')).join(' ');
|
||||
} else if (fmt === 'base64') {
|
||||
const hex = await res.text();
|
||||
const arr = new Uint8Array(hex.match(/.{2}/g).map(x => parseInt(x, 16)));
|
||||
output.textContent = btoa(String.fromCharCode(...arr));
|
||||
} else {
|
||||
output.textContent = await res.text();
|
||||
}
|
||||
output.className = 'output';
|
||||
statBytes.textContent = bytes; statTime.textContent = elapsed + 'ms';
|
||||
} catch (e) { output.className = 'output error'; output.textContent = 'Error: ' + e.message; }
|
||||
btn.disabled = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
150
src/main.rs
150
src/main.rs
|
|
@ -2,7 +2,7 @@ use axum::{
|
|||
body::Body,
|
||||
extract::Query,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
response::{Html, IntoResponse, Response, Json},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
|
@ -12,13 +12,21 @@ use nokhwa::{
|
|||
Camera,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_BYTES_PER_REQUEST: usize = 1024;
|
||||
const MAX_CONCURRENT: usize = 4;
|
||||
const DEFAULT_PORT: u16 = 8787;
|
||||
|
||||
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0);
|
||||
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn is_fake_camera() -> bool {
|
||||
std::env::var("FAKE_CAMERA")
|
||||
.map(|v| v == "1" || v.to_lowercase() == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RandomQuery {
|
||||
|
|
@ -32,32 +40,56 @@ fn default_bytes() -> usize { 32 }
|
|||
|
||||
#[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);
|
||||
|
||||
// Test camera access at startup
|
||||
println!("Testing camera access...");
|
||||
if let Err(e) = test_camera() {
|
||||
eprintln!("Camera error: {}. Server will still start.", e);
|
||||
let port = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
|
||||
|
||||
if is_fake_camera() {
|
||||
println!("FAKE_CAMERA mode enabled - using /dev/urandom for entropy");
|
||||
} else {
|
||||
println!("Camera OK");
|
||||
println!("Testing camera access...");
|
||||
if let Err(e) = test_camera() {
|
||||
eprintln!("Camera error: {}. Server will still start.", e);
|
||||
} else {
|
||||
println!("Camera OK");
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/random", get(get_random))
|
||||
.route("/health", get(health));
|
||||
.route("/health", get(health))
|
||||
.route("/.well-known/mcp.json", get(mcp_wellknown));
|
||||
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
println!("Camera TRNG on http://{}", addr);
|
||||
println!(" GET /random?bytes=N&hex=bool (max {}B)", MAX_BYTES_PER_REQUEST);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
|
||||
async fn health() -> &'static str { "ok" }
|
||||
|
||||
async fn mcp_wellknown() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"mcp": {
|
||||
"spec_version": "2026-01-21",
|
||||
"status": "active",
|
||||
"servers": [],
|
||||
"tools": [{
|
||||
"name": "camera-trng",
|
||||
"description": "True random number generator using camera sensor thermal/shot noise",
|
||||
"url_template": "{origin}/random?bytes={bytes}&hex={hex}",
|
||||
"capabilities": ["random-generation", "entropy-source"],
|
||||
"auth": { "type": "none" },
|
||||
"parameters": {
|
||||
"bytes": { "type": "integer", "default": 32, "max": 1024, "description": "Number of random bytes" },
|
||||
"hex": { "type": "boolean", "default": false, "description": "Return hex-encoded string" }
|
||||
}
|
||||
}]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn test_camera() -> Result<(), String> {
|
||||
let index = CameraIndex::Index(0);
|
||||
let format = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
|
||||
|
|
@ -65,38 +97,37 @@ fn test_camera() -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str { "ok" }
|
||||
|
||||
async fn get_random(Query(params): Query<RandomQuery>) -> Response {
|
||||
// Simple rate limiting
|
||||
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);
|
||||
return (StatusCode::BAD_REQUEST, "bytes must be > 0").into_response();
|
||||
}
|
||||
|
||||
// Run camera ops in blocking task
|
||||
let result = tokio::task::spawn_blocking(move || extract_entropy(bytes)).await;
|
||||
let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let use_fake = is_fake_camera();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
if use_fake {
|
||||
extract_entropy_fake(bytes, request_id)
|
||||
} else {
|
||||
extract_entropy_camera(bytes, request_id)
|
||||
}
|
||||
}).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")
|
||||
.body(Body::from(data))
|
||||
.unwrap()
|
||||
Response::builder().header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from(data)).unwrap()
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
|
|
@ -104,42 +135,59 @@ async fn get_random(Query(params): Query<RandomQuery>) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_entropy(num_bytes: usize) -> Result<Vec<u8>, String> {
|
||||
let index = CameraIndex::Index(0);
|
||||
let format = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
|
||||
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
|
||||
|
||||
camera.open_stream().map_err(|e| e.to_string())?;
|
||||
fn extract_entropy_fake(num_bytes: usize, request_id: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut entropy = Vec::with_capacity(num_bytes);
|
||||
let mut hasher = Sha256::new();
|
||||
let frames_needed = (num_bytes / 32) + 1;
|
||||
|
||||
for _ in 0..frames_needed {
|
||||
let frame = camera.frame().map_err(|e| e.to_string())?;
|
||||
let raw = frame.buffer();
|
||||
|
||||
// Extract LSBs - thermal/shot noise lives here
|
||||
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
|
||||
|
||||
// Hash to whiten and remove bias
|
||||
// Simulate reading "frames" from /dev/urandom
|
||||
let mut urandom = std::fs::File::open("/dev/urandom").map_err(|e| e.to_string())?;
|
||||
let mut fake_frame = vec![0u8; 640 * 480 * 3]; // Simulated RGB frame
|
||||
|
||||
for frame_idx in 0..frames_needed {
|
||||
urandom.read_exact(&mut fake_frame).map_err(|e| e.to_string())?;
|
||||
let lsbs: Vec<u8> = fake_frame.iter().map(|b| b & 0x03).collect();
|
||||
hasher.update(&lsbs);
|
||||
hasher.update(&request_id.to_le_bytes());
|
||||
hasher.update(&(frame_idx as u64).to_le_bytes());
|
||||
hasher.update(&nanos_now().to_le_bytes());
|
||||
let hash = hasher.finalize_reset();
|
||||
entropy.extend_from_slice(&hash);
|
||||
if entropy.len() >= num_bytes { break; }
|
||||
}
|
||||
entropy.truncate(num_bytes);
|
||||
Ok(entropy)
|
||||
}
|
||||
|
||||
fn extract_entropy_camera(num_bytes: usize, request_id: u64) -> Result<Vec<u8>, String> {
|
||||
let index = CameraIndex::Index(0);
|
||||
let format = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
|
||||
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
|
||||
camera.open_stream().map_err(|e| e.to_string())?;
|
||||
let mut entropy = Vec::with_capacity(num_bytes);
|
||||
let mut hasher = Sha256::new();
|
||||
let frames_needed = (num_bytes / 32) + 1;
|
||||
for frame_idx in 0..frames_needed {
|
||||
let frame = camera.frame().map_err(|e| e.to_string())?;
|
||||
let raw = frame.buffer();
|
||||
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
|
||||
hasher.update(&lsbs);
|
||||
hasher.update(&request_id.to_le_bytes());
|
||||
hasher.update(&(frame_idx as u64).to_le_bytes());
|
||||
hasher.update(&nanos_now().to_le_bytes());
|
||||
|
||||
let hash = hasher.finalize_reset();
|
||||
entropy.extend_from_slice(&hash);
|
||||
|
||||
if entropy.len() >= num_bytes { break; }
|
||||
}
|
||||
|
||||
camera.stop_stream().ok();
|
||||
entropy.truncate(num_bytes);
|
||||
Ok(entropy)
|
||||
}
|
||||
|
||||
fn nanos_now() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
}
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
|
|
|
|||
Loading…
Reference in New Issue