first release

This commit is contained in:
2026-05-12 11:04:17 +02:00
parent d49cebf616
commit e8ef2081ed
13 changed files with 1403 additions and 0 deletions
View File
+181
View File
@@ -0,0 +1,181 @@
"""Kerio Connect Admin API Client (JSON-RPC).
Endpoint: POST https://<host>:4040/admin/api/jsonrpc/
Login liefert ein Token, das via X-Token-Header bei jedem Folgeaufruf gesetzt wird.
Hinweis: die exakten Methodennamen (Pop3Accounts.create vs RemotePop3.set etc.)
können sich zwischen Kerio Connect Versionen leicht unterscheiden. Die hier
verwendeten Namen entsprechen Kerio Connect 9.x. Bei Fehlern wirft der Client
die Originalmeldung mit Methodenname so siehst du sofort, was anzupassen ist.
"""
from typing import Optional
import requests
import urllib3
class KerioError(Exception):
pass
class KerioClient:
def __init__(self, host: str, *, user: str, password: str,
port: int = 4040, verify: bool = True):
if not host:
raise KerioError("Kerio-Host ist leer")
self.base = f"https://{host}:{port}"
self.session = requests.Session()
self.session.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.token: Optional[str] = None
self._req_id = 0
self._login(user, password)
def _next_id(self) -> int:
self._req_id += 1
return self._req_id
def _call(self, method: str, params: Optional[dict] = None) -> dict:
payload = {
"jsonrpc": "2.0",
"id": self._next_id(),
"method": method,
"params": params or {},
}
headers = {"Content-Type": "application/json"}
if self.token:
headers["X-Token"] = self.token
try:
r = self.session.post(
f"{self.base}/admin/api/jsonrpc/",
json=payload, headers=headers, timeout=30,
)
except requests.RequestException as e:
raise KerioError(f"Kerio Verbindung fehlgeschlagen: {e}")
if not r.ok:
raise KerioError(f"Kerio HTTP {r.status_code} bei {method}: {r.text[:300]}")
try:
data = r.json()
except ValueError:
raise KerioError(f"Kerio: ungültige Antwort: {r.text[:300]}")
if "error" in data:
err = data["error"]
raise KerioError(
f"Kerio {method}{err.get('code')} {err.get('message')} "
f"({err.get('data', {})})"
)
return data.get("result") or {}
def _login(self, user: str, password: str) -> None:
result = self._call("Session.login", {
"userName": user,
"password": password,
"application": {
"name": "deploy-email-plesk-kerio-nextcloud",
"vendor": "intern",
"version": "1.0",
},
})
self.token = result.get("token")
if not self.token:
raise KerioError("Kerio: Login lieferte kein Token")
def logout(self) -> None:
try:
self._call("Session.logout")
except Exception:
pass
# ----- Domains / Users -----
def get_domain_id(self, domain_name: str) -> str:
result = self._call("Domains.get", {
"query": {
"fields": ["id", "name"],
"conditions": [{
"fieldName": "name",
"comparator": "Eq",
"value": domain_name,
}],
"combining": "And",
"start": 0,
"limit": 50,
"orderBy": [],
},
})
items = result.get("list", [])
if not items:
raise KerioError(f"Kerio: Domain '{domain_name}' nicht in Kerio konfiguriert")
return items[0]["id"]
def user_exists(self, email: str) -> bool:
local, domain = email.split("@", 1)
try:
domain_id = self.get_domain_id(domain)
except KerioError:
return False
result = self._call("Users.get", {
"query": {
"fields": ["id", "loginName"],
"conditions": [{
"fieldName": "loginName",
"comparator": "Eq",
"value": local,
}],
"combining": "And",
"start": 0,
"limit": 5,
"orderBy": [],
},
"domainId": domain_id,
})
return bool(result.get("list"))
def create_user(self, email: str, password: str, full_name: str) -> str:
local, domain = email.split("@", 1)
domain_id = self.get_domain_id(domain)
user_def = {
"loginName": local,
"fullName": full_name,
"domainId": domain_id,
"password": password,
"authType": "Internal",
"isEnabled": True,
"role": "UserRole",
"emailAddresses": [email],
# User darf sein Passwort NICHT selbst ändern
"mayChangePassword": False,
"forceChangePassword": False,
}
result = self._call("Users.create", {"users": [user_def]})
errors = result.get("errors") or []
if errors:
raise KerioError(f"Kerio Users.create errors: {errors}")
items = result.get("result") or []
if not items:
raise KerioError("Kerio Users.create lieferte keinen Datensatz zurück")
return items[0].get("id")
# ----- POP3 Sammler -----
def add_pop3_collection(self, *, kerio_user_id: str,
server: str, login_name: str, password: str,
port: int = 465, ssl: bool = True,
leave_days: int = 14) -> None:
account = {
"enabled": True,
"deliverTo": kerio_user_id,
"server": server,
"loginName": login_name,
"password": password,
"port": port,
"ssl": ssl,
"useSpecificPort": True,
"leaveOnServer": True,
"deleteOnServer": True,
"deleteOnServerDays": leave_days,
}
result = self._call("Pop3Accounts.create", {"accounts": [account]})
errors = result.get("errors") or []
if errors:
raise KerioError(f"Kerio Pop3Accounts.create errors: {errors}")
+78
View File
@@ -0,0 +1,78 @@
"""Nextcloud Provisioning API (OCS) Client."""
from typing import Optional
import requests
import urllib3
class NextcloudError(Exception):
pass
class NextcloudClient:
def __init__(self, host: str, *, admin_user: str, admin_password: str,
verify: bool = True, scheme: str = "https"):
if not host:
raise NextcloudError("Nextcloud-Host ist leer")
self.base = f"{scheme}://{host}"
self.session = requests.Session()
self.session.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.session.auth = (admin_user, admin_password)
self.session.headers["OCS-APIRequest"] = "true"
self.session.headers["Accept"] = "application/json"
def _meta(self, r: requests.Response, op: str) -> dict:
if not r.ok and r.status_code not in (400, 401, 403, 404):
raise NextcloudError(f"Nextcloud {op} HTTP {r.status_code}: {r.text[:300]}")
try:
data = r.json()
except ValueError:
raise NextcloudError(f"Nextcloud {op}: ungültige Antwort: {r.text[:300]}")
return data.get("ocs", {})
def user_exists(self, userid: str) -> bool:
r = self.session.get(f"{self.base}/ocs/v2.php/cloud/users/{userid}")
ocs = self._meta(r, "user-lookup")
sc = ocs.get("meta", {}).get("statuscode")
return sc in (100, 200)
def ensure_group(self, group: str) -> None:
if not group:
return
r = self.session.post(
f"{self.base}/ocs/v2.php/cloud/groups",
data={"groupid": group},
)
ocs = self._meta(r, "group-create")
sc = ocs.get("meta", {}).get("statuscode")
# 100/200 = ok, 102 = exists already
if sc not in (100, 200, 102):
raise NextcloudError(
f"Nextcloud Gruppe '{group}': {sc} {ocs.get('meta', {}).get('message')}"
)
def create_user(self, *, userid: str, password: str,
email: Optional[str] = None,
display_name: Optional[str] = None,
group: Optional[str] = None,
quota_gb: Optional[int] = None) -> None:
body = [("userid", userid), ("password", password)]
if email:
body.append(("email", email))
if display_name:
body.append(("displayName", display_name))
if group:
body.append(("groups[]", group))
body.append(("quota", f"{quota_gb} GB" if quota_gb else "none"))
r = self.session.post(
f"{self.base}/ocs/v2.php/cloud/users",
data=body,
)
ocs = self._meta(r, "user-create")
sc = ocs.get("meta", {}).get("statuscode")
if sc not in (100, 200):
raise NextcloudError(
f"Nextcloud user-create: {sc} {ocs.get('meta', {}).get('message')}"
)
+81
View File
@@ -0,0 +1,81 @@
"""Plesk REST API Client.
Nutzt den CLI-Wrapper /api/v2/cli/{utility}/call. Vorteile:
- mappt 1:1 auf `plesk bin mail`
- stabil über Plesk-Versionen hinweg
- Fehlertexte sind die gleichen wie auf der Shell.
"""
from typing import Optional
import requests
import urllib3
class PleskError(Exception):
pass
class PleskClient:
def __init__(self, host: str, *, api_key: Optional[str] = None,
user: Optional[str] = None, password: Optional[str] = None,
port: int = 8443, verify: bool = True):
if not host:
raise PleskError("Plesk-Host ist leer")
self.base = f"https://{host}:{port}"
self.session = requests.Session()
self.session.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if api_key:
self.session.headers["X-API-Key"] = api_key
elif user and password:
self.session.auth = (user, password)
else:
raise PleskError(
"Plesk: Weder API-Key noch User/Passwort gesetzt. "
"Siehe README → Plesk-API-Key per SSH erzeugen."
)
self.session.headers["Content-Type"] = "application/json"
self.session.headers["Accept"] = "application/json"
def close(self) -> None:
try:
self.session.close()
except Exception:
pass
def _cli(self, utility: str, params: list) -> dict:
url = f"{self.base}/api/v2/cli/{utility}/call"
try:
r = self.session.post(url, json={"params": params}, timeout=30)
except requests.RequestException as e:
raise PleskError(f"Plesk Verbindung fehlgeschlagen: {e}")
if r.status_code == 401:
raise PleskError("Plesk: Authentifizierung fehlgeschlagen (API-Key/Login prüfen)")
if not r.ok:
raise PleskError(f"Plesk HTTP {r.status_code}: {r.text[:300]}")
try:
data = r.json()
except ValueError:
raise PleskError(f"Plesk: ungültige Antwort: {r.text[:300]}")
if data.get("code", 0) != 0:
err = (data.get("stderr") or data.get("stdout") or "").strip()
raise PleskError(f"Plesk CLI exit {data.get('code')}: {err}")
return data
def mail_exists(self, email: str) -> bool:
try:
self._cli("mail", ["--info", email])
return True
except PleskError as e:
msg = str(e).lower()
if "does not exist" in msg or "not found" in msg or "unknown mailname" in msg:
return False
raise
def create_mail(self, email: str, password: str) -> None:
# plesk bin mail --create user@dom -passwd 'pw' -mailbox true
self._cli("mail", [
"--create", email,
"-passwd", password,
"-mailbox", "true",
])
+112
View File
@@ -0,0 +1,112 @@
"""Plesk SSH-Backend.
Wird genutzt, wenn die Plesk-Instanz keinen REST-API-Zugriff erlaubt.
Verbindet sich per SSH zum Plesk-Server und ruft `plesk bin mail …` auf.
Funktioniert auch ohne API-Key nur SSH-Login (Key oder Passwort) nötig.
"""
from __future__ import annotations
import shlex
from typing import Optional
import paramiko
class PleskSshError(Exception):
pass
class PleskSshClient:
def __init__(self, host: str, *, ssh_port: int = 22,
ssh_user: str, ssh_password: Optional[str] = None,
ssh_key: Optional[str] = None,
ssh_key_passphrase: Optional[str] = None,
use_sudo: bool = False):
if not host:
raise PleskSshError("Plesk-Host ist leer")
if not ssh_user:
raise PleskSshError("PLESK_SSH_USER ist nicht gesetzt")
if not (ssh_password or ssh_key):
raise PleskSshError(
"Weder PLESK_SSH_PASSWORD noch PLESK_SSH_KEY gesetzt"
)
self.host = host
self.use_sudo = use_sudo
self.client = paramiko.SSHClient()
self.client.load_system_host_keys()
# AutoAdd: praktisch beim erstmaligen Lauf. Wer's strenger will,
# füllt vorher ~/.ssh/known_hosts und setzt RejectPolicy hier.
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {
"hostname": host, "port": ssh_port, "username": ssh_user,
"timeout": 15, "allow_agent": True, "look_for_keys": True,
}
if ssh_key:
kwargs["pkey"] = self._load_key(ssh_key, ssh_key_passphrase or None)
elif ssh_password:
kwargs["password"] = ssh_password
kwargs["allow_agent"] = False
kwargs["look_for_keys"] = False
try:
self.client.connect(**kwargs)
except paramiko.AuthenticationException as e:
raise PleskSshError(f"SSH-Login {ssh_user}@{host}:{ssh_port} abgelehnt: {e}") from e
except Exception as e:
raise PleskSshError(f"SSH-Verbindung {ssh_user}@{host}:{ssh_port}: {e}") from e
@staticmethod
def _load_key(path: str, passphrase: Optional[str]):
last_err = None
for KeyClass in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return KeyClass.from_private_key_file(path, password=passphrase)
except paramiko.SSHException as e:
last_err = e
continue
raise PleskSshError(f"SSH-Key {path} konnte nicht geladen werden: {last_err}")
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
def _run_mail(self, args: list) -> tuple:
cmd = ["plesk", "bin", "mail", *args]
if self.use_sudo:
cmd = ["sudo", "-n", *cmd]
cmd_str = " ".join(shlex.quote(c) for c in cmd)
try:
_stdin, stdout, stderr = self.client.exec_command(cmd_str, timeout=60)
except Exception as e:
raise PleskSshError(f"SSH exec fehlgeschlagen: {e}") from e
rc = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
return rc, out, err
def mail_exists(self, email: str) -> bool:
rc, out, err = self._run_mail(["--info", email])
if rc == 0:
return True
msg = (err + " " + out).lower()
if any(s in msg for s in (
"does not exist", "not found", "unknown mailname",
"no such mail", "non existent",
)):
return False
raise PleskSshError(
f"plesk bin mail --info {email} → rc={rc}: "
f"{(err or out).strip() or '<keine Ausgabe>'}"
)
def create_mail(self, email: str, password: str) -> None:
rc, out, err = self._run_mail([
"--create", email,
"-passwd", password,
"-mailbox", "true",
])
if rc != 0:
raise PleskSshError(
f"plesk bin mail --create {email} → rc={rc}: "
f"{(err or out).strip()}"
)