Python 3.10+ SDK wrapping the ISPConfig remote SOAP API so we stop writing
throwaway PHP snippets every time we need to touch a site, zone, or mailbox.
Why no zeep: ISPConfig's /remote/index.php exposes PHP SoapServer in non-WSDL
mode and refuses WSDL generation (?wsdl returns a fault). zeep requires WSDL,
so the stated dependency wouldn't actually work. Instead we hand-roll SOAP
envelopes with the stdlib (urllib + xml.etree). Zero runtime deps.
Structure:
- src/ispconfig/_soap.py — envelope encode/decode, fault surfacing
- src/ispconfig/client.py — ISPConfigClient context manager, retry
- src/ispconfig/exceptions.py — ISPConfigError / Auth / Permission / NotFound / Fault
- src/ispconfig/sites.py — web_domain get/add/update/delete + enable_php / enable_letsencrypt helpers
- src/ispconfig/dns.py — zones + A/CNAME/MX/TXT records, dns_a_add type-column workaround
- src/ispconfig/mail.py — mail domains, users, forwards, create_mailbox helper
- src/ispconfig/databases.py — convenience facade over sites_database_*
- src/ispconfig/clients.py — client + sys_groupid lookups
- src/ispconfig/types.py — TypedDicts for response shapes (no pydantic)
Institutional knowledge baked into docstrings + README footgun list:
- sites_web_domain_update 2nd arg is client_id (not primary_id); admin = 0
- sys_groupid=1 -> pass client_id=0 on update, else ownership churns
- fastcgi_php_version vs server_php_id depending on panel version
- dns_a_add type-column bug (<= 3.2.11) — wrapper issues follow-up update
- dns_zone_get_id wants origin WITHOUT trailing dot on 3.2.11+ (contrary to
what the older snippets say). Verified live against Rackham 2026-04-22.
- mail_user_get returns a bare map on exactly-one-match filter dicts —
wrapper normalizes to list
- session timeouts mid-op: client detects + re-auths once (max_retries knob)
Tests:
- tests/test_unit.py — 12 unit tests against a fake transport
- tests/test_smoke.py — live read-only smoke test, gated on env vars:
ISPCONFIG_TEST_URL, ISPCONFIG_TEST_USER, ISPCONFIG_TEST_PASS
Covers login, web_domain_get(156), dns_zone_get_id, mail_user_get filter.
Tooling:
- mypy strict-ish (disallow untyped defs, warn-return-any, no implicit optional)
- ruff with E/F/W/I/B/UP/N/SLF/RUF lint sets
- pip install -e .[dev] for pytest / mypy / ruff
262 lines
9.5 KiB
Python
262 lines
9.5 KiB
Python
"""Low-level SOAP transport for the ISPConfig remote API.
|
|
|
|
ISPConfig's ``/remote/index.php`` exposes PHP ``SoapServer`` in non-WSDL mode
|
|
and refuses to generate a WSDL (``?wsdl`` returns a fault). That kills zeep,
|
|
which requires WSDL. So we hand-roll the envelopes over ``urllib`` — ISPConfig
|
|
only uses a handful of XSD scalar types plus ``xml-soap`` Map/Array, and the
|
|
responses are parseable in ~80 lines of stdlib XML.
|
|
|
|
This module is private. Callers stick to :class:`~ispconfig.client.ISPConfigClient`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Iterable, Mapping
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
from xml.sax.saxutils import escape as xml_escape
|
|
|
|
log = logging.getLogger("ispconfig")
|
|
|
|
_NS = {
|
|
"soap": "http://schemas.xmlsoap.org/soap/envelope/",
|
|
"xsd": "http://www.w3.org/2001/XMLSchema",
|
|
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
|
"ns1": "urn:ispconfig",
|
|
}
|
|
|
|
# PHP SoapServer emits xmlns:ns1="/remote/index.php" — we match by localname
|
|
# rather than by namespace, so responses decode regardless of the ns1 URI.
|
|
|
|
|
|
class SoapFault(Exception):
|
|
"""Raw SOAP fault. Mapped to typed exceptions by the client layer."""
|
|
|
|
def __init__(self, faultcode: str, faultstring: str) -> None:
|
|
super().__init__(f"{faultcode}: {faultstring}")
|
|
self.faultcode = faultcode
|
|
self.faultstring = faultstring
|
|
|
|
|
|
class SoapTransport:
|
|
"""Minimal non-WSDL SOAP transport over HTTPS."""
|
|
|
|
def __init__(self, url: str, *, verify_ssl: bool = True, timeout: float = 30.0) -> None:
|
|
self.url = url
|
|
self.timeout = timeout
|
|
if verify_ssl:
|
|
self._ctx = ssl.create_default_context()
|
|
else:
|
|
self._ctx = ssl._create_unverified_context() # noqa: SLF001
|
|
|
|
# ---- public API ---------------------------------------------------
|
|
|
|
def call(self, method: str, args: Iterable[tuple[str, Any]]) -> Any:
|
|
"""Invoke a SOAP method with positional params (name, value) pairs.
|
|
|
|
ISPConfig's SOAP dispatch uses positional args — the local names
|
|
``session_id``, ``primary_id``, ``params`` etc. are cosmetic; what
|
|
actually matters is order.
|
|
"""
|
|
envelope = self._build_envelope(method, args)
|
|
body = self._post(method, envelope)
|
|
return self._parse_response(method, body)
|
|
|
|
# ---- internals ----------------------------------------------------
|
|
|
|
def _post(self, method: str, envelope: str) -> bytes:
|
|
req = urllib.request.Request(
|
|
self.url,
|
|
data=envelope.encode("utf-8"),
|
|
headers={
|
|
"Content-Type": "text/xml; charset=UTF-8",
|
|
"SOAPAction": f"urn:ispconfig#{method}",
|
|
"User-Agent": "ispconfig-py/0.1",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, context=self._ctx, timeout=self.timeout) as resp:
|
|
data: bytes = resp.read()
|
|
return data
|
|
except urllib.error.HTTPError as e:
|
|
# PHP SoapServer returns faults as HTTP 500 with a fault envelope
|
|
# in the body — still parseable.
|
|
if e.code == 500 and e.headers.get("Content-Type", "").startswith("text/xml"):
|
|
err_data: bytes = e.read()
|
|
return err_data
|
|
raise
|
|
|
|
@staticmethod
|
|
def _build_envelope(method: str, args: Iterable[tuple[str, Any]]) -> str:
|
|
arg_xml = "".join(_encode_arg(name, value) for name, value in args)
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
'<SOAP-ENV:Envelope'
|
|
' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"'
|
|
' xmlns:ns1="urn:ispconfig"'
|
|
' xmlns:xsd="http://www.w3.org/2001/XMLSchema"'
|
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/">'
|
|
f'<SOAP-ENV:Body><ns1:{method}>{arg_xml}</ns1:{method}></SOAP-ENV:Body>'
|
|
'</SOAP-ENV:Envelope>'
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_response(method: str, body: bytes) -> Any:
|
|
try:
|
|
root = ET.fromstring(body)
|
|
except ET.ParseError as e:
|
|
raise SoapFault("Client.ParseError", f"Invalid XML: {e}") from e
|
|
|
|
# Walk by local-name so the bogus ns1="/remote/index.php" doesn't bite us.
|
|
fault = _find_local(root, "Fault")
|
|
if fault is not None:
|
|
code = _text(_find_local(fault, "faultcode")) or "Server"
|
|
msg = _text(_find_local(fault, "faultstring")) or "Unknown SOAP fault"
|
|
raise SoapFault(code, msg)
|
|
|
|
resp = _find_local(root, f"{method}Response")
|
|
if resp is None:
|
|
raise SoapFault(
|
|
"Client.UnexpectedResponse",
|
|
f"No <{method}Response> element in SOAP body",
|
|
)
|
|
ret = _find_local(resp, "return")
|
|
if ret is None:
|
|
return None
|
|
return _decode(ret)
|
|
|
|
|
|
# ---- XML encoding -----------------------------------------------------
|
|
|
|
|
|
def _encode_arg(name: str, value: Any) -> str:
|
|
if isinstance(value, bool):
|
|
return f'<{name} xsi:type="xsd:boolean">{"true" if value else "false"}</{name}>'
|
|
if isinstance(value, int):
|
|
return f'<{name} xsi:type="xsd:int">{value}</{name}>'
|
|
if isinstance(value, float):
|
|
return f'<{name} xsi:type="xsd:double">{value}</{name}>'
|
|
if isinstance(value, str):
|
|
return f'<{name} xsi:type="xsd:string">{xml_escape(value)}</{name}>'
|
|
if value is None:
|
|
return f'<{name} xsi:nil="true"/>'
|
|
if isinstance(value, Mapping):
|
|
items = "".join(
|
|
f'<item><key xsi:type="xsd:string">{xml_escape(str(k))}</key>'
|
|
f'{_encode_value_tag("value", v)}</item>'
|
|
for k, v in value.items()
|
|
)
|
|
return f'<{name} xsi:type="ns2:Map" xmlns:ns2="http://xml.apache.org/xml-soap">{items}</{name}>'
|
|
if isinstance(value, (list, tuple)):
|
|
items = "".join(_encode_value_tag("item", v) for v in value)
|
|
return (
|
|
f'<{name} xsi:type="SOAP-ENC:Array" '
|
|
f'SOAP-ENC:arrayType="xsd:anyType[{len(value)}]">{items}</{name}>'
|
|
)
|
|
raise TypeError(f"Cannot encode {type(value).__name__} for SOAP arg {name!r}")
|
|
|
|
|
|
def _encode_value_tag(tag: str, value: Any) -> str:
|
|
# Same as _encode_arg but with a shared `value`/`item` tag name.
|
|
return _encode_arg(tag, value)
|
|
|
|
|
|
# ---- XML decoding -----------------------------------------------------
|
|
|
|
|
|
def _decode(el: ET.Element) -> Any:
|
|
xsi_type = _xsi_type(el)
|
|
# Arrays first — a response can be an Array of Maps, and the outer element
|
|
# has <item xsi:type="Map">...</item> children that we must NOT confuse
|
|
# with map entries.
|
|
if (xsi_type and xsi_type.endswith(":Array")) or _is_array(el):
|
|
return _decode_array(el)
|
|
if (xsi_type and xsi_type.endswith(":Map")) or _is_map(el):
|
|
return _decode_map(el)
|
|
if xsi_type and xsi_type.endswith(":boolean"):
|
|
return (el.text or "").strip().lower() == "true"
|
|
if xsi_type and (xsi_type.endswith(":int") or xsi_type.endswith(":long")):
|
|
try:
|
|
return int((el.text or "").strip())
|
|
except ValueError:
|
|
return el.text
|
|
if xsi_type and (xsi_type.endswith(":double") or xsi_type.endswith(":float")):
|
|
try:
|
|
return float((el.text or "").strip())
|
|
except ValueError:
|
|
return el.text
|
|
nil = el.get("{http://www.w3.org/2001/XMLSchema-instance}nil")
|
|
if nil == "true":
|
|
return None
|
|
# Structs where ISPConfig returns arrays of scalars (e.g. ints) still
|
|
# render as strings — PHP's $app->db->queryAll output is stringified.
|
|
return el.text if el.text is not None else ""
|
|
|
|
|
|
def _decode_map(el: ET.Element) -> dict[str, Any]:
|
|
out: dict[str, Any] = {}
|
|
for item in _iter_local(el, "item"):
|
|
key_el = _find_local(item, "key")
|
|
val_el = _find_local(item, "value")
|
|
if key_el is None:
|
|
continue
|
|
key = (key_el.text or "").strip()
|
|
out[key] = _decode(val_el) if val_el is not None else None
|
|
return out
|
|
|
|
|
|
def _decode_array(el: ET.Element) -> list[Any]:
|
|
return [_decode(child) for child in _iter_local(el, "item")]
|
|
|
|
|
|
def _is_map(el: ET.Element) -> bool:
|
|
# apache-style Map with no xsi:type on the outer element but item/key/value
|
|
# children. Check DIRECT children only — descendants may match for a
|
|
# deeply nested array-of-maps and fool us.
|
|
kids = list(el)
|
|
if not kids:
|
|
return False
|
|
if not all(_local(k.tag) == "item" for k in kids):
|
|
return False
|
|
return any(
|
|
any(_local(gk.tag) == "key" for gk in item)
|
|
for item in kids
|
|
)
|
|
|
|
|
|
def _is_array(el: ET.Element) -> bool:
|
|
arr = el.get("{http://schemas.xmlsoap.org/soap/encoding/}arrayType")
|
|
return arr is not None
|
|
|
|
|
|
def _xsi_type(el: ET.Element) -> str | None:
|
|
return el.get("{http://www.w3.org/2001/XMLSchema-instance}type")
|
|
|
|
|
|
# ---- XML walking (namespace-agnostic) ---------------------------------
|
|
|
|
|
|
def _local(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def _text(el: ET.Element | None) -> str | None:
|
|
return el.text if el is not None else None
|
|
|
|
|
|
def _find_local(root: ET.Element, name: str) -> ET.Element | None:
|
|
if _local(root.tag) == name:
|
|
return root
|
|
for el in root.iter():
|
|
if _local(el.tag) == name:
|
|
return el
|
|
return None
|
|
|
|
|
|
def _iter_local(parent: ET.Element, name: str) -> list[ET.Element]:
|
|
return [el for el in parent if _local(el.tag) == name]
|