Skip to content

Commit 800e8a8

Browse files
committed
add optional ssl support
Signed-off-by: Florian Agbuya <[email protected]>
1 parent 3422611 commit 800e8a8

File tree

7 files changed

+130
-15
lines changed

7 files changed

+130
-15
lines changed

doc/index.rst

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Remote Procedure Call tool
6565

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

7070
* Listing existing targets
7171

@@ -127,3 +127,56 @@ Command-line details:
127127
.. argparse::
128128
:ref: sipyco.sipyco_rpctool.get_argparser
129129
:prog: sipyco_rpctool
130+
131+
132+
SSL Setup
133+
=========
134+
135+
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:
136+
137+
**Generate key and certificate:**
138+
139+
Run the following command twice. Once with server filenames (e.g., ``server.key``, ``server.pem``) and once with client filenames (e.g., ``client.key``, ``client.pem``):
140+
141+
.. code-block:: bash
142+
143+
openssl req -x509 -newkey rsa -keyout OUTPUT.key -nodes -out OUTPUT.pem -sha256 -subj "/" --addext "subjectAltName=IP:127.0.0.1"
144+
145+
.. note::
146+
The ``--addext "subjectAltName=IP:127.0.0.1"`` parameter must specify a valid IP address that will be included in the certificate. You should replace this with the actual IP address that will be used for connections.
147+
148+
Examples for different network configurations:
149+
150+
- For IPv6 localhost: ``--addext "subjectAltName=IP:::1"``
151+
- For local network IP: ``--addext "subjectAltName=IP:192.168.1.100"``
152+
- For multiple IPs: ``--addext "subjectAltName=IP:127.0.0.1,IP:::1"``
153+
- For hostname (if needed): ``--addext "subjectAltName=DNS:your.hostname.com"``
154+
155+
This creates:
156+
157+
- A server certificate (``server.pem``) and key (``server.key``)
158+
- A client certificate (``client.pem``) and key (``client.key``)
159+
160+
161+
Enabling SSL
162+
------------
163+
164+
To start with SSL enabled, the server requires its own key and certificate, as well as the certificate of a client to trust. Similarly, the client requires its own key and certificate, as well as the certificate of a server to trust.
165+
166+
**For servers:**
167+
168+
.. code-block:: python
169+
170+
simple_server_loop(targets, host, port,
171+
local_cert="path/to/server.pem",
172+
local_key="path/to/server.key",
173+
peer_cert="path/to/client.pem")
174+
175+
**For clients:**
176+
177+
.. code-block:: python
178+
179+
client = Client(host, port,
180+
local_cert="path/to/client.pem",
181+
local_key="path/to/client.key",
182+
peer_cert="path/to/server.pem")

sipyco/broadcast.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22

33
from sipyco import keepalive, pyon
4-
from sipyco.asyncio_tools import AsyncioServer
4+
from sipyco.tools import AsyncioServer
55

66

77
_init_string = b"ARTIQ broadcast\n"

sipyco/logging_tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44

55
from sipyco import keepalive
6-
from sipyco.asyncio_tools import TaskObject, AsyncioServer
6+
from sipyco.tools import TaskObject, AsyncioServer
77

88

99
logging.TRACE = 5

sipyco/pc_rpc.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from operator import itemgetter
2121

2222
from sipyco import keepalive, pyon
23-
from sipyco.asyncio_tools import SignalHandler, AsyncioServer as _AsyncioServer
23+
from sipyco.tools import SignalHandler, AsyncioServer as _AsyncioServer, configure_ssl_context
2424
from sipyco.packed_exceptions import *
2525

