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>
This commit is contained in:
@@ -261,6 +261,33 @@ class EasyRSAError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _easyrsa_diagnostics(stdout: str, stderr: str) -> str:
|
||||
"""Merge both streams of a failed EasyRSA run into displayable error text.
|
||||
|
||||
EasyRSA's print() is `printf '%s\\n'`, so die() and user_error() write their
|
||||
diagnostics to *stdout*; stderr only carries output from the tools EasyRSA
|
||||
shells out to (openssl). Reporting stderr alone therefore leaves most
|
||||
failures with an empty message. Blank padding and the version banner around
|
||||
EasyRSA's error block are dropped so the text fits the TUI error dialog.
|
||||
|
||||
stderr comes first: when EasyRSA dies at the openssl step its stdout also
|
||||
carries the warn() banner and show_host() footer, so the specific cause
|
||||
(openssl's own message) must lead or it is what the dialog clips.
|
||||
"""
|
||||
lines = []
|
||||
for stream in (stderr, stdout):
|
||||
for raw in (stream or "").splitlines():
|
||||
line = raw.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.startswith("EasyRSA version ") or set(line.strip()) in (
|
||||
{"-"}, {"="},
|
||||
):
|
||||
continue
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _run_easyrsa(cmd: List[str], cwd: str,
|
||||
extra_env: Optional[Dict[str, str]] = None) -> None:
|
||||
env = None
|
||||
@@ -274,8 +301,9 @@ def _run_easyrsa(cmd: List[str], cwd: str,
|
||||
if result.returncode != 0:
|
||||
# Find the first non-flag argument after the binary (the actual easyrsa verb)
|
||||
verb = next((c for c in cmd[1:] if not c.startswith("-")), "unknown")
|
||||
detail = _easyrsa_diagnostics(result.stdout, result.stderr)
|
||||
raise EasyRSAError(
|
||||
f"{verb} failed (exit {result.returncode}): {result.stderr.strip()}"
|
||||
f"{verb} failed (exit {result.returncode}):\n{detail}"
|
||||
)
|
||||
|
||||
|
||||
@@ -286,6 +314,21 @@ def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> List[str]:
|
||||
return cmd
|
||||
|
||||
|
||||
def issued_cert_path(pki_dir: str, cn: str) -> str:
|
||||
return f"{pki_dir}/issued/{cn}.crt"
|
||||
|
||||
|
||||
def has_issued_cert(pki_dir: str, cn: str) -> bool:
|
||||
"""Whether EasyRSA still holds the signed certificate for this CN.
|
||||
|
||||
index.txt can carry a V-status line whose .crt is gone — e.g. an index
|
||||
copied over from an older EasyRSA install without the issued/ files.
|
||||
EasyRSA reads the serial out of the .crt itself, so `revoke-issued` fails
|
||||
outright on those CNs; the re-issue workflow skips the revoke step instead.
|
||||
"""
|
||||
return os.path.isfile(issued_cert_path(pki_dir, cn))
|
||||
|
||||
|
||||
def revoke_issued(
|
||||
easyrsa_dir: str, pki_dir: str, cn: str, ca_passphrase: str
|
||||
) -> None:
|
||||
@@ -1126,12 +1169,25 @@ class CursesApp:
|
||||
final_cn = form.cn
|
||||
self._msg(stdscr, f"Generating certificate for {final_cn}…")
|
||||
|
||||
if is_renewal:
|
||||
revoked = False
|
||||
if is_renewal and not has_issued_cert(EASYRSA_PKI_DIR, final_cn):
|
||||
# index.txt lists the cert but the .crt is gone, so there is
|
||||
# nothing EasyRSA can revoke. Issue the replacement anyway.
|
||||
warning = (
|
||||
f"No certificate file for {final_cn} at\n"
|
||||
f"{issued_cert_path(EASYRSA_PKI_DIR, final_cn)}\n\n"
|
||||
f"Nothing to revoke — skipping revoke and CRL update.\n"
|
||||
f"Issuing a new certificate only."
|
||||
)
|
||||
self._log(f"{final_cn}: no issued cert file, revoke skipped")
|
||||
self._msg(stdscr, warning, wait=True)
|
||||
elif is_renewal:
|
||||
try:
|
||||
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, final_cn, CA_PASSPHRASE)
|
||||
except EasyRSAError as exc:
|
||||
self._error(stdscr, f"EasyRSA error during revoke:\n{exc}")
|
||||
return True
|
||||
revoked = True
|
||||
# The old cert is now revoked, so the published CRL is stale
|
||||
# until regenerated — do that now rather than leaving it to a
|
||||
# separate manual "Regenerate CRL" step. Not fatal: the new
|
||||
@@ -1152,7 +1208,7 @@ class CursesApp:
|
||||
final_cn, form.password, CA_PASSPHRASE,
|
||||
email=form.email)
|
||||
except EasyRSAError as exc:
|
||||
if is_renewal:
|
||||
if revoked:
|
||||
self._error(
|
||||
stdscr,
|
||||
f"EasyRSA error during build-client-full:\n{exc}\n\n"
|
||||
@@ -1370,13 +1426,25 @@ class CliRunner:
|
||||
send_email_flag: bool = True, show_eml: bool = False) -> None:
|
||||
password = generate_password()
|
||||
|
||||
if is_renewal:
|
||||
revoked = False
|
||||
if is_renewal and not has_issued_cert(EASYRSA_PKI_DIR, cn):
|
||||
# index.txt lists the cert but the .crt is gone, so there is
|
||||
# nothing EasyRSA can revoke. Issue the replacement anyway.
|
||||
print(
|
||||
f"warning: no certificate file at "
|
||||
f"{issued_cert_path(EASYRSA_PKI_DIR, cn)}\n"
|
||||
f"warning: nothing to revoke for {cn} — skipping revoke and CRL "
|
||||
f"update, issuing a new certificate only.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif is_renewal:
|
||||
print(f"Revoking existing cert for {cn}…", file=sys.stderr)
|
||||
try:
|
||||
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
||||
except EasyRSAError as exc:
|
||||
print(f"error during revoke: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
revoked = True
|
||||
# The old cert is now revoked, so the published CRL is stale
|
||||
# until regenerated — do that now rather than leaving it to a
|
||||
# separate --gen-crl run. Not fatal: the new cert is more
|
||||
@@ -1398,7 +1466,7 @@ class CliRunner:
|
||||
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, password, CA_PASSPHRASE,
|
||||
email=email_addr)
|
||||
except EasyRSAError as exc:
|
||||
if is_renewal:
|
||||
if revoked:
|
||||
print(
|
||||
f"error during build-client-full: {exc}\n"
|
||||
f"WARNING: {cn} has been revoked but no new cert was built.\n"
|
||||
|
||||
Reference in New Issue
Block a user