#!/usr/bin/env python3 """ Benennt einen Benutzer in Kerio Connect vollstaendig um: 1. Login-Name (und optional voller Name) ueber die Administration API 2. das zugehoerige Mailbox-Verzeichnis im Store auf der Platte Kerio legt die Mailbox unter /mail/// ab und zieht das Verzeichnis beim Aendern des Login-Namens nicht mit. Ohne Schritt 2 findet der Benutzer nach der Umbenennung eine leere Mailbox vor. Damit das Verzeichnis gefahrlos verschoben werden kann, wird der Kerio-Dienst waehrend des Umbenennens gestoppt und danach wieder gestartet. Das Script muss deshalb LOKAL AUF DEM KERIO-SERVER ALS ROOT laufen. Beispiel: sudo ./kerio_rename_user.py \\ --admin admin \\ --domain firma.de \\ --old-login anna.mueller \\ --new-login anna.schmidt \\ --full-name "Anna Schmidt" \\ --dry-run Ohne --dry-run wird vor dem Schreiben nachgefragt (ausser mit --yes). """ from __future__ import annotations import argparse import getpass import json import os import sys import time from pathlib import Path from kerio_common import ( DEFAULT_SERVICE, DEFAULT_STORE_DIR, AbortedError, KerioAdminApi, KerioError, ServiceController, find_domain, find_user, ) # Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann. # Alle folgen dem Schema //// STORE_SUBTREES = ("mail", "archive") # ========================================================================== # Store-Verzeichnisse # ========================================================================== def find_dir_ci(parent: Path, name: str) -> Path | None: """Sucht ein Unterverzeichnis - erst exakt, dann ohne Ruecksicht auf Gross/Klein. Kerio legt die Verzeichnisse meist klein geschrieben an, auch wenn der Login-Name gemischt geschrieben ist. """ direct = parent / name if direct.is_dir(): return direct if not parent.is_dir(): return None wanted = name.lower() for child in parent.iterdir(): if child.is_dir() and child.name.lower() == wanted: return child return None def plan_store_renames(store_dir: Path, domain_name: str, old_login: str, new_login: str) -> list[tuple[Path, Path]]: """Ermittelt alle zu verschiebenden Verzeichnisse als (quelle, ziel)-Paare.""" if not store_dir.is_dir(): raise AbortedError( f"Store-Verzeichnis '{store_dir}' existiert nicht. " "Richtigen Pfad mit --store-dir angeben." ) renames: list[tuple[Path, Path]] = [] for subtree in STORE_SUBTREES: domain_dir = find_dir_ci(store_dir / subtree, domain_name) if domain_dir is None: continue source = find_dir_ci(domain_dir, old_login) if source is None: continue # Schreibweise des vorhandenen Verzeichnisses uebernehmen. target_name = new_login.lower() if source.name.islower() else new_login renames.append((source, domain_dir / target_name)) if not renames: raise AbortedError( f"Kein Mailbox-Verzeichnis fuer '{old_login}' unter {store_dir} gefunden " f"(gesucht in: {', '.join(STORE_SUBTREES)}/{domain_name}/). " "Pfad mit --store-dir pruefen." ) return renames def check_rename_targets(renames: list[tuple[Path, Path]]) -> None: """Stellt sicher, dass kein Ziel bereits belegt ist.""" for source, target in renames: if target.exists() and target.resolve() != source.resolve(): raise AbortedError( f"Zielverzeichnis '{target}' existiert bereits. " "Bitte manuell pruefen und wegraeumen." ) try: same_fs = source.stat().st_dev == target.parent.stat().st_dev except OSError as exc: raise AbortedError(f"'{source}' ist nicht mehr lesbar: {exc}") from exc if not same_fs: raise AbortedError( f"'{source}' und '{target.parent}' liegen auf verschiedenen " "Dateisystemen - das wuerde ein Kopieren statt Verschieben bedeuten." ) def apply_store_renames(renames: list[tuple[Path, Path]], dry_run: bool) -> list[tuple[Path, Path]]: """Fuehrt die Verschiebungen aus. Gibt zurueck, was tatsaechlich getan wurde.""" done: list[tuple[Path, Path]] = [] for source, target in renames: if dry_run: print(f" [dry-run] wuerde verschieben: {source} -> {target}") continue try: os.rename(source, target) except OSError as exc: # Bereits erfolgte Verschiebungen zuruecknehmen. _revert_store_renames(done) raise AbortedError( f"Verschieben von '{source}' nach '{target}' fehlgeschlagen: {exc}" ) from exc done.append((source, target)) print(f" Verschoben: {source} -> {target}") return done def _revert_store_renames(done: list[tuple[Path, Path]]) -> None: for source, target in reversed(done): try: os.rename(target, source) print(f" Zurueckgenommen: {target} -> {source}", file=sys.stderr) except OSError as exc: print(f" WARNUNG: Rueckgaengigmachen von '{target}' fehlgeschlagen: {exc}", file=sys.stderr) # ========================================================================== # Ablauf # ========================================================================== def rename_user(api: KerioAdminApi, args: argparse.Namespace, service: ServiceController) -> int: store_dir = Path(args.store_dir) # -- Vorpruefungen: nichts wird geschrieben --------------------------- domain = find_domain(api, args.domain) domain_id = domain["id"] domain_name = domain["name"] print(f"Domain: {domain_name} (id={domain_id})") user = find_user(api, domain_id, args.old_login) if user is None: raise KerioError( f"Benutzer '{args.old_login}' existiert nicht in Domain '{domain_name}'." ) old_login = user["loginName"] new_login = args.new_login or old_login item_source = user.get("itemSource") if item_source and item_source.lower() != "internal": message = ( f"Benutzer stammt aus '{item_source}' (Verzeichnisdienst/LDAP). " "Der Login-Name muss dort geaendert werden, nicht in Kerio." ) if not args.force: raise AbortedError(message + " Mit --force trotzdem versuchen.") print(f" WARNUNG: {message}") if new_login != old_login and find_user(api, domain_id, new_login) is not None: raise AbortedError(f"Login-Name '{new_login}' ist bereits vergeben.") pattern: dict = {} if new_login != old_login: pattern["loginName"] = new_login if args.full_name and args.full_name != user.get("fullName"): pattern["fullName"] = args.full_name renames: list[tuple[Path, Path]] = [] if new_login != old_login: renames = plan_store_renames(store_dir, domain_name, old_login, new_login) check_rename_targets(renames) service.preflight() if not pattern and not renames: print("\nNichts zu tun.") return 0 # -- Plan ausgeben ---------------------------------------------------- print("\nGeplante Aenderungen") print("--------------------") print(f" Benutzer-ID : {user['id']}") if "loginName" in pattern: print(f" Login-Name : {old_login} -> {new_login}") else: print(f" Login-Name : {old_login} (unveraendert)") old_full_name = user.get("fullName", "") if "fullName" in pattern: print(f" Voller Name : {old_full_name} -> {args.full_name}") else: print(f" Voller Name : {old_full_name} (unveraendert)") print(f" Adresse : {old_login}@{domain_name} -> {new_login}@{domain_name}") if renames: print(f" Dienst : {service.describe()}") for source, target in renames: print(f" Verzeichnis : {source}") print(f" -> {target}") addresses = user.get("emailAddresses") or [] if addresses: print(f" Weitere Adressen am Benutzer (unveraendert): " f"{json.dumps(addresses, ensure_ascii=False)}") if args.dry_run: print("\n--dry-run: es wurde nichts geaendert.") return 0 if not args.yes: print("\nAchtung: der Kerio-Dienst wird dabei kurz gestoppt.") answer = input("Aenderungen jetzt durchfuehren? [j/N] ").strip().lower() if answer not in ("j", "ja", "y", "yes"): print("Abgebrochen.") return 1 # -- Schreiben -------------------------------------------------------- print() if pattern: api.call("Users.set", {"userIds": [user["id"]], "pattern": pattern}) print(f" Benutzer aktualisiert: {json.dumps(pattern, ensure_ascii=False)}") if not renames: print("\nFertig (keine Verzeichnisse zu verschieben).") return 0 # Ab hier ist die API-Aenderung schon durch. Faellt das Verschieben aus, # wird sie zurueckgedreht, damit Konfiguration und Platte zusammenpassen. api.logout() try: service.stop() # Nach dem Stoppen erneut pruefen: Kerio koennte in der Zwischenzeit # ein leeres Verzeichnis unter dem neuen Namen angelegt haben. check_rename_targets(renames) apply_store_renames(renames, dry_run=False) except AbortedError as exc: print(f"\nFEHLER beim Verschieben: {exc}", file=sys.stderr) _rollback_api_change(args, user, old_login, service) return 2 finally: service.start() print(f"\nFertig. Benutzer heisst jetzt '{new_login}', " f"Mailbox liegt unter '{renames[0][1]}'.") print("Hinweis: Der Benutzer muss sich in allen Mail-Clients mit dem " "neuen Login neu anmelden.") return 0 def _rollback_api_change(args: argparse.Namespace, user: dict, old_login: str, service: ServiceController) -> None: """Setzt den Login-Namen zurueck, wenn das Verschieben gescheitert ist.""" print(" Setze Login-Namen zurueck ...", file=sys.stderr) service.start() # Nach dem Start braucht die Admin-API einen Moment, bis sie antwortet. last_error: Exception | None = None for attempt in range(10): try: with KerioAdminApi(args.server, args.port, verify_tls=not args.insecure) as api: api.login(args.admin, args.password) api.call("Users.set", { "userIds": [user["id"]], "pattern": {"loginName": old_login}, }) print(f" Login-Name wieder auf '{old_login}' gesetzt.", file=sys.stderr) return except KerioError as exc: last_error = exc time.sleep(3) print(f" WARNUNG: Zuruecksetzen fehlgeschlagen: {last_error}\n" f" Der Benutzer heisst in Kerio jetzt '{args.new_login}', " f"die Mailbox liegt aber noch unter '{old_login}'. " "Bitte von Hand richten!", file=sys.stderr) # ========================================================================== # CLI # ========================================================================== def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Benennt einen Kerio-Connect-Benutzer inklusive Store-Verzeichnis um.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("--server", default="localhost", help="Hostname der Admin-API (Standard: localhost)") parser.add_argument("--port", type=int, default=4040, help="Port der Admin-API (Standard: 4040)") parser.add_argument("--admin", required=True, help="Benutzername des Administrators") parser.add_argument("--password", help="Admin-Passwort. Besser: Umgebungsvariable " "KERIO_ADMIN_PASSWORD oder interaktive Eingabe.") parser.add_argument("--domain", required=True, help="Mail-Domain des Benutzers, z. B. firma.de") parser.add_argument("--old-login", required=True, help="Bisheriger Login-Name (ohne @domain)") parser.add_argument("--new-login", help="Neuer Login-Name (ohne @domain)") parser.add_argument("--full-name", help='Neuer voller Name, z. B. "Anna Schmidt"') parser.add_argument("--store-dir", default=DEFAULT_STORE_DIR, help=f"Kerio-Store-Verzeichnis (Standard: {DEFAULT_STORE_DIR})") parser.add_argument("--service-name", default=DEFAULT_SERVICE, help=f"Name des Kerio-Dienstes (Standard: {DEFAULT_SERVICE})") parser.add_argument("--service-manager", default="auto", choices=("auto", "systemd", "initd", "none"), help="Wie der Dienst gestoppt wird. 'none' fasst ihn nicht an - " "dann muss Kerio selbst gestoppt werden.") parser.add_argument("--service-wait", type=int, default=90, help="Sekunden, die auf das Stoppen gewartet wird (Standard: 90)") parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, was passieren wuerde") parser.add_argument("--yes", action="store_true", help="Rueckfrage ueberspringen") parser.add_argument("--force", action="store_true", help="Auch bei LDAP-/AD-gemappten Benutzern versuchen") parser.add_argument("--insecure", action="store_true", help="TLS-Zertifikat nicht pruefen (selbstsigniertes Zertifikat)") args = parser.parse_args(argv) if not args.new_login and not args.full_name: parser.error("Mindestens --new-login oder --full-name angeben.") if args.new_login and not args.dry_run and os.geteuid() != 0: parser.error( "Zum Verschieben des Store-Verzeichnisses werden root-Rechte benoetigt. " "Mit sudo starten (oder --dry-run zum Testen)." ) if not args.password: args.password = os.environ.get("KERIO_ADMIN_PASSWORD") if not args.password: args.password = getpass.getpass(f"Passwort fuer {args.admin}: ") return args def main(argv: list[str]) -> int: args = parse_args(argv) service = ServiceController( name=args.service_name, mode=args.service_manager, wait_seconds=args.service_wait, dry_run=args.dry_run, ) try: with KerioAdminApi(args.server, args.port, verify_tls=not args.insecure) as api: api.login(args.admin, args.password) print(f"Angemeldet an {args.server}:{args.port} als {args.admin}") return rename_user(api, args, service) except (KerioError, AbortedError) as exc: print(f"\nFEHLER: {exc}", file=sys.stderr) return 2 except KeyboardInterrupt: print("\nAbgebrochen.", file=sys.stderr) return 130 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))