Files
openvpncertupdate/CLAUDE.md
T
Vlad DolomanandClaude Opus 5 9e5df8d9bb Fix re-issue for CNs whose issued cert file is missing
Renewal failed with an empty error box for any CN listed in index.txt
without a corresponding pki/issued/<CN>.crt — the state you get when an
index.txt is carried over from an older EasyRSA install but the issued/
files are not.

Two defects:

1. EasyRSA writes its diagnostics to stdout, not stderr: 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, and
   even that is silenced under -S/--silent-ssl. _run_easyrsa built its
   message from stderr alone, so every EasyRSA failure reported blank.
   _easyrsa_diagnostics() now merges both streams (stderr first, so the
   specific openssl message is not what the dialog clips) and drops the
   version banner and blank padding.

2. EasyRSA reads the serial out of the .crt itself, so revoke-issued
   cannot revoke a CN whose cert file is gone — and there is nothing to
   add to the CRL either. has_issued_cert() now gates the revoke and CRL
   steps in both CliRunner._issue() and CursesApp._process_cert(); the
   workflow warns and goes straight to build-client-full. An explicit
   --revoke / TUI `r` still fails loudly rather than silently no-op.

The post-build failure message now keys off whether a revoke actually
happened, not off is_renewal, so it no longer claims "has been revoked"
when nothing was.

Adds tests/test_app_reissue.py: the TUI re-issue path had no coverage at
all, and it is the path this bug was reported from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:08:25 +03:00

98 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| `--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
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