Reissue internally revokes the old cert (revoke_issued), which makes the published CRL immediately stale, but neither the CLI --reissue path nor the TUI renew flow ever regenerated/copied it — only standalone revoke and "Regenerate CRL" did. A cert revoked as part of a reissue could keep authenticating against OpenVPN until someone remembered to regenerate the CRL by hand. Both reissue paths now call gen_crl()+copy_crl() right after a successful revoke_issued(), same as standalone revoke. This isn't gated on the subsequent build_client_full() succeeding, since the CRL is already stale the moment the old cert is revoked. A CRL-regen failure here is reported as a warning and the reissue continues to build the replacement cert — a new cert is more urgent than a fresh CRL, and "Regenerate CRL"/--gen-crl remain available to retry. Also add a RESTORECON_BINARY setting (default "restorecon", "" to disable): copy_crl() now runs it on the destination file after every copy, everywhere copy_crl() is called. Best-effort like is_ca_key_encrypted() — a missing/misconfigured binary is swallowed rather than breaking CRL deployment on non-SELinux systems. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.8 KiB
6.8 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 |
--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, 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
revoke-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 - 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