diff --git a/.gitignore b/.gitignore index ea8c4bf..94676fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +scripts/node_modules/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..3b0ad55 --- /dev/null +++ b/.woodpecker.yml @@ -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] diff --git a/Cargo.lock b/Cargo.lock index be4b204..99f8503 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,7 @@ dependencies = [ "hex", "nokhwa", "serde", + "serde_json", "sha2", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 057804d..b52ead2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12ae38a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 245f89a..9ab98ed 100644 --- a/README.md +++ b/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 | +| `` | Specific commit (first 8 chars) | +| `` | 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 `` +- **On version tag**: Tags `` 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: diff --git a/scripts/obs-noise-camera.mjs b/scripts/obs-noise-camera.mjs new file mode 100644 index 0000000..3b5591f --- /dev/null +++ b/scripts/obs-noise-camera.mjs @@ -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, + + + + + + + +`; + + 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(); diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..9def39c --- /dev/null +++ b/scripts/package-lock.json @@ -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 + } + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..e898170 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "obs-virtual-camera", + "version": "1.0.0", + "type": "module", + "dependencies": { + "obs-websocket-js": "^5.0.6" + } +} diff --git a/scripts/start-obs-noise.sh b/scripts/start-obs-noise.sh new file mode 100755 index 0000000..550ddd2 --- /dev/null +++ b/scripts/start-obs-noise.sh @@ -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 "" diff --git a/scripts/stop-obs-noise.sh b/scripts/stop-obs-noise.sh new file mode 100755 index 0000000..00a492d --- /dev/null +++ b/scripts/stop-obs-noise.sh @@ -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 diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..541b122 --- /dev/null +++ b/src/index.html @@ -0,0 +1,417 @@ + + + + + + + Camera TRNG - True Random Number Generator + + + + + + + + Skip to content +
+ +
+ + + +
+

Camera TRNG

+

True random numbers from camera sensor noise

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
Click generate to get random bytes...
+
+
Bytes: -
+
Time: -
+
+
+ +
+

API Usage

+

Examples update automatically based on your settings above.

+ +
+ + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

MCP Integration

+

This service exposes a .well-known/mcp.json endpoint for machine-readable service discovery:

+ +

Fetch MCP Manifest

+
+
+ +
+

How it works

+

Entropy source: 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.

+
+
+ + + + diff --git a/src/main.rs b/src/main.rs index f50f75b..5769a85 100644 --- a/src/main.rs +++ b/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> { - 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 { + 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::(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) -> 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) -> Response { } } -fn extract_entropy(num_bytes: usize) -> Result, String> { - let index = CameraIndex::Index(0); - let format = RequestedFormat::new::(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, 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 = 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 = 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, String> { + let index = CameraIndex::Index(0); + let format = RequestedFormat::new::(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 = 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");