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/__init__.py +17 -0
- devnomads/acme/__init__.py +49 -0
- devnomads/acme/challenge_server.py +103 -0
- devnomads/acme/client.py +364 -0
- devnomads/acme/dns01.py +42 -0
- devnomads/acme/errors.py +9 -0
- devnomads/acme/http01.py +91 -0
- devnomads/acme/keys.py +104 -0
- devnomads/acme/verify.py +109 -0
- devnomads/api/__init__.py +28 -0
- devnomads/api/client.py +197 -0
- devnomads/api/credentials.py +164 -0
- devnomads/api/errors.py +32 -0
- devnomads/dns/__init__.py +31 -0
- devnomads/dns/errors.py +13 -0
- devnomads/dns/names.py +75 -0
- devnomads/dns/zones.py +211 -0
- devnomads/py.typed +0 -0
- devnomads-0.1.0.dist-info/METADATA +243 -0
- devnomads-0.1.0.dist-info/RECORD +21 -0
- devnomads-0.1.0.dist-info/WHEEL +4 -0
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,,
|