#!/usr/bin/env python3 """openvpncertupdate — Manage OpenVPN user certificates via EasyRSA.""" import argparse import base64 import curses import email.encoders import email.mime.base import email.mime.multipart import email.mime.text import getpass import json import os import secrets import shutil import smtplib import string import subprocess import sys import urllib.error import urllib.request import warnings from dataclasses import dataclass, field from datetime import date, datetime, timedelta, timezone from enum import Enum, auto from pathlib import Path from typing import Callable, Dict, List, Optional, Set, Tuple try: with warnings.catch_warnings(): # cryptography <41 pinned for Python 3.6 warns on every import that 3.6 # support is deprecated; the pin keeps us on a supported release, so the # warning is noise that would otherwise pollute CLI/cron stderr each run. warnings.filterwarnings("ignore", message="Python 3.6 is no longer supported") from cryptography.hazmat.primitives.ciphers.aead import AESGCM except ImportError: # pragma: no cover AESGCM = None # type: ignore # ============================================================ # SETTINGS — edit these for your environment # ============================================================ CONFIG_PATH = "" # explicit path to an external .conf file overriding the settings # below (same syntax as this block). Overridden by --config. # Empty = look for ".conf" next to the script; # if that default file is absent it is skipped silently. An # explicit CONFIG_PATH or --config that doesn't exist is an error. EASYRSA_DIR = "/etc/easy-rsa" EASYRSA_PKI_DIR = "/etc/easy-rsa/pki" CA_PASSPHRASE = "" # "" = auto-detect & prompt if the CA key is encrypted # "!empty" = CA key is unencrypted, never check/prompt # "!ask" = always prompt, skip detection # any other value = used as the literal passphrase OVPN_TEMPLATE_PATH = "./template.ovpn" CONFIG_NAME = "client.ovpn" VPN_CONFIGS_DIR = "./vpn-configs" CRL_DEST_PATH = "/etc/openvpn/crl.pem" RESTORECON_BINARY = "restorecon" # run on CRL_DEST_PATH after each copy; "" disables (non-SELinux systems) 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"; ignored when SMTP_HOST is set SMTP_HOST = "" # e.g. "smtp.example.com"; set to use smtplib instead of MAIL_BINARY SMTP_PORT = 587 # 587=STARTTLS 465=SSL 25=plain SMTP_USER = "" # leave empty to skip authentication SMTP_PASSWORD = "" SMTP_TLS = "starttls" # "starttls" | "ssl" | "" (plain) DAYS_PAST = 30 DAYS_AHEAD = 14 CERT_DAYS = "default" # lifetime of certs this tool issues, in days. # "default" or "" = leave it to EasyRSA (your vars' # EASYRSA_CERT_EXPIRE). A positive integer overrides # it — e.g. 90. # Names an external .conf file is allowed to override — keep in sync with the # SETTINGS block above. Deliberately excludes CONFIG_PATH itself. _OVERRIDABLE_SETTINGS = frozenset({ "EASYRSA_DIR", "EASYRSA_PKI_DIR", "CA_PASSPHRASE", "OVPN_TEMPLATE_PATH", "CONFIG_NAME", "VPN_CONFIGS_DIR", "CRL_DEST_PATH", "RESTORECON_BINARY", "CRYPTGEON_URL", "MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY", "SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS", "DAYS_PAST", "DAYS_AHEAD", "CERT_DAYS", }) # ============================================================ # === SETTINGS OVERRIDE === # ============================================================ class ConfigError(Exception): pass def load_settings_overrides( cli_config: Optional[str], configured_path: str, script_path: str ) -> dict: """Load SETTINGS overrides from an external .conf file (same Python syntax as the SETTINGS block above — it's executed, so only run trusted files). Resolution order: --config CLI flag > CONFIG_PATH setting > ".conf" next to this script. A path from the CLI flag or CONFIG_PATH is explicit: a missing file raises ConfigError. The default ".conf" path is optional: a missing file returns {} silently. """ explicit = bool(cli_config or configured_path) path = cli_config or configured_path or f"{script_path}.conf" if not os.path.isfile(path): if explicit: raise ConfigError(f"config file not found: {path}") return {} namespace: dict = {} exec(compile(Path(path).read_text(), path, "exec"), namespace) return {name: value for name, value in namespace.items() if name in _OVERRIDABLE_SETTINGS} # ============================================================ # === PKI === # ============================================================ @dataclass class CertInfo: cn: str expires: datetime # UTC days_left: int # negative = already expired email: str = "" # False when index.txt lists the CN but /issued/.crt is gone # — see has_issued_cert(). Such a CN can still be re-issued, but nothing # can be revoked for it, so the list marks it. has_cert_file: bool = True 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_current_certs(pki_dir: str) -> List[CertInfo]: """Return one CertInfo per CN: its most recent V-status index.txt entry. index.txt is append-only, so a CN can accumulate several V-status lines (e.g. an old cert left unrevoked after it expired, then reissued) as well as older R-status (revoked) ones. Scan the whole file in order and keep only the last non-revoked line per CN — same rule get_email() uses for picking an email among duplicates, applied here to the row itself so expiring/all-certs views don't show stale duplicate rows. """ now = datetime.now(tz=timezone.utc) by_cn: Dict[str, 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 by_cn[cn] = CertInfo( cn=cn, expires=expiry, days_left=(expiry - now).days, email=email, ) certs = list(by_cn.values()) # After the dedup, so a CN with several V-lines is stat'ed once. for c in certs: c.has_cert_file = has_issued_cert(pki_dir, c.cn) return certs def load_expiring_certs( pki_dir: str, days_past: int, days_ahead: int, ) -> List[CertInfo]: """Return current 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 = [c for c in _load_current_certs(pki_dir) if cutoff_past <= c.expires <= cutoff_future] results.sort(key=lambda c: c.expires) return results def load_all_certs(pki_dir: str) -> List[CertInfo]: """Return all current certs, sorted by expiry date.""" results = _load_current_certs(pki_dir) results.sort(key=lambda c: c.expires) return results def get_email(pki_dir: str, cn: str) -> str: """Return the emailAddress on CN's current (most recent V-status) entry.""" for c in _load_current_certs(pki_dir): if c.cn == cn: return c.email return "" # ============================================================ # === PASSWORD === # ============================================================ _BANNED_ALL = frozenset("oO01lIQ5S2Z8B") _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 _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 if extra_env: env = os.environ.copy() env.update(extra_env) result = subprocess.run( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, env=env, ) 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}):\n{detail}" ) 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 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: _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, email: str = "", days: str = "", ) -> None: extra_env = {"EASYRSA_REQ_EMAIL": email} if email else None _run_easyrsa( _base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + [f"--passout=pass:{key_passphrase}"] # Global option, so it must precede the verb. Deliberately not in # _base_cmd(): gen-crl reads --days as CRL validity instead. + ([f"--days={days}"] if days else []) + ["build-client-full", cn], cwd=easyrsa_dir, extra_env=extra_env, ) 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, restorecon_binary: str = "") -> None: shutil.copy2(f"{pki_dir}/crl.pem", dest_path) os.chmod(dest_path, 0o644) if restorecon_binary: # Best-effort, like is_ca_key_encrypted(): a missing/misconfigured # restorecon must not break CRL deployment on non-SELinux systems. try: subprocess.run( [restorecon_binary, dest_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) except OSError: pass def is_ca_key_encrypted(pki_dir: str) -> bool: """Best-effort check of the CA private key's PEM header. Missing/unreadable key => False (let the normal EasyRSA flow surface any real problem).""" try: header = Path(f"{pki_dir}/private/ca.key").read_text() except OSError: return False return "ENCRYPTED" in header def resolve_ca_passphrase( configured: str, pki_dir: str, prompt: Callable[[str], str] = getpass.getpass ) -> str: """Resolve the SETTINGS CA_PASSPHRASE sentinel into an actual passphrase. "" → auto-detect: prompt only if the CA key is actually encrypted "!empty" → explicitly unencrypted; never check, never prompt "!ask" → always prompt, without checking other → used as the literal passphrase """ if configured == "!empty": return "" if configured == "!ask": return prompt("CA passphrase: ") if configured == "": if is_ca_key_encrypted(pki_dir): return prompt("CA key is encrypted; enter CA passphrase: ") return "" return configured def resolve_cert_days(configured, name: str = "CERT_DAYS") -> str: """Resolve a configured certificate lifetime into an EasyRSA --days value. "default" / "" → "" (omit --days; EasyRSA's EASYRSA_CERT_EXPIRE wins) positive number → that many days, normalised ("090" → "90") Rejects 0, negatives and non-numeric input. EasyRSA rejects those itself, but failing here keeps the error next to the input the user actually typed — in the TUI that means the form is still open, and on the CLI it means no CA passphrase prompt and no subprocess round-trip first. `name` names the source ("CERT_DAYS", "--days", "Days") for the message. """ text = str(configured).strip() if text in ("", "default"): return "" try: days = int(text) except ValueError: raise ConfigError( f'{name} must be a positive number of days, "default" or "" ' f'(inherit from EasyRSA); got {configured!r}' ) if days <= 0: raise ConfigError( f"{name} must be a positive number of days " f"(EasyRSA rejects 0); got {configured!r}" ) return str(days) # ============================================================ # === 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 (occulto's AES.encrypt + API.create): key (32 bytes, used directly, no derivation) → URL fragment (hex) contents = base64(b"AES-GCM") + "--" + base64(nonce) + "--" + base64(ciphertext) meta = JSON string '{"type": "text"}' """ if AESGCM is None: raise CryptgeonError("cryptography package not installed (pip install cryptography)") key = os.urandom(32) nonce = os.urandom(12) aesgcm = AESGCM(key) ciphertext = aesgcm.encrypt(nonce, content.encode("utf-8"), None) contents = "--".join( base64.b64encode(part).decode("ascii") for part in (b"AES-GCM", nonce, ciphertext) ) payload = json.dumps({ "contents": contents, "meta": json.dumps({"type": "text"}), "views": 1, }).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"] return f"{base_url.rstrip('/')}/note/{note_id}#{key.hex()}" # ============================================================ # === 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, smtp_host: str = "", smtp_port: int = 587, smtp_user: str = "", smtp_password: str = "", smtp_tls: str = "starttls", ) -> None: """Compose and send email with .ovpn attachment. Uses smtplib when smtp_host is set; falls back to mail_binary otherwise. """ msg = build_mime_message(to_address, cn, one_time_url, ovpn_path, mail_from, subject, template_path) if smtp_host: if smtp_tls == "ssl": conn: smtplib.SMTP = smtplib.SMTP_SSL(smtp_host, smtp_port) else: conn = smtplib.SMTP(smtp_host, smtp_port) if smtp_tls == "starttls": conn.starttls() with conn: if smtp_user: conn.login(smtp_user, smtp_password) conn.sendmail(mail_from, [to_address], msg.as_bytes()) else: 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() try: curses.use_default_colors() bg = -1 except curses.error: # Terminal (e.g. a serial console) lacks default-color support # (terminfo has no "op"/"AX" capability); fall back to explicit black. bg = curses.COLOR_BLACK disabled_fg = 8 if curses.COLORS > 8 else curses.COLOR_WHITE # Some serial-console terminfo entries advertise start_color()/COLORS # support but still can't actually set custom pairs (e.g. COLOR_PAIRS is # too small). Set each pair independently so a rejected pair just falls # back to the terminal's default rendering instead of crashing the app. for pair_id, fg, pair_bg in ( (COLOR_NORMAL, curses.COLOR_WHITE, bg), (COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN), (COLOR_DISABLED, disabled_fg, bg), (COLOR_TITLE, curses.COLOR_WHITE, bg), (COLOR_ERROR, curses.COLOR_RED, bg), (COLOR_SUCCESS, curses.COLOR_GREEN, bg), ): try: curses.init_pair(pair_id, fg, pair_bg) except curses.error: pass 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, allowed: Optional[str] = None, ) -> None: self._win = win self._y = y self._x = x self._width = width self._mask = mask self._allowed = allowed 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) except curses.error: pass def place_cursor(self) -> None: start = max(0, self._cur - self._width + 1) try: 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: ch = chr(key) if self._allowed is not None and ch not in self._allowed: return self._buf.insert(self._cur, ch) 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() # Clamp to the screen: an unbounded width (e.g. a long Cryptgeon URL line) # makes curses.newwin() raise "curses function returned NULL" outright on # a narrow terminal (e.g. a serial console) instead of just wrapping badly. h = min(len(lines) + 6, sh) w = min(max(max(len(l) for l in lines) + 6, 38), sw) try: win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) except curses.error: return False 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 days: str = "" _FORM_FIELDS = ("cn", "email", "days", "password") _FORM_LABELS = { "cn": "CN", "email": "Email", "days": "Days (blank = EasyRSA default)", "password": "Password", } _FORM_FIELD_Y = {"cn": 3, "email": 5, "days": 7, "password": 9} def show_cert_form( stdscr, cn: str = "", email: str = "", password: str = "", days: str = "", cn_readonly: bool = False, ) -> CertFormResult: """Modal cert-detail form. Tab / Shift-Tab / Up / Down cycle fields and buttons. F5 regenerates password. Enter or Space activates the focused button. Ctrl-G confirms immediately. Escape cancels.""" sh, sw = stdscr.getmaxyx() # Clamp to the screen so curses.newwin() can't raise "curses function # returned NULL" outright on a terminal narrower/shorter than the usual # 80x24 (e.g. a serial console with an unusually small viewport). h, w = min(15, sh), min(62, sw) try: win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) except curses.error: return CertFormResult(cn=cn, email="", password="", confirmed=False) win.keypad(True) fw = w - 6 # field width _BTN_CONTINUE = "_continue" _BTN_CANCEL = "_cancel" _BTN_Y = 11 _btn_x_cont = (w - 26) // 2 # "[ Continue ]"=12 + gap=4 + "[ Cancel ]"=10 = 26 _btn_x_canc = _btn_x_cont + 16 active = ([n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)] + [_BTN_CONTINUE, _BTN_CANCEL]) 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), "days": InputField(win, _FORM_FIELD_Y["days"], 3, fw, initial=days, allowed=string.digits), "password": InputField(win, _FORM_FIELD_Y["password"], 3, fw, initial=password if password else generate_password(), mask=True), } focus = 0 error = "" def _submit() -> Optional[CertFormResult]: nonlocal error try: # Validate with the same resolver the CLI and .conf use, so what # counts as valid never diverges between entry points. days_value = resolve_cert_days(fields["days"].value, "Days") except ConfigError: # The resolver's full sentence does not fit a 62-column window. error = "Days must be a positive number, or blank to inherit" return None error = "" final_cn = cn if cn_readonly else fields["cn"].value return CertFormResult( cn=final_cn, email=fields["email"].value, password=fields["password"].value, days=days_value, confirmed=True, ) curses.curs_set(1) while True: win.erase() # see show_main_screen's draw() for why not 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() current = active[focus] for btn_label, btn_id, bx in ( ("Continue", _BTN_CONTINUE, _btn_x_cont), ("Cancel", _BTN_CANCEL, _btn_x_canc), ): if current == btn_id: btn_attr = curses.color_pair(COLOR_SELECTED) | curses.A_REVERSE else: btn_attr = curses.color_pair(COLOR_NORMAL) try: win.addstr(_BTN_Y, bx, f"[ {btn_label} ]", btn_attr | curses.A_BOLD) except curses.error: pass hint = "Tab=next F5=regen pwd Ctrl-G=confirm Esc=cancel" hint_attr = curses.color_pair(COLOR_DISABLED) if error: hint, hint_attr = error, curses.color_pair(COLOR_ERROR) try: win.addstr(h - 2, 2, hint[:w - 4], hint_attr) except curses.error: pass if current == _BTN_CONTINUE: try: win.move(_BTN_Y, _btn_x_cont) except curses.error: pass elif current == _BTN_CANCEL: try: win.move(_BTN_Y, _btn_x_canc) except curses.error: pass else: fields[current].place_cursor() win.refresh() key = win.getch() if key == 27: curses.curs_set(0) return CertFormResult(cn=cn, email="", password="", confirmed=False) if key == 0x07: # Ctrl-G: immediate submit result = _submit() if result is not None: curses.curs_set(0) return result continue if key in (9, curses.KEY_DOWN): # Tab / Down: next focus = (focus + 1) % len(active) continue if key in (curses.KEY_BTAB, curses.KEY_UP): # Shift-Tab / Up: prev 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 current in (_BTN_CONTINUE, _BTN_CANCEL): if key in (curses.KEY_LEFT, curses.KEY_RIGHT): focus = active.index( _BTN_CANCEL if current == _BTN_CONTINUE else _BTN_CONTINUE ) continue if key in (10, 13, curses.KEY_ENTER, ord(" ")): if current == _BTN_CONTINUE: result = _submit() if result is not None: curses.curs_set(0) return result continue curses.curs_set(0) return CertFormResult(cn=cn, email="", password="", confirmed=False) else: if key in (10, 13, curses.KEY_ENTER): focus = (focus + 1) % len(active) continue fields[current].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)) esc_pending = False 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(): # erase() (not clear()) so refresh() only transmits the cells that # actually changed instead of repainting the whole screen on every # keystroke — clear() forces a full repaint and is slow/flickery # over a low-bandwidth link like a serial console. stdscr.erase() 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 if esc_pending: hint = " Press Esc again to quit" else: 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_cur: # A_REVERSE keeps the cursor visible even when the terminal # (e.g. a serial console) silently rejects the COLOR_SELECTED # pair and this color_pair() call is a no-op. sel_attr |= curses.A_REVERSE if is_cert(i): cert = certs[i] box = "[X]" if cert.cn in checked else "[ ]" lbl = _expiry_label(cert) # Sits before the email so a long address truncating at the # right edge can't push the marker off screen, and kept # short so a typical address still fits at 80 columns. note = "" if cert.has_cert_file else "(no cert) " line = f" {box} {lbl:<18} {cert.cn:<20} {note}{cert.email}" 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) if is_cur: attr |= curses.A_REVERSE 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 == curses.KEY_RESIZE: # Force one true full repaint on the actual new dimensions — # erase()+refresh() diffs against the pre-resize screen model, # which isn't reliable across a genuine size change. stdscr.clear() continue if key == 27: # Esc if esc_pending: return ScreenResult(action=Action.EXIT) esc_pending = True continue esc_pending = False 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 __init__(self) -> None: self._session_log: List[str] = [] def _log(self, entry: str) -> None: self._session_log.append(entry) def _print_session_log(self) -> None: if not self._session_log: return bar = "─" * 64 print(f"\n{bar}") for entry in self._session_log: print(entry) print(bar) def run(self) -> None: try: curses.wrapper(self._main) finally: self._print_session_log() 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: for cn in result.selected_cns: email_addr = get_email(EASYRSA_PKI_DIR, cn) 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, days=CERT_DAYS, cn_readonly=is_renewal) if not form.confirmed: return False final_cn = form.cn self._msg(stdscr, f"Generating certificate for {final_cn}…") 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 # cert is more urgent than a fresh CRL, so keep going either way. try: gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE) copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY) except EasyRSAError as exc: self._error( stdscr, f"CRL update failed:\n{exc}\n\n" f"{final_cn} was revoked but the published CRL is stale.\n" f"Use \"Regenerate CRL\" once this finishes.", ) try: build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, final_cn, form.password, CA_PASSPHRASE, email=form.email, days=form.days) except EasyRSAError as exc: if revoked: 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 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, smtp_host=SMTP_HOST, smtp_port=SMTP_PORT, smtp_user=SMTP_USER, smtp_password=SMTP_PASSWORD, smtp_tls=SMTP_TLS) self._msg(stdscr, f"Email sent to {form.email}\nConfig: {ovpn_path}", wait=True) self._log( f"Certificate issued for: {final_cn}\n" f" Config : {ovpn_path}\n" f" URL : {one_time_url}\n" f" Password : {form.password}\n" f" Email sent to: {form.email}" ) except RuntimeError as exc: self._error(stdscr, f"Email failed:\n{exc}\n\n" f"URL : {one_time_url}\n" f"Config: {ovpn_path}") self._log( f"Certificate issued for: {final_cn}\n" f" Config : {ovpn_path}\n" f" URL : {one_time_url}\n" f" Password : {form.password}\n" f" Email FAILED to: {form.email}" ) 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, RESTORECON_BINARY) except EasyRSAError as exc: self._error(stdscr, f"Revoke failed:\n{exc}") return self._log(f"{cn} revoked, CRL updated → {CRL_DEST_PATH}") 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, RESTORECON_BINARY) except EasyRSAError as exc: self._error(stdscr, f"gen-crl failed:\n{exc}") return self._log(f"CRL regenerated → {CRL_DEST_PATH}") 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._log( f"Certificate issued for: {cn}\n" f" Config : {ovpn_path}\n" f" URL : {one_time_url}\n" f" Password : {password}" ) 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 === # ============================================================ def _format_cert_table(certs: List[CertInfo]) -> str: """Render CN / expiry / email as an aligned table, like `ls -l`. A trailing CERT column appears only when at least one CN has lost its issued .crt — it is pure noise on a healthy PKI. """ if not certs: return "(no certificates)" rows = [ (c.cn, _expiry_label(c).strip(), get_email(EASYRSA_PKI_DIR, c.cn) or "(none)", "" if c.has_cert_file else "MISSING") for c in certs ] show_cert_col = any(r[3] for r in rows) cn_w = max(len("CN"), max(len(r[0]) for r in rows)) exp_w = max(len("EXPIRES"), max(len(r[1]) for r in rows)) if not show_cert_col: lines = [f"{'CN':<{cn_w}} {'EXPIRES':<{exp_w}} EMAIL"] for cn, exp, email, _ in rows: lines.append(f"{cn:<{cn_w}} {exp:<{exp_w}} {email}") return "\n".join(lines) mail_w = max(len("EMAIL"), max(len(r[2]) for r in rows)) lines = [f"{'CN':<{cn_w}} {'EXPIRES':<{exp_w}} {'EMAIL':<{mail_w}} CERT"] for cn, exp, email, cert in rows: lines.append( f"{cn:<{cn_w}} {exp:<{exp_w}} {email:<{mail_w}} {cert}".rstrip() ) return "\n".join(lines) 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 the emailAddress # already on the existing cert's subject in index.txt. 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, RESTORECON_BINARY) 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, RESTORECON_BINARY) except EasyRSAError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) print(f"CRL updated → {CRL_DEST_PATH}") def list_certs(self, show_all: bool) -> None: certs = (load_all_certs(EASYRSA_PKI_DIR) if show_all else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD)) print(_format_cert_table(certs)) def _issue(self, cn: str, email_addr: str, is_renewal: bool, send_email_flag: bool = True, show_eml: bool = False) -> None: password = generate_password() 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 # urgent than a fresh CRL, so keep going either way. print(f"Regenerating CRL…", file=sys.stderr) try: gen_crl(EASYRSA_DIR, EASYRSA_PKI_DIR, CA_PASSPHRASE) copy_crl(EASYRSA_PKI_DIR, CRL_DEST_PATH, RESTORECON_BINARY) except EasyRSAError as exc: print( f"warning: CRL update failed: {exc}\n" f"warning: {cn} was revoked but the published CRL is stale; " f"run --gen-crl once this finishes.", file=sys.stderr, ) print(f"Building certificate for {cn}…", file=sys.stderr) try: build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, password, CA_PASSPHRASE, email=email_addr, days=CERT_DAYS) except EasyRSAError as exc: if revoked: 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) 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, smtp_host=SMTP_HOST, smtp_port=SMTP_PORT, smtp_user=SMTP_USER, smtp_password=SMTP_PASSWORD, smtp_tls=SMTP_TLS) print(f"Email sent to {email_addr}", file=sys.stderr) except RuntimeError as exc: print(f"warning: email failed: {exc}", file=sys.stderr) elif send_email_flag and not email_addr: print(f"warning: email skipped (no email address on file for {cn})", 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") group.add_argument("--list", action="store_true", help="List recently-expired/soon-to-expire CNs " "(per DAYS_PAST/DAYS_AHEAD) with email") group.add_argument("--list-all", action="store_true", help="List all CNs with email") parser.add_argument("--email", metavar="EMAIL", help="Email address (required for --create; optional for --reissue)") parser.add_argument("--days", metavar="N", help="Certificate lifetime in days for --create/--reissue " "(overrides CERT_DAYS; omit to use CERT_DAYS)") 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)") parser.add_argument("--config", metavar="PATH", help="External .conf file overriding SETTINGS (overrides CONFIG_PATH; " "a missing file here is an error)") return parser # ============================================================ # === ENTRY POINT === # ============================================================ def main() -> None: parser = _build_parser() args = parser.parse_args() try: overrides = load_settings_overrides(args.config, CONFIG_PATH, __file__) except ConfigError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) globals().update(overrides) global CERT_DAYS try: CERT_DAYS = (resolve_cert_days(args.days, "--days") if args.days is not None else resolve_cert_days(CERT_DAYS)) except ConfigError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) if args.list: CliRunner().list_certs(show_all=False) return if args.list_all: CliRunner().list_certs(show_all=True) return global CA_PASSPHRASE CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR) # --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()