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}