Code review findings on the "skip revoke when .crt is missing" migration
path:
- CliRunner._issue() took the skip path whenever has_issued_cert() was
False, which is also true for a typo'd/nonexistent CN — it would warn,
skip the revoke, and go on to build, package, and email a brand-new
certificate for a CN nobody asked to renew. The skip now only fires when
the CN has a current index.txt entry (via _load_current_certs()); an
unknown CN prints an error and exits 1 with nothing built. The TUI's
_process_cert() doesn't need the same guard — renewal there always opens
on an existing row (cn_readonly pins the CN), so a typo'd CN can't reach
the branch.
- Skipping the revoke leaves pki/reqs/<CN>.req and pki/private/<CN>.key in
place (normally revoke-issued archives both), which makes EasyRSA's
build-client-full abort. Both CliRunner._issue() and
CursesApp._process_cert() now check for those leftovers before building
and fail fast with the exact paths, rather than surfacing EasyRSA's
confusing error after the CA passphrase prompt. Neither path touches the
files itself.
Also: strengthened two under-specified tests (test_main_rejects_bad_days_flag
now checks the resolver's message text, not just "--days", which also
appears in argparse's unrelated error; test_show_cert_form_confirm now pins
the Enter-keypress count so a partial "days" field reversion is caught), and
folded a malformed CLAUDE.md table row into its neighbor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Remove Python 3.7+ constructs so the tool runs on 3.6:
- Drop `from __future__ import annotations` (3.7+) and convert all
builtin-generic annotations (list[]/dict[]/tuple[]/set[]) to their
typing.List/Dict/Tuple/Set equivalents, since without the future
import these annotations are evaluated eagerly and builtin generic
subscription only exists on 3.9+.
- Replace subprocess.run(capture_output=, text=) — both 3.7+ — with
stdout/stderr=PIPE and universal_newlines=True; update the matching
test assertion.
- requirements.txt: gate cryptography>=41 behind python_version>=3.7 and
pin cryptography<41 (last 3.6-compatible line, 40.0.2) plus the
dataclasses backport for 3.6.
- Suppress cryptography 40.x's per-import "Python 3.6 is no longer
supported" deprecation warning so it doesn't pollute CLI/cron stderr;
the <41 pin keeps us on a supported release regardless.
The only cryptography API used is AESGCM (present since 2.0), so no
x509/API-surface changes are needed for the older pin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
index.txt is append-only: a CN left unrevoked after expiring, then
reissued, accumulates multiple V-status lines. load_expiring_certs()
and load_all_certs() previously returned one CertInfo per line, so
such a CN showed as duplicate rows in the TUI main menu (and in
--list/--list-all).
Extract the existing "last non-revoked entry wins" scan (previously
only in get_email()) into a shared _load_current_certs(), and build
load_expiring_certs()/load_all_certs()/get_email() on top of it. The
date-window filter in load_expiring_certs() now applies to each CN's
canonical (most recent) entry rather than to raw lines, so a stale
duplicate that happens to fall in the "recently expired" window no
longer masks a live reissued cert whose real expiry is outside it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
clear() forces a full physical screen repaint on the next refresh();
erase() just blanks the buffer content and lets curses diff against
what's already on the terminal, sending only the changed cells. The
main cert list and the cert-entry form both redraw on every keystroke,
so clear() there meant retransmitting the whole screen on every arrow
key / typed character — slow and flickery over a low-bandwidth link
like a serial console.
One-shot dialogs (show_confirm, _msg, _error) draw once and don't
loop-redraw, so they keep clear() as-is — no benefit to changing them
and it's one less thing to get wrong.
Also handle curses.KEY_RESIZE explicitly in the main list loop: force
one real clear() right after a detected resize, since erase()'s diff
against the pre-resize screen model isn't reliable across a genuine
dimension change. Screen-size changes were already picked up on the
next redraw before this (draw() runs unconditionally every loop
iteration), this just guarantees a clean repaint at the moment of the
actual resize instead of trusting the diff engine through it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CertInfo.email was already populated from index.txt but never
rendered; add it as a fixed-width column next to CN in the cert
list rows (blank when unknown).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
show_confirm() sized its window purely from message content, with no
upper bound — a long line (the Cryptgeon password URL in the
post-issuance "send email?" prompt) could easily exceed a narrow
terminal's width (e.g. a serial console), making curses.newwin()
raise "curses function returned NULL" and crash the whole app.
show_cert_form() had the same unguarded newwin() call, just with a
fixed 13x62 size instead of content-driven.
Clamp both windows' height/width to the actual screen size, and wrap
newwin() itself in try/except as a last-resort fallback (declined
confirm / cancelled form) in case clamping still isn't enough.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
init_colors() now survives a terminal rejecting every init_pair() call
(some serial-console terminfo entries claim start_color()/COLORS support
but can't actually set custom pairs), but that exposed a follow-on bug:
the list cursor and the cert-form Continue/Cancel buttons indicated
selection purely via the COLOR_SELECTED color pair, so once that pair
silently no-ops the current row/button becomes indistinguishable from
an unselected one. Add curses.A_REVERSE alongside the color pair for
the selected state — a plain video attribute that doesn't depend on
custom-pair support, so the cursor stays visible either way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
curses.use_default_colors() raises on terminals (e.g. serial consoles
using TERM=linux/vt100) whose terminfo lacks default-color support;
init_colors() now falls back to an explicit black background and caps
the "disabled" color to 8-color terminals.
--reissue without --email silently sent no mail when the CN wasn't in
openvpncertupdate-metadata.json, even though its email was already
embedded in the cert's index.txt subject (via EASYRSA_REQ_EMAIL) and
the TUI already fell back to it. Since build_client_full() always
writes that subject field whenever an email is known, metadata.json
was a redundant, driftable copy — remove it and have get_email() read
index.txt directly, picking the last non-revoked entry for a CN since
index.txt can contain revoked/duplicate lines for the same CN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cryptgeon's real protocol (verified against upstream occulto/frontend/backend
source) never matched what create_note() sent: it used a SHA-256-derived key
instead of the raw one, a single-blob ciphertext instead of the delimited
AES-GCM--nonce--ciphertext format, an empty meta field instead of a JSON
string, and a hash-bang URL instead of the real /note/<id>#<key> route -
notes uploaded fine but were undecryptable in the browser.
Also adds CA_PASSPHRASE auto-detection/prompting ("" auto-detects an
encrypted CA key and prompts, "!empty"/"!ask" opt out of/force the prompt)
and an external .conf file (--config / CONFIG_PATH / <script>.conf) that can
override the SETTINGS block without editing the script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build_client_full now passes EASYRSA_REQ_EMAIL in the subprocess
environment so EasyRSA bakes the user's email into the new cert's
subject DN, matching what was shown in the form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CNs, passwords, URLs, and paths are collected during the session and
printed to the terminal once curses closes — including on crashes,
since _print_session_log runs in a finally block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add SMTP_HOST/PORT/USER/PASSWORD/TLS settings. When SMTP_HOST is set,
send_email() uses smtplib (supports STARTTLS, SSL, or plain); auth is
skipped when SMTP_USER is empty. Falls back to MAIL_BINARY when SMTP_HOST
is unset, preserving existing behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- First Esc in main screen shows "Press Esc again to quit" in footer;
second Esc exits. Any other key resets the pending state.
- Update test_posts_to_correct_endpoint to expect contents as base64 string
and presence of meta field, matching the updated Cryptgeon API payload.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Newer Cryptgeon versions expect `contents` as a base64 string (not a byte
array) and require a `meta` string field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix cursor not appearing in Certificate Details text fields (hint addstr
was clobbering the cursor position set by place_cursor before refresh)
- Add Left/Right arrow key switching between Continue and Cancel buttons
- Expand password generation banned characters from oO01lI to oO01lIQ5S2Z8B
(adds visually ambiguous pairs: Q/0, 5/S, 2/Z, 8/B)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tab (or Down/Enter on a field) now cycles through the three text fields
and then onto [ Continue ] and [ Cancel ] buttons at the bottom of the
form. Enter or Space activates the focused button. Ctrl-G still
confirms immediately from any position. Escape still cancels.
The form window is trimmed from h=16 to h=13 since the buttons replace
the now-redundant empty space below the password field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ctrl-S (0x13) is the XOFF flow-control character; terminals intercept it
before curses sees it, causing output suspension in Konsole and others.
Replaced with Ctrl-G (0x07 / BEL) which has no terminal-level meaning.
Updated hint bar and test stub accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs combined to make the cursor invisible:
- curses.curs_set(0) was set globally in _main() and never re-enabled
for the form, so the cursor was hidden the whole time.
- InputField.draw() called win.move() to position the cursor, but all
fields were drawn in order, so the cursor always landed on the last
field (password) regardless of which field had focus.
Fix: split draw() into draw() (render only) + place_cursor() (move
only). show_cert_form() now calls curses.curs_set(1) on entry,
place_cursor() on the focused field after drawing all fields, and
curses.curs_set(0) before each return.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_parse_index_line now extracts the emailAddress segment from the DN
alongside CN, returning a (cn, expiry, email) triple. CertInfo gains an
email field populated by both load functions.
In _main(), RENEW_SELECTED falls back to cert.email when the metadata
store has no entry for the CN — so certs created outside this tool (or
before the metadata store existed) pre-fill the email field correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Counter overflow: drop len==2 guard in build_ovpn so suffixes grow
naturally beyond _99 instead of silently resetting to _00
- TOGGLE_ALL: preserve checked set and cursor across A-key view switches
by threading saved_checked/saved_cursor through ScreenResult and back
into show_main_screen on the next call
- CLI Cryptgeon fallback: skip email/eml when Cryptgeon fails instead of
embedding the raw plaintext password in the email body
- --show-eml silent skip: warn explicitly when --show-eml is requested
but no email address is available
- URL truncation: show full one-time URL in TUI confirmation dialog
- _fatal(): remove misleading alias; inline self._error() + return at the
sole call site so termination is explicit
- argparse: replace store_true/store_false with store_const for the
three-state --send-email / --no-send-email / neither pattern
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split revoke_issued and build_client_full into separate try/except blocks.
When renewal is in progress and build-client-full fails after a successful
revoke, display an explicit warning that the old certificate has been revoked
but no new one was built, and advise re-running the renewal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement COLOR_* constants, initialization, box/centered drawing,
clamp utility, and InputField with handle_key/value logic for pure
text editing without live curses. All tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add send_email() function that composes multipart MIME messages with
template-based body and .ovpn file attachment, piped to mail binary via
subprocess.Popen. Supports Cryptgeon URL insertion and sender metadata.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements generate_password() function that produces 28-character passwords
following charset rules: position 1 uppercase, position 2 and last lowercase
with restricted characters, middle positions alphanumeric. Uses secrets module
for cryptographic randomness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>