Files
duffyduckandClaude Opus 5 5011491067 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>
2026-08-07 13:44:31 +02:00

210 lines
8.0 KiB
Python

"""
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):
<config>
<list name="User">
<listitem>
<variable name="Name">max.mustermann</variable>
<variable name="Domain">local.lan</variable>
<variable name="Guid">94480090-...</variable>
<variable name="Fullname">Max Mustermann</variable>
...
</listitem>
</list>
<list name="Group"> ... </list>
<list name="Alias"> ... </list>
<list name="UserAdditionalData">
<listitem>
<variable name="Name">max.mustermann</variable>
<variable name="Domain">local.lan</variable>
<variable name="InternalDb">1</variable>
...
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 <variable name="Name"> 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'<list name="(?P<name>[^"]+)">(?P<body>.*?)</list>', re.DOTALL)
_ITEM_RE = re.compile(r"<listitem>.*?</listitem>", re.DOTALL)
def _var(block: str, name: str) -> str | None:
match = re.search(rf'<variable name="{re.escape(name)}">([^<]*)</variable>', 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 <listitem>-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'(<variable name="Name">)[^<]*(</variable>)',
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'(<variable name="Fullname">)[^<]*(</variable>)',
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'<list name="{match.group("name")}">{body}</list>'
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)