ispconfig-py/README.md
Cobb Hayes 99ee5f1a9a 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.
2026-05-27 10:59:58 -07:00

11 KiB

ispconfig-py

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 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.

Why no zeep?

ISPConfig's /remote/index.php uses PHP's SoapServer in non-WSDL mode and refuses to generate a WSDL (?wsdl returns a fault). zeep requires WSDL. So we hand-roll SOAP envelopes with the stdlib (urllib + xml.etree). Zero runtime dependencies — fewer moving parts, nothing to pin.

Install

pip install -e .            # for use
pip install -e .[dev]       # with pytest / mypy / ruff

Python 3.10+.

Quick start

from ispconfig import ISPConfigClient

with ISPConfigClient(
    "https://panel.example.com:8080/remote/index.php",
    username="admin",
    password="...",
) as c:
    site = c.sites.web_domain_get(156)
    print(site["domain"], site["php"])

    # Flip on Let's Encrypt in one call.
    c.sites.enable_letsencrypt(156)

The with block handles login on enter and logout on exit. Session IDs are managed internally — callers don't touch them.

Set verify_ssl=False for dev boxes with self-signed certs. Default is True.

Changelog

0.2.0 — 2026-04-22

Full-coverage extension. Every remote method ISPConfig exposes is now wrapped in one of the submodules below. Hand-audited helpers from v0.1 (footgun fixes, convenience wrappers) are preserved beneath a delimiter and survive regeneration. New submodules: admin, aps, backups, cron, domains, ftp, misc, monitor, openvz, server, shell, webdav.

  • Code generator at tools/gen_methods.py driven by tools/method_inventory.json.
  • One-command refresh for future ISPConfig upgrades (see below).
  • ISPConfigClient.raw_call(method, *args) escape hatch for methods not yet in the inventory.
  • ISPConfigClient.list_functions() introspects the panel's exposed method list.
  • Live smoke test extended from 4 to 21 read-only calls covering every module.

0.1.0 — 2026-04-21

Initial release. Hand-audited coverage of sites / dns / mail / databases / clients.

Modules

Hand-audited (stable API, verified in prod)

Names normalized for ergonomics; footgun fixes and retries baked in. These are the wrappers you should prefer.

sites

c.sites.web_domain_get(156)
c.sites.web_domain_update(client_id=0, primary_id=156, params={"php": "fast-cgi"})
c.sites.enable_php(156, mode="php-fpm", server_php_id=2, pm="ondemand")
c.sites.enable_letsencrypt(156)

dns

zone_id = c.dns.zone_get_id("example.com")    # trailing dot is stripped either way
zone = c.dns.zone_get(zone_id)
records = c.dns.rr_get_all_by_zone(zone_id)
rr_id = c.dns.a_add(0, {"zone": zone_id, "name": "www", "data": "1.2.3.4", "ttl": 3600, "active": "Y"})

mail

md = c.mail.domain_get_by_domain("example.com")
users = c.mail.user_get({"email": "%@example.com"})   # always a list
new_id = c.mail.create_mailbox(client_id=5, domain="example.com",
                               local_part="info", password="x", quota_mb=2048)

databases

db = c.databases.get(42)
c.databases.user_update(client_id=5, primary_id=42, params={"database_password": "x"})

clients

cli = c.clients.get_by_username("alice")
groupid = c.clients.get_groupid(cli["client_id"])
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 ISPConfig 3.2.11 but not exercised in every code path — file issues if you hit one.

Module Class Methods
admin AdminModule 10
aps ApsModule 10
backups BackupsModule 2
clients ClientsModule 19
cron CronModule 4
databases DatabasesModule 9
dns DnsModule 87
domains DomainsModule 5
ftp FtpModule 5
mail MailModule 82
misc MiscModule 4
monitor MonitorModule 1
openvz OpenvzModule 22
server ServerModule 12
shell ShellModule 4
sites SitesModule 29
webdav WebdavModule 4

Every auto-generated method carries a docstring with the original PHP filename + line number and an AUTO-GENERATED — param shapes may need verification warning. Methods that exist in both the auto and hand-audited blocks are skipped in the auto block — the hand version wins.

Escape hatch

