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

2
certctl/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Core package for NetScaler Console certificate automation."""

2
certctl/ca/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Certificate authority adapters."""

157
certctl/ca/adcs.py Normal file
View File

@@ -0,0 +1,157 @@
"""ADCS adapter."""
from __future__ import annotations
import base64
import re
import shutil
import subprocess
import textwrap
from dataclasses import dataclass
from typing import Optional
import requests
from .base import CAAdapter, CertStatus, SubmitResult
@dataclass
class AdcsConfig:
base_url: str
username: str
password: str
template: Optional[str] = None
verify: object = True
_REQ_ID_RE = re.compile(r"ReqID=([0-9]+)")
_REQ_ID_TEXT_RE = re.compile(r"Request\\s+Id\\s+is\\s+([0-9]+)", re.IGNORECASE)
_DISPOSITION_RE = re.compile(r"Disposition\\s*=\\s*([0-9]+)")
def _parse_req_id(body: str) -> str:
match = _REQ_ID_RE.search(body) or _REQ_ID_TEXT_RE.search(body)
if not match:
raise RuntimeError("Unable to parse ADCS request id from response.")
return match.group(1)
def _parse_disposition(body: str) -> Optional[str]:
match = _DISPOSITION_RE.search(body)
if not match:
return None
return match.group(1)
def _b64_to_pem(b64_data: str) -> str:
cleaned = "".join(b64_data.strip().split())
lines = textwrap.fill(cleaned, 64)
return "-----BEGIN CERTIFICATE-----\n" + lines + "\n-----END CERTIFICATE-----\n"
def _require_openssl() -> str:
path = shutil.which("openssl")
if not path:
raise RuntimeError("OpenSSL not found in PATH.")
return path
def _p7b_to_pem(b64_data: str) -> str:
_require_openssl()
der = base64.b64decode("".join(b64_data.strip().split()))
proc = subprocess.run(
["openssl", "pkcs7", "-inform", "DER", "-print_certs"],
input=der,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return proc.stdout.decode("utf-8", errors="replace")
class AdcsAdapter(CAAdapter):
name = "adcs"
def __init__(self, config: AdcsConfig):
self.config = config
self.session = requests.Session()
self.session.auth = (self.config.username, self.config.password)
def submit_csr(self, csr_pem: str, **kwargs) -> SubmitResult:
attrib = ""
if self.config.template:
attrib = f"CertificateTemplate:{self.config.template}"
data = {
"Mode": "newreq",
"CertRequest": csr_pem,
"CertAttrib": attrib,
"TargetStoreFlags": "0",
"SaveCert": "yes",
}
resp = self.session.post(
f"{self.config.base_url.rstrip('/')}/certfnsh.asp",
data=data,
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
req_id = _parse_req_id(resp.text)
disposition = _parse_disposition(resp.text)
if disposition == "5":
raise RuntimeError("ADCS request was denied.")
return SubmitResult(request_id=req_id, ca=self.name)
def poll_status(self, request_id: str) -> CertStatus:
resp = self.session.get(
f"{self.config.base_url.rstrip('/')}/certnew.cer",
params={"ReqID": request_id, "Enc": "b64"},
verify=self.config.verify,
timeout=60,
)
if resp.status_code == 200 and "<html" not in resp.text.lower():
return CertStatus(status="Issued", raw={"request_id": request_id})
body = resp.text.lower()
if "pending" in body or "certsrv_e_pending" in body:
return CertStatus(status="Pending", raw={"request_id": request_id})
if "denied" in body or "rejected" in body:
return CertStatus(status="Denied", raw={"request_id": request_id})
return CertStatus(status="Unknown", raw={"request_id": request_id, "body": resp.text[:2000]})
def collect_certificate(self, request_id: str, *, format_name=None) -> str:
resp = self.session.get(
f"{self.config.base_url.rstrip('/')}/certnew.cer",
params={"ReqID": request_id, "Enc": "b64"},
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
body = resp.text.strip()
if "BEGIN CERTIFICATE" in body:
return body if body.endswith("\n") else body + "\n"
if "<html" in body.lower():
raise RuntimeError("ADCS did not return a certificate.")
return _b64_to_pem(body)
def collect_chain(self, request_id: str) -> str:
resp = self.session.get(
f"{self.config.base_url.rstrip('/')}/certnew.p7b",
params={"ReqID": request_id, "Enc": "b64"},
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
body = resp.text.strip()
if "BEGIN PKCS7" in body:
# Convert PEM PKCS7 to PEM certificates.
_require_openssl()
proc = subprocess.run(
["openssl", "pkcs7", "-inform", "PEM", "-print_certs"],
input=body.encode("utf-8"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return proc.stdout.decode("utf-8", errors="replace")
if "<html" in body.lower():
raise RuntimeError("ADCS did not return a certificate chain.")
return _p7b_to_pem(body)

31
certctl/ca/base.py Normal file
View File

@@ -0,0 +1,31 @@
"""CA adapter interfaces."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class SubmitResult:
request_id: str
ca: str
@dataclass
class CertStatus:
status: str
raw: dict
class CAAdapter:
name: str
def submit_csr(self, csr_pem: str, **kwargs) -> SubmitResult:
raise NotImplementedError
def poll_status(self, request_id: str) -> CertStatus:
raise NotImplementedError
def collect_certificate(self, request_id: str, *, format_name: Optional[str] = None) -> str:
raise NotImplementedError

85
certctl/ca/sectigo.py Normal file
View File

@@ -0,0 +1,85 @@
"""Sectigo (Cert Manager) adapter."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
import requests
from .base import CAAdapter, CertStatus, SubmitResult
@dataclass
class SectigoConfig:
base_url: str
login: str
password: str
customer_uri: str
org_id: int
cert_type: int
term_days: int
verify: object = True
class SectigoAdapter(CAAdapter):
name = "sectigo"
def __init__(self, config: SectigoConfig):
self.config = config
def _headers(self) -> Dict[str, str]:
return {
"Content-Type": "application/json;charset=UTF-8",
"login": self.config.login,
"password": self.config.password,
"customerUri": self.config.customer_uri,
}
def submit_csr(self, csr_pem: str, **kwargs: Any) -> SubmitResult:
payload: Dict[str, Any] = {
"orgId": self.config.org_id,
"csr": csr_pem,
"certType": self.config.cert_type,
"term": self.config.term_days,
}
subj_alt_names = kwargs.get("subj_alt_names")
if subj_alt_names:
payload["subjAltNames"] = subj_alt_names
resp = requests.post(
f"{self.config.base_url.rstrip('/')}/api/ssl/v1/enroll",
headers=self._headers(),
json=payload,
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
data = resp.json()
ssl_id = data.get("sslId")
if ssl_id is None:
raise RuntimeError(f"Sectigo response missing sslId: {data}")
return SubmitResult(request_id=str(ssl_id), ca=self.name)
def poll_status(self, request_id: str) -> CertStatus:
resp = requests.get(
f"{self.config.base_url.rstrip('/')}/api/ssl/v1/{request_id}",
headers=self._headers(),
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
data = resp.json()
status = data.get("status", "Unknown")
return CertStatus(status=status, raw=data)
def collect_certificate(self, request_id: str, *, format_name: Optional[str] = None) -> str:
fmt = format_name or "pem"
resp = requests.get(
f"{self.config.base_url.rstrip('/')}/api/ssl/v1/collect/{request_id}",
headers=self._headers(),
params={"format": fmt},
verify=self.config.verify,
timeout=60,
)
resp.raise_for_status()
return resp.text

264
certctl/ca/selfsigned.py Normal file
View File

@@ -0,0 +1,264 @@
"""Self-signed CA adapter for workflow testing."""
from __future__ import annotations
import shutil
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple
from .base import CAAdapter, CertStatus, SubmitResult
DEFAULT_RSA_BITS = 4096
DEFAULT_EC_CURVE = "secp384r1"
@dataclass
class SelfSignedConfig:
ca_dir: str = "./out/selfsigned"
passphrase: str = ""
ca_days: int = 60
leaf_days: int = 59
rsa_cn: str = "Molloy Root CA (RSA)"
ecdsa_cn: str = "Molloy Root CA (ECDSA)"
def _require_openssl() -> str:
path = shutil.which("openssl")
if not path:
raise RuntimeError("OpenSSL not found in PATH.")
return path
def _run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
def _write_text(path: Path, data: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data, encoding="utf-8")
def _detect_key_type(csr_path: Path) -> str:
_require_openssl()
proc = subprocess.run(
["openssl", "req", "-in", str(csr_path), "-noout", "-text"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
text=True,
)
for line in proc.stdout.splitlines():
if "Public Key Algorithm" in line:
lowered = line.lower()
if "ec" in lowered:
return "ecdsa"
if "rsa" in lowered:
return "rsa"
return "rsa"
def _extract_sans(csr_path: Path) -> list[str]:
_require_openssl()
proc = subprocess.run(
["openssl", "req", "-in", str(csr_path), "-noout", "-text"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
text=True,
)
sans = []
saw_san = False
for line in proc.stdout.splitlines():
line = line.strip()
if "Subject Alternative Name" in line:
saw_san = True
continue
if saw_san:
if line.startswith("DNS:") or line.startswith("IP Address:"):
entries = [e.strip() for e in line.split(",")]
for entry in entries:
if entry.startswith("DNS:"):
sans.append(f"DNS:{entry[4:]}")
elif entry.startswith("IP Address:"):
sans.append(f"IP:{entry.split(':', 1)[1]}")
else:
saw_san = False
return sans
def _ca_paths(ca_dir: Path, kind: str) -> Tuple[Path, Path, Path]:
key_path = ca_dir / f"molloy_root_ca_{kind}.key"
cert_path = ca_dir / f"molloy_root_ca_{kind}.pem"
serial_path = ca_dir / f"molloy_root_ca_{kind}.srl"
return key_path, cert_path, serial_path
def _ensure_ca(config: SelfSignedConfig, kind: str) -> Tuple[Path, Path, Path]:
ca_dir = Path(config.ca_dir)
key_path, cert_path, serial_path = _ca_paths(ca_dir, kind)
if key_path.exists() and cert_path.exists():
_require_openssl()
check_cmd = [
"openssl",
"pkey",
"-in",
str(key_path),
"-passin",
f"pass:{config.passphrase}",
"-noout",
]
try:
_run(check_cmd)
return key_path, cert_path, serial_path
except subprocess.CalledProcessError:
key_path.rename(key_path.with_suffix(key_path.suffix + ".badpass"))
cert_path.rename(cert_path.with_suffix(cert_path.suffix + ".badpass"))
_require_openssl()
ca_dir.mkdir(parents=True, exist_ok=True)
if kind == "rsa":
key_cmd = [
"openssl",
"genpkey",
"-algorithm",
"RSA",
"-pkeyopt",
f"rsa_keygen_bits:{DEFAULT_RSA_BITS}",
"-aes-256-cbc",
"-pass",
f"pass:{config.passphrase}",
"-out",
str(key_path),
]
subject_cn = config.rsa_cn
else:
key_cmd = [
"openssl",
"genpkey",
"-algorithm",
"EC",
"-pkeyopt",
f"ec_paramgen_curve:{DEFAULT_EC_CURVE}",
"-pkeyopt",
"ec_param_enc:named_curve",
"-aes-256-cbc",
"-pass",
f"pass:{config.passphrase}",
"-out",
str(key_path),
]
subject_cn = config.ecdsa_cn
_run(key_cmd)
ca_cmd = [
"openssl",
"req",
"-x509",
"-new",
"-key",
str(key_path),
"-passin",
f"pass:{config.passphrase}",
"-subj",
f"/CN={subject_cn}",
"-days",
str(config.ca_days),
"-sha256",
"-addext",
"basicConstraints=critical,CA:TRUE,pathlen:1",
"-addext",
"keyUsage=critical,keyCertSign,cRLSign",
"-out",
str(cert_path),
]
_run(ca_cmd)
return key_path, cert_path, serial_path
def _leaf_extfile(ca_dir: Path, sans: list[str]) -> Path:
ext_path = ca_dir / "leaf_ext.cnf"
content = "\n".join(
[
"basicConstraints=CA:FALSE",
"keyUsage=digitalSignature,keyEncipherment",
"extendedKeyUsage=serverAuth,clientAuth",
]
)
if sans:
content += "\n" + "subjectAltName=" + ", ".join(sans)
_write_text(ext_path, content + "\n")
return ext_path
class SelfSignedAdapter(CAAdapter):
name = "selfsigned"
def __init__(self, config: SelfSignedConfig):
self.config = config
self._issued: Dict[str, str] = {}
self._request_kind: Dict[str, str] = {}
self._ca_paths: Dict[str, Tuple[Path, Path, Path]] = {}
def submit_csr(self, csr_pem: str, **kwargs) -> SubmitResult:
ca_dir = Path(self.config.ca_dir)
ca_dir.mkdir(parents=True, exist_ok=True)
stamp = str(time.time_ns())
csr_path = ca_dir / f"csr_{stamp}.pem"
_write_text(csr_path, csr_pem)
kind = _detect_key_type(csr_path)
key_path, cert_path, serial_path = _ensure_ca(self.config, kind)
self._ca_paths[kind] = (key_path, cert_path, serial_path)
leaf_path = ca_dir / f"leaf_{stamp}.pem"
sans = _extract_sans(csr_path)
ext_path = _leaf_extfile(ca_dir, sans)
leaf_cmd = [
"openssl",
"x509",
"-req",
"-in",
str(csr_path),
"-CA",
str(cert_path),
"-CAkey",
str(key_path),
"-passin",
f"pass:{self.config.passphrase}",
"-CAcreateserial",
"-CAserial",
str(serial_path),
"-days",
str(self.config.leaf_days),
"-sha256",
"-extfile",
str(ext_path),
"-out",
str(leaf_path),
]
_run(leaf_cmd)
leaf_pem = leaf_path.read_text(encoding="utf-8")
request_id = f"selfsigned-{stamp}"
self._issued[request_id] = leaf_pem if leaf_pem.endswith("\n") else leaf_pem + "\n"
self._request_kind[request_id] = kind
return SubmitResult(request_id=request_id, ca=self.name)
def poll_status(self, request_id: str) -> CertStatus:
return CertStatus(status="Issued", raw={"request_id": request_id})
def collect_certificate(self, request_id: str, *, format_name: Optional[str] = None) -> str:
if request_id not in self._issued:
raise RuntimeError(f"Unknown request id: {request_id}")
return self._issued[request_id]
def collect_chain(self, request_id: str) -> str:
if request_id not in self._issued:
raise RuntimeError(f"Unknown request id: {request_id}")
cert = self._issued[request_id]
kind = self._request_kind.get(request_id, "rsa")
_, ca_cert_path, _ = self._ca_paths[kind]
ca_pem = ca_cert_path.read_text(encoding="utf-8")
return cert + (ca_pem if ca_pem.endswith("\n") else ca_pem + "\n")

208
certctl/console.py Normal file
View File

@@ -0,0 +1,208 @@
"""NetScaler Console NITRO API helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union
import requests
from urllib3.exceptions import InsecureRequestWarning
VerifyType = Union[bool, str]
class NitroError(RuntimeError):
def __init__(self, status_code: int, message: str, payload: Any = None, headers: Any = None):
super().__init__(f"NITRO error (HTTP {status_code}): {message}")
self.status_code = status_code
self.message = message
self.payload = payload
self.headers = headers
@dataclass
class NitroConsoleClient:
base: str
verify: VerifyType
token: Optional[str] = None
timeout: int = 60
def __post_init__(self) -> None:
if self.verify is False:
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) # type: ignore[attr-defined]
def _url(self, path: str) -> str:
return self.base.rstrip("/") + path
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
headers: Dict[str, str] = {"Accept": "*/*"}
if self.token:
headers["Cookie"] = f"NITRO_AUTH_TOKEN={self.token}"
if extra:
headers.update(extra)
return headers
def login(self, username: str, password: str) -> str:
url = self._url("/nitro/v2/config/login")
payload = {"login": {"username": username, "password": password}}
resp = requests.post(
url,
headers={"Content-Type": "application/json", "Accept": "application/json"},
json=payload,
verify=self.verify,
timeout=self.timeout,
)
data = self._parse_json(resp)
try:
session_id = data["login"][0]["sessionid"]
except Exception as exc: # pragma: no cover - defensive
raise NitroError(resp.status_code, "Login response did not include sessionid", data) from exc
if not session_id:
raise NitroError(resp.status_code, "Login returned empty sessionid", data)
self.token = session_id
return session_id
def _parse_json(self, resp: requests.Response) -> Dict[str, Any]:
headers = dict(resp.headers)
try:
data = resp.json()
except Exception:
if resp.status_code >= 400:
raise NitroError(resp.status_code, resp.text, headers=headers)
raise NitroError(resp.status_code, "Expected JSON but got non-JSON response", headers=headers)
if resp.status_code >= 400:
msg = data.get("message") if isinstance(data, dict) else resp.text
raise NitroError(resp.status_code, str(msg), data, headers=headers)
if isinstance(data, dict):
err = data.get("errorcode")
if err not in (0, "0", None):
msg = data.get("message", "Unknown NITRO error")
raise NitroError(resp.status_code, str(msg), data, headers=headers)
return data
def get_json(self, path: str, *, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
url = self._url(path)
resp = requests.get(
url,
headers=self._headers({"Accept": "application/json"}),
params=params,
verify=self.verify,
timeout=self.timeout,
)
return self._parse_json(resp)
def post_json(
self,
path: str,
payload: Dict[str, Any],
*,
params: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
use_cookie: bool = True,
) -> Dict[str, Any]:
url = self._url(path)
base_headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
if use_cookie and self.token:
base_headers["Cookie"] = f"NITRO_AUTH_TOKEN={self.token}"
if headers:
base_headers.update(headers)
resp = requests.post(
url,
headers=base_headers,
params=params,
json=payload,
verify=self.verify,
timeout=self.timeout,
)
return self._parse_json(resp)
def put_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = self._url(path)
resp = requests.put(
url,
headers=self._headers({"Content-Type": "application/json", "Accept": "application/json"}),
json=payload,
verify=self.verify,
timeout=self.timeout,
)
return self._parse_json(resp)
def upload_file(
self,
path: str,
file_path: str,
*,
basic_user: Optional[str] = None,
basic_password: Optional[str] = None,
) -> Dict[str, Any]:
url = self._url(path)
headers = self._headers()
if basic_user and basic_password:
headers["X-NITRO-USER"] = basic_user
headers["X-NITRO-PASS"] = basic_password
with open(file_path, "rb") as handle:
resp = requests.post(
url,
headers=headers,
files={"file": handle},
verify=self.verify,
timeout=self.timeout,
)
return self._parse_json(resp)
def download_file(self, resource: str, file_name: str) -> bytes:
url = self._url(f"/nitro/v2/download/{resource}/{file_name}")
resp = requests.get(
url,
headers=self._headers(),
verify=self.verify,
timeout=self.timeout,
)
if resp.status_code >= 400:
raise NitroError(resp.status_code, resp.text, headers=dict(resp.headers))
return resp.content
def get_system_settings(self) -> Dict[str, Any]:
data = self.get_json("/nitro/v2/config/system_settings")
settings = data.get("system_settings")
if isinstance(settings, list) and settings:
return settings[0]
if isinstance(settings, dict):
return settings
return {}
def set_basicauth(self, enabled: bool) -> None:
settings = self.get_system_settings()
settings_id = settings.get("id")
if not settings_id:
raise NitroError(400, "system_settings id not found")
payload = {"system_settings": {"basicauth": bool(enabled)}}
self.put_json(f"/nitro/v2/config/system_settings/{settings_id}", payload)
def create_key(
self,
key_name: str,
*,
algo: str,
keyform: str = "PEM",
keysize: Optional[int] = None,
ec_curve: Optional[str] = None,
password: Optional[str] = None,
file_location_path: str = "",
) -> Dict[str, Any]:
obj: Dict[str, Any] = {
"file_name": key_name,
"keyform": keyform,
"algo": algo,
"file_location_path": file_location_path,
}
if keysize is not None:
obj["keysize"] = int(keysize)
if ec_curve:
obj["ec_curve"] = ec_curve
if password:
obj["password"] = password
return self.post_json("/nitro/v2/config/ns_ssl_key", {"ns_ssl_key": obj}, params={"action": "create"})

View File

@@ -0,0 +1,2 @@
"""Runnable single-purpose scripts."""

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Probe minimal cert_store payloads for Console uploads."""
from __future__ import annotations
import argparse
import base64
import getpass
import os
from pathlib import Path
from typing import Optional
from certctl.console import NitroConsoleClient, NitroError
def _read_bytes(path: str) -> bytes:
return Path(path).expanduser().read_bytes()
def _b64_bytes(data: bytes) -> str:
return base64.b64encode(data).decode("ascii").rstrip("%")
def _load_console_password(username: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {username}@{console}: ")
def _load_key_password(provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_KEY_PASSPHRASE")
if env_pw:
return env_pw
return getpass.getpass("Key passphrase (AES-256): ")
def _probe_server(
client: NitroConsoleClient,
*,
name: str,
cert_path: str,
key_path: str,
passphrase: str,
domain: Optional[str],
cert_file_name: Optional[str],
cert_type: Optional[str],
include_key_file: bool,
dry_run: bool,
) -> None:
cert_bytes = _read_bytes(cert_path)
key_bytes = _read_bytes(key_path)
cert_b64 = _b64_bytes(cert_bytes)
key_b64 = _b64_bytes(key_bytes)
payload = {
"cert_store": {
"name": name,
"cert_format": "PEM",
"cert_data": {"file_name": cert_file_name or f"{name}.pem", "file_data": cert_b64},
"key_data": key_b64,
"password": passphrase,
}
}
if cert_type:
payload["cert_store"]["cert_type"] = cert_type
if domain:
payload["cert_store"]["domain"] = domain
if include_key_file:
payload["cert_store"]["key_file"] = name
print(
"[probe] server payload sizes:"
f" cert_b64={len(cert_b64)}"
f" key_b64={len(key_b64)}"
)
if dry_run:
print("[probe] dry run; skipping server cert_store upload.")
return
response = client.post_json("/nitro/v2/config/cert_store", payload)
print("[probe] server response:", response)
def _probe_ca(
client: NitroConsoleClient,
*,
name: str,
cert_path: str,
cert_file_name: Optional[str],
cert_type: Optional[str],
dry_run: bool,
) -> None:
cert_bytes = _read_bytes(cert_path)
cert_b64 = _b64_bytes(cert_bytes)
payload = {
"cert_store": {
"name": name,
"cert_format": "PEM",
"cert_data": {"file_name": cert_file_name or f"{name}.pem", "file_data": cert_b64},
}
}
if cert_type:
payload["cert_store"]["cert_type"] = cert_type
print(f"[probe] CA payload sizes: cert_b64={len(cert_b64)}")
if dry_run:
print("[probe] dry run; skipping CA cert_store upload.")
return
response = client.post_json("/nitro/v2/config/cert_store", payload)
print("[probe] CA response:", response)
def main() -> None:
parser = argparse.ArgumentParser(
description="Probe minimal cert_store uploads (server cert/key and optional CA cert)."
)
parser.add_argument("--console", required=True, help="Console base URL, e.g. https://192.168.0.1")
parser.add_argument("--user", required=True, help="Console username")
parser.add_argument("--password", help="Console password (or CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="CA bundle path for TLS verification")
parser.add_argument("--dry-run", action="store_true", help="Print sizes only; do not POST")
parser.add_argument("--name", help="Cert_store name for server cert")
parser.add_argument("--cert", dest="cert_path", help="Server certificate path (PEM)")
parser.add_argument("--key", dest="key_path", help="Server key path (PEM)")
parser.add_argument("--key-pass", dest="key_pass", help="Key passphrase")
parser.add_argument("--domain", help="Domain for server cert_store entry")
parser.add_argument("--cert-file-name", help="Override server cert file_name field")
parser.add_argument("--cert-type", help="Set cert_type in server payload (e.g., server_cert)")
parser.add_argument(
"--include-key-file",
action="store_true",
help="Include key_file field in payload",
)
parser.add_argument("--ca-name", help="Cert_store name for CA cert")
parser.add_argument("--ca-cert", dest="ca_cert_path", help="CA certificate path (PEM)")
parser.add_argument("--ca-cert-file-name", help="Override CA cert file_name field")
parser.add_argument("--ca-cert-type", help="Set cert_type in CA payload (e.g., root_cert)")
args = parser.parse_args()
if not args.name and not args.ca_name:
raise SystemExit("Provide --name for server probe and/or --ca-name for CA probe.")
if args.name and not (args.cert_path and args.key_path):
raise SystemExit("Server probe requires --name, --cert, and --key.")
if args.ca_name and not args.ca_cert_path:
raise SystemExit("CA probe requires --ca-name and --ca-cert.")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
password = _load_console_password(args.user, args.console, args.password)
client.login(args.user, password)
if args.ca_name:
_probe_ca(
client,
name=args.ca_name,
cert_path=args.ca_cert_path,
cert_file_name=args.ca_cert_file_name,
cert_type=args.ca_cert_type,
dry_run=args.dry_run,
)
if args.name:
key_pass = _load_key_password(args.key_pass)
_probe_server(
client,
name=args.name,
cert_path=args.cert_path,
key_path=args.key_path,
passphrase=key_pass,
domain=args.domain,
cert_file_name=args.cert_file_name,
cert_type=args.cert_type,
include_key_file=args.include_key_file,
dry_run=args.dry_run,
)
if __name__ == "__main__":
try:
main()
except NitroError as exc:
raise SystemExit(str(exc)) from exc

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Inspect Console settings that can impact file uploads."""
from __future__ import annotations
import argparse
import getpass
import json
import os
from typing import Any, Dict, Optional
from certctl.console import NitroConsoleClient, NitroError
def _load_password(user: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {user}@{console}: ")
def _select_settings(settings: Dict[str, Any]) -> Dict[str, Any]:
keys = [
"basicauth",
"secure_access_only",
"enable_certificate_download",
"enable_shell_access",
"enable_apiproxy_credentials",
"authorize_deviceapiproxy",
"session_timeout",
"session_timeout_unit",
]
return {k: settings.get(k) for k in keys if k in settings}
def _get_settings(client: NitroConsoleClient) -> Dict[str, Any]:
data = client.get_json("/nitro/v2/config/system_settings")
settings = data.get("system_settings")
if isinstance(settings, list) and settings:
return settings[0]
if isinstance(settings, dict):
return settings
return {}
def _get_summary(client: NitroConsoleClient) -> Dict[str, Any]:
try:
data = client.get_json("/nitro/v2/config/mas_summary")
except NitroError:
return {}
summary = data.get("mas_summary")
if isinstance(summary, list) and summary:
return summary[0]
if isinstance(summary, dict):
return summary
return {}
def run(args: argparse.Namespace) -> int:
if not args.console or not args.user:
raise SystemExit("Console URL and user are required.")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = _load_password(args.user, args.console, args.password)
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
settings = _get_settings(client)
if not settings:
print("No system_settings returned.")
else:
selected = _select_settings(settings)
print("System settings (selected):")
print(json.dumps(selected, indent=2, sort_keys=True))
if args.full:
print("System settings (full):")
print(json.dumps(settings, indent=2, sort_keys=True))
summary = _get_summary(client)
if summary:
print("MAS summary (selected):")
pick = {k: summary.get(k) for k in ["ns_count", "ns_ssl_certkey_count", "sdx_count", "tenant_count"]}
print(json.dumps(pick, indent=2, sort_keys=True))
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Inspect Console settings relevant to file uploads.")
parser.add_argument("--console", help="Console URL (e.g., https://192.0.2.10)")
parser.add_argument("--user", help="Console username")
parser.add_argument("--password", help="Console password (optional; env CERTCTL_CONSOLE_PASSWORD used by default)")
parser.add_argument("--insecure", action="store_true", help="Skip TLS verification")
parser.add_argument("--ca-bundle", help="CA bundle path for TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
parser.add_argument("--full", action="store_true", help="Include full system_settings output")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Probe Console file upload endpoints to see if uploads are blocked."""
from __future__ import annotations
import argparse
import getpass
import os
import tempfile
from pathlib import Path
from typing import Optional
from certctl.console import NitroConsoleClient, NitroError
def _load_password(user: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {user}@{console}: ")
def _probe_upload(client: NitroConsoleClient, resource: str, payload: Path, *, user: str, password: str) -> None:
try:
client.upload_file(
f"/nitro/v2/upload/{resource}",
str(payload),
basic_user=user,
basic_password=password,
)
print(f"[ok] upload succeeded: {resource}")
except NitroError as exc:
print(f"[error] upload failed: {resource} -> {exc}")
def run(args: argparse.Namespace) -> int:
if not args.console or not args.user:
raise SystemExit("Console URL and user are required.")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = _load_password(args.user, args.console, args.password)
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
with tempfile.TemporaryDirectory() as td:
payload = Path(td) / "probe.txt"
payload.write_text("upload probe", encoding="utf-8")
for resource in args.resource:
_probe_upload(client, resource, payload, user=args.user, password=password)
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Probe Console upload endpoints.")
parser.add_argument("--console", help="Console URL (e.g., https://192.0.2.10)")
parser.add_argument("--user", help="Console username")
parser.add_argument("--password", help="Console password (optional; env CERTCTL_CONSOLE_PASSWORD used by default)")
parser.add_argument("--insecure", action="store_true", help="Skip TLS verification")
parser.add_argument("--ca-bundle", help="CA bundle path for TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
parser.add_argument(
"--resource",
action="append",
default=["mps_image", "ns_ssl_cert", "ns_ssl_key"],
help="Upload resource name (repeatable). Default: mps_image, ns_ssl_cert, ns_ssl_key",
)
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Create a CSR from an encrypted private key with optional SANs."""
from __future__ import annotations
import argparse
import getpass
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable, Optional
def _require_openssl() -> str:
path = shutil.which("openssl")
if not path:
raise SystemExit("OpenSSL not found in PATH.")
return path
def _normalize_sans(values: Iterable[str]) -> list[str]:
normalized = []
for value in values:
parts = [part.strip() for part in value.split(",") if part.strip()]
normalized.extend(parts)
return normalized
def _write_openssl_config(subject: str, sans: Iterable[str]) -> str:
alt_lines = []
dns_i = 1
ip_i = 1
for san in _normalize_sans(sans):
if san.lower().startswith("dns:"):
alt_lines.append(f"DNS.{dns_i} = {san[4:]}")
dns_i += 1
elif san.lower().startswith("ip:"):
alt_lines.append(f"IP.{ip_i} = {san[3:]}")
ip_i += 1
elif all(part.isdigit() for part in san.split(".")) and san.count(".") == 3:
alt_lines.append(f"IP.{ip_i} = {san}")
ip_i += 1
else:
alt_lines.append(f"DNS.{dns_i} = {san}")
dns_i += 1
alt_block = "\n".join(alt_lines) if alt_lines else ""
if alt_lines:
cfg = f"""[req]
default_bits = 2048
prompt = no
distinguished_name = dn
req_extensions = v3_req
[dn]
{_subject_to_dn(subject)}
[v3_req]
subjectAltName = @alt_names
[alt_names]
{alt_block}
"""
else:
cfg = f"""[req]
default_bits = 2048
prompt = no
distinguished_name = dn
[dn]
{_subject_to_dn(subject)}
"""
handle = tempfile.NamedTemporaryFile("w", delete=False)
handle.write(cfg)
handle.close()
return handle.name
def _timestamp() -> str:
import datetime as dt
return dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def _subject_to_dn(subject: str) -> str:
subject = subject.strip()
if subject.startswith("/"):
parts = [p for p in subject.split("/") if p]
kv_pairs = [p.split("=", 1) for p in parts if "=" in p]
else:
kv_pairs = [p.split("=", 1) for p in subject.split(",") if "=" in p]
lines = []
for key, value in kv_pairs:
lines.append(f"{key.strip()} = {value.strip()}")
return "\n".join(lines)
def _extract_cn(subject: str) -> str:
subject = subject.strip()
if subject.startswith("/"):
parts = [p for p in subject.split("/") if p]
for part in parts:
if part.upper().startswith("CN="):
return part.split("=", 1)[1].strip()
return ""
for part in subject.split(","):
if part.strip().upper().startswith("CN="):
return part.split("=", 1)[1].strip()
return ""
def _build_subject_from_args(args: argparse.Namespace) -> str:
if args.subject:
return args.subject
cn = args.cn
if not cn:
cn = input("Common Name (CN): ").strip()
if not cn:
raise SystemExit("Common Name (CN) is required.")
parts = [
f"/C={args.country}",
f"/ST={args.state}",
f"/L={args.locality}",
f"/O={args.organization}",
f"/OU={args.org_unit}",
f"/CN={cn}",
f"/emailAddress={args.email}",
]
return "".join(parts)
def _run(cmd: list[str], env: Optional[dict[str, str]] = None) -> None:
subprocess.run(cmd, check=True, env=env)
def _get_passphrase(args: argparse.Namespace) -> str:
if args.passphrase:
return args.passphrase
env = os.environ.get("CERTCTL_KEY_PASSPHRASE")
if env:
return env
return getpass.getpass("Key passphrase (AES-256): ")
def _build_csr_name(cn: str, stamp: str) -> str:
return f"{cn}-{stamp}.csr"
def run(args: argparse.Namespace) -> int:
_require_openssl()
passphrase = _get_passphrase(args)
key_path = Path(args.key_file)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
subject = _build_subject_from_args(args)
cn = args.cn or _extract_cn(subject)
if not cn:
raise SystemExit("Common Name (CN) is required to build the CSR filename.")
stamp = args.stamp or _timestamp()
out_path = out_dir / _build_csr_name(cn, stamp)
cfg_path = _write_openssl_config(subject, args.san or [])
try:
cmd = [
"openssl",
"req",
"-new",
"-key",
str(key_path),
"-out",
str(out_path),
"-config",
cfg_path,
"-passin",
f"pass:{passphrase}",
]
_run(cmd)
finally:
os.unlink(cfg_path)
print(f"Wrote CSR: {out_path}")
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Create a CSR from an encrypted private key.")
parser.add_argument("--key-file", required=True, help="Path to encrypted private key PEM")
parser.add_argument("--subject", help="Subject (e.g., /C=US/ST=CA/CN=example.com)")
parser.add_argument("--cn", help="Common Name (CN) when building a subject from defaults")
parser.add_argument("--country", default="US", help="CountryName (C)")
parser.add_argument("--state", default="Alabama", help="StateName (ST)")
parser.add_argument("--organization", default="Regions Financial Corporation", help="OrganizationName (O)")
parser.add_argument("--org-unit", default="ECommerce", help="OrganizationITName (OU)")
parser.add_argument("--locality", default="Birmingham", help="LocalityName (L)")
parser.add_argument("--email", default="was@regions.com", help="emailAddress")
parser.add_argument(
"--san",
action="append",
help="SubjectAltName entries (comma-separated or repeatable)",
)
parser.add_argument("--out", default="./out", help="Output directory for CSR files")
parser.add_argument("--passphrase", help="Key passphrase (or set CERTCTL_KEY_PASSPHRASE)")
parser.add_argument("--stamp", help="Timestamp to use in the filename (e.g., 20260101-120000)")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Submit a CSR to a CA adapter and optionally collect the certificate."""
from __future__ import annotations
import argparse
import getpass
import json
import os
import time
import subprocess
from pathlib import Path
from typing import Optional
from certctl.ca.adcs import AdcsAdapter, AdcsConfig
from certctl.ca.selfsigned import SelfSignedAdapter, SelfSignedConfig
from certctl.secretstore import delete_secret, get_secret, is_available, set_secret
from certctl.ca.sectigo import SectigoAdapter, SectigoConfig
def _load_csr(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def _build_sectigo(args: argparse.Namespace) -> SectigoAdapter:
base_url = args.sectigo_base_url or os.environ.get("SECTIGO_BASE_URL", "https://cert-manager.com")
login = args.sectigo_login or os.environ.get("SECTIGO_LOGIN")
password = args.sectigo_password or os.environ.get("SECTIGO_PASSWORD")
customer_uri = args.sectigo_customer_uri or os.environ.get("SECTIGO_CUSTOMER_URI")
org_id = args.sectigo_org_id or os.environ.get("SECTIGO_ORG_ID")
cert_type = args.sectigo_cert_type or os.environ.get("SECTIGO_CERT_TYPE")
term_days = args.sectigo_term or os.environ.get("SECTIGO_TERM")
if not login:
login = input("Sectigo login: ")
if not password:
password = getpass.getpass("Sectigo password: ")
if not customer_uri:
customer_uri = input("Sectigo customerUri: ")
if not org_id or not cert_type or not term_days:
raise SystemExit("Sectigo org-id, cert-type, and term are required.")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
config = SectigoConfig(
base_url=base_url,
login=login,
password=password,
customer_uri=customer_uri,
org_id=int(org_id),
cert_type=int(cert_type),
term_days=int(term_days),
verify=verify,
)
return SectigoAdapter(config)
def _normalize_sans(values: Optional[str]) -> list[str]:
if not values:
return []
parts = [part.strip() for part in values.split(",") if part.strip()]
return parts
def _choose_ca(cn: Optional[str], sans: Optional[str]) -> str:
tokens = []
if cn:
tokens.append(cn)
tokens.extend(_normalize_sans(sans))
for token in tokens:
lowered = token.lower()
if "rgbk.com" in lowered:
return "adcs"
return "sectigo"
def _parse_csr_subject_and_sans(csr_path: str) -> tuple[Optional[str], Optional[str]]:
proc = subprocess.run(
["openssl", "req", "-in", csr_path, "-noout", "-text"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
text=True,
)
text = proc.stdout
cn = None
sans = []
saw_san = False
for line in text.splitlines():
line = line.strip()
if line.startswith("Subject:"):
parts = line.split("Subject:", 1)[1].split(",")
for part in parts:
part = part.strip()
if part.startswith("CN="):
cn = part.split("=", 1)[1].strip()
if "Subject Alternative Name" in line:
saw_san = True
continue
if saw_san:
if line.startswith("DNS:") or line.startswith("IP Address:"):
entries = [e.strip() for e in line.split(",")]
for entry in entries:
if entry.startswith("DNS:"):
sans.append(f"DNS:{entry[4:]}")
elif entry.startswith("IP Address:"):
sans.append(f"IP:{entry.split(':', 1)[1]}")
else:
saw_san = False
sans_value = ", ".join(sans) if sans else None
return cn, sans_value
def _load_json(path: str) -> dict:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _adcs_key(service: str, field: str) -> str:
return f"{service}:{field}"
def _load_adcs_keychain(service: str) -> dict:
if not is_available():
return {}
return {
"base_url": get_secret(service, _adcs_key(service, "base_url")),
"username": get_secret(service, _adcs_key(service, "username")),
"password": get_secret(service, _adcs_key(service, "password")),
"template": get_secret(service, _adcs_key(service, "template")),
}
def _save_adcs_keychain(service: str, values: dict) -> None:
if not is_available():
return
for field in ("base_url", "username", "password", "template"):
value = values.get(field)
if value:
set_secret(service, _adcs_key(service, field), str(value))
def _reset_adcs_keychain(service: str) -> None:
if not is_available():
return
for field in ("base_url", "username", "password", "template"):
delete_secret(service, _adcs_key(service, field))
def _build_adcs(args: argparse.Namespace) -> AdcsAdapter:
cfg = {}
if args.adcs_config:
cfg = _load_json(args.adcs_config)
service = args.adcs_keychain_service or "certctl.adcs"
if args.adcs_reset_keychain:
_reset_adcs_keychain(service)
keychain_values = _load_adcs_keychain(service)
base_url = (
args.adcs_base_url
or keychain_values.get("base_url")
or cfg.get("base_url")
or os.environ.get("ADCS_BASE_URL")
)
username = (
args.adcs_username
or keychain_values.get("username")
or cfg.get("username")
or os.environ.get("ADCS_USERNAME")
)
password = (
args.adcs_password
or keychain_values.get("password")
or cfg.get("password")
or os.environ.get("ADCS_PASSWORD")
)
template = (
args.adcs_template
or keychain_values.get("template")
or cfg.get("template")
or os.environ.get("ADCS_TEMPLATE")
)
if not base_url:
raise SystemExit("ADCS base URL is required.")
if not username:
username = input("ADCS username: ")
if not password:
password = getpass.getpass("ADCS password: ")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
if is_available() and (args.adcs_save_keychain or (not args.adcs_config and not keychain_values.get("password"))):
_save_adcs_keychain(
service,
{
"base_url": base_url,
"username": username,
"password": password,
"template": template or "",
},
)
return AdcsAdapter(
AdcsConfig(
base_url=base_url,
username=username,
password=password,
template=template,
verify=verify,
)
)
def _build_selfsigned(args: argparse.Namespace) -> SelfSignedAdapter:
passphrase = (
args.selfsign_passphrase
or os.environ.get("CERTCTL_SELFSIGN_PASSPHRASE")
or os.environ.get("CERTCTL_KEY_PASSPHRASE")
)
if not passphrase:
passphrase = getpass.getpass("Self-signed CA passphrase (AES-256): ")
if not passphrase:
raise SystemExit("Self-signed CA passphrase is required.")
return SelfSignedAdapter(
SelfSignedConfig(
ca_dir=args.selfsign_ca_dir,
passphrase=passphrase,
ca_days=args.selfsign_ca_days,
leaf_days=args.selfsign_leaf_days,
)
)
def run(args: argparse.Namespace) -> int:
csr_pem = _load_csr(args.csr)
ca = args.ca
if not ca and args.auto_ca:
cn = args.cn
san = args.san
if not cn and not san:
cn, san = _parse_csr_subject_and_sans(args.csr)
ca = _choose_ca(cn, san)
if not ca:
raise SystemExit("--ca is required unless --auto-ca is set.")
if ca == "sectigo":
adapter = _build_sectigo(args)
elif ca == "adcs":
adapter = _build_adcs(args)
else:
adapter = _build_selfsigned(args)
submit = adapter.submit_csr(csr_pem, subj_alt_names=args.sectigo_sans)
print(json.dumps({"ca": submit.ca, "request_id": submit.request_id}, indent=2))
if not args.wait:
return 0
deadline = time.time() + args.timeout
while True:
status = adapter.poll_status(submit.request_id)
if status.status.lower() == "issued":
cert_pem = adapter.collect_certificate(submit.request_id, format_name=args.collect_format)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(cert_pem, encoding="utf-8")
print(f"Wrote certificate: {out_path}")
if args.adcs_include_chain and hasattr(adapter, "collect_chain"):
chain_out = Path(args.adcs_chain_out)
chain_out.parent.mkdir(parents=True, exist_ok=True)
chain_pem = adapter.collect_chain(submit.request_id) # type: ignore[attr-defined]
chain_out.write_text(chain_pem, encoding="utf-8")
print(f"Wrote certificate chain: {chain_out}")
if args.selfsign_include_chain and hasattr(adapter, "collect_chain"):
chain_out = Path(args.selfsign_chain_out)
chain_out.parent.mkdir(parents=True, exist_ok=True)
chain_pem = adapter.collect_chain(submit.request_id) # type: ignore[attr-defined]
chain_out.write_text(chain_pem, encoding="utf-8")
print(f"Wrote self-signed chain: {chain_out}")
return 0
if time.time() > deadline:
raise SystemExit(f"Timed out waiting for issuance; last status: {status.status}")
print(f"Status: {status.status}; waiting {args.interval}s...")
time.sleep(args.interval)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Submit a CSR to a CA and collect the certificate.")
parser.add_argument("--ca", choices=["sectigo", "adcs", "selfsigned"], help="CA adapter to use")
parser.add_argument("--auto-ca", action="store_true", default=False, help="Auto-select CA based on CN/SANs")
parser.add_argument("--cn", help="Common Name for auto CA selection")
parser.add_argument("--san", help="SAN list for auto CA selection (comma-separated)")
parser.add_argument("--csr", required=True, help="Path to CSR PEM file")
parser.add_argument("--out", default="./out/cert.pem", help="Output certificate path")
parser.add_argument("--wait", action="store_true", default=False, help="Wait for issuance and collect")
parser.add_argument("--interval", type=int, default=30, help="Polling interval in seconds")
parser.add_argument("--timeout", type=int, default=1800, help="Polling timeout in seconds")
parser.add_argument("--collect-format", default="pem", help="Sectigo collect format")
parser.add_argument("--sectigo-base-url", help="Sectigo API base URL")
parser.add_argument("--sectigo-login", help="Sectigo login")
parser.add_argument("--sectigo-password", help="Sectigo password")
parser.add_argument("--sectigo-customer-uri", help="Sectigo customerUri")
parser.add_argument("--sectigo-org-id", type=int, help="Sectigo orgId")
parser.add_argument("--sectigo-cert-type", type=int, help="Sectigo certType profile ID")
parser.add_argument("--sectigo-term", type=int, help="Sectigo term in days")
parser.add_argument("--sectigo-sans", help="Comma-separated SANs for Sectigo")
parser.add_argument("--adcs-config", help="Path to ADCS JSON config")
parser.add_argument("--adcs-base-url", help="ADCS base URL, e.g. https://adcs/certsrv")
parser.add_argument("--adcs-username", help="ADCS username")
parser.add_argument("--adcs-password", help="ADCS password")
parser.add_argument("--adcs-template", help="ADCS certificate template (optional)")
parser.add_argument("--adcs-keychain-service", help="Keychain service name for ADCS creds")
parser.add_argument(
"--adcs-save-keychain",
action="store_true",
default=False,
help="Save ADCS creds to keychain when prompted",
)
parser.add_argument(
"--adcs-reset-keychain",
action="store_true",
default=False,
help="Reset (delete) ADCS creds stored in keychain",
)
parser.add_argument(
"--adcs-include-chain",
action="store_true",
default=False,
help="Collect certificate chain (ADCS only)",
)
parser.add_argument(
"--adcs-chain-out",
default="./out/chain.pem",
help="Output path for ADCS chain PEM",
)
parser.add_argument("--selfsign-ca-dir", default="./out/selfsigned", help="Output directory for self-signed CA")
parser.add_argument("--selfsign-passphrase", help="Self-signed CA key passphrase")
parser.add_argument("--selfsign-ca-days", type=int, default=60, help="Self-signed CA validity in days")
parser.add_argument("--selfsign-leaf-days", type=int, default=59, help="Self-signed leaf validity in days")
parser.add_argument(
"--selfsign-include-chain",
action="store_true",
default=False,
help="Write a combined leaf+CA chain (self-signed only)",
)
parser.add_argument(
"--selfsign-chain-out",
default="./out/selfsigned_chain.pem",
help="Output path for the self-signed chain PEM",
)
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="Path to CA bundle for TLS verification")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Deploy a Console certificate to Imperva WAF using a combined PEM."""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from certctl.console import NitroConsoleClient
def _parse_cn(subject: str) -> Optional[str]:
if not subject:
return None
match = re.search(r"(?:^|[,/\\s])CN\\s*=\\s*([^,/]+)", subject)
if match:
return match.group(1).strip()
return None
def _normalize_certkey_name(name: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "_", name or "").strip("_")
def _load_password(username: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {username}@{console}: ")
def _get_key_passphrase(provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_KEY_PASSPHRASE")
if env_pw:
return env_pw
return getpass.getpass("Key passphrase (AES-256): ")
def _list_certkeys(client: NitroConsoleClient) -> List[Dict[str, Any]]:
payload = client.get_json("/nitro/v2/config/ns_ssl_certkey", params={"pagesize": "200"})
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _find_certkey(client: NitroConsoleClient, name: str) -> Dict[str, Any]:
payload = client.get_json(
"/nitro/v2/config/ns_ssl_certkey",
params={"filter": f"certkeypair_name:{name}"},
)
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return items
if isinstance(items, list) and items:
return items[0]
normalized = _normalize_certkey_name(name)
if normalized and normalized != name:
payload = client.get_json(
"/nitro/v2/config/ns_ssl_certkey",
params={"filter": f"certkeypair_name:{normalized}"},
)
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return items
if isinstance(items, list) and items:
return items[0]
candidates = _list_certkeys(client)
matches = []
for cert in candidates:
subject = str(cert.get("subject") or cert.get("certificate_dn") or "")
cn = _parse_cn(subject)
if cn and cn.lower() == name.lower():
matches.append(cert)
if len(matches) == 1:
return matches[0]
if matches:
names = ", ".join(sorted({m.get("certkeypair_name", "unknown") for m in matches}))
raise SystemExit(f"Multiple certkeypairs match CN {name}: {names}")
raise SystemExit(f"Certkeypair not found: {name}")
def _first_value(cert: Dict[str, Any], keys: List[str]) -> Optional[str]:
for key in keys:
value = cert.get(key)
if value:
return str(value)
return None
def _extract_chain_files(cert: Dict[str, Any]) -> List[str]:
chain = cert.get("certkeychain") or []
if isinstance(chain, dict):
chain = [chain]
names = []
for entry in chain:
if not isinstance(entry, dict):
continue
name = entry.get("certificate_file_name") or entry.get("cert_name") or entry.get("linked_to")
if name and name not in names:
names.append(str(name))
return names
def _auth_type(cert: Dict[str, Any]) -> str:
algo = str(cert.get("public_key_algorithm") or cert.get("signature_algorithm") or "").upper()
if "EC" in algo or "ECDSA" in algo:
return "ECC"
return "RSA"
def _read_pem(content: bytes) -> str:
text = content.decode("utf-8", errors="replace")
return text if text.endswith("\n") else text + "\n"
def _b64(content: str) -> str:
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def run(args: argparse.Namespace) -> int:
if not args.console or not args.user:
raise SystemExit("Console URL and user are required.")
if not args.site_id:
raise SystemExit("Imperva site id is required (--site-id).")
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = _load_password(args.user, args.console, args.password)
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
cert = _find_certkey(client, args.certkeypair)
cert_file = _first_value(cert, ["certificate_file_name", "ssl_certificate", "file_name"])
key_file = _first_value(cert, ["key_file_name", "ssl_key", "key_name"])
if not cert_file:
raise SystemExit("Console certkey did not include certificate_file_name.")
if not key_file:
raise SystemExit("Console certkey did not include key_file_name.")
chain_files = _extract_chain_files(cert)
print(f"Using certificate: {cert_file}")
print(f"Using key: {key_file}")
if chain_files:
print(f"Using CA chain files: {', '.join(chain_files)}")
else:
print("No CA chain metadata found; Imperva upload will include only the leaf cert.")
cert_pem = _read_pem(client.download_file("ns_ssl_cert", cert_file))
key_pem = _read_pem(client.download_file("ns_ssl_key", key_file))
chain_pems = []
for name in chain_files:
chain_pems.append(_read_pem(client.download_file("ns_ssl_cert", name)))
full_chain = cert_pem + "".join(chain_pems)
key_passphrase = _get_key_passphrase(args.key_passphrase)
api_id = args.api_id or os.environ.get("IMPERVA_API_ID")
api_key = args.api_key or os.environ.get("IMPERVA_API_KEY")
if not api_id:
api_id = input("Imperva API Id: ")
if not api_key:
api_key = getpass.getpass("Imperva API Key: ")
payload = {
"certificate": _b64(full_chain),
"private_key": _b64(key_pem),
"auth_type": _auth_type(cert),
}
if key_passphrase:
payload["passphrase"] = key_passphrase
base_url = args.imperva_base.rstrip("/")
url = f"{base_url}/api/prov/v2/sites/{args.site_id}/customCertificate"
headers = {
"x-API-Id": api_id,
"x-API-Key": api_key,
"Content-Type": "application/json",
"Accept": "application/json",
}
resp = requests.put(url, headers=headers, json=payload, timeout=args.imperva_timeout)
if resp.status_code >= 400:
raise SystemExit(f"Imperva upload failed ({resp.status_code}): {resp.text}")
try:
data = resp.json()
except Exception:
data = {"raw": resp.text}
print(json.dumps(data, indent=2))
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Deploy a Console certificate to Imperva (combined PEM is built automatically)."
)
parser.add_argument("--certkeypair", required=True, help="Console certkeypair name or CN")
parser.add_argument("--console", help="Console base URL, e.g. https://console")
parser.add_argument("--user", help="Console username")
parser.add_argument("--password", help="Console password (or set CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification for Console")
parser.add_argument("--ca-bundle", help="Path to CA bundle for Console TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="Console HTTP timeout in seconds")
parser.add_argument("--key-passphrase", help="Passphrase for the private key (or CERTCTL_KEY_PASSPHRASE)")
parser.add_argument("--imperva-base", default="https://my.impervaservices.com", help="Imperva API base URL")
parser.add_argument("--imperva-timeout", type=int, default=60, help="Imperva HTTP timeout in seconds")
parser.add_argument("--site-id", required=True, help="Imperva site ID")
parser.add_argument("--api-id", help="Imperva API ID (or IMPERVA_API_ID)")
parser.add_argument("--api-key", help="Imperva API key (or IMPERVA_API_KEY)")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

108
certctl/scripts/keycsr.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Generate a key and CSR with a shared timestamp."""
from __future__ import annotations
import argparse
from pathlib import Path
from certctl.scripts import csr_create, keygen
def run(args: argparse.Namespace) -> int:
if not args.keychain_username:
args.keychain_username = keygen.getpass.getuser()
stamp = args.stamp or keygen._timestamp()
passphrase = keygen._get_passphrase(args)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
key_name = keygen._build_key_name(args.cn, stamp)
key_path = out_dir / key_name
keygen._generate_key(args.kind, key_path, passphrase)
print(f"Wrote key: {key_path}")
if args.upload_console:
if not args.console or not args.user:
raise SystemExit("Console URL and user are required for --upload-console.")
keygen._upload_to_console(args, key_path, passphrase)
print("Uploaded key to NetScaler Console.")
if args.register_console:
print("Registered key metadata in NetScaler Console.")
csr_args = argparse.Namespace(
key_file=str(key_path),
subject=args.subject,
cn=args.cn,
country=args.country,
state=args.state,
organization=args.organization,
org_unit=args.org_unit,
locality=args.locality,
email=args.email,
san=args.san,
out=args.out,
passphrase=passphrase,
stamp=stamp,
)
csr_create.run(csr_args)
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate a key and CSR with a shared timestamp.")
parser.add_argument("--cn", required=True, help="Common Name (CN) for key/CSR filenames")
parser.add_argument("--kind", choices=["rsa", "ecdsa"], required=True, help="Key type")
parser.add_argument("--out", default="./out", help="Output directory for key/CSR files")
parser.add_argument("--stamp", help="Timestamp to use in filenames (e.g., 20260101-120000)")
parser.add_argument("--passphrase", help="Key passphrase (or set CERTCTL_KEY_PASSPHRASE)")
parser.add_argument("--keychain-service", help="Keychain service name for passphrase storage")
parser.add_argument(
"--keychain-username",
default=None,
help="Keychain username (default: current user)",
)
parser.add_argument(
"--save-passphrase",
action="store_true",
default=False,
help="Store passphrase in keychain (requires keyring)",
)
parser.add_argument("--subject", help="Full subject string to use for CSR")
parser.add_argument("--country", default="US", help="CountryName (C)")
parser.add_argument("--state", default="Alabama", help="StateName (ST)")
parser.add_argument("--organization", default="Regions Financial Corporation", help="OrganizationName (O)")
parser.add_argument("--org-unit", default="ECommerce", help="OrganizationITName (OU)")
parser.add_argument("--locality", default="Birmingham", help="LocalityName (L)")
parser.add_argument("--email", default="was@regions.com", help="emailAddress")
parser.add_argument("--san", action="append", help="SubjectAltName entry (repeatable)")
parser.add_argument(
"--upload-console",
action="store_true",
default=False,
help="Upload the key to NetScaler Console",
)
parser.add_argument(
"--register-console",
action="store_true",
default=False,
help="Register key metadata in NetScaler Console after upload",
)
parser.add_argument("--console", help="Console base URL, e.g. https://console")
parser.add_argument("--user", help="Console username")
parser.add_argument("--console-password", help="Console password (or set CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="Path to CA bundle for TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

203
certctl/scripts/keygen.py Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Generate an AES-encrypted RSA or EC private key in PEM format."""
from __future__ import annotations
import argparse
import getpass
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from certctl.console import NitroConsoleClient
from certctl.secretstore import get_secret, is_available, set_secret
DEFAULT_RSA_BITS = 4096
DEFAULT_EC_CURVE = "secp384r1"
def _require_openssl() -> str:
path = shutil.which("openssl")
if not path:
raise SystemExit("OpenSSL not found in PATH.")
return path
def _timestamp() -> str:
import datetime as dt
return dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def _build_key_name(cn: str, stamp: str) -> str:
return f"{cn}-{stamp}.key"
def _get_passphrase(args: argparse.Namespace) -> str:
if args.passphrase:
return args.passphrase
env = os.environ.get("CERTCTL_KEY_PASSPHRASE")
if env:
return env
if args.keychain_service and is_available():
stored = get_secret(args.keychain_service, args.keychain_username)
if stored:
return stored
while True:
pw = getpass.getpass("Key passphrase (AES-256): ")
confirm = getpass.getpass("Confirm passphrase: ")
if pw and pw == confirm:
if args.keychain_service and args.save_passphrase:
set_secret(args.keychain_service, args.keychain_username, pw)
return pw
print("Passphrases did not match, try again.")
def _run(cmd: list[str], env: Optional[dict[str, str]] = None) -> None:
subprocess.run(cmd, check=True, env=env)
def _generate_key(kind: str, out_path: Path, passphrase: str) -> None:
_require_openssl()
if kind == "rsa":
cmd = [
"openssl",
"genpkey",
"-algorithm",
"RSA",
"-pkeyopt",
f"rsa_keygen_bits:{DEFAULT_RSA_BITS}",
"-aes-256-cbc",
"-pass",
f"pass:{passphrase}",
"-out",
str(out_path),
]
else:
cmd = [
"openssl",
"genpkey",
"-algorithm",
"EC",
"-pkeyopt",
f"ec_paramgen_curve:{DEFAULT_EC_CURVE}",
"-pkeyopt",
"ec_param_enc:named_curve",
"-aes-256-cbc",
"-pass",
f"pass:{passphrase}",
"-out",
str(out_path),
]
_run(cmd)
os.chmod(out_path, 0o600)
def _upload_to_console(args: argparse.Namespace, key_path: Path, passphrase: str) -> None:
if not args.upload_console:
return
verify: object
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = args.console_password or os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if not password:
password = getpass.getpass(f"Console password for {args.user}@{args.console}: ")
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
client.upload_file(
"/nitro/v2/upload/ns_ssl_key",
str(key_path),
basic_user=args.user,
basic_password=password,
)
if args.register_console:
algo = "RSA" if args.kind == "rsa" else "ECDSA"
keysize = DEFAULT_RSA_BITS if args.kind == "rsa" else None
ec_curve = DEFAULT_EC_CURVE if args.kind == "ecdsa" else None
client.create_key(
key_path.name,
algo=algo,
keyform="PEM",
keysize=keysize,
ec_curve=ec_curve,
password=passphrase,
)
def run(args: argparse.Namespace) -> int:
passphrase = _get_passphrase(args)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
stamp = args.stamp or _timestamp()
key_name = _build_key_name(args.cn, stamp)
key_path = out_dir / key_name
_generate_key(args.kind, key_path, passphrase)
print(f"Wrote key: {key_path}")
if args.upload_console:
if not args.console or not args.user:
raise SystemExit("Console URL and user are required for --upload-console.")
_upload_to_console(args, key_path, passphrase)
print("Uploaded key to NetScaler Console.")
if args.register_console:
print("Registered key metadata in NetScaler Console.")
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate an AES-encrypted RSA or EC private key.")
parser.add_argument("--cn", required=True, help="Common Name (CN) for the key filename")
parser.add_argument("--kind", choices=["rsa", "ecdsa"], required=True, help="Key type")
parser.add_argument("--out", default="./out", help="Output directory for key files")
parser.add_argument("--stamp", help="Timestamp to use in the filename (e.g., 20260101-120000)")
parser.add_argument("--passphrase", help="Key passphrase (or set CERTCTL_KEY_PASSPHRASE)")
parser.add_argument("--keychain-service", help="Keychain service name for passphrase storage")
parser.add_argument(
"--keychain-username",
default=getpass.getuser(),
help="Keychain username (default: current user)",
)
parser.add_argument(
"--save-passphrase",
action="store_true",
default=False,
help="Store passphrase in keychain (requires keyring)",
)
parser.add_argument(
"--upload-console",
action="store_true",
default=False,
help="Upload the key to NetScaler Console",
)
parser.add_argument(
"--register-console",
action="store_true",
default=False,
help="Register key metadata in NetScaler Console after upload",
)
parser.add_argument("--console", help="Console base URL, e.g. https://console")
parser.add_argument("--user", help="Console username")
parser.add_argument("--console-password", help="Console password (or set CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="Path to CA bundle for TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""Poll NetScaler Console for SSL cert inventory and write a report."""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import getpass
import json
import os
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from certctl.console import NitroConsoleClient
DEFAULT_TIMEOUT = 60
MAX_CELL = 60
def _now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def _to_int(value: Any) -> int:
try:
return int(value)
except Exception:
return 0
def _clip(value: Any, width: int = MAX_CELL) -> str:
text = "" if value is None else str(value)
if len(text) <= width:
return text
return text[: max(0, width - 3)] + "..."
def _is_in_use(item: Dict[str, Any]) -> bool:
if _to_int(item.get("no_of_bound_entities")) > 0:
return True
status = (item.get("certkey_status") or item.get("status") or "").upper()
return status == "ACTIVE"
def _extract_row(item: Dict[str, Any], mapping_index: Dict[str, List[Dict[str, Any]]]) -> Dict[str, Any]:
bindings = _normalize_bindings(item.get("entity_binding_arr"))
entity_names = _join_unique(binding.get("entity_name") for binding in bindings)
entity_types = _join_unique(binding.get("entity_type") for binding in bindings)
binding_devices = _join_unique(
binding.get("display_name") or binding.get("hostname") for binding in bindings
)
cert_id = item.get("cert_store_id") or ""
mappings = mapping_index.get(str(cert_id), [])
mapping_entities = _join_unique(mapping.get("entity_name") for mapping in mappings)
mapping_entity_types = _join_unique(mapping.get("entity_type") for mapping in mappings)
mapping_instances = _join_unique(
mapping.get("instance_display_name") or mapping.get("instance_host_name") for mapping in mappings
)
mapping_instance_ips = _join_unique(mapping.get("instance_ip") for mapping in mappings)
return {
"certkeypair_name": item.get("certkeypair_name") or "",
"device_name": item.get("device_name") or item.get("display_name") or item.get("hostname") or "",
"ns_ip_address": item.get("ns_ip_address") or "",
"subject": item.get("subject") or "",
"issuer": item.get("issuer") or item.get("issuer_cn") or "",
"certificate_dn": item.get("certificate_dn") or "",
"valid_from": item.get("valid_from") or "",
"valid_to": item.get("valid_to") or "",
"days_to_expiry": item.get("days_to_expiry") or "",
"no_of_bound_entities": item.get("no_of_bound_entities") or "",
"certkey_status": item.get("certkey_status") or item.get("status") or "",
"binding_count": len(bindings),
"binding_entities": entity_names,
"binding_types": entity_types,
"binding_devices": binding_devices,
"mapping_count": len(mappings),
"mapping_entities": mapping_entities,
"mapping_entity_types": mapping_entity_types,
"mapping_instances": mapping_instances,
"mapping_instance_ips": mapping_instance_ips,
}
def _normalize_bindings(value: Any) -> List[Dict[str, Any]]:
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
return [value]
return []
def _normalize_mappings(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
items = payload.get("cert_store_mapping", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _join_unique(values: Iterable[Optional[str]]) -> str:
seen = []
for value in values:
if not value:
continue
if value not in seen:
seen.append(value)
return ", ".join(seen)
def _render_table(rows: Iterable[Dict[str, Any]], columns: List[str]) -> str:
rows_list = list(rows)
widths = {col: len(col) for col in columns}
for row in rows_list:
for col in columns:
widths[col] = max(widths[col], len(_clip(row.get(col, ""))))
def fmt_row(row: Dict[str, Any]) -> str:
return " ".join(_clip(row.get(col, "")).ljust(widths[col]) for col in columns)
header = fmt_row({col: col for col in columns})
sep = " ".join("-" * widths[col] for col in columns)
body = "\n".join(fmt_row(row) for row in rows_list)
return "\n".join([header, sep, body]).strip()
def _write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
def _write_csv(path: Path, rows: List[Dict[str, Any]], columns: List[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
for row in rows:
writer.writerow({col: row.get(col, "") for col in columns})
def _load_password(username: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {username}@{console}: ")
def _normalize_items(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return items
return []
def _load_config(path: str) -> Dict[str, Any]:
config_path = Path(path).expanduser()
raw = config_path.read_text(encoding="utf-8")
if config_path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml # type: ignore
except Exception as exc: # pragma: no cover - optional dependency
raise RuntimeError("PyYAML is required for YAML config files.") from exc
data = yaml.safe_load(raw) or {}
else:
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise RuntimeError("Config file must contain a JSON/YAML object at the root.")
return data
def _apply_config(args: argparse.Namespace) -> argparse.Namespace:
if not args.config:
return args
data = _load_config(args.config)
defaults = data.get("defaults", {}) if isinstance(data.get("defaults"), dict) else {}
consoles = data.get("consoles", {}) if isinstance(data.get("consoles"), dict) else {}
profile = args.profile or "default"
profile_data = consoles.get(profile, {}) if isinstance(consoles.get(profile), dict) else {}
# Merge defaults then profile, then explicit CLI values.
merged: Dict[str, Any] = {}
merged.update(defaults)
merged.update(profile_data)
def pick(name: str, current: Any, fallback: Any = None) -> Any:
if current is not None:
return current
return merged.get(name, fallback)
args.console = pick("console", args.console) or merged.get("url", args.console)
args.user = pick("user", args.user)
args.ca_bundle = pick("ca_bundle", args.ca_bundle)
args.insecure = bool(pick("insecure", args.insecure, False))
args.timeout = pick("timeout", args.timeout, DEFAULT_TIMEOUT)
args.format = pick("format", args.format, "table")
args.out = pick("out", args.out)
args.all = bool(pick("all", args.all, False))
args.inventory = bool(pick("inventory", args.inventory, False))
args.include_mappings = bool(pick("include_mappings", args.include_mappings, False))
args.expires_within = pick("expires_within", args.expires_within)
args.filter = pick("filter", args.filter)
return args
def _matches_filter(item: Dict[str, Any], needle: str) -> bool:
hay_fields = [
item.get("certkeypair_name"),
item.get("subject"),
item.get("certificate_dn"),
item.get("issuer"),
item.get("issuer_cn"),
item.get("device_name"),
item.get("display_name"),
item.get("hostname"),
item.get("ns_ip_address"),
]
haystack = " ".join(str(value) for value in hay_fields if value)
return needle.lower() in haystack.lower()
def run(args: argparse.Namespace) -> int:
args = _apply_config(args)
if args.timeout is None:
args.timeout = DEFAULT_TIMEOUT
if args.format is None:
args.format = "table"
if args.insecure is None:
args.insecure = False
if args.all is None:
args.all = False
if args.inventory is None:
args.inventory = False
if args.include_mappings is None:
args.include_mappings = False
if args.expires_within is None:
args.expires_within = None
if args.filter is None:
args.filter = None
if not args.console or not args.user:
raise SystemExit("Console URL and user are required (use CLI args or --config/--profile).")
verify: Any
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = _load_password(args.user, args.console, args.password)
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
if args.inventory:
client.post_json(
"/nitro/v2/config/ns_ssl_certkey",
{"ns_ssl_certkey": {}},
params={"action": "inventory"},
)
payload = client.get_json("/nitro/v2/config/ns_ssl_certkey")
items = _normalize_items(payload)
if args.filter:
items = [item for item in items if _matches_filter(item, args.filter)]
mapping_index: Dict[str, List[Dict[str, Any]]] = {}
if args.include_mappings:
mapping_payload = client.get_json("/nitro/v2/config/cert_store_mapping")
mappings = _normalize_mappings(mapping_payload)
for mapping in mappings:
cert_id = mapping.get("cert_id")
if cert_id is None:
continue
mapping_index.setdefault(str(cert_id), []).append(mapping)
if not args.all:
items = [item for item in items if _is_in_use(item)]
if args.expires_within is not None:
cutoff = int(args.expires_within)
filtered = []
for item in items:
raw = item.get("days_to_expiry")
days = _to_int(raw) if raw not in (None, "") else 7
if days <= cutoff:
filtered.append(item)
items = filtered
rows = [_extract_row(item, mapping_index) for item in items]
rows.sort(key=lambda row: (row.get("subject") or "").lower())
columns = [
"certkeypair_name",
"device_name",
"ns_ip_address",
"certkey_status",
"no_of_bound_entities",
"binding_count",
"binding_types",
"binding_devices",
"binding_entities",
"mapping_count",
"mapping_entity_types",
"mapping_instances",
"mapping_instance_ips",
"mapping_entities",
"days_to_expiry",
"valid_to",
"subject",
"certificate_dn",
]
report: Optional[Dict[str, Any]] = None
output: Optional[str] = None
if args.format == "json":
report = {
"generated_at": _now_utc().isoformat(),
"count": len(rows),
"items": rows,
}
output = json.dumps(report, indent=2, sort_keys=True)
elif args.format == "table":
output = _render_table(rows, columns)
if args.out:
out_path = Path(args.out)
if args.format == "json":
_write_json(out_path, report or {"generated_at": _now_utc().isoformat(), "count": 0, "items": []})
elif args.format == "csv":
_write_csv(out_path, rows, columns)
else:
_write_text(out_path, output)
else:
if args.format == "csv":
writer = csv.DictWriter(os.sys.stdout, fieldnames=columns)
writer.writeheader()
for row in rows:
writer.writerow({col: row.get(col, "") for col in columns})
else:
print(output)
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Poll NetScaler Console for SSL cert inventory and output a report."
)
parser.add_argument("--console", help="Console base URL, e.g. https://console")
parser.add_argument("--user", help="Console username")
parser.add_argument("--password", help="Console password (or set CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--config", help="Path to JSON/YAML config file")
parser.add_argument("--profile", help="Config profile name (default: default)")
parser.add_argument("--insecure", action="store_true", default=None, help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="Path to CA bundle for TLS verification")
parser.add_argument("--timeout", type=int, default=None, help="HTTP timeout in seconds")
parser.add_argument(
"--format",
choices=["table", "csv", "json"],
default=None,
help="Report format",
)
parser.add_argument("--out", help="Write report to a file instead of stdout")
parser.add_argument(
"--all",
action="store_true",
default=None,
help="Include unbound or inactive certs (default is in-use only)",
)
parser.add_argument(
"--inventory",
action="store_true",
default=None,
help="Trigger inventory refresh before fetching certs",
)
parser.add_argument(
"--include-mappings",
action="store_true",
default=None,
help="Include cert_store_mapping data in the report",
)
parser.add_argument(
"--expires-within",
type=int,
default=None,
help="Only include certs expiring within N days",
)
parser.add_argument(
"--filter",
help="Filter by substring match (name, subject, issuer, device, or IP)",
)
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Poll all configured NetScaler Consoles and write per-profile reports."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, Dict
from certctl.scripts import nsconsole_certpoll
def _load_config(path: str) -> Dict[str, Any]:
config_path = Path(path).expanduser()
raw = config_path.read_text(encoding="utf-8")
if config_path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml # type: ignore
except Exception as exc: # pragma: no cover - optional dependency
raise RuntimeError("PyYAML is required for YAML config files.") from exc
data = yaml.safe_load(raw) or {}
else:
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise RuntimeError("Config file must contain a JSON/YAML object at the root.")
return data
def _ext_for_format(fmt: str) -> str:
return {"json": "json", "csv": "csv"}.get(fmt, "txt")
def _collect_items(format_name: str, inputs: Dict[str, Path]) -> list[Dict[str, Any]]:
items: list[Dict[str, Any]] = []
if format_name == "csv":
import csv
for profile, path in inputs.items():
with path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
for row in reader:
row = dict(row)
row["profile"] = profile
items.append(row)
else:
for profile, path in inputs.items():
payload = json.loads(path.read_text(encoding="utf-8"))
rows = payload.get("items", [])
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict):
row = dict(row)
row["profile"] = profile
items.append(row)
return items
def _merge_reports(format_name: str, inputs: Dict[str, Path], output: Path) -> None:
merged: Dict[str, Any] = {
"generated_at": None,
"profiles": {},
"count": 0,
"items": [],
}
if format_name == "csv":
for profile, path in inputs.items():
rows = _collect_items("csv", {profile: path})
merged["profiles"][profile] = {"generated_at": None, "count": len(rows)}
merged["items"].extend(rows)
merged["count"] += len(rows)
else:
for profile, path in inputs.items():
payload = json.loads(path.read_text(encoding="utf-8"))
merged["profiles"][profile] = {
"generated_at": payload.get("generated_at"),
"count": payload.get("count", 0),
}
if merged["generated_at"] is None:
merged["generated_at"] = payload.get("generated_at")
rows = _collect_items("json", {profile: path})
merged["items"].extend(rows)
merged["count"] += payload.get("count", 0)
output.parent.mkdir(parents=True, exist_ok=True)
if format_name == "csv":
# Build a union of keys to ensure CSV columns cover all profiles.
keys = []
for item in merged["items"]:
for key in item.keys():
if key not in keys:
keys.append(key)
if "profile" not in keys:
keys.insert(0, "profile")
with output.open("w", newline="", encoding="utf-8") as handle:
import csv
writer = csv.DictWriter(handle, fieldnames=keys)
writer.writeheader()
for item in merged["items"]:
writer.writerow({k: item.get(k, "") for k in keys})
else:
output.write_text(json.dumps(merged, indent=2, sort_keys=True), encoding="utf-8")
def _write_rollup(format_name: str, items: list[Dict[str, Any]], output: Path) -> None:
groups: Dict[str, Dict[str, Any]] = {}
for item in items:
subject = (item.get("subject") or "").strip() or "(unknown)"
entry = groups.setdefault(
subject,
{
"subject": subject,
"count": 0,
"profiles": [],
"certkeypair_names": [],
},
)
entry["count"] += 1
profile = item.get("profile")
if profile and profile not in entry["profiles"]:
entry["profiles"].append(profile)
name = item.get("certkeypair_name")
if name and name not in entry["certkeypair_names"]:
entry["certkeypair_names"].append(name)
rollup = {
"count_subjects": len(groups),
"subjects": sorted(groups.values(), key=lambda x: x["subject"].lower()),
}
output.parent.mkdir(parents=True, exist_ok=True)
if format_name == "csv":
import csv
with output.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=["subject", "count", "profiles", "certkeypair_names"],
)
writer.writeheader()
for entry in rollup["subjects"]:
writer.writerow(
{
"subject": entry["subject"],
"count": entry["count"],
"profiles": ", ".join(entry["profiles"]),
"certkeypair_names": ", ".join(entry["certkeypair_names"]),
}
)
else:
output.write_text(json.dumps(rollup, indent=2, sort_keys=True), encoding="utf-8")
def run(args: argparse.Namespace) -> int:
data = _load_config(args.config)
consoles = data.get("consoles", {})
if not isinstance(consoles, dict) or not consoles:
raise SystemExit("Config must include a non-empty 'consoles' mapping.")
out_dir = Path(args.out_dir).expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
profiles = [args.profile] if args.profile else list(consoles.keys())
report_paths: Dict[str, Path] = {}
for profile in profiles:
if profile not in consoles:
raise SystemExit(f"Profile '{profile}' not found in config.")
ext = _ext_for_format(args.format)
out_path = out_dir / f"{profile}.{ext}"
poll_args = argparse.Namespace(
console=None,
user=None,
password=None,
config=args.config,
profile=profile,
insecure=None,
ca_bundle=None,
timeout=None,
format=args.format,
out=str(out_path),
all=args.all,
inventory=args.inventory,
include_mappings=args.include_mappings,
expires_within=args.expires_within,
)
nsconsole_certpoll.run(poll_args)
report_paths[profile] = out_path
print(f"[{profile}] wrote {args.format} report to {out_path}")
if args.merge:
if args.format not in ("json", "csv"):
raise SystemExit("--merge is only supported with --format json or csv.")
merged_path = out_dir / f"all.{_ext_for_format(args.format)}"
_merge_reports(args.format, report_paths, merged_path)
print(f"[all] wrote merged {args.format} report to {merged_path}")
if args.rollup:
if args.format not in ("json", "csv"):
raise SystemExit("--rollup is only supported with --format json or csv.")
items = _collect_items(args.format, report_paths)
rollup_path = out_dir / f"rollup_subjects.{_ext_for_format(args.format)}"
_write_rollup(args.format, items, rollup_path)
print(f"[rollup] wrote subject rollup to {rollup_path}")
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Poll all configured NetScaler Consoles and write per-profile reports."
)
parser.add_argument("--config", required=True, help="Path to JSON/YAML config file")
parser.add_argument("--out-dir", default="./reports", help="Output directory for reports")
parser.add_argument(
"--format",
choices=["table", "csv", "json"],
default="json",
help="Report format",
)
parser.add_argument("--profile", help="Only run a single profile")
parser.add_argument(
"--all",
action="store_true",
default=None,
help="Include unbound or inactive certs (default is in-use only)",
)
parser.add_argument(
"--inventory",
action="store_true",
default=None,
help="Trigger inventory refresh before fetching certs",
)
parser.add_argument(
"--include-mappings",
action="store_true",
default=None,
help="Include cert_store_mapping data in the report",
)
parser.add_argument(
"--merge",
action="store_true",
default=False,
help="Write a combined report across all profiles (json/csv only)",
)
parser.add_argument(
"--rollup",
action="store_true",
default=False,
help="Write a subject rollup across all profiles (json/csv only)",
)
parser.add_argument(
"--expires-within",
type=int,
default=None,
help="Only include certs expiring within N days",
)
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""Deploy a Console certkey to ADCs and link CA certs."""
from __future__ import annotations
import argparse
import getpass
import json
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from certctl.console import NitroConsoleClient, NitroError
_DEBUG = False
def _set_debug(enabled: bool) -> None:
global _DEBUG
_DEBUG = bool(enabled)
def _redact_payload(value: Any) -> Any:
if isinstance(value, dict):
redacted = {}
for key, item in value.items():
if key in {"password", "key_data", "certificate_data", "token", "secret"}:
redacted[key] = "<redacted>"
continue
if key == "cert_data" and isinstance(item, dict):
redacted[key] = {**item, "file_data": "<redacted>"} if "file_data" in item else item
continue
if key == "certchain_data" and isinstance(item, list):
redacted[key] = [
{**entry, "file_data": "<redacted>"} if isinstance(entry, dict) and "file_data" in entry else entry
for entry in item
]
continue
redacted[key] = _redact_payload(item)
return redacted
if isinstance(value, list):
return [_redact_payload(item) for item in value]
return value
def _debug_payload(label: str, payload: Dict[str, Any]) -> None:
if not _DEBUG:
return
print(f"[debug] {label}:")
print(json.dumps(_redact_payload(payload), indent=2, sort_keys=True))
def _load_config(path: str) -> Dict[str, Any]:
config_path = Path(path).expanduser()
raw = config_path.read_text(encoding="utf-8")
if config_path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml # type: ignore
except Exception as exc: # pragma: no cover - optional dependency
raise RuntimeError("PyYAML is required for YAML config files.") from exc
data = yaml.safe_load(raw) or {}
else:
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise RuntimeError("Config file must contain a JSON/YAML object at the root.")
return data
def _apply_config(args: argparse.Namespace) -> argparse.Namespace:
if not args.config:
return args
data = _load_config(args.config)
defaults = data.get("defaults", {}) if isinstance(data.get("defaults"), dict) else {}
consoles = data.get("consoles", {}) if isinstance(data.get("consoles"), dict) else {}
profile = args.profile or data.get("default_profile") or "default"
profile_data = consoles.get(profile, {}) if isinstance(consoles.get(profile), dict) else {}
merged: Dict[str, Any] = {}
merged.update(defaults)
merged.update(profile_data)
def pick(name: str, current: Any, fallback: Any = None) -> Any:
if current is not None:
return current
return merged.get(name, fallback)
args.console = pick("console", args.console) or merged.get("url", args.console)
args.user = pick("user", args.user)
args.ca_bundle = pick("ca_bundle", args.ca_bundle)
args.insecure = bool(pick("insecure", args.insecure, False))
args.timeout = pick("timeout", args.timeout, 60)
return args
def _load_password(username: str, console: str, provided: Optional[str]) -> str:
if provided:
return provided
env_pw = os.environ.get("CERTCTL_CONSOLE_PASSWORD")
if env_pw:
return env_pw
return getpass.getpass(f"Console password for {username}@{console}: ")
def _parse_cn(subject: str) -> Optional[str]:
if not subject:
return None
match = re.search(r"(?:^|[,/\\s])CN\\s*=\\s*([^,/]+)", subject)
if match:
return match.group(1).strip()
return None
def _list_certkeys(client: NitroConsoleClient) -> List[Dict[str, Any]]:
payload = client.get_json("/nitro/v2/config/ns_ssl_certkey", params={"pagesize": "200"})
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _find_certkey(client: NitroConsoleClient, name: str, *, resolve_cn: bool) -> Dict[str, Any]:
payload = client.get_json(
"/nitro/v2/config/ns_ssl_certkey",
params={"filter": f"certkeypair_name:{name}"},
)
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return items
if isinstance(items, list) and items:
return items[0]
normalized = _normalize_certkey_name(name)
if normalized and normalized != name:
payload = client.get_json(
"/nitro/v2/config/ns_ssl_certkey",
params={"filter": f"certkeypair_name:{normalized}"},
)
items = payload.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return items
if isinstance(items, list) and items:
return items[0]
if not resolve_cn:
raise SystemExit(f"Certkeypair not found: {name}")
candidates = _list_certkeys(client)
matches = []
for cert in candidates:
subject = str(cert.get("subject") or cert.get("certificate_dn") or "")
cn = _parse_cn(subject)
if cn and cn.lower() == name.lower():
matches.append(cert)
if len(matches) == 1:
return matches[0]
if matches:
names = ", ".join(sorted({m.get("certkeypair_name", "unknown") for m in matches}))
raise SystemExit(f"Multiple certkeypairs match CN {name}: {names}")
raise SystemExit(f"Certkeypair not found: {name}")
def _trigger_inventory(client: NitroConsoleClient) -> None:
client.post_json(
"/nitro/v2/config/ns_ssl_certkey",
{"ns_ssl_certkey": {}},
params={"action": "inventory"},
)
def _poll_adcs(client: NitroConsoleClient, ns_ips: List[str]) -> None:
if not ns_ips:
return
payload = {"ns_ssl_certkey_policy": {"ns_ip_address": ns_ips}}
client.post_json("/nitro/v2/config/ns_ssl_certkey_policy", payload, params={"action": "do_poll"})
def _normalize_certkey_name(name: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "_", name or "").strip("_")
def _list_entity_certs(client: NitroConsoleClient, ns_ip: str) -> List[Dict[str, Any]]:
payload = {"ns_ssl_certkey": {"source_ipaddress": ns_ip}}
data = client.post_json("/nitro/v2/config/ns_ssl_certkey", payload, params={"action": "list_entity_cert"})
items = data.get("ns_ssl_certkey", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _try_import_with_name(
client: NitroConsoleClient,
target_name: str,
*,
ns_ip: str,
source_name: str,
) -> Tuple[bool, Optional[str]]:
payload = {
"ns_ssl_certkey": {
"certkeypair_name": target_name,
"ns_ip_address_arr": [ns_ip],
"source_ipaddress": ns_ip,
"source_certificate": source_name,
}
}
try:
_debug_payload("ns_ssl_certkey POST (import)", payload)
client.post_json("/nitro/v2/config/ns_ssl_certkey", payload)
except NitroError as exc:
return False, f"{exc.status_code} {exc.message}"
return True, None
def _match_adc_cert(certs: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
matches = []
normalized = _normalize_certkey_name(name)
for cert in certs:
cert_name = str(cert.get("certkeypair_name") or "")
if cert_name.lower() == name.lower():
matches.append(cert)
continue
if normalized and _normalize_certkey_name(cert_name).lower() == normalized.lower():
matches.append(cert)
continue
subject = str(cert.get("subject") or cert.get("certificate_dn") or "")
cn = _parse_cn(subject)
if cn and cn.lower() == name.lower():
matches.append(cert)
if len(matches) == 1:
return matches[0]
if matches:
names = ", ".join(sorted({m.get("certkeypair_name", "unknown") for m in matches}))
raise SystemExit(f"Multiple ADC certs match {name}: {names}")
return None
def _import_from_adc(
client: NitroConsoleClient,
target_name: str,
*,
adc_ips: List[str],
) -> bool:
last_error = None
for ns_ip in adc_ips:
try:
certs = _list_entity_certs(client, ns_ip)
except NitroError as exc:
message = str(exc.message).lower()
if exc.status_code not in (404, 405) and "not supported" not in message:
raise
candidates = [target_name, _normalize_certkey_name(target_name)]
seen = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
ok, err = _try_import_with_name(client, target_name, ns_ip=ns_ip, source_name=candidate)
if ok:
return True
last_error = err
continue
match = _match_adc_cert(certs, target_name)
if not match:
continue
source_name = match.get("certkeypair_name") or target_name
ok, err = _try_import_with_name(client, target_name, ns_ip=ns_ip, source_name=source_name)
if ok:
return True
last_error = err
if last_error:
raise SystemExit(f"Console import failed: {last_error}")
return False
def _find_certkey_with_sync(
client: NitroConsoleClient,
name: str,
*,
resolve_cn: bool,
sync: bool,
sync_wait: int,
import_missing: bool,
import_wait: int,
import_adc_ips: List[str],
) -> Dict[str, Any]:
try:
return _find_certkey(client, name, resolve_cn=resolve_cn)
except SystemExit:
pass
if sync:
_trigger_inventory(client)
if sync_wait > 0:
time.sleep(sync_wait)
try:
return _find_certkey(client, name, resolve_cn=resolve_cn)
except SystemExit:
pass
if not import_missing:
raise SystemExit(f"Certkeypair not found: {name}")
if not import_adc_ips:
raise SystemExit("Cannot import missing cert without --adc-ip or --list-adc menu selection.")
if not _import_from_adc(client, name, adc_ips=import_adc_ips):
raise SystemExit(f"Certkeypair not found on ADCs: {name}")
print(f"Imported cert from ADCs for {name}. Refreshing Console inventory...")
_trigger_inventory(client)
if import_wait > 0:
print(f"Waiting {import_wait} seconds as requested...")
time.sleep(import_wait)
try:
return _find_certkey(client, name, resolve_cn=resolve_cn)
except SystemExit as exc:
raise SystemExit(
f"Imported cert from ADC but not yet visible in Console inventory: {name}. "
"Try --import-wait or --sync --sync-wait."
) from exc
def _extract_bindings(cert: Dict[str, Any]) -> List[Dict[str, Any]]:
bindings = cert.get("entity_binding_arr") or []
if isinstance(bindings, dict):
bindings = [bindings]
if isinstance(bindings, list) and bindings:
return [b for b in bindings if isinstance(b, dict)]
ips = cert.get("ns_ip_address_arr") or []
if isinstance(ips, str):
ips = [ips]
if isinstance(ips, list):
return [{"ns_ip_address": ip} for ip in ips]
return []
def _bind_cert(
client: NitroConsoleClient,
certkeypair: str,
cert_id: Optional[str],
binding: Dict[str, Any],
) -> None:
payload = {
"ns_ssl_certkey": {
"certkeypair_name": certkeypair,
"entity_binding_arr": [
{
"certkeypair_name": certkeypair,
"ns_ip_address": binding.get("ns_ip_address"),
"id": binding.get("id"),
}
],
}
}
try:
_debug_payload("ns_ssl_certkey POST (bind_cert)", payload)
client.post_json("/nitro/v2/config/ns_ssl_certkey", payload, params={"action": "bind_cert"})
return
except NitroError as exc:
message = str(exc.message).lower()
if exc.status_code not in (404, 405) and "not supported" not in message:
raise
if not cert_id:
raise SystemExit("Console does not support bind_cert and cert id is missing for fallback.")
modify_payload = {
"ns_ssl_certkey": {
"id": cert_id,
"certkeypair_name": certkeypair,
"entity_binding_arr": payload["ns_ssl_certkey"]["entity_binding_arr"],
}
}
_debug_payload("ns_ssl_certkey PUT (bind_cert fallback)", modify_payload)
client.put_json(f"/nitro/v2/config/ns_ssl_certkey/{cert_id}", modify_payload)
def _cert_exists(client: NitroConsoleClient, filename: str) -> bool:
try:
client.get_json(f"/nitro/v2/config/ns_ssl_cert/{filename}")
return True
except Exception:
return False
def _certkey_exists(client: NitroConsoleClient, name: str) -> bool:
try:
_find_certkey(client, name)
return True
except SystemExit:
return False
def _upload_ca_cert(
client: NitroConsoleClient,
ca_name: str,
cert_path: Path,
ns_ip_addresses: List[str],
*,
client_user: str,
client_password: str,
) -> None:
if not _cert_exists(client, cert_path.name):
client.upload_file(
"/nitro/v2/upload/ns_ssl_cert",
str(cert_path),
basic_user=client_user,
basic_password=client_password,
)
if not _certkey_exists(client, ca_name):
payload = {
"ns_ssl_certkey": {
"certkeypair_name": ca_name,
"ns_ip_address_arr": ns_ip_addresses,
"certificate_file_name": cert_path.name,
"ssl_certificate": cert_path.name,
}
}
_debug_payload("ns_ssl_certkey POST (CA upload)", payload)
client.post_json("/nitro/v2/config/ns_ssl_certkey", payload)
def _link_ca(client: NitroConsoleClient, certkeypair: str, ca_name: str, ns_ip: str) -> None:
payload = {"ns_ssl_certlink": {"certkey": certkeypair, "linkcertkeyname": ca_name, "ns_ip_address": ns_ip}}
_debug_payload("ns_ssl_certlink POST (link)", payload)
client.post_json("/nitro/v2/config/ns_ssl_certlink", payload, params={"action": "link"})
def _parse_ca_inputs(args: argparse.Namespace) -> List[Tuple[str, Optional[Path]]]:
names = args.ca_certkey or []
files = args.ca_cert_file or []
pairs = []
for idx, name in enumerate(names):
path = Path(files[idx]).expanduser() if idx < len(files) else None
pairs.append((name, path))
return pairs
def _extract_chain_names(cert: Dict[str, Any]) -> List[str]:
chain = cert.get("certkeychain") or []
if isinstance(chain, dict):
chain = [chain]
names = []
for entry in chain:
if not isinstance(entry, dict):
continue
name = entry.get("cert_name") or entry.get("certificate_file_name") or entry.get("linked_to")
if name and name not in names:
names.append(name)
return names
def _extract_adc_ip(device: Dict[str, Any]) -> Optional[str]:
return (
device.get("ip_address")
or device.get("mgmt_ip_address")
or device.get("device_host_ip")
or device.get("ipv4_address")
)
def _is_primary(device: Dict[str, Any]) -> bool:
state = str(device.get("ha_master_state") or "").lower()
if state in ("primary", "master"):
return True
if state in ("secondary", "slave"):
return False
# If HA status is unknown, default to include.
return True
def _list_managed_devices(client: NitroConsoleClient, *, primary_only: bool) -> List[Dict[str, Any]]:
payload = client.get_json("/nitro/v2/config/managed_device")
items = payload.get("managed_device", [])
if isinstance(items, dict):
items = [items]
if isinstance(items, list):
devices = [item for item in items if isinstance(item, dict)]
if primary_only:
devices = [device for device in devices if _is_primary(device)]
return devices
return []
def _write_adc_list_json(path: Path, devices: List[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
rows = []
for device in devices:
rows.append(
{
"id": device.get("id"),
"name": device.get("name"),
"display_name": device.get("display_name"),
"hostname": device.get("hostname"),
"device_family": device.get("device_family"),
"ip_address": _extract_adc_ip(device),
"version": device.get("version"),
"status": device.get("status"),
"ha_master_state": device.get("ha_master_state"),
"ha_ip_address": device.get("ha_ip_address"),
"is_ha_configured": device.get("is_ha_configured"),
"cluster_node_ip_list": device.get("cluster_node_ip_list"),
}
)
path.write_text(json.dumps(rows, indent=2, sort_keys=True), encoding="utf-8")
def _select_adc_menu(devices: List[Dict[str, Any]]) -> List[str]:
rows = []
for idx, device in enumerate(devices, start=1):
ip = _extract_adc_ip(device) or "unknown"
name = device.get("display_name") or device.get("name") or device.get("hostname") or "unknown"
state = device.get("ha_master_state") or "unknown"
rows.append((idx, name, ip, state))
for idx, name, ip, state in rows:
print(f"{idx:>2}) {name} [{ip}] (HA: {state})")
choice = input("Select ADCs (comma-separated numbers) or 'q' to cancel: ").strip()
if not choice or choice.lower() == "q":
return []
indices = {int(part.strip()) for part in choice.split(",") if part.strip().isdigit()}
selected_ips = []
for idx, _, ip, _ in rows:
if idx in indices and ip != "unknown":
selected_ips.append(ip)
return selected_ips
def run(args: argparse.Namespace) -> int:
args = _apply_config(args)
_set_debug(args.debug)
if args.dry_run:
_set_debug(True)
if args.list_adc:
raise SystemExit("--list-adc is not supported with --dry-run.")
source_name = args.source_certkeypair or args.certkeypair
ns_ips = args.adc_ip or ["<adc-ip>"]
print("[dry-run] ADC deploy payloads:")
if args.import_missing:
for ns_ip in ns_ips:
payload = {
"ns_ssl_certkey": {
"certkeypair_name": args.certkeypair,
"ns_ip_address_arr": [ns_ip],
"source_ipaddress": ns_ip,
"source_certificate": source_name,
}
}
_debug_payload("ns_ssl_certkey POST (import)", payload)
for ns_ip in ns_ips:
payload = {
"ns_ssl_certkey": {
"certkeypair_name": args.certkeypair,
"entity_binding_arr": [
{
"certkeypair_name": args.certkeypair,
"ns_ip_address": ns_ip,
"id": "<binding-id>",
}
],
}
}
_debug_payload("ns_ssl_certkey POST (bind_cert)", payload)
ca_pairs = _parse_ca_inputs(args)
if args.link_ca:
if not ca_pairs:
ca_pairs = [("CA_CERTKEY", None)]
for ca_name, ca_path in ca_pairs:
if ca_path:
upload_payload = {
"ns_ssl_certkey": {
"certkeypair_name": ca_name,
"ns_ip_address_arr": ns_ips,
"certificate_file_name": ca_path.name,
"ssl_certificate": ca_path.name,
}
}
_debug_payload("ns_ssl_certkey POST (CA upload)", upload_payload)
for ns_ip in ns_ips:
link_payload = {
"ns_ssl_certlink": {
"certkey": args.certkeypair,
"linkcertkeyname": ca_name,
"ns_ip_address": ns_ip,
}
}
_debug_payload("ns_ssl_certlink POST (link)", link_payload)
return 0
if not args.console or not args.user:
raise SystemExit("Console URL and user are required (use CLI args or --config/--profile).")
verify: Any
if args.insecure:
verify = False
elif args.ca_bundle:
verify = args.ca_bundle
else:
verify = True
password = _load_password(args.user, args.console, args.password)
client = NitroConsoleClient(base=args.console, verify=verify, timeout=args.timeout)
client.login(args.user, password)
if args.list_adc:
devices = _list_managed_devices(client, primary_only=not args.all_adc)
if args.list_adc == "json":
out_path = Path(args.list_adc_out)
_write_adc_list_json(out_path, devices)
print(f"Wrote ADC list: {out_path}")
return 0
if args.list_adc == "menu":
selected = _select_adc_menu(devices)
if not selected:
raise SystemExit("No ADCs selected.")
args.adc_ip = selected
if args.poll_adc:
_poll_adcs(client, selected)
if args.poll_wait > 0:
time.sleep(args.poll_wait)
import_adc_ips = args.adc_ip or []
source_name = args.source_certkeypair or args.certkeypair
source_cert = _find_certkey_with_sync(
client,
source_name,
resolve_cn=args.resolve_cn,
sync=args.sync,
sync_wait=args.sync_wait,
import_missing=args.import_missing,
import_wait=args.import_wait,
import_adc_ips=import_adc_ips,
)
if args.certkeypair == source_name:
target_cert = source_cert
else:
target_cert = _find_certkey_with_sync(
client,
args.certkeypair,
resolve_cn=args.resolve_cn,
sync=args.sync,
sync_wait=args.sync_wait,
import_missing=args.import_missing,
import_wait=args.import_wait,
import_adc_ips=import_adc_ips,
)
bindings = _extract_bindings(source_cert)
if not bindings and args.adc_ip:
bindings = [{"ns_ip_address": ip} for ip in args.adc_ip]
if not bindings:
raise SystemExit(f"No bindings found for certkeypair: {source_name}")
ns_ips = [b.get("ns_ip_address") for b in bindings if b.get("ns_ip_address")]
if ns_ips:
print(f"Using ADC bindings for {source_name}: {', '.join(ns_ips)}")
else:
print(f"No ADC IPs resolved for {source_name}; bindings will be attempted without IPs.")
ca_pairs = _parse_ca_inputs(args)
if args.link_ca and not ca_pairs:
chain_names = _extract_chain_names(source_cert)
ca_pairs = [(name, None) for name in chain_names]
if args.link_ca:
if ca_pairs:
ca_names = ", ".join([name for name, _ in ca_pairs if name])
print(f"CA linking enabled. CA certs: {ca_names}")
else:
print("CA linking enabled but no CA certs specified or found in chain metadata.")
for ca_name, ca_path in ca_pairs:
if ca_path:
_upload_ca_cert(
client,
ca_name,
ca_path,
ns_ips,
client_user=args.user,
client_password=password,
)
for binding in bindings:
_bind_cert(client, args.certkeypair, target_cert.get("id"), binding)
if args.link_ca:
for ca_name, _ in ca_pairs:
_link_ca(client, args.certkeypair, ca_name, binding.get("ns_ip_address"))
print(f"Deployed {args.certkeypair} to {len(bindings)} ADC(s).")
return 0
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Deploy a Console certkey to ADCs via bindings.")
parser.add_argument("--certkeypair", required=True, help="Target certkeypair name to deploy")
parser.add_argument("--source-certkeypair", help="Source certkeypair name to copy bindings from")
parser.add_argument("--adc-ip", action="append", help="Target ADC IP (repeatable)")
parser.add_argument(
"--list-adc",
choices=["json", "menu"],
help="List managed ADCs and exit or select interactively",
)
parser.add_argument(
"--all-adc",
action="store_true",
default=False,
help="Include non-primary HA nodes in ADC listing",
)
parser.add_argument(
"--list-adc-out",
default="./out/managed_devices.json",
help="Output path for --list-adc json",
)
parser.add_argument("--link-ca", action="store_true", default=True, help="Link CA certs after deploy")
parser.add_argument("--no-link-ca", dest="link_ca", action="store_false", help="Disable CA linking")
parser.add_argument("--ca-certkey", action="append", help="CA certkeypair name to link (repeatable)")
parser.add_argument("--ca-cert-file", action="append", help="CA cert PEM file to upload (repeatable)")
parser.add_argument("--console", help="Console base URL, e.g. https://console")
parser.add_argument("--user", help="Console username")
parser.add_argument("--password", help="Console password (or set CERTCTL_CONSOLE_PASSWORD)")
parser.add_argument("--config", help="Path to JSON/YAML config file")
parser.add_argument("--profile", help="Config profile name (default: default)")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--ca-bundle", help="Path to CA bundle for TLS verification")
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
parser.add_argument("--debug", action="store_true", help="Enable debug logging and payload output")
parser.add_argument("--dry-run", action="store_true", help="Print payloads without API calls")
parser.add_argument(
"--sync",
action="store_true",
default=False,
help="Trigger Console inventory refresh before looking up certs",
)
parser.add_argument(
"--sync-wait",
type=int,
default=0,
help="Seconds to wait after inventory refresh (default: 0)",
)
parser.add_argument(
"--import-missing",
action="store_true",
default=False,
help="Import missing certkeypairs from ADCs via Console proxy before failing",
)
parser.add_argument(
"--import-wait",
type=int,
default=0,
help="Seconds to wait after importing from ADCs (default: 0)",
)
parser.add_argument(
"--poll-adc",
action="store_true",
default=False,
help="Poll selected ADCs via Console before lookup (requires --list-adc menu or --adc-ip)",
)
parser.add_argument(
"--poll-wait",
type=int,
default=0,
help="Seconds to wait after ADC poll (default: 0)",
)
parser.add_argument(
"--no-resolve-cn",
dest="resolve_cn",
action="store_false",
help="Disable CN lookup when certkeypair name is not found",
)
parser.set_defaults(resolve_cn=True)
return parser
def main() -> None:
parser = build_arg_parser()
args = parser.parse_args()
raise SystemExit(run(args))
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

38
certctl/secretstore.py Normal file
View File

@@ -0,0 +1,38 @@
"""Optional keychain helper using keyring when available."""
from __future__ import annotations
from typing import Optional
try: # pragma: no cover - exercised when keyring is installed
import keyring # type: ignore
except Exception: # pragma: no cover - keyring optional
keyring = None # type: ignore
def is_available() -> bool:
return keyring is not None
def get_secret(service: str, username: str) -> Optional[str]:
if keyring is None:
return None
try:
return keyring.get_password(service, username) # type: ignore
except Exception:
return None
def set_secret(service: str, username: str, value: str) -> None:
if keyring is None:
raise RuntimeError("keyring is not available (pip install keyring).")
keyring.set_password(service, username, value) # type: ignore
def delete_secret(service: str, username: str) -> None:
if keyring is None:
return
try:
keyring.delete_password(service, username) # type: ignore
except Exception:
return