Skip to content

Commit 27fec59

Browse files
committed
support for SSL protocol
1 parent 92b2f58 commit 27fec59

File tree

7 files changed

+272
-48
lines changed

7 files changed

+272
-48
lines changed

README.md

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# Ser2tcp
22

3-
Simple proxy for connecting over TCP or telnet to serial port
3+
Simple proxy for connecting over TCP, TELNET or SSL to serial port
44

55
https://github.com/cortexm/ser2tcp
66

77
## Features
88

99
- can serve multiple serial ports using pyserial library
1010
- each serial port can have multiple servers
11-
- server can use TCP or TELNET protocol
11+
- server can use TCP, TELNET or SSL protocol
1212
- TCP protocol just bridge whole RAW serial stream to TCP
1313
- TELNET protocol will send every character immediately and not wait for ENTER, it is useful to use standard `telnet` as serial terminal
14+
- SSL protocol provides encrypted TCP connection with optional mutual TLS (mTLS) client certificate verification
1415
- servers accepts multiple connections at one time
1516
- each connected client can sent to serial port
1617
- serial port send received data to all connected clients
@@ -119,6 +120,7 @@ Match attributes: `vid`, `pid`, `serial_number`, `manufacturer`, `product`, `loc
119120
- Wildcard `*` supported (e.g. `"product": "CP210*"`)
120121
- Matching is case-insensitive
121122
- Error if multiple devices match the criteria
123+
- Device is resolved when client connects, not at startup (device does not need to exist at startup)
122124
- `baudrate` is optional (default 9600, CDC devices ignore it)
123125

124126
### Server configuration
@@ -127,10 +129,82 @@ Match attributes: `vid`, `pid`, `serial_number`, `manufacturer`, `product`, `loc
127129
|-----------|-------------|---------|
128130
| `address` | Bind address | required |
129131
| `port` | TCP port | required |
130-
| `protocol` | `tcp` or `telnet` | required |
132+
| `protocol` | `tcp`, `telnet` or `ssl` | required |
133+
| `ssl` | SSL configuration (required for `ssl` protocol) | - |
131134
| `send_timeout` | Disconnect client if data cannot be sent within this time (seconds) | 5.0 |
132135
| `buffer_limit` | Maximum send buffer size per client (bytes), `null` for unlimited | null |
133136

137+
#### SSL configuration
138+
139+
For `ssl` protocol, add `ssl` object with certificate paths:
140+
141+
```json
142+
{
143+
"address": "0.0.0.0",
144+
"port": 10003,
145+
"protocol": "ssl",
146+
"ssl": {
147+
"certfile": "/path/to/server.crt",
148+
"keyfile": "/path/to/server.key",
149+
"ca_certs": "/path/to/ca.crt"
150+
}
151+
}
152+
```
153+
154+
| Parameter | Description | Required |
155+
|-----------|-------------|----------|
156+
| `certfile` | Server certificate (PEM) | yes |
157+
| `keyfile` | Server private key (PEM) | yes |
158+
| `ca_certs` | CA certificate for client verification (mTLS) | no |
159+
160+
If `ca_certs` is specified, clients must provide a valid certificate signed by the CA.
161+
162+
##### Creating self-signed certificates
163+
164+
Generate CA and server certificate for testing:
165+
166+
```bash
167+
# Create CA key and certificate
168+
openssl genrsa -out ca.key 2048
169+
openssl req -new -x509 -days 365 -key ca.key -out ca.crt -subj "/CN=ser2tcp CA" \
170+
-addext "basicConstraints=critical,CA:TRUE" \
171+
-addext "keyUsage=critical,keyCertSign,cRLSign"
172+
173+
# Create server key and certificate signing request
174+
openssl genrsa -out server.key 2048
175+
openssl req -new -key server.key -out server.csr -subj "/CN=localhost"
176+
177+
# Sign server certificate with CA
178+
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
179+
180+
# For certificate bound to specific domain/IP (SAN - Subject Alternative Name):
181+
openssl req -new -key server.key -out server.csr -subj "/CN=myserver.example.com" -addext "subjectAltName=DNS:myserver.example.com,DNS:localhost,IP:192.168.1.100"
182+
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -copy_extensions copy
183+
184+
# Clean up CSR
185+
rm server.csr
186+
```
187+
188+
For mTLS (mutual TLS with client certificates):
189+
190+
```bash
191+
# Create client key and certificate
192+
openssl genrsa -out client.key 2048
193+
openssl req -new -key client.key -out client.csr -subj "/CN=client"
194+
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
195+
rm client.csr
196+
```
197+
198+
Testing SSL connection:
199+
200+
```bash
201+
# Without client certificate
202+
openssl s_client -connect localhost:10003
203+
204+
# With client certificate (mTLS)
205+
openssl s_client -connect localhost:10003 -cert client.crt -key client.key
206+
```
207+
134208
## Usage examples
135209

