diff --git a/README.md b/README.md index a0e5c63..dac97d2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,19 @@ # kerio-connect-rename-user -Benennt einen Benutzer in Kerio Connect vollständig um — Login-Name in der -Serverkonfiguration **und** das zugehörige Mailbox-Verzeichnis im Store. +Werkzeuge für Kerio Connect auf der Kommandozeile. + +| Datei | Zweck | +| --- | --- | +| [`kerio_rename_user.py`](kerio_rename_user.py) | Benutzer umbenennen — Login-Name **und** Mailbox-Verzeichnis im Store | +| [`kerio_trial_setup.py`](kerio_trial_setup.py) | Am Erstkonfigurationsassistenten vorbei, wenn dessen Lizenzschritt klemmt | +| [`kerio_common.py`](kerio_common.py) | Gemeinsame Bausteine: API-Client, Dienststeuerung | + +Beide Scripts laufen **lokal auf dem Kerio-Server als root**, brauchen nur die +Python-Standardbibliothek und bieten `--dry-run`. + +--- + +# Benutzer umbenennen ## Warum @@ -110,8 +122,62 @@ selbst gestoppt sein, bevor das Script läuft. - Weitere Adressen am Benutzer (`emailAddresses`) bleiben unverändert und werden im Plan zur Kontrolle mit angezeigt. -## API-Referenz +--- + +# Erstkonfigurationsassistent überspringen + +## Warum + +Der Assistent verlangt im Schritt „Registrierte Testaktivierung" eine +Testlizenznummer samt CAPTCHA. Der dokumentierte Link „Im unregistrierten Modus +aktivieren" fehlt je nach Version oder ist im Fenster abgeschnitten, und die +verlinkte Registrierungsseite ist seit der Übernahme durch GFI teilweise tot — +man kommt im Assistenten nicht weiter. + +Kerio kennt dafür den Schalter `ConfigWizardDone` in `mailserver.cfg`. Steht er +auf `1`, öffnet die Administrationskonsole den Assistenten nicht mehr und der +Server läuft 30 Tage ab Installation im unregistrierten Modus. + +Im unregistrierten Modus fehlen Antivirus-Updates, ActiveSync, Greylisting und +Hersteller-Support; nach 30 Tagen stoppt die Engine. **Benutzerverwaltung, +Administrationskonsole und Admin-API sind nicht eingeschränkt** — für einen +Testserver also unproblematisch. + +## Benutzung + +```bash +./kerio_trial_setup.py --status # Zustand anzeigen (kein root nötig) +sudo ./kerio_trial_setup.py --skip-wizard # Assistenten überspringen +sudo ./kerio_trial_setup.py --restore # Backup zurückspielen +``` + +`--status` zeigt Dienstzustand, `ConfigWizardDone`, `TrialID`, vorhandene +Lizenzdateien und eine Schätzung, wann der Testzeitraum abläuft. + +## Der springende Punkt + +`mailserver.cfg` darf **nur bei gestopptem Dienst** bearbeitet werden. Kerio +schreibt seine Konfiguration beim Beenden aus dem Speicher zurück und würde eine +Änderung am laufenden Dienst wieder überschreiben. Das Script stoppt deshalb den +Dienst, liest die Datei **danach neu ein** (die Version von vorher ist zu diesem +Zeitpunkt bereits veraltet), schreibt atomar über eine temporäre Datei im selben +Verzeichnis, startet den Dienst und liest den Wert zur Kontrolle noch einmal. + +Vor jeder Änderung wird ein Backup als `mailserver.cfg.bak-trial-setup` angelegt. + +## Falls du doch eine registrierte Trial willst + +Geht aus der laufenden Instanz heraus, ohne Installer: **Dashboard → „Become a +registered trial user"**. Braucht ausgehendes HTTPS auf Port 443 zum +GFI-Registrierungsserver. + +--- + +## API- und Doku-Referenz - [Administration API for Kerio Connect — Users](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/interfacekerio_1_1jsonapi_1_1admin_1_1_users.html) - [Sample communication (Session.login, X-Token)](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/sample_communication.html) - [User struct](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/structkerio_1_1jsonapi_1_1admin_1_1_user.html) +- [Registering Kerio Connect (unregistered mode)](https://manuals.gfi.com/en/kerio/connect/content/registration-and-licenses/registering-kerio-connect-1134.html) +- [Initial Wizard Settings Are Not Saving (ConfigWizardDone)](https://support.kerioconnect.gfi.com/article/114858-initial-wizard-settings-are-not-saving-in-kerio-connect) +- [Modifying the mailserver.cfg](https://support.kerioconnect.gfi.com/en-us/article/114788-modifying-the-mailserver-cfg) diff --git a/kerio_common.py b/kerio_common.py new file mode 100644 index 0000000..32731ef --- /dev/null +++ b/kerio_common.py @@ -0,0 +1,301 @@ +""" +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 http.cookiejar +import json +import shutil +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." + ) + + 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.") diff --git a/kerio_rename_user.py b/kerio_rename_user.py index 499058a..373a1ee 100755 --- a/kerio_rename_user.py +++ b/kerio_rename_user.py @@ -31,306 +31,30 @@ from __future__ import annotations import argparse import getpass -import http.cookiejar import json import os -import shutil -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_STORE_DIR = "/opt/kerio/mailserver/store" -DEFAULT_SERVICE = "kerio-connect" +from kerio_common import ( + DEFAULT_SERVICE, + DEFAULT_STORE_DIR, + AbortedError, + KerioAdminApi, + KerioError, + ServiceController, + find_domain, + find_user, +) # Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann. # Alle folgen dem Schema //// STORE_SUBTREES = ("mail", "archive") -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.""" - - # ========================================================================== -# Teil 1: 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 - - -# ========================================================================== -# Teil 2: Kerio-Dienst steuern -# ========================================================================== - - -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 des Verschiebens steht. - """ - - def __init__(self, name: str, mode: str, 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": - result = self._run(f"/etc/init.d/{self.name}", "status") - return result.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": - result = self._run("systemctl", "cat", self.name) - if result.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: {result.stderr.strip() or result.stdout.strip()}" - ) - - # Warten, bis der Dienst wirklich unten ist - sonst schreibt Kerio - # noch in Dateien, die wir gerade verschieben. - 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." - ) - - 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.") - - -# ========================================================================== -# Teil 3: Store-Verzeichnisse +# Store-Verzeichnisse # ========================================================================== @@ -434,7 +158,7 @@ def _revert_store_renames(done: list[tuple[Path, Path]]) -> None: # ========================================================================== -# Teil 4: Ablauf +# Ablauf # ========================================================================== diff --git a/kerio_trial_setup.py b/kerio_trial_setup.py new file mode 100755 index 0000000..3eab827 --- /dev/null +++ b/kerio_trial_setup.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +Bringt eine frische Kerio-Connect-Installation am Erstkonfigurationsassistenten +vorbei, wenn dessen Lizenzierungsschritt nicht durchlaufbar ist. + +Hintergrund: Der Assistent verlangt im Schritt "Registrierte Testaktivierung" +eine Testlizenznummer samt CAPTCHA. Der Link "Im unregistrierten Modus +aktivieren" fehlt je nach Version oder ist im Fenster abgeschnitten, und die +verlinkte Registrierungsseite ist seit der Uebernahme durch GFI teilweise tot. + +Kerio kennt dafuer den Schalter ConfigWizardDone in mailserver.cfg. Steht er +auf 1, oeffnet die Administrationskonsole den Assistenten nicht mehr und der +Server laeuft 30 Tage ab Installation im unregistrierten Modus. + +Was im unregistrierten Modus fehlt: Antivirus-Updates, ActiveSync, Greylisting, +Hersteller-Support. Nach 30 Tagen stoppt die Engine. Benutzerverwaltung, +Administrationskonsole und Admin-API sind nicht eingeschraenkt. + +WICHTIG: mailserver.cfg darf nur bei gestopptem Dienst bearbeitet werden. +Kerio schreibt seine Konfiguration beim Beenden aus dem Speicher zurueck und +wuerde eine Aenderung am laufenden Dienst wieder ueberschreiben. Genau das +erledigt dieses Script in der richtigen Reihenfolge. + +Das Script muss LOKAL AUF DEM KERIO-SERVER ALS ROOT laufen. + +Beispiele: + + ./kerio_trial_setup.py --status # nur anzeigen + sudo ./kerio_trial_setup.py --skip-wizard # Assistenten ueberspringen + sudo ./kerio_trial_setup.py --restore # letztes Backup zuruecksetzen +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +from kerio_common import ( + DEFAULT_INSTALL_DIR, + DEFAULT_SERVICE, + AbortedError, + ServiceController, +) + +# Kerio deaktiviert die Engine 30 Tage nach der Installation. +TRIAL_DAYS = 30 + +BACKUP_SUFFIX = ".bak-trial-setup" + +# 0 +WIZARD_PATTERN = re.compile( + r'()([^<]*)()' +) + + +class ConfigFile: + """Liest und schreibt mailserver.cfg mit Backup.""" + + 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 wizard_done(self) -> str | None: + match = WIZARD_PATTERN.search(self.text) + return match.group(2).strip() if match else None + + def trial_id(self) -> str | None: + match = re.search( + r'([^<]*)', self.text) + return match.group(1).strip() if match else None + + def store_dir(self) -> str | None: + match = re.search( + r'([^<]*)', self.text) + return match.group(1).strip() if match else None + + # -- Schreiben --------------------------------------------------------- + + def set_wizard_done(self, value: str = "1") -> bool: + """Setzt ConfigWizardDone. Gibt False zurueck, wenn schon so.""" + match = WIZARD_PATTERN.search(self.text) + if match is None: + raise AbortedError( + "ConfigWizardDone steht nicht in der Konfiguration. " + "Andere Kerio-Version? Bitte die Datei von Hand pruefen." + ) + if match.group(2).strip() == value: + return False + self.text = WIZARD_PATTERN.sub(rf"\g<1>{value}\g<3>", self.text, count=1) + return True + + def backup(self) -> Path: + target = self.path.with_name(self.path.name + BACKUP_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-trial-setup") + temp.write_text(self.text, encoding="utf-8") + shutil.copystat(self.path, temp) + os.replace(temp, self.path) + + +def estimate_install_date(install_dir: Path, config: ConfigFile) -> datetime | None: + """Schaetzt den Installationszeitpunkt. + + Kerio fuehrt kein Installationsdatum in der Konfiguration, deshalb bleibt + nur der Umweg ueber Zeitstempel: + + 1. der Eintrag der Paketverwaltung - am zuverlaessigsten + 2. sonst der aelteste Zeitstempel unter den Store-Unterverzeichnissen, + die Kerio einmalig beim ersten Start anlegt + + Verzeichnisse wie store/ selbst taugen nicht: ihr Zeitstempel wandert + mit jeder Aenderung darin. Der Wert ist eine Schaetzung, kein Beweis. + """ + for package_file in ( + Path("/var/lib/dpkg/info/kerio-connect.list"), + Path("/var/lib/dpkg/info/kerio-connect:amd64.list"), + ): + if package_file.is_file(): + try: + return datetime.fromtimestamp(package_file.stat().st_mtime) + except OSError: + pass + + store = Path(config.store_dir() or (install_dir / "store")) + stamps = [] + for name in ("queue", "saas", "spamassassin", "logs"): + path = store / name + if path.is_dir(): + try: + stamps.append(path.stat().st_mtime) + except OSError: + continue + if stamps: + return datetime.fromtimestamp(min(stamps)) + return None + + +def print_status(config: ConfigFile, install_dir: Path, + service: ServiceController) -> None: + print("Status") + print("------") + print(f" Konfiguration : {config.path}") + + running = service.is_running() + state = {True: "laeuft", False: "gestoppt", None: "unbekannt"}[running] + print(f" Dienst : {service.describe()} -> {state}") + + wizard = config.wizard_done() + if wizard == "1": + wizard_text = "1 (Assistent wird uebersprungen)" + elif wizard == "0": + wizard_text = "0 (Assistent erscheint beim Oeffnen der Konsole)" + else: + wizard_text = f"{wizard!r} (unerwarteter Wert)" + print(f" ConfigWizard : {wizard_text}") + + trial = config.trial_id() + print(f" TrialID : {trial or '(leer - unregistrierter Modus)'}") + + license_dir = install_dir / "license" + if license_dir.is_dir(): + entries = [e for e in license_dir.iterdir() if not e.name.startswith(".")] + print(f" Lizenzdateien : {len(entries) or 'keine'}") + + installed = estimate_install_date(install_dir, config) + if installed: + expires = installed + timedelta(days=TRIAL_DAYS) + remaining = (expires - datetime.now()).days + print(f" Installiert : ~{installed:%Y-%m-%d %H:%M} (geschaetzt)") + if remaining >= 0: + print(f" Testzeitraum : laeuft ~{expires:%Y-%m-%d} ab " + f"(noch ~{remaining} Tage)") + else: + print(f" Testzeitraum : seit ~{expires:%Y-%m-%d} abgelaufen " + "- die Engine wird deaktiviert sein") + + +def skip_wizard(config: ConfigFile, service: ServiceController, + args: argparse.Namespace) -> int: + if config.wizard_done() == "1": + print("\nConfigWizardDone steht bereits auf 1 - nichts zu tun.") + return 0 + + service.preflight() + + print("\nGeplante Aenderung") + print("------------------") + print(f" {config.path}") + print(" ConfigWizardDone: 0 -> 1") + print(f" Backup nach : {config.path.name}{BACKUP_SUFFIX}") + print(f" Dienst : {service.describe()} (stoppen, aendern, starten)") + + if args.dry_run: + print("\n--dry-run: es wurde nichts geaendert.") + return 0 + + if not args.yes: + print("\nAchtung: der Kerio-Dienst wird dabei kurz gestoppt.") + if input("Jetzt durchfuehren? [j/N] ").strip().lower() not in ("j", "ja", "y", "yes"): + print("Abgebrochen.") + return 1 + + print() + was_running = service.is_running() + service.stop() + + try: + # Erst nach dem Stoppen neu einlesen: Kerio hat die Datei beim + # Beenden zurueckgeschrieben, unsere Kopie von vorhin ist veraltet. + fresh = ConfigFile(config.path) + backup = fresh.backup() + print(f" Backup: {backup}") + + if not fresh.set_wizard_done("1"): + print(" ConfigWizardDone stand nach dem Stoppen schon auf 1.") + else: + fresh.save() + print(" ConfigWizardDone auf 1 gesetzt.") + except (AbortedError, OSError) as exc: + print(f"\nFEHLER: {exc}", file=sys.stderr) + if was_running: + service.start() + return 2 + finally: + if was_running: + service.start() + + # Gegenlesen: haelt der Wert, nachdem Kerio wieder laeuft? + time.sleep(2) + check = ConfigFile(config.path) + if check.wizard_done() != "1": + print("\n WARNUNG: Der Wert steht nach dem Start nicht mehr auf 1. " + "Lief der Dienst beim Schreiben doch noch?", file=sys.stderr) + return 2 + + print("\nFertig. Die Administrationskonsole oeffnet den Assistenten nicht mehr.") + print("Kerio laeuft im unregistrierten Modus - 30 Tage ab Installation.") + return 0 + + +def restore(config_path: Path, service: ServiceController, + args: argparse.Namespace) -> int: + backup = config_path.with_name(config_path.name + BACKUP_SUFFIX) + if not backup.is_file(): + raise AbortedError(f"Kein Backup gefunden: {backup}") + + print(f"\nStellt '{backup}' nach '{config_path}' wieder her.") + if args.dry_run: + print("--dry-run: es wurde nichts geaendert.") + return 0 + if not args.yes: + if input("Jetzt zuruecksetzen? [j/N] ").strip().lower() not in ("j", "ja", "y", "yes"): + print("Abgebrochen.") + return 1 + + print() + was_running = service.is_running() + service.stop() + try: + shutil.copy2(backup, config_path) + print(f" Wiederhergestellt aus {backup}") + finally: + if was_running: + service.start() + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Kerio Connect am Erstkonfigurationsassistenten vorbeibringen.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--install-dir", default=DEFAULT_INSTALL_DIR, + help=f"Kerio-Installationsverzeichnis " + f"(Standard: {DEFAULT_INSTALL_DIR})") + parser.add_argument("--service-name", default=DEFAULT_SERVICE, + help=f"Name des Kerio-Dienstes (Standard: {DEFAULT_SERVICE})") + parser.add_argument("--service-manager", default="auto", + choices=("auto", "systemd", "initd", "none"), + help="Wie der Dienst gestoppt wird") + parser.add_argument("--service-wait", type=int, default=90, + help="Sekunden Wartezeit aufs Stoppen (Standard: 90)") + + action = parser.add_mutually_exclusive_group() + action.add_argument("--status", action="store_true", + help="Nur den Zustand anzeigen (Standard)") + action.add_argument("--skip-wizard", action="store_true", + help="ConfigWizardDone auf 1 setzen") + action.add_argument("--restore", action="store_true", + help="Konfiguration aus dem Backup zuruecksetzen") + + parser.add_argument("--dry-run", action="store_true", + help="Nur anzeigen, was passieren wuerde") + parser.add_argument("--yes", action="store_true", + help="Rueckfrage ueberspringen") + + args = parser.parse_args(argv) + + if (args.skip_wizard or args.restore) and not args.dry_run and os.geteuid() != 0: + parser.error( + "Zum Aendern der Konfiguration werden root-Rechte benoetigt. " + "Mit sudo starten (oder --dry-run zum Testen)." + ) + return args + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + install_dir = Path(args.install_dir) + config_path = install_dir / "mailserver.cfg" + + service = ServiceController( + name=args.service_name, + mode=args.service_manager, + wait_seconds=args.service_wait, + dry_run=args.dry_run, + ) + + try: + if args.restore: + return restore(config_path, service, args) + + config = ConfigFile(config_path) + print_status(config, install_dir, service) + + if args.skip_wizard: + return skip_wizard(config, service, args) + return 0 + except AbortedError as exc: + print(f"\nFEHLER: {exc}", file=sys.stderr) + return 2 + except PermissionError as exc: + print(f"\nFEHLER: Keine Berechtigung: {exc}\nMit sudo starten.", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("\nAbgebrochen.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))