Umbenennung ueber users.cfg statt Administration API

Der Test gegen einen echten Server (Kerio Connect 10.0.9 patch 2) hat
gezeigt, dass Users.set das Feld loginName stillschweigend ignoriert -
kein Fehler, keine Wirkung, waehrend fullName im selben Aufruf sauber
uebernommen wird. Vier Varianten geprueft (nur loginName, mit domainId,
mit leeren emailAddresses, vollstaendiges User-Objekt): alle wirkungslos.
Der bisherige Ansatz konnte also gar nicht funktionieren.

Der Login-Name steht in users.cfg, und zwar in zwei Listen: User und
UserAdditionalData. Beide verweisen ueber Name+Domain statt ueber die
Guid, beide muessen mit, sonst verliert der Benutzer seine
Passworthistorie. Geaendert wird der Rohtext, damit Formatierung und
unbekannte Felder unangetastet bleiben.

users.cfg und Store-Verzeichnis wandern jetzt in EINEM Stopp-Fenster.
Laeuft Kerio zwischendurch mit nur einer Haelfte, legt es die
vermeintlich fehlende Mailbox sofort neu an - beim Entwickeln genau so
passiert. Damit entfaellt auch der Admin-Zugang: das Script braucht
keine Zugangsdaten mehr, --list zeigt die Benutzer aus der Datei.

Ausserdem gefunden: itemSource heisst 'DSInternalSource', nicht
'Internal' wie angenommen - die alte Pruefung haette bei jedem lokalen
Benutzer faelschlich abgebrochen. Die Erkennung laeuft jetzt ueber
InternalDb in users.cfg.

