TLS Certificates skill for Codex
Certificates that renew themselves and never surprise you. Codex diagnoses TLS failures with openssl s_client and a bundled expiry checker before changing anything, then sets up issuance that renews itself: certbot with deploy hooks, DNS-01 for wildcards, or cert-manager Issuers in Kubernetes. It plans for shrinking certificate lifetimes, iterates on Let's Encrypt staging to avoid rate limits, and proves the reload path works.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- tls-certificates Installs to ~/.agents/skills/tls-certificates/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install tls-certificates The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project tls-certificates Installs to .agents/skills/tls-certificates/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/tls-certificates.zipunzip -o tls-certificates.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) tls-certificates PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $tls-certificates, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Issues, renews and debugs TLS certificates - Let's Encrypt via certbot or other ACME clients (HTTP-01, DNS-01, wildcards), cert-manager Issuers and Certificates in Kubernetes, private CAs - and diagnoses handshake failures with openssl s_client, curl and a bundled expiry checker. Use when a certificate expired or is about to, browsers or clients report untrusted or mismatched certificates, renewal silently stopped, cert-manager is stuck pending, a wildcard is needed, or certificate lifetimes are shrinking and automation must keep up.
Use it when
- A certificate expired, or renewal silently stopped
- Clients report untrusted, mismatched or incomplete certificate chains
- You need a wildcard certificate or DNS-01 validation
- cert-manager Certificates are stuck not ready
Use something else when
- You want a proxy that handles HTTPS on its own: use caddy-server
- The certificate is fine and you need nginx TLS settings or redirects: use nginx-config
How to check the result
Run the bundled check_cert.py against every public name and backend, confirm certbot renew --dry-run or cert-manager Ready=True, and force a staging renewal to prove the served serial changes.
The mistake it prevents
Serving cert.pem instead of fullchain.pem: browsers quietly fetch the missing intermediate, but curl, Java and mobile clients fail.
Done well means: issuance and renewal are fully automated, the server sends the leaf plus intermediates (never the root), every name clients use is in the SANs, the process serving TLS reloads after renewal, and an independent check alerts before expiry.
Public certificate lifetimes are shrinking. Under the CA/Browser Forum schedule the maximum is 200 days from March 2026 and falls further in 2027 and 2029. Let’s Encrypt has announced moving its default from 90 days to 64 days (February 2027) and 45 days (February 2028), with shorter opt-in profiles already available. Treat any manual or calendar-driven renewal as a defect.
Diagnose first
python3 scripts/check_cert.py example.com api.example.com:8443 --days 21
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
curl -sv https://example.com -o /dev/null 2>&1 | grep -E 'SSL|subject|issuer|expire'
scripts/check_cert.py (standard library, needs network) verifies trust and hostname,
prints expiry, issuer and SANs, and exits non-zero on failure or when fewer than --days
remain. Use --sni when connecting to an IP or load balancer and --cafile for private
CAs. If the system Python has an empty CA store (python.org builds on macOS), the script
falls back to the OS bundle. Always pass -servername to s_client; without SNI you often get the default cert.
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser fine, curl/Java/Android/Go fail “unable to get local issuer” | Intermediate missing; browsers fetch it via AIA, others do not | Serve fullchain.pem, not cert.pem |
| “certificate has expired” right after renewal | Server process still holds the old cert | Reload hook (systemctl reload nginx), restart pods, re-upload to LB |
| Hostname mismatch | Name missing from SANs (CN is ignored), or wrong SNI vhost | Reissue with every name; check vhost server_name |
| Old cert on some requests only | Multiple LB nodes or CDN edge with its own cert | Check each backend IP with --sni; update the CDN/LB copy |
| Issuance fails “CAA record prevents issuance” | CAA allows a different CA | Add 0 issue "letsencrypt.org" (and issuewild for wildcards) |
| HTTP-01 fails | Port 80 blocked, redirect loop, or /.well-known/acme-challenge/ not routed | Allow 80, exempt the challenge path from auth and redirects |
| Clients fail “certificate not yet valid” | Clock skew on client or server | Fix NTP (timedatectl) |
| Key mismatch error on server start | Cert and key from different issuances | Compare public key hashes (below) |
# Do cert and key belong together? The two hashes must match.
openssl x509 -in cert.pem -noout -pubkey | openssl sha256
openssl pkey -in key.pem -pubout | openssl sha256
# Does the chain verify on its own?
openssl verify -untrusted chain.pem cert.pem
Pick the issuance path
| Situation | Method |
|---|---|
| Single VM, nginx/Apache, port 80 reachable | certbot with --webroot (or the nginx plugin) and a deploy hook |
| Caddy or Traefik in front | Let the proxy handle ACME itself; do not add certbot |
| Kubernetes ingress or Gateway | cert-manager with a ClusterIssuer |
| Wildcard, or host not reachable from the internet | DNS-01 with a scoped DNS API token |
| Internal services, mTLS between services | Private CA (step-ca, Vault PKI, cert-manager CA issuer) or a service mesh |
| Managed LB/CDN (ALB, CloudFront, Cloudflare) | Provider-managed certificates; do not import ACME certs by hand |
certbot on a VM
Installing certbot and every issuance needs network access; in a Codex sandbox ask for approval. Test against staging first; staging certs are untrusted but do not count against production rate limits.
sudo certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com --dry-run
sudo certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com \
--deploy-hook "systemctl reload nginx"
sudo certbot certificates # what is installed, expiry, paths
sudo certbot renew --dry-run # proves the renewal path works
systemctl list-timers | grep -i certbot # or snap.certbot.renew.timer
- Point the server at
/etc/letsencrypt/live/<name>/fullchain.pemandprivkey.pem(symlinks that move on renewal). Copying the files elsewhere breaks renewal. - Deploy hooks run only when a certificate actually renews; put reloads there, not in cron.
- Let’s Encrypt stopped sending expiry reminder emails in 2025 and ended OCSP; monitor
expiry yourself and remove
ssl_staplingexpectations for Let’s Encrypt certs. - Rate limits (production): duplicate certificates for the same exact name set and new certificates per registered domain are capped weekly. Repeated failed debugging against production locks you out for hours; always iterate on staging.
- Renewal timing: ACME clients that support ACME Renewal Information (ARI) let the CA pick the window. Otherwise renew at about two thirds of the lifetime, never on a fixed “every 60 days” schedule, which breaks once lifetimes drop below that.
DNS-01 and wildcards
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'
- The API token gets only DNS edit on the one zone. Store the credentials file
0600. - Delegate
_acme-challenge.example.comvia CNAME to a dedicated validation zone when you cannot give automation write access to the main zone. - A wildcard covers one label:
*.example.comcoversapi.example.com, notexample.comorv2.api.example.com.
cert-manager
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-staging-account
solvers:
- http01:
ingress:
ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: web
namespace: shop
spec:
secretName: web-tls
dnsNames: [shop.example.com, www.shop.example.com]
issuerRef:
kind: ClusterIssuer
name: letsencrypt-staging
Switch to the production directory (https://acme-v02.api.letsencrypt.org/directory)
in a second ClusterIssuer once staging issues cleanly. On an Ingress, the annotation
cert-manager.io/cluster-issuer: letsencrypt-prod plus a tls: block creates the
Certificate for you.
Debug top-down; each resource’s events name the failing step:
kubectl -n shop describe certificate web
kubectl -n shop get certificaterequest,order,challenge
kubectl -n shop describe challenge <name> # the real error is usually here
kubectl -n cert-manager logs deploy/cert-manager --since=15m | grep -i shop
Common causes: the solver’s ingress class does not match the controller, the HTTP-01
self-check cannot reach the public address from inside the cluster (hairpin NAT), DNS-01
token lacks permission, or a stale Order keeps retrying with backoff. Delete only the
failed CertificateRequest to force a retry, after confirming with the user.
Private CAs and internal TLS
- Distribute the root, never the intermediate key. Clients trust the root via the OS store,
SSL_CERT_FILE, a JVM truststore, orNODE_EXTRA_CA_CERTS. - Keep internal leaf lifetimes short (days to weeks) and automated; long-lived internal certs are where outages hide.
- For service-to-service mTLS across many workloads, a mesh is often simpler: see
$service-mesh-istio.
Hard rules
- Never commit private keys or ACME account keys. Never paste them into chat or logs.
- Never disable verification (
curl -k,verify=False,InsecureSkipVerify) as a fix; only as a temporary diagnostic, removed before finishing. - Revoking a certificate, deleting a cert-manager Secret, or switching production traffic to a new certificate are outward-facing changes: confirm with the user first.
- Do not pin intermediates or leaf certs in clients. CAs rotate intermediates regularly.
Verification
python3 scripts/check_cert.py <every public name> --days 30returns 0.certbot renew --dry-runsucceeds, or cert-manager showsReady=Truewith arenewalTimein the future (kubectl get certificate -o wide).- The reload path is proven: in a staging environment force a renewal (
cmctl renew <certificate>for cert-manager;certbot renew --force-renewalon a staging host only, with user approval) and confirm the served serial changes. Never force-renew production certs repeatedly; it burns rate limits. - External expiry monitoring exists (uptime checker or the script in CI on a schedule).
Report template
**Names:** example.com, www.example.com, api.example.com (SNI checked on both LB nodes)
**Issuer:** Let's Encrypt via cert-manager ClusterIssuer letsencrypt-prod (HTTP-01)
**Root cause:** nginx served cert.pem without intermediate; Android clients failed
**Fix:** ssl_certificate -> fullchain.pem; deploy hook reloads nginx
**Checks:** check_cert.py OK (63 days left); openssl verify OK; renew --dry-run OK
**Follow-up:** add expiry check to uptime monitoring (alert at 21 days) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "TLS Certificates"
short_description: "Certificates that renew themselves and never surprise you"
default_prompt: "$tls-certificates Our cert expired over the weekend even though certbot is installed, find out why" scripts/check_cert.py
#!/usr/bin/env python3
"""Check the TLS certificate a server presents: trust, hostname, SANs, expiry.
Usage:
python3 scripts/check_cert.py example.com
python3 scripts/check_cert.py example.com:8443 api.example.com --days 21
python3 scripts/check_cert.py 10.0.0.5:443 --sni app.internal --cafile ca.pem
Exit codes: 0 all good, 1 at least one host failed or expires within --days,
2 usage error. Standard library only; needs network access to the hosts.
"""
from __future__ import annotations
import argparse
import datetime as dt
import socket
import ssl
import sys
def parse_target(target: str) -> tuple[str, int]:
if target.startswith("["): # [::1]:443
host, _, rest = target[1:].partition("]")
return host, int(rest.lstrip(":") or 443)
if target.count(":") == 1:
host, port = target.split(":")
return host, int(port)
return target, 443
def name_of(rdns) -> str:
short = {"commonName": "CN", "organizationName": "O"}
parts = [f"{short[k]}={v}" for rdn in rdns for k, v in rdn if k in short]
return ", ".join(parts) or "?"
SYSTEM_BUNDLES = (
"/etc/ssl/certs/ca-certificates.crt", # Debian, Ubuntu, Alpine
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL, Fedora
"/etc/ssl/cert.pem", # macOS, BSD
)
def make_context(cafile: str | None) -> ssl.SSLContext:
ctx = ssl.create_default_context(cafile=cafile)
# Some Python builds (python.org on macOS) ship with an empty default store.
if not ctx.get_ca_certs() and ctx.cert_store_stats()["x509_ca"] == 0:
for bundle in SYSTEM_BUNDLES:
try:
ctx.load_verify_locations(cafile=bundle)
break
except OSError:
continue
return ctx
def check(target: str, sni: str | None, cafile: str | None, days: int, timeout: float) -> bool:
host, port = parse_target(target)
server_name = sni or host
ctx = make_context(cafile)
label = f"{host}:{port}" + (f" (SNI {server_name})" if server_name != host else "")
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=server_name) as tls:
cert = tls.getpeercert()
version = tls.version()
except ssl.SSLCertVerificationError as exc:
print(f"FAIL {label}: verification failed: {exc.verify_message} (code {exc.verify_code})")
hints = {
10: "certificate expired - renewal is not running or the server was not reloaded",
18: "self-signed certificate - wrong cert served or missing --cafile",
19: "self-signed CA in chain - private CA not trusted here",
20: "unable to get local issuer - server likely omits the intermediate (serve fullchain)",
62: "hostname mismatch - check SANs and SNI",
}
if exc.verify_code in hints:
print(f" hint: {hints[exc.verify_code]}")
return False
except (OSError, ssl.SSLError) as exc:
print(f"FAIL {label}: {type(exc).__name__}: {exc}")
return False
not_after = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notAfter"]), dt.timezone.utc)
not_before = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notBefore"]), dt.timezone.utc)
left = (not_after - dt.datetime.now(dt.timezone.utc)).days
sans = [v for k, v in cert.get("subjectAltName", ()) if k in ("DNS", "IP Address")]
lifetime = (not_after - not_before).days
ok = left >= days
status = "OK " if ok else "WARN"
print(f"{status} {label}: {version}, expires {not_after:%Y-%m-%d} ({left} days left, {lifetime}-day cert)")
print(f" subject: {name_of(cert.get('subject', ()))}")
print(f" issuer: {name_of(cert.get('issuer', ()))}")
print(f" SANs: {', '.join(sans) if sans else '(none)'}")
if not ok:
print(f" expires within the {days}-day threshold - check the renewal job and reload hook")
return ok
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("targets", nargs="+", help="host or host:port")
ap.add_argument("--days", type=int, default=14, help="warn if fewer days remain (default 14)")
ap.add_argument("--sni", help="server name to send instead of the host (for IPs or load balancers)")
ap.add_argument("--cafile", help="extra CA bundle for private CAs")
ap.add_argument("--timeout", type=float, default=8.0)
args = ap.parse_args()
results = [check(t, args.sni, args.cafile, args.days, args.timeout) for t in args.targets]
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.