Zoneminder / ReoLink PTZ Preset Integration

Links within this blog post contain affiliate links. If you purchase items using these links, trenchesofit may earn a commission. Thank you.

Zoneminder is an open-source surveillance solution that allows recording, monitoring, and analyzing your security cameras. I have been using Zoneminder personally for many years. I recently purchased a PTZ Reolink camera and wanted to integrate control of the camera from the watch page within Zoneminder.

Take me straight to the code: https://github.com/trenchesofit/zoneminder-reolink-plugin

PTZ Camera:

My Reolink camera is the RLC-823A POE IP 4k version. https://amzn.to/3jvwvJ4 (Affiliate link)

Before I begin, I should mention I will use insecure coding practices throughout this blog. My camera environment is housed within a monitored network with layers of security. I coded this solution with my specific threat model in mind.

Getting Started

To start I found some excellent documentation on the Reolink API located here. The first step was to request a token using a POST request containing the username and password for the camera. This response will contain the needed authentication token for all the future calls to the API.

I started building the needed requests using BurpSuite and saving them in Repeater.

Example token POST request:

POST /cgi-bin/api.cgi?cmd=Login HTTP/1.1
Host: 10.0.10.103
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
X-Requested-With: XMLHttpRequest
Origin: http://10.0.10.103
Referer: http://10.0.10.103/
Connection: close
Content-Type: application/json
Content-Length: 149

[
  {
    "cmd":"Login",
    "param":{
      "User":{             
        "userName":"admin", 
        "password":""
      }
    }
  }
]

Example token POST response:

HTTP/1.1 200 OK
Server: nginx/1.6.2
Date: Wed, 01 Feb 2023 23:32:27 GMT
Content-Type: text/html
Connection: close
Content-Length: 184

[
   {
      "cmd" : "Login",
      "code" : 0,
      "value" : {
         "Token" : {
            "leaseTime" : 3600,
            "name" : "adceedcae4d2415"
         }
      }
   }
]

Now that I had a token, I needed to get a list of the preset PTZ positions configured on the target camera. This request included a URL containing the received token along with the ptzCtrl command in the body.

Example PTZ list request:

POST /cgi-bin/api.cgi?cmd=GetTime&token=64c5a4d810d029c HTTP/1.1
Host: 10.0.10.103
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
X-Requested-With: XMLHttpRequest
Origin: http://10.0.10.103
Referer: http://10.0.10.103/
Connection: close
Content-Type: application/json
Content-Length: 60

[
  {
"action": 1,
 "cmd": "GetPtzPreset",
 "param":{
  "channel":0
  }
 }
]

Example PTZ list response:

HTTP/1.1 200 OK
Server: nginx/1.6.2
Date: Thu, 02 Feb 2023 01:10:44 GMT
Content-Type: text/html
Connection: close
Content-Length: 9749

