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
This commit is contained in:
Kaan Barmore-Genç 2022-08-23 01:17:17 -04:00 committed by GitHub
parent 98e2931493
commit eaabec35b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 225 additions and 16 deletions

1
Cargo.lock generated
View file

@ -643,6 +643,7 @@ dependencies = [
"governor", "governor",
"httpmock", "httpmock",
"json", "json",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"tokio", "tokio",

View file

@ -9,12 +9,20 @@ strip = "symbols"
lto = true lto = true
[dependencies] [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" toml = "0.5"
json = "0.12" json = "0.12"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
directories = "4.0" 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"] } tokio = { version = "1.20", features = ["full"] }
futures = "0.3" futures = "0.3"
anyhow = "1.0" anyhow = "1.0"
@ -25,3 +33,4 @@ die-exit = { git = "https://github.com/Xavientois/die.git", rev = "31d3801f4e216
[dev-dependencies] [dev-dependencies]
httpmock = "0.6" httpmock = "0.6"
regex = "1.6"

View file

@ -15,6 +15,13 @@ api_key = "xxxxxxxxxxxxxxxxxxxxxxxx"
# your IP address propagate quickly. # your IP address propagate quickly.
ttl = 300 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. # For every domain or subdomain you want to update, create an entry below.
[[entry]] [[entry]]

View file

@ -17,10 +17,26 @@ fn default_ttl() -> u32 {
return 300; 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)] #[derive(Deserialize, Debug)]
pub struct Config { pub struct Config {
fqdn: String, fqdn: String,
pub api_key: String, pub api_key: String,
#[serde(default)]
pub ip_source: IPSourceName,
pub entry: Vec<Entry>, pub entry: Vec<Entry>,
#[serde(default = "default_ttl")] #[serde(default = "default_ttl")]
pub ttl: u32, pub ttl: u32,
@ -82,3 +98,78 @@ pub fn validate_config(config: &Config) -> anyhow::Result<()> {
} }
return Ok(()); 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);
}
}

6
src/ip_source/Readme.md Normal file
View file

@ -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.

View file

@ -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<String> {
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<String> {
Ok(get_ip("https://ipv4.icanhazip.com")
.await?
// icanazip puts a newline at the end
.trim()
.to_string())
}
async fn get_ipv6() -> anyhow::Result<String> {
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()))
}
}

View file

@ -20,3 +20,33 @@ impl IPSource for IPSourceIpify {
get_ip("https://api6.ipify.org").await 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()))
}
}

View file

@ -1,2 +1,3 @@
pub(crate) mod icanhazip;
pub(crate) mod ip_source; pub(crate) mod ip_source;
pub(crate) mod ipify; pub(crate) mod ipify;

View file

@ -3,8 +3,9 @@ use crate::gandi::GandiAPI;
use crate::ip_source::{ip_source::IPSource, ipify::IPSourceIpify}; use crate::ip_source::{ip_source::IPSource, ipify::IPSourceIpify};
use anyhow; use anyhow;
use clap::Parser; use clap::Parser;
use config::IPSourceName;
use futures; use futures;
use opts::Opts; use ip_source::icanhazip::IPSourceIcanhazip;
use reqwest::{header, Client, ClientBuilder, StatusCode}; use reqwest::{header, Client, ClientBuilder, StatusCode};
use serde::Serialize; use serde::Serialize;
use std::{num::NonZeroU32, sync::Arc, time::Duration}; use std::{num::NonZeroU32, sync::Arc, time::Duration};
@ -41,9 +42,7 @@ pub struct APIPayload {
pub rrset_ttl: u32, pub rrset_ttl: u32,
} }
async fn run<IP: IPSource>(base_url: &str, opts: Opts) -> anyhow::Result<()> { async fn run<IP: IPSource>(base_url: &str, conf: Config) -> anyhow::Result<()> {
let conf = config::load_config(&opts)
.die_with(|error| format!("Failed to read config file: {}", error));
config::validate_config(&conf).die_with(|error| format!("Invalid config: {}", error)); config::validate_config(&conf).die_with(|error| format!("Invalid config: {}", error));
println!("Finding out the IP address..."); println!("Finding out the IP address...");
let ipv4_result = IP::get_ipv4().await; let ipv4_result = IP::get_ipv4().await;
@ -122,14 +121,20 @@ async fn run<IP: IPSource>(base_url: &str, opts: Opts) -> anyhow::Result<()> {
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let opts = opts::Opts::parse(); let opts = opts::Opts::parse();
run::<IPSourceIpify>("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::<IPSourceIpify>("https://api.gandi.net", conf).await,
IPSourceName::Icanhazip => run::<IPSourceIcanhazip>("https://api.gandi.net", conf).await,
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::env::temp_dir; 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 async_trait::async_trait;
use httpmock::MockServer; use httpmock::MockServer;
use tokio::fs; use tokio::fs;
@ -172,14 +177,13 @@ mod tests {
then.status(200); then.status(200);
}); });
run::<IPSourceMock>( let opts = Opts {
server.base_url().as_str(), config: Some(temp.to_string_lossy().to_string()),
Opts { };
config: Some(temp.to_string_lossy().to_string()), let conf = config::load_config(&opts).expect("Failed to load config");
}, run::<IPSourceMock>(server.base_url().as_str(), conf)
) .await
.await .expect("Failed when running the update");
.expect("Failed when running the update");
// Assert // Assert
mock.assert(); mock.assert();