Script fuer den Erstkonfigurationsassistenten, gemeinsames Modul

kerio_trial_setup.py bringt eine frische Installation am Assistenten
vorbei, wenn dessen Lizenzschritt nicht durchlaufbar ist: der Link
"Im unregistrierten Modus aktivieren" fehlt je nach Version und die
verlinkte Registrierungsseite ist seit der GFI-Uebernahme teils tot.
Gesetzt wird ConfigWizardDone in mailserver.cfg; Kerio laeuft danach
30 Tage unregistriert, ohne Einschraenkung bei Benutzerverwaltung und
Admin-API.

Die Datei darf nur bei gestopptem Dienst bearbeitet werden, weil Kerio
seine Konfiguration beim Beenden aus dem Speicher zurueckschreibt. Das
Script stoppt den Dienst, liest danach neu ein, schreibt atomar ueber
eine temporaere Datei und liest den Wert nach dem Start zur Kontrolle
erneut. Backup vor jeder Aenderung, --restore spielt es zurueck.

API-Client und Dienststeuerung wandern nach kerio_common.py, damit
beide Scripts sie teilen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-07 13:26:09 +02:00
co-authored by Claude Opus 5
parent 485a681a0a
commit b12041fc81
4 changed files with 742 additions and 291 deletions
+69 -3
View File
@@ -1,7 +1,19 @@
# kerio-connect-rename-user # kerio-connect-rename-user
Benennt einen Benutzer in Kerio Connect vollständig um — Login-Name in der Werkzeuge für Kerio Connect auf der Kommandozeile.
Serverkonfiguration **und** das zugehörige Mailbox-Verzeichnis im Store.
| 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 ## Warum
@@ -110,8 +122,62 @@ selbst gestoppt sein, bevor das Script läuft.
- Weitere Adressen am Benutzer (`emailAddresses`) bleiben unverändert und werden - Weitere Adressen am Benutzer (`emailAddresses`) bleiben unverändert und werden
im Plan zur Kontrolle mit angezeigt. 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) - [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) - [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) - [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)
+301
View File
@@ -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.")
+12 -288
View File
@@ -31,306 +31,30 @@ from __future__ import annotations
import argparse import argparse
import getpass import getpass
import http.cookiejar
import json import json
import os import os
import shutil
import ssl
import subprocess
import sys import sys
import time import time
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
APP_INFO = { from kerio_common import (
"name": "kerio-connect-user-rename", DEFAULT_SERVICE,
"vendor": "inhouse", DEFAULT_STORE_DIR,
"version": "2.0", AbortedError,
} KerioAdminApi,
KerioError,
# Seitengroesse fuer die Paginierung von Users.get / Domains.get. ServiceController,
PAGE_SIZE = 500 find_domain,
find_user,
DEFAULT_STORE_DIR = "/opt/kerio/mailserver/store" )
DEFAULT_SERVICE = "kerio-connect"
# Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann. # Store-Unterbaeume, in denen pro Benutzer ein Verzeichnis liegen kann.
# Alle folgen dem Schema <store>/<subtree>/<domain>/<loginname>/ # Alle folgen dem Schema <store>/<subtree>/<domain>/<loginname>/
STORE_SUBTREES = ("mail", "archive") 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) # Store-Verzeichnisse
# ==========================================================================
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
# ========================================================================== # ==========================================================================
@@ -434,7 +158,7 @@ def _revert_store_renames(done: list[tuple[Path, Path]]) -> None:
# ========================================================================== # ==========================================================================
# Teil 4: Ablauf # Ablauf
# ========================================================================== # ==========================================================================
+360
View File
@@ -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"
# <variable name="ConfigWizardDone">0</variable>
WIZARD_PATTERN = re.compile(
r'(<variable\s+name="ConfigWizardDone"\s*>)([^<]*)(</variable>)'
)
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'<variable\s+name="TrialID"\s*>([^<]*)</variable>', self.text)
return match.group(1).strip() if match else None
def store_dir(self) -> str | None:
match = re.search(
r'<variable\s+name="StoreDir"\s*>([^<]*)</variable>', 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:]))