From 427e1bb97f0ff51ba9f39b373db90462f68c45d8 Mon Sep 17 00:00:00 2001 From: Sulkta Date: Wed, 27 May 2026 10:59:58 -0700 Subject: [PATCH] Public-flip audit: URL refresh + minor scrubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository URLs, version strings, and example creds normalized for the public git.sulkta.com endpoint. No code-behavior change. Audit-applied by the public-flip rolling-audit pass — see kayos/openclaw-workspace memory/2026-05-27 logs for the campaign context. --- README.md | 23 +++++++------- src/ispconfig/__init__.py | 2 +- src/ispconfig/_soap.py | 2 +- src/ispconfig/clients.py | 4 --- src/ispconfig/databases.py | 4 --- src/ispconfig/dns.py | 21 ++++++------- src/ispconfig/mail.py | 4 --- src/ispconfig/sites.py | 4 --- tests/conftest.py | 5 ++++ tests/test_smoke.py | 61 +++++++++++++++++++++++++++----------- 10 files changed, 71 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b09a7b8..5c976e5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # ispconfig-py -Python SDK for the ISPConfig remote SOAP API. A small client for the ISPConfig -panel. It wraps the 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 the ISPConfig panel 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. @@ -33,7 +33,7 @@ from ispconfig import ISPConfigClient with ISPConfigClient( "https://panel.example.com:8080/remote/index.php", username="admin", - password="password", + password="...", ) as c: site = c.sites.web_domain_get(156) print(site["domain"], site["php"]) @@ -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 the ISPConfig panel -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 the ISPConfig panel 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. @@ -261,10 +260,10 @@ require admin privileges skip gracefully with a documented reason. ## Regenerating for newer ISPConfig versions -When ISPConfig ships a new version (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; requires panel access): +# 1. Pull fresh PHP sources from the panel host (sudo required): mkdir -p /tmp/ispconfig-php-src ssh PANEL_HOST "sudo tar -cz -C /usr/local/ispconfig/interface/lib/classes/remote.d ." \ | tar -xz -C /tmp/ispconfig-php-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/clients.py b/src/ispconfig/clients.py index f6182c9..1605db7 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 85b4e22..aebf816 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 19bad17..84ebdfd 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 2a70080..7322390 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 1866fa0..ad795cb 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 69b7aec..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. +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 (``admin`` 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))