xmpp_cert_sync: push asxp.io and xmpp.asxp.io as separate files
Some checks failed
deploy / ansible (push) Failing after 59s
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
This commit is contained in:
parent
5e009c4a47
commit
cf142c6e3b
7 changed files with 88 additions and 53 deletions
|
|
@ -1,60 +1,68 @@
|
|||
#!/usr/bin/env python3
|
||||
# Managed by Ansible (role xmpp_cert_sync).
|
||||
# Push the XMPP certificate from Traefik's acme.json to ejabberd on
|
||||
# asxpio-services whenever it changes (xmpp-cert-sync.path), and daily as
|
||||
# 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"
|
||||
MAIN = "{{ xmpp_cert_main }}"
|
||||
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/pushed.sha256"
|
||||
STATE = "/var/lib/xmpp-cert-sync"
|
||||
PROM = "{{ monitoring_textfile_dir }}/xmpp_cert.prom"
|
||||
|
||||
|
||||
def metrics(**m):
|
||||
lines = []
|
||||
for name, (help_, value) in m.items():
|
||||
lines += [f"# HELP {name} {help_}", f"# TYPE {name} gauge", f"{name} {value}"]
|
||||
tmp = PROM + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
os.replace(tmp, 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()
|
||||
|
||||
|
||||
certs = json.load(open(ACME))["letsencrypt"]["Certificates"] or []
|
||||
entry = next((c for c in certs if c["domain"]["main"] == MAIN), None)
|
||||
if not entry:
|
||||
print(f"no certificate for {MAIN} in {ACME} yet")
|
||||
sys.exit(0)
|
||||
pem = base64.b64decode(entry["certificate"]) + base64.b64decode(entry["key"])
|
||||
digest = hashlib.sha256(pem).hexdigest()
|
||||
|
||||
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
|
||||
not_after = subprocess.run(["date", "-d", end.split("=", 1)[1].strip(), "+%s"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
try:
|
||||
pushed = open(STATE).read().strip()
|
||||
except OSError:
|
||||
pushed = ""
|
||||
if pushed != digest or "--force" in sys.argv:
|
||||
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], input=pem, check=True, timeout=120)
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
with open(STATE, "w") as f:
|
||||
TARGET, name], input=pem, check=True, timeout=120)
|
||||
with open(state, "w") as f:
|
||||
f.write(digest + "\n")
|
||||
print(f"pushed {MAIN} (valid until {end.split('=', 1)[1].strip()})")
|
||||
else:
|
||||
print("ejabberd already has the current certificate")
|
||||
print(f"{name}: pushed (valid until {end})")
|
||||
|
||||
metrics(
|
||||
xmpp_cert_source_not_after_timestamp_seconds=("Expiry of the XMPP certificate in Traefik's acme.json.", not_after),
|
||||
xmpp_cert_sync_last_success_timestamp_seconds=("Last run that left ejabberd with Traefik's current certificate.", int(time.time())),
|
||||
)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue