Skip to content

Commit

Permalink
add optional ssl support
Browse files Browse the repository at this point in the history
Signed-off-by: Florian Agbuya <[email protected]>
  • Loading branch information
fsagbuya committed Jan 23, 2025
1 parent 094a6cd commit 7369459
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 11 deletions.
56 changes: 55 additions & 1 deletion doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Remote Procedure Call tool

This tool is the preferred way of handling simple RPC servers.
Instead of writing a client for simple cases, you can simply use this tool
to call remote functions of an RPC server.
to call remote functions of an RPC server. For secure connections, see `SSL Setup`_.

* Listing existing targets

Expand Down Expand Up @@ -127,3 +127,57 @@ Command-line details:
.. argparse::
:ref: sipyco.sipyco_rpctool.get_argparser
:prog: sipyco_rpctool


SSL Setup
=========

SiPyCo supports SSL/TLS encryption with mutual authentication for secure communication, but it is disabled by default. To enable and use SSL, follow these steps:

**Generate server certificate:**

.. code-block:: bash
openssl req -x509 -newkey rsa -keyout server.key -nodes -out server.pem -sha256 -subj "/CN=hostname"
**Generate client certificate:**

.. code-block:: bash
openssl req -x509 -newkey rsa -keyout client.key -nodes -out client.pem -sha256 -subj "/CN=hostname"
.. note::
.. note::
The ``-subj "/CN=hostname"`` parameter sets the Common Name (CN) field in the certificate, which is needed for hostname verification. Replace "hostname" with your actual hostname.

This creates:

- A server certificate (``server.pem``) and key (``server.key``)
- A client certificate (``client.pem``) and key (``client.key``)


Enabling SSL
------------

To enable SSL, the server needs its certificate/key and trusts the client's certificate, while the client needs its certificate/key and trusts the server's certificate:

**For servers:**

.. code-block:: python
simple_server_loop(targets, host, port,
local_cert="path/to/server.pem",
local_key="path/to/server.key",
peer_cert="path/to/client.pem")
**For clients:**

.. code-block:: python
client = Client(host, port,
local_cert="path/to/client.pem",
local_key="path/to/client.key",
peer_cert="path/to/server.pem")
.. note::
When SSL is enabled, mutual TLS authentication is mandatory. Both server and client must provide valid certificates and each must trust the other's certificate for the connection to be established.
8 changes: 7 additions & 1 deletion sipyco/asyncio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from copy import copy

from sipyco import keepalive
from sipyco.ssl_tools import create_ssl_context

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -47,7 +48,7 @@ class AsyncioServer:
def __init__(self):
self._client_tasks = set()

async def start(self, host, port):
async def start(self, host, port, local_cert=None, local_key=None, peer_cert=None):
"""Starts the server.
The user must call :meth:`stop`
Expand All @@ -58,9 +59,14 @@ async def start(self, host, port):
:param host: Bind address of the server (see ``asyncio.start_server``
from the Python standard library).
:param port: TCP port to bind to.
:param local_cert: Server's SSL certificate file. Providing this enables SSL.
:param local_key: Server's private key file. Required when local_cert is provided.
:param peer_cert: Client's SSL certificate file to trust. Required when local_cert is provided.
"""
self.ssl_context = create_ssl_context(local_cert, local_key, peer_cert, server_mode=True)
self.server = await asyncio.start_server(self._handle_connection,
host, port,
ssl=self.ssl_context,
limit=4*1024*1024)

async def stop(self):
Expand Down
39 changes: 31 additions & 8 deletions sipyco/pc_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from operator import itemgetter

from sipyco import keepalive, pyon
from sipyco.ssl_tools import create_ssl_context
from sipyco.asyncio_tools import SignalHandler, AsyncioServer as _AsyncioServer
from sipyco.packed_exceptions import *

Expand Down Expand Up @@ -97,6 +98,9 @@ class Client:
Use ``None`` to skip selecting a target. The list of targets can then
be retrieved using :meth:`~sipyco.pc_rpc.Client.get_rpc_id`
and then one can be selected later using :meth:`~sipyco.pc_rpc.Client.select_rpc_target`.
:param local_cert: Client's certificate file. Providing this enables SSL.
:param local_key: Client's private key file. Required when local_cert is provided.
:param peer_cert: Server's SSL certificate file to trust. Required when local_cert is provided.
:param timeout: Socket operation timeout. Use ``None`` for blocking
(default), ``0`` for non-blocking, and a finite value to raise
``socket.timeout`` if an operation does not complete within the
Expand All @@ -106,9 +110,12 @@ class Client:
client).
"""

def __init__(self, host, port, target_name=AutoTarget, timeout=None):
def __init__(self, host, port, target_name=AutoTarget,
local_cert=None, local_key=None, peer_cert=None, timeout=None):
self.__socket = socket.create_connection((host, port), timeout)

ssl_context = create_ssl_context(local_cert, local_key, peer_cert)
if ssl_context is not None:
self.__socket = ssl_context.wrap_socket(self.__socket, server_hostname=host)
try:
self.__socket.sendall(_init_string)

Expand Down Expand Up @@ -206,12 +213,14 @@ def __init__(self):
self.__description = None
self.__valid_methods = set()

async def connect_rpc(self, host, port, target_name=AutoTarget):
async def connect_rpc(self, host, port, target_name=AutoTarget,
local_cert=None, local_key=None, peer_cert=None):
"""Connects to the server. This cannot be done in __init__ because
this method is a coroutine. See :class:`sipyco.pc_rpc.Client` for a description of the
parameters."""
ssl_context = create_ssl_context(local_cert, local_key, peer_cert)
self.__reader, self.__writer = \
await keepalive.async_open_connection(host, port, limit=100 * 1024 * 1024)
await keepalive.async_open_connection(host, port, ssl=ssl_context, limit=100 * 1024 * 1024)
try:
self.__writer.write(_init_string)
server_identification = await self.__recv()
Expand Down Expand Up @@ -303,17 +312,22 @@ class BestEffortClient:
RPC calls that failed because of network errors return ``None``. Other RPC
calls are blocking and return the correct value.
See :class:`sipyco.pc_rpc.Client` for a description of the other parameters.
:param firstcon_timeout: Timeout to use during the first (blocking)
connection attempt at object initialization.
:param retry: Amount of time to wait between retries when reconnecting
in the background.
"""

def __init__(self, host, port, target_name,
firstcon_timeout=1.0, retry=5.0):
def __init__(self, host, port, target_name, local_cert=None,
local_key=None, peer_cert=None, firstcon_timeout=1.0, retry=5.0):
self.__host = host
self.__port = port
self.__target_name = target_name
self.__local_cert = local_cert
self.__local_key = local_key
self.__peer_cert = peer_cert
self.__retry = retry

self.__conretry_terminate = False
Expand All @@ -337,6 +351,14 @@ def __coninit(self, timeout):
else:
self.__socket = socket.create_connection(
(self.__host, self.__port), timeout)
ssl_context = create_ssl_context(
local_cert=self.__local_cert,
local_key=self.__local_key,
peer_cert=self.__peer_cert
)
if ssl_context is not None:
self.__socket = ssl_context.wrap_socket(self.__socket,
server_hostname=self.__host)
self.__socket.sendall(_init_string)
server_identification = self.__recv()
target_name = _validate_target_name(self.__target_name,
Expand Down Expand Up @@ -635,7 +657,8 @@ async def wait_terminate(self):
await self._terminate_request.wait()


def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None):
def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None,
local_cert=None, local_key=None, peer_cert=None):
"""Runs a server until an exception is raised (e.g. the user hits Ctrl-C)
or termination is requested by a client.
Expand All @@ -651,7 +674,7 @@ def simple_server_loop(targets, host, port, description=None, allow_parallel=Fal
signal_handler.setup()
try:
server = Server(targets, description, True, allow_parallel)
used_loop.run_until_complete(server.start(host, port))
used_loop.run_until_complete(server.start(host, port, local_cert, local_key, peer_cert))
try:
_, pending = used_loop.run_until_complete(asyncio.wait(
[used_loop.create_task(signal_handler.wait_terminate()),
Expand Down
11 changes: 10 additions & 1 deletion sipyco/sipyco_rpctool.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def get_argparser():
help="hostname or IP of the controller to connect to")
parser.add_argument("port", metavar="PORT", type=int,
help="TCP port to use to connect to the controller")
parser.add_argument("--ssl", nargs=3, metavar=('CERT', 'KEY', 'PEER'),
help="Enable SSL authentication: "
"CERT: client certificate file, "
"KEY: client private key, "
"PEER: server certificate to trust")
subparsers = parser.add_subparsers(dest="action")
subparsers.add_parser("list-targets", help="list existing targets")
parser_list_methods = subparsers.add_parser("list-methods",
Expand Down Expand Up @@ -97,8 +102,12 @@ def main():
args = get_argparser().parse_args()
if not args.action:
args.target = None
if args.ssl:
cert, key, peer = args.ssl
else:
cert, key, peer = None, None, None

remote = Client(args.server, args.port, None)
remote = Client(args.server, args.port, None, local_cert=cert, local_key=key, peer_cert=peer)
targets, description = remote.get_rpc_id()
if args.action != "list-targets":
if not args.target:
Expand Down
30 changes: 30 additions & 0 deletions sipyco/ssl_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import ssl
import logging

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

def create_ssl_context(local_cert=None, local_key=None, peer_cert=None, server_mode=False):
"""Create an SSL context with mutual authentication.
:param local_cert: Certificate file. Providing this enables SSL.
:param local_key: Private key file. Required when local_cert is provided.
:param peer_cert: Peer's certificate file to trust. Required when local_cert is provided.
:param server_mode: If True, create a server context, otherwise client context.
"""
if local_cert is None:
return None
if local_key is None:
raise ValueError("local_key is required when local_cert is provided")
if peer_cert is None:
raise ValueError("peer_cert is required when local_cert is provided")

if server_mode:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.verify_mode = ssl.CERT_REQUIRED
else:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)

context.load_verify_locations(cafile=peer_cert)
context.load_cert_chain(local_cert, local_key)
return context

0 comments on commit 7369459

Please sign in to comment.