commit 3618d3a73b656512eb3778fbf5c462fef1821bea Author: Vlad Doloman Date: Tue Jun 23 23:46:44 2026 +0300 feat: project skeleton + settings + PKI index.txt reader diff --git a/openvpncertupdate.py b/openvpncertupdate.py new file mode 100644 index 0000000..0c9452c --- /dev/null +++ b/openvpncertupdate.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11d3e7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +cryptography>=41.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_pki.py b/tests/test_pki.py new file mode 100644 index 0000000..49fa171 --- /dev/null +++ b/tests/test_pki.py @@ -0,0 +1,59 @@ +import textwrap +import pytest +from datetime import datetime, timezone, timedelta +from openvpncertupdate import load_expiring_certs, CertInfo, _parse_index_line + + +def _fmt(dt: datetime) -> str: + return dt.strftime("%y%m%d%H%M%S") + "Z" + + +def make_pki(tmp_path, now): + pki = tmp_path / "pki" + pki.mkdir() + content = textwrap.dedent(f"""\ + V\t{_fmt(now + timedelta(days=10))}\t\t02\tunknown\t/CN=soon + V\t{_fmt(now + timedelta(days=90))}\t\t03\tunknown\t/CN=later + V\t{_fmt(now - timedelta(days=15))}\t\t04\tunknown\t/CN=past15 + V\t{_fmt(now - timedelta(days=40))}\t\t05\tunknown\t/CN=past40 + R\t{_fmt(now + timedelta(days=10))}\t230101Z,keyCompromise\t06\tunknown\t/CN=revoked + """) + (pki / "index.txt").write_text(content) + return str(pki) + + +def test_filters_within_window(tmp_path): + now = datetime.now(tz=timezone.utc) + pki = make_pki(tmp_path, now) + certs = load_expiring_certs(pki, days_past=30, days_ahead=14) + cns = {c.cn for c in certs} + assert "soon" in cns # 10d ahead < 14d threshold + assert "later" not in cns # 90d > 14d + assert "past15" in cns # 15d past < 30d threshold + assert "past40" not in cns # 40d past > 30d threshold + assert "revoked" not in cns # R status skipped + + +def test_sorted_ascending(tmp_path): + now = datetime.now(tz=timezone.utc) + pki = make_pki(tmp_path, now) + certs = load_expiring_certs(pki, days_past=30, days_ahead=14) + dates = [c.expires for c in certs] + assert dates == sorted(dates) + + +def test_days_left_negative_for_expired(tmp_path): + now = datetime.now(tz=timezone.utc) + pki = make_pki(tmp_path, now) + certs = load_expiring_certs(pki, days_past=30, days_ahead=14) + expired = next(c for c in certs if c.cn == "past15") + assert expired.days_left < 0 + + +def test_parse_4digit_year(): + line = "V\t20251231120000Z\t\t07\tunknown\t/CN=future" + result = _parse_index_line(line) + assert result is not None + cn, dt = result + assert cn == "future" + assert dt.year == 2025