#!/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()