Metadata-Version: 2.4
Name: devnomads
Version: 0.2.5
Summary: Python client library for the DevNomads API (transport, DNS, ACME)
Project-URL: Homepage, https://devnomads.nl
Author-email: DevNomads <support@devnomads.nl>
License: MIT
Keywords: acme,api,devnomads,dns,powerdns
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: acme
Requires-Dist: acme>=2.11; extra == 'acme'
Requires-Dist: cryptography>=42; extra == 'acme'
Requires-Dist: dnspython>=2.6; extra == 'acme'
Requires-Dist: josepy>=1.14; extra == 'acme'
Description-Content-Type: text/markdown

# devnomads

Python client library for the [DevNomads](https://devnomads.nl) API:
HTTP transport, a generated resource SDK covering the whole API, DNS
zone/record management, and ACME certificate issuance.

It is the shared foundation of the DevNomads command-line tool `dncli`,
exposed so you can build against the same primitives directly instead of
reimplementing them.

## Contents

- [Features](#features)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Architecture](#architecture)
- [Authentication](#authentication)
- [Transport](#transport)
- [Services](#services)
- [DNS](#dns)
- [Certificates](#certificates)
- [Errors](#errors)
- [Logging](#logging)
- [Design](#design)
- [Development](#development)
- [License](#license)

## Features

- Authenticated HTTP transport with retries (`Retry-After` aware) and
  Laravel resource-envelope unwrapping.
- A generated resource SDK covering the whole API as client
  attributes, e.g. `client.servers.list()`.
- DNS zone discovery (longest-suffix matching), merge-aware TXT updates,
  and generic record management.
- ACME certificate issuance over DNS-01 and HTTP-01 - including
  wildcards and per-identifier challenge mixing in a single order.
- Two HTTP-01 strategies: write challenge files to a web root, or answer
  from a built-in standalone server.
- One credential scheme, shared with the `dncli` command-line tool.
- Typed exceptions, standard-library logging, full type hints
  (`py.typed`). No printing, no `sys.exit`.

## Installation

```bash
pip install devnomads          # transport + DNS (httpx only)
pip install devnomads[acme]    # + the ACME client and its dependencies
```

Requires Python 3.10 or newer. The core install depends only on `httpx`.
The `acme` extra adds `acme`, `josepy`, `cryptography`, and `dnspython`.
`devnomads.acme` only imports with that extra present; without it the
import raises an `ImportError` naming the extra.

## Quick start

```python
from devnomads.api import Client
from devnomads.dns import Dns

with Client.from_environment() as client:
    dns = Dns(client)
    dns.set_txt("_acme-challenge.example.com", "token-value")
    dns.unset_txt("_acme-challenge.example.com", "token-value")
```

## Architecture

The package is split into three layers by dependency weight. Import only
what you need.

```text
devnomads.api    transport + generated resource SDK         (httpx)
      ▲
devnomads.dns    zones, records, TXT/ACME challenges        (httpx)
      ▲
devnomads.acme   ACME issuance: DNS-01 + HTTP-01   (+ acme extra deps)
```

`devnomads.api` carries both the low-level `Client` transport and a
generated SDK that maps the full API onto client attributes (see
[Services](#services)).

The DevNomads DNS API is a PowerDNS proxy scoped to the zones a key may
access, so zone ids and record names are absolute (trailing dot) and TXT
content is stored quoted; the library handles these conventions for you.

## Authentication

Build a client from the environment, from explicit credentials, or from a
resolved `Credentials` object.

```python
from devnomads.api import Client, resolve

client = Client.from_environment()             # env + credentials file
client = Client("https://api.devnomads.nl", "dn_xxx")   # explicit

creds = resolve(profile="work")                # -> Credentials
client = Client.from_credentials(creds)
```

`Client` is a context manager and holds one connection pool:

```python
with Client.from_environment() as client:
    ...
```

### Resolution order

The API key is resolved as:

1. an explicit value passed to `Client`/`resolve`;
2. `DN_API_KEY` (or the `DEVNOMADS_API_KEY` alias);
3. the `api_key` of the selected profile in an INI credentials file.

The credentials file is `DN_CREDENTIALS_FILE` if set, otherwise the first
of `/etc/devnomads/credentials` and `<config dir>/credentials`. The config
directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/dncli`, else
`~/.config/dncli` - i.e. the file `dncli configure` writes. The profile is
`DN_PROFILE`, else `default`.

### Credentials file

```ini
[default]
api_key = dn_xxxxxxxxxxxxxxxx

[work]
api_key = dn_yyyyyyyyyyyyyyyy
api_url = https://api.devnomads.nl
```

### Environment variables

- `DN_API_KEY` / `DEVNOMADS_API_KEY` - API key.
- `DN_API_URL` / `DEVNOMADS_API_URL` - base URL override.
- `DN_PROFILE` / `DEVNOMADS_PROFILE` - profile name (default `default`).
- `DN_CREDENTIALS_FILE` - explicit credentials file path.
- `DN_CONFIG_DIR` - config directory (default `~/.config/dncli`).

`config_dir()` and `credentials_path()` expose the resolved paths.

## Transport

`devnomads.api.Client` is a thin wrapper over `httpx` that owns auth,
retries, and error handling. Use it directly for endpoints the higher
layers don't cover.

```python
Client(api_url, api_key, *, user_agent=None, timeout=30.0)
```

```python
with Client.from_environment() as client:
    zones = client.request("GET", "/services/dns/zones")
    client.request(
        "PATCH",
        "/services/dns/zones/example.com.",
        json_body={"rrsets": [rrset]},
    )
```

`request(method, path, *, params=None, json_body=None)` returns parsed
JSON and:

- unwraps the Laravel `{"data": ...}` envelope;
- retries `429` and `5xx` with backoff, honouring `Retry-After`;
- returns `None` for `404` and for empty/`204` bodies, so a missing
  resource reads as absence;
- raises `AuthError` on `401`/`403` and `ApiError` on any other `4xx`.

## Services

Beyond the curated DNS and ACME helpers, the `Client` exposes the rest
of the API through a generated, resource-attribute SDK. Each top-level
resource is an attribute on the client; nested resources chain as
further attributes; operations are methods.

```python
from devnomads.api import Client

with Client.from_environment() as client:
    client.servers.list()
    client.servers.show(server_id)
    client.databases.clusters.list()
    client.databases.create(body={"name": "blog"})
    client.emails.mailboxes.list(service_id)
    client.emails.mailboxes.create(service_id, body={...})
    client.proxies.up(service_id)
```

The top-level resources are `servers`, `databases`, `emails`, `spams`,
`containers`, `proxies`, `sites`, `buckets`, `domains`, `forwards`,
`searches`, `apps`, and `handles`. (DNS stays the curated
`devnomads.dns` interface and is not generated here.)

### Conventions

- **Path parameters** are positional, in URL order, snake-cased from
  the path template - e.g. `client.emails.mailboxes.show(service_id,
  emailaddress)`.
- **Query parameters** go through `params=` as a dict.
- **Request bodies** (POST/PUT/PATCH) go through `body=`, sent as JSON.
- **Method names** map the API action: `index` -> `list`, `store`/
  `create` -> `create`, `show`, `update`, `destroy`/`delete` ->
  `delete`, `patch`; any other action (`state`, `deploy`, `logs`, `up`,
  `down`, `enable`, ...) passes through snake-cased.

Returns are the parsed JSON payload (envelope already unwrapped). The
OpenAPI spec ships no component schemas, so there are no typed response
models - methods return `Any`. The SDK is generated from the committed
`openapi.json`; see [Development](#development).

```python
mailbox = client.emails.mailboxes.create(
    service_id, body={"address": "hi@example.com"}
)
aliases = client.emails.aliases.list(service_id, params={"page": 2})
```

## DNS

`Dns` wraps a `Client` with zone-scoped operations. Zone discovery lists
the accessible zones and selects the longest matching suffix, so a deep
host resolves to its flat parent zone automatically.

```python
from devnomads.api import Client
from devnomads.dns import Dns

with Client.from_environment() as client:
    dns = Dns(client)
```

### TXT records and ACME challenges

```python
result = dns.set_txt("_acme-challenge.example.com", "token")
# result.zone, result.values, result.changed  (a TxtResult)

dns.unset_txt("_acme-challenge.example.com", "token")
dns.unset_txt("_acme-challenge.example.com")   # remove the whole rrset
```

`set_txt` merges with any values already present - so a wildcard and its
base domain can validate the same name concurrently - and makes no request
when the value is already there. `unset_txt` removes one value (or the
whole rrset when no value is given) and deletes the rrset once its last
value is gone. Both return `TxtResult(zone, values, changed)` and accept
an optional `ttl` (default 60).

### Generic records

```python
dns.replace_records(
    "host.example.com", "A", ["192.0.2.1", "192.0.2.2"], ttl=300
)
dns.delete_records("host.example.com", "A")
```

`replace_records` sends content verbatim (use it for A/AAAA/CNAME/MX/...);
`set_txt` is the TXT-specific path that handles quoting and merging.

### Zones and name helpers

```python
names = dns.list_zone_names()
zone = dns.find_zone("_acme-challenge.deep.host.example.com")
data = dns.get_zone(zone)
dns.patch_rrset(zone, rrset)        # low-level rrset change
```

```python
from devnomads.dns import challenge_name, fqdn, zone_id, current_txt_values

challenge_name("*.example.com")     # "_acme-challenge.example.com"
fqdn("www", "example.com")          # "www.example.com."
zone_id("example.com")              # "example.com."
current_txt_values(data, "_acme-challenge.example.com")   # ["token"]
```

Also exported: `absolute`, `quote_txt`, `unquote_txt`, `DEFAULT_TXT_TTL`,
and the `ZoneNotFound` error.

## Certificates

`devnomads.acme` (needs the `acme` extra) issues certificates from an ACME
CA - Let's Encrypt by default - over DNS-01, HTTP-01, or a mix of both in
one order. It manages the account key, builds the CSR, verifies DNS
propagation against the authoritative nameservers, and can select a
preferred chain.

```python
AcmeClient(
    account_key_path,
    *,
    directory_url=DEFAULT_DIRECTORY_URL,    # Let's Encrypt production
    account_key_algorithm="ec256",          # rsa2048/rsa4096/ec256/ec384/ec521
    contact_email=None,
    preferred_chain=None,                   # match alternate chain by CN
    recursive_nameservers=None,             # resolvers for propagation
    user_agent="devnomads",
    eab_kid=None,                           # external account binding key id
    eab_hmac_key=None,                      # external account binding HMAC key
    eab_hmac_alg="HS256",                   # HS256/HS384/HS512
)
```

Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
`directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
also need `eab_kid` and `eab_hmac_key`.

The account key at `account_key_path` is loaded if present and created
(mode 0600) otherwise.

`obtain_certificate` returns
`(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` - the first three
as `str`, the key as `bytes`.

### DNS-01

```python
from pathlib import Path
from devnomads.api import Client
from devnomads.dns import Dns
from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key

acme = AcmeClient(
    "/etc/devnomads/account.key",
    contact_email="ops@example.com",
    preferred_chain="ISRG Root X1",
)
domain_key = generate_key("ec256")

with Client.from_environment() as client:
    provider = DevNomadsDnsProvider(Dns(client))
    cert, fullchain, chain, key_pem = acme.obtain_certificate(
        "example.com",
        "dns-01",
        domain_key,
        sans=["www.example.com", "*.example.com"],
        dns_provider=provider,
    )

out = Path("/etc/pki/tls/private")
(out / "fullchain.pem").write_text(fullchain)
(out / "privkey.pem").write_bytes(key_pem)
```

`dns_propagation_delay` (default 2s) sets how long to wait after writing
records before polling; `recursive_nameservers` (IPv6 first for graceful
failover) picks the resolvers used for that check.

### HTTP-01

Answer HTTP-01 challenges from the built-in server, or by writing files
under an existing web root:

```python
from devnomads.acme import StandaloneSolver, WebrootSolver

# Standalone: bind :80 and serve challenges directly.
with StandaloneSolver(port=80) as solver:
    acme.obtain_certificate(
        "example.com", "http-01", domain_key, http01_solver=solver
    )

# Webroot: write files for an existing server to serve.
acme.obtain_certificate(
    "example.com",
    "http-01",
    domain_key,
    http01_solver=WebrootSolver("/var/www/html"),
)
```

`StandaloneSolver` is a context manager that starts/stops the bundled
`ChallengeServer`; a bind failure is logged (not raised) so a co-located
instance already holding the port is tolerated.

### Mixed challenges

Pass a `{identifier: challenge_type}` mapping to use different challenge
types per name in one order - e.g. HTTP-01 for the base domain and DNS-01
for its wildcard:

```python
acme.obtain_certificate(
    "example.com",
    {"example.com": "http-01", "*.example.com": "dns-01"},
    domain_key,
    sans=["*.example.com"],
    dns_provider=provider,
    http01_solver=solver,
)
```

### Staging

```python
acme = AcmeClient(
    "/etc/devnomads/account.key",
    directory_url=(
        "https://acme-staging-v02.api.letsencrypt.org/directory"
    ),
)
```

### Providers (Let's Encrypt, ZeroSSL)

`for_provider` resolves a named CA from `CA_DIRECTORIES`
(`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
remember directory URLs. Let's Encrypt stays the default everywhere else.

```python
from devnomads.acme import AcmeClient

# Let's Encrypt (default CA) - no EAB needed.
acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")

# ZeroSSL - requires External Account Binding credentials from your
# ZeroSSL account (Developer -> EAB Credentials, or the REST API).
acme = AcmeClient.for_provider(
    "zerossl",
    "/etc/devnomads/zerossl-account.key",
    contact_email="ops@example.com",
    eab_kid="...",        # EAB key id
    eab_hmac_key="...",   # base64url EAB HMAC key
)
```

Issuance is otherwise identical - the same `obtain_certificate` calls,
DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
directory URL and EAB fields can also be passed straight to the
`AcmeClient(...)` constructor if you prefer not to use the alias.

### Key helpers

```python
from devnomads.acme import (
    generate_key,
    serialize_key,
    build_csr,
    load_or_create_account_key,
)

key = generate_key("rsa4096")       # rsa2048/rsa4096/ec256/ec384/ec521
pem = serialize_key(key)            # unencrypted PEM bytes
csr = build_csr(key, ["example.com", "www.example.com"])
jwk = load_or_create_account_key("/etc/devnomads/account.key", "ec256")
```

### Custom backends

`DevNomadsDnsProvider` is the bundled DNS-01 backend. Implement
`DnsProvider` (or `Http01Solver`) to drive your own:

```python
from devnomads.acme import DnsProvider

class MyProvider(DnsProvider):
    def create_challenge(self, fqdn, validation):
        ...

    def delete_challenge(self, fqdn, validation=None):
        ...
```

`verify_txt_record(fqdn, expected_value, *, timeout=120, interval=5,
nameservers=None)` is exposed if you want to run the propagation check
yourself.

## Errors

The library never prints or exits; operations return values and failures
raise a `DevNomadsError` subclass, so the caller decides how to present
them.

```text
DevNomadsError
├── ConfigError        # credentials / config could not be resolved
├── ApiError           # API returned an error (.status, .detail)
│   └── AuthError      # 401 / 403
├── DnsError           # a DNS operation failed
│   └── ZoneNotFound   # no accessible zone matches the name
└── AcmeError          # an ACME issuance step failed
```

```python
from devnomads.api import DevNomadsError, AuthError

try:
    dns.set_txt(name, value)
except AuthError:
    ...  # missing, invalid, or unauthorized key
except DevNomadsError as exc:
    ...  # everything else this library raises
```

`ApiError` carries `.status` and `.detail`. Remember that `request`
returns `None` (rather than raising) for a `404`.

## Logging

Progress and warnings go through the standard `logging` module under the
`devnomads` logger (`devnomads.api`, `devnomads.acme`). It is silent
unless you configure logging:

```python
import logging

logging.getLogger("devnomads").setLevel(logging.INFO)
```

## Design

- **Library, not application.** It raises and returns; it never prints,
  exits, or formats for humans. The calling CLI, hook, or daemon owns
  presentation.
- **Layered by dependency weight.** DNS-only consumers never pull the
  ACME stack; the heavy dependencies live behind the `acme` extra.
- **One credential scheme** shared with the DevNomads CLI, so a host
  configured for `dncli` works unchanged.

## Development

```bash
uv sync --dev --extra acme
uv run pytest
uv run black . && uv run flake8 src tests
```

The `Services` SDK in `src/devnomads/api/_services.py` is generated from
the committed `openapi.json` snapshot. Regenerate it after refreshing
the spec; CI fails if the checked-in file is stale.

```bash
make spec       # refresh openapi.json from the live API
make generate   # rewrite _services.py from openapi.json
make check      # lint + type + test + generate --check
```

## License

MIT
