ispconfig-py/README.md
Cobb Hayes 99ee5f1a9a Public-flip audit: URL refresh + minor scrubs
Repository URLs, version strings, and example creds normalized for the
public git.sulkta.com endpoint. No code-behavior change.

Audit-applied by the public-flip rolling-audit pass — see
kayos/openclaw-workspace memory/2026-05-27 logs for the campaign
context.
2026-05-27 10:59:58 -07:00

306 lines
11 KiB
Markdown

# 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`.