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>
210 lines
8.0 KiB
Python
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)
|