feat: project skeleton + settings + PKI index.txt reader

This commit is contained in:
Vlad Doloman
2026-06-23 23:46:44 +03:00
commit 3618d3a73b
4 changed files with 197 additions and 0 deletions

137
openvpncertupdate.py Normal file
View 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()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
cryptography>=41.0.0

0
tests/__init__.py Normal file
View File

59
tests/test_pki.py Normal file
View File

@@ -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