Skip to main content

CCTV Implementation

This document covers how we implemented the CCTV system at DizHaus, including the challenges, solutions, and lessons learned.


System Architecture

┌─────────────────────────────────────────────────────────────────┐
│ Internet │
│ │ │
│ Fixed WAN IP: 211.27.58.145 │
│ │ │
│ ┌──────▼──────┐ │
│ │ Router │ │
│ │ Port Fwd │ │
│ └──────┬──────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Lucas │ │ Bazzite │ │ NVR │ │
│ │ Windows │ │ Linux │ │ Swann │ │
│ │ .77.6 │ │ .77.11 │ │ .77.9 │ │
│ │ │ │ │ │ │ │
│ │ nginx │ │ OME │ │ 16 Cams │ │
│ │ backend │ │ Capture │ │ RTSP │ │
│ │ frontend │ │ HTTP │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘

Components

HostIPRole
Lucas192.168.77.6nginx reverse proxy, Express backend, admin frontend
Bazzite192.168.77.11OvenMediaEngine (Docker), capture service, replay HTTP server
NVR192.168.77.9Swann NVR with 16 cameras, RTSP source

Port Forwarding (Router)

External PortInternalPurpose
443192.168.77.6:443HTTPS (nginx)
3333192.168.77.11:3333OME WebSocket signaling
3478192.168.77.11:3478OME TURN relay

OvenMediaEngine (OME) Setup

Why OME?

  • Converts RTSP to WebRTC for browser playback
  • No browser plugins required
  • Low latency (~1-2 seconds)

Configuration: Server.xml

Location: /home/brettwatson/ome/origin_conf/Server.xml

Key sections:

