diff options
author | Erg <uinarf@autistici.org> | 2024-11-27 17:03:03 +0100 |
---|---|---|
committer | Erg <uinarf@autistici.org> | 2024-11-27 17:03:03 +0100 |
commit | f50222fc531eb700b9f4afa92d55ac424f2a499f (patch) | |
tree | 1b7b9b27321be75703355356274d298b92406378 | |
parent | 83c4d2e1b9213c78b0b472a1ed4484cf2590531f (diff) | |
download | MQTT_for_pie-f50222fc531eb700b9f4afa92d55ac424f2a499f.tar.gz MQTT_for_pie-f50222fc531eb700b9f4afa92d55ac424f2a499f.tar.bz2 MQTT_for_pie-f50222fc531eb700b9f4afa92d55ac424f2a499f.zip |
-rw-r--r-- | README.md | 25 | ||||
-rw-r--r-- | certificate_validity_check.py | 284 | ||||
-rw-r--r-- | settings.cfg.example | 11 |
3 files changed, 315 insertions, 5 deletions
@@ -1,7 +1,8 @@ -Contains two scripts: +Contains: - Bash script generating rrd database and generating graphs from it - Python script readding from MQTT and updating rrd database - Settings for the Python script + - Python script regenerating certificates within grace period of their expiry. DEPENDENCIES: - configparser @@ -18,6 +19,7 @@ Steps to get going: - Set up certificates (Check out Pico project for Bash script generating them) - Run Python script. - Set up cron job regenerating graphs with frequency to your liking. + - Set up SSL/TLS certificates with scripts provided. Certificate directory structure shall adhere to the schema below. (So that I don't forget:) What is still missing: @@ -25,3 +27,24 @@ What is still missing: - Python script could generate more graphs based on averages from rrd database showing long term trends. - Graph generation could be done with rrdcgi (not sure it is needed though) - Documentation is lacking detailed step by step setup guide. + - In order to prevent a rougue client from publishing to a certificate renewal channel, Access Controll List needs to be implemented. + - There should be a script generating ACL file. + - Before adding a certificate for a user ACL file shall be updated. + +Certificate directory structure: + +$ tree -d /etc/mosquitto/certs +├── CA +├── DH +├── clients +│ ├── onge +│ └── pico +├── csr_files +└── server + +The acl file shall be located at: +$ /etc/mosquitto/mosquitto.acl + +An entry shall be present in the acl file: + +$ pattern readwrite cert_reneval/%c/# diff --git a/certificate_validity_check.py b/certificate_validity_check.py new file mode 100644 index 0000000..4b864d6 --- /dev/null +++ b/certificate_validity_check.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python + +""" +Script checking certificate validity. +If valid for less than a week, regenerate and send MQTT message on a dedicated +channel containing new certificate. +""" + +import os +import sys +import subprocess +import logging +from datetime import datetime +from configparser import ConfigParser +import ssl +from paho.mqtt.client import Client as MQTTClient + +LOG_LEVELS = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, +} + +CONFIG_FILE = 'settings.cfg' + +TIMEFORMAT = "%b %d %H:%M:%S %Y %Z" + + +CERT_MESSAGE_FMT = 'certificate: %s' +KEY_MESSAGE_FMT = 'key: %s' + +# User certificate and key naming convention. %s is replaced by username: +# This only works for DER certificate format that Pico uses +CERT_FMT = '%s_crt.pem' +KEY_FMT = '%s_key.pem' + +SSL_SET = None +SSL_CA_CERT = None +SSL_CLIENTS_FOLDER = None +SSL_SERVER_CERT = None +SSL_SERVER_KEY = None +SSL_CLIENT_CERT = None +SSL_CLIENT_KEY = None +MQTT_SERVER = None +MQTT_PORT = None +MQTT_KEEPALIVE = None +MQTT_TOPIC_CERT_RENEWAL = None +MQTT_LOG_FILE = None +MQTT_LOG_LEVEL = None +MQTT_CLIENT_USERNAME = None +GRACE_PERIOD = None +SSL_SCRIPTS_DIR = None + +def check_cert_valid(username, grace_period, server=False): + """ + Check certificate valid for specified time. + Return: + 0 - valid for more than a <grace_period> + 1 - needs renewal + 2 - expired + """ + retval = -1 + expiry_date, delta, stdout = None, None, None + + cert_pathname, _ = get_cert_key_paths(username, server) + + if not cert_pathname: + logger.error("Did not find certificate files for user %s. Wrong certificate format?", username) + else: + logger.debug("Checking certificate for user %s", username) + logger.debug("Certificate path: %s ", cert_pathname) + openssl_cmd_check = ['openssl', 'x509', '-enddate', '-noout', '-in', cert_pathname] + with subprocess.Popen( + openssl_cmd_check, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as proc: + stdout, stderr = proc.communicate() + if stderr: + logger.warning('Encountered error checking certificate expiation date for user %s: %r', username, stderr) + if proc.returncode: + logger.error('openssl return code non-zero: %d', proc.returncode, exc_info=True) + if stdout: + logger.debug("Stdout from certificate check: %r", stdout) + try: + expiry_date = str(stdout).split('=')[1].strip("\\n'") + logger.debug('Certificate expires on : %r', expiry_date) + except IndexError: + logger.error('Invalid data from openssl: %r', stdout) + if expiry_date: + delta = check_time(expiry_date) + logger.debug("Time delta for the validity of the certificate: %r", delta) + if delta: + if delta.days >= 0: + logger.debug("SSL certificate for user '%s' expired.", username) + # Certificate expired. + retval = 2 + elif delta.days > grace_period: + logger.debug("SSL certificate for user '%s' does not need renewal", username) + # Ceritficate valid for longer than grace_period + retval = 0 + else: + logger.debug("SSL certificate for user '%s' needs renewing", username) + # Certificate needs renewal + retval = 1 + return retval + + +def renew_certificate(username): + """ + Use shell script to recreate certificate. + Backs up old certificate. + """ + ssl_script_path = os.path.join(SSL_SCRIPTS_DIR, 'client_maker_erg.sh') + openssl_cmd_create = [ssl_script_path, 'der', username] + with subprocess.Popen( + openssl_cmd_create, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as proc: + stdout, stderr = proc.communicate() + if stderr: + logger.warning("Error creating certificate for %s: %r", username, stderr) + return proc.returncode + + +def check_time(date_as_string): + """Calculate time delta and compare to the minimum valid period.""" + validity_time = datetime.strptime(date_as_string, TIMEFORMAT) + current_date = datetime.now() + return current_date - validity_time + + +def on_message_callback(): + pass + + +def handle_renewal(username): + # subscrite to renewal topic. + # publish certificate + # listen to oncoming OK/NOK message + # If NOK - log, else log and restart mqtt + renewal_topic_set = f"{MQTT_TOPIC_CERT_RENEWAL}/#" + cert_pathname, key_pathname = get_cert_key_paths(username) + client_topic = f"MQTT_TOPIC_CERT_RENEWAL/{username}" + + client = MQTTClient() + client.tls_set( + ca_certs=SSL_CA_CERT, + certfile=SSL_SERVER_CERT, + keyfile=SSL_SERVER_KEY, + cert_reqs=ssl.CERT_REQUIRED, + ) + client.connect( + host=MQTT_SERVER, + port=MQTT_PORT, + keepalive=MQTT_KEEPALIVE + ) + client.subscribe(renewal_topic_set) + client.on_message = on_message_callback + + if not cert_pathname: + logger.warning("SSL certificate for user %s does not exist, will not renew!", username) + if not key_pathname: + logger.warning("SSL key for user %s does not exist, will not renew!", username) + if cert_pathname and key_pathname: + try: + with open(cert_pathname, 'r', encoding='utf-8') as f: + cert_data = f.read() + cert_message = CERT_MESSAGE_FMT % cert_data + with open(key_pathname, 'r', encoding='utf-8') as f: + key_data = f.read() + key_message = KEY_MESSAGE_FMT % key_data + + logger.debug("Sending user '%s' SSL certificate on channel: %s", username, client_topic) + client.publish(client_topic, cert_message) + logger.debug("Sending user '%s' SSL key on channel: %s", username, client_topic) + client.publish(client_topic, key_message) + except Exception as exc: + logger.error("Exception sending cert/key pair for user %s: %r", username, exc, exc_info=True) + + +def get_settings(config_file): + """ + Get settings from configparser. + """ + cfg = ConfigParser(interpolation=None) + cfg.read(config_file) + + global SSL_SET + global SSL_CA_CERT + global SSL_CLIENTS_FOLDER + global SSL_SERVER_CERT + global SSL_SERVER_KEY + global SSL_CLIENT_CERT + global SSL_CLIENT_KEY + global MQTT_SERVER + global MQTT_PORT + global MQTT_KEEPALIVE + global MQTT_TOPIC_CERT_RENEWAL + global MQTT_LOG_FILE + global MQTT_LOG_LEVEL + global MQTT_CLIENT_USERNAME + global GRACE_PERIOD + global SSL_SCRIPTS_DIR + # SSL certificates: + SSL_SET = cfg.getboolean('Certificates', 'ssl') + if SSL_SET: + SSL_CA_CERT = cfg.get('Certificates', 'ca_crt') + SSL_SERVER_CERT = cfg.get('Certificates', 'server_cert_file') + SSL_SERVER_KEY = cfg.get('Certificates', 'server_key_file') + SSL_CLIENTS_FOLDER = cfg.get('Certificates', 'clients_folder') + SSL_CLIENT_CERT = cfg.get('Certificates', 'client_cert_file') + SSL_CLIENT_KEY = cfg.get('Certificates', 'client_key_file') + MQTT_CLIENT_USERNAME = cfg.get('Certificates', 'client_username') + SSL_SCRIPTS_DIR = cfg.get('Certificates', 'scripts_dir') + GRACE_PERIOD = cfg.getint('Certificates', 'grace_period') + else: + logger.error( + "SSL_SET either not set or set to false in config file, exiting" + ) + sys.exit(1) + + MQTT_SERVER = cfg.get('Mqtt', 'hostname') + MQTT_PORT = cfg.getint('Mqtt', 'port') + MQTT_KEEPALIVE = cfg.getint('Mqtt', 'keepalive') + MQTT_TOPIC_CERT_RENEWAL = cfg.get('Mqtt', 'topic') + MQTT_LOG_FILE = cfg.get('Mqtt', 'log_file') + _mqtt_log_level = cfg.get('Mqtt', 'log_level') + MQTT_LOG_LEVEL = LOG_LEVELS[_mqtt_log_level.upper()] + + +def get_usernames(): + """Return a list of usernames.""" + return next(os.walk(SSL_CLIENTS_FOLDER))[1] + + +def get_cert_key_paths(username, server): + """ + Return a tuple of user certificate and key pathnames. + Below only works if we follow the certificate folder convention from the + bash script generating certificates. + Checks if files exist and returns None if not. + """ + if server: + cert_pathname, key_pathname = SSL_SERVER_CERT, SSL_SERVER_KEY + else: + client_path = os.path.join(SSL_CLIENTS_FOLDER, username) + cert_filename = CERT_FMT % username + key_filename = KEY_FMT % username + cert_pathname = os.path.join(client_path, cert_filename) + key_pathname = os.path.join(client_path, key_filename) + if not os.path.isfile(cert_pathname): + cert_pathname = None + if not os.path.isfile(key_pathname): + key_pathname = None + return cert_pathname, key_pathname + +def check_server_cert_valid(): + pass + +def main(): + """Check all user certificates valid and if not, renew.""" + usernames = get_usernames() + for username in usernames: + check_server_cert_valid() + validity = check_cert_valid(username, GRACE_PERIOD) + if validity == 2: + logger.error( + "SSL Certificate for user %s expired, manual installation of a new certificate required!", username) + elif validity == 1: + if not renew_certificate(username): + handle_renewal(username) + else: + logger.error("Renewing certificate for %s failed", username) + + +if __name__ == '__main__': + logger = logging.getLogger() + get_settings(CONFIG_FILE) + logging.basicConfig(filename=MQTT_LOG_FILE, level=MQTT_LOG_LEVEL) + main() diff --git a/settings.cfg.example b/settings.cfg.example index 0cdbc45..653610e 100644 --- a/settings.cfg.example +++ b/settings.cfg.example @@ -1,7 +1,6 @@ [Main] data_length_limit = 10 timeformat = %Y-%m-%d %H:%M:%S -topic = temperature y_max = 51 y_min = -20 x_limit = 2000 @@ -9,10 +8,13 @@ interval = 5000 [Certificates] ssl = True -certificate_directory = +grace_period = # Grace period within which certificates should be renewed +# Need full paths to files: ca_crt = -cert_file = -key_file = +server_cert_file = +server_key_file = +client_cert_file = +client_key_file = [Sensor_mapping] upper = @@ -23,3 +25,4 @@ hostname = port = 8883 keepalive = 60 topic = temperature +topic_cert_renewal = cert_renewal |