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:
co-authored by
Claude Opus 5
parent
b12041fc81
commit
5011491067
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user