Skip to content

Commit 9b2499b

Browse files
committed
SSL context handling, #318
1 parent 18a6d32 commit 9b2499b

2 files changed

Lines changed: 93 additions & 29 deletions

File tree

mktxp/flow/router_connection.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,11 @@ def wrapper(self, *args, **kwargs):
5454

5555
class RouterAPIConnection:
5656
''' Base wrapper interface for the routeros_api library
57-
'''
57+
'''
5858
def __init__(self, router_name, config_entry):
5959
self.router_name = router_name
6060
self.config_entry = config_entry
6161
self.last_failure_timestamp = self.successive_failure_count = 0
62-
63-
ctx = None
64-
if self.config_entry.use_ssl:
65-
ctx = ssl.create_default_context(
66-
cafile=self.config_entry.ssl_ca_file if self.config_entry.ssl_ca_file else None
67-
)
68-
if self.config_entry.no_ssl_certificate:
69-
ctx.check_hostname = False
70-
ctx.set_ciphers('ADH:@SECLEVEL=0')
71-
elif not self.config_entry.ssl_certificate_verify:
72-
ctx.check_hostname = False
73-
ctx.verify_mode = ssl.CERT_NONE
74-
else:
75-
ctx.check_hostname = self.config_entry.ssl_check_hostname
76-
ctx.verify_mode = ssl.CERT_REQUIRED
7762

7863
username = self.config_entry.username
7964
password = self.config_entry.password
@@ -85,7 +70,7 @@ def __init__(self, router_name, config_entry):
8570
raise yaml.YAMLError("Credentials file is not a valid key-value map.")
8671
except yaml.YAMLError as e:
8772
print(f"Error parsing credentials file: {e}\nCheck that it is valid YAML (e.g., 'username: user').")
88-
exit(1)
73+
exit(1)
8974
username = credentials.get('username', username)
9075
password = credentials.get('password', password)
9176

@@ -95,11 +80,30 @@ def __init__(self, router_name, config_entry):
9580
password = password,
9681
port = self.config_entry.port,
9782
plaintext_login = True,
98-
ssl_context = ctx)
99-
83+
ssl_context = None)
84+
10085
self.connection.socket_timeout = config_handler.system_entry.socket_timeout
10186
self.api = None
102-
87+
88+
def _build_ssl_context(self):
89+
if not self.config_entry.use_ssl:
90+
return None
91+
92+
ctx = ssl.create_default_context(
93+
cafile = self.config_entry.ssl_ca_file if self.config_entry.ssl_ca_file else None
94+
)
95+
if self.config_entry.no_ssl_certificate:
96+
ctx.check_hostname = False
97+
ctx.set_ciphers('ADH:@SECLEVEL=0')
98+
elif not self.config_entry.ssl_certificate_verify:
99+
ctx.check_hostname = False
100+
ctx.verify_mode = ssl.CERT_NONE
101+
else:
102+
ctx.check_hostname = self.config_entry.ssl_check_hostname
103+
ctx.verify_mode = ssl.CERT_REQUIRED
104+
105+
return ctx
106+
103107
def is_connected(self):
104108
if self.connection and self.connection.connected and self.api:
105109
return True
@@ -113,6 +117,8 @@ def connect(self):
113117
print(f'Connecting to router {self.router_name}@{self.config_entry.hostname}')
114118
if self.config_entry.use_ssl and self.config_entry.no_ssl_certificate:
115119
print(f'Warning: API_SSL connect without router SSL certificate is insecure and should not be used in production environments!')
120+
if self.config_entry.use_ssl:
121+
self.connection.ssl_context = self._build_ssl_context()
116122
self.connection.plaintext_login = self.config_entry.plaintext_login
117123
self.api = self.connection.get_api()
118124
self._set_connect_state(success = True, connect_time = connect_time)
@@ -132,11 +138,11 @@ def router_api(self):
132138
def _in_connect_timeout(self, connect_timestamp):
133139
connect_delay = self._connect_delay()
134140
if (connect_timestamp - self.last_failure_timestamp) < connect_delay:
135-
if config_handler.system_entry.verbose_mode:
141+
if config_handler.system_entry.verbose_mode:
136142
print(f'{self.router_name}@{self.config_entry.hostname}: in connect timeout, {int(connect_delay - (connect_timestamp - self.last_failure_timestamp))}secs remaining')
137143
print(f'Successive failure count: {self.successive_failure_count}')
138144
return True
139-
if config_handler.system_entry.verbose_mode:
145+
if config_handler.system_entry.verbose_mode:
140146
print(f'{self.router_name}@{self.config_entry.hostname}: OK to connect')
141147
if self.last_failure_timestamp > 0:
142148
print(f'Seconds since last failure: {connect_timestamp - self.last_failure_timestamp}')
@@ -157,5 +163,5 @@ def _set_connect_state(self, success = False, connect_time = datetime.now(), exc
157163
else:
158164
self.api = None
159165
self.successive_failure_count += 1
160-
self.last_failure_timestamp = connect_time.timestamp()
166+
self.last_failure_timestamp = connect_time.timestamp()
161167
print(f'{connect_time.strftime("%Y-%m-%d %H:%M:%S")} Connection to router {self.router_name}@{self.config_entry.hostname} has failed: {exc}')

tests/flow/test_router_connection.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@
1313

1414
import pytest
1515
import routeros_api.api_structure
16+
from types import SimpleNamespace
17+
from unittest.mock import MagicMock, patch
1618
# monkey patch via importing the module
1719
import mktxp.flow.router_connection
20+
from mktxp.flow.router_connection import RouterAPIConnection, RouterAPIConnectionError
1821

1922
@pytest.mark.parametrize("input_bytes, expected_string", [
20-
(b'But are these differences really that small?', 'But are these differences really that small?'),
21-
(b'\xe4\xbd\x86\xe6\x98\xaf\xef\xbc\x8c\xe8\xbf\x99\xe4\xba\x9b\xe5\xb7\xae\xe5\xbc\x82\xe7\x9c\x9f\xe7\x9a\x84\xe9\x82\xa3\xe4\xb9\x88\xe5\xb0\x8f\xe5\x90\x97\xef\xbc\x9f', '但是,这些差异真的那么小吗?'),
22-
(b'Mas ser\xc3\xa3o estas diferen\xc3\xa7as assim t\xc3\xa3o pequenas?', 'Mas serão estas diferenças assim tão pequenas?'),
23-
(b'\xd0\x9d\xd0\xbe \xd0\xb4\xd0\xb5\xd0\xb9\xd1\x81\xd1\x82\xd0\xb2\xd0\xb8\xd1\x82\xd0\xb5\xd0\xbb\xd1\x8c\xd0\xbd\xd0\xbe \xd0\xbb\xd0\xb8 \xd1\x8d\xd1\x82\xd0\xb8 \xd1\x80\xd0\xb0\xd0\xb7\xd0\xbb\xd0\xb8\xd1\x87\xd0\xb8\xd1\x8f \xd1\x82\xd0\xb0\xd0\xba \xd0\xbc\xd0\xb0\xd0\xbb\xd1\x8b?', 'Но действительно ли эти различия так малы?'),
24-
(b'\xd0\x90\xd0\xbb\xd0\xb5 \xd1\x87\xd0\xb8 \xd1\x81\xd0\xbf\xd1\x80\xd0\xb0\xd0\xb2\xd0\xb4\xd1\x96 \xd1\x86\xd1\x96 \xd0\xb2\xd1\x96\xd0\xb4\xd0\xbc\xd1\x96\xd0\xbd\xd0\xbd\xd0\xbe\xd1\x81\xd1\x82\xd1\x96 \xd1\x82\xd0\xb0\xd0\xba\xd1\x96 \xd0\xbd\xd0\xb5\xd0\xb7\xd0\xbd\xd0\xb0\xd1\x87\xd0\xbd\xd1\x96?', 'Але чи справді ці відмінності такі незначні?'),
25-
(b'Jsou v\xc5\xa1ak tyto rozd\xc3\xadly opravdu tak mal\xc3\xa9?', 'Jsou však tyto rozdíly opravdu tak malé?'),
23+
(b'But are these differences really that small?', 'But are these differences really that small?'),
24+
(b'\xe4\xbd\x86\xe6\x98\xaf\xef\xbc\x8c\xe8\xbf\x99\xe4\xba\x9b\xe5\xb7\xae\xe5\xbc\x82\xe7\x9c\x9f\xe7\x9a\x84\xe9\x82\xa3\xe4\xb9\x88\xe5\xb0\x8f\xe5\x90\x97\xef\xbc\x9f', '但是,这些差异真的那么小吗?'),
25+
(b'Mas ser\xc3\xa3o estas diferen\xc3\xa7as assim t\xc3\xa3o pequenas?', 'Mas serão estas diferenças assim tão pequenas?'),
26+
(b'\xd0\x9d\xd0\xbe \xd0\xb4\xd0\xb5\xd0\xb9\xd1\x81\xd1\x82\xd0\xb2\xd0\xb8\xd1\x82\xd0\xb5\xd0\xbb\xd1\x8c\xd0\xbd\xd0\xbe \xd0\xbb\xd0\xb8 \xd1\x8d\xd1\x82\xd0\xb8 \xd1\x80\xd0\xb0\xd0\xb7\xd0\xbb\xd0\xb8\xd1\x87\xd0\xb8\xd1\x8f \xd1\x82\xd0\xb0\xd0\xba \xd0\xbc\xd0\xb0\xd0\xbb\xd1\x8b?', 'Но действительно ли эти различия так малы?'),
27+
(b'\xd0\x90\xd0\xbb\xd0\xb5 \xd1\x87\xd0\xb8 \xd1\x81\xd0\xbf\xd1\x80\xd0\xb0\xd0\xb2\xd0\xb4\xd1\x96 \xd1\x86\xd1\x96 \xd0\xb2\xd1\x96\xd0\xb4\xd0\xbc\xd1\x96\xd0\xbd\xd0\xbd\xd0\xbe\xd1\x81\xd1\x82\xd1\x96 \xd1\x82\xd0\xb0\xd0\xba\xd1\x96 \xd0\xbd\xd0\xb5\xd0\xb7\xd0\xbd\xd0\xb0\xd1\x87\xd0\xbd\xd1\x96?', 'Але чи справді ці відмінності такі незначні?'),
28+
(b'Jsou v\xc5\xa1ak tyto rozd\xc3\xadly opravdu tak mal\xc3\xa9?', 'Jsou však tyto rozdíly opravdu tak malé?'),
2629
(b'\xa1\xa3', '¡£'), # Some latin-1 characters
2730
(b'J\xf6rgensen', 'Jörgensen'),
2831
(b'fran\xe7aise', 'française'),
@@ -33,3 +36,58 @@
3336
def test_decode_bytes_monkey_patch(input_bytes, expected_string):
3437
field = routeros_api.api_structure.StringField()
3538
assert field.get_python_value(input_bytes) == expected_string
39+
40+
41+
def _config_entry(use_ssl = True):
42+
return SimpleNamespace(
43+
hostname = '192.0.2.1',
44+
port = 8729,
45+
username = 'user',
46+
password = 'pass',
47+
credentials_file = '',
48+
use_ssl = use_ssl,
49+
no_ssl_certificate = False,
50+
ssl_certificate_verify = False,
51+
ssl_check_hostname = True,
52+
ssl_ca_file = '',
53+
plaintext_login = True,
54+
)
55+
56+
57+
def test_router_connection_builds_fresh_ssl_context_for_each_connect_attempt():
58+
ctx1 = MagicMock()
59+
ctx2 = MagicMock()
60+
pool = MagicMock()
61+
pool.connected = False
62+
pool.get_api.side_effect = [Exception('ssl boom'), object()]
63+
64+
with patch('mktxp.flow.router_connection.RouterOsApiPool', return_value = pool) as routeros_pool, \
65+
patch('mktxp.flow.router_connection.ssl.create_default_context', side_effect = [ctx1, ctx2]):
66+
connection = RouterAPIConnection('router-a', _config_entry())
67+
68+
# SSL context should no longer be created eagerly in __init__.
69+
assert routeros_pool.call_args.kwargs['ssl_context'] is None
70+
71+
connection._in_connect_timeout = lambda *_: False
72+
73+
with pytest.raises(RouterAPIConnectionError):
74+
connection.connect()
75+
assert pool.ssl_context is ctx1
76+
77+
connection.connect()
78+
assert pool.ssl_context is ctx2
79+
80+
81+
def test_router_connection_skips_ssl_context_building_when_ssl_disabled():
82+
pool = MagicMock()
83+
pool.connected = False
84+
pool.get_api.return_value = object()
85+
86+
with patch('mktxp.flow.router_connection.RouterOsApiPool', return_value = pool) as routeros_pool, \
87+
patch('mktxp.flow.router_connection.ssl.create_default_context') as create_context:
88+
connection = RouterAPIConnection('router-a', _config_entry(use_ssl = False))
89+
connection._in_connect_timeout = lambda *_: False
90+
connection.connect()
91+
92+
create_context.assert_not_called()
93+
assert routeros_pool.call_args.kwargs['ssl_context'] is None

0 commit comments

Comments
 (0)