Add option to use icanhazip as an IP source

This commit is contained in:
Kaan Barmore-Genç 2022-08-23 01:02:36 -04:00
parent 98e2931493
commit 9a685be5cb
Signed by: kaan
GPG key ID: B2E280771CD62FCF
8 changed files with 215 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);
}
}

View file

@ -0,0 +1,58 @@
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]
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]
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,31 @@ 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]
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]
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();