diff --git a/openvpncertupdate.py b/openvpncertupdate.py index b96e836..b3316ee 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -75,6 +75,10 @@ SMTP_TLS = "starttls" # "starttls" | "ssl" | "" (plain) DAYS_PAST = 30 DAYS_AHEAD = 14 +CERT_DAYS = "default" # lifetime of certs this tool issues, in days. + # "default" or "" = leave it to EasyRSA (your vars' + # EASYRSA_CERT_EXPIRE). A positive integer overrides + # it — e.g. 90. # Names an external .conf file is allowed to override — keep in sync with the # SETTINGS block above. Deliberately excludes CONFIG_PATH itself. @@ -85,7 +89,7 @@ _OVERRIDABLE_SETTINGS = frozenset({ "CRYPTGEON_URL", "MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY", "SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS", - "DAYS_PAST", "DAYS_AHEAD", + "DAYS_PAST", "DAYS_AHEAD", "CERT_DAYS", }) @@ -413,6 +417,37 @@ def resolve_ca_passphrase( return configured +def resolve_cert_days(configured, name: str = "CERT_DAYS") -> str: + """Resolve a configured certificate lifetime into an EasyRSA --days value. + + "default" / "" → "" (omit --days; EasyRSA's EASYRSA_CERT_EXPIRE wins) + positive number → that many days, normalised ("090" → "90") + + Rejects 0, negatives and non-numeric input. EasyRSA rejects those itself, + but failing here keeps the error next to the input the user actually typed + — in the TUI that means the form is still open, and on the CLI it means no + CA passphrase prompt and no subprocess round-trip first. + + `name` names the source ("CERT_DAYS", "--days", "Days") for the message. + """ + text = str(configured).strip() + if text in ("", "default"): + return "" + try: + days = int(text) + except ValueError: + raise ConfigError( + f'{name} must be a positive number of days, "default" or "" ' + f'(inherit from EasyRSA); got {configured!r}' + ) + if days <= 0: + raise ConfigError( + f"{name} must be a positive number of days " + f"(EasyRSA rejects 0); got {configured!r}" + ) + return str(days) + + # ============================================================ # === CONFIG === # ============================================================ diff --git a/tests/test_config_file.py b/tests/test_config_file.py index 0e0d997..96487b9 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -60,3 +60,10 @@ def test_comments_and_non_string_types_supported(tmp_path): ) overrides = load_settings_overrides(None, "", _script_path(tmp_path)) assert overrides == {"SMTP_PORT": 2525, "CA_PASSPHRASE": ""} + + +def test_cert_days_is_overridable(tmp_path): + conf = tmp_path / "app.conf" + conf.write_text('CERT_DAYS = 90\n') + out = load_settings_overrides(str(conf), "", str(tmp_path / "app.py")) + assert out["CERT_DAYS"] == 90 diff --git a/tests/test_easyrsa.py b/tests/test_easyrsa.py index 1c3b5ae..f72a0d8 100644 --- a/tests/test_easyrsa.py +++ b/tests/test_easyrsa.py @@ -4,7 +4,7 @@ import pytest from unittest.mock import patch, MagicMock from openvpncertupdate import ( revoke_issued, build_client_full, gen_crl, copy_crl, EasyRSAError, - is_ca_key_encrypted, resolve_ca_passphrase, + is_ca_key_encrypted, resolve_ca_passphrase, resolve_cert_days, ConfigError, has_issued_cert, issued_cert_path, ) @@ -244,3 +244,45 @@ def test_resolve_literal_passphrase_passed_through_unchanged(tmp_path): result = resolve_ca_passphrase("mysecret", str(tmp_path), prompt=prompt) assert result == "mysecret" prompt.assert_not_called() + + +# --------------------------------------------------------------------------- +# resolve_cert_days +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("configured", ["default", "", " ", " default "]) +def test_resolve_cert_days_sentinels_mean_inherit(configured): + # "" and "default" leave the lifetime to EasyRSA's own EASYRSA_CERT_EXPIRE, + # so deploying this cannot silently shorten certs on an existing PKI. + assert resolve_cert_days(configured) == "" + + +@pytest.mark.parametrize("configured,expected", [ + (90, "90"), ("90", "90"), (" 90 ", "90"), (1, "1"), (3650, "3650"), +]) +def test_resolve_cert_days_accepts_positive_numbers(configured, expected): + assert resolve_cert_days(configured) == expected + + +def test_resolve_cert_days_normalises_leading_zeros(): + # EasyRSA's own pattern rejects "090" with "Number expected", which reads + # as nonsense to someone who typed only digits. + assert resolve_cert_days("090") == "90" + + +def test_resolve_cert_days_rejects_zero(): + with pytest.raises(ConfigError, match="positive"): + resolve_cert_days("0") + + +@pytest.mark.parametrize("configured", ["-5", -5, "abc", "9 0", "90.5", 90.5, None]) +def test_resolve_cert_days_rejects_junk(configured): + with pytest.raises(ConfigError): + resolve_cert_days(configured) + + +def test_resolve_cert_days_error_names_the_source(): + # The same resolver serves CERT_DAYS, --days and the TUI field; the message + # must say which one the user actually touched. + with pytest.raises(ConfigError, match="--days"): + resolve_cert_days("0", "--days")