[
   {
      "cmd" : "GetPtzPreset",
      "code" : 0,
      "range" : {
         "PtzPreset" : {
            "channel" : 0,
            "enable" : "boolean",
            "id" : {
               "max" : 64,
               "min" : 1
            },
            "name" : {
               "maxLen" : 31
            }
         }
      },
      "value" : {
         "PtzPreset" : [
            {
               "channel" : 0,
               "enable" : 1,
               "id" : 1,
               "name" : "Driveway"
            },
            {
               "channel" : 0,
               "enable" : 1,
               "id" : 2,
               "name" : "DogLot"
            },
            {
               "channel" : 0,
               "enable" : 1,
               "id" : 3,
               "name" : "Zoomed Driveway"
            },
            {
               "channel" : 0,
               "enable" : 1,
               "id" : 4,
               "name" : "other one"
            },
            {{--Additional Lines Truncated for Brevity--}}

Next, I took the values from the list PTZ preset response and applied them to the PtzCtrl command to set the camera to the provided preset.

Example PTZ preset request:

POST /cgi-bin/api.cgi?cmd=GetTime&token=64c5a4d810d029c HTTP/1.1
Host: 10.0.10.103
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
X-Requested-With: XMLHttpRequest
Origin: http://10.0.10.103
Referer: http://10.0.10.103/
Connection: close
Content-Type: application/json
Content-Length: 73

[
 {
  "cmd": "PtzCtrl",
  "param":{
    "channel":0,
    "op":"ToPos",
    "id":1,
    "speed":32
  }
 }
]

Example PTZ preset response:

HTTP/1.1 200 OK
Server: nginx/1.6.2
Date: Thu, 02 Feb 2023 01:06:00 GMT
Content-Type: text/html
Connection: close
Content-Length: 108

[
   {
      "cmd" : "PtzCtrl",
      "code" : 0,
      "value" : {
         "rspCode" : 200
      }
   }
]

We now had all the requests set up as needed. I installed a Burp extension called “Copy As Python Request”. This allowed me to quickly set up a PoC to chain the authentication requests followed by the list and control actions.

BurpSuite Repeater Tabs

Once “Copy As Python-Requests” is installed, you can just right-click the request you would like to code and select the extension.

BurpSuite “Python-Requests” Extension

The first PoC fetches the token by passing the “Login” command with the camera username and password. The token is then passed to the following commands to grab the list of PTZ preset values and call the preset.

Early PoC:

import requests
import json

burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=Login&token=null"
burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_json=[{"action": 0, "cmd": "Login", "param": {"User": {"password": "", "userName": "admin"}}}]
response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json)
print(requests.post)
print(response.text)
#print(response.text)
responseJson = json.loads(response.text)
#parse token from response
token = (responseJson[0]['value']['Token']['name'])

#gets possible ptz preset positions
burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=GetTime&token="+token
burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_json=[{"action": 1, "cmd": "GetPtzPreset","param":{"channel":0}}]
response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json)

#selects the ptz id
burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=GetTime&token="+token
burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_json=[{"cmd": "PtzCtrl","param":{"channel":0,"op":"ToPos","id":1,"speed":32}}]
response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json)
print(response.text)

Now that we had a working PoC, we needed to configure the buttons within Zoneminder to call the script. The page I added this code to was the “watch.php” page located in “/usr/share/zoneminder/www/skins/classic/views/”. For this, I used the following code.

 <form method='post'>
 <input type='submit' value='Zoomed Driveway' name='GO3'>
 <?php
 if(isset($_POST['GO3']))
 {
shell_exec('python3 ZoomedDriveway.py');
 echo'success';
 }
 ?>

I added this code after the buttons above the streaming view. This started on line 71.

This gave me a final look at what the integration will look like.

Final Script

The final script iterates over the configuration file for each preset configured in each camera. A button is created along with a python file to take action for that preset. Suppose a new preset is added to the camera configuration. In that case, the user needs to re-run the script to have the older code removed and the new code put in place.

import requests
import json
import logging
import configparser
import re
import fileinput
import shutil
import datetime
import sys

# noinspection PyArgumentList
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.DEBUG,
                    handlers=[logging.FileHandler("zoneminder-reolink-plugin.log"), logging.StreamHandler()])

# Configuration file read
config = configparser.ConfigParser()
config.read('secrets.cfg')


