first release
This commit is contained in:
@@ -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}")
|
||||
@@ -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')}"
|
||||
)
|
||||
@@ -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",
|
||||
])
|
||||
@@ -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()}"
|
||||
)
|
||||
Reference in New Issue
Block a user