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
|
||
|---|---|---|
| src/ispconfig | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
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 isclient_id, notprimary_id. For admin-owned sites passclient_id=0. Wrong order = permission denied or silent ownership change.SitesModule.web_domain_updatesignature enforces the correct order.sys_groupid=1is the admin group. On update calls, if you read a record withsys_groupid=1, passclient_id=0, not1, or ISPConfig re-owns the record to "admin's admin".enable_php/enable_letsencrypthandle this automatically.fastcgi_php_versionvsserver_php_id. Older installs use the string fieldfastcgi_php_version(e.g."PHP-8.2:/usr/bin/php-cgi8.2:/etc/php/8.2/cgi"), newer installs useserver_php_id(int FK toserver_php). Ourenable_phpwraps the new field; set it directly viaweb_domain_updateif 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.")raisesno_domain_found. Our wrapper strips a trailing dot for you so either call works. Verified against Rackham 2026-04-22. mail_user_getwith a filter dict returns inconsistent shapes. If the filter matches multiple rows you get an array; exactly-one match returns a bare map. Ourmail.user_get(filter_dict)always normalizes to a list.no_domain_foundfault. Bothdns_zone_get_idand (in some paths)mail_domain_get_by_domainreturn a SOAP fault withfaultcode=no_domain_foundwhen the record is missing. Mapped toNotFoundError.dns_a_addtype-column bug. On some ISPConfig versions (<= ~3.2.11)dns_a_addinserts thedns_rrrow without setting thetypecolumn, so BIND never emits the record.DnsModule.a_add(..., fix_type_bug=True)(default) issues a follow-updns_rr_updatewith{"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=1by default). Tune viaISPConfigClient(..., max_retries=3)for very long batch jobs. mail_forward_*, notmail_forwarding_*. The remote method is singular; we expose it asc.mail.forward_addand keepc.mail.forwarding_addas 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_listdns_{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_getserver_get,server_get_all,admin.*,monitor.*,aps.*,openvz.*,domains.*
License
MIT — see LICENSE.