From c93a7b0c00a6d8f0efcd48a4de98ec01cb31ac5b Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Tue, 23 Jun 2026 23:50:59 +0300 Subject: [PATCH] feat: EasyRSA subprocess wrappers (revoke, build, gen-crl, copy-crl) Co-Authored-By: Claude Sonnet 4.6 --- openvpncertupdate.py | 58 +++++++++++++++++++++++++++++++++++++ tests/test_easyrsa.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/test_easyrsa.py diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 4b5b2ee..910cf2e 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -149,6 +149,64 @@ def generate_password() -> str: return "".join(pw) +# ============================================================ +# === EASYRSA === +# ============================================================ + + +class EasyRSAError(Exception): + pass + + +def _run_easyrsa(cmd: list[str], cwd: str) -> None: + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + # Find the first non-flag argument after the binary (the actual easyrsa verb) + verb = next((c for c in cmd[1:] if not c.startswith("-")), "unknown") + raise EasyRSAError( + f"{verb} failed (exit {result.returncode}): {result.stderr.strip()}" + ) + + +def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> list[str]: + cmd = [f"{easyrsa_dir}/easyrsa", "--batch", f"--pki={pki_dir}"] + if ca_passphrase: + cmd.append(f"--passin=pass:{ca_passphrase}") + return cmd + + +def revoke_issued( + easyrsa_dir: str, pki_dir: str, cn: str, ca_passphrase: str +) -> None: + _run_easyrsa( + _base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + ["revoke-issued", cn], + cwd=easyrsa_dir, + ) + + +def build_client_full( + easyrsa_dir: str, pki_dir: str, cn: str, + key_passphrase: str, ca_passphrase: str, +) -> None: + _run_easyrsa( + _base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + + [f"--passout=pass:{key_passphrase}", "build-client-full", cn], + cwd=easyrsa_dir, + ) + + +def gen_crl(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> None: + _run_easyrsa( + _base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + ["gen-crl"], + cwd=easyrsa_dir, + ) + + +def copy_crl(pki_dir: str, dest_path: str) -> None: + shutil.copy2(f"{pki_dir}/crl.pem", dest_path) + os.chmod(dest_path, 0o644) + + # (remaining sections added in later tasks) # ============================================================ diff --git a/tests/test_easyrsa.py b/tests/test_easyrsa.py new file mode 100644 index 0000000..5e91b5d --- /dev/null +++ b/tests/test_easyrsa.py @@ -0,0 +1,67 @@ +import pytest +from unittest.mock import patch, MagicMock +from openvpncertupdate import ( + revoke_issued, build_client_full, gen_crl, copy_crl, EasyRSAError, +) + + +def ok_result(): + r = MagicMock(); r.returncode = 0; r.stderr = ""; return r + +def err_result(): + r = MagicMock(); r.returncode = 1; r.stderr = "oops"; return r + + +@patch("openvpncertupdate.subprocess.run") +def test_revoke_includes_required_args(mock_run): + mock_run.return_value = ok_result() + revoke_issued("/er", "/pki", "alice", "capass") + args = mock_run.call_args[0][0] + assert args[0] == "/er/easyrsa" + assert "--batch" in args + assert "--pki=/pki" in args + assert "--passin=pass:capass" in args + assert "revoke-issued" in args + assert "alice" in args + + +@patch("openvpncertupdate.subprocess.run") +def test_revoke_omits_passin_when_empty(mock_run): + mock_run.return_value = ok_result() + revoke_issued("/er", "/pki", "alice", "") + args = mock_run.call_args[0][0] + assert not any(a.startswith("--passin") for a in args) + + +@patch("openvpncertupdate.subprocess.run") +def test_revoke_raises_on_failure(mock_run): + mock_run.return_value = err_result() + with pytest.raises(EasyRSAError, match="revoke-issued"): + revoke_issued("/er", "/pki", "alice", "") + + +@patch("openvpncertupdate.subprocess.run") +def test_build_client_full_args(mock_run): + mock_run.return_value = ok_result() + build_client_full("/er", "/pki", "bob", "keypass", "capass") + args = mock_run.call_args[0][0] + assert "--passout=pass:keypass" in args + assert "--passin=pass:capass" in args + assert "build-client-full" in args + assert "bob" in args + + +@patch("openvpncertupdate.subprocess.run") +def test_gen_crl_args(mock_run): + mock_run.return_value = ok_result() + gen_crl("/er", "/pki", "capass") + args = mock_run.call_args[0][0] + assert "gen-crl" in args + + +@patch("openvpncertupdate.shutil.copy2") +@patch("openvpncertupdate.os.chmod") +def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy): + copy_crl("/pki", "/etc/openvpn/crl.pem") + mock_copy.assert_called_once_with("/pki/crl.pem", "/etc/openvpn/crl.pem") + mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)