diff --git a/Cargo.toml b/Cargo.toml index b94cede..a68f3a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ log = "0.4.22" netlink-packet-core = "0.7.0" netlink-packet-route = "0.19.0" netlink-sys = { version = "0.8.6", features = ["tokio"] } -reqwest = "0.12.5" +reqwest = { version = "0.12.5", features = ["json"] } rtnetlink = "0.14.1" # Updated to a version compatible with netlink-packet-route v0.20.1 serde = { version = "1.0.204", features = ["rc", "derive"] } serde_json = "1.0.120" diff --git a/src/cloudflare.rs b/src/cloudflare.rs index e69de29..837e550 100644 --- a/src/cloudflare.rs +++ b/src/cloudflare.rs @@ -0,0 +1,151 @@ +use anyhow::{Context, Result}; +use log::{error, info}; +use reqwest::Client; +use serde::{self, Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr}, + sync::Arc, +}; + +pub struct CloudflareClient { + client: Client, + domains: Vec>, + current_ip: Ipv4Addr, + api_key: Box, + zone_id: Box, +} +// Some external site to check this +async fn get_current_public_ipv4(client: &Client) -> Result { + let response = client + .get("https://api.ipify.org?format=json") + .send() + .await? + .error_for_status()? + .json::>() + .await?; + + Ok(response + .get("ip") + .context("Field 'ip' wasn't found")? + .parse()?) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct DnsRecord { + id: String, + #[serde(rename = "type")] + record_type: Box, + name: Box, + content: Box, + ttl: u32, + proxied: bool, + locked: bool, + zone_id: Box, + zone_name: Box, + modified_on: Box, + created_on: Box, + meta: HashMap, +} + +impl CloudflareClient { + pub async fn new(api_key: Box, zone_id: Box, domains: Vec>) -> Result { + let force_ipv4 = IpAddr::from([0, 0, 0, 0]); + let client = reqwest::ClientBuilder::new() + .local_address(force_ipv4) + .build()?; + + let current_ip = get_current_public_ipv4(&client).await?; + + Ok(Self { + client, + domains, + current_ip, + api_key, + zone_id, + }) + } + + pub async fn check(&mut self) -> Result<()> { + let new_ip = get_current_public_ipv4(&self.client).await?; + if new_ip == self.current_ip { + return Ok(()); + } + self.update_dns_records(new_ip).await; + self.current_ip = new_ip; + + Ok(()) + } + + async fn update_dns_records(&self, new_ip: Ipv4Addr) { + for domain in &self.domains { + let records = match self.get_dns_records(domain).await { + Ok(r) => r, + Err(e) => { + error!( + "Could not getch dns records for domain '{}': {:?}", + &domain, &e + ); + continue; + } + }; + + let new_ip_s = new_ip.to_string().into_boxed_str(); + for mut record in records.into_iter() { + if record.content == new_ip_s { + continue; + } + info!( + "On {}, updating {}: '{}' -> '{}'", + &domain, record.name, record.content, &new_ip_s + ); + if let Err(e) = self.update_dns_record(&mut record, new_ip_s.clone()).await { + error!( + "On {}, failed to update {}: '{}' -> '{}': {:?}", + &domain, record.name, record.content, &new_ip_s, &e + ); + } + } + } + } + + async fn get_dns_records(&self, domain: &str) -> Result> { + let url = format!( + "https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A&name={}", + self.zone_id, domain + ); + let mut response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .send() + .await? + .error_for_status()? + .json::>>() + .await?; + + response + .remove("result") + .context("Key result not in return") + } + + async fn update_dns_record(&self, record: &mut DnsRecord, ip_content: Box) -> Result<()> { + let url = format!( + "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", + self.zone_id, record.id + ); + + record.content = ip_content; + self.client + .put(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&record) + .send() + .await? + .error_for_status()?; + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index a7cfe87..defa550 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,9 @@ +use anyhow; use dirs; use log::warn; use serde::{Deserialize, Serialize}; -use std::{env, fs, path::PathBuf}; +use std::{env, path::PathBuf}; +use tokio::{fs, io::AsyncReadExt}; use crate::PROGRAM_NAME; @@ -11,11 +13,11 @@ pub struct Config { pub cloudflare_api_key: Box, } -pub fn get_config_path() -> Option { +pub async fn get_config_path() -> Option { match env::current_dir() { Ok(current_dir) => { let cwd_config = current_dir.join(format!("{}.toml", PROGRAM_NAME)); - if let Ok(meta) = fs::metadata(&cwd_config) { + if let Ok(meta) = fs::metadata(&cwd_config).await { if meta.is_file() { return Some(cwd_config); } @@ -29,7 +31,7 @@ pub fn get_config_path() -> Option { match dirs::config_dir() { Some(config_dir) => { let config_file = config_dir.join(PROGRAM_NAME).join("config.toml"); - if let Ok(meta) = fs::metadata(&config_file) { + if let Ok(meta) = fs::metadata(&config_file).await { if meta.is_file() { return Some(config_file); } @@ -41,8 +43,9 @@ pub fn get_config_path() -> Option { None } -pub fn read_config(path: PathBuf) -> Option{ - - - None -} \ No newline at end of file +pub async fn read_config(path: &PathBuf) -> anyhow::Result { + let mut file = fs::File::open(&path).await?; + let mut buf = String::new(); + file.read_to_string(&mut buf).await?; + Ok(toml::from_str(&buf)?) +} diff --git a/src/lib.rs b/src/lib.rs index e848e0e..9c3782b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ mod cloudflare; mod config; -pub use config::{Config, get_config_path}; +pub use config::{Config, get_config_path, read_config}; pub const PROGRAM_NAME: &'static str = "dynip-cloudflare"; diff --git a/src/main.rs b/src/main.rs index a0046c0..b00d3dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use anyhow::{anyhow, Result}; use env_logger; use futures::stream::StreamExt; use log::{debug, error, info, log_enabled, Level}; @@ -7,7 +6,7 @@ use netlink_packet_route::RouteNetlinkMessage as RtnlMessage; use netlink_sys::{AsyncSocket, SocketAddr}; use rtnetlink::new_connection; -use dynip_cloudflare::{get_config_path, Config}; +use dynip_cloudflare::{get_config_path, read_config, Config}; const RTNLGRP_LINK: u32 = 1; const RTNLGRP_IPV4_IFADDR: u32 = 5; @@ -24,24 +23,31 @@ const fn nl_mgrp(group: u32) -> u32 { } #[tokio::main] -async fn main() -> Result<()> { +async fn main() { env_logger::init(); - let config_path = match get_config_path() { + let config_path = match get_config_path().await { Some(cp) => cp, None => { error!("Failed to find any config file"); - return Ok(()); + return; + } + }; + let config = match read_config(&config_path).await { + Ok(config) => config, + Err(e) => { + error!("Failed to read and parse config file {:?}", &e); + return; } }; - let (mut conn, mut _handle, mut messages) = new_connection().map_err(|e| anyhow!(e))?; + let (mut conn, mut _handle, mut messages) = new_connection().unwrap(); let groups = nl_mgrp(RTNLGRP_LINK) | nl_mgrp(RTNLGRP_IPV4_IFADDR); let addr = SocketAddr::new(0, groups); if let Err(e) = conn.socket_mut().socket_mut().bind(&addr) { error!("Failed to bind to socket: {:?}", &e); - return Err(anyhow!(e)); + return; } tokio::spawn(conn); @@ -84,6 +90,4 @@ async fn main() -> Result<()> { } } } - - Ok(()) }