Compare commits

..

14 Commits

Author SHA1 Message Date
Vlad Doloman
6b39c1209f Merge branch 'python36-compat'
Bring Python 3.6 compatibility into master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:20:11 +03:00
Vlad Doloman
6c60a63eb2 Add planning docs
Track the superpowers planning documents under docs/superpowers/plans/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:56:29 +03:00
Vlad Doloman
62fdcabdd4 Add .gitignore for __pycache__
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:56:29 +03:00
Vlad Doloman
7e3bab4e0c Add planning docs
Track the superpowers planning documents under docs/superpowers/plans/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:56:22 +03:00
Vlad Doloman
caa9951dc8 Add .gitignore for __pycache__
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:56:22 +03:00
Vlad Doloman
b8158e260a Restore Python 3.6 compatibility
Remove Python 3.7+ constructs so the tool runs on 3.6:

- Drop `from __future__ import annotations` (3.7+) and convert all
  builtin-generic annotations (list[]/dict[]/tuple[]/set[]) to their
  typing.List/Dict/Tuple/Set equivalents, since without the future
  import these annotations are evaluated eagerly and builtin generic
  subscription only exists on 3.9+.
- Replace subprocess.run(capture_output=, text=) — both 3.7+ — with
  stdout/stderr=PIPE and universal_newlines=True; update the matching
  test assertion.
- requirements.txt: gate cryptography>=41 behind python_version>=3.7 and
  pin cryptography<41 (last 3.6-compatible line, 40.0.2) plus the
  dataclasses backport for 3.6.
- Suppress cryptography 40.x's per-import "Python 3.6 is no longer
  supported" deprecation warning so it doesn't pollute CLI/cron stderr;
  the <41 pin keeps us on a supported release regardless.

The only cryptography API used is AESGCM (present since 2.0), so no
x509/API-surface changes are needed for the older pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:12:41 +03:00
Vlad Doloman
0477392b5e regenerate and copy CRL during reissue; run restorecon after every CRL copy
Reissue internally revokes the old cert (revoke_issued), which makes
the published CRL immediately stale, but neither the CLI --reissue
path nor the TUI renew flow ever regenerated/copied it — only
standalone revoke and "Regenerate CRL" did. A cert revoked as part of
a reissue could keep authenticating against OpenVPN until someone
remembered to regenerate the CRL by hand.

Both reissue paths now call gen_crl()+copy_crl() right after a
successful revoke_issued(), same as standalone revoke. This isn't
gated on the subsequent build_client_full() succeeding, since the CRL
is already stale the moment the old cert is revoked. A CRL-regen
failure here is reported as a warning and the reissue continues to
build the replacement cert — a new cert is more urgent than a fresh
CRL, and "Regenerate CRL"/--gen-crl remain available to retry.

Also add a RESTORECON_BINARY setting (default "restorecon", "" to
disable): copy_crl() now runs it on the destination file after every
copy, everywhere copy_crl() is called. Best-effort like
is_ca_key_encrypted() — a missing/misconfigured binary is swallowed
rather than breaking CRL deployment on non-SELinux systems.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 10:35:17 +03:00
Vlad Doloman
bb11702c4f dedupe cert list rows to the last non-revoked entry per CN
index.txt is append-only: a CN left unrevoked after expiring, then
reissued, accumulates multiple V-status lines. load_expiring_certs()
and load_all_certs() previously returned one CertInfo per line, so
such a CN showed as duplicate rows in the TUI main menu (and in
--list/--list-all).

Extract the existing "last non-revoked entry wins" scan (previously
only in get_email()) into a shared _load_current_certs(), and build
load_expiring_certs()/load_all_certs()/get_email() on top of it. The
date-window filter in load_expiring_certs() now applies to each CN's
canonical (most recent) entry rather than to raw lines, so a stale
duplicate that happens to fall in the "recently expired" window no
longer masks a live reissued cert whose real expiry is outside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 17:52:26 +03:00
Vlad Doloman
2640dbf81d use erase() instead of clear() in per-keystroke TUI redraw loops
clear() forces a full physical screen repaint on the next refresh();
erase() just blanks the buffer content and lets curses diff against
what's already on the terminal, sending only the changed cells. The
main cert list and the cert-entry form both redraw on every keystroke,
so clear() there meant retransmitting the whole screen on every arrow
key / typed character — slow and flickery over a low-bandwidth link
like a serial console.

One-shot dialogs (show_confirm, _msg, _error) draw once and don't
loop-redraw, so they keep clear() as-is — no benefit to changing them
and it's one less thing to get wrong.

Also handle curses.KEY_RESIZE explicitly in the main list loop: force
one real clear() right after a detected resize, since erase()'s diff
against the pre-resize screen model isn't reliable across a genuine
dimension change. Screen-size changes were already picked up on the
next redraw before this (draw() runs unconditionally every loop
iteration), this just guarantees a clean repaint at the moment of the
actual resize instead of trusting the diff engine through it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 17:45:13 +03:00
Vlad Doloman
92d512f0d2 show email alongside CN in TUI main menu cert list
CertInfo.email was already populated from index.txt but never
rendered; add it as a fixed-width column next to CN in the cert
list rows (blank when unknown).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 16:43:29 +03:00
Vlad Doloman
f7ce56a6d4 fix curses.newwin() crash on narrow terminals in TUI dialogs
show_confirm() sized its window purely from message content, with no
upper bound — a long line (the Cryptgeon password URL in the
post-issuance "send email?" prompt) could easily exceed a narrow
terminal's width (e.g. a serial console), making curses.newwin()
raise "curses function returned NULL" and crash the whole app.
show_cert_form() had the same unguarded newwin() call, just with a
fixed 13x62 size instead of content-driven.

