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:
Leopere 2026-01-22 16:14:30 -05:00
parent 1b7e21a8a0
commit 8608b530ee
No known key found for this signature in database
GPG Key ID: EA43219BE7B419F1
13 changed files with 1373 additions and 57 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
scripts/node_modules/

279
.woodpecker.yml Normal file
View File

@ -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]

1
Cargo.lock generated
View File

@ -177,6 +177,7 @@ dependencies = [
"hex",
"nokhwa",
"serde",
"serde_json",
"sha2",
"tokio",
]

View File

@ -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"

51
Dockerfile Normal file
View File

@ -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
View File

@ -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:

View File

@ -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();

119
scripts/package-lock.json generated Normal file
View File

@ -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
}
}
}
}
}

8
scripts/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "obs-virtual-camera",
"version": "1.0.0",
"type": "module",
"dependencies": {
"obs-websocket-js": "^5.0.6"
}
}

57
scripts/start-obs-noise.sh Executable file
View File

@ -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 ""

10
scripts/stop-obs-noise.sh Executable file
View File

@ -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

417
src/index.html Normal file
View File

@ -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>

View File

@ -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");