#!/usr/bin/env python3
"""
cdp-mail-sni-sync - Zertifikate je Mailname fuer Postfix und Dovecot.

Liest den Bestand unter /etc/letsencrypt/live, zieht aus jedem Zertifikat die
Namen mail.<domain> und webmail.<domain> und schreibt daraus die SNI-Karte fuer
Postfix sowie die local_name-Bloecke fuer Dovecot. Laedt die Dienste nur neu,
wenn sich etwas geaendert hat. Mehrfach ausfuehrbar; ab dem zweiten Lauf meldet
das Skript "unveraendert".
"""
import json
import os
import re
import subprocess
import sys

LIVE = "/etc/letsencrypt/live"
SNI_MAP = "/etc/postfix/cdp-sni-map"
DOVECOT_CONF = "/etc/dovecot/conf.d/98-cdp-virtual.conf"
CONFIG_JSON = "/etc/controldeskpanel/config.json"
PANEL_HOST_JSON = "/etc/controldeskpanel/panel-host.json"
SELFSIGNED = ("/etc/controldeskpanel/tls/cert.pem", "/etc/controldeskpanel/tls/key.pem")
BEGIN = "# BEGIN CDP_MAIL_SNI"
END = "# END CDP_MAIL_SNI"


def read_json(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except Exception:
        return {}


def panel_host():
    """Hostname dieses Panels. Konfiguration zuerst, kein fest verdrahteter Wert."""
    cfg = read_json(CONFIG_JSON)
    for value in (cfg.get("primaryDomain"), read_json(PANEL_HOST_JSON).get("hostname"),
                  cfg.get("serverName"), read_json(PANEL_HOST_JSON).get("ip")):
        host = str(value or "").strip().lower()
        if host and host not in ("localhost", "_"):
            return host
    return ""


def base_cert():
    """Grundzertifikat: Let's Encrypt fuer den Panel-Hostnamen, sonst selbstsigniert."""
    host = panel_host()
    if host:
        full = os.path.join(LIVE, host, "fullchain.pem")
        key = os.path.join(LIVE, host, "privkey.pem")
        if os.path.exists(full) and os.path.exists(key):
            return full, key
    return SELFSIGNED


def cert_names(certfile):
    """Alle Namen eines Zertifikats, Betreff und SAN zusammen."""
    try:
        out = subprocess.run(
            ["openssl", "x509", "-in", certfile, "-noout", "-text"],
            capture_output=True, text=True, timeout=15,
        ).stdout
    except Exception:
        return set()
    names = set(re.findall(r"DNS:([^,\s]+)", out))
    m = re.search(r"Subject:.*?CN\s*=\s*([^\s,/]+)", out)
    if m:
        names.add(m.group(1))
    return {n.strip().lower().rstrip(".") for n in names if n.strip()}


def collect():
    """Zuordnung Mailname -> (Schluessel, Zertifikat) aus dem Bestand."""
    mapping = {}
    if not os.path.isdir(LIVE):
        return mapping
    for entry in sorted(os.listdir(LIVE)):
        d = os.path.join(LIVE, entry)
        full = os.path.join(d, "fullchain.pem")
        key = os.path.join(d, "privkey.pem")
        if not (os.path.isdir(d) and os.path.exists(full) and os.path.exists(key)):
            continue
        for name in sorted(cert_names(full)):
            if name.startswith("mail.") or name.startswith("webmail."):
                mapping.setdefault(name, (key, full))
    return mapping


def collect_apex():
    """
    Zuordnung Apex -> (Schluessel, Zertifikat), also z.B. certthor.net selbst.
    Nur fuer die Postfix-SNI-Karte: Mailclients, die <domain> statt
    mail.<domain> als Server eingetragen haben, bekommen sonst das
    Hostname-Zertifikat des Servers und melden einen Zertifikatsfehler
    ("TLS SNI <domain> not matched, using default chain"). Die
    Dovecot-Bloecke bleiben bewusst auf mail./webmail. beschraenkt, weil
    domain-ssl.js die Apex-Namen in 99-cdp-ssl-sni.conf pflegt und eine
    zweite Definition Dovecot nicht mehr laden liesse.
    """
    mapping = {}
    if not os.path.isdir(LIVE):
        return mapping
    for entry in sorted(os.listdir(LIVE)):
        d = os.path.join(LIVE, entry)
        full = os.path.join(d, "fullchain.pem")
        key = os.path.join(d, "privkey.pem")
        if not (os.path.isdir(d) and os.path.exists(full) and os.path.exists(key)):
            continue
        name = entry.strip().lower().rstrip(".")
        if name.startswith(("mail.", "webmail.", "www.", "*.")):
            continue
        if "." not in name:
            continue
        if name in cert_names(full):
            mapping.setdefault(name, (key, full))
    return mapping


def render_sni(mapping):
    lines = ["# Erzeugt von cdp-mail-sni-sync. Aenderungen gehen verloren.",
             "# Reihenfolge je Zeile: Name, Schluessel, Zertifikat."]
    for name in sorted(mapping):
        key, full = mapping[name]
        lines.append("%s %s %s" % (name, key, full))
    return "\n".join(lines) + "\n"


def render_dovecot_block(mapping):
    lines = [BEGIN,
             "# Erzeugt von cdp-mail-sni-sync. Aenderungen gehen verloren."]
    for name in sorted(mapping):
        key, full = mapping[name]
        lines.append("local_name %s {" % name)
        lines.append("  ssl_server_cert_file = %s" % full)
        lines.append("  ssl_server_key_file = %s" % key)
        lines.append("}")
    lines.append(END)
    return "\n".join(lines) + "\n"


def replace_block(text, block):
    """
    Ersetzt den Bereich zwischen den Marken. Zeilenweise, nicht ueber einen
    mehrzeiligen Ausdruck: bei aufeinanderfolgenden Bloecken verbraucht sonst
    der erste Treffer den Umbruch, den der zweite als Anfang braucht, und es
    ueberlebt jeder zweite Block.
    """
    out, skipping, seen = [], False, False
    for line in text.splitlines(True):
        stripped = line.strip()
        if not skipping and stripped == BEGIN:
            skipping, seen = True, True
            out.append(block)
            continue
        if skipping:
            if stripped == END:
                skipping = False
            continue
        out.append(line)
    result = "".join(out)
    if not seen:
        if result and not result.endswith("\n"):
            result += "\n"
        result += "\n" + block
    return result


def set_base_cert(text, full, key):
    text = re.sub(r"(?m)^ssl_server_cert_file\s*=.*$", "ssl_server_cert_file = %s" % full, text, count=1)
    text = re.sub(r"(?m)^ssl_server_key_file\s*=.*$", "ssl_server_key_file = %s" % key, text, count=1)
    return text


def write_if_changed(path, content):
    old = ""
    if os.path.exists(path):
        with open(path, encoding="utf-8") as fh:
            old = fh.read()
    if old == content:
        return False
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        fh.write(content)
    os.chmod(tmp, 0o644)
    os.replace(tmp, path)
    return True


def reload_unit(name):
    subprocess.run(["systemctl", "reload-or-restart", name], check=False,
                   capture_output=True, timeout=60)


def main():
    mapping = collect()
    apex_mapping = collect_apex()
    full, key = base_cert()
    changed = []

    sni_mapping = dict(mapping)
    for name, pair in apex_mapping.items():
        sni_mapping.setdefault(name, pair)
    if write_if_changed(SNI_MAP, render_sni(sni_mapping)):
        changed.append("postfix")

    if os.path.exists(DOVECOT_CONF):
        with open(DOVECOT_CONF, encoding="utf-8") as fh:
            text = fh.read()
        next_text = set_base_cert(replace_block(text, render_dovecot_block(mapping)), full, key)
        if write_if_changed(DOVECOT_CONF, next_text):
            changed.append("dovecot")

    if not changed:
        print("cdp-mail-sni-sync: unveraendert (%d Mailnamen)" % len(mapping))
        return 0

    if "postfix" in changed:
        subprocess.run(["postmap", SNI_MAP], check=False, capture_output=True, timeout=60)
        reload_unit("postfix")
    if "dovecot" in changed:
        reload_unit("dovecot")
    print("cdp-mail-sni-sync: aktualisiert (%d Mailnamen, neu geladen: %s)"
          % (len(mapping), ", ".join(changed)))
    return 0


if __name__ == "__main__":
    sys.exit(main())