bindy/dns_errors.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! DNS operation and HTTP API error types for Bindy.
5//!
6//! This module provides specialized error types for:
7//! - Bindcar HTTP API operations (zone and record management)
8//! - Hickory DNS client operations (dynamic updates, zone transfers)
9//! - TSIG authentication failures
10//! - Network connectivity issues with BIND9 instances
11//!
12//! These errors provide structured error handling for DNS operations,
13//! enabling better error reporting in status conditions and metrics.
14
15use thiserror::Error;
16
17/// Errors that can occur during DNS zone operations via Bindcar HTTP API.
18///
19/// These errors represent failures when interacting with the Bindcar HTTP API
20/// on BIND9 instances for zone management operations.
21#[derive(Error, Debug, Clone)]
22pub enum ZoneError {
23 /// Zone not found (HTTP 404 from bindcar API)
24 ///
25 /// Returned when attempting to operate on a zone that doesn't exist on the BIND9 server.
26 /// This could happen if the zone was deleted externally or was never created.
27 #[error("Zone '{zone}' not found on endpoint {endpoint} (HTTP 404)")]
28 ZoneNotFound {
29 /// The zone name that was not found
30 zone: String,
31 /// The BIND9 endpoint (IP:port) that returned the error
32 endpoint: String,
33 },
34
35 /// Failed to create a new zone (generic creation error)
36 ///
37 /// Returned when zone creation fails for reasons other than the zone already existing.
38 /// This could be due to invalid zone configuration, filesystem errors, or BIND9 internal errors.
39 #[error("Failed to create zone '{zone}' on endpoint {endpoint}: {reason}")]
40 ZoneCreationFailed {
41 /// The zone name that failed to create
42 zone: String,
43 /// The BIND9 endpoint (IP:port) where creation failed
44 endpoint: String,
45 /// Specific reason for the failure
46 reason: String,
47 },
48
49 /// Zone already exists when attempting to create a new zone
50 ///
51 /// Returned when attempting to create a zone that already exists on the BIND9 server.
52 /// This is typically a non-fatal error that can be safely ignored.
53 #[error("Zone '{zone}' already exists on endpoint {endpoint}")]
54 ZoneAlreadyExists {
55 /// The zone name that already exists
56 zone: String,
57 /// The BIND9 endpoint (IP:port) where the zone exists
58 endpoint: String,
59 },
60
61 /// Failed to delete a zone
62 ///
63 /// Returned when zone deletion fails. This could be due to the zone being in use,
64 /// permissions issues, or BIND9 being unable to clean up zone files.
65 #[error("Failed to delete zone '{zone}' on endpoint {endpoint}: {reason}")]
66 ZoneDeletionFailed {
67 /// The zone name that failed to delete
68 zone: String,
69 /// The BIND9 endpoint (IP:port) where deletion failed
70 endpoint: String,
71 /// Specific reason for the failure
72 reason: String,
73 },
74
75 /// Invalid zone configuration
76 ///
77 /// Returned when the zone configuration is malformed or contains invalid parameters.
78 /// This includes invalid SOA records, bad nameserver IPs, or invalid zone type.
79 #[error("Invalid zone configuration for '{zone}': {reason}")]
80 InvalidZoneConfiguration {
81 /// The zone name with invalid configuration
82 zone: String,
83 /// Explanation of what is invalid
84 reason: String,
85 },
86}
87
88/// Errors that can occur during DNS record operations via Hickory DNS client.
89///
90/// These errors represent failures when performing dynamic DNS updates (nsupdate)
91/// or querying DNS records using the Hickory DNS client library.
92#[derive(Error, Debug, Clone)]
93pub enum RecordError {
94 /// DNS record not found when querying the primary server (NXDOMAIN or no records)
95 ///
96 /// Returned when querying for a DNS record that doesn't exist in the zone.
97 /// This is typically returned as an NXDOMAIN response or an empty answer section.
98 #[error("DNS record '{name}' in zone '{zone}' not found on server {server} (no answer)")]
99 RecordNotFound {
100 /// The record name (e.g., "www", "@")
101 name: String,
102 /// The zone name (e.g., "example.com")
103 zone: String,
104 /// The DNS server that was queried
105 server: String,
106 },
107
108 /// Failed to add or update a DNS record via dynamic update
109 ///
110 /// Returned when a dynamic DNS update (nsupdate) fails. This could be due to
111 /// TSIG authentication failure, zone not allowing updates, or invalid record data.
112 #[error("Failed to update record '{name}.{zone}' on server {server}: {reason}")]
113 RecordUpdateFailed {
114 /// The record name being updated
115 name: String,
116 /// The zone containing the record
117 zone: String,
118 /// The DNS server where the update failed
119 server: String,
120 /// Specific reason for the failure
121 reason: String,
122 },
123
124 /// Failed to delete a DNS record via dynamic update
125 ///
126 /// Returned when attempting to delete a DNS record via nsupdate fails.
127 #[error("Failed to delete record '{name}.{zone}' on server {server}: {reason}")]
128 RecordDeletionFailed {
129 /// The record name being deleted
130 name: String,
131 /// The zone containing the record
132 zone: String,
133 /// The DNS server where deletion failed
134 server: String,
135 /// Specific reason for the failure
136 reason: String,
137 },
138
139 /// Invalid record data (malformed IP, invalid FQDN, etc.)
140 ///
141 /// Returned when record data fails validation before attempting to create/update.
142 /// This includes invalid IP addresses, malformed FQDNs, or out-of-range TTL values.
143 #[error("Invalid record data for '{name}.{zone}': {reason}")]
144 InvalidRecordData {
145 /// The record name with invalid data
146 name: String,
147 /// The zone containing the record
148 zone: String,
149 /// Explanation of what is invalid
150 reason: String,
151 },
152}
153
154/// Errors related to BIND9 instance availability and connectivity.
155///
156/// These errors occur when the Bindcar HTTP API is unreachable or returns
157/// gateway errors, indicating the BIND9 instance or bindcar service is unavailable.
158#[derive(Error, Debug, Clone)]
159pub enum InstanceError {
160 /// BIND9 instance unavailable (HTTP 502 Bad Gateway or 503 Service Unavailable)
161 ///
162 /// Returned when the bindcar HTTP API returns a gateway error, indicating the
163 /// underlying BIND9 instance is not responding or the bindcar process cannot
164 /// communicate with BIND9.
165 #[error("BIND9 instance at {endpoint} unavailable (HTTP {status_code})")]
166 Bind9InstanceUnavailable {
167 /// The endpoint (IP:port) that is unavailable
168 endpoint: String,
169 /// HTTP status code (502 or 503)
170 status_code: u16,
171 },
172
173 /// HTTP connection failed (network unreachable, connection refused, timeout)
174 ///
175 /// Returned when the HTTP client cannot establish a connection to the bindcar API.
176 /// This indicates network issues, the pod being down, or the service not listening.
177 #[error("HTTP connection to {endpoint} failed: {reason}")]
178 HttpConnectionFailed {
179 /// The endpoint (IP:port) that couldn't be reached
180 endpoint: String,
181 /// Reason for the connection failure
182 reason: String,
183 },
184
185 /// HTTP request timeout
186 ///
187 /// Returned when an HTTP request to bindcar exceeds the configured timeout.
188 /// This typically indicates a heavily loaded or unresponsive BIND9 instance.
189 #[error("HTTP request to {endpoint} timed out after {timeout_ms}ms")]
190 HttpRequestTimeout {
191 /// The endpoint (IP:port) that timed out
192 endpoint: String,
193 /// Timeout duration in milliseconds
194 timeout_ms: u64,
195 },
196
197 /// Unexpected HTTP response from bindcar API
198 ///
199 /// Returned when bindcar returns an unexpected HTTP status code that doesn't
200 /// map to a known error condition.
201 #[error("Unexpected HTTP response from {endpoint}: {status_code} {reason}")]
202 UnexpectedHttpResponse {
203 /// The endpoint that returned the unexpected response
204 endpoint: String,
205 /// HTTP status code
206 status_code: u16,
207 /// Response body or error message
208 reason: String,
209 },
210}
211
212/// Errors related to TSIG (Transaction Signature) authentication for dynamic DNS updates.
213///
214/// TSIG is used to authenticate dynamic DNS update requests. These errors occur when
215/// TSIG authentication fails due to invalid keys, mismatched algorithms, or replay attacks.
216#[derive(Error, Debug, Clone)]
217pub enum TsigError {
218 /// TSIG connection or authentication error when attempting dynamic updates
219 ///
220 /// Returned when TSIG authentication fails. This could be due to:
221 /// - Incorrect TSIG key name or secret
222 /// - Mismatched TSIG algorithm between client and server
223 /// - Clock skew between client and server
224 /// - TSIG key not configured on the BIND9 server
225 #[error("TSIG authentication failed for server {server}: {reason}")]
226 TsigConnectionError {
227 /// The DNS server (IP:port) where TSIG failed
228 server: String,
229 /// Specific reason for TSIG failure
230 reason: String,
231 },
232
233 /// TSIG key not found in Kubernetes secret
234 ///
235 /// Returned when the expected TSIG key secret doesn't exist in Kubernetes.
236 /// This indicates a configuration error or missing secret.
237 #[error("TSIG key secret '{secret_name}' not found in namespace '{namespace}'")]
238 TsigKeyNotFound {
239 /// The Kubernetes secret name
240 secret_name: String,
241 /// The namespace where the secret should exist
242 namespace: String,
243 },
244
245 /// Invalid TSIG key data in Kubernetes secret
246 ///
247 /// Returned when the TSIG secret exists but contains invalid data (missing fields,
248 /// malformed base64, unsupported algorithm).
249 #[error("Invalid TSIG key data in secret '{secret_name}': {reason}")]
250 InvalidTsigKeyData {
251 /// The Kubernetes secret name
252 secret_name: String,
253 /// Explanation of what is invalid
254 reason: String,
255 },
256
257 /// TSIG verification failed (server rejected the signature)
258 ///
259 /// Returned when the BIND9 server rejects the TSIG signature on a dynamic update.
260 /// This indicates the signature doesn't match, suggesting key mismatch or tampering.
261 #[error("TSIG verification failed on server {server} for key '{key_name}'")]
262 TsigVerificationFailed {
263 /// The DNS server that rejected the signature
264 server: String,
265 /// The TSIG key name that was used
266 key_name: String,
267 },
268}
269
270/// Errors related to zone transfer operations (AXFR/IXFR).
271///
272/// These errors occur when attempting to trigger or perform zone transfers
273/// between primary and secondary BIND9 instances.
274#[derive(Error, Debug, Clone)]
275pub enum ZoneTransferError {
276 /// Zone transfer failed (AXFR or IXFR)
277 ///
278 /// Returned when a zone transfer from primary to secondary fails.
279 /// This could be due to network issues, TSIG authentication failure,
280 /// or the zone not being configured for transfer.
281 #[error("Zone transfer for '{zone}' from {primary} to {secondary} failed: {reason}")]
282 TransferFailed {
283 /// The zone being transferred
284 zone: String,
285 /// The primary server IP
286 primary: String,
287 /// The secondary server IP
288 secondary: String,
289 /// Reason for the transfer failure
290 reason: String,
291 },
292
293 /// Zone transfer not allowed by primary server
294 ///
295 /// Returned when the primary server refuses zone transfer (typically due to
296 /// allow-transfer ACL configuration).
297 #[error("Zone transfer for '{zone}' refused by primary {primary} (not in allow-transfer)")]
298 TransferRefused {
299 /// The zone being transferred
300 zone: String,
301 /// The primary server that refused
302 primary: String,
303 },
304
305 /// Zone transfer timeout
306 ///
307 /// Returned when a zone transfer operation exceeds the timeout.
308 /// This typically indicates a very large zone or network issues.
309 #[error("Zone transfer for '{zone}' from {primary} timed out after {timeout_secs}s")]
310 TransferTimeout {
311 /// The zone being transferred
312 zone: String,
313 /// The primary server
314 primary: String,
315 /// Timeout in seconds
316 timeout_secs: u64,
317 },
318}
319
320/// Composite error type that encompasses all DNS operation errors.
321///
322/// This is the primary error type returned by Bindy's DNS operation functions.
323/// It provides a unified interface for handling all possible DNS-related errors.
324#[derive(Error, Debug, Clone)]
325pub enum DnsError {
326 /// Zone-related error (creation, deletion, not found, etc.)
327 #[error(transparent)]
328 Zone(#[from] ZoneError),
329
330 /// DNS record-related error (update, query, deletion, etc.)
331 #[error(transparent)]
332 Record(#[from] RecordError),
333
334 /// BIND9 instance or bindcar API unavailability
335 #[error(transparent)]
336 Instance(#[from] InstanceError),
337
338 /// TSIG authentication or key management error
339 #[error(transparent)]
340 Tsig(#[from] TsigError),
341
342 /// Zone transfer error
343 #[error(transparent)]
344 ZoneTransfer(#[from] ZoneTransferError),
345
346 /// Generic error for operations that don't fit other categories
347 #[error("DNS operation failed: {0}")]
348 Generic(String),
349}
350
351impl DnsError {
352 /// Returns true if this error is transient and the operation should be retried.
353 ///
354 /// Transient errors include network failures, timeouts, and temporary unavailability.
355 /// Non-transient errors include not found, invalid data, and authentication failures.
356 #[must_use]
357 pub fn is_transient(&self) -> bool {
358 match self {
359 // Transient errors that should be retried
360 Self::Zone(
361 ZoneError::ZoneCreationFailed { .. } | ZoneError::ZoneDeletionFailed { .. },
362 )
363 | Self::Record(
364 RecordError::RecordUpdateFailed { .. } | RecordError::RecordDeletionFailed { .. },
365 )
366 | Self::Instance(_)
367 | Self::Tsig(TsigError::TsigConnectionError { .. })
368 | Self::ZoneTransfer(
369 ZoneTransferError::TransferFailed { .. }
370 | ZoneTransferError::TransferTimeout { .. },
371 )
372 | Self::Generic(_) => true,
373
374 // Permanent errors that should not be retried
375 Self::Zone(
376 ZoneError::ZoneNotFound { .. }
377 | ZoneError::ZoneAlreadyExists { .. }
378 | ZoneError::InvalidZoneConfiguration { .. },
379 )
380 | Self::Record(
381 RecordError::RecordNotFound { .. } | RecordError::InvalidRecordData { .. },
382 )
383 | Self::Tsig(
384 TsigError::TsigKeyNotFound { .. }
385 | TsigError::InvalidTsigKeyData { .. }
386 | TsigError::TsigVerificationFailed { .. },
387 )
388 | Self::ZoneTransfer(ZoneTransferError::TransferRefused { .. }) => false,
389 }
390 }
391
392 /// Returns the Kubernetes status reason code for this error.
393 ///
394 /// This is used when updating CRD status conditions to provide
395 /// structured error information.
396 #[must_use]
397 pub fn status_reason(&self) -> &'static str {
398 match self {
399 Self::Zone(ZoneError::ZoneNotFound { .. }) => "ZoneNotFound",
400 Self::Zone(ZoneError::ZoneCreationFailed { .. }) => "ZoneCreationFailed",
401 Self::Zone(ZoneError::ZoneAlreadyExists { .. }) => "ZoneAlreadyExists",
402 Self::Zone(ZoneError::ZoneDeletionFailed { .. }) => "ZoneDeletionFailed",
403 Self::Zone(ZoneError::InvalidZoneConfiguration { .. }) => "InvalidZoneConfiguration",
404
405 Self::Record(RecordError::RecordNotFound { .. }) => "RecordNotFound",
406 Self::Record(RecordError::RecordUpdateFailed { .. }) => "RecordUpdateFailed",
407 Self::Record(RecordError::RecordDeletionFailed { .. }) => "RecordDeletionFailed",
408 Self::Record(RecordError::InvalidRecordData { .. }) => "InvalidRecordData",
409
410 Self::Instance(InstanceError::Bind9InstanceUnavailable { .. }) => {
411 "Bind9InstanceUnavailable"
412 }
413 Self::Instance(InstanceError::HttpConnectionFailed { .. }) => "HttpConnectionFailed",
414 Self::Instance(InstanceError::HttpRequestTimeout { .. }) => "HttpRequestTimeout",
415 Self::Instance(InstanceError::UnexpectedHttpResponse { .. }) => {
416 "UnexpectedHttpResponse"
417 }
418
419 Self::Tsig(TsigError::TsigConnectionError { .. }) => "TsigConnectionError",
420 Self::Tsig(TsigError::TsigKeyNotFound { .. }) => "TsigKeyNotFound",
421 Self::Tsig(TsigError::InvalidTsigKeyData { .. }) => "InvalidTsigKeyData",
422 Self::Tsig(TsigError::TsigVerificationFailed { .. }) => "TsigVerificationFailed",
423
424 Self::ZoneTransfer(ZoneTransferError::TransferFailed { .. }) => "ZoneTransferFailed",
425 Self::ZoneTransfer(ZoneTransferError::TransferRefused { .. }) => "ZoneTransferRefused",
426 Self::ZoneTransfer(ZoneTransferError::TransferTimeout { .. }) => "ZoneTransferTimeout",
427
428 Self::Generic(_) => "DnsOperationFailed",
429 }
430 }
431}
432
433// Conversion from anyhow::Error to DnsError for backward compatibility
434impl From<anyhow::Error> for DnsError {
435 fn from(err: anyhow::Error) -> Self {
436 Self::Generic(err.to_string())
437 }
438}