138 lines
3.7 KiB
Python
138 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""openvpncertupdate — Manage OpenVPN user certificates via EasyRSA."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 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
|
|
|
|
|
|
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]]:
|
|
"""Return (cn, expiry) 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
|
|
for seg in dn.split("/"):
|
|
if seg.startswith("CN="):
|
|
cn = seg[3:]
|
|
break
|
|
if not cn:
|
|
return None
|
|
return cn, expiry
|
|
|
|
|
|
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 = parsed
|
|
if cutoff_past <= expiry <= cutoff_future:
|
|
results.append(CertInfo(
|
|
cn=cn,
|
|
expires=expiry,
|
|
days_left=(expiry - now).days,
|
|
))
|
|
results.sort(key=lambda c: c.expires)
|
|
return results
|
|
|
|
|
|
# ============================================================
|
|
# (remaining sections added in later tasks)
|
|
# ============================================================
|
|
|
|
|
|
def main() -> None:
|
|
pass # replaced in Task 11
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|