This commit is contained in:
deamonkai
2026-01-23 12:11:21 -06:00
commit fc94008530
16494 changed files with 2974672 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
"""certctl package
Lightweight package scaffold for Netscaler certificate tooling.
"""
__all__ = ["utils", "netscaler"]

View File

@@ -0,0 +1,42 @@
"""certctl - orchestration entrypoint.
Today:
certctl keycsr-console ... (create key on Console, download key, generate CSR)
This file exists so you can run `python -m certctl ...` and so `bin/certctl`
works without packaging.
"""
from __future__ import annotations
import argparse
import sys
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
p = argparse.ArgumentParser(prog="certctl", add_help=True)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("keycsr-console", help="Create key on Console, download it, then generate CSR locally")
sub.add_parser("poll", help="(placeholder) Poll cert expiry across Console/ADCs/WAF")
sub.add_parser("renew", help="(placeholder) Renew certs in renewal window")
ns, rest = p.parse_known_args(argv)
if ns.cmd == "keycsr-console":
from certctl.scripts.keycsr_console import main as _m
return _m(rest)
if ns.cmd in {"poll", "renew"}:
print(f"{ns.cmd}: not implemented yet (repo scaffold only)")
return 2
p.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())

13
legacy/certctl/ca/base.py Normal file
View File

@@ -0,0 +1,13 @@
"""Base CA adapter interface."""
from typing import Protocol, Optional
class CAAdapter(Protocol):
def submit_csr(self, csr_pem: str, name: str, options: Optional[dict] = None) -> str:
"""Submit a CSR and return a request id or token."""
def poll_status(self, request_id: str, timeout: int = 60) -> str:
"""Poll request status and return a final state string (e.g., 'issued' / 'pending' / 'failed')."""
def download_certificate(self, request_id: str) -> str:
"""Return the issued certificate PEM for the request_id."""

27
legacy/certctl/ca/mock.py Normal file
View File

@@ -0,0 +1,27 @@
"""A simple mock CA adapter for tests and local runs."""
from typing import Optional
from .base import CAAdapter
class MockCA(CAAdapter):
def __init__(self):
self._store = {}
self._counter = 0
def submit_csr(self, csr_pem: str, name: str, options: Optional[dict] = None) -> str:
self._counter += 1
rid = f"mock-{self._counter}"
# store and pretend it's issued immediately for simplicity
self._store[rid] = {
"csr": csr_pem,
"name": name,
"status": "issued",
"cert": f"-----BEGIN CERTIFICATE-----\nMockCertFor:{name}\n-----END CERTIFICATE-----\n",
}
return rid
def poll_status(self, request_id: str, timeout: int = 60) -> str:
return self._store.get(request_id, {}).get("status", "unknown")
def download_certificate(self, request_id: str) -> str:
return self._store.get(request_id, {}).get("cert")

View File

@@ -0,0 +1,35 @@
"""Sectigo CA adapter skeleton.
This file provides a class with the expected interface. Implementing a full
Sectigo integration requires API credentials and network access; this is a
skeleton with TODOs and a clear place to add HTTP calls.
"""
from typing import Optional
from .base import CAAdapter
class SectigoCA(CAAdapter):
def __init__(self, api_base: str = "https://api.sectigo.com", api_key: Optional[str] = None):
self.api_base = api_base
self.api_key = api_key
def submit_csr(self, csr_pem: str, name: str, options: Optional[dict] = None) -> str:
"""Submit CSR to Sectigo and return request id.
TODO: Implement actual HTTP POSTs with authentication and error handling.
"""
raise NotImplementedError("Sectigo submission not implemented yet")
def poll_status(self, request_id: str, timeout: int = 60) -> str:
"""Poll Sectigo for request status.
TODO: implement polling logic using Sectigo APIs.
"""
raise NotImplementedError("Sectigo polling not implemented yet")
def download_certificate(self, request_id: str) -> str:
"""Download issued certificate PEM.
TODO: fetch the issued certificate from Sectigo.
"""
raise NotImplementedError("Sectigo download not implemented yet")

231
legacy/certctl/cli.py Normal file
View File

