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",
"httpmock",
"json",
"regex",
"reqwest",
"serde",
"tokio",

View file

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

View file

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

View file

@ -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<Entry>,
#[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);
}
}

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
}
}
#[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 ipify;

View file

@ -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<IP: IPSource>(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<IP: IPSource>(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<IP: IPSource>(base_url: &str, opts: Opts) -> anyhow::Result<()> {
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
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)]
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::<IPSourceMock>(
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::<IPSourceMock>(server.base_url().as_str(), conf)
.await
.expect("Failed when running the update");
// Assert
mock.assert();