_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>
1190 lines
41 KiB
Python
1190 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""openvpncertupdate — Manage OpenVPN user certificates via EasyRSA."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import curses
|
|
import email.encoders
|
|
import email.mime.base
|
|
import email.mime.multipart
|
|
import email.mime.text
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
try:
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
except ImportError: # pragma: no cover
|
|
AESGCM = None # type: ignore
|
|
|
|
# ============================================================
|
|
# SETTINGS — edit these for your environment
|
|
# ============================================================
|
|
|
|
EASYRSA_DIR = "/etc/easy-rsa"
|
|
EASYRSA_PKI_DIR = "/etc/easy-rsa/pki"
|
|
CA_PASSPHRASE = "" # empty string = no passphrase
|
|
|
|
OVPN_TEMPLATE_PATH = "./template.ovpn"
|
|
CONFIG_NAME = "client.ovpn"
|
|
VPN_CONFIGS_DIR = "./vpn-configs"
|
|
|
|
CRL_DEST_PATH = "/etc/openvpn/crl.pem"
|
|
|
|
CRYPTGEON_URL = "https://cryptgeon.example.com"
|
|
|
|
MAIL_FROM = "vpn-admin@example.com"
|
|
MAIL_SUBJECT = "Your VPN Configuration"
|
|
EMAIL_TEMPLATE_PATH = "./email_template.txt"
|
|
MAIL_BINARY = "msmtp" # or "sendmail"
|
|
|
|
DAYS_PAST = 30
|
|
DAYS_AHEAD = 14
|
|
|
|
# ============================================================
|
|
# === PKI ===
|
|
# ============================================================
|
|
|
|
|
|
@dataclass
|
|
class CertInfo:
|
|
cn: str
|
|
expires: datetime # UTC
|
|
days_left: int # negative = already expired
|
|
email: str = ""
|
|
|
|
|
|
def _parse_date(raw: str) -> datetime:
|
|
"""Parse OpenSSL date YYMMDDHHMMSSZ or YYYYMMDDHHMMSSZ."""
|
|
raw = raw.rstrip("Z")
|
|
if len(raw) == 12:
|
|
yy = int(raw[:2])
|
|
year = 2000 + yy if yy < 50 else 1900 + yy
|
|
rest = raw[2:]
|
|
dt = datetime.strptime(f"{year}{rest}", "%Y%m%d%H%M%S")
|
|
else:
|
|
dt = datetime.strptime(raw, "%Y%m%d%H%M%S")
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
|
|
"""Return (cn, expiry, email) for valid (V-status) index.txt lines, else None."""
|
|
parts = line.rstrip("\n").split("\t")
|
|
if len(parts) < 6 or parts[0] != "V":
|
|
return None
|
|
try:
|
|
expiry = _parse_date(parts[1])
|
|
except ValueError:
|
|
return None
|
|
dn = parts[5]
|
|
cn = None
|
|
email = ""
|
|
for seg in dn.split("/"):
|
|
if seg.startswith("CN="):
|
|
cn = seg[3:]
|
|
elif seg.startswith("emailAddress="):
|
|
email = seg[len("emailAddress="):]
|
|
if not cn:
|
|
return None
|
|
return cn, expiry, email
|
|
|
|
|
|
def load_expiring_certs(
|
|
pki_dir: str,
|
|
days_past: int,
|
|
days_ahead: int,
|
|
) -> list[CertInfo]:
|
|
"""Return valid certs whose expiry falls within [-days_past, +days_ahead]."""
|
|
now = datetime.now(tz=timezone.utc)
|
|
cutoff_past = now - timedelta(days=days_past)
|
|
cutoff_future = now + timedelta(days=days_ahead)
|
|
results: list[CertInfo] = []
|
|
with open(os.path.join(pki_dir, "index.txt")) as fh:
|
|
for line in fh:
|
|
parsed = _parse_index_line(line)
|
|
if parsed is None:
|
|
continue
|
|
cn, expiry, email = parsed
|
|
if cutoff_past <= expiry <= cutoff_future:
|
|
results.append(CertInfo(
|
|
cn=cn,
|
|
expires=expiry,
|
|
days_left=(expiry - now).days,
|
|
email=email,
|
|
))
|
|
results.sort(key=lambda c: c.expires)
|
|
return results
|
|
|
|
|
|
def load_all_certs(pki_dir: str) -> list[CertInfo]:
|
|
"""Return all valid (V-status) certs, sorted by expiry date."""
|
|
now = datetime.now(tz=timezone.utc)
|
|
results: list[CertInfo] = []
|
|
with open(os.path.join(pki_dir, "index.txt")) as fh:
|
|
for line in fh:
|
|
parsed = _parse_index_line(line)
|
|
if parsed is None:
|
|
continue
|
|
cn, expiry, email = parsed
|
|
results.append(CertInfo(
|
|
cn=cn,
|
|
expires=expiry,
|
|
days_left=(expiry - now).days,
|
|
email=email,
|
|
))
|
|
results.sort(key=lambda c: c.expires)
|
|
return results
|
|
|
|
|
|
# ============================================================
|
|
# === PASSWORD ===
|
|
# ============================================================
|
|
|
|
_BANNED_ALL = frozenset("oO01lI")
|
|
_BANNED_POS2_LAST = _BANNED_ALL | frozenset("j")
|
|
|
|
_UPPER = [c for c in string.ascii_uppercase if c not in _BANNED_ALL]
|
|
_LOWER_RESTRICTED = [c for c in string.ascii_lowercase if c not in _BANNED_POS2_LAST]
|
|
_LOWER_MID = [c for c in string.ascii_lowercase if c not in _BANNED_ALL]
|
|
_DIGITS_MID = [c for c in string.digits if c not in _BANNED_ALL]
|
|
_MID = _UPPER + _LOWER_MID + _DIGITS_MID # 55 chars for pos 3-27
|
|
|
|
|
|
def generate_password() -> str:
|
|
pw = [
|
|
secrets.choice(_UPPER), # pos 1: uppercase
|
|
secrets.choice(_LOWER_RESTRICTED), # pos 2: lowercase, no j
|
|
]
|
|
for _ in range(25): # pos 3-27: any safe alphanumeric
|
|
pw.append(secrets.choice(_MID))
|
|
pw.append(secrets.choice(_LOWER_RESTRICTED)) # pos 28: lowercase, no j
|
|
return "".join(pw)
|
|
|
|
|
|
# ============================================================
|
|
# === EASYRSA ===
|
|
# ============================================================
|
|
|
|
|
|
class EasyRSAError(Exception):
|
|
pass
|
|
|
|
|
|
def _run_easyrsa(cmd: list[str], cwd: str) -> None:
|
|
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
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")
|
|
raise EasyRSAError(
|
|
f"{verb} failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> list[str]:
|
|
cmd = [f"{easyrsa_dir}/easyrsa", "--batch", f"--pki={pki_dir}"]
|
|
if ca_passphrase:
|
|
cmd.append(f"--passin=pass:{ca_passphrase}")
|
|
return cmd
|
|
|
|
|
|
def revoke_issued(
|
|
easyrsa_dir: str, pki_dir: str, cn: str, ca_passphrase: str
|
|
) -> None:
|
|
_run_easyrsa(
|
|
_base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + ["revoke-issued", cn],
|
|
cwd=easyrsa_dir,
|
|
)
|
|
|
|
|
|
def build_client_full(
|
|
easyrsa_dir: str, pki_dir: str, cn: str,
|
|
key_passphrase: str, ca_passphrase: str,
|
|
) -> None:
|
|
_run_easyrsa(
|
|
_base_cmd(easyrsa_dir, pki_dir, ca_passphrase)
|
|
+ [f"--passout=pass:{key_passphrase}", "build-client-full", cn],
|
|
cwd=easyrsa_dir,
|
|
)
|
|
|
|
|
|
def gen_crl(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> None:
|
|
_run_easyrsa(
|
|
_base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + ["gen-crl"],
|
|
cwd=easyrsa_dir,
|
|
)
|
|
|
|
|
|
def copy_crl(pki_dir: str, dest_path: str) -> None:
|
|
shutil.copy2(f"{pki_dir}/crl.pem", dest_path)
|
|
os.chmod(dest_path, 0o644)
|
|
|
|
|
|
# ============================================================
|
|
# === METADATA ===
|
|
# ============================================================
|
|
|
|
_METADATA_FILE = "openvpncertupdate-metadata.json"
|
|
|
|
|
|
def load_metadata(pki_dir: str) -> dict[str, str]:
|
|
p = os.path.join(pki_dir, _METADATA_FILE)
|
|
if not os.path.exists(p):
|
|
return {}
|
|
with open(p) as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def save_email(pki_dir: str, cn: str, email_addr: str) -> None:
|
|
data = load_metadata(pki_dir)
|
|
data[cn] = email_addr
|
|
with open(os.path.join(pki_dir, _METADATA_FILE), "w") as fh:
|
|
json.dump(data, fh, indent=2)
|
|
|
|
|
|
def get_email(pki_dir: str, cn: str) -> str:
|
|
return load_metadata(pki_dir).get(cn, "")
|
|
|
|
|
|
# ============================================================
|
|
# === CONFIG ===
|
|
# ============================================================
|
|
|
|
|
|
def build_ovpn(
|
|
cn: str,
|
|
pki_dir: str,
|
|
template_path: str,
|
|
output_base_dir: str,
|
|
config_name: str,
|
|
) -> str:
|
|
"""Concat template + inline file. Return absolute path to written .ovpn."""
|
|
inline_path = os.path.join(pki_dir, "inline", "private", f"{cn}.inline")
|
|
template_content = Path(template_path).read_text()
|
|
inline_content = Path(inline_path).read_text()
|
|
today = date.today().strftime("%Y-%m-%d")
|
|
prefix = f"{cn}_{today}_"
|
|
base = Path(output_base_dir)
|
|
existing = [
|
|
int(d.name[len(prefix):])
|
|
for d in base.iterdir()
|
|
if d.is_dir() and d.name.startswith(prefix)
|
|
and d.name[len(prefix):].isdigit()
|
|
] if base.exists() else []
|
|
next_n = max(existing) + 1 if existing else 0
|
|
out_dir = base / f"{prefix}{next_n:02d}"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
out_file = out_dir / config_name
|
|
out_file.write_text(template_content + "\n" + inline_content)
|
|
return str(out_file.resolve())
|
|
|
|
|
|
# ============================================================
|
|
# === CRYPTGEON ===
|
|
# ============================================================
|
|
|
|
|
|
class CryptgeonError(Exception):
|
|
pass
|
|
|
|
|
|
def create_note(content: str, base_url: str) -> str:
|
|
"""AES-256-GCM encrypt content, POST to Cryptgeon, return one-time URL.
|
|
|
|
Encryption matches the Cryptgeon browser client:
|
|
raw_key (32 bytes) → URL fragment (base64url, no padding)
|
|
aes_key = SHA-256(raw_key) → AES-256-GCM
|
|
payload = list(nonce + ciphertext)
|
|
"""
|
|
if AESGCM is None:
|
|
raise CryptgeonError("cryptography package not installed (pip install cryptography)")
|
|
|
|
raw_key = os.urandom(32)
|
|
aes_key = hashlib.sha256(raw_key).digest()
|
|
nonce = os.urandom(12)
|
|
aesgcm = AESGCM(aes_key)
|
|
ciphertext = aesgcm.encrypt(nonce, content.encode("utf-8"), None)
|
|
|
|
payload = json.dumps({
|
|
"contents": list(nonce + ciphertext),
|
|
"views": 1,
|
|
"type": "text",
|
|
}).encode("utf-8")
|
|
|
|
api_url = base_url.rstrip("/") + "/api/notes/"
|
|
req = urllib.request.Request(
|
|
api_url,
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req) as resp:
|
|
body = json.loads(resp.read())
|
|
except urllib.error.HTTPError as exc:
|
|
raise CryptgeonError(f"Cryptgeon API returned {exc.code}: {exc.reason}") from exc
|
|
except Exception as exc:
|
|
raise CryptgeonError(f"Cryptgeon request failed: {exc}") from exc
|
|
|
|
note_id = body["id"]
|
|
key_fragment = base64.urlsafe_b64encode(raw_key).rstrip(b"=").decode()
|
|
return f"{base_url.rstrip('/')}/#/note/{note_id}/{key_fragment}"
|
|
|
|
|
|
# ============================================================
|
|
# === MAILER ===
|
|
# ============================================================
|
|
|
|
|
|
def build_mime_message(
|
|
to_address: str,
|
|
cn: str,
|
|
one_time_url: str,
|
|
ovpn_path: str,
|
|
mail_from: str,
|
|
subject: str,
|
|
template_path: str,
|
|
) -> email.mime.multipart.MIMEMultipart:
|
|
"""Build the MIME message with .ovpn attachment; does not send."""
|
|
config_name = os.path.basename(ovpn_path)
|
|
body = Path(template_path).read_text().format(
|
|
cn=cn, url=one_time_url, config_name=config_name,
|
|
)
|
|
msg = email.mime.multipart.MIMEMultipart()
|
|
msg["From"] = mail_from
|
|
msg["To"] = to_address
|
|
msg["Subject"] = subject
|
|
msg.attach(email.mime.text.MIMEText(body, "plain"))
|
|
|
|
with open(ovpn_path, "rb") as fh:
|
|
part = email.mime.base.MIMEBase("application", "octet-stream")
|
|
part.set_payload(fh.read())
|
|
email.encoders.encode_base64(part)
|
|
part.add_header("Content-Disposition", f'attachment; filename="{config_name}"')
|
|
msg.attach(part)
|
|
return msg
|
|
|
|
|
|
def send_email(
|
|
to_address: str,
|
|
cn: str,
|
|
one_time_url: str,
|
|
ovpn_path: str,
|
|
mail_from: str,
|
|
subject: str,
|
|
template_path: str,
|
|
mail_binary: str,
|
|
) -> None:
|
|
"""Compose and send email with .ovpn attachment via msmtp/sendmail."""
|
|
msg = build_mime_message(to_address, cn, one_time_url, ovpn_path,
|
|
mail_from, subject, template_path)
|
|
proc = subprocess.Popen(
|
|
[mail_binary, "-t"],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
)
|
|
_, err = proc.communicate(input=msg.as_bytes())
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"mail delivery failed (exit {proc.returncode}): {err.decode().strip()}"
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# === TUI WIDGETS ===
|
|
# ============================================================
|
|
|
|
COLOR_NORMAL = 1
|
|
COLOR_SELECTED = 2
|
|
COLOR_DISABLED = 3
|
|
COLOR_TITLE = 4
|
|
COLOR_ERROR = 5
|
|
COLOR_SUCCESS = 6
|
|
|
|
|
|
def init_colors() -> None:
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
curses.init_pair(COLOR_NORMAL, curses.COLOR_WHITE, -1)
|
|
curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
curses.init_pair(COLOR_DISABLED, 8, -1)
|
|
curses.init_pair(COLOR_TITLE, curses.COLOR_WHITE, -1)
|
|
curses.init_pair(COLOR_ERROR, curses.COLOR_RED, -1)
|
|
curses.init_pair(COLOR_SUCCESS, curses.COLOR_GREEN, -1)
|
|
|
|
|
|
def draw_box(win, title: str = "") -> None:
|
|
win.box()
|
|
if title:
|
|
_, w = win.getmaxyx()
|
|
label = f" {title} "
|
|
x = max(1, (w - len(label)) // 2)
|
|
try:
|
|
win.addstr(0, x, label, curses.color_pair(COLOR_TITLE) | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
|
|
|
|
def draw_centered(win, y: int, text: str, attr: int = 0) -> None:
|
|
_, w = win.getmaxyx()
|
|
x = max(0, (w - len(text)) // 2)
|
|
try:
|
|
win.addstr(y, x, text, attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
|
|
def clamp(value: int, lo: int, hi: int) -> int:
|
|
return max(lo, min(hi, value))
|
|
|
|
|
|
class InputField:
|
|
"""Single-line editable text field. draw() needs a live curses window;
|
|
handle_key() + value are pure logic."""
|
|
|
|
def __init__(
|
|
self, win, y: int, x: int, width: int,
|
|
initial: str = "", mask: bool = False,
|
|
) -> None:
|
|
self._win = win
|
|
self._y = y
|
|
self._x = x
|
|
self._width = width
|
|
self._mask = mask
|
|
self._buf = list(initial)
|
|
self._cur = len(self._buf)
|
|
|
|
@property
|
|
def value(self) -> str:
|
|
return "".join(self._buf)
|
|
|
|
def draw(self) -> None:
|
|
display = ("*" * len(self._buf)) if self._mask else "".join(self._buf)
|
|
start = max(0, self._cur - self._width + 1)
|
|
visible = display[start: start + self._width].ljust(self._width)
|
|
try:
|
|
self._win.addstr(self._y, self._x, visible,
|
|
curses.color_pair(COLOR_NORMAL) | curses.A_UNDERLINE)
|
|
self._win.move(self._y, self._x + min(self._cur - start, self._width - 1))
|
|
except curses.error:
|
|
pass
|
|
|
|
def handle_key(self, key: int) -> None:
|
|
if key in (curses.KEY_BACKSPACE, 127, 8):
|
|
if self._cur > 0:
|
|
del self._buf[self._cur - 1]
|
|
self._cur -= 1
|
|
elif key == curses.KEY_LEFT:
|
|
self._cur = clamp(self._cur - 1, 0, len(self._buf))
|
|
elif key == curses.KEY_RIGHT:
|
|
self._cur = clamp(self._cur + 1, 0, len(self._buf))
|
|
elif key == curses.KEY_HOME:
|
|
self._cur = 0
|
|
elif key == curses.KEY_END:
|
|
self._cur = len(self._buf)
|
|
elif key == curses.KEY_DC:
|
|
if self._cur < len(self._buf):
|
|
del self._buf[self._cur]
|
|
elif 32 <= key <= 126:
|
|
self._buf.insert(self._cur, chr(key))
|
|
self._cur += 1
|
|
|
|
|
|
# ============================================================
|
|
# === TUI DIALOGS ===
|
|
# ============================================================
|
|
|
|
|
|
def show_confirm(stdscr, message: str) -> bool:
|
|
"""Y/N modal dialog. Returns True if user presses y/Y."""
|
|
sh, sw = stdscr.getmaxyx()
|
|
lines = message.splitlines()
|
|
h = len(lines) + 6
|
|
w = max(max(len(l) for l in lines) + 6, 38)
|
|
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
|
win.keypad(True)
|
|
draw_box(win, "Confirm")
|
|
for i, line in enumerate(lines):
|
|
try:
|
|
win.addstr(2 + i, 3, line, curses.color_pair(COLOR_NORMAL))
|
|
except curses.error:
|
|
pass
|
|
prompt = " [Y] Confirm [N / Esc] Cancel "
|
|
try:
|
|
win.addstr(h - 2, max(1, (w - len(prompt)) // 2), prompt,
|
|
curses.color_pair(COLOR_NORMAL))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
while True:
|
|
key = win.getch()
|
|
if key in (ord("y"), ord("Y")):
|
|
return True
|
|
if key in (ord("n"), ord("N"), 27, ord("q"), ord("Q")):
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class CertFormResult:
|
|
cn: str
|
|
email: str
|
|
password: str
|
|
confirmed: bool
|
|
|
|
|
|
_FORM_FIELDS = ("cn", "email", "password")
|
|
_FORM_LABELS = {
|
|
"cn": "CN",
|
|
"email": "Email",
|
|
"password": "Password",
|
|
}
|
|
_FORM_FIELD_Y = {"cn": 3, "email": 5, "password": 7}
|
|
|
|
|
|
def show_cert_form(
|
|
stdscr,
|
|
cn: str = "",
|
|
email: str = "",
|
|
password: str = "",
|
|
cn_readonly: bool = False,
|
|
) -> CertFormResult:
|
|
"""Modal cert-detail form.
|
|
Tab / Shift-Tab / Up / Down cycle fields. F5 regenerates password.
|
|
Enter on last field or Ctrl-S confirms. Escape cancels."""
|
|
sh, sw = stdscr.getmaxyx()
|
|
h, w = 16, 62
|
|
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
|
win.keypad(True)
|
|
fw = w - 6 # field width
|
|
|
|
active = [n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
|
|
fields: dict[str, InputField] = {
|
|
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
|
|
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
|
|
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
|
|
initial=password if password else generate_password(),
|
|
mask=True),
|
|
}
|
|
focus = 0
|
|
|
|
def _submit() -> CertFormResult:
|
|
final_cn = cn if cn_readonly else fields["cn"].value
|
|
return CertFormResult(
|
|
cn=final_cn,
|
|
email=fields["email"].value,
|
|
password=fields["password"].value,
|
|
confirmed=True,
|
|
)
|
|
|
|
while True:
|
|
win.clear()
|
|
draw_box(win, "Certificate Details")
|
|
for name in _FORM_FIELDS:
|
|
fy = _FORM_FIELD_Y[name]
|
|
label = _FORM_LABELS[name]
|
|
ro = name == "cn" and cn_readonly
|
|
attr = curses.color_pair(COLOR_DISABLED if ro else COLOR_NORMAL)
|
|
try:
|
|
win.addstr(fy - 1, 3, label, attr)
|
|
except curses.error:
|
|
pass
|
|
if ro:
|
|
try:
|
|
win.addstr(fy, 3, cn.ljust(fw)[:fw],
|
|
curses.color_pair(COLOR_DISABLED) | curses.A_UNDERLINE)
|
|
except curses.error:
|
|
pass
|
|
else:
|
|
fields[name].draw()
|
|
|
|
hint = "Tab=next F5=regen password Ctrl-S=confirm Esc=cancel"
|
|
try:
|
|
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
key = win.getch()
|
|
|
|
if key == 27:
|
|
return CertFormResult(cn=cn, email="", password="", confirmed=False)
|
|
if key == 0x13: # Ctrl-S: immediate submit
|
|
return _submit()
|
|
if key == 9: # Tab
|
|
focus = (focus + 1) % len(active)
|
|
continue
|
|
if key == curses.KEY_BTAB: # Shift-Tab
|
|
focus = (focus - 1) % len(active)
|
|
continue
|
|
if key == curses.KEY_DOWN:
|
|
focus = (focus + 1) % len(active)
|
|
continue
|
|
if key == curses.KEY_UP:
|
|
focus = (focus - 1) % len(active)
|
|
continue
|
|
if key == curses.KEY_F5:
|
|
fields["password"] = InputField(
|
|
win, _FORM_FIELD_Y["password"], 3, fw,
|
|
initial=generate_password(), mask=True,
|
|
)
|
|
continue
|
|
if key in (10, 13, curses.KEY_ENTER):
|
|
if focus < len(active) - 1:
|
|
focus += 1
|
|
continue
|
|
return _submit()
|
|
fields[active[focus]].handle_key(key)
|
|
|
|
|
|
# ============================================================
|
|
# === TUI SCREEN ===
|
|
# ============================================================
|
|
|
|
|
|
class Action(Enum):
|
|
RENEW_SELECTED = auto()
|
|
NEW_CERT = auto()
|
|
REGEN_CRL = auto()
|
|
REVOKE = auto()
|
|
TOGGLE_ALL = auto()
|
|
EXIT = auto()
|
|
|
|
|
|
@dataclass
|
|
class ScreenResult:
|
|
action: Action
|
|
selected_cns: list[str] = field(default_factory=list)
|
|
saved_checked: list[str] = field(default_factory=list)
|
|
saved_cursor: int = 0
|
|
|
|
|
|
_MENU_NEW = "Create New Certificate"
|
|
_MENU_CRL = "Regenerate CRL"
|
|
_MENU_EXIT = "Exit"
|
|
_MENU_ITEMS = [_MENU_NEW, _MENU_CRL, _MENU_EXIT]
|
|
|
|
|
|
def _expiry_label(c: CertInfo) -> str:
|
|
if c.days_left < 0:
|
|
return f"[EXPIRED {abs(c.days_left)}d ago]"
|
|
if c.days_left == 0:
|
|
return "[EXPIRES TODAY!]"
|
|
return f"[exp in {c.days_left}d] "
|
|
|
|
|
|
def show_main_screen(
|
|
stdscr, certs: list[CertInfo], show_all: bool = False,
|
|
initial_checked: Optional[set[str]] = None,
|
|
initial_cursor: int = 0,
|
|
) -> ScreenResult:
|
|
"""Display cert selection list. Returns when user activates an action."""
|
|
init_colors()
|
|
curses.curs_set(0)
|
|
|
|
n_certs = len(certs)
|
|
n_items = n_certs + len(_MENU_ITEMS)
|
|
checked: set[str] = set(initial_checked) if initial_checked else set()
|
|
cursor = clamp(initial_cursor, 0, max(0, n_items - 1))
|
|
|
|
def is_cert(idx): return idx < n_certs
|
|
def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None
|
|
|
|
def draw():
|
|
stdscr.clear()
|
|
sh, sw = stdscr.getmaxyx()
|
|
|
|
# Header bar — shows current filter mode
|
|
mode = "ALL certs" if show_all else "expiring/expired"
|
|
hdr = f" openvpncertupdate — VPN Certificate Manager [{mode}] "
|
|
try:
|
|
stdscr.addstr(0, 0, hdr.ljust(sw)[:sw],
|
|
curses.color_pair(COLOR_TITLE) | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
|
|
# Footer hint
|
|
hint = " ↑↓:Navigate Space:Check Enter:Confirm R:Revoke A:Toggle view Q:Quit"
|
|
try:
|
|
stdscr.addstr(sh - 1, 0, hint[:sw], curses.color_pair(COLOR_DISABLED))
|
|
except curses.error:
|
|
pass
|
|
|
|
list_h = sh - 2
|
|
scroll = clamp(cursor - list_h // 2, 0, max(0, n_items - list_h))
|
|
row_y = 1
|
|
|
|
for i in range(scroll, min(scroll + list_h, n_items)):
|
|
if row_y >= sh - 1:
|
|
break
|
|
is_cur = (i == cursor)
|
|
sel_attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_NORMAL)
|
|
|
|
if is_cert(i):
|
|
cert = certs[i]
|
|
box = "[X]" if cert.cn in checked else "[ ]"
|
|
lbl = _expiry_label(cert)
|
|
line = f" {box} {lbl:<18} {cert.cn}"
|
|
try:
|
|
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr)
|
|
except curses.error:
|
|
pass
|
|
else:
|
|
item = menu_item(i)
|
|
any_check = bool(checked)
|
|
if item == _MENU_NEW and any_check:
|
|
notice = " ← clear all checkboxes to use"
|
|
line = f" {item}{notice}"
|
|
attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_DISABLED)
|
|
else:
|
|
line = f" {item}"
|
|
attr = sel_attr
|
|
try:
|
|
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], attr)
|
|
except curses.error:
|
|
pass
|
|
row_y += 1
|
|
|
|
stdscr.refresh()
|
|
|
|
while True:
|
|
draw()
|
|
key = stdscr.getch()
|
|
|
|
if key in (ord("q"), ord("Q")):
|
|
return ScreenResult(action=Action.EXIT)
|
|
|
|
elif key == curses.KEY_UP:
|
|
cursor = clamp(cursor - 1, 0, n_items - 1)
|
|
|
|
elif key == curses.KEY_DOWN:
|
|
cursor = clamp(cursor + 1, 0, n_items - 1)
|
|
|
|
elif key == ord(" ") and is_cert(cursor):
|
|
cn = certs[cursor].cn
|
|
if cn in checked:
|
|
checked.discard(cn)
|
|
else:
|
|
checked.add(cn)
|
|
|
|
elif key in (10, 13, curses.KEY_ENTER):
|
|
if is_cert(cursor):
|
|
# Enter on a cert row: add to selection and confirm immediately
|
|
checked.add(certs[cursor].cn)
|
|
return ScreenResult(action=Action.RENEW_SELECTED,
|
|
selected_cns=sorted(checked))
|
|
else:
|
|
item = menu_item(cursor)
|
|
if item == _MENU_NEW and not checked:
|
|
return ScreenResult(action=Action.NEW_CERT)
|
|
elif item == _MENU_CRL:
|
|
return ScreenResult(action=Action.REGEN_CRL)
|
|
elif item == _MENU_EXIT:
|
|
return ScreenResult(action=Action.EXIT)
|
|
# _MENU_NEW when checked → grayed, ignore
|
|
|
|
elif key in (ord("a"), ord("A")):
|
|
return ScreenResult(action=Action.TOGGLE_ALL,
|
|
saved_checked=sorted(checked),
|
|
saved_cursor=cursor)
|
|
|
|
elif key in (ord("r"), ord("R")) and is_cert(cursor):
|
|
cert = certs[cursor]
|
|
if show_confirm(
|
|
stdscr,
|
|
f"Revoke certificate for:\n CN: {cert.cn}\n\nThis cannot be undone.",
|
|
):
|
|
return ScreenResult(action=Action.REVOKE, selected_cns=[cert.cn])
|
|
|
|
|
|
# ============================================================
|
|
# === APP ===
|
|
# ============================================================
|
|
|
|
|
|
class CursesApp:
|
|
def run(self) -> None:
|
|
curses.wrapper(self._main)
|
|
|
|
def _main(self, stdscr) -> None:
|
|
curses.curs_set(0)
|
|
init_colors()
|
|
|
|
show_all = False
|
|
saved_checked: list[str] = []
|
|
saved_cursor: int = 0
|
|
|
|
while True:
|
|
try:
|
|
certs = (
|
|
load_all_certs(EASYRSA_PKI_DIR)
|
|
if show_all
|
|
else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD)
|
|
)
|
|
except FileNotFoundError as exc:
|
|
self._error(stdscr, f"Cannot read PKI index.txt:\n{exc}")
|
|
return
|
|
|
|
result = show_main_screen(stdscr, certs, show_all=show_all,
|
|
initial_checked=set(saved_checked),
|
|
initial_cursor=saved_cursor)
|
|
saved_checked = []
|
|
saved_cursor = 0
|
|
|
|
if result.action == Action.EXIT:
|
|
return
|
|
elif result.action == Action.TOGGLE_ALL:
|
|
show_all = not show_all
|
|
saved_checked = result.saved_checked
|
|
saved_cursor = result.saved_cursor
|
|
elif result.action == Action.REGEN_CRL:
|
|
self._regen_crl(stdscr)
|
|
elif result.action == Action.REVOKE:
|
|
self._do_revoke_and_crl(stdscr, result.selected_cns[0])
|
|
elif result.action == Action.NEW_CERT:
|
|
self._process_cert(stdscr, cn="", email="", is_renewal=False)
|
|
elif result.action == Action.RENEW_SELECTED:
|
|
cert_by_cn = {c.cn: c for c in certs}
|
|
for cn in result.selected_cns:
|
|
email_addr = get_email(EASYRSA_PKI_DIR, cn)
|
|
if not email_addr and cn in cert_by_cn:
|
|
email_addr = cert_by_cn[cn].email
|
|
if not self._process_cert(stdscr, cn=cn, email=email_addr,
|
|
is_renewal=True):
|
|
break
|
|
# Loop back: reload cert list (it changed after renewal/revoke)
|
|
|
|
# ── Workflows ────────────────────────────────────────────────────────────
|
|
|
|
def _process_cert(
|
|
self, stdscr, cn: str, email: str, is_renewal: bool,
|
|
) -> bool:
|
|
"""Form → generate → deliver. Returns False if user cancelled."""
|
|
form = show_cert_form(stdscr, cn=cn, email=email, cn_readonly=is_renewal)
|
|
if not form.confirmed:
|
|
return False
|
|
|
|
final_cn = form.cn
|
|
self._msg(stdscr, f"Generating certificate for {final_cn}…")
|
|
|
|
if 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
|
|
|
|
try:
|
|
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR,
|
|
final_cn, form.password, CA_PASSPHRASE)
|
|
except EasyRSAError as exc:
|
|
if is_renewal:
|
|
self._error(
|
|
stdscr,
|
|
f"EasyRSA error during build-client-full:\n{exc}\n\n"
|
|
f"WARNING: {final_cn} has been revoked but no new cert was built.\n"
|
|
f"Re-run renewal for this CN to issue a new certificate.",
|
|
)
|
|
else:
|
|
self._error(stdscr, f"EasyRSA error:\n{exc}")
|
|
return True
|
|
|
|
if form.email:
|
|
save_email(EASYRSA_PKI_DIR, final_cn, form.email)
|
|
|
|
try:
|
|
ovpn_path = build_ovpn(final_cn, EASYRSA_PKI_DIR,
|
|
OVPN_TEMPLATE_PATH, VPN_CONFIGS_DIR, CONFIG_NAME)
|
|
except Exception as exc:
|
|
self._error(stdscr, f"Failed to build .ovpn:\n{exc}")
|
|
return True
|
|
|
|
try:
|
|
one_time_url = create_note(form.password, CRYPTGEON_URL)
|
|
except CryptgeonError as exc:
|
|
self._error(stdscr, f"Cryptgeon error:\n{exc}")
|
|
self._show_result(stdscr, final_cn, ovpn_path, form.password,
|
|
one_time_url="(Cryptgeon failed — see error above)")
|
|
return True
|
|
|
|
if form.email and show_confirm(
|
|
stdscr,
|
|
f"Send email to {form.email}?\n\n"
|
|
f"Attachment : {os.path.basename(ovpn_path)}\n"
|
|
f"Password URL: {one_time_url}",
|
|
):
|
|
try:
|
|
send_email(form.email, final_cn, one_time_url, ovpn_path,
|
|
MAIL_FROM, MAIL_SUBJECT, EMAIL_TEMPLATE_PATH, MAIL_BINARY)
|
|
self._msg(stdscr,
|
|
f"Email sent to {form.email}\nConfig: {ovpn_path}",
|
|
wait=True)
|
|
except RuntimeError as exc:
|
|
self._error(stdscr,
|
|
f"Email failed:\n{exc}\n\n"
|
|
f"URL : {one_time_url}\n"
|
|
f"Config: {ovpn_path}")
|
|
else:
|
|
self._show_result(stdscr, final_cn, ovpn_path, form.password, one_time_url)
|
|
|
|
return True
|
|
|
|
def _do_revoke_and_crl(self, stdscr, cn: str) -> None:
|
|
self._msg(stdscr, f"Revoking {cn}…")
|
|
try:
|
|
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
|
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
|
|
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
|
|
except EasyRSAError as exc:
|
|
self._error(stdscr, f"Revoke failed:\n{exc}")
|
|
return
|
|
self._msg(stdscr, f"{cn} revoked and CRL updated → {CRL_DEST_PATH}", wait=True)
|
|
|
|
def _regen_crl(self, stdscr) -> None:
|
|
self._msg(stdscr, "Regenerating CRL…")
|
|
try:
|
|
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
|
|
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
|
|
except EasyRSAError as exc:
|
|
self._error(stdscr, f"gen-crl failed:\n{exc}")
|
|
return
|
|
self._msg(stdscr, f"CRL regenerated → {CRL_DEST_PATH}", wait=True)
|
|
|
|
# ── Display helpers ───────────────────────────────────────────────────────
|
|
|
|
def _msg(self, stdscr, text: str, wait: bool = False) -> None:
|
|
stdscr.clear()
|
|
sh, sw = stdscr.getmaxyx()
|
|
lines = text.splitlines()
|
|
start_y = max(1, (sh - len(lines)) // 2)
|
|
for i, line in enumerate(lines):
|
|
try:
|
|
stdscr.addstr(start_y + i,
|
|
max(0, (sw - len(line)) // 2),
|
|
line[:sw], curses.color_pair(COLOR_NORMAL))
|
|
except curses.error:
|
|
pass
|
|
if wait:
|
|
try:
|
|
stdscr.addstr(sh - 2, 2, "Press any key to continue…",
|
|
curses.color_pair(COLOR_DISABLED))
|
|
except curses.error:
|
|
pass
|
|
stdscr.refresh()
|
|
if wait:
|
|
stdscr.getch()
|
|
|
|
def _error(self, stdscr, text: str) -> None:
|
|
lines = ["── ERROR ──", ""] + text.splitlines() + ["", "Press any key…"]
|
|
stdscr.clear()
|
|
sh, sw = stdscr.getmaxyx()
|
|
start_y = max(1, (sh - len(lines)) // 2)
|
|
for i, line in enumerate(lines):
|
|
attr = (curses.color_pair(COLOR_ERROR) | curses.A_BOLD
|
|
if i == 0 else curses.color_pair(COLOR_NORMAL))
|
|
try:
|
|
stdscr.addstr(start_y + i,
|
|
max(0, (sw - len(line)) // 2),
|
|
line[:sw], attr)
|
|
except curses.error:
|
|
pass
|
|
stdscr.refresh()
|
|
stdscr.getch()
|
|
|
|
def _show_result(
|
|
self, stdscr, cn: str, ovpn_path: str, password: str, one_time_url: str,
|
|
) -> None:
|
|
self._msg(
|
|
stdscr,
|
|
f"Certificate issued for: {cn}\n"
|
|
f"\n"
|
|
f"Config file:\n {ovpn_path}\n"
|
|
f"\n"
|
|
f"Password URL (one-time):\n {one_time_url}\n"
|
|
f"\n"
|
|
f"Password (shown once):\n {password}",
|
|
wait=True,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# === CLI ===
|
|
# ============================================================
|
|
|
|
|
|
class CliRunner:
|
|
"""Non-interactive runner for scripting / cron use."""
|
|
|
|
def create(self, cn: str, email_addr: str, send_email_flag: bool = True,
|
|
show_eml: bool = False) -> None:
|
|
self._issue(cn, email_addr, is_renewal=False,
|
|
send_email_flag=send_email_flag, show_eml=show_eml)
|
|
|
|
def reissue(self, cn: str, email_addr: str, send_email_flag: bool = True,
|
|
show_eml: bool = False) -> None:
|
|
# Prefer the caller-supplied email; fall back to stored metadata.
|
|
final_email = email_addr or get_email(EASYRSA_PKI_DIR, cn)
|
|
self._issue(cn, final_email, is_renewal=True,
|
|
send_email_flag=send_email_flag, show_eml=show_eml)
|
|
|
|
def revoke(self, cn: str) -> None:
|
|
print(f"Revoking {cn}…", file=sys.stderr)
|
|
try:
|
|
revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, CA_PASSPHRASE)
|
|
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
|
|
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
|
|
except EasyRSAError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"Revoked {cn}. CRL updated → {CRL_DEST_PATH}")
|
|
|
|
def regen_crl(self) -> None:
|
|
print("Regenerating CRL…", file=sys.stderr)
|
|
try:
|
|
gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE)
|
|
copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH)
|
|
except EasyRSAError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"CRL updated → {CRL_DEST_PATH}")
|
|
|
|
def _issue(self, cn: str, email_addr: str, is_renewal: bool,
|
|
send_email_flag: bool = True, show_eml: bool = False) -> None:
|
|
password = generate_password()
|
|
|
|
if 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)
|
|
|
|
print(f"Building certificate for {cn}…", file=sys.stderr)
|
|
try:
|
|
build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, password, CA_PASSPHRASE)
|
|
except EasyRSAError as exc:
|
|
if is_renewal:
|
|
print(
|
|
f"error during build-client-full: {exc}\n"
|
|
f"WARNING: {cn} has been revoked but no new cert was built.\n"
|
|
f"Re-run --reissue for this CN to issue a new certificate.",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if email_addr:
|
|
save_email(EASYRSA_PKI_DIR, cn, email_addr)
|
|
|
|
try:
|
|
ovpn_path = build_ovpn(cn, EASYRSA_PKI_DIR, OVPN_TEMPLATE_PATH,
|
|
VPN_CONFIGS_DIR, CONFIG_NAME)
|
|
except Exception as exc:
|
|
print(f"error building .ovpn: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
one_time_url = ""
|
|
try:
|
|
one_time_url = create_note(password, CRYPTGEON_URL)
|
|
except CryptgeonError as exc:
|
|
print(f"warning: Cryptgeon failed: {exc}", file=sys.stderr)
|
|
|
|
if show_eml:
|
|
if not email_addr:
|
|
print("warning: --show-eml skipped (no email address)", file=sys.stderr)
|
|
elif not one_time_url:
|
|
print("warning: --show-eml skipped (Cryptgeon failed)", file=sys.stderr)
|
|
else:
|
|
try:
|
|
msg = build_mime_message(email_addr, cn, one_time_url,
|
|
ovpn_path, MAIL_FROM, MAIL_SUBJECT,
|
|
EMAIL_TEMPLATE_PATH)
|
|
print(base64.b64encode(msg.as_bytes()).decode())
|
|
except Exception as exc:
|
|
print(f"warning: could not build .eml: {exc}", file=sys.stderr)
|
|
|
|
if email_addr and send_email_flag:
|
|
if not one_time_url:
|
|
print("warning: email skipped (Cryptgeon failed; use password from stdout)",
|
|
file=sys.stderr)
|
|
else:
|
|
try:
|
|
send_email(email_addr, cn, one_time_url, ovpn_path,
|
|
MAIL_FROM, MAIL_SUBJECT, EMAIL_TEMPLATE_PATH, MAIL_BINARY)
|
|
print(f"Email sent to {email_addr}", file=sys.stderr)
|
|
except RuntimeError as exc:
|
|
print(f"warning: email failed: {exc}", file=sys.stderr)
|
|
|
|
print(f"config: {ovpn_path}")
|
|
if one_time_url:
|
|
print(f"password-url: {one_time_url}")
|
|
else:
|
|
print(f"password: {password}")
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="openvpncertupdate",
|
|
description="Manage OpenVPN user certificates via EasyRSA.",
|
|
)
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--create", metavar="CN", help="Create a new certificate for CN")
|
|
group.add_argument("--reissue", metavar="CN", help="Revoke and reissue certificate for CN")
|
|
group.add_argument("--revoke", metavar="CN", help="Revoke certificate for CN and regenerate CRL")
|
|
group.add_argument("--gen-crl", action="store_true", help="Regenerate and copy CRL")
|
|
parser.add_argument("--email", metavar="EMAIL",
|
|
help="Email address (required for --create; optional for --reissue)")
|
|
parser.add_argument("--send-email", dest="send_email", action="store_const", const=True,
|
|
default=None, help="Send email after issuing cert (default unless --show-eml)")
|
|
parser.add_argument("--no-send-email", dest="send_email", action="store_const", const=False,
|
|
help="Skip email delivery; print URL to stdout instead")
|
|
parser.add_argument("--show-eml", action="store_true",
|
|
help="Print the generated email as base64-encoded .eml to stdout "
|
|
"(implies --no-send-email unless --send-email is also given)")
|
|
return parser
|
|
|
|
|
|
# ============================================================
|
|
# === ENTRY POINT ===
|
|
# ============================================================
|
|
|
|
|
|
def main() -> None:
|
|
parser = _build_parser()
|
|
args = parser.parse_args()
|
|
|
|
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
|
|
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)
|
|
|
|
if args.create:
|
|
if not args.email:
|
|
parser.error("--email is required with --create")
|
|
CliRunner().create(args.create, args.email,
|
|
send_email_flag=send_email_flag, show_eml=args.show_eml)
|
|
elif args.reissue:
|
|
CliRunner().reissue(args.reissue, args.email or "",
|
|
send_email_flag=send_email_flag, show_eml=args.show_eml)
|
|
elif args.revoke:
|
|
CliRunner().revoke(args.revoke)
|
|
elif args.gen_crl:
|
|
CliRunner().regen_crl()
|
|
else:
|
|
CursesApp().run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|