Compare commits

..

6 commits

Author SHA1 Message Date
Kaan Barmore-Genç 84bef554b0
ignore signature files 2022-08-23 01:32:57 -04:00
Kaan Barmore-Genç 3daee43540
1.4.0 2022-08-23 01:21:28 -04:00
Kaan Barmore-Genç e91a9c5c4f
Update deps 2022-08-23 01:20:30 -04:00
Kaan Barmore-Genç eaabec35b4
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
2022-08-23 01:17:17 -04:00
Kaan Barmore-Genç 98e2931493
Update shields 2022-08-22 23:47:51 -04:00
Kaan Barmore-Genç 90c5485cce
Add first test (#38)
* wip

* wip implement test

* complete test

* add lint and test actions

* fix bad format
2022-08-22 22:20:10 -04:00
17 changed files with 1285 additions and 130 deletions

17
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,17 @@
on: push
name: lint checks
jobs:
rust_lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: rustup component add clippy
- uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features
rust_format_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: cargo fmt --all --check

25
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: test
on:
push:
branches:
- "main"
pull_request:
branches:
- "*"
jobs:
test:
name: run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup toolchain install stable --component llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate code coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: lcov.info
fail_ci_if_error: true

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
/target /target
gandi.toml gandi.toml
*.tar* *.tar*
*.zip *.zip
*.sig

View file

@ -3,5 +3,6 @@
"gandi", "gandi",
"rrset", "rrset",
"structopt" "structopt"
] ],
"editor.formatOnSave": true
} }

982
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "gandi-live-dns" name = "gandi-live-dns"
version = "1.3.0" version = "1.4.0"
edition = "2021" edition = "2021"
authors = ["Kaan Barmore-Genç <kaan@bgenc.net>"] authors = ["Kaan Barmore-Genç <kaan@bgenc.net>"]
@ -9,16 +9,28 @@ strip = "symbols"
lto = true lto = true
[dependencies] [dependencies]
reqwest = { version = "0.11", default-features = false, features = [
reqwest = { version = "0.11", default-features= false, features = ["json", "rustls-tls"] } "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"
governor = "0.4" governor = "0.4"
async-trait = "0.1"
# TODO: Relies on a yet-unreleased interface. Switch to an actual crate release once available # TODO: Relies on a yet-unreleased interface. Switch to an actual crate release once available
die-exit = { git = "https://github.com/Xavientois/die.git", rev = "31d3801f4e21654b0b28430987b1e21fc7728676" } die-exit = { git = "https://github.com/Xavientois/die.git", rev = "31d3801f4e21654b0b28430987b1e21fc7728676" }
[dev-dependencies]
httpmock = "0.6"
regex = "1.6"

View file

@ -1,6 +1,6 @@
## gandi-live-dns-rust ## gandi-live-dns-rust
[![Releases](https://img.shields.io/github/v/release/SeriousBug/gandi-live-dns-rust?include_prereleases)](https://github.com/SeriousBug/gandi-live-dns-rust/releases) [![Docker Image Size](https://img.shields.io/docker/image-size/seriousbug/gandi-live-dns-rust)](https://hub.docker.com/r/seriousbug/gandi-live-dns-rust) [![MIT license](https://img.shields.io/github/license/SeriousBug/gandi-live-dns-rust)](https://github.com/SeriousBug/gandi-live-dns-rust/blob/master/LICENSE.txt) [![tests](https://img.shields.io/github/workflow/status/SeriousBug/gandi-live-dns-rust/test?label=tests)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/test.yml) [![lint checks](https://img.shields.io/github/workflow/status/SeriousBug/gandi-live-dns-rust/lint%20checks?label=lints)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/lint.yml) [![Releases](https://img.shields.io/github/v/release/SeriousBug/gandi-live-dns-rust?include_prereleases)](https://github.com/SeriousBug/gandi-live-dns-rust/releases) [![Docker Image Size](https://img.shields.io/docker/image-size/seriousbug/gandi-live-dns-rust)](https://hub.docker.com/r/seriousbug/gandi-live-dns-rust) [![MIT license](https://img.shields.io/github/license/SeriousBug/gandi-live-dns-rust)](https://github.com/SeriousBug/gandi-live-dns-rust/blob/master/LICENSE.txt)
A program that can set the IP addresses for configured DNS entries in A program that can set the IP addresses for configured DNS entries in
[Gandi](https://gandi.net)'s domain configuration. Thanks to Gandi's [Gandi](https://gandi.net)'s domain configuration. Thanks to Gandi's

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

@ -13,12 +13,30 @@ pub struct Entry {
ttl: Option<u32>, ttl: Option<u32>,
} }
fn default_ttl() -> u32 { return 300; } 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)] #[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,
@ -80,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);
}
}

15
src/gandi/mod.rs Normal file
View file

@ -0,0 +1,15 @@
pub(crate) struct GandiAPI<'t> {
pub(crate) base_url: &'t str,
pub(crate) fqdn: &'t str,
pub(crate) rrset_name: &'t str,
pub(crate) rrset_type: &'t str,
}
impl<'t> GandiAPI<'t> {
pub(crate) fn url(&self) -> String {
format!(
"{}/v5/livedns/domains/{}/records/{}/{}",
self.base_url, self.fqdn, self.rrset_name, self.rrset_type
)
}
}

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

@ -0,0 +1,8 @@
use anyhow;
use async_trait::async_trait;
#[async_trait]
pub trait IPSource {
async fn get_ipv4() -> anyhow::Result<String>;
async fn get_ipv6() -> anyhow::Result<String>;
}

52
src/ip_source/ipify.rs Normal file
View file

