Compare commits
10 Commits
444e35f8cd
...
6c60a63eb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c60a63eb2 | ||
|
|
62fdcabdd4 | ||
|
|
0477392b5e | ||
|
|
bb11702c4f | ||
|
|
2640dbf81d | ||
|
|
92d512f0d2 | ||
|
|
f7ce56a6d4 | ||
|
|
702dc2fe14 | ||
|
|
6248ff47b8 | ||
|
|
3c4eef6bb0 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
19
CLAUDE.md
19
CLAUDE.md
@@ -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 --reissue CN [--email user@example.com]
|
||||||
python3 openvpncertupdate.py --revoke CN
|
python3 openvpncertupdate.py --revoke CN
|
||||||
python3 openvpncertupdate.py --gen-crl
|
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.
|
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 |
|
| Flag | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--create CN` | Issue new cert (requires `--email`) |
|
| `--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 |
|
| `--revoke CN` | Revoke cert and regenerate CRL |
|
||||||
| `--gen-crl` | Regenerate and copy CRL only |
|
| `--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 |
|
| `--email EMAIL` | Recipient address |
|
||||||
| `--send-email` | Force email delivery |
|
| `--send-email` | Force email delivery |
|
||||||
| `--no-send-email` | Skip email; print URL to stdout |
|
| `--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 | all-caps constants |
|
||||||
| SETTINGS OVERRIDE | `ConfigError`, `load_settings_overrides()`, `_OVERRIDABLE_SETTINGS` |
|
| 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()` |
|
| PASSWORD | `generate_password()` |
|
||||||
| EASYRSA | `EasyRSAError`, `revoke_issued()`, `build_client_full()`, `gen_crl()`, `copy_crl()`, `is_ca_key_encrypted()`, `resolve_ca_passphrase()` |
|
| 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` |
|
| 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
|
## Re-issue workflow
|
||||||
|
|
||||||
1. `revoke-issued <CN>` — archives old key + CSR to `pki/revoked/`
|
1. `revoke-issued <CN>` — archives old key + CSR to `pki/revoked/`
|
||||||
2. `build-client-full <CN> --passout=pass:<pw>` — generates new key + cert
|
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. CRL **not** auto-updated during renewal; use "Regenerate CRL" menu item or `r` hotkey
|
3. `build-client-full <CN> --passout=pass:<pw>` — generates new key + cert
|
||||||
|
|
||||||
## TUI key bindings
|
## 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
|
- 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=""`
|
- 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
|
- 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()>`
|
- 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
|
- 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`
|
- 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
|
||||||
|
|||||||
1930
docs/superpowers/plans/2026-06-23-openvpncertupdate.md
Normal file
1930
docs/superpowers/plans/2026-06-23-openvpncertupdate.md
Normal file
File diff suppressed because it is too large
Load Diff
425
docs/superpowers/plans/2026-07-08-cli-list-certs.md
Normal file
425
docs/superpowers/plans/2026-07-08-cli-list-certs.md
Normal 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 1–2 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.
|
||||||
135
docs/superpowers/plans/2026-07-08-tui-list-emails.md
Normal file
135
docs/superpowers/plans/2026-07-08-tui-list-emails.md
Normal 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.
|
||||||
@@ -54,6 +54,7 @@ CONFIG_NAME = "client.ovpn"
|
|||||||
VPN_CONFIGS_DIR = "./vpn-configs"
|
VPN_CONFIGS_DIR = "./vpn-configs"
|
||||||
|
|
||||||
CRL_DEST_PATH = "/etc/openvpn/crl.pem"
|
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"
|
CRYPTGEON_URL = "https://cryptgeon.example.com"
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ DAYS_AHEAD = 14
|
|||||||
_OVERRIDABLE_SETTINGS = frozenset({
|
_OVERRIDABLE_SETTINGS = frozenset({
|
||||||
"EASYRSA_DIR", "EASYRSA_PKI_DIR", "CA_PASSPHRASE",
|
"EASYRSA_DIR", "EASYRSA_PKI_DIR", "CA_PASSPHRASE",
|
||||||
"OVPN_TEMPLATE_PATH", "CONFIG_NAME", "VPN_CONFIGS_DIR",
|
"OVPN_TEMPLATE_PATH", "CONFIG_NAME", "VPN_CONFIGS_DIR",
|
||||||
"CRL_DEST_PATH",
|
"CRL_DEST_PATH", "RESTORECON_BINARY",
|
||||||
"CRYPTGEON_URL",
|
"CRYPTGEON_URL",
|
||||||
"MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY",
|
"MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY",
|
||||||
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS",
|
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS",
|
||||||
@@ -165,71 +166,61 @@ 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]:
|
||||||
|
"""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)
|
||||||
|
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
|
||||||
|
by_cn[cn] = CertInfo(
|
||||||
|
cn=cn,
|
||||||
|
expires=expiry,
|
||||||
|
days_left=(expiry - now).days,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
return list(by_cn.values())
|
||||||
|
|
||||||
|
|
||||||
def load_expiring_certs(
|
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 valid 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)
|
||||||
cutoff_future = now + timedelta(days=days_ahead)
|
cutoff_future = now + timedelta(days=days_ahead)
|
||||||
results: list[CertInfo] = []
|
results = [c for c in _load_current_certs(pki_dir)
|
||||||
with open(os.path.join(pki_dir, "index.txt")) as fh:
|
if cutoff_past <= c.expires <= cutoff_future]
|
||||||
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)
|
results.sort(key=lambda c: c.expires)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def load_all_certs(pki_dir: str) -> list[CertInfo]:
|
def load_all_certs(pki_dir: str) -> list[CertInfo]:
|
||||||
"""Return all valid (V-status) certs, sorted by expiry date."""
|
"""Return all current certs, sorted by expiry date."""
|
||||||
now = datetime.now(tz=timezone.utc)
|
results = _load_current_certs(pki_dir)
|
||||||
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(
|
|
||||||
cn=cn,
|
|
||||||
expires=expiry,
|
|
||||||
days_left=(expiry - now).days,
|
|
||||||
email=email,
|
|
||||||
))
|
|
||||||
results.sort(key=lambda c: c.expires)
|
results.sort(key=lambda c: c.expires)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_email(pki_dir: str, cn: str) -> str:
|
def get_email(pki_dir: str, cn: str) -> str:
|
||||||
"""Return the emailAddress from CN's most recent V-status index.txt entry.
|
"""Return the emailAddress on CN's current (most recent V-status) entry."""
|
||||||
|
for c in _load_current_certs(pki_dir):
|
||||||
index.txt is append-only, so a CN can have several V-status lines (e.g.
|
if c.cn == cn:
|
||||||
issued directly with easyrsa, bypassing this tool's revoke-before-build
|
return c.email
|
||||||
renewal flow) as well as older R-status (revoked) ones; scan the whole
|
return ""
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -317,9 +308,16 @@ 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)
|
shutil.copy2(f"{pki_dir}/crl.pem", dest_path)
|
||||||
os.chmod(dest_path, 0o644)
|
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], capture_output=True, text=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def is_ca_key_encrypted(pki_dir: str) -> bool:
|
def is_ca_key_encrypted(pki_dir: str) -> bool:
|
||||||
@@ -651,9 +649,15 @@ def show_confirm(stdscr, message: str) -> bool:
|
|||||||
"""Y/N modal dialog. Returns True if user presses y/Y."""
|
"""Y/N modal dialog. Returns True if user presses y/Y."""
|
||||||
sh, sw = stdscr.getmaxyx()
|
sh, sw = stdscr.getmaxyx()
|
||||||
lines = message.splitlines()
|
lines = message.splitlines()
|
||||||
h = len(lines) + 6
|
# Clamp to the screen: an unbounded width (e.g. a long Cryptgeon URL line)
|
||||||
w = max(max(len(l) for l in lines) + 6, 38)
|
# makes curses.newwin() raise "curses function returned NULL" outright on
|
||||||
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
# 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)
|
win.keypad(True)
|
||||||
draw_box(win, "Confirm")
|
draw_box(win, "Confirm")
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
@@ -704,8 +708,14 @@ def show_cert_form(
|
|||||||
Tab / Shift-Tab / Up / Down cycle fields and buttons. F5 regenerates password.
|
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."""
|
Enter or Space activates the focused button. Ctrl-G confirms immediately. Escape cancels."""
|
||||||
sh, sw = stdscr.getmaxyx()
|
sh, sw = stdscr.getmaxyx()
|
||||||
h, w = 13, 62
|
# Clamp to the screen so curses.newwin() can't raise "curses function
|
||||||
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
# 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)
|
win.keypad(True)
|
||||||
fw = w - 6 # field width
|
fw = w - 6 # field width
|
||||||
|
|
||||||
@@ -737,7 +747,7 @@ def show_cert_form(
|
|||||||
|
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
while True:
|
while True:
|
||||||
win.clear()
|
win.erase() # see show_main_screen's draw() for why not clear()
|
||||||
draw_box(win, "Certificate Details")
|
draw_box(win, "Certificate Details")
|
||||||
for name in _FORM_FIELDS:
|
for name in _FORM_FIELDS:
|
||||||
fy = _FORM_FIELD_Y[name]
|
fy = _FORM_FIELD_Y[name]
|
||||||
@@ -885,7 +895,11 @@ def show_main_screen(
|
|||||||
def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None
|
def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None
|
||||||
|
|
||||||
def draw():
|
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()
|
sh, sw = stdscr.getmaxyx()
|
||||||
|
|
||||||
# Header bar — shows current filter mode
|
# Header bar — shows current filter mode
|
||||||
@@ -926,7 +940,7 @@ def show_main_screen(
|
|||||||
cert = certs[i]
|
cert = certs[i]
|
||||||
box = "[X]" if cert.cn in checked else "[ ]"
|
box = "[X]" if cert.cn in checked else "[ ]"
|
||||||
lbl = _expiry_label(cert)
|
lbl = _expiry_label(cert)
|
||||||
line = f" {box} {lbl:<18} {cert.cn}"
|
line = f" {box} {lbl:<18} {cert.cn:<20} {cert.email}"
|
||||||
try:
|
try:
|
||||||
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr)
|
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
@@ -955,6 +969,13 @@ def show_main_screen(
|
|||||||
draw()
|
draw()
|
||||||
key = stdscr.getch()
|
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 key == 27: # Esc
|
||||||
if esc_pending:
|
if esc_pending:
|
||||||
return ScreenResult(action=Action.EXIT)
|
return ScreenResult(action=Action.EXIT)
|
||||||
@@ -1100,6 +1121,20 @@ class CursesApp:
|
|||||||
except EasyRSAError as exc:
|
except EasyRSAError as exc:
|
||||||
self._error(stdscr, f"EasyRSA error during revoke:\n{exc}")
|
self._error(stdscr, f"EasyRSA error during revoke:\n{exc}")
|
||||||
return True
|
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:
|
try:
|
||||||
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR,
|
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR,
|
||||||
@@ -1176,7 +1211,7 @@ class CursesApp:
|
|||||||
try:
|
try:
|
||||||
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
||||||
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, 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:
|
except EasyRSAError as exc:
|
||||||
self._error(stdscr, f"Revoke failed:\n{exc}")
|
self._error(stdscr, f"Revoke failed:\n{exc}")
|
||||||
return
|
return
|
||||||
@@ -1187,7 +1222,7 @@ class CursesApp:
|
|||||||
self._msg(stdscr, "Regenerating CRL…")
|
self._msg(stdscr, "Regenerating CRL…")
|
||||||
try:
|
try:
|
||||||
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, 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:
|
except EasyRSAError as exc:
|
||||||
self._error(stdscr, f"gen-crl failed:\n{exc}")
|
self._error(stdscr, f"gen-crl failed:\n{exc}")
|
||||||
return
|
return
|
||||||
@@ -1299,7 +1334,7 @@ class CliRunner:
|
|||||||
try:
|
try:
|
||||||
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
||||||
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, 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:
|
except EasyRSAError as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -1309,12 +1344,17 @@ class CliRunner:
|
|||||||
print("Regenerating CRL…", file=sys.stderr)
|
print("Regenerating CRL…", file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, 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:
|
except EasyRSAError as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(f"CRL updated → {CRL_DEST_PATH}")
|
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,
|
def _issue(self, cn: str, email_addr: str, is_renewal: bool,
|
||||||
send_email_flag: bool = True, show_eml: bool = False) -> None:
|
send_email_flag: bool = True, show_eml: bool = False) -> None:
|
||||||
password = generate_password()
|
password = generate_password()
|
||||||
@@ -1326,6 +1366,21 @@ class CliRunner:
|
|||||||
except EasyRSAError as exc:
|
except EasyRSAError as exc:
|
||||||
print(f"error during revoke: {exc}", file=sys.stderr)
|
print(f"error during revoke: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
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)
|
print(f"Building certificate for {cn}…", file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
@@ -1405,6 +1460,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
group.add_argument("--reissue", metavar="CN", help="Revoke and reissue certificate for CN")
|
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("--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("--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",
|
parser.add_argument("--email", metavar="EMAIL",
|
||||||
help="Email address (required for --create; optional for --reissue)")
|
help="Email address (required for --create; optional for --reissue)")
|
||||||
parser.add_argument("--send-email", dest="send_email", action="store_const", const=True,
|
parser.add_argument("--send-email", dest="send_email", action="store_const", const=True,
|
||||||
@@ -1436,6 +1495,13 @@ def main() -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
globals().update(overrides)
|
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
|
global CA_PASSPHRASE
|
||||||
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
|
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
|
||||||
|
|
||||||
|
|||||||
@@ -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"):
|
one_time_url="https://cg.example.com/#/note/abc/xyz"):
|
||||||
"""Patch all side-effectful callables used by CliRunner._issue."""
|
"""Patch all side-effectful callables used by CliRunner._issue."""
|
||||||
monkeypatch.setattr("openvpncertupdate.revoke_issued", MagicMock())
|
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_client_full", MagicMock())
|
||||||
monkeypatch.setattr("openvpncertupdate.build_ovpn", MagicMock(return_value=ovpn_path))
|
monkeypatch.setattr("openvpncertupdate.build_ovpn", MagicMock(return_value=ovpn_path))
|
||||||
monkeypatch.setattr("openvpncertupdate.create_note", MagicMock(return_value=one_time_url))
|
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"))
|
monkeypatch.setattr("openvpncertupdate.generate_password", MagicMock(return_value="Testpass1234567890abcdefgh"))
|
||||||
return {
|
return {
|
||||||
"revoke_issued": sys.modules["openvpncertupdate"].revoke_issued,
|
"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_client_full": sys.modules["openvpncertupdate"].build_client_full,
|
||||||
"build_ovpn": sys.modules["openvpncertupdate"].build_ovpn,
|
"build_ovpn": sys.modules["openvpncertupdate"].build_ovpn,
|
||||||
"create_note": sys.modules["openvpncertupdate"].create_note,
|
"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"
|
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):
|
def test_reissue_falls_back_to_index_txt_email(monkeypatch, capsys):
|
||||||
# get_email() reads straight from index.txt; this stubs that lookup
|
# 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).
|
# 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
|
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
|
# main() argument dispatch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -251,6 +323,40 @@ def test_main_dispatches_gen_crl(monkeypatch):
|
|||||||
runner.regen_crl.assert_called_once_with()
|
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):
|
def test_main_no_args_launches_tui(monkeypatch):
|
||||||
monkeypatch.setattr(sys, "argv", ["prog"])
|
monkeypatch.setattr(sys, "argv", ["prog"])
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
|
|||||||
@@ -62,6 +62,31 @@ def test_show_confirm_esc():
|
|||||||
assert result is False
|
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
|
# show_cert_form tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -77,6 +102,18 @@ def test_show_cert_form_cancel():
|
|||||||
assert result.confirmed is False
|
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():
|
def test_show_cert_form_confirm():
|
||||||
"""Enter advances through the 3 fields to the Continue button; Enter on
|
"""Enter advances through the 3 fields to the Continue button; Enter on
|
||||||
Continue confirms. That's 4 Enter keypresses total."""
|
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")
|
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||||||
assert isinstance(result, CertFormResult)
|
assert isinstance(result, CertFormResult)
|
||||||
assert result.confirmed is False
|
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
|
||||||
|
|||||||
@@ -68,6 +68,33 @@ def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy):
|
|||||||
mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)
|
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"], capture_output=True, text=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
|
# is_ca_key_encrypted
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ from openvpncertupdate import (
|
|||||||
# Helpers
|
# 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."""
|
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
|
||||||
return CertInfo(
|
return CertInfo(
|
||||||
cn=cn,
|
cn=cn,
|
||||||
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
||||||
days_left=days_left,
|
days_left=days_left,
|
||||||
|
email=email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +103,29 @@ def test_q_exits():
|
|||||||
assert result.selected_cns == []
|
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():
|
def test_capital_Q_exits():
|
||||||
result = _run([ord("Q")])
|
result = _run([ord("Q")])
|
||||||
assert result.action == Action.EXIT
|
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)
|
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
|
# Enter on cert → RENEW_SELECTED
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -109,6 +109,43 @@ def test_load_all_certs_sorted_ascending(tmp_path):
|
|||||||
assert dates == sorted(dates)
|
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):
|
def test_get_email_reads_from_index_txt(tmp_path):
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
pki = tmp_path / "pki"
|
pki = tmp_path / "pki"
|
||||||
|
|||||||
Reference in New Issue
Block a user