""" Liest und aendert /opt/kerio/mailserver/users.cfg. Warum diese Datei und nicht 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 dieser Datei aendern. Aufbau der Datei (XML, UTF-8): max.mustermann local.lan 94480090-... Max Mustermann ... ... ... max.mustermann local.lan 1 ... Der Login-Name steht in ZWEI Listen: 'User' und 'UserAdditionalData'. Beide verweisen ueber Name+Domain, nicht ueber die Guid - beide muessen mitgeaendert werden, sonst verliert der Benutzer seine Passworthistorie. Geaendert wird der Rohtext, nicht ein neu serialisierter XML-Baum: so bleiben Formatierung und alle Felder, die wir nicht kennen, unangetastet. """ from __future__ import annotations import os import re import shutil from pathlib import Path from kerio_common import AbortedError # Listen, in denen ein Benutzer ueber gefuehrt wird. USER_LISTS = ("User", "UserAdditionalData") # Erlaubte Zeichen fuer einen Login-Namen. Bewusst eng gehalten: der Name # landet im Dateisystem als Verzeichnisname und im XML als Elementinhalt. VALID_LOGIN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$") _LIST_RE = re.compile( r'(?P.*?)', re.DOTALL) _ITEM_RE = re.compile(r".*?", re.DOTALL) def _var(block: str, name: str) -> str | None: match = re.search(rf'([^<]*)', block) return match.group(1) if match else None def validate_login(name: str) -> None: if not VALID_LOGIN.match(name): raise AbortedError( f"'{name}' ist als Login-Name nicht zulaessig. Erlaubt sind " "Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich; " "Anfang und Ende muessen alphanumerisch sein." ) class UsersConfig: """Rohtext-Sicht auf users.cfg mit gezielten Aenderungen.""" def __init__(self, path: Path) -> None: self.path = path if not path.is_file(): raise AbortedError( f"'{path}' existiert nicht. Installationsverzeichnis mit " "--install-dir angeben." ) self.text = path.read_text(encoding="utf-8") # -- Lesen ------------------------------------------------------------- def items(self, list_name: str) -> list[str]: """Alle -Bloecke einer Liste als Rohtext.""" for match in _LIST_RE.finditer(self.text): if match.group("name") == list_name: return _ITEM_RE.findall(match.group("body")) return [] def domains(self) -> list[str]: seen = [] for block in self.items("User"): domain = _var(block, "Domain") if domain and domain not in seen: seen.append(domain) return seen def users(self, domain: str | None = None) -> list[dict]: """Benutzer als Dicts mit den Feldern, die uns interessieren.""" result = [] for block in self.items("User"): entry = { "loginName": _var(block, "Name") or "", "domain": _var(block, "Domain") or "", "fullName": _var(block, "Fullname") or "", "guid": _var(block, "Guid") or "", "enabled": _var(block, "Account_enabled") == "1", } if domain is None or entry["domain"].lower() == domain.lower(): result.append(entry) return result def find_user(self, login_name: str, domain: str) -> dict | None: wanted = login_name.strip().lower() for user in self.users(domain): if user["loginName"].lower() == wanted: return user return None def is_internal(self, login_name: str, domain: str) -> bool: """InternalDb=1 heisst: Benutzer liegt in Kerios eigener Datenbank. Bei Benutzern aus einem Verzeichnisdienst gehoert der Name dorthin und darf hier nicht geaendert werden. """ for block in self.items("UserAdditionalData"): if (_var(block, "Name") or "").lower() == login_name.lower() and \ (_var(block, "Domain") or "").lower() == domain.lower(): internal = _var(block, "InternalDb") return internal is None or internal == "1" # Kein Eintrag: nicht raten, als intern behandeln. return True def other_occurrences(self, login_name: str, domain: str) -> list[str]: """Sucht den Namen ausserhalb von User/UserAdditionalData. Kerio speichert Gruppenmitgliedschaften ueber Guids, daher ist hier normalerweise nichts zu finden. Falls doch, wird gewarnt statt blind zu aendern - besser ein Hinweis als eine kaputte Konfiguration. """ hits = [] for match in _LIST_RE.finditer(self.text): list_name = match.group("name") if list_name in USER_LISTS: continue for block in _ITEM_RE.findall(match.group("body")): if re.search(rf"\b{re.escape(login_name)}\b", block): label = _var(block, "Name") or "(ohne Name)" hits.append(f"{list_name}: {label}") return hits # -- Schreiben --------------------------------------------------------- def rename_user(self, old_login: str, new_login: str, domain: str, full_name: str | None = None) -> int: """Setzt Name (und optional Fullname) in allen zustaendigen Listen. Gibt die Anzahl geaenderter listitem-Bloecke zurueck. """ validate_login(new_login) changed = 0 def patch_list(match: re.Match) -> str: nonlocal changed if match.group("name") not in USER_LISTS: return match.group(0) def patch_item(item: re.Match) -> str: nonlocal changed block = item.group(0) if (_var(block, "Name") or "").lower() != old_login.lower(): return block if (_var(block, "Domain") or "").lower() != domain.lower(): return block new_block = re.sub( r'()[^<]*()', lambda m: m.group(1) + new_login + m.group(2), block, count=1) if full_name is not None and _var(block, "Fullname") is not None: new_block = re.sub( r'()[^<]*()', lambda m: m.group(1) + full_name + m.group(2), new_block, count=1) changed += 1 return new_block body = _ITEM_RE.sub(patch_item, match.group("body")) return f'{body}' self.text = _LIST_RE.sub(patch_list, self.text) return changed def backup(self, suffix: str) -> Path: target = self.path.with_name(self.path.name + suffix) shutil.copy2(self.path, target) return target def save(self) -> None: # Ueber eine temporaere Datei im selben Verzeichnis, damit die # Konfiguration nie halb geschrieben auf der Platte liegt. temp = self.path.with_name(self.path.name + ".tmp-rename") temp.write_text(self.text, encoding="utf-8") shutil.copystat(self.path, temp) os.replace(temp, self.path)