From eaabec35b44bba791de2d3f00f996d9ec5e7ae08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Barmore-Gen=C3=A7?= Date: Tue, 23 Aug 2022 01:17:17 -0400 Subject: [PATCH] Add option to use icanhazip as an IP source (#40) * Add option to use icanhazip as an IP source * skip IP source API tests in CI --- Cargo.lock | 1 + Cargo.toml | 13 +++++- example.toml | 7 +++ src/config.rs | 91 ++++++++++++++++++++++++++++++++++++++ src/ip_source/Readme.md | 6 +++ src/ip_source/icanhazip.rs | 60 +++++++++++++++++++++++++ src/ip_source/ipify.rs | 30 +++++++++++++ src/ip_source/mod.rs | 1 + src/main.rs | 32 ++++++++------ 9 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 src/ip_source/Readme.md create mode 100644 src/ip_source/icanhazip.rs diff --git a/Cargo.lock b/Cargo.lock index f997e20..166daf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -643,6 +643,7 @@ dependencies = [ "governor", "httpmock", "json", + "regex", "reqwest", "serde", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d8e37e2..1091bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,20 @@ strip = "symbols" lto = true [dependencies] -reqwest = { version = "0.11", default-features= false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.11", default-features = false, features = [ + "json", + "rustls-tls", +] } toml = "0.5" json = "0.12" serde = { version = "1.0", features = ["derive"] } directories = "4.0" -clap = { version = "3.2", features = ["derive", "cargo", "unicode", "wrap_help"]} +clap = { version = "3.2", features = [ + "derive", + "cargo", + "unicode", + "wrap_help", +] } tokio = { version = "1.20", features = ["full"] } futures = "0.3" anyhow = "1.0" @@ -25,3 +33,4 @@ die-exit = { git = "https://github.com/Xavientois/die.git", rev = "31d3801f4e216 [dev-dependencies] httpmock = "0.6" +regex = "1.6" diff --git a/example.toml b/example.toml index 7f1d9be..7b10121 100644 --- a/example.toml +++ b/example.toml @@ -15,6 +15,13 @@ api_key = "xxxxxxxxxxxxxxxxxxxxxxxx" # your IP address propagate quickly. ttl = 300 +# Where to query your IP address from. These options are free and unlimited. +# Ipify is used by default. If you want to change it, uncomment the one you want +# to use. +# +# ip_source = "Ipify" +# ip_source = "Icanhazip" + # For every domain or subdomain you want to update, create an entry below. [[entry]] diff --git a/src/config.rs b/src/config.rs index 59f5e37..f8aaec5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,10 +17,26 @@ fn default_ttl() -> u32 { return 300; } +#[derive(Deserialize, PartialEq, Debug)] +pub enum IPSourceName { + Ipify, + Icanhazip, +} + +impl Default for IPSourceName { + fn default() -> Self { + // Ipify was the first IP source gandi-live-dns had, before it supported + // multiple sources. Keeping that as the default. + Self::Ipify + } +} + #[derive(Deserialize, Debug)] pub struct Config { fqdn: String, pub api_key: String, + #[serde(default)] + pub ip_source: IPSourceName, pub entry: Vec, #[serde(default = "default_ttl")] pub ttl: u32, @@ -82,3 +98,78 @@ pub fn validate_config(config: &Config) -> anyhow::Result<()> { } return Ok(()); } + +#[cfg(test)] +mod tests { + use super::load_config; + use crate::{config::IPSourceName, opts::Opts}; + use std::{env::temp_dir, fs}; + + #[test] + fn load_config_test() { + let mut temp = temp_dir().join("gandi-live-dns-test"); + fs::create_dir_all(&temp).expect("Failed to create test dir"); + temp.push("test-1.toml"); + fs::write( + &temp, + r#" +fqdn = "example.com" +api_key = "xxx" +ttl = 300 + +[[entry]] +name = "@" +"#, + ) + .expect("Failed to write test config file"); + + let opts = Opts { + config: Some(temp.to_string_lossy().to_string()), + }; + let conf = load_config(&opts).expect("Failed to load config file"); + + assert_eq!(conf.fqdn, "example.com"); + assert_eq!(conf.api_key, "xxx"); + assert_eq!(conf.ttl, 300); + assert_eq!(conf.entry.len(), 1); + assert_eq!(conf.entry[0].name, "@"); + // default + assert_eq!(conf.ip_source, IPSourceName::Ipify); + } + + #[test] + fn load_config_change_ip_source() { + let mut temp = temp_dir().join("gandi-live-dns-test"); + fs::create_dir_all(&temp).expect("Failed to create test dir"); + temp.push("test-2.toml"); + fs::write( + &temp, + r#" +fqdn = "example.com" +api_key = "yyy" +ttl = 1200 +ip_source = "Icanhazip" + +[[entry]] +name = "www" + +[[entry]] +name = "@" +"#, + ) + .expect("Failed to write test config file"); + + let opts = Opts { + config: Some(temp.to_string_lossy().to_string()), + }; + let conf = load_config(&opts).expect("Failed to load config file"); + + assert_eq!(conf.fqdn, "example.com"); + assert_eq!(conf.api_key, "yyy"); + assert_eq!(conf.ttl, 1200); + assert_eq!(conf.entry.len(), 2); + assert_eq!(conf.entry[0].name, "www"); + assert_eq!(conf.entry[1].name, "@"); + assert_eq!(conf.ip_source, IPSourceName::Icanhazip); + } +} diff --git a/src/ip_source/Readme.md b/src/ip_source/Readme.md new file mode 100644 index 0000000..2b641ea --- /dev/null +++ b/src/ip_source/Readme.md @@ -0,0 +1,6 @@ +The IP sources. These are APIs that we can query to get the IP address of the +current service. + +The tests under this directory are all marked to be skipped, the tests hit the +actual APIs and can be flakey in CI. Make sure to run the tests manually if you +have to modify the code. diff --git a/src/ip_source/icanhazip.rs b/src/ip_source/icanhazip.rs new file mode 100644 index 0000000..7f28a2b --- /dev/null +++ b/src/ip_source/icanhazip.rs @@ -0,0 +1,60 @@ +use anyhow; +use async_trait::async_trait; + +use super::ip_source::IPSource; + +pub(crate) struct IPSourceIcanhazip {} + +async fn get_ip(api_url: &str) -> anyhow::Result { + let response = reqwest::get(api_url).await?; + let text = response.text().await?; + Ok(text) +} + +#[async_trait] +impl IPSource for IPSourceIcanhazip { + async fn get_ipv4() -> anyhow::Result { + Ok(get_ip("https://ipv4.icanhazip.com") + .await? + // icanazip puts a newline at the end + .trim() + .to_string()) + } + async fn get_ipv6() -> anyhow::Result { + Ok(get_ip("https://ipv6.icanhazip.com") + .await? + // icanazip puts a newline at the end + .trim() + .to_string()) + } +} + +#[cfg(test)] +mod tests { + use regex::Regex; + + use super::IPSource; + use super::IPSourceIcanhazip; + + #[tokio::test] + #[ignore] + async fn ipv4_test() { + let ipv4 = IPSourceIcanhazip::get_ipv4() + .await + .expect("Failed to get the IP address"); + assert!(Regex::new(r"^\d+[.]\d+[.]\d+[.]\d+$") + .unwrap() + .is_match(ipv4.as_str())) + } + + #[tokio::test] + #[ignore] + async fn ipv6_test() { + let ipv6 = IPSourceIcanhazip::get_ipv6() + .await + .expect("Failed to get the IP address"); + assert!(Regex::new(r"^([0-9a-fA-F]*:){7}[0-9a-fA-F]*$") + .unwrap() + .is_match(ipv6.as_str())) + } +} diff --git a/src/ip_source/ipify.rs b/src/ip_source/ipify.rs index f418562..304fa2c 100644 --- a/src/ip_source/ipify.rs +++ b/src/ip_source/ipify.rs @@ -20,3 +20,33 @@ impl IPSource for IPSourceIpify { get_ip("https://api6.ipify.org").await } } + +#[cfg(test)] +mod tests { + use regex::Regex; + + use super::IPSource; + use super::IPSourceIpify; + + #[tokio::test] + #[ignore] + async fn ipv4_test() { + let ipv4 = IPSourceIpify::get_ipv4() + .await + .expect("Failed to get the IP address"); + assert!(Regex::new(r"^\d+[.]\d+[.]\d+[.]\d+$") + .unwrap() + .is_match(ipv4.as_str())) + } + + #[tokio::test] + #[ignore] + async fn ipv6_test() { + let ipv6 = IPSourceIpify::get_ipv6() + .await + .expect("Failed to get the IP address"); + assert!(Regex::new(r"^([0-9a-fA-F]*:){7}[0-9a-fA-F]*$") + .unwrap() + .is_match(ipv6.as_str())) + } +} diff --git a/src/ip_source/mod.rs b/src/ip_source/mod.rs index f8070e9..7dcf7c5 100644 --- a/src/ip_source/mod.rs +++ b/src/ip_source/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod icanhazip; pub(crate) mod ip_source; pub(crate) mod ipify; diff --git a/src/main.rs b/src/main.rs index f68cea6..89afc86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,8 +3,9 @@ use crate::gandi::GandiAPI; use crate::ip_source::{ip_source::IPSource, ipify::IPSourceIpify}; use anyhow; use clap::Parser; +use config::IPSourceName; use futures; -use opts::Opts; +use ip_source::icanhazip::IPSourceIcanhazip; use reqwest::{header, Client, ClientBuilder, StatusCode}; use serde::Serialize; use std::{num::NonZeroU32, sync::Arc, time::Duration}; @@ -41,9 +42,7 @@ pub struct APIPayload { pub rrset_ttl: u32, } -async fn run(base_url: &str, opts: Opts) -> anyhow::Result<()> { - let conf = config::load_config(&opts) - .die_with(|error| format!("Failed to read config file: {}", error)); +async fn run(base_url: &str, conf: Config) -> anyhow::Result<()> { config::validate_config(&conf).die_with(|error| format!("Invalid config: {}", error)); println!("Finding out the IP address..."); let ipv4_result = IP::get_ipv4().await; @@ -122,14 +121,20 @@ async fn run(base_url: &str, opts: Opts) -> anyhow::Result<()> { #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { let opts = opts::Opts::parse(); - run::("https://api.gandi.net", opts).await + let conf = config::load_config(&opts) + .die_with(|error| format!("Failed to read config file: {}", error)); + + match conf.ip_source { + IPSourceName::Ipify => run::("https://api.gandi.net", conf).await, + IPSourceName::Icanhazip => run::("https://api.gandi.net", conf).await, + } } #[cfg(test)] mod tests { use std::env::temp_dir; - use crate::{ip_source::ip_source::IPSource, opts::Opts, run}; + use crate::{config, ip_source::ip_source::IPSource, opts::Opts, run}; use async_trait::async_trait; use httpmock::MockServer; use tokio::fs; @@ -172,14 +177,13 @@ mod tests { then.status(200); }); - run::( - server.base_url().as_str(), - Opts { - config: Some(temp.to_string_lossy().to_string()), - }, - ) - .await - .expect("Failed when running the update"); + let opts = Opts { + config: Some(temp.to_string_lossy().to_string()), + }; + let conf = config::load_config(&opts).expect("Failed to load config"); + run::(server.base_url().as_str(), conf) + .await + .expect("Failed when running the update"); // Assert mock.assert();