From ab80e90fd59989b6dbd7fcfe609f4a71e4b658dd Mon Sep 17 00:00:00 2001 From: Bryan Richardson Date: Thu, 2 Jul 2020 15:38:15 -0600 Subject: [PATCH] Add support for Modbus over TLS (Modbus/TCP Security) (#97) --- sunspec/core/client.py | 52 +++++++++++++++++++++++++++++---- sunspec/core/modbus/client.py | 54 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/sunspec/core/client.py b/sunspec/core/client.py index 508287a..4962a9a 100755 --- a/sunspec/core/client.py +++ b/sunspec/core/client.py @@ -81,6 +81,27 @@ class ClientDevice(device.Device): For :const:`TCP` devices, device IP port. Defaulted by modbus module to 502. + tls : + For :const:`TCP` devices, use TLS (Modbus/TCP Security). Defaults + to `tls=False`. + + cafile : + For :const:`TCP` devices, path to certificate authority (CA) + certificate to use for validating server certificates. Only used + if `tls=True`. + + certfile : + For :const:`TCP` devices, path to client TLS certificate to use + for client authentication. Only used if `tls=True`. + + keyfile : + For :const:`TCP` devices, path to client TLS key to use for + client authentication. Only used if `tls=True`. + + insecure_skip_tls_verify : + Skip verification of server TLS certificate. Only used if + `tls=True`. + timeout : Modbus request timeout in seconds. Fractional seconds are permitted such as .5. @@ -120,8 +141,8 @@ class ClientDevice(device.Device): first time. """ - def __init__(self, device_type, slave_id=None, name=None, pathlist = None, baudrate=None, parity=None, ipaddr=None, ipport=None, - timeout=None, trace=False): + def __init__(self, device_type, slave_id=None, name=None, pathlist=None, baudrate=None, parity=None, ipaddr=None, ipport=None, + tls=False, cafile=None, certfile=None, keyfile=None, insecure_skip_tls_verify=False, timeout=None, trace=False): device.Device.__init__(self, addr=None) self.type = device_type @@ -136,7 +157,7 @@ def __init__(self, device_type, slave_id=None, name=None, pathlist = None, baudr if device_type == RTU: self.modbus_device = modbus.ModbusClientDeviceRTU(slave_id, name, baudrate, parity, timeout, self, trace) elif device_type == TCP: - self.modbus_device = modbus.ModbusClientDeviceTCP(slave_id, ipaddr, ipport, timeout, self, trace) + self.modbus_device = modbus.ModbusClientDeviceTCP(slave_id, ipaddr, ipport, timeout, self, trace, tls, cafile, certfile, keyfile, insecure_skip_tls_verify) elif device_type == MAPPED: if name is not None: self.modbus_device = modbus.ModbusClientDeviceMapped(slave_id, name, pathlist, self) @@ -735,6 +756,27 @@ class SunSpecClientDevice(object): For :const:`TCP` devices, device IP port. Defaulted by modbus module to 502. + tls : + For :const:`TCP` devices, use TLS (Modbus/TCP Security). Defaults + to `tls=False`. + + cafile : + For :const:`TCP` devices, path to certificate authority (CA) + certificate to use for validating server certificates. Only used + if `tls=True`. + + certfile : + For :const:`TCP` devices, path to client TLS certificate to use + for client authentication. Only used if `tls=True`. + + keyfile : + For :const:`TCP` devices, path to client TLS key to use for + client authentication. Only used if `tls=True`. + + insecure_skip_tls_verify : + Skip verification of server TLS certificate. Only used if + `tls=True`. + timeout : Modbus request timeout in seconds. Fractional seconds are permitted such as .5. @@ -764,10 +806,10 @@ class SunSpecClientDevice(object): """ def __init__(self, device_type, slave_id=None, name=None, pathlist = None, baudrate=None, parity=None, ipaddr=None, ipport=None, - timeout=None, trace=False, scan_progress=None, scan_delay=None): + tls=False, cafile=None, certfile=None, keyfile=None, insecure_skip_tls_verify=False, timeout=None, trace=False, scan_progress=None, scan_delay=None): # super(self.__class__, self).__init__(device_type, slave_id, name, pathlist, baudrate, parity, ipaddr, ipport) - self.device = ClientDevice(device_type, slave_id, name, pathlist, baudrate, parity, ipaddr, ipport, timeout, trace) + self.device = ClientDevice(device_type, slave_id, name, pathlist, baudrate, parity, ipaddr, ipport, tls, cafile, certfile, keyfile, insecure_skip_tls_verify, timeout, trace) self.models = [] try: diff --git a/sunspec/core/modbus/client.py b/sunspec/core/modbus/client.py index 2ba48c6..7509187 100755 --- a/sunspec/core/modbus/client.py +++ b/sunspec/core/modbus/client.py @@ -22,6 +22,7 @@ """ import os +import ssl import socket import struct import serial @@ -585,6 +586,25 @@ class ModbusClientDeviceTCP(object): Trace function to use for detailed logging. No detailed logging is perform is a trace function is not supplied. + tls : + Use TLS (Modbus/TCP Security). Defaults to `tls=False`. + + cafile : + Path to certificate authority (CA) certificate to use for + validating server certificates. Only used if `tls=True`. + + certfile : + Path to client TLS certificate to use for client authentication. + Only used if `tls=True`. + + keyfile : + Path to client TLS key to use for client authentication. Only + used if `tls=True`. + + insecure_skip_tls_verify : + Skip verification of server TLS certificate. Only used if + `tls=True`. + max_count : Maximum register count for a single Modbus request. @@ -628,11 +648,30 @@ class ModbusClientDeviceTCP(object): trace_func Trace function to use for detailed logging. + tls + Use TLS (Modbus/TCP Security). Defaults to `tls=False`. + + cafile + Path to certificate authority (CA) certificate to use for + validating server certificates. Only used if `tls=True`. + + certfile + Path to client TLS certificate to use for client authentication. + Only used if `tls=True`. + + keyfile + Path to client TLS key to use for client authentication. Only + used if `tls=True`. + + insecure_skip_tls_verify : + Skip verification of server TLS certificate. Only used if + `tls=True`. + max_count Maximum register count for a single Modbus request. """ - def __init__(self, slave_id, ipaddr, ipport=502, timeout=None, ctx=None, trace_func=None, max_count=REQ_COUNT_MAX, test=False): + def __init__(self, slave_id, ipaddr, ipport=502, timeout=None, ctx=None, trace_func=None, tls=False, cafile=None, certfile=None, keyfile=None, insecure_skip_tls_verify=False, max_count=REQ_COUNT_MAX, test=False): self.slave_id = slave_id self.ipaddr = ipaddr self.ipport = ipport @@ -640,6 +679,11 @@ def __init__(self, slave_id, ipaddr, ipport=502, timeout=None, ctx=None, trace_f self.ctx = ctx self.socket = None self.trace_func = trace_func + self.tls = tls + self.cafile = cafile + self.certfile = certfile + self.keyfile = keyfile + self.tls_verify = not insecure_skip_tls_verify self.max_count = max_count if ipport is None: @@ -673,6 +717,14 @@ def connect(self, timeout=None): try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(timeout) + + if self.tls: + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=self.cafile) + context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) + context.check_hostname = self.tls_verify + + self.socket = context.wrap_socket(self.socket, server_side=False, server_hostname=self.ipaddr) + self.socket.connect((self.ipaddr, self.ipport)) except Exception as e: raise ModbusClientError('Connection error: %s' % str(e))