def generate_code(camera):
    # Pull values from configuration file
    try:
        ip_address = config[camera]['ip']
        port = config[camera]['port']
        username = config[camera]['username']
        password = config[camera]['password']
    except Exception as config_error:
        logging.error(config_error)
        sys.exit(0)
    try:
        # Grabs the needed authentication token
        login_url = f"http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=Login&token=null"
        headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0;"
                   "Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36",
                   "Content-Type": "application/json", "Origin": f"http://{ip_address}", "Referer":
                       f"http://{ip_address}/", "Accept-Encoding": "gzip, deflate", "Accept-Language":
                       "en-US,en;q=0.9", "Connection": "close"}
        login_json = [{"action": 0, "cmd": "Login", "param": {"User": {"password": f"{password}",
                       "userName": f"{username}"}}}]
        response = requests.post(login_url, headers=headers, json=login_json)
        login_response_json = json.loads(response.text)
        token = (login_response_json[0]['value']['Token']['name'])
        # Gets possible ptz preset positions
        preset_url = f"http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=GetTime&token={token}"
        preset_json = [{"action": 1, "cmd": "GetPtzPreset", "param": {"channel": 0}}]
        response = requests.post(preset_url, headers=headers, json=preset_json)
        preset_response_json = json.loads(response.text)
        preset_data = (preset_response_json[0]['value']['PtzPreset'])
        # Iterates through preset PTZ configuration to create PHP code
        for preset in preset_data:
            if preset['enable'] == 1:
                # Removes spaces in preset names
                safe_name = preset['name'].replace(" ", "")
                # Generates PHP code to add buttons to watch.php file
                php_code = f" <form method='post'>\n <input type='submit' value='" + preset['name'] + "' name='GO" + \
                           str(preset['id']) + "'>\n <?php \n if(isset($_POST['GO" + str(preset['id']) + "']))\n { \n"\
                           "shell_exec('python3 " + safe_name + ".py'); \n echo'success'; \n } \n ?>\n" \
                                                                "<!-- End of camera configuration.-->"
                comment = f"<!-- This configuration is for reolink integration for camera " \
                          f"{preset['name']} on preset {preset}-->"
                # Generates Python code to create action files
                python_code = f'import requests\nimport json\nusername = "{username}"\npassword = "{password}"\n' \
                              f'burp0_url = "http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=Login&token=' \
                              'null"\nburp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest",' \
                              '"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, ' \
                              'like Gecko) Chrome/109.0.5414.75 Safari/537.36","Content-Type": "application/json", ' \
                              f'"Origin": "http://{ip_address}","Referer": "http://{ip_address}/", "Accept-Encoding": '\
                              '"gzip, deflate","Accept-Language": "en-US,en;q=0.9", "Connection": "close"}\n' \
                              'burp0_json = [{"action": 0, "cmd": "Login", "param": {"User": {"password": ""' \
                              '+password+"", "userName": ""+username+""}}}]\nresponse = requests.post(burp0_url, ' \
                              'headers=burp0_headers, json=burp0_json)\nresponseJson = json.loads(response.text)\n' \
                              'token = (responseJson[0]["value"]["Token"]["name"])\n' \
                              f'burp0_url = "http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=GetTime&token="+token \n' \
                              'burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", \n ' \
                              '"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' \
                              '(KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", \n "Content-Type": ' \
                              f'"application/json", "Origin": "http://{ip_address}", \n "Referer": "http://' \
                              f'{ip_address}/", "Accept-Encoding": "gzip, deflate", \n "Accept-Language": ' \
                              '"en-US,en;q=0.9", "Connection": "close"}\nburp0_json = [{"cmd": "PtzCtrl", "param": ' \
                              '{"channel": 0, "op": "ToPos", "id": ' + str(preset['id']) + ', "speed": 32}}] \n' \
                              'response = requests.post (burp0_url, headers=burp0_headers, json=burp0_json)'

                code_write(php_code, comment, python_code, safe_name)
        logging.info("Success! Camera presets should now be visible on the camera stream within Zoneminder.")
    except Exception as error:
        logging.error(error)


# Backup watch file before altering, save with date and time
def backup_watch():
    logging.info("Backing up the target watch.php file.")
    try:
        target_file = "/usr/share/zoneminder/www/skins/classic/views/watch.php"
        now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
        backup_file = f"/usr/share/zoneminder/www/skins/classic/views/watch.{now}.php.bak"
        shutil.copy(target_file, backup_file)
        logging.info("Backup successful")
    except IOError as e:
        logging.error(e)


# Check watch.php for previous configurations pushed to file
def code_check():
    try:
        for i, line in enumerate(open('/usr/share/zoneminder/www/skins/classic/views/watch.php')):
            for match in re.finditer("This configuration is for reolink integration for camera", line):
                logging.info(f"Previous configuration detected on line {i+1}: {match.group()}")
                return True
    except IOError as e:
        logging.error(e)


# Remove previous config / leaves last comment
def remove_previous_config():
    beginning_comment = "<!-- This configuration is for reolink"
    ending_comment = "<!-- End of camera configuration.-->"
    delete_line = False
    logging.info(f"Removing previous configuration.")
    try:
        for line in fileinput.input('/usr/share/zoneminder/www/skins/classic/views/watch.php', inplace=True):
            if beginning_comment in line:
                delete_line = True
            elif ending_comment in line:
                delete_line = False
                print(line, end='')
            elif not delete_line:
                print(line, end='')
    except IOError as e:
        logging.error(e)


