v0.1 shipped ~15 hand-audited methods across sites/dns/mail/databases/clients. That's enough for daily ops but every new Tort Host / cWHO feature has been hitting the wall at the edge of that coverage. This extends the SDK to wrap every method the panel exposes — 312 of them as of Rackham 2026-04-22, verified against the live list_functions() introspection call with only one name-mismatch (``__construct``, a PHP lifecycle artifact, not a real API method). The hand-audited helpers stay where they are. Every module now has two clearly-delimited sections: an auto-generated block at the top (emitted by tools/gen_methods.py from tools/method_inventory.json), and a HAND-EDIT ONLY BELOW block at the bottom that survives regeneration. Name collisions between auto and hand always resolve in favor of the hand version — the generator emits a ``# skipped foo: hand-audited helper takes precedence`` comment in the auto block for traceability. Pipeline: - tools/extract_inventory.py reads remote.*.inc.php + remoting.inc.php, pulls docblocks + param defaults, dumps one JSON record per method. Regex is balanced-paren aware so ``$params = array()`` defaults don't truncate the signature at the wrong close-paren (that footgun hid three methods from the first run — sites_aps_available_packages_list, sites_aps_instance_delete, openvz_vm_add_from_template). - tools/method_inventory.json is the committed inventory — future ISPConfig upgrades diff against this file to see scope at a glance. - tools/gen_methods.py groups by method-name prefix onto the module classes listed in the README table, emits a 1:1 Python wrapper per method with the original PHP filename + line number in the docstring, and ensures ``from typing import Any`` is present in preexisting modules before emitting ``Any`` type annotations. New submodules (all auto-generated, wired into ISPConfigClient.__init__): admin, aps, backups, cron, domains, ftp, misc, monitor, openvz, server, shell, webdav. Existing modules (sites, dns, mail, databases, clients) got their auto block filled in and their hand-audited helpers preserved. Escape hatches on the top-level client: - raw_call(method, *args) routes an arbitrary method name through the same session-management + retry + fault-mapping pipeline the typed wrappers use. Fix for "panel shipped a new method, SDK hasn't caught up" — callers don't have to reach back into _soap. - list_functions() wraps get_function_list() for panel introspection. Fault mapping widened: ``no_client_found`` and "no user account" messages now map to NotFoundError instead of FaultError, matching the existing ``no_domain_found`` convention. Older code that caught raw FaultError there will still work (NotFoundError extends ISPConfigError) but callers can now catch the specific type. Testing: - tests/test_unit.py — 12 existing pure-unit tests pass unchanged. - tests/test_smoke.py — extended from 4 read-only calls to 21. One probe per new auto-generated module plus raw_call and list_functions smoke tests. Methods gated behind admin permission skip gracefully with a documented reason (kayos is a reseller, not admin): monitor_jobqueue_count, sites_cron_get, sites_ftp_user_get, openvz_get_free_ip, quota_get_by_user. Results against Rackham 2026-04-22: 28 passed, 5 skipped (all documented admin-only), 0 failed. - ISPCONFIG_TEST_VERIFY_SSL=0 env-var knob added to conftest for panels with self-signed or mismatched certs. Version bump 0.1.0 -> 0.2.0. README restructured into Hand-audited / Auto-generated / Escape hatch / Footguns sections with a regeneration recipe for future ISPConfig upgrades. Ruff per-file SLF001 ignore extended to every submodule (submodules are all authorized callers of the client's private ``_call`` dispatcher by design). mypy strict passes; ruff check passes; ruff format applied across src / tools / tests.
189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
"""``ISPConfigClient`` — top-level entry point, session lifecycle, submodule wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from types import TracebackType
|
|
from typing import Any
|
|
|
|
from . import exceptions as _exc
|
|
from ._soap import SoapFault, SoapTransport
|
|
from .admin import AdminModule
|
|
from .aps import ApsModule
|
|
from .backups import BackupsModule
|
|
from .clients import ClientsModule
|
|
from .cron import CronModule
|
|
from .databases import DatabasesModule
|
|
from .dns import DnsModule
|
|
from .domains import DomainsModule
|
|
from .ftp import FtpModule
|
|
from .mail import MailModule
|
|
from .misc import MiscModule
|
|
from .monitor import MonitorModule
|
|
from .openvz import OpenvzModule
|
|
from .server import ServerModule
|
|
from .shell import ShellModule
|
|
from .sites import SitesModule
|
|
from .webdav import WebdavModule
|
|
|
|
log = logging.getLogger("ispconfig")
|
|
|
|
|
|
class ISPConfigClient:
|
|
"""High-level client for the ISPConfig remote SOAP API.
|
|
|
|
Use as a context manager to auto-login on enter and auto-logout on exit::
|
|
|
|
with ISPConfigClient(url, "admin", "password") as c:
|
|
site = c.sites.web_domain_get(156)
|
|
print(site["domain"])
|
|
|
|
Session IDs are managed internally and never returned to callers.
|
|
If a call fails with a session-expired fault, the client re-authenticates
|
|
once and retries (controlled by ``max_retries``).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
username: str,
|
|
password: str,
|
|
*,
|
|
verify_ssl: bool = True,
|
|
timeout: float = 30.0,
|
|
max_retries: int = 1,
|
|
) -> None:
|
|
self._url = url
|
|
self._username = username
|
|
self._password = password
|
|
self._max_retries = max_retries
|
|
self._transport = SoapTransport(url, verify_ssl=verify_ssl, timeout=timeout)
|
|
self._session_id: str | None = None
|
|
|
|
# Hand-audited modules (stable API).
|
|
self.sites = SitesModule(self)
|
|
self.dns = DnsModule(self)
|
|
self.mail = MailModule(self)
|
|
self.databases = DatabasesModule(self)
|
|
self.clients = ClientsModule(self)
|
|
# Auto-generated modules (full surface, param shapes not yet
|
|
# verified in prod use — see per-method docstrings).
|
|
self.admin = AdminModule(self)
|
|
self.aps = ApsModule(self)
|
|
self.backups = BackupsModule(self)
|
|
self.cron = CronModule(self)
|
|
self.domains = DomainsModule(self)
|
|
self.ftp = FtpModule(self)
|
|
self.misc = MiscModule(self)
|
|
self.monitor = MonitorModule(self)
|
|
self.openvz = OpenvzModule(self)
|
|
self.server = ServerModule(self)
|
|
self.shell = ShellModule(self)
|
|
self.webdav = WebdavModule(self)
|
|
|
|
# ---- context manager ---------------------------------------------
|
|
|
|
def __enter__(self) -> ISPConfigClient:
|
|
self.login()
|
|
return self
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
tb: TracebackType | None,
|
|
) -> None:
|
|
try:
|
|
self.logout()
|
|
except Exception as e: # pragma: no cover — cleanup path, never raise out
|
|
log.warning("logout on __exit__ failed: %s", e)
|
|
|
|
# ---- session lifecycle -------------------------------------------
|
|
|
|
def login(self) -> None:
|
|
"""Open a session. Stores the session id internally."""
|
|
try:
|
|
sid = self._transport.call(
|
|
"login",
|
|
(("username", self._username), ("password", self._password)),
|
|
)
|
|
except SoapFault as f:
|
|
raise _exc.map_fault(f.faultcode, f.faultstring) from f
|
|
if not isinstance(sid, str) or not sid:
|
|
raise _exc.AuthError("login returned empty session id")
|
|
self._session_id = sid
|
|
log.debug("ispconfig login ok (session %s...)", sid[:8])
|
|
|
|
def logout(self) -> bool:
|
|
"""Close the session. Safe to call even if never logged in."""
|
|
if self._session_id is None:
|
|
return False
|
|
sid = self._session_id
|
|
self._session_id = None
|
|
try:
|
|
result = self._transport.call("logout", (("session_id", sid),))
|
|
except SoapFault as f:
|
|
log.debug("logout fault ignored: %s", f)
|
|
return False
|
|
return bool(result)
|
|
|
|
@property
|
|
def session_id(self) -> str | None:
|
|
"""Read-only accessor — exposed for debugging, not for API calls."""
|
|
return self._session_id
|
|
|
|
# ---- escape hatches ----------------------------------------------
|
|
|
|
def raw_call(self, method: str, *args: Any) -> Any:
|
|
"""Invoke an arbitrary ISPConfig remote method by name.
|
|
|
|
Use this when the SDK doesn't yet wrap the method you need —
|
|
newer ISPConfig versions may expose calls our inventory hasn't
|
|
caught up with. Args are passed positionally; names are cosmetic
|
|
on the wire, so we auto-number them as ``arg1``, ``arg2``, ...
|
|
|
|
If the call fails, capture ``FaultError.faultcode`` /
|
|
``FaultError.faultstring`` and file an issue against
|
|
``Sulkta-OSS/ispconfig-py`` so we can add the method properly.
|
|
"""
|
|
named_args = tuple((f"arg{i + 1}", v) for i, v in enumerate(args))
|
|
return self._call(method, *named_args)
|
|
|
|
def list_functions(self) -> list[str]:
|
|
"""Introspect the panel: return the list of remote methods it exposes.
|
|
|
|
Wrapper for ISPConfig's own ``get_function_list``. Handy when
|
|
checking whether your panel version supports a given call before
|
|
attempting it via :meth:`raw_call`.
|
|
"""
|
|
result = self._call("get_function_list")
|
|
if isinstance(result, list):
|
|
return [str(x) for x in result]
|
|
if isinstance(result, dict):
|
|
# Some versions return a map keyed by integer index.
|
|
return [str(v) for v in result.values()]
|
|
return []
|
|
|
|
# ---- the hot path ------------------------------------------------
|
|
|
|
def _call(self, method: str, *args: tuple[str, Any]) -> Any:
|
|
"""Invoke ``method(session_id, *args)`` with typed error mapping + retry.
|
|
|
|
This is what the submodules call. It prepends the session id,
|
|
translates SOAP faults, and retries once on session expiry.
|
|
"""
|
|
attempts = 0
|
|
while True:
|
|
if self._session_id is None:
|
|
self.login()
|
|
sid_arg = ("session_id", self._session_id or "")
|
|
try:
|
|
return self._transport.call(method, (sid_arg, *args))
|
|
except SoapFault as f:
|
|
mapped = _exc.map_fault(f.faultcode, f.faultstring)
|
|
if _exc.is_session_expired(mapped) and attempts < self._max_retries:
|
|
log.info("ispconfig session expired, re-authenticating")
|
|
self._session_id = None
|
|
attempts += 1
|
|
continue
|
|
raise mapped from f
|