Public-flip audit: URL refresh + minor scrubs
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.
This commit is contained in:
parent
44ce76cb44
commit
99ee5f1a9a
12 changed files with 79 additions and 67 deletions
33
README.md
33
README.md
|
|
@ -1,11 +1,11 @@
|
||||||
# ispconfig-py
|
# ispconfig-py
|
||||||
|
|
||||||
Python SDK for the ISPConfig remote SOAP API. Internal tooling for the Sulkta
|
Python SDK for the ISPConfig remote SOAP API. Wraps the panel's SOAP surface
|
||||||
Coop — wraps the panel's SOAP surface so we stop writing throw-away PHP
|
so you stop writing throw-away PHP scripts every time you need to touch a
|
||||||
scripts every time we need to touch a site, zone, or mailbox.
|
site, zone, or mailbox.
|
||||||
|
|
||||||
v0.2 covers the **full remote API** — every method exposed by ISPConfig's
|
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
|
`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
|
helpers (stable names, param-order fixes, convenience wrappers) sit on top
|
||||||
of auto-generated wrappers that mirror the PHP surface 1:1.
|
of auto-generated wrappers that mirror the PHP surface 1:1.
|
||||||
|
|
||||||
|
|
@ -32,8 +32,8 @@ from ispconfig import ISPConfigClient
|
||||||
|
|
||||||
with ISPConfigClient(
|
with ISPConfigClient(
|
||||||
"https://panel.example.com:8080/remote/index.php",
|
"https://panel.example.com:8080/remote/index.php",
|
||||||
username="kayos",
|
username="admin",
|
||||||
password="hunter2",
|
password="...",
|
||||||
) as c:
|
) as c:
|
||||||
site = c.sites.web_domain_get(156)
|
site = c.sites.web_domain_get(156)
|
||||||
print(site["domain"], site["php"])
|
print(site["domain"], site["php"])
|
||||||
|
|
@ -112,7 +112,7 @@ c.databases.user_update(client_id=5, primary_id=42, params={"database_password":
|
||||||
#### `clients`
|
#### `clients`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
cli = c.clients.get_by_username("jacob")
|
cli = c.clients.get_by_username("alice")
|
||||||
groupid = c.clients.get_groupid(cli["client_id"])
|
groupid = c.clients.get_groupid(cli["client_id"])
|
||||||
ids = c.clients.get_all()
|
ids = c.clients.get_all()
|
||||||
```
|
```
|
||||||
|
|
@ -120,8 +120,8 @@ ids = c.clients.get_all()
|
||||||
### Auto-generated (full surface, v0.2)
|
### Auto-generated (full surface, v0.2)
|
||||||
|
|
||||||
Wrappers mirror the PHP method names 1:1. Param shapes come from PHPDoc where
|
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
|
available and default to `Any` otherwise. Verified to wire up against
|
||||||
2026-04-22 but not yet battle-tested in production use — file issues if you
|
ISPConfig 3.2.11 but not exercised in every code path — file issues if you
|
||||||
hit one.
|
hit one.
|
||||||
|
|
||||||
| Module | Class | Methods |
|
| Module | Class | Methods |
|
||||||
|
|
@ -191,8 +191,7 @@ funcs = c.list_functions()
|
||||||
of older documentation and PHP snippets floating around, ISPConfig 3.2.11+
|
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")`
|
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
|
works, `dns_zone_get_id("example.com.")` raises `no_domain_found`. Our
|
||||||
wrapper strips a trailing dot for you so either call works. Verified
|
wrapper strips a trailing dot for you so either call works.
|
||||||
against Rackham 2026-04-22.
|
|
||||||
- **`mail_user_get` with a filter dict returns inconsistent shapes.** If the
|
- **`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
|
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.
|
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
|
```bash
|
||||||
export ISPCONFIG_TEST_URL="https://panel.example.com:8080/remote/index.php"
|
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_PASS="..."
|
||||||
export ISPCONFIG_TEST_VERIFY_SSL=0 # for self-signed certs
|
export ISPCONFIG_TEST_VERIFY_SSL=0 # for self-signed certs
|
||||||
pytest tests/test_smoke.py
|
pytest tests/test_smoke.py
|
||||||
|
|
@ -261,14 +260,14 @@ require admin privileges skip gracefully with a documented reason.
|
||||||
|
|
||||||
## Regenerating for newer ISPConfig versions
|
## 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
|
```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
|
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/
|
| 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
|
> /tmp/ispconfig-php-src/remoting.inc.php
|
||||||
|
|
||||||
# 2. Re-extract the method inventory:
|
# 2. Re-extract the method inventory:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||||
[project]
|
[project]
|
||||||
name = "ispconfig"
|
name = "ispconfig"
|
||||||
version = "0.2.0"
|
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"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
@ -25,7 +25,7 @@ dev = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from .exceptions import (
|
||||||
PermissionError,
|
PermissionError,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthError",
|
"AuthError",
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class SoapTransport:
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "text/xml; charset=UTF-8",
|
"Content-Type": "text/xml; charset=UTF-8",
|
||||||
"SOAPAction": f"urn:ispconfig#{method}",
|
"SOAPAction": f"urn:ispconfig#{method}",
|
||||||
"User-Agent": "ispconfig-py/0.1",
|
"User-Agent": "ispconfig-py/0.2",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ISPConfigClient:
|
||||||
|
|
||||||
Use as a context manager to auto-login on enter and auto-logout on exit::
|
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)
|
site = c.sites.web_domain_get(156)
|
||||||
print(site["domain"])
|
print(site["domain"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -297,10 +297,6 @@ class ClientsModule:
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
# ---- HAND-EDIT ONLY BELOW ----
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
def get(self, primary_id: int) -> Client:
|
def get(self, primary_id: int) -> Client:
|
||||||
return cast(Client, self._c._call("client_get", ("primary_id", int(primary_id))))
|
return cast(Client, self._c._call("client_get", ("primary_id", int(primary_id))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,10 +142,6 @@ class DatabasesModule:
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
# ---- HAND-EDIT ONLY BELOW ----
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
def get(self, primary_id: int) -> Database:
|
def get(self, primary_id: int) -> Database:
|
||||||
return self._c.sites.database_get(primary_id)
|
return self._c.sites.database_get(primary_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
|
|
||||||
Footguns:
|
Footguns:
|
||||||
|
|
||||||
* Zone origins **must end in a dot** per BIND convention:
|
* ISPConfig 3.2.11+ wants the zone origin **without** a trailing dot —
|
||||||
``dns_zone_get_id(..., 'example.com.')`` — without the trailing dot
|
``dns_zone_get_id(..., 'example.com')``, not ``'example.com.'``. The
|
||||||
ISPConfig returns 0 and you'll spend 20 minutes wondering why.
|
dotted form raises ``no_domain_found``. :meth:`DnsModule.zone_get_id`
|
||||||
* ``dns_a_add`` in some ISPConfig builds (<= 3.2.11-ish) has a known bug
|
strips a trailing dot for you so either form works.
|
||||||
where the ``type`` column in ``dns_rr`` is not populated by the add
|
* ``dns_a_add`` in some ISPConfig builds has a known bug where the
|
||||||
handler, which means the record exists in the table but BIND never
|
``type`` column in ``dns_rr`` is not populated by the add handler,
|
||||||
emits it. Workaround: after add, ``dns_rr_update`` with ``{'type':'A'}``
|
which means the record exists in the table but BIND never emits it.
|
||||||
to force the column. :meth:`DnsModule.a_add` does this for you and
|
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.
|
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 ----
|
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- zones --------------------------------------------------------
|
# ---- zones --------------------------------------------------------
|
||||||
|
|
||||||
def zone_get(self, primary_id: int) -> DnsZone:
|
def zone_get(self, primary_id: int) -> DnsZone:
|
||||||
|
|
|
||||||
|
|
@ -1082,10 +1082,6 @@ class MailModule:
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
# ---- HAND-EDIT ONLY BELOW ----
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- mail domains -------------------------------------------------
|
# ---- mail domains -------------------------------------------------
|
||||||
|
|
||||||
def domain_get(self, primary_id: int) -> MailDomain:
|
def domain_get(self, primary_id: int) -> MailDomain:
|
||||||
|
|
|
||||||
|
|
@ -430,10 +430,6 @@ class SitesModule:
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
# ---- HAND-EDIT ONLY BELOW ----
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- HAND-EDIT ONLY BELOW ----
|
|
||||||
|
|
||||||
# ---- web domain ---------------------------------------------------
|
# ---- web domain ---------------------------------------------------
|
||||||
|
|
||||||
def web_domain_get(self, primary_id: int) -> WebDomain:
|
def web_domain_get(self, primary_id: int) -> WebDomain:
|
||||||
|
|
|
||||||
|
|
@ -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
|
Set ``ISPCONFIG_TEST_VERIFY_SSL=0`` for panels with self-signed or
|
||||||
mismatched certs.
|
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
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,25 @@
|
||||||
Gated on env vars: ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``,
|
Gated on env vars: ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``,
|
||||||
``ISPCONFIG_TEST_PASS``. These tests are skipped if any is unset.
|
``ISPCONFIG_TEST_PASS``. These tests are skipped if any is unset.
|
||||||
|
|
||||||
They are **read-only** — no ``_add`` / ``_update`` / ``_delete`` calls. Safe
|
They are read-only — no ``_add`` / ``_update`` / ``_delete`` calls. Safe to
|
||||||
to run against production (Rackham).
|
run against production.
|
||||||
|
|
||||||
Every new auto-generated module gets at least one read-only call here so we
|
Two further env vars target a known record on your panel so the hand-audited
|
||||||
know the wrappers actually wire up against a live panel. Methods that the
|
helpers have something to fetch:
|
||||||
API user lacks permission for (admin-only, etc.) are documented skips —
|
|
||||||
see the README's "Known admin-only" list.
|
* ``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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -33,6 +41,20 @@ def client(live_creds: dict[str, str]) -> Iterator[ISPConfigClient]:
|
||||||
yield c
|
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) -----------------------------------
|
# ---- 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
|
assert c.logout() is True
|
||||||
|
|
||||||
|
|
||||||
def test_get_domain_156(client: ISPConfigClient) -> None:
|
def test_get_known_domain(client: ISPConfigClient) -> None:
|
||||||
site = client.sites.web_domain_get(156)
|
domain_id = _known_domain_id()
|
||||||
assert site["domain"] == "mcbindustrial.com"
|
expected_name = _known_domain_name()
|
||||||
assert site["php"] == "fast-cgi"
|
site = client.sites.web_domain_get(domain_id)
|
||||||
|
assert site["domain"] == expected_name
|
||||||
|
|
||||||
|
|
||||||
def test_dns_zone_lookup(client: ISPConfigClient) -> None:
|
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?"
|
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.
|
# 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)
|
assert isinstance(users, list)
|
||||||
# Don't assert count — just shape. Zero mailboxes is a valid state.
|
# Don't assert count — just shape. Zero mailboxes is a valid state.
|
||||||
for u in users:
|
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 -------------------
|
# ---- auto-generated modules: one read-only probe each -------------------
|
||||||
#
|
#
|
||||||
# These prove the wrappers encode/decode correctly against a real panel.
|
# These prove the wrappers encode/decode correctly against a real panel.
|
||||||
# Each test tolerates the method being restricted to admin (``kayos`` is a
|
# Each test tolerates the method being restricted to admin — reseller logins
|
||||||
# reseller, not an admin) — those skip with a clear reason.
|
# fault with "permission denied" on those, and we skip with a clear reason.
|
||||||
|
|
||||||
|
|
||||||
def test_raw_call_list_functions(client: ISPConfigClient) -> None:
|
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:
|
def test_backups_list(client: ISPConfigClient) -> None:
|
||||||
"""``sites_web_domain_backup_list`` on a known domain."""
|
"""``sites_web_domain_backup_list`` on a known domain."""
|
||||||
|
domain_id = _known_domain_id()
|
||||||
try:
|
try:
|
||||||
result = client.backups.sites_web_domain_backup_list(156)
|
result = client.backups.sites_web_domain_backup_list(domain_id)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pytest.skip("sites_web_domain_backup_list: admin-only on this panel")
|
pytest.skip("sites_web_domain_backup_list: admin-only on this panel")
|
||||||
except NotFoundError:
|
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))
|
assert result is None or isinstance(result, (list, dict))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue