Sentinel by SourceBox — turn any USB webcam into a cloud-connected security camera.
Quick Start
·
Configuration
·
Docker
·
Troubleshooting
CloudNode runs on your local network, detects USB cameras, and streams live video to the Sentinel Command Center via HLS. All configuration is stored locally in an encrypted SQLite database — no cloud dependency for setup.
What it does:
- Detects USB cameras and transcodes each to HLS using FFmpeg (with hardware acceleration when available)
- Pushes 1-second
.tssegments directly to Command Center's in-memory cache — no S3, no presigned URLs - Detects motion from FFmpeg scene-change analysis and reports events to Command Center over HTTP (
POST /api/cameras/{id}/motion) - Stores recordings and snapshots locally in an encrypted SQLite database with automatic retention
- Runs a live terminal dashboard with slash commands and log viewer
Supported platforms:
| Platform | Status | Camera API |
|---|---|---|
| Linux x86_64 / ARM64 | Production ready | Video4Linux2 |
| Windows 10 / 11 | Production ready | DirectShow |
| macOS | Experimental | AVFoundation |
CloudNode runs in one of two modes, chosen interactively by the setup wizard's first prompt:
- Local-only — free, runs on your home / office LAN, no account, no cloud. Live camera viewing + snapshots + recording + recording playback through a browser dashboard at
http://<node-ip>:8080. Single LAN, single node. Plex / Home Assistant / Synology model. - Connected — pair with a Sentinel Command Center account. Adds multi-site dashboards, the Sentinel AI agent, mobile remote access, email alerts, MCP integrations, and team workflows. Requires a free Command Center account and a node API key.
The same binary serves both modes; pick whichever fits. Local installs can later be paired by re-running setup, but for now the choice is made once at first boot.
- A USB webcam
- Connected mode only: a Command Center account with a Node ID and API Key (generated from the Settings page)
- Docker (recommended) or Rust 1.70+ with FFmpeg
The fastest way to install CloudNode:
Linux / macOS:
curl -fsSL https://opensentry-command.fly.dev/install.sh | bashWindows:
- Download
sourcebox-sentry-cloudnode-windows-x86_64.msifrom the latest release. - Run the MSI (UAC prompt). SmartScreen will warn "Windows protected your PC" because the installer is unsigned — click More info → Run anyway. (Code signing is on the roadmap.)
- From the Start menu, click Sentinel CloudNode. First launch runs the setup wizard interactively, then drops into the foreground TUI dashboard with cameras streaming. Every launch after just streams.
Config + recordings live under C:\ProgramData\SourceBoxSentry\. The setup wizard checks for FFmpeg and offers to install it via winget install Gyan.FFmpeg if it isn't already on PATH.
Manual install (build from source)
git clone https://github.com/SourceBox-LLC/Sentinel-CameraNode.git
cd Sentinel-CameraNode
cargo build --release
# Run the interactive setup wizard
./target/release/sourcebox-sentry-cloudnode setupThe setup wizard handles everything automatically:
- Asks "Connect this node to a Command Center?" —
Yesfor the SaaS-paired mode,Nofor local-only. - Detects your platform and verifies connected cameras.
- Verifies FFmpeg via your OS package manager — on Windows offers to run
winget install Gyan.FFmpeg, on macOSbrew install ffmpeg, on Linux prints the rightapt/dnf/pacmancommand. CloudNode uses the system FFmpeg (no bundled copy). - Connected mode only: prompts for Node ID, API Key, and Command Center URL.
- Detects the best available hardware encoder (NVENC, QSV, AMF).
- Encrypts and stores credentials locally in the SQLite config DB (path varies by platform — see Configuration).
After setup, start the node:
./target/release/sourcebox-sentry-cloudnodeThe TUI status bar prints the local browser-dashboard URL (e.g.
http://192.168.1.42:8080). Open that URL on any device on the same
LAN to see live camera feeds, take snapshots, toggle recording (Local
mode), and play back past recordings.
In addition to the in-terminal TUI, every CloudNode now serves a
React-based browser dashboard at http://<node-ip>:8080/. In Local
mode it's the primary management surface; in Connected mode it's
the only place to view node-local snapshots and recordings (Command
Center streams the live feed but doesn't store the archive).
What the browser dashboard does:
- Cameras tab (
/) — in Local mode, a live HLS grid with snapshot + record-toggle buttons per tile, refreshing every 5 s. In Connected mode the live HLS player is replaced with a "Live view in Command Center" panel + CTA link (CC has the same live feed and is the canonical viewer); the Snapshot button stays (taking a snapshot in Connected mode still archives locally, useful when CC is unreachable), and the Record toggle is hidden (CC owns recording policy via the heartbeat reconciler). - Snapshots tab (
/snapshots) — gallery of every still you've captured, click-to-zoom modal, per-tile delete. Polls every 10 s so snapshots triggered from Command Center appear without a manual refresh. Image bytes come from the encrypted SQLite blob store via/api/snapshots/{id}. - Recordings tab (
/recordings) — one cell per (camera, date). Polls every 10 s. Click → modal player with HLS.js seeking through the encrypted SQLite blob store via/api/recordings/{cam}/{date}/playlist.m3u8. - Mode pill in the header shows
LocalorConnectedso the operator always knows which surface they're on. - Command Center upsell footer — Local-mode installs see a tasteful "Get more out of your cameras" footer at the bottom of every page linking to Command Center. Connected installs don't see it.
Authentication: none in v1. The server binds to 127.0.0.1 in
Connected mode (localhost-only) and to 0.0.0.0 in Local mode (any
device on the LAN). See docs/runbooks/local-mode-setup.md
for the threat model and discovery options.
CloudNode also runs a full-screen terminal dashboard showing camera status, upload progress, and live logs.
The status bar surfaces a [LOCAL] or [PRO PLUS] mode badge plus the URLs to click. In Local mode you see the LAN URL (http://<lan-ip>:8080); in Connected mode you see both the local URL and the Command Center URL, joined by ·. Both URLs are OSC 8 terminal hyperlinks — modern terminals (Windows Terminal, iTerm2, kitty, WezTerm, GNOME Terminal, tmux ≥3.4) render them as Ctrl/Cmd-click links. Older terminals strip the escape sequences and show the bare URL text.
In Connected mode the log buffer also includes a per-heartbeat diagnostic line like Heartbeat: 1 cam in policy (1 on) so you can see at a glance what Command Center is telling the node about recording policy. If you click Record in CC and the next heartbeat still reports (0 on), the bug is CC-side (the continuous_24_7 flag isn't flipping). If it flips to (1 on) and a Recording started — <camera_id> transition log follows, the archive is being written. See docs/runbooks/local-mode-setup.md for the troubleshooting flow.
Type / and press Enter to open the command menu.
Main view:
| Command | Description |
|---|---|
/settings |
Open the settings page |
/status |
Show node status summary |
/clear |
Clear the log panel |
/quit |
Stop the node and exit |
Settings page:
| Command | Description |
|---|---|
/export-logs |
Save logs to a timestamped file |
/wipe confirm |
Erase all stored data and reset |
/reauth confirm |
Clear credentials and re-run setup |
/back |
Return to the dashboard |
Press Esc to return from settings. Destructive commands (/wipe, /reauth) require confirmation: either press the command twice within 30 seconds — the first press arms the confirmation, the second executes — or pass the confirm argument explicitly (/wipe confirm). Any other command in between clears the armed state.
CloudNode resolves configuration in this order (highest priority last):
- SQLite database — created by the setup wizard, primary source of truth. The database lives at:
$SOURCEBOX_SENTRY_DATA_DIR/node.dbif the env var is set (Docker)./data/node.dbif it already exists (legacy /cargo buildinstalls)C:\ProgramData\SourceBoxSentry\node.dbon Windows-MSI installs./data/node.dbotherwise (fresh manual install on Linux/macOS)
- YAML file (
config.yaml) — legacy fallback, auto-migrated to the DB on first load - Environment variables — override any stored values
- CLI flags — highest priority
Use environment variables to override database values without modifying the DB:
| Variable | Description |
|---|---|
SOURCEBOX_SENTRY_NODE_ID |
Node ID |
SOURCEBOX_SENTRY_API_KEY |
API Key |
SOURCEBOX_SENTRY_API_URL |
Command Center URL |
SOURCEBOX_SENTRY_ENCODER |
Video encoder override (e.g. h264_nvenc, libx264) |
RUST_LOG |
Log level: trace, debug, info, warn, error |
sourcebox-sentry-cloudnode --node-id <ID> --api-key <KEY> --api-url <URL>Motion detection is on by default. After each successful segment upload, the uploader spawns a per-segment task that runs FFmpeg's select='gt(scene,THRESHOLD)' scorer against the just-written .ts file; scores above the threshold POST a motion_detected event to POST /api/cameras/{id}/motion (HTTP-only; the WS path that pre-v0.1.61 wired this through motion_tx was dead code and got removed). Per-camera cooldown (a Mutex<Option<Instant>> shared across tasks for the same camera) prevents flapping. In Local mode report_motion short-circuits to Ok(()) so detection still runs but no network call is made.
Defaults (configurable via config.yaml motion: section):
| Setting | Default | Meaning |
|---|---|---|
enabled |
true |
Toggle motion detection |
threshold |
0.02 |
Scene-change score (0.0 = identical, 1.0 = totally different) |
cooldown_secs |
30 |
Minimum seconds between events per camera |
The API key is encrypted at rest using AES-256-GCM with a machine-derived key (SHA-256 of the OS machine identifier + application salt). CloudNode reads the identifier from /etc/machine-id on Linux, HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid on Windows, and IOPlatformUUID on macOS — values that are set once at OS install time, unique per host, and not user-modifiable. Moving node.db to a different host makes the stored key unreadable.
DBs written by older CloudNode versions that derived the key from the hostname are transparently re-encrypted with the new machine-ID-derived key on first load.
Docker: Alpine-based images don't ship with /etc/machine-id, so CloudNode generates a per-container ID on first run and stores it inside the mounted data volume ($SOURCEBOX_SENTRY_DATA_DIR/.machine-id). The ID persists across container rebuilds because it lives in the volume. For stronger encryption — a key tied to the host rather than the data volume — run the container with -v /etc/machine-id:/etc/machine-id:ro.
The Windows MSI installs CloudNode and creates a Start menu shortcut. The shortcut launches the binary as a foreground TUI dashboard — a console window opens with the live log, FFmpeg pushes segments, and the node stays online for as long as the window stays open. First launch detects no credentials and runs the setup wizard interactively before dropping into the dashboard; subsequent launches stream straight away.
This is the everyday-use path: you can see what's happening, hit a slash command, and close it cleanly.
| Path | Purpose |
|---|---|
C:\Program Files\Sentinel CloudNode\sourcebox-sentry-cloudnode.exe |
Binary (read-only after install) |
C:\ProgramData\SourceBoxSentry\node.db |
Encrypted SQLite — config + (optionally) recordings |
C:\ProgramData\SourceBoxSentry\logs\ |
Log files (only written when running unattended; the foreground TUI logs to the console) |
Close the dashboard window, re-run setup, relaunch:
sourcebox-sentry-cloudnode setup
# Then click the Start menu shortcut again, or run the binary with no args.Use Settings → Apps → Sentinel CloudNode → Uninstall. The MSI uninstaller removes the binary and wipes C:\ProgramData\SourceBoxSentry\ — including your encrypted config and recordings. FFmpeg installed via winget stays put because it's a separately-managed package.
Heads up: the MSI is unsigned today. SmartScreen flags unsigned installers with "Windows protected your PC" — click More info → Run anyway to proceed. Code signing is deferred until we have a sustained release cadence worth the EV-cert fee; the binary itself is open source and reproducibly buildable from this repository if you want to verify.
In Connected mode, your live stream goes to Command Center. In Local mode, the cloud upload is skipped — segments are written to disk only, and the embedded SPA streams them direct from the LAN. Either way, a small rolling buffer stays on this machine, and anything you explicitly record (plus every snapshot you capture) is archived in an encrypted SQLite database on this machine — these are the only things with any real footprint.
┌────────── YOUR CAMERAS ──────────┐
│ │
▼ ▼
┌─────────────────────────┐ ┌───────────────────────────┐
│ data/hls/…/*.ts │ │ Command Center (cloud) │
│ │──┬──▶│ │
│ rolling 30-second │ │ │ the live feed your │
│ buffer, ~12 MB per cam │ │ │ dashboard plays │
└─────────────────────────┘ │ └───────────────────────────┘
│ │
│ └── uploaded once per second, always on
│
│ if a camera is set to Record:
│ save a copy into the archive below
▼
┌────────────────────────────────────────────────────┐
│ data/node.db — encrypted SQLite, one file │
│ │
│ • snapshots (JPEG images) │
│ • recordings (video chunks, opt-in) │
│ • config + API key (AES-256-GCM, hardware- │
│ bound so copying the │
│ file off this machine │
│ can't impersonate it) │
│ • logs (recent dashboard history) │
│ │
│ Capped at storage.max_size_gb (default 64 GB). │
│ Oldest recordings and snapshots are deleted │
│ first when you hit the cap. │
└────────────────────────────────────────────────────┘
| Location | Holds | Retention | Size |
|---|---|---|---|
data/hls/ on this machine |
The last ~30 seconds per camera, in 1-second video chunks | Rolling — swept every minute, keeps newest ~30 chunks | ~12 MB per camera at any moment |
| Command Center (cloud) | The live stream — what your dashboard actually plays | Per your Command Center plan | Handled by the backend |
data/node.db on this machine |
Snapshots, opt-in recordings, config, recent logs | Until it hits storage.max_size_gb (default 64 GB); oldest data purged first |
Whatever you set |
Live streaming to the cloud is always on whenever the node is running — nothing to configure, nothing to toggle. Recording is a separate switch per camera that, when enabled, also saves each chunk into data/node.db so you have a local copy even if the cloud is unreachable later.
Three ways to enable it from Command Center:
- Continuous 24/7 — toggle on per camera in Settings → Camera Nodes. Records all the time the node is online.
- Scheduled — toggle on per camera with a wall-clock window (e.g. 18:00–06:00). Times are interpreted in your org's timezone, set in Settings → Time Zone.
- Manual — the Record button on a camera tile in the dashboard. Same end state as Continuous 24/7 (it just flips the same flag).
The two policy modes are mutually exclusive — turning Continuous on auto-clears Scheduled in the same change, and vice versa.
Self-healing across restarts (v0.1.43+): the recording state lives in the backend's per-camera continuous_24_7 / scheduled_recording columns. CloudNode reconciles its in-memory recording set from the heartbeat response every ~30s, so a node restart picks up the correct policy on its next heartbeat — operators don't have to re-enable anything after a power cycle.
From the dashboard, Take Snapshot pulls one JPEG frame from that camera's most recent complete video chunk and saves it into data/node.db. You can list and view snapshots from the dashboard. They're also subject to the same retention cap.
Encrypted at rest: your API key, in the config table, using AES-256-GCM with a key derived from this machine's hardware ID (see the Security section above for the full key-derivation story). Copying data/node.db to another machine makes the API key undecryptable there — an attacker can't impersonate your node just by lifting the file.
Not encrypted at rest: the video chunks and JPEG snapshots themselves. The assumption is that anyone with filesystem access to data/node.db can already watch the live stream from the same machine, so encrypting the BLOBs would add complexity without meaningfully raising the bar. If you need at-rest encryption for the video content too, put the data/ directory on an encrypted volume — BitLocker on Windows, LUKS on Linux, FileVault on macOS.
Two safety nets:
-
Automatic retention. Every 5 minutes the node checks total stored bytes against the operator-chosen
max_size_gbcap. When the cap is exceeded, oldest recording segments are deleted first (FIFO) until usage is back under the cap. You won't get an error; the node just keeps running with fresh data. The Command Center dashboard surfaces a per-node usage bar so you can see it climbing toward the cap. -
Host-disk safety floor. Cross-platform via
sysinfo: when the underlying filesystem drops below 1 GiB free, the node pauses durable recording writes on the next heartbeat tick (regardless of how the cap was set). Live streaming continues unchanged; only the archive is paused. Recording resumes automatically once free disk recovers above the floor — typically because retention has freed up space, or the operator cleared other files.
Ordered from least to most destructive — pick the one that matches your goal:
- Lower the cap. Re-run the setup wizard and pick a smaller
max_size_gb; retention will sweep oldest segments at the next 5-minute tick to fit the new cap. - Wipe recordings and snapshots but keep your credentials. From the node's live dashboard (TUI), open the command bar and run
/wipe. This clears the recording and snapshot tables indata/node.dband asks the backend to drop the node record; setup will re-pair on next launch if you want. - Reset credentials only. If your node has the wrong ID or API key, the dashboard will surface a red "Registration Failed" screen offering to wipe credentials and re-launch the setup wizard. Accept it and you're back at step 1.
- Full reinstall. Stop the node and delete the
data/directory. On next launch the setup wizard runs from scratch.
Yes — from the Command Center dashboard. Recorded video and snapshots show up in the camera's history view; the backend fetches them from this node's archive over the same channel it uses for everything else.
The node's own local HTTP server (127.0.0.1:8080) only serves the 30-second live buffer — it has no endpoint for reaching into the archive. That's deliberate: the archive is private to the node and reachable only through your authenticated Command Center session.
Prebuilt multi-arch images (linux/amd64, linux/arm64) are published to GitHub Container Registry on every release tag.
docker pull ghcr.io/sourcebox-llc/sentinel-cameranode:latest
docker run -d \
--name sourcebox-sentry-cloudnode \
--device /dev/video0:/dev/video0 \
-e SOURCEBOX_SENTRY_NODE_ID=your_node_id \
-e SOURCEBOX_SENTRY_API_KEY=your_api_key \
-e SOURCEBOX_SENTRY_API_URL=https://your-backend.example.com \
-p 8080:8080 \
-v ./data:/app/data \
ghcr.io/sourcebox-llc/sentinel-cameranode:latestPin to a specific release instead of :latest when you want reproducible deploys — e.g. ghcr.io/sourcebox-llc/sentinel-cameranode:0.1.18. Major.minor tags like :0.1 are also published and float to the newest patch. See releases for the current version.
cp .env.example .env
# Edit .env with your credentials
docker-compose up -dThe bundled
docker-compose.ymlcurrently builds from source (build: .). To use the prebuilt image instead, swap thebuild:line forimage: ghcr.io/sourcebox-llc/sentinel-cameranode:latestand rundocker compose pull && docker compose up -d.
docker build -t sourcebox-sentry-cloudnode .
docker run -d \
--name sourcebox-sentry-cloudnode \
--device /dev/video0:/dev/video0 \
-e SOURCEBOX_SENTRY_NODE_ID=your_node_id \
-e SOURCEBOX_SENTRY_API_KEY=your_api_key \
-e SOURCEBOX_SENTRY_API_URL=https://your-backend.example.com \
-p 8080:8080 \
-v ./data:/app/data \
sourcebox-sentry-cloudnodePass each device to the container:
docker run -d \
--device /dev/video0:/dev/video0 \
--device /dev/video2:/dev/video2 \
-e SOURCEBOX_SENTRY_NODE_ID=your_node_id \
-e SOURCEBOX_SENTRY_API_KEY=your_api_key \
-e SOURCEBOX_SENTRY_API_URL=https://your-backend.example.com \
-p 8080:8080 \
ghcr.io/sourcebox-llc/sentinel-cameranode:latest USB Cameras
│
┌───────┴───────────────────────────────┐
│ CloudNode │
│ │
│ Camera detection ──► FFmpeg (HLS) │
│ │ │
│ ▼ │
│ .ts + .m3u8 ──► HlsUploader ──┐
│ │ │
│ Dashboard (TUI + browser SPA) │ │
│ │ │ post-segment
│ HTTP server :8080 │ │ motion score
│ (live HLS + /api/* SPA bundle) │ │ (HTTP only)
│ │ ▼
│ WebSocket client (Connected mode │ SegmentUploader
│ only — heartbeat, commands) │ │ push
│ │ │ (Connected mode)
└───────────────────────────────────────┘ │
▼
Command Center
Video pipeline: Camera → FFmpeg subprocess → rolling HLS segments (.ts) → SegmentUploader pushes each segment to Command Center via POST /api/cameras/{id}/push-segment (Connected mode only; Local mode short-circuits the push) → backend caches in memory → browser fetches via same-origin proxy. No S3, no presigned URLs.
Playlist: Every time FFmpeg rewrites stream.m3u8, CloudNode also POSTs the playlist text to POST /api/cameras/{id}/playlist so the backend's rewritten (relative-URL) copy stays fresh.
Motion: After each segment, the uploader spawns a per-segment motion-detection task that runs FFmpeg's select='gt(scene,THRESHOLD)' scorer against the just-written .ts, applies the per-camera cooldown, and — if motion crossed the threshold — calls POST /api/cameras/{id}/motion directly via the HTTP client. Local mode short-circuits the POST. (A WS-based real-time push existed pre-v0.1.61 but was dead code; HTTP is the only delivery path.)
Local storage: SQLite database (data/node.db) stores configuration, snapshots, and recordings as BLOBs (not exposed in open folders). Retention is enforced automatically — oldest data is deleted first when max_size_gb is exceeded. See the Storage section for a full walkthrough of the three tiers (disk buffer, cloud, local archive) and how to manage them.
Hardware encoding: At startup, CloudNode probes for a hardware encoder (NVENC, QSV, AMF) and caches the result in the database. Falls back to libx264 if none is found.
The node runs a single warp HTTP server on port 8080 that powers both the live HLS feed and the browser dashboard's /api/* surface.
| Method | Endpoint | Description |
|---|---|---|
| GET | /health |
Health check (also used by the Docker HEALTHCHECK) |
| GET | /hls/{camera_id}/stream.m3u8 |
Live HLS playlist |
| GET | /hls/{camera_id}/segment_{n}.ts |
Live video segment — filename must be segment_<digits>.ts |
These power the embedded SPA at http://<node-ip>:8080/. Same shape in both modes; the recording-toggle endpoint returns 409 in Connected mode (Command Center is the source of truth there).
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/cameras |
List cameras with status + hls_url for each tile |
| POST | /api/cameras/{id}/snapshot |
Capture one JPEG from latest complete segment, save to encrypted SQLite, return metadata |
| POST | /api/cameras/{id}/recording |
Body { "recording": bool } — Local: flip + persist; Connected: returns 409 |
| GET | /api/snapshots |
List snapshots (optional ?camera_id= filter) |
| GET | /api/snapshots/{id} |
Decrypted JPEG bytes (Content-Type: image/jpeg) |
| DELETE | /api/snapshots/{id} |
Delete a snapshot row |
| GET | /api/recordings |
List (camera_id, date) buckets with segment count + bytes |
| GET | /api/recordings/{cam}/{date}/playlist.m3u8 |
Dynamic VOD HLS playlist (sealed, with EXT-X-ENDLIST) |
| GET | /api/recordings/{cam}/{date}/segment_{n}.ts |
Decrypted MPEG-TS segment from SQLite |
| GET | /api/status |
Node status — mode, version, uptime, camera count, plan |
The server has no authentication. Bind defaults differ by mode:
- Connected mode default —
bind = 127.0.0.1(localhost-only). Anyone with shell access on the box could already wipedata/node.db, so the additional surface is zero. - Local mode default —
bind = 0.0.0.0(any device on the LAN). Operators on the same network can read live HLS, snapshots, recordings, and toggle the local recording flag. Acceptable for v1's home / small-business LAN target. Don't expose this server to the public internet. See docs/runbooks/local-mode-setup.md for the threat model and discovery options.
The snapshot route validates camera_id against the dashboard's known set before touching the filesystem to defeat path-traversal payloads. find_latest_segment additionally canonicalises the chosen segment and refuses anything that doesn't live under the camera's HLS dir as defence-in-depth.
Outbound calls to Command Center (via reqwest):
All authenticated outbound calls use the same header: X-Node-API-Key: <api_key>. The WebSocket is the only exception — it takes the key as a query parameter.
| Endpoint | Purpose |
|---|---|
POST /api/nodes/validate |
Validate a node_id + API key pair before saving config (setup wizard) |
POST /api/nodes/register |
Register node + cameras on startup |
POST /api/nodes/heartbeat |
Liveness (every 30s by default) |
POST /api/cameras/{id}/codec |
Report detected video/audio codec |
POST /api/cameras/{id}/push-segment?filename=… |
Push a .ts segment into the backend's in-memory cache |
POST /api/cameras/{id}/playlist |
Update the rewritten HLS playlist |
POST /api/cameras/{id}/motion |
Motion event delivery (HTTP-only as of v0.1.61) |
WS /ws/node?api_key=…&node_id=… |
Bidirectional channel: heartbeat ack + inbound commands (take_snapshot, list_snapshots, list_recordings, wipe_data). Key passed as query param. |
cargo build # Debug
cargo build --release # Optimized
cargo test # Run tests
cargo clippy # Lint
cargo fmt -- --check # Format checksrc/
├── main.rs # CLI entry point (clap)
├── lib.rs # Library re-exports
├── dashboard/ # Live TUI dashboard + slash commands (split package)
├── error.rs # Custom Error enum + Result type
├── logging.rs # tracing subscriber setup
├── api/ # Cloud API client + shared command implementations
│ ├── client.rs # ApiClient — register, heartbeat, codec, push-segment, playlist, motion;
│ │ # CC-only methods short-circuit when is_local()
│ ├── commands.rs # Shared take_snapshot — used by both WS dispatcher (Connected)
│ │ # and /api/cameras/{id}/snapshot HTTP route (Local web UI)
│ ├── websocket.rs # WebSocket loop with auto-reconnect; handles inbound commands (snapshots, list_*, wipe_data)
│ ├── types.rs # Request/response types
│ └── mod.rs
├── camera/ # Detection and capture (platform-specific)
│ ├── detector.rs # Auto-detect USB cameras
│ ├── capture.rs # Frame capture
│ ├── platform/ # Linux (v4l2) / Windows (DirectShow) / macOS (AVFoundation)
│ └── types.rs
├── config/ # Config loader (DB → YAML → env → CLI) — includes NodeMode (Local | Connected)
├── node/ # Orchestration and lifecycle
│ └── runner.rs # Runtime fork: Local skips registration / heartbeat / WS / segment-push
├── server/ # Local HTTP server (warp)
│ ├── http.rs # /health + /hls/* routes; chains to api.rs
│ └── api.rs # /api/* routes + LocalApiState + embedded SPA static_routes()
├── setup/ # Interactive TUI setup wizard — first prompt picks Local vs Connected
├── streaming/ # HLS pipeline
│ ├── hls_generator.rs # FFmpeg subprocess per camera (HLS muxer)
│ ├── supervisor.rs # Per-camera FFmpeg supervisor with backoff + stall watchdog
│ ├── hls_uploader.rs # Watches HLS dir, hands segments to SegmentUploader, updates playlist
│ ├── segment_uploader.rs# Posts each .ts to POST /push-segment with retry (Local: no-op)
│ ├── motion_detector.rs # Parallel FFmpeg scene-change scorer
│ └── codec_detector.rs # FFprobe-based codec detection
└── storage/ # SQLite-backed local storage (BLOBs + config)
└── database.rs # NodeDatabase — snapshots, recordings, config, logs, local_recording_state
web/ # Browser dashboard SPA — Vite + React 18 + TypeScript
├── src/ # Source: pages (Cameras, Snapshots, Recordings),
│ # components (HlsPlayer), lib (typed /api/* fetch wrappers)
└── package.json # Pinned: react 18, vite 5, hls.js 1.5, react-router-dom 6
# Builds to ../web-dist/ which rust-embed picks up at compile time
build.rs # Pre-build hook — writes a placeholder web-dist/index.html if the
# dir is empty, so cargo build succeeds before npm run build runs
# Raspberry Pi (ARM64)
rustup target add aarch64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnuLinux
Camera devices appear at /dev/video*. Add your user to the video group:
sudo usermod -a -G video $USER
# Log out and back inInstall FFmpeg:
sudo apt install ffmpeg # Ubuntu / Debian
sudo dnf install ffmpeg # Fedora
sudo pacman -S ffmpeg # ArchRaspberry Pi (4 / 5, 64-bit)
CloudNode runs on 64-bit Raspberry Pi OS. Build from source (the prebuilt ARM64 Docker image also works, but a native build skips the container USB-passthrough setup):
sudo apt install -y build-essential pkg-config libssl-dev ffmpeg
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
git clone https://github.com/SourceBox-LLC/Sentinel-CameraNode.git
cd Sentinel-CameraNode
cargo build --release
./target/release/sourcebox-sentry-cloudnode setupThe first cargo build --release on a Pi 4 takes 15–20 minutes. Subsequent incremental builds after git pull are 1–3 minutes.
Software encoding only. CloudNode deliberately does not use the Pi's h264_v4l2m2m hardware encoder — it produces a malformed SPS that the browser's Media Source Extensions layer rejects (video never appears, even though FFmpeg reports success). The node encodes with libx264 -preset ultrafast instead, which sustains 1080p30 at about 1.5 cores per camera on a Pi 4. A Pi 4 comfortably runs 2 cameras at 1080p30; a Pi 5 runs 3–4.
USB cameras. Plug webcams directly into the Pi's USB ports rather than through a hub when possible — unpowered hubs frequently brown out under the combined draw of two UVC cameras streaming at 1080p, and a hub fault can wedge the entire xhci controller until reboot. Use the blue USB 3.0 ports (top pair) for more power budget even if the camera only needs USB 2.0 bandwidth.
Thermal. Two simultaneous libx264 streams will push a bare Pi 4 past 80 °C and trip the kernel's thermal throttle, dropping frame rate. A heatsink and fan are a small upgrade that make this non-issue. Check with vcgencmd measure_temp and vcgencmd get_throttled (anything other than throttled=0x0 indicates a power or thermal event).
Windows
CloudNode runs natively on Windows using DirectShow. FFmpeg is not bundled — the setup wizard checks PATH and offers to run winget install Gyan.FFmpeg if it isn't already installed.
Camera names (e.g. MEE USB Camera, Integrated Webcam) are detected via DirectShow enumeration.
WSL2 deployment (optional): The setup wizard on Windows can also deploy inside WSL2 (useful for Linux-native builds and tighter V4L2 integration). The wizard's WSL preflight detects whether WSL is installed, finds a usable distro, installs FFmpeg inside it, and prints the usbipd bind / usbipd attach --wsl commands you need to run in an admin PowerShell to forward USB cameras from the host into the distro. Elevation-required steps (installing WSL, installing usbipd-win, usbipd bind) are printed for the operator to run rather than executed on their behalf.
macOS
Install FFmpeg via Homebrew:
brew install ffmpegYou may need to grant camera access in System Settings > Privacy & Security > Camera.
For the full end-to-end "live video isn't showing up in the dashboard" workflow, see docs/runbooks/video-not-showing.md. The most common causes are also captured below.
No cameras detected
Linux: Verify device exists and permissions are correct:
ls -l /dev/video*
# Should show crw-rw---- with group 'video'Add your user to the video group if needed:
sudo usermod -a -G video $USERWindows: Ensure the camera is not in use by another application (Zoom, Teams, etc.).
FFmpeg not found
Windows: Install FFmpeg via winget install Gyan.FFmpeg (or download from gyan.dev and add to PATH), then re-run sourcebox-sentry-cloudnode setup. CloudNode looks for FFmpeg on PATH only.
Linux / macOS: Install FFmpeg using your package manager (see Platform Notes).
HLS stream not playing
- Verify the node is running:
curl http://localhost:8080/health - Check the dashboard for FFmpeg errors
- Confirm HLS files are being created in
data/hls/ - Watch the dashboard log for
Pushed segment …lines — those mean segments are reaching the backend - Try
/export-logsfrom the settings page for detailed diagnostics
Cannot connect to Command Center
- Verify your API URL:
curl https://your-backend.example.com/api/health - Open
/settingsin the dashboard to confirm Node ID and API URL - Use
/reauth confirmfrom settings to re-enter credentials
Motion events not firing
- Check that
motion.enabledistrue(default) - Lower
motion.threshold(default0.02) if the scene is dim / low-contrast - The dashboard logs
Motion detected on <camera> (score N%)when an event fires
Docker container can't access camera
Pass each camera device explicitly:
docker run --device /dev/video0:/dev/video0 ...FFmpeg exits with status 234 in a restart loop (encoder open failure)
The dashboard log shows repeated FFmpeg exited with exit status: 234 and messages like Could not open encoder before EOF or Error parsing option '...' with value '...'. This means the selected encoder can't be initialized with the current argument set.
- Look for the line
Selected encoder: <name>orUsing software encoder (configured)in the startup log to see which encoder was picked. - If the encoder is a hardware codec (
h264_nvenc,h264_qsv,h264_amf) and the driver on this machine is broken, override with software: setSOURCEBOX_SENTRY_ENCODER=libx264or change it via the setup wizard. - On CloudNode ≥ v0.1.14 the
h264_v4l2m2mPi codec is automatically retired — a stale DB entry naming it is cleared on next launch (look forRetired encoder '…' in config — clearing for re-detection). If you're on an older node, run/wipefrom the dashboard's settings page to clear the cached encoder and let auto-detect re-run. - If libx264 itself crashes with
Error parsing option 'level' with value 'auto', upgrade — that was a bug in the libx264 branch ofbuild_encoding_argsfixed in v0.1.15.
Raspberry Pi: cameras drop off the USB bus under load
Symptoms: cameras appear at startup then disappear after minutes/hours; lsusb shows only root hubs; dmesg shows usb usb1-port1: disabled by hub (EMI?) or device descriptor read/64, error -110 or xhci_hcd ... Setup ERROR.
This is almost always a USB hub fault or power issue, not a software problem.
- Reboot (
sudo reboot). The xhci controller can wedge in a state hot-replugging doesn't recover; only a kernel restart clears it. - Plug cameras directly into the Pi's ports — remove any external hub, splitter, or extension cable from the path. Unpowered hubs routinely brown out under two 1080p UVC cameras.
- Check power —
vcgencmd get_throttledmust return0x0. Any non-zero value means under-voltage or over-current events have happened; use the official 5V/3A USB-C supply. - Try USB 3.0 (blue) ports — more power budget than USB 2.0 (black) even for USB 2.0 devices.
- Verify post-reboot —
lsusbshould show your camera's VID:PID (e.g.1bcf:2283 Sunplus ... MEE USB Camera), and/dev/video0//dev/video2should exist.
If a full power cycle, direct-to-Pi connection, and official PSU all fail, the camera itself is the most likely cause — test each camera alone on the Pi before replacing hubs.
Video plays in the dashboard but browser shows a black frame
The CloudNode dashboard's STREAMING status and the ↑ segs counter only prove that segments are being produced and pushed to the backend — not that they're decodable by the browser's MSE (Media Source Extensions) layer.
- In the Command Center dashboard, check the camera card's codec badge. A valid line like
avc1.42e01f(Baseline, Level 3.1) oravc1.4d401e(Main, Level 3.0) means the SPS is parseable. A missing codec badge oravc1.000000means the encoder produced a malformed bitstream. - On the node, run
ffprobeagainst a recent segment:Valid output shows a recognizablels -t data/hls/*/segment_*.ts | head -1 | xargs ffprobe 2>&1 | head -20
Profile(Baseline / Main / High) and a positiveLevel(e.g.Level: 31). If you seeProfile: unknownorLevel: -99, the encoder is producing garbage — forcelibx264as described in the encoder-crash entry above.
Licensed under the GNU General Public License v3.0.
CloudNode uses GPL-3.0 to ensure users can always inspect, modify, and verify what runs on their cameras. For commercial licensing, contact SourceBox LLC.
Sentinel Command Center · Made by the Sentinel Team