Skip to main content

Python client for the DevNomads API (transport, DNS, ACME)

Project description

devnomads

Python client library for the DevNomads API: HTTP transport, DNS zone/record management, and ACME certificate issuance.

It is the shared foundation of the DevNomads command-line tools - dnctl and dnsync - exposed so you can build against the same primitives directly instead of reimplementing them.

Contents

Features

  • Authenticated HTTP transport with retries (Retry-After aware) and Laravel resource-envelope unwrapping.
  • 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, compatible with dnctl and dnsync.
  • Typed exceptions, standard-library logging, full type hints (py.typed). No printing, no sys.exit.

Installation

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

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.

devnomads.api    transport: auth, retries, errors          (httpx)
      ▲
devnomads.dns    zones, records, TXT/ACME challenges        (httpx)
      ▲
devnomads.acme   ACME issuance: DNS-01 + HTTP-01   (+ acme extra deps)

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.

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:

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/dnctl, else ~/.config/dnctl - i.e. the file dnctl configure writes. The profile is DN_PROFILE, else default.

Credentials file

[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/dnctl).

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.

Client(api_url, api_key, *, user_agent=None, timeout=30.0)
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.

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.

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

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

TXT records and ACME challenges

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

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

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

AcmeClient(
    account_key_path,
    *,
    directory_url=DEFAULT_DIRECTORY_URL,    # Let's Encrypt production
    account_key_algorithm="ec256",          # rsa2048/rsa4096/ec256/ec384
    contact_email=None,
    preferred_chain=None,                   # match alternate chain by CN
    recursive_nameservers=None,             # resolvers for propagation
    user_agent="devnomads",
)

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

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:

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:

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

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

Key helpers

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

key = generate_key("rsa4096")       # rsa2048/rsa4096/ec256/ec384
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:

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.

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
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:

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 CLIs, so a host configured for dnctl works unchanged.

Development

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

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

devnomads-0.1.3.tar.gz (142.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

devnomads-0.1.3-py3-none-any.whl (28.7 kB view details)

Uploaded Python 3

File details

Details for the file devnomads-0.1.3.tar.gz.

File metadata

  • Download URL: devnomads-0.1.3.tar.gz
  • Upload date:
  • Size: 142.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.4","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for devnomads-0.1.3.tar.gz
Algorithm Hash digest
SHA256 f0dc4d141eb1b3f8cec8cfad42337fc3195b1a5ec667609a20f7e971c1364e7c
MD5 3b5e7197d88a423a09eec809f693a0a8
BLAKE2b-256 2e0b212f90847572767d61cdc11b363664b7372f11e7b53da5c0057b513d39eb

See more details on using hashes here.

File details

Details for the file devnomads-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: devnomads-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 28.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.4","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for devnomads-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 1043eba5fd0a1f7221dadefa02363ba5737c7fd8c8d42afd4f3f3137bb3f6530
MD5 1010cedab0eda329a488fa2ec61dc9a5
BLAKE2b-256 3fd5c2720f71027fc037e6ebf243177de40b4957b8797d5171d7b4d440b5c7f5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page