@ -0,0 +1,52 @@
use anyhow;
use async_trait::async_trait;
use super::ip_source::IPSource;
pub(crate) struct IPSourceIpify {}
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 IPSourceIpify {
async fn get_ipv4() -> anyhow::Result<String> {
get_ip("https://api.ipify.org").await
}
async fn get_ipv6() -> anyhow::Result<String> {
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()))
}
}

3
src/ip_source/mod.rs Normal file
View file

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

View file

@ -1,29 +1,27 @@
use crate::config::Config; use crate::config::Config;
use crate::gandi::GandiAPI;
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 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};
use tokio::{self, task::JoinHandle}; use tokio::{self, task::JoinHandle};
mod config; mod config;
mod gandi;
mod ip_source;
mod opts; mod opts;
use die_exit::*; use die_exit::*;
use governor; use governor;
/// 30 requests per minute, see https://api.gandi.net/docs/reference/ /// 30 requests per minute, see https://api.gandi.net/docs/reference/
const GANDI_RATE_LIMIT: u32 = 30; const GANDI_RATE_LIMIT: u32 = 30;
/// If we hit the rate limit, wait up to this many seconds before next attempt /// If we hit the rate limit, wait up to this many seconds before next attempt
const GANDI_DELAY_JITTER: u64 = 20; const GANDI_DELAY_JITTER: u64 = 20;
fn gandi_api_url(fqdn: &str, rrset_name: &str, rrset_type: &str) -> String {
return format!(
" https://api.gandi.net/v5/livedns/domains/{}/records/{}/{}",
fqdn, rrset_name, rrset_type
);
}
fn api_client(api_key: &str) -> anyhow::Result<Client> { fn api_client(api_key: &str) -> anyhow::Result<Client> {
let client_builder = ClientBuilder::new(); let client_builder = ClientBuilder::new();
@ -38,27 +36,17 @@ fn api_client(api_key: &str) -> anyhow::Result<Client> {
return Ok(client); return Ok(client);
} }
async fn get_ip(api_url: &str) -> anyhow::Result<String> {
let response = reqwest::get(api_url).await?;
let text = response.text().await?;
Ok(text)
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct APIPayload { pub struct APIPayload {
pub rrset_values: Vec<String>, pub rrset_values: Vec<String>,
pub rrset_ttl: u32, pub rrset_ttl: u32,
} }
#[tokio::main(flavor = "current_thread")] async fn run<IP: IPSource>(base_url: &str, conf: Config) -> anyhow::Result<()> {
async fn main() -> anyhow::Result<()> {
let opts = opts::Opts::parse();
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 = get_ip("https://api.ipify.org").await; let ipv4_result = IP::get_ipv4().await;
let ipv6_result = get_ip("https://api6.ipify.org").await; let ipv6_result = IP::get_ipv6().await;
let ipv4 = ipv4_result.as_ref(); let ipv4 = ipv4_result.as_ref();
let ipv6 = ipv6_result.as_ref(); let ipv6 = ipv6_result.as_ref();
println!("Found these:"); println!("Found these:");
@ -84,10 +72,16 @@ async fn main() -> anyhow::Result<()> {
for entry in &conf.entry { for entry in &conf.entry {
for entry_type in Config::types(entry) { for entry_type in Config::types(entry) {
let fqdn = Config::fqdn(&entry, &conf).to_string(); let fqdn = Config::fqdn(&entry, &conf).to_string();
let url = gandi_api_url(&fqdn, entry.name.as_str(), entry_type); let url = GandiAPI {
fqdn: &fqdn,
rrset_name: &entry.name,
rrset_type: &entry_type,
base_url,
}
.url();
let ip = match entry_type { let ip = match entry_type {
"A" => ipv4.die_with(|error| format!("Needed IPv4 for {}: {}", fqdn, error)), "A" => ipv4.die_with(|error| format!("Needed IPv4 for {fqdn}: {error}")),
"AAAA" => ipv6.die_with(|error| format!("Needed IPv6 for {}: {}", fqdn, error)), "AAAA" => ipv6.die_with(|error| format!("Needed IPv6 for {fqdn}: {error}")),
bad_entry_type => die!("Unexpected type in config: {}", bad_entry_type), bad_entry_type => die!("Unexpected type in config: {}", bad_entry_type),
}; };
let payload = APIPayload { let payload = APIPayload {
@ -121,5 +115,77 @@ async fn main() -> anyhow::Result<()> {
println!("{} - {}", status, body); println!("{} - {}", status, body);
} }
return Ok(()); Ok(())
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let opts = opts::Opts::parse();
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::{config, ip_source::ip_source::IPSource, opts::Opts, run};
use async_trait::async_trait;
use httpmock::MockServer;
use tokio::fs;
struct IPSourceMock {}
#[async_trait]
impl IPSource for IPSourceMock {
async fn get_ipv4() -> anyhow::Result<String> {
Ok("192.168.0.0".to_string())
}
async fn get_ipv6() -> anyhow::Result<String> {
Ok("fe80:0000:0208:74ff:feda:625c".to_string())
}
}
#[tokio::test]
async fn create_repo_success_test() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp)
.await
.expect("Failed to create test dir");
temp.push("test.toml");
fs::write(
&temp,
"fqdn = \"example.com\"\napi_key = \"xxx\"\nttl = 300\n[[entry]]\nname =\"@\"\n",
)
.await
.expect("Failed to write test config file");
let fqdn = "example.com";
let rname = "@";
let rtype = "A";
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0");
then.status(200);
});
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();
}
} }

View file

@ -7,5 +7,4 @@ pub struct Opts {
/// The path to the configuration file. /// The path to the configuration file.
#[clap(long)] #[clap(long)]
pub config: Option<String>, pub config: Option<String>,
} }