diff --git a/doc/index.rst b/doc/index.rst index 6042cd0..76f9b8f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -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 @@ -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. \ No newline at end of file diff --git a/sipyco/asyncio_tools.py b/sipyco/asyncio_tools.py index 4a96dcb..1af41a4 100644 --- a/sipyco/asyncio_tools.py +++ b/sipyco/asyncio_tools.py @@ -7,6 +7,7 @@ from copy import copy from sipyco import keepalive +from sipyco.ssl_tools import create_ssl_context logger = logging.getLogger(__name__) @@ -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` @@ -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): diff --git a/sipyco/pc_rpc.py b/sipyco/pc_rpc.py index 69a90d8..a32211e 100644 --- a/sipyco/pc_rpc.py +++ b/sipyco/pc_rpc.py @@ -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 * @@ -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 @@ -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) @@ -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() @@ -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 @@ -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, @@ -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. @@ -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()), diff --git a/sipyco/sipyco_rpctool.py b/sipyco/sipyco_rpctool.py index cb9106c..d4c46d9 100755 --- a/sipyco/sipyco_rpctool.py +++ b/sipyco/sipyco_rpctool.py @@ -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", @@ -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: diff --git a/sipyco/ssl_tools.py b/sipyco/ssl_tools.py new file mode 100644 index 0000000..1226787 --- /dev/null +++ b/sipyco/ssl_tools.py @@ -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