qudag_network/
dns.rs

1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3
4//! DNS integration module for ruv.io using Cloudflare API.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Errors that can occur during DNS operations
10#[derive(Error, Debug)]
11pub enum DnsError {
12    /// API request failed
13    #[error("API request failed: {0}")]
14    ApiError(String),
15
16    /// Invalid record data
17    #[error("Invalid record data: {0}")]
18    ValidationError(String),
19
20    /// Record not found
21    #[error("Record not found: {0}")]
22    NotFound(String),
23}
24
25/// DNS record types supported by the system
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub enum RecordType {
28    /// A record pointing to IPv4 address
29    A,
30    /// AAAA record pointing to IPv6 address  
31    AAAA,
32    /// TXT record for storing text data
33    TXT,
34    /// CNAME record for aliases
35    CNAME,
36}
37
38/// A DNS record entry
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DnsRecord {
41    /// Record name/hostname
42    pub name: String,
43    /// Record type
44    pub record_type: RecordType,
45    /// Record content/value
46    pub content: String,
47    /// Time-to-live in seconds
48    pub ttl: u32,
49    /// Proxied through Cloudflare
50    pub proxied: bool,
51}
52
53/// Configuration for Cloudflare API client
54#[derive(Debug, Clone)]
55pub struct CloudflareConfig {
56    /// API token for authentication
57    api_token: String,
58    /// Zone ID for the domain
59    zone_id: String,
60}
61
62/// Client for interacting with Cloudflare DNS API
63pub struct CloudflareClient {
64    config: CloudflareConfig,
65    http_client: reqwest::Client,
66}
67
68impl CloudflareClient {
69    /// Creates a new Cloudflare API client
70    pub fn new(config: CloudflareConfig) -> Self {
71        Self {
72            config,
73            http_client: reqwest::Client::new(),
74        }
75    }
76
77    const API_BASE: &'static str = "https://api.cloudflare.com/client/v4";
78
79    /// Lists all DNS records in the zone
80    pub async fn list_records(&self) -> Result<Vec<DnsRecord>, DnsError> {
81        let url = format!(
82            "{}/zones/{}/dns_records",
83            Self::API_BASE,
84            self.config.zone_id
85        );
86
87        let response = self
88            .http_client
89            .get(&url)
90            .header("Authorization", format!("Bearer {}", self.config.api_token))
91            .header("Content-Type", "application/json")
92            .send()
93            .await
94            .map_err(|e| DnsError::ApiError(e.to_string()))?;
95
96        if !response.status().is_success() {
97            return Err(DnsError::ApiError(format!(
98                "API request failed: {}",
99                response.status()
100            )));
101        }
102
103        let records = response
104            .json::<Vec<DnsRecord>>()
105            .await
106            .map_err(|e| DnsError::ApiError(e.to_string()))?;
107
108        Ok(records)
109    }
110
111    /// Creates a new DNS record
112    pub async fn create_record(&self, record: DnsRecord) -> Result<DnsRecord, DnsError> {
113        let url = format!(
114            "{}/zones/{}/dns_records",
115            Self::API_BASE,
116            self.config.zone_id
117        );
118
119        let response = self
120            .http_client
121            .post(&url)
122            .header("Authorization", format!("Bearer {}", self.config.api_token))
123            .header("Content-Type", "application/json")
124            .json(&record)
125            .send()
126            .await
127            .map_err(|e| DnsError::ApiError(e.to_string()))?;
128
129        if !response.status().is_success() {
130            return Err(DnsError::ApiError(format!(
131                "API request failed: {}",
132                response.status()
133            )));
134        }
135
136        let created_record = response
137            .json::<DnsRecord>()
138            .await
139            .map_err(|e| DnsError::ApiError(e.to_string()))?;
140
141        Ok(created_record)
142    }
143
144    /// Updates an existing DNS record
145    pub async fn update_record(
146        &self,
147        record_id: &str,
148        record: DnsRecord,
149    ) -> Result<DnsRecord, DnsError> {
150        let url = format!(
151            "{}/zones/{}/dns_records/{}",
152            Self::API_BASE,
153            self.config.zone_id,
154            record_id
155        );
156
157        let response = self
158            .http_client
159            .put(&url)
160            .header("Authorization", format!("Bearer {}", self.config.api_token))
161            .header("Content-Type", "application/json")
162            .json(&record)
163            .send()
164            .await
165            .map_err(|e| DnsError::ApiError(e.to_string()))?;
166
167        if !response.status().is_success() {
168            return Err(DnsError::ApiError(format!(
169                "API request failed: {}",
170                response.status()
171            )));
172        }
173
174        let updated_record = response
175            .json::<DnsRecord>()
176            .await
177            .map_err(|e| DnsError::ApiError(e.to_string()))?;
178
179        Ok(updated_record)
180    }
181
182    /// Deletes a DNS record
183    pub async fn delete_record(&self, record_id: &str) -> Result<(), DnsError> {
184        let url = format!(
185            "{}/zones/{}/dns_records/{}",
186            Self::API_BASE,
187            self.config.zone_id,
188            record_id
189        );
190
191        let response = self
192            .http_client
193            .delete(&url)
194            .header("Authorization", format!("Bearer {}", self.config.api_token))
195            .header("Content-Type", "application/json")
196            .send()
197            .await
198            .map_err(|e| DnsError::ApiError(e.to_string()))?;
199
200        if !response.status().is_success() {
201            return Err(DnsError::ApiError(format!(
202                "API request failed: {}",
203                response.status()
204            )));
205        }
206
207        Ok(())
208    }
209}
210
211/// DNS record manager for handling record operations
212pub struct DnsManager {
213    client: CloudflareClient,
214}
215
216impl DnsManager {
217    /// Creates a new DNS record manager
218    pub fn new(config: CloudflareConfig) -> Self {
219        Self {
220            client: CloudflareClient::new(config),
221        }
222    }
223
224    /// Lists all DNS records
225    pub async fn list_records(&self) -> Result<Vec<DnsRecord>, DnsError> {
226        self.client.list_records().await
227    }
228
229    /// Creates a new DNS record
230    pub async fn create_record(&self, record: DnsRecord) -> Result<DnsRecord, DnsError> {
231        // Validate record data
232        self.validate_record(&record)?;
233        self.client.create_record(record).await
234    }
235
236    /// Updates an existing DNS record
237    pub async fn update_record(
238        &self,
239        record_id: &str,
240        record: DnsRecord,
241    ) -> Result<DnsRecord, DnsError> {
242        // Validate record data
243        self.validate_record(&record)?;
244        self.client.update_record(record_id, record).await
245    }
246
247    /// Deletes a DNS record
248    pub async fn delete_record(&self, record_id: &str) -> Result<(), DnsError> {
249        self.client.delete_record(record_id).await
250    }
251
252    /// Validates record data before operations
253    fn validate_record(&self, record: &DnsRecord) -> Result<(), DnsError> {
254        // Validate record name
255        if record.name.is_empty() || record.name.len() > 255 {
256            return Err(DnsError::ValidationError(
257                "Invalid record name length".to_string(),
258            ));
259        }
260
261        // Basic hostname validation
262        if !record
263            .name
264            .chars()
265            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
266        {
267            return Err(DnsError::ValidationError(
268                "Invalid characters in record name".to_string(),
269            ));
270        }
271
272        // Validate content based on record type
273        match record.record_type {
274            RecordType::A => {
275                // Validate IPv4 address
276                if !record.content.split('.').count() == 4
277                    && !record
278                        .content
279                        .split('.')
280                        .all(|octet| octet.parse::<u8>().is_ok())
281                {
282                    return Err(DnsError::ValidationError(
283                        "Invalid IPv4 address".to_string(),
284                    ));
285                }
286            }
287            RecordType::AAAA => {
288                // Basic IPv6 validation
289                if !record.content.contains(':') || record.content.len() > 39 {
290                    return Err(DnsError::ValidationError(
291                        "Invalid IPv6 address".to_string(),
292                    ));
293                }
294            }
295            RecordType::TXT => {
296                // Validate TXT record length
297                if record.content.len() > 255 {
298                    return Err(DnsError::ValidationError("TXT record too long".to_string()));
299                }
300            }
301            RecordType::CNAME => {
302                // Validate CNAME is a valid hostname
303                if !record
304                    .content
305                    .chars()
306                    .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
307                {
308                    return Err(DnsError::ValidationError("Invalid CNAME value".to_string()));
309                }
310            }
311        }
312
313        // Validate TTL
314        if record.ttl < 60 || record.ttl > 86400 {
315            return Err(DnsError::ValidationError(
316                "TTL must be between 60 and 86400 seconds".to_string(),
317            ));
318        }
319
320        Ok(())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use mockito::mock;
328    use serde_json::json;
329
330    fn setup_test_config() -> CloudflareConfig {
331        CloudflareConfig {
332            api_token: "test_token".to_string(),
333            zone_id: "test_zone".to_string(),
334        }
335    }
336
337    fn create_test_record() -> DnsRecord {
338        DnsRecord {
339            name: "test.example.com".to_string(),
340            record_type: RecordType::A,
341            content: "192.0.2.1".to_string(),
342            ttl: 3600,
343            proxied: false,
344        }
345    }
346
347    #[tokio::test]
348    async fn test_list_records() {
349        let _m = mock("GET", "/zones/test_zone/dns_records")
350            .with_header("Authorization", "Bearer test_token")
351            .with_status(200)
352            .with_body(
353                json!({
354                    "success": true,
355                    "result": [{
356                        "name": "test.example.com",
357                        "type": "A",
358                        "content": "192.0.2.1",
359                        "ttl": 3600,
360                        "proxied": false
361                    }]
362                })
363                .to_string(),
364            )
365            .create();
366
367        let client = CloudflareClient::new(setup_test_config());
368        let records = client.list_records().await.unwrap();
369        assert_eq!(records.len(), 1);
370        assert_eq!(records[0].name, "test.example.com");
371    }
372
373    #[tokio::test]
374    async fn test_create_record() {
375        let record = create_test_record();
376        let _m = mock("POST", "/zones/test_zone/dns_records")
377            .with_header("Authorization", "Bearer test_token")
378            .with_status(200)
379            .with_body(
380                json!({
381                    "success": true,
382                    "result": {
383                        "name": "test.example.com",
384                        "type": "A",
385                        "content": "192.0.2.1",
386                        "ttl": 3600,
387                        "proxied": false
388                    }
389                })
390                .to_string(),
391            )
392            .create();
393
394        let client = CloudflareClient::new(setup_test_config());
395        let created = client.create_record(record.clone()).await.unwrap();
396        assert_eq!(created.name, record.name);
397        assert_eq!(created.content, record.content);
398    }
399
400    #[tokio::test]
401    async fn test_update_record() {
402        let record = create_test_record();
403        let _m = mock("PUT", "/zones/test_zone/dns_records/test_id")
404            .with_header("Authorization", "Bearer test_token")
405            .with_status(200)
406            .with_body(
407                json!({
408                    "success": true,
409                    "result": {
410                        "name": "test.example.com",
411                        "type": "A",
412                        "content": "192.0.2.2",
413                        "ttl": 3600,
414                        "proxied": false
415                    }
416                })
417                .to_string(),
418            )
419            .create();
420
421        let client = CloudflareClient::new(setup_test_config());
422        let updated = client.update_record("test_id", record).await.unwrap();
423        assert_eq!(updated.content, "192.0.2.2");
424    }
425
426    #[tokio::test]
427    async fn test_delete_record() {
428        let _m = mock("DELETE", "/zones/test_zone/dns_records/test_id")
429            .with_header("Authorization", "Bearer test_token")
430            .with_status(200)
431            .with_body(
432                json!({
433                    "success": true,
434                    "result": {}
435                })
436                .to_string(),
437            )
438            .create();
439
440        let client = CloudflareClient::new(setup_test_config());
441        client.delete_record("test_id").await.unwrap();
442    }
443
444    #[test]
445    fn test_record_validation() {
446        let dns_manager = DnsManager::new(setup_test_config());
447
448        // Test valid record
449        let valid_record = create_test_record();
450        assert!(dns_manager.validate_record(&valid_record).is_ok());
451
452        // Test invalid name
453        let mut invalid_record = valid_record.clone();
454        invalid_record.name = "invalid@name".to_string();
455        assert!(dns_manager.validate_record(&invalid_record).is_err());
456
457        // Test invalid IPv4
458        let mut invalid_record = valid_record.clone();
459        invalid_record.content = "256.256.256.256".to_string();
460        assert!(dns_manager.validate_record(&invalid_record).is_err());
461
462        // Test invalid TTL
463        let mut invalid_record = valid_record.clone();
464        invalid_record.ttl = 30; // Too low
465        assert!(dns_manager.validate_record(&invalid_record).is_err());
466    }
467}