Skip updating the IP address if it did not change (#88)

* Skip updating the IP address if it did not change

* Update readme
This commit is contained in:
Kaan Barmore-Genç 2023-02-01 02:00:09 -05:00 committed by GitHub
parent f8060fad42
commit 327b14a00a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 258 additions and 95 deletions

View File

@ -1,5 +1,7 @@
## gandi-live-dns-rust ## Gandi Live Dns Rust <!-- omit in toc -->
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?label=contributors)](#contributors) <!-- ALL-CONTRIBUTORS-BADGE:END --> [![Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?label=contributors)](#contributors) <!-- ALL-CONTRIBUTORS-BADGE:END -->
[![tests](https://img.shields.io/github/actions/workflow/status/SeriousBug/gandi-live-dns-rust/test.yml?label=tests&branch=master)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/test.yml) [![tests](https://img.shields.io/github/actions/workflow/status/SeriousBug/gandi-live-dns-rust/test.yml?label=tests&branch=master)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/test.yml)
[![Test coverage report](https://img.shields.io/codecov/c/github/SeriousBug/gandi-live-dns-rust)](https://codecov.io/gh/SeriousBug/gandi-live-dns-rust) [![Test coverage report](https://img.shields.io/codecov/c/github/SeriousBug/gandi-live-dns-rust)](https://codecov.io/gh/SeriousBug/gandi-live-dns-rust)
@ -19,6 +21,23 @@ program can update both IPv4 and IPv6 addresses for one or more domains and
subdomains. It can be used as a one-shot tool managed with a systemd timer subdomains. It can be used as a one-shot tool managed with a systemd timer
or cron, or a long-running process that reschedules itself. or cron, or a long-running process that reschedules itself.
## Table of Contents <!-- omit in toc -->
- [Usage](#usage)
- [System packages](#system-packages)
- [Prebuilt binaries](#prebuilt-binaries)
- [With docker](#with-docker)
- [From source](#from-source)
- [Automation](#automation)
- [By running as a background process](#by-running-as-a-background-process)
- [Skipped updates](#skipped-updates)
- [With a Systemd timer](#with-a-systemd-timer)
- [Development](#development)
- [Local builds](#local-builds)
- [Making a release](#making-a-release)
- [Alternatives](#alternatives)
- [Contributors](#contributors)
## Usage ## Usage
The Gandi Live DNS API is rate limited at 30 requests per minute. This program The Gandi Live DNS API is rate limited at 30 requests per minute. This program
@ -90,15 +109,23 @@ docker run --rm -it -v $(pwd)/gandi.toml:/gandi.toml:ro seriousbug/gandi-live-dn
Or with a `docker-compose.yml` file, add it in the arguments: Or with a `docker-compose.yml` file, add it in the arguments:
```yml ```yml
gandi-live-dns: gandi-live-dns:
image: seriousbug/gandi-live-dns-rust:latest image: seriousbug/gandi-live-dns-rust:latest
restart: always restart: always
volumes: volumes:
- ./gandi.toml:/gandi.toml:ro - ./gandi.toml:/gandi.toml:ro
# Repeat the update every day # Repeat the update every day
command: --repeat=86400 command: --repeat=86400
``` ```
#### Skipped updates
In background process mode, the tool will avoid sending an update to Gandi if
your IP address has not changed since the last update. This only works so long
as the tool continues to run, it will send an update when restarted even if your
IP address has not changed. You can also override this behavior by adding
`always_update = true` to the top of your config file.
### With a Systemd timer ### With a Systemd timer
The `Packaging` folder contains a Systemd service and timer, which you can use The `Packaging` folder contains a Systemd service and timer, which you can use

View File

@ -44,6 +44,8 @@ pub struct Config {
pub entry: Vec<Entry>, pub entry: Vec<Entry>,
#[serde(default = "default_ttl")] #[serde(default = "default_ttl")]
pub ttl: u32, pub ttl: u32,
#[serde(default)]
pub always_update: bool,
} }
const DEFAULT_TYPES: &[&str] = &["A"]; const DEFAULT_TYPES: &[&str] = &["A"];
@ -156,6 +158,7 @@ name = "@"
assert_eq!(conf.entry[1].types, vec!["A".to_string()]); assert_eq!(conf.entry[1].types, vec!["A".to_string()]);
// default // default
assert_eq!(conf.ip_source, IPSourceName::Ipify); assert_eq!(conf.ip_source, IPSourceName::Ipify);
assert_eq!(conf.always_update, false);
} }
#[test] #[test]
@ -170,6 +173,7 @@ fqdn = "example.com"
api_key = "yyy" api_key = "yyy"
ttl = 1200 ttl = 1200
ip_source = "Icanhazip" ip_source = "Icanhazip"
always_update = true
[[entry]] [[entry]]
name = "www" name = "www"
@ -193,6 +197,7 @@ name = "@"
assert_eq!(conf.entry[0].name, "www"); assert_eq!(conf.entry[0].name, "www");
assert_eq!(conf.entry[1].name, "@"); assert_eq!(conf.entry[1].name, "@");
assert_eq!(conf.ip_source, IPSourceName::Icanhazip); assert_eq!(conf.ip_source, IPSourceName::Icanhazip);
assert_eq!(conf.always_update, true);
} }
#[test] #[test]

View File

@ -4,6 +4,7 @@ use crate::ip_source::{ip_source::IPSource, ipify::IPSourceIpify};
use clap::Parser; use clap::Parser;
use config::IPSourceName; use config::IPSourceName;
use ip_source::icanhazip::IPSourceIcanhazip; use ip_source::icanhazip::IPSourceIcanhazip;
use opts::Opts;
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};
@ -40,76 +41,109 @@ pub struct APIPayload {
pub rrset_ttl: u32, pub rrset_ttl: u32,
} }
async fn run(base_url: &str, ip_source: &Box<dyn IPSource>, conf: &Config) -> anyhow::Result<()> { async fn run(
config::validate_config(conf).die_with(|error| format!("Invalid config: {}", error)); base_url: &str,
println!("Finding out the IP address..."); ip_source: &Box<dyn IPSource>,
let (ipv4_result, ipv6_result) = join!(ip_source.get_ipv4(), ip_source.get_ipv6()); conf: &Config,
let ipv4 = ipv4_result.as_ref(); opts: &Opts,
let ipv6 = ipv6_result.as_ref(); ) -> anyhow::Result<()> {
println!("Found these:"); let mut last_ipv4: Option<String> = None;
match ipv4 { let mut last_ipv6: Option<String> = None;
Ok(ip) => println!("\tIPv4: {}", ip),
Err(err) => eprintln!("\tIPv4 failed: {}", err),
}
match ipv6 {
Ok(ip) => println!("\tIPv6: {}", ip),
Err(err) => eprintln!("\tIPv6 failed: {}", err),
}
let client = api_client(&conf.api_key)?; loop {
let mut tasks: Vec<JoinHandle<(StatusCode, String)>> = Vec::new(); println!("Finding out the IP address...");
println!("Attempting to update DNS entries now"); let (ipv4_result, ipv6_result) = join!(ip_source.get_ipv4(), ip_source.get_ipv6());
let ipv4 = ipv4_result.as_ref();
let governor = Arc::new(governor::RateLimiter::direct(governor::Quota::per_minute( let ipv6 = ipv6_result.as_ref();
NonZeroU32::new(GANDI_RATE_LIMIT).die("Governor rate is 0"), println!("Found these:");
))); match ipv4 {
let retry_jitter = Ok(ip) => println!("\tIPv4: {}", ip),
governor::Jitter::new(Duration::ZERO, Duration::from_secs(GANDI_DELAY_JITTER)); Err(err) => eprintln!("\tIPv4 failed: {}", err),
}
for entry in &conf.entry { match ipv6 {
for entry_type in Config::types(entry) { Ok(ip) => println!("\tIPv6: {}", ip),
let fqdn = Config::fqdn(entry, conf).to_string(); Err(err) => eprintln!("\tIPv6 failed: {}", err),
let url = GandiAPI {
fqdn: &fqdn,
rrset_name: &entry.name,
rrset_type: entry_type,
base_url,
}
.url();
let ip = match entry_type {
"A" => ipv4.die_with(|error| format!("Needed IPv4 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),
};
let payload = APIPayload {
rrset_values: vec![ip.to_string()],
rrset_ttl: Config::ttl(entry, conf),
};
let req = client.put(url).json(&payload);
let task_governor = governor.clone();
let entry_type = entry_type.to_string();
let task = tokio::task::spawn(async move {
task_governor.until_ready_with_jitter(retry_jitter).await;
println!("Updating {} record for {}", entry_type, &fqdn);
match req.send().await {
Ok(response) => (
response.status(),
response
.text()
.await
.unwrap_or_else(|error| error.to_string()),
),
Err(error) => (StatusCode::IM_A_TEAPOT, error.to_string()),
}
});
tasks.push(task);
} }
}
let results = futures::future::try_join_all(tasks).await?; let ipv4_same = last_ipv4
println!("Updates done for {} entries", results.len()); .as_ref()
for (status, body) in results { .map(|p| ipv4.map(|q| p == q).unwrap_or(false))
println!("{} - {}", status, body); .unwrap_or(false);
let ipv6_same = last_ipv6
.as_ref()
.map(|p| ipv6.map(|q| p == q).unwrap_or(false))
.unwrap_or(false);
last_ipv4 = ipv4.ok().map(|v| v.to_string());
last_ipv6 = ipv6.ok().map(|v| v.to_string());
if !ipv4_same || !ipv6_same || conf.always_update {
let client = api_client(&conf.api_key)?;
let mut tasks: Vec<JoinHandle<(StatusCode, String)>> = Vec::new();
println!("Attempting to update DNS entries now");
let governor = Arc::new(governor::RateLimiter::direct(governor::Quota::per_minute(
NonZeroU32::new(GANDI_RATE_LIMIT).die("Governor rate is 0"),
)));
let retry_jitter =
governor::Jitter::new(Duration::ZERO, Duration::from_secs(GANDI_DELAY_JITTER));
for entry in &conf.entry {
for entry_type in Config::types(entry) {
let fqdn = Config::fqdn(entry, conf).to_string();
let url = GandiAPI {
fqdn: &fqdn,
rrset_name: &entry.name,
rrset_type: entry_type,
base_url,
}
.url();
let ip = match entry_type {
"A" => ipv4.die_with(|error| format!("Needed IPv4 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),
};
let payload = APIPayload {
rrset_values: vec![ip.to_string()],
rrset_ttl: Config::ttl(entry, conf),
};
let req = client.put(url).json(&payload);
let task_governor = governor.clone();
let entry_type = entry_type.to_string();
let task = tokio::task::spawn(async move {
task_governor.until_ready_with_jitter(retry_jitter).await;
println!("Updating {} record for {}", entry_type, &fqdn);
match req.send().await {
Ok(response) => (
response.status(),
response
.text()
.await
.unwrap_or_else(|error| error.to_string()),
),
Err(error) => (StatusCode::IM_A_TEAPOT, error.to_string()),
}
});
tasks.push(task);
}
}
let results = futures::future::try_join_all(tasks).await?;
println!("Updates done for {} entries", results.len());
for (status, body) in results {
println!("{} - {}", status, body);
}
} else {
println!("IP address has not changed since last update");
}
if let Some(repeat) = opts.repeat {
// If configured to repeat, do so
sleep(Duration::from_secs(repeat)).await;
continue;
}
// Otherwise this is one-shot, we should exit now
break;
} }
Ok(()) Ok(())
@ -121,36 +155,23 @@ async fn main() -> anyhow::Result<()> {
let conf = config::load_config(&opts) let conf = config::load_config(&opts)
.die_with(|error| format!("Failed to read config file: {}", error)); .die_with(|error| format!("Failed to read config file: {}", error));
// run indefinitely if repeat is given
if let Some(delay) = opts.repeat {
loop {
run_dispatch(&conf).await.ok();
sleep(Duration::from_secs(delay)).await
}
}
// otherwise run just once
else {
run_dispatch(&conf).await?;
Ok(())
}
}
async fn run_dispatch(conf: &Config) -> anyhow::Result<()> {
let ip_source: Box<dyn IPSource> = match conf.ip_source { let ip_source: Box<dyn IPSource> = match conf.ip_source {
IPSourceName::Ipify => Box::new(IPSourceIpify), IPSourceName::Ipify => Box::new(IPSourceIpify),
IPSourceName::Icanhazip => Box::new(IPSourceIcanhazip), IPSourceName::Icanhazip => Box::new(IPSourceIcanhazip),
}; };
run("https://api.gandi.net", &ip_source, conf).await config::validate_config(&conf).die_with(|error| format!("Invalid config: {}", error));
run("https://api.gandi.net", &ip_source, &conf, &opts).await?;
Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::env::temp_dir; use std::{env::temp_dir, time::Duration};
use crate::{config, 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, task::LocalSet, time::sleep};
struct IPSourceMock; struct IPSourceMock;
@ -165,7 +186,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn create_repo_success_test() { async fn single_shot() {
let mut temp = temp_dir().join("gandi-live-dns-test"); let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp) fs::create_dir_all(&temp)
.await .await
@ -196,11 +217,121 @@ mod tests {
}; };
let conf = config::load_config(&opts).expect("Failed to load config"); let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock); let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(server.base_url().as_str(), &ip_source, &conf) run(server.base_url().as_str(), &ip_source, &conf, &opts)
.await .await
.expect("Failed when running the update"); .expect("Failed when running the update");
// Assert // Assert
mock.assert(); mock.assert();
} }
#[test]
fn repeat() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
LocalSet::new().block_on(&runtime, async {
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 server_url = server.base_url();
let handle = tokio::task::spawn_local(async move {
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
repeat: Some(1),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(&server_url, &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
});
sleep(Duration::from_secs(3)).await;
handle.abort();
// Only should update once because the IP doesn't change
mock.assert();
});
}
#[test]
fn repeat_always_update() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
LocalSet::new().block_on(&runtime, async {
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\"\nalways_update = true\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 server_url = server.base_url();
let handle = tokio::task::spawn_local(async move {
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
repeat: Some(1),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(&server_url, &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
});
sleep(Duration::from_secs(3)).await;
handle.abort();
// Should update multiple times since always_update
assert!(mock.hits() > 1);
});
}
} }