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
| Host | IP | Role |
|---|---|---|
| Lucas | 192.168.77.6 | nginx reverse proxy, Express backend, admin frontend |
| Bazzite | 192.168.77.11 | OvenMediaEngine (Docker), capture service, replay HTTP server |
| NVR | 192.168.77.9 | Swann NVR with 16 cameras, RTSP source |
Port Forwarding (Router)
| External Port | Internal | Purpose |
|---|---|---|
| 443 | 192.168.77.6:443 | HTTPS (nginx) |
| 3333 | 192.168.77.11:3333 | OME WebSocket signaling |
| 3478 | 192.168.77.11:3478 | OME 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
.errfiles 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
| Endpoint | Purpose |
|---|---|
GET /api/v1/cctv/replay/:site/:camera/dates | List available date folders |
GET /api/v1/cctv/replay/:site/:camera/:date/frames | List frames for a date |
GET /api/v1/cctv/replay/:site/:camera/:date/:filename | Proxy 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_IPis public IP - Check TCP 3333 and 3478 port forwarding
- Confirm
<TcpForce>true</TcpForce>in Server.xml
Lessons Learned
-
TCP > UDP for WebRTC through NAT - UDP hole punching is unreliable. Force TCP relay.
-
NVR session limits are real - Consumer NVRs can't handle 30+ concurrent RTSP connections. Stagger and rate-limit.
-
Special characters in RTSP URLs - Don't URL-encode in Server.xml. Use raw characters.
-
H.264 mandatory - OME bypass mode doesn't transcode. Cameras must output H.264.
-
Systemd timeout for slow starts - Staggered startup takes time. Increase
TimeoutStartSec. -
Clean restarts matter - When things go wrong, power cycle NVR AND restart OME for clean slate.
Last updated: December 2025