ispconfig-py/src/ispconfig/sites.py
Sulkta fb6e235f2d feat: initial ispconfig-py SDK (sites / dns / mail / databases / clients)
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
2026-04-22 13:24:58 -07:00

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)),
))