Config Management · python · 33 copies
F5 Certificate Expiry Audit
Which certificates expire when, and exactly what breaks: the chain from certificate to client-SSL profile to virtual server, sorted by urgency. Read-only.
- F5 BIG-IP (TMOS)
$
pip install "requests>=2.28" "PyYAML>=6.0"terminal
f5_cert_audit.py
#!/usr/bin/env python3
"""
F5 Certificate Expiry Audit
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/f5-cert-audit
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28" "PyYAML>=6.0"
"""
import argparse
import datetime
import os
import sys
import requests
import urllib3
import yaml
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# --- iControl REST client -------------------------------------------------
#
# Inlined verbatim rather than imported: these scripts download standalone
# from the site, so there is no shared module to import. The same block
# appears in every F5 script here; change it in all of them or in none.
def credentials():
user = os.environ.get("F5_USER")
password = os.environ.get("F5_PASS")
if not user or not password:
raise SystemExit(
"ERROR: F5_USER and F5_PASS must be set in the environment.\n"
"\n"
" Linux / macOS: export F5_USER=svc_automation\n"
" export F5_PASS='...'\n"
" PowerShell: $env:F5_USER='svc_automation'\n"
" $env:F5_PASS='...'\n"
"\n"
"They are read from the environment on purpose: an argument is\n"
"visible in `ps auxww` while the process runs and lands in shell\n"
"history."
)
return user, password
class BigIP:
"""Minimal iControl REST client.
Token auth rather than Basic on every call: a token is scoped, expires,
and does not put the password on the wire repeatedly.
"""
def __init__(self, host, user, password, verify=True, timeout=30):
self.base = "https://%s/mgmt" % host
self.host = host
self.timeout = timeout
self.s = requests.Session()
self.s.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._login(user, password)
def _login(self, user, password):
r = self.s.post(
self.base + "/shared/authn/login",
json={"username": user, "password": password, "loginProviderName": "tmos"},
timeout=self.timeout,
)
if r.status_code == 401:
raise SystemExit("ERROR: %s rejected the credentials (401)." % self.host)
r.raise_for_status()
token = r.json()["token"]["token"]
self.s.headers.update({"X-F5-Auth-Token": token})
def get(self, path, **kw):
r = self.s.get(self.base + path, timeout=self.timeout, **kw)
r.raise_for_status()
return r.json()
def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def post(self, path, body):
r = self.s.post(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def load_inventory(path):
"""Read the device inventory. Credentials never live in it."""
try:
with open(path, "r", encoding="utf-8") as fh:
doc = yaml.safe_load(fh) or {}
except FileNotFoundError:
raise SystemExit("ERROR: inventory file not found: %s" % path)
except yaml.YAMLError as exc:
raise SystemExit("ERROR: %s is not valid YAML: %s" % (path, exc))
defaults = doc.get("defaults") or {}
devices = doc.get("devices")
if not devices:
raise SystemExit(
"ERROR: %s has no `devices:` list.\n"
"\n"
" defaults:\n"
" verify_ssl: true\n"
" devices:\n"
" - host: ltm01.example.com\n"
" - host: 10.0.0.10\n"
" verify_ssl: false" % path
)
out = []
for i, d in enumerate(devices, 1):
merged = dict(defaults)
merged.update(d or {})
if not merged.get("host"):
raise SystemExit("ERROR: device #%d in %s has no `host`." % (i, path))
out.append(merged)
return out
def encode(name):
"""tmsh paths use ~ for / in the REST URI: /Common/pool -> ~Common~pool"""
return name.replace("/", "~")
# --- certificate audit ----------------------------------------------------
def parse_expiry(epoch):
try:
return datetime.datetime.fromtimestamp(int(epoch), datetime.timezone.utc)
except (TypeError, ValueError):
return None
def ssl_profiles_using(bip):
"""Map certificate name -> the client-SSL profiles that reference it."""
used = {}
data = bip.get("/tm/ltm/profile/client-ssl")
for prof in data.get("items", []):
names = []
if prof.get("cert"):
names.append(prof["cert"])
for entry in prof.get("certKeyChain", []) or []:
if entry.get("cert"):
names.append(entry["cert"])
for n in names:
used.setdefault(n, []).append(prof["fullPath"])
return used
def virtuals_using(bip, profiles):
"""Map client-SSL profile -> the virtual servers that attach it."""
hits = {}
data = bip.get("/tm/ltm/virtual")
for vs in data.get("items", []):
ref = vs.get("profilesReference", {}).get("link")
if not ref:
continue
path = ref.split("/mgmt")[-1].split("?")[0]
try:
attached = bip.get(path)
except requests.exceptions.RequestException:
continue
for p in attached.get("items", []):
if p.get("fullPath") in profiles:
hits.setdefault(p["fullPath"], []).append(vs["fullPath"])
return hits
def main():
ap = argparse.ArgumentParser(
description="Audit certificates on BIG-IPs: what expires when, and "
"which client-SSL profiles and virtual servers would "
"break. Read-only.")
ap.add_argument("--inventory", required=True, help="YAML inventory of BIG-IPs")
ap.add_argument("--days", type=int, default=30,
help="Warn below this many days remaining (default 30)")
ap.add_argument("--critical-days", type=int, default=7,
help="Treat as critical below this many days (default 7)")
ap.add_argument("--unused", action="store_true",
help="Also list certificates no client-SSL profile references")
args = ap.parse_args()
user, password = credentials()
devices = load_inventory(args.inventory)
now = datetime.datetime.now(datetime.timezone.utc)
worst = 0
for dev in devices:
host = dev["host"]
print("=" * 72)
print(host)
print("=" * 72)
try:
bip = BigIP(host, user, password,
verify=dev.get("verify_ssl", True),
timeout=dev.get("timeout", 60))
certs = bip.get("/tm/sys/crypto/cert").get("items", [])
used = ssl_profiles_using(bip)
vs_map = virtuals_using(bip, set(p for ps in used.values() for p in ps))
rows, unused = [], []
for c in certs:
name = c.get("fullPath", c.get("name", "?"))
exp = parse_expiry(c.get("expirationDate"))
profiles = used.get(name, [])
if not profiles:
unused.append(name)
if exp is None:
print(" COULD NOT VERIFY %-44s no expiry in the API response" % name)
continue
days = (exp - now).days
if days <= args.days:
rows.append((days, name, exp, profiles))
rows.sort()
if not rows:
print(" No certificate expires within %d days." % args.days)
for days, name, exp, profiles in rows:
sev = "CRITICAL" if days <= args.critical_days else "WARNING"
if days < 0:
sev = "EXPIRED"
print(" [%-8s] %-44s %s (%d days)"
% (sev, name, exp.strftime("%Y-%m-%d"), days))
if profiles:
for p in profiles:
vss = vs_map.get(p, [])
print(" profile %s" % p)
for v in vss:
print(" -> virtual server %s" % v)
else:
print(" not referenced by any client-SSL profile")
worst = max(worst, 2 if sev in ("CRITICAL", "EXPIRED") else 1)
if args.unused:
print()
print(" Certificates no client-SSL profile references (%d):" % len(unused))
for n in sorted(unused):
print(" %s" % n)
print(" NOTE: this only checks client-SSL profiles. A certificate")
print(" can still be used by server-SSL, LDAP, SAML or device trust.")
print(" Treat the list as candidates to investigate, not to delete.")
except requests.exceptions.SSLError as exc:
print(" ERROR: TLS failed: %s" % exc)
worst = 2
except requests.exceptions.RequestException as exc:
print(" ERROR: %s" % exc)
worst = 2
print()
return worst
if __name__ == "__main__":
sys.exit(main())
--inventorytextrequiredYAML inventory of BIG-IPs. Credentials never go in it
--daysnumberWarn when a certificate has fewer days than this remaining. Default 30
--critical-daysnumberTreat as critical below this many days remaining. Default 7
--unusedflagAlso list certificates no client-SSL profile references. Candidates to investigate, never a delete list
What This Solves
A certificate on a BIG-IP expires at 3am and an application goes down. The certificate was renewed months ago somewhere else; nobody knew this device had a copy. The hard part is not finding expiry dates. It is answering the next question: if this certificate expires, WHAT BREAKS? A certificate name tells you nothing. You need the client-SSL profiles that reference it, and the virtual servers that attach those profiles. This script produces that chain: certificate -> profile -> virtual server, sorted by how soon it expires.
Is It Safe to Run
Completely. It is read-only. - It issues GET requests only. It never creates, modifies or deletes anything on the BIG-IP. - It does not download private keys. It reads certificate metadata. Endpoints read: /tm/sys/crypto/cert, /tm/ltm/profile/client-ssl, /tm/ltm/virtual and each virtual server's profile list. An Operator-level account is enough.
Before You Start
- Python 3.8 or newer - pip install requests PyYAML - A BIG-IP account with read access over REST - An inventory file (see REFERENCE below)
Your First Run
1. Create devices.yaml: devices: - host: ltm01.example.com 2. Set the credentials: export F5_USER=svc_automation export F5_PASS='...' 3. Run it: python3 f5_cert_audit.py --inventory devices.yaml --days 60
What the Output Means
======================================================================== ltm01.example.com ======================================================================== [EXPIRED ] /Common/legacy-api.crt 2026-09-30 (-15 days) not referenced by any client-SSL profile [CRITICAL] /Common/www.example.com 2026-10-19 (4 days) profile /Common/clientssl_www -> virtual server /Common/vs_www_https -> virtual server /Common/vs_www_redirect [WARNING ] /Common/api.example.com 2026-11-20 (36 days) profile /Common/clientssl_api -> virtual server /Common/vs_api Read it as a blast radius. The CRITICAL line is not "one certificate" - it is two virtual servers that stop serving in four days. The EXPIRED one referenced by nothing is the common case: an old certificate nobody removed. It is not an outage, and it is also not safe to delete without checking, for the reason below. Three severities: EXPIRED Already past its date. CRITICAL Within --critical-days (default 7). WARNING Within --days (default 30). And one line that is NOT a finding: COULD NOT VERIFY /Common/odd.crt no expiry in the API response That is deliberate. "Could not verify" and "verified as bad" are different facts, and reporting the first as the second puts fiction in a report. --unused lists certificates no client-SSL profile references. Read the note the script prints with it: it only checks client-SSL. A certificate can still be used by server-SSL, LDAP, SAML or device trust, so the list is candidates to investigate, never a delete list.
Exit Codes for Cron and CI
0 Nothing expiring inside the warning window. 1 At least one WARNING. 2 At least one CRITICAL or EXPIRED, or a device failed. Suitable for a daily cron or a CI gate. Exit 2 means something breaks very soon.
Every Flag
The inventory file Passed with --inventory devices.yaml. Credentials NEVER go in it. defaults: # optional, applied to every device below verify_ssl: true timeout: 60 devices: - host: ltm01.example.com - host: 10.0.0.10 verify_ssl: false # per-device override, self-signed cert timeout: 300 host REQUIRED. Management IP or DNS name of the BIG-IP. verify_ssl Verify the management certificate. Default true. Set false ONLY for a known self-signed management certificate. timeout Seconds per HTTP request. Credentials - environment only F5_USER username (required) F5_PASS password (required) Linux / macOS: export F5_USER=svc_automation export F5_PASS='...' PowerShell: $env:F5_USER='svc_automation' $env:F5_PASS='...' CMD: set F5_USER=svc_automation set F5_PASS=... Environment rather than arguments on purpose: an argument is visible in ps auxww while the process runs, and lands in shell history. The account needs a role with REST access. Operator is enough for the read-only work; changing pool member state needs more. Arguments --inventory PATH REQUIRED. YAML inventory of BIG-IPs. --days N Warn below this many days remaining. Default 30. --critical-days N Treat as critical below this many days. Default 7. --unused Also list certificates no client-SSL profile references. Candidates to investigate, not to delete.
When Something Fails
ERROR: F5_USER and F5_PASS must be set in the environment You exported them in a different shell, or in a shell that has since closed. Export them again in this one. ERROR: <host> rejected the credentials (401) Wrong password, or the account has no REST access. Confirm with: curl -ku USER:PASS https://HOST/mgmt/tm/sys/version ERROR: TLS failed The management certificate is self-signed and verify_ssl is true. Set verify_ssl: false for that device in the inventory, and only for that device. Do not turn it off globally out of convenience. ERROR: <host> unreachable Port 443 to the management address is blocked, or the management interface is on a network you cannot reach from here. 404 on a pool or object name Names are case sensitive and include the partition. Use the full path, /Common/pool_app, not pool_app. A certificate you know is in use shows as unreferenced It is attached to something other than a client-SSL profile: server-SSL, an LDAP or SAML configuration, or device trust. The script says so, and the --unused list is explicitly not a delete list. The virtual server column is empty for a profile you know is attached The profile is attached through a different object, or the account cannot read that virtual server's profile list. Confirm with: tmsh list ltm virtual VS_NAME profiles Expiry dates look wrong by hours They are rendered in UTC. The BIG-IP GUI may show local time.
Agents
/agents/f5-expertTags
f5big-ipcertificatestlsexpiryicontrol-restaudit