Fuer den Fall, dass der alte Benutzer schon geloescht und der neue von Hand angelegt wurde - dann liegt nur noch das Verzeichnis herum. users.cfg bleibt unangetastet, es wandert allein das Verzeichnis. Geprueft wird vorher, dass der Zielbenutzer existiert und der alte nicht mehr; gibt es ihn noch, verweist das Script auf die normale Umbenennung. Hat das Ziel schon ein Verzeichnis mit Mails, bricht es ab - mit --force wird das bestehende als <name>.abgeloest-<zeitstempel> beiseitegeschoben statt geloescht. Die Zielpruefung arbeitet jetzt als Folge statt paarweise: bei der Uebernahme raeumt der erste Schritt das Ziel, das der zweite belegt. Eine isolierte Pruefung haette das als Konflikt gemeldet. Dazu die Kontaktkarte: .personal traegt Namen und Adresse des vorherigen Eigentuemers. Sie wird jetzt umgeschrieben statt entfernt - Kerio legt sie naemlich nicht neu an, wenn sie fehlt, weder beim Neustart noch bei einer Aenderung ueber die Admin-API. Das gilt auch fuer die normale Umbenennung, wo bisher die alte Adresse stehenblieb. Beides gegen den Testserver verifiziert: nach der Uebernahme sieht der Zielbenutzer alle Mails samt Unterordnern und Flags, die Kontaktkarte stimmt, users.cfg ist unveraendert und neue Mail wird zugestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
693 lines
27 KiB
Python
Executable File
693 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Benennt einen Benutzer in Kerio Connect vollstaendig um:
|
|
|
|
1. den Login-Namen in users.cfg (und optional den angezeigten Namen)
|
|
2. das zugehoerige Mailbox-Verzeichnis im Store auf der Platte
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
Das Script muss LOKAL AUF DEM KERIO-SERVER ALS ROOT laufen.
|
|
|
|
Beispiele:
|
|
|
|
./kerio_rename_user.py --list # Benutzer anzeigen
|
|
sudo ./kerio_rename_user.py \\
|
|
--domain firma.de \\
|
|
--old-login anna.mueller \\
|
|
--new-login anna.schmidt \\
|
|
--full-name "Anna Schmidt" \\
|
|
--dry-run
|
|
|
|
Mit --adopt laesst sich ein verwaistes Mailbox-Verzeichnis einem bereits
|
|
angelegten Benutzer zuordnen - fuer den Fall, dass der alte Benutzer schon
|
|
geloescht und der neue von Hand neu angelegt wurde. users.cfg bleibt dabei
|
|
unangetastet, es wandert nur das Verzeichnis:
|
|
|
|
sudo ./kerio_rename_user.py --adopt \\
|
|
--domain firma.de \\
|
|
--old-login anna.mueller \\
|
|
--new-login anna.schmidt
|
|
|
|
Ohne --dry-run wird vor dem Schreiben nachgefragt (ausser mit --yes).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from kerio_common import (
|
|
DEFAULT_INSTALL_DIR,
|
|
DEFAULT_SERVICE,
|
|
DEFAULT_STORE_DIR,
|
|
AbortedError,
|
|
ServiceController,
|
|
)
|
|
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
|
|
# ==========================================================================
|
|
|
|
|
|
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))
|
|
return renames
|
|
|
|
|
|
def rewrite_personal_vcard(mailbox: Path, old_address: str, new_address: str,
|
|
new_fullname: str | None) -> bool:
|
|
"""Schreibt die persoenliche Kontaktkarte auf den neuen Benutzer um.
|
|
|
|
<mailbox>/.personal ist eine vCard im Mail-Format und traegt Adresse und
|
|
Namen des Benutzers, dem die Mailbox vorher gehoerte. Sie einfach zu
|
|
entfernen geht nicht: Kerio legt sie nicht neu an - weder beim Start noch
|
|
bei einer Aenderung am Benutzer (geprueft gegen 10.0.9). Der Benutzer
|
|
stuende dann ohne eigene Kontaktkarte da.
|
|
|
|
Gearbeitet wird auf Bytes, damit die CRLF-Zeilenenden erhalten bleiben.
|
|
"""
|
|
path = mailbox / ".personal"
|
|
if not path.is_file():
|
|
return False
|
|
|
|
try:
|
|
data = path.read_bytes()
|
|
except OSError:
|
|
return False
|
|
|
|
updated = data.replace(old_address.encode("utf-8"), new_address.encode("utf-8"))
|
|
|
|
if new_fullname:
|
|
name = new_fullname.encode("utf-8")
|
|
# FN: und Subject: tragen den angezeigten Namen. [^\r\n]* laesst die
|
|
# Zeilenenden in Ruhe.
|
|
updated = re.sub(rb"(?m)^(FN:)[^\r\n]*", lambda m: m.group(1) + name, updated)
|
|
updated = re.sub(rb"(?mi)^(Subject:[ \t]*)[^\r\n]*",
|
|
lambda m: m.group(1) + name, updated)
|
|
|
|
if updated == data:
|
|
return False
|
|
|
|
temp = mailbox / ".personal.tmp-rename"
|
|
temp.write_bytes(updated)
|
|
shutil.copystat(path, temp)
|
|
os.replace(temp, path)
|
|
return True
|
|
|
|
|
|
def count_mails(path: Path) -> int:
|
|
"""Zaehlt die Mail-Dateien unterhalb eines Verzeichnisses."""
|
|
try:
|
|
return sum(1 for _ in path.rglob("*.eml"))
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
def plan_store_adoption(store_dir: Path, domain_name: str, source_name: str,
|
|
target_login: str, stamp: str,
|
|
force: bool) -> list[tuple[Path, Path]]:
|
|
"""Plant die Uebernahme eines verwaisten Verzeichnisses fuer einen Benutzer.
|
|
|
|
Liegt am Ziel schon ein Verzeichnis, wird es beiseitegeschoben statt
|
|
geloescht - erst danach rueckt die Quelle nach. Beides sind Umbenennungen,
|
|
deshalb reicht dieselbe (quelle, ziel)-Liste wie beim normalen Rename.
|
|
"""
|
|
if not store_dir.is_dir():
|
|
raise AbortedError(
|
|
f"Store-Verzeichnis '{store_dir}' existiert nicht. "
|
|
"Richtigen Pfad mit --store-dir angeben."
|
|
)
|
|
|
|
moves: list[tuple[Path, Path]] = []
|
|
found_any = False
|
|
|
|
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, source_name)
|
|
if source is None:
|
|
continue
|
|
found_any = True
|
|
|
|
target_name = target_login.lower() if source.name.islower() else target_login
|
|
target = find_dir_ci(domain_dir, target_login)
|
|
|
|
if target is not None and target.resolve() != source.resolve():
|
|
existing_mails = count_mails(target)
|
|
if existing_mails and not force:
|
|
raise AbortedError(
|
|
f"Das Zielverzeichnis '{target}' enthaelt bereits "
|
|
f"{existing_mails} Mail(s). Die Uebernahme wuerde sie "
|
|
"beiseiteschieben statt zusammenzufuehren. Mit --force "
|
|
"trotzdem, dann bleiben sie unter "
|
|
f"'{target.name}.abgeloest-{stamp}' erhalten."
|
|
)
|
|
moves.append((target, domain_dir / f"{target.name}.abgeloest-{stamp}"))
|
|
|
|
moves.append((source, domain_dir / target_name))
|
|
|
|
if not found_any:
|
|
raise AbortedError(
|
|
f"Kein Verzeichnis '{source_name}' unter {store_dir} gefunden "
|
|
f"(gesucht in: {', '.join(STORE_SUBTREES)}/{domain_name}/)."
|
|
)
|
|
return moves
|
|
|
|
|
|
def check_rename_targets(renames: list[tuple[Path, Path]]) -> None:
|
|
"""Prueft die Verschiebungen als Folge, nicht einzeln.
|
|
|
|
Bei der Uebernahme eines verwaisten Verzeichnisses raeumt der erste
|
|
Schritt das Ziel, das der zweite dann belegt. Eine Pruefung, die jedes
|
|
Paar isoliert betrachtet, wuerde das faelschlich als Konflikt melden -
|
|
deshalb wird mitgefuehrt, welche Pfade unterwegs frei bzw. belegt werden.
|
|
"""
|
|
freed: set[str] = set()
|
|
used: set[str] = set()
|
|
|
|
for source, target in renames:
|
|
if str(target) in used:
|
|
raise AbortedError(
|
|
f"Zwei Verschiebungen zielen auf '{target}' - das kann nicht "
|
|
"aufgehen."
|
|
)
|
|
if target.exists() and target.resolve() != source.resolve() \
|
|
and str(target) not in freed:
|
|
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."
|
|
)
|
|
freed.add(str(source))
|
|
used.add(str(target))
|
|
|
|
|
|
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:
|
|
try:
|
|
os.rename(source, target)
|
|
except OSError as exc:
|
|
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 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 adopt_store(args: argparse.Namespace, config: UsersConfig,
|
|
service: ServiceController) -> int:
|
|
"""Haengt ein verwaistes Mailbox-Verzeichnis an einen bestehenden Benutzer.
|
|
|
|
Fuer den Fall, dass der alte Benutzer schon geloescht und der neue bereits
|
|
angelegt wurde - dann ist nur noch das Verzeichnis uebrig. users.cfg wird
|
|
dabei nicht angefasst, der Zielbenutzer existiert ja bereits.
|
|
"""
|
|
store_dir = Path(args.store_dir)
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
|
|
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)'}"
|
|
)
|
|
|
|
# Der Zielbenutzer muss es geben - sonst laeuft die Mailbox ins Leere.
|
|
target_user = config.find_user(args.new_login, domain)
|
|
if target_user is None:
|
|
raise AbortedError(
|
|
f"Zielbenutzer '{args.new_login}' existiert nicht in Domain "
|
|
f"'{domain}'. Erst in der Administrationskonsole anlegen."
|
|
)
|
|
|
|
# Gibt es den alten Benutzer noch, ist die normale Umbenennung richtig.
|
|
if config.find_user(args.old_login, domain) is not None:
|
|
raise AbortedError(
|
|
f"Benutzer '{args.old_login}' existiert noch. Fuer diesen Fall "
|
|
"ist die normale Umbenennung gedacht (ohne --adopt), die auch "
|
|
"users.cfg mitzieht."
|
|
)
|
|
|
|
moves = plan_store_adoption(store_dir, domain, args.old_login,
|
|
args.new_login, stamp, args.force)
|
|
check_rename_targets(moves)
|
|
service.preflight()
|
|
|
|
source_mails = sum(count_mails(s) for s, _ in moves
|
|
if s.name.lower() == args.old_login.lower())
|
|
|
|
print("\nGeplante Uebernahme")
|
|
print("-------------------")
|
|
print(f" Domain : {domain}")
|
|
print(f" Zielbenutzer : {target_user['loginName']} "
|
|
f"({target_user['fullName']})")
|
|
print(f" Guid : {target_user['guid']}")
|
|
print(f" Mails in der Quelle: {source_mails}")
|
|
print(f" users.cfg : wird NICHT geaendert")
|
|
print(f" Dienst : {service.describe()}")
|
|
for source, target in moves:
|
|
marker = "beiseite" if ".abgeloest-" in target.name else "uebernehmen"
|
|
print(f" {marker:<12}: {source}")
|
|
print(f" -> {target}")
|
|
|
|
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.")
|
|
if input("Uebernahme jetzt durchfuehren? [j/N] ").strip().lower() \
|
|
not in ("j", "ja", "y", "yes"):
|
|
print("Abgebrochen.")
|
|
return 1
|
|
|
|
print()
|
|
was_running = service.is_running()
|
|
service.stop()
|
|
|
|
done: list[tuple[Path, Path]] = []
|
|
try:
|
|
check_rename_targets(moves)
|
|
done = apply_store_renames(moves)
|
|
|
|
# Die uebernommene Mailbox traegt noch die Identitaet des alten
|
|
# Benutzers. stats.usr legt Kerio selbst neu an, die Kontaktkarte
|
|
# nicht - die muss umgeschrieben werden.
|
|
for _, target in done:
|
|
if ".abgeloest-" in target.name:
|
|
continue
|
|
if rewrite_personal_vcard(
|
|
target,
|
|
f"{args.old_login}@{domain}",
|
|
f"{args.new_login}@{domain}",
|
|
target_user["fullName"]):
|
|
print(f" Kontaktkarte auf {target_user['fullName']} umgeschrieben")
|
|
stats = target / "stats.usr"
|
|
if stats.is_file():
|
|
stats.rename(target / f"stats.usr.alt-{stamp}")
|
|
print(" Beiseite: stats.usr (Kerio legt sie neu an)")
|
|
except (AbortedError, OSError) as exc:
|
|
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
|
revert_store_renames(done)
|
|
if was_running:
|
|
service.start()
|
|
return 2
|
|
|
|
if was_running:
|
|
service.start()
|
|
|
|
adopted = [t for _, t in done if ".abgeloest-" not in t.name]
|
|
print(f"\nFertig. '{args.new_login}' hat jetzt {source_mails} Mail(s) "
|
|
f"unter '{adopted[0] if adopted else '?'}'.")
|
|
print("Der Benutzer meldet sich unverändert mit seinem eigenen Kennwort an - "
|
|
"das Kennwort des alten Benutzers wird nicht uebernommen.")
|
|
beiseite = [t for _, t in done if ".abgeloest-" in t.name]
|
|
if beiseite:
|
|
print(f"Das vorherige Verzeichnis liegt als '{beiseite[0].name}' daneben.")
|
|
return 0
|
|
|
|
|
|
def rename_user(args: argparse.Namespace, config: UsersConfig,
|
|
service: ServiceController) -> int:
|
|
store_dir = Path(args.store_dir)
|
|
|
|
# -- Vorpruefungen: nichts wird geschrieben ---------------------------
|
|
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 = config.find_user(args.old_login, domain)
|
|
if user is None:
|
|
raise AbortedError(
|
|
f"Benutzer '{args.old_login}' existiert nicht in Domain '{domain}'."
|
|
)
|
|
|
|
old_login = user["loginName"]
|
|
new_login = args.new_login or old_login
|
|
if new_login != old_login:
|
|
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, 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()
|
|
|
|
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" Domain : {domain}")
|
|
print(f" Guid : {user['guid']}")
|
|
|
|
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)")
|
|
|
|
if needs_fullname:
|
|
print(f" Voller Name : {user['fullName']} -> {args.full_name}")
|
|
else:
|
|
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}")
|
|
|
|
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.")
|
|
return 0
|
|
|
|
if not args.yes:
|
|
print("\nAchtung: der Kerio-Dienst wird dabei kurz gestoppt.")
|
|
if input("Aenderungen jetzt durchfuehren? [j/N] ").strip().lower() \
|
|
not in ("j", "ja", "y", "yes"):
|
|
print("Abgebrochen.")
|
|
return 1
|
|
|
|
# -- Schreiben: users.cfg und Verzeichnisse in einem Stopp-Fenster ----
|
|
print()
|
|
was_running = service.is_running()
|
|
service.stop()
|
|
|
|
done: list[tuple[Path, Path]] = []
|
|
backup: Path | None = None
|
|
try:
|
|
# 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)
|
|
|
|
# Die Kontaktkarte traegt noch die alte Adresse - Kerio zieht dort
|
|
# nur den Namen nach, nicht die Adresse.
|
|
display_name = args.full_name if needs_fullname else user["fullName"]
|
|
for _, target in done:
|
|
if rewrite_personal_vcard(target, f"{old_login}@{domain}",
|
|
f"{new_login}@{domain}", display_name):
|
|
print(" Kontaktkarte auf die neue Adresse umgeschrieben")
|
|
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
|
|
|
|
if was_running:
|
|
service.start()
|
|
|
|
# -- 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
|
|
|
|
|
|
# ==========================================================================
|
|
# 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("--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",
|
|
help="Bisheriger Login-Name (ohne @domain)")
|
|
parser.add_argument("--new-login",
|
|
help="Neuer Login-Name (ohne @domain)")
|
|
parser.add_argument("--full-name",
|
|
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("--adopt", action="store_true",
|
|
help="Verwaistes Mailbox-Verzeichnis (--old-login) einem "
|
|
"bereits angelegten Benutzer (--new-login) zuordnen. "
|
|
"users.cfg bleibt unangetastet.")
|
|
|
|
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 sein.")
|
|
parser.add_argument("--service-wait", type=int, default=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 Benutzern aus einem Verzeichnisdienst")
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if not args.list:
|
|
needed = ("domain", "old_login", "new_login") if args.adopt \
|
|
else ("domain", "old_login")
|
|
missing = [n for n in needed if not getattr(args, n)]
|
|
if missing:
|
|
parser.error("Benoetigt: " + ", ".join("--" + m.replace("_", "-")
|
|
for m in missing))
|
|
if args.adopt:
|
|
if args.full_name:
|
|
parser.error("--full-name ergibt mit --adopt keinen Sinn: der "
|
|
"Zielbenutzer existiert bereits und behaelt seine "
|
|
"Angaben.")
|
|
elif 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,
|
|
mode=args.service_manager,
|
|
wait_seconds=args.service_wait,
|
|
dry_run=args.dry_run,
|
|
)
|
|
|
|
try:
|
|
config = UsersConfig(config_path)
|
|
if args.list:
|
|
return list_users(config, args.domain)
|
|
if args.adopt:
|
|
return adopt_store(args, config, service)
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|