2626
logger = logging.getLogger(__name__)
@@ -97,6 +97,9 @@ class Client:
9797
Use ``None`` to skip selecting a target. The list of targets can then
9898
be retrieved using :meth:`~sipyco.pc_rpc.Client.get_rpc_id`
9999
and then one can be selected later using :meth:`~sipyco.pc_rpc.Client.select_rpc_target`.
100+
:param local_cert: Client's certificate file. Providing this enables SSL.
101+
:param local_key: Client's private key file. Required when local_cert is provided.
102+
:param peer_cert: Server's SSL certificate file to trust. Required when local_cert is provided.
100103
:param timeout: Socket operation timeout. Use ``None`` for blocking
101104
(default), ``0`` for non-blocking, and a finite value to raise
102105
``socket.timeout`` if an operation does not complete within the
@@ -106,9 +109,12 @@ class Client:
106109
client).
107110
"""
108111

109-
def __init__(self, host, port, target_name=AutoTarget, timeout=None):
112+
def __init__(self, host, port, target_name=AutoTarget,
113+
local_cert=None, local_key=None, peer_cert=None, timeout=None):
110114
self.__socket = socket.create_connection((host, port), timeout)
111-
115+
if local_cert is not None:
116+
ssl_context = configure_ssl_context(local_cert, local_key, peer_cert)
117+
self.__socket = ssl_context.wrap_socket(self.__socket, server_hostname=host)
112118
try:
113119
self.__socket.sendall(_init_string)
114120

@@ -206,12 +212,17 @@ def __init__(self):
206212
self.__description = None
207213
self.__valid_methods = set()
208214

209-
async def connect_rpc(self, host, port, target_name=AutoTarget):
215+
async def connect_rpc(self, host, port, target_name=AutoTarget,
216+
local_cert=None, local_key=None, peer_cert=None):
210217
"""Connects to the server. This cannot be done in __init__ because
211218
this method is a coroutine. See :class:`sipyco.pc_rpc.Client` for a description of the
212219
parameters."""
220+
if local_cert is None:
221+
ssl_context = None
222+
else:
223+
ssl_context = configure_ssl_context(local_cert, local_key, peer_cert)
213224
self.__reader, self.__writer = \
214-
await keepalive.async_open_connection(host, port, limit=100 * 1024 * 1024)
225+
await keepalive.async_open_connection(host, port, ssl=ssl_context, limit=100 * 1024 * 1024)
215226
try:
216227
self.__writer.write(_init_string)
217228
server_identification = await self.__recv()
@@ -303,17 +314,22 @@ class BestEffortClient:
303314
RPC calls that failed because of network errors return ``None``. Other RPC
304315
calls are blocking and return the correct value.
305316
317+
See :class:`sipyco.pc_rpc.Client` for a description of the other parameters.
318+
306319
:param firstcon_timeout: Timeout to use during the first (blocking)
307320
connection attempt at object initialization.
308321
:param retry: Amount of time to wait between retries when reconnecting
309322
in the background.
310323
"""
311324

312-
def __init__(self, host, port, target_name,
313-
firstcon_timeout=1.0, retry=5.0):
325+
def __init__(self, host, port, target_name, local_cert=None,
326+
local_key=None, peer_cert=None, firstcon_timeout=1.0, retry=5.0):
314327
self.__host = host
315328
self.__port = port
316329
self.__target_name = target_name
330+
self.__local_cert = local_cert
331+
self.__local_key = local_key
332+
self.__peer_cert = peer_cert
317333
self.__retry = retry
318334

