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/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)
@@ -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
+ ]
@@ -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
@@ -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
+ ]
@@ -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."""