For methods not yet in the inventory (e.g. on a newer ISPConfig version than we've regenerated against):

result = c.raw_call("some_new_method", arg1, arg2)

raw_call routes through the same session-management + fault-mapping pipeline as typed methods, so auth/retry still works. To see what the panel exposes:

funcs = c.list_functions()

Not covered

  • __construct — PHP constructor, not a real API method.
  • Anything gated by ISPConfig plugins we don't have installed — probing via raw_call will return a faultstring like "Method not found".

Footguns (captured here so nobody has to rediscover them)

  • sites_web_domain_update's second arg is client_id, not primary_id. For admin-owned sites pass client_id=0. Wrong order = permission denied or silent ownership change. SitesModule.web_domain_update signature enforces the correct order.
  • sys_groupid=1 is the admin group. On update calls, if you read a record with sys_groupid=1, pass client_id=0, not 1, or ISPConfig re-owns the record to "admin's admin". enable_php / enable_letsencrypt handle this automatically.
  • fastcgi_php_version vs server_php_id. Older installs use the string field fastcgi_php_version (e.g. "PHP-8.2:/usr/bin/php-cgi8.2:/etc/php/8.2/cgi"), newer installs use server_php_id (int FK to server_php). Our enable_php wraps the new field; set it directly via web_domain_update if you need the old one.
  • BIND trailing dot on zone origins — BUT in reverse. Contrary to a lot 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.
  • 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.
  • no_domain_found / no_client_found faults. These are typed as NotFoundError; client_get_by_username("nope") raises it. Older code may have caught raw FaultError — v0.2 reclassifies both.
  • dns_a_add type-column bug. On some ISPConfig versions (<= ~3.2.11) dns_a_add inserts the dns_rr row without setting the type column, so BIND never emits the record. DnsModule.a_add(..., fix_type_bug=True) (default) issues a follow-up dns_rr_update with {"type": "A"} — no-op on fixed versions, required on the broken ones.
  • Session timeouts. ISPConfig sessions expire mid-long-operation without warning. The client detects the "session not valid" fault, re-authenticates, and retries once (max_retries=1 by default). Tune via ISPConfigClient(..., max_retries=3) for very long batch jobs.
  • mail_forward_*, not mail_forwarding_*. The remote method is singular; we expose it as c.mail.forward_add and keep c.mail.forwarding_add as an alias for PHP-snippet refugees.
  • Filter dicts on mail_user_get. Pass an int to get one row; pass a dict like {"email": "%@example.com"} to get a list. The SOAP method is overloaded and untyped on the wire.
  • PHP method signatures with array() defaults. ISPConfig's extract regex used to stop at the first ) in $params = array(), missing three methods on 3.2.x (sites_aps_available_packages_list, sites_aps_instance_delete, openvz_vm_add_from_template). Fixed in tools/extract_inventory.py with balanced-paren matching — worth double-checking on future panel upgrades.
  • Known admin-only methods. Reseller logins fault with "permission denied" on a non-admin user. These are skipped in the smoke tests: monitor_jobqueue_count, sites_cron_get, sites_ftp_user_get, openvz_*, quota_get_by_user, client_templates_get_all. Use an admin login if you need them.

Errors

Typed hierarchy — never zeep.exceptions.Fault (we don't use zeep), never a raw SOAP fault leaked to callers:

ISPConfigError
├── AuthError           # login failed OR session expired
├── PermissionError     # caller lacks rights on the record
├── NotFoundError       # primary_id didn't match anything
└── FaultError          # everything else (.faultcode, .faultstring)

Tests

pytest                  # unit tests only, no network (12 tests)

To run the live smoke test against a real panel:

export ISPCONFIG_TEST_URL="https://panel.example.com:8080/remote/index.php"
export ISPCONFIG_TEST_USER="admin"
export ISPCONFIG_TEST_PASS="..."
export ISPCONFIG_TEST_VERIFY_SSL=0    # for self-signed certs
pytest tests/test_smoke.py

21 read-only calls covering every auto-generated module + the originals. No _add / _update / _delete calls — safe against production. Methods that require admin privileges skip gracefully with a documented reason.

Regenerating for newer ISPConfig versions

When ISPConfig ships a new version, resync against its PHP sources:

# 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/
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:
python3 tools/extract_inventory.py /tmp/ispconfig-php-src tools/method_inventory.json

# 3. Regenerate wrappers:
python3 tools/gen_methods.py

# 4. Clean up formatting + sanity check:
ruff format src/ tools/
ruff check src/ tools/
mypy src/ispconfig
pytest

# 5. Review and commit:
git diff --stat
git diff tools/method_inventory.json    # new methods jump out here

Hand-edits below the HAND-EDIT ONLY BELOW marker in each module survive regeneration. Method-name collisions between auto and hand are resolved in favor of the hand version; the generator emits a # skipped ... comment in the auto block for traceability.

Development

pip install -e .[dev]
ruff check src/
mypy src/ispconfig
pytest

License

MIT — see LICENSE.