Files
kerio-connect-rename-user/kerio_common.py
T
duffyduckandClaude Opus 5 24070cba4e Assistent mit Menuefuehrung, dazu zwei Haertungen
kerio_tui.py fuehrt durch Umbenennung und Uebernahme, ohne dass man
Parameter kennen muss: Benutzer und Domains kommen zur Auswahl, verwaiste
Mailbox-Verzeichnisse findet der Assistent selbst, und vor dem Ausfuehren
zeigt er den gleichwertigen Kommandozeilenaufruf. Nur curses aus der
Standardbibliothek, keine neue Abhaengigkeit.

Beim Durchfahren ueber ein PTY kamen zwei Dinge heraus:

keypad(True) loest die Pfeiltasten auf diesem Server nicht auf - getch()
liefert 27, 91, 66 statt KEY_DOWN, wofuer ein unpassendes terminfo
genuegt. Das fuehrende ESC haette als "zurueck" gegolten und den
Assistenten bei jedem Pfeildruck verlassen. Er loest die Sequenzen jetzt
selbst auf; ein einzelnes ESC wirkt weiterhin als Abbruch.

Und: Bricht die Verbindung weg, waehrend Kerio gestoppt ist, stirbt das
Script an SIGHUP und laesst den Dienst unten - genau so passiert, als der
Testtreiber die Verbindung schloss. ServiceController.abgesichert() faengt
SIGHUP, SIGTERM und SIGINT ab und startet den Dienst wieder, bevor der
Abbruch durchgereicht wird. Gegen den Testserver geprueft: Signal mitten
im Stopp-Fenster, Dienst kommt hoch, Daten unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:54:33 +02:00

335 lines
12 KiB
Python

