Skip to content

Commit ddaf019

Browse files
authored
✨ add MySQL SSL certificate options (--mysql-ssl-ca, --mysql-ssl-cert, --mysql-ssl-key) (#128)
* Add MySQL SSL certificate options (--mysql-ssl-ca, --mysql-ssl-cert, --mysql-ssl-key) Add support for specifying SSL certificate, key, and CA certificate file paths when connecting to MySQL databases that require secure transport. This addresses connections where --require_secure_transport=ON is set on the MySQL server. Changes: - Add --mysql-ssl-ca, --mysql-ssl-cert, --mysql-ssl-key CLI options - Thread SSL params through types, transporter, and mysql.connector.connect - Update README.md and docs/README.rst - Add unit tests for SSL param passthrough, partial combinations, defaults - Add mysql_ssl_certs fixture to extract certs from Docker MySQL containers - Add functional SSL tests against real MySQL (skipped on versions without certs) * Address review feedback: add SSL validation and improve cert extraction * Fail fast on unexpected cert extraction errors instead of skipping * Enable ssl_verify_cert when CA is provided, document SSL constraints
1 parent 149a6b3 commit ddaf019

9 files changed

Lines changed: 660 additions & 3 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,15 @@ Options:
6969
--mysql-charset TEXT MySQL database and table character set
7070
[default: utf8mb4]
7171
--mysql-collation TEXT MySQL database and table collation
72+
--mysql-ssl-ca PATH Path to SSL CA certificate file.
73+
--mysql-ssl-cert PATH Path to SSL certificate file.
74+
Must be provided together with
75+
--mysql-ssl-key.
76+
--mysql-ssl-key PATH Path to SSL key file.
77+
Must be provided together with
78+
--mysql-ssl-cert.
7279
-S, --skip-ssl Disable MySQL connection encryption.
80+
Cannot be used with --mysql-ssl-* options.
7381
-c, --chunk INTEGER Chunk reading/writing SQL records
7482
-l, --log-file PATH Log file
7583
--json-as-text Transfer JSON columns as TEXT.

docs/README.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ Connection Options
4747
- ``-h, --mysql-host TEXT``: MySQL host. Defaults to localhost.
4848
- ``-P, --mysql-port INTEGER``: MySQL port. Defaults to 3306.
4949
- ``--mysql-charset TEXT``: MySQL database and table character set. The default is utf8mb4.
50-
- ``--mysql-collation TEXT``: MySQL database and table collation
51-
- ``-S, --skip-ssl``: Disable MySQL connection encryption.
50+
- ``--mysql-collation TEXT``: MySQL database and table collation.
51+
- ``--mysql-ssl-ca PATH``: Path to SSL CA certificate file.
52+
- ``--mysql-ssl-cert PATH``: Path to SSL certificate file. Must be provided together with ``--mysql-ssl-key``.
53+
- ``--mysql-ssl-key PATH``: Path to SSL key file. Must be provided together with ``--mysql-ssl-cert``.
54+
- ``-S, --skip-ssl``: Disable MySQL connection encryption. Cannot be used together with ``--mysql-ssl-*`` options.
5255

5356
Other Options
5457
"""""""""""""

src/mysql_to_sqlite3/cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,24 @@
137137
default=None,
138138
help="MySQL database and table collation",
139139
)
140+
@click.option(
141+
"--mysql-ssl-ca",
142+
type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True),
143+
default=None,
144+
help="Path to SSL CA certificate file.",
145+
)
146+
@click.option(
147+
"--mysql-ssl-cert",
148+
type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True),
149+
default=None,
150+
help="Path to SSL certificate file.",
151+
)
152+
@click.option(
153+
"--mysql-ssl-key",
154+
type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True),
155+
default=None,
156+
help="Path to SSL key file.",
157+
)
140158
@click.option("-S", "--skip-ssl", is_flag=True, help="Disable MySQL connection encryption.")
141159
@click.option(
142160
"-c",
@@ -184,6 +202,9 @@ def cli(
184202
mysql_port: int,
185203
mysql_charset: str,
186204
mysql_collation: str,
205+
mysql_ssl_ca: t.Optional[str],
206+
mysql_ssl_cert: t.Optional[str],
207+
mysql_ssl_key: t.Optional[str],
187208
skip_ssl: bool,
188209
chunk: int,
189210
log_file: t.Union[str, "os.PathLike[t.Any]"],
@@ -215,6 +236,16 @@ def cli(
215236
if mysql_tables is not None and exclude_mysql_tables is not None:
216237
raise click.UsageError("Illegal usage: --mysql-tables and --exclude-mysql-tables are mutually exclusive!")
217238

239+
if skip_ssl and any((mysql_ssl_ca, mysql_ssl_cert, mysql_ssl_key)):
240+
raise click.UsageError(
241+
"Illegal usage: --skip-ssl and --mysql-ssl-ca/--mysql-ssl-cert/--mysql-ssl-key are mutually exclusive!"
242+
)
243+
244+
if bool(mysql_ssl_cert) != bool(mysql_ssl_key):
245+
raise click.UsageError(
246+
"Illegal usage: --mysql-ssl-cert and --mysql-ssl-key must be provided together."
247+
)
248+
218249
converter = MySQLtoSQLite(
219250
sqlite_file=sqlite_file,
220251
mysql_user=mysql_user,
@@ -234,6 +265,9 @@ def cli(
234265
mysql_port=mysql_port,
235266
mysql_charset=mysql_charset,
236267
mysql_collation=mysql_collation,
268+
mysql_ssl_ca=mysql_ssl_ca,
269+
mysql_ssl_cert=mysql_ssl_cert,
270+
mysql_ssl_key=mysql_ssl_key,
237271
mysql_ssl_disabled=skip_ssl,
238272
chunk=chunk,
239273
json_as_text=json_as_text,

src/mysql_to_sqlite3/transporter.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,17 @@ def __init__(self, **kwargs: Unpack[MySQLtoSQLiteParams]) -> None:
110110
if self._without_tables and self._without_data:
111111
raise ValueError("Unable to continue without transferring data or creating tables!")
112112

113+
self._mysql_ssl_ca = kwargs.get("mysql_ssl_ca") or None
114+
self._mysql_ssl_cert = kwargs.get("mysql_ssl_cert") or None
115+
self._mysql_ssl_key = kwargs.get("mysql_ssl_key") or None
113116
self._mysql_ssl_disabled = bool(kwargs.get("mysql_ssl_disabled", False))
114117

118+
if self._mysql_ssl_disabled and any((self._mysql_ssl_ca, self._mysql_ssl_cert, self._mysql_ssl_key)):
119+
raise ValueError("Cannot use SSL certificate options when SSL is disabled")
120+
121+
if bool(self._mysql_ssl_cert) != bool(self._mysql_ssl_key):
122+
raise ValueError("mysql_ssl_cert and mysql_ssl_key must be provided together")
123+
115124
self._current_chunk_number = 0
116125

117126
self._chunk_size = kwargs.get("chunk") or None
@@ -161,6 +170,10 @@ def __init__(self, **kwargs: Unpack[MySQLtoSQLiteParams]) -> None:
161170
password=self._mysql_password,
162171
host=self._mysql_host,
163172
port=self._mysql_port,
173+
ssl_ca=self._mysql_ssl_ca,
174+
ssl_cert=self._mysql_ssl_cert,
175+
ssl_key=self._mysql_ssl_key,
176+
ssl_verify_cert=self._mysql_ssl_ca is not None,
164177
ssl_disabled=self._mysql_ssl_disabled,
165178
charset=self._mysql_charset,
166179
collation=self._mysql_collation,

src/mysql_to_sqlite3/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class MySQLtoSQLiteParams(TypedDict):
3232
mysql_port: int
3333
mysql_charset: t.Optional[str]
3434
mysql_collation: t.Optional[str]
35+
mysql_ssl_ca: t.Optional[str]
36+
mysql_ssl_cert: t.Optional[str]
37+
mysql_ssl_key: t.Optional[str]
3538
mysql_ssl_disabled: t.Optional[bool]
3639
mysql_tables: t.Optional[t.Sequence[str]]
3740
mysql_user: str
@@ -67,6 +70,9 @@ class MySQLtoSQLiteAttributes:
6770
_mysql_port: int
6871
_mysql_charset: str
6972
_mysql_collation: str
73+
_mysql_ssl_ca: t.Optional[str]
74+
_mysql_ssl_cert: t.Optional[str]
75+
_mysql_ssl_key: t.Optional[str]
7076
_mysql_ssl_disabled: bool
7177
_mysql_tables: t.Sequence[str]
7278
_mysql_user: str

tests/conftest.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import io
12
import json
23
import os
34
import socket
5+
import tarfile
46
import typing as t
57
from codecs import open
68
from contextlib import contextmanager
@@ -271,6 +273,84 @@ def mysql_instance(mysql_credentials: MySQLCredentials, pytestconfig: Config) ->
271273
container.kill()
272274

273275

276+
class MySQLSSLCerts(t.NamedTuple):
277+
"""Paths to MySQL SSL certificate files extracted from the Docker container."""
278+
279+
ca: str
280+
client_cert: str
281+
client_key: str
282+
283+
284+
@pytest.fixture(scope="session")
285+
def mysql_ssl_certs(
286+
mysql_instance: MySQLConnection,
287+
pytestconfig: Config,
288+
tmp_path_factory: pytest.TempPathFactory,
289+
) -> t.Optional[MySQLSSLCerts]:
290+
db_credentials_file = abspath(join(dirname(__file__), "db_credentials.json"))
291+
if isfile(db_credentials_file):
292+
return None
293+
294+
if not pytestconfig.getoption("use_docker"):
295+
return None
296+
297+
client: DockerClient = docker.from_env()
298+
try:
299+
container: t.Optional[Container] = None
300+
for c in client.containers.list():
301+
if c.name == "pytest_mysql_to_sqlite3":
302+
container = c
303+
break
304+
305+
if container is None:
306+
pytest.fail("MySQL test container is running, but SSL cert extraction could not find it")
307+
308+
ssl_dir = tmp_path_factory.mktemp("mysql_ssl_certs")
309+
310+
cert_files = {
311+
"ca.pem": "ca.pem",
312+
"client-cert.pem": "client-cert.pem",
313+
"client-key.pem": "client-key.pem",
314+
}
315+
316+
extracted: t.Dict[str, str] = {}
317+
for filename, dest_name in cert_files.items():
318+
try:
319+
data_stream, _stat = container.get_archive(f"/var/lib/mysql/{filename}")
320+
except NotFound:
321+
# Cert files not present - MySQL version likely doesn't auto-generate them
322+
return None
323+
324+
buf = io.BytesIO()
325+
for chunk in data_stream:
326+
buf.write(chunk)
327+
buf.seek(0)
328+
with tarfile.open(fileobj=buf) as tar:
329+
member = next(
330+
(m for m in tar.getmembers() if Path(m.name).name == filename),
331+
None,
332+
)
333+
if member is None:
334+
pytest.fail(f"Docker returned an archive for {filename}, but the file was not present")
335+
336+
fobj = tar.extractfile(member)
337+
if fobj is None:
338+
pytest.fail(f"Could not read {filename} from the Docker archive")
339+
340+
with fobj:
341+
dest_path = ssl_dir / dest_name
342+
dest_path.write_bytes(fobj.read())
343+
extracted[filename] = str(dest_path)
344+
345+
return MySQLSSLCerts(
346+
ca=extracted["ca.pem"],
347+
client_cert=extracted["client-cert.pem"],
348+
client_key=extracted["client-key.pem"],
349+
)
350+
finally:
351+
client.close()
352+
353+
274354
@pytest.fixture(scope="session")
275355
def mysql_database(
276356
tmpdir_factory: TempdirFactory,

0 commit comments

Comments
 (0)