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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.log
|
||||
@@ -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
|
||||
|
||||
```
|
||||
<store>/mail/<domain>/<loginname>/
|
||||
```
|
||||
|
||||
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)
|
||||
Executable
+683
@@ -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 <store>/mail/<domain>/<loginname>/ 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>/<subtree>/<domain>/<loginname>/
|
||||
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:]))
|
||||
Reference in New Issue
Block a user