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 26, 2024
1 parent 094a6cd commit be0b423
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 12 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 "/"
**Generate client certificate:**

.. code-block:: bash
openssl req -x509 -newkey rsa -keyout client.key -nodes -out client.pem -sha256 -subj "/"
.. note::
.. note::
The ``-subj "/"`` parameter bypasses the interactive prompts for certificate information.

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",
peerfile="path/to/client.pem")
**For clients:**

.. code-block:: python
client = Client(host, port,
certfile="path/to/client.pem",
keyfile="path/to/client.key",
peerfile="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.
17 changes: 16 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 @@ -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, certfile=None, keyfile=None, peerfile=None):
"""Starts the server.
The user must call :meth:`stop`
Expand All @@ -58,9 +59,23 @@ 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 certfile: Server's SSL certificate file. Providing this enables SSL.
:param keyfile: Server's private key file. Required when cert is provided.
:param peerfile: Client's SSL certificate file to trust. Required when SSL is enabled.
"""
self.ssl_context = None
if certfile:
if keyfile is None:
raise ValueError("keyfile is required when certfile is provided")
if peerfile is None:
raise ValueError("peerfile 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(peerfile)
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
54 changes: 46 additions & 8 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 peerfile: 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,18 @@ 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, peerfile=None, timeout=None):
self.__socket = socket.create_connection((host, port), timeout)

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

Expand Down Expand Up @@ -206,12 +219,22 @@ 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, peerfile=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 keyfile is None:
raise ValueError("keyfile is required when certfile is provided")
if peerfile is None:
raise ValueError("peerfile is required when SSL is enabled")
ssl_context = ssl.create_default_context(cafile=peerfile)
ssl_context.check_hostname = False
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 +326,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, peerfile=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.__peerfile = peerfile
self.__retry = retry

self.__conretry_terminate = False
Expand All @@ -337,6 +365,15 @@ def __coninit(self, timeout):
else:
self.__socket = socket.create_connection(
(self.__host, self.__port), timeout)
if self.__certfile:
if self.__keyfile is None:
raise ValueError("keyfile is required when certfile is provided")
if self.__peerfile is None:
raise ValueError("peerfile is required when SSL is enabled")
ssl_context = ssl.create_default_context(cafile=self.__peerfile)
ssl_context.check_hostname = False
ssl_context.load_cert_chain(self.__certfile, self.__keyfile)
self.__socket = ssl_context.wrap_socket(self.__socket)
self.__socket.sendall(_init_string)
server_identification = self.__recv()
target_name = _validate_target_name(self.__target_name,
Expand Down Expand Up @@ -635,7 +672,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, peerfile=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 +689,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, certfile, keyfile, peerfile))
try:
_, pending = used_loop.run_until_complete(asyncio.wait(
[used_loop.create_task(signal_handler.wait_terminate()),
Expand Down
12 changes: 10 additions & 2 deletions 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: server certificate file, "
"KEY: server private key, "
"PEER: client 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,11 @@ def main():
args = get_argparser().parse_args()
if not args.action:
args.target = None

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

0 comments on commit be0b423

Please sign in to comment.