# Writes code to the watch.php page and creates the needed python file action
def code_write(php_code, comment, python_code, safe_name):
    if php_code:
        try:
            with open('/usr/share/zoneminder/www/skins/classic/views/watch.php', "r+") as watch_ui:
                file_data = watch_ui.read()
                text_pattern = re.compile(re.escape("fa-exclamation-circle\"></i></button>"), flags=0)
                file_contents = text_pattern.sub(f"fa-exclamation-circle\"></i></button>\n{comment}\n{php_code}",
                                                 file_data)
                watch_ui.seek(0)
                watch_ui.truncate()
                watch_ui.write(file_contents)
        except IOError as e:
            logging.error(f"Error writing PHP code. {e}")
    if python_code:
        try:
            with open('/usr/share/zoneminder/www/'+safe_name+'.py', 'w') as python_file:
                python_file.write(python_code)
                python_file.close()
        except IOError as e:
            logging.error(f"Error writing Python code. {e}")


def main():
    boolean_response = code_check()
    backup_watch()
    if boolean_response is None:
        for camera in config.sections():
            generate_code(camera)
    else:
        remove_previous_config()
        for camera in config.sections():
            generate_code(camera)
    return 0


if __name__ == '__main__':
    exit_code = main()
    exit(exit_code)

Setup

The configuration file needs to be manually created within the “/usr/share/zoneminder/www/” directory. This will need to be updated to contain your specific configuration per camera.

[camera1]
ip=10.0.10.103
port=80
username=admin
password=
[camera2]
ip=10.0.10.104
port=80
username=testusername
password=testpassword

Grab the script and place it in your “/usr/share/zoneminder/www/” directory. Depending on your specific configuration you may need to run the script with sudo.

trenchesofit@zoneminder:/usr/share/zoneminder/www$ sudo python3 zoneminder-reolink-plugin.py
2023-02-05 11:43:35,721 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera
2023-02-05 11:43:35,722 - root - INFO - Backing up the target watch.php file.
2023-02-05 11:43:35,723 - root - INFO - Backup successful
2023-02-05 11:43:35,723 - root - INFO - Removing previous configuration.
2023-02-05 11:43:35,730 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:43:35,745 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None
2023-02-05 11:43:35,749 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:43:35,821 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=b4c1965a54516bf HTTP/1.1" 200 None
2023-02-05 11:43:35,845 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder.

Backup

A backup of the original “watch.php” file is created before writing in the event it botches something on the way.

Logging

Logs are stored in a created file named “zoneminder-reolink-plugin.log”

trenchesofit@zoneminder:/usr/share/zoneminder/www$ cat zoneminder-reolink-plugin.log
2023-02-05 11:29:28,839 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera
2023-02-05 11:29:28,839 - root - INFO - Removing previous configuration.
2023-02-05 11:29:28,846 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:29:28,865 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None
2023-02-05 11:29:28,868 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:29:28,973 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=51236b93c14eaaa HTTP/1.1" 200 None
2023-02-05 11:29:29,156 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder.
2023-02-05 11:43:35,721 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera
2023-02-05 11:43:35,722 - root - INFO - Backing up the target watch.php file.
2023-02-05 11:43:35,723 - root - INFO - Backup successful
2023-02-05 11:43:35,723 - root - INFO - Removing previous configuration.
2023-02-05 11:43:35,730 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:43:35,745 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None
2023-02-05 11:43:35,749 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80
2023-02-05 11:43:35,821 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=b4c1965a54516bf HTTP/1.1" 200 None
2023-02-05 11:43:35,845 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder.

Conclusion

This small project was a nice change of pace from pen-testing and allowed me to build something new. We now have a nice scalable Reolink PTZ preset integration into Zoneminder. I look forward to adding more features and automation to the plugin as time allows. Try out the code for yourself and let me know what you think. I have added the project to GitHub here.

Until next time, stay safe in the Trenches of IT!