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>