From f0db11597af788519c2e852b14b2151d099ebd71 Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Wed, 24 Jun 2026 12:33:25 +0300 Subject: [PATCH] fix: populate cert form email from index.txt emailAddress field on reissue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _parse_index_line now extracts the emailAddress segment from the DN alongside CN, returning a (cn, expiry, email) triple. CertInfo gains an email field populated by both load functions. In _main(), RENEW_SELECTED falls back to cert.email when the metadata store has no entry for the CN — so certs created outside this tool (or before the metadata store existed) pre-fill the email field correctly. Co-Authored-By: Claude Sonnet 4.6 --- openvpncertupdate.py | 20 ++++++++++++++------ tests/test_pki.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 102e343..a91120f 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -65,6 +65,7 @@ class CertInfo: cn: str expires: datetime # UTC days_left: int # negative = already expired + email: str = "" def _parse_date(raw: str) -> datetime: @@ -80,8 +81,8 @@ def _parse_date(raw: str) -> datetime: return dt.replace(tzinfo=timezone.utc) -def _parse_index_line(line: str) -> Optional[tuple[str, datetime]]: - """Return (cn, expiry) for valid (V-status) index.txt lines, else None.""" +def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]: + """Return (cn, expiry, email) for valid (V-status) index.txt lines, else None.""" parts = line.rstrip("\n").split("\t") if len(parts) < 6 or parts[0] != "V": return None @@ -91,13 +92,15 @@ def _parse_index_line(line: str) -> Optional[tuple[str, datetime]]: return None dn = parts[5] cn = None + email = "" for seg in dn.split("/"): if seg.startswith("CN="): cn = seg[3:] - break + elif seg.startswith("emailAddress="): + email = seg[len("emailAddress="):] if not cn: return None - return cn, expiry + return cn, expiry, email def load_expiring_certs( @@ -115,12 +118,13 @@ def load_expiring_certs( parsed = _parse_index_line(line) if parsed is None: continue - cn, expiry = parsed + cn, expiry, email = parsed if cutoff_past <= expiry <= cutoff_future: results.append(CertInfo( cn=cn, expires=expiry, days_left=(expiry - now).days, + email=email, )) results.sort(key=lambda c: c.expires) return results @@ -135,11 +139,12 @@ def load_all_certs(pki_dir: str) -> list[CertInfo]: parsed = _parse_index_line(line) if parsed is None: continue - cn, expiry = parsed + cn, expiry, email = parsed results.append(CertInfo( cn=cn, expires=expiry, days_left=(expiry - now).days, + email=email, )) results.sort(key=lambda c: c.expires) return results @@ -850,8 +855,11 @@ class CursesApp: elif result.action == Action.NEW_CERT: self._process_cert(stdscr, cn="", email="", is_renewal=False) elif result.action == Action.RENEW_SELECTED: + cert_by_cn = {c.cn: c for c in certs} for cn in result.selected_cns: email_addr = get_email(EASYRSA_PKI_DIR, cn) + if not email_addr and cn in cert_by_cn: + email_addr = cert_by_cn[cn].email if not self._process_cert(stdscr, cn=cn, email=email_addr, is_renewal=True): break diff --git a/tests/test_pki.py b/tests/test_pki.py index 2b7c425..6e37dba 100644 --- a/tests/test_pki.py +++ b/tests/test_pki.py @@ -54,9 +54,40 @@ def test_parse_4digit_year(): line = "V\t20251231120000Z\t\t07\tunknown\t/CN=future" result = _parse_index_line(line) assert result is not None - cn, dt = result + cn, dt, email = result assert cn == "future" assert dt.year == 2025 + assert email == "" + + +def test_parse_email_from_dn(): + line = "V\t20251231120000Z\t\t07\tunknown\t/CN=alice/emailAddress=alice@example.com" + result = _parse_index_line(line) + assert result is not None + cn, dt, email = result + assert cn == "alice" + assert email == "alice@example.com" + + +def test_parse_email_absent_returns_empty(): + line = "V\t20251231120000Z\t\t07\tunknown\t/CN=bob" + result = _parse_index_line(line) + assert result is not None + cn, dt, email = result + assert cn == "bob" + assert email == "" + + +def test_load_expiring_certs_populates_email(tmp_path): + now = datetime.now(tz=timezone.utc) + pki = tmp_path / "pki" + pki.mkdir() + expiry = now + timedelta(days=5) + line = f"V\t{expiry.strftime('%y%m%d%H%M%S')}Z\t\t01\tunknown\t/CN=carol/emailAddress=carol@example.com\n" + (pki / "index.txt").write_text(line) + certs = load_expiring_certs(str(pki), days_past=0, days_ahead=14) + assert len(certs) == 1 + assert certs[0].email == "carol@example.com" def test_load_all_certs_includes_all_valid(tmp_path):