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
199 lines
7.1 KiB
Markdown
199 lines
7.1 KiB
Markdown
# 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`.
|