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
This commit is contained in:
commit
fb6e235f2d
19 changed files with 1797 additions and 0 deletions
157
src/ispconfig/sites.py
Normal file
157
src/ispconfig/sites.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""``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)),
|
||||
))
|
||||
Loading…
Add table
Add a link
Reference in a new issue