411 lines
14 KiB
Python
411 lines
14 KiB
Python
#!/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()
|