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>
361 lines
13 KiB
Python
Executable File
361 lines
13 KiB
Python
Executable File
#!/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:]))
|