@@ -0,0 +1,231 @@
"""Minimal CLI for certctl tooling.
Provides a `keygen` subcommand which wraps `certctl.keygen.generate_private_key`.
This is intentionally small and testable: `main(argv)` accepts an argv list.
"""
from __future__ import annotations
import argparse
import sys
from typing import List, Optional
from . import keygen
def _build_subj_from_dict(subject: dict) -> str:
parts = []
for k in ["C", "ST", "L", "O", "OU", "CN"]:
v = subject.get(k)
if v:
parts.append(f"/{k}={v}")
return "".join(parts)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="certctl", description="certctl control utility")
sub = p.add_subparsers(dest="command")
keygen_p = sub.add_parser("keygen", help="Generate a private key")
keygen_p.add_argument("--kind", choices=["rsa", "ec"], default="rsa")
keygen_p.add_argument("--bits", type=int, default=4096, help="RSA key bits")
keygen_p.add_argument("--curve", default="prime256v1", help="EC curve")
keygen_p.add_argument("--passphrase", help="Passphrase to encrypt key (insecure on CLI)")
keygen_p.add_argument("--keychain-service", help="Keychain service name to read/write passphrase")
keygen_p.add_argument("--save-passphrase", action="store_true", help="Save provided passphrase into Keychain")
keygen_p.add_argument("--keychain-account", default="certctl", help="Keychain account name when saving passphrase")
keygen_p.add_argument("--out", help="Write key to file instead of stdout")
# csr subcommands
csr_p = sub.add_parser("csr", help="Create and inspect CSRs")
csr_sub = csr_p.add_subparsers(dest="csr_cmd")
csr_create = csr_sub.add_parser("create", help="Create a CSR from a private key file")
csr_create.add_argument("--key-file", required=True, help="Path to the private key PEM")
csr_create.add_argument("--subject", help="Subject string in /C=.../ST=.../CN=... format or as key=value pairs like C=US,ST=CA,CN=example.com")
csr_create.add_argument("--san", action="append", help="SubjectAltName entry (DNS or IP); can be given multiple times")
csr_create.add_argument("--allow-wildcard", action="store_true", help="Allow wildcard SANs without interactive confirmation. Use with caution: wildcards broaden certificate scope; in non-interactive contexts this flag bypasses interactive confirmation.")
csr_create.add_argument("--from-cert", help="Path to an existing certificate to populate subject and SANs from")
csr_create.add_argument("--passphrase", help="Passphrase for encrypted key (insecure on CLI)")
csr_create.add_argument("--out", help="Write CSR to file instead of stdout")
csr_show = csr_sub.add_parser("show", help="Show CSR details (like SANs)")
csr_show.add_argument("--csr-file", required=True, help="Path to CSR PEM file")
csr_submit = csr_sub.add_parser("submit", help="Submit a CSR to a CA adapter")
csr_submit.add_argument("--csr-file", required=True, help="CSR PEM file to submit")
csr_submit.add_argument("--ca", choices=["mock", "sectigo"], default="mock", help="CA adapter to use")
csr_submit.add_argument("--name", required=True, help="Name for the cert request")
csr_submit.add_argument("--wait", action="store_true", help="Wait for issuance and download cert")
csr_submit.add_argument("--out", help="Write downloaded cert to file")
csr_submit.add_argument("--allow-wildcard", action="store_true", help="Allow submission of CSRs containing wildcard SANs. Use with caution: wildcards broaden certificate scope and may be unsafe.")
return p
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "keygen":
if args.kind == "rsa":
pem = keygen.generate_rsa_key(bits=args.bits, passphrase=args.passphrase, keychain_service=args.keychain_service, save_to_keychain=args.save_passphrase, keychain_account=args.keychain_account)
else:
pem = keygen.generate_ec_key(curve=args.curve, passphrase=args.passphrase, keychain_service=args.keychain_service, save_to_keychain=args.save_passphrase, keychain_account=args.keychain_account)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(pem)
else:
sys.stdout.write(pem)
return 0
if args.command == "csr":
if args.csr_cmd == "create":
# Parse subject: accept both /C=... style or comma separated key=value
subject = {}
subj = args.subject or ""
if subj.startswith("/"):
# convert /C=US/ST=... into dict
for part in subj.split("/"):
if not part:
continue
if "=" in part:
k, v = part.split("=", 1)
subject[k] = v
elif subj:
for part in subj.split(","):
if "=" in part:
k, v = part.split("=", 1)
subject[k.strip()] = v.strip()
csr_pem = None
from . import csr as csrmod
if args.from_cert:
# Populate subject and SANs from an existing cert
csr_pem = csrmod.create_csr_from_cert(args.from_cert, args.key_file, passphrase=args.passphrase)
else:
subj_str = subj if subj and subj.startswith("/") else _build_subj_from_dict(subject) if subject else None
# If no subject and no SANs provided, prompt interactively
if not subj_str and not args.san:
subj_dict, san_list = csrmod.prompt_for_subject_and_sans()
subj_str = _build_subj_from_dict(subj_dict) if subj_dict else None
csr_pem = csrmod.create_csr_from_key(args.key_file, subj_str, sans=san_list, passphrase=args.passphrase)
else:
# Validate SANs supplied on CLI
norm_sans = []
if args.san:
wildcard_sans = []
try:
for s in args.san:
ns = csrmod.normalize_and_validate_san(s)
norm_sans.append(ns)
if ns.startswith("DNS:*.") or "*" in ns:
wildcard_sans.append(ns)
except ValueError as e:
from .term import print_error
print_error(f"Invalid SAN provided: {e}")
return 1
# Handle wildcard SANs: interactive confirmation or explicit allow
if wildcard_sans:
if args.allow_wildcard:
# Use formatted warning helper for reusable behavior
from .term import print_warning
print_warning("WARNING: --allow-wildcard used. Wildcard SANs broaden certificate scope and can be risky.")
else:
# If interactive terminal, confirm each wildcard SAN
try:
is_tty = sys.stdin.isatty()
except Exception:
is_tty = False
if not is_tty:
from .term import print_error
print_error("Wildcard SAN detected in CLI arguments; rerun with --allow-wildcard to allow it in non-interactive mode.")
return 1
# interactive confirm
for w in list(wildcard_sans):
ans = input(f"Wildcard SAN detected: {w}. Type 'yes' to include it, or anything else to exclude: ").strip().lower()
if ans != "yes":
# remove from normalized list
norm_sans = [x for x in norm_sans if x != w]
from .term import print_info
print_info(f"Excluded {w}")
# it as None so OpenSSL will not add subjectAltName.
san_to_use = norm_sans if args.san else None
csr_pem = csrmod.create_csr_from_key(args.key_file, subj_str, sans=san_to_use, passphrase=args.passphrase)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(csr_pem)
else:
sys.stdout.write(csr_pem)
return 0
if args.csr_cmd == "show":
with open(args.csr_file, "r", encoding="utf-8") as f:
csr_pem = f.read()
from . import csr as csrmod
# Rudimentary output: show the CSR text parsed by openssl
import subprocess
openssl = csrmod._openssl_bin()
p = subprocess.run([openssl, "req", "-in", "/dev/stdin", "-noout", "-text"], input=csr_pem.encode("utf-8"), check=True, capture_output=True)
sys.stdout.write(p.stdout.decode("utf-8"))
return 0
if args.csr_cmd == "submit":
with open(args.csr_file, "r", encoding="utf-8") as f:
csr_pem = f.read()
# validate SANs if present and wildcard rules
from . import csr as csrmod
# parse SANs in the CSR via openssl text
import subprocess
openssl = csrmod._openssl_bin()
p = subprocess.run([openssl, "req", "-in", "/dev/stdin", "-noout", "-text"], input=csr_pem.encode("utf-8"), check=True, capture_output=True)
txt = p.stdout.decode("utf-8")
import re
sans = [f"DNS:{m.group(1)}" for m in re.finditer(r"DNS:([^,\s]+)", txt)]
for m in re.finditer(r"IP:?\s*Address:?\s*([^,\s]+)", txt, flags=re.IGNORECASE):
sans.append(f"IP:{m.group(1)}")
# wildcard checks
if any(s.startswith("DNS:*." ) or "*" in s for s in sans) and not args.allow_wildcard:
from .term import print_error
print_error("Wildcard SAN detected in CSR; pass --allow-wildcard to confirm you want to submit it.")
return 1
# dispatch to adapter
from .ca.mock import MockCA
from .ca.sectigo import SectigoCA
if args.ca == "mock":
adapter = MockCA()
else:
adapter = SectigoCA()
req_id = adapter.submit_csr(csr_pem, args.name)
from .term import print_info
print_info(f"Submitted CSR, request id: {req_id}")
if args.wait:
status = adapter.poll_status(req_id, timeout=300)
from .term import print_info
print_info(f"Final status: {status}")
if status == "issued":
cert = adapter.download_certificate(req_id)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(cert)
else:
sys.stdout.write(cert)
else:
from .term import print_error
print_error(f"Request ended with status: {status}")
return 0
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())

200
legacy/certctl/console.py Normal file
View File

