Skip to content

Commit

Permalink
Add TLS feature for Modbus asynchronous (#470)
Browse files Browse the repository at this point in the history
* 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.

* Add TLS feature for Modbus asynchronous server

Since we have Modbus TLS server in synchronous mode, we can also
implement Modbus TLS server in asynchronous mode with ASYNC_IO.
  • Loading branch information
starnight authored Jan 29, 2020
1 parent 635d8ab commit ce19705
Show file tree
Hide file tree
Showing 8 changed files with 464 additions and 4 deletions.
7 changes: 7 additions & 0 deletions examples/common/asyncio_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# --------------------------------------------------------------------------- #
import asyncio
from pymodbus.server.asyncio import StartTcpServer
from pymodbus.server.asyncio import StartTlsServer
from pymodbus.server.asyncio import StartUdpServer
from pymodbus.server.asyncio import StartSerialServer

Expand Down Expand Up @@ -127,6 +128,12 @@ async def run_server():
# StartTcpServer(context, identity=identity,
# framer=ModbusRtuFramer, address=("0.0.0.0", 5020))

# Tls:
# await StartTlsServer(context, identity=identity, address=("localhost", 8020),
# certfile="server.crt", keyfile="server.key",
# allow_reuse_address=True, allow_reuse_port=True,
# defer_start=False)

# Udp:
# server = await StartUdpServer(context, identity=identity, address=("0.0.0.0", 5020),
# allow_reuse_address=True, defer_start=True)
Expand Down
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

Loading

0 comments on commit ce19705

Please sign in to comment.