1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3
4use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Error, Debug)]
11pub enum DnsError {
12 #[error("API request failed: {0}")]
14 ApiError(String),
15
16 #[error("Invalid record data: {0}")]
18 ValidationError(String),
19
20 #[error("Record not found: {0}")]
22 NotFound(String),
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub enum RecordType {
28 A,
30 AAAA,
32 TXT,
34 CNAME,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DnsRecord {
41 pub name: String,
43 pub record_type: RecordType,
45 pub content: String,
47 pub ttl: u32,
49 pub proxied: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct CloudflareConfig {
56 api_token: String,
58 zone_id: String,
60}
61
62pub struct CloudflareClient {
64 config: CloudflareConfig,
65 http_client: reqwest::Client,
66}
67
68impl CloudflareClient {
69 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 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 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 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 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
211pub struct DnsManager {
213 client: CloudflareClient,
214}
215
216impl DnsManager {
217 pub fn new(config: CloudflareConfig) -> Self {
219 Self {
220 client: CloudflareClient::new(config),
221 }
222 }
223
224 pub async fn list_records(&self) -> Result<Vec<DnsRecord>, DnsError> {
226 self.client.list_records().await
227 }
228
229 pub async fn create_record(&self, record: DnsRecord) -> Result<DnsRecord, DnsError> {
231 self.validate_record(&record)?;
233 self.client.create_record(record).await
234 }
235
236 pub async fn update_record(
238 &self,
239 record_id: &str,
240 record: DnsRecord,
241 ) -> Result<DnsRecord, DnsError> {
242 self.validate_record(&record)?;
244 self.client.update_record(record_id, record).await
245 }
246
247 pub async fn delete_record(&self, record_id: &str) -> Result<(), DnsError> {
249 self.client.delete_record(record_id).await
250 }
251
252 fn validate_record(&self, record: &DnsRecord) -> Result<(), DnsError> {
254 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 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 match record.record_type {
274 RecordType::A => {
275 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 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 if record.content.len() > 255 {
298 return Err(DnsError::ValidationError("TXT record too long".to_string()));
299 }
300 }
301 RecordType::CNAME => {
302 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 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 let valid_record = create_test_record();
450 assert!(dns_manager.validate_record(&valid_record).is_ok());
451
452 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 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 let mut invalid_record = valid_record.clone();
464 invalid_record.ttl = 30; assert!(dns_manager.validate_record(&invalid_record).is_err());
466 }
467}