""" Gemeinsame Bausteine fuer die Kerio-Connect-Scripts: * KerioAdminApi - JSON-RPC-Client fuer die Administration API * ServiceController - startet und stoppt den Kerio-Dienst * Hilfsfunktionen zum Auffinden von Domains und Benutzern """ from __future__ import annotations import contextlib import http.cookiejar import json import shutil import signal import ssl import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path APP_INFO = { "name": "kerio-connect-user-rename", "vendor": "inhouse", "version": "2.0", } # Seitengroesse fuer die Paginierung von Users.get / Domains.get. PAGE_SIZE = 500 DEFAULT_INSTALL_DIR = "/opt/kerio/mailserver" DEFAULT_STORE_DIR = "/opt/kerio/mailserver/store" DEFAULT_SERVICE = "kerio-connect" class KerioError(Exception): """Fehler, den der Kerio-Server gemeldet hat (oder ein Transportfehler).""" class AbortedError(Exception): """Der Benutzer hat abgebrochen oder eine Vorpruefung ist fehlgeschlagen.""" # ========================================================================== # Administration API (JSON-RPC) # ========================================================================== class KerioAdminApi: """Duenner JSON-RPC-Client fuer die Kerio Connect Admin-API. Die API will nach dem Login drei Dinge sehen: das Session-Cookie, den X-Token-Header und denselben Token nochmal im JSON-Payload. """ def __init__(self, host: str, port: int = 4040, verify_tls: bool = True, timeout: int = 30) -> None: self.url = f"https://{host}:{port}/admin/api/jsonrpc/" self.timeout = timeout self.token: str | None = None self._request_id = 0 context = ssl.create_default_context() if not verify_tls: context.check_hostname = False context.verify_mode = ssl.CERT_NONE self._opener = urllib.request.build_opener( urllib.request.HTTPSHandler(context=context), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), ) # -- Transport --------------------------------------------------------- def call(self, method: str, params: dict | None = None) -> dict: self._request_id += 1 payload: dict = {"jsonrpc": "2.0", "id": self._request_id, "method": method} if params is not None: payload["params"] = params if self.token: payload["token"] = self.token headers = {"Content-Type": "application/json-rpc; charset=UTF-8"} if self.token: headers["X-Token"] = self.token request = urllib.request.Request( self.url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", ) try: with self._opener.open(request, timeout=self.timeout) as response: body = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise KerioError(f"HTTP {exc.code} bei {method}: {exc.read()[:500]!r}") from exc except urllib.error.URLError as exc: raise KerioError(f"Verbindung zu {self.url} fehlgeschlagen: {exc.reason}") from exc except ssl.SSLError as exc: raise KerioError( f"TLS-Fehler: {exc}. Bei selbstsigniertem Zertifikat --insecure verwenden." ) from exc if "error" in body: error = body["error"] raise KerioError( f"{method}: {error.get('message', 'unbekannter Fehler')} " f"(Code {error.get('code')})" ) result = body.get("result", {}) _raise_on_error_list(method, result) return result def call_paged(self, method: str, params: dict, list_key: str = "list") -> list[dict]: """Ruft eine get-Methode so oft auf, bis alle totalItems eingesammelt sind.""" items: list[dict] = [] while True: query = dict(params.get("query") or {}) query.update({"start": len(items), "limit": PAGE_SIZE}) query.setdefault("fields", []) query.setdefault("conditions", []) query.setdefault("combining", "Or") result = self.call(method, {**params, "query": query}) page = result.get(list_key) or [] items.extend(page) total = result.get("totalItems", len(items)) if not page or len(items) >= total: return items # -- Session ----------------------------------------------------------- def login(self, username: str, password: str) -> None: result = self.call("Session.login", { "userName": username, "password": password, "application": APP_INFO, }) self.token = result.get("token") if not self.token: raise KerioError("Login lieferte keinen Token zurueck.") def logout(self) -> None: if self.token: try: self.call("Session.logout") except KerioError: pass # Ausloggen ist best effort finally: self.token = None def __enter__(self) -> "KerioAdminApi": return self def __exit__(self, *_exc_info) -> None: self.logout() def _raise_on_error_list(method: str, result: dict) -> None: """Kerio liefert Teilfehler nicht als JSON-RPC-error, sondern als 'errors'-Liste.""" errors = result.get("errors") if not errors: return details = "; ".join( f"{e.get('message', '?')} (Code {e.get('code')}, Feld {e.get('inputIndex')})" for e in errors ) raise KerioError(f"{method} meldet Fehler: {details}") def find_domain(api: KerioAdminApi, domain_name: str) -> dict: domains = api.call_paged("Domains.get", {}) wanted = domain_name.strip().lower() for domain in domains: if domain.get("name", "").lower() == wanted: return domain known = ", ".join(sorted(d.get("name", "?") for d in domains)) or "(keine)" raise KerioError(f"Domain '{domain_name}' nicht gefunden. Vorhanden: {known}") def find_user(api: KerioAdminApi, domain_id: str, login_name: str) -> dict | None: users = api.call_paged("Users.get", {"domainId": domain_id}) wanted = login_name.strip().lower() for user in users: if user.get("loginName", "").lower() == wanted: return user return None # ========================================================================== # Dienststeuerung # ========================================================================== class ServiceController: """Startet und stoppt den Kerio-Connect-Dienst. Modus 'none' fasst den Dienst nicht an - dann muss man selbst dafuer sorgen, dass Kerio waehrend der Aenderung steht. """ def __init__(self, name: str = DEFAULT_SERVICE, mode: str = "auto", wait_seconds: int = 90, dry_run: bool = False) -> None: self.name = name self.wait_seconds = wait_seconds self.dry_run = dry_run self.mode = self._resolve_mode(mode) @staticmethod def _resolve_mode(mode: str) -> str: if mode != "auto": return mode if shutil.which("systemctl"): return "systemd" if Path("/etc/init.d", DEFAULT_SERVICE).exists(): return "initd" return "none" def _run(self, *command: str) -> subprocess.CompletedProcess: return subprocess.run(command, capture_output=True, text=True, timeout=120) def describe(self) -> str: if self.mode == "none": return "Dienst wird nicht angefasst (--service-manager none)" return f"{self.mode}: {self.name}" def is_running(self) -> bool | None: """True/False, oder None wenn der Status nicht ermittelbar ist.""" if self.mode == "systemd": return self._run("systemctl", "is-active", "--quiet", self.name).returncode == 0 if self.mode == "initd": return self._run(f"/etc/init.d/{self.name}", "status").returncode == 0 return None def preflight(self) -> None: """Prueft vor allen Aenderungen, ob der Dienst ueberhaupt steuerbar ist.""" if self.mode == "none": return if self.mode == "systemd": if self._run("systemctl", "cat", self.name).returncode != 0: raise AbortedError( f"systemd kennt die Unit '{self.name}' nicht. " "Richtigen Namen mit --service-name angeben oder " "--service-manager none verwenden." ) elif self.mode == "initd": if not Path(f"/etc/init.d/{self.name}").exists(): raise AbortedError(f"/etc/init.d/{self.name} existiert nicht.") def stop(self) -> None: if self.mode == "none": print(" Dienst: uebersprungen (--service-manager none)") return if self.dry_run: print(f" [dry-run] wuerde Dienst '{self.name}' stoppen") return print(f" Stoppe Dienst '{self.name}' ...") if self.mode == "systemd": result = self._run("systemctl", "stop", self.name) else: result = self._run(f"/etc/init.d/{self.name}", "stop") if result.returncode != 0: raise AbortedError( f"Dienst konnte nicht gestoppt werden: " f"{result.stderr.strip() or result.stdout.strip()}" ) # Warten, bis der Dienst wirklich unten ist - sonst schreibt Kerio # noch in Dateien, die wir gerade anfassen. deadline = time.monotonic() + self.wait_seconds while time.monotonic() < deadline: if self.is_running() is False: print(" Dienst gestoppt.") return time.sleep(1) raise AbortedError( f"Dienst '{self.name}' laeuft nach {self.wait_seconds}s immer noch. Abbruch." ) @contextlib.contextmanager def abgesichert(self, war_aktiv: bool): """Startet den Dienst wieder, wenn das Script abgeschossen wird. Diese Scripts laufen ueblicherweise ueber SSH. Bricht die Verbindung weg, kommt SIGHUP - und ohne Vorkehrung bliebe Kerio gestoppt zurueck, weil der Prozess stirbt, bevor er ihn wieder starten kann. Dasselbe gilt fuer SIGTERM und Strg-C. """ def behandle(signalnummer, _rahmen): print(f"\n Signal {signalnummer} empfangen - Kerio wird wieder " "gestartet, dann Abbruch.", file=sys.stderr) if war_aktiv: self.start() raise SystemExit(130) vorher = {} for signalnummer in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): try: vorher[signalnummer] = signal.signal(signalnummer, behandle) except (ValueError, OSError): pass # z. B. wenn nicht im Hauptthread try: yield finally: for signalnummer, handler in vorher.items(): try: signal.signal(signalnummer, handler) except (ValueError, OSError): pass def start(self) -> None: if self.mode == "none": return if self.dry_run: print(f" [dry-run] wuerde Dienst '{self.name}' starten") return print(f" Starte Dienst '{self.name}' ...") if self.mode == "systemd": result = self._run("systemctl", "start", self.name) else: result = self._run(f"/etc/init.d/{self.name}", "start") if result.returncode != 0: print(f" WARNUNG: Start fehlgeschlagen: " f"{result.stderr.strip() or result.stdout.strip()}", file=sys.stderr) else: print(" Dienst gestartet.")