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 <fa@m-labs.ph>
  • Loading branch information
fsagbuya committed Nov 7, 2024
1 parent 094a6cd commit 12ac4f4
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 13 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=localhost"
**Generate client certificate:**

.. code-block:: bash
openssl req -x509 -newkey rsa -keyout client.key -nodes -out client.pem -sha256 -subj "/CN=localhost"
.. note::
.. note::
The ``-subj`` parameter bypasses the interactive prompts for certificate information. You can specify a different name by changing the CN value (e.g., "/CN=myhost").

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,
certfile="path/to/server.pem",
keyfile="path/to/server.key",
cafile="path/to/client.pem")
**For clients:**

.. code-block:: python
client = Client(host, port,
certfile="path/to/client.pem",
keyfile="path/to/client.key",
cafile="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.
14 changes: 13 additions & 1 deletion sipyco/asyncio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import atexit
import collections
import logging
import ssl
from copy import copy

from sipyco import keepalive
Expand Down Expand Up @@ -44,8 +45,18 @@ class AsyncioServer:
:meth:`~sipyco.asyncio_server.AsyncioServer._handle_connection_cr`
method/coroutine.
"""
def __init__(self):
def __init__(self, certfile=None, keyfile=None, cafile=None):
self._client_tasks = set()
self.ssl_context = None
if certfile:
if not keyfile:
raise ValueError("keyfile is required when certfile is provided")
if not cafile:
raise ValueError("cafile is required when SSL is enabled")
self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.ssl_context.load_cert_chain(certfile, keyfile)
self.ssl_context.verify_mode = ssl.CERT_REQUIRED
self.ssl_context.load_verify_locations(cafile)

async def start(self, host, port):
"""Starts the server.
Expand All @@ -61,6 +72,7 @@ async def start(self, host, port):
"""
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
59 changes: 49 additions & 10 deletions sipyco/pc_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import socket
import threading
import time
import ssl
from operator import itemgetter

from sipyco import keepalive, pyon
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 certfile: Client's certificate file. Providing this enables SSL.
:param keyfile: Client's private key file. Required when certfile is provided.
:param cafile: Server's SSL certificate file to trust. Required when SSL is enabled.
: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,17 @@ class Client:
client).
"""

def __init__(self, host, port, target_name=AutoTarget, timeout=None):
def __init__(self, host, port, target_name=AutoTarget,
certfile=None, keyfile=None, cafile=None, timeout=None):
self.__socket = socket.create_connection((host, port), timeout)

if certfile:
if not keyfile:
raise ValueError("keyfile is required when certfile is provided")
if not cafile:
raise ValueError("cafile is required when SSL is enabled")
ssl_context = ssl.create_default_context(cafile=cafile)
ssl_context.load_cert_chain(certfile, keyfile)
self.__socket = ssl_context.wrap_socket(self.__socket, server_hostname=host)
try:
self.__socket.sendall(_init_string)

Expand Down Expand Up @@ -206,12 +218,21 @@ 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,
certfile=None, keyfile=None, cafile=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 = None
if certfile:
if not keyfile:
raise ValueError("keyfile is required when certfile is provided")
if not cafile:
raise ValueError("cafile is required when SSL is enabled")
ssl_context = ssl.create_default_context(cafile=cafile)
ssl_context.load_cert_chain(certfile, keyfile)
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 +324,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, certfile=None,
keyfile=None, cafile=None, firstcon_timeout=1.0, retry=5.0):
self.__host = host
self.__port = port
self.__target_name = target_name
self.__certfile = certfile
self.__keyfile = keyfile
self.__cafile = cafile
self.__retry = retry

self.__conretry_terminate = False
Expand All @@ -337,6 +363,15 @@ def __coninit(self, timeout):
else:
self.__socket = socket.create_connection(
(self.__host, self.__port), timeout)
if self.__certfile:
if not self.__keyfile:
raise ValueError("keyfile is required when certfile is provided")
if not self.__cafile:
raise ValueError("cafile is required when SSL is enabled")
ssl_context = ssl.create_default_context(cafile=self.__cafile)
ssl_context.load_cert_chain(self.__certfile, self.__keyfile)
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 @@ -485,11 +520,14 @@ class Server(_AsyncioServer):
requests from clients.
:param allow_parallel: Allow concurrent asyncio calls to the target's
methods.
:param certfile: Server's SSL certificate file. Providing this enables SSL.
:param keyfile: Server's private key file. Required when cert is provided.
:param cafile: Client's SSL certificate file to trust. Required when SSL is enabled.
"""

def __init__(self, targets, description=None, builtin_terminate=False,
allow_parallel=False):
_AsyncioServer.__init__(self)
allow_parallel=False, certfile=None, keyfile=None, cafile=None):
_AsyncioServer.__init__(self, certfile=certfile, keyfile=keyfile, cafile=cafile)
self.targets = targets
self.description = description
self.builtin_terminate = builtin_terminate
Expand Down Expand Up @@ -635,7 +673,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,
certfile=None, keyfile=None, cafile=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 @@ -650,7 +689,7 @@ def simple_server_loop(targets, host, port, description=None, allow_parallel=Fal
signal_handler = SignalHandler()
signal_handler.setup()
try:
server = Server(targets, description, True, allow_parallel)
server = Server(targets, description, True, allow_parallel, certfile, keyfile, cafile)
used_loop.run_until_complete(server.start(host, port))
try:
_, pending = used_loop.run_until_complete(asyncio.wait(
Expand Down
8 changes: 7 additions & 1 deletion sipyco/sipyco_rpctool.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ 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("--cert", default=None,
help="Client's certificate file. Providing this enables SSL (default: %(default)s)")
parser.add_argument("--key", default=None,
help="Client's private key file. Required when certfile is provided")
parser.add_argument("--cafile", default=None,
help="Server's SSL certificate file to trust. Required when SSL is enabled.")
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 @@ -98,7 +104,7 @@ def main():
if not args.action:
args.target = None

remote = Client(args.server, args.port, None)
remote = Client(args.server, args.port, None, cafile=args.cafile, certfile=args.cert, keyfile=args.key)
targets, description = remote.get_rpc_id()
if args.action != "list-targets":
if not args.target:
Expand Down

0 comments on commit 12ac4f4

Please sign in to comment.