Renewing such a CN now works (it skips the revoke), but the list gave no hint that it was a special case until the workflow printed its warning. Flag it at selection time instead. _load_current_certs() sets CertInfo.has_cert_file, so every view built on it — the TUI list, --list and --list-all — gets the flag for free. The stat happens after the per-CN dedup, so a CN with several V-lines in index.txt is checked once. TUI rows render "(no cert file)" between the CN and the email, placed before the email so a long address truncating at the right edge cannot push the marker off screen. --list/--list-all grow a trailing CERT column holding MISSING; the column is omitted entirely when every CN has its .crt, since it is pure noise on a healthy PKI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.2 KiB
8.2 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 --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 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 |
--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() |
| 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-allrevoke-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 - 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