Files
openvpncertupdate/CLAUDE.md
Vlad Doloman 8adee23a69 Mark CNs with no issued cert file in the TUI and CLI lists
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>
2026-08-15 04:12:38 +03:00

8.2 KiB
Raw Blame History

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

  1. has_issued_cert() gates steps 12: if <PKI_DIR>/issued/<CN>.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
  2. revoke-issued <CN> — archives old key + CSR to pki/revoked/
  3. 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)
  4. 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 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=""
  • EasyRSA called with --batch; --passin=pass:<passphrase> omitted when the resolved passphrase is empty
  • EasyRSA error text arrives on stdout, not stderr: its print() is printf '%s\n', and both die() and user_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 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()>
  • 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
  • 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; 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