319335
self.__conretry_terminate = False
@@ -337,6 +353,9 @@ def __coninit(self, timeout):
337353
else:
338354
self.__socket = socket.create_connection(
339355
(self.__host, self.__port), timeout)
356+
if self.__local_cert is not None:
357+
ssl_context = configure_ssl_context(self.__local_cert, self.__local_key, self.__peer_cert)
358+
self.__socket = ssl_context.wrap_socket(self.__socket, server_hostname=self.__host)
340359
self.__socket.sendall(_init_string)
341360
server_identification = self.__recv()
342361
target_name = _validate_target_name(self.__target_name,
@@ -635,7 +654,8 @@ async def wait_terminate(self):
635654
await self._terminate_request.wait()
636655

637656

638-
def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None):
657+
def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None,
658+
local_cert=None, local_key=None, peer_cert=None):
639659
"""Runs a server until an exception is raised (e.g. the user hits Ctrl-C)
640660
or termination is requested by a client.
641661
@@ -651,7 +671,7 @@ def simple_server_loop(targets, host, port, description=None, allow_parallel=Fal
651671
signal_handler.setup()
652672
try:
653673
server = Server(targets, description, True, allow_parallel)
654-
used_loop.run_until_complete(server.start(host, port))
674+
used_loop.run_until_complete(server.start(host, port, local_cert, local_key, peer_cert))
655675
try:
656676
_, pending = used_loop.run_until_complete(asyncio.wait(
657677
[used_loop.create_task(signal_handler.wait_terminate()),

sipyco/sipyco_rpctool.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ def get_argparser():
1818
help="hostname or IP of the controller to connect to")
1919
parser.add_argument("port", metavar="PORT", type=int,
2020
help="TCP port to use to connect to the controller")
21+
parser.add_argument("--ssl", nargs=3, metavar=('CERT', 'KEY', 'PEER'),
22+
help="Enable SSL authentication: "
23+
"CERT: client certificate file, "
24+
"KEY: client private key, "
25+
"PEER: server certificate to trust")
2126
subparsers = parser.add_subparsers(dest="action")
2227
subparsers.add_parser("list-targets", help="list existing targets")
2328
parser_list_methods = subparsers.add_parser("list-methods",
@@ -97,8 +102,12 @@ def main():
97102
args = get_argparser().parse_args()
98103
if not args.action:
99104
args.target = None
105+
if args.ssl:
106+
cert, key, peer = args.ssl
107+
else:
108+
cert, key, peer = None, None, None
100109

101-
remote = Client(args.server, args.port, None)
110+
remote = Client(args.server, args.port, None, local_cert=cert, local_key=key, peer_cert=peer)
102111
targets, description = remote.get_rpc_id()
103112
if args.action != "list-targets":
104113
if not args.target:

sipyco/sync_struct.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import logging
1919

2020
from sipyco import keepalive, pyon
21-
from sipyco.asyncio_tools import AsyncioServer
21+
from sipyco.tools import AsyncioServer
2222

2323

2424
logger = logging.getLogger(__name__)

sipyco/asyncio_tools.py renamed to sipyco/tools.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import signal
33
import socket
4+
import ssl
45
import atexit
56
import collections
67
import logging
@@ -11,6 +12,30 @@
1112
logger = logging.getLogger(__name__)
1213

1314

15+
def configure_ssl_context(local_cert=None, local_key=None, peer_cert=None, server_mode=False):
16+
"""Configure an SSL context with mutual authentication.
17+
18+
:param local_cert: Certificate file. Providing this enables SSL.
19+
:param local_key: Private key file. Required when local_cert is provided.
20+
:param peer_cert: Peer's certificate file to trust. Required when local_cert is provided.
21+
:param server_mode: If True, create a server context, otherwise client context.
22+
"""
23+
if local_key is None:
24+
raise ValueError("local_key is required when local_cert is provided")
25+
if peer_cert is None:
26+
raise ValueError("peer_cert is required when local_cert is provided")
27+
28+
if server_mode:
29+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
30+
context.verify_mode = ssl.CERT_REQUIRED
31+
else:
32+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
33+
34+
context.load_verify_locations(cafile=peer_cert)
35+
context.load_cert_chain(local_cert, local_key)
36+
return context
37+
38+
1439
class TaskObject:
1540
def start(self, *, loop=None):
1641
"""loop must be specified unless this is called from a running event loop."""
@@ -47,7 +72,7 @@ class AsyncioServer:
4772
def __init__(self):
4873
self._client_tasks = set()
4974

50-
async def start(self, host, port):
75+
async def start(self, host, port, local_cert=None, local_key=None, peer_cert=None):
5176
"""Starts the server.
5277
5378
The user must call :meth:`stop`
@@ -58,9 +83,17 @@ async def start(self, host, port):
5883
:param host: Bind address of the server (see ``asyncio.start_server``
5984
from the Python standard library).
6085
:param port: TCP port to bind to.
86+
:param local_cert: Server's SSL certificate file. Providing this enables SSL.
87+
:param local_key: Server's private key file. Required when local_cert is provided.
88+
:param peer_cert: Client's SSL certificate file to trust. Required when local_cert is provided.
6189
"""
90+
if local_cert is None:
91+
ssl_context = None
92+
else:
93+
ssl_context = configure_ssl_context(local_cert, local_key, peer_cert, server_mode=True)
6294
self.server = await asyncio.start_server(self._handle_connection,
6395
host, port,
96+
ssl=ssl_context,
6497
limit=4*1024*1024)
6598

6699
async def stop(self):

0 commit comments

Comments
 (0)