Tests fuer beide Haelften unter tests/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-07 13:44:31 +02:00
co-authored by Claude Opus 5
parent b12041fc81
commit 5011491067
5 changed files with 715 additions and 240 deletions
+186 -166
View File
@@ -2,22 +2,29 @@
"""
Benennt einen Benutzer in Kerio Connect vollstaendig um:
1. Login-Name (und optional voller Name) ueber die Administration API
1. den Login-Namen in users.cfg (und optional den angezeigten Namen)
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.
Warum nicht ueber die Administration API: Kerio nimmt loginName in Users.set
zwar entgegen, ignoriert das Feld aber stillschweigend - kein Fehler, keine
Wirkung (geprueft gegen Kerio Connect 10.0.9). Der Login-Name laesst sich nur
in users.cfg aendern. Deshalb kommt dieses Script ohne Admin-Zugangsdaten aus.
Damit das Verzeichnis gefahrlos verschoben werden kann, wird der Kerio-Dienst
waehrend des Umbenennens gestoppt und danach wieder gestartet.
Kerio legt die Mailbox unter <store>/mail/<domain>/<loginname>/ ab. Ohne
Schritt 2 findet der Benutzer nach der Umbenennung eine leere Mailbox vor,
waehrend die alten Mails unter dem alten Verzeichnisnamen liegenbleiben.
Das Script muss deshalb LOKAL AUF DEM KERIO-SERVER ALS ROOT laufen.
Beide Aenderungen passieren in EINEM Stopp-Fenster des Dienstes. Das ist keine
Bequemlichkeit: laeuft Kerio zwischendurch mit nur einer der beiden Haelften,
legt es die fehlende Mailbox sofort neu an und man hat ein verwaistes
Verzeichnis mehr.
Beispiel:
Das Script muss LOKAL AUF DEM KERIO-SERVER ALS ROOT laufen.
Beispiele:
./kerio_rename_user.py --list # Benutzer anzeigen
sudo ./kerio_rename_user.py \\
--admin admin \\
--domain firma.de \\
--old-login anna.mueller \\
--new-login anna.schmidt \\
@@ -30,28 +37,26 @@ Ohne --dry-run wird vor dem Schreiben nachgefragt (ausser mit --yes).
from __future__ import annotations
import argparse
import getpass
import json
import os
import shutil
import sys
import time
from pathlib import Path
from kerio_common import (
DEFAULT_INSTALL_DIR,
DEFAULT_SERVICE,
DEFAULT_STORE_DIR,
AbortedError,
KerioAdminApi,
KerioError,
ServiceController,
find_domain,
find_user,
)
from kerio_users_config import UsersConfig, validate_login
# Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann.
# Alle folgen dem Schema <store>/<subtree>/<domain>/<loginname>/
STORE_SUBTREES = ("mail", "archive")
BACKUP_SUFFIX = ".bak-rename-user"
# ==========================================================================
# Store-Verzeichnisse
@@ -97,13 +102,6 @@ def plan_store_renames(store_dir: Path, domain_name: str, old_login: str,
# 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
@@ -126,19 +124,14 @@ def check_rename_targets(renames: list[tuple[Path, Path]]) -> None:
)
def apply_store_renames(renames: list[tuple[Path, Path]],
dry_run: bool) -> list[tuple[Path, Path]]:
def apply_store_renames(renames: list[tuple[Path, Path]]) -> 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)
revert_store_renames(done)
raise AbortedError(
f"Verschieben von '{source}' nach '{target}' fehlgeschlagen: {exc}"
) from exc
@@ -147,7 +140,7 @@ def apply_store_renames(renames: list[tuple[Path, Path]],
return done
def _revert_store_renames(done: list[tuple[Path, Path]]) -> None:
def revert_store_renames(done: list[tuple[Path, Path]]) -> None:
for source, target in reversed(done):
try:
os.rename(target, source)
@@ -162,82 +155,105 @@ def _revert_store_renames(done: list[tuple[Path, Path]]) -> None:
# ==========================================================================
def rename_user(api: KerioAdminApi, args: argparse.Namespace,
def list_users(config: UsersConfig, domain: str | None) -> int:
domains = config.domains()
if not domains:
print("Keine Benutzer in users.cfg gefunden.")
return 1
for name in domains:
if domain and name.lower() != domain.lower():
continue
print(f"\nDomain: {name}")
for user in config.users(name):
flags = [] if user["enabled"] else ["deaktiviert"]
if not config.is_internal(user["loginName"], name):
flags.append("Verzeichnisdienst")
suffix = f" [{', '.join(flags)}]" if flags else ""
print(f" {user['loginName']:<28} {user['fullName']}{suffix}")
return 0
def rename_user(args: argparse.Namespace, config: UsersConfig,
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})")
domains = config.domains()
domain = next((d for d in domains if d.lower() == args.domain.lower()), None)
if domain is None:
raise AbortedError(
f"Domain '{args.domain}' kommt in users.cfg nicht vor. "
f"Vorhanden: {', '.join(domains) or '(keine)'}"
)
user = find_user(api, domain_id, args.old_login)
user = config.find_user(args.old_login, domain)
if user is None:
raise KerioError(
f"Benutzer '{args.old_login}' existiert nicht in Domain '{domain_name}'."
raise AbortedError(
f"Benutzer '{args.old_login}' existiert nicht in Domain '{domain}'."
)
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
validate_login(new_login)
if not config.is_internal(old_login, domain) and not args.force:
raise AbortedError(
"Benutzer stammt aus einem Verzeichnisdienst (LDAP/AD). Der "
"Login-Name muss dort geaendert werden, nicht in Kerio. "
"Mit --force trotzdem versuchen."
)
if new_login.lower() != old_login.lower() and \
config.find_user(new_login, domain) is not None:
raise AbortedError(f"Login-Name '{new_login}' ist bereits vergeben.")
renames: list[tuple[Path, Path]] = []
if new_login != old_login:
renames = plan_store_renames(store_dir, domain_name, old_login, new_login)
renames = plan_store_renames(store_dir, domain, old_login, new_login)
if not renames:
print(f" Hinweis: kein Mailbox-Verzeichnis fuer '{old_login}' gefunden. "
"Es wird nur users.cfg geaendert.")
check_rename_targets(renames)
service.preflight()
if not pattern and not renames:
needs_name = new_login != old_login
needs_fullname = bool(args.full_name) and args.full_name != user["fullName"]
if not needs_name and not needs_fullname:
print("\nNichts zu tun.")
return 0
# -- Plan ausgeben ----------------------------------------------------
print("\nGeplante Aenderungen")
print("--------------------")
print(f" Benutzer-ID : {user['id']}")
print(f" Domain : {domain}")
print(f" Guid : {user['guid']}")
if "loginName" in pattern:
if needs_name:
print(f" Login-Name : {old_login} -> {new_login}")
print(f" Adresse : {old_login}@{domain} -> {new_login}@{domain}")
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}")
if needs_fullname:
print(f" Voller Name : {user['fullName']} -> {args.full_name}")
else:
print(f" Voller Name : {old_full_name} (unveraendert)")
print(f" Adresse : {old_login}@{domain_name} -> {new_login}@{domain_name}")
print(f" Voller Name : {user['fullName']} (unveraendert)")
print(f" users.cfg : {config.path}")
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)}")
extra = config.other_occurrences(old_login, domain) if needs_name else []
if extra:
print("\n ACHTUNG: Der alte Name kommt auch hier vor und wird NICHT "
"automatisch geaendert:")
for hit in extra:
print(f" - {hit}")
print(" Bitte nach der Umbenennung in der Administrationskonsole pruefen.")
if args.dry_run:
print("\n--dry-run: es wurde nichts geaendert.")
@@ -245,74 +261,78 @@ def rename_user(api: KerioAdminApi, args: argparse.Namespace,
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"):
if input("Aenderungen jetzt durchfuehren? [j/N] ").strip().lower() \
not in ("j", "ja", "y", "yes"):
print("Abgebrochen.")
return 1
# -- Schreiben --------------------------------------------------------
# -- Schreiben: users.cfg und Verzeichnisse in einem Stopp-Fenster ----
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()
was_running = service.is_running()
service.stop()
done: list[tuple[Path, Path]] = []
backup: Path | None = None
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)
# Nach dem Stoppen neu einlesen: Kerio hat users.cfg beim Beenden
# aus dem Speicher zurueckgeschrieben, unsere Kopie ist veraltet.
fresh = UsersConfig(config.path)
backup = fresh.backup(BACKUP_SUFFIX)
print(f" Backup: {backup}")
changed = fresh.rename_user(
old_login, new_login, domain,
args.full_name if needs_fullname else None)
if changed == 0:
raise AbortedError(
"Kein passender Eintrag in users.cfg gefunden - nichts geaendert."
)
fresh.save()
print(f" users.cfg: {changed} Eintraege angepasst "
f"(Listen: User, UserAdditionalData)")
if renames:
# Zielpruefung wiederholen: der Zustand kann sich seit der
# Vorpruefung geaendert haben.
check_rename_targets(renames)
done = apply_store_renames(renames)
except (AbortedError, OSError) as exc:
print(f"\nFEHLER: {exc}", file=sys.stderr)
revert_store_renames(done)
if backup and backup.is_file():
try:
shutil.copy2(backup, config.path)
print(f" users.cfg aus {backup} zurueckgesetzt.", file=sys.stderr)
except OSError as restore_exc:
print(f" WARNUNG: Zuruecksetzen von users.cfg fehlgeschlagen: "
f"{restore_exc}", file=sys.stderr)
if was_running:
service.start()
return 2
finally:
if was_running:
service.start()
print(f"\nFertig. Benutzer heisst jetzt '{new_login}', "
f"Mailbox liegt unter '{renames[0][1]}'.")
# -- Gegenlesen -------------------------------------------------------
check = UsersConfig(config.path)
result = check.find_user(new_login, domain)
if result is None:
print(f"\n WARNUNG: '{new_login}' ist in users.cfg nach dem Start nicht "
"auffindbar. Bitte pruefen.", file=sys.stderr)
return 2
print(f"\nFertig. Benutzer heisst jetzt '{result['loginName']}' "
f"({result['fullName']}).")
if done:
print(f"Mailbox liegt unter '{done[0][1]}'.")
print("Hinweis: Der Benutzer muss sich in allen Mail-Clients mit dem "
"neuen Login neu anmelden.")
if needs_name:
print("Die alte Adresse nimmt keine Mail mehr an. Wer das braucht, legt "
"in der Administrationskonsole einen Alias an.")
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
# ==========================================================================
@@ -320,67 +340,64 @@ def _rollback_api_change(args: argparse.Namespace, user: dict, old_login: str,
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Benennt einen Kerio-Connect-Benutzer inklusive Store-Verzeichnis um.",
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,
parser.add_argument("--install-dir", default=DEFAULT_INSTALL_DIR,
help=f"Kerio-Installationsverzeichnis "
f"(Standard: {DEFAULT_INSTALL_DIR})")
parser.add_argument("--store-dir", default=DEFAULT_STORE_DIR,
help=f"Kerio-Store-Verzeichnis (Standard: {DEFAULT_STORE_DIR})")
parser.add_argument("--domain",
help="Mail-Domain des Benutzers, z. B. firma.de")
parser.add_argument("--old-login", required=True,
parser.add_argument("--old-login",
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})")
help='Neuer angezeigter Name, z. B. "Anna Schmidt"')
parser.add_argument("--list", action="store_true",
help="Benutzer aus users.cfg auflisten und beenden")
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.")
help="Wie der Dienst gestoppt wird. 'none' fasst ihn nicht "
"an - dann muss Kerio selbst gestoppt sein.")
parser.add_argument("--service-wait", type=int, default=90,
help="Sekunden, die auf das Stoppen gewartet wird (Standard: 90)")
help="Sekunden Wartezeit aufs Stoppen (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)")
help="Auch bei Benutzern aus einem Verzeichnisdienst")
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}: ")
if not args.list:
missing = [n for n in ("domain", "old_login") if not getattr(args, n)]
if missing:
parser.error("Benoetigt: " + ", ".join("--" + m.replace("_", "-")
for m in missing))
if not args.new_login and not args.full_name:
parser.error("Mindestens --new-login oder --full-name angeben.")
if not args.dry_run and os.geteuid() != 0:
parser.error(
"Zum Aendern von users.cfg und zum Verschieben des "
"Store-Verzeichnisses werden root-Rechte benoetigt. "
"Mit sudo starten (oder --dry-run zum Testen)."
)
return args
def main(argv: list[str]) -> int:
args = parse_args(argv)
config_path = Path(args.install_dir) / "users.cfg"
service = ServiceController(
name=args.service_name,
@@ -390,14 +407,17 @@ def main(argv: list[str]) -> int:
)
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:
config = UsersConfig(config_path)
if args.list:
return list_users(config, args.domain)
return rename_user(args, config, service)
except AbortedError as exc:
print(f"\nFEHLER: {exc}", file=sys.stderr)
return 2
except PermissionError as exc:
print(f"\nFEHLER: Keine Berechtigung: {exc}\nMit sudo starten.",
file=sys.stderr)
return 2
except KeyboardInterrupt:
print("\nAbgebrochen.", file=sys.stderr)
return 130