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>
335 lines
12 KiB
Python
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.")
|