Clamp both windows' height/width to the actual screen size, and wrap
newwin() itself in try/except as a last-resort fallback (declined
confirm / cancelled form) in case clamping still isn't enough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 16:33:38 +03:00
Vlad Doloman
702dc2fe14 document --list/--list-all in CLAUDE.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 16:23:06 +03:00
Vlad Doloman
6248ff47b8 add --list/--list-all CLI flags
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 16:22:22 +03:00
Vlad Doloman
3c4eef6bb0 add CliRunner.list_certs()
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 16:21:24 +03:00
12 changed files with 2947 additions and 90 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -15,6 +15,8 @@ python3 openvpncertupdate.py --create CN --email user@example.com
python3 openvpncertupdate.py --reissue CN [--email user@example.com]
python3 openvpncertupdate.py --revoke CN
python3 openvpncertupdate.py --gen-crl
python3 openvpncertupdate.py --list
python3 openvpncertupdate.py --list-all
```
Edit the `SETTINGS` block at the top of `openvpncertupdate.py` before first run.
@@ -24,9 +26,11 @@ Edit the `SETTINGS` block at the top of `openvpncertupdate.py` before first run.
| Flag | Effect |
|---|---|
| `--create CN` | Issue new cert (requires `--email`) |
| `--reissue CN` | Revoke + reissue cert (`--email` optional, falls back to stored) |
| `--reissue CN` | Revoke + regenerate CRL + reissue cert (`--email` optional, falls back to stored) |
| `--revoke CN` | Revoke cert and regenerate CRL |
| `--gen-crl` | Regenerate and copy CRL only |
| `--list` | List recently-expired/soon-to-expire CNs (per `DAYS_PAST`/`DAYS_AHEAD`) with email; read-only, no CA passphrase needed |
| `--list-all` | List all CNs with email; read-only, no CA passphrase needed |
| `--email EMAIL` | Recipient address |
| `--send-email` | Force email delivery |
| `--no-send-email` | Skip email; print URL to stdout |
@@ -47,7 +51,7 @@ python3 -m pytest tests/test_pki.py::test_sorted_ascending -v # single test
|---|---|
| SETTINGS | all-caps constants |
| SETTINGS OVERRIDE | `ConfigError`, `load_settings_overrides()`, `_OVERRIDABLE_SETTINGS` |
| PKI | `CertInfo`, `load_expiring_certs()`, `load_all_certs()`, `_parse_index_line()`, `get_email()` |
| PKI | `CertInfo`, `_load_current_certs()`, `load_expiring_certs()`, `load_all_certs()`, `_parse_index_line()`, `get_email()` |
| PASSWORD | `generate_password()` |
| EASYRSA | `EasyRSAError`, `revoke_issued()`, `build_client_full()`, `gen_crl()`, `copy_crl()`, `is_ca_key_encrypted()`, `resolve_ca_passphrase()` |
| CONFIG | `build_ovpn()``vpn-configs/<CN>_<YYYY-MM-DD>_<NN>/CONFIG_NAME` |
@@ -63,8 +67,8 @@ python3 -m pytest tests/test_pki.py::test_sorted_ascending -v # single test
## Re-issue workflow
1. `revoke-issued <CN>` — archives old key + CSR to `pki/revoked/`
2. `build-client-full <CN> --passout=pass:<pw>` — generates new key + cert
3. CRL **not** auto-updated during renewal; use "Regenerate CRL" menu item or `r` hotkey
2. CRL regenerated and copied to `CRL_DEST_PATH` immediately after the revoke succeeds — the old cert is already revoked at this point, so the published CRL would otherwise be stale until a separate manual regen. Not fatal: a failure here is reported but the workflow continues to step 3 (a new cert is more urgent than a fresh CRL, and "Regenerate CRL" / `--gen-crl` remain available to retry)
3. `build-client-full <CN> --passout=pass:<pw>` — generates new key + cert
## TUI key bindings
@@ -82,9 +86,10 @@ python3 -m pytest tests/test_pki.py::test_sorted_ascending -v # single test
- External config file (`load_settings_overrides()`, run once in `main()` right after arg parsing, before dispatch): resolution order is `--config PATH` > `CONFIG_PATH` setting > `<this-script-path>.conf` next to the script. The CLI flag or `CONFIG_PATH` make the path explicit — a missing file there is a fatal `ConfigError`; the default `<script>.conf` path is optional and silently skipped if absent. The file is executed as Python (same syntax as the `SETTINGS` block, so only run trusted files) and only names listed in `_OVERRIDABLE_SETTINGS` are applied — `CONFIG_PATH` itself is deliberately not overridable this way
- Email: set `SMTP_HOST` to use smtplib (SMTP_TLS: `"starttls"`/`"ssl"`/`""`); leave empty to use `MAIL_BINARY`. Auth skipped when `SMTP_USER=""`
- EasyRSA called with `--batch`; `--passin=pass:<passphrase>` omitted when the resolved passphrase is empty
- CA passphrase resolution (`resolve_ca_passphrase()`, run once in `main()` right after arg parsing, before dispatch): `CA_PASSPHRASE=""` → auto-detect via `is_ca_key_encrypted()` (checks `<PKI_DIR>/private/ca.key` PEM header for `ENCRYPTED`) and prompt only if encrypted; `"!empty"` → never check/prompt, passphrase is `""`; `"!ask"` → always prompt, skip detection; any other value → used literally
- CA passphrase resolution (`resolve_ca_passphrase()`, run once in `main()` right after arg parsing, before dispatch): `CA_PASSPHRASE=""` → auto-detect via `is_ca_key_encrypted()` (checks `<PKI_DIR>/private/ca.key` PEM header for `ENCRYPTED`) and prompt only if encrypted; `"!empty"` → never check/prompt, passphrase is `""`; `"!ask"` → always prompt, skip detection; any other value → used literally. `--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA
- Cryptgeon: matches the `occulto` browser client — `key=os.urandom(32)` used directly (no derivation) for AES-256-GCM; `contents` = `base64(b"AES-GCM") + "--" + base64(nonce) + "--" + base64(ciphertext)`; `meta` = JSON string `{"type": "text"}`; URL = `<base>/note/<id>#<key.hex()>`
- `copy_crl()` does `chmod 644` after copy
- `copy_crl()` does `chmod 644` after copy, then runs `RESTORECON_BINARY` (default `restorecon`) on the copied file — best-effort like `is_ca_key_encrypted()`: a missing/misconfigured binary is swallowed, not fatal. Set `RESTORECON_BINARY=""` to disable on non-SELinux systems
- Password: pos 1=uppercase, pos 2=lowercase (no j), pos 3-27=alphanumeric, pos 28=lowercase (no j); `oO01lIQ5S2Z8B` banned everywhere
- Inline file path: `<PKI_DIR>/inline/private/<CN>.inline`
- User emails are not stored separately: `build_client_full()` sets `EASYRSA_REQ_EMAIL` whenever an email is known, which EasyRSA embeds as `emailAddress=` in the cert subject — so it round-trips through `<PKI_DIR>/index.txt` itself. `get_email()` reads it back from there (`load_all_certs()` + CN match); there is no `openvpncertupdate-metadata.json`
- User emails are not stored separately: `build_client_full()` sets `EASYRSA_REQ_EMAIL` whenever an email is known, which EasyRSA embeds as `emailAddress=` in the cert subject — so it round-trips through `<PKI_DIR>/index.txt` itself. `get_email()` reads it back from there; there is no `openvpncertupdate-metadata.json`
- `index.txt` is append-only and a CN can accumulate multiple V-status lines (e.g. left unrevoked after expiring, then reissued) alongside older R-status ones. `_load_current_certs()` is the single place that resolves this: keeps only the last (most recently appended) V-status line per CN. `load_expiring_certs()`, `load_all_certs()`, and `get_email()` all build on it, so the TUI list, `--list`/`--list-all`, and email lookups never show/use a stale duplicate

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
# CLI --list / --list-all Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add two non-interactive CLI flags, `--list` and `--list-all`, that print an aligned CN/expiry/email table to stdout — `--list` for the same recently-expired/soon-to-expire window the TUI's default view uses, `--list-all` for every valid cert.
**Architecture:** A pure formatting function (`_format_cert_table()`) turns a `list[CertInfo]` into an aligned table string, reusing the existing `_expiry_label()` (TUI wording) and `get_email()` (index.txt-backed lookup, no metadata file). A new `CliRunner.list_certs(show_all: bool)` method picks `load_expiring_certs()` or `load_all_certs()` and prints the table. `main()` dispatches the two new mutually-exclusive flags to it, and — since listing never touches the CA private key — skips `resolve_ca_passphrase()` entirely for this path instead of prompting needlessly.
**Tech Stack:** Python 3.9, stdlib `argparse`, existing `CertInfo`/`load_all_certs`/`load_expiring_certs`/`get_email`/`_expiry_label` from `openvpncertupdate.py`. Tests via `pytest` + `unittest.mock`, matching the patterns already used in `tests/test_cli.py`.
## Global Constraints
- Single-file script: all production code stays in `openvpncertupdate.py`; no new modules.
- Follow existing `CliRunner` conventions: business logic prints via `print()`/`sys.stderr`, no return values needed by callers.
- `DAYS_PAST` / `DAYS_AHEAD` (SETTINGS constants, currently 30 / 14) define the `--list` window — do not hardcode different values.
- No email storage beyond `index.txt` (established in the prior session's refactor) — `get_email()` is the only lookup path, do not reintroduce a metadata file.
- Column layout for the table: `CN`, `EXPIRES` (from `_expiry_label()`, whitespace-stripped), `EMAIL` (from `get_email()`, or the literal string `(none)` when empty) — two spaces between columns, dynamically sized to the data, header row included. Empty cert list prints the literal string `(no certificates)`.
- `--list`/`--list-all` must be mutually exclusive with each other and with `--create`/`--reissue`/`--revoke`/`--gen-crl` (same `argparse` group), and must NOT trigger `resolve_ca_passphrase()`.
---
## File Structure
- **Modify `openvpncertupdate.py`:**
- Add `_format_cert_table()` — new function, CLI section, directly above `class CliRunner:`.
- Add `CliRunner.list_certs()` — new method on the existing class.
- Modify `_build_parser()` — add `--list` / `--list-all` to the existing mutually-exclusive group.
- Modify `main()` — dispatch the two new flags, and restructure so CA-passphrase resolution happens after (not before) that dispatch check, since list actions return early.
- **Modify `tests/test_cli.py`:** add a new "list_certs / _format_cert_table" section following the file's existing conventions (`_patch_issue`-style monkeypatching, `sys.argv` dispatch tests).
- **Modify `CLAUDE.md`:** add the two flags to the "CLI flags" table, add example invocations to "Running", and note the CA-passphrase-resolution exception in "Key constraints".
---
### Task 1: `_format_cert_table()` formatting helper
**Files:**
- Modify: `openvpncertupdate.py` (insert new function directly above `class CliRunner:`, currently at line 1265 — search for `# === CLI ===` section header)
- Test: `tests/test_cli.py`
**Interfaces:**
- Consumes: `CertInfo` (fields: `cn: str`, `expires: datetime`, `days_left: int`, `email: str = ""`), `_expiry_label(c: CertInfo) -> str`, `get_email(pki_dir: str, cn: str) -> str`, module global `EASYRSA_PKI_DIR: str`.
- Produces: `_format_cert_table(certs: list[CertInfo]) -> str` — used by Task 2.
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_cli.py`, in a new section after the existing `CliRunner.revoke` / `CliRunner.regen_crl` tests and before `main() argument dispatch`:
```python
# ---------------------------------------------------------------------------
# _format_cert_table
# ---------------------------------------------------------------------------
def test_format_cert_table_empty_list():
import openvpncertupdate
assert openvpncertupdate._format_cert_table([]) == "(no certificates)"
def test_format_cert_table_header_and_columns(monkeypatch):
import openvpncertupdate
certs = [
openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24),
openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5),
]
monkeypatch.setattr(
"openvpncertupdate.get_email",
MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")),
)
table = openvpncertupdate._format_cert_table(certs)
lines = table.splitlines()
assert lines[0].split() == ["CN", "EXPIRES", "EMAIL"]
assert len(lines) == 3
def test_format_cert_table_shows_email_and_none_placeholder(monkeypatch):
import openvpncertupdate
certs = [
openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24),
openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5),
]
monkeypatch.setattr(
"openvpncertupdate.get_email",
MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")),
)
table = openvpncertupdate._format_cert_table(certs)
lines = table.splitlines()
assert "alice@example.com" in lines[1]
assert "(none)" in lines[2]
def test_format_cert_table_columns_are_aligned(monkeypatch):
import openvpncertupdate
certs = [
openvpncertupdate.CertInfo(cn="a", expires=None, days_left=24),
openvpncertupdate.CertInfo(cn="a-much-longer", expires=None, days_left=5),
]
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value=""))
table = openvpncertupdate._format_cert_table(certs)
lines = table.splitlines()
# The EMAIL column must start at the same character offset on every row.
email_col = len(lines[0]) - len("EMAIL")
for line in lines[1:]:
assert line.rstrip().endswith("(none)")
assert line.index("(none)") == email_col
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v`
Expected: FAIL — `AttributeError: module 'openvpncertupdate' has no attribute '_format_cert_table'`
- [ ] **Step 3: Implement `_format_cert_table()`**
In `openvpncertupdate.py`, insert directly above `class CliRunner:` (i.e. right after the `# === CLI ===` section header block and before `class CliRunner:`):
```python
def _format_cert_table(certs: list[CertInfo]) -> str:
"""Render CN / expiry / email as an aligned table, like `ls -l`."""
if not certs:
return "(no certificates)"
rows = [
(c.cn, _expiry_label(c).strip(), get_email(EASYRSA_PKI_DIR, c.cn) or "(none)")
for c in certs
]
cn_w = max(len("CN"), max(len(r[0]) for r in rows))
exp_w = max(len("EXPIRES"), max(len(r[1]) for r in rows))
lines = [f"{'CN':<{cn_w}} {'EXPIRES':<{exp_w}} EMAIL"]
for cn, exp, email in rows:
lines.append(f"{cn:<{cn_w}} {exp:<{exp_w}} {email}")
return "\n".join(lines)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v`
Expected: 4 passed
- [ ] **Step 5: Commit**
```bash
git add openvpncertupdate.py tests/test_cli.py
git commit -m "add _format_cert_table() helper for CLI cert listings"
```
---
### Task 2: `CliRunner.list_certs()`
**Files:**
- Modify: `openvpncertupdate.py` (add method to `class CliRunner`, currently spanning from `def create(...)` through `def _issue(...)`)
- Test: `tests/test_cli.py`
**Interfaces:**
- Consumes: `_format_cert_table(certs: list[CertInfo]) -> str` (Task 1), `load_expiring_certs(pki_dir: str, days_past: int, days_ahead: int) -> list[CertInfo]`, `load_all_certs(pki_dir: str) -> list[CertInfo]`, module globals `EASYRSA_PKI_DIR`, `DAYS_PAST`, `DAYS_AHEAD`.
- Produces: `CliRunner.list_certs(self, show_all: bool) -> None` — used by Task 3's `main()` dispatch.
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_cli.py`, directly after the `_format_cert_table` tests from Task 1:
```python
# ---------------------------------------------------------------------------
# CliRunner.list_certs
# ---------------------------------------------------------------------------
def test_list_certs_expiring_uses_days_settings(monkeypatch, capsys):
mock_load = MagicMock(return_value=[])
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", mock_load)
monkeypatch.setattr("openvpncertupdate.DAYS_PAST", 30)
monkeypatch.setattr("openvpncertupdate.DAYS_AHEAD", 14)
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
CliRunner().list_certs(show_all=False)
mock_load.assert_called_once_with("/pki", 30, 14)
assert "(no certificates)" in capsys.readouterr().out
def test_list_certs_all_uses_load_all_certs(monkeypatch, capsys):
mock_load = MagicMock(return_value=[])
monkeypatch.setattr("openvpncertupdate.load_all_certs", mock_load)
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
CliRunner().list_certs(show_all=True)
mock_load.assert_called_once_with("/pki")
assert "(no certificates)" in capsys.readouterr().out
def test_list_certs_prints_formatted_table(monkeypatch, capsys):
import openvpncertupdate
certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=10)]
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", MagicMock(return_value=certs))
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="alice@example.com"))
CliRunner().list_certs(show_all=False)
out = capsys.readouterr().out
assert "alice" in out
assert "alice@example.com" in out
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m pytest tests/test_cli.py -k list_certs -v`
Expected: FAIL — `AttributeError: 'CliRunner' object has no attribute 'list_certs'`
- [ ] **Step 3: Implement `CliRunner.list_certs()`**
In `openvpncertupdate.py`, add this method to `class CliRunner`, directly after `def regen_crl(self) -> None:` and before `def _issue(...)`:
```python
def list_certs(self, show_all: bool) -> None:
certs = (load_all_certs(EASYRSA_PKI_DIR) if show_all
else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD))
print(_format_cert_table(certs))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m pytest tests/test_cli.py -k list_certs -v`
Expected: 3 passed
- [ ] **Step 5: Commit**
```bash
git add openvpncertupdate.py tests/test_cli.py
git commit -m "add CliRunner.list_certs()"
```
---
### Task 3: `--list` / `--list-all` CLI flags and dispatch
**Files:**
- Modify: `openvpncertupdate.py` (`_build_parser()` and `main()`)
- Test: `tests/test_cli.py`
**Interfaces:**
- Consumes: `CliRunner.list_certs(show_all: bool) -> None` (Task 2), existing `resolve_ca_passphrase()`, `_build_parser()`.
- Produces: `args.list: bool`, `args.list_all: bool` on the parsed namespace — internal to `main()`, no other task depends on these names.
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_cli.py`, in the `main() argument dispatch` section, directly after `test_main_dispatches_gen_crl`:
```python
def test_main_dispatches_list(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main()
runner.list_certs.assert_called_once_with(show_all=False)
def test_main_dispatches_list_all(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main()
runner.list_certs.assert_called_once_with(show_all=True)
def test_main_list_skips_ca_passphrase_resolution(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
mock_resolve = MagicMock()
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
main()
mock_resolve.assert_not_called()
def test_main_list_all_skips_ca_passphrase_resolution(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
mock_resolve = MagicMock()
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
main()
mock_resolve.assert_not_called()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m pytest tests/test_cli.py -k "dispatches_list or list_skips or list_all_skips" -v`
Expected: FAIL — `error: unrecognized arguments: --list` (argparse `SystemExit`)
- [ ] **Step 3: Add the flags to `_build_parser()`**
In `openvpncertupdate.py`, find this existing line in `_build_parser()`:
```python
group.add_argument("--gen-crl", action="store_true", help="Regenerate and copy CRL")
```
Leave it unchanged, and insert these two new lines directly after it:
```python
group.add_argument("--list", action="store_true",
help="List recently-expired/soon-to-expire CNs "
"(per DAYS_PAST/DAYS_AHEAD) with email")
group.add_argument("--list-all", action="store_true", help="List all CNs with email")
```
- [ ] **Step 4: Dispatch the flags in `main()`, skipping CA passphrase resolution**
In `openvpncertupdate.py`, `main()` currently reads:
```python
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
try:
overrides = load_settings_overrides(args.config, CONFIG_PATH, __file__)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
globals().update(overrides)
global CA_PASSPHRASE
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)
if args.create:
```
Replace the block from `global CA_PASSPHRASE` up to (not including) `if args.create:` with:
```python
if args.list:
CliRunner().list_certs(show_all=False)
return
if args.list_all:
CliRunner().list_certs(show_all=True)
return
global CA_PASSPHRASE
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)
if args.create:
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `python3 -m pytest tests/test_cli.py -v`
Expected: all tests in the file PASS (this also re-verifies Tasks 12 didn't regress, and that the pre-existing `test_main_resolves_ca_passphrase_before_dispatch` / `test_main_dispatches_gen_crl` etc. still pass since `--gen-crl` and friends are untouched)
- [ ] **Step 6: Run the full suite**
Run: `python3 -m pytest tests/ -v`
Expected: all tests PASS, no regressions in other files
- [ ] **Step 7: Commit**
```bash
git add openvpncertupdate.py tests/test_cli.py
git commit -m "add --list/--list-all CLI flags"
```
---
### Task 4: Update CLAUDE.md
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: nothing (documentation only).
- Produces: nothing (documentation only).
- [ ] **Step 1: Add example invocations**
In `CLAUDE.md`, in the `## Running` fenced code block, add two lines after `python3 openvpncertupdate.py --gen-crl`:
```
python3 openvpncertupdate.py --list
python3 openvpncertupdate.py --list-all
```
- [ ] **Step 2: Add the flags to the CLI flags table**
In `CLAUDE.md`, in the "### CLI flags" table, add two rows directly after the `--gen-crl` row:
```
| `--gen-crl` | Regenerate and copy CRL only |
| `--list` | List recently-expired/soon-to-expire CNs (per `DAYS_PAST`/`DAYS_AHEAD`) with email; read-only, no CA passphrase needed |
| `--list-all` | List all CNs with email; read-only, no CA passphrase needed |
```
- [ ] **Step 3: Note the CA-passphrase exception in Key constraints**
In `CLAUDE.md`, in `## Key constraints`, the line starting `- CA passphrase resolution (...)` currently ends with `any other value → used literally`. Append a new sentence to that same bullet:
```
`--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA
```
So the full bullet reads:
```
- CA passphrase resolution (`resolve_ca_passphrase()`, run once in `main()` right after arg parsing, before dispatch): `CA_PASSPHRASE=""` → auto-detect via `is_ca_key_encrypted()` (checks `<PKI_DIR>/private/ca.key` PEM header for `ENCRYPTED`) and prompt only if encrypted; `"!empty"` → never check/prompt, passphrase is `""`; `"!ask"` → always prompt, skip detection; any other value → used literally. `--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA
```
- [ ] **Step 4: Verify no stale claims remain**
Run: `grep -n "openvpncertupdate-metadata" CLAUDE.md`
Expected: no output (confirms Task 4 didn't reintroduce the removed metadata-file reference)
- [ ] **Step 5: Commit**
```bash
git add CLAUDE.md
git commit -m "document --list/--list-all in CLAUDE.md"
```
---
## Final Verification
- [ ] Run `python3 -m pytest tests/ -v` — all tests pass, count should be 126 (prior) + 11 new = 137.
- [ ] Manually sanity-check the two new flags against the real `tests/` fixtures aren't needed (no live PKI in this repo) — rely on the unit tests above; if a live PKI directory is available, run `python3 openvpncertupdate.py --list` and `--list-all` by hand to eyeball alignment.

View File

@@ -0,0 +1,135 @@
# TUI Main Menu Email Column Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show each cert's email address alongside its CN in the TUI main menu's cert list rows.
**Architecture:** `CertInfo.email` is already populated by `load_expiring_certs()`/`load_all_certs()` from `index.txt` (`_parse_index_line()`), and `show_main_screen()` already receives the full `certs: list[CertInfo]`. This is a pure rendering change: extend the existing row-format string (`openvpncertupdate.py`, inside `show_main_screen()`'s render loop) to append the email, padding the CN column to a fixed width so columns stay aligned. No new data plumbing.
**Tech Stack:** Python 3.9 stdlib `curses`. Tests via `pytest` + `unittest.mock`, matching `tests/test_main_screen.py`'s existing conventions (mocked `stdscr`, inspecting `stdscr.addstr.call_args_list`).
## Global Constraints
- Single-file script: change stays in `openvpncertupdate.py`, no new modules.
- Applies to both the expiring-only and "all certs" (`A`-toggle) views — they share this same render loop, so no separate handling needed.
- Unknown email renders as blank (not a placeholder string like `(none)`) — matches the TUI's existing minimalist row style; this is a deliberate difference from the `--list`/`--list-all` CLI table, which does use `(none)`.
- CN column gets a fixed width of 20 characters (padding with spaces), consistent with the existing fixed 18-char width already used for the expiry label column on the same line.
---
## File Structure
- **Modify `openvpncertupdate.py`:** one line inside `show_main_screen()`'s render loop (currently `line = f" {box} {lbl:<18} {cert.cn}"`, around line 941 — find it via `_expiry_label(cert)` immediately above it).
- **Modify `tests/test_main_screen.py`:** extend the `_cert()` test helper to accept an optional `email` argument, and add two new tests.
---
### Task 1: Add email column to the cert list row
**Files:**
- Modify: `openvpncertupdate.py` (inside `show_main_screen()`, the `if is_cert(i):` branch of the render loop)
- Modify: `tests/test_main_screen.py` (`_cert()` helper + new tests)
**Interfaces:**
- Consumes: `CertInfo.email: str` (already exists, default `""`), `CertInfo.cn: str`.
- Produces: nothing new — this is a leaf rendering change with no other task depending on it.
- [ ] **Step 1: Extend the `_cert()` test helper**
In `tests/test_main_screen.py`, replace:
```python
def _cert(cn: str, days_left: int) -> CertInfo:
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
return CertInfo(
cn=cn,
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
days_left=days_left,
)
```
with:
```python
def _cert(cn: str, days_left: int, email: str = "") -> CertInfo:
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
return CertInfo(
cn=cn,
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
days_left=days_left,
email=email,
)
```
- [ ] **Step 2: Write the failing tests**
In `tests/test_main_screen.py`, add directly after `test_cursor_row_has_reverse_attribute` (search for that function name):
```python
def test_cert_row_shows_email():
certs = [_cert("alice", 10, email="alice@example.com"), _cert("bob", 3)]
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])]
assert alice_calls
assert "alice@example.com" in alice_calls[0].args[2]
def test_cert_row_blank_when_no_email():
certs = [_cert("bob", 3)] # email defaults to ""
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])]
assert bob_calls
assert "(none)" not in bob_calls[0].args[2]
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `python3 -m pytest tests/test_main_screen.py -k cert_row_shows_email -v`
Expected: FAIL — `assert "alice@example.com" in alice_calls[0].args[2]` fails because the row doesn't include the email yet (`test_cert_row_blank_when_no_email` will already pass since it only checks for absence of `"(none)"`, which is fine — it's asserting current behavior stays true after the change too, so it's not required to fail here, but run both to confirm the shows_email one fails)
- [ ] **Step 4: Update the row format**
In `openvpncertupdate.py`, inside `show_main_screen()`, find:
```python
if is_cert(i):
cert = certs[i]
box = "[X]" if cert.cn in checked else "[ ]"
lbl = _expiry_label(cert)
line = f" {box} {lbl:<18} {cert.cn}"
```
Replace the `line = ...` assignment with:
```python
line = f" {box} {lbl:<18} {cert.cn:<20} {cert.email}"
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `python3 -m pytest tests/test_main_screen.py -v`
Expected: all tests in the file PASS (this also re-verifies no regressions in the pre-existing tests, since the row content assertions there — e.g. `test_cursor_row_has_reverse_attribute` — check for `"bob"`/`"alice"` substrings, not exact row content, so the added columns don't break them)
- [ ] **Step 6: Run the full suite**
Run: `python3 -m pytest tests/ -v`
Expected: all tests PASS, no regressions in other files
- [ ] **Step 7: Commit**
```bash
git add openvpncertupdate.py tests/test_main_screen.py
git commit -m "show email alongside CN in TUI main menu cert list"
```
---
## Final Verification
- [ ] Run `python3 -m pytest tests/ -v` — all tests pass, count should be 141 (prior) + 2 new = 143.

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env python3
"""openvpncertupdate — Manage OpenVPN user certificates via EasyRSA."""
from __future__ import annotations
import argparse
import base64
import curses
@@ -21,14 +19,20 @@ import subprocess
import sys
import urllib.error
import urllib.request
import warnings
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from enum import Enum, auto
from pathlib import Path
from typing import Callable, Optional
from typing import Callable, Dict, List, Optional, Set, Tuple
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
with warnings.catch_warnings():
# cryptography <41 pinned for Python 3.6 warns on every import that 3.6
# support is deprecated; the pin keeps us on a supported release, so the
# warning is noise that would otherwise pollute CLI/cron stderr each run.
warnings.filterwarnings("ignore", message="Python 3.6 is no longer supported")
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError: # pragma: no cover
AESGCM = None # type: ignore
@@ -54,6 +58,7 @@ CONFIG_NAME = "client.ovpn"
VPN_CONFIGS_DIR = "./vpn-configs"
CRL_DEST_PATH = "/etc/openvpn/crl.pem"
RESTORECON_BINARY = "restorecon" # run on CRL_DEST_PATH after each copy; "" disables (non-SELinux systems)
CRYPTGEON_URL = "https://cryptgeon.example.com"
@@ -76,7 +81,7 @@ DAYS_AHEAD = 14
_OVERRIDABLE_SETTINGS = frozenset({
"EASYRSA_DIR", "EASYRSA_PKI_DIR", "CA_PASSPHRASE",
"OVPN_TEMPLATE_PATH", "CONFIG_NAME", "VPN_CONFIGS_DIR",
"CRL_DEST_PATH",
"CRL_DEST_PATH", "RESTORECON_BINARY",
"CRYPTGEON_URL",
"MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY",
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS",
@@ -143,7 +148,7 @@ def _parse_date(raw: str) -> datetime:
return dt.replace(tzinfo=timezone.utc)
def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
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":
@@ -165,71 +170,61 @@ def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
return cn, expiry, email
def load_expiring_certs(
pki_dir: str,
days_past: int,
days_ahead: int,
) -> list[CertInfo]:
"""Return valid certs whose expiry falls within [-days_past, +days_ahead]."""
def _load_current_certs(pki_dir: str) -> List[CertInfo]:
"""Return one CertInfo per CN: its most recent V-status index.txt entry.
index.txt is append-only, so a CN can accumulate several V-status lines
(e.g. an old cert left unrevoked after it expired, then reissued) as
well as older R-status (revoked) ones. Scan the whole file in order and
keep only the last non-revoked line per CN — same rule get_email() uses
for picking an email among duplicates, applied here to the row itself
so expiring/all-certs views don't show stale duplicate rows.
"""
now = datetime.now(tz=timezone.utc)
cutoff_past = now - timedelta(days=days_past)
cutoff_future = now + timedelta(days=days_ahead)
results: list[CertInfo] = []
by_cn: Dict[str, CertInfo] = {}
with open(os.path.join(pki_dir, "index.txt")) as fh:
for line in fh:
parsed = _parse_index_line(line)
if parsed is None:
continue
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
def load_all_certs(pki_dir: str) -> list[CertInfo]:
"""Return all valid (V-status) certs, sorted by expiry date."""
now = datetime.now(tz=timezone.utc)
results: list[CertInfo] = []
with open(os.path.join(pki_dir, "index.txt")) as fh:
for line in fh:
parsed = _parse_index_line(line)
if parsed is None:
continue
cn, expiry, email = parsed
results.append(CertInfo(
by_cn[cn] = CertInfo(
cn=cn,
expires=expiry,
days_left=(expiry - now).days,
email=email,
))
)
return list(by_cn.values())
def load_expiring_certs(
pki_dir: str,
days_past: int,
days_ahead: int,
) -> List[CertInfo]:
"""Return current certs whose expiry falls within [-days_past, +days_ahead]."""
now = datetime.now(tz=timezone.utc)
cutoff_past = now - timedelta(days=days_past)
cutoff_future = now + timedelta(days=days_ahead)
results = [c for c in _load_current_certs(pki_dir)
if cutoff_past <= c.expires <= cutoff_future]
results.sort(key=lambda c: c.expires)
return results
def load_all_certs(pki_dir: str) -> List[CertInfo]:
"""Return all current certs, sorted by expiry date."""
results = _load_current_certs(pki_dir)
results.sort(key=lambda c: c.expires)
return results
def get_email(pki_dir: str, cn: str) -> str:
"""Return the emailAddress from CN's most recent V-status index.txt entry.
index.txt is append-only, so a CN can have several V-status lines (e.g.
issued directly with easyrsa, bypassing this tool's revoke-before-build
renewal flow) as well as older R-status (revoked) ones; scan the whole
file in order and keep the last non-revoked match, or '' if none.
"""
found = ""
with open(os.path.join(pki_dir, "index.txt")) as fh:
for line in fh:
parsed = _parse_index_line(line)
if parsed is None:
continue
line_cn, _expiry, email = parsed
if line_cn == cn:
found = email
return found
"""Return the emailAddress on CN's current (most recent V-status) entry."""
for c in _load_current_certs(pki_dir):
if c.cn == cn:
return c.email
return ""
# ============================================================
@@ -266,13 +261,16 @@ class EasyRSAError(Exception):
pass
def _run_easyrsa(cmd: list[str], cwd: str,
extra_env: Optional[dict[str, str]] = None) -> None:
def _run_easyrsa(cmd: List[str], cwd: str,
extra_env: Optional[Dict[str, str]] = None) -> None:
env = None
if extra_env:
env = os.environ.copy()
env.update(extra_env)
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
result = subprocess.run(
cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, env=env,
)
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")
@@ -281,7 +279,7 @@ def _run_easyrsa(cmd: list[str], cwd: str,
)
def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> list[str]:
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}")
@@ -317,9 +315,20 @@ def gen_crl(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> None:
)
def copy_crl(pki_dir: str, dest_path: str) -> None:
def copy_crl(pki_dir: str, dest_path: str, restorecon_binary: str = "") -> None:
shutil.copy2(f"{pki_dir}/crl.pem", dest_path)
os.chmod(dest_path, 0o644)
if restorecon_binary:
# Best-effort, like is_ca_key_encrypted(): a missing/misconfigured
# restorecon must not break CRL deployment on non-SELinux systems.
try:
subprocess.run(
[restorecon_binary, dest_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
)
except OSError:
pass
def is_ca_key_encrypted(pki_dir: str) -> bool:
@@ -651,9 +660,15 @@ def show_confirm(stdscr, message: str) -> bool:
"""Y/N modal dialog. Returns True if user presses y/Y."""
sh, sw = stdscr.getmaxyx()
lines = message.splitlines()
h = len(lines) + 6
w = max(max(len(l) for l in lines) + 6, 38)
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
# Clamp to the screen: an unbounded width (e.g. a long Cryptgeon URL line)
# makes curses.newwin() raise "curses function returned NULL" outright on
# a narrow terminal (e.g. a serial console) instead of just wrapping badly.
h = min(len(lines) + 6, sh)
w = min(max(max(len(l) for l in lines) + 6, 38), sw)
try:
win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2))
except curses.error:
return False
win.keypad(True)
draw_box(win, "Confirm")
for i, line in enumerate(lines):
@@ -704,8 +719,14 @@ def show_cert_form(
Tab / Shift-Tab / Up / Down cycle fields and buttons. F5 regenerates password.
Enter or Space activates the focused button. Ctrl-G confirms immediately. Escape cancels."""
sh, sw = stdscr.getmaxyx()
h, w = 13, 62
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
# Clamp to the screen so curses.newwin() can't raise "curses function
# returned NULL" outright on a terminal narrower/shorter than the usual
# 80x24 (e.g. a serial console with an unusually small viewport).
h, w = min(13, sh), min(62, sw)
try:
win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2))
except curses.error:
return CertFormResult(cn=cn, email="", password="", confirmed=False)
win.keypad(True)
fw = w - 6 # field width
@@ -717,7 +738,7 @@ def show_cert_form(
active = ([n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
+ [_BTN_CONTINUE, _BTN_CANCEL])
fields: dict[str, InputField] = {
fields: Dict[str, InputField] = {
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
@@ -737,7 +758,7 @@ def show_cert_form(
curses.curs_set(1)
while True:
win.clear()
win.erase() # see show_main_screen's draw() for why not clear()
draw_box(win, "Certificate Details")
for name in _FORM_FIELDS:
fy = _FORM_FIELD_Y[name]
@@ -847,8 +868,8 @@ class Action(Enum):
@dataclass
class ScreenResult:
action: Action
selected_cns: list[str] = field(default_factory=list)
saved_checked: list[str] = field(default_factory=list)
selected_cns: List[str] = field(default_factory=list)
saved_checked: List[str] = field(default_factory=list)
saved_cursor: int = 0
@@ -867,8 +888,8 @@ def _expiry_label(c: CertInfo) -> str:
def show_main_screen(
stdscr, certs: list[CertInfo], show_all: bool = False,
initial_checked: Optional[set[str]] = None,
stdscr, certs: List[CertInfo], show_all: bool = False,
initial_checked: Optional[Set[str]] = None,
initial_cursor: int = 0,
) -> ScreenResult:
"""Display cert selection list. Returns when user activates an action."""
@@ -877,7 +898,7 @@ def show_main_screen(
n_certs = len(certs)
n_items = n_certs + len(_MENU_ITEMS)
checked: set[str] = set(initial_checked) if initial_checked else set()
checked: Set[str] = set(initial_checked) if initial_checked else set()
cursor = clamp(initial_cursor, 0, max(0, n_items - 1))
esc_pending = False
@@ -885,7 +906,11 @@ def show_main_screen(
def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None
def draw():
stdscr.clear()
# erase() (not clear()) so refresh() only transmits the cells that
# actually changed instead of repainting the whole screen on every
# keystroke — clear() forces a full repaint and is slow/flickery
# over a low-bandwidth link like a serial console.
stdscr.erase()
sh, sw = stdscr.getmaxyx()
# Header bar — shows current filter mode
@@ -926,7 +951,7 @@ def show_main_screen(
cert = certs[i]
box = "[X]" if cert.cn in checked else "[ ]"
lbl = _expiry_label(cert)
line = f" {box} {lbl:<18} {cert.cn}"
line = f" {box} {lbl:<18} {cert.cn:<20} {cert.email}"
try:
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr)
except curses.error:
@@ -955,6 +980,13 @@ def show_main_screen(
draw()
key = stdscr.getch()
if key == curses.KEY_RESIZE:
# Force one true full repaint on the actual new dimensions —
# erase()+refresh() diffs against the pre-resize screen model,
# which isn't reliable across a genuine size change.
stdscr.clear()
continue
if key == 27: # Esc
if esc_pending:
return ScreenResult(action=Action.EXIT)
@@ -1016,7 +1048,7 @@ def show_main_screen(
class CursesApp:
def __init__(self) -> None:
self._session_log: list[str] = []
self._session_log: List[str] = []
def _log(self, entry: str) -> None:
self._session_log.append(entry)
@@ -1041,7 +1073,7 @@ class CursesApp:
init_colors()
show_all = False
saved_checked: list[str] = []
saved_checked: List[str] = []
saved_cursor: int = 0
while True:
@@ -1100,6 +1132,20 @@ class CursesApp:
except EasyRSAError as exc:
self._error(stdscr, f"EasyRSA error during revoke:\n{exc}")
return True
# The old cert is now revoked, so the published CRL is stale
# until regenerated — do that now rather than leaving it to a
# separate manual "Regenerate CRL" step. Not fatal: the new
# cert is more urgent than a fresh CRL, so keep going either way.
try:
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
self._error(
stdscr,
f"CRL update failed:\n{exc}\n\n"
f"{final_cn} was revoked but the published CRL is stale.\n"
f"Use \"Regenerate CRL\" once this finishes.",
)
try:
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR,
@@ -1176,7 +1222,7 @@ class CursesApp:
try:
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
self._error(stdscr, f"Revoke failed:\n{exc}")
return
@@ -1187,7 +1233,7 @@ class CursesApp:
self._msg(stdscr, "Regenerating CRL…")
try:
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
self._error(stdscr, f"gen-crl failed:\n{exc}")
return
@@ -1262,7 +1308,7 @@ class CursesApp:
# ============================================================
def _format_cert_table(certs: list[CertInfo]) -> str:
def _format_cert_table(certs: List[CertInfo]) -> str:
"""Render CN / expiry / email as an aligned table, like `ls -l`."""
if not certs:
return "(no certificates)"
@@ -1299,7 +1345,7 @@ class CliRunner:
try:
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
@@ -1309,12 +1355,17 @@ class CliRunner:
print("Regenerating CRL…", file=sys.stderr)
try:
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
print(f"CRL updated → {CRL_DEST_PATH}")
def list_certs(self, show_all: bool) -> None:
certs = (load_all_certs(EASYRSA_PKI_DIR) if show_all
else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD))
print(_format_cert_table(certs))
def _issue(self, cn: str, email_addr: str, is_renewal: bool,
send_email_flag: bool = True, show_eml: bool = False) -> None:
password = generate_password()
@@ -1326,6 +1377,21 @@ class CliRunner:
except EasyRSAError as exc:
print(f"error during revoke: {exc}", file=sys.stderr)
sys.exit(1)
# The old cert is now revoked, so the published CRL is stale
# until regenerated — do that now rather than leaving it to a
# separate --gen-crl run. Not fatal: the new cert is more
# urgent than a fresh CRL, so keep going either way.
print(f"Regenerating CRL…", file=sys.stderr)
try:
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY)
except EasyRSAError as exc:
print(
f"warning: CRL update failed: {exc}\n"
f"warning: {cn} was revoked but the published CRL is stale; "
f"run --gen-crl once this finishes.",
file=sys.stderr,
)
print(f"Building certificate for {cn}", file=sys.stderr)
try:
@@ -1405,6 +1471,10 @@ def _build_parser() -> argparse.ArgumentParser:
group.add_argument("--reissue", metavar="CN", help="Revoke and reissue certificate for CN")
group.add_argument("--revoke", metavar="CN", help="Revoke certificate for CN and regenerate CRL")
group.add_argument("--gen-crl", action="store_true", help="Regenerate and copy CRL")
group.add_argument("--list", action="store_true",
help="List recently-expired/soon-to-expire CNs "
"(per DAYS_PAST/DAYS_AHEAD) with email")
group.add_argument("--list-all", action="store_true", help="List all CNs with email")
parser.add_argument("--email", metavar="EMAIL",
help="Email address (required for --create; optional for --reissue)")
parser.add_argument("--send-email", dest="send_email", action="store_const", const=True,
@@ -1436,6 +1506,13 @@ def main() -> None:
sys.exit(1)
globals().update(overrides)
if args.list:
CliRunner().list_certs(show_all=False)
return
if args.list_all:
CliRunner().list_certs(show_all=True)
return
global CA_PASSPHRASE
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)

View File

@@ -1 +1,3 @@
cryptography>=41.0.0
cryptography>=41.0.0; python_version >= "3.7"
cryptography>=3.4,<41; python_version < "3.7"
dataclasses>=0.8; python_version < "3.7"

View File

@@ -14,6 +14,8 @@ def _patch_issue(monkeypatch, ovpn_path="/out/cn_2026-01-01/client.ovpn",
one_time_url="https://cg.example.com/#/note/abc/xyz"):
"""Patch all side-effectful callables used by CliRunner._issue."""
monkeypatch.setattr("openvpncertupdate.revoke_issued", MagicMock())
monkeypatch.setattr("openvpncertupdate.gen_crl", MagicMock())
monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock())
monkeypatch.setattr("openvpncertupdate.build_client_full", MagicMock())
monkeypatch.setattr("openvpncertupdate.build_ovpn", MagicMock(return_value=ovpn_path))
monkeypatch.setattr("openvpncertupdate.create_note", MagicMock(return_value=one_time_url))
@@ -21,6 +23,8 @@ def _patch_issue(monkeypatch, ovpn_path="/out/cn_2026-01-01/client.ovpn",
monkeypatch.setattr("openvpncertupdate.generate_password", MagicMock(return_value="Testpass1234567890abcdefgh"))
return {
"revoke_issued": sys.modules["openvpncertupdate"].revoke_issued,
"gen_crl": sys.modules["openvpncertupdate"].gen_crl,
"copy_crl": sys.modules["openvpncertupdate"].copy_crl,
"build_client_full": sys.modules["openvpncertupdate"].build_client_full,
"build_ovpn": sys.modules["openvpncertupdate"].build_ovpn,
"create_note": sys.modules["openvpncertupdate"].create_note,
@@ -87,6 +91,33 @@ def test_reissue_calls_revoke_then_build(monkeypatch, capsys):
assert mocks["build_client_full"].call_args.args[2] == "bob"
def test_reissue_regenerates_and_copies_crl(monkeypatch, capsys):
# The old cert is revoked as part of reissue, so the published CRL is
# stale until regenerated — reissue must do that itself now.
mocks = _patch_issue(monkeypatch)
CliRunner().reissue("bob", "bob@example.com")
mocks["gen_crl"].assert_called_once()
mocks["copy_crl"].assert_called_once()
def test_create_does_not_touch_crl(monkeypatch, capsys):
# --create never revokes anything, so there's nothing stale to fix.
mocks = _patch_issue(monkeypatch)
CliRunner().create("alice", "alice@example.com")
mocks["gen_crl"].assert_not_called()
mocks["copy_crl"].assert_not_called()
def test_reissue_continues_building_when_crl_regen_fails(monkeypatch, capsys):
import openvpncertupdate
mocks = _patch_issue(monkeypatch)
monkeypatch.setattr("openvpncertupdate.gen_crl",
MagicMock(side_effect=openvpncertupdate.EasyRSAError("gen-crl failed")))
CliRunner().reissue("bob", "bob@example.com")
mocks["build_client_full"].assert_called_once()
assert "CRL update failed" in capsys.readouterr().err
def test_reissue_falls_back_to_index_txt_email(monkeypatch, capsys):
# get_email() reads straight from index.txt; this stubs that lookup
# rather than the file itself (index.txt parsing is covered in test_pki.py).
@@ -207,6 +238,47 @@ def test_format_cert_table_columns_are_aligned(monkeypatch):
assert line.index("(none)") == email_col
# ---------------------------------------------------------------------------
# CliRunner.list_certs
# ---------------------------------------------------------------------------
def test_list_certs_expiring_uses_days_settings(monkeypatch, capsys):
mock_load = MagicMock(return_value=[])
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", mock_load)
monkeypatch.setattr("openvpncertupdate.DAYS_PAST", 30)
monkeypatch.setattr("openvpncertupdate.DAYS_AHEAD", 14)
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
CliRunner().list_certs(show_all=False)
mock_load.assert_called_once_with("/pki", 30, 14)
assert "(no certificates)" in capsys.readouterr().out
def test_list_certs_all_uses_load_all_certs(monkeypatch, capsys):
mock_load = MagicMock(return_value=[])
monkeypatch.setattr("openvpncertupdate.load_all_certs", mock_load)
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
CliRunner().list_certs(show_all=True)
mock_load.assert_called_once_with("/pki")
assert "(no certificates)" in capsys.readouterr().out
def test_list_certs_prints_formatted_table(monkeypatch, capsys):
import openvpncertupdate
certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=10)]
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", MagicMock(return_value=certs))
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="alice@example.com"))
CliRunner().list_certs(show_all=False)
out = capsys.readouterr().out
assert "alice" in out
assert "alice@example.com" in out
# ---------------------------------------------------------------------------
# main() argument dispatch
# ---------------------------------------------------------------------------
@@ -251,6 +323,40 @@ def test_main_dispatches_gen_crl(monkeypatch):
runner.regen_crl.assert_called_once_with()
def test_main_dispatches_list(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main()
runner.list_certs.assert_called_once_with(show_all=False)
def test_main_dispatches_list_all(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main()
runner.list_certs.assert_called_once_with(show_all=True)
def test_main_list_skips_ca_passphrase_resolution(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
mock_resolve = MagicMock()
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
main()
mock_resolve.assert_not_called()
def test_main_list_all_skips_ca_passphrase_resolution(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
mock_resolve = MagicMock()
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
main()
mock_resolve.assert_not_called()
def test_main_no_args_launches_tui(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog"])
app = MagicMock()

View File

@@ -62,6 +62,31 @@ def test_show_confirm_esc():
assert result is False
def test_show_confirm_clamps_width_to_screen_for_long_line():
"""A long message line (e.g. a Cryptgeon password URL) must not make the
requested newwin() width exceed the screen — that's what made
curses.newwin() raise 'curses function returned NULL' on a narrow
terminal such as a serial console."""
stdscr = _make_stdscr(rows=24, cols=80)
win = _make_win()
win.getch.side_effect = [ord("y")]
long_url = "https://cryptgeon.example.com/note/" + "a" * 100 + "#" + "b" * 64
with patch("curses.newwin", return_value=win) as mock_newwin:
show_confirm(stdscr, f"Send email?\n\nPassword URL: {long_url}")
h, w, y, x = mock_newwin.call_args.args
assert w <= 80
assert x >= 0
def test_show_confirm_newwin_failure_returns_false():
"""If curses.newwin() still fails despite clamping, show_confirm must
degrade to 'declined' rather than crash the whole app."""
stdscr = _make_stdscr()
with patch("curses.newwin", side_effect=_curses.error("returned NULL")):
result = show_confirm(stdscr, "Do you want to continue?")
assert result is False
# ---------------------------------------------------------------------------
# show_cert_form tests
# ---------------------------------------------------------------------------
@@ -77,6 +102,18 @@ def test_show_cert_form_cancel():
assert result.confirmed is False
def test_show_cert_form_uses_erase_not_clear():
# clear() forces a full-screen repaint on every refresh(); the form
# redraws on every keystroke, so it must use erase() instead.
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [9, 27] # Tab, then Escape
with patch("curses.newwin", return_value=win):
show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert win.erase.called
assert not win.clear.called
def test_show_cert_form_confirm():
"""Enter advances through the 3 fields to the Continue button; Enter on
Continue confirms. That's 4 Enter keypresses total."""
@@ -102,3 +139,28 @@ def test_show_cert_form_cancel_button():
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult)
assert result.confirmed is False
def test_show_cert_form_clamps_to_narrow_screen():
"""The form's fixed 13x62 size must not exceed a smaller-than-usual
screen, which would make curses.newwin() raise."""
stdscr = _make_stdscr(rows=10, cols=40)
win = _make_win()
win.getch.side_effect = [27]
with patch("curses.newwin", return_value=win) as mock_newwin:
show_cert_form(stdscr, cn="alice", email="alice@example.com")
h, w, y, x = mock_newwin.call_args.args
assert h <= 10
assert w <= 40
assert y >= 0
assert x >= 0
def test_show_cert_form_newwin_failure_returns_cancelled():
"""If curses.newwin() still fails despite clamping, show_cert_form must
degrade to an unconfirmed result rather than crash the whole app."""
stdscr = _make_stdscr()
with patch("curses.newwin", side_effect=_curses.error("returned NULL")):
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult)
assert result.confirmed is False

View File

@@ -1,3 +1,5 @@
import subprocess
import pytest
from unittest.mock import patch, MagicMock
from openvpncertupdate import (
@@ -68,6 +70,35 @@ def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy):
mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)
@patch("openvpncertupdate.subprocess.run")
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_skips_restorecon_when_binary_empty(mock_chmod, mock_copy, mock_run):
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="")
mock_run.assert_not_called()
@patch("openvpncertupdate.subprocess.run")
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_runs_restorecon_when_binary_set(mock_chmod, mock_copy, mock_run):
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon")
mock_run.assert_called_once_with(
["restorecon", "/etc/openvpn/crl.pem"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
@patch("openvpncertupdate.subprocess.run", side_effect=FileNotFoundError("no restorecon"))
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_restorecon_failure_is_non_fatal(mock_chmod, mock_copy, mock_run):
# Missing/misconfigured restorecon must not break CRL deployment.
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon") # must not raise
mock_copy.assert_called_once()
mock_chmod.assert_called_once()
# ---------------------------------------------------------------------------
# is_ca_key_encrypted
# ---------------------------------------------------------------------------

View File

@@ -28,12 +28,13 @@ from openvpncertupdate import (
# Helpers
# ---------------------------------------------------------------------------
def _cert(cn: str, days_left: int) -> CertInfo:
def _cert(cn: str, days_left: int, email: str = "") -> CertInfo:
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
return CertInfo(
cn=cn,
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
days_left=days_left,
email=email,
)
@@ -102,6 +103,29 @@ def test_q_exits():
assert result.selected_cns == []
def test_draw_uses_erase_not_clear():
# clear() forces a full-screen repaint on every refresh(); erase() lets
# curses diff against the previous frame instead — important over a
# low-bandwidth link like a serial console.
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, _CERTS)
assert stdscr.erase.called
assert not stdscr.clear.called
def test_key_resize_forces_one_full_clear():
# A genuine terminal resize should force one true full repaint rather
# than relying on erase()'s diff against pre-resize screen contents.
stdscr = _make_stdscr()
stdscr.getch.side_effect = [_curses.KEY_RESIZE, ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, _CERTS)
assert stdscr.clear.call_count == 1
assert stdscr.erase.call_count == 2 # once before the resize key, once after
def test_capital_Q_exits():
result = _run([ord("Q")])
assert result.action == Action.EXIT
@@ -121,6 +145,28 @@ def test_cursor_row_has_reverse_attribute():
assert alice_calls and not (alice_calls[0].args[3] & _curses.A_REVERSE)
def test_cert_row_shows_email():
certs = [_cert("alice", 10, email="alice@example.com"), _cert("bob", 3)]
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])]
assert alice_calls
assert "alice@example.com" in alice_calls[0].args[2]
def test_cert_row_blank_when_no_email():
certs = [_cert("bob", 3)] # email defaults to ""
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])]
assert bob_calls
assert "(none)" not in bob_calls[0].args[2]
# ---------------------------------------------------------------------------
# Enter on cert → RENEW_SELECTED
# ---------------------------------------------------------------------------

