Remove Python 3.7+ constructs so the tool runs on 3.6: - Drop `from __future__ import annotations` (3.7+) and convert all builtin-generic annotations (list[]/dict[]/tuple[]/set[]) to their typing.List/Dict/Tuple/Set equivalents, since without the future import these annotations are evaluated eagerly and builtin generic subscription only exists on 3.9+. - Replace subprocess.run(capture_output=, text=) — both 3.7+ — with stdout/stderr=PIPE and universal_newlines=True; update the matching test assertion. - requirements.txt: gate cryptography>=41 behind python_version>=3.7 and pin cryptography<41 (last 3.6-compatible line, 40.0.2) plus the dataclasses backport for 3.6. - Suppress cryptography 40.x's per-import "Python 3.6 is no longer supported" deprecation warning so it doesn't pollute CLI/cron stderr; the <41 pin keeps us on a supported release regardless. The only cryptography API used is AESGCM (present since 2.0), so no x509/API-surface changes are needed for the older pin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1540 lines
56 KiB
Python
1540 lines
56 KiB
Python
#!/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 "<this-script-path>.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
|
|
|
|
# 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",
|
|
})
|
|
|
|
|
|
# ============================================================
|
|
# === 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 > "<script_path>.conf"
|
|
next to this script. A path from the CLI flag or CONFIG_PATH is explicit: a
|
|
missing file raises ConfigError. The default "<script_path>.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 = ""
|
|
|
|
|
|
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,
|
|
)
|
|
return list(by_cn.values())
|
|
|
|
|
|
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 _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")
|
|
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, email: 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}", "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
|
|
|
|
|
|
# ============================================================
|
|
# === 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,
|
|
) -> 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)
|
|
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:
|
|
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()
|
|
# 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
|
|
|
|
|
|
_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 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(13, 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 = 9
|
|
_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),
|
|
"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,
|
|
)
|
|
|
|
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"
|
|
try:
|
|
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED))
|
|
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
|
|
curses.curs_set(0)
|
|
return _submit()
|
|
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(" ")):
|
|
curses.curs_set(0)
|
|
if current == _BTN_CONTINUE:
|
|
return _submit()
|
|
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)
|
|
line = f" {box} {lbl:<18} {cert.cn:<20} {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, 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
|
|
# 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)
|
|
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
|
|
|
|
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`."""
|
|
if not certs:
|
|
return "(no certificates)"
|
|
rows = [
|
|
(c.cn, _expiry_label(c).strip(), get_email(EASYRSA_PKI_DIR, c.cn) or "(none)")
|
|
for c in certs
|
|
]
|
|
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))
|
|
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)
|
|
|
|
|
|
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()
|
|
|
|
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)
|
|
# 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)
|
|
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)
|
|
|
|
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("--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)
|
|
|
|
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()
|