#!/usr/bin/env python3 """Create a private key on NetScaler Console, download it, and generate a CSR locally. This is intentionally narrow-scope ("script 3" + "script 4" from your plan), so it can be composed by a higher-level orchestrator later. Outputs (PEM): - / - / - (optional) /latest.key and latest.csr symlinks/copies Security: - Console password and optional key passphrase can be pulled from the OS keychain (macOS Keychain today; enterprise vault adapters can be added later). NetScaler Console behavior: - We log in once via /nitro/v2/config/login (POST) to obtain a session token. - We include Cookie: NITRO_AUTH_TOKEN= on subsequent calls (especially downloads). Usage example: python3 scripts/keycsr_console.py --console https://192.168.113.2 --user nsroot \ --app-name example.com --out-dir ./out --insecure """ from __future__ import annotations import argparse import os import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path from certctl.console import ConsoleClient, NitroError from certctl.csr import Subject, make_csr_openssl from certctl.secretstore import get_password, set_password from certctl.san import normalize_san_list @dataclass(frozen=True) class DerivedNames: key_name: str csr_name: str def _ts() -> str: # Stable + filename safe return datetime.now().strftime("%Y%m%d-%H%M%S") def derive_names(app_name: str, rotate: bool) -> DerivedNames: if rotate: stamp = _ts() base = f"{app_name}_{stamp}" else: base = app_name return DerivedNames(key_name=f"{base}.key", csr_name=f"{base}.csr") def prompt_choice(prompt: str, choices: list[str], default_index: int = 0) -> int: while True: print(prompt) for i, c in enumerate(choices, 1): default = " (default)" if i - 1 == default_index else "" print(f" {i}) {c}{default}") raw = input(f"Select [1-{len(choices)}]: ").strip() if raw == "": return default_index if raw.isdigit() and 1 <= int(raw) <= len(choices): return int(raw) - 1 print("Invalid selection. Try again.\n") def prompt_yesno(prompt: str, default_yes: bool = True) -> bool: suf = "[Y/n]" if default_yes else "[y/N]" raw = input(f"{prompt} {suf} ").strip().lower() if raw == "": return default_yes return raw in ("y", "yes") def write_latest(out_dir: Path, latest_name: str, target_name: str) -> None: latest = out_dir / latest_name target = out_dir / target_name try: if latest.exists() or latest.is_symlink(): latest.unlink() latest.symlink_to(target.name) except Exception: # Windows / restrictive FS: fall back to copy latest.write_bytes(target.read_bytes()) def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="keycsr_console.py", description="Create key on NetScaler Console, download it, and generate CSR locally.", ) p.add_argument("--console", required=True, help="Console base URL, e.g. https://192.168.113.2") p.add_argument("--user", required=True, help="Console username") p.add_argument("--app-name", required=True, help="App name / base CN (used for derived filenames)") p.add_argument("--out-dir", default="./out", help="Output directory") p.add_argument("--rotate", action="store_true", default=True, help="Always timestamp filenames (default: on)") p.add_argument( "--no-rotate", dest="rotate", action="store_false", help="Do not timestamp filenames (NOT recommended)" ) p.add_argument( "--write-latest", action="store_true", default=True, help="Write latest.key/latest.csr pointers (default: on)", ) p.add_argument( "--no-write-latest", dest="write_latest", action="store_false", help="Disable writing latest.key/latest.csr", ) p.add_argument("--insecure", action="store_true", help="Disable TLS verification") args = p.parse_args(argv) out_dir = Path(args.out_dir).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) names = derive_names(args.app_name, rotate=args.rotate) print("\n[info] Derived names:") print(f" Key: {names.key_name}") print(f" CSR: {names.csr_name}") print(f" Rotate: {'ON' if args.rotate else 'OFF'}") print(f" Write latest: {'ON' if args.write_latest else 'OFF'}") if not prompt_yesno("Proceed with these names?", default_yes=True): print("Aborted.") return 2 # ---- key type / encryption choices key_choice = prompt_choice( "Select key type:", ["RSA 4096 (common/compatible)", "ECDSA (best-practice default curve)"], default_index=1, ) key_algo = "RSA" if key_choice == 0 else "EC" keysize = 4096 if key_algo == "RSA" else None ec_curve = "P_256" if key_algo == "EC" else None encrypt_key = prompt_yesno("Encrypt the private key with a passphrase?", default_yes=True) # ---- credentials (keychain) password = get_password( service="netscaler-console", account=f"{args.user}@{args.console}", prompt=f"Console password for {args.user}@{args.console}: ", store_prompt=True, ) key_passphrase: str | None = None if encrypt_key: key_passphrase = get_password( service="cert-private-key", account=f"{args.app_name}", prompt="Key passphrase (will be stored in keychain if you choose): ", store_prompt=True, ) # ---- CSR subject prompts cn = input(f"CSR Common Name (CN): ").strip() or args.app_name o = input("Organization (O) [optional]: ").strip() ou = input("Org Unit (OU) [optional]: ").strip() l = input("City/Locality (L) [optional]: ").strip() st = input("State/Province (ST) [optional, spell out like 'Alabama' (not 'AL')]: ").strip() c = input("Country (C) 2-letter [optional]: ").strip() san_raw = input("SubjectAltName string (e.g. DNS:example.com,DNS:www.example.com) [optional]: ").strip() san_list = normalize_san_list(san_raw) subj = Subject(cn=cn, o=o or None, ou=ou or None, l=l or None, st=st or None, c=c or None) # ---- Console operations print("\n[console] Logging in to establish session (for download/upload authorization)...") client = ConsoleClient(base_url=args.console, username=args.user, password=password, verify_tls=not args.insecure) client.login() print("[console] Login OK (token acquired).") print("\n[console] Creating key on Console (never reuse keys)...") try: client.create_ssl_key( file_name=names.key_name, algo=key_algo, keyform="PEM", keysize=keysize, ec_curve=ec_curve, password=key_passphrase, # prefer AES if supported; Console may fall back internally cipher="AES256" if encrypt_key else None, ) except NitroError as e: # Common: name collision if rotate is off. raise print(f"[console] Key create accepted: {names.key_name}") print("\n[console] Downloading KEY via /nitro/v2/download ...") key_bytes = client.download_key(names.key_name) key_path = out_dir / names.key_name key_path.write_bytes(key_bytes) os.chmod(key_path, 0o600) print(f"[local] Wrote key: {key_path}") if args.write_latest: write_latest(out_dir, "latest.key", names.key_name) print(f"[local] Wrote latest.key -> {names.key_name}") # ---- CSR generation locally print("\n[local] Generating CSR with OpenSSL...") csr_pem = make_csr_openssl( key_pem_path=str(key_path), subject=subj, san_entries=san_list, key_passphrase=key_passphrase, ) csr_path = out_dir / names.csr_name csr_path.write_text(csr_pem, encoding="utf-8") os.chmod(csr_path, 0o644) print(f"[local] Wrote CSR: {csr_path}") if args.write_latest: write_latest(out_dir, "latest.csr", names.csr_name) print(f"[local] Wrote latest.csr -> {names.csr_name}") print("\nDone.") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except NitroError as e: print(str(e), file=sys.stderr) return_code = 1 raise SystemExit(return_code)