Code review findings on the "skip revoke when .crt is missing" migration path: - CliRunner._issue() took the skip path whenever has_issued_cert() was False, which is also true for a typo'd/nonexistent CN — it would warn, skip the revoke, and go on to build, package, and email a brand-new certificate for a CN nobody asked to renew. The skip now only fires when the CN has a current index.txt entry (via _load_current_certs()); an unknown CN prints an error and exits 1 with nothing built. The TUI's _process_cert() doesn't need the same guard — renewal there always opens on an existing row (cn_readonly pins the CN), so a typo'd CN can't reach the branch. - Skipping the revoke leaves pki/reqs/<CN>.req and pki/private/<CN>.key in place (normally revoke-issued archives both), which makes EasyRSA's build-client-full abort. Both CliRunner._issue() and CursesApp._process_cert() now check for those leftovers before building and fail fast with the exact paths, rather than surfacing EasyRSA's confusing error after the CA passphrase prompt. Neither path touches the files itself. Also: strengthened two under-specified tests (test_main_rejects_bad_days_flag now checks the resolver's message text, not just "--days", which also appears in argparse's unrelated error; test_show_cert_form_confirm now pins the Enter-keypress count so a partial "days" field reversion is caught), and folded a malformed CLAUDE.md table row into its neighbor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
10 KiB
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
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
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/<CN>_<YYYY-MM-DD>_<NN>/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
has_issued_cert()gates steps 1–2: if<PKI_DIR>/issued/<CN>.crtis absent, both are skipped with a warning and the workflow goes straight to step 3. EasyRSA reads the serial out of the.crtitself, sorevoke-issuedcan only fail on such a CN — and there is nothing to add to the CRL either. This happens when anindex.txtis carried over from an older EasyRSA install without theissued/files: the index still lists V-status certs whose.crtnever came along.--revoke/ the TUIrkey 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()setsCertInfo.has_cert_file(one stat per CN, after the dedup), rendered as(no cert)before the email in the TUI list and as aMISSINGcell in theCERTcolumn 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 CNis not a migration gap and is rejected witherror: 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 leftoverpki/reqs/<CN>.req/pki/private/<CN>.key— normallyrevoke-issuedarchives both intopki/revoked/, but skipping it leaves them in place, and EasyRSA'sbuild-client-fullaborts 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 itselfrevoke-issued <CN>— archives old key + CSR topki/revoked/- CRL regenerated and copied to
CRL_DEST_PATHimmediately 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-crlremain available to retry) build-client-full <CN> --passout=pass:<pw>— 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 inmain()right after arg parsing, before dispatch): resolution order is--config PATH>CONFIG_PATHsetting ><this-script-path>.confnext to the script. The CLI flag orCONFIG_PATHmake the path explicit — a missing file there is a fatalConfigError; the default<script>.confpath is optional and silently skipped if absent. The file is executed as Python (same syntax as theSETTINGSblock, so only run trusted files) and only names listed in_OVERRIDABLE_SETTINGSare applied —CONFIG_PATHitself is deliberately not overridable this way - Email: set
SMTP_HOSTto use smtplib (SMTP_TLS:"starttls"/"ssl"/""); leave empty to useMAIL_BINARY. Auth skipped whenSMTP_USER="" - EasyRSA called with
--batch;--passin=pass:<passphrase>omitted when the resolved passphrase is empty - EasyRSA error text arrives on stdout, not stderr: its
print()isprintf '%s\n', and bothdie()anduser_error()route through it. stderr only carries output from the tools EasyRSA shells out to (openssl), and even that is silenced under-S/--silent-ssl(not passed here)._easyrsa_diagnostics()therefore merges both streams — building an error from stderr alone reports failures as blank - CA passphrase resolution (
resolve_ca_passphrase(), run once inmain()right after arg parsing, before dispatch):CA_PASSPHRASE=""→ auto-detect viais_ca_key_encrypted()(checks<PKI_DIR>/private/ca.keyPEM header forENCRYPTED) and prompt only if encrypted;"!empty"→ never check/prompt, passphrase is"";"!ask"→ always prompt, skip detection; any other value → used literally.--list/--list-allskip this resolution entirely since they only readindex.txtand never touch the CA - Certificate lifetime (
resolve_cert_days(), run once inmain()right after the config overrides are applied — before the--listearly return, since it validates config and never prompts):CERT_DAYS = "default"or""→ omit--daysentirely and let EasyRSA's ownEASYRSA_CERT_EXPIRE(fromvars) decide; a positive number → passed as--days=N, normalised so"090"becomes"90".0, negatives and non-numeric values raiseConfigError— a fast-fail mirror of EasyRSA's own gate (Cannot use --days=0 for command build-client-full), not a substitute for it.--days NoverridesCERT_DAYSfor one CLI run; the TUI's Days field overrides it per certificate. The flag is added inbuild_client_full()only, never in_base_cmd()—gen-crlreads--daysas CRL validity - Cryptgeon: matches the
occultobrowser 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()doeschmod 644after copy, then runsRESTORECON_BINARY(defaultrestorecon) on the copied file — best-effort likeis_ca_key_encrypted(): a missing/misconfigured binary is swallowed, not fatal. SetRESTORECON_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);
oO01lIQ5S2Z8Bbanned everywhere - Inline file path:
<PKI_DIR>/inline/private/<CN>.inline - User emails are not stored separately:
build_client_full()setsEASYRSA_REQ_EMAILwhenever an email is known, which EasyRSA embeds asemailAddress=in the cert subject — so it round-trips through<PKI_DIR>/index.txtitself.get_email()reads it back from there; there is noopenvpncertupdate-metadata.json index.txtis 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(), andget_email()all build on it, so the TUI list,--list/--list-all, and email lookups never show/use a stale duplicate