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
157 lines
5.9 KiB
Python
157 lines
5.9 KiB
Python
"""``sites.*`` — web domains, databases, database users.
|
|
|
|
Footguns baked into these wrappers so callers don't have to rediscover them:
|
|
|
|
* ``sites_web_domain_update``'s second positional arg is ``client_id``, **not**
|
|
the primary id. For admin-owned sites pass ``client_id=0``. This is a common
|
|
footgun — the ISPConfig remote API is inconsistent about this across methods.
|
|
* ``sys_groupid=1`` means admin-owned. Pass it through unchanged on update
|
|
calls or ISPConfig will reassign ownership.
|
|
* ``fastcgi_php_version`` is the legacy field; newer installs use
|
|
``server_php_id`` (int, references ``server_php.server_php_id``). Set the
|
|
one your panel version knows about — :meth:`SitesModule.enable_php`
|
|
handles this.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from .types import Database, DatabaseUser, WebDomain
|
|
|
|
if TYPE_CHECKING:
|
|
from .client import ISPConfigClient
|
|
|
|
log = logging.getLogger("ispconfig.sites")
|
|
|
|
|
|
class SitesModule:
|
|
def __init__(self, client: ISPConfigClient) -> None:
|
|
self._c = client
|
|
|
|
# ---- web domain ---------------------------------------------------
|
|
|
|
def web_domain_get(self, primary_id: int) -> WebDomain:
|
|
"""Fetch a single ``web_domain`` row by its ``domain_id``."""
|
|
return cast(WebDomain, self._c._call("sites_web_domain_get", ("primary_id", int(primary_id))))
|
|
|
|
def web_domain_add(self, client_id: int, params: Mapping[str, Any], read_only: bool = False) -> int:
|
|
"""Create a new site. Returns the new ``domain_id``.
|
|
|
|
``client_id=0`` creates an admin-owned site.
|
|
"""
|
|
return int(self._c._call(
|
|
"sites_web_domain_add",
|
|
("client_id", int(client_id)),
|
|
("params", dict(params)),
|
|
("read_only", bool(read_only)),
|
|
))
|
|
|
|
def web_domain_update(self, client_id: int, primary_id: int, params: Mapping[str, Any]) -> int:
|
|
"""Update a site.
|
|
|
|
.. warning::
|
|
The second positional arg is ``client_id``, not ``primary_id``.
|
|
Pass 0 for admin-owned. See module docstring.
|
|
"""
|
|
return int(self._c._call(
|
|
"sites_web_domain_update",
|
|
("client_id", int(client_id)),
|
|
("primary_id", int(primary_id)),
|
|
("params", dict(params)),
|
|
))
|
|
|
|
def web_domain_delete(self, primary_id: int) -> int:
|
|
return int(self._c._call("sites_web_domain_delete", ("primary_id", int(primary_id))))
|
|
|
|
def web_domain_set_status(self, primary_id: int, status: str) -> int:
|
|
"""``status`` is typically ``'active'`` or ``'inactive'``."""
|
|
return int(self._c._call(
|
|
"sites_web_domain_set_status",
|
|
("primary_id", int(primary_id)),
|
|
("status", status),
|
|
))
|
|
|
|
# ---- helpers ------------------------------------------------------
|
|
|
|
def enable_php(
|
|
self,
|
|
domain_id: int,
|
|
mode: str = "fast-cgi",
|
|
server_php_id: int = 0,
|
|
pm: str = "ondemand",
|
|
pm_max_children: int = 10,
|
|
pm_start_servers: int = 2,
|
|
pm_min_spare_servers: int = 1,
|
|
pm_max_spare_servers: int = 5,
|
|
) -> int:
|
|
"""Flip on PHP with sane defaults.
|
|
|
|
Covers the usual field soup: ``php`` (the mode), ``server_php_id``
|
|
(what PHP binary), and the ``pm_*`` family (only relevant for
|
|
``php-fpm`` / ``fast-cgi``). Preserves existing ``client_id`` /
|
|
``sys_groupid`` by reading the record first.
|
|
"""
|
|
current = self.web_domain_get(domain_id)
|
|
client_id = int(current.get("sys_groupid", 0) or 0)
|
|
# sys_groupid==1 => admin; pass 0 as client_id for update.
|
|
if client_id == 1:
|
|
client_id = 0
|
|
params: dict[str, Any] = {
|
|
"php": mode,
|
|
"server_php_id": int(server_php_id),
|
|
"pm": pm,
|
|
"pm_max_children": int(pm_max_children),
|
|
"pm_start_servers": int(pm_start_servers),
|
|
"pm_min_spare_servers": int(pm_min_spare_servers),
|
|
"pm_max_spare_servers": int(pm_max_spare_servers),
|
|
}
|
|
return self.web_domain_update(client_id, domain_id, params)
|
|
|
|
def enable_letsencrypt(self, domain_id: int) -> int:
|
|
"""Turn on SSL + Let's Encrypt + force-https in one shot."""
|
|
current = self.web_domain_get(domain_id)
|
|
client_id = int(current.get("sys_groupid", 0) or 0)
|
|
if client_id == 1:
|
|
client_id = 0
|
|
params = {
|
|
"ssl": "y",
|
|
"ssl_letsencrypt": "y",
|
|
"rewrite_to_https": "y",
|
|
}
|
|
return self.web_domain_update(client_id, domain_id, params)
|
|
|
|
# ---- databases ----------------------------------------------------
|
|
|
|
def database_get(self, primary_id: int) -> Database:
|
|
return cast(Database, self._c._call("sites_database_get", ("primary_id", int(primary_id))))
|
|
|
|
def database_add(self, client_id: int, params: Mapping[str, Any]) -> int:
|
|
return int(self._c._call(
|
|
"sites_database_add",
|
|
("client_id", int(client_id)),
|
|
("params", dict(params)),
|
|
))
|
|
|
|
def database_delete(self, primary_id: int) -> int:
|
|
return int(self._c._call("sites_database_delete", ("primary_id", int(primary_id))))
|
|
|
|
def database_user_get(self, primary_id: int) -> DatabaseUser:
|
|
return cast(DatabaseUser, self._c._call("sites_database_user_get", ("primary_id", int(primary_id))))
|
|
|
|
def database_user_add(self, client_id: int, params: Mapping[str, Any]) -> int:
|
|
return int(self._c._call(
|
|
"sites_database_user_add",
|
|
("client_id", int(client_id)),
|
|
("params", dict(params)),
|
|
))
|
|
|
|
def database_user_update(self, client_id: int, primary_id: int, params: Mapping[str, Any]) -> int:
|
|
return int(self._c._call(
|
|
"sites_database_user_update",
|
|
("client_id", int(client_id)),
|
|
("primary_id", int(primary_id)),
|
|
("params", dict(params)),
|
|
))
|