"""
Gemeinsame Bausteine fuer die Kerio-Connect-Scripts:
* KerioAdminApi - JSON-RPC-Client fuer die Administration API
* ServiceController - startet und stoppt den Kerio-Dienst
* Hilfsfunktionen zum Auffinden von Domains und Benutzern
"""
from __future__ import annotations
import contextlib
import http.cookiejar
import json
import shutil
import signal
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
APP_INFO = {
"name": "kerio-connect-user-rename",
"vendor": "inhouse",
"version": "2.0",
}
# Seitengroesse fuer die Paginierung von Users.get / Domains.get.
PAGE_SIZE = 500
DEFAULT_INSTALL_DIR = "/opt/kerio/mailserver"
DEFAULT_STORE_DIR = "/opt/kerio/mailserver/store"
DEFAULT_SERVICE = "kerio-connect"
class KerioError(Exception):
"""Fehler, den der Kerio-Server gemeldet hat (oder ein Transportfehler)."""
class AbortedError(Exception):
"""Der Benutzer hat abgebrochen oder eine Vorpruefung ist fehlgeschlagen."""
# ==========================================================================
# Administration API (JSON-RPC)
# ==========================================================================
class KerioAdminApi:
"""Duenner JSON-RPC-Client fuer die Kerio Connect Admin-API.
Die API will nach dem Login drei Dinge sehen: das Session-Cookie, den
X-Token-Header und denselben Token nochmal im JSON-Payload.
"""
def __init__(self, host: str, port: int = 4040, verify_tls: bool = True,
timeout: int = 30) -> None:
self.url = f"https://{host}:{port}/admin/api/jsonrpc/"
self.timeout = timeout
self.token: str | None = None
self._request_id = 0
context = ssl.create_default_context()
if not verify_tls:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self._opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context),
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
)
# -- Transport ---------------------------------------------------------
def call(self, method: str, params: dict | None = None) -> dict:
self._request_id += 1
payload: dict = {"jsonrpc": "2.0", "id": self._request_id, "method": method}
if params is not None:
payload["params"] = params
if self.token:
payload["token"] = self.token
headers = {"Content-Type": "application/json-rpc; charset=UTF-8"}
if self.token:
headers["X-Token"] = self.token
request = urllib.request.Request(
self.url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with self._opener.open(request, timeout=self.timeout) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise KerioError(f"HTTP {exc.code} bei {method}: {exc.read()[:500]!r}") from exc
except urllib.error.URLError as exc:
raise KerioError(f"Verbindung zu {self.url} fehlgeschlagen: {exc.reason}") from exc
except ssl.SSLError as exc:
raise KerioError(
f"TLS-Fehler: {exc}. Bei selbstsigniertem Zertifikat --insecure verwenden."
) from exc
if "error" in body:
error = body["error"]
raise KerioError(
f"{method}: {error.get('message', 'unbekannter Fehler')} "
f"(Code {error.get('code')})"
)
result = body.get("result", {})
_raise_on_error_list(method, result)
return result
def call_paged(self, method: str, params: dict, list_key: str = "list") -> list[dict]:
"""Ruft eine get-Methode so oft auf, bis alle totalItems eingesammelt sind."""
items: list[dict] = []
while True:
query = dict(params.get("query") or {})
query.update({"start": len(items), "limit": PAGE_SIZE})
query.setdefault("fields", [])
query.setdefault("conditions", [])
query.setdefault("combining", "Or")
result = self.call(method, {**params, "query": query})
page = result.get(list_key) or []
items.extend(page)
total = result.get("totalItems", len(items))
if not page or len(items) >= total:
return items
# -- Session -----------------------------------------------------------
def login(self, username: str, password: str) -> None:
result = self.call("Session.login", {
"userName": username,
"password": password,
"application": APP_INFO,
})
self.token = result.get("token")
if not self.token:
raise KerioError("Login lieferte keinen Token zurueck.")
def logout(self) -> None:
if self.token:
try:
self.call("Session.logout")
except KerioError:
pass # Ausloggen ist best effort
finally:
self.token = None
def __enter__(self) -> "KerioAdminApi":
return self
def __exit__(self, *_exc_info) -> None:
self.logout()
def _raise_on_error_list(method: str, result: dict) -> None:
"""Kerio liefert Teilfehler nicht als JSON-RPC-error, sondern als 'errors'-Liste."""
errors = result.get("errors")
if not errors:
return
details = "; ".join(
f"{e.get('message', '?')} (Code {e.get('code')}, Feld {e.get('inputIndex')})"
for e in errors
)
raise KerioError(f"{method} meldet Fehler: {details}")
def find_domain(api: KerioAdminApi, domain_name: str) -> dict:
domains = api.call_paged("Domains.get", {})
wanted = domain_name.strip().lower()
for domain in domains:
if domain.get("name", "").lower() == wanted:
return domain
known = ", ".join(sorted(d.get("name", "?") for d in domains)) or "(keine)"
raise KerioError(f"Domain '{domain_name}' nicht gefunden. Vorhanden: {known}")
def find_user(api: KerioAdminApi, domain_id: str, login_name: str) -> dict | None:
users = api.call_paged("Users.get", {"domainId": domain_id})
wanted = login_name.strip().lower()
for user in users:
if user.get("loginName", "").lower() == wanted:
return user
return None
# ==========================================================================
# Dienststeuerung
# ==========================================================================
class ServiceController:
"""Startet und stoppt den Kerio-Connect-Dienst.
Modus 'none' fasst den Dienst nicht an - dann muss man selbst dafuer
sorgen, dass Kerio waehrend der Aenderung steht.
"""
def __init__(self, name: str = DEFAULT_SERVICE, mode: str = "auto",
wait_seconds: int = 90, dry_run: bool = False) -> None:
self.name = name
self.wait_seconds = wait_seconds
self.dry_run = dry_run
self.mode = self._resolve_mode(mode)
@staticmethod
def _resolve_mode(mode: str) -> str:
if mode != "auto":
return mode
if shutil.which("systemctl"):
return "systemd"
if Path("/etc/init.d", DEFAULT_SERVICE).exists():
return "initd"
return "none"
def _run(self, *command: str) -> subprocess.CompletedProcess:
return subprocess.run(command, capture_output=True, text=True, timeout=120)
def describe(self) -> str:
if self.mode == "none":
return "Dienst wird nicht angefasst (--service-manager none)"
return f"{self.mode}: {self.name}"
def is_running(self) -> bool | None:
"""True/False, oder None wenn der Status nicht ermittelbar ist."""
if self.mode == "systemd":
return self._run("systemctl", "is-active", "--quiet", self.name).returncode == 0
if self.mode == "initd":
return self._run(f"/etc/init.d/{self.name}", "status").returncode == 0
return None
def preflight(self) -> None:
"""Prueft vor allen Aenderungen, ob der Dienst ueberhaupt steuerbar ist."""
if self.mode == "none":
return
if self.mode == "systemd":
if self._run("systemctl", "cat", self.name).returncode != 0:
raise AbortedError(
f"systemd kennt die Unit '{self.name}' nicht. "
"Richtigen Namen mit --service-name angeben oder "
"--service-manager none verwenden."
)
elif self.mode == "initd":
if not Path(f"/etc/init.d/{self.name}").exists():
raise AbortedError(f"/etc/init.d/{self.name} existiert nicht.")
def stop(self) -> None:
if self.mode == "none":
print(" Dienst: uebersprungen (--service-manager none)")
return
if self.dry_run:
print(f" [dry-run] wuerde Dienst '{self.name}' stoppen")
return
print(f" Stoppe Dienst '{self.name}' ...")
if self.mode == "systemd":
result = self._run("systemctl", "stop", self.name)
else:
result = self._run(f"/etc/init.d/{self.name}", "stop")
if result.returncode != 0:
raise AbortedError(
f"Dienst konnte nicht gestoppt werden: "
f"{result.stderr.strip() or result.stdout.strip()}"
)
# Warten, bis der Dienst wirklich unten ist - sonst schreibt Kerio
# noch in Dateien, die wir gerade anfassen.
deadline = time.monotonic() + self.wait_seconds
while time.monotonic() < deadline:
if self.is_running() is False:
print(" Dienst gestoppt.")
return
time.sleep(1)
raise AbortedError(
f"Dienst '{self.name}' laeuft nach {self.wait_seconds}s immer noch. Abbruch."
)
@contextlib.contextmanager
def abgesichert(self, war_aktiv: bool):
"""Startet den Dienst wieder, wenn das Script abgeschossen wird.
Diese Scripts laufen ueblicherweise ueber SSH. Bricht die Verbindung
weg, kommt SIGHUP - und ohne Vorkehrung bliebe Kerio gestoppt
zurueck, weil der Prozess stirbt, bevor er ihn wieder starten kann.
Dasselbe gilt fuer SIGTERM und Strg-C.
"""
def behandle(signalnummer, _rahmen):
print(f"\n Signal {signalnummer} empfangen - Kerio wird wieder "
"gestartet, dann Abbruch.", file=sys.stderr)
if war_aktiv:
self.start()
raise SystemExit(130)
vorher = {}
for signalnummer in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
try:
vorher[signalnummer] = signal.signal(signalnummer, behandle)
except (ValueError, OSError):
pass # z. B. wenn nicht im Hauptthread
try:
yield
finally:
for signalnummer, handler in vorher.items():
try:
signal.signal(signalnummer, handler)
except (ValueError, OSError):
pass
def start(self) -> None:
if self.mode == "none":
return
if self.dry_run:
print(f" [dry-run] wuerde Dienst '{self.name}' starten")
return
print(f" Starte Dienst '{self.name}' ...")
if self.mode == "systemd":
result = self._run("systemctl", "start", self.name)
else:
result = self._run(f"/etc/init.d/{self.name}", "start")
if result.returncode != 0:
print(f" WARNUNG: Start fehlgeschlagen: "
f"{result.stderr.strip() or result.stdout.strip()}", file=sys.stderr)
else:
print(" Dienst gestartet.")