@@ -0,0 +1,200 @@
"""NetScaler Console (ADM/Console) NITRO helpers.
Design goals:
- Log in ONCE to obtain a session token ("sessionid" in the login response).
- Use that token on subsequent requests (Cookie: NITRO_AUTH_TOKEN=...).
- Keep credentials out of git: caller can provide password via keychain/env.
Notes:
- In some Console builds, download endpoints authorize *only* via the cookie,
even if Basic auth / X-NITRO headers are supplied.
- Requests' cookie jar can contain multiple cookies with the same name but
different paths, leading to CookieConflictError. To avoid that entire class
of issues, we do not rely on the cookie jar; we store the token ourselves
and set the Cookie header explicitly.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, Optional
import requests
class NitroError(RuntimeError):
def __init__(self, status: int, message: str, payload: Any | None = None, headers: Dict[str, str] | None = None):
super().__init__(f"NITRO error (HTTP {status}): {message}")
self.status = status
self.message = message
self.payload = payload
self.headers = headers or {}
@dataclass
class ConsoleSession:
base_url: str
username: str
password: str
insecure: bool = False
timeout: int = 30
_token: Optional[str] = None
_http: Optional[requests.Session] = None
def _sess(self) -> requests.Session:
if self._http is None:
self._http = requests.Session()
return self._http
@property
def token(self) -> str:
if not self._token:
raise RuntimeError("Not logged in yet")
return self._token
def login(self) -> str:
"""Login and return the session token (NITRO_AUTH_TOKEN value)."""
url = f"{self.base_url.rstrip('/')}/nitro/v2/config/login"
payload = {"login": {"username": self.username, "password": self.password}}
r = requests.post(
url,
json=payload,
auth=(self.username, self.password),
verify=not self.insecure,
timeout=self.timeout,
)
try:
j = r.json()
except Exception:
raise NitroError(r.status_code, r.text)
if r.status_code >= 400 or (isinstance(j, dict) and j.get("errorcode") not in (None, 0)):
msg = j.get("message") if isinstance(j, dict) else str(j)
raise NitroError(r.status_code, msg or "Login failed", j)
try:
token = j["login"][0]["sessionid"]
except Exception:
raise NitroError(r.status_code, "Login response missing sessionid", j)
self._token = token
return token
# ---------------------- low-level HTTP helpers ----------------------
def _headers(self, extra: Optional[Dict[str, str]] = None, include_auth_headers: bool = True) -> Dict[str, str]:
h: Dict[str, str] = {}
if self._token:
h["Cookie"] = f"NITRO_AUTH_TOKEN={self._token}"
if include_auth_headers:
h["X-NITRO-USER"] = self.username
h["X-NITRO-PASS"] = self.password
if extra:
h.update(extra)
return h
def request(self, method: str, path: str, *, params: Dict[str, str] | None = None,
json_body: Any | None = None, data: Any | None = None,
headers: Dict[str, str] | None = None,
include_auth_headers: bool = True,
stream: bool = False) -> requests.Response:
if not self._token:
self.login()
url = f"{self.base_url.rstrip('/')}{path}"
r = self._sess().request(
method,
url,
params=params,
json=json_body,
data=data,
headers=self._headers(headers, include_auth_headers=include_auth_headers),
verify=not self.insecure,
timeout=self.timeout,
stream=stream,
)
return r
def _raise_for_nitro(self, r: requests.Response) -> None:
if r.status_code < 400:
return
# try to decode JSON error payload
msg = r.text
payload: Any = None
try:
payload = r.json()
if isinstance(payload, dict) and "message" in payload:
msg = payload.get("message") or msg
except Exception:
payload = None
raise NitroError(r.status_code, msg, payload, headers=dict(r.headers))
# ---------------------- Console operations we need ----------------------
def create_ssl_key(self, *, file_name: str, algo: str, keysize: int | None = None,
ec_curve: str | None = None, keyform: str = "PEM",
encrypt: bool = False, passphrase: str | None = None,
des_type: str = "AES256") -> Dict[str, Any]:
"""Create a private key file stored on Console.
This maps to the ns_ssl_key resource in Console NITRO v2.
We send form-urlencoded with an `object=` payload because Console
rejects raw JSON for some POSTs.
"""
obj: Dict[str, Any] = {
"file_name": file_name,
"keyform": keyform,
"algo": algo,
}
if keysize is not None:
obj["keysize"] = str(keysize)
if ec_curve:
obj["ec_curve"] = ec_curve
if encrypt:
# Console uses des_type naming even for AES in some builds
obj["des_type"] = des_type
if passphrase:
obj["passphrase"] = passphrase
payload = {"ns_ssl_key": obj}
data = {"object": json.dumps(payload)}
r = self.request(
"POST",
"/nitro/v2/config/ns_ssl_key",
params={"action": "create"},
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
self._raise_for_nitro(r)
return r.json()
def download_file(self, resource: str, file_name: str) -> bytes:
"""Download a stored file via /nitro/v2/download/<resource>/<file_name>."""
# For download endpoints, some builds want ONLY the cookie.
r = self.request(
"GET",
f"/nitro/v2/download/{resource}/{file_name}",
include_auth_headers=False,
stream=True,
)
self._raise_for_nitro(r)
return r.content
def list_ssl_certkeys(self, *, attrs: str | None = None) -> Dict[str, Any]:
"""List certificate-key objects stored on Console.
This uses the Console NITRO resource `ns_ssl_certkey`.
The response includes expiry-related fields such as `valid_to`
and `days_to_expiry` (stringified).
"""
params: Dict[str, str] = {}
if attrs:
params["attrs"] = attrs
r = self.request("GET", "/nitro/v2/config/ns_ssl_certkey", params=params)
self._raise_for_nitro(r)
return r.json()

116
legacy/certctl/csr.py Normal file
View File

@@ -0,0 +1,116 @@
"""CSR generation helpers.
We intentionally generate CSRs *locally* (OpenSSL) rather than via Console
because "ns_ssl_csr" is not supported in some Console deployments.
All outputs are PEM.
"""
from __future__ import annotations
import ipaddress
import re
import subprocess
from dataclasses import dataclass
from typing import Iterable, List, Optional
@dataclass
class Subject:
cn: str
o: str = ""
ou: str = ""
l: str = ""
st: str = ""
c: str = ""
def to_openssl_subj(self) -> str:
# Keep ordering stable; omit empty parts.
parts = [("CN", self.cn), ("O", self.o), ("OU", self.ou), ("L", self.l), ("ST", self.st), ("C", self.c)]
s = ""
for k, v in parts:
if v:
# OpenSSL expects slashes; escape slashes in values.
v = v.replace("/", "\\/")
s += f"/{k}={v}"
return s or f"/CN={self.cn}"
_SAN_SPLIT = re.compile(r"\s*,\s*|")
def parse_san_entries(raw: str) -> List[str]:
"""Parse a SAN string into OpenSSL-compatible entries.
Accepts input like:
- "DNS:example.com,DNS:www.example.com,IP:10.0.0.1"
- "example.com, www.example.com" (bare names become DNS:...)
Returns entries like ["DNS:example.com", "DNS:www.example.com", "IP:10.0.0.1"].
Raises ValueError if any entry is malformed.
"""
if not raw:
return []
# Split on commas; tolerate accidental whitespace.
items = [x.strip() for x in raw.split(",") if x.strip()]
out: List[str] = []
for item in items:
if ":" not in item:
# Bare value: infer DNS unless it's an IP.
try:
ipaddress.ip_address(item)
out.append(f"IP:{item}")
continue
except ValueError:
out.append(f"DNS:{item}")
continue
prefix, value = item.split(":", 1)
prefix_u = prefix.strip().upper()
value = value.strip()
if prefix_u in {"DNS", "IP"}:
if not value:
raise ValueError(f"Empty SAN value in '{item}'")
if prefix_u == "IP":
try:
ipaddress.ip_address(value)
except ValueError:
raise ValueError(f"Invalid IP SAN: '{item}'")
out.append(f"{prefix_u}:{value}")
else:
raise ValueError(
f"Unsupported SAN type '{prefix}' in '{item}'. Use DNS: or IP: (or bare hostnames)."
)
return out
def make_csr_openssl(
*,
key_pem_path: str,
subject: Subject,
san_entries: Iterable[str] | None = None,
key_passphrase: Optional[str] = None,
) -> str:
"""Generate a PEM CSR using openssl.
We prefer OpenSSL's `-addext` to avoid version-sensitive config quirks.
"""
cmd = ["openssl", "req", "-new", "-key", key_pem_path, "-subj", subject.to_openssl_subj()]
if key_passphrase:
cmd += ["-passin", f"pass:{key_passphrase}"]
san_entries = list(san_entries or [])
if san_entries:
san_value = ",".join(san_entries)
cmd += ["-addext", f"subjectAltName={san_value}"]
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
if p.returncode != 0:
raise RuntimeError(
"OpenSSL CSR generation failed:\n" + err.decode(errors="ignore")
)
return out.decode("utf-8", errors="ignore")

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import os
import time
from dataclasses import dataclass
@dataclass
class FileLock:
"""Cross-platform advisory file lock.
- POSIX: fcntl.flock
- Windows: msvcrt.locking
Best-effort: intended for a single machine on a local filesystem.
"""
path: str
timeout_seconds: float = 0.0
poll_interval_seconds: float = 0.1
def __post_init__(self) -> None:
self._fh = None
def acquire(self) -> None:
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or '.', exist_ok=True)
fh = open(self.path, 'a+b')
start = time.time()
while True:
try:
if os.name == 'nt':
import msvcrt
# lock 1 byte; non-blocking attempt
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
self._fh = fh
return
except Exception:
if self.timeout_seconds and (time.time() - start) >= self.timeout_seconds:
fh.close()
raise TimeoutError(f"Timed out waiting for lock: {self.path}")
time.sleep(self.poll_interval_seconds)
def release(self) -> None:
if not self._fh:
return
try:
if os.name == 'nt':
import msvcrt
self._fh.seek(0)
msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
finally:
self._fh.close()
self._fh = None
def __enter__(self) -> "FileLock":
self.acquire()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.release()

120
legacy/certctl/keygen.py Normal file
View File

@@ -0,0 +1,120 @@
"""Key generation helpers using the OpenSSL CLI.
This module intentionally uses the system `openssl` command for portability on
macOS systems where the `cryptography` library may not be available by
default.
Functions return the PEM text of the generated private key.
"""
from typing import Optional
import shutil
import subprocess
class OpenSSLNotFound(Exception):
pass
def _openssl_bin() -> str:
path = shutil.which("openssl")
if not path:
raise OpenSSLNotFound("`openssl` not found in PATH")
return path
def generate_rsa_key(bits: int = 4096, passphrase: Optional[str] = None, cipher: Optional[str] = "des3", keychain_service: Optional[str] = None, save_to_keychain: bool = False, keychain_account: str = "certctl") -> str:
"""Generate an RSA private key in PEM format.
If `passphrase` is provided or available in `keychain_service`, the PEM
will be encrypted with the provided `cipher` (defaults to 3DES via
OpenSSL's `des3` v2 option).
If `save_to_keychain` is True and `keychain_service` is provided and a
passphrase was supplied by the caller, the passphrase will be saved into
the macOS Keychain using `certctl.storage.keychain_set`.
Returns PEM text.
"""
# Optionally save provided passphrase to keychain
if passphrase and keychain_service and save_to_keychain:
try:
from . import storage
storage.keychain_set(keychain_service, passphrase, account=keychain_account)
except Exception:
# Best-effort only; do not fail the key generation on keychain errors
pass
# Resolve passphrase via keychain if requested and not provided
if not passphrase and keychain_service:
try:
from . import storage
passphrase = storage.keychain_get(keychain_service)
except Exception:
pass
openssl = _openssl_bin()
# Generate an unencrypted private key into stdout using genpkey
gen_cmd = [openssl, "genpkey", "-algorithm", "RSA", "-pkeyopt", f"rsa_keygen_bits:{bits}", "-outform", "PEM"]
gen = subprocess.run(gen_cmd, check=True, capture_output=True)
private_pem = gen.stdout
if passphrase:
# Convert to PKCS#8 and encrypt
# Use -v2 <cipher> for modern encryption (des3 corresponds to 3DES)
topk8_cmd = [openssl, "pkcs8", "-topk8", "-v2", cipher, "-passout", f"pass:{passphrase}", "-outform", "PEM"]
topk8 = subprocess.run(topk8_cmd, input=private_pem, check=True, capture_output=True)
return topk8.stdout.decode("utf-8")
return private_pem.decode("utf-8")
def generate_ec_key(curve: str = "prime256v1", passphrase: Optional[str] = None, cipher: Optional[str] = "des3", keychain_service: Optional[str] = None, save_to_keychain: bool = False, keychain_account: str = "certctl") -> str:
"""Generate an EC private key (PEM) for given curve name (e.g., prime256v1 / secp384r1).
If `passphrase` is provided or available in `keychain_service`, the PEM
will be encrypted using PKCS#8 v2.
If `save_to_keychain` is True and `keychain_service` is provided and a
passphrase was supplied by the caller, the passphrase will be saved into
the macOS Keychain using `certctl.storage.keychain_set`.
"""
# Optionally save provided passphrase to keychain
if passphrase and keychain_service and save_to_keychain:
try:
from . import storage
storage.keychain_set(keychain_service, passphrase, account=keychain_account)
except Exception:
pass
# Resolve passphrase via keychain if requested and not provided
if not passphrase and keychain_service:
try:
from . import storage
passphrase = storage.keychain_get(keychain_service)
except Exception:
pass
openssl = _openssl_bin()
gen_cmd = [openssl, "ecparam", "-name", curve, "-genkey", "-noout", "-outform", "PEM"]
gen = subprocess.run(gen_cmd, check=True, capture_output=True)
private_pem = gen.stdout
if passphrase:
topk8_cmd = [openssl, "pkcs8", "-topk8", "-v2", cipher, "-passout", f"pass:{passphrase}", "-outform", "PEM"]
topk8 = subprocess.run(topk8_cmd, input=private_pem, check=True, capture_output=True)
return topk8.stdout.decode("utf-8")
return private_pem.decode("utf-8")
def generate_private_key(kind: str = "rsa", **kwargs) -> str:
"""Generic helper to generate a private key of a given `kind` ('rsa' or 'ec')."""
kind = kind.lower()
if kind == "rsa":
return generate_rsa_key(**kwargs)
if kind == "ec":
return generate_ec_key(**kwargs)
raise ValueError("Unsupported key kind: use 'rsa' or 'ec'")

View File

@@ -0,0 +1,32 @@
"""Minimal Netscaler Console helpers.
This module provides a thin wrapper around the extractor and a clear place to
add Console-specific parsing and upload helpers later.
"""
from typing import Optional
from .utils import extract_pem_from_json
def extract_csr_text(response_json: dict) -> Optional[str]:
"""Return CSR PEM text from a Console/device response JSON.
Prioritize the Console `ns_ssl_csr` response shape, then fall back to a
recursive search for the PEM block.
"""
if not isinstance(response_json, dict):
return extract_pem_from_json(response_json)
# Prefer well-known ns_ssl_csr array
ns_ssl_csr = response_json.get("ns_ssl_csr")
if isinstance(ns_ssl_csr, list):
for entry in ns_ssl_csr:
if isinstance(entry, dict):
csr = entry.get("csr")
if csr:
found = extract_pem_from_json(csr)
if found:
return found
# Fallback general search
return extract_pem_from_json(response_json)

185
legacy/certctl/poll.py Normal file
View File

@@ -0,0 +1,185 @@
from __future__ import annotations
import csv
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List
from .console import ConsoleSession
def _parse_valid_to(s: str) -> datetime | None:
"""Parse a `valid_to` string returned by Console.
Observed formats include:
- "Jan 09 2026 04:09:55"
- "2026-01-09 04:09:55"
Returns a timezone-aware datetime (UTC) if parsing succeeds.
"""
if not s:
return None
s = s.strip()
fmts = [
"%b %d %Y %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%b %d %Y",
"%Y-%m-%d",
]
for fmt in fmts:
try:
dt = datetime.strptime(s, fmt)
return dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
return None
@dataclass
class CertKeyRow:
file_name: str
display_name: str
days_to_expiry: int | None
valid_to: str
no_of_bound_entities: int | None
def poll_console_certkeys(
session: ConsoleSession,
*,
window_days: int = 30,
) -> Dict[str, Any]:
"""Fetch cert inventory and produce filtered views.
Returns a dict with:
- all: list[CertKeyRow]
- expired: list[CertKeyRow]
- expiring: list[CertKeyRow] (0 < days <= window_days)
- raw: raw NITRO JSON response
"""
attrs = ",".join(
[
"file_name",
"display_name",
"valid_to",
"days_to_expiry",
"no_of_bound_entities",
]
)
raw = session.list_ssl_certkeys(attrs=attrs)
items = raw.get("ns_ssl_certkey") or raw.get("ns_ssl_certkey_response") or []
rows: List[CertKeyRow] = []
for it in items:
file_name = str(it.get("file_name", "") or "")
display_name = str(it.get("display_name", "") or "")
valid_to = str(it.get("valid_to", "") or "")
dte_raw = it.get("days_to_expiry")
days_to_expiry: int | None = None
if dte_raw is not None and str(dte_raw).strip() != "":
try:
days_to_expiry = int(str(dte_raw))
except ValueError:
days_to_expiry = None
bound_raw = it.get("no_of_bound_entities")
bound: int | None = None
if bound_raw is not None and str(bound_raw).strip() != "":
try:
bound = int(str(bound_raw))
except ValueError:
bound = None
rows.append(
CertKeyRow(
file_name=file_name,
display_name=display_name,
days_to_expiry=days_to_expiry,
valid_to=valid_to,
no_of_bound_entities=bound,
)
)
def key_sort(r: CertKeyRow):
if r.days_to_expiry is None:
dt = _parse_valid_to(r.valid_to)
if dt:
delta = dt - datetime.now(timezone.utc)
return int(delta.total_seconds())
return 10**9
return r.days_to_expiry * 24 * 3600
rows_sorted = sorted(rows, key=key_sort)
expired = [r for r in rows_sorted if r.days_to_expiry is not None and r.days_to_expiry <= 0]
expiring = [
r
for r in rows_sorted
if r.days_to_expiry is not None and 0 < r.days_to_expiry <= window_days
]
return {"all": rows_sorted, "expired": expired, "expiring": expiring, "raw": raw}
def write_report(
report: Dict[str, Any],
*,
fmt: str = "table",
out_path: str | None = None,
include_all: bool = False,
) -> str:
"""Write report to a file (or return as a string for stdout)."""
rows: List[CertKeyRow] = report["all"] if include_all else (report["expired"] + report["expiring"])
if fmt == "json":
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"expired": [r.__dict__ for r in report["expired"]],
"expiring": [r.__dict__ for r in report["expiring"]],
}
s = json.dumps(payload, indent=2)
elif fmt == "csv":
import io
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["file_name", "display_name", "days_to_expiry", "valid_to", "no_of_bound_entities"])
for r in rows:
w.writerow([r.file_name, r.display_name, r.days_to_expiry, r.valid_to, r.no_of_bound_entities])
s = buf.getvalue()
else:
# simple fixed-width table
headers = ["File", "Display", "Days", "Valid To", "Bound"]
data = [
[
r.file_name,
r.display_name,
"" if r.days_to_expiry is None else str(r.days_to_expiry),
r.valid_to,
"" if r.no_of_bound_entities is None else str(r.no_of_bound_entities),
]
for r in rows
]
cols = list(zip(headers, *data)) if data else [(h,) for h in headers]
widths = [min(60, max(len(str(x)) for x in col)) for col in cols]
def fmt_row(vals: List[str]) -> str:
return " ".join(str(v)[:w].ljust(w) for v, w in zip(vals, widths))
lines = [fmt_row(headers), fmt_row(["-" * w for w in widths])]
for row in data:
lines.append(fmt_row(row))
s = "\n".join(lines)
if out_path:
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write(s)
return s

52
legacy/certctl/san.py Normal file
View File

@@ -0,0 +1,52 @@
"""SAN parsing/normalization.
Accepts inputs like:
- "DNS:example.com,DNS:www.example.com"
- "example.com, www.example.com" (bare hostnames treated as DNS)
- "IP:10.0.0.1, 10.0.0.2" (bare IPs treated as IP)
Returns a list suitable for OpenSSL: ["DNS:...", "IP:...", ...].
"""
from __future__ import annotations
import ipaddress
from typing import List
def normalize_san_list(san_raw: str) -> List[str]:
if not san_raw:
return []
items = [x.strip() for x in san_raw.split(",") if x.strip()]
out: List[str] = []
for item in items:
if ":" in item:
k, v = item.split(":", 1)
k = k.strip().upper()
v = v.strip()
if not v:
continue
if k in {"DNS", "IP", "URI", "EMAIL", "RID"}:
out.append(f"{k}:{v}")
continue
# Unknown prefix: treat whole thing as DNS if it isn't an IP
item = v
# Bare value: detect IP vs DNS
try:
ipaddress.ip_address(item)
out.append(f"IP:{item}")
except ValueError:
out.append(f"DNS:{item}")
# de-dupe but preserve order
seen = set()
deduped: List[str] = []
for x in out:
k = x.upper()
if k not in seen:
seen.add(k)
deduped.append(x)
return deduped

View File

View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
cert_poll.py
Poll NetScaler Console for certificate inventory + expiry dates and generate a report.
Goals:
- Cross-platform (macOS/Windows/Linux)
- No external dependencies beyond requests (and optionally keyring)
- Uses NITRO v2 login once -> session token -> Cookie: NITRO_AUTH_TOKEN=...
- Produces JSON + Markdown report
- File lock to prevent concurrent runs on same machine
NOTE:
- NetScaler Console NITRO resources vary by build/license.
- This script tries a few known endpoints and will fall back gracefully.
"""
import argparse
import datetime as dt
import getpass
import json
import os
import platform
import re
import socket
import sys
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
# -----------------------------
# Utilities
# -----------------------------
class NitroError(RuntimeError):
def __init__(self, status_code: int, message: str, payload: Optional[dict] = None):
super().__init__(f"NITRO error (HTTP {status_code}): {message}")
self.status_code = status_code
self.message = message
self.payload = payload or {}
def _now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def _ts_local_compact() -> str:
# Example: 20260109-231530
return dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def _safe_mkdir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def _write_text(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def _write_bytes(path: Path, content: bytes) -> None:
path.write_bytes(content)
def _iso(dt_obj: Optional[dt.datetime]) -> Optional[str]:
if not dt_obj:
return None
return dt_obj.astimezone(dt.timezone.utc).isoformat()
def _parse_datetime_any(s: str) -> Optional[dt.datetime]:
"""
Attempts to parse common date formats seen in Console inventory.
Examples observed:
- "2026-01-09 04:09:55"
- "2026-01-09T04:09:55Z"
- epoch seconds as string
"""
if not s:
return None
s = str(s).strip()
if not s:
return None
# epoch seconds
if re.fullmatch(r"\d{9,12}", s):
try:
return dt.datetime.fromtimestamp(int(s), tz=dt.timezone.utc)
except Exception:
return None
# "YYYY-MM-DD HH:MM:SS"
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
try:
return dt.datetime.strptime(s, fmt).replace(tzinfo=dt.timezone.utc)
except Exception:
pass
# ISO-ish
try:
# normalize trailing Z
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return dt.datetime.fromisoformat(s)
except Exception:
return None
# -----------------------------
# Cross-platform file lock
# -----------------------------
class FileLock:
"""
Simple cross-platform lock using atomic directory creation.
This avoids platform-specific fcntl/msvcrt issues and works well on local filesystems.
Lock is represented by a directory, e.g.:
/tmp/certctl.lock/
We also write metadata inside for debugging.
"""
def __init__(self, lock_dir: Path, stale_seconds: int = 3600):
self.lock_dir = lock_dir
self.stale_seconds = stale_seconds
def acquire(self, wait_seconds: int = 0) -> None:
start = time.time()
while True:
try:
self.lock_dir.mkdir(parents=True, exist_ok=False)
# write metadata
meta = {
"pid": os.getpid(),
"host": socket.gethostname(),
"user": getpass.getuser(),
"platform": platform.platform(),
"created": _now_utc().isoformat(),
}
_write_text(self.lock_dir / "meta.json", json.dumps(meta, indent=2))
return
except FileExistsError:
# check staleness
meta_path = self.lock_dir / "meta.json"
if meta_path.exists():
try:
m = json.loads(meta_path.read_text(encoding="utf-8"))
created = _parse_datetime_any(m.get("created", ""))
if created:
age = (_now_utc() - created.astimezone(dt.timezone.utc)).total_seconds()
if age > self.stale_seconds:
# stale lock -> remove
self.release(force=True)
continue
except Exception:
pass
if wait_seconds <= 0:
raise RuntimeError(f"Lock already held: {self.lock_dir}")
if time.time() - start > wait_seconds:
raise RuntimeError(f"Timed out waiting for lock: {self.lock_dir}")
time.sleep(0.25)
def release(self, force: bool = False) -> None:
if not self.lock_dir.exists():
return
# best effort cleanup
try:
for child in self.lock_dir.iterdir():
try:
child.unlink()
except Exception:
pass
self.lock_dir.rmdir()
except Exception:
if not force:
raise
# -----------------------------
# NetScaler Console NITRO v2 client
# -----------------------------
class ConsoleClient:
def __init__(self, console_url: str, insecure: bool = False, timeout: int = 30):
self.console_url = console_url.rstrip("/")
self.insecure = insecure
self.timeout = timeout
self.session = requests.Session()
self.token: Optional[str] = None
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return self.console_url + path
def login(self, username: str, password: str) -> str:
"""
POST /nitro/v2/config/login
Response includes login[0].sessionid, which must be used as NITRO_AUTH_TOKEN cookie.
"""
payload = {"login": {"username": username, "password": password}}
r = self.session.post(
self._url("/nitro/v2/config/login"),
json=payload,
verify=not self.insecure,
timeout=self.timeout,
)
j = self._json_or_raise(r)
try:
token = j["login"][0]["sessionid"]
except Exception:
raise NitroError(r.status_code, "Login response missing sessionid", j)
self.token = token
return token
def _headers(self) -> Dict[str, str]:
h: Dict[str, str] = {}
if self.token:
h["Cookie"] = f"NITRO_AUTH_TOKEN={self.token}"
return h
def get(self, path: str, params: Optional[dict] = None) -> dict:
r = self.session.get(
self._url(path),
headers=self._headers(),
params=params or {},
verify=not self.insecure,
timeout=self.timeout,
)
return self._json_or_raise(r)
def _json_or_raise(self, r: requests.Response) -> dict:
try:
j = r.json()
except Exception:
raise NitroError(r.status_code, f"Non-JSON response: {r.text[:200]}")
# Console often returns 200 with errorcode != 0
if r.status_code >= 400:
msg = j.get("message") or r.reason or "HTTP error"
raise NitroError(r.status_code, msg, j)
if isinstance(j, dict) and "errorcode" in j and j.get("errorcode") not in (0, "0", None):
msg = j.get("message") or "NITRO error"
raise NitroError(r.status_code, msg, j)
return j
# -----------------------------
# Report model
# -----------------------------
@dataclass
class CertRecord:
name: str
subject: Optional[str]
issuer: Optional[str]
not_after: Optional[str]
days_remaining: Optional[int]
source: str # "console"
raw: Dict[str, Any]
def _days_remaining(not_after_dt: Optional[dt.datetime]) -> Optional[int]:
if not not_after_dt:
return None
delta = not_after_dt.astimezone(dt.timezone.utc) - _now_utc()
return int(delta.total_seconds() // 86400)
# -----------------------------
# Inventory fetch logic
# -----------------------------
def fetch_console_certs(client: ConsoleClient) -> List[CertRecord]:
"""
Tries multiple known endpoints and normalizes results.
Console may expose:
- /nitro/v1/config/ssl_cert
- /nitro/v1/config/ns_ssl_cert
- /nitro/v2/config/ssl_cert
- /nitro/v2/config/ns_ssl_cert
- /nitro/v1/config/ssl_certificate (analytics type) [not the same thing]
We'll try v2 first, then v1.
"""
candidates = [
("/nitro/v2/config/ns_ssl_cert?attrs=*", "ns_ssl_cert"),
("/nitro/v2/config/ssl_cert?attrs=*", "ssl_cert"),
("/nitro/v1/config/ns_ssl_cert?attrs=*", "ns_ssl_cert"),
("/nitro/v1/config/ssl_cert?attrs=*", "ssl_cert"),
]
last_err: Optional[Exception] = None
for path, key in candidates:
try:
j = client.get(path)
objs = j.get(key, [])
if not isinstance(objs, list):
continue
out: List[CertRecord] = []
for o in objs:
if not isinstance(o, dict):
continue
# Try common fields
name = (
o.get("file_name")
or o.get("certkey")
or o.get("name")
or o.get("certificate")
or "UNKNOWN"
)
subject = o.get("subject") or o.get("cert_subject") or o.get("certsubject")
issuer = o.get("issuer") or o.get("cert_issuer") or o.get("certissuer")
# expiry fields vary wildly
not_after = (
o.get("notafter")
or o.get("not_after")
or o.get("valid_to")
or o.get("expiry_date")
or o.get("cert_expiry")
or o.get("expiration")
)
not_after_dt = _parse_datetime_any(str(not_after)) if not_after else None
days = _days_remaining(not_after_dt)
out.append(
CertRecord(
name=str(name),
subject=str(subject) if subject else None,
issuer=str(issuer) if issuer else None,
not_after=_iso(not_after_dt),
days_remaining=days,
source="console",
raw=o,
)
)
# If endpoint exists but empty, still valid
return out
except Exception as e:
last_err = e
continue
raise RuntimeError(f"Unable to fetch cert inventory from Console. Last error: {last_err}")
# -----------------------------
# Renderers
# -----------------------------
def render_markdown(records: List[CertRecord], window_days: int, console: str) -> str:
now = _now_utc()
lines: List[str] = []
lines.append(f"# Certificate Expiry Report")
lines.append("")
lines.append(f"- Generated (UTC): `{now.isoformat()}`")
lines.append(f"- Console: `{console}`")
lines.append(f"- Renewal window: `{window_days} days`")
lines.append(f"- Total certs discovered: `{len(records)}`")
lines.append("")
expiring = [r for r in records if r.days_remaining is not None and r.days_remaining <= window_days]
expiring.sort(key=lambda r: (r.days_remaining if r.days_remaining is not None else 999999, r.name))
lines.append(f"## Expiring within {window_days} days ({len(expiring)})")
lines.append("")
if not expiring:
lines.append("_No certificates found in the renewal window._")
lines.append("")
return "\n".join(lines)
lines.append("| Days | Not After (UTC) | Name | Subject | Issuer |")
lines.append("|---:|---|---|---|---|")
for r in expiring:
lines.append(
f"| {r.days_remaining} | `{r.not_after or ''}` | `{r.name}` | `{r.subject or ''}` | `{r.issuer or ''}` |"
)
lines.append("")
return "\n".join(lines)
# -----------------------------
# Main
# -----------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description="Poll NetScaler Console for cert expiry and generate a report."
)
ap.add_argument("--console", required=True, help="NetScaler Console base URL (e.g. https://192.168.113.2)")
ap.add_argument("--user", required=True, help="Console username")
ap.add_argument("--password", help="Console password (if omitted, prompt)")
ap.add_argument("--insecure", action="store_true", help="Disable TLS verification (lab use)")
ap.add_argument("--timeout", type=int, default=30, help="HTTP timeout seconds (default: 30)")
ap.add_argument("--window-days", type=int, default=30, help="Renewal window in days (default: 30)")
ap.add_argument("--out-dir", default="./reports", help="Output directory for reports (default: ./reports)")
ap.add_argument("--lock-file", default="./.certctl.lock", help="Lock dir path (default: ./.certctl.lock)")
ap.add_argument("--lock-wait", type=int, default=0, help="Seconds to wait for lock (default: 0)")
ap.add_argument("--lock-stale", type=int, default=3600, help="Stale lock seconds (default: 3600)")
args = ap.parse_args()
out_dir = Path(args.out_dir).expanduser().resolve()
lock_dir = Path(args.lock_file).expanduser().resolve()
_safe_mkdir(out_dir)
lock = FileLock(lock_dir, stale_seconds=args.lock_stale)
lock.acquire(wait_seconds=args.lock_wait)
try:
password = args.password or getpass.getpass(f"Console password for {args.user}@{args.console}: ")
client = ConsoleClient(args.console, insecure=args.insecure, timeout=args.timeout)
print("[console] Logging in...")
client.login(args.user, password)
print("[console] Login OK.")
print("[console] Fetching cert inventory...")
records = fetch_console_certs(client)
print(f"[console] Discovered {len(records)} cert records.")
# Filter only those with known expiry for reporting relevance
# (we still include unknown expiry in JSON for troubleshooting)
window = int(args.window_days)
report_ts = _ts_local_compact()
json_path = out_dir / f"expiry_{report_ts}.json"
md_path = out_dir / f"expiry_{report_ts}.md"
payload = {
"generated_utc": _now_utc().isoformat(),
"console": args.console,
"window_days": window,
"total_records": len(records),
"records": [asdict(r) for r in records],
}
_write_text(json_path, json.dumps(payload, indent=2))
_write_text(md_path, render_markdown(records, window, args.console))
print(f"[report] Wrote JSON: {json_path}")
print(f"[report] Wrote MD: {md_path}")
# convenience: latest pointers
latest_json = out_dir / "latest_expiry.json"
latest_md = out_dir / "latest_expiry.md"
_write_text(latest_json, json.dumps(payload, indent=2))
_write_text(latest_md, render_markdown(records, window, args.console))
print(f"[report] Updated latest_expiry.json / latest_expiry.md")
# Exit code useful for automation:
# 0 = ok
# 2 = certs in renewal window found
expiring = [r for r in records if r.days_remaining is not None and r.days_remaining <= window]
if expiring:
print(f"[alert] {len(expiring)} cert(s) within {window} days of expiry.")
return 2
return 0
finally:
lock.release(force=True)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,246 @@
#!/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):
- <out_dir>/<key_name>
- <out_dir>/<csr_name>
- (optional) <out_dir>/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=<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)

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Poll NetScaler Console for certificate expiry and generate a report.
This is a standalone script meant to be called from cron/CI or by a future
overlay orchestrator.
Data source
---------
Uses NITRO resource `ns_ssl_certkey` on NetScaler Console, which includes
fields like `valid_to` and `days_to_expiry`.
Exit codes
---------
0: No expired certs and none within the renewal window
2: At least one cert is within the renewal window
3: At least one cert is expired
Security
---------
- Console password can be pulled from macOS Keychain via the existing
secretstore helper.
- Use `--insecure` for self-signed lab certs, or provide `--ca-bundle`.
"""
from __future__ import annotations
# Allow running this script directly from the repo without installation.
import pathlib
import sys
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import argparse
import os
import sys
from certctl.console import ConsoleSession
from certctl.filelock import FileLock
from certctl.poll import poll_console_certkeys, write_report
from certctl import secretstore
from certctl.term import print_error, print_info
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="nsconsole_certpoll",
description="Poll NetScaler Console for expired / expiring certificates.",
)
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("--insecure", action="store_true", help="Disable TLS verification")
p.add_argument("--ca-bundle", default=None, help="Path to CA bundle for TLS verify")
p.add_argument("--window-days", type=int, default=30, help="Renewal window in days (default: 30)")
p.add_argument("--format", choices=["table", "csv", "json"], default="table", help="Output format")
p.add_argument(
"--out",
default=None,
help="Write report to this file instead of stdout (csv/json only)",
)
p.add_argument(
"--lock-file",
default=None,
help="Optional lock file path (default: <out-dir>/.certctl.lock or ./ .certctl.lock)",
)
p.add_argument(
"--out-dir",
default=".",
help="Used only to place default lock file if --lock-file isn't set.",
)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
# File lock keeps cron from overlapping runs.
lock_path = args.lock_file or os.path.join(os.path.abspath(args.out_dir), ".certctl.lock")
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
# Our lightweight FileLock takes `timeout_seconds` (not `timeout`).
# timeout_seconds=0 means: fail immediately if another run holds the lock.
with FileLock(lock_path, timeout_seconds=0.0):
# Password lookup order:
# 1) CERTCTL_NSCONSOLE_PASS
# 2) macOS Keychain (service=certctl:nsconsole, account=<user>@<console_url>)
# 3) Prompt (optionally store to Keychain)
password = secretstore.env_or_keychain(
env_var="CERTCTL_NSCONSOLE_PASS",
service="certctl:nsconsole",
account=f"{args.user}@{args.console}",
)
if not password:
import getpass
password = getpass.getpass(f"Console password for {args.user}@{args.console}: ")
if password and secretstore.is_macos_keychain_available():
yn = input("Store Console password in keychain? [Y/n] ").strip().lower()
if yn in ("", "y", "yes"):
secretstore.set_in_keychain(
service="certctl:nsconsole",
account=f"{args.user}@{args.console}",
secret=password,
)
if not password:
# We intentionally avoid a mandatory interactive prompt here so the script
# stays automation-friendly. If you want to run interactively, just export
# CERTCTL_NSCONSOLE_PASS or store it in keychain.
print_error(
"No Console password found. Set CERTCTL_NSCONSOLE_PASS, "
"or store it in keychain under service 'certctl:nsconsole' and account '<user>@<console_url>'."
)
return 1
if args.ca_bundle:
print_error(
"--ca-bundle is not wired up yet in this script. "
"Use --insecure or rely on the system trust store."
)
return 1
sess = ConsoleSession(
base_url=args.console,
username=args.user,
password=password,
insecure=args.insecure,
)
sess.login()
report = poll_console_certkeys(sess, window_days=args.window_days)
write_report(report, fmt=args.format, out_path=args.out)
if args.out:
print_info(f"Wrote {args.format} report: {args.out}")
# Exit codes suitable for CI
expired = report.get("expired", [])
expiring = report.get("expiring", [])
if expired:
return 3
if expiring:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,71 @@
{
"generated_utc": "2026-01-14T20:17:20.209722+00:00",
"console": "https://192.168.113.2",
"window_days": 30,
"total_records": 7,
"records": [
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
}
]
}

View File

@@ -0,0 +1,10 @@
# Certificate Expiry Report
- Generated (UTC): `2026-01-14T20:17:20.210081+00:00`
- Console: `https://192.168.113.2`
- Renewal window: `30 days`
- Total certs discovered: `7`
## Expiring within 30 days (0)
_No certificates found in the renewal window._

View File

@@ -0,0 +1,71 @@
{
"generated_utc": "2026-01-14T20:17:36.678328+00:00",
"console": "https://192.168.113.2",
"window_days": 60,
"total_records": 7,
"records": [
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
}
]
}

View File

@@ -0,0 +1,10 @@
# Certificate Expiry Report
- Generated (UTC): `2026-01-14T20:17:36.678687+00:00`
- Console: `https://192.168.113.2`
- Renewal window: `60 days`
- Total certs discovered: `7`
## Expiring within 60 days (0)
_No certificates found in the renewal window._

View File

@@ -0,0 +1,71 @@
{
"generated_utc": "2026-01-14T20:17:36.678328+00:00",
"console": "https://192.168.113.2",
"window_days": 60,
"total_records": 7,
"records": [
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
},
{
"name": "UNKNOWN",
"subject": null,
"issuer": null,
"not_after": null,
"days_remaining": null,
"source": "console",
"raw": {}
}
]
}

View File

@@ -0,0 +1,10 @@
# Certificate Expiry Report
- Generated (UTC): `2026-01-14T20:17:36.678957+00:00`
- Console: `https://192.168.113.2`
- Renewal window: `60 days`
- Total certs discovered: `7`
## Expiring within 60 days (0)
_No certificates found in the renewal window._

View File

@@ -0,0 +1,80 @@
"""Small secret storage abstraction.
For now:
- macOS: uses the built-in Keychain via the `security` CLI.
- other OSes: prompts only (no persistent storage) unless CERTCTL_* env vars used.
This keeps dependencies minimal and avoids coupling to a specific enterprise vault.
"""
from __future__ import annotations
import os
import platform
import subprocess
from typing import Optional
def _is_macos() -> bool:
return platform.system().lower() == "darwin"
def get_from_keychain(service: str, account: str) -> Optional[str]:
if not _is_macos():
return None
try:
# -w prints password only
out = subprocess.check_output(
["security", "find-generic-password", "-s", service, "-a", account, "-w"],
stderr=subprocess.DEVNULL,
)
return out.decode().strip()
except subprocess.CalledProcessError:
return None
def set_in_keychain(service: str, account: str, secret: str) -> None:
if not _is_macos():
return
# Add or update
subprocess.check_call(
[
"security",
"add-generic-password",
"-U",
"-s",
service,
"-a",
account,
"-w",
secret,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def env_or_keychain(env_var: str, service: str, account: str) -> Optional[str]:
v = os.environ.get(env_var)
if v:
return v
return get_from_keychain(service, account)
# ---------------------------------------------------------------------------
# Backwards-compatible aliases
# ---------------------------------------------------------------------------
def is_macos_keychain_available() -> bool:
"""Return True if this host is macOS (Keychain supported via `security`)."""
return _is_macos()
def get_secret(service: str, account: str) -> Optional[str]:
"""Alias for get_from_keychain(service, account)."""
return get_from_keychain(service, account)
def set_secret(service: str, account: str, secret: str) -> None:
"""Alias for set_in_keychain(service, account, secret)."""
set_in_keychain(service, account, secret)

62
legacy/certctl/storage.py Normal file
View File

@@ -0,0 +1,62 @@
"""Credential storage abstraction for macOS Keychain.
Provides small helpers to get/set generic passwords using the `security`
command on macOS. Functions are intentionally simple and easily testable via
stubbing subprocess calls.
"""
from typing import Optional
import subprocess
import shlex
import getpass
def keychain_get(service: str) -> Optional[str]:
"""Return the password for `service` from macOS Keychain, or None if not found."""
try:
# 'security find-generic-password -s <service> -w' prints the password
completed = subprocess.run(["security", "find-generic-password", "-s", service, "-w"], check=True, capture_output=True)
return completed.stdout.decode("utf-8").rstrip("\n")
except subprocess.CalledProcessError:
return None
def keychain_set(service: str, password: str, account: str = "certctl") -> bool:
"""Set a generic password in the Keychain for the given service."""
# Use -U to update if exists
try:
subprocess.run(["security", "add-generic-password", "-a", account, "-s", service, "-w", password, "-U"], check=True, capture_output=True)
return True
except subprocess.CalledProcessError:
return False
def keychain_delete(service: str) -> bool:
try:
subprocess.run(["security", "delete-generic-password", "-s", service], check=True, capture_output=True)
return True
except subprocess.CalledProcessError:
return False
def get_or_prompt_passphrase(service: Optional[str], prompt: Optional[str] = None) -> str:
"""Get passphrase from keychain or prompt the user interactively.
If `service` is provided, on a user-entered passphrase we attempt to store
it back into the Keychain for future use.
"""
if service:
existing = keychain_get(service)
if existing:
return existing
# Fallback to interactive prompt
text = getpass.getpass(prompt or "Enter passphrase for private key: ")
if service and text:
# best effort: set into keychain but ignore failures
try:
keychain_set(service, text)
except Exception:
pass
return text

73
legacy/certctl/term.py Normal file
View File

@@ -0,0 +1,73 @@
"""Terminal/TTY helpers for CLI formatting and safe output.
Provides small helpers to format and print warnings with optional ANSI styling
when the target stream is a TTY.
"""
from typing import Optional, TextIO
import sys
def format_warning(msg: str, stream: Optional[TextIO] = None) -> str:
"""Return a formatted warning string.
If ``stream`` is a tty (supports ``.isatty()``) the message will be wrapped
with ANSI bold yellow codes for prominence; otherwise the plain message is
returned.
"""
if stream is None:
stream = sys.stderr
try:
is_tty = stream.isatty()
except Exception:
is_tty = False
if is_tty:
return f"\033[1;33m{msg}\033[0m"
return msg
def print_warning(msg: str, stream: Optional[TextIO] = None) -> None:
"""Write a warning message followed by a newline to ``stream`` (defaults to stderr)."""
if stream is None:
stream = sys.stderr
stream.write(format_warning(msg, stream) + "\n")
# Additional semantic helpers
def format_error(msg: str, stream: Optional[TextIO] = None) -> str:
"""Return a formatted error string (bold red on TTY)."""
if stream is None:
stream = sys.stderr
try:
is_tty = stream.isatty()
except Exception:
is_tty = False
if is_tty:
return f"\033[1;31m{msg}\033[0m"
return msg
def print_error(msg: str, stream: Optional[TextIO] = None) -> None:
"""Write an error message followed by a newline to ``stream`` (defaults to stderr)."""
if stream is None:
stream = sys.stderr
stream.write(format_error(msg, stream) + "\n")
def format_info(msg: str, stream: Optional[TextIO] = None) -> str:
"""Return a formatted info string (bold green on TTY)."""
if stream is None:
stream = sys.stderr
try:
is_tty = stream.isatty()
except Exception:
is_tty = False
if is_tty:
return f"\033[1;32m{msg}\033[0m"
return msg
def print_info(msg: str, stream: Optional[TextIO] = None) -> None:
"""Write an info message followed by a newline to ``stream`` (defaults to stderr)."""
if stream is None:
stream = sys.stderr
stream.write(format_info(msg, stream) + "\n")

62
legacy/certctl/utils.py Normal file
View File

@@ -0,0 +1,62 @@
"""Utility helpers for certctl package.
Includes a robust PEM extractor that searches arbitrary Console JSON payloads
for a PEM CSR block (-----BEGIN CERTIFICATE REQUEST----- ...
-----END CERTIFICATE REQUEST-----).
"""
from typing import Optional, Any, TextIO
import re
import sys
PEM_RE = re.compile(r"-----BEGIN CERTIFICATE REQUEST-----(?:.|\n)+?-----END CERTIFICATE REQUEST-----", re.DOTALL)
# Compatibility exports: delegate terminal formatting to certctl.term
from .term import format_warning, print_warning, format_error, print_error, format_info, print_info # re-exported for backwards compatibility
def extract_pem_from_json(obj: Any) -> Optional[str]:
"""Recursively search `obj` (dict/list/str) for the first PEM CSR block.
Returns the matched PEM string or None if not found.
"""
if isinstance(obj, str):
m = PEM_RE.search(obj)
if m:
return m.group(0)
return None
if isinstance(obj, dict):
# check common CSR locations quickly
# e.g., {"ns_ssl_csr": [{"csr": "-----BEGIN..."}]}
if "ns_ssl_csr" in obj:
val = obj.get("ns_ssl_csr")
if isinstance(val, list):
for item in val:
if isinstance(item, dict):
csr = item.get("csr")
if csr:
m = PEM_RE.search(csr)
if m:
return m.group(0)
# generic recursive walk
for v in obj.values():
res = extract_pem_from_json(v)
if res:
return res
if isinstance(obj, list):
# If it's a list of strings (rows), join them and search the whole block
if all(isinstance(i, str) for i in obj):
joined = "\n".join(obj)
m = PEM_RE.search(joined)
if m:
return m.group(0)
for item in obj:
res = extract_pem_from_json(item)
if res:
return res
return None