devnomads 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devnomads/dns/names.py ADDED
@@ -0,0 +1,75 @@
1
+ """DNS name helpers.
2
+
3
+ The DevNomads DNS API is a PowerDNS proxy, so zone ids and record names
4
+ are absolute (trailing dot) and TXT content is stored quoted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .errors import DnsError
10
+
11
+ CHALLENGE_PREFIX = "_acme-challenge"
12
+
13
+
14
+ def zone_id(zone: str) -> str:
15
+ """PowerDNS zone ids carry a trailing dot; accept names without it."""
16
+
17
+ return zone if zone.endswith(".") else f"{zone}."
18
+
19
+
20
+ def absolute(name: str) -> str:
21
+ """Return ``name`` with a trailing dot, PowerDNS style."""
22
+
23
+ return name if name.endswith(".") else f"{name}."
24
+
25
+
26
+ def fqdn(name: str, zone: str) -> str:
27
+ """Absolute record name for ``name`` within ``zone``.
28
+
29
+ Accepts relative names, ``@``/empty for the apex, or a name that
30
+ already includes the zone, and always returns the trailing-dot form.
31
+ """
32
+
33
+ zone_root = zone.rstrip(".")
34
+ relative = name.rstrip(".")
35
+ if relative in ("@", ""):
36
+ return f"{zone_root}."
37
+ if relative == zone_root or relative.endswith(f".{zone_root}"):
38
+ return f"{relative}."
39
+ return f"{relative}.{zone_root}."
40
+
41
+
42
+ def challenge_name(domain: str) -> str:
43
+ """The TXT record name to validate for ``domain``.
44
+
45
+ Accepts a plain domain (``example.com`` or ``*.example.com``) and
46
+ returns ``_acme-challenge.example.com``; a name already starting with
47
+ ``_acme-challenge`` is passed through unchanged, so callers can hand us
48
+ the full record name directly.
49
+ """
50
+
51
+ name = domain.strip().rstrip(".")
52
+ if name.startswith("*."):
53
+ name = name[2:]
54
+ if not name:
55
+ raise DnsError("empty domain")
56
+ if name == CHALLENGE_PREFIX or name.startswith(f"{CHALLENGE_PREFIX}."):
57
+ return name
58
+ return f"{CHALLENGE_PREFIX}.{name}"
59
+
60
+
61
+ def quote_txt(value: str) -> str:
62
+ """PowerDNS stores TXT content quoted; quote ``value`` if it is not."""
63
+
64
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
65
+ return value
66
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
67
+ return f'"{escaped}"'
68
+
69
+
70
+ def unquote_txt(value: str) -> str:
71
+ """Inverse of :func:`quote_txt`."""
72
+
73
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
74
+ return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
75
+ return value
devnomads/dns/zones.py ADDED
@@ -0,0 +1,211 @@
1
+ """Zone and record operations on top of the API transport.
2
+
3
+ The DevNomads DNS API exposes three PowerDNS-style endpoints scoped to the
4
+ zones a key may access::
5
+
6
+ GET /services/dns/zones list zones
7
+ GET /services/dns/zones/{zone_id} zone with its rrsets
8
+ PATCH /services/dns/zones/{zone_id} apply an rrset change
9
+
10
+ :class:`Dns` wraps a :class:`~devnomads.api.Client` with the higher-level
11
+ operations the tools need: zone discovery, reading current values, and the
12
+ merge-aware TXT updates ACME challenges require.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from ..api import Client
20
+ from .errors import ZoneNotFound
21
+ from .names import absolute, quote_txt, unquote_txt, zone_id
22
+
23
+ DEFAULT_TXT_TTL = 60
24
+
25
+
26
+ @dataclass
27
+ class TxtResult:
28
+ """Outcome of a TXT set/unset operation.
29
+
30
+ ``values`` is the list of values present after the operation;
31
+ ``changed`` says whether the API was actually modified.
32
+ """
33
+
34
+ zone: str
35
+ values: list[str]
36
+ changed: bool
37
+
38
+
39
+ def current_txt_values(zone_data: dict, record_name: str) -> list[str]:
40
+ """Unquoted TXT values already present at ``record_name`` in the zone."""
41
+
42
+ target = absolute(record_name).lower()
43
+ for rrset in zone_data.get("rrsets", []) or []:
44
+ if rrset.get("type") == "TXT" and (rrset.get("name") or "").lower() == target:
45
+ return [
46
+ unquote_txt(rec.get("content", "")) for rec in rrset.get("records", [])
47
+ ]
48
+ return []
49
+
50
+
51
+ def _replace_rrset(
52
+ record_name: str,
53
+ rtype: str,
54
+ contents: list[str],
55
+ ttl: int,
56
+ ) -> dict:
57
+ return {
58
+ "name": absolute(record_name),
59
+ "type": rtype,
60
+ "changetype": "REPLACE",
61
+ "ttl": ttl,
62
+ "records": [{"content": c, "disabled": False} for c in contents],
63
+ }
64
+
65
+
66
+ def _delete_rrset(record_name: str, rtype: str) -> dict:
67
+ return {"name": absolute(record_name), "type": rtype, "changetype": "DELETE"}
68
+
69
+
70
+ class Dns:
71
+ """DNS operations bound to an API client."""
72
+
73
+ def __init__(self, client: Client) -> None:
74
+ self.client = client
75
+
76
+ # -- raw zone access ---------------------------------------------------
77
+
78
+ def list_zone_names(self) -> list[str]:
79
+ """Names of every zone this key may access (one request, no probing).
80
+
81
+ This API returns 403 (not 404) for names that are not owned zones,
82
+ so probing label suffixes would misread permissions and risk
83
+ throttling. Listing once and matching locally avoids that.
84
+ """
85
+
86
+ data = self.client.request("GET", "/services/dns/zones") or []
87
+ return [z.get("name", "") for z in data if isinstance(z, dict)]
88
+
89
+ def get_zone(self, zone: str) -> dict | None:
90
+ return self.client.request("GET", f"/services/dns/zones/{zone_id(zone)}")
91
+
92
+ def patch_rrset(self, zone: str, rrset: dict) -> None:
93
+ self.client.request(
94
+ "PATCH",
95
+ f"/services/dns/zones/{zone_id(zone)}",
96
+ json_body={"rrsets": [rrset]},
97
+ )
98
+
99
+ def find_zone(self, record_name: str) -> str:
100
+ """Return the owned zone that is the longest suffix of ``record_name``.
101
+
102
+ A deep host like ``_acme-challenge.a.b.example.com`` lives in the
103
+ flat ``example.com`` zone. Longest-suffix wins, so a delegated
104
+ sub-zone (``a.example.com``) is preferred over its parent when both
105
+ are present.
106
+ """
107
+
108
+ target = record_name.rstrip(".").lower()
109
+ best = ""
110
+ for name in self.list_zone_names():
111
+ zone = name.rstrip(".").lower()
112
+ if zone and (target == zone or target.endswith(f".{zone}")):
113
+ if len(zone) > len(best):
114
+ best = zone
115
+ if not best:
116
+ raise ZoneNotFound(
117
+ f"no DevNomads zone found for {record_name!r} - is the domain "
118
+ "hosted at DevNomads and reachable with this API key?"
119
+ )
120
+ return best
121
+
122
+ # -- generic records ---------------------------------------------------
123
+
124
+ def replace_records(
125
+ self,
126
+ record_name: str,
127
+ rtype: str,
128
+ contents: list[str],
129
+ *,
130
+ ttl: int,
131
+ zone: str | None = None,
132
+ ) -> None:
133
+ """Replace ``record_name``'s ``rtype`` rrset with ``contents``.
134
+
135
+ Content is sent verbatim (use for A/AAAA/CNAME etc.); for TXT use
136
+ :meth:`set_txt`, which handles quoting and value merging.
137
+ """
138
+
139
+ zone = zone or self.find_zone(record_name)
140
+ self.patch_rrset(zone, _replace_rrset(record_name, rtype, contents, ttl))
141
+
142
+ def delete_records(
143
+ self,
144
+ record_name: str,
145
+ rtype: str,
146
+ *,
147
+ zone: str | None = None,
148
+ ) -> None:
149
+ zone = zone or self.find_zone(record_name)
150
+ self.patch_rrset(zone, _delete_rrset(record_name, rtype))
151
+
152
+ # -- TXT / ACME challenges ---------------------------------------------
153
+
154
+ def set_txt(
155
+ self,
156
+ record_name: str,
157
+ value: str,
158
+ *,
159
+ ttl: int = DEFAULT_TXT_TTL,
160
+ ) -> TxtResult:
161
+ """Add ``value`` to the TXT rrset at ``record_name``.
162
+
163
+ Merges with any values already present (so a wildcard + base-domain
164
+ order can validate the same name twice) and is idempotent: a value
165
+ already present makes no API call.
166
+ """
167
+
168
+ if not value:
169
+ raise ValueError("a challenge value is required to set a TXT record")
170
+ zone = self.find_zone(record_name)
171
+ values = current_txt_values(self.get_zone(zone) or {}, record_name)
172
+ if value in values:
173
+ return TxtResult(zone=zone, values=values, changed=False)
174
+ values = values + [value]
175
+ self.patch_rrset(
176
+ zone,
177
+ _replace_rrset(record_name, "TXT", [quote_txt(v) for v in values], ttl),
178
+ )
179
+ return TxtResult(zone=zone, values=values, changed=True)
180
+
181
+ def unset_txt(
182
+ self,
183
+ record_name: str,
184
+ value: str | None = None,
185
+ *,
186
+ ttl: int = DEFAULT_TXT_TTL,
187
+ ) -> TxtResult:
188
+ """Remove ``value`` from the TXT rrset at ``record_name``.
189
+
190
+ Deletes the whole rrset once the last value is gone. With
191
+ ``value=None`` the entire rrset is removed. Idempotent: absence of
192
+ the value makes no API call.
193
+ """
194
+
195
+ zone = self.find_zone(record_name)
196
+ values = current_txt_values(self.get_zone(zone) or {}, record_name)
197
+ if not values:
198
+ return TxtResult(zone=zone, values=[], changed=False)
199
+ remaining = [] if value is None else [v for v in values if v != value]
200
+ if remaining == values:
201
+ return TxtResult(zone=zone, values=values, changed=False)
202
+ if remaining:
203
+ self.patch_rrset(
204
+ zone,
205
+ _replace_rrset(
206
+ record_name, "TXT", [quote_txt(v) for v in remaining], ttl
207
+ ),
208
+ )
209
+ else:
210
+ self.patch_rrset(zone, _delete_rrset(record_name, "TXT"))
211
+ return TxtResult(zone=zone, values=remaining, changed=True)
devnomads/py.typed ADDED
File without changes
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.4
2
+ Name: devnomads
3
+ Version: 0.1.0
4
+ Summary: Python client for the DevNomads API (transport, DNS, ACME)
5
+ Project-URL: Homepage, https://devnomads.nl
6
+ Project-URL: Source, https://gitlab.infrapod.nl/infrapod/devnomads-libs
7
+ Author-email: Loek Geleijn <support@devnomads.nl>
8
+ License: MIT
9
+ Keywords: acme,api,devnomads,dns,powerdns
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: acme
13
+ Requires-Dist: acme>=2.11; extra == 'acme'
14
+ Requires-Dist: cryptography>=42; extra == 'acme'
15
+ Requires-Dist: dnspython>=2.6; extra == 'acme'
16
+ Requires-Dist: josepy>=1.14; extra == 'acme'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # devnomads
20
+
21
+ Python client library for the [DevNomads](https://devnomads.nl) API:
22
+ HTTP transport, DNS zone/record management, and ACME certificate
23
+ issuance.
24
+
25
+ It is the shared foundation of the DevNomads command-line tools - `dnctl`
26
+ and `dnsync` - exposed so you can build against the same primitives
27
+ directly instead of reimplementing them.
28
+
29
+ ## Layers
30
+
31
+ The package is split by dependency weight, lightest first. `httpx` is the
32
+ only hard dependency; import what you need.
33
+
34
+ - **`devnomads.api`** - HTTP transport: authentication, retries with
35
+ `Retry-After`, Laravel envelope unwrapping, and a typed error hierarchy.
36
+ - **`devnomads.dns`** - DNS helpers on top of the transport: zone
37
+ discovery, rrset merging, A/AAAA records, and TXT/ACME-challenge
38
+ handling.
39
+ - **`devnomads.acme`** - ACME client for certificate issuance over DNS-01
40
+ and HTTP-01. Requires the `acme` extra (`acme`, `josepy`,
41
+ `cryptography`, `dnspython`).
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install devnomads # api + dns (httpx only)
47
+ pip install devnomads[acme] # + the ACME client and its dependencies
48
+ ```
49
+
50
+ `devnomads.acme` only imports cleanly with the `acme` extra installed;
51
+ without it, importing the module raises an `ImportError` that names the
52
+ extra. Requires Python 3.10 or newer.
53
+
54
+ ## Authentication
55
+
56
+ Build a client from the environment, or pass credentials explicitly:
57
+
58
+ ```python
59
+ from devnomads.api import Client
60
+
61
+ # Resolved from the environment and credentials file.
62
+ client = Client.from_environment()
63
+
64
+ # Or explicit:
65
+ client = Client("https://api.devnomads.nl", "dn_xxx")
66
+ ```
67
+
68
+ `Client` is a context manager and reuses one underlying connection pool:
69
+
70
+ ```python
71
+ with Client.from_environment() as client:
72
+ ...
73
+ ```
74
+
75
+ The API key is resolved in this order:
76
+
77
+ 1. an explicit value passed to `Client`/`resolve`;
78
+ 2. `DN_API_KEY` (or the `DEVNOMADS_API_KEY` alias);
79
+ 3. the `api_key` of the selected profile in an INI credentials file.
80
+
81
+ The credentials file is taken from `DN_CREDENTIALS_FILE` if set, otherwise
82
+ the first of `/etc/devnomads/credentials` and `~/.config/dnctl/credentials`
83
+ (the file `dnctl configure` writes). The profile is `DN_PROFILE`, else
84
+ `default`. This matches the resolution used by `dnctl` and `dnsync`, so a
85
+ host already configured for those works unchanged.
86
+
87
+ ## DNS
88
+
89
+ `Dns` wraps a `Client` with zone-scoped operations. Zone discovery is
90
+ done by listing accessible zones and picking the longest matching suffix,
91
+ so a deep host resolves to its flat parent zone automatically.
92
+
93
+ ```python
94
+ from devnomads.api import Client
95
+ from devnomads.dns import Dns
96
+
97
+ with Client.from_environment() as client:
98
+ dns = Dns(client)
99
+
100
+ # Merge-aware, idempotent TXT updates (used for ACME challenges).
101
+ dns.set_txt("_acme-challenge.example.com", "token-value")
102
+ dns.unset_txt("_acme-challenge.example.com", "token-value")
103
+
104
+ # Generic records (content sent verbatim).
105
+ dns.replace_records("host.example.com", "A", ["192.0.2.1"], ttl=300)
106
+ dns.delete_records("host.example.com", "A")
107
+
108
+ zone = dns.find_zone("_acme-challenge.deep.host.example.com")
109
+ ```
110
+
111
+ `set_txt`/`unset_txt` return a `TxtResult(zone, values, changed)`;
112
+ `set_txt` merges with any values already present (so a wildcard and its
113
+ base domain can validate the same name concurrently) and makes no request
114
+ when the value is already there. `unset_txt` deletes the rrset once its
115
+ last value is gone.
116
+
117
+ The `devnomads.dns` module also exports name helpers - `challenge_name`,
118
+ `fqdn`, `zone_id`, `quote_txt`, `unquote_txt` - and the `ZoneNotFound`
119
+ error.
120
+
121
+ ## ACME
122
+
123
+ `AcmeClient` issues certificates from an ACME CA (Let's Encrypt by
124
+ default) over DNS-01, HTTP-01, or a mix of both within one order. It
125
+ manages the account key, builds the CSR, verifies DNS propagation against
126
+ the authoritative nameservers, and selects the preferred chain.
127
+
128
+ ```python
129
+ from devnomads.api import Client
130
+ from devnomads.dns import Dns
131
+ from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key
132
+
133
+ acme = AcmeClient(
134
+ "/etc/devnomads/account.key",
135
+ contact_email="ops@example.com",
136
+ )
137
+ domain_key = generate_key("ec256")
138
+
139
+ # DNS-01, including wildcards:
140
+ with Client.from_environment() as client:
141
+ provider = DevNomadsDnsProvider(Dns(client))
142
+ leaf, fullchain, chain, key_pem = acme.obtain_certificate(
143
+ "example.com",
144
+ "dns-01",
145
+ domain_key,
146
+ sans=["*.example.com"],
147
+ dns_provider=provider,
148
+ )
149
+ ```
150
+
151
+ `obtain_certificate` returns
152
+ `(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` as strings (the key
153
+ as bytes).
154
+
155
+ ### HTTP-01
156
+
157
+ Answer HTTP-01 challenges either from the bundled in-memory server or by
158
+ writing files under an existing web root:
159
+
160
+ ```python
161
+ from devnomads.acme import StandaloneSolver, WebrootSolver
162
+
163
+ # Standalone: bind :80 and serve challenges directly.
164
+ with StandaloneSolver(port=80) as solver:
165
+ acme.obtain_certificate(
166
+ "example.com", "http-01", domain_key, http01_solver=solver
167
+ )
168
+
169
+ # Webroot: write files for an existing server to serve.
170
+ acme.obtain_certificate(
171
+ "example.com",
172
+ "http-01",
173
+ domain_key,
174
+ http01_solver=WebrootSolver("/var/www/html"),
175
+ )
176
+ ```
177
+
178
+ ### Mixed challenges
179
+
180
+ Pass a `{identifier: challenge_type}` mapping to use different challenge
181
+ types per name in a single order - e.g. HTTP-01 for the base domain and
182
+ DNS-01 for its wildcard:
183
+
184
+ ```python
185
+ acme.obtain_certificate(
186
+ "example.com",
187
+ {"example.com": "http-01", "*.example.com": "dns-01"},
188
+ domain_key,
189
+ sans=["*.example.com"],
190
+ dns_provider=provider,
191
+ http01_solver=solver,
192
+ )
193
+ ```
194
+
195
+ To target staging, pass
196
+ `directory_url="https://acme-staging-v02.api.letsencrypt.org/directory"`.
197
+ Implement `devnomads.acme.DnsProvider` to drive a DNS backend other than
198
+ DevNomads.
199
+
200
+ ## Errors
201
+
202
+ The library never prints or exits; operations return values and failures
203
+ raise `devnomads.api.DevNomadsError` subclasses, so the caller decides how
204
+ to present them.
205
+
206
+ ```python
207
+ from devnomads.api import DevNomadsError, AuthError
208
+
209
+ try:
210
+ dns.set_txt(name, value)
211
+ except AuthError:
212
+ ... # 401/403: missing, invalid, or unauthorized key
213
+ except DevNomadsError as exc:
214
+ ... # ApiError, ConfigError, DnsError, AcmeError, ...
215
+ ```
216
+
217
+ `ApiError` carries `status` and `detail`. Note that `Client.request`
218
+ returns `None` (rather than raising) for a 404, so a missing resource
219
+ reads as absence.
220
+
221
+ ## Logging
222
+
223
+ Progress and warnings go through the standard `logging` module under the
224
+ `devnomads` logger (`devnomads.api`, `devnomads.acme`). It is silent
225
+ unless you configure logging:
226
+
227
+ ```python
228
+ import logging
229
+
230
+ logging.getLogger("devnomads").setLevel(logging.INFO)
231
+ ```
232
+
233
+ ## Development
234
+
235
+ ```bash
236
+ uv sync --dev --extra acme
237
+ uv run pytest
238
+ uv run black . && uv run flake8 src tests
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT
@@ -0,0 +1,21 @@
1
+ devnomads/__init__.py,sha256=Dl4cUcfwLuA7wk_fPMud2JJoTFUjjEQDnKdcwYrgKyU,577
2
+ devnomads/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ devnomads/acme/__init__.py,sha256=NH5OKG41WeO--4CBnO5woTbsdz0R0Fma4CqLggeUN98,1629
4
+ devnomads/acme/challenge_server.py,sha256=Enfu3ROfMJ5lnUOqbEVIJnvzOWl1rA89ZOs0JQigbxw,3637
5
+ devnomads/acme/client.py,sha256=BhymHVHcdCS-euT2cgQ2180v8vHwlEiXIYTFTQT565M,14064
6
+ devnomads/acme/dns01.py,sha256=q_KBd_baAejjuAJ4a_sKd4gA3e5OvUspWwTzTgTu70I,1398
7
+ devnomads/acme/errors.py,sha256=FLqf3Ad2FuhgF1F8uAGSuSZlbz2jbXqsfCPsQcP7sMc,184
8
+ devnomads/acme/http01.py,sha256=3XjgPTxNbV-0tInpJVJkvKIlX-W4eV5KnkITC6E2w0I,3028
9
+ devnomads/acme/keys.py,sha256=E6dABXpDo5NP_x1R6pdy1kNiS11J1DN6Ffgl7gU7YFg,3776
10
+ devnomads/acme/verify.py,sha256=McjEQ0iK-KCs7t0TbMswkbtUrZlnTuf1BGRh4nUS7Bg,3374
11
+ devnomads/api/__init__.py,sha256=431itPZsjEzz7sYhVKsAoa8KRiLHF8CU_9ARvv7Du9c,554
12
+ devnomads/api/client.py,sha256=Vk6vo-BnTLgpjHvWsal8KaKu63fY77O5YKyrc42uNmQ,5986
13
+ devnomads/api/credentials.py,sha256=yxPhiAZgvk5ddh7mojQRimYRFFR8XR6FjqfCbZiOsa0,5102
14
+ devnomads/api/errors.py,sha256=2Atwqmv9ZJzKS0ht0WczrKC4Gha93YDISrtruWcEQ1Y,874
15
+ devnomads/dns/__init__.py,sha256=xNtQg1sgrtXfPwyAbDsxAYUaIxmKUQNzcvVb7ry8Zt8,595
16
+ devnomads/dns/errors.py,sha256=8vgaBpbqEwQbJH_GT49rYLPJsfOQ6MjfHlcTDMPiZEU,288
17
+ devnomads/dns/names.py,sha256=4G7DvDmyZcw_v4KfTea7aoq4eD4ViXNbGM2PeeONO0A,2273
18
+ devnomads/dns/zones.py,sha256=ReSQaAMnWyzOwCry6k5QsllgpEz45dEswATOvNyZAg8,7061
19
+ devnomads-0.1.0.dist-info/METADATA,sha256=tWEFjSBM7JtELN57wYnBnkDKrQlxQ9I8-aP9nJND7Qs,7329
20
+ devnomads-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
21
+ devnomads-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any