View File

@@ -109,6 +109,43 @@ def test_load_all_certs_sorted_ascending(tmp_path):
assert dates == sorted(dates)
def _make_duplicate_cn_pki(tmp_path, now):
"""index.txt with an old (still V-status, never revoked) entry for
"dave" plus a newer reissued V-status entry for the same CN."""
pki = tmp_path / "pki"
pki.mkdir()
e_old = (now - timedelta(days=5)).strftime("%y%m%d%H%M%S") + "Z" # expired, left unrevoked
e_new = (now + timedelta(days=365)).strftime("%y%m%d%H%M%S") + "Z" # reissued, far future
content = (
f"V\t{e_old}\t\t01\tunknown\t/CN=dave/emailAddress=old@example.com\n"
f"V\t{e_new}\t\t02\tunknown\t/CN=dave/emailAddress=newest@example.com\n"
)
(pki / "index.txt").write_text(content)
return str(pki), e_old, e_new
def test_load_all_certs_dedups_to_last_valid_entry(tmp_path):
# A CN left unrevoked after expiring, then reissued, must appear once
# — as its most recent entry — not as two rows.
now = datetime.now(tz=timezone.utc)
pki, _e_old, _e_new = _make_duplicate_cn_pki(tmp_path, now)
certs = load_all_certs(pki)
dave_rows = [c for c in certs if c.cn == "dave"]
assert len(dave_rows) == 1
assert dave_rows[0].email == "newest@example.com"
assert dave_rows[0].days_left > 300
def test_load_expiring_certs_filters_by_current_entry_not_stale_duplicate(tmp_path):
# The stale (unrevoked) old entry for "dave" falls inside the
# recently-expired window, but the *current* entry (reissued, far in
# the future) doesn't — so "dave" must NOT show up as "expiring soon".
now = datetime.now(tz=timezone.utc)
pki, _e_old, _e_new = _make_duplicate_cn_pki(tmp_path, now)
certs = load_expiring_certs(pki, days_past=30, days_ahead=14)
assert "dave" not in {c.cn for c in certs}
def test_get_email_reads_from_index_txt(tmp_path):
now = datetime.now(tz=timezone.utc)
pki = tmp_path / "pki"