iac/ansible/roles/xmpp_cert_sync/templates/xmpp-cert-sync.j2
Sergei Poljanski 81191c951f
All checks were successful
deploy / ansible (push) Successful in 7m24s
xmpp_cert_sync: keep Jinja out of the script's f-strings
The metric line's {{cert=...}} braces were read as a Jinja expression
and failed the template. Render only the settings, the rest verbatim.

Assisted-by: Claude:opus-5-5
2026-11-13 08:28:27 +04:00

70 lines
3.2 KiB
Django/Jinja

#!/usr/bin/env python3
# Managed by Ansible (role xmpp_cert_sync).
# Push the XMPP certificates from Traefik's acme.json to ejabberd on
# asxpio-services whenever they change (xmpp-cert-sync.path), and daily as
# a fallback. The SSH key may only run xmpp-cert-install there.
import base64, hashlib, json, os, subprocess, sys, tempfile, time
ACME = "/opt/traefik/letsencrypt/acme.json"
CERTS = {{ xmpp_cert_push | to_json }}
TARGET = "root@{{ asxpio_services_vpc_ip }}"
KEY = "/root/.ssh/xmpp_cert_sync"
KNOWN = "/root/.ssh/xmpp_cert_sync_known_hosts"
STATE = "/var/lib/xmpp-cert-sync"
PROM = "{{ monitoring_textfile_dir }}/xmpp_cert.prom"
{% raw %}
def not_after(pem):
with tempfile.NamedTemporaryFile() as t:
t.write(pem); t.flush()
end = subprocess.run(["openssl", "x509", "-in", t.name, "-noout", "-enddate"],
capture_output=True, text=True, check=True).stdout
end = end.split("=", 1)[1].strip()
return end, subprocess.run(["date", "-d", end, "+%s"], capture_output=True,
text=True, check=True).stdout.strip()
acme = json.load(open(ACME))["letsencrypt"]["Certificates"] or []
os.makedirs(STATE, exist_ok=True)
expiry, missing = {}, []
for name in CERTS:
entry = next((c for c in acme if c["domain"]["main"] == name), None)
if not entry:
print(f"no certificate for {name} in {ACME} yet")
missing.append(name)
continue
pem = base64.b64decode(entry["certificate"]) + base64.b64decode(entry["key"])
end, expiry[name] = not_after(pem)
digest = hashlib.sha256(pem).hexdigest()
state = os.path.join(STATE, name + ".sha256")
try:
pushed = open(state).read().strip()
except OSError:
pushed = ""
if pushed == digest and "--force" not in sys.argv:
print(f"{name}: ejabberd already has it (valid until {end})")
continue
subprocess.run(["ssh", "-i", KEY, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", f"UserKnownHostsFile={KNOWN}", "-o", "StrictHostKeyChecking=accept-new",
TARGET, name], input=pem, check=True, timeout=120)
with open(state, "w") as f:
f.write(digest + "\n")
print(f"{name}: pushed (valid until {end})")
lines = ["# HELP xmpp_cert_source_not_after_timestamp_seconds Expiry of each XMPP certificate in Traefik's acme.json.",
"# TYPE xmpp_cert_source_not_after_timestamp_seconds gauge"]
lines += [f'xmpp_cert_source_not_after_timestamp_seconds{{cert="{n}"}} {t}' for n, t in expiry.items()]
if not missing:
lines += ["# HELP xmpp_cert_sync_last_success_timestamp_seconds Last run that left ejabberd with all of Traefik's current certificates.",
"# TYPE xmpp_cert_sync_last_success_timestamp_seconds gauge",
f"xmpp_cert_sync_last_success_timestamp_seconds {int(time.time())}"]
else:
try: # keep the last success, so the stale alert still counts from it
lines += [l.rstrip("\n") for l in open(PROM) if l.startswith(("# HELP xmpp_cert_sync", "# TYPE xmpp_cert_sync", "xmpp_cert_sync"))]
except OSError:
pass
with open(PROM + ".tmp", "w") as f:
f.write("\n".join(lines) + "\n")
os.replace(PROM + ".tmp", PROM)
{% endraw %}