# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is `openvpncertupdate` is a single-file Python + curses TUI tool for managing OpenVPN user certificates via EasyRSA 3.2.x. It lists expiring/expired certs, re-issues them with fresh keys, creates new certs, and delivers configs via Cryptgeon (one-time password URL) and email. It also supports a non-interactive CLI mode for scripting and cron use. ## Running ```bash pip install -r requirements.txt # just: cryptography>=41 python3 openvpncertupdate.py # interactive TUI python3 openvpncertupdate.py --create CN --email user@example.com python3 openvpncertupdate.py --reissue CN [--email user@example.com] python3 openvpncertupdate.py --create CN --email user@example.com --days 90 python3 openvpncertupdate.py --revoke CN python3 openvpncertupdate.py --gen-crl python3 openvpncertupdate.py --list python3 openvpncertupdate.py --list-all ``` Edit the `SETTINGS` block at the top of `openvpncertupdate.py` before first run. ### CLI flags | Flag | Effect | |---|---| | `--create CN` | Issue new cert (requires `--email`) | | `--reissue CN` | Revoke + regenerate CRL + reissue cert (`--email` optional, falls back to stored) | | `--revoke CN` | Revoke cert and regenerate CRL | | `--gen-crl` | Regenerate and copy CRL only | | `--list` | List recently-expired/soon-to-expire CNs (per `DAYS_PAST`/`DAYS_AHEAD`) with email; read-only, no CA passphrase needed | | `--list-all` | List all CNs with email; read-only, no CA passphrase needed. Both `--list`/`--list-all` grow a trailing `CERT` column marking `MISSING` CNs — see `CertInfo.has_cert_file`. The column is omitted entirely when every CN has its `.crt` | | `--email EMAIL` | Recipient address | | `--days N` | Certificate lifetime in days for `--create`/`--reissue`; overrides `CERT_DAYS` for that run | | `--send-email` | Force email delivery | | `--no-send-email` | Skip email; print URL to stdout | | `--show-eml` | Print base64-encoded `.eml` to stdout (implies `--no-send-email` unless `--send-email` also given) | | `--config PATH` | External `.conf` file overriding `SETTINGS` (overrides `CONFIG_PATH`; missing file here is an error) | ## Tests ```bash python3 -m pytest tests/ -v python3 -m pytest tests/test_password.py -v # single file python3 -m pytest tests/test_pki.py::test_sorted_ascending -v # single test ``` ## File layout — sections inside `openvpncertupdate.py` | Section | Key symbols | |---|---| | SETTINGS | all-caps constants | | SETTINGS OVERRIDE | `ConfigError`, `load_settings_overrides()`, `_OVERRIDABLE_SETTINGS` | | PKI | `CertInfo`, `_load_current_certs()`, `load_expiring_certs()`, `load_all_certs()`, `_parse_index_line()`, `get_email()` | | PASSWORD | `generate_password()` | | EASYRSA | `EasyRSAError`, `_easyrsa_diagnostics()`, `issued_cert_path()`, `has_issued_cert()`, `revoke_issued()`, `build_client_full()`, `gen_crl()`, `copy_crl()`, `is_ca_key_encrypted()`, `resolve_ca_passphrase()`, `resolve_cert_days()` | | CONFIG | `build_ovpn()` → `vpn-configs/__/CONFIG_NAME` | | CRYPTGEON | `CryptgeonError`, `create_note()` | | MAILER | `build_mime_message()`, `send_email()` | | TUI WIDGETS | `InputField`, `clamp()`, `draw_box()`, `init_colors()`, `COLOR_*` | | TUI DIALOGS | `show_confirm()`, `show_cert_form()`, `CertFormResult` | | TUI SCREEN | `show_main_screen()`, `Action`, `ScreenResult` | | APP | `CursesApp` | | CLI | `CliRunner`, `_build_parser()` | | ENTRY POINT | `main()` | ## Re-issue workflow 0. `has_issued_cert()` gates steps 1–2: if `/issued/.crt` is absent, both are **skipped** with a warning and the workflow goes straight to step 3. EasyRSA reads the serial out of the `.crt` itself, so `revoke-issued` can only fail on such a CN — and there is nothing to add to the CRL either. This happens when an `index.txt` is carried over from an older EasyRSA install without the `issued/` files: the index still lists V-status certs whose `.crt` never came along. `--revoke` / the TUI `r` key deliberately do *not* skip — an explicit revoke request should fail loudly rather than silently no-op. Such CNs are flagged before the user picks one: `_load_current_certs()` sets `CertInfo.has_cert_file` (one stat per CN, after the dedup), rendered as `(no cert)` before the email in the TUI list and as a `MISSING` cell in the `CERT` column of `--list`/`--list-all`. Two guards bound the skip so it can't silently do the wrong thing: (a) on the CLI, the skip only fires for a CN that `_load_current_certs()` actually knows about — a typo'd/nonexistent `--reissue CN` is *not* a migration gap and is rejected with `error: unknown CN ...` before anything is built (the TUI can't hit this: renewal always opens on an existing row, so the CN is never freeform there); (b) whichever entry point takes the skip, it first checks for leftover `pki/reqs/.req` / `pki/private/.key` — normally `revoke-issued` archives both into `pki/revoked/`, but skipping it leaves them in place, and EasyRSA's `build-client-full` aborts outright rather than overwrite them. Either leftover fails the workflow fast with the exact path(s), before the CA passphrase prompt — this tool never moves or deletes a private key itself 1. `revoke-issued ` — archives old key + CSR to `pki/revoked/` 2. CRL regenerated and copied to `CRL_DEST_PATH` immediately after the revoke succeeds — the old cert is already revoked at this point, so the published CRL would otherwise be stale until a separate manual regen. Not fatal: a failure here is reported but the workflow continues to step 3 (a new cert is more urgent than a fresh CRL, and "Regenerate CRL" / `--gen-crl` remain available to retry) 3. `build-client-full --passout=pass:` — generates new key + cert ## TUI key bindings | Key | Action | |---|---| | `↑`/`↓` | Navigate list | | `Space` | Toggle checkbox selection | | `Enter` | Confirm / open selected item | | `r` | Revoke selected cert | | `A` | Toggle between expiring-only and all-certs view | | `q`/`Esc` | Quit | ## Key constraints - External config file (`load_settings_overrides()`, run once in `main()` right after arg parsing, before dispatch): resolution order is `--config PATH` > `CONFIG_PATH` setting > `.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 `