diff --git a/README.md b/README.md index 1e7da0c..5c976e5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # ispconfig-py -Python SDK for the ISPConfig remote SOAP API. Internal tooling for the Sulkta -Coop — wraps the panel's SOAP surface so we stop writing throw-away PHP -scripts every time we need to touch a site, zone, or mailbox. +Python SDK for the ISPConfig remote SOAP API. Wraps the panel's SOAP surface +so you stop writing throw-away PHP scripts every time you need to touch a +site, zone, or mailbox. -v0.2 covers the **full remote API** — every method exposed by ISPConfig's -`remote/index.php`, 312 of them as of Rackham 2026-04-22. The hand-audited +v0.2 covers the full remote API — every method exposed by ISPConfig's +`remote/index.php`, 312 of them as of ISPConfig 3.2.11. The hand-audited helpers (stable names, param-order fixes, convenience wrappers) sit on top of auto-generated wrappers that mirror the PHP surface 1:1. @@ -32,8 +32,8 @@ from ispconfig import ISPConfigClient with ISPConfigClient( "https://panel.example.com:8080/remote/index.php", - username="kayos", - password="hunter2", + username="admin", + password="...", ) as c: site = c.sites.web_domain_get(156) print(site["domain"], site["php"]) @@ -112,7 +112,7 @@ c.databases.user_update(client_id=5, primary_id=42, params={"database_password": #### `clients` ```python -cli = c.clients.get_by_username("jacob") +cli = c.clients.get_by_username("alice") groupid = c.clients.get_groupid(cli["client_id"]) ids = c.clients.get_all() ``` @@ -120,8 +120,8 @@ ids = c.clients.get_all() ### Auto-generated (full surface, v0.2) Wrappers mirror the PHP method names 1:1. Param shapes come from PHPDoc where -available and default to `Any` otherwise. Verified to wire up against Rackham -2026-04-22 but not yet battle-tested in production use — file issues if you +available and default to `Any` otherwise. Verified to wire up against +ISPConfig 3.2.11 but not exercised in every code path — file issues if you hit one. | Module | Class | Methods | @@ -191,8 +191,7 @@ funcs = c.list_functions() of older documentation and PHP snippets floating around, ISPConfig 3.2.11+ wants the origin **without** a trailing dot: `dns_zone_get_id("example.com")` works, `dns_zone_get_id("example.com.")` raises `no_domain_found`. Our - wrapper strips a trailing dot for you so either call works. Verified - against Rackham 2026-04-22. + wrapper strips a trailing dot for you so either call works. - **`mail_user_get` with a filter dict returns inconsistent shapes.** If the filter matches multiple rows you get an array; exactly-one match returns a bare map. Our `mail.user_get(filter_dict)` always normalizes to a list. @@ -249,7 +248,7 @@ To run the live smoke test against a real panel: ```bash export ISPCONFIG_TEST_URL="https://panel.example.com:8080/remote/index.php" -export ISPCONFIG_TEST_USER="kayos" +export ISPCONFIG_TEST_USER="admin" export ISPCONFIG_TEST_PASS="..." export ISPCONFIG_TEST_VERIFY_SSL=0 # for self-signed certs pytest tests/test_smoke.py @@ -261,14 +260,14 @@ require admin privileges skip gracefully with a documented reason. ## Regenerating for newer ISPConfig versions -When ISPConfig ships a new version on Rackham (or another panel), resync: +When ISPConfig ships a new version, resync against its PHP sources: ```bash -# 1. Pull fresh PHP sources from the panel (sudo required; ask Cobb for creds): +# 1. Pull fresh PHP sources from the panel host (sudo required): mkdir -p /tmp/ispconfig-php-src -ssh rackham "sudo tar -cz -C /usr/local/ispconfig/interface/lib/classes/remote.d ." \ +ssh PANEL_HOST "sudo tar -cz -C /usr/local/ispconfig/interface/lib/classes/remote.d ." \ | tar -xz -C /tmp/ispconfig-php-src/ -ssh rackham "sudo cat /usr/local/ispconfig/interface/lib/classes/remoting.inc.php" \ +ssh PANEL_HOST "sudo cat /usr/local/ispconfig/interface/lib/classes/remoting.inc.php" \ > /tmp/ispconfig-php-src/remoting.inc.php # 2. Re-extract the method inventory: diff --git a/pyproject.toml b/pyproject.toml index 8e538d3..811c0d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ispconfig" version = "0.2.0" -description = "Python SDK for the ISPConfig remote SOAP API — Sulkta Coop internal tooling." +description = "Python SDK for the ISPConfig remote SOAP API." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" @@ -25,7 +25,7 @@ dev = [ ] [project.urls] -Homepage = "http://192.168.0.5:3001/Sulkta-Coop/ispconfig-py" +Homepage = "https://git.sulkta.com/Sulkta-Coop/ispconfig-py" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/ispconfig/__init__.py b/src/ispconfig/__init__.py index 112d5dc..93d3f66 100644 --- a/src/ispconfig/__init__.py +++ b/src/ispconfig/__init__.py @@ -23,7 +23,7 @@ from .exceptions import ( PermissionError, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "AuthError", diff --git a/src/ispconfig/_soap.py b/src/ispconfig/_soap.py index 4433e41..fe85d2f 100644 --- a/src/ispconfig/_soap.py +++ b/src/ispconfig/_soap.py @@ -75,7 +75,7 @@ class SoapTransport: headers={ "Content-Type": "text/xml; charset=UTF-8", "SOAPAction": f"urn:ispconfig#{method}", - "User-Agent": "ispconfig-py/0.1", + "User-Agent": "ispconfig-py/0.2", }, ) try: diff --git a/src/ispconfig/client.py b/src/ispconfig/client.py index a5490f2..626c53a 100644 --- a/src/ispconfig/client.py +++ b/src/ispconfig/client.py @@ -34,7 +34,7 @@ class ISPConfigClient: Use as a context manager to auto-login on enter and auto-logout on exit:: - with ISPConfigClient(url, "kayos", "hunter2") as c: + with ISPConfigClient(url, "admin", "password") as c: site = c.sites.web_domain_get(156) print(site["domain"]) diff --git a/src/ispconfig/clients.py b/src/ispconfig/clients.py index 6585d53..cf43131 100644 --- a/src/ispconfig/clients.py +++ b/src/ispconfig/clients.py @@ -297,10 +297,6 @@ class ClientsModule: # ---- HAND-EDIT ONLY BELOW ---- - # ---- HAND-EDIT ONLY BELOW ---- - - # ---- HAND-EDIT ONLY BELOW ---- - def get(self, primary_id: int) -> Client: return cast(Client, self._c._call("client_get", ("primary_id", int(primary_id)))) diff --git a/src/ispconfig/databases.py b/src/ispconfig/databases.py index 4164552..3007065 100644 --- a/src/ispconfig/databases.py +++ b/src/ispconfig/databases.py @@ -142,10 +142,6 @@ class DatabasesModule: # ---- HAND-EDIT ONLY BELOW ---- - # ---- HAND-EDIT ONLY BELOW ---- - - # ---- HAND-EDIT ONLY BELOW ---- - def get(self, primary_id: int) -> Database: return self._c.sites.database_get(primary_id) diff --git a/src/ispconfig/dns.py b/src/ispconfig/dns.py index 5d2cffe..ea7d267 100644 --- a/src/ispconfig/dns.py +++ b/src/ispconfig/dns.py @@ -2,14 +2,15 @@ Footguns: -* Zone origins **must end in a dot** per BIND convention: - ``dns_zone_get_id(..., 'example.com.')`` — without the trailing dot - ISPConfig returns 0 and you'll spend 20 minutes wondering why. -* ``dns_a_add`` in some ISPConfig builds (<= 3.2.11-ish) has a known bug - where the ``type`` column in ``dns_rr`` is not populated by the add - handler, which means the record exists in the table but BIND never - emits it. Workaround: after add, ``dns_rr_update`` with ``{'type':'A'}`` - to force the column. :meth:`DnsModule.a_add` does this for you and +* ISPConfig 3.2.11+ wants the zone origin **without** a trailing dot — + ``dns_zone_get_id(..., 'example.com')``, not ``'example.com.'``. The + dotted form raises ``no_domain_found``. :meth:`DnsModule.zone_get_id` + strips a trailing dot for you so either form works. +* ``dns_a_add`` in some ISPConfig builds has a known bug where the + ``type`` column in ``dns_rr`` is not populated by the add handler, + which means the record exists in the table but BIND never emits it. + Workaround: after add, ``dns_rr_update`` with ``{'type':'A'}`` to + force the column. :meth:`DnsModule.a_add` does this for you and emits a warning log — check your ISPConfig version. """ @@ -1288,10 +1289,6 @@ class DnsModule: # ---- HAND-EDIT ONLY BELOW ---- - # ---- HAND-EDIT ONLY BELOW ---- - - # ---- HAND-EDIT ONLY BELOW ---- - # ---- zones -------------------------------------------------------- def zone_get(self, primary_id: int) -> DnsZone: diff --git a/src/ispconfig/mail.py b/src/ispconfig/mail.py index 6d7865d..833f546 100644 --- a/src/ispconfig/mail.py +++ b/src/ispconfig/mail.py @@ -1082,10 +1082,6 @@ class MailModule: # ---- HAND-EDIT ONLY BELOW ---- - # ---- HAND-EDIT ONLY BELOW ---- - - # ---- HAND-EDIT ONLY BELOW ---- - # ---- mail domains ------------------------------------------------- def domain_get(self, primary_id: int) -> MailDomain: diff --git a/src/ispconfig/sites.py b/src/ispconfig/sites.py index a1a7680..bfe6b04 100644 --- a/src/ispconfig/sites.py +++ b/src/ispconfig/sites.py @@ -430,10 +430,6 @@ class SitesModule: # ---- HAND-EDIT ONLY BELOW ---- - # ---- HAND-EDIT ONLY BELOW ---- - - # ---- HAND-EDIT ONLY BELOW ---- - # ---- web domain --------------------------------------------------- def web_domain_get(self, primary_id: int) -> WebDomain: diff --git a/tests/conftest.py b/tests/conftest.py index 18e670d..52dc984 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,11 @@ are skipped — so the default ``pytest`` run on a laptop never phones home. Set ``ISPCONFIG_TEST_VERIFY_SSL=0`` for panels with self-signed or mismatched certs. + +Tests that read a specific record additionally read +``ISPCONFIG_TEST_DOMAIN_ID`` (a ``web_domain`` primary id) and +``ISPCONFIG_TEST_DOMAIN_NAME`` (the matching domain/zone origin). They skip +individually if either is missing — see ``tests/test_smoke.py``. """ from __future__ import annotations diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 6b7f67b..e018155 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -3,17 +3,25 @@ Gated on env vars: ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``, ``ISPCONFIG_TEST_PASS``. These tests are skipped if any is unset. -They are **read-only** — no ``_add`` / ``_update`` / ``_delete`` calls. Safe -to run against production (Rackham). +They are read-only — no ``_add`` / ``_update`` / ``_delete`` calls. Safe to +run against production. -Every new auto-generated module gets at least one read-only call here so we -know the wrappers actually wire up against a live panel. Methods that the -API user lacks permission for (admin-only, etc.) are documented skips — -see the README's "Known admin-only" list. +Two further env vars target a known record on your panel so the hand-audited +helpers have something to fetch: + +* ``ISPCONFIG_TEST_DOMAIN_ID`` — a ``web_domain`` primary id (int). +* ``ISPCONFIG_TEST_DOMAIN_NAME`` — the matching ``domain`` string + DNS zone + origin (e.g. ``example.com``). + +If either is missing, the domain/dns/mail tests below are skipped — only +the auto-generated module probes run, since those don't need a known record. +Methods that the API user lacks permission for (admin-only, etc.) skip +cleanly — see the README's "Known admin-only" list. """ from __future__ import annotations +import os from collections.abc import Iterator import pytest @@ -33,6 +41,20 @@ def client(live_creds: dict[str, str]) -> Iterator[ISPConfigClient]: yield c +def _known_domain_id() -> int: + val = os.environ.get("ISPCONFIG_TEST_DOMAIN_ID") + if not val: + pytest.skip("set ISPCONFIG_TEST_DOMAIN_ID to a real web_domain primary id") + return int(val) + + +def _known_domain_name() -> str: + val = os.environ.get("ISPCONFIG_TEST_DOMAIN_NAME") + if not val: + pytest.skip("set ISPCONFIG_TEST_DOMAIN_NAME to the matching domain/zone origin") + return val + + # ---- hand-audited modules (first pass) ----------------------------------- @@ -44,20 +66,24 @@ def test_login_returns_session(live_creds: dict[str, str]) -> None: assert c.logout() is True -def test_get_domain_156(client: ISPConfigClient) -> None: - site = client.sites.web_domain_get(156) - assert site["domain"] == "mcbindustrial.com" - assert site["php"] == "fast-cgi" +def test_get_known_domain(client: ISPConfigClient) -> None: + domain_id = _known_domain_id() + expected_name = _known_domain_name() + site = client.sites.web_domain_get(domain_id) + assert site["domain"] == expected_name def test_dns_zone_lookup(client: ISPConfigClient) -> None: - zone_id = client.dns.zone_get_id("mcbindustrial.com.") + expected_name = _known_domain_name() + # Pass with a trailing dot to exercise the wrapper's stripping behavior. + zone_id = client.dns.zone_get_id(expected_name + ".") assert zone_id > 0, "zone_get_id returned 0 — is the zone present?" -def test_mail_users_under_mcbindustrial(client: ISPConfigClient) -> None: +def test_mail_users_filter_returns_list(client: ISPConfigClient) -> None: # mail_user_get accepts a filter-dict and returns a list. - users = client.mail.user_get({"email": "%@mcbindustrial.com"}) + expected_name = _known_domain_name() + users = client.mail.user_get({"email": f"%@{expected_name}"}) assert isinstance(users, list) # Don't assert count — just shape. Zero mailboxes is a valid state. for u in users: @@ -67,8 +93,8 @@ def test_mail_users_under_mcbindustrial(client: ISPConfigClient) -> None: # ---- auto-generated modules: one read-only probe each ------------------- # # These prove the wrappers encode/decode correctly against a real panel. -# Each test tolerates the method being restricted to admin (``kayos`` is a -# reseller, not an admin) — those skip with a clear reason. +# Each test tolerates the method being restricted to admin — reseller logins +# fault with "permission denied" on those, and we skip with a clear reason. def test_raw_call_list_functions(client: ISPConfigClient) -> None: @@ -188,12 +214,13 @@ def test_cron_get_missing(client: ISPConfigClient) -> None: def test_backups_list(client: ISPConfigClient) -> None: """``sites_web_domain_backup_list`` on a known domain.""" + domain_id = _known_domain_id() try: - result = client.backups.sites_web_domain_backup_list(156) + result = client.backups.sites_web_domain_backup_list(domain_id) except PermissionError: pytest.skip("sites_web_domain_backup_list: admin-only on this panel") except NotFoundError: - pytest.skip("no backups configured for domain 156") + pytest.skip(f"no backups configured for domain {domain_id}") assert result is None or isinstance(result, (list, dict))