diff --git a/src/cloudflare.rs b/src/cloudflare.rs index 0d87f3a..33ac704 100644 --- a/src/cloudflare.rs +++ b/src/cloudflare.rs @@ -1 +1,357 @@ -const API_BASE: &str = "https://api.cloudflare.com/client/v4"; \ No newline at end of file +use std::collections::HashMap; +use std::env; +use std::env::VarError; +use std::error::Error; +use std::net::{Ipv4Addr, Ipv6Addr}; +use log::{error, warn}; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::{Url}; +use reqwest::blocking::{Response, Client}; +use serde_derive::{Deserialize, Serialize}; +use strum_macros::{Display, IntoStaticStr}; +use systemd_journal_logger::connected_to_journal; +use url::ParseError; +use crate::config::{ZoneConfig, ZoneEntry}; + +const API_BASE: &str = "https://api.cloudflare.com/client/v4"; + +#[derive(Serialize, Deserialize, Debug)] +struct CloudflareApiResults { + result: Vec, + success: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +struct CloudflareApiResult { + success: bool, +} + +pub(crate) struct CloudflareZone { + email: String, + key: String, + id: String, +} + +impl CloudflareZone { + pub(crate) fn new(zone: &ZoneConfig) -> Result { + let key = env::var("CF_API_TOKEN")?; + Ok(Self { + email: zone.email.clone(), + key, + id: zone.id.clone(), + }) + } + + fn generate_auth_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Auth-Email", + HeaderValue::from_str(self.email.as_str()).expect("After CloudflareZone gets created the required Values should exist"), + ); + headers.insert( + "X-Auth-Key", + HeaderValue::from_str(self.key.as_str()).expect("After CloudflareZone gets created the required Values should exist"), + ); + headers + } + + pub(crate) fn get_entries(&self) -> Result, ()> { + let endpoint = format!("{}/zones/{}/dns_records", API_BASE, self.id); + + match self.get(&endpoint) { + Ok(response) => { + return if response.status().is_success() { + let entries = match response.json::() { + Ok(data) => data, + Err(e) => { + let err_msg = format!("Unable to parse API response. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + return Err(()) + } + }; + + Ok(entries.result) + } else { + let err_msg = format!("Unable to fetch Cloudflare Zone Entries. Error: {}", response.status()); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } + Err(e) => { + let err_msg = format!("Unable to access Cloudflare API. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + }, + } + } + + pub(crate) fn update(&self, entry: &ZoneEntry, r#type: &DnsRecordType, id: &String, ipv6: Option, ipv4: Option) -> Result<(), ()> { + let endpoint = format!("{}/zones/{}/dns_records/{}", API_BASE, self.id, id); + + match r#type { + DnsRecordType::A => { + return if let Some(ip) = ipv4 { + let json_body = self.create_body("A", &entry.name, ip.to_string().as_str()); + + match self.put(&endpoint, &json_body) { + Ok(response) => { + self.validate_response(response) + }, + Err(e) => { + let err_msg = format!("Unable to access Cloudflare API. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } + } else { + let err_msg = "Missing IPv4 for Update."; + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + }, + DnsRecordType::AAAA => { + return if let Some(ip) = ipv6 { + let json_body = self.create_body("AAAA", &entry.name, ip.to_string().as_str()); + + match self.put(&endpoint, &json_body) { + Ok(response) => { + self.validate_response(response) + }, + Err(e) => { + let err_msg = format!("Unable to access Cloudflare API. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } + } else { + let err_msg = "Missing IPv6 for Update."; + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + }, + _ => { + let warn_msg = "Config contains unsupported type identifier"; + match connected_to_journal() { + true => warn!("[WARN] {warn_msg}"), + false => println!("[WARN] {warn_msg}"), + } + return Err(()) + } + } + } + + pub(crate) fn create(&self, entry: &ZoneEntry, r#type: &DnsRecordType, ipv6: Option, ipv4: Option) -> Result<(), ()> { + let endpoint = format!("{}/zones/{}/dns_records", API_BASE, self.id); + + match r#type { + DnsRecordType::A => { + return if let Some(ip) = ipv4 { + let json_body = self.create_body("A", &entry.name, ip.to_string().as_str()); + + match self.post(&endpoint, &json_body) { + Ok(response) => { + self.validate_response(response) + }, + Err(e) => { + let err_msg = format!("Unable to access Cloudflare API. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } + } else { + let err_msg = "Missing IPv4 for Update."; + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + }, + DnsRecordType::AAAA => { + return if let Some(ip) = ipv6 { + let json_body = self.create_body("AAAA", &entry.name, ip.to_string().as_str()); + + match self.post(&endpoint, &json_body) { + Ok(response) => { + self.validate_response(response) + }, + Err(e) => { + let err_msg = format!("Unable to access Cloudflare API. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } + } else { + let err_msg = "Missing IPv6 for Update."; + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + }, + _ => { + let warn_msg = "Config contains unsupported type identifier"; + match connected_to_journal() { + true => warn!("[WARN] {warn_msg}"), + false => println!("[WARN] {warn_msg}"), + } + return Err(()) + } + } + } + + fn get(&self, url: &str) -> Result> { + let url_parsed = self.parse_url(url)?; + + match Client::new() + .get(url_parsed) + .headers(self.generate_auth_headers()) + .send() { + Ok(result) => Ok(result), + Err(e) => Err(Box::new(e)), + } + } + + fn post(&self, url: &str, data: &HashMap) -> Result> { + let url_parsed = self.parse_url(url)?; + + match Client::new() + .post(url_parsed) + .headers(self.generate_auth_headers()) + .json(data) + .send() { + Ok(result) => Ok(result), + Err(e) => Err(Box::new(e)), + } + } + + fn put(&self, url: &str, data: &HashMap) -> Result> { + let url_parsed = self.parse_url(url)?; + + match Client::new() + .put(url_parsed) + .headers(self.generate_auth_headers()) + .json(data) + .send() { + Ok(result) => Ok(result), + Err(e) => Err(Box::new(e)), + } + } + + fn parse_url(&self, input: &str) -> Result { + match Url::parse(input) { + Ok(url) => Ok(url), + Err(e) => { + let err_msg = format!("Unable to parse URL. Error: {}", e); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + return Err(e) + } + } + } + + fn create_body(&self, r#type: &str, name: &String, ip: &str) -> HashMap { + let mut body = HashMap::new(); + body.insert("type".to_string(), r#type.to_string()); + body.insert("name".to_string(), name.clone()); + body.insert("content".to_string(), ip.to_string()); + body + } + + fn validate_response(&self, response: Response) -> Result<(), ()> { + if response.status().is_success() { + let data = match response.json::() { + Ok(data) => data, + Err(e) => { + let err_msg = format!("Unable to parse API response. Error: {e}"); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + return Err(()) + } + }; + + match data.success { + true => return Ok(()), + false => { + let err_msg = format!("Unexpected error while updating DNS record. Info: {:?}", data); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + return Err(()) + } + } + } else { + let err_msg = format!("Unable to post/put Cloudflare DNS entry. Error: {}", response.status()); + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + Err(()) + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy, Display, IntoStaticStr)] +pub(crate) enum DnsRecordType { + A, + AAAA, + CAA, + CERT, + CNAME, + DNSKEY, + DS, + HTTPS, + LOC, + MX, + NAPTR, + NS, + PTR, + SMIMEA, + SRV, + SSHFP, + SVCB, + TLSA, + TXT, + URI +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub(crate) struct CloudflareDnsRecord { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) r#type: DnsRecordType, + content: String +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 8734b2d..2858c8a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,13 +2,19 @@ use std::collections::HashMap; use std::error::Error; use std::fs; use std::hash::Hash; +use std::net::Ipv6Addr; +use ipnet::{IpAdd, IpBitAnd, IpBitOr, IpSub}; +use std::str::FromStr; use log::{error, warn}; use serde_derive::{Deserialize, Serialize}; +use systemd_journal_logger::connected_to_journal; +use crate::cloudflare::DnsRecordType; +use crate::cloudflare::DnsRecordType::{A, AAAA}; #[derive(Serialize, Deserialize, Clone, Debug)] pub(crate) struct InterfaceConfig { - pub(crate) host_address: String, - pub(crate) interfaces: HashMap, + pub(crate) host_address: Ipv6Addr, + pub(crate) interfaces: HashMap, } impl InterfaceConfig { @@ -26,13 +32,30 @@ impl InterfaceConfig { Ok(cfg) } + + pub(crate) fn full_v6(&self, interface_name: &String, host_v6: Ipv6Addr) -> Result { + let host_range = Ipv6Addr::from(host_v6.saturating_sub(self.host_address)); + let interface_address = match self.interfaces.get(interface_name) { + Some(address) => address.clone(), + None => { + let err_msg = "Malformed IP in interfaces.toml"; + match connected_to_journal() { + true => error!("[ERROR] {err_msg}"), + false => eprintln!("[ERROR] {err_msg}"), + } + return Err(()); + } + }; + let interface_ip = host_range.bitor(interface_address); + Ok(interface_ip) + } } impl Default for InterfaceConfig { fn default() -> Self { InterfaceConfig { - host_address: "::".to_string(), - interfaces: HashMap::from([(" ".to_string(), "::".to_string())]), + host_address: Ipv6Addr::from_str("::").expect("Malformed literal in code"), + interfaces: HashMap::from([(" ".to_string(), Ipv6Addr::from(0))]), } } } @@ -42,7 +65,7 @@ impl Default for InterfaceConfig { #[derive(Serialize, Deserialize, Clone, Debug)] pub(crate) struct ZoneEntry { pub(crate) name: String, - pub(crate) rtype: u8, + pub(crate) r#type: Vec, pub(crate) interface: String, } @@ -50,7 +73,7 @@ impl Default for ZoneEntry { fn default() -> Self { ZoneEntry { name: " ".to_string(), - rtype: 10, + r#type: vec![A, AAAA], interface: " ".to_string(), } } @@ -59,7 +82,7 @@ impl Default for ZoneEntry { #[derive(Serialize, Deserialize, Clone, Debug)] pub(crate) struct ZoneConfig { pub(crate) email: String, - pub(crate) zone: String, + pub(crate) name: String, pub(crate) id: String, #[serde(alias="entry")] pub(crate) entries: Vec @@ -109,7 +132,7 @@ impl Default for ZoneConfig { fn default() -> Self { ZoneConfig { email: " ".to_string(), - zone: " ".to_string(), + name: " ".to_string(), id: " ".to_string(), entries: vec![ZoneEntry::default()], }