TCP-only ICE (UDP didn't work through NAT):

<IceCandidates>
<!-- UDP disabled - not working through NAT -->
<!-- <IceCandidate>${env:OME_HOST_IP:*}:10000-10009/udp</IceCandidate> -->
<TcpRelay>${env:OME_HOST_IP:*}:3478</TcpRelay>
<TcpForce>true</TcpForce>
</IceCandidates>

RTSP Pull Origins (one per camera):

<Origin>
<Location>/DizHaus/Cam01</Location>
<Pass>
<Scheme>rtsp</Scheme>
<Urls><Url>admin:77Dizzle!@192.168.77.9:554/ip1/0</Url></Urls>
</Pass>
</Origin>

RTSPPull Provider:

<Providers>
<RTSPPull />
</Providers>

Docker Run Command

docker run -d --name ome --restart unless-stopped --network host \
-e OME_HOST_IP=211.27.58.145 \
-v /home/brettwatson/ome/origin_conf:/opt/ovenmediaengine/bin/origin_conf:Z \
-v /home/brettwatson/ome/media:/opt/ovenmediaengine/media:Z \
airensoft/ovenmediaengine:latest

Critical: OME_HOST_IP must be the PUBLIC IP for ICE candidates to work externally.

Challenges & Solutions

UDP ICE Failures

Problem: Browser WebRTC couldn't connect via UDP ports 10000-10009. Root cause: NAT/firewall issues with UDP hole punching. Solution: Disable UDP entirely, force TCP relay on port 3478.

H.265 Codec

Problem: OME can't transcode H.265 in bypass mode. Solution: Reconfigure cameras to use H.264.

Password Special Characters

Problem: URL-encoded %21 for ! caused 401 errors. Solution: Use raw ! character in Server.xml (not URL-encoded).


Replay Capture System

The DDOS Problem

What happened: We implemented frame capture (ffmpeg pulling RTSP → saving JPGs) alongside OME streaming. With 14 cameras at 2-3 second intervals, we were making ~30 RTSP connection attempts per minute to the NVR.

Result: The NVR got overwhelmed:

  • OME streams would connect then immediately drop
  • "Could not request Stop to RTSP server" errors
  • Epoll errors (8193, 8201, 8217)
  • Reconnect loops hammered the NVR further

Key insight: Each ffmpeg capture opens a NEW RTSP connection (TCP handshake, auth, grab frame, close). The NVR has limited concurrent session capacity.

Solution: Staggered Capture with Backoff

Reduced frequency:

  • Important cameras (entry, driveway): 4 seconds
  • Medium cameras (indoor): 8-16 seconds
  • Low priority cameras: 20+ seconds

Staggered startup: 2-second delay between each camera starting.

Failure handling: Backoff 30 seconds after failure, auto-disable after 5 consecutive failures.

Capture Script: /mnt/cctv-replay/capture.sh

Key features:

  • Error logging to separate .err files per camera
  • Failure counting with auto-disable
  • Staggered startup to spread NVR load
  • Status command shows running cameras and recent errors

Configuration: /mnt/cctv-replay/config/capture-config.json

{
"cameras": {
"Cam01": { "name": "Drive South", "interval": 4, "retention": 3, "enabled": true, "rtsp": "rtsp://admin:77Dizzle!@192.168.77.9:554/ip1/0" },
...
},
"settings": {
"storagePath": "/mnt/cctv-replay/storage",
"thumbWidth": 320,
"thumbHeight": 180
}
}

Storage Structure

/mnt/cctv-replay/
├── capture.sh
├── cleanup.sh
├── doCAMS.sh
├── stopCAMS.sh
├── config/
│ ├── capture-config.json
│ ├── capture.log
│ ├── pids/
│ └── errors/
└── storage/
└── DizHaus/
├── Cam01/
│ └── 2025-12-14/
│ ├── 143052.jpg (full res)
│ └── 143052_t.jpg (thumbnail)
├── Cam02/
...

Systemd Services

cctv-capture.service:

[Unit]
Description=CCTV Replay Capture Service
After=network.target

[Service]
Type=forking
User=brettwatson
ExecStart=/bin/bash /mnt/cctv-replay/capture.sh start
ExecStop=/bin/bash /mnt/cctv-replay/capture.sh stop
RemainAfterExit=yes
TimeoutStartSec=180

[Install]
WantedBy=multi-user.target

cctv-http.service (Python HTTP server on port 8088):

[Service]
Type=simple
User=brettwatson
WorkingDirectory=/mnt/cctv-replay/storage
ExecStart=/usr/bin/python3 -m http.server 8088 --bind 0.0.0.0

cctv-cleanup.timer (daily at 3am):

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

nginx Configuration

WebSocket Proxy for OME

# In admin.codexdiz.com server block
location /ws-ome/ {
proxy_pass http://192.168.77.11:3333/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}

API Routes Priority

# ^~ ensures API routes take priority over static file regex
location ^~ /api/ {
proxy_pass http://192.168.77.6:3009;
...
}

Why ^~: Without it, requests like /api/v1/cctv/replay/.../image.jpg were caught by the static file caching rule and served as text/html.


Frontend Implementation

OvenPlayer Integration

CDN script in HTML:

<script src="https://cdn.jsdelivr.net/npm/ovenplayer/dist/ovenplayer.js"></script>

Player creation:

const player = OvenPlayer.create('player-div', {
sources: [{
type: 'webrtc',
file: 'wss://admin.codexdiz.com/ws-ome/DizHaus/Cam01'
}]
});

Replay API Endpoints

EndpointPurpose
GET /api/v1/cctv/replay/:site/:camera/datesList available date folders
GET /api/v1/cctv/replay/:site/:camera/:date/framesList frames for a date
GET /api/v1/cctv/replay/:site/:camera/:date/:filenameProxy image with auth

Auth for Images

<img src> can't send Authorization headers. Solution: fetch with headers, convert to blob URL:

const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const blob = await response.blob();
img.src = URL.createObjectURL(blob);

Management Scripts

doCAMS.sh

Starts all CCTV services (OME, capture, HTTP server, cleanup timer). Safe to run repeatedly.

stopCAMS.sh

Stops all CCTV services gracefully.

Location

Both scripts in /mnt/cctv-replay/ on bazzite.


Troubleshooting Reference

OME won't start

docker logs ome | head -50

Check for Server.xml syntax errors.

Streams connect then drop

  • NVR overloaded - reduce capture frequency
  • Check for "Could not request Stop" errors
  • Power cycle NVR to clear zombie sessions

Capture not saving images

/mnt/cctv-replay/capture.sh status
cat /mnt/cctv-replay/config/errors/Cam01.err

"Invalid parse status: 400" in OME logs

Internet scanners probing your ports. Harmless noise.

ICE timeout

  • Verify OME_HOST_IP is public IP
  • Check TCP 3333 and 3478 port forwarding
  • Confirm <TcpForce>true</TcpForce> in Server.xml

Lessons Learned

  1. TCP > UDP for WebRTC through NAT - UDP hole punching is unreliable. Force TCP relay.

  2. NVR session limits are real - Consumer NVRs can't handle 30+ concurrent RTSP connections. Stagger and rate-limit.

  3. Special characters in RTSP URLs - Don't URL-encode in Server.xml. Use raw characters.

  4. H.264 mandatory - OME bypass mode doesn't transcode. Cameras must output H.264.

  5. Systemd timeout for slow starts - Staggered startup takes time. Increase TimeoutStartSec.

  6. Clean restarts matter - When things go wrong, power cycle NVR AND restart OME for clean slate.


Last updated: December 2025