feat: project skeleton + settings + PKI index.txt reader
This commit is contained in:
137
openvpncertupdate.py
Normal file
137
openvpncertupdate.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user