Some checks failed
deploy / ansible (push) Failing after 59s
Traefik never requests a certificate for a name it already holds one for, so the XMPP certificate came out without asxp.io, the XMPP domain itself. ejabberd picks the matching certificate file per domain, so push two: the site's asxp.io certificate and xmpp.asxp.io + conference. The receiver takes the name from the SSH command (asxp.io or a subdomain), checks the certificate matches it, and retires the hand-made server.pem once both files are in place. A second probe on 5223 sends SNI asxp.io, as clients do, and the outdated-certificate alert compares each served certificate with its source in acme.json. Assisted-by: Claude:opus-5-5
68 lines
3.2 KiB
Django/Jinja
68 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"
|
|
|
|
|
|
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)
|