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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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