Files
openvpncertupdate/openvpncertupdate.py
Vlad Doloman f0db11597a fix: populate cert form email from index.txt emailAddress field on reissue
_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>
2026-06-24 12:33:25 +03:00

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()