136210
```

ser2tcp/connection_ssl.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Connection SSL"""
2+
3+
import ssl as _ssl
4+
5+
import ser2tcp.connection_tcp as _connection_tcp
6+
7+
8+
class SslHandshakeError(Exception):
9+
"""SSL handshake failed"""
10+
11+
12+
class ConnectionSsl(_connection_tcp.ConnectionTcp):
13+
"""SSL/TLS connection"""
14+
15+
def __init__(
16+
self, connection, ser, send_timeout=None, buffer_limit=None,
17+
log=None, ssl_context=None):
18+
sock, addr = connection
19+
self._socket = None
20+
try:
21+
ssl_sock = ssl_context.wrap_socket(sock, server_side=True)
22+
except _ssl.SSLError as err:
23+
sock.close()
24+
raise SslHandshakeError(f"SSL handshake failed: {err}") from err
25+
super().__init__(
26+
(ssl_sock, addr), ser, send_timeout, buffer_limit, log)
27+
28+
def _log_connected(self):
29+
self._log.info("Client connected: %s:%d SSL", *self._addr)

ser2tcp/connection_tcp.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55

66
class ConnectionTcp(_connection.Connection):
77
"""TCP connection"""
8+
89
def __init__(
910
self, connection, ser, send_timeout=None, buffer_limit=None,
1011
log=None):
1112
super().__init__(connection, send_timeout, buffer_limit, log)
1213
self._serial = ser
14+
self._log_connected()
15+
16+
def _log_connected(self):
1317
self._log.info("Client connected: %s:%d TCP", *self._addr)
1418

1519
def on_received(self, data):

ser2tcp/serial_proxy.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ def __init__(self, config, log=None):
3636
self._log = log if log else _logging.Logger(self.__class__.__name__)
3737
self._serial = None
3838
self._servers = []
39-
self._serial_config = self.fix_serial_config(config['serial'])
39+
self._serial_config = self._init_serial_config(config['serial'])
40+
self._match = self._serial_config.pop('match', None)
41+
port = self._serial_config.get('port')
4042
baudrate = self._serial_config.get('baudrate')
43+
name = port if port else f"match:{self._match}"
4144
if baudrate:
42-
self._log.info(
43-
"Serial: %s %d", self._serial_config['port'], baudrate)
45+
self._log.info("Serial: %s %d", name, baudrate)
4446
else:
45-
self._log.info("Serial: %s", self._serial_config['port'])
47+
self._log.info("Serial: %s", name)
4648
for server_config in config['servers']:
4749
self._servers.append(_server.Server(server_config, self, log))
4850

49-
def fix_serial_config(self, config):
50-
"""Fix serial configuration - resolve match, convert enum values"""
51-
if 'port' not in config:
52-
if 'match' not in config:
53-
raise ValueError("Serial config must have 'port' or 'match'")
54-
config['port'] = self.find_port_by_match(config.pop('match'))
51+
def _init_serial_config(self, config):
52+
"""Initialize serial configuration - validate and convert enum values"""
53+
if 'port' not in config and 'match' not in config:
54+
raise ValueError("Serial config must have 'port' or 'match'")
5555
if 'parity' in config:
5656
for key, val in self.PARITY_CONFIG.items():
5757
if config['parity'] == key:
@@ -106,6 +106,13 @@ def __del__(self):
106106
def connect(self):
107107
"""Connect to serial port"""
108108
if not self._serial:
109+
if self._match:
110+
try:
111+
self._serial_config['port'] = self.find_port_by_match(
112+
self._match)
113+
except ValueError as err:
114+
self._log.warning(err)
115+
return False
109116
try:
110117
self._serial = _serial.Serial(**self._serial_config)
111118
except (_serial.SerialException, OSError) as err:

ser2tcp/server.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
# pylint: disable=C0209
44

5-
import socket as _socket
65
import logging as _logging
6+
import socket as _socket
7+
import ssl as _ssl
8+
9+
import ser2tcp.connection_ssl as _connection_ssl
710
import ser2tcp.connection_tcp as _connection_tcp
811
import ser2tcp.connection_telnet as _connection_telnet
912

@@ -18,6 +21,7 @@ class Server():
1821
CONNECTIONS = {
1922
'TCP': _connection_tcp.ConnectionTcp,
2023
'TELNET': _connection_telnet.ConnectionTelnet,
24+
'SSL': _connection_ssl.ConnectionSsl,
2125
}
2226

2327
def __init__(self, config, ser, log=None):
@@ -28,6 +32,7 @@ def __init__(self, config, ser, log=None):
2832
self._protocol = self._config['protocol'].upper()
2933
self._send_timeout = self._config.get('send_timeout')
3034
self._buffer_limit = self._config.get('buffer_limit')
35+
self._ssl_context = None
3136
self._socket = None
3237
self._log.info(
3338
" Server: %s %d %s",
@@ -36,6 +41,8 @@ def __init__(self, config, ser, log=None):
3641
self._protocol)
3742
if self._protocol not in self.CONNECTIONS:
3843
raise ConfigError('Unknown protocol %s' % self._protocol)
44+
if self._protocol == 'SSL':
45+
self._ssl_context = self._create_ssl_context()
3946
self._socket = _socket.socket(
4047
_socket.AF_INET, _socket.SOCK_STREAM, _socket.IPPROTO_TCP)
4148
self._socket.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
@@ -45,21 +52,40 @@ def __init__(self, config, ser, log=None):
4552
def __del__(self):
4653
self.close()
4754

55+
def _create_ssl_context(self):
56+
"""Create SSL context from config"""
57+
ssl_config = self._config.get('ssl', {})
58+
certfile = ssl_config.get('certfile')
59+
keyfile = ssl_config.get('keyfile')
60+
ca_certs = ssl_config.get('ca_certs')
61+
if not certfile or not keyfile:
62+
raise ConfigError('SSL protocol requires certfile and keyfile')
63+
context = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
64+
context.load_cert_chain(certfile, keyfile)
65+
if ca_certs:
66+
context.load_verify_locations(ca_certs)
67+
context.verify_mode = _ssl.CERT_REQUIRED
68+
return context
69+
4870
def _client_connect(self):
4971
"""connect to client, will accept waiting connection"""
5072
sock, addr = self._socket.accept()
51-
if not self._connections:
52-
if not self._serial.connect():
53-
self._log.info("Client canceled: %s:%d", *addr)
54-
sock.close()
55-
return
56-
connection = self.CONNECTIONS[self._protocol](
57-
connection=(sock, addr),
58-
ser=self._serial,
59-
send_timeout=self._send_timeout,
60-
buffer_limit=self._buffer_limit,
61-
log=self._log,
62-
)
73+
kwargs = {
74+
'connection': (sock, addr),
75+
'ser': self._serial,
76+
'send_timeout': self._send_timeout,
77+
'buffer_limit': self._buffer_limit,
78+
'log': self._log,
79+
}
80+
if self._ssl_context:
81+
kwargs['ssl_context'] = self._ssl_context
82+
try:
83+
connection = self.CONNECTIONS[self._protocol](**kwargs)
84+
except _connection_ssl.SslHandshakeError as err:
85+
self._log.info("Client rejected: %s:%d (%s)", *addr, err)
86+
if not self._connections:
87+
self._serial.disconnect()
88+
return
6389
if self._serial.connect():
6490
self._connections.append(connection)
6591
else:
@@ -113,7 +139,7 @@ def process_read(self, read_sockets):
113139
try:
114140
data = con.socket().recv(4096)
115141
self._log.debug("(%s:%d): %s", *con.get_address(), data)
116-
except ConnectionResetError as err:
142+
except (ConnectionResetError, _ssl.SSLError) as err:
117143
self._log.info("(%s:%d): %s", *con.get_address(), err)
118144
if not data:
119145
self._remove_connection(con)

tests/test_connection_ssl.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for ConnectionSsl class"""
2+
3+
import unittest
4+
import unittest.mock
5+
from unittest.mock import Mock
6+
7+
from ser2tcp.connection_ssl import ConnectionSsl
8+
9+
10+
class MockSocket:
11+
"""Mock socket for testing"""
12+
def __init__(self):
13+
self.sent_data = bytearray()
14+
self.closed = False
15+
self._fileno = 5
16+
17+
def send(self, data):
18+
self.sent_data.extend(data)
19+
return len(data)
20+
21+
def close(self):
22+
self.closed = True
23+
24+
def fileno(self):
25+
return self._fileno
26+
27+
28+
class TestConnectionSsl(unittest.TestCase):
29+
def _make_connection(self):
30+
"""Helper to create ConnectionSsl with mock socket"""
31+
mock_socket = MockSocket()
32+
mock_ssl_socket = MockSocket()
33+
addr = ('127.0.0.1', 12345)
34+
mock_serial = Mock()
35+
log = Mock()
36+
mock_context = Mock()
37+
mock_context.wrap_socket.return_value = mock_ssl_socket
38+
conn = ConnectionSsl(
39+
(mock_socket, addr),
40+
mock_serial,
41+
log=log,
42+
ssl_context=mock_context)
43+
return conn, mock_serial, mock_context, mock_socket
44+
45+
def test_wraps_socket_with_ssl_context(self):
46+
"""SSL context should wrap the socket"""
47+
conn, _, context, raw_socket = self._make_connection()
48+
context.wrap_socket.assert_called_once_with(
49+
raw_socket, server_side=True)
50+
51+
def test_log_connected_shows_ssl(self):
52+
"""Log message should show SSL protocol"""
53+
mock_socket = MockSocket()
54+
mock_ssl_socket = MockSocket()
55+
addr = ('127.0.0.1', 12345)
56+
mock_serial = Mock()
57+
log = Mock()
58+
mock_context = Mock()
59+
mock_context.wrap_socket.return_value = mock_ssl_socket
60+
conn = ConnectionSsl(
61+
(mock_socket, addr),
62+
mock_serial,
63+
log=log,
64+
ssl_context=mock_context)
65+
# Check that SSL was logged (first call, before any disconnect)
66+
first_call = log.info.call_args_list[0]
67+
self.assertEqual(
68+
first_call,
69+
unittest.mock.call(
70+
"Client connected: %s:%d SSL", '127.0.0.1', 12345))
71+
conn.close()
72+
73+
def test_on_received_forwards_to_serial(self):
74+
"""Data should be forwarded to serial"""
75+
conn, serial, _, _ = self._make_connection()
76+
conn.on_received(b'hello')
77+
serial.send.assert_called_once_with(b'hello')
78+
79+
def test_send_adds_to_buffer(self):
80+
"""Send should add data to buffer"""
81+
conn, _, _, _ = self._make_connection()
82+
result = conn.send(b'test')
83+
self.assertEqual(result, 4)
84+
self.assertTrue(conn.has_pending_data())
85+
86+
87+
if __name__ == "__main__":
88+
unittest.main()

0 commit comments

Comments
 (0)