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>
99 lines
8.2 KiB
Markdown
99 lines
8.2 KiB
Markdown
# 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 --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
|
||
|
||
```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()` |
|
||
| 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
|
||
|
||
0. `has_issued_cert()` gates steps 1–2: 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`
|
||
1. `revoke-issued <CN>` — 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 <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
|