diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8c7636d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`openvpncertupdate` is a single-file Python + curses TUI tool for managing OpenVPN user certificates via EasyRSA 3.2.x. It lists expiring/expired certs, re-issues them with fresh keys, creates new certs, and delivers configs via Cryptgeon (one-time password URL) and email. + +## Running + +```bash +pip install -r requirements.txt # just: cryptography>=41 +python3 openvpncertupdate.py +``` + +Edit the `SETTINGS` block at the top of `openvpncertupdate.py` before first run. + +## Tests + +```bash +python3 -m pytest tests/ -v +python3 -m pytest tests/test_password.py -v # single file +python3 -m pytest tests/test_pki.py::test_sorted_ascending -v # single test +``` + +## File layout — sections inside `openvpncertupdate.py` + +| Section | Key symbols | +|---|---| +| SETTINGS | all-caps constants | +| PKI | `CertInfo`, `load_expiring_certs()`, `_parse_index_line()` | +| PASSWORD | `generate_password()` | +| EASYRSA | `EasyRSAError`, `revoke_issued()`, `build_client_full()`, `gen_crl()`, `copy_crl()` | +| METADATA | `load_metadata()`, `save_email()`, `get_email()` | +| CONFIG | `build_ovpn()` → `vpn-configs/_/` | +| CRYPTGEON | `CryptgeonError`, `create_note()` | +| MAILER | `send_email()` | +| TUI WIDGETS | `InputField`, `clamp()`, `draw_box()`, `init_colors()`, `COLOR_*` | +| TUI DIALOGS | `show_confirm()`, `show_cert_form()`, `CertFormResult` | +| TUI SCREEN | `show_main_screen()`, `Action`, `ScreenResult` | +| APP | `CursesApp` | +| ENTRY POINT | `main()` | + +## Re-issue workflow + +1. `revoke-issued ` — archives old key + CSR to `pki/revoked/` +2. `build-client-full --passout=pass:` — generates new key + cert +3. CRL **not** auto-updated during renewal; use "Regenerate CRL" menu item or `r` hotkey + +## Key constraints + +- EasyRSA called with `--batch`; `--passin=pass:` omitted when `CA_PASSPHRASE=""` +- Cryptgeon: `raw_key=os.urandom(32)`, `aes_key=SHA-256(raw_key)`, AES-256-GCM, URL fragment=`base64url(raw_key)` +- `copy_crl()` does `chmod 644` after copy +- Password: pos 1=uppercase, pos 2=lowercase (no j), pos 3-27=alphanumeric, pos 28=lowercase (no j); `oO01lI` banned everywhere +- Inline file path: `/inline/private/.inline` +- User emails stored in `/openvpncertupdate-metadata.json` diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 9dbde1c..9783b8f 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -746,12 +746,196 @@ def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: return ScreenResult(action=Action.REVOKE, selected_cns=[cert.cn]) -# (remaining sections added in later tasks) +# ============================================================ +# === APP === +# ============================================================ + + +class CursesApp: + def run(self) -> None: + curses.wrapper(self._main) + + def _main(self, stdscr) -> None: + curses.curs_set(0) + init_colors() + + while True: + try: + certs = load_expiring_certs( + EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD, + ) + except FileNotFoundError as exc: + self._fatal(stdscr, f"Cannot read PKI index.txt:\n{exc}") + return + + result = show_main_screen(stdscr, certs) + + if result.action == Action.EXIT: + return + 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 form.cancelled: + return False + + final_cn = form.cn + self._msg(stdscr, f"Generating certificate for {final_cn}…") + + try: + if is_renewal: + revoke_issued(EASYRSA_DIR, EASYRSA_PKI_DIR, final_cn, CA_PASSPHRASE) + build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, + final_cn, form.password, CA_PASSPHRASE) + except EasyRSAError as exc: + self._error(stdscr, f"EasyRSA error:\n{exc}") + return True + + if form.email: + save_email(EASYRSA_PKI_DIR, final_cn, form.email) + + 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[:50]}…", + ): + try: + send_email(form.email, final_cn, one_time_url, ovpn_path, + MAIL_FROM, MAIL_SUBJECT, EMAIL_TEMPLATE_PATH, MAIL_BINARY) + self._msg(stdscr, + f"Email sent to {form.email}\nConfig: {ovpn_path}", + wait=True) + except RuntimeError as exc: + self._error(stdscr, + f"Email failed:\n{exc}\n\n" + f"URL : {one_time_url}\n" + f"Config: {ovpn_path}") + 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) + except EasyRSAError as exc: + self._error(stdscr, f"Revoke failed:\n{exc}") + return + 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) + except EasyRSAError as exc: + self._error(stdscr, f"gen-crl failed:\n{exc}") + return + 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 _fatal(self, stdscr, text: str) -> None: + self._error(stdscr, text) + + def _show_result( + self, stdscr, cn: str, ovpn_path: str, password: str, one_time_url: str, + ) -> None: + 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, + ) + + +# ============================================================ +# === ENTRY POINT === # ============================================================ def main() -> None: - pass # replaced in Task 11 + CursesApp().run() if __name__ == "__main__":