Compare commits

..

3 Commits

Author SHA1 Message Date
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
7 changed files with 2533 additions and 25 deletions

1
.gitignore vendored Normal file
View File

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

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 #!/usr/bin/env python3
"""openvpncertupdate — Manage OpenVPN user certificates via EasyRSA.""" """openvpncertupdate — Manage OpenVPN user certificates via EasyRSA."""
from __future__ import annotations
import argparse import argparse
import base64 import base64
import curses import curses
@@ -21,14 +19,20 @@ import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Dict, List, Optional, Set, Tuple
try: 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 except ImportError: # pragma: no cover
AESGCM = None # type: ignore AESGCM = None # type: ignore
@@ -144,7 +148,7 @@ def _parse_date(raw: str) -> datetime:
return dt.replace(tzinfo=timezone.utc) 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.""" """Return (cn, expiry, email) for valid (V-status) index.txt lines, else None."""
parts = line.rstrip("\n").split("\t") parts = line.rstrip("\n").split("\t")
if len(parts) < 6 or parts[0] != "V": if len(parts) < 6 or parts[0] != "V":
@@ -166,7 +170,7 @@ def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
return cn, expiry, email return cn, expiry, email
def _load_current_certs(pki_dir: str) -> list[CertInfo]: def _load_current_certs(pki_dir: str) -> List[CertInfo]:
"""Return one CertInfo per CN: its most recent V-status index.txt entry. """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 index.txt is append-only, so a CN can accumulate several V-status lines
@@ -177,7 +181,7 @@ def _load_current_certs(pki_dir: str) -> list[CertInfo]:
so expiring/all-certs views don't show stale duplicate rows. so expiring/all-certs views don't show stale duplicate rows.
""" """
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
by_cn: dict[str, CertInfo] = {} by_cn: Dict[str, CertInfo] = {}
with open(os.path.join(pki_dir, "index.txt")) as fh: with open(os.path.join(pki_dir, "index.txt")) as fh:
for line in fh: for line in fh:
parsed = _parse_index_line(line) parsed = _parse_index_line(line)
@@ -197,7 +201,7 @@ def load_expiring_certs(
pki_dir: str, pki_dir: str,
days_past: int, days_past: int,
days_ahead: int, days_ahead: int,
) -> list[CertInfo]: ) -> List[CertInfo]:
"""Return current certs whose expiry falls within [-days_past, +days_ahead].""" """Return current certs whose expiry falls within [-days_past, +days_ahead]."""
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
cutoff_past = now - timedelta(days=days_past) cutoff_past = now - timedelta(days=days_past)
@@ -208,7 +212,7 @@ def load_expiring_certs(
return results return results
def load_all_certs(pki_dir: str) -> list[CertInfo]: def load_all_certs(pki_dir: str) -> List[CertInfo]:
"""Return all current certs, sorted by expiry date.""" """Return all current certs, sorted by expiry date."""
results = _load_current_certs(pki_dir) results = _load_current_certs(pki_dir)
results.sort(key=lambda c: c.expires) results.sort(key=lambda c: c.expires)
@@ -257,13 +261,16 @@ class EasyRSAError(Exception):
pass pass
def _run_easyrsa(cmd: list[str], cwd: str, def _run_easyrsa(cmd: List[str], cwd: str,
extra_env: Optional[dict[str, str]] = None) -> None: extra_env: Optional[Dict[str, str]] = None) -> None:
env = None env = None
if extra_env: if extra_env:
env = os.environ.copy() env = os.environ.copy()
env.update(extra_env) 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: if result.returncode != 0:
# Find the first non-flag argument after the binary (the actual easyrsa verb) # 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") verb = next((c for c in cmd[1:] if not c.startswith("-")), "unknown")
@@ -272,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}"] cmd = [f"{easyrsa_dir}/easyrsa", "--batch", f"--pki={pki_dir}"]
if ca_passphrase: if ca_passphrase:
cmd.append(f"--passin=pass:{ca_passphrase}") cmd.append(f"--passin=pass:{ca_passphrase}")
@@ -315,7 +322,11 @@ def copy_crl(pki_dir: str, dest_path: str, restorecon_binary: str = "") -> None:
# Best-effort, like is_ca_key_encrypted(): a missing/misconfigured # Best-effort, like is_ca_key_encrypted(): a missing/misconfigured
# restorecon must not break CRL deployment on non-SELinux systems. # restorecon must not break CRL deployment on non-SELinux systems.
try: try:
subprocess.run([restorecon_binary, dest_path], capture_output=True, text=True) subprocess.run(
[restorecon_binary, dest_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
)
except OSError: except OSError:
pass pass
@@ -727,7 +738,7 @@ def show_cert_form(
active = ([n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)] active = ([n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
+ [_BTN_CONTINUE, _BTN_CANCEL]) + [_BTN_CONTINUE, _BTN_CANCEL])
fields: dict[str, InputField] = { fields: Dict[str, InputField] = {
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn), "cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email), "email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw, "password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
@@ -857,8 +868,8 @@ class Action(Enum):
@dataclass @dataclass
class ScreenResult: class ScreenResult:
action: Action action: Action
selected_cns: list[str] = field(default_factory=list) selected_cns: List[str] = field(default_factory=list)
saved_checked: list[str] = field(default_factory=list) saved_checked: List[str] = field(default_factory=list)
saved_cursor: int = 0 saved_cursor: int = 0
@@ -877,8 +888,8 @@ def _expiry_label(c: CertInfo) -> str:
def show_main_screen( def show_main_screen(
stdscr, certs: list[CertInfo], show_all: bool = False, stdscr, certs: List[CertInfo], show_all: bool = False,
initial_checked: Optional[set[str]] = None, initial_checked: Optional[Set[str]] = None,
initial_cursor: int = 0, initial_cursor: int = 0,
) -> ScreenResult: ) -> ScreenResult:
"""Display cert selection list. Returns when user activates an action.""" """Display cert selection list. Returns when user activates an action."""
@@ -887,7 +898,7 @@ def show_main_screen(
n_certs = len(certs) n_certs = len(certs)
n_items = n_certs + len(_MENU_ITEMS) 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)) cursor = clamp(initial_cursor, 0, max(0, n_items - 1))
esc_pending = False esc_pending = False
@@ -1037,7 +1048,7 @@ def show_main_screen(
class CursesApp: class CursesApp:
def __init__(self) -> None: def __init__(self) -> None:
self._session_log: list[str] = [] self._session_log: List[str] = []
def _log(self, entry: str) -> None: def _log(self, entry: str) -> None:
self._session_log.append(entry) self._session_log.append(entry)
@@ -1062,7 +1073,7 @@ class CursesApp:
init_colors() init_colors()
show_all = False show_all = False
saved_checked: list[str] = [] saved_checked: List[str] = []
saved_cursor: int = 0 saved_cursor: int = 0
while True: while True:
@@ -1297,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`.""" """Render CN / expiry / email as an aligned table, like `ls -l`."""
if not certs: if not certs:
return "(no certificates)" return "(no certificates)"

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

@@ -1,3 +1,5 @@
import subprocess
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from openvpncertupdate import ( from openvpncertupdate import (
@@ -82,7 +84,9 @@ def test_copy_crl_skips_restorecon_when_binary_empty(mock_chmod, mock_copy, mock
def test_copy_crl_runs_restorecon_when_binary_set(mock_chmod, mock_copy, mock_run): 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") copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
["restorecon", "/etc/openvpn/crl.pem"], capture_output=True, text=True) ["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.subprocess.run", side_effect=FileNotFoundError("no restorecon"))