ispconfig-py/README.md
Kayos 9438b4e751 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
2026-04-22 13:24:58 -07:00

7.1 KiB

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.

The SDK covers the methods we actually use today (sites, DNS, mail, databases, clients). More methods can be added as needed.

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="kayos",
    password="hunter2",
) 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.

Modules at a glance

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.")    # note the trailing dot
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"})
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("jacob")
groupid = c.clients.get_groupid(cli["client_id"])

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. Verified against Rackham 2026-04-22.
  • 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 fault. Both dns_zone_get_id and (in some paths) mail_domain_get_by_domain return a SOAP fault with faultcode=no_domain_found when the record is missing. Mapped to NotFoundError.
  • 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.

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

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="kayos"
export ISPCONFIG_TEST_PASS="..."
pytest tests/test_smoke.py

The smoke test is read-only — no _add / _update / _delete calls. Safe against production.

Development

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

Not yet covered

The remote API surface is huge. These are intentionally left out of v0.1 — add as needed:

  • sites_web_aliasdomain_*, sites_web_subdomain_*, sites_web_vhost_{subdomain,aliasdomain}_*
  • sites_ftp_user_*, sites_shell_user_*, sites_webdav_user_*
  • sites_cron_*
  • sites_web_domain_backup, sites_web_domain_backup_list, mail_user_backup, mail_user_backup_list
  • dns_{aaaa,ns,srv,ptr,tlsa,ds,caa,sshfp,dname,loc,hinfo,naptr,rp,alias}_*
  • dns_slave_*, dns_zone_set_dnssec, dns_zone_get_by_user, dns_templatezone_*
  • mail_alias_*, mail_aliasdomain_*, mail_catchall_*, mail_filter_*, mail_fetchmail_*, mail_mailinglist_*, mail_policy_*, mail_relay_{domain,recipient}_*, mail_transport_*, mail_{whitelist,blacklist}_*, mail_spamfilter_*, mail_user_filter_*
  • client_add, client_update, client_delete, client_change_password, client_template_additional_*, client_templates_get_all, client_login_get
  • server_get, server_get_all, admin.*, monitor.*, aps.*, openvz.*, domains.*

License

MIT — see LICENSE.