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/acme/keys.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Key generation, serialization, and CSR building.
|
|
2
|
+
|
|
3
|
+
Account and certificate keys are RSA or EC; the ACME account key is
|
|
4
|
+
persisted (0600) so the same account is reused across runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.backends import default_backend
|
|
13
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
15
|
+
from cryptography.x509.oid import NameOID
|
|
16
|
+
from josepy import jwk
|
|
17
|
+
|
|
18
|
+
from .errors import AcmeError
|
|
19
|
+
|
|
20
|
+
PrivateKey = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_key(algorithm: str) -> PrivateKey:
|
|
24
|
+
"""Generate a private key for ``algorithm`` (rsa2048/rsa4096/ec256/ec384)."""
|
|
25
|
+
|
|
26
|
+
algo = algorithm.lower()
|
|
27
|
+
if algo.startswith("rsa"):
|
|
28
|
+
size = int(algo.replace("rsa", ""))
|
|
29
|
+
return rsa.generate_private_key(
|
|
30
|
+
public_exponent=65537, key_size=size, backend=default_backend()
|
|
31
|
+
)
|
|
32
|
+
if algo in ("ec256", "ecdsa256"):
|
|
33
|
+
return ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
|
|
34
|
+
if algo in ("ec384", "ecdsa384"):
|
|
35
|
+
return ec.generate_private_key(ec.SECP384R1(), backend=default_backend())
|
|
36
|
+
raise AcmeError(f"unsupported key algorithm: {algorithm}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def serialize_key(key: PrivateKey) -> bytes:
|
|
40
|
+
"""Serialize ``key`` to unencrypted PEM."""
|
|
41
|
+
|
|
42
|
+
return key.private_bytes(
|
|
43
|
+
encoding=serialization.Encoding.PEM,
|
|
44
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
45
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _secure_write(path: str, data: bytes) -> None:
|
|
50
|
+
"""Write ``data`` to ``path`` with owner-only (0600) permissions."""
|
|
51
|
+
|
|
52
|
+
directory = os.path.dirname(path)
|
|
53
|
+
if directory:
|
|
54
|
+
os.makedirs(directory, exist_ok=True)
|
|
55
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
56
|
+
with os.fdopen(fd, "wb") as fh:
|
|
57
|
+
fh.write(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_or_create_account_key(path: str, algorithm: str):
|
|
61
|
+
"""Load the ACME account key from ``path`` or create and persist one.
|
|
62
|
+
|
|
63
|
+
Returns a josepy JWK suitable for ACME account operations.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
private_key: PrivateKey | None = None
|
|
67
|
+
if os.path.isfile(path):
|
|
68
|
+
try:
|
|
69
|
+
with open(path, "rb") as fh:
|
|
70
|
+
loaded = serialization.load_pem_private_key(
|
|
71
|
+
fh.read(), password=None, backend=default_backend()
|
|
72
|
+
)
|
|
73
|
+
# Only keep the key if it is a type we support; anything else
|
|
74
|
+
# falls through and is regenerated.
|
|
75
|
+
if isinstance(loaded, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
|
|
76
|
+
private_key = loaded
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
private_key = None # fall through and regenerate
|
|
79
|
+
|
|
80
|
+
if private_key is None:
|
|
81
|
+
private_key = generate_key(algorithm)
|
|
82
|
+
_secure_write(path, serialize_key(private_key))
|
|
83
|
+
|
|
84
|
+
if isinstance(private_key, rsa.RSAPrivateKey):
|
|
85
|
+
return jwk.JWKRSA(key=private_key)
|
|
86
|
+
if isinstance(private_key, ec.EllipticCurvePrivateKey):
|
|
87
|
+
return jwk.JWKEC(key=private_key)
|
|
88
|
+
raise AcmeError("unsupported account key type")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_csr(private_key: PrivateKey, identifiers: list[str]) -> bytes:
|
|
92
|
+
"""Build a PEM CSR: ``identifiers[0]`` is the CN, all are SANs."""
|
|
93
|
+
|
|
94
|
+
if not identifiers:
|
|
95
|
+
raise AcmeError("no identifiers for CSR")
|
|
96
|
+
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, identifiers[0])])
|
|
97
|
+
san = x509.SubjectAlternativeName([x509.DNSName(i) for i in identifiers])
|
|
98
|
+
csr = (
|
|
99
|
+
x509.CertificateSigningRequestBuilder()
|
|
100
|
+
.subject_name(subject)
|
|
101
|
+
.add_extension(san, critical=False)
|
|
102
|
+
.sign(private_key, hashes.SHA256(), default_backend())
|
|
103
|
+
)
|
|
104
|
+
return csr.public_bytes(serialization.Encoding.PEM)
|
devnomads/acme/verify.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Authoritative-nameserver verification of DNS-01 TXT records.
|
|
2
|
+
|
|
3
|
+
Before answering a dns-01 challenge we confirm the TXT record is visible at
|
|
4
|
+
the domain's authoritative nameservers, so we don't hand the CA a name that
|
|
5
|
+
has not propagated yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from time import sleep, time
|
|
12
|
+
|
|
13
|
+
import dns.exception
|
|
14
|
+
import dns.resolver
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("devnomads.acme")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_resolver(nameservers: list[str] | None) -> dns.resolver.Resolver:
|
|
20
|
+
resolver = dns.resolver.Resolver()
|
|
21
|
+
if nameservers:
|
|
22
|
+
resolver.nameservers = nameservers
|
|
23
|
+
return resolver
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_ns_hostname(hostname: str, resolver: dns.resolver.Resolver) -> list[str]:
|
|
27
|
+
"""Resolve a nameserver hostname to IPs, AAAA first then A."""
|
|
28
|
+
|
|
29
|
+
ips: list[str] = []
|
|
30
|
+
for rdtype in ("AAAA", "A"):
|
|
31
|
+
try:
|
|
32
|
+
ips.extend(str(ip) for ip in resolver.resolve(hostname, rdtype))
|
|
33
|
+
except dns.exception.DNSException:
|
|
34
|
+
continue
|
|
35
|
+
return ips
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def authoritative_nameservers(
|
|
39
|
+
domain: str, nameservers: list[str] | None = None
|
|
40
|
+
) -> list[str]:
|
|
41
|
+
"""Return the authoritative NS hostnames for ``domain`` (walking upward)."""
|
|
42
|
+
|
|
43
|
+
resolver = _make_resolver(nameservers)
|
|
44
|
+
parts = domain.split(".")
|
|
45
|
+
for i in range(len(parts)):
|
|
46
|
+
check = ".".join(parts[i:])
|
|
47
|
+
if not check:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
answers = resolver.resolve(check, "NS")
|
|
51
|
+
return [str(rdata.target).rstrip(".") for rdata in answers]
|
|
52
|
+
except dns.exception.DNSException:
|
|
53
|
+
continue
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def verify_txt_record(
|
|
58
|
+
fqdn: str,
|
|
59
|
+
expected_value: str,
|
|
60
|
+
*,
|
|
61
|
+
timeout: int = 120,
|
|
62
|
+
interval: int = 5,
|
|
63
|
+
nameservers: list[str] | None = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Poll authoritative nameservers until ``_acme-challenge.<fqdn>`` carries
|
|
66
|
+
``expected_value``. Returns False on timeout."""
|
|
67
|
+
|
|
68
|
+
challenge = f"_acme-challenge.{fqdn}"
|
|
69
|
+
base_resolver = _make_resolver(nameservers)
|
|
70
|
+
auth_ns: list[str | None] = list(authoritative_nameservers(fqdn, nameservers))
|
|
71
|
+
if not auth_ns:
|
|
72
|
+
log.warning("no authoritative NS for %s, using default resolver", fqdn)
|
|
73
|
+
auth_ns = [None]
|
|
74
|
+
else:
|
|
75
|
+
log.info("polling authoritative nameservers for %s: %s", challenge, auth_ns)
|
|
76
|
+
|
|
77
|
+
start = time()
|
|
78
|
+
while time() - start < timeout:
|
|
79
|
+
if _all_nameservers_have(challenge, expected_value, auth_ns, base_resolver):
|
|
80
|
+
log.info("all %d nameserver(s) verified %s", len(auth_ns), challenge)
|
|
81
|
+
return True
|
|
82
|
+
sleep(interval)
|
|
83
|
+
|
|
84
|
+
log.warning("timeout waiting for TXT record %s", challenge)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _all_nameservers_have(
|
|
89
|
+
challenge: str,
|
|
90
|
+
expected_value: str,
|
|
91
|
+
auth_ns: list[str | None],
|
|
92
|
+
base_resolver: dns.resolver.Resolver,
|
|
93
|
+
) -> bool:
|
|
94
|
+
for ns in auth_ns:
|
|
95
|
+
resolver = dns.resolver.Resolver()
|
|
96
|
+
if ns:
|
|
97
|
+
ns_ips = _resolve_ns_hostname(ns, base_resolver)
|
|
98
|
+
if not ns_ips:
|
|
99
|
+
log.debug("failed to resolve nameserver %s", ns)
|
|
100
|
+
return False
|
|
101
|
+
resolver.nameservers = ns_ips
|
|
102
|
+
try:
|
|
103
|
+
answers = resolver.resolve(challenge, "TXT")
|
|
104
|
+
except dns.exception.DNSException:
|
|
105
|
+
return False
|
|
106
|
+
values = [str(rdata).strip('"') for rdata in answers]
|
|
107
|
+
if expected_value not in values:
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""DevNomads API transport and credential resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .credentials import (
|
|
7
|
+
DEFAULT_API_URL,
|
|
8
|
+
DEFAULT_PROFILE,
|
|
9
|
+
Credentials,
|
|
10
|
+
config_dir,
|
|
11
|
+
credentials_path,
|
|
12
|
+
resolve,
|
|
13
|
+
)
|
|
14
|
+
from .errors import ApiError, AuthError, ConfigError, DevNomadsError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Client",
|
|
18
|
+
"Credentials",
|
|
19
|
+
"resolve",
|
|
20
|
+
"config_dir",
|
|
21
|
+
"credentials_path",
|
|
22
|
+
"DEFAULT_API_URL",
|
|
23
|
+
"DEFAULT_PROFILE",
|
|
24
|
+
"DevNomadsError",
|
|
25
|
+
"ConfigError",
|
|
26
|
+
"ApiError",
|
|
27
|
+
"AuthError",
|
|
28
|
+
]
|
devnomads/api/client.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""HTTP transport for the DevNomads API.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over ``httpx`` that owns authentication, retries, and error
|
|
4
|
+
handling so higher layers never touch HTTP directly. It is presentation
|
|
5
|
+
free: failures raise :class:`~devnomads.api.errors.ApiError`, progress is
|
|
6
|
+
reported through the standard :mod:`logging` module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .credentials import Credentials, resolve
|
|
18
|
+
from .errors import ApiError, AuthError
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("devnomads.api")
|
|
21
|
+
|
|
22
|
+
REQUEST_TIMEOUT = 30.0
|
|
23
|
+
MAX_ATTEMPTS = 4
|
|
24
|
+
MAX_RETRY_DELAY = 30.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Client:
|
|
28
|
+
"""Authenticated client for the DevNomads API.
|
|
29
|
+
|
|
30
|
+
Construct from explicit credentials, or use :meth:`from_environment`
|
|
31
|
+
to resolve them from the environment and credentials file.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_url: str,
|
|
37
|
+
api_key: str,
|
|
38
|
+
*,
|
|
39
|
+
user_agent: str | None = None,
|
|
40
|
+
timeout: float = REQUEST_TIMEOUT,
|
|
41
|
+
) -> None:
|
|
42
|
+
from .. import __version__
|
|
43
|
+
|
|
44
|
+
self._http = httpx.Client(
|
|
45
|
+
base_url=api_url,
|
|
46
|
+
headers={
|
|
47
|
+
"Authorization": f"Bearer {api_key}",
|
|
48
|
+
"Accept": "application/json",
|
|
49
|
+
"User-Agent": user_agent or f"devnomads/{__version__}",
|
|
50
|
+
},
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_credentials(cls, creds: Credentials, **kwargs: Any) -> "Client":
|
|
56
|
+
return cls(creds.api_url, creds.api_key, **kwargs)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_environment(
|
|
60
|
+
cls,
|
|
61
|
+
*,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
api_url: str | None = None,
|
|
64
|
+
profile: str | None = None,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> "Client":
|
|
67
|
+
"""Resolve credentials (env + credentials file) and build a client."""
|
|
68
|
+
|
|
69
|
+
creds = resolve(api_key=api_key, api_url=api_url, profile=profile)
|
|
70
|
+
return cls.from_credentials(creds, **kwargs)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._http.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> "Client":
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(self, *exc: object) -> None:
|
|
79
|
+
self.close()
|
|
80
|
+
|
|
81
|
+
def request(
|
|
82
|
+
self,
|
|
83
|
+
method: str,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
params: dict[str, Any] | None = None,
|
|
87
|
+
json_body: Any = None,
|
|
88
|
+
) -> Any:
|
|
89
|
+
"""Send a request and return parsed JSON.
|
|
90
|
+
|
|
91
|
+
Retries 429 and 5xx responses with backoff (honouring
|
|
92
|
+
``Retry-After``). Returns ``None`` on 404 and on empty/204 bodies,
|
|
93
|
+
so callers can treat a missing resource as absence rather than an
|
|
94
|
+
error. Raises :class:`AuthError` on 401/403 and :class:`ApiError`
|
|
95
|
+
on any other 4xx.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
response = self._send(method, path, params, json_body)
|
|
99
|
+
for attempt in range(1, MAX_ATTEMPTS):
|
|
100
|
+
if response.status_code != 429 and response.status_code < 500:
|
|
101
|
+
break
|
|
102
|
+
delay = _retry_delay(response, attempt)
|
|
103
|
+
log.warning(
|
|
104
|
+
"got %s from %s, retrying in %.0fs", response.status_code, path, delay
|
|
105
|
+
)
|
|
106
|
+
time.sleep(delay)
|
|
107
|
+
response = self._send(method, path, params, json_body)
|
|
108
|
+
|
|
109
|
+
status = response.status_code
|
|
110
|
+
if status == 404:
|
|
111
|
+
return None
|
|
112
|
+
if status in (401, 403):
|
|
113
|
+
raise AuthError(
|
|
114
|
+
_error_message(response),
|
|
115
|
+
status=status,
|
|
116
|
+
detail=_error_detail(response),
|
|
117
|
+
)
|
|
118
|
+
if status >= 400:
|
|
119
|
+
raise ApiError(
|
|
120
|
+
_error_message(response),
|
|
121
|
+
status=status,
|
|
122
|
+
detail=_error_detail(response),
|
|
123
|
+
)
|
|
124
|
+
if status == 204 or not response.content:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
return _unwrap(response.json())
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise ApiError(f"API returned invalid JSON: {exc}", status=status) from exc
|
|
130
|
+
|
|
131
|
+
def _send(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
path: str,
|
|
135
|
+
params: dict[str, Any] | None,
|
|
136
|
+
json_body: Any,
|
|
137
|
+
) -> httpx.Response:
|
|
138
|
+
log.debug("> %s %s", method, path)
|
|
139
|
+
try:
|
|
140
|
+
response = self._http.request(method, path, params=params, json=json_body)
|
|
141
|
+
except httpx.HTTPError as exc:
|
|
142
|
+
raise ApiError(f"request to {path} failed: {exc}", status=0) from exc
|
|
143
|
+
log.debug("< %s %s", response.status_code, response.text[:500])
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _unwrap(body: Any) -> Any:
|
|
148
|
+
"""Strip the Laravel resource envelope (``{"data": ...}`` with optional
|
|
149
|
+
``links``/``meta``) so callers see the actual payload."""
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
isinstance(body, dict)
|
|
153
|
+
and "data" in body
|
|
154
|
+
and set(body) <= {"data", "links", "meta"}
|
|
155
|
+
):
|
|
156
|
+
return body["data"]
|
|
157
|
+
return body
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
161
|
+
try:
|
|
162
|
+
delay = float(response.headers.get("Retry-After", ""))
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
delay = float(2 ** (attempt - 1))
|
|
165
|
+
return min(delay, MAX_RETRY_DELAY)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
169
|
+
try:
|
|
170
|
+
body = response.json()
|
|
171
|
+
except ValueError:
|
|
172
|
+
return ""
|
|
173
|
+
if not isinstance(body, dict):
|
|
174
|
+
return ""
|
|
175
|
+
detail = str(body.get("message") or body.get("error") or "")
|
|
176
|
+
errors = body.get("errors")
|
|
177
|
+
if isinstance(errors, dict):
|
|
178
|
+
flat = "; ".join(
|
|
179
|
+
(
|
|
180
|
+
f"{field}: {' '.join(map(str, messages))}"
|
|
181
|
+
if isinstance(messages, list)
|
|
182
|
+
else f"{field}: {messages}"
|
|
183
|
+
)
|
|
184
|
+
for field, messages in errors.items()
|
|
185
|
+
)
|
|
186
|
+
detail = f"{detail} ({flat})" if detail else flat
|
|
187
|
+
return detail
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _error_message(response: httpx.Response) -> str:
|
|
191
|
+
detail = _error_detail(response)
|
|
192
|
+
message = f"API error {response.status_code}"
|
|
193
|
+
if detail:
|
|
194
|
+
message += f": {detail}"
|
|
195
|
+
if response.status_code in (401, 403):
|
|
196
|
+
message += " - check your API key and that it may edit this resource"
|
|
197
|
+
return message
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Credential resolution shared by every DevNomads tool.
|
|
2
|
+
|
|
3
|
+
The scheme matches what ``dnctl configure`` writes, so a host already set
|
|
4
|
+
up for dnctl works with no extra configuration. Resolution order for the
|
|
5
|
+
API key:
|
|
6
|
+
|
|
7
|
+
1. an explicit value passed by the caller,
|
|
8
|
+
2. ``DN_API_KEY`` (``DEVNOMADS_API_KEY`` is accepted as an alias),
|
|
9
|
+
3. the ``api_key`` of the selected profile in an INI credentials file.
|
|
10
|
+
|
|
11
|
+
The credentials file is found at ``DN_CREDENTIALS_FILE`` if set, otherwise
|
|
12
|
+
the first that exists of ``/etc/devnomads/credentials`` and
|
|
13
|
+
``<config dir>/credentials`` (the dnctl config dir). The profile is the
|
|
14
|
+
caller's argument, else ``DN_PROFILE``, else ``default``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import configparser
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import stat
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .errors import ConfigError
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("devnomads.api")
|
|
29
|
+
|
|
30
|
+
DEFAULT_API_URL = "https://api.devnomads.nl"
|
|
31
|
+
DEFAULT_PROFILE = "default"
|
|
32
|
+
|
|
33
|
+
ENV_API_KEYS = ("DN_API_KEY", "DEVNOMADS_API_KEY")
|
|
34
|
+
ENV_PROFILES = ("DN_PROFILE", "DEVNOMADS_PROFILE")
|
|
35
|
+
ENV_CREDENTIALS_FILES = ("DN_CREDENTIALS_FILE", "DEVNOMADS_CREDENTIALS_FILE")
|
|
36
|
+
ENV_API_URLS = ("DN_API_URL", "DEVNOMADS_API_URL")
|
|
37
|
+
ENV_CONFIG_DIR = "DN_CONFIG_DIR"
|
|
38
|
+
|
|
39
|
+
SYSTEM_CREDENTIAL_PATHS = ("/etc/devnomads/credentials",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Credentials:
|
|
44
|
+
"""A resolved API key and the base URL to use it against."""
|
|
45
|
+
|
|
46
|
+
api_key: str
|
|
47
|
+
api_url: str = DEFAULT_API_URL
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _first_env(names: tuple[str, ...]) -> str | None:
|
|
51
|
+
for name in names:
|
|
52
|
+
value = os.environ.get(name)
|
|
53
|
+
if value:
|
|
54
|
+
return value
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def config_dir() -> Path:
|
|
59
|
+
"""The dnctl config directory, matching dnctl's own resolution.
|
|
60
|
+
|
|
61
|
+
``DN_CONFIG_DIR`` wins, then ``$XDG_CONFIG_HOME/dnctl``, then
|
|
62
|
+
``~/.config/dnctl``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
override = os.environ.get(ENV_CONFIG_DIR)
|
|
66
|
+
if override:
|
|
67
|
+
return Path(override)
|
|
68
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
69
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
70
|
+
return base / "dnctl"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def credentials_path() -> Path:
|
|
74
|
+
"""Default per-user credentials file (``<config dir>/credentials``)."""
|
|
75
|
+
|
|
76
|
+
return config_dir() / "credentials"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _candidate_files() -> list[Path]:
|
|
80
|
+
candidates = [Path(p) for p in SYSTEM_CREDENTIAL_PATHS]
|
|
81
|
+
candidates.append(credentials_path())
|
|
82
|
+
return candidates
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve(
|
|
86
|
+
*,
|
|
87
|
+
api_key: str | None = None,
|
|
88
|
+
api_url: str | None = None,
|
|
89
|
+
profile: str | None = None,
|
|
90
|
+
) -> Credentials:
|
|
91
|
+
"""Resolve credentials from explicit args, environment, and files.
|
|
92
|
+
|
|
93
|
+
Raises :class:`ConfigError` if no API key can be found.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
profile = profile or _first_env(ENV_PROFILES) or DEFAULT_PROFILE
|
|
97
|
+
url = api_url or _first_env(ENV_API_URLS)
|
|
98
|
+
|
|
99
|
+
# An explicit or environment API key short-circuits file lookup, but we
|
|
100
|
+
# still let the file supply an api_url if one was not given.
|
|
101
|
+
direct_key = api_key or _first_env(ENV_API_KEYS)
|
|
102
|
+
|
|
103
|
+
override = _first_env(ENV_CREDENTIALS_FILES)
|
|
104
|
+
if override:
|
|
105
|
+
path = Path(override).expanduser()
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
raise ConfigError(f"credentials file {path} does not exist")
|
|
108
|
+
candidates = [path]
|
|
109
|
+
else:
|
|
110
|
+
candidates = _candidate_files()
|
|
111
|
+
|
|
112
|
+
section = _find_profile(candidates, profile, required=bool(override))
|
|
113
|
+
|
|
114
|
+
resolved_key = direct_key or (section.get("api_key") if section else None)
|
|
115
|
+
resolved_url = (
|
|
116
|
+
url or (section.get("api_url") if section else None) or DEFAULT_API_URL
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not resolved_key:
|
|
120
|
+
searched = ", ".join(str(p) for p in candidates)
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
"no API key found - set DN_API_KEY or run 'dnctl configure' to "
|
|
123
|
+
f"create a credentials file (searched: {searched})"
|
|
124
|
+
)
|
|
125
|
+
return Credentials(api_key=resolved_key.strip(), api_url=resolved_url)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _find_profile(
|
|
129
|
+
candidates: list[Path],
|
|
130
|
+
profile: str,
|
|
131
|
+
*,
|
|
132
|
+
required: bool,
|
|
133
|
+
) -> configparser.SectionProxy | None:
|
|
134
|
+
"""Return the [profile] section from the first readable file that has it.
|
|
135
|
+
|
|
136
|
+
When ``required`` (an explicit credentials file was given) a missing
|
|
137
|
+
section is an error; otherwise we fall through so env/explicit keys can
|
|
138
|
+
still satisfy the caller.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
last_path: Path | None = None
|
|
142
|
+
for path in candidates:
|
|
143
|
+
path = path.expanduser()
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
continue
|
|
146
|
+
last_path = path
|
|
147
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
148
|
+
if mode & 0o077:
|
|
149
|
+
log.warning(
|
|
150
|
+
"%s is readable by others (mode %03o); run: chmod 600 %s",
|
|
151
|
+
path,
|
|
152
|
+
mode,
|
|
153
|
+
path,
|
|
154
|
+
)
|
|
155
|
+
parser = configparser.ConfigParser()
|
|
156
|
+
try:
|
|
157
|
+
parser.read(path)
|
|
158
|
+
except configparser.Error as exc:
|
|
159
|
+
raise ConfigError(f"cannot parse credentials file {path}: {exc}") from exc
|
|
160
|
+
if parser.has_section(profile):
|
|
161
|
+
return parser[profile]
|
|
162
|
+
if required and last_path is not None:
|
|
163
|
+
raise ConfigError(f"credentials file {last_path} has no [{profile}] section")
|
|
164
|
+
return None
|
devnomads/api/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exception hierarchy for the DevNomads client.
|
|
2
|
+
|
|
3
|
+
The library never prints or exits; it raises these so callers (a CLI, a
|
|
4
|
+
hook, a daemon) decide how to present failures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DevNomadsError(Exception):
|
|
11
|
+
"""Base class for every error raised by this library."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(DevNomadsError):
|
|
15
|
+
"""Credentials or configuration could not be resolved."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(DevNomadsError):
|
|
19
|
+
"""The API returned an error response.
|
|
20
|
+
|
|
21
|
+
``status`` is the HTTP status code; ``detail`` is the human-readable
|
|
22
|
+
message extracted from the response body, if any.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, status: int, detail: str = "") -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.detail = detail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthError(ApiError):
|
|
32
|
+
"""The API rejected the credentials (401/403)."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""DNS zone and record helpers for the DevNomads API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import DnsError, ZoneNotFound
|
|
6
|
+
from .names import (
|
|
7
|
+
CHALLENGE_PREFIX,
|
|
8
|
+
absolute,
|
|
9
|
+
challenge_name,
|
|
10
|
+
fqdn,
|
|
11
|
+
quote_txt,
|
|
12
|
+
unquote_txt,
|
|
13
|
+
zone_id,
|
|
14
|
+
)
|
|
15
|
+
from .zones import DEFAULT_TXT_TTL, Dns, TxtResult, current_txt_values
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Dns",
|
|
19
|
+
"TxtResult",
|
|
20
|
+
"DEFAULT_TXT_TTL",
|
|
21
|
+
"current_txt_values",
|
|
22
|
+
"DnsError",
|
|
23
|
+
"ZoneNotFound",
|
|
24
|
+
"CHALLENGE_PREFIX",
|
|
25
|
+
"zone_id",
|
|
26
|
+
"absolute",
|
|
27
|
+
"fqdn",
|
|
28
|
+
"challenge_name",
|
|
29
|
+
"quote_txt",
|
|
30
|
+
"unquote_txt",
|
|
31
|
+
]
|
devnomads/dns/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""DNS-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..api.errors import DevNomadsError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DnsError(DevNomadsError):
|
|
9
|
+
"""A DNS operation could not be completed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ZoneNotFound(DnsError):
|
|
13
|
+
"""No accessible zone matches the requested record name."""
|