diff --git a/Cargo.toml b/Cargo.toml index bc329ef..c843475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,3 +49,6 @@ typetag = "0.2.18" strip = true lto = true codegen-units = 1 + +[dev-dependencies] +wiremock = "0.6.2" diff --git a/src/client.rs b/src/client.rs index 1dbf452..2e90740 100755 --- a/src/client.rs +++ b/src/client.rs @@ -50,8 +50,14 @@ pub enum IpSource { /// Update sent to each provider #[derive(Debug, Clone, PartialEq, Eq)] pub struct IpUpdate { - v4: Option, - v6: Option, + pub v4: Option, + pub v6: Option, +} + +impl IpUpdate { + pub fn as_array(&self) -> [(IpVersion, Option); 2] { + [(IpVersion::V4, self.v4), (IpVersion::V6, self.v6)] + } } impl Display for IpUpdate { @@ -65,12 +71,6 @@ impl Display for IpUpdate { } } -impl IpUpdate { - pub fn as_array(&self) -> [(IpVersion, Option); 2] { - [(IpVersion::V4, self.v4), (IpVersion::V6, self.v6)] - } -} - /// Provider trait for updating DNS records or DDNS services #[async_trait] #[typetag::serde(tag = "type")] diff --git a/src/providers/cloudflare.rs b/src/providers/cloudflare.rs index f1a6061..9170e95 100644 --- a/src/providers/cloudflare.rs +++ b/src/providers/cloudflare.rs @@ -7,14 +7,18 @@ use smallvec::SmallVec; use crate::client::{IpUpdate, IpVersion, Provider}; -const CLOUDFLARE_API: &str = "https://api.cloudflare.com/client/v4"; - /// Cloudflare DNS update provider #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Cloudflare { zone: String, api_token: String, domains: SmallVec<[Domains; 2]>, + #[serde(default = "default_api_url")] + api_url: String, +} + +fn default_api_url() -> String { + "https://api.cloudflare.com/client/v4".to_string() } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -55,17 +59,18 @@ struct RecordResult { #[derive(Debug, Deserialize)] struct UpdatedResult { #[serde(rename = "errors")] - _errors: Vec>, + _errors: Vec, #[serde(rename = "messages")] - _messages: Vec>, + _messages: Vec, success: bool, } #[derive(Debug, Deserialize)] struct CreatedResult { #[serde(rename = "errors")] - _errors: Vec>, - _messages: Vec>, + _errors: Vec, + #[serde(rename = "messages")] + _messages: Vec, success: bool, } @@ -74,7 +79,7 @@ struct CreatedResult { impl Provider for Cloudflare { async fn update(&self, update: IpUpdate, request: Client) -> Result { let zones = request - .get(format!("{CLOUDFLARE_API}/zones")) + .get(format!("{}/zones", self.api_url)) .query(&[("name", &self.zone)]) .bearer_auth(&self.api_token) .send() @@ -90,49 +95,28 @@ impl Provider for Cloudflare { .id; for domain in &self.domains { for (version, address) in update.as_array() { - if let Some(address) = address { - let record_type = match version { - IpVersion::V4 => "A", - IpVersion::V6 => "AAAA", - }; - let records = request - .get(format!("{CLOUDFLARE_API}/zones/{zone_id}/dns_records")) - .query(&[("name", &domain.name)]) - .query(&[("type", record_type)]) - .bearer_auth(&self.api_token) - .send() - .await? - .json::() - .await?; - if let Some(record) = records.result.and_then(|vec| vec.into_iter().next()) { - let updated = request - .put(format!( - "{CLOUDFLARE_API}/zones/{zone_id}/dns_records/{0}", - record.id - )) - .json(&json!({ - "type": record_type, - "name": domain.name, - "content": address, - "ttl": domain.ttl, - "proxied": domain.proxied, - "comment": domain.comment, - })) - .bearer_auth(&self.api_token) - .send() - .await? - .json::() - .await?; - if !updated.success { - return Err(anyhow!( - "Failed to update Cloudflare domain ({}) record", - domain.name - )); - } - return Ok(true); - } - let created = request - .post(format!("{CLOUDFLARE_API}/zones/{zone_id}/dns_records")) + if address.is_none() { + continue; + } + let record_type = match version { + IpVersion::V4 => "A", + IpVersion::V6 => "AAAA", + }; + let records = request + .get(format!("{}/zones/{zone_id}/dns_records", self.api_url)) + .query(&[("name", &domain.name)]) + .query(&[("type", record_type)]) + .bearer_auth(&self.api_token) + .send() + .await? + .json::() + .await?; + if let Some(record) = records.result.and_then(|vec| vec.into_iter().next()) { + let updated = request + .put(format!( + "{}/zones/{zone_id}/dns_records/{}", + self.api_url, record.id, + )) .json(&json!({ "type": record_type, "name": domain.name, @@ -144,18 +128,637 @@ impl Provider for Cloudflare { .bearer_auth(&self.api_token) .send() .await? - .json::() + .json::() .await?; - if !created.success { + if !updated.success { return Err(anyhow!( - "Failed to create Cloudflare domain ({}) record", + "Failed to update Cloudflare domain ({}) record", domain.name )); } - return Ok(true); + continue; + } + let created = request + .post(format!("{}/zones/{zone_id}/dns_records", self.api_url)) + .json(&json!({ + "type": record_type, + "name": domain.name, + "content": address, + "ttl": domain.ttl, + "proxied": domain.proxied, + "comment": domain.comment, + })) + .bearer_auth(&self.api_token) + .send() + .await? + .json::() + .await?; + if !created.success { + return Err(anyhow!( + "Failed to create Cloudflare domain ({}) record", + domain.name + )); } } } Ok(true) } } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use smallvec::smallvec; + use wiremock::{ + matchers::{bearer_token, method, path, query_param}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + const UPDATE_BOTH: IpUpdate = IpUpdate { + v4: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), + v6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + }; + const UPDATE_V4: IpUpdate = IpUpdate { + v4: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), + v6: None, + }; + const UPDATE_V6: IpUpdate = IpUpdate { + v4: None, + v6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + }; + + #[tokio::test] + async fn test_cloudflare_bad_token() { + let mock = MockServer::start().await; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "bad_token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: None, + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "success": false, + "errors": [ + { + "code": 9109, + "message": "Invalid access token" + } + ], + "messages": [], + "result": null + }))) + .mount(&mock) + .await; + + let error = provider + .update(UPDATE_BOTH, Client::new()) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "Failed to list Cloudflare zones"); + } + + #[tokio::test] + async fn test_cloudflare_no_matching_zones() { + let mock = MockServer::start().await; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: None, + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "errors": [], + "messages": [], + "result": [] + }))) + .mount(&mock) + .await; + + let error = provider + .update(UPDATE_BOTH, Client::new()) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "Failed to find a matching Cloudflare zone" + ); + } + + #[tokio::test] + async fn test_cloudflare_no_domains() { + let mock = MockServer::start().await; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "example.com", + } + ] + }))) + .mount(&mock) + .await; + + let result = provider.update(UPDATE_BOTH, Client::new()).await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn test_cloudflare_update_both() { + let mock = MockServer::start().await; + let zone_id = "023e105f4ecef8ad9ca31a8372d0c353"; + let v4_record_id = "89c0cbe7d4554cd29120ed30d8e6ef17"; + let v6_record_id = "25f1b0da807484b9668f812480f5c734"; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: Some("Created by DDRS".to_string()), + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": zone_id, + "name": "example.com", + } + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "A")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "comment": "Created by DDRS", + "name": "example.com", + "proxied": true, + "ttl": 3600, + "content": "192.168.1.1", + "type": "A", + "id": v4_record_id, + }, + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "AAAA")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "comment": "Created by DDRS", + "name": "example.com", + "proxied": true, + "ttl": 3600, + "content": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "type": "AAAA", + "id": v6_record_id, + } + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("PUT")) + .and(path(format!("/zones/{zone_id}/dns_records/{v4_record_id}"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .mount(&mock) + .await; + + Mock::given(method("PUT")) + .and(path(format!("/zones/{zone_id}/dns_records/{v6_record_id}"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .mount(&mock) + .await; + + let result = provider.update(UPDATE_BOTH, Client::new()).await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn test_cloudflare_update_ipv4() { + let mock = MockServer::start().await; + let zone_id = "023e105f4ecef8ad9ca31a8372d0c353"; + let v4_record_id = "89c0cbe7d4554cd29120ed30d8e6ef17"; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: Some("Created by DDRS".to_string()), + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": zone_id, + "name": "example.com", + } + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "A")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "comment": "Created by DDRS", + "name": "example.com", + "proxied": true, + "ttl": 3600, + "content": "192.168.1.1", + "type": "A", + "id": v4_record_id, + }, + ] + }))) + .expect(1) + .mount(&mock) + .await; + + Mock::given(method("PUT")) + .and(path(format!("/zones/{zone_id}/dns_records/{v4_record_id}"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .expect(1) + .mount(&mock) + .await; + + let result = provider.update(UPDATE_V4, Client::new()).await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn test_cloudflare_update_v6() { + let mock = MockServer::start().await; + let zone_id = "023e105f4ecef8ad9ca31a8372d0c353"; + let v6_record_id = "25f1b0da807484b9668f812480f5c734"; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: Some("Created by DDRS".to_string()), + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": zone_id, + "name": "example.com", + } + ] + }))) + .expect(1) + .named("List Zones") + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "comment": "Created by DDRS", + "name": "example.com", + "proxied": true, + "ttl": 3600, + "content": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "type": "AAAA", + "id": v6_record_id, + } + ] + }))) + .expect(1) + .named("List DNS Records") + .mount(&mock) + .await; + + Mock::given(method("PUT")) + .and(path(format!("/zones/{zone_id}/dns_records/{v6_record_id}"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .expect(1) + .named("Update DNS Record") + .mount(&mock) + .await; + + let result = provider.update(UPDATE_V6, Client::new()).await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn test_cloudflare_create_both() { + let mock = MockServer::start().await; + let zone_id = "023e105f4ecef8ad9ca31a8372d0c353"; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: Some("Created by DDRS".to_string()), + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": zone_id, + "name": "example.com", + } + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "A")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "AAAA")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [] + }))) + .mount(&mock) + .await; + + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .mount(&mock) + .await; + + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .mount(&mock) + .await; + + let result = provider.update(UPDATE_BOTH, Client::new()).await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn test_cloudflare_create_v4_update_v6() { + let mock = MockServer::start().await; + let zone_id = "023e105f4ecef8ad9ca31a8372d0c353"; + let v4_record_id = "89c0cbe7d4554cd29120ed30d8e6ef17"; + + let provider = Cloudflare { + zone: "example.com".to_string(), + api_token: "token".to_string(), + domains: smallvec![Domains { + name: "example.com".to_string(), + ttl: 1, + proxied: true, + comment: Some("Created by DDRS".to_string()), + }], + api_url: mock.uri(), + }; + + Mock::given(method("GET")) + .and(path("/zones")) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.zone)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "id": zone_id, + "name": "example.com", + } + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "A")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [ + { + "comment": "Created by DDRS", + "name": "example.com", + "proxied": true, + "ttl": 3600, + "content": "192.168.1.1", + "type": "A", + "id": v4_record_id, + }, + ] + }))) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .and(query_param("name", &provider.domains[0].name)) + .and(query_param("type", "AAAA")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + "result": [] + }))) + .mount(&mock) + .await; + + Mock::given(method("PUT")) + .and(path(format!("/zones/{zone_id}/dns_records/{v4_record_id}"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .expect(1) + .mount(&mock) + .await; + + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .and(bearer_token(&provider.api_token)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [], + "messages": [], + "success": true, + }))) + .expect(1) + .mount(&mock) + .await; + + let result = provider.update(UPDATE_BOTH, Client::new()).await.unwrap(); + assert!(result); + } +}