# 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 ```bash pip install -e . # for use pip install -e .[dev] # with pytest / mypy / ruff ``` Python 3.10+. ## Quick start ```python 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` ```python 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` ```python 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` ```python 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` ```python db = c.databases.get(42) c.databases.user_update(client_id=5, primary_id=42, params={"database_password": "x"}) ``` ### `clients` ```python 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 ```bash pytest # unit tests only, no network ``` To run the live smoke test against a real panel: ```bash 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 ```bash 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`.