Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add TLS feature for Modbus asynchronous client
Since we have Modbus TLS client in synchronous mode, we can also
implement Modbus TLS client in asynchronous mode with ASYNC_IO.
  • Loading branch information
starnight committed Nov 20, 2019
commit 26886912ace785d8b971eec183b1778e7f483d6c
40 changes: 40 additions & 0 deletions examples/contrib/asynchronous_asyncio_modbus_tls_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python
"""
Simple Asynchronous Modbus TCP over TLS client
---------------------------------------------------------------------------

This is a simple example of writing a asynchronous modbus TCP over TLS client
that uses Python builtin module ssl - TLS/SSL wrapper for socket objects for
the TLS feature and asyncio.
"""
# -------------------------------------------------------------------------- #
# import neccessary libraries
# -------------------------------------------------------------------------- #
import ssl
from pymodbus.client.asynchronous.tls import AsyncModbusTLSClient
from pymodbus.client.asynchronous.schedulers import ASYNC_IO

# -------------------------------------------------------------------------- #
# the TLS detail security can be set in SSLContext which is the context here
# -------------------------------------------------------------------------- #
context = ssl.create_default_context()
context.options |= ssl.OP_NO_SSLv2
context.options |= ssl.OP_NO_SSLv3
context.options |= ssl.OP_NO_TLSv1
context.options |= ssl.OP_NO_TLSv1_1

async def start_async_test(client):
result = await client.read_coils(1, 8)
print(result.bits)
await client.write_coils(1, [False]*3)
result = await client.read_coils(1, 8)
print(result.bits)

if __name__ == '__main__':
# -------------------------------------------------------------------------- #
# pass SSLContext which is the context here to ModbusTcpClient()
# -------------------------------------------------------------------------- #
loop, client = AsyncModbusTLSClient(ASYNC_IO, 'test.host.com', 8020,
sslctx=context)
loop.run_until_complete(start_async_test(client.protocol))
loop.close()
84 changes: 83 additions & 1 deletion pymodbus/client/asynchronous/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
import socket
import asyncio
import functools
import ssl
from pymodbus.exceptions import ConnectionException
from pymodbus.client.asynchronous.mixins import AsyncModbusClientMixin
from pymodbus.compat import byte2int
from pymodbus.transaction import FifoTransactionManager
import logging

_logger = logging.getLogger(__name__)

DGRAM_TYPE = socket.SocketKind.SOCK_DGRAM


class BaseModbusAsyncClientProtocol(AsyncModbusClientMixin):
"""
Asyncio specific implementation of asynchronous modbus client protocol.
Expand Down Expand Up @@ -423,6 +424,66 @@ def protocol_lost_connection(self, protocol):
' callback called while not connected.')


class ReconnectingAsyncioModbusTlsClient(ReconnectingAsyncioModbusTcpClient):
"""
Client to connect to modbus device repeatedly over TLS."
"""
def __init__(self, protocol_class=None, loop=None, framer=None):
"""
Initialize ReconnectingAsyncioModbusTcpClient
:param protocol_class: Protocol used to talk to modbus device.
:param loop: Event loop to use
"""
self.framer = framer
ReconnectingAsyncioModbusTcpClient.__init__(self, protocol_class, loop)

@asyncio.coroutine
def start(self, host, port=802, sslctx=None, server_hostname=None):
"""
Initiates connection to start client
:param host:
:param port:
:param sslctx:
:param server_hostname:
:return:
"""
self.sslctx = sslctx
if self.sslctx is None:
self.sslctx = ssl.create_default_context()
# According to MODBUS/TCP Security Protocol Specification, it is
# TLSv2 at least
self.sslctx.options |= ssl.OP_NO_TLSv1_1
self.sslctx.options |= ssl.OP_NO_TLSv1
self.sslctx.options |= ssl.OP_NO_SSLv3
self.sslctx.options |= ssl.OP_NO_SSLv2
self.server_hostname = server_hostname
yield from ReconnectingAsyncioModbusTcpClient.start(self, host, port)

@asyncio.coroutine
def _connect(self):
_logger.debug('Connecting.')
try:
yield from self.loop.create_connection(self._create_protocol,
self.host,
self.port,
ssl=self.sslctx,
server_hostname=self.server_hostname)
except Exception as ex:
_logger.warning('Failed to connect: %s' % ex)
asyncio.ensure_future(self._reconnect(), loop=self.loop)
else:
_logger.info('Connected to %s:%s.' % (self.host, self.port))
self.reset_delay()

def _create_protocol(self):
"""
Factory function to create initialized protocol instance.
"""
protocol = self.protocol_class(framer=self.framer)
protocol.transaction = FifoTransactionManager(self)
protocol.factory = self
return protocol

class ReconnectingAsyncioModbusUdpClient(object):
"""
Client to connect to modbus device repeatedly over UDP.
Expand Down Expand Up @@ -774,6 +835,27 @@ def init_tcp_client(proto_cls, loop, host, port, **kwargs):
return client


@asyncio.coroutine
def init_tls_client(proto_cls, loop, host, port, sslctx=None,
server_hostname=None, framer=None, **kwargs):
"""
Helper function to initialize tcp client
:param proto_cls:
:param loop:
:param host:
:param port:
:param sslctx:
:param server_hostname:
:param framer:
:param kwargs:
:return:
"""
client = ReconnectingAsyncioModbusTlsClient(protocol_class=proto_cls,
loop=loop, framer=framer)
yield from client.start(host, port, sslctx, server_hostname)
return client


@asyncio.coroutine
def init_udp_client(proto_cls, loop, host, port, **kwargs):
"""
Expand Down
60 changes: 60 additions & 0 deletions pymodbus/client/asynchronous/factory/tls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Factory to create asynchronous tls clients based on asyncio
"""
from __future__ import unicode_literals
from __future__ import absolute_import

import logging

from pymodbus.client.asynchronous import schedulers
from pymodbus.client.asynchronous.thread import EventLoopThread
from pymodbus.constants import Defaults

LOGGER = logging.getLogger(__name__)

def async_io_factory(host="127.0.0.1", port=Defaults.TLSPort, sslctx=None,
server_hostname=None, framer=None, source_address=None,
timeout=None, **kwargs):
"""
Factory to create asyncio based asynchronous tls clients
:param host: Host IP address
:param port: Port
:param sslctx: The SSLContext to use for TLS (default None and auto create)
:param server_hostname: Target server's name matched for certificate
:param framer: Modbus Framer
:param source_address: Bind address
:param timeout: Timeout in seconds
:param kwargs:
:return: asyncio event loop and tcp client
"""
import asyncio
from pymodbus.client.asynchronous.asyncio import init_tls_client
loop = kwargs.get("loop") or asyncio.new_event_loop()
proto_cls = kwargs.get("proto_cls", None)
if not loop.is_running():
asyncio.set_event_loop(loop)
cor = init_tls_client(proto_cls, loop, host, port, sslctx, server_hostname,
framer)
client = loop.run_until_complete(asyncio.gather(cor))[0]
else:
cor = init_tls_client(proto_cls, loop, host, port, sslctx, server_hostname,
framer)
future = asyncio.run_coroutine_threadsafe(cor, loop=loop)
client = future.result()

return loop, client


def get_factory(scheduler):
"""
Gets protocol factory based on the backend scheduler being used
:param scheduler: ASYNC_IO
:return
"""
if scheduler == schedulers.ASYNC_IO:
return async_io_factory
else:
LOGGER.warning("Allowed Schedulers: {}".format(
schedulers.ASYNC_IO
))
raise Exception("Invalid Scheduler '{}'".format(scheduler))
52 changes: 52 additions & 0 deletions pymodbus/client/asynchronous/tls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import unicode_literals
from __future__ import absolute_import

import logging
from pymodbus.client.asynchronous.factory.tls import get_factory
from pymodbus.constants import Defaults
from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION
from pymodbus.client.asynchronous.schedulers import ASYNC_IO
from pymodbus.factory import ClientDecoder
from pymodbus.transaction import ModbusTlsFramer

logger = logging.getLogger(__name__)


class AsyncModbusTLSClient(object):
"""
Actual Async TLS Client to be used.

To use do::

from pymodbus.client.asynchronous.tls import AsyncModbusTLSClient
"""
def __new__(cls, scheduler, host="127.0.0.1", port=Defaults.TLSPort,
framer=None, sslctx=None, server_hostname=None,
source_address=None, timeout=None, **kwargs):
"""
Scheduler to use:
- async_io (asyncio)
:param scheduler: Backend to use
:param host: Host IP address
:param port: Port
:param framer: Modbus Framer to use
:param sslctx: The SSLContext to use for TLS (default None and auto create)
:param server_hostname: Target server's name matched for certificate
:param source_address: source address specific to underlying backend
:param timeout: Time out in seconds
:param kwargs: Other extra args specific to Backend being used
:return:
"""
if (not (IS_PYTHON3 and PYTHON_VERSION >= (3, 4))
and scheduler == ASYNC_IO):
logger.critical("ASYNCIO is supported only on python3")
import sys
sys.exit(1)
framer = framer or ModbusTlsFramer(ClientDecoder())
factory_class = get_factory(scheduler)
yieldable = factory_class(host=host, port=port, sslctx=sslctx,
server_hostname=server_hostname,
framer=framer, source_address=source_address,
timeout=timeout, **kwargs)
return yieldable

27 changes: 26 additions & 1 deletion test/test_client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
if IS_PYTHON3 and PYTHON_VERSION >= (3, 4):
from unittest.mock import patch, Mock, MagicMock
import asyncio
from pymodbus.client.asynchronous.asyncio import ReconnectingAsyncioModbusTlsClient
from pymodbus.client.asynchronous.asyncio import AsyncioModbusSerialClient
from serial_asyncio import SerialTransport
else:
Expand All @@ -14,6 +15,7 @@

from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient
from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient
from pymodbus.client.asynchronous.tls import AsyncModbusTLSClient
from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient

from pymodbus.client.asynchronous.tornado import AsyncModbusSerialClient as AsyncTornadoModbusSerialClient
Expand All @@ -22,9 +24,11 @@
from pymodbus.client.asynchronous import schedulers
from pymodbus.factory import ClientDecoder
from pymodbus.exceptions import ConnectionException
from pymodbus.transaction import ModbusSocketFramer, ModbusRtuFramer, ModbusAsciiFramer, ModbusBinaryFramer
from pymodbus.transaction import ModbusSocketFramer, ModbusTlsFramer, ModbusRtuFramer, ModbusAsciiFramer, ModbusBinaryFramer
from pymodbus.client.asynchronous.twisted import ModbusSerClientProtocol

import ssl

IS_DARWIN = platform.system().lower() == "darwin"
OSX_SIERRA = LooseVersion("10.12")
if IS_DARWIN:
Expand Down Expand Up @@ -104,6 +108,27 @@ def testTcpAsyncioClient(self, mock_gather, mock_loop):
"""
pytest.skip("TBD")

# -----------------------------------------------------------------------#
# Test TLS Client client
# -----------------------------------------------------------------------#
@pytest.mark.skipif(not IS_PYTHON3 or PYTHON_VERSION < (3, 4),
reason="requires python3.4 or above")
def testTlsAsyncioClient(self):
"""
Test the TLS AsyncIO client
"""
loop, client = AsyncModbusTLSClient(schedulers.ASYNC_IO)
assert(isinstance(client, ReconnectingAsyncioModbusTlsClient))
assert(isinstance(client.framer, ModbusTlsFramer))
assert(isinstance(client.sslctx, ssl.SSLContext))
assert(client.port == 802)

def handle_failure(failure):
assert(isinstance(failure.exception(), ConnectionException))

client.stop()
assert(client.host is None)

# -----------------------------------------------------------------------#
# Test UDP client
# -----------------------------------------------------------------------#
Expand Down