-
-
Save velzend/895c18d533b3992f3a0cc128f27c0894 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3 | |
""" | |
I wanted to replace the self-signed certificate with one signed by Let's Encrypt, and | |
did not want to perform this action manually. | |
I asked Reolink technical support, but they answered there is no API to manage certificates. | |
So I did some research and found the Reolink doorbell camera and probably other models | |
do support to upload your own certificates. | |
In developer tools I checked the API and found the annoying client side AES encryption | |
implementation where the key is generated/ rotated in the script during login. | |
To reveal the key and iv, I simply set a breakpoint on the login logic and have the `e` and `t` values. | |
To decrypt the JSON payload I wrote the script below: | |
-------------------------------------------------------------------------------- | |
#!/usr/bin/env python3 | |
from crypto.Cipher import AES | |
import base64 | |
#e = "B642D317BD521D58", t = "0D6A4261FCD46185" | |
key = b"B642D317BD521D58" | |
iv = b"0D6A4261FCD46185" | |
encrypted_payload = "Plha/2eKtaMXwqNXZAlawvZB88qw3KdkRpLrMRol2nh1EmKPQJN****" | |
decipher = AES.new(key, AES.MODE_CFB, IV=iv, segment_size=128) | |
decrypted_payload = decipher.decrypt(base64.b64decode(encrypted_payload)) | |
print(decrypted_payload.decode("UTF-8")) | |
-------------------------------------------------------------------------------- | |
Now I know the endpoints and payload of API requests. | |
Important notes: | |
The webservice in the doorbell camera only supports RSA certificates and not EC (Elliptic Curve, ec256 for example). | |
If you use this script the certificate and key filenames are hardcoded to `server.crt` and `server.key`, | |
but during testing I found using filenames that contains the FQDN the API fails. | |
Next, you need a certificate and key. | |
I use Cloudflare as DNS provider, and prefer Lego as ACME client. | |
To request the certificate from Let's Encrypt I used the Lego container image below: | |
docker run \ | |
-v "$(pwd)/.lego:/.lego" \ | |
-e "CF_DNS_API_TOKEN=***" \ | |
goacme/lego \ | |
--key-type="rsa4096" \ | |
--accept-tos \ | |
--email="***" \ | |
--domains="***" \ | |
--dns="cloudflare" \ | |
run | |
Next step is to update some values in the script below, schedule this | |
script to run periodically after the docker run above and | |
enjoy automatic certificate updates/ rotation. | |
""" | |
import requests | |
import os | |
import base64 | |
import time | |
import ssl | |
import sys | |
os.environ['no_proxy'] = '*' | |
base_url = "https://***" | |
username = "admin" | |
password = "****" | |
certificate_path = ".lego/***.crt" | |
key_path = ".lego/***.key" | |
class Reolink(object): | |
def __init__(self, **kwargs): | |
self.base_url = kwargs.pop('base_url', None) | |
self.username = kwargs.pop('username', None) | |
self.password = kwargs.pop('password', None) | |
self.token = None | |
def login(self): | |
login_req = [{"cmd":"Login", | |
"param": {"User": {"userName": self.username, | |
"password": self.password} | |
} | |
} | |
] | |
url = f'{self.base_url}/cgi-bin/api.cgi?cmd=Login' | |
login_resp = requests.post(url=url, json=login_req, verify=False) | |
login_data = login_resp.json() | |
self.token = login_data[0]['value']['Token']['name'] | |
print(f"Login was succesfull, got token: {self.token}") | |
return self.token | |
def verify_ssl_certificate(self): | |
try: | |
response = requests.get(self.base_url) | |
response.raise_for_status() | |
print(f"Certificate for {self.base_url} is valid.") | |
return True | |
except ssl.SSLCertVerificationError as err: | |
print(f"Certificate verification failed for {self.base_url}, error: {err}", file=sys.stderr) | |
return False | |
def clear_certs(self): | |
url = f"{self.base_url}/cgi-bin/api.cgi?cmd=CertificateClear&token={self.token}" | |
clear_req = [{ | |
"cmd": "CertificateClear", | |
"action": 0, | |
"param": {} | |
}] | |
clear_certs_resp = requests.post(url=url, json=clear_req, verify=False) | |
clear_certs_data = clear_certs_resp.json | |
return clear_certs_data | |
def update_certs(self, certificate_path, key_path): | |
crtfile_stats = os.stat(certificate_path) | |
crt_filesize = crtfile_stats.st_size | |
with open(certificate_path, "rb") as crt_file: | |
b64_crt = base64.b64encode(crt_file.read()) | |
keyfile_stats = os.stat(key_path) | |
key_filesize = keyfile_stats.st_size | |
with open(key_path, "rb") as key_file: | |
b64_key = base64.b64encode(key_file.read()) | |
cert_req = [{ | |
"cmd": "ImportCertificate", | |
"action": 0, | |
"param": { | |
"importCertificate": { | |
"crt": { | |
"size": crt_filesize, | |
"name": "server.crt", | |
"content": b64_crt.decode("UTF-8") | |
}, | |
"key": { | |
"size": key_filesize, | |
"name": "server.key", | |
"content": b64_key.decode("UTF-8") | |
} | |
} | |
} | |
} | |
] | |
url = f"{self.base_url}/cgi-bin/api.cgi?cmd=ImportCertificate&token={self.token}" | |
update_certs_resp = requests.post(url=url, json=cert_req, verify=False) | |
update_certs_data = update_certs_resp.json | |
return update_certs_data | |
def logout(self): | |
url = f"{self.base_url}/cgi-bin/api.cgi?cmd=Logout&token={self.token}" | |
logout_resp = requests.get(url=url, verify=False) | |
logout_data = logout_resp.json | |
print(f"Logout was succesfull, got response: {logout_data}") | |
return logout_data | |
def main(): | |
reolink = Reolink(base_url=base_url, | |
username=username, | |
password=password) | |
reolink.login() | |
reolink.clear_certs() | |
# the doorbell will restart the internal web daemon | |
time.sleep(5) | |
reolink.update_certs(certificate_path=certificate_path, | |
key_path=key_path) | |
# the doorbell will restart the internal web daemon | |
time.sleep(5) | |
reolink.logout() | |
if not reolink.verify_ssl_certificate(): | |
exit(1) | |
if __name__ == "__main__": | |
main() |
typo in line 189, should be
reolink.clear_certs()
Thanks, good finding... I updated certs using the script. So now I am not sure if it is really required to clear the certs if the API is used... In the GUI you first need to clear the certs, and re-login before it is possible to select and upload new certs...
Anyway, I have updated the script, and can test the script tomorrow. I also want to verify if clearing out the certs is required at all...
First, many thx for the script, I use it as basis for my own (added vars as params)
I am not sure if it is really required to clear the certs if the API is used...
I tried it with several ReoLink cams (E1 Outdoor, RLC410W, RLC420, RLC520 which all needs longer delays between the actions) and cert clear is required:
ImportCertificate
probably assumes an empty cert configuration, in any case imported certs cannot be overwritten, you have to execute CertificateClear
first.
Great thing.
Do you think it's possible to get in touch with the developers of opnsense firewall? There is an acme client which allows to automate such scripts for example to change certs in true nas, fritz box routers, promox servers and so. It would be really great, if they would merge your code into theirs
I found that I had to login again after the reolink.clear_certs()
and subsequent sleep
was executed. I simply added a reolink.login()
before reolink.update_certs()
In addition, the Let's Encrypt certs I am using seem to fail the SSL validation function, so I commented that out
I also disabled the warnings:
import urllib3
# I am choosing to disable the URL SSL cert warnings as the whole purpose of this script is
# to update or replace invalid certs
urllib3.disable_warnings()
to clean up the output a bit
Thanks for sharing your helpfull comments.
I found that I had to login again after the
reolink.clear_certs()
and subsequentsleep
was executed. I simply added areolink.login()
beforereolink.update_certs()
Thanks so much, I did the same and got things working.
No need to clear the certificate on my rlc-1212a and rlc-823a it updates like a charm.
Thank you all, for your comments and @velzend for your script and your time.
Thanks for this. Worked well for the RLC-820A with some of the modifications above:
- Adding a login before updating the cert, as clearing them logs the user out
- Increasing the sleep time. The camera sometimes will take up to 7/8s to cycle. I went with 10s and that seems to be sufficient in all cases (tested with a loop of 30 times with no failures)
I'm using the certs generated by acme.sh for Lets Encrypt certs: https://github.com/acmesh-official/acme.sh
Even though these are .pem, they work fine. I'm using the fullchain.pem and key.pem, which get passed as a .crt and .key in the POST request.
A local DNS record for a domain I own, but has no public facing records works great. Remember if you run a Pi-hole or similar, you will need to register the record there.
I wanted to come back here and say that for whatever reason reolink.verify_ssl_certificate()
could not re-connect to the cameras. I confirmed that it is the requests module having a problem. Commenting out the reolink.verify_ssl_certificate()
lines allows the script to run without issue and it does still update the cameras.
Like @Chouhada I also had to increase the timeout. I have a handful of PTZ cameras from reolink and so far this script works like a charm
@Chouhada Hi, can you share your script for the acme platform. Thanks!
I had one problem with an E1 Pro: the certificate is not activated until a reboot.
typo in line 189, should be