# 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 ```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="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` ```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") # 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` ```python 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` ```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("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): ```python 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: ```python 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 ```bash pytest # unit tests only, no network (12 tests) ``` 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="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: ```bash # 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 ```bash pip install -e .[dev] ruff check src/ mypy src/ispconfig pytest ``` ## License MIT — see `LICENSE`.