From 485a681a0aec562eaab80d221fb6a05a8c26ddab Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 7 Aug 2026 12:54:12 +0200 Subject: [PATCH] Benutzer-Umbenennung fuer Kerio Connect Benennt einen Benutzer vollstaendig um: Login-Name und voller Name ueber die Administration API, dazu das Mailbox-Verzeichnis im Store auf der Platte. Kerio zieht das Verzeichnis beim Aendern des Login-Namens nicht mit, sodass der Benutzer sonst eine leere Mailbox vorfindet. Ablauf: Vorpruefungen (Benutzer vorhanden, neuer Name frei, kein LDAP-Benutzer, Verzeichnis vorhanden und Ziel frei, gleiches Dateisystem, Dienst steuerbar), dann Users.set, Dienst stoppen, Verzeichnisse verschieben, Dienst starten. Verschoben werden mail/ und archive/. Scheitert das Verschieben, werden bereits verschobene Verzeichnisse zurueckgenommen und der Login-Name zurueckgesetzt, damit Konfiguration und Platte konsistent bleiben. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 5 + README.md | 117 ++++++++ kerio_rename_user.py | 683 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 805 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 kerio_rename_user.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9403642 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0e5c63 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# kerio-connect-rename-user + +Benennt einen Benutzer in Kerio Connect vollständig um — Login-Name in der +Serverkonfiguration **und** das zugehörige Mailbox-Verzeichnis im Store. + +## Warum + +Kerio Connect legt die Mailbox eines Benutzers unter + +``` +/mail/// +``` + +ab. Ändert man den Login-Namen über die Administration API oder die +Admin-Konsole, zieht dieses Verzeichnis nicht mit — der Benutzer findet danach +eine leere Mailbox vor, während die alten Daten unter dem alten Verzeichnisnamen +liegenbleiben. Typischer Anlass: Heirat oder ein anderer Namenswechsel. + +Dieses Script erledigt beide Hälften in der richtigen Reihenfolge. + +## Voraussetzungen + +- Python 3.9 oder neuer, nur Standardbibliothek — keine Pakete zu installieren +- Läuft **lokal auf dem Kerio-Server als root** (Dateisystemzugriff + Dienstkontrolle) +- Administration API erreichbar (Standard: `https://localhost:4040/admin/api/jsonrpc/`) + +## Ablauf + +1. **Vorprüfungen**, bevor irgendetwas geschrieben wird: + - Domain und Benutzer über die API auflösen + - Benutzer aus einem Verzeichnisdienst (LDAP/AD)? → Abbruch, denn der Name + gehört dann dem Verzeichnisdienst + - neuer Login-Name noch frei? + - Mailbox-Verzeichnis vorhanden, Zielname noch nicht belegt, + Quelle und Ziel auf demselben Dateisystem? + - ist der Dienst überhaupt steuerbar? +2. Plan ausgeben. `--dry-run` endet hier, sonst Rückfrage (`--yes` überspringt). +3. `Users.set` setzt `loginName` und optional `fullName`. +4. Kerio-Dienst stoppen und warten, bis er wirklich unten ist. +5. Verzeichnisse per `os.rename` verschieben (`mail/`, und `archive/` falls vorhanden). +6. Dienst wieder starten. + +Schlägt Schritt 5 fehl, werden bereits verschobene Verzeichnisse zurückgenommen +und der Login-Name in Kerio auf den alten Wert zurückgesetzt — Konfiguration und +Platte bleiben konsistent. + +## Benutzung + +```bash +# Erst ansehen, was passieren würde (braucht kein root): +./kerio_rename_user.py \ + --admin admin \ + --domain firma.de \ + --old-login anna.mueller \ + --new-login anna.schmidt \ + --full-name "Anna Schmidt" \ + --dry-run + +# Dann wirklich: +sudo -E ./kerio_rename_user.py \ + --admin admin \ + --domain firma.de \ + --old-login anna.mueller \ + --new-login anna.schmidt \ + --full-name "Anna Schmidt" +``` + +Das Passwort kommt aus der Umgebungsvariablen `KERIO_ADMIN_PASSWORD` oder wird +interaktiv abgefragt — nicht über die Kommandozeile, sonst landet es in der +Shell-History. (`sudo -E` reicht die Variable durch.) + +Nur den angezeigten Namen ändern, ohne Login und Verzeichnis anzufassen: + +```bash +./kerio_rename_user.py --admin admin --domain firma.de \ + --old-login anna.mueller --full-name "Anna Schmidt" +``` + +## Optionen + +| Option | Bedeutung | +| --- | --- | +| `--server`, `--port` | Admin-API, Standard `localhost:4040` | +| `--admin`, `--password` | Admin-Zugang; besser `KERIO_ADMIN_PASSWORD` | +| `--domain` | Mail-Domain, z. B. `firma.de` | +| `--old-login`, `--new-login` | Login-Namen ohne `@domain` | +| `--full-name` | Neuer angezeigter Name | +| `--store-dir` | Store-Pfad, Standard `/opt/kerio/mailserver/store` | +| `--service-name` | Dienstname, Standard `kerio-connect` | +| `--service-manager` | `auto`, `systemd`, `initd` oder `none` | +| `--service-wait` | Sekunden Wartezeit aufs Stoppen, Standard 90 | +| `--dry-run` | Nur anzeigen | +| `--yes` | Rückfrage überspringen | +| `--force` | Auch bei LDAP-/AD-Benutzern versuchen | +| `--insecure` | TLS-Zertifikat nicht prüfen (selbstsigniert) | + +`--service-manager none` fasst den Dienst nicht an — dafür muss Kerio dann +selbst gestoppt sein, bevor das Script läuft. + +## Hinweise + +- **Vorher ein Backup des Stores anlegen.** Das Script verschiebt Verzeichnisse. +- Erst an einem Testbenutzer ausprobieren. +- Der Benutzer sollte während des Laufs nicht angemeldet sein. +- Nach der Umbenennung müssen sich alle Mail-Clients des Benutzers mit dem neuen + Login neu anmelden. +- Die alte Adresse wird bewusst **nicht** als Alias angelegt. Wer möchte, dass + Mail an die alte Adresse weiterhin ankommt, legt den Alias anschließend in der + Admin-Konsole an. +- Weitere Adressen am Benutzer (`emailAddresses`) bleiben unverändert und werden + im Plan zur Kontrolle mit angezeigt. + +## API-Referenz + +- [Administration API for Kerio Connect — Users](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/interfacekerio_1_1jsonapi_1_1admin_1_1_users.html) +- [Sample communication (Session.login, X-Token)](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/sample_communication.html) +- [User struct](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/structkerio_1_1jsonapi_1_1admin_1_1_user.html) diff --git a/kerio_rename_user.py b/kerio_rename_user.py new file mode 100755 index 0000000..499058a --- /dev/null +++ b/kerio_rename_user.py @@ -0,0 +1,683 @@ +#!/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 http.cookiejar +import json +import os +import shutil +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_STORE_DIR = "/opt/kerio/mailserver/store" +DEFAULT_SERVICE = "kerio-connect" + +# Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann. +# Alle folgen dem Schema //// +STORE_SUBTREES = ("mail", "archive") + + +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.""" + + +# ========================================================================== +# Teil 1: 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 + + +# ========================================================================== +# Teil 2: Kerio-Dienst steuern +# ========================================================================== + + +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 des Verschiebens steht. + """ + + def __init__(self, name: str, mode: str, 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": + result = self._run(f"/etc/init.d/{self.name}", "status") + return result.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": + result = self._run("systemctl", "cat", self.name) + if result.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: {result.stderr.strip() or result.stdout.strip()}" + ) + + # Warten, bis der Dienst wirklich unten ist - sonst schreibt Kerio + # noch in Dateien, die wir gerade verschieben. + 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." + ) + + 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.") + + +# ========================================================================== +# Teil 3: 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) + + +# ========================================================